feat: best-in-class multi-line input editor (replaces readline)#52
Conversation
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.
…line/delete/move)
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
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.
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()
…ter variants) 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.
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.
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).
…wiring) 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)
… (TSFORGE_BASIC_INPUT fallback)
…lette integration
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).
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.
There was a problem hiding this comment.
Code Review
This pull request replaces the Node readline interactive prompt in tsforge with a custom, grapheme-aware multi-line input editor supporting advanced editing features like bracketed paste, emacs-style kill-ring, and coalesced undo/redo. The code review identified several critical bugs and improvement opportunities: intermediate undo snapshots during multiline inserts should be avoided by parameterizing newline(); lastYank must be cleared in clearSticky() to prevent buffer corruption during yankPop; the active draft should be stored in a separate variable to avoid polluting the shared history; and several string slicing and wrapping operations must be updated to use grapheme counts instead of UTF-16 code units to ensure correct emoji and combining character handling.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
- buffer.ts: Add snapshot parameter to newline() to preserve multiline insert atomicity. insertRaw calls newline(false) to prevent intermediate snapshots. Fix yankPop corruption by clearing stickyCol directly instead of calling clearSticky(), which would invalidate lastYank. Add lastYank nullification to clearSticky() so moves invalidate yank state. (findings #1-3, #39) - controller.ts: Import graphemes for grapheme-correct string slicing. Use draftText closure var instead of polluting history array. Refactor navigateHistoryUp/Down to separate draft state from history. Fix trailing backslash detection to use grapheme slicing instead of substring() which fails with emoji. (findings #4-9, #70, #72) - view.ts: Replace row.length (UTF-16 units) with rowGraphemeCount for wrap boundary and cursor column tracking. Emojis and combining chars now wrap and position correctly. (finding #5) - input-editor.mdx: Correct copy-paste brand reference from "Dreamdata Platform" to "tsforge". (finding #12) - Test additions: Regression tests for multiline undo atomicity, yankPop safety after moves, history draft preservation, emoji backslash detection, and emoji wrap/cursor positioning. All house rules followed: no `as` casts, no eslint-disable, complexity ≤20. 1678 tests pass, 0 fail.
|
All 12 review findings fixed in |
… streaming - Add buildEditorFrame() to render multi-row editor block with absolute positioning - Add StatusBar.setEditor() to paint editor in fixed area above input row - Rewire editor controller: repaint() now uses renderEditor callback instead of streaming - Editor frames are cleared+redrawn in place each keystroke (like input row), not streamed - Add debug logging (TSFORGE_EDITOR_DEBUG env var) for input chunks, key events, repaint frames - Import fs.appendFileSync at module level for clean debug helper - Tests: 8 new tests for buildEditorFrame and setEditor; all 1701 validate pass - Fixes: typing repeating on new lines, cursor position resets, frame accumulation
When the multi-row editor block shrinks (e.g. 4 rows → 1 row), the old rows above the new block were never cleared, leaving stale text on screen. Track editorRows (previous render height) in StatusBar and pass it to buildEditorFrame as clearRows. Clear max(previous, current) rows so shrinking blocks erase their old top rows. This mirrors the pattern already established in buildOverlayFrame for the @ picker. Tests: render 4 rows, then shrink to 1 row and assert all 4 rows are cleared (rows 18-21 all get erase-line sequences).
The multi-row editor block now dynamically resizes the DECSTBM scroll region when its height changes. Previously, the editor rows were painted inside the scrollable region, causing old frames to persist as ghosts when streaming agent output scrolled within the region. Now: when setEditor() detects a height change, it shrinks the scroll region's end to `rows - reserved - editorRows`, pinning the editor block (and bar + input row) below the scrollable area. Streaming output scrolls only in rows 1..regionEnd, leaving the pinned block untouched. On shrink, the region expands, and teardown/resize restore it correctly. The approach mirrors buildOverlayFrame (which reserves rows for the @ picker above the input) but applies to the editor block itself — both are pinned, neither scrolls. Tests: - setEditor adjusts scroll region when height changes - Clamping prevents invalid scroll regions on tiny terminals - resize() and teardown() update region correctly - All existing status-bar and editor tests remain green
…r/input bugs it caught Adds VirtualScreen, a headless VT100 emulator that applies the real emitted byte stream onto a cell grid, plus an e2e harness wiring the real controller → real StatusBar → FakeTerm → VirtualScreen and driving real keystrokes. Tests now assert the *rendered screen* (the screenshot equivalent), not escape-string substrings — which is why the prior unit tests passed while the screen showed ghosts. The harness caught three real bugs, all fixed here: - Ghost rows: buildEditorFrame top-anchored the block, so on shrink the start moved down and the old top rows were never cleared. Bottom-anchor the block (mirror buildOverlayFrame); clearRows=prevHeight now covers the prior frame. - Non-ASCII input dropped: keys.ts rejected any unit >= 0x7f, killing emoji, accented Latin, and CJK. Use codePointAt and reject only C0/DEL/C1 controls. - Cursor line clipped on tall input: controller passed maxRows=rows to renderEditor but setEditor clamps to rows-3, slicing off the cursor line. Window to the real editor capacity instead. 30 new tests (9 emulator fidelity + 21 e2e). status-bar shrink test updated to assert the correct bottom-anchored render.
Added: in-process terminal e2e testing — and it caught 3 real bugsThe ghost rows persisted after the prior DECSTBM fix. Rather than guess a third time, I built the missing test layer: a headless VT100 emulator ( It reproduced the ghost bug deterministically on the first run, and surfaced two more real bugs:
Tests: 30 new (9 emulator-fidelity + 21 e2e). Editor/render/keys suites: 126 pass, 0 fail. typecheck + strict lint + format clean. |
… shrink, wrapped cursor 8 more VirtualScreen e2e tests covering vertical cursor movement, ctrl+w/ctrl+k/ ctrl+y kill-yank, undo/redo, scroll-indicator overflow on a small terminal, terminal shrink mid-edit, and wrapped-line cursor positioning. All green — no new bugs; locks in the editor's render correctness across realistic interactions.
The render/CLI manifest entry still described the readline REPL; PR #52 replaced it with the multi-line editor (src/editor/*), which had no contract. Refresh render/CLI invariants (bottom-anchored block, resize updates editor dims, use the VirtualScreen grid harness) and add a dedicated editor subsystem entry (grapheme cursor, paste-never-submits, non-ASCII accepted, window-to-visible-capacity).
* fix(editor): re-window on terminal resize (P2 from harness-review) The editor captured columns/rows once at startEditor and IEditorHandle had no resize hook, so the CLI's resize handler only re-pinned the status bar — the editor kept wrapping at the old width and windowing at the old height. After a shrink, renderEditor produced a frame sized for the pre-resize terminal and setEditor (live rows) clipped it, dropping the current line off-screen. Reproduced via VirtualScreen: type 12 lines on a 24-row terminal, shrink to 10 → the current line vanished. Fix: IEditorHandle.resize(columns, rows) updates the (now-mutable) dims and repaints; cli.ts calls it from the resize handler via a resizeEditor callback (editorHandle lives in a nested scope). Ignores non-positive dims. 2 regression tests in editor-e2e.test.ts (shrink keeps the line visible; 0×0 is a no-op). bun run validate green (1781 pass). * docs(harness): refresh render/CLI contract + add editor subsystem entry The render/CLI manifest entry still described the readline REPL; PR #52 replaced it with the multi-line editor (src/editor/*), which had no contract. Refresh render/CLI invariants (bottom-anchored block, resize updates editor dims, use the VirtualScreen grid harness) and add a dedicated editor subsystem entry (grapheme cursor, paste-never-submits, non-ASCII accepted, window-to-visible-capacity). * refactor(cli/editor): address PR #54 review — named resize handler + dims-changed guard - cli.ts: extract the resize listener to a named handleResize and detach it on loop exit (process.stdout.off) so an anonymous listener doesn't pin the REPL closure for the process lifetime (Gemini). Dropped the suggested ?? 0 — columns /rows are typed number here, so it's an unnecessary-condition lint error. - controller.ts: resize only repaints when the dimensions actually changed, so duplicate/high-frequency resize events don't cause no-op repaints/flicker (Gemini).
What
Replaces Node
readlinefor the interactive prompt with a purpose-built, grapheme-aware multi-line editor. Fixes the original bug (a multi-line paste became N messages/steers) and makes the input a real editor.\+Enter insert a newline.[paste #N +M lines]and expand on send./palette +@file picker operate on the editor buffer.TSFORGE_BASIC_INPUT=1falls back to the old single-line readline path.How it's built
Pure, unit-tested modules (
editor/):segments,buffer(text model + kill-ring + undo + paste markers),keys(decoder),paste(bracketed-paste scanner),view(multi-line renderer) — driven bycontroller(sole stdin owner in editor mode) and wired intocli.ts. Best-of pi + hermes + our painted input row.Process & quality
Subagent-driven: 11 TDD tasks, each task-reviewed + fixed, plus a final whole-branch review — READY TO MERGE, zero findings. Stdin-ownership invariant, module composition, and the unchanged non-TTY/fallback path all verified.
bun run validategreen: 1672 pass, 0 fail. 53 editor unit tests + cli wiring.Spec:
docs/superpowers/specs/2026-06-26-multiline-editor-design.md· Plan:docs/superpowers/plans/2026-06-26-multiline-editor.md.Live verification needed before merge (no PTY in CI)
\+Enter → newline.[paste #N +M lines], log shows expanded text./palette +@picker insert into the buffer.TSFORGE_BASIC_INPUT=1falls back to single-line;echo x | tsforgeunaffected.🤖 Generated with Claude Code