From 3ef2d8a75a688da8b1d47e3b45038bc1c44face1 Mon Sep 17 00:00:00 2001 From: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:31:00 +0700 Subject: [PATCH] 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 --- src/ui/Diff.tsx | 56 +++++++++++++++++++++------------- src/ui/Markdown.tsx | 18 ++++++++++- src/ui/PromptInput.tsx | 27 +++++++++++++++++ test/diff.test.tsx | 39 ++++++++++++++++++++++++ test/input.test.tsx | 67 +++++++++++++++++++++++++++++++++++++++++ test/ui-panels.test.tsx | 18 +++++++++++ 6 files changed, 203 insertions(+), 22 deletions(-) diff --git a/src/ui/Diff.tsx b/src/ui/Diff.tsx index 57ea981..4e32e2a 100644 --- a/src/ui/Diff.tsx +++ b/src/ui/Diff.tsx @@ -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(); 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' ? ( - - {` ... ${line.count} unchanged line${line.count === 1 ? '' : 's'}`} - + {` ... lines ${line.from}-${line.to} unchanged`} ) : ( - {`${line.kind === 'add' ? ' + ' : line.kind === 'remove' ? ' - ' : ' '}${line.text}`} - + >{` ${String(line.at).padStart(3)} ${line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' '} ${line.text}`} ), )} {hidden > 0 && {` ... ${hidden} more diff lines`}} diff --git a/src/ui/Markdown.tsx b/src/ui/Markdown.tsx index 492796e..944bdab 100644 --- a/src/ui/Markdown.tsx +++ b/src/ui/Markdown.tsx @@ -48,7 +48,22 @@ function BlockView({ block, width }: { block: Block; width: number }) { ); case 'paragraph': return ; - 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 ( + + {`${' '.repeat(block.indent)}${done ? '[x]' : '[ ]'} `} + + + + + ); + } return ( {`${' '.repeat(block.indent)}${block.marker} `} @@ -57,6 +72,7 @@ function BlockView({ block, width }: { block: Block; width: number }) { ); + } case 'quote': return ( diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 66bff96..8f6ba3f 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -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*$/, ''); diff --git a/test/diff.test.tsx b/test/diff.test.tsx index 248c207..64942d5 100644 --- a/test/diff.test.tsx +++ b/test/diff.test.tsx @@ -50,6 +50,45 @@ test('the rendered diff marks additions and removals and counts them', () => { 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(); + + 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(); + + 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(); + + // 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', () => { const before = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n'); const after = Array.from({ length: 200 }, (_, i) => `changed ${i}`).join('\n'); diff --git a/test/input.test.tsx b/test/input.test.tsx index e90c821..bcca42d 100644 --- a/test/input.test.tsx +++ b/test/input.test.tsx @@ -134,6 +134,73 @@ test('ctrl-u clears to the start of the line', async () => { app.unmount(); }, 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 () => { const { app, session } = mount(); await wait(150); diff --git a/test/ui-panels.test.tsx b/test/ui-panels.test.tsx index 37ffe3f..3ecad47 100644 --- a/test/ui-panels.test.tsx +++ b/test/ui-panels.test.tsx @@ -20,6 +20,24 @@ test('markdown renders headings, bullets, and code distinctly', () => { 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(); + 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(); + 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', () => { const app = render(); const frame = app.lastFrame() ?? '';