diff --git a/docs/harness-subsystems.md b/docs/harness-subsystems.md index 7c18aa6..3bf8b7a 100644 --- a/docs/harness-subsystems.md +++ b/docs/harness-subsystems.md @@ -152,16 +152,48 @@ profile-gated rules only fire under their profile; meta-rules are change-scoped. ## render / CLI — `src/cli.ts`, `src/render/*` -Spinner, pinned status bar, readline REPL, command palette, plan-mode wiring. - -**Invariants** nothing writes to the readline input line mid-turn (no inline `\r` while -a prompt is attached); the status bar tears down idempotently on exit/resize/clear; -mid-turn input is queued, not dropped. +Spinner, pinned status bar (`status-bar.ts`), the multi-line input editor (driven via +`src/editor/*`, replacing the old readline REPL since PR #52), command palette +(`command-menu.ts`), `@`-file picker (`file-menu.ts`), setup wizard (`wizard.ts`), +plan-mode wiring. + +**Invariants** the status bar tears down idempotently on exit/resize/clear and never +leaves a scroll region pinned after exit; the editor block is BOTTOM-anchored and a +shrink clears its old top rows (no ghost rows) — same for the `@`-picker overlay +(`buildOverlayFrame`); streamed agent output scrolls in the region ABOVE the pinned +input, never over it; a terminal resize updates BOTH the bar's scroll region AND the +editor's wrap/window dimensions (the editor caches its size — `IEditorHandle.resize` +must be called or the current line is clipped at the stale size); mid-turn input is +queued, not dropped. **Risk areas** inline spinner clobbering input on a tiny TTY (the fixed P2b); a scroll -region left pinned after exit. - -**Checklist** spinner inline gate off in the interactive REPL; teardown on `process.on("exit")`. +region left pinned after exit; **stale editor dimensions after resize** (fixed: PR #54 +— editor cached columns/rows and the resize handler only re-pinned the bar); a render +function asserted by escape-string substring instead of the rendered grid (use the +`VirtualScreen` e2e harness — `tests/helpers/virtual-screen.ts` — which is what caught +the ghost-row / non-ASCII / cursor-clip bugs string assertions missed). + +**Checklist** spinner inline gate off in the interactive REPL; teardown on +`process.on("exit")`; resize handler calls `statusBar.resize` AND `editorHandle.resize`. + +## editor — `src/editor/*` (`buffer.ts`, `keys.ts`, `paste.ts`, `view.ts`, `controller.ts`, `segments.ts`) + +The multi-line input editor that replaced readline: a grapheme-correct buffer, a key +decoder (Kitty CSI-u + modifyOtherKeys + legacy), a bracketed-paste scanner, a +wrap/window view renderer, and a controller wiring stdin → buffer → `setEditor`. + +**Invariants** cursor offsets are grapheme indices, never UTF-16 (`segments.ts`); a +paste NEVER auto-submits — Enter submits, Shift/Alt+Enter and a trailing `\`+Enter +insert a newline (`controller.ts`); all printable input is accepted, including +non-ASCII / emoji / CJK — only C0/DEL/C1 controls are rejected (`keys.ts` — a +`charCodeAt >= 0x7f` guard dropped every non-ASCII char, fixed via `codePointAt`); +multi-line insert + undo is atomic; the view windows to the editor's visible capacity +(`rows - EDITOR_RESERVED_ROWS`) so the cursor line is always shown. + +**Risk areas** a key guard that drops a valid grapheme; an in-place repaint that ghosts +on shrink (top- vs bottom-anchored block); stale dims after resize; a paste path that +auto-submits. **Tested via the `VirtualScreen` e2e harness** (`tests/editor-e2e.test.ts`) +— assert the rendered grid, not emitted escape strings. ## mcp — `src/mcp/*` diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index da17102..57ac4fe 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1480,6 +1480,11 @@ async function repl(args: ICliArgs): Promise { // inactive and `prompt()` falls back to the inline status line (pipes, --log). const statusBar = new StatusBar(process.stdout, true, true, useInputRow); + // Set once the multi-line editor is created (it lives in a nested scope); the + // resize handler below calls it so the editor re-wraps/re-windows at the new + // size instead of clipping the current line at its pre-resize dimensions. + let resizeEditor: ((columns: number, rows: number) => void) | null = null; + // Route streamed agent output through the bar so it scrolls above the pinned // input row; cleared on loop exit so later/headless writes go straight to stdout. if (useInputRow) { @@ -1525,9 +1530,18 @@ async function repl(args: ICliArgs): Promise { } }); - process.stdout.on("resize", () => { + // Named so it can be detached on loop exit (an anonymous listener on the + // global process.stdout would pin the whole REPL closure for the process + // lifetime). columns/rows are typed `number` here, so no nullish guard is + // needed; the editor's resize ignores non-positive values regardless. + const handleResize = (): void => { statusBar.resize(statusInfo()); - }); + // The editor wraps/windows at the dimensions it was created with; without + // this it keeps using the pre-resize size and can clip the current line. + resizeEditor?.(process.stdout.columns, process.stdout.rows); + }; + + process.stdout.on("resize", handleResize); // Restore the terminal even on an unexpected exit (teardown is idempotent). process.on("exit", () => { @@ -1880,6 +1894,10 @@ async function repl(args: ICliArgs): Promise { openFilePicker, }); + resizeEditor = (columns, rows): void => { + editorHandle?.resize(columns, rows); + }; + editorHandle.onSubmit(submitLine); editorHandle.onInterrupt(() => { if (active === null) { @@ -1917,6 +1935,7 @@ async function repl(args: ICliArgs): Promise { }); statusBar.teardown(); // belt-and-suspenders: restore the terminal on loop exit + process.stdout.off("resize", handleResize); // don't pin the REPL closure interactiveStream = null; // later/headless writes go straight to stdout again return 0; diff --git a/packages/core/src/editor/controller.ts b/packages/core/src/editor/controller.ts index 9573e7d..0b75204 100644 --- a/packages/core/src/editor/controller.ts +++ b/packages/core/src/editor/controller.ts @@ -11,6 +11,11 @@ export interface IEditorHandle { onInterrupt(cb: () => void): void; onExit(cb: () => void): void; getBuffer(): EditorBuffer; + /** Update the terminal dimensions and repaint. The CLI calls this on a + * terminal resize; without it the editor keeps wrapping/windowing at the + * dimensions captured when it was created, so after a resize the current + * line can be clipped off the (now-shorter) block or wrapped at a stale width. */ + resize(columns: number, rows: number): void; close(): void; } @@ -141,12 +146,15 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { stdin, out, renderEditor: renderEditorFn, - columns = 80, - rows = 10, openPalette, openFilePicker, } = deps; + // Mutable so a terminal resize can update them (see the handle's `resize`); + // renderEditor wraps at `columns` and windows at `rows - EDITOR_RESERVED_ROWS`. + let columns = deps.columns ?? 80; + let rows = deps.rows ?? 10; + const buffer = new EditorBuffer(); const pasteScanner = createPasteScanner(); const keyDispatchTable = buildKeyDispatchTable(); @@ -547,6 +555,19 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { return buffer; }, + resize(nextColumns: number, nextRows: number): void { + const targetColumns = nextColumns > 0 ? nextColumns : columns; + const targetRows = nextRows > 0 ? nextRows : rows; + + // Only repaint when the size actually changed — some terminals emit + // duplicate/high-frequency resize events, and a no-op repaint flickers. + if (targetColumns !== columns || targetRows !== rows) { + columns = targetColumns; + rows = targetRows; + repaint(); + } + }, + close(): void { if (!isOpen) { return; diff --git a/packages/core/tests/editor-e2e.test.ts b/packages/core/tests/editor-e2e.test.ts index 195e89d..1dc5d9b 100644 --- a/packages/core/tests/editor-e2e.test.ts +++ b/packages/core/tests/editor-e2e.test.ts @@ -592,3 +592,40 @@ describe("editor e2e — wrapped-line cursor math", () => { expect(joined).toContain("X0123456789abcdefghijklmno"); }); }); + +describe("editor e2e — terminal resize updates the editor dimensions", () => { + test("after the terminal shrinks, the current line stays visible (no stale-dims clip)", () => { + const h = buildHarness(24, 80); + + // A buffer that fits a 24-row terminal (maxRows = 21) but NOT a 10-row one. + for (let i = 0; i < 11; i += 1) { + h.stdin.feed(`line${i}`); + h.stdin.feed("\x1b\r"); + } + + h.stdin.feed("lastline"); + + // Shrink the terminal: the bar re-pins AND the editor must be told the new + // size (the bug: the editor kept windowing at rows=24 and clipped the cursor). + h.term.rows = 10; + h.bar.resize(INFO); + h.handle.resize(80, 10); + h.stdin.feed("!"); + + const screen = new VirtualScreen(10, 80); + + screen.feed(h.term.text()); + expect(screen.rowsContaining("lastline!")).toBe(1); + }); + + test("resize ignores non-positive dimensions (keeps the last good size)", () => { + const h = buildHarness(24, 80); + + h.stdin.feed("keepme"); + // A spurious 0×0 resize (can happen transiently) must not wipe the render. + h.handle.resize(0, 0); + h.stdin.feed("!"); + + expect(h.screen().rowsContaining("keepme!")).toBe(1); + }); +});