From 2906380c8e44e21d9806cf1533f2054bd9919dc4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 26 Jun 2026 23:20:25 +0200 Subject: [PATCH 01/18] docs(editor): design spec for the multi-line input editor Best-of pi (grapheme buffer, paste markers, sticky-column nav) + hermes (terminal-protocol aliases for reliable Shift/Ctrl+Enter, paste timeout valve, env-gated keys) + our painted input row / palette / @ picker. Enter submits, Shift+Enter/Alt+Enter/\+Enter newline; paste lands in buffer, never auto-submits. Pure buffer/decoder/paste units + FakeTerm view tests; TSFORGE_BASIC_INPUT=1 readline fallback. --- .../2026-06-26-multiline-editor-design.md | 170 ++++++++++++++++++ packages/core/src/render/paste.ts | 74 ++++++++ 2 files changed, 244 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-26-multiline-editor-design.md create mode 100644 packages/core/src/render/paste.ts 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/render/paste.ts b/packages/core/src/render/paste.ts new file mode 100644 index 0000000..81a4ddd --- /dev/null +++ b/packages/core/src/render/paste.ts @@ -0,0 +1,74 @@ +/** + * 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; +} + +function normalizeNewlines(s: string): string { + return s.replace(/\r\n?|\n/gu, "\n"); +} + +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 = normalizeNewlines(buf); + + active = false; + buf = ""; + + return { content, active: false }; + }, + }; +} From dd17751841bbf5dfd5e34a417d5441b3c550a029 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 26 Jun 2026 23:32:44 +0200 Subject: [PATCH 02/18] docs(editor): implementation plan for the multi-line editor (11 tasks, TDD) --- .../plans/2026-06-26-multiline-editor.md | 686 ++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-26-multiline-editor.md 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. From 2b8eee105ec8731545bb312d1e49d0d88b227b63 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 26 Jun 2026 23:40:22 +0200 Subject: [PATCH 03/18] feat(editor): EditorBuffer core text model (grapheme-aware insert/newline/delete/move) --- packages/core/src/editor/buffer.ts | 133 ++++++++++++++++++++++ packages/core/src/editor/segments.ts | 15 +++ packages/core/tests/editor-buffer.test.ts | 42 +++++++ 3 files changed, 190 insertions(+) create mode 100644 packages/core/src/editor/buffer.ts create mode 100644 packages/core/src/editor/segments.ts create mode 100644 packages/core/tests/editor-buffer.test.ts diff --git a/packages/core/src/editor/buffer.ts b/packages/core/src/editor/buffer.ts new file mode 100644 index 0000000..e80e0b5 --- /dev/null +++ b/packages/core/src/editor/buffer.ts @@ -0,0 +1,133 @@ +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; + } + } +} 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/tests/editor-buffer.test.ts b/packages/core/tests/editor-buffer.test.ts new file mode 100644 index 0000000..7c48911 --- /dev/null +++ b/packages/core/tests/editor-buffer.test.ts @@ -0,0 +1,42 @@ +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"); +}); From 681e7cf9c9a44337c6efa3478200a392ce413c4b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 26 Jun 2026 23:45:13 +0200 Subject: [PATCH 04/18] feat(editor): word/line/doc navigation + sticky-column vertical moves Task 2: Extend EditorBuffer with word/line/document navigation and sticky-column vertical move semantics. Implements moveWordLeft/Right, moveLineStart/End, moveDocStart/End, and moveUp/Down with stickyCol preservation across short lines. Clears sticky state on all horizontal and edit operations to ensure vertical moves only persist consecutively. - Add stickyCol field and clearSticky() helper - Call clearSticky() at top of all horizontal/edit ops - Implement word boundary navigation (whitespace transition) - Implement line and document boundary jumps - Implement vertical moves with sticky column clamping - All tests green; bun run validate passes --- packages/core/src/editor/buffer.ts | 95 +++++++++++++++++++++++ packages/core/tests/editor-buffer.test.ts | 33 ++++++++ 2 files changed, 128 insertions(+) diff --git a/packages/core/src/editor/buffer.ts b/packages/core/src/editor/buffer.ts index e80e0b5..2e3094d 100644 --- a/packages/core/src/editor/buffer.ts +++ b/packages/core/src/editor/buffer.ts @@ -4,6 +4,7 @@ export class EditorBuffer { private lines: string[]; private cursorLine: number; private cursorCol: number; // grapheme offset within lines[cursorLine] + private stickyCol: number | null = null; constructor(initial = "") { this.lines = initial.split("\n"); @@ -23,7 +24,12 @@ export class EditorBuffer { return graphemes(this.lines[this.cursorLine] ?? ""); } + private clearSticky(): void { + this.stickyCol = null; + } + insert(text: string): void { + this.clearSticky(); // text has no newlines here (newline() handles those); split defensively. const parts = text.split("\n"); @@ -42,6 +48,7 @@ export class EditorBuffer { } newline(): void { + this.clearSticky(); const g = this.curG(); const left = g.slice(0, this.cursorCol).join(""); const right = g.slice(this.cursorCol).join(""); @@ -52,6 +59,8 @@ export class EditorBuffer { } deleteBackward(): void { + this.clearSticky(); + if (this.cursorCol > 0) { const g = this.curG(); @@ -79,6 +88,7 @@ export class EditorBuffer { } deleteForward(): void { + this.clearSticky(); const g = this.curG(); if (this.cursorCol < g.length) { @@ -102,6 +112,8 @@ export class EditorBuffer { } moveLeft(): void { + this.clearSticky(); + if (this.cursorCol > 0) { this.cursorCol -= 1; } else if (this.cursorLine > 0) { @@ -111,6 +123,8 @@ export class EditorBuffer { } moveRight(): void { + this.clearSticky(); + if (this.cursorCol < this.curG().length) { this.cursorCol += 1; } else if (this.cursorLine < this.lines.length - 1) { @@ -130,4 +144,85 @@ export class EditorBuffer { 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); + } } diff --git a/packages/core/tests/editor-buffer.test.ts b/packages/core/tests/editor-buffer.test.ts index 7c48911..5554cf6 100644 --- a/packages/core/tests/editor-buffer.test.ts +++ b/packages/core/tests/editor-buffer.test.ts @@ -40,3 +40,36 @@ test("emoji is one grapheme for cursor + delete", () => { 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 }); +}); From e3ad37da589c1465b6f12e209896dcc03dbd08ba Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 26 Jun 2026 23:52:17 +0200 Subject: [PATCH 05/18] feat(editor): kill-ring + word/line/to-edge deletes with yank/yank-pop --- packages/core/src/editor/buffer.ts | 114 ++++++++++++++++++++++ packages/core/src/editor/kill-ring.ts | 35 +++++++ packages/core/tests/editor-buffer.test.ts | 21 ++++ 3 files changed, 170 insertions(+) create mode 100644 packages/core/src/editor/kill-ring.ts diff --git a/packages/core/src/editor/buffer.ts b/packages/core/src/editor/buffer.ts index 2e3094d..752ed92 100644 --- a/packages/core/src/editor/buffer.ts +++ b/packages/core/src/editor/buffer.ts @@ -1,11 +1,21 @@ import { graphemes } from "./segments"; +import { KillRing } from "./kill-ring"; 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; + constructor(initial = "") { this.lines = initial.split("\n"); this.cursorLine = this.lines.length - 1; @@ -225,4 +235,108 @@ export class EditorBuffer { moveDown(): void { this.vertical(1); } + + deleteWordBackward(): void { + this.clearSticky(); + + 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(); + + 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(); + + 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(); + + 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(); + + const text = this.killRing.current(); + const startCol = this.cursorCol; + + this.insert(text); + this.lastYank = { start: startCol, length: graphemes(text).length }; + this.lastWasKill = false; + } + + yankPop(): void { + this.clearSticky(); + + if (this.lastYank === null) { + return; + } + + 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 }; + } } 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/tests/editor-buffer.test.ts b/packages/core/tests/editor-buffer.test.ts index 5554cf6..7e28995 100644 --- a/packages/core/tests/editor-buffer.test.ts +++ b/packages/core/tests/editor-buffer.test.ts @@ -73,3 +73,24 @@ test("moveDocStart/End jump to buffer ends", () => { 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 "); +}); From 3a2c78617ba54739fb4d4c448ee80adba7aeaf44 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Fri, 26 Jun 2026 23:57:33 +0200 Subject: [PATCH 06/18] feat(editor): coalesced undo/redo --- packages/core/src/editor/buffer.ts | 55 +++++++++++++++++++++++ packages/core/src/editor/undo-stack.ts | 36 +++++++++++++++ packages/core/tests/editor-buffer.test.ts | 25 +++++++++++ 3 files changed, 116 insertions(+) create mode 100644 packages/core/src/editor/undo-stack.ts diff --git a/packages/core/src/editor/buffer.ts b/packages/core/src/editor/buffer.ts index 752ed92..a106060 100644 --- a/packages/core/src/editor/buffer.ts +++ b/packages/core/src/editor/buffer.ts @@ -1,5 +1,6 @@ import { graphemes } from "./segments"; import { KillRing } from "./kill-ring"; +import { UndoStack, type ISnapshot } from "./undo-stack"; export class EditorBuffer { private lines: string[]; @@ -16,6 +17,10 @@ export class EditorBuffer { private lastYank: { start: number; length: number } | null = null; + private undoStack: UndoStack = new UndoStack(); + + private lastSnapshotKind: string | null = null; + constructor(initial = "") { this.lines = initial.split("\n"); this.cursorLine = this.lines.length - 1; @@ -38,10 +43,28 @@ export class EditorBuffer { this.stickyCol = 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; + } + } + insert(text: string): void { this.clearSticky(); // text has no newlines here (newline() handles those); split defensively. const parts = text.split("\n"); + const kind = text.trim().length === 0 ? "insert-space" : "insert-word"; + + this.maybeSnapshot(kind); for (let i = 0; i < parts.length; i += 1) { if (i > 0) { @@ -59,6 +82,8 @@ export class EditorBuffer { newline(): void { this.clearSticky(); + + this.maybeSnapshot("other"); const g = this.curG(); const left = g.slice(0, this.cursorCol).join(""); const right = g.slice(this.cursorCol).join(""); @@ -70,6 +95,7 @@ export class EditorBuffer { deleteBackward(): void { this.clearSticky(); + this.maybeSnapshot("delete"); if (this.cursorCol > 0) { const g = this.curG(); @@ -99,6 +125,7 @@ export class EditorBuffer { deleteForward(): void { this.clearSticky(); + this.maybeSnapshot("delete"); const g = this.curG(); if (this.cursorCol < g.length) { @@ -238,6 +265,7 @@ export class EditorBuffer { deleteWordBackward(): void { this.clearSticky(); + this.maybeSnapshot("delete"); const g = this.curG(); let start = this.cursorCol; @@ -262,6 +290,7 @@ export class EditorBuffer { deleteWordForward(): void { this.clearSticky(); + this.maybeSnapshot("delete"); const g = this.curG(); let end = this.cursorCol; @@ -285,6 +314,7 @@ export class EditorBuffer { deleteToLineStart(): void { this.clearSticky(); + this.maybeSnapshot("delete"); const g = this.curG(); const removed = g.slice(0, this.cursorCol).join(""); @@ -299,6 +329,7 @@ export class EditorBuffer { deleteToLineEnd(): void { this.clearSticky(); + this.maybeSnapshot("delete"); const g = this.curG(); const removed = g.slice(this.cursorCol).join(""); @@ -312,6 +343,7 @@ export class EditorBuffer { yank(): void { this.clearSticky(); + this.maybeSnapshot("other"); const text = this.killRing.current(); const startCol = this.cursorCol; @@ -328,6 +360,7 @@ export class EditorBuffer { return; } + this.maybeSnapshot("other"); this.killRing.rotate(); const text = this.killRing.current(); const g = this.curG(); @@ -339,4 +372,26 @@ export class EditorBuffer { 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; + } + } } 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/tests/editor-buffer.test.ts b/packages/core/tests/editor-buffer.test.ts index 7e28995..d3041af 100644 --- a/packages/core/tests/editor-buffer.test.ts +++ b/packages/core/tests/editor-buffer.test.ts @@ -94,3 +94,28 @@ test("deleteWordBackward removes the previous word", () => { 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"); +}); From 7da333ac3a6050520c2cd859552bb428fd6ce4f0 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 00:01:04 +0200 Subject: [PATCH 07/18] fix: yank produces single undo snapshot, not double MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract insertRaw() as pure mutation without snapshotting. Public insert() snapshots then calls insertRaw(). yank() now calls insertRaw() to avoid duplicate snapshots — one yank = one undo step, not two. yankPop() already uses direct mutation, no change needed. Add regression test: yank undo step contract verified. --- packages/core/src/editor/buffer.ts | 17 +++++++++++------ packages/core/tests/editor-buffer.test.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/core/src/editor/buffer.ts b/packages/core/src/editor/buffer.ts index a106060..7e37aef 100644 --- a/packages/core/src/editor/buffer.ts +++ b/packages/core/src/editor/buffer.ts @@ -58,13 +58,10 @@ export class EditorBuffer { } } - insert(text: string): void { - this.clearSticky(); + 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"); - const kind = text.trim().length === 0 ? "insert-space" : "insert-word"; - - this.maybeSnapshot(kind); for (let i = 0; i < parts.length; i += 1) { if (i > 0) { @@ -80,6 +77,14 @@ export class EditorBuffer { } } + insert(text: string): void { + this.clearSticky(); + const kind = text.trim().length === 0 ? "insert-space" : "insert-word"; + + this.maybeSnapshot(kind); + this.insertRaw(text); + } + newline(): void { this.clearSticky(); @@ -348,7 +353,7 @@ export class EditorBuffer { const text = this.killRing.current(); const startCol = this.cursorCol; - this.insert(text); + this.insertRaw(text); this.lastYank = { start: startCol, length: graphemes(text).length }; this.lastWasKill = false; } diff --git a/packages/core/tests/editor-buffer.test.ts b/packages/core/tests/editor-buffer.test.ts index d3041af..193634e 100644 --- a/packages/core/tests/editor-buffer.test.ts +++ b/packages/core/tests/editor-buffer.test.ts @@ -119,3 +119,20 @@ test("space then word are separate undo units", () => { 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(""); +}); From dd4c346b71be87084868c142000e79e36da7cee4 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 00:06:24 +0200 Subject: [PATCH 08/18] feat(editor): large-paste markers with expand-on-submit Implements insertPaste() and expand() on EditorBuffer: - insertPaste() inserts large pastes (>10 lines or >1000 chars) as [paste #N +M lines] markers, stashing the real text - expand() replaces all markers with their stashed text for submit - Small pastes insert literally via insert() --- packages/core/src/editor/buffer.ts | 26 +++++++++++++++++++++++ packages/core/tests/editor-buffer.test.ts | 20 +++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/core/src/editor/buffer.ts b/packages/core/src/editor/buffer.ts index 7e37aef..ac916dd 100644 --- a/packages/core/src/editor/buffer.ts +++ b/packages/core/src/editor/buffer.ts @@ -21,6 +21,10 @@ export class EditorBuffer { 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; @@ -85,6 +89,21 @@ export class EditorBuffer { 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(): void { this.clearSticky(); @@ -399,4 +418,11 @@ export class EditorBuffer { 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/tests/editor-buffer.test.ts b/packages/core/tests/editor-buffer.test.ts index 193634e..ae27611 100644 --- a/packages/core/tests/editor-buffer.test.ts +++ b/packages/core/tests/editor-buffer.test.ts @@ -136,3 +136,23 @@ test("a yank is a single undo unit (no wasted undo step)", () => { 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); +}); From f0138375f25179bad4e16572d39cf5b7f0af5a2e Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 00:14:58 +0200 Subject: [PATCH 09/18] feat(editor): key decoder (Kitty CSI-u + modifyOtherKeys + legacy, Enter variants) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a pure, high-performance key decoder that transforms raw stdin bytes into normalized IKeyEvent objects. Handles Kitty CSI-u format (priority), xterm modifyOtherKeys, legacy arrows/home/end, control sequences, and printable graphemes. Correctly decodes all Enter variants (plain CR, Alt+Enter, Shift+Enter) which determine submit vs newline. CC ≤ 20 via helper functions. All 6 brief tests green. --- packages/core/src/editor/keys.ts | 280 ++++++++++++++++++++++++ packages/core/tests/editor-keys.test.ts | 104 +++++++++ 2 files changed, 384 insertions(+) create mode 100644 packages/core/src/editor/keys.ts create mode 100644 packages/core/tests/editor-keys.test.ts diff --git a/packages/core/src/editor/keys.ts b/packages/core/src/editor/keys.ts new file mode 100644 index 0000000..7a21de3 --- /dev/null +++ b/packages/core/src/editor/keys.ts @@ -0,0 +1,280 @@ +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 { + const ch = chunk.charCodeAt(idx); + + if (ch < 0x20 || ch >= 0x7f) { + 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/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" }); + } + }); +}); From 6fc045dcf14989415ec83bd49ff80a2f4283cbd7 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 00:20:11 +0200 Subject: [PATCH 10/18] feat(editor): finalize PasteScanner (timeout valve + tmux CSI-u decode) Add forceEnd() method for timeout valve when bracketed paste EOF marker doesn't arrive. Decode tmux-style CSI-u control sequences in paste content. Strip non-printable control chars (except newlines). Comprehensive test coverage for all edge cases. --- packages/core/src/render/paste.ts | 35 ++++++++++- packages/core/tests/editor-paste.test.ts | 75 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/core/tests/editor-paste.test.ts diff --git a/packages/core/src/render/paste.ts b/packages/core/src/render/paste.ts index 81a4ddd..9a8eead 100644 --- a/packages/core/src/render/paste.ts +++ b/packages/core/src/render/paste.ts @@ -26,12 +26,33 @@ export interface IPasteScan { 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 = ""; @@ -63,12 +84,24 @@ export function createPasteScanner(): IPasteScanner { } buf += rest.slice(0, end); - const content = normalizeNewlines(buf); + 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/tests/editor-paste.test.ts b/packages/core/tests/editor-paste.test.ts new file mode 100644 index 0000000..54d8792 --- /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/render/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"); + }); +}); From 07e082fe7c3c7399321efd72765beb885a678993 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 00:21:54 +0200 Subject: [PATCH 11/18] chore(editor): move paste.ts under editor/ (consistent module layout) --- packages/core/src/{render => editor}/paste.ts | 0 packages/core/tests/editor-paste.test.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/core/src/{render => editor}/paste.ts (100%) diff --git a/packages/core/src/render/paste.ts b/packages/core/src/editor/paste.ts similarity index 100% rename from packages/core/src/render/paste.ts rename to packages/core/src/editor/paste.ts diff --git a/packages/core/tests/editor-paste.test.ts b/packages/core/tests/editor-paste.test.ts index 54d8792..5cbf3c1 100644 --- a/packages/core/tests/editor-paste.test.ts +++ b/packages/core/tests/editor-paste.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { createPasteScanner } from "../src/render/paste"; +import { createPasteScanner } from "../src/editor/paste"; describe("PasteScanner", () => { test("extracts a real bracketed paste, CR→\\n, no markers", () => { From 386829b791dbb07cdbebd0d91d86a97eef2bfec6 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 00:31:00 +0200 Subject: [PATCH 12/18] feat(editor): EditorView multi-line renderer (wrap + scroll + cursor) --- packages/core/src/editor/view.ts | 370 ++++++++++++++++++++++++ packages/core/tests/editor-view.test.ts | 34 +++ 2 files changed, 404 insertions(+) create mode 100644 packages/core/src/editor/view.ts create mode 100644 packages/core/tests/editor-view.test.ts diff --git a/packages/core/src/editor/view.ts b/packages/core/src/editor/view.ts new file mode 100644 index 0000000..8547aab --- /dev/null +++ b/packages/core/src/editor/view.ts @@ -0,0 +1,370 @@ +import { graphemes } from "./segments"; + +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 rowCursorCol: number | undefined; + let visualRow = 0; + + for (let i = 0; i < graphemeList.length; i += 1) { + const g = graphemeList[i]; + + if (row.length >= columns) { + rows.push({ + text: row, + cursorRow: rowCursorCol !== undefined ? visualRow : undefined, + cursorCol: rowCursorCol, + }); + row = ""; + rowCursorCol = undefined; + visualRow += 1; + } + + if (i === cursorCol) { + rowCursorCol = row.length; + } + + if (g !== undefined) { + row += g; + } + } + + // Handle cursor at end of line + if (graphemeList.length === cursorCol) { + rowCursorCol = row.length; + } + + // 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, + clippedAbove: boolean, + 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) { + const offset = clippedAbove ? 1 : 0; + + foundCursor = { + row: currentTotalRows + wrappedRow.cursorRow + offset, + 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 +): { + 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 = `↑ ${moreCount} more`; + + 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, + window.clippedAbove, + 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"; + totalVisualRows += 1; + } + } + + // Add "↓ N more" indicator if clipped below + if (window.clippedBelow) { + frame += "\n"; + totalVisualRows += 1; + + const moreCount = lines.length - window.endLine; + const indicator = `↓ ${moreCount} more`; + + 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 } = 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); + + return { + frame, + rows: Math.min(totalRows, maxRows), + cursorRow, + cursorCol: cursorColResult, + }; +} diff --git a/packages/core/tests/editor-view.test.ts b/packages/core/tests/editor-view.test.ts new file mode 100644 index 0000000..1d54a9c --- /dev/null +++ b/packages/core/tests/editor-view.test.ts @@ -0,0 +1,34 @@ +import { test, expect } from "bun:test"; +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"); +}); From 9116bb4a53d6a4c5f430916814f7bcc889df5317 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 00:35:52 +0200 Subject: [PATCH 13/18] fix(editor): resolve row counting and cursor offset bugs Bug 1 (Critical): Remove double-counting of inter-logical-line separators. The separator newline does not itself constitute a visual row; dropping the totalVisualRows += 1 after inter-line separators fixes row counts (was inflating by 1 per line after the first) and downstream cursorRow offsets. Bug 2 (Important): Eliminate offset double-count in clipped-above case. currentTotalRows already includes the indicator row; the offset added an extra row to cursorRow coordinates. Removed clippedAbove parameter and offset logic. Bug 3 (Important): Strengthen test assertions to pin exact behavior and catch these bugs. Replaced loose bounds checks with exact row counts for multi-line, wrapped-line, and cursor-coordinate scenarios. Bug 4 (Minor): Use the color option to dim scroll indicators via paint/STYLE.dim instead of leaving it unused. Supports future UI polish on clipped-content hints. All 6 editor tests pass with exact assertions. Full bun run validate passes (1652 tests). --- packages/core/src/editor/view.ts | 19 ++++----- packages/core/tests/editor-view.test.ts | 52 +++++++++++++++++++++---- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/packages/core/src/editor/view.ts b/packages/core/src/editor/view.ts index 8547aab..6b120d2 100644 --- a/packages/core/src/editor/view.ts +++ b/packages/core/src/editor/view.ts @@ -1,4 +1,5 @@ import { graphemes } from "./segments"; +import { paint, STYLE } from "../render/style"; export interface IEditorInput { lines: string[]; @@ -202,7 +203,6 @@ function renderLineToFrame( isCurrentLine: boolean, cursorCol: number, columns: number, - clippedAbove: boolean, currentTotalRows: number ): { frameStr: string; @@ -224,10 +224,8 @@ function renderLineToFrame( frameStr += wrappedRow.text; if (isCurrentLine && wrappedRow.cursorRow !== undefined) { - const offset = clippedAbove ? 1 : 0; - foundCursor = { - row: currentTotalRows + wrappedRow.cursorRow + offset, + row: currentTotalRows + wrappedRow.cursorRow, col: wrappedRow.cursorCol ?? 0, }; } @@ -255,7 +253,8 @@ function buildFrameString( window: IVisibleWindow, cursorLine: number, cursorCol: number, - columns: number + columns: number, + color: boolean ): { frame: string; cursorRow: number; @@ -270,7 +269,7 @@ function buildFrameString( // Add "↑ N more" indicator if clipped above if (window.clippedAbove) { const moreCount = window.startLine; - const indicator = `↑ ${moreCount} more`; + const indicator = paint(`↑ ${moreCount} more`, STYLE.dim, color); frame += indicator + "\n"; totalVisualRows += 1; @@ -295,7 +294,6 @@ function buildFrameString( isCurrentLine, cursorCol, columns, - window.clippedAbove, totalVisualRows ); @@ -310,7 +308,6 @@ function buildFrameString( // Add newline between logical lines (but not after last line) if (lineIdx < window.endLine - 1) { frame += "\n"; - totalVisualRows += 1; } } @@ -320,7 +317,7 @@ function buildFrameString( totalVisualRows += 1; const moreCount = lines.length - window.endLine; - const indicator = `↓ ${moreCount} more`; + const indicator = paint(`↓ ${moreCount} more`, STYLE.dim, color); frame += indicator; } @@ -343,7 +340,7 @@ export function renderEditor( opts: IEditorOptions ): IEditorFrame { const { lines, cursorLine, cursorCol } = input; - const { columns, maxRows } = opts; + const { columns, maxRows, color } = opts; // Handle empty buffer if (lines.length === 0 || columns <= 0 || maxRows <= 0) { @@ -359,7 +356,7 @@ export function renderEditor( cursorRow, cursorCol: cursorColResult, totalRows, - } = buildFrameString(lines, window, cursorLine, cursorCol, columns); + } = buildFrameString(lines, window, cursorLine, cursorCol, columns, color); return { frame, diff --git a/packages/core/tests/editor-view.test.ts b/packages/core/tests/editor-view.test.ts index 1d54a9c..ff0084c 100644 --- a/packages/core/tests/editor-view.test.ts +++ b/packages/core/tests/editor-view.test.ts @@ -1,28 +1,53 @@ import { test, expect } from "bun:test"; import { renderEditor } from "../src/editor/view"; -test("single line renders one row with the gutter", () => { +test("two unwrapped lines return exact row count", () => { const r = renderEditor( - { lines: ["hello"], cursorLine: 0, cursorCol: 5 }, + { lines: ["hello", "world"], cursorLine: 0, cursorCol: 0 }, { columns: 40, maxRows: 6, color: false } ); - expect(r.rows).toBe(1); + expect(r.rows).toBe(2); expect(r.frame).toContain("hello"); - expect(r.cursorRow).toBe(0); + expect(r.frame).toContain("world"); }); -test("a long line wraps to multiple visual rows", () => { +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).toBeGreaterThan(1); + 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("buffer taller than maxRows clips with a scroll indicator", () => { +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 }, @@ -31,4 +56,17 @@ test("buffer taller than maxRows clips with a scroll indicator", () => { 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); }); From fda720406b05d55dd7bed67fa55dddd680821c05 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 00:51:54 +0200 Subject: [PATCH 14/18] feat(editor): EditorController (raw stdin glue, submit/newline/paste wiring) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements IEditorHandle interface with pure stdin/repaint loop: - EditorController wires stdin chunks through PasteScanner, then decodeKeys - Submit on plain return; newline on Shift/Alt+return; trailing-\ rule - Paste path: swallow keys while scanner.isActive(), insertPaste on complete - Key→action dispatch table (CC ≤20) for editing ops (delete, motion, kills, yank, undo) - Ctrl+char routed through dispatch (e.g. ctrl+u→deleteToLineStart) - Repaint via renderEditor after each change - Terminal setup (raw mode, bracketed paste, Kitty, modifyOtherKeys gated by env) - FakeStdin test helper with EventEmitter-like interface Tests: 12 passing (type/newline/paste/backspace/delete/ctrl+u/onChange/close) --- packages/core/src/editor/controller.ts | 405 ++++++++++++++++++ packages/core/src/editor/index.ts | 18 + packages/core/tests/editor-controller.test.ts | 220 ++++++++++ 3 files changed, 643 insertions(+) create mode 100644 packages/core/src/editor/controller.ts create mode 100644 packages/core/src/editor/index.ts create mode 100644 packages/core/tests/editor-controller.test.ts diff --git a/packages/core/src/editor/controller.ts b/packages/core/src/editor/controller.ts new file mode 100644 index 0000000..6047769 --- /dev/null +++ b/packages/core/src/editor/controller.ts @@ -0,0 +1,405 @@ +import { EditorBuffer } from "./buffer"; +import { decodeKeys } from "./keys"; +import { createPasteScanner } from "./paste"; +import { renderEditor } from "./view"; + +export interface IEditorHandle { + onSubmit(cb: (message: string) => void): void; + onChange(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; + columns?: number; + rows?: number; + openPalette?: () => Promise; + openFilePicker?: () => Promise; +} + +type KeyAction = (buffer: EditorBuffer) => void; + +/** + * 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 + table.set("left", (buf) => { + buf.moveLeft(); + }); + table.set("right", (buf) => { + buf.moveRight(); + }); + table.set("up", (buf) => { + buf.moveUp(); + }); + table.set("down", (buf) => { + buf.moveDown(); + }); + 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, + 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)[] = []; + 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: rows, + color: true, + } + ); + + out(frame.frame); + } + + function notifyChange(): void { + changeCallbacks.forEach((cb) => { + cb(); + }); + } + + 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 beforeCursor = currentLine.substring(0, col); + + 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(); + + buffer.setText(""); + repaint(); + notifyChange(); + submitCallbacks.forEach((cb) => { + cb(message); + }); + } + } + + function handleCharKey( + text: string, + ctrl: boolean, + alt: boolean, + shift: boolean + ): void { + 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 onDataChunk(chunk: string): void { + if (!isOpen) { + return; + } + + const wasActive = pasteScanner.isActive(); + const pasteScan = pasteScanner.feed(chunk); + + if (pasteScan.content !== null) { + buffer.insertPaste(pasteScan.content); + repaint(); + notifyChange(); + + return; + } + + if (pasteScanner.isActive() || wasActive) { + return; + } + + const keyEvents = decodeKeys(chunk); + + for (const event of keyEvents) { + const { name, text, ctrl, alt, shift } = event; + + if (name === "return") { + handleReturnKey(ctrl, alt, shift); + continue; + } + + if (name === "char") { + handleCharKey(text, ctrl, alt, shift); + continue; + } + + 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(); + } + } + + // 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); + }, + + 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/tests/editor-controller.test.ts b/packages/core/tests/editor-controller.test.ts new file mode 100644 index 0000000..178b911 --- /dev/null +++ b/packages/core/tests/editor-controller.test.ts @@ -0,0 +1,220 @@ +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); + }); + } + + 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("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(""); + }); +}); From d7636be74ba35151820f5ca288d29cb94cb2b594 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 01:04:40 +0200 Subject: [PATCH 15/18] feat(cli): drive the interactive prompt through the multi-line editor (TSFORGE_BASIC_INPUT fallback) --- packages/core/src/cli.ts | 125 ++++++++++++++----- packages/core/src/config/config.constants.ts | 1 + packages/core/src/config/flags.ts | 3 + packages/core/tests/cli.test.ts | 120 ++++++++++++++++++ 4 files changed, 220 insertions(+), 29 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 46329dd..e8dcc6c 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -54,6 +54,8 @@ import { import { makeSpinner, spinnerPhase } from "./render/spinner"; import { validate } from "./validate"; import { isPolicyMode } from "./policy"; +import { startEditor, type IEditorHandle } from "./editor"; +import { flags } from "./config/flags"; import { PROVIDER_LIMITS, PROVIDER_DEFAULTS, @@ -1494,6 +1496,8 @@ async function repl(args: ICliArgs): Promise { process.stdout.write("› "); }; + let editorHandle: IEditorHandle | null = null; + await new Promise((resolveLoop) => { let busy = false; let closed = false; @@ -1507,6 +1511,44 @@ 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(); + 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; @@ -1630,7 +1672,10 @@ async function repl(args: ICliArgs): Promise { // 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) { + // When the editor is active, palette and picker are triggered via the editor's + // internal handlers (passed as openPalette/openFilePicker deps), not keypress. + if (process.stdin.isTTY && (!useInputRow || flags.basicInput())) { + // Only set up keypress detection for readline mode. emitKeypressEvents(process.stdin); process.stdin.on("keypress", (str: string | undefined) => { syncInput(); // keep the pinned input row in sync as the user types @@ -1665,41 +1710,53 @@ 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(); + // When the editor is active, submitLine is wired via onSubmit; otherwise it's + // called here from readline. + if (useInputRow && !flags.basicInput()) { + // Use the multiline editor instead of readline. + const stdinAdapter = { + 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(); + }, + }; - if (line.length === 0) { - if (!busy) { - prompt(); - } + // setEncoding is optional on IStdin, so we can omit it here to avoid type + // conflicts with process.stdin.setEncoding's BufferEncoding parameter. + // The editor will work fine without it if process.stdin is in the right state. - return; - } + editorHandle = startEditor({ + stdin: stdinAdapter, + out: (s: string) => { + statusBar.writeStream(s); + }, + columns: process.stdout.columns, + rows: process.stdout.rows, + openPalette, + openFilePicker, + }); - // 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`); - } + editorHandle.onSubmit(submitLine); + } else { + // Fall back to readline for non-TTY or when the basic flag is set. + rl.on("line", submitLine); + } - if (busy) { - if (line === "/exit" || line === "/quit") { - active?.abort(); - rl.close(); - } else { - pending.push(line); - echo(" ↳ queued (steers the next turn)\n"); - } + rl.on("close", () => { + closed = true; - return; + if (editorHandle !== null) { + editorHandle.close(); } - void runLine(line); - }); - - rl.on("close", () => { - closed = true; statusBar.teardown(); maybeFinish(); }); @@ -1714,6 +1771,16 @@ async function repl(args: ICliArgs): Promise { } }); + // Clean up the editor if it was active (it's already closed via rl.on("close") + // above, but this is belt-and-suspenders to ensure the terminal is fully restored). + const closeEditor = (handle: IEditorHandle | null): void => { + if (handle) { + handle.close(); + } + }; + + closeEditor(editorHandle); + statusBar.teardown(); // belt-and-suspenders: restore the terminal on loop exit interactiveStream = null; // later/headless writes go straight to stdout again 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/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(); +}); From f3d7030398639558b353126761b4d6b9c97e8d9c Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 01:26:48 +0200 Subject: [PATCH 16/18] fix(editor-cli): fix dual stdin/raw-mode conflict + history/Ctrl-C/palette integration --- packages/core/src/cli.ts | 221 +++++++++++++++---------- packages/core/src/editor/controller.ts | 220 +++++++++++++++++++----- 2 files changed, 313 insertions(+), 128 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e8dcc6c..e0f8364 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -55,6 +55,7 @@ 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, @@ -924,17 +925,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 @@ -944,13 +951,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. @@ -1413,7 +1422,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); }); @@ -1478,7 +1487,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; @@ -1496,9 +1508,8 @@ async function repl(args: ICliArgs): Promise { process.stdout.write("› "); }; - let editorHandle: IEditorHandle | null = null; - await new Promise((resolveLoop) => { + let editorHandle: IEditorHandle | null = null; let busy = false; let closed = false; let paletteOpen = false; @@ -1533,7 +1544,10 @@ async function repl(args: ICliArgs): Promise { if (busy) { if (line === "/exit" || line === "/quit") { active?.abort(); - rl.close(); + + if (rl !== null) { + rl.close(); + } if (editorHandle !== null) { editorHandle.close(); @@ -1556,7 +1570,9 @@ async function repl(args: ICliArgs): Promise { try { if (line.startsWith("/")) { if (await command(line)) { - rl.close(); + if (rl !== null) { + rl.close(); + } return; } @@ -1590,6 +1606,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. @@ -1597,28 +1634,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(); + } } } }; @@ -1626,13 +1674,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 => { @@ -1656,26 +1709,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. - // When the editor is active, palette and picker are triggered via the editor's - // internal handlers (passed as openPalette/openFilePicker deps), not keypress. - if (process.stdin.isTTY && (!useInputRow || flags.basicInput())) { - // Only set up keypress detection for readline mode. + // 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 @@ -1684,13 +1744,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. @@ -1711,30 +1771,24 @@ async function repl(args: ICliArgs): Promise { // 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. // When the editor is active, submitLine is wired via onSubmit; otherwise it's - // called here from readline. - if (useInputRow && !flags.basicInput()) { - // Use the multiline editor instead of readline. - const stdinAdapter = { - 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(); - }, - }; - - // setEncoding is optional on IStdin, so we can omit it here to avoid type - // conflicts with process.stdin.setEncoding's BufferEncoding parameter. - // The editor will work fine without it if process.stdin is in the right state. - + // 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: stdinAdapter, + 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(); + }, + }, out: (s: string) => { statusBar.writeStream(s); }, @@ -1745,18 +1799,27 @@ async function repl(args: ICliArgs): Promise { }); editorHandle.onSubmit(submitLine); - } else { - // Fall back to readline for non-TTY or when the basic flag is set. + editorHandle.onInterrupt(() => { + if (active === null) { + closed = true; + editorHandle?.close(); + maybeFinish(); + } else { + active.abort(); + } + }); + editorHandle.onExit(() => { + closed = true; + editorHandle?.close(); + maybeFinish(); + }); + } else if (rl !== null) { rl.on("line", submitLine); } - rl.on("close", () => { + rl?.on("close", () => { closed = true; - - if (editorHandle !== null) { - editorHandle.close(); - } - + editorHandle?.close(); statusBar.teardown(); maybeFinish(); }); @@ -1771,16 +1834,6 @@ async function repl(args: ICliArgs): Promise { } }); - // Clean up the editor if it was active (it's already closed via rl.on("close") - // above, but this is belt-and-suspenders to ensure the terminal is fully restored). - const closeEditor = (handle: IEditorHandle | null): void => { - if (handle) { - handle.close(); - } - }; - - closeEditor(editorHandle); - statusBar.teardown(); // belt-and-suspenders: restore the terminal on loop exit interactiveStream = null; // later/headless writes go straight to stdout again diff --git a/packages/core/src/editor/controller.ts b/packages/core/src/editor/controller.ts index 6047769..a41e1a9 100644 --- a/packages/core/src/editor/controller.ts +++ b/packages/core/src/editor/controller.ts @@ -6,6 +6,8 @@ import { renderEditor } from "./view"; 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; } @@ -47,19 +49,14 @@ function buildKeyDispatchTable(): Map { buf.insert("\t"); }); - // Motion keys + // 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("up", (buf) => { - buf.moveUp(); - }); - table.set("down", (buf) => { - buf.moveDown(); - }); table.set("home", (buf) => { buf.moveLineStart(); }); @@ -133,6 +130,12 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { 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 dataListener: ((chunk: string) => void) | null = null; function repaint(): void { @@ -165,6 +168,61 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { }); } + // Save the current buffer as a history item and emit onSubmit callbacks + function saveToHistory(message: string): void { + history.push(message); + 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 + const draftText = buffer.getText(); + + buffer.setText("", false); // Save a slot for the draft + history.push(draftText); + historyIndex = history.length - 2; + } 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 + } + + historyIndex += 1; + + if (historyIndex === history.length - 1) { + // Restore draft + const draft = history[historyIndex]; + + buffer.setText(draft ?? ""); + history.pop(); + historyIndex = -1; + } else if (historyIndex < history.length) { + buffer.setText(history[historyIndex] ?? ""); + } else { + // Fell off the end + historyIndex = -1; + buffer.setText(""); + } + + repaint(); + notifyChange(); + } + function handleReturnKey(ctrl: boolean, alt: boolean, shift: boolean): void { const bufferText = buffer.getText(); const { col } = buffer.getCursor(); @@ -192,6 +250,7 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { } else if (!ctrl && !alt && !shift) { const message = buffer.expand(); + saveToHistory(message); buffer.setText(""); repaint(); notifyChange(); @@ -207,6 +266,24 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { 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[] = []; @@ -265,67 +342,114 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { } } - function onDataChunk(chunk: string): void { - if (!isOpen) { + 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; } - const wasActive = pasteScanner.isActive(); - const pasteScan = pasteScanner.feed(chunk); + if (name === "char") { + handleCharKey(text, ctrl, alt, shift); - if (pasteScan.content !== null) { - buffer.insertPaste(pasteScan.content); + 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 (pasteScanner.isActive() || wasActive) { + 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 keyEvents = decodeKeys(chunk); + const keyParts: string[] = []; - for (const event of keyEvents) { - const { name, text, ctrl, alt, shift } = event; + if (ctrl) { + keyParts.push("ctrl"); + } - if (name === "return") { - handleReturnKey(ctrl, alt, shift); - continue; - } + if (alt) { + keyParts.push("alt"); + } - if (name === "char") { - handleCharKey(text, ctrl, alt, shift); - continue; - } + if (shift) { + keyParts.push("shift"); + } - const keyParts: string[] = []; + keyParts.push(name); + const normalizedKey = keyParts.join("+"); - if (ctrl) { - keyParts.push("ctrl"); - } + const action = keyDispatchTable.get(normalizedKey); - if (alt) { - keyParts.push("alt"); - } + if (action) { + action(buffer); + repaint(); + notifyChange(); + } - if (shift) { - keyParts.push("shift"); - } + triggerPaletteOrPicker(); + } - keyParts.push(name); - const normalizedKey = keyParts.join("+"); + function onDataChunk(chunk: string): void { + if (!isOpen) { + return; + } - const action = keyDispatchTable.get(normalizedKey); + const wasActive = pasteScanner.isActive(); + const pasteScan = pasteScanner.feed(chunk); - if (action) { - action(buffer); - repaint(); - notifyChange(); - } + if (pasteScan.content !== null) { + buffer.insertPaste(pasteScan.content); + repaint(); + notifyChange(); - triggerPaletteOrPicker(); + return; + } + + if (pasteScanner.isActive() || wasActive) { + return; + } + + const keyEvents = decodeKeys(chunk); + + for (const event of keyEvents) { + dispatchKeyEvent(event); } } @@ -371,6 +495,14 @@ export function startEditor(deps: IStartEditorDeps): IEditorHandle { changeCallbacks.push(cb); }, + onInterrupt(cb: () => void) { + interruptCallbacks.push(cb); + }, + + onExit(cb: () => void) { + exitCallbacks.push(cb); + }, + getBuffer(): EditorBuffer { return buffer; }, From 4e16b5bdee809a5af0a5f8c92e2e4ff322738187 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 01:42:36 +0200 Subject: [PATCH 17/18] fix(repl): reduce cognitive complexity + add editor controller tests FIX 1: Extract REPL session initialization into separate helper function initReplSession() to reduce repl() cognitive complexity from 24 to 20. Helper encapsulates model resolution, session loading, context window detection, and session creation (lines 787-924 moved to helper). FIX 2: Add unit tests for new editor controller callbacks: - Ctrl-C invokes onInterrupt callback - Ctrl-D on empty buffer invokes onExit callback - Ctrl-D with text does NOT exit - Up arrow on first line recalls previous submitted message - Down arrow after Up returns to draft - Multiple submits create history; navigation works correctly All tests green (1672 pass, 0 fail), validate passes (0 lint errors). --- packages/core/src/cli.ts | 95 +++++++++++++-- packages/core/tests/editor-controller.test.ts | 115 ++++++++++++++++++ 2 files changed, 197 insertions(+), 13 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e0f8364..e574307 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -767,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); @@ -822,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)) ?? @@ -869,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: @@ -882,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, @@ -1208,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; @@ -1305,7 +1374,7 @@ async function repl(args: ICliArgs): Promise { arg, provider, activeName, - fallbackEntry: activeModel.entry, + fallbackEntry: activeModelEntry, contextWindow, }); diff --git a/packages/core/tests/editor-controller.test.ts b/packages/core/tests/editor-controller.test.ts index 178b911..7d6c288 100644 --- a/packages/core/tests/editor-controller.test.ts +++ b/packages/core/tests/editor-controller.test.ts @@ -217,4 +217,119 @@ describe("EditorController", () => { // 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"); + }); }); From 61d57e9bf6cbcf90bf0c1125763411c20d46377f Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 27 Jun 2026 01:55:19 +0200 Subject: [PATCH 18/18] docs(editor): document the multi-line input editor + keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive reference page for the interactive input editor: - Submit vs. newline bindings (Enter, Shift+Enter, Alt+Enter, trailing backslash) - Navigation (arrows, word jump, line/document start/end) - Deletion and kill-ring operations (Ctrl+W, Ctrl+U, Ctrl+K, yank) - Undo (Ctrl+_) - History navigation (↑/↓ at buffer edges) - Multi-line paste handling and large paste display - Slash commands (/ for palette, @ for file picker) - Interrupt and exit keys (Ctrl+C, Ctrl+D) - Fallback mode (TSFORGE_BASIC_INPUT=1) for compatibility - Terminal compatibility notes Link the new reference page from the Interactive CLI guide. --- .../docs/src/content/docs/cli/interactive.mdx | 2 + .../content/docs/reference/input-editor.mdx | 89 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 apps/docs/src/content/docs/reference/input-editor.mdx 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..781e1a1 --- /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 Dreamdata Platform's 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.