Skip to content

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

Open
agjs wants to merge 18 commits into
mainfrom
feat/multiline-editor
Open

feat: best-in-class multi-line input editor (replaces readline)#52
agjs wants to merge 18 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 on lines +71 to +73
if (i > 0) {
this.newline();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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.

Suggested change
if (i > 0) {
this.newline();
}
if (i > 0) {
this.newline(false);
}

Comment on lines +107 to +112
newline(): void {
this.clearSticky();

this.maybeSnapshot("other");
const g = this.curG();
const left = g.slice(0, this.cursorCol).join("");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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.

Suggested change
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");
}

Comment on lines +46 to +48
private clearSticky(): void {
this.stickyCol = null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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.

Suggested change
private clearSticky(): void {
this.stickyCol = null;
}
private clearSticky(): void {
this.stickyCol = null;
this.lastYank = null;
}

Comment on lines +380 to +385
yankPop(): void {
this.clearSticky();

if (this.lastYank === null) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Since clearSticky() now invalidates this.lastYank, yankPop should clear stickyCol directly instead of calling clearSticky() to avoid immediately returning on the lastYank === null check.

Suggested change
yankPop(): void {
this.clearSticky();
if (this.lastYank === null) {
return;
}
yankPop(): void {
this.stickyCol = null;
if (this.lastYank === null) {
return;
}

Comment on lines +138 to +139
let historyIndex = -1; // -1 = not in history, >= 0 = viewing history item
let dataListener: ((chunk: string) => void) | null = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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.

Suggested change
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;

Comment on lines +200 to +224
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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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();
  }

Comment on lines +1 to +4
import { EditorBuffer } from "./buffer";
import { decodeKeys } from "./keys";
import { createPasteScanner } from "./paste";
import { renderEditor } from "./view";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Import graphemes from ./segments to enable grapheme-correct string slicing.

Suggested change
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";

Comment on lines +233 to +239
if (col > 0) {
const beforeCursor = currentLine.substring(0, col);

if (beforeCursor.endsWith("\\")) {
hasTrailingBackslash = true;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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.

Suggested change
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;
}
}

Comment on lines +41 to +92
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Correct the copy-paste brand reference from 'The Dreamdata Platform's CLI' to 'The tsforge CLI'.

The tsforge CLI uses an interactive multi-line input editor for the REPL session. It supports full keyboard navigation, history recall, word deletion, and multi-line paste.

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