Number diff lines, render task lists, and add word navigation

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Muhammad Zakir Ramadhan
2026-09-05 17:31:00 +07:00
co-authored by Sisyphus
parent 940a1e7401
commit 3ef2d8a75a
6 changed files with 203 additions and 22 deletions
+35 -21
View File
@@ -1,11 +1,14 @@
import { Box, Text } from 'ink';
import React from 'react';
export type DiffLine = { kind: 'context' | 'add' | 'remove'; text: string };
export type DiffLine = { kind: 'context' | 'add' | 'remove'; text: string; at: number };
/**
* Line-level diff by longest common subsequence. O(n*m) is fine here because an
* edit_file payload is a handful of lines, not a whole file.
*
* `at` is the 1-based line number in the file the line came from: the reader's copy
* for a removal, the changed copy for an addition, either for context.
*/
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split('\n');
@@ -25,44 +28,59 @@ export function diffLines(before: string, after: string): DiffLine[] {
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ kind: 'context', text: a[i]! });
out.push({ kind: 'context', text: a[i]!, at: i + 1 });
i++;
j++;
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
out.push({ kind: 'remove', text: a[i]! });
out.push({ kind: 'remove', text: a[i]!, at: i + 1 });
i++;
} else {
out.push({ kind: 'add', text: b[j]! });
out.push({ kind: 'add', text: b[j]!, at: j + 1 });
j++;
}
}
while (i < n) out.push({ kind: 'remove', text: a[i++]! });
while (j < m) out.push({ kind: 'add', text: b[j++]! });
while (i < n) out.push({ kind: 'remove', text: a[i]!, at: i + 1 }), i++;
while (j < m) out.push({ kind: 'add', text: b[j]!, at: j + 1 }), j++;
return out;
}
/** Drops runs of unchanged lines longer than `context` on both sides of a change. */
export function collapseContext(lines: DiffLine[], context = 2): (DiffLine | { kind: 'gap'; count: number })[] {
/**
* Drops runs of unchanged lines longer than `context` on both sides of a change.
*
* A gap carries the line range it hides rather than only a count: "27 unchanged
* lines" says how much was skipped, "lines 1-27" says where in the file the reader
* is, which is what they actually need when they go and open it.
*/
export function collapseContext(
lines: DiffLine[],
context = 2,
): (DiffLine | { kind: 'gap'; count: number; from: number; to: number })[] {
const keep = new Set<number>();
lines.forEach((line, i) => {
if (line.kind === 'context') return;
for (let k = i - context; k <= i + context; k++) if (k >= 0 && k < lines.length) keep.add(k);
});
const out: (DiffLine | { kind: 'gap'; count: number })[] = [];
let skipped = 0;
const out: (DiffLine | { kind: 'gap'; count: number; from: number; to: number })[] = [];
let skipped: DiffLine[] = [];
lines.forEach((line, i) => {
if (keep.has(i)) {
if (skipped > 0) {
out.push({ kind: 'gap', count: skipped });
skipped = 0;
if (skipped.length > 0) {
const first = skipped[0]!;
const last = skipped.at(-1)!;
out.push({ kind: 'gap', count: skipped.length, from: first.at, to: last.at });
skipped = [];
}
out.push(line);
} else {
skipped++;
skipped.push(line);
}
});
if (skipped > 0) out.push({ kind: 'gap', count: skipped });
if (skipped.length > 0) {
const first = skipped[0]!;
const last = skipped.at(-1)!;
out.push({ kind: 'gap', count: skipped.length, from: first.at, to: last.at });
}
return out;
}
@@ -84,17 +102,13 @@ export function Diff({ before, after, path }: { before: string; after: string; p
)}
{shown.map((line, i) =>
line.kind === 'gap' ? (
<Text key={i} dimColor>
{` ... ${line.count} unchanged line${line.count === 1 ? '' : 's'}`}
</Text>
<Text key={i} dimColor>{` ... lines ${line.from}-${line.to} unchanged`}</Text>
) : (
<Text
key={i}
color={line.kind === 'add' ? 'green' : line.kind === 'remove' ? 'red' : undefined}
dimColor={line.kind === 'context'}
>
{`${line.kind === 'add' ? ' + ' : line.kind === 'remove' ? ' - ' : ' '}${line.text}`}
</Text>
>{` ${String(line.at).padStart(3)} ${line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' '} ${line.text}`}</Text>
),
)}
{hidden > 0 && <Text dimColor>{` ... ${hidden} more diff lines`}</Text>}
+17 -1
View File
@@ -48,7 +48,22 @@ function BlockView({ block, width }: { block: Block; width: number }) {
);
case 'paragraph':
return <Inline spans={block.spans} />;
case 'bullet':
case 'bullet': {
// A markdown task list: `- [x] done`. The checkbox is the marker, and the
// text of a done task reads as already read — dimmed and struck through,
// the same treatment the todo panel gives a finished entry.
const task = /^\[( |x)\]\s+(.*)$/.exec(block.spans.map((s) => s.text).join(''));
if (task) {
const done = task[1] === 'x';
return (
<Box>
<Text dimColor>{`${' '.repeat(block.indent)}${done ? '[x]' : '[ ]'} `}</Text>
<Text strikethrough={done} dimColor={done}>
<Inline spans={parseInline(task[2]!)} />
</Text>
</Box>
);
}
return (
<Box>
<Text dimColor>{`${' '.repeat(block.indent)}${block.marker} `}</Text>
@@ -57,6 +72,7 @@ function BlockView({ block, width }: { block: Block; width: number }) {
</Box>
</Box>
);
}
case 'quote':
return (
<Box>
+27
View File
@@ -71,6 +71,22 @@ export function PromptInput({
setCursor(clamped);
};
/** Position after the next run of spaces, i.e. the start of the following word. */
const wordForward = (from: number) => {
let at = from;
while (at < value.length && value[at] === ' ') at++;
while (at < value.length && value[at] !== ' ') at++;
return at;
};
/** Position before the run of spaces preceding the current word. */
const wordBack = (from: number) => {
let at = from;
while (at > 0 && value[at - 1] === ' ') at--;
while (at > 0 && value[at - 1] !== ' ') at--;
return at;
};
useInput(
(input, key) => {
if (onKey?.(input, key as KeyLike)) return;
@@ -98,6 +114,14 @@ export function PromptInput({
return;
}
// Word-wise motion. Terminals send ctrl-left/right as a modified arrow, but
// Ink reports some of these sequences as a plain input with ctrl held rather
// than as key.leftArrow, so both shapes are handled.
const wordLeft = key.leftArrow && (key.ctrl || key.meta);
const wordRight = key.rightArrow && (key.ctrl || key.meta);
if (wordLeft) return setCursor((c) => wordBack(c));
if (wordRight) return setCursor((c) => wordForward(c));
if (key.leftArrow) return setCursor((c) => Math.max(0, c - 1));
if (key.rightArrow) return setCursor((c) => Math.min(value.length, c + 1));
if (key.home || (key.ctrl && input === 'a')) return setCursor(0);
@@ -105,6 +129,9 @@ export function PromptInput({
if (key.ctrl && input === 'k') return set(value.slice(0, cursor), cursor);
if (key.ctrl && input === 'u') return set(value.slice(cursor), 0);
// The delete key's forward cousin: without it, fixing a typo ahead of the
// cursor means walking to the end or backspacing and retyping the tail.
if (key.ctrl && input === 'd') return set(value.slice(0, cursor) + value.slice(cursor + 1), cursor);
if (key.ctrl && input === 'w') {
const upto = value.slice(0, cursor);
const trimmed = upto.replace(/\S+\s*$/, '');