Skip to content

feat: best-in-class multi-line input editor (replaces readline)#52

Merged
agjs merged 24 commits into
mainfrom
feat/multiline-editor
Jun 27, 2026
Merged

feat: best-in-class multi-line input editor (replaces readline)#52
agjs merged 24 commits into
mainfrom
feat/multiline-editor

Conversation

@agjs

@agjs agjs commented Jun 27, 2026

Copy link
Copy Markdown
Owner

What

Replaces Node readline for 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 submits; Shift+Enter / Alt+Enter / trailing \+Enter insert a newline.
  • Paste lands in the buffer and never auto-submits; huge pastes show [paste #N +M lines] and expand on send.
  • Grapheme-correct editing; word/line/document motion; kill-ring + yank; coalesced undo/redo; in-session history (↑/↓ at buffer edges); / palette + @ file picker operate on the editor buffer.
  • Bracketed paste + Kitty keyboard + xterm modifyOtherKeys (env-gated for Windows/WSL/SSH) make Shift+Enter reliable.
  • TSFORGE_BASIC_INPUT=1 falls 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 by controller (sole stdin owner in editor mode) and wired into cli.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 validate green: 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)

  1. Enter submits; Shift+Enter / Alt+Enter / trailing \+Enter → newline.
  2. Multi-line paste lands in buffer (not N messages), sends as one on Enter; huge paste → [paste #N +M lines], log shows expanded text.
  3. ↑/↓ recall history at buffer edges.
  4. Ctrl+W / Ctrl+Y / Ctrl+U editing; / palette + @ picker insert into the buffer.
  5. Ctrl-C aborts a run / quits at idle; Ctrl-D on empty quits; terminal restored after exit.
  6. Resize re-wraps cleanly.
  7. TSFORGE_BASIC_INPUT=1 falls back to single-line; echo x | tsforge unaffected.

🤖 Generated with Claude Code

agjs added 18 commits June 26, 2026 23:20
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.
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)
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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/editor/buffer.ts
Comment thread packages/core/src/editor/buffer.ts Outdated
Comment thread packages/core/src/editor/buffer.ts
Comment thread packages/core/src/editor/buffer.ts
Comment thread packages/core/src/editor/controller.ts
Comment thread packages/core/src/editor/controller.ts
Comment thread packages/core/src/editor/controller.ts
Comment thread packages/core/src/editor/controller.ts
Comment thread packages/core/src/editor/view.ts
Comment thread apps/docs/src/content/docs/reference/input-editor.mdx Outdated
- 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.
@agjs

agjs commented Jun 27, 2026

Copy link
Copy Markdown
Owner Author

All 12 review findings fixed in ce3b85f (validate green, 1678 pass), pushed. Summary: buffer.ts — multiline insert is now ONE undo unit (newline(snapshot=false) for internal newlines); yankPop made safe (clearSticky invalidates lastYank, yankPop clears stickyCol directly). controller.ts — history uses a separate draftText (no more polluting the shared history array); trailing-\ detection is grapheme-correct (graphemes slice, not UTF-16 substring). view.ts — wrap + cursor use grapheme width, not .length (emoji/combining chars). Plus the docs fix. Added regression tests: one-undo multiline paste, yankPop-after-move safety, submit-while-browsing-history isolation, emoji trailing-\ + emoji wrap. Thanks — these were real bugs my own review missed.

agjs added 4 commits June 27, 2026 09:39
… 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.
@agjs

agjs commented Jun 27, 2026

Copy link
Copy Markdown
Owner Author

Added: in-process terminal e2e testing — and it caught 3 real bugs

The ghost rows persisted after the prior DECSTBM fix. Rather than guess a third time, I built the missing test layer: a headless VT100 emulator (VirtualScreen) 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 (screenshot-equivalent), not escape-string substrings — which is exactly why the old unit tests passed while the screen showed ghosts.

It reproduced the ghost bug deterministically on the first run, and surfaced two more real bugs:

Bug Root cause Fix
Ghost rows buildEditorFrame top-anchored the block; on shrink the start moved down and old top rows were never cleared Bottom-anchor the block (mirror buildOverlayFrame); clearRows=prevHeight now covers the prior frame
All non-ASCII input dropped (emoji, é, ñ, CJK) keys.ts rejected any code unit >= 0x7f codePointAt + reject only C0/DEL/C1 control ranges
Cursor line clipped on tall input controller passed maxRows: rows but setEditor clamps to rows-3, slicing off the cursor line controller windows to the real editor capacity

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.
@agjs agjs merged commit eeed281 into main Jun 27, 2026
8 checks passed
@agjs agjs deleted the feat/multiline-editor branch June 27, 2026 10:18
agjs added a commit that referenced this pull request Jun 27, 2026
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).
agjs added a commit that referenced this pull request Jun 27, 2026
* 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant