From 69edfdb6196408bd96eedcf462b84d052251148d Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 13:17:11 +0200 Subject: [PATCH 1/3] fix(editor): re-window on terminal resize (P2 from harness-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor captured columns/rows once at startEditor and IEditorHandle had no resize hook, so the CLI's resize handler only re-pinned the status bar — the editor kept wrapping at the old width and windowing at the old height. After a shrink, renderEditor produced a frame sized for the pre-resize terminal and setEditor (live rows) clipped it, dropping the current line off-screen. Reproduced via VirtualScreen: type 12 lines on a 24-row terminal, shrink to 10 → the current line vanished. Fix: IEditorHandle.resize(columns, rows) updates the (now-mutable) dims and repaints; cli.ts calls it from the resize handler via a resizeEditor callback (editorHandle lives in a nested scope). Ignores non-positive dims. 2 regression tests in editor-e2e.test.ts (shrink keeps the line visible; 0×0 is a no-op). bun run validate green (1781 pass). --- packages/core/src/cli.ts | 12 +++++++++ packages/core/src/editor/controller.ts | 18 +++++++++++-- packages/core/tests/editor-e2e.test.ts | 37 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index da17102..dd14149 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) { @@ -1527,6 +1532,9 @@ async function repl(args: ICliArgs): Promise { process.stdout.on("resize", () => { 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); }); // Restore the terminal even on an unexpected exit (teardown is idempotent). @@ -1880,6 +1888,10 @@ async function repl(args: ICliArgs): Promise { openFilePicker, }); + resizeEditor = (columns, rows): void => { + editorHandle?.resize(columns, rows); + }; + editorHandle.onSubmit(submitLine); editorHandle.onInterrupt(() => { if (active === null) { diff --git a/packages/core/src/editor/controller.ts b/packages/core/src/editor/controller.ts index 9573e7d..b44faca 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,12 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { return buffer; }, + resize(nextColumns: number, nextRows: number): void { + columns = nextColumns > 0 ? nextColumns : columns; + rows = nextRows > 0 ? nextRows : rows; + 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); + }); +}); From e2b1e527b59fbf62ef7e8a9b715133a98c3399fb Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 13:18:13 +0200 Subject: [PATCH 2/3] docs(harness): refresh render/CLI contract + add editor subsystem entry The render/CLI manifest entry still described the readline REPL; PR #52 replaced it with the multi-line editor (src/editor/*), which had no contract. Refresh render/CLI invariants (bottom-anchored block, resize updates editor dims, use the VirtualScreen grid harness) and add a dedicated editor subsystem entry (grapheme cursor, paste-never-submits, non-ASCII accepted, window-to-visible-capacity). --- docs/harness-subsystems.md | 48 +++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 8 deletions(-) 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/*` From debe2a6eb797f5706a6dc5ca1854a69d6070d110 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 13:43:13 +0200 Subject: [PATCH 3/3] =?UTF-8?q?refactor(cli/editor):=20address=20PR=20#54?= =?UTF-8?q?=20review=20=E2=80=94=20named=20resize=20handler=20+=20dims-cha?= =?UTF-8?q?nged=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli.ts: extract the resize listener to a named handleResize and detach it on loop exit (process.stdout.off) so an anonymous listener doesn't pin the REPL closure for the process lifetime (Gemini). Dropped the suggested ?? 0 — columns /rows are typed number here, so it's an unnecessary-condition lint error. - controller.ts: resize only repaints when the dimensions actually changed, so duplicate/high-frequency resize events don't cause no-op repaints/flicker (Gemini). --- packages/core/src/cli.ts | 11 +++++++++-- packages/core/src/editor/controller.ts | 13 ++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index dd14149..57ac4fe 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1530,12 +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", () => { @@ -1929,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 b44faca..0b75204 100644 --- a/packages/core/src/editor/controller.ts +++ b/packages/core/src/editor/controller.ts @@ -556,9 +556,16 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { }, resize(nextColumns: number, nextRows: number): void { - columns = nextColumns > 0 ? nextColumns : columns; - rows = nextRows > 0 ? nextRows : rows; - repaint(); + 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 {