Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions docs/harness-subsystems.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/*`

Expand Down
23 changes: 21 additions & 2 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,11 @@ async function repl(args: ICliArgs): Promise<number> {
// 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) {
Expand Down Expand Up @@ -1525,9 +1530,18 @@ async function repl(args: ICliArgs): Promise<number> {
}
});

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", () => {
Expand Down Expand Up @@ -1880,6 +1894,10 @@ async function repl(args: ICliArgs): Promise<number> {
openFilePicker,
});

resizeEditor = (columns, rows): void => {
editorHandle?.resize(columns, rows);
};

editorHandle.onSubmit(submitLine);
editorHandle.onInterrupt(() => {
if (active === null) {
Expand Down Expand Up @@ -1917,6 +1935,7 @@ async function repl(args: ICliArgs): Promise<number> {
});

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;
Expand Down
25 changes: 23 additions & 2 deletions packages/core/src/editor/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
},
Comment thread
agjs marked this conversation as resolved.

close(): void {
if (!isOpen) {
return;
Expand Down
37 changes: 37 additions & 0 deletions packages/core/tests/editor-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});