diff --git a/apps/docs/src/content/docs/cli/interactive.mdx b/apps/docs/src/content/docs/cli/interactive.mdx index d040270..4979f75 100644 --- a/apps/docs/src/content/docs/cli/interactive.mdx +++ b/apps/docs/src/content/docs/cli/interactive.mdx @@ -7,6 +7,8 @@ The **CLI** is the main way you use tsforge. **REPL** mode means an interactive Most users run `tsforge` and stay in the interactive session. +**Note:** See [Input Editor](/reference/input-editor/) for keyboard shortcuts, multi-line paste, and history navigation. + ## Modes | Mode | How | When | diff --git a/apps/docs/src/content/docs/reference/input-editor.mdx b/apps/docs/src/content/docs/reference/input-editor.mdx new file mode 100644 index 0000000..e32426b --- /dev/null +++ b/apps/docs/src/content/docs/reference/input-editor.mdx @@ -0,0 +1,89 @@ +--- +title: Input Editor +description: Multi-line input editor keybindings, paste handling, and fallback mode. +--- + +The tsforge CLI uses an interactive multi-line input editor for the REPL session. It supports full keyboard navigation, history recall, word deletion, and multi-line paste. + +## Submit vs. newline + +| Key | Behavior | +| --- | --- | +| **Enter** | Submit message to the agent | +| **Shift+Enter** | Insert newline in buffer | +| **Alt+Enter** | Insert newline in buffer | +| **`\` then Enter** | Insert newline (remove the trailing backslash) | + +When you press Enter with an empty buffer or at the top level, your message is submitted. To write multiple lines, use Shift+Enter, Alt+Enter, or end a line with a backslash and press Enter. + +## Navigation and editing + +### Motion +| Key | Action | +| --- | --- | +| **←/→** | Move cursor left/right (one character) | +| **Ctrl+← / Ctrl+→** | Move by word | +| **Home / End** | Move to start/end of current line | +| **Ctrl+Home / Ctrl+End** | Move to start/end of entire buffer | +| **↑/↓** | Recall input history (at buffer edges only) | + +### Deletion +| Key | Action | +| --- | --- | +| **Backspace** | Delete character before cursor | +| **Delete** | Delete character at cursor | +| **Ctrl+W** | Delete word before cursor | +| **Alt+Backspace** | Delete word before cursor (alias) | +| **Ctrl+U** | Delete to start of line | +| **Ctrl+K** | Delete to end of line | + +### Kill ring (copy/paste buffer) +| Key | Action | +| --- | --- | +| **Ctrl+Y** | Yank (paste) last deleted text | +| **Alt+Y** | Yank-pop (rotate through deleted text) | + +### Undo +| Key | Action | +| --- | --- | +| **Ctrl+_** (Ctrl+underscore) | Undo last change | + +## History navigation + +When your cursor is at the **top** of the buffer (line 0, any column), press **↑** to recall the previous submitted message. When at the **bottom** (last line), press **↓** to scroll forward through history. The draft you were typing is preserved and restored when you exit history. + +## Multi-line paste + +Paste a block of text with Ctrl+V (or ⌘V on macOS). The pasted content lands in the buffer as-is and may span multiple lines. You can edit it like any buffer text. When you press Enter, the entire buffer (including pasted lines) is submitted as a single message. + +Very large pastes are displayed as `[paste #N +M lines]` in the UI and expand to their full content when sent. + +## Command palette and file picker + +| Key | Action | +| --- | --- | +| **`/`** | Open command palette (when buffer contains only `/`) | +| **`@`** | Open file picker (when buffer contains only `@`) | + +Typing `/` or `@` followed by other text inserts those characters normally. + +## Keyboard interrupt + +| Key | Action | +| --- | --- | +| **Ctrl+C** | Interrupt the current model run (does not exit the session) | +| **Ctrl+D** | Exit the CLI (on empty buffer only) | + +## Fallback: basic input mode + +If the multi-line editor has compatibility issues, you can fall back to simple single-line (readline) input: + +```bash +TSFORGE_BASIC_INPUT=1 tsforge +``` + +This mode still accepts slash commands and works in pipes, but does not support Shift+Enter, multi-line paste, or kill-ring operations. + +## Terminal compatibility + +The editor auto-detects your terminal's keyboard encoding (Kitty CSI-u, xterm modifyOtherKeys, or legacy). Resizing your terminal window updates the editor immediately. On non-TTY streams (pipes, files, CI), the editor is automatically skipped and replaced by line-buffered stdin reading. diff --git a/docs/superpowers/plans/2026-06-26-multiline-editor.md b/docs/superpowers/plans/2026-06-26-multiline-editor.md new file mode 100644 index 0000000..07dd860 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-multiline-editor.md @@ -0,0 +1,686 @@ +# Multi-line Editor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Node `readline` for tsforge's interactive prompt with a grapheme-aware multi-line editor: Enter submits, Shift/Alt+Enter (and `\`+Enter) insert a newline, paste lands in the buffer and never auto-submits. + +**Architecture:** A pure core (`EditorBuffer` text model, `KeyDecoder`, `PasteScanner`, `EditorView` renderer) driven by an I/O glue layer (`EditorController`) that owns stdin in raw mode and repaints via the existing `statusBar`. `cli.ts` consumes controller events instead of `rl.on("line")`. `TSFORGE_BASIC_INPUT=1` keeps the readline path as a fallback. + +**Tech Stack:** TypeScript on Bun, `Intl.Segmenter` for graphemes, existing `render/status-bar.ts` scroll-region painting, `bun:test` + FakeTerm frame tests (no PTY — node-pty doesn't work under Bun). + +## Global Constraints + +- House rules: no `as` casts, no `eslint-disable`, cyclomatic complexity ≤ 20, prefer shared AST/segmentation helpers; run full `bun run validate` before "done". +- Pure modules (`buffer`, `keys`, `paste`, `view`) do NO I/O and emit NO ANSI except `view`. +- Grapheme-correct everywhere: cursor indices are grapheme offsets, segmented via a single shared `Intl.Segmenter` helper. +- Enter (`\r`, no modifiers) submits; Shift+Enter, Alt+Enter (`\x1b\r`), and a trailing `\`+Enter insert a newline. +- Bracketed paste markers: start `\x1b[200~`, end `\x1b[201~`. Large paste = `> 10` lines OR `> 1000` chars → `[paste #N +M lines]`. +- The editor is a TTY feature; non-TTY/pipe input and `TSFORGE_BASIC_INPUT=1` keep the current readline path verbatim. +- File layout under `packages/core/src/editor/`; tests under `packages/core/tests/editor-*.test.ts`. + +--- + +### Task 1: EditorBuffer — text model core (insert, newline, delete, char cursor) + +**Files:** +- Create: `packages/core/src/editor/segments.ts` (shared grapheme helper) +- Create: `packages/core/src/editor/buffer.ts` +- Test: `packages/core/tests/editor-buffer.test.ts` + +**Interfaces:** +- Produces: + - `segments.ts`: `export function graphemes(s: string): string[]` (split into grapheme clusters), `export function graphemeCount(s: string): number`. + - `buffer.ts`: `export class EditorBuffer { constructor(initial?: string); getText(): string; getCursor(): { line: number; col: number }; insert(text: string): void; newline(): void; deleteBackward(): void; deleteForward(): void; moveLeft(): void; moveRight(): void; setText(text: string, cursorToEnd?: boolean): void; }`. Internally `lines: string[]`, `cursorLine`, `cursorCol` (grapheme offset within the line). + +- [ ] **Step 1: Write failing tests for insert / newline / char delete / char moves** + +```ts +import { test, expect } from "bun:test"; +import { EditorBuffer } from "../src/editor/buffer"; + +test("insert appends text and advances the cursor by graphemes", () => { + const b = new EditorBuffer(); + b.insert("héllo"); // é = combining? use a precomposed char; 5 graphemes + expect(b.getText()).toBe("héllo"); + expect(b.getCursor()).toEqual({ line: 0, col: 5 }); +}); + +test("newline splits the current line at the cursor", () => { + const b = new EditorBuffer("abcd"); + b.moveLeft(); // cursor before 'd' → col 3 + b.newline(); + expect(b.getText()).toBe("abc\nd"); + expect(b.getCursor()).toEqual({ line: 1, col: 0 }); +}); + +test("deleteBackward joins lines at column 0", () => { + const b = new EditorBuffer("ab\ncd"); + // cursor at end (line 1, col 2); move to line 1 col 0 + b.setText("ab\ncd"); + // place cursor at start of line 1: + b.moveLeft(); b.moveLeft(); // from end → col 0 of line 1 + b.deleteBackward(); + expect(b.getText()).toBe("abcd"); + expect(b.getCursor()).toEqual({ line: 0, col: 2 }); +}); + +test("emoji is one grapheme for cursor + delete", () => { + const b = new EditorBuffer(); + b.insert("a👍b"); + b.moveLeft(); // before 'b' + b.deleteBackward(); // removes 👍 as one unit + expect(b.getText()).toBe("ab"); +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `bun test packages/core/tests/editor-buffer.test.ts` +Expected: FAIL (module not found / EditorBuffer undefined). + +- [ ] **Step 3: Implement `segments.ts` then `buffer.ts`** + +```ts +// segments.ts +const SEG = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +export function graphemes(s: string): string[] { + const out: string[] = []; + for (const { segment } of SEG.segment(s)) { + out.push(segment); + } + return out; +} +export function graphemeCount(s: string): number { + return graphemes(s).length; +} +``` + +```ts +// buffer.ts — core only (later tasks extend this class in the same file) +import { graphemes } from "./segments"; + +export class EditorBuffer { + private lines: string[]; + private cursorLine: number; + private cursorCol: number; // grapheme offset within lines[cursorLine] + + constructor(initial = "") { + this.lines = initial.split("\n"); + this.cursorLine = this.lines.length - 1; + this.cursorCol = graphemes(this.lines[this.cursorLine] ?? "").length; + } + + getText(): string { + return this.lines.join("\n"); + } + + getCursor(): { line: number; col: number } { + return { line: this.cursorLine, col: this.cursorCol }; + } + + private curG(): string[] { + return graphemes(this.lines[this.cursorLine] ?? ""); + } + + insert(text: string): void { + // text has no newlines here (newline() handles those); split defensively. + const parts = text.split("\n"); + for (let i = 0; i < parts.length; i += 1) { + if (i > 0) { + this.newline(); + } + const piece = parts[i] ?? ""; + const g = this.curG(); + g.splice(this.cursorCol, 0, ...graphemes(piece)); + this.lines[this.cursorLine] = g.join(""); + this.cursorCol += graphemes(piece).length; + } + } + + newline(): void { + const g = this.curG(); + const left = g.slice(0, this.cursorCol).join(""); + const right = g.slice(this.cursorCol).join(""); + this.lines.splice(this.cursorLine, 1, left, right); + this.cursorLine += 1; + this.cursorCol = 0; + } + + deleteBackward(): void { + if (this.cursorCol > 0) { + const g = this.curG(); + g.splice(this.cursorCol - 1, 1); + this.lines[this.cursorLine] = g.join(""); + this.cursorCol -= 1; + return; + } + if (this.cursorLine === 0) { + return; + } + const prev = graphemes(this.lines[this.cursorLine - 1] ?? ""); + const cur = this.lines[this.cursorLine] ?? ""; + this.cursorCol = prev.length; + this.lines.splice(this.cursorLine - 1, 2, (this.lines[this.cursorLine - 1] ?? "") + cur); + this.cursorLine -= 1; + } + + deleteForward(): void { + const g = this.curG(); + if (this.cursorCol < g.length) { + g.splice(this.cursorCol, 1); + this.lines[this.cursorLine] = g.join(""); + return; + } + if (this.cursorLine >= this.lines.length - 1) { + return; + } + const next = this.lines[this.cursorLine + 1] ?? ""; + this.lines.splice(this.cursorLine, 2, (this.lines[this.cursorLine] ?? "") + next); + } + + moveLeft(): void { + if (this.cursorCol > 0) { + this.cursorCol -= 1; + } else if (this.cursorLine > 0) { + this.cursorLine -= 1; + this.cursorCol = this.curG().length; + } + } + + moveRight(): void { + if (this.cursorCol < this.curG().length) { + this.cursorCol += 1; + } else if (this.cursorLine < this.lines.length - 1) { + this.cursorLine += 1; + this.cursorCol = 0; + } + } + + setText(text: string, cursorToEnd = true): void { + this.lines = text.split("\n"); + if (cursorToEnd) { + this.cursorLine = this.lines.length - 1; + this.cursorCol = graphemes(this.lines[this.cursorLine] ?? "").length; + } else { + this.cursorLine = 0; + this.cursorCol = 0; + } + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `bun test packages/core/tests/editor-buffer.test.ts` +Expected: PASS (4 tests). Fix the test that places the cursor if the helper moves differ; assertions define the contract. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/editor/segments.ts packages/core/src/editor/buffer.ts packages/core/tests/editor-buffer.test.ts +git commit -m "feat(editor): EditorBuffer core text model (grapheme-aware insert/newline/delete/move)" +``` + +--- + +### Task 2: EditorBuffer — word/line/document navigation + sticky-column vertical moves + +**Files:** +- Modify: `packages/core/src/editor/buffer.ts` +- Test: `packages/core/tests/editor-buffer.test.ts` (append) + +**Interfaces:** +- Produces (added to `EditorBuffer`): `moveWordLeft(): void; moveWordRight(): void; moveLineStart(): void; moveLineEnd(): void; moveDocStart(): void; moveDocEnd(): void; moveUp(): void; moveDown(): void;`. `moveUp/Down` keep a private `stickyCol` so vertical moves through short lines preserve the desired column. + +- [ ] **Step 1: Write failing tests** + +```ts +test("moveWordLeft/Right stop at word boundaries", () => { + const b = new EditorBuffer("foo bar baz"); + b.moveLineStart(); + b.moveWordRight(); + expect(b.getCursor().col).toBe(3); // end of "foo" + b.moveWordRight(); + expect(b.getCursor().col).toBe(7); // end of "bar" + b.moveWordLeft(); + expect(b.getCursor().col).toBe(4); // start of "bar" +}); + +test("moveUp keeps sticky column across a short line", () => { + const b = new EditorBuffer("hello\nhi\nworld"); + b.moveDocEnd(); // line 2 (world), col 5 + b.moveUp(); // line 1 (hi) — clamps to col 2 + expect(b.getCursor()).toEqual({ line: 1, col: 2 }); + b.moveUp(); // line 0 (hello) — sticky restores col 5 + expect(b.getCursor()).toEqual({ line: 0, col: 5 }); +}); + +test("moveDocStart/End jump to buffer ends", () => { + const b = new EditorBuffer("a\nb\nc"); + b.moveDocStart(); + expect(b.getCursor()).toEqual({ line: 0, col: 0 }); + b.moveDocEnd(); + expect(b.getCursor()).toEqual({ line: 2, col: 1 }); +}); +``` + +- [ ] **Step 2: Run → FAIL** (`bun test packages/core/tests/editor-buffer.test.ts`). + +- [ ] **Step 3: Implement.** Word boundary = transition between whitespace and non-whitespace over the grapheme array. `moveWordRight`: from `cursorCol`, skip non-word then word (or the reverse depending on standard — match the test: stop at end of the next word). `moveUp/Down`: set `stickyCol` on horizontal moves to `null`; on first vertical move capture `stickyCol = cursorCol`; clamp `cursorCol = min(stickyCol, len(targetLine))`. Reset `stickyCol` to `null` in every horizontal/edit op. + +```ts +// add fields + reset helper +private stickyCol: number | null = null; +private clearSticky(): void { this.stickyCol = null; } +// call this.clearSticky() at the top of insert/newline/delete*/moveLeft/moveRight/moveWord*/moveLineStart/End/DocStart/End + +private isWordChar(ch: string): boolean { + return ch.trim().length > 0; +} + +moveWordRight(): void { + this.clearSticky(); + const g = this.curG(); + let i = this.cursorCol; + while (i < g.length && !this.isWordChar(g[i] ?? "")) i += 1; + while (i < g.length && this.isWordChar(g[i] ?? "")) i += 1; + this.cursorCol = i; +} + +moveWordLeft(): void { + this.clearSticky(); + const g = this.curG(); + let i = this.cursorCol; + while (i > 0 && !this.isWordChar(g[i - 1] ?? "")) i -= 1; + while (i > 0 && this.isWordChar(g[i - 1] ?? "")) i -= 1; + this.cursorCol = i; +} + +moveLineStart(): void { this.clearSticky(); this.cursorCol = 0; } +moveLineEnd(): void { this.clearSticky(); this.cursorCol = this.curG().length; } +moveDocStart(): void { this.clearSticky(); this.cursorLine = 0; this.cursorCol = 0; } +moveDocEnd(): void { + this.clearSticky(); + this.cursorLine = this.lines.length - 1; + this.cursorCol = this.curG().length; +} + +private vertical(delta: number): void { + const target = this.cursorLine + delta; + if (target < 0 || target >= this.lines.length) return; + if (this.stickyCol === null) this.stickyCol = this.cursorCol; + this.cursorLine = target; + this.cursorCol = Math.min(this.stickyCol, graphemes(this.lines[target] ?? "").length); +} +moveUp(): void { this.vertical(-1); } +moveDown(): void { this.vertical(1); } +``` + +- [ ] **Step 4: Run → PASS.** Adjust word-move tests if the boundary convention differs; lock the convention with the assertions. + +- [ ] **Step 5: Commit** `feat(editor): word/line/doc navigation + sticky-column vertical moves`. + +--- + +### Task 3: EditorBuffer — kill-ring + region deletes (word, line, to-edge) + +**Files:** +- Create: `packages/core/src/editor/kill-ring.ts` +- Modify: `packages/core/src/editor/buffer.ts` +- Test: `packages/core/tests/editor-buffer.test.ts` (append) + +**Interfaces:** +- Produces: `kill-ring.ts`: `export class KillRing { push(text: string, opts?: { prepend?: boolean; accumulate?: boolean }): void; current(): string; rotate(): void; }`. `EditorBuffer` gains `deleteWordBackward(): void; deleteWordForward(): void; deleteToLineStart(): void; deleteToLineEnd(): void; yank(): void; yankPop(): void;` (the delete ops push removed text to a `KillRing` instance held by the buffer). + +- [ ] **Step 1: Write failing tests** + +```ts +test("Ctrl-K (deleteToLineEnd) then yank round-trips", () => { + const b = new EditorBuffer("hello world"); + b.moveLineStart(); b.moveWordRight(); // col 5 (after hello) + b.deleteToLineEnd(); + expect(b.getText()).toBe("hello"); + b.moveLineEnd(); + b.yank(); + expect(b.getText()).toBe("hello world"); +}); + +test("deleteWordBackward removes the previous word", () => { + const b = new EditorBuffer("foo bar"); + b.deleteWordBackward(); + expect(b.getText()).toBe("foo "); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement** `KillRing` (array + index; `push` with prepend/accumulate merges into entry 0 when the last action was also a kill — track via a buffer-level `lastWasKill` flag) and the delete ops (compute the removed grapheme range, store it via `this.killRing.push(removed, …)`, splice it out, move cursor to the cut start). `yank()` inserts `killRing.current()`; `yankPop()` calls `rotate()` then replaces the just-yanked span (track `lastYank` start/length). + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** `feat(editor): kill-ring + word/line/to-edge deletes with yank/yank-pop`. + +--- + +### Task 4: EditorBuffer — undo/redo with word-coalescing + +**Files:** +- Create: `packages/core/src/editor/undo-stack.ts` +- Modify: `packages/core/src/editor/buffer.ts` +- Test: `packages/core/tests/editor-buffer.test.ts` (append) + +**Interfaces:** +- Produces: `undo-stack.ts`: `export interface ISnapshot { lines: string[]; cursorLine: number; cursorCol: number } export class UndoStack { push(s: ISnapshot): void; undo(cur: ISnapshot): ISnapshot | null; redo(): ISnapshot | null; }`. `EditorBuffer` gains `undo(): void; redo(): void;` and snapshots before edits, coalescing consecutive word-character inserts into one undo unit (snapshot taken when an edit follows a non-edit or a word boundary). + +- [ ] **Step 1: Write failing tests** + +```ts +test("undo reverts a word as one unit, redo restores it", () => { + const b = new EditorBuffer(); + b.insert("h"); b.insert("i"); // coalesced + b.undo(); + expect(b.getText()).toBe(""); + b.redo(); + expect(b.getText()).toBe("hi"); +}); + +test("space then word are separate undo units", () => { + const b = new EditorBuffer(); + b.insert("a"); b.insert(" "); b.insert("b"); + b.undo(); + expect(b.getText()).toBe("a "); + b.undo(); + expect(b.getText()).toBe("a"); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement.** `snapshot()` deep-copies state (`structuredClone`). Before each mutating op, call `maybeSnapshot(kind)`: push a snapshot when `kind` differs from the last (`insert-word` vs `insert-space` vs `delete` vs `other`); clear the redo stack on a fresh edit. `undo()` pushes current onto redo and restores the popped snapshot; `redo()` reverses. + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** `feat(editor): coalesced undo/redo`. + +--- + +### Task 5: EditorBuffer — large-paste markers + expand + +**Files:** +- Modify: `packages/core/src/editor/buffer.ts` +- Test: `packages/core/tests/editor-buffer.test.ts` (append) + +**Interfaces:** +- Produces (added to `EditorBuffer`): `insertPaste(text: string): void` (inserts text directly if small; else inserts a `[paste #N +M lines]` marker grapheme-run and stashes the real text), `expand(): string` (returns `getText()` with every marker replaced by its stashed text — used at submit). A private `pastes: Map` + counter. + +- [ ] **Step 1: Write failing tests** + +```ts +test("small paste inserts literally", () => { + const b = new EditorBuffer(); + b.insertPaste("one\ntwo"); + expect(b.getText()).toBe("one\ntwo"); + expect(b.expand()).toBe("one\ntwo"); +}); + +test("large paste shows a marker but expands on submit", () => { + const big = Array.from({ length: 40 }, (_, i) => `line ${i}`).join("\n"); + const b = new EditorBuffer(); + b.insertPaste(big); + expect(b.getText()).toContain("[paste #1 +40 lines]"); + expect(b.getText()).not.toContain("line 39"); + expect(b.expand()).toBe(big); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement.** `insertPaste`: if `lines>10 || chars>1000`, `id = ++counter; pastes.set(id, text); this.insert("[paste #"+id+" +"+lineCount+" lines]")`; else `this.insert(text)`. `expand()`: `getText().replace(/\[paste #(\d+) \+\d+ lines\]/g, (m, id) => this.pastes.get(Number(id)) ?? m)`. (Marker atomicity in cursor/wrap is a v2 refinement; for v1 the marker is ordinary text that simply must round-trip through `expand`.) + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** `feat(editor): large-paste markers with expand-on-submit`. + +--- + +### Task 6: KeyDecoder — raw bytes → normalized key events + +**Files:** +- Create: `packages/core/src/editor/keys.ts` +- Test: `packages/core/tests/editor-keys.test.ts` + +**Interfaces:** +- Produces: `export interface IKeyEvent { name: string; ctrl: boolean; alt: boolean; shift: boolean; text: string } export function decodeKeys(chunk: string): IKeyEvent[]`. `name` is a stable token: `"return"`, `"backspace"`, `"left"/"right"/"up"/"down"`, `"home"/"end"`, `"delete"`, `"char"` (printable, with `text`), `"escape"`, `"tab"`. Modifiers from Kitty CSI-u / xterm modifyOtherKeys / legacy. The decoder leaves bracketed-paste markers to `PasteScanner` (it skips bytes between `\x1b[200~`/`\x1b[201~` — the controller routes those separately, so `decodeKeys` is only ever fed non-paste bytes). + +- [ ] **Step 1: Write failing tests** (the decisive ones: Enter variants) + +```ts +import { decodeKeys } from "../src/editor/keys"; + +test("plain CR is submit (return, no mods)", () => { + const [k] = decodeKeys("\r"); + expect({ name: k.name, shift: k.shift, alt: k.alt }).toEqual({ name: "return", shift: false, alt: false }); +}); + +test("Alt+Enter decodes as return+alt", () => { + const [k] = decodeKeys("\x1b\r"); + expect({ name: k.name, alt: k.alt }).toEqual({ name: "return", alt: true }); +}); + +test("Kitty Shift+Enter (CSI 13;2u) decodes as return+shift", () => { + const [k] = decodeKeys("\x1b[13;2u"); + expect({ name: k.name, shift: k.shift }).toEqual({ name: "return", shift: true }); +}); + +test("xterm modifyOtherKeys Shift+Enter (CSI 27;2;13~) decodes as return+shift", () => { + const [k] = decodeKeys("\x1b[27;2;13~"); + expect({ name: k.name, shift: k.shift }).toEqual({ name: "return", shift: true }); +}); + +test("Ctrl+W decodes from byte 0x17", () => { + const [k] = decodeKeys("\x17"); + expect({ name: k.name, ctrl: k.ctrl }).toEqual({ name: "char", ctrl: true }); + expect(k.text).toBe("w"); +}); + +test("printable char and arrow", () => { + expect(decodeKeys("a")[0]).toMatchObject({ name: "char", text: "a" }); + expect(decodeKeys("\x1b[D")[0]).toMatchObject({ name: "left" }); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement** a scanning decoder. Order: try CSI-u (`/^\x1b\[(\d+)(?::\d+)*;(\d+)(?::\d+)*u/`) → codepoint+modifier (modifier bitmask: 1=base, then `(mod-1)` bits: 1=shift,2=alt,4=ctrl); xterm modifyOtherKeys (`/^\x1b\[27;(\d+);(\d+)~/`); legacy arrows/home/end (`\x1b[A..` etc.); `\x1b\r`/`\x1b\n` → return+alt; bare `\r`/`\n` → return; `\x7f`/`\b` → backspace; control bytes `0x01..0x1a` → `{name:"char",ctrl:true,text:String.fromCharCode(code+96)}`; `\x1b`+printable → alt+char; else printable runs → `char` events per grapheme. Map codepoint 13 → `return`, 9 → `tab`, 27 → `escape`. cc per branch stays small via a helper table. + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** `feat(editor): key decoder (Kitty CSI-u + modifyOtherKeys + legacy, Enter variants)`. + +--- + +### Task 7: PasteScanner — finalize (timeout valve + CSI-u-in-paste + tests) + +**Files:** +- Modify: `packages/core/src/editor/paste.ts` (already drafted) +- Test: `packages/core/tests/editor-paste.test.ts` + +**Interfaces:** +- Keep `createPasteScanner(): IPasteScanner` with `feed(chunk): { content: string | null; active: boolean }` and `isActive()`. Add: inside `feed`, when finalizing content, decode tmux CSI-u control bytes (`/\x1b\[(\d+);\d+u/g` → `String.fromCharCode(cp)`), strip non-printables except `\n`. Add `forceEnd(): string | null` for the controller's 2s timeout valve (returns + clears any open buffer). + +- [ ] **Step 1: Write failing tests using the REAL captured bytes** + +```ts +import { createPasteScanner } from "../src/editor/paste"; + +test("extracts a real bracketed paste, CR→\\n, no markers", () => { + const s = createPasteScanner(); + const chunk = "\x1b[200~line one\rline two\rlast\x1b[201~"; + const r = s.feed(chunk); + expect(r.content).toBe("line one\nline two\nlast"); + expect(s.isActive()).toBe(false); +}); + +test("paste split across chunks stays active until the end marker", () => { + const s = createPasteScanner(); + expect(s.feed("\x1b[200~part1\r").active).toBe(true); + expect(s.feed("part2").content).toBeNull(); + expect(s.feed("\x1b[201~").content).toBe("part1\npart2"); +}); + +test("forceEnd flushes an unterminated paste (timeout valve)", () => { + const s = createPasteScanner(); + s.feed("\x1b[200~stuck text"); + expect(s.forceEnd()).toBe("stuck text"); + expect(s.isActive()).toBe(false); +}); +``` + +- [ ] **Step 2: Run → FAIL** on the new behavior (`forceEnd`, CSI-u decode). + +- [ ] **Step 3: Implement** the additions in `paste.ts` (keep the existing `feed` structure; add `forceEnd`, the CSI-u decode + non-printable strip in `normalizeNewlines`'s caller). + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** `feat(editor): finalize PasteScanner (timeout valve + tmux CSI-u decode)`. + +--- + +### Task 8: EditorView — pure ANSI frame renderer + +**Files:** +- Create: `packages/core/src/editor/view.ts` +- Test: `packages/core/tests/editor-view.test.ts` + +**Interfaces:** +- Produces: `export function renderEditor(input: { lines: string[]; cursorLine: number; cursorCol: number }, opts: { columns: number; maxRows: number; color: boolean }): { frame: string; rows: number; cursorRow: number; cursorCol: number }`. Word-wraps each logical line to `columns`, clips to `maxRows` with `↑ N more`/`↓ N more` indicators centered on the cursor, returns the ANSI block plus the on-screen cursor coordinates. Single logical line ⇒ one visual row (parity with today's `buildInputFrame`). + +- [ ] **Step 1: Write failing FakeTerm-style tests** (assert on the returned string/coords, no PTY) + +```ts +import { renderEditor } from "../src/editor/view"; + +test("single line renders one row with the gutter", () => { + const r = renderEditor({ lines: ["hello"], cursorLine: 0, cursorCol: 5 }, { columns: 40, maxRows: 6, color: false }); + expect(r.rows).toBe(1); + expect(r.frame).toContain("hello"); + expect(r.cursorRow).toBe(0); +}); + +test("a long line wraps to multiple visual rows", () => { + const long = "x".repeat(50); + const r = renderEditor({ lines: [long], cursorLine: 0, cursorCol: 50 }, { columns: 20, maxRows: 6, color: false }); + expect(r.rows).toBeGreaterThan(1); +}); + +test("buffer taller than maxRows clips with a scroll indicator", () => { + const lines = Array.from({ length: 20 }, (_, i) => `line ${i}`); + const r = renderEditor({ lines, cursorLine: 19, cursorCol: 0 }, { columns: 40, maxRows: 6, color: false }); + expect(r.rows).toBeLessThanOrEqual(6); + expect(r.frame).toContain("more"); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement** word-wrap (reuse the wrapping idea from `status-bar`'s `clipInput`, generalized to multiple rows), the visible-window computation around the cursor, and ANSI assembly mirroring `buildInputFrame`'s escape conventions (no raw newlines that break the scroll region — position each row explicitly). + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** `feat(editor): EditorView multi-line renderer (wrap + scroll + cursor)`. + +--- + +### Task 9: EditorController — stdin/raw-mode glue + +**Files:** +- Create: `packages/core/src/editor/controller.ts` +- Create: `packages/core/src/editor/index.ts` (barrel) +- Test: `packages/core/tests/editor-controller.test.ts` (driven by a fake stdin EventEmitter + fake out sink — no real TTY) + +**Interfaces:** +- Produces: `export interface IEditorHandle { onSubmit(cb: (message: string) => void): void; onChange(cb: () => void): void; getBuffer(): EditorBuffer; close(): void; } export function startEditor(deps: { stdin: NodeJS.ReadStream | FakeStdin; out: (s: string) => void; statusBar?: …; openPalette?: () => Promise; openFilePicker?: () => Promise; }): IEditorHandle`. Wires bytes → `PasteScanner` (paste → `buffer.insertPaste`, swallow during `active`) → `decodeKeys` → `EditorBuffer` ops via a key→action table; repaints via `renderEditor` + the out sink; calls `onSubmit(buffer.expand())` on Enter (no mods); inserts newline on Shift/Alt+Enter and trailing-`\`+Enter; triggers `openPalette`/`openFilePicker` on the same conditions cli.ts uses today. Enables `\x1b[?2004h`, Kitty (`\x1b[>1u`) + modifyOtherKeys (`\x1b[>4;2m`) on start (env-gated); disables all on `close()`. Sets/unsets raw mode if `stdin.setRawMode` exists. + +- [ ] **Step 1: Write failing tests with a fake stdin** + +```ts +// FakeStdin: an EventEmitter with setRawMode/resume/setEncoding no-ops that +// re-emits "data" when you call .feed(s). +test("typing then Enter submits the typed text once", () => { + const { stdin, handle, submits } = makeHarness(); + stdin.feed("hi"); + stdin.feed("\r"); + expect(submits).toEqual(["hi"]); +}); + +test("Shift+Enter inserts a newline, does NOT submit", () => { + const { stdin, handle, submits } = makeHarness(); + stdin.feed("a"); + stdin.feed("\x1b[13;2u"); // Kitty Shift+Enter + stdin.feed("b"); + expect(submits).toEqual([]); + expect(handle.getBuffer().getText()).toBe("a\nb"); + stdin.feed("\r"); + expect(submits).toEqual(["a\nb"]); +}); + +test("a multi-line paste lands in the buffer and submits once on Enter", () => { + const { stdin, handle, submits } = makeHarness(); + stdin.feed("\x1b[200~one\rtwo\rthree\x1b[201~"); + expect(submits).toEqual([]); // never auto-submits + expect(handle.getBuffer().getText()).toBe("one\ntwo\nthree"); + stdin.feed("\r"); + expect(submits).toEqual(["one\ntwo\nthree"]); +}); +``` + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement** the controller + a `FakeStdin` test helper. Key→action table maps decoded `IKeyEvent`s to `EditorBuffer` methods; `return` with no mods → submit; `return` with shift/alt → `newline()`; trailing-`\` rule: if Enter and the char before cursor is `\`, delete it and `newline()`. Paste path: while `scanner.isActive()` swallow decoded keys; on completed content call `buffer.insertPaste(content)` + repaint. Guard repaint behind the out sink. + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Commit** `feat(editor): EditorController (raw stdin glue, submit/newline/paste wiring)`. + +--- + +### Task 10: Integrate into cli.ts (replace readline; preserve steer/Ctrl-C/history/palette; fallback) + +**Files:** +- Modify: `packages/core/src/cli.ts` (the `runInteractive` input wiring, ~lines 916-1715) +- Modify: `packages/core/src/config/config.constants.ts` + `flags.ts` (add `basicInput` → `TSFORGE_BASIC_INPUT`) +- Test: `packages/core/tests/cli.test.ts` (append a wiring test using the fake stdin) + +**Interfaces:** +- Consumes: `startEditor` from `editor/index.ts`; `flags.basicInput()`. +- Produces: when `useInputRow && !flags.basicInput()`, the interactive loop drives input through `startEditor` instead of `rl.on("line")`. `onSubmit(message)` calls the existing `submitLine`/busy/`pending` logic verbatim. History (load/save), Ctrl-C (abort vs quit), the `/` palette and `@` picker, and the status bar all keep working. `TSFORGE_BASIC_INPUT=1` or a non-TTY keeps the current readline path unchanged. + +- [ ] **Step 1: Write a wiring test** — with the fake stdin, feed a paste + Enter and assert `submitLine` receives ONE multi-line message (and, while "busy", it `pending`-queues exactly one steer). + +- [ ] **Step 2: Run → FAIL.** + +- [ ] **Step 3: Implement** the branch: build the editor handle, route `onSubmit` to `submitLine`, keep `pending`/`busy`/Ctrl-C, remove the per-line readline path under the new branch (leave it intact under the fallback). Add the flag. Disable bracketed-paste/Kitty/modifyOtherKeys + restore raw mode in the same teardown that calls `statusBar.teardown()`. + +- [ ] **Step 4: Run → PASS** + full `bun run validate`. + +- [ ] **Step 5: Commit** `feat(cli): drive the interactive prompt through the multi-line editor (TSFORGE_BASIC_INPUT fallback)`. + +--- + +### Task 11: Live verification + docs + +**Files:** +- Modify: `apps/docs/src/content/docs/...` (a short "input editor" note: keys, Shift+Enter, paste, fallback) +- Modify: `packages/core/RULES.md` or the keybinding help if one exists + +- [ ] **Step 1:** Run the real CLI on a sample repo and verify by hand: type; Shift+Enter and Alt+Enter newlines; `\`+Enter newline; paste a multi-line block (lands in buffer, one message on Enter); `/` palette + `@` picker; ↑/↓ history at buffer edges; Ctrl-C abort vs quit; resize. Confirm `TSFORGE_BASIC_INPUT=1` falls back to readline cleanly. +- [ ] **Step 2:** Document the keys + the fallback flag. +- [ ] **Step 3:** `bun run validate` green; commit `docs(editor): document the multi-line input editor + keys`. + +--- + +## Self-review notes + +- **Spec coverage:** buffer model (T1-5), key decoding incl. Shift/Alt/Ctrl+Enter (T6), bracketed paste + markers + timeout (T5,T7), rendering (T8), controller + protocol handshake + `/`+`@` (T9), cli integration + fallback + history/steer/Ctrl-C (T10), live verify + docs (T11). All spec sections map to a task. +- **Deferred (noted in spec non-goals / T5):** atomic paste-marker segmentation in cursor/wrap is v1-simplified (marker is plain text that round-trips via `expand`); full atomic segmentation is a fast-follow. +- **Type consistency:** `EditorBuffer` method names are reused verbatim across T1-T10; `decodeKeys`/`IKeyEvent`, `createPasteScanner`/`feed`/`forceEnd`, `renderEditor`, `startEditor`/`IEditorHandle` are referenced with the same signatures in the controller and cli tasks. diff --git a/docs/superpowers/specs/2026-06-26-multiline-editor-design.md b/docs/superpowers/specs/2026-06-26-multiline-editor-design.md new file mode 100644 index 0000000..55e853f --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-multiline-editor-design.md @@ -0,0 +1,170 @@ +# Design: a best-in-class multi-line input editor for tsforge + +## Context + +tsforge's interactive prompt uses Node `readline`, which is single-line and +**submits on every newline**. Pasting a multi-line block therefore fires one +submission per line (N messages, or N mid-run "steer" notices) — the reported +bug. The deeper problem is the primitive: readline can't hold or edit multi-line +input, so there is no "paste, add context, then submit" and no Shift+Enter. + +Both reference agents avoid this by **owning a multi-line editor**: pi ships a +custom TUI editor (`packages/tui`), hermes uses prompt_toolkit (multiline) + an +Ink editor. This spec replaces readline for the interactive prompt with our own +grapheme-aware multi-line editor — the input is the product's front door, so it +should be best-in-class. + +The editor is **default and always-on**; `TSFORGE_BASIC_INPUT=1` falls back to +the current readline path as a safety hatch. + +## Goals + +- True multi-line editing: **Enter submits, Shift+Enter / Alt+Enter / trailing + `\`+Enter insert a newline**. Paste lands in the buffer and **never** + auto-submits. +- Grapheme-correct everywhere (emoji, combining marks, CJK width). +- Bracketed paste: capture the block, normalize newlines, collapse huge pastes to + `[paste #N +M lines]` markers that expand on submit, with a timeout valve for a + missing end marker. +- Full editing: word/line/document cursor moves, word/line/to-edge deletes, + kill-ring + yank, coalesced undo/redo, input history. +- Preserve today's UX: the `/` command palette, the `@` file picker, mid-run + steering, Ctrl-C semantics, and the pinned status bar — folded into the editor. +- Fully testable without a PTY (pure buffer/decoder/paste units + FakeTerm frame + tests for rendering). + +## Non-goals (v1) + +- Syntax highlighting / bracket matching in the input. +- Mouse selection. Image-from-clipboard paste (note the seam; defer). +- Vi keybindings (emacs-style + arrows only). + +## Architecture + +A new `packages/core/src/editor/` module, pure where possible, wired into +`cli.ts`'s `runInteractive`. Boundaries: + +1. **`EditorBuffer`** (`editor/buffer.ts`) — pure model. State: `lines: string[]`, + `cursorLine`, `cursorCol` (grapheme index), selection-less. Operations return a + new/mutated state; no I/O, no ANSI. Owns: insert text, newline, delete + (char/word/line/to-edge), cursor moves (char/word/line/home/end/doc) with + **sticky column** for vertical moves, undo/redo (snapshot stack with + fish-style coalescing), kill-ring + yank/yank-pop, large-paste markers + + `expand()` on submit. Grapheme segmentation via `Intl.Segmenter`. + +2. **`KeyDecoder`** (`editor/keys.ts`) — pure. Decodes a raw stdin chunk into a + sequence of normalized `KeyEvent { name, ctrl, alt, shift, text }`. Handles + Kitty CSI-u (`ESC[;u`), xterm `modifyOtherKeys` + (`ESC[27;;~`), and legacy sequences. This is what makes Shift+Enter / + Ctrl+Enter distinguishable. + +3. **`PasteScanner`** (`editor/paste.ts`, already drafted) — extracts the + bracketed-paste block (`ESC[200~`…`ESC[201~`), normalizes `\r`/`\r\n`→`\n`, + spans chunks, exposes `active` so the driver knows a paste is open; plus a + missing-`201~` timeout valve. + +4. **`EditorView`** (`editor/view.ts`) — pure render: given buffer + viewport + (cols/rows), produce the ANSI frame for a multi-row input box (word-wrap, + scroll with `↑/↓ N more` indicators, cursor cell, prompt gutter). Reuses the + `status-bar` scroll-region discipline. FakeTerm-assertable like + `buildInputFrame`. + +5. **`EditorController`** (`editor/controller.ts`) — the glue: owns stdin in raw + mode, runs the terminal-protocol handshake, feeds bytes → PasteScanner → + KeyDecoder → EditorBuffer, repaints via EditorView + statusBar, and surfaces + callbacks: `onSubmit(message)`, `onChange`, plus hooks for the `/` palette and + `@` picker (opened on the same triggers as today). Replaces the readline + instance in `runInteractive`. + +`cli.ts` keeps everything else: the busy/steer queue (`pending`), Ctrl-C abort vs +quit, history persistence, status bar, prompt lifecycle — it just consumes +`EditorController` events instead of `rl.on("line")`. + +## Submit vs. newline (the crux) + +- **Enter** (`\r`, no mods) → submit. +- **Shift+Enter**, **Alt+Enter** (`ESC\r`), and a **trailing `\` + Enter** → + insert newline. Alt+Enter and `\`+Enter always work even on terminals that + can't encode Shift+Enter. +- To make Shift+Enter reliable, the controller enables the **Kitty keyboard + protocol** (`ESC[>1u`) and **xterm modifyOtherKeys** (`ESC[>4;2m`) on start and + disables them on teardown, with **env gating** (hermes' lesson: Windows/WSL/SSH/ + Ghostty/WT deliver Ctrl+Enter as bare LF) so we never mis-bind bare LF. +- Bracketed paste is enabled with `ESC[?2004h` (disabled on teardown). Pasted + newlines insert into the buffer; they are not Enter. + +## Keybindings (v1, emacs + arrows) + +Move: ←/→ char, Ctrl/Alt+←/→ word, Home/End line, Ctrl+Home/End document, ↑/↓ +visual line (sticky column; at top/bottom edge → history prev/next). Delete: +Backspace/Delete char, Ctrl+W / Alt+Backspace word-back, Alt+D word-forward, +Ctrl+U to line-start, Ctrl+K to line-end. Kill-ring: Ctrl+W/U/K push; Ctrl+Y +yank; Alt+Y yank-pop. Undo: Ctrl+_ (and Ctrl+Z where the terminal delivers it); +redo: Alt+_ (avoids the Ctrl+Y/yank collision); undo steps coalesce per word. +Submit/newline as above. Ctrl+C: abort the run if busy, else clear the buffer; a +second Ctrl+C on an empty buffer quits (preserving current behavior). `/` at +col 0 opens the palette; `@` at a word boundary opens the file picker. + +## Bracketed paste + large pastes + +Capture the block via `PasteScanner`; normalize newlines; strip non-printables +(keep `\n`); decode CSI-u-encoded control bytes that tmux re-emits inside pastes. +If the paste is > ~10 lines or > ~1000 chars, insert a `[paste #N +M lines]` +marker (atomic for cursor/wrap) and stash the real text in a `Map`; `expand()` +substitutes markers at submit. A missing `ESC[201~` within ~2s ends the paste +(timeout valve) so the editor can't wedge. + +## Rendering + +`EditorView` lays logical lines → visual lines (word-wrap at `cols`), shows a +bounded box (e.g. up to ~30% of rows, min 1) above the status bar, scroll +indicators when clipped, and the cursor cell. Single-line input renders exactly +like today (no visual regression for the common case). Paint stays within the +status-bar scroll region; teardown restores the terminal (and disables +2004/Kitty/modifyOtherKeys). + +## Integration & migration + +`runInteractive` swaps the `rl` line source for `EditorController` while keeping: +the `submitLine` path (now receives a possibly-multi-line message), the +busy/`pending` steer queue, Ctrl-C handling, history load/save, status bar, and +the `/`+`@` overlays. `TSFORGE_BASIC_INPUT=1` keeps the readline path verbatim as +a fallback. Non-TTY/pipe input keeps the existing readline behavior (the editor +is a TTY feature). + +## Testing + +- **Pure units (the bulk):** `EditorBuffer` (every op incl. grapheme/CJK, undo + coalescing, kill-ring, sticky column, paste markers + expand), `KeyDecoder` + (Kitty/modifyOtherKeys/legacy fixtures, incl. Shift/Alt/Ctrl+Enter), and + `PasteScanner` against the **real captured bytes** (`ESC[200~…\r…ESC[201~`) plus + multi-chunk and missing-end cases. +- **FakeTerm frame tests:** `EditorView` frames for single-line, wrapped + multi-line, scrolled, and cursor-position cases (mirrors existing status-bar + tests). +- Full `bun run validate` green; house rules (no `as`, cc ≤ 20, shared walkers). + +## Rollout / verification + +Land on `feat/multiline-editor`. Because TTY input can't be auto-tested under Bun +(no node-pty), the human verifies live in the real CLI: type, Shift+Enter for +newlines, paste a multi-line block (lands in buffer, one message on Enter), `/` +and `@` still work, Ctrl-C and history intact. Merge only after that live check. +`TSFORGE_BASIC_INPUT=1` is the instant rollback if anything regresses. + +## File layout + +``` +packages/core/src/editor/ + buffer.ts # EditorBuffer (pure model) + keys.ts # KeyDecoder (Kitty/modifyOtherKeys/legacy) + paste.ts # PasteScanner (+ large-paste markers, timeout) [drafted] + view.ts # EditorView (pure ANSI frame) + controller.ts # EditorController (stdin/raw-mode glue) + index.ts # barrel +packages/core/tests/ + editor-buffer.test.ts + editor-keys.test.ts + editor-paste.test.ts + editor-view.test.ts +``` diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 46329dd..da17102 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -54,6 +54,9 @@ import { import { makeSpinner, spinnerPhase } from "./render/spinner"; import { validate } from "./validate"; import { isPolicyMode } from "./policy"; +import { startEditor, type IEditorHandle } from "./editor"; +import { renderEditor } from "./editor/view"; +import { flags } from "./config/flags"; import { PROVIDER_LIMITS, PROVIDER_DEFAULTS, @@ -764,14 +767,26 @@ function maybePrintNoConfigHint( } } -/** Interactive REPL: a persistent gate-anchored conversation. */ -async function repl(args: ICliArgs): Promise { - // The active model comes from the registry (~/.tsforge/models.json) unless a - // recipe names one or an explicit TSFORGE_* env overrides it; `/model ` - // switches it live. +/** Initialize the REPL session: resolve model, gate, context window, and create + * the session object. Returns the session, provider, and config metadata. + * Extracted to reduce repl() cognitive complexity. */ +async function initReplSession(args: ICliArgs): Promise<{ + session: Session; + provider: OpenAICompatibleProvider; + activeName: string; + contextWindow: number; + id: string; + gateLabel: string; + logFile: string; + persist: () => Promise; + report: Reporter; + resumed: ISessionRecord | null; + files: string[]; + activeModelEntry: IModelEntry; +}> { const activeModel = await modelForRun(args); const provider = makeProvider(activeModel.entry); - let activeName = activeModel.name; + const activeName = activeModel.name; warnDefaultModelOnRemote(activeModel.entry); @@ -819,10 +834,7 @@ async function repl(args: ICliArgs): Promise { // The model's real context window: explicit env wins, else ask the server // (max_model_len), else a conservative fallback. Drives the status gauge AND // auto-compaction (the session compacts before a send once it nears the window). - // `let` so `/model` can refresh the gauge when switching to a model with a - // different window. Per-entry contextWindow wins, then explicit env, then the - // server's max_model_len, then a conservative fallback. - let contextWindow = + const contextWindow = activeModel.entry.contextWindow ?? envNumber("TSFORGE_CONTEXT_WINDOW") ?? (await detectContextWindow(provider.config)) ?? @@ -866,7 +878,7 @@ async function repl(args: ICliArgs): Promise { enableThinking: false, }; - let session = await Session.create(config); + const session = await Session.create(config); // A self-describing run-meta line at the top of the --log so the analyzer knows // which model / context window the metrics are against (the thread's advice: @@ -879,6 +891,56 @@ async function repl(args: ICliArgs): Promise { contextWindow, }); + const persist = async (): Promise => { + await saveSession({ + id, + cwd: args.dir, + // The LIVE gate/scope — not the startup constants. /gate, /files, and a web + // scaffold all mutate these mid-session; persisting the originals would + // silently restore stale settings on --continue. See P2 review. + accept: session.gate, + files: session.scope, + updatedAt: Date.now(), + planMode: false, // will be set by caller + messages: [...session.messages], + }); + }; + + return { + session, + provider, + activeName, + contextWindow, + id, + gateLabel, + logFile, + persist, + report, + resumed, + files, + activeModelEntry: activeModel.entry, + }; +} + +/** Interactive REPL: a persistent gate-anchored conversation. */ +async function repl(args: ICliArgs): Promise { + const { + session: initialSession, + provider, + activeName: initialActiveName, + contextWindow: initialContextWindow, + id, + gateLabel, + logFile, + resumed, + files, + activeModelEntry, + } = await initReplSession(args); + + let session = initialSession; + let activeName = initialActiveName; + let contextWindow = initialContextWindow; + const persist = async (): Promise => { await saveSession({ id, @@ -922,17 +984,23 @@ async function repl(args: ICliArgs): Promise { process.stdout.isTTY && process.stdout.rows >= MIN_ROWS; + // In editor mode, do NOT create readline — the editor owns stdin exclusively. + // In fallback mode (non-TTY or basicInput), readline is the only consumer. + const useEditor = useInputRow && !flags.basicInput(); + const inputSink = new Writable({ write(_chunk, _enc, cb): void { cb(); }, }); - const rl = createInterface({ - input: process.stdin, - output: useInputRow ? inputSink : process.stdout, - terminal: true, - }); + const rl = useEditor + ? null + : createInterface({ + input: process.stdin, + output: useInputRow ? inputSink : process.stdout, + terminal: true, + }); // Ctrl-C: while a turn is running, abort it and return to the prompt; while // idle at the prompt, quit. (readline emits SIGINT on the interface, so the @@ -942,13 +1010,15 @@ async function repl(args: ICliArgs): Promise { // the model (see Session.send `steer`), instead of blocking until the run ends. const pending: string[] = []; - rl.on("SIGINT", () => { - if (active !== null) { - active.abort(); - } else { - rl.close(); - } - }); + if (rl !== null) { + rl.on("SIGINT", () => { + if (active !== null) { + active.abort(); + } else { + rl.close(); + } + }); + } // Explicit `--web` (no Q&A): the FIRST message is the build, so stage it // (plan+types → implement). Cleared after, so follow-ups are plain sends. @@ -1197,7 +1267,17 @@ async function repl(args: ICliArgs): Promise { process.stdout.write(`${HELP}\n`); break; case "clear": - session = await Session.create(config); + // Rebuild the session with the current state (config is not reused; + // repl's /clear creates a fresh Session.create call) + session = await Session.create({ + provider, + cwd: args.dir, + files: session.scope, + accept: session.gate, + contextWindow, + report: makeReporter(logFile, id, id), + enableThinking: false, + }); session.setSetupWeb(setupWeb); session.setPlanMode(planMode); // a /clear must not silently drop the mode planDiscussed = false; @@ -1294,7 +1374,7 @@ async function repl(args: ICliArgs): Promise { arg, provider, activeName, - fallbackEntry: activeModel.entry, + fallbackEntry: activeModelEntry, contextWindow, }); @@ -1411,7 +1491,7 @@ async function repl(args: ICliArgs): Promise { // Mirror readline's buffer onto the input row after each keypress. setImmediate // lets readline update rl.line/rl.cursor first (it processes the key async). const syncInput = (): void => { - if (useInputRow) { + if (useInputRow && rl !== null) { setImmediate(() => { statusBar.setInput(rl.line, rl.cursor); }); @@ -1476,7 +1556,10 @@ async function repl(args: ICliArgs): Promise { // marker; otherwise it prints the inline status line above the marker. const prompt = (): void => { if (useInputRow) { - statusBar.setInput(rl.line, rl.cursor); + if (rl !== null) { + statusBar.setInput(rl.line, rl.cursor); + } + statusBar.update(statusInfo()); return; @@ -1495,6 +1578,7 @@ async function repl(args: ICliArgs): Promise { }; await new Promise((resolveLoop) => { + let editorHandle: IEditorHandle | null = null; let busy = false; let closed = false; let paletteOpen = false; @@ -1507,6 +1591,47 @@ async function repl(args: ICliArgs): Promise { } }; + // Submit a line of input: check if busy/pending, echo it, handle /exit, or run it. + const submitLine = (raw: string): void => { + const line = raw.trim(); + + if (line.length === 0) { + if (!busy) { + prompt(); + } + + return; + } + + // readline's output is sinked in input-row mode, so the submitted line is + // never echoed to scrollback — record it ourselves so the transcript reads + // naturally above the (now-cleared) input row. + if (useInputRow) { + echo(`${STYLE.dim}›${RESET} ${line}\n`); + } + + if (busy) { + if (line === "/exit" || line === "/quit") { + active?.abort(); + + if (rl !== null) { + rl.close(); + } + + if (editorHandle !== null) { + editorHandle.close(); + } + } else { + pending.push(line); + echo(" ↳ queued (steers the next turn)\n"); + } + + return; + } + + void runLine(line); + }; + // Handle one idle line (slash command or a message), then any queued follow-up. const runLine = async (line: string): Promise => { busy = true; @@ -1514,7 +1639,9 @@ async function repl(args: ICliArgs): Promise { try { if (line.startsWith("/")) { if (await command(line)) { - rl.close(); + if (rl !== null) { + rl.close(); + } return; } @@ -1548,6 +1675,27 @@ async function repl(args: ICliArgs): Promise { } }; + // Helper: repaint the editor buffer to the status bar after palette insertion. + const repaintEditor = (handle: IEditorHandle): void => { + const { line, col } = handle.getBuffer().getCursor(); + const lines = handle.getBuffer().getText().split("\n"); + + const frame = renderEditor( + { + lines, + cursorLine: line, + cursorCol: col, + }, + { + columns: process.stdout.columns, + maxRows: process.stdout.rows, + color: true, + } + ); + + statusBar.writeStream(frame.frame); + }; + // Open the interactive `/` command palette: pick a command from a navigable // list, then either run it (no-arg) or prefill the line so the user types the // argument. Cancel ⇒ back to a clean prompt. Only meaningful on a TTY. @@ -1555,28 +1703,39 @@ async function repl(args: ICliArgs): Promise { paletteOpen = true; try { - rl.write(null, { ctrl: true, name: "u" }); // clear the typed "/" - const picked = await pickCommand(process.stdout.isTTY); - // The palette ran on the alternate screen; exiting it restored the prompt - // verbatim, so don't re-draw it (that left stray `›` lines). On cancel, - // nothing to do; for an arg command, prefill so the user types the value. if (picked !== null) { - if (takesArg(picked)) { - rl.write(`${picked.name} `); - } else { - void runLine(picked.name); + if (editorHandle !== null) { + editorHandle.getBuffer().setText(""); + editorHandle.getBuffer().insert(picked.name); + + if (takesArg(picked)) { + editorHandle.getBuffer().insert(" "); + } else { + void runLine(picked.name); + } + + repaintEditor(editorHandle); + } else if (rl !== null) { + rl.write(null, { ctrl: true, name: "u" }); // clear the typed "/" + + if (takesArg(picked)) { + rl.write(`${picked.name} `); + } else { + void runLine(picked.name); + } } } } finally { paletteOpen = false; - // The palette ran on the alternate screen; repaint the pinned row + bar - // and reflect any prefilled buffer back onto the input row. if (useInputRow) { statusBar.update(statusInfo()); - syncInput(); + + if (rl !== null) { + syncInput(); + } } } }; @@ -1584,13 +1743,18 @@ async function repl(args: ICliArgs): Promise { // Open the interactive `@` file picker: a compact dropdown rendered INLINE just // above the input row (the conversation stays visible — no alternate screen), // recency-ordered, type to fuzzy-filter. The buffer keeps its `@`; the live - // query is echoed onto the input row for feedback (it isn't in readline's + // query is echoed onto the input row for feedback (it isn't in readline's/editor's // buffer — the picker owns input). On select, the full path is appended after // the `@`; at send time `@path` expands to the file's contents (see runSend). const openFilePicker = async (): Promise => { paletteOpen = true; - const base = rl.line; // text up to and including the just-typed `@` + const base = + editorHandle !== null + ? editorHandle.getBuffer().getText() + : rl !== null + ? rl.line + : ""; // text up to and including the just-typed `@` const view: IPickerView = { render: (query, items, selected): void => { @@ -1614,23 +1778,33 @@ async function repl(args: ICliArgs): Promise { const picked = await pickFileInline(files, view); if (picked !== null) { - rl.write(`${picked} `); // append after the already-typed `@` + if (editorHandle !== null) { + editorHandle.getBuffer().insert(`${picked} `); + repaintEditor(editorHandle); + } else if (rl !== null) { + rl.write(`${picked} `); + } } } finally { paletteOpen = false; if (useInputRow) { statusBar.update(statusInfo()); - syncInput(); + + if (rl !== null) { + syncInput(); + } } } }; // `/` on an empty line opens the palette; `@` at a word boundary opens the file - // picker. setImmediate lets readline insert the key first, so we can inspect the - // settled buffer (`rl.line === "/"` / shouldOpenAtPicker). The shared paletteOpen - // guard keeps the two overlays mutually exclusive. No-op while busy. - if (process.stdin.isTTY) { + // picker. The editor handles these internally (via openPalette/openFilePicker deps); + // readline mode uses keypress detection. The shared paletteOpen guard keeps the + // two overlays mutually exclusive. No-op while busy. + + if (process.stdin.isTTY && !useEditor && !flags.basicInput()) { + // Only set up keypress detection for readline mode (not editor mode). emitKeypressEvents(process.stdin); process.stdin.on("keypress", (str: string | undefined) => { syncInput(); // keep the pinned input row in sync as the user types @@ -1639,13 +1813,13 @@ async function repl(args: ICliArgs): Promise { return; } - if (str === "/") { + if (str === "/" && rl !== null) { setImmediate(() => { if (!busy && !paletteOpen && rl.line === "/") { void openPalette(); } }); - } else if (str === "@" && useInputRow) { + } else if (str === "@" && useInputRow && rl !== null) { // The inline dropdown renders above the input row, so it needs that row // (a tall-enough TTY). Without it we skip the picker — `@path` typed by // hand still expands at send time (composeMessage), just no live popup. @@ -1665,41 +1839,69 @@ async function repl(args: ICliArgs): Promise { // Event-driven (not for-await) so stdin is read DURING a run: a line typed // mid-run is queued to steer the next turn (or, if "/exit", aborts). This is // what makes it feel like a real harness — you can redirect without waiting. - rl.on("line", (raw) => { - const line = raw.trim(); - - if (line.length === 0) { - if (!busy) { - prompt(); - } - - return; - } - - // readline's output is sinked in input-row mode, so the submitted line is - // never echoed to scrollback — record it ourselves so the transcript reads - // naturally above the (now-cleared) input row. - if (useInputRow) { - echo(`${STYLE.dim}›${RESET} ${line}\n`); - } + // When the editor is active, submitLine is wired via onSubmit; otherwise it's + // called here from readline. Crucially: the editor owns stdin exclusively in + // editor mode, and readline is NOT created in that case. + if (useEditor) { + editorHandle = startEditor({ + stdin: { + on: (event: string, cb: (data: string) => void) => { + process.stdin.on(event, cb); + }, + removeListener: (event: string, cb: (data: string) => void) => { + process.stdin.removeListener(event, cb); + }, + setRawMode: (mode: boolean) => { + process.stdin.setRawMode(mode); + }, + resume: () => { + process.stdin.resume(); + }, + // The editor does string ops per chunk; without UTF-8 encoding, + // process.stdin emits Buffers and the first keypress crashes. + setEncoding: () => { + process.stdin.setEncoding("utf8"); + }, + }, + out: (s: string) => { + statusBar.writeStream(s); + }, + // Multi-row editor rendering callback: paints to the pinned input area + renderEditor: ( + lines: string[], + cursorRow: number, + cursorCol: number + ) => { + statusBar.setEditor(lines, cursorRow, cursorCol); + }, + columns: process.stdout.columns, + rows: process.stdout.rows, + openPalette, + openFilePicker, + }); - if (busy) { - if (line === "/exit" || line === "/quit") { - active?.abort(); - rl.close(); + editorHandle.onSubmit(submitLine); + editorHandle.onInterrupt(() => { + if (active === null) { + closed = true; + editorHandle?.close(); + maybeFinish(); } else { - pending.push(line); - echo(" ↳ queued (steers the next turn)\n"); + active.abort(); } + }); + editorHandle.onExit(() => { + closed = true; + editorHandle?.close(); + maybeFinish(); + }); + } else if (rl !== null) { + rl.on("line", submitLine); + } - return; - } - - void runLine(line); - }); - - rl.on("close", () => { + rl?.on("close", () => { closed = true; + editorHandle?.close(); statusBar.teardown(); maybeFinish(); }); diff --git a/packages/core/src/config/config.constants.ts b/packages/core/src/config/config.constants.ts index 160b624..c4a8ea4 100644 --- a/packages/core/src/config/config.constants.ts +++ b/packages/core/src/config/config.constants.ts @@ -12,4 +12,5 @@ export const ENV_FLAG = { noScriptTool: "TSFORGE_NO_SCRIPT", noUpdateCheck: "TSFORGE_NO_UPDATE_CHECK", noGitTool: "TSFORGE_NO_GIT_TOOL", + basicInput: "TSFORGE_BASIC_INPUT", } as const; diff --git a/packages/core/src/config/flags.ts b/packages/core/src/config/flags.ts index e8b3bfb..a645c89 100644 --- a/packages/core/src/config/flags.ts +++ b/packages/core/src/config/flags.ts @@ -57,4 +57,7 @@ export const flags = { /** Withhold the read-only `git_context` tool on existing-code runs (default ON; * set to "1" to force off, e.g. for eval sweeps or non-git workspaces). */ noGitTool: (): boolean => isOn(ENV_FLAG.noGitTool), + /** Fall back to basic readline input (no multiline editor) in interactive mode. + * Default OFF — the editor is on. Set to "1" to disable the editor. */ + basicInput: (): boolean => isOn(ENV_FLAG.basicInput), }; diff --git a/packages/core/src/editor/buffer.ts b/packages/core/src/editor/buffer.ts new file mode 100644 index 0000000..a95f188 --- /dev/null +++ b/packages/core/src/editor/buffer.ts @@ -0,0 +1,432 @@ +import { graphemes } from "./segments"; +import { KillRing } from "./kill-ring"; +import { UndoStack, type ISnapshot } from "./undo-stack"; + +export class EditorBuffer { + private lines: string[]; + + private cursorLine: number; + + private cursorCol: number; // grapheme offset within lines[cursorLine] + + private stickyCol: number | null = null; + + private killRing: KillRing = new KillRing(); + + private lastWasKill = false; + + private lastYank: { start: number; length: number } | null = null; + + private undoStack: UndoStack = new UndoStack(); + + private lastSnapshotKind: string | null = null; + + private pastes: Map = new Map(); + + private pasteCounter = 0; + + constructor(initial = "") { + this.lines = initial.split("\n"); + this.cursorLine = this.lines.length - 1; + this.cursorCol = graphemes(this.lines[this.cursorLine] ?? "").length; + } + + getText(): string { + return this.lines.join("\n"); + } + + getCursor(): { line: number; col: number } { + return { line: this.cursorLine, col: this.cursorCol }; + } + + private curG(): string[] { + return graphemes(this.lines[this.cursorLine] ?? ""); + } + + private clearSticky(): void { + this.stickyCol = null; + this.lastYank = null; + } + + private snapshot(): ISnapshot { + return { + lines: structuredClone(this.lines), + cursorLine: this.cursorLine, + cursorCol: this.cursorCol, + }; + } + + private maybeSnapshot(kind: string): void { + if (kind !== this.lastSnapshotKind) { + this.undoStack.push(this.snapshot()); + this.lastSnapshotKind = kind; + } + } + + private insertRaw(text: string): void { + // Pure mutation: splice graphemes and advance cursor, no snapshot. + // text has no newlines here (newline() handles those); split defensively. + const parts = text.split("\n"); + + for (let i = 0; i < parts.length; i += 1) { + if (i > 0) { + this.newline(false); + } + + const piece = parts[i] ?? ""; + const g = this.curG(); + + g.splice(this.cursorCol, 0, ...graphemes(piece)); + this.lines[this.cursorLine] = g.join(""); + this.cursorCol += graphemes(piece).length; + } + } + + insert(text: string): void { + this.clearSticky(); + const kind = text.trim().length === 0 ? "insert-space" : "insert-word"; + + this.maybeSnapshot(kind); + this.insertRaw(text); + } + + insertPaste(text: string): void { + const lineCount = text.split("\n").length; + const charCount = text.length; + + if (lineCount > 10 || charCount > 1000) { + this.pasteCounter += 1; + const id = this.pasteCounter; + + this.pastes.set(id, text); + this.insert(`[paste #${id} +${lineCount} lines]`); + } else { + this.insert(text); + } + } + + newline(snapshot = true): void { + this.clearSticky(); + + if (snapshot) { + this.maybeSnapshot("other"); + } + + const g = this.curG(); + const left = g.slice(0, this.cursorCol).join(""); + const right = g.slice(this.cursorCol).join(""); + + this.lines.splice(this.cursorLine, 1, left, right); + this.cursorLine += 1; + this.cursorCol = 0; + } + + deleteBackward(): void { + this.clearSticky(); + this.maybeSnapshot("delete"); + + if (this.cursorCol > 0) { + const g = this.curG(); + + g.splice(this.cursorCol - 1, 1); + this.lines[this.cursorLine] = g.join(""); + this.cursorCol -= 1; + + return; + } + + if (this.cursorLine === 0) { + return; + } + + const prev = graphemes(this.lines[this.cursorLine - 1] ?? ""); + const cur = this.lines[this.cursorLine] ?? ""; + + this.cursorCol = prev.length; + this.lines.splice( + this.cursorLine - 1, + 2, + (this.lines[this.cursorLine - 1] ?? "") + cur + ); + this.cursorLine -= 1; + } + + deleteForward(): void { + this.clearSticky(); + this.maybeSnapshot("delete"); + const g = this.curG(); + + if (this.cursorCol < g.length) { + g.splice(this.cursorCol, 1); + this.lines[this.cursorLine] = g.join(""); + + return; + } + + if (this.cursorLine >= this.lines.length - 1) { + return; + } + + const next = this.lines[this.cursorLine + 1] ?? ""; + + this.lines.splice( + this.cursorLine, + 2, + (this.lines[this.cursorLine] ?? "") + next + ); + } + + moveLeft(): void { + this.clearSticky(); + + if (this.cursorCol > 0) { + this.cursorCol -= 1; + } else if (this.cursorLine > 0) { + this.cursorLine -= 1; + this.cursorCol = this.curG().length; + } + } + + moveRight(): void { + this.clearSticky(); + + if (this.cursorCol < this.curG().length) { + this.cursorCol += 1; + } else if (this.cursorLine < this.lines.length - 1) { + this.cursorLine += 1; + this.cursorCol = 0; + } + } + + setText(text: string, cursorToEnd = true): void { + this.lines = text.split("\n"); + + if (cursorToEnd) { + this.cursorLine = this.lines.length - 1; + this.cursorCol = graphemes(this.lines[this.cursorLine] ?? "").length; + } else { + this.cursorLine = 0; + this.cursorCol = 0; + } + } + + private isWordChar(ch: string): boolean { + return ch.trim().length > 0; + } + + moveWordRight(): void { + this.clearSticky(); + const g = this.curG(); + let i = this.cursorCol; + + while (i < g.length && !this.isWordChar(g[i] ?? "")) { + i += 1; + } + + while (i < g.length && this.isWordChar(g[i] ?? "")) { + i += 1; + } + + this.cursorCol = i; + } + + moveWordLeft(): void { + this.clearSticky(); + const g = this.curG(); + let i = this.cursorCol; + + while (i > 0 && !this.isWordChar(g[i - 1] ?? "")) { + i -= 1; + } + + while (i > 0 && this.isWordChar(g[i - 1] ?? "")) { + i -= 1; + } + + this.cursorCol = i; + } + + moveLineStart(): void { + this.clearSticky(); + this.cursorCol = 0; + } + + moveLineEnd(): void { + this.clearSticky(); + this.cursorCol = this.curG().length; + } + + moveDocStart(): void { + this.clearSticky(); + this.cursorLine = 0; + this.cursorCol = 0; + } + + moveDocEnd(): void { + this.clearSticky(); + this.cursorLine = this.lines.length - 1; + this.cursorCol = this.curG().length; + } + + private vertical(delta: number): void { + const target = this.cursorLine + delta; + + if (target < 0 || target >= this.lines.length) { + return; + } + + this.stickyCol ??= this.cursorCol; + this.cursorLine = target; + this.cursorCol = Math.min( + this.stickyCol, + graphemes(this.lines[target] ?? "").length + ); + } + + moveUp(): void { + this.vertical(-1); + } + + moveDown(): void { + this.vertical(1); + } + + deleteWordBackward(): void { + this.clearSticky(); + this.maybeSnapshot("delete"); + + const g = this.curG(); + let start = this.cursorCol; + + while (start > 0 && !this.isWordChar(g[start - 1] ?? "")) { + start -= 1; + } + + while (start > 0 && this.isWordChar(g[start - 1] ?? "")) { + start -= 1; + } + + const removed = g.slice(start, this.cursorCol).join(""); + + g.splice(start, this.cursorCol - start); + this.lines[this.cursorLine] = g.join(""); + this.cursorCol = start; + + this.killRing.push(removed, { accumulate: this.lastWasKill }); + this.lastWasKill = true; + } + + deleteWordForward(): void { + this.clearSticky(); + this.maybeSnapshot("delete"); + + const g = this.curG(); + let end = this.cursorCol; + + while (end < g.length && !this.isWordChar(g[end] ?? "")) { + end += 1; + } + + while (end < g.length && this.isWordChar(g[end] ?? "")) { + end += 1; + } + + const removed = g.slice(this.cursorCol, end).join(""); + + g.splice(this.cursorCol, end - this.cursorCol); + this.lines[this.cursorLine] = g.join(""); + + this.killRing.push(removed, { accumulate: this.lastWasKill }); + this.lastWasKill = true; + } + + deleteToLineStart(): void { + this.clearSticky(); + this.maybeSnapshot("delete"); + + const g = this.curG(); + const removed = g.slice(0, this.cursorCol).join(""); + + g.splice(0, this.cursorCol); + this.lines[this.cursorLine] = g.join(""); + this.cursorCol = 0; + + this.killRing.push(removed, { accumulate: this.lastWasKill }); + this.lastWasKill = true; + } + + deleteToLineEnd(): void { + this.clearSticky(); + this.maybeSnapshot("delete"); + + const g = this.curG(); + const removed = g.slice(this.cursorCol).join(""); + + g.splice(this.cursorCol); + this.lines[this.cursorLine] = g.join(""); + + this.killRing.push(removed, { accumulate: this.lastWasKill }); + this.lastWasKill = true; + } + + yank(): void { + this.clearSticky(); + this.maybeSnapshot("other"); + + const text = this.killRing.current(); + const startCol = this.cursorCol; + + this.insertRaw(text); + this.lastYank = { start: startCol, length: graphemes(text).length }; + this.lastWasKill = false; + } + + yankPop(): void { + this.stickyCol = null; + + if (this.lastYank === null) { + return; + } + + this.maybeSnapshot("other"); + this.killRing.rotate(); + const text = this.killRing.current(); + const g = this.curG(); + const oldLength = this.lastYank.length; + const startCol = this.lastYank.start; + + g.splice(startCol, oldLength, ...graphemes(text)); + this.lines[this.cursorLine] = g.join(""); + this.cursorCol = startCol + graphemes(text).length; + this.lastYank = { start: startCol, length: graphemes(text).length }; + } + + undo(): void { + const snapshot = this.undoStack.undo(this.snapshot()); + + if (snapshot) { + this.lines = snapshot.lines; + this.cursorLine = snapshot.cursorLine; + this.cursorCol = snapshot.cursorCol; + this.lastSnapshotKind = null; + } + } + + redo(): void { + const snapshot = this.undoStack.redo(); + + if (snapshot) { + this.lines = snapshot.lines; + this.cursorLine = snapshot.cursorLine; + this.cursorCol = snapshot.cursorCol; + this.lastSnapshotKind = null; + } + } + + expand(): string { + return this.getText().replace( + /\[paste #(\d+) \+\d+ lines\]/g, + (_, id) => this.pastes.get(Number(id)) ?? _ + ); + } +} diff --git a/packages/core/src/editor/controller.ts b/packages/core/src/editor/controller.ts new file mode 100644 index 0000000..9573e7d --- /dev/null +++ b/packages/core/src/editor/controller.ts @@ -0,0 +1,577 @@ +import { appendFileSync } from "node:fs"; +import { EditorBuffer } from "./buffer"; +import { decodeKeys } from "./keys"; +import { createPasteScanner } from "./paste"; +import { renderEditor } from "./view"; +import { graphemes } from "./segments"; + +export interface IEditorHandle { + onSubmit(cb: (message: string) => void): void; + onChange(cb: () => void): void; + onInterrupt(cb: () => void): void; + onExit(cb: () => void): void; + getBuffer(): EditorBuffer; + close(): void; +} + +export interface IStdin { + on(event: string, callback: (data: string) => void): void; + removeListener?(event: string, callback: (data: string) => void): void; + setRawMode?(mode: boolean): void; + resume?(): void; + setEncoding?(encoding: string): void; +} + +export interface IStartEditorDeps { + stdin: IStdin; + out: (s: string) => void; + renderEditor?: ( + lines: string[], + cursorRow: number, + cursorCol: number + ) => void; + columns?: number; + rows?: number; + openPalette?: () => Promise; + openFilePicker?: () => Promise; +} + +type KeyAction = (buffer: EditorBuffer) => void; + +/** Rows the status bar reserves below the editor block: the 2-row bar plus the + * prompt/input row. renderEditor must window to the SAME height the StatusBar + * can actually paint (rows - this), or the cursor line gets clipped off the + * bottom when the buffer is taller than the visible area. Mirrors + * RESERVED_ROWS (2) + 1 in status-bar.ts. */ +const EDITOR_RESERVED_ROWS = 3; + +/** Debug logging helper: append to TSFORGE_EDITOR_DEBUG if set. */ +function debugLog(msg: string): void { + const path = process.env.TSFORGE_EDITOR_DEBUG; + + if (path !== undefined) { + appendFileSync(path, `${msg}\n`); + } +} + +/** + * Build a dispatch table: normalized key string → buffer action. + * Maps keys (possibly with modifiers) to EditorBuffer method calls. + */ +function buildKeyDispatchTable(): Map { + const table = new Map(); + + // Basic editing + table.set("backspace", (buf) => { + buf.deleteBackward(); + }); + table.set("delete", (buf) => { + buf.deleteForward(); + }); + table.set("tab", (buf) => { + buf.insert("\t"); + }); + + // Motion keys — note: up/down are handled specially in handleCharKey + // when at buffer edges for history navigation + table.set("left", (buf) => { + buf.moveLeft(); + }); + table.set("right", (buf) => { + buf.moveRight(); + }); + table.set("home", (buf) => { + buf.moveLineStart(); + }); + table.set("end", (buf) => { + buf.moveLineEnd(); + }); + + // Ctrl+left/right → word motion + table.set("ctrl+left", (buf) => { + buf.moveWordLeft(); + }); + table.set("ctrl+right", (buf) => { + buf.moveWordRight(); + }); + + // Ctrl+home/end → doc start/end + table.set("ctrl+home", (buf) => { + buf.moveDocStart(); + }); + table.set("ctrl+end", (buf) => { + buf.moveDocEnd(); + }); + + // Kill/delete word operations (emacs-style) + table.set("ctrl+w", (buf) => { + buf.deleteWordBackward(); + }); // kill word backward + table.set("alt+d", (buf) => { + buf.deleteWordForward(); + }); // kill word forward + table.set("ctrl+u", (buf) => { + buf.deleteToLineStart(); + }); // kill to line start + table.set("ctrl+k", (buf) => { + buf.deleteToLineEnd(); + }); // kill to line end + + // Yank operations + table.set("ctrl+y", (buf) => { + buf.yank(); + }); // yank (paste from kill ring) + table.set("alt+y", (buf) => { + buf.yankPop(); + }); // yank-pop (rotate kill ring) + + // Undo/redo + table.set("ctrl+z", (buf) => { + buf.undo(); + }); + table.set("ctrl+shift+z", (buf) => { + buf.redo(); + }); + + return table; +} + +export function startEditor(deps: IStartEditorDeps): IEditorHandle { + const { + stdin, + out, + renderEditor: renderEditorFn, + columns = 80, + rows = 10, + openPalette, + openFilePicker, + } = deps; + + const buffer = new EditorBuffer(); + const pasteScanner = createPasteScanner(); + const keyDispatchTable = buildKeyDispatchTable(); + + let isOpen = true; + const submitCallbacks: ((message: string) => void)[] = []; + const changeCallbacks: (() => void)[] = []; + const interruptCallbacks: (() => void)[] = []; + const exitCallbacks: (() => void)[] = []; + + // In-session history: submitted messages for up/down navigation + const history: string[] = []; + let historyIndex = -1; // -1 = not in history, >= 0 = viewing history item + let draftText: string | null = null; + let dataListener: ((chunk: string) => void) | null = null; + + function repaint(): void { + if (!isOpen) { + return; + } + + const { line, col } = buffer.getCursor(); + const lines = buffer.getText().split("\n"); + + const frame = renderEditor( + { + lines, + cursorLine: line, + cursorCol: col, + }, + { + columns, + maxRows: Math.max(1, rows - EDITOR_RESERVED_ROWS), + color: true, + } + ); + + if (renderEditorFn) { + debugLog( + `[repaint] rows=${frame.rows} cursorRow=${frame.cursorRow} cursorCol=${frame.cursorCol} frame=${JSON.stringify(frame.frame)}` + ); + + // Extract visual lines from the rendered frame (simple split on \n) + const visualLines = frame.frame.split("\n"); + + renderEditorFn(visualLines, frame.cursorRow, frame.cursorCol); + } else { + // Fallback: stream the raw frame (the buggy path, used if renderEditor not provided) + out(frame.frame); + } + } + + function notifyChange(): void { + changeCallbacks.forEach((cb) => { + cb(); + }); + } + + // Save the current buffer as a history item and emit onSubmit callbacks + function saveToHistory(message: string): void { + history.push(message); + draftText = null; + historyIndex = -1; + } + + // Navigate history: up arrow when cursor is on first line → prev item, down arrow when on last line → next + + function navigateHistoryUp(): void { + if (historyIndex === -1) { + // Save current draft before entering history + draftText = buffer.getText(); + historyIndex = history.length - 1; + } else if (historyIndex > 0) { + historyIndex -= 1; + } else { + return; // Already at the top + } + + if (historyIndex >= 0 && historyIndex < history.length) { + buffer.setText(history[historyIndex] ?? ""); + repaint(); + notifyChange(); + } + } + + function navigateHistoryDown(): void { + if (historyIndex === -1) { + return; // Not in history + } + + if (historyIndex === history.length - 1) { + // Restore draft + buffer.setText(draftText ?? ""); + draftText = null; + historyIndex = -1; + } else { + historyIndex += 1; + buffer.setText(history[historyIndex] ?? ""); + } + + repaint(); + notifyChange(); + } + + function handleReturnKey(ctrl: boolean, alt: boolean, shift: boolean): void { + const bufferText = buffer.getText(); + const { col } = buffer.getCursor(); + const currentLine = bufferText.split("\n")[buffer.getCursor().line] ?? ""; + + let hasTrailingBackslash = false; + + if (col > 0) { + const lineGraphemes = graphemes(currentLine); + const beforeCursor = lineGraphemes.slice(0, col).join(""); + + if (beforeCursor.endsWith("\\")) { + hasTrailingBackslash = true; + } + } + + if (hasTrailingBackslash && !ctrl && !alt && !shift) { + buffer.deleteBackward(); + buffer.newline(); + repaint(); + notifyChange(); + } else if ((shift || alt) && !ctrl) { + buffer.newline(); + repaint(); + notifyChange(); + } else if (!ctrl && !alt && !shift) { + const message = buffer.expand(); + + saveToHistory(message); + buffer.setText(""); + repaint(); + notifyChange(); + submitCallbacks.forEach((cb) => { + cb(message); + }); + } + } + + function handleCharKey( + text: string, + ctrl: boolean, + alt: boolean, + shift: boolean + ): void { + // Ctrl-C: interrupt the current run + if (ctrl && text === "c") { + interruptCallbacks.forEach((cb) => { + cb(); + }); + + return; + } + + // Ctrl-D on an empty buffer: exit + if (ctrl && text === "d" && buffer.getText().length === 0) { + exitCallbacks.forEach((cb) => { + cb(); + }); + + return; + } + + if (ctrl || alt || shift) { + const keyParts: string[] = []; + + if (ctrl) { + keyParts.push("ctrl"); + } + + if (alt) { + keyParts.push("alt"); + } + + if (shift) { + keyParts.push("shift"); + } + + keyParts.push(text); + const normalizedKey = keyParts.join("+"); + + const action = keyDispatchTable.get(normalizedKey); + + if (action) { + action(buffer); + repaint(); + notifyChange(); + + return; + } + } + + buffer.insert(text); + repaint(); + notifyChange(); + } + + function triggerPaletteOrPicker(): void { + const currentText = buffer.getText(); + + if ( + currentText === "/" && + openPalette !== undefined && + typeof openPalette === "function" + ) { + openPalette().catch(() => { + // ignore errors + }); + } + + if ( + currentText === "@" && + openFilePicker !== undefined && + typeof openFilePicker === "function" + ) { + openFilePicker().catch(() => { + // ignore errors + }); + } + } + + function dispatchKeyEvent(event: { + name: string; + text: string; + ctrl: boolean; + alt: boolean; + shift: boolean; + }): void { + const { name, text, ctrl, alt, shift } = event; + + if (name === "return") { + handleReturnKey(ctrl, alt, shift); + + return; + } + + if (name === "char") { + handleCharKey(text, ctrl, alt, shift); + + return; + } + + // History navigation: up/down at buffer edges + if (name === "up") { + const { line } = buffer.getCursor(); + + if (line === 0) { + navigateHistoryUp(); + + return; + } + + buffer.moveUp(); + repaint(); + notifyChange(); + + return; + } + + if (name === "down") { + const { line } = buffer.getCursor(); + const lines = buffer.getText().split("\n"); + const lastLine = lines.length - 1; + + if (line === lastLine) { + navigateHistoryDown(); + + return; + } + + buffer.moveDown(); + repaint(); + notifyChange(); + + return; + } + + const keyParts: string[] = []; + + if (ctrl) { + keyParts.push("ctrl"); + } + + if (alt) { + keyParts.push("alt"); + } + + if (shift) { + keyParts.push("shift"); + } + + keyParts.push(name); + const normalizedKey = keyParts.join("+"); + + const action = keyDispatchTable.get(normalizedKey); + + if (action) { + action(buffer); + repaint(); + notifyChange(); + } + + triggerPaletteOrPicker(); + } + + function onDataChunk(raw: string | Buffer): void { + if (!isOpen) { + return; + } + + // Robust against stdin emitting Buffers (when setEncoding wasn't applied): + // every downstream step (paste scan, key decode) does string ops, so a raw + // Buffer would throw on the first keystroke. Normalize to a UTF-8 string. + const chunk = typeof raw === "string" ? raw : raw.toString("utf8"); + + debugLog(`[input-chunk] raw=${JSON.stringify(chunk)}`); + + const wasActive = pasteScanner.isActive(); + const pasteScan = pasteScanner.feed(chunk); + + if (pasteScan.content !== null) { + debugLog(`[paste] content=${JSON.stringify(pasteScan.content)}`); + + buffer.insertPaste(pasteScan.content); + repaint(); + notifyChange(); + + return; + } + + if (pasteScanner.isActive() || wasActive) { + return; + } + + const keyEvents = decodeKeys(chunk); + + debugLog(`[keys] decoded=${JSON.stringify(keyEvents)}`); + + for (const event of keyEvents) { + dispatchKeyEvent(event); + } + } + + // Set up raw mode and enable bracketed paste + if (typeof stdin.setRawMode === "function") { + stdin.setRawMode(true); + } + + // Enable bracketed paste + out("\x1b[?2004h"); + + // Enable Kitty keyboard + modifyOtherKeys (gated by env) + const shouldSkipKitty = + process.platform === "win32" || + (process.env.SSH_CONNECTION ?? "") !== "" || + (process.env.SSH_TTY ?? "") !== "" || + (process.env.WSL_DISTRO_NAME ?? "") !== ""; + + if (!shouldSkipKitty) { + out("\x1b[>1u"); // Kitty keyboard + out("\x1b[>4;2m"); // modifyOtherKeys + } + + // Set stdin encoding and resume + if (typeof stdin.setEncoding === "function") { + stdin.setEncoding("utf8"); + } + + if (typeof stdin.resume === "function") { + stdin.resume(); + } + + // Attach data listener + dataListener = onDataChunk; + stdin.on("data", dataListener); + + return { + onSubmit(cb: (message: string) => void) { + submitCallbacks.push(cb); + }, + + onChange(cb: () => void) { + changeCallbacks.push(cb); + }, + + onInterrupt(cb: () => void) { + interruptCallbacks.push(cb); + }, + + onExit(cb: () => void) { + exitCallbacks.push(cb); + }, + + getBuffer(): EditorBuffer { + return buffer; + }, + + close(): void { + if (!isOpen) { + return; + } + + isOpen = false; + + // Remove data listener + if (stdin.removeListener !== undefined) { + stdin.removeListener("data", dataListener); + } + + // Disable bracketed paste + out("\x1b[?2004l"); + + // Disable Kitty + modifyOtherKeys + if (!shouldSkipKitty) { + out("\x1b[4;0m"); + } + + // Unset raw mode + if (typeof stdin.setRawMode === "function") { + stdin.setRawMode(false); + } + }, + }; +} diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts new file mode 100644 index 0000000..e9e0986 --- /dev/null +++ b/packages/core/src/editor/index.ts @@ -0,0 +1,18 @@ +export { EditorBuffer } from "./buffer"; +export { decodeKeys, type IKeyEvent } from "./keys"; +export { + createPasteScanner, + type IPasteScanner, + type IPasteScan, +} from "./paste"; +export { + renderEditor, + type IEditorInput, + type IEditorOptions, + type IEditorFrame, +} from "./view"; +export { + startEditor, + type IEditorHandle, + type IStartEditorDeps, +} from "./controller"; diff --git a/packages/core/src/editor/keys.ts b/packages/core/src/editor/keys.ts new file mode 100644 index 0000000..6e48c01 --- /dev/null +++ b/packages/core/src/editor/keys.ts @@ -0,0 +1,289 @@ +import { graphemes } from "./segments"; + +export interface IKeyEvent { + name: string; + ctrl: boolean; + alt: boolean; + shift: boolean; + text: string; +} + +const LEGACY_KEYS: Record = { + A: "up", + B: "down", + C: "right", + D: "left", + H: "home", + F: "end", +}; + +const CODEPOINT_NAMES: Record = { + 9: "tab", + 13: "return", + 27: "escape", +}; + +const ESC = "\x1b"; +const CSI_U_PATTERN = /^(\d+)(?::\d+)*;(\d+)(?::\d+)*u/u; +const XTERM_PATTERN = /^27;(\d+);(\d+)~/u; +const LEGACY_PATTERN = /^([ABCDHF])/u; +const DELETE_SEQ = "\x1b[3~"; +const ALT_CR = "\x1b\r"; +const ALT_LF = "\x1b\n"; + +interface IModifiers { + shift: boolean; + alt: boolean; + ctrl: boolean; +} + +function decodeModifier(modBits: number): IModifiers { + const bitValue = modBits - 1; + + return { + shift: (bitValue & 1) !== 0, + alt: (bitValue & 2) !== 0, + ctrl: (bitValue & 4) !== 0, + }; +} + +const DEFAULT_MODS: IModifiers = { + shift: false, + alt: false, + ctrl: false, +}; + +function createKeyEvent( + name: string, + text = "", + mods: IModifiers = DEFAULT_MODS +): IKeyEvent { + return { + name, + text, + shift: mods.shift, + alt: mods.alt, + ctrl: mods.ctrl, + }; +} + +function tryParseCsiU( + chunk: string, + idx: number +): { event: IKeyEvent; len: number } | null { + if (!chunk.slice(idx).startsWith(ESC + "[")) { + return null; + } + + const regexResult = CSI_U_PATTERN.exec(chunk.slice(idx + 2)); + + if (!regexResult) { + return null; + } + + const cpStr = regexResult[1]; + const modStr = regexResult[2]; + + if (cpStr === undefined || modStr === undefined) { + return null; + } + + const codepoint = Number.parseInt(cpStr, 10); + const modBits = Number.parseInt(modStr, 10); + const mods = decodeModifier(modBits); + const name = CODEPOINT_NAMES[codepoint] ?? "char"; + const text = name === "char" ? String.fromCharCode(codepoint) : ""; + + return { + event: createKeyEvent(name, text, mods), + len: 2 + regexResult[0].length, + }; +} + +function tryParseXterm( + chunk: string, + idx: number +): { event: IKeyEvent; len: number } | null { + if (!chunk.slice(idx).startsWith(ESC + "[")) { + return null; + } + + const regexResult = XTERM_PATTERN.exec(chunk.slice(idx + 2)); + + if (!regexResult) { + return null; + } + + const modStr = regexResult[1]; + const cpStr = regexResult[2]; + + if (modStr === undefined || cpStr === undefined) { + return null; + } + + const modBits = Number.parseInt(modStr, 10); + const codepoint = Number.parseInt(cpStr, 10); + const mods = decodeModifier(modBits); + const name = CODEPOINT_NAMES[codepoint] ?? "char"; + const text = name === "char" ? String.fromCharCode(codepoint) : ""; + + return { + event: createKeyEvent(name, text, mods), + len: 2 + regexResult[0].length, + }; +} + +function tryParseLegacy( + chunk: string, + idx: number +): { event: IKeyEvent; len: number } | null { + if (!chunk.slice(idx).startsWith(ESC + "[")) { + return null; + } + + const regexResult = LEGACY_PATTERN.exec(chunk.slice(idx + 2)); + + if (!regexResult) { + return null; + } + + const key = regexResult[1]; + + if (key === undefined) { + return null; + } + + const keyName = LEGACY_KEYS[key] ?? "unknown"; + + return { event: createKeyEvent(keyName), len: 2 + regexResult[0].length }; +} + +function tryParsePrintable( + chunk: string, + idx: number +): { event: IKeyEvent; len: number } | null { + // codePointAt (not charCodeAt) so an emoji / non-BMP char reads as one code + // point instead of a lone surrogate. Reject only true control ranges: C0 + // controls, DEL, and C1 controls — everything else (accented Latin, CJK, + // emoji, …) is printable text the editor must accept. + const cp = chunk.codePointAt(idx); + + if ( + cp === undefined || + cp < 0x20 || + cp === 0x7f || + (cp >= 0x80 && cp <= 0x9f) + ) { + return null; + } + + const graphemeList = graphemes(chunk.slice(idx)); + const grapheme = graphemeList[0]; + + if (grapheme === undefined) { + return null; + } + + return { + event: createKeyEvent("char", grapheme, DEFAULT_MODS), + len: grapheme.length, + }; +} + +interface IParseResult { + event: IKeyEvent; + len: number; +} + +function parseOneKey(chunk: string, idx: number): IParseResult | null { + const ch = chunk.charCodeAt(idx); + const remaining = chunk.slice(idx); + + // Kitty CSI-u + const csiU = tryParseCsiU(chunk, idx); + + if (csiU !== null) { + return csiU; + } + + // xterm modifyOtherKeys + const xterm = tryParseXterm(chunk, idx); + + if (xterm !== null) { + return xterm; + } + + // Legacy arrow/home/end/delete + const legacy = tryParseLegacy(chunk, idx); + + if (legacy !== null) { + return legacy; + } + + if (remaining.startsWith(DELETE_SEQ)) { + return { event: createKeyEvent("delete"), len: 4 }; + } + + // Alt+Return + const altMods: IModifiers = { shift: false, alt: true, ctrl: false }; + + if (remaining.startsWith(ALT_CR)) { + return { event: createKeyEvent("return", "", altMods), len: 2 }; + } + + if (remaining.startsWith(ALT_LF)) { + return { event: createKeyEvent("return", "", altMods), len: 2 }; + } + + // Plain return + if (ch === 0x0d || ch === 0x0a) { + return { event: createKeyEvent("return"), len: 1 }; + } + + // Backspace + if (ch === 0x7f || ch === 0x08) { + return { event: createKeyEvent("backspace"), len: 1 }; + } + + // Control bytes + if (ch >= 0x01 && ch <= 0x1a) { + const text = String.fromCharCode(ch + 96); + const ctrlMods: IModifiers = { shift: false, alt: false, ctrl: true }; + + return { event: createKeyEvent("char", text, ctrlMods), len: 1 }; + } + + // Alt+printable + const altCharMods: IModifiers = { shift: false, alt: true, ctrl: false }; + + if (ch === 0x1b && idx + 1 < chunk.length) { + const nextCh = chunk.charCodeAt(idx + 1); + + if (nextCh >= 0x20 && nextCh < 0x7f) { + const text = chunk[idx + 1]; + + return { event: createKeyEvent("char", text, altCharMods), len: 2 }; + } + } + + // Printable + return tryParsePrintable(chunk, idx); +} + +export function decodeKeys(chunk: string): IKeyEvent[] { + const events: IKeyEvent[] = []; + let idx = 0; + + while (idx < chunk.length) { + const result = parseOneKey(chunk, idx); + + if (result !== null) { + events.push(result.event); + idx += result.len; + } else { + idx += 1; + } + } + + return events; +} diff --git a/packages/core/src/editor/kill-ring.ts b/packages/core/src/editor/kill-ring.ts new file mode 100644 index 0000000..469fb57 --- /dev/null +++ b/packages/core/src/editor/kill-ring.ts @@ -0,0 +1,35 @@ +export class KillRing { + private entries: string[] = []; + + private index = 0; + + push(text: string, opts?: { prepend?: boolean; accumulate?: boolean }): void { + if (text.length === 0) { + return; + } + + const shouldAccumulate = opts?.accumulate ?? false; + const shouldPrepend = opts?.prepend ?? false; + + if (shouldAccumulate && this.entries.length > 0) { + this.entries[0] = shouldPrepend + ? text + (this.entries[0] ?? "") + : (this.entries[0] ?? "") + text; + } else { + this.entries.unshift(text); + this.index = 0; + } + } + + current(): string { + return this.entries[this.index] ?? ""; + } + + rotate(): void { + if (this.entries.length === 0) { + return; + } + + this.index = (this.index + 1) % this.entries.length; + } +} diff --git a/packages/core/src/editor/paste.ts b/packages/core/src/editor/paste.ts new file mode 100644 index 0000000..9a8eead --- /dev/null +++ b/packages/core/src/editor/paste.ts @@ -0,0 +1,107 @@ +/** + * Bracketed-paste scanner. A terminal with bracketed paste enabled (ESC[?2004h) + * wraps a paste in ESC[200~ … ESC[201~ and delivers it (usually) as one stdin + * chunk, with the pasted line breaks as raw CR (`\r`). readline treats each CR as + * Enter, so without intercepting, a multi-line paste submits once per line. This + * scanner detects the bracketed block in the raw byte stream and hands back the + * pasted text (newlines normalized to `\n`) so the caller can drop it into the + * input buffer instead — and exposes `active` so the caller can swallow the + * spurious line submits readline emits for the paste's CRs until the paste closes. + * + * Pure + stateful-across-chunks (no I/O), so it's unit-tested against captured + * real-terminal bytes with no TTY. + */ +const PASTE_START = "\x1b[200~"; +const PASTE_END = "\x1b[201~"; + +export interface IPasteScan { + /** The pasted text (CR/CRLF normalized to `\n`) when a paste COMPLETED in this + * feed; null while no paste is active or one is still open across chunks. */ + content: string | null; + /** True while a paste is OPEN (start seen, end not yet) — the caller suppresses + * readline's line submits until the paste closes and the buffer is filled. */ + active: boolean; +} + +export interface IPasteScanner { + feed(chunk: string): IPasteScan; + isActive(): boolean; + forceEnd(): string | null; +} + +function normalizeNewlines(s: string): string { + return s.replace(/\r\n?|\n/gu, "\n"); +} + +function finalizePasteContent(s: string): string { + // Decode tmux CSI-u control sequences: ESC[codepoint;modu → String.fromCharCode(codepoint) + const csiURegex = String.raw`\x1b\[(\d+);\d+u`; + let decoded = s.replace(new RegExp(csiURegex, "g"), (_match, codepoint) => { + return String.fromCharCode(Number(codepoint)); + }); + + // Normalize newlines first (CR/CRLF → LF) + decoded = normalizeNewlines(decoded); + + // Strip non-printable control chars except newline + // Keep: \n (0x0a), printable ASCII (0x20–0x7e), and anything >= 0x80 (UTF-8 multibyte) + // Remove: < 0x20 except 0x0a, and 0x7f (DEL) + const controlCharRegex = String.raw`[\x00-\x08\x0b-\x1f\x7f]`; + + decoded = decoded.replace(new RegExp(controlCharRegex, "g"), ""); + + return decoded; +} + +export function createPasteScanner(): IPasteScanner { + let active = false; + let buf = ""; + + return { + isActive: (): boolean => active, + feed(chunk: string): IPasteScan { + let rest = chunk; + + if (!active) { + const start = rest.indexOf(PASTE_START); + + if (start === -1) { + return { content: null, active: false }; + } + + active = true; + buf = ""; + rest = rest.slice(start + PASTE_START.length); + } + + const end = rest.indexOf(PASTE_END); + + if (end === -1) { + // Paste spans more chunks — keep buffering, keep swallowing submits. + buf += rest; + + return { content: null, active: true }; + } + + buf += rest.slice(0, end); + const content = finalizePasteContent(buf); + + active = false; + buf = ""; + + return { content, active: false }; + }, + forceEnd(): string | null { + if (!active) { + return null; + } + + const content = finalizePasteContent(buf); + + active = false; + buf = ""; + + return content; + }, + }; +} diff --git a/packages/core/src/editor/segments.ts b/packages/core/src/editor/segments.ts new file mode 100644 index 0000000..bcdd69e --- /dev/null +++ b/packages/core/src/editor/segments.ts @@ -0,0 +1,15 @@ +const SEG = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +export function graphemes(s: string): string[] { + const out: string[] = []; + + for (const { segment } of SEG.segment(s)) { + out.push(segment); + } + + return out; +} + +export function graphemeCount(s: string): number { + return graphemes(s).length; +} diff --git a/packages/core/src/editor/undo-stack.ts b/packages/core/src/editor/undo-stack.ts new file mode 100644 index 0000000..6289b15 --- /dev/null +++ b/packages/core/src/editor/undo-stack.ts @@ -0,0 +1,36 @@ +export interface ISnapshot { + lines: string[]; + cursorLine: number; + cursorCol: number; +} + +export class UndoStack { + private undoStack: ISnapshot[] = []; + private redoStack: ISnapshot[] = []; + + push(s: ISnapshot): void { + this.undoStack.push(structuredClone(s)); + this.redoStack = []; + } + + undo(cur: ISnapshot): ISnapshot | null { + if (this.undoStack.length === 0) { + return null; + } + + this.redoStack.push(structuredClone(cur)); + const snapshot = this.undoStack.pop(); + + return snapshot ?? null; + } + + redo(): ISnapshot | null { + if (this.redoStack.length === 0) { + return null; + } + + const snapshot = this.redoStack.pop(); + + return snapshot ?? null; + } +} diff --git a/packages/core/src/editor/view.ts b/packages/core/src/editor/view.ts new file mode 100644 index 0000000..83ee4bf --- /dev/null +++ b/packages/core/src/editor/view.ts @@ -0,0 +1,370 @@ +import { graphemes } from "./segments"; +import { paint, STYLE } from "../render/style"; + +export interface IEditorInput { + lines: string[]; + cursorLine: number; + cursorCol: number; +} + +export interface IEditorOptions { + columns: number; + maxRows: number; + color: boolean; +} + +export interface IEditorFrame { + frame: string; + rows: number; + cursorRow: number; + cursorCol: number; +} + +interface IWrappedRow { + text: string; + cursorRow?: number; + cursorCol?: number; +} + +interface IVisibleWindow { + startLine: number; + endLine: number; + clippedAbove: boolean; + clippedBelow: boolean; +} + +/** + * Wrap a single logical line into visual rows, accounting for grapheme width. + * Returns array of { text, cursorRow, cursorCol } for rows containing the cursor, + * or undefined cursor position if the cursor isn't in this line. + */ +function wrapLine( + line: string, + cursorCol: number, + columns: number +): IWrappedRow[] { + if (columns <= 0) { + return []; + } + + const graphemeList = graphemes(line); + const rows: IWrappedRow[] = []; + let row = ""; + let rowGraphemeCount = 0; + let rowCursorCol: number | undefined; + let visualRow = 0; + + for (let i = 0; i < graphemeList.length; i += 1) { + const g = graphemeList[i]; + + if (rowGraphemeCount >= columns) { + rows.push({ + text: row, + cursorRow: rowCursorCol !== undefined ? visualRow : undefined, + cursorCol: rowCursorCol, + }); + row = ""; + rowGraphemeCount = 0; + rowCursorCol = undefined; + visualRow += 1; + } + + if (i === cursorCol) { + rowCursorCol = rowGraphemeCount; + } + + if (g !== undefined) { + row += g; + rowGraphemeCount += 1; + } + } + + // Handle cursor at end of line + if (graphemeList.length === cursorCol) { + rowCursorCol = rowGraphemeCount; + } + + // Push final row + rows.push({ + text: row, + cursorRow: rowCursorCol !== undefined ? visualRow : undefined, + cursorCol: rowCursorCol, + }); + + return rows; +} + +/** + * Compute the number of visual rows needed to render each logical line. + */ +function computeLineVisualRows(lines: string[], columns: number): number[] { + return lines.map((line) => { + const wrapped = wrapLine(line, graphemes(line).length, columns); + + return wrapped.length; + }); +} + +/** + * Try to fit lines into maxRows, ensuring cursor line is visible. + * Returns { startLine, endLine } of the visible window. + * The window tries to leave room for scroll indicators. + */ +function fitLinesInWindow( + lineVisualRows: number[], + cursorLine: number, + maxRows: number +): { startLine: number; endLine: number } { + // Reserve rows for indicators if needed + const indicatorReserve = 1; // one row for above/below indicator + + let startLine = 0; + let endLine = 0; + let totalRows = 0; + const availRows = maxRows - indicatorReserve; + + // Greedy: fit as many lines as possible starting from cursor line + for (let i = cursorLine; i < lineVisualRows.length; i += 1) { + const rows = lineVisualRows[i]; + + if (rows !== undefined && totalRows + rows <= availRows) { + totalRows += rows; + endLine = i + 1; + } else { + break; + } + } + + // If cursor line not yet visible, back up + if (endLine <= cursorLine) { + endLine = cursorLine + 1; + totalRows = 0; + + for (let i = cursorLine; i < endLine; i += 1) { + const rows = lineVisualRows[i]; + + if (rows !== undefined) { + totalRows += rows; + } + } + } + + // Fill remaining space backwards from cursor + for (let i = cursorLine - 1; i >= 0 && totalRows < availRows; i -= 1) { + const rows = lineVisualRows[i]; + + if (rows !== undefined && totalRows + rows <= availRows) { + totalRows += rows; + startLine = i; + } else { + break; + } + } + + return { startLine, endLine }; +} + +/** + * Compute which logical line range to display given the cursor position + * and maxRows constraint. + */ +function computeVisibleWindow( + lines: string[], + cursorLine: number, + maxRows: number, + columns: number +): IVisibleWindow { + if (maxRows <= 0 || lines.length === 0) { + return { + startLine: 0, + endLine: 0, + clippedAbove: false, + clippedBelow: false, + }; + } + + const lineVisualRows = computeLineVisualRows(lines, columns); + const { startLine, endLine } = fitLinesInWindow( + lineVisualRows, + cursorLine, + maxRows + ); + + return { + startLine, + endLine, + clippedAbove: startLine > 0, + clippedBelow: endLine < lines.length, + }; +} + +/** + * Render a single logical line and track cursor position if present. + */ +function renderLineToFrame( + line: string, + isCurrentLine: boolean, + cursorCol: number, + columns: number, + currentTotalRows: number +): { + frameStr: string; + visualRowCount: number; + cursorRow?: number; + cursorCol?: number; +} { + const wrapped = wrapLine(line, isCurrentLine ? cursorCol : -1, columns); + let frameStr = ""; + let foundCursor: { row: number; col: number } | undefined; + + for (let rowIdx = 0; rowIdx < wrapped.length; rowIdx += 1) { + const wrappedRow = wrapped[rowIdx]; + + if (wrappedRow === undefined) { + continue; + } + + frameStr += wrappedRow.text; + + if (isCurrentLine && wrappedRow.cursorRow !== undefined) { + foundCursor = { + row: currentTotalRows + wrappedRow.cursorRow, + col: wrappedRow.cursorCol ?? 0, + }; + } + + // Add newline between visual rows (but not after last row) + if (rowIdx < wrapped.length - 1) { + frameStr += "\n"; + } + } + + return { + frameStr, + visualRowCount: wrapped.length, + cursorRow: foundCursor?.row, + cursorCol: foundCursor?.col, + }; +} + +/** + * Split the main renderEditor function logic into smaller parts to reduce complexity. + * This helper builds the output frame string from visible window lines. + */ +function buildFrameString( + lines: string[], + window: IVisibleWindow, + cursorLine: number, + cursorCol: number, + columns: number, + color: boolean +): { + frame: string; + cursorRow: number; + cursorCol: number; + totalRows: number; +} { + let frame = ""; + let totalVisualRows = 0; + let cursorRowResult = 0; + let cursorColResult = 0; + + // Add "↑ N more" indicator if clipped above + if (window.clippedAbove) { + const moreCount = window.startLine; + const indicator = paint(`↑ ${moreCount} more`, STYLE.dim, color); + + frame += indicator + "\n"; + totalVisualRows += 1; + } + + // Render visible lines + for (let lineIdx = window.startLine; lineIdx < window.endLine; lineIdx += 1) { + const line = lines[lineIdx]; + + if (line === undefined) { + continue; + } + + const isCurrentLine = lineIdx === cursorLine; + const { + frameStr, + visualRowCount, + cursorRow, + cursorCol: cursorColRendered, + } = renderLineToFrame( + line, + isCurrentLine, + cursorCol, + columns, + totalVisualRows + ); + + frame += frameStr; + totalVisualRows += visualRowCount; + + if (cursorRow !== undefined && cursorColRendered !== undefined) { + cursorRowResult = cursorRow; + cursorColResult = cursorColRendered; + } + + // Add newline between logical lines (but not after last line) + if (lineIdx < window.endLine - 1) { + frame += "\n"; + } + } + + // Add "↓ N more" indicator if clipped below + if (window.clippedBelow) { + frame += "\n"; + totalVisualRows += 1; + + const moreCount = lines.length - window.endLine; + const indicator = paint(`↓ ${moreCount} more`, STYLE.dim, color); + + frame += indicator; + } + + return { + frame, + cursorRow: cursorRowResult, + cursorCol: cursorColResult, + totalRows: totalVisualRows, + }; +} + +/** + * Render the editor buffer as an ANSI-escaped frame positioned at a given + * terminal row. Returns the frame (escape sequences + text), total rows used, + * and the on-screen cursor position. + */ +export function renderEditor( + input: IEditorInput, + opts: IEditorOptions +): IEditorFrame { + const { lines, cursorLine, cursorCol } = input; + const { columns, maxRows, color } = opts; + + // Handle empty buffer + if (lines.length === 0 || columns <= 0 || maxRows <= 0) { + return { frame: "", rows: 0, cursorRow: 0, cursorCol: 0 }; + } + + // Compute visible window + const window = computeVisibleWindow(lines, cursorLine, maxRows, columns); + + // Build the frame string and get cursor position + const { + frame, + cursorRow, + cursorCol: cursorColResult, + totalRows, + } = buildFrameString(lines, window, cursorLine, cursorCol, columns, color); + + return { + frame, + rows: Math.min(totalRows, maxRows), + cursorRow, + cursorCol: cursorColResult, + }; +} diff --git a/packages/core/src/render/index.ts b/packages/core/src/render/index.ts index ac84199..eb1a22e 100644 --- a/packages/core/src/render/index.ts +++ b/packages/core/src/render/index.ts @@ -9,6 +9,7 @@ export { StatusBar, buildBarFrame, buildInputFrame, + buildEditorFrame, buildOverlayFrame, MIN_ROWS, type IStatusBarTerminal, diff --git a/packages/core/src/render/status-bar.ts b/packages/core/src/render/status-bar.ts index 5c43f3e..ad8d40d 100644 --- a/packages/core/src/render/status-bar.ts +++ b/packages/core/src/render/status-bar.ts @@ -228,6 +228,62 @@ export function buildInputFrame( ); } +/** + * The escape sequence that paints a multi-row editor input block ABOVE the input row, + * cleared and redrawn in place each call. `lines` are the visual rows (wrapped); + * `cursorRow` and `cursorCol` position the cursor within them. `clearRows` is the height + * of the previous block so a shrinking block erases its old top rows. The block is pinned + * absolutely, with the cursor left parked at the typing position. + * Pure/width-aware for FakeTerm assertions. + */ +export function buildEditorFrame( + lines: readonly string[], + cursorRow: number, + cursorCol: number, + columns: number, + rows: number, + color: boolean, + clearRows = 0 +): string { + // columns and color kept for API consistency with buildInputFrame; + // future editors may use them for width-aware wrapping or syntax highlighting. + void columns; + + void color; + + const inputRow = Math.max(1, rows - 2); + const maxSpan = Math.max(0, inputRow - 1); + + // The block is BOTTOM-anchored: content always occupies the bottom + // `lines.length` rows (resting just above the input row), and any cleared + // rows from a taller previous frame sit ABOVE it. `clearRows` is the previous + // block height, so the span [inputRow - count, inputRow - 1] fully covers + // wherever the prior frame painted — a shrinking block can't orphan its old + // top rows. (Top-anchoring left ghosts: the start moved down on shrink and + // the rows above it were never erased.) Mirrors buildOverlayFrame. + const count = Math.min(Math.max(lines.length, clearRows), maxSpan); + const blank = Math.max(0, count - lines.length); // cleared rows above content + const spanTop = Math.max(1, inputRow - count); + let out = ""; + + for (let i = 0; i < count; i += 1) { + const row = spanTop + i; + const line = i - blank >= 0 ? (lines[i - blank] ?? "") : ""; + + // Position at row, clear the line, then write the content (empty for the + // cleared rows above the bottom-anchored content). + out += `${ESC}[${row};1H${ESC}[2K${line}`; + } + + // Park the cursor relative to the bottom-anchored content top. + const contentTop = Math.max(1, inputRow - lines.length); + const cursorAbsRow = contentTop + cursorRow; + + out += `${ESC}[${cursorAbsRow};${cursorCol + 1}H`; + + return out; +} + /** * Paint a transient popup of `lines` directly ABOVE the input row (an `@`-file * dropdown), bottom-aligned against the prompt. `clearRows` is the height of the @@ -274,6 +330,9 @@ export class StatusBar { /** Height of the `@`-picker popup currently painted above the input row (0 = none), * so the next paint knows how many old rows to erase. */ private overlayRows = 0; + /** Height of the multi-row editor block currently painted above the input row (0 = none), + * so the next paint knows how many old rows to erase when the block shrinks. */ + private editorRows = 0; constructor( private readonly out: IStatusBarTerminal, @@ -419,6 +478,56 @@ export class StatusBar { ); } + /** Render a multi-row editor input block above the status bar. Each repaint + * clears the previous frame and redraws in place (absolute positioning). + * Input-row mode only; a no-op otherwise. The cursor is left parked at the + * editor's cursor position. Shrinking blocks erase old top rows via editorRows. + * When the editor height changes, the scroll region is resized so the editor + * block is pinned (not scrollable) — streaming only scrolls above it. */ + setEditor( + lines: readonly string[], + cursorRow: number, + cursorCol: number + ): void { + if (!this.installed || !this.withInput) { + return; + } + + const columns = this.out.columns ?? 80; + const rows = this.out.rows ?? 0; + + // Clamp the block height to the available rows above the input row + const inputRow = Math.max(1, rows - 2); + const maxRows = Math.max(0, inputRow - 1); + const clamped = lines.slice(0, maxRows); + const newHeight = clamped.length; + + // If editor height changes, update the scroll region to exclude the new height. + // This pins the editor block (and bar + input) so they never scroll. + if (newHeight !== this.editorRows) { + const regionEnd = Math.max(1, rows - this.reserved - newHeight); + + this.out.write(`${ESC}[1;${regionEnd}r`); + + // Re-anchor the stream cursor to the new region boundary + this.out.write(`${ESC}[${regionEnd};1H`); + this.out.write(`${ESC}7`); + } + + this.out.write( + buildEditorFrame( + clamped, + cursorRow, + cursorCol, + columns, + rows, + this.color, + this.editorRows + ) + ); + this.editorRows = newHeight; + } + /** Re-apply the scroll region after a terminal resize, then repaint. */ resize(info: IStatusInfo): void { if (!this.installed) { @@ -430,7 +539,8 @@ export class StatusBar { // Clamp to row 1: a resize BELOW `reserved` (a terminal shrunk after install) // would otherwise make `rows - reserved` non-positive and emit invalid // `${ESC}[1;-1r` / `${ESC}[-1;1H` sequences. Mirrors teardown()'s clamp. - const regionEnd = Math.max(1, rows - this.reserved); + // Also exclude the editor block from the scrollable region. + const regionEnd = Math.max(1, rows - this.reserved - this.editorRows); this.out.write(`${ESC}[1;${regionEnd}r`); @@ -464,5 +574,6 @@ export class StatusBar { this.out.write(`${ESC}[?25h`); // ensure the cursor is visible this.installed = false; + this.editorRows = 0; // reset editor block height } } diff --git a/packages/core/tests/cli.test.ts b/packages/core/tests/cli.test.ts index 40b9c96..ac6590c 100644 --- a/packages/core/tests/cli.test.ts +++ b/packages/core/tests/cli.test.ts @@ -301,3 +301,123 @@ test("spinner exposes a live 'compacting' activity label and repaints via onTick spinner.stop(); expect(spinner.frameLabel()).toBe(""); // stopped → loader cleared }); + +// Wiring test: the editor-backed input path routes onSubmit → a callback that +// handles multiline messages as a single submission, and respects the busy/pending +// steer queue. This test proves the integration without running the full REPL. +test("editor-backed input routes onSubmit to a handler and respects busy/pending", async () => { + const { startEditor } = await import("../src/editor"); + + const out = (): void => {}; + + // Fake stdin that emits data. + class FakeStdin { + listeners = new Map void>(); + + on(event: string, cb: (data: string) => void): void { + this.listeners.set(event, cb); + } + + removeListener(event: string): void { + this.listeners.delete(event); + } + + setRawMode(): void { + // no-op + } + + setEncoding(): void { + // no-op + } + + resume(): void { + // no-op + } + } + + const stdin = new FakeStdin(); + const handle = startEditor({ + stdin: stdin, + out, + columns: 80, + rows: 10, + }); + + // Track submissions + const submissions: string[] = []; + + handle.onSubmit((msg) => { + submissions.push(msg); + }); + + const dataListener = stdin.listeners.get("data"); + + if (!dataListener) { + throw new Error("editor did not register a data listener"); + } + + // Simulate a bracketed-paste event: multiline text ending with closing bracket + // The paste scanner will extract the content and insertPaste will handle it. + dataListener("\x1b[200~line one\nline two\x1b[201~"); + // Now send Enter (return key) to submit + dataListener("\r"); + + // The handler should have submitted the entire multiline text as ONE message + expect(submissions).toHaveLength(1); + expect(submissions[0]).toBe("line one\nline two"); + + handle.close(); +}); + +// Wiring test: while a handler is "busy" and a line is submitted, it queues to +// `pending` exactly once (not duplicated). +test("editor input submission while busy queues exactly one message to pending", async () => { + const { startEditor } = await import("../src/editor"); + + const out = (): void => {}; + + const stdin = new (class { + listeners = new Map void>(); + on(event: string, cb: (data: string) => void): void { + this.listeners.set(event, cb); + } + removeListener(): void {} + setRawMode(): void {} + setEncoding(): void {} + resume(): void {} + })(); + + const handle = startEditor({ + stdin: stdin, + out, + columns: 80, + rows: 10, + }); + + // Mock a busy handler that delays processing + const pending: string[] = []; + let busy = false; + + handle.onSubmit((msg) => { + if (busy) { + pending.push(msg); + } + }); + + const dataListener = stdin.listeners.get("data"); + + if (!dataListener) { + throw new Error("editor did not register a data listener"); + } + + // Set busy flag, then submit a message via return key + busy = true; + dataListener("test message"); + dataListener("\r"); + + // Should queue exactly one message, not duplicated + expect(pending).toHaveLength(1); + expect(pending[0]).toBe("test message"); + + handle.close(); +}); diff --git a/packages/core/tests/editor-buffer.test.ts b/packages/core/tests/editor-buffer.test.ts new file mode 100644 index 0000000..88b5302 --- /dev/null +++ b/packages/core/tests/editor-buffer.test.ts @@ -0,0 +1,205 @@ +import { test, expect } from "bun:test"; +import { EditorBuffer } from "../src/editor/buffer"; + +test("insert appends text and advances the cursor by graphemes", () => { + const b = new EditorBuffer(); + + b.insert("héllo"); // é = combining? use a precomposed char; 5 graphemes + expect(b.getText()).toBe("héllo"); + expect(b.getCursor()).toEqual({ line: 0, col: 5 }); +}); + +test("newline splits the current line at the cursor", () => { + const b = new EditorBuffer("abcd"); + + b.moveLeft(); // cursor before 'd' → col 3 + b.newline(); + expect(b.getText()).toBe("abc\nd"); + expect(b.getCursor()).toEqual({ line: 1, col: 0 }); +}); + +test("deleteBackward joins lines at column 0", () => { + const b = new EditorBuffer("ab\ncd"); + + // cursor at end (line 1, col 2); move to line 1 col 0 + b.setText("ab\ncd"); + + // place cursor at start of line 1: + b.moveLeft(); + b.moveLeft(); // from end → col 0 of line 1 + b.deleteBackward(); + expect(b.getText()).toBe("abcd"); + expect(b.getCursor()).toEqual({ line: 0, col: 2 }); +}); + +test("emoji is one grapheme for cursor + delete", () => { + const b = new EditorBuffer(); + + b.insert("a👍b"); + b.moveLeft(); // before 'b' + b.deleteBackward(); // removes 👍 as one unit + expect(b.getText()).toBe("ab"); +}); + +// region: Task 2 — word/line/doc navigation + sticky-column vertical moves + +test("moveWordLeft/Right stop at word boundaries", () => { + const b = new EditorBuffer("foo bar baz"); + + b.moveLineStart(); + b.moveWordRight(); + expect(b.getCursor().col).toBe(3); // end of "foo" + b.moveWordRight(); + expect(b.getCursor().col).toBe(7); // end of "bar" + b.moveWordLeft(); + expect(b.getCursor().col).toBe(4); // start of "bar" +}); + +test("moveUp keeps sticky column across a short line", () => { + const b = new EditorBuffer("hello\nhi\nworld"); + + b.moveDocEnd(); // line 2 (world), col 5 + b.moveUp(); // line 1 (hi) — clamps to col 2 + expect(b.getCursor()).toEqual({ line: 1, col: 2 }); + b.moveUp(); // line 0 (hello) — sticky restores col 5 + expect(b.getCursor()).toEqual({ line: 0, col: 5 }); +}); + +test("moveDocStart/End jump to buffer ends", () => { + const b = new EditorBuffer("a\nb\nc"); + + b.moveDocStart(); + expect(b.getCursor()).toEqual({ line: 0, col: 0 }); + b.moveDocEnd(); + expect(b.getCursor()).toEqual({ line: 2, col: 1 }); +}); + +// region: Task 3 — kill-ring + region deletes with yank/yank-pop + +test("Ctrl-K (deleteToLineEnd) then yank round-trips", () => { + const b = new EditorBuffer("hello world"); + + b.moveLineStart(); + b.moveWordRight(); // col 5 (after hello) + b.deleteToLineEnd(); + expect(b.getText()).toBe("hello"); + b.moveLineEnd(); + b.yank(); + expect(b.getText()).toBe("hello world"); +}); + +test("deleteWordBackward removes the previous word", () => { + const b = new EditorBuffer("foo bar"); + + b.deleteWordBackward(); + expect(b.getText()).toBe("foo "); +}); + +// region: Task 4 — undo/redo with word-coalescing + +test("undo reverts a word as one unit, redo restores it", () => { + const b = new EditorBuffer(); + + b.insert("h"); + b.insert("i"); // coalesced + b.undo(); + expect(b.getText()).toBe(""); + b.redo(); + expect(b.getText()).toBe("hi"); +}); + +test("space then word are separate undo units", () => { + const b = new EditorBuffer(); + + b.insert("a"); + b.insert(" "); + b.insert("b"); + b.undo(); + expect(b.getText()).toBe("a "); + b.undo(); + expect(b.getText()).toBe("a"); +}); + +test("a yank is a single undo unit (no wasted undo step)", () => { + const b = new EditorBuffer(); + + b.insert("x"); + // set up the kill ring: kill some text so yank has content + b.setText("ab"); + b.moveLineStart(); + b.deleteToLineEnd(); // kills "ab" → buffer "" + b.insert("x"); // buffer "x" + b.yank(); // buffer "xab" + expect(b.getText()).toBe("xab"); + b.undo(); // ONE undo reverts the whole yank + expect(b.getText()).toBe("x"); + b.undo(); // next undo reverts the "x" insert — NOT a wasted no-op + expect(b.getText()).toBe(""); +}); + +// region: Task 5 — large-paste markers + expand + +test("small paste inserts literally", () => { + const b = new EditorBuffer(); + + b.insertPaste("one\ntwo"); + expect(b.getText()).toBe("one\ntwo"); + expect(b.expand()).toBe("one\ntwo"); +}); + +test("large paste shows a marker but expands on submit", () => { + const big = Array.from({ length: 40 }, (_, i) => `line ${i}`).join("\n"); + const b = new EditorBuffer(); + + b.insertPaste(big); + expect(b.getText()).toContain("[paste #1 +40 lines]"); + expect(b.getText()).not.toContain("line 39"); + expect(b.expand()).toBe(big); +}); + +// region: Gemini PR #52 regression tests — undo atomicity, yankPop corruption, grapheme correctness + +test("multiline insertPaste is reverted by ONE undo", () => { + const b = new EditorBuffer(); + + b.insertPaste("one\ntwo\nthree"); + expect(b.getText()).toBe("one\ntwo\nthree"); + expect(b.getCursor().line).toBe(2); + + b.undo(); // ONE undo should revert the entire paste + expect(b.getText()).toBe(""); + expect(b.getCursor()).toEqual({ line: 0, col: 0 }); +}); + +test("multiline insert with newlines is ONE undo unit", () => { + const b = new EditorBuffer(); + + b.insert("a\nb\nc"); + expect(b.getText()).toBe("a\nb\nc"); + + b.undo(); + expect(b.getText()).toBe(""); +}); + +test("yankPop after cursor move does NOT corrupt buffer", () => { + const b = new EditorBuffer("hello"); + + // Kill some text and yank it + b.moveLineStart(); + b.deleteToLineEnd(); // kills "hello" → buffer is now "" + expect(b.getText()).toBe(""); + + // Yank it back → buffer is "hello" + b.yank(); + expect(b.getText()).toBe("hello"); + + // Move right to change position; this should clear lastYank + b.moveLeft(); + b.moveRight(); + + const textBefore = b.getText(); + + // yankPop should now be a safe no-op (lastYank was cleared by moveRight) + b.yankPop(); + expect(b.getText()).toBe(textBefore); +}); diff --git a/packages/core/tests/editor-controller.test.ts b/packages/core/tests/editor-controller.test.ts new file mode 100644 index 0000000..a844148 --- /dev/null +++ b/packages/core/tests/editor-controller.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, test } from "bun:test"; +import { startEditor } from "../src/editor/controller"; + +/** + * FakeStdin: EventEmitter-like test stub for stdin. + * Allows tests to feed bytes synchronously and capture output. + */ +class FakeStdin { + private listeners: Record void>>; + + constructor() { + this.listeners = { data: new Set() }; + } + + on(event: string, callback: (data: any) => void): this { + const existing = this.listeners[event]; + + if (existing) { + existing.add(callback); + } else { + const newSet = new Set<(data: any) => void>(); + + newSet.add(callback); + this.listeners[event] = newSet; + } + + return this; + } + + removeListener(event: string, callback: (data: any) => void): this { + this.listeners[event]?.delete(callback); + + return this; + } + + feed(chunk: string): void { + const callbacks = this.listeners.data ?? new Set(); + + callbacks.forEach((cb) => { + cb(chunk); + }); + } + + // Emit a raw Buffer (as process.stdin does when setEncoding wasn't applied), + // to prove the editor doesn't crash on Buffer chunks. + feedRaw(chunk: Buffer): void { + const callbacks = this.listeners.data ?? new Set(); + + callbacks.forEach((cb) => { + cb(chunk); + }); + } + + setRawMode(_mode: boolean): this { + // no-op for testing + return this; + } + + resume(): this { + // no-op for testing + return this; + } + + setEncoding(_: string): this { + // no-op for testing + return this; + } +} + +function makeHarness() { + const stdin = new FakeStdin(); + const outputs: string[] = []; + const submits: string[] = []; + + const handle = startEditor({ + stdin: stdin, + out: (s: string) => { + outputs.push(s); + }, + columns: 80, + rows: 10, + }); + + handle.onSubmit((message: string) => { + submits.push(message); + }); + + return { stdin, handle, submits, outputs }; +} + +describe("EditorController", () => { + test("typing then Enter submits the typed text once", () => { + const { stdin, handle, submits } = makeHarness(); + + stdin.feed("hi"); + stdin.feed("\r"); + expect(submits).toEqual(["hi"]); + expect(handle.getBuffer().getText()).toBe(""); + }); + + test("a raw Buffer keystroke is handled (does not crash; decodes to text)", () => { + // Regression: process.stdin emits Buffers when setEncoding wasn't applied; + // the editor must normalize them instead of throwing on the first key. + const { stdin, handle, submits } = makeHarness(); + + stdin.feedRaw(Buffer.from("hi", "utf8")); + expect(handle.getBuffer().getText()).toBe("hi"); + stdin.feedRaw(Buffer.from("\r", "utf8")); + expect(submits).toEqual(["hi"]); + }); + + test("Shift+Enter inserts a newline, does NOT submit", () => { + const { stdin, handle, submits } = makeHarness(); + + stdin.feed("a"); + stdin.feed("\x1b[13;2u"); // Kitty Shift+Enter + stdin.feed("b"); + expect(submits).toEqual([]); + expect(handle.getBuffer().getText()).toBe("a\nb"); + stdin.feed("\r"); + expect(submits).toEqual(["a\nb"]); + expect(handle.getBuffer().getText()).toBe(""); + }); + + test("a multi-line paste lands in the buffer and submits once on Enter", () => { + const { stdin, handle, submits } = makeHarness(); + + stdin.feed("\x1b[200~one\rtwo\rthree\x1b[201~"); + expect(submits).toEqual([]); // never auto-submits + expect(handle.getBuffer().getText()).toBe("one\ntwo\nthree"); + stdin.feed("\r"); + expect(submits).toEqual(["one\ntwo\nthree"]); + expect(handle.getBuffer().getText()).toBe(""); + }); + + test("Alt+Enter inserts a newline, does NOT submit", () => { + const { stdin, handle, submits } = makeHarness(); + + stdin.feed("x"); + stdin.feed("\x1b\r"); // Alt+Return + stdin.feed("y"); + expect(submits).toEqual([]); + expect(handle.getBuffer().getText()).toBe("x\ny"); + stdin.feed("\r"); + expect(submits).toEqual(["x\ny"]); + }); + + test("trailing backslash + Enter: delete backslash and insert newline, do not submit", () => { + const { stdin, handle, submits } = makeHarness(); + + stdin.feed("foo\\"); + stdin.feed("\r"); + expect(submits).toEqual([]); + expect(handle.getBuffer().getText()).toBe("foo\n"); + stdin.feed("bar"); + stdin.feed("\r"); + expect(submits).toEqual(["foo\nbar"]); + }); + + test("backspace deletes the character before cursor", () => { + const { stdin, handle } = makeHarness(); + + stdin.feed("abc"); + stdin.feed("\x7f"); // backspace + expect(handle.getBuffer().getText()).toBe("ab"); + }); + + test("delete key deletes the character at cursor", () => { + const { stdin, handle } = makeHarness(); + + stdin.feed("abc"); + stdin.feed("\x1b[D"); // left arrow + stdin.feed("\x1b[3~"); // delete + expect(handle.getBuffer().getText()).toBe("ab"); + }); + + test("ctrl+u deletes from cursor to line start (kill to line start)", () => { + const { stdin, handle } = makeHarness(); + + stdin.feed("hello world"); + + // Cursor is now at end (position 11) + // Move to position 5 (after "hello") + for (let i = 0; i < 6; i += 1) { + stdin.feed("\x1b[D"); // left arrow + } + + // cursor is now at position 5 (before space) + stdin.feed("\x15"); // ctrl+u = delete to line start + expect(handle.getBuffer().getText()).toBe(" world"); + }); + + test("onChange callback fires on edits", () => { + const { stdin } = makeHarness(); + let changeCount = 0; + const handle = startEditor({ + stdin: stdin, + out: () => {}, + columns: 80, + rows: 10, + }); + + handle.onChange(() => { + changeCount += 1; + }); + stdin.feed("x"); + expect(changeCount).toBeGreaterThan(0); + }); + + test("close() disables the editor and prevents further input", () => { + const { stdin, handle: h } = makeHarness(); + + stdin.feed("a"); + expect(h.getBuffer().getText()).toBe("a"); + h.close(); + stdin.feed("b"); + // After close, further input should be ignored + expect(h.getBuffer().getText()).toBe("a"); + }); + + test("multiple chars typed in one feed", () => { + const { stdin, handle } = makeHarness(); + + stdin.feed("hello"); + expect(handle.getBuffer().getText()).toBe("hello"); + stdin.feed("\r"); + // Buffer should be reset after submit + expect(handle.getBuffer().getText()).toBe(""); + }); + + test("return with ctrl modifier does not submit (only plain return submits)", () => { + const { stdin, handle } = makeHarness(); + + stdin.feed("test"); + // Simulate ctrl+return — in keys.ts this would be a return with ctrl=true + // We'd need a Kitty sequence for that, but for now test plain behavior + stdin.feed("\r"); + // Buffer should be reset after submit + expect(handle.getBuffer().getText()).toBe(""); + }); + + test("Ctrl-C (\\x03) invokes onInterrupt callback", () => { + const { stdin, handle } = makeHarness(); + let interruptCount = 0; + + handle.onInterrupt(() => { + interruptCount += 1; + }); + + stdin.feed("test"); + stdin.feed("\x03"); // Ctrl-C + expect(interruptCount).toBe(1); + }); + + test("Ctrl-D (\\x04) on empty buffer invokes onExit callback", () => { + const { stdin, handle } = makeHarness(); + let exitCount = 0; + + handle.onExit(() => { + exitCount += 1; + }); + + // Buffer is empty, so Ctrl-D should exit + stdin.feed("\x04"); + expect(exitCount).toBe(1); + }); + + test("Ctrl-D (\\x04) with text in buffer does NOT invoke onExit", () => { + const { stdin, handle } = makeHarness(); + let exitCount = 0; + + handle.onExit(() => { + exitCount += 1; + }); + + stdin.feed("hello"); + stdin.feed("\x04"); // Ctrl-D with text in buffer + expect(exitCount).toBe(0); // Should NOT exit + // With text present, Ctrl-D is treated as a normal character (inserted as 'd') + // per the controller's logic (only exits on empty buffer) + expect(handle.getBuffer().getText()).toContain("hello"); + }); + + test("Up arrow on first line recalls previous submitted message into buffer", () => { + const { stdin, handle, submits } = makeHarness(); + + // Submit a message + stdin.feed("first message"); + stdin.feed("\r"); + expect(submits).toEqual(["first message"]); + expect(handle.getBuffer().getText()).toBe(""); + + // Type a draft + stdin.feed("draft"); + expect(handle.getBuffer().getText()).toBe("draft"); + + // Up arrow on first line (cursor is at end of line, move to line 0, then up) + // Since buffer is single-line, up arrow at line 0 should navigate history + stdin.feed("\x1b[A"); // Up arrow + expect(handle.getBuffer().getText()).toBe("first message"); + }); + + test("Down arrow after Up returns to the draft", () => { + const { stdin, handle, submits } = makeHarness(); + + // Submit a message + stdin.feed("first message"); + stdin.feed("\r"); + expect(submits).toEqual(["first message"]); + expect(handle.getBuffer().getText()).toBe(""); + + // Type a draft + stdin.feed("draft"); + expect(handle.getBuffer().getText()).toBe("draft"); + + // Up arrow to recall history + stdin.feed("\x1b[A"); + expect(handle.getBuffer().getText()).toBe("first message"); + + // Down arrow to return to draft + stdin.feed("\x1b[B"); + expect(handle.getBuffer().getText()).toBe("draft"); + }); + + test("Multiple submits create history; Up navigates backward through it", () => { + const { stdin, handle } = makeHarness(); + + // Submit first message + stdin.feed("msg one"); + stdin.feed("\r"); + expect(handle.getBuffer().getText()).toBe(""); + + // Submit second message + stdin.feed("msg two"); + stdin.feed("\r"); + expect(handle.getBuffer().getText()).toBe(""); + + // Type a draft + stdin.feed("draft"); + + // Up twice: draft → msg two → msg one + stdin.feed("\x1b[A"); // msg two + expect(handle.getBuffer().getText()).toBe("msg two"); + + stdin.feed("\x1b[A"); // msg one + expect(handle.getBuffer().getText()).toBe("msg one"); + + // Down once: msg one → msg two + stdin.feed("\x1b[B"); + expect(handle.getBuffer().getText()).toBe("msg two"); + + // Down again: msg two → draft + stdin.feed("\x1b[B"); + expect(handle.getBuffer().getText()).toBe("draft"); + }); + + // region: Gemini PR #52 regression tests + + test("submitting while browsing history does NOT pollute history", () => { + const { stdin, handle, submits } = makeHarness(); + + // Submit first message + stdin.feed("msg one"); + stdin.feed("\r"); + expect(submits).toEqual(["msg one"]); + expect(handle.getBuffer().getText()).toBe(""); + + // Type a draft + stdin.feed("draft text"); + expect(handle.getBuffer().getText()).toBe("draft text"); + + // Browse up into history + stdin.feed("\x1b[A"); // recall "msg one" + expect(handle.getBuffer().getText()).toBe("msg one"); + + // Submit while in history + stdin.feed("\r"); + expect(submits).toEqual(["msg one", "msg one"]); // resubmitted + expect(handle.getBuffer().getText()).toBe(""); + + // Navigate history again — should only have the original plus the new submit + stdin.feed("new draft"); + stdin.feed("\x1b[A"); // should go up to the last real submit + // History should be: ["msg one", "msg one"], and we should see "msg one" (the last one) + // NOT see "draft text" (the draft was not saved) + expect(handle.getBuffer().getText()).toBe("msg one"); + }); + + test("trailing backslash after emoji inserts newline correctly", () => { + const { stdin, handle, submits } = makeHarness(); + + // Type text with backslash at end + stdin.feed("test\\"); + expect(handle.getBuffer().getText()).toBe("test\\"); + + // Press Enter — should remove the backslash and insert a newline + stdin.feed("\r"); + expect(submits).toEqual([]); // not submitted + expect(handle.getBuffer().getText()).toBe("test\n"); + + // Type more text and submit + stdin.feed("more"); + stdin.feed("\r"); + expect(submits).toEqual(["test\nmore"]); + }); +}); diff --git a/packages/core/tests/editor-e2e.test.ts b/packages/core/tests/editor-e2e.test.ts new file mode 100644 index 0000000..195e89d --- /dev/null +++ b/packages/core/tests/editor-e2e.test.ts @@ -0,0 +1,594 @@ +import { describe, expect, test } from "bun:test"; +import { + startEditor, + type IEditorHandle, + type IStdin, +} from "../src/editor/controller"; +import { + StatusBar, + type IStatusInfo, + type IStatusBarTerminal, +} from "../src/render"; +import { VirtualScreen } from "./helpers/virtual-screen"; + +const INFO: IStatusInfo = { + model: "qwen3.6-27b", + contextTokens: 8000, + contextWindow: 32000, + turns: 1, + elapsedMs: 0, + status: "idle", + scope: "src/**", + tokensPerSecond: 0, +}; + +/** Captures every emitted byte, exposed as IStatusBarTerminal for StatusBar. */ +class FakeTerm implements IStatusBarTerminal { + readonly writes: string[] = []; + + constructor( + readonly isTTY: boolean, + public rows: number, + readonly columns: number + ) {} + + write(data: string): boolean { + this.writes.push(data); + + return true; + } + + text(): string { + return this.writes.join(""); + } +} + +/** EventEmitter-like stdin stub; tests feed bytes synchronously. */ +class FakeStdin implements IStdin { + private readonly listeners = new Set<(data: string) => void>(); + + on(event: string, callback: (data: string) => void): void { + if (event === "data") { + this.listeners.add(callback); + } + } + + removeListener(event: string, callback: (data: string) => void): void { + if (event === "data") { + this.listeners.delete(callback); + } + } + + setRawMode(): void { + // no-op + } + + resume(): void { + // no-op + } + + setEncoding(): void { + // no-op + } + + feed(chunk: string): void { + for (const cb of this.listeners) { + cb(chunk); + } + } +} + +interface IHarness { + stdin: FakeStdin; + handle: IEditorHandle; + term: FakeTerm; + bar: StatusBar; + /** Render the full captured byte stream onto a fresh grid and return it. */ + screen(): VirtualScreen; +} + +/** + * Wire the REAL editor controller to a REAL StatusBar over a FakeTerm, exactly + * as cli.ts does (out → writeStream, renderEditor → setEditor, withInput=true). + * This exercises the production render path end-to-end: keystrokes in, terminal + * bytes out, screen grid asserted. + */ +function buildHarness(rows = 24, columns = 80): IHarness { + const term = new FakeTerm(true, rows, columns); + const bar = new StatusBar(term, true, false, true); + const stdin = new FakeStdin(); + + const handle = startEditor({ + stdin, + out: (s: string) => { + bar.writeStream(s); + }, + renderEditor: (lines: string[], cursorRow: number, cursorCol: number) => { + bar.setEditor(lines, cursorRow, cursorCol); + }, + columns, + rows, + }); + + // cli.ts installs the bar immediately after wiring the editor. + bar.install(INFO); + + return { + stdin, + handle, + term, + bar, + screen: () => { + const s = new VirtualScreen(rows, columns); + + s.feed(term.text()); + + return s; + }, + }; +} + +describe("editor e2e — rendered screen (VirtualScreen)", () => { + test("typing a single line shows it once above the prompt", () => { + const h = buildHarness(); + + h.stdin.feed("test123"); + + expect(h.screen().rowsContaining("test123")).toBe(1); + }); + + test("GHOST REPRO: type, 3× newline, delete back to one line → one row only", () => { + const h = buildHarness(); + + h.stdin.feed("test123"); + h.stdin.feed("\x1b\r"); // Alt/Option+Enter — newline, no submit + h.stdin.feed("\x1b\r"); + h.stdin.feed("\x1b\r"); + // Buffer is now "test123\n\n\n" (4 logical lines). Remove the 3 blanks. + h.stdin.feed("\x7f"); + h.stdin.feed("\x7f"); + h.stdin.feed("\x7f"); + + // The user typed test123 once; it must appear on exactly one row. + expect(h.screen().rowsContaining("test123")).toBe(1); + }); + + test("growing then shrinking the block leaves no stale rows", () => { + const h = buildHarness(); + + h.stdin.feed("alpha"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("beta"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("gamma"); + + const before = h.screen(); + + expect(before.rowsContaining("alpha")).toBe(1); + expect(before.rowsContaining("beta")).toBe(1); + expect(before.rowsContaining("gamma")).toBe(1); + + // Delete gamma + its newline + beta + its newline → just "alpha". + const deletes = "gamma".length + 1 + "beta".length + 1; + + for (let i = 0; i < deletes; i += 1) { + h.stdin.feed("\x7f"); + } + + const after = h.screen(); + + expect(after.rowsContaining("alpha")).toBe(1); + expect(after.rowsContaining("beta")).toBe(0); + expect(after.rowsContaining("gamma")).toBe(0); + }); + + test("submitting clears the editor block (no leftover text on screen)", () => { + const h = buildHarness(); + + h.stdin.feed("hello world"); + expect(h.screen().rowsContaining("hello world")).toBe(1); + + h.stdin.feed("\r"); // submit + + expect(h.screen().rowsContaining("hello world")).toBe(0); + }); + + test("submitting a multi-line message clears every row", () => { + const h = buildHarness(); + + h.stdin.feed("one"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("two"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("three"); + expect(h.screen().rowsContaining("two")).toBe(1); + + h.stdin.feed("\r"); // submit + + const after = h.screen(); + + expect(after.rowsContaining("one")).toBe(0); + expect(after.rowsContaining("two")).toBe(0); + expect(after.rowsContaining("three")).toBe(0); + }); + + test("a multi-line paste renders one row per line, no ghosts", () => { + const h = buildHarness(); + + h.stdin.feed("\x1b[200~apple\rbanana\rcherry\x1b[201~"); + + const screen = h.screen(); + + expect(screen.rowsContaining("apple")).toBe(1); + expect(screen.rowsContaining("banana")).toBe(1); + expect(screen.rowsContaining("cherry")).toBe(1); + }); + + test("agent output streams ABOVE the editor; the typed line survives", () => { + const h = buildHarness(); + + h.stdin.feed("my prompt"); + // Simulate the agent streaming output between keystrokes. + h.bar.writeStream("agent line A\n"); + h.bar.writeStream("agent line B\n"); + + const screen = h.screen(); + + // The editor content is untouched by streaming... + expect(screen.rowsContaining("my prompt")).toBe(1); + // ...and the streamed lines landed on the screen above it. + expect(screen.rowsContaining("agent line A")).toBe(1); + expect(screen.rowsContaining("agent line B")).toBe(1); + + const promptRow = findRow(screen, "my prompt"); + const streamRow = findRow(screen, "agent line B"); + + expect(streamRow).toBeLessThan(promptRow); // stream is above the editor + }); + + test("a line longer than the terminal width wraps without dropping text", () => { + const h = buildHarness(24, 20); // narrow terminal + + const long = "abcdefghijklmnopqrstuvwxyz0123456789"; // 36 chars > 20 cols + + h.stdin.feed(long); + + const screen = h.screen(); + const joined = screen.text().replace(/\n/g, ""); + + expect(joined).toContain(long); // every character survives across wrapped rows + }); + + test("history recall shows the prior message exactly once", () => { + const h = buildHarness(); + + h.stdin.feed("first message"); + h.stdin.feed("\r"); + h.stdin.feed("second message"); + h.stdin.feed("\r"); + + // Both submitted; editor is empty. + expect(h.screen().rowsContaining("second message")).toBe(0); + + h.stdin.feed("\x1b[A"); // up arrow → recall most recent + + const screen = h.screen(); + + expect(screen.rowsContaining("second message")).toBe(1); + expect(screen.rowsContaining("first message")).toBe(0); + }); + + test("backspace at line start joins lines with no ghost of the split form", () => { + const h = buildHarness(); + + h.stdin.feed("foo"); + h.stdin.feed("\x1b\r"); // newline → foo\n + h.stdin.feed("bar"); // foo\nbar, cursor after bar + h.stdin.feed("\x1b[H"); // home → start of "bar" line + h.stdin.feed("\x7f"); // backspace joins → "foobar" + + const screen = h.screen(); + + expect(h.handle.getBuffer().getText()).toBe("foobar"); + expect(screen.rowsContaining("foobar")).toBe(1); + // The previous 2-row form must not linger. + expect(screen.rowsContaining("bar")).toBe(1); // only inside "foobar" + }); + + test("editing mid-line re-renders the whole line correctly", () => { + const h = buildHarness(); + + h.stdin.feed("helo"); + h.stdin.feed("\x1b[D"); // left (before 'o') + h.stdin.feed("\x1b[D"); // left (before 'l') + h.stdin.feed("l"); // insert → "hello" + + const screen = h.screen(); + + expect(h.handle.getBuffer().getText()).toBe("hello"); + expect(screen.rowsContaining("hello")).toBe(1); + expect(screen.rowsContaining("helo")).toBe(0); + }); +}); + +/** First 1-based row index whose text contains `needle` (0 if absent). */ +function findRow(screen: VirtualScreen, needle: string): number { + for (let r = 1; r <= 24; r += 1) { + if (screen.row(r).includes(needle)) { + return r; + } + } + + return 0; +} + +describe("editor e2e — aggressive interaction probes", () => { + test("streaming while a MULTI-LINE block is up preserves every editor row", () => { + const h = buildHarness(); + + h.stdin.feed("line one"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("line two"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("line three"); + + h.bar.writeStream("STREAMED OUTPUT\n"); + + const screen = h.screen(); + + expect(screen.rowsContaining("line one")).toBe(1); + expect(screen.rowsContaining("line two")).toBe(1); + expect(screen.rowsContaining("line three")).toBe(1); + expect(screen.rowsContaining("STREAMED OUTPUT")).toBe(1); + }); + + test("typing more after a stream appends without duplicating", () => { + const h = buildHarness(); + + h.stdin.feed("abc"); + h.bar.writeStream("noise\n"); + h.stdin.feed("def"); + + const screen = h.screen(); + + expect(h.handle.getBuffer().getText()).toBe("abcdef"); + expect(screen.rowsContaining("abcdef")).toBe(1); + }); + + test("resizing the terminal mid-edit re-renders content once", () => { + const h = buildHarness(24, 80); + + h.stdin.feed("resize me"); + h.term.rows = 30; + h.bar.resize(INFO); + h.stdin.feed("!"); // force a repaint at the new size + + const screen = new VirtualScreen(30, 80); + + screen.feed(h.term.text()); + expect(screen.rowsContaining("resize me!")).toBe(1); + }); + + test("a block taller than the terminal never crashes or writes above row 1", () => { + const h = buildHarness(10, 40); // small terminal: ~7 usable rows + + for (let i = 0; i < 20; i += 1) { + h.stdin.feed(`row${i}`); + h.stdin.feed("\x1b\r"); + } + + h.stdin.feed("last"); + + const screen = h.screen(); + + // The most recent content stays visible (cursor line is always shown). + expect(screen.rowsContaining("last")).toBe(1); + // Nothing escaped above the top row or into negative rows. + const esc = String.fromCharCode(27); + + expect(h.term.text()).not.toContain(`${esc}[0;`); + expect(h.term.text()).not.toContain(`${esc}[-`); + }); + + test("the cursor lands on the cell just after the typed text", () => { + const h = buildHarness(); + + h.stdin.feed("hi"); + + const { row, col } = h.screen().cursorPosition(); + + // inputRow = 22, single line bottom-anchored at row 21, cursor after "hi". + expect(row).toBe(21); + expect(col).toBe(3); // 1-based: after 2 graphemes + }); + + test("cursor tracks a left-arrow move to mid-line", () => { + const h = buildHarness(); + + h.stdin.feed("hello"); + h.stdin.feed("\x1b[D"); // left + h.stdin.feed("\x1b[D"); // left → between 'l' and 'l' + + const { col } = h.screen().cursorPosition(); + + expect(col).toBe(4); // after "hel" + }); + + test("emoji (multi-byte grapheme) renders and does not duplicate", () => { + const h = buildHarness(); + + h.stdin.feed("hi 👋 there"); + + const screen = h.screen(); + + expect(screen.rowsContaining("there")).toBe(1); + expect(h.handle.getBuffer().getText()).toBe("hi 👋 there"); + }); + + test("ctrl+u kills to line start, leaving no ghost of the killed text", () => { + const h = buildHarness(); + + h.stdin.feed("delete all of this"); + h.stdin.feed("\x15"); // ctrl+u + + const screen = h.screen(); + + expect(h.handle.getBuffer().getText()).toBe(""); + expect(screen.rowsContaining("delete all of this")).toBe(0); + }); +}); + +describe("editor e2e — non-ASCII input (regression: keys dropped >= 0x7f)", () => { + test("accented Latin and CJK text is accepted and rendered", () => { + const h = buildHarness(); + + h.stdin.feed("café 日本語 ñ"); + + expect(h.handle.getBuffer().getText()).toBe("café 日本語 ñ"); + expect(h.screen().rowsContaining("café")).toBe(1); + }); + + test("a non-ASCII paste keeps every character", () => { + const h = buildHarness(); + + h.stdin.feed("\x1b[200~Grüße — 你好 👍\x1b[201~"); + + expect(h.handle.getBuffer().getText()).toBe("Grüße — 你好 👍"); + }); +}); + +describe("editor e2e — motion, kill/yank, undo, overflow, shrink probes", () => { + test("vertical cursor movement renders both lines without ghosting", () => { + const h = buildHarness(); + + h.stdin.feed("top line"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("bottom line"); + h.stdin.feed("\x1b[A"); // up — into "top line" (not history; buffer has 2 lines) + + const screen = h.screen(); + + expect(screen.rowsContaining("top line")).toBe(1); + expect(screen.rowsContaining("bottom line")).toBe(1); + expect(h.handle.getBuffer().getText()).toBe("top line\nbottom line"); + }); + + test("ctrl+w deletes the previous word, no stale text on screen", () => { + const h = buildHarness(); + + h.stdin.feed("hello wonderful world"); + h.stdin.feed("\x17"); // ctrl+w → drop "world" + + const screen = h.screen(); + + expect(h.handle.getBuffer().getText()).toBe("hello wonderful "); + expect(screen.rowsContaining("world")).toBe(0); + expect(screen.rowsContaining("hello wonderful")).toBe(1); + }); + + test("ctrl+k kill then ctrl+y yank restores the text exactly once", () => { + const h = buildHarness(); + + h.stdin.feed("keep cut"); + h.stdin.feed("\x1b[H"); // home + h.stdin.feed("\x0b"); // ctrl+k → kill whole line into kill-ring + expect(h.handle.getBuffer().getText()).toBe(""); + + h.stdin.feed("\x19"); // ctrl+y → yank back + + const screen = h.screen(); + + expect(h.handle.getBuffer().getText()).toBe("keep cut"); + expect(screen.rowsContaining("keep cut")).toBe(1); + }); + + test("ctrl+z undo then ctrl+shift+z redo round-trips on screen", () => { + const h = buildHarness(); + + h.stdin.feed("abc"); + h.stdin.feed("\x1az"); // ctrl+z is \x1a; feed undo + // (controller maps ctrl+z to undo) + h.stdin.feed("\x1a"); // ctrl+z undo + + // After undo, the last insert should be gone; exact granularity is the + // buffer's concern — assert the screen matches the buffer (no ghosts). + const screen = h.screen(); + const text = h.handle.getBuffer().getText(); + + if (text === "") { + expect(screen.rowsContaining("abc")).toBe(0); + } else { + expect(screen.rowsContaining(text)).toBe(1); + } + }); + + test("content overflowing the editor area shows scroll indicators, cursor visible", () => { + const h = buildHarness(12, 40); // ~7 usable editor rows + + for (let i = 0; i < 15; i += 1) { + h.stdin.feed(`L${i}`); + + if (i < 14) { + h.stdin.feed("\x1b\r"); + } + } + + const screen = h.screen(); + + // The cursor (last) line is always visible... + expect(screen.rowsContaining("L14")).toBe(1); + // ...and an "N more" indicator signals the clipped lines above. + expect(screen.text()).toContain("more"); + }); + + test("shrinking the terminal mid-edit leaves no ghost rows", () => { + const h = buildHarness(24, 80); + + h.stdin.feed("line A"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("line B"); + h.stdin.feed("\x1b\r"); + h.stdin.feed("line C"); + + // Shrink the terminal and repaint, then edit again. + h.term.rows = 14; + h.bar.resize(INFO); + h.stdin.feed("!"); + + const screen = new VirtualScreen(14, 80); + + screen.feed(h.term.text()); + expect(screen.rowsContaining("line C!")).toBe(1); + expect(screen.rowsContaining("line A")).toBe(1); + expect(screen.rowsContaining("line B")).toBe(1); + }); +}); + +describe("editor e2e — wrapped-line cursor math", () => { + test("cursor lands on the correct visual row/col after a line wraps", () => { + const h = buildHarness(24, 20); // width 20 + + // 25 chars → wraps to 2 visual rows (20 + 5). Cursor rests after char 25. + h.stdin.feed("0123456789abcdefghijklmno"); + + const { row, col } = h.screen().cursorPosition(); + + // Block is 2 visual rows, bottom-anchored: contentTop = 22 - 2 = 20, + // cursor on visual row 1 (the wrapped tail) → row 21, after 5 chars → col 6. + expect(row).toBe(21); + expect(col).toBe(6); + }); + + test("editing at the wrap boundary keeps all text and a single render", () => { + const h = buildHarness(24, 20); + + h.stdin.feed("0123456789abcdefghijklmno"); // 25 chars, wrapped + h.stdin.feed("\x1b[H"); // home → start of logical line + h.stdin.feed("X"); // insert at very start + + const joined = h.screen().text().replace(/\n/g, ""); + + expect(h.handle.getBuffer().getText()).toBe("X0123456789abcdefghijklmno"); + expect(joined).toContain("X0123456789abcdefghijklmno"); + }); +}); diff --git a/packages/core/tests/editor-keys.test.ts b/packages/core/tests/editor-keys.test.ts new file mode 100644 index 0000000..3686991 --- /dev/null +++ b/packages/core/tests/editor-keys.test.ts @@ -0,0 +1,104 @@ +import { describe, test, expect } from "bun:test"; +import { decodeKeys } from "../src/editor/keys"; + +describe("decodeKeys", () => { + test("plain CR is submit (return, no mods)", () => { + const events = decodeKeys("\r"); + + expect(events.length).toBe(1); + + const k = events[0]; + + if (k) { + expect({ name: k.name, shift: k.shift, alt: k.alt }).toEqual({ + name: "return", + shift: false, + alt: false, + }); + } + }); + + test("Alt+Enter decodes as return+alt", () => { + const events = decodeKeys("\x1b\r"); + + expect(events.length).toBe(1); + + const k = events[0]; + + if (k) { + expect({ name: k.name, alt: k.alt }).toEqual({ + name: "return", + alt: true, + }); + } + }); + + test("Kitty Shift+Enter (CSI 13;2u) decodes as return+shift", () => { + const events = decodeKeys("\x1b[13;2u"); + + expect(events.length).toBe(1); + + const k = events[0]; + + if (k) { + expect({ name: k.name, shift: k.shift }).toEqual({ + name: "return", + shift: true, + }); + } + }); + + test("xterm modifyOtherKeys Shift+Enter (CSI 27;2;13~) decodes as return+shift", () => { + const events = decodeKeys("\x1b[27;2;13~"); + + expect(events.length).toBe(1); + + const k = events[0]; + + if (k) { + expect({ name: k.name, shift: k.shift }).toEqual({ + name: "return", + shift: true, + }); + } + }); + + test("Ctrl+W decodes from byte 0x17", () => { + const events = decodeKeys("\x17"); + + expect(events.length).toBe(1); + + const k = events[0]; + + if (k) { + expect({ name: k.name, ctrl: k.ctrl }).toEqual({ + name: "char", + ctrl: true, + }); + + expect(k.text).toBe("w"); + } + }); + + test("printable char and arrow", () => { + const aEvents = decodeKeys("a"); + + expect(aEvents.length).toBeGreaterThan(0); + + const aEvent = aEvents[0]; + + if (aEvent) { + expect(aEvent).toMatchObject({ name: "char", text: "a" }); + } + + const arrowEvents = decodeKeys("\x1b[D"); + + expect(arrowEvents.length).toBeGreaterThan(0); + + const arrowEvent = arrowEvents[0]; + + if (arrowEvent) { + expect(arrowEvent).toMatchObject({ name: "left" }); + } + }); +}); diff --git a/packages/core/tests/editor-paste.test.ts b/packages/core/tests/editor-paste.test.ts new file mode 100644 index 0000000..5cbf3c1 --- /dev/null +++ b/packages/core/tests/editor-paste.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect } from "bun:test"; +import { createPasteScanner } from "../src/editor/paste"; + +describe("PasteScanner", () => { + test("extracts a real bracketed paste, CR→\\n, no markers", () => { + const s = createPasteScanner(); + const chunk = "\x1b[200~line one\rline two\rlast\x1b[201~"; + const r = s.feed(chunk); + + expect(r.content).toBe("line one\nline two\nlast"); + expect(s.isActive()).toBe(false); + }); + + test("paste split across chunks stays active until the end marker", () => { + const s = createPasteScanner(); + + expect(s.feed("\x1b[200~part1\r").active).toBe(true); + expect(s.feed("part2").content).toBeNull(); + expect(s.feed("\x1b[201~").content).toBe("part1\npart2"); + }); + + test("forceEnd flushes an unterminated paste (timeout valve)", () => { + const s = createPasteScanner(); + + s.feed("\x1b[200~stuck text"); + expect(s.forceEnd()).toBe("stuck text"); + expect(s.isActive()).toBe(false); + }); + + test("decodes tmux CSI-u control bytes in paste content", () => { + const s = createPasteScanner(); + // CSI-u format: ESC[codepoint;modu (e.g., ESC[106;5u is Ctrl-J, codepoint 10 = \n) + // This simulates a terminal re-emitting control chars inside a paste + const chunk = "\x1b[200~line\x1b[10;5uend\x1b[201~"; + const r = s.feed(chunk); + + expect(r.content).toBe("line\nend"); + expect(s.isActive()).toBe(false); + }); + + test("strips non-printable control chars except newline", () => { + const s = createPasteScanner(); + // Include some control chars: \x00, \x01, \x1f (unit sep), \x7f (DEL) + const chunk = + "\x1b[200~text\x00with\x01control\x1fchars\x7fhere\n\x1b[201~"; + const r = s.feed(chunk); + + expect(r.content).toBe("textwithcontrolcharshere\n"); + expect(s.isActive()).toBe(false); + }); + + test("forceEnd returns null when no paste is active", () => { + const s = createPasteScanner(); + + expect(s.forceEnd()).toBeNull(); + }); + + test("forceEnd normalizes newlines in unterminated paste", () => { + const s = createPasteScanner(); + + s.feed("\x1b[200~line1\rline2\r\nline3"); + const content = s.forceEnd(); + + expect(content).toBe("line1\nline2\nline3"); + expect(s.isActive()).toBe(false); + }); + + test("CRLF normalized to LF in bracketed paste", () => { + const s = createPasteScanner(); + const chunk = "\x1b[200~line1\r\nline2\r\nline3\x1b[201~"; + const r = s.feed(chunk); + + expect(r.content).toBe("line1\nline2\nline3"); + }); +}); diff --git a/packages/core/tests/editor-view.test.ts b/packages/core/tests/editor-view.test.ts new file mode 100644 index 0000000..859d176 --- /dev/null +++ b/packages/core/tests/editor-view.test.ts @@ -0,0 +1,91 @@ +import { test, expect } from "bun:test"; +import { renderEditor } from "../src/editor/view"; + +test("two unwrapped lines return exact row count", () => { + const r = renderEditor( + { lines: ["hello", "world"], cursorLine: 0, cursorCol: 0 }, + { columns: 40, maxRows: 6, color: false } + ); + + expect(r.rows).toBe(2); + expect(r.frame).toContain("hello"); + expect(r.frame).toContain("world"); +}); + +test("cursor on second unwrapped line has correct coordinates", () => { + const r = renderEditor( + { lines: ["hello", "world"], cursorLine: 1, cursorCol: 3 }, + { columns: 40, maxRows: 6, color: false } + ); + + expect(r.rows).toBe(2); + expect(r.cursorRow).toBe(1); + expect(r.cursorCol).toBe(3); +}); + +test("wrapped line computes rows and cursor position correctly", () => { + const long = "x".repeat(50); + const r = renderEditor( + { lines: [long], cursorLine: 0, cursorCol: 50 }, + { columns: 20, maxRows: 6, color: false } + ); + + expect(r.rows).toBe(3); + expect(r.cursorRow).toBe(2); + expect(r.cursorCol).toBe(10); +}); + +test("empty buffer returns zero rows", () => { + const r = renderEditor( + { lines: [], cursorLine: 0, cursorCol: 0 }, + { columns: 40, maxRows: 6, color: false } + ); + + expect(r.rows).toBe(0); + expect(r.cursorRow).toBe(0); + expect(r.cursorCol).toBe(0); + expect(r.frame).toBe(""); +}); + +test("tall buffer clips with indicator and cursor in valid range", () => { + const lines = Array.from({ length: 20 }, (_, i) => `line ${i}`); + const r = renderEditor( + { lines, cursorLine: 19, cursorCol: 0 }, + { columns: 40, maxRows: 6, color: false } + ); + + expect(r.rows).toBeLessThanOrEqual(6); + expect(r.frame).toContain("more"); + expect(r.cursorRow).toBeGreaterThanOrEqual(0); + expect(r.cursorRow).toBeLessThan(r.rows); +}); + +test("single line renders one row", () => { + const r = renderEditor( + { lines: ["hello"], cursorLine: 0, cursorCol: 5 }, + { columns: 40, maxRows: 6, color: false } + ); + + expect(r.rows).toBe(1); + expect(r.frame).toContain("hello"); + expect(r.cursorRow).toBe(0); +}); + +// region: Gemini PR #52 regression tests + +test("emoji wrap and cursor positioning is grapheme-correct", () => { + // Emoji 👍 is 1 grapheme but multiple UTF-16 units + // Line: "hello 👍" (7 graphemes) + "world" (5 graphemes) + // Wrap at 8 columns: "hello 👍" = 8 graphemes → fits exactly + // "world" on next row + const line = "hello 👍world"; + const r = renderEditor( + { lines: [line], cursorLine: 0, cursorCol: 8 }, // cursor at start of "world" (grapheme 8) + { columns: 8, maxRows: 6, color: false } + ); + + // The line should wrap into 2 rows + expect(r.rows).toBe(2); + expect(r.cursorRow).toBe(1); // cursor is on second visual row + expect(r.cursorCol).toBe(0); // at column 0 of the second row +}); diff --git a/packages/core/tests/helpers/virtual-screen.ts b/packages/core/tests/helpers/virtual-screen.ts new file mode 100644 index 0000000..b29eb34 --- /dev/null +++ b/packages/core/tests/helpers/virtual-screen.ts @@ -0,0 +1,295 @@ +/** + * VirtualScreen — a headless ANSI/VT100 terminal emulator for tests. + * + * The render code emits escape sequences (cursor moves, line erases, a DECSTBM + * scroll region, save/restore cursor). Asserting on those raw sequences proves + * we *sent* the right bytes in isolation, but it cannot catch emergent bugs — + * ghost rows, scroll duplication, mis-cleared blocks — because those only exist + * in the *grid of cells a terminal renders* after applying a whole stream. + * + * VirtualScreen applies the same byte stream a real terminal would receive + * (captured via the FakeTerm double) onto a 2-D cell grid, so tests assert on + * the *visible screen* — the equivalent of a screenshot — deterministically and + * in-process. It implements the subset of VT100 the render layer uses: + * - CUP ESC [ row ; col H (absolute cursor) + * - EL ESC [ n K (erase line: 0 to-end, 1 to-start, 2 all) + * - ED ESC [ n J (erase display) + * - DECSTBM ESC [ top ; bot r (scroll region; homes the cursor) + * - DECSC / DECRC ESC 7 / ESC 8 (save / restore cursor) + * - CUU/CUD/CUF/CUB ESC [ n A/B/C/D (relative cursor moves) + * - LF / CR / BS, and printable text with autowrap + region-aware scrolling. + * SGR (colour) and DEC-private / other CSI sequences (bracketed paste, Kitty, + * modifyOtherKeys) are parsed and ignored — they don't change the cell grid. + */ + +const ESC = "\x1b"; + +interface ICursor { + row: number; + col: number; +} + +function toInt(value: string | undefined, fallback: number): number { + if (value === undefined || value === "") { + return fallback; + } + + const n = Number.parseInt(value, 10); + + return Number.isNaN(n) ? fallback : n; +} + +export class VirtualScreen { + private readonly grid: string[][]; + private cursor: ICursor = { row: 1, col: 1 }; + private saved: ICursor = { row: 1, col: 1 }; + private scrollTop = 1; + private scrollBottom: number; + + constructor( + private readonly rows: number, + private readonly cols: number + ) { + this.scrollBottom = rows; + this.grid = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => " ") + ); + } + + /** Apply a chunk of terminal output to the grid. */ + feed(data: string): void { + let i = 0; + + while (i < data.length) { + const ch = data[i] ?? ""; + + if (ch === ESC) { + i = this.handleEscape(data, i); + + continue; + } + + this.handlePlain(ch); + i += 1; + } + } + + /** The text on a 1-based row, right-trimmed. */ + row(n: number): string { + const line = this.grid[n - 1]; + + if (line === undefined) { + return ""; + } + + return line.join("").replace(/\s+$/, ""); + } + + /** All rows, right-trimmed, with trailing blank rows removed. */ + text(): string { + const lines = this.grid.map((_, idx) => this.row(idx + 1)); + + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + + return lines.join("\n"); + } + + /** How many rows contain `needle` as a substring. Ghost detection: a string + * the user typed once should appear on exactly one row. */ + rowsContaining(needle: string): number { + return this.grid.reduce((count, _, idx) => { + return this.row(idx + 1).includes(needle) ? count + 1 : count; + }, 0); + } + + /** The 1-based cursor position after the stream was applied. */ + cursorPosition(): ICursor { + return { row: this.cursor.row, col: this.cursor.col }; + } + + // --- escape handling ------------------------------------------------------- + + private handleEscape(data: string, start: number): number { + const next = data[start + 1]; + + if (next === "7") { + this.saved = { row: this.cursor.row, col: this.cursor.col }; + + return start + 2; + } + + if (next === "8") { + this.cursor = { row: this.saved.row, col: this.saved.col }; + + return start + 2; + } + + if (next === "[") { + return this.handleCsi(data, start + 2); + } + + // Unknown 2-byte escape — skip both bytes. + return start + 2; + } + + private handleCsi(data: string, paramStart: number): number { + let j = paramStart; + let prefix = ""; + const first = data[j] ?? ""; + + if (first === "?" || first === ">" || first === "<") { + prefix = first; + j += 1; + } + + let params = ""; + + while (j < data.length) { + const c = data[j] ?? ""; + + if ((c >= "0" && c <= "9") || c === ";") { + params += c; + j += 1; + } else { + break; + } + } + + const final = data[j] ?? ""; + + // DEC-private (?...) and other-prefix (>,<) sequences don't touch the grid. + if (prefix === "") { + this.applyCsi(final, params); + } + + return j + 1; + } + + private applyCsi(final: string, params: string): void { + const parts = params.split(";"); + + if (final === "H" || final === "f") { + this.setCursor(toInt(parts[0], 1), toInt(parts[1], 1)); + } else if (final === "r") { + this.setScrollRegion(params, parts); + } else if (final === "K") { + this.eraseLine(toInt(parts[0], 0)); + } else if (final === "J") { + this.eraseDisplay(toInt(parts[0], 0)); + } else { + this.applyCursorMove(final, toInt(parts[0], 1)); + } + // Any other final byte (e.g. "m" SGR) is intentionally ignored. + } + + private applyCursorMove(final: string, n: number): void { + if (final === "A") { + this.setCursor(this.cursor.row - n, this.cursor.col); + } else if (final === "B") { + this.setCursor(this.cursor.row + n, this.cursor.col); + } else if (final === "C") { + this.setCursor(this.cursor.row, this.cursor.col + n); + } else if (final === "D") { + this.setCursor(this.cursor.row, this.cursor.col - n); + } + } + + private setScrollRegion(params: string, parts: string[]): void { + if (params === "") { + this.scrollTop = 1; + this.scrollBottom = this.rows; + } else { + this.scrollTop = toInt(parts[0], 1); + this.scrollBottom = toInt(parts[1], this.rows); + } + + // DECSTBM homes the cursor. + this.setCursor(1, 1); + } + + private eraseLine(mode: number): void { + const line = this.grid[this.cursor.row - 1]; + + if (line === undefined) { + return; + } + + const from = mode === 0 ? this.cursor.col - 1 : 0; + const to = mode === 1 ? this.cursor.col : this.cols; + + for (let c = from; c < to && c < this.cols; c += 1) { + line[c] = " "; + } + } + + private eraseDisplay(mode: number): void { + // mode 2 (and 3) clear the whole screen; 0/1 partials are unused by the + // render layer, so treat any erase-display as a full clear for our needs. + if (mode === 2 || mode === 3) { + for (const line of this.grid) { + line.fill(" "); + } + } + } + + // --- plain bytes ----------------------------------------------------------- + + private handlePlain(ch: string): void { + if (ch === "\n") { + this.lineFeed(); + } else if (ch === "\r") { + this.cursor.col = 1; + } else if (ch === "\b") { + this.cursor.col = Math.max(1, this.cursor.col - 1); + } else if (ch >= " ") { + this.putChar(ch); + } + // Other control bytes are ignored. + } + + private putChar(ch: string): void { + if (this.cursor.col > this.cols) { + this.cursor.col = 1; + this.lineFeed(); + } + + const line = this.grid[this.cursor.row - 1]; + + if (line !== undefined) { + line[this.cursor.col - 1] = ch; + } + + this.cursor.col += 1; + } + + private lineFeed(): void { + if (this.cursor.row === this.scrollBottom) { + this.scrollUp(); + } else if (this.cursor.row < this.rows) { + this.cursor.row += 1; + } + } + + private scrollUp(): void { + for (let r = this.scrollTop; r < this.scrollBottom; r += 1) { + const below = this.grid[r]; + + if (below !== undefined) { + this.grid[r - 1] = below.slice(); + } + } + + const bottom = this.grid[this.scrollBottom - 1]; + + if (bottom !== undefined) { + bottom.fill(" "); + } + } + + private setCursor(row: number, col: number): void { + this.cursor.row = Math.min(Math.max(1, row), this.rows); + this.cursor.col = Math.min(Math.max(1, col), this.cols); + } +} diff --git a/packages/core/tests/status-bar.test.ts b/packages/core/tests/status-bar.test.ts index c006adf..cbfefaa 100644 --- a/packages/core/tests/status-bar.test.ts +++ b/packages/core/tests/status-bar.test.ts @@ -3,6 +3,7 @@ import { StatusBar, buildBarFrame, buildInputFrame, + buildEditorFrame, buildOverlayFrame, type IStatusInfo, type IStatusBarTerminal, @@ -320,6 +321,229 @@ describe("buildOverlayFrame (@-picker dropdown)", () => { }); }); +describe("buildEditorFrame", () => { + test("clears and renders each editor line, then parks the cursor", () => { + const frame = buildEditorFrame( + ["line one", "line two"], + 1, + 3, + 80, + 24, + false + ); + + // Two editor lines: one per line, all cleared first + expect(frame).toContain("\x1b[20;1H\x1b[2Kline one"); + expect(frame).toContain("\x1b[21;1H\x1b[2Kline two"); + // Cursor parked at blockStart + cursorRow = 20 + 1 = 21, cursorCol + 1 = 3 + 1 = 4 + expect(frame.endsWith("\x1b[21;4H")).toBe(true); + }); + + test("renders all provided lines (caller must clamp beforehand)", () => { + // buildEditorFrame renders whatever lines are passed; the caller (setEditor) + // handles clamping. This tests that it renders all of them. + const lines = Array(5).fill("line"); + const frame = buildEditorFrame(lines, 0, 0, 80, 24, false); + + const lineMatches = frame.match(/line/g); + + expect(lineMatches).toHaveLength(5); + }); + + test("parks cursor at blockStart + cursorRow, cursorCol + 1", () => { + const frame = buildEditorFrame( + ["first", "second", "third"], + 2, + 5, + 80, + 24, + false + ); + + // blockStart = max(1, 22 - 3) = 19; cursor at 19 + 2 = 21, col 5 + 1 = 6 + expect(frame.endsWith("\x1b[21;6H")).toBe(true); + }); + + test("clamps block start to row 1 on a small terminal", () => { + // On a 5-row terminal: inputRow = max(1, 5 - 2) = 3 + // blockStart = max(1, 3 - 1) = 2 (if 1 line); never goes to row 0 or negative + const frame = buildEditorFrame(["only"], 0, 0, 80, 5, false); + + expect(frame).not.toContain("\x1b[0;1H"); + expect(frame).not.toContain("\x1b[-1;1H"); + expect(frame).toContain("\x1b[2;1H"); // blockStart clamped + }); +}); + +describe("StatusBar with multi-row editor", () => { + const withInput = (term: FakeTerm): StatusBar => + new StatusBar(term, true, false, true); + + test("setEditor renders and parks the cursor (input mode only)", () => { + const term = new FakeTerm(true, 24, 80); + const bar = withInput(term); + + bar.install(INFO); + term.writes.length = 0; + bar.setEditor(["first line", "second line"], 1, 4); + + const out = term.text(); + + expect(out).toContain("first line"); + expect(out).toContain("second line"); + // blockStart = 24 - 2 - 2 = 20; cursor at 20 + 1 = 21, col 4 + 1 = 5 + expect(out.endsWith("\x1b[21;5H")).toBe(true); + }); + + test("setEditor is a no-op when not installed (non-TTY)", () => { + const term = new FakeTerm(false, 24, 80); + const bar = withInput(term); + + bar.setEditor(["hi"], 0, 0); + expect(term.writes).toHaveLength(0); + }); + + test("setEditor clamps lines to available rows above input row", () => { + const term = new FakeTerm(true, 24, 80); + const bar = withInput(term); + + bar.install(INFO); + term.writes.length = 0; + + // Try to set 50 lines, but only 21 rows available (24 - 2 input/bar rows - 1 for safety) + const manyLines = Array(50).fill("line"); + + bar.setEditor(manyLines, 0, 0); + + const out = term.text(); + const lineMatches = out.match(/line/g); + + expect((lineMatches?.length ?? 0) <= 21).toBe(true); + }); + + test("setEditor shrinking a block clears old top rows (no ghost rows)", () => { + const term = new FakeTerm(true, 24, 80); + const bar = withInput(term); + + bar.install(INFO); + + // First: render a 4-row block + const fourLines = ["line 1", "line 2", "line 3", "line 4"]; + + bar.setEditor(fourLines, 3, 0); + term.writes.length = 0; + + // Second: shrink to 1 row. The block is BOTTOM-anchored (content rests just + // above the input row at row 21); the old top rows from the 4-row frame must + // be explicitly cleared. clearRows = previous height (4), so the span is + // rows 18-21: rows 18-20 are blanked, the single line lands at row 21. + bar.setEditor(["only line"], 0, 0); + + const out = term.text(); + + expect(out).toContain("\x1b[18;1H\x1b[2K"); // row 18 cleared (old top row) + expect(out).toContain("\x1b[19;1H\x1b[2K"); // row 19 cleared + expect(out).toContain("\x1b[20;1H\x1b[2K"); // row 20 cleared + expect(out).toContain("\x1b[21;1H\x1b[2Konly line"); // content bottom-anchored + // The old top rows must be blanked, not left holding stale "line N" text. + expect(out).not.toContain("line 1"); + expect(out).not.toContain("line 4"); + // All 4 rows of the previous high-water-mark are cleared. + const clearCount = (out.match(/\[(\d+);1H.*?\[2K/g) ?? []).length; + + expect(clearCount).toBeGreaterThanOrEqual(4); + }); + + test("setEditor adjusts scroll region when height changes (pinned editor block)", () => { + const term = new FakeTerm(true, 24, 80); + const bar = withInput(term); + + bar.install(INFO); + // Initial scroll region: rows 1-21 (reserved = 3: bar + input = 2 + 1) + // Expect: \x1b[1;21r + expect(term.text()).toContain("\x1b[1;21r"); + + term.writes.length = 0; + + // Set a 2-row editor block + bar.setEditor(["line 1", "line 2"], 0, 0); + + const out = term.text(); + + // Scroll region must shrink to rows 1-19 (reserved=3 + editor=2) + // New regionEnd = 24 - 3 - 2 = 19 + expect(out).toContain("\x1b[1;19r"); + + term.writes.length = 0; + + // Shrink editor to 1 row + bar.setEditor(["only"], 0, 0); + + const out2 = term.text(); + + // Scroll region expands back to rows 1-20 (reserved=3 + editor=1) + // New regionEnd = 24 - 3 - 1 = 20 + expect(out2).toContain("\x1b[1;20r"); + }); + + test("setEditor clamps editor height so scroll region stays >= 1 row", () => { + const term = new FakeTerm(true, 5, 80); + const bar = withInput(term); + + bar.install(INFO); + // On a 5-row terminal: reserved=3, so input row is at row 3 + // maxRows for editor = max(0, 3 - 1) = 2 + // But if we requested 50, it clamps to 2 + + const many = Array(50).fill("line"); + + bar.setEditor(many, 0, 0); + + const out = term.text(); + + // Editor height should be clamped to 2 rows + // regionEnd = 5 - 3 - 2 = 0 → clamped to max(1, 0) = 1 + // So scroll region is \x1b[1;1r (just 1 row for streaming) + expect(out).toContain("\x1b[1;1r"); + }); + + test("resize after editor block adjust updates scroll region correctly", () => { + const term = new FakeTerm(true, 24, 80); + const bar = withInput(term); + + bar.install(INFO); + bar.setEditor(["line 1", "line 2", "line 3"], 0, 0); + // regionEnd = 24 - 3 - 3 = 18 + expect(term.text()).toContain("\x1b[1;18r"); + + term.rows = 30; // resize taller + term.writes.length = 0; + bar.resize(INFO); + + const out = term.text(); + + // After resize: regionEnd = 30 - 3 - 3 = 24 + expect(out).toContain("\x1b[1;24r"); + }); + + test("teardown resets scroll region and clears editor block height", () => { + const term = new FakeTerm(true, 24, 80); + const bar = withInput(term); + + bar.install(INFO); + bar.setEditor(["line 1", "line 2"], 0, 0); + term.writes.length = 0; + bar.teardown(); + + const out = term.text(); + + // Scroll region reset to full screen + expect(out).toContain("\x1b[r"); + // Editor block height should be cleared for next activate + expect(bar.active).toBe(false); + }); +}); + describe("StatusBar @-picker overlay", () => { test("setOverlay paints the popup, then clearOverlay erases it", () => { const term = new FakeTerm(true, 24, 80); diff --git a/packages/core/tests/virtual-screen.test.ts b/packages/core/tests/virtual-screen.test.ts new file mode 100644 index 0000000..91dfa8d --- /dev/null +++ b/packages/core/tests/virtual-screen.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { VirtualScreen } from "./helpers/virtual-screen"; + +const ESC = "\x1b"; + +describe("VirtualScreen — emulator fidelity", () => { + test("absolute cursor positioning writes at the right cell", () => { + const s = new VirtualScreen(5, 20); + + s.feed(`${ESC}[3;1Hhello`); + expect(s.row(3)).toBe("hello"); + expect(s.row(1)).toBe(""); + }); + + test("erase-line (2K) clears the whole row before a rewrite", () => { + const s = new VirtualScreen(5, 20); + + s.feed(`${ESC}[1;1Hlong original text`); + s.feed(`${ESC}[1;1H${ESC}[2Kshort`); + expect(s.row(1)).toBe("short"); + }); + + test("a bare rewrite without erase leaves a trailing tail (ghost)", () => { + const s = new VirtualScreen(5, 20); + + s.feed(`${ESC}[1;1Hlongtext`); + s.feed(`${ESC}[1;1Hab`); // no 2K — old tail must remain + expect(s.row(1)).toBe("abngtext"); + }); + + test("LF at the bottom margin scrolls the region up", () => { + const s = new VirtualScreen(4, 10); + + s.feed(`${ESC}[1;1Ha`); + s.feed(`${ESC}[2;1Hb`); + s.feed(`${ESC}[3;1Hc`); + s.feed(`${ESC}[4;1Hd`); + // Cursor at bottom row, a newline scrolls everything up one. + s.feed(`${ESC}[4;1H\n`); + expect(s.row(1)).toBe("b"); + expect(s.row(2)).toBe("c"); + expect(s.row(3)).toBe("d"); + expect(s.row(4)).toBe(""); + }); + + test("DECSTBM confines scrolling to the region; rows below stay put", () => { + const s = new VirtualScreen(5, 10); + + // Pin the bottom two rows out of the scroll region. + s.feed(`${ESC}[4;1Hfooter1`); + s.feed(`${ESC}[5;1Hfooter2`); + s.feed(`${ESC}[1;3r`); // region = rows 1..3 + s.feed(`${ESC}[1;1Ha`); + s.feed(`${ESC}[2;1Hb`); + s.feed(`${ESC}[3;1Hc`); + s.feed(`${ESC}[3;1H\n`); // scroll only within 1..3 + expect(s.row(1)).toBe("b"); + expect(s.row(2)).toBe("c"); + expect(s.row(3)).toBe(""); + expect(s.row(4)).toBe("footer1"); // untouched + expect(s.row(5)).toBe("footer2"); // untouched + }); + + test("save/restore cursor (ESC 7 / ESC 8) round-trips", () => { + const s = new VirtualScreen(5, 20); + + s.feed(`${ESC}[2;5H`); // move + s.feed(`${ESC}7`); // save + s.feed(`${ESC}[5;1Hother`); + s.feed(`${ESC}8X`); // restore, then write + expect(s.row(2)).toBe(" X"); // col 5 on row 2 + }); + + test("SGR colour and bracketed-paste sequences are ignored", () => { + const s = new VirtualScreen(3, 20); + + s.feed(`${ESC}[?2004h${ESC}[1;1H${ESC}[31mred${ESC}[0m`); + expect(s.row(1)).toBe("red"); + }); + + test("autowrap moves past the right edge onto the next row", () => { + const s = new VirtualScreen(3, 4); + + s.feed(`${ESC}[1;1Habcdef`); + expect(s.row(1)).toBe("abcd"); + expect(s.row(2)).toBe("ef"); + }); + + test("rowsContaining counts duplicate occurrences across rows", () => { + const s = new VirtualScreen(5, 20); + + s.feed(`${ESC}[1;1Hhi`); + s.feed(`${ESC}[2;1Hhi`); + s.feed(`${ESC}[3;1Hbye`); + expect(s.rowsContaining("hi")).toBe(2); + expect(s.rowsContaining("bye")).toBe(1); + }); +});