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:
co-authored by
Sisyphus
parent
940a1e7401
commit
3ef2d8a75a
+35
-21
@@ -1,11 +1,14 @@
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import React from 'react';
|
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
|
* 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.
|
* 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[] {
|
export function diffLines(before: string, after: string): DiffLine[] {
|
||||||
const a = before.split('\n');
|
const a = before.split('\n');
|
||||||
@@ -25,44 +28,59 @@ export function diffLines(before: string, after: string): DiffLine[] {
|
|||||||
let j = 0;
|
let j = 0;
|
||||||
while (i < n && j < m) {
|
while (i < n && j < m) {
|
||||||
if (a[i] === b[j]) {
|
if (a[i] === b[j]) {
|
||||||
out.push({ kind: 'context', text: a[i]! });
|
out.push({ kind: 'context', text: a[i]!, at: i + 1 });
|
||||||
i++;
|
i++;
|
||||||
j++;
|
j++;
|
||||||
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
|
} 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++;
|
i++;
|
||||||
} else {
|
} else {
|
||||||
out.push({ kind: 'add', text: b[j]! });
|
out.push({ kind: 'add', text: b[j]!, at: j + 1 });
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (i < n) out.push({ kind: 'remove', text: a[i++]! });
|
while (i < n) out.push({ kind: 'remove', text: a[i]!, at: i + 1 }), i++;
|
||||||
while (j < m) out.push({ kind: 'add', text: b[j++]! });
|
while (j < m) out.push({ kind: 'add', text: b[j]!, at: j + 1 }), j++;
|
||||||
return out;
|
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>();
|
const keep = new Set<number>();
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
if (line.kind === 'context') return;
|
if (line.kind === 'context') return;
|
||||||
for (let k = i - context; k <= i + context; k++) if (k >= 0 && k < lines.length) keep.add(k);
|
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 })[] = [];
|
const out: (DiffLine | { kind: 'gap'; count: number; from: number; to: number })[] = [];
|
||||||
let skipped = 0;
|
let skipped: DiffLine[] = [];
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
if (keep.has(i)) {
|
if (keep.has(i)) {
|
||||||
if (skipped > 0) {
|
if (skipped.length > 0) {
|
||||||
out.push({ kind: 'gap', count: skipped });
|
const first = skipped[0]!;
|
||||||
skipped = 0;
|
const last = skipped.at(-1)!;
|
||||||
|
out.push({ kind: 'gap', count: skipped.length, from: first.at, to: last.at });
|
||||||
|
skipped = [];
|
||||||
}
|
}
|
||||||
out.push(line);
|
out.push(line);
|
||||||
} else {
|
} 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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +102,13 @@ export function Diff({ before, after, path }: { before: string; after: string; p
|
|||||||
)}
|
)}
|
||||||
{shown.map((line, i) =>
|
{shown.map((line, i) =>
|
||||||
line.kind === 'gap' ? (
|
line.kind === 'gap' ? (
|
||||||
<Text key={i} dimColor>
|
<Text key={i} dimColor>{` ... lines ${line.from}-${line.to} unchanged`}</Text>
|
||||||
{` ... ${line.count} unchanged line${line.count === 1 ? '' : 's'}`}
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
key={i}
|
key={i}
|
||||||
color={line.kind === 'add' ? 'green' : line.kind === 'remove' ? 'red' : undefined}
|
color={line.kind === 'add' ? 'green' : line.kind === 'remove' ? 'red' : undefined}
|
||||||
dimColor={line.kind === 'context'}
|
dimColor={line.kind === 'context'}
|
||||||
>
|
>{` ${String(line.at).padStart(3)} ${line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' '} ${line.text}`}</Text>
|
||||||
{`${line.kind === 'add' ? ' + ' : line.kind === 'remove' ? ' - ' : ' '}${line.text}`}
|
|
||||||
</Text>
|
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{hidden > 0 && <Text dimColor>{` ... ${hidden} more diff lines`}</Text>}
|
{hidden > 0 && <Text dimColor>{` ... ${hidden} more diff lines`}</Text>}
|
||||||
|
|||||||
+17
-1
@@ -48,7 +48,22 @@ function BlockView({ block, width }: { block: Block; width: number }) {
|
|||||||
);
|
);
|
||||||
case 'paragraph':
|
case 'paragraph':
|
||||||
return <Inline spans={block.spans} />;
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text dimColor>{`${' '.repeat(block.indent)}${block.marker} `}</Text>
|
<Text dimColor>{`${' '.repeat(block.indent)}${block.marker} `}</Text>
|
||||||
@@ -57,6 +72,7 @@ function BlockView({ block, width }: { block: Block; width: number }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'quote':
|
case 'quote':
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ export function PromptInput({
|
|||||||
setCursor(clamped);
|
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(
|
useInput(
|
||||||
(input, key) => {
|
(input, key) => {
|
||||||
if (onKey?.(input, key as KeyLike)) return;
|
if (onKey?.(input, key as KeyLike)) return;
|
||||||
@@ -98,6 +114,14 @@ export function PromptInput({
|
|||||||
return;
|
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.leftArrow) return setCursor((c) => Math.max(0, c - 1));
|
||||||
if (key.rightArrow) return setCursor((c) => Math.min(value.length, c + 1));
|
if (key.rightArrow) return setCursor((c) => Math.min(value.length, c + 1));
|
||||||
if (key.home || (key.ctrl && input === 'a')) return setCursor(0);
|
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 === 'k') return set(value.slice(0, cursor), cursor);
|
||||||
if (key.ctrl && input === 'u') return set(value.slice(cursor), 0);
|
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') {
|
if (key.ctrl && input === 'w') {
|
||||||
const upto = value.slice(0, cursor);
|
const upto = value.slice(0, cursor);
|
||||||
const trimmed = upto.replace(/\S+\s*$/, '');
|
const trimmed = upto.replace(/\S+\s*$/, '');
|
||||||
|
|||||||
@@ -50,6 +50,45 @@ test('the rendered diff marks additions and removals and counts them', () => {
|
|||||||
app.unmount();
|
app.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('changed lines carry their line numbers, old and new', () => {
|
||||||
|
const before = ['one', 'two', 'three'].join('\n');
|
||||||
|
const after = ['one', 'TWO', 'three'].join('\n');
|
||||||
|
const app = render(<Diff before={before} after={after} />);
|
||||||
|
|
||||||
|
const frame = app.lastFrame() ?? '';
|
||||||
|
// `two` was line 2 before and `TWO` is line 2 after: a diff without numbers
|
||||||
|
// forces the reader to count them, which is exactly what the panel is for.
|
||||||
|
expect(frame).toMatch(/2.*- two/);
|
||||||
|
expect(frame).toMatch(/2.*\+ TWO/);
|
||||||
|
app.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a gap names the line range it hides, so the reader can jump there', () => {
|
||||||
|
const lines = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`).join('\n');
|
||||||
|
const app = render(<Diff before={lines} after={lines.replace('line 30', 'CHANGED')} />);
|
||||||
|
|
||||||
|
const frame = app.lastFrame() ?? '';
|
||||||
|
expect(frame).toContain('CHANGED');
|
||||||
|
// "27 unchanged lines" says how much; "lines 1-27" says where, which is what a
|
||||||
|
// reader needs to open the file at the right place.
|
||||||
|
expect(frame).toMatch(/lines \d+-\d+/);
|
||||||
|
app.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('added and removed lines count up separately through one diff', () => {
|
||||||
|
const before = ['a', 'b'].join('\n');
|
||||||
|
const after = ['a', 'B', 'c', 'd'].join('\n');
|
||||||
|
const app = render(<Diff before={before} after={after} path="src/x.ts" />);
|
||||||
|
|
||||||
|
// The header is the part a reader scans for: +3 -1 answers "how big was this"
|
||||||
|
// before the lines are read at all.
|
||||||
|
const frame = app.lastFrame() ?? '';
|
||||||
|
expect(frame).toContain('+3');
|
||||||
|
expect(frame).toContain('-1');
|
||||||
|
expect(frame).toContain('src/x.ts');
|
||||||
|
app.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
test('a very large diff is truncated with a notice', () => {
|
test('a very large diff is truncated with a notice', () => {
|
||||||
const before = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n');
|
const before = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n');
|
||||||
const after = Array.from({ length: 200 }, (_, i) => `changed ${i}`).join('\n');
|
const after = Array.from({ length: 200 }, (_, i) => `changed ${i}`).join('\n');
|
||||||
|
|||||||
@@ -134,6 +134,73 @@ test('ctrl-u clears to the start of the line', async () => {
|
|||||||
app.unmount();
|
app.unmount();
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
// Word-wise motion: the escape sequences a shell sends for ctrl-left/right.
|
||||||
|
const CTRL_LEFT = '\u001B[1;5D';
|
||||||
|
const CTRL_RIGHT = '\u001B[1;5C';
|
||||||
|
|
||||||
|
test('ctrl-left jumps the cursor back one word', async () => {
|
||||||
|
const { app, session } = mount();
|
||||||
|
await wait(150);
|
||||||
|
await type(app, 'one two');
|
||||||
|
await press(app, CTRL_LEFT);
|
||||||
|
await type(app, 'X ');
|
||||||
|
await press(app, '\r', 500);
|
||||||
|
// Cursor was after "two"; a word jump puts it before it, so the X lands between.
|
||||||
|
expect(session.messages[0]?.content).toBe('one X two');
|
||||||
|
app.unmount();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
test('ctrl-right jumps the cursor forward one word over a gap', async () => {
|
||||||
|
const { app, session } = mount();
|
||||||
|
await wait(150);
|
||||||
|
await press(app, 'one two', 150);
|
||||||
|
// Back before "one", then one word forward: the jump crosses the word and stops
|
||||||
|
// at its end, not one character along.
|
||||||
|
await press(app, CTRL_LEFT);
|
||||||
|
await press(app, CTRL_LEFT);
|
||||||
|
await press(app, CTRL_RIGHT);
|
||||||
|
await type(app, 'X');
|
||||||
|
await press(app, '\r', 500);
|
||||||
|
expect(session.messages[0]?.content).toBe('oneX two');
|
||||||
|
app.unmount();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
test('a word jump over the line edge stays put', async () => {
|
||||||
|
const { app, session } = mount();
|
||||||
|
await wait(150);
|
||||||
|
await type(app, 'abc');
|
||||||
|
// Three jumps past the start: the cursor must clamp, not walk off the string.
|
||||||
|
await press(app, CTRL_LEFT);
|
||||||
|
await press(app, CTRL_LEFT);
|
||||||
|
await type(app, 'X');
|
||||||
|
await press(app, '\r', 500);
|
||||||
|
expect(session.messages[0]?.content).toBe('Xabc');
|
||||||
|
app.unmount();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
test('ctrl-d deletes forward from the cursor', async () => {
|
||||||
|
const { app, session } = mount();
|
||||||
|
await wait(150);
|
||||||
|
await type(app, 'abcd');
|
||||||
|
// Back to the start, then delete two characters forward.
|
||||||
|
await press(app, '\u0001');
|
||||||
|
await press(app, '\u0004');
|
||||||
|
await press(app, '\u0004');
|
||||||
|
await press(app, '\r', 500);
|
||||||
|
expect(session.messages[0]?.content).toBe('cd');
|
||||||
|
app.unmount();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
test('ctrl-d at the end of the line deletes nothing', async () => {
|
||||||
|
const { app, session } = mount();
|
||||||
|
await wait(150);
|
||||||
|
await type(app, 'end');
|
||||||
|
await press(app, '\u0004');
|
||||||
|
await press(app, '\r', 500);
|
||||||
|
expect(session.messages[0]?.content).toBe('end');
|
||||||
|
app.unmount();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
test('a pasted multi-character chunk is inserted whole', async () => {
|
test('a pasted multi-character chunk is inserted whole', async () => {
|
||||||
const { app, session } = mount();
|
const { app, session } = mount();
|
||||||
await wait(150);
|
await wait(150);
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ test('markdown renders headings, bullets, and code distinctly', () => {
|
|||||||
app.unmount();
|
app.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a task list renders as checkboxes, not as literal brackets', () => {
|
||||||
|
// Models write progress as markdown task lists. Rendering "- [x] done" literally
|
||||||
|
// turns a status report into markup noise.
|
||||||
|
const app = render(<Markdown text={'- [x] first\n- [ ] second'} />);
|
||||||
|
const frame = app.lastFrame() ?? '';
|
||||||
|
expect(frame).toContain('[x] first');
|
||||||
|
expect(frame).toContain('[ ] second');
|
||||||
|
app.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ordered list keeps its numbers rather than becoming dashes', () => {
|
||||||
|
const app = render(<Markdown text={'1. first step\n2. second step'} />);
|
||||||
|
const frame = app.lastFrame() ?? '';
|
||||||
|
expect(frame).toContain('1. first step');
|
||||||
|
expect(frame).toContain('2. second step');
|
||||||
|
app.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
test('markdown strips the markup characters from the rendered output', () => {
|
test('markdown strips the markup characters from the rendered output', () => {
|
||||||
const app = render(<Markdown text={'Use **bold** and `code` here.'} />);
|
const app = render(<Markdown text={'Use **bold** and `code` here.'} />);
|
||||||
const frame = app.lastFrame() ?? '';
|
const frame = app.lastFrame() ?? '';
|
||||||
|
|||||||
Reference in New Issue
Block a user