feat: best-in-class multi-line input editor (replaces readline)#52
feat: best-in-class multi-line input editor (replaces readline)#52agjs wants to merge 18 commits into
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.
| if (i > 0) { | ||
| this.newline(); | ||
| } |
There was a problem hiding this comment.
When inserting multiline text, calling this.newline() directly triggers maybeSnapshot('other') on each newline. This creates multiple undo snapshots for a single multiline insert or paste operation, forcing the user to press undo multiple times to revert a single paste. Passing false to newline prevents intermediate snapshots.
| if (i > 0) { | |
| this.newline(); | |
| } | |
| if (i > 0) { | |
| this.newline(false); | |
| } |
| newline(): void { | ||
| this.clearSticky(); | ||
|
|
||
| this.maybeSnapshot("other"); | ||
| const g = this.curG(); | ||
| const left = g.slice(0, this.cursorCol).join(""); |
There was a problem hiding this comment.
Update the newline method signature to accept an optional snapshot parameter. This allows internal operations like insertRaw to perform newlines without creating intermediate undo snapshots, preserving the atomicity of multiline inserts.
| newline(): void { | |
| this.clearSticky(); | |
| this.maybeSnapshot("other"); | |
| const g = this.curG(); | |
| const left = g.slice(0, this.cursorCol).join(""); | |
| newline(snapshot = true): void { | |
| this.clearSticky(); | |
| if (snapshot) { | |
| this.maybeSnapshot("other"); | |
| } |
| private clearSticky(): void { | ||
| this.stickyCol = null; | ||
| } |
There was a problem hiding this comment.
To prevent buffer corruption, this.lastYank must be cleared whenever any other motion or editing command is executed. Otherwise, performing a yankPop after moving the cursor or editing another line will execute the splice at the old column index on the new line.
| private clearSticky(): void { | |
| this.stickyCol = null; | |
| } | |
| private clearSticky(): void { | |
| this.stickyCol = null; | |
| this.lastYank = null; | |
| } |
| yankPop(): void { | ||
| this.clearSticky(); | ||
|
|
||
| if (this.lastYank === null) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Since clearSticky() now invalidates this.lastYank, yankPop should clear stickyCol directly instead of calling clearSticky() to avoid immediately returning on the lastYank === null check.
| yankPop(): void { | |
| this.clearSticky(); | |
| if (this.lastYank === null) { | |
| return; | |
| } | |
| yankPop(): void { | |
| this.stickyCol = null; | |
| if (this.lastYank === null) { | |
| return; | |
| } |
| let historyIndex = -1; // -1 = not in history, >= 0 = viewing history item | ||
| let dataListener: ((chunk: string) => void) | null = null; |
There was a problem hiding this comment.
Declare a separate draftText variable in the closure to store the active draft. Pushing the draft directly to the shared history array pollutes the submitted message history if the user submits a message while browsing history.
| let historyIndex = -1; // -1 = not in history, >= 0 = viewing history item | |
| let dataListener: ((chunk: string) => void) | null = null; | |
| let historyIndex = -1; // -1 = not in history, >= 0 = viewing history item | |
| let draftText: string | null = null; | |
| let dataListener: ((chunk: string) => void) | null = null; |
| 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(); | ||
| } |
There was a problem hiding this comment.
Refactor navigateHistoryDown to restore the draft from draftText and clear it, avoiding the need to pop from the shared history array.
function navigateHistoryDown(): void {
if (historyIndex === -1) {
return; // Not in history
}
if (historyIndex === history.length - 1) {
// Restore draft
buffer.setText(draftText ?? "");
draftText = null;
historyIndex = -1;
} else {
historyIndex += 1;
buffer.setText(history[historyIndex] ?? "");
}
repaint();
notifyChange();
}| import { EditorBuffer } from "./buffer"; | ||
| import { decodeKeys } from "./keys"; | ||
| import { createPasteScanner } from "./paste"; | ||
| import { renderEditor } from "./view"; |
There was a problem hiding this comment.
Import graphemes from ./segments to enable grapheme-correct string slicing.
| import { EditorBuffer } from "./buffer"; | |
| import { decodeKeys } from "./keys"; | |
| import { createPasteScanner } from "./paste"; | |
| import { renderEditor } from "./view"; | |
| import { EditorBuffer } from "./buffer"; | |
| import { decodeKeys } from "./keys"; | |
| import { createPasteScanner } from "./paste"; | |
| import { renderEditor } from "./view"; | |
| import { graphemes } from "./segments"; |
| if (col > 0) { | ||
| const beforeCursor = currentLine.substring(0, col); | ||
|
|
||
| if (beforeCursor.endsWith("\\")) { | ||
| hasTrailingBackslash = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
Using currentLine.substring(0, col) is incorrect because col is a grapheme offset, whereas substring expects UTF-16 code units. If there are any multi-unit graphemes (like emojis) before the cursor, the substring will be cut too early and fail to detect a trailing backslash. Slice the graphemes array instead.
| if (col > 0) { | |
| const beforeCursor = currentLine.substring(0, col); | |
| if (beforeCursor.endsWith("\\")) { | |
| hasTrailingBackslash = true; | |
| } | |
| } | |
| if (col > 0) { | |
| const lineGraphemes = graphemes(currentLine); | |
| const beforeCursor = lineGraphemes.slice(0, col).join(""); | |
| if (beforeCursor.endsWith("\\")) { | |
| hasTrailingBackslash = true; | |
| } | |
| } |
| 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; | ||
| } |
There was a problem hiding this comment.
Using row.length to wrap lines and set rowCursorCol is incorrect because it counts UTF-16 code units instead of visual columns (graphemes). Emojis and combining characters will cause premature wrapping and incorrect cursor positioning. Use a grapheme counter instead.
function wrapLine(
line: string,
cursorCol: number,
columns: number
): IWrappedRow[] {
if (columns <= 0) {
return [];
}
const graphemeList = graphemes(line);
const rows: IWrappedRow[] = [];
let row = "";
let rowGraphemeCount = 0;
let rowCursorCol: number | undefined;
let visualRow = 0;
for (let i = 0; i < graphemeList.length; i += 1) {
const g = graphemeList[i];
if (rowGraphemeCount >= columns) {
rows.push({
text: row,
cursorRow: rowCursorCol !== undefined ? visualRow : undefined,
cursorCol: rowCursorCol,
});
row = "";
rowGraphemeCount = 0;
rowCursorCol = undefined;
visualRow += 1;
}
if (i === cursorCol) {
rowCursorCol = rowGraphemeCount;
}
if (g !== undefined) {
row += g;
rowGraphemeCount += 1;
}
}
// Handle cursor at end of line
if (graphemeList.length === cursorCol) {
rowCursorCol = rowGraphemeCount;
}
// Push final row
rows.push({
text: row,
cursorRow: rowCursorCol !== undefined ? visualRow : undefined,
cursorCol: rowCursorCol,
});
return rows;
}| 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. |
There was a problem hiding this comment.
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