diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 996e3335..9f626b4c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6286,23 +6286,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/codex-mini": { - id: "openai/codex-mini", - name: "OpenAI: Codex Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.5, - output: 6, - cacheRead: 0.375, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f847a342..5722c889 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Extensions using `Editor` directly must now pass `TUI` as the first constructor argument: `new Editor(tui, theme)`. The `tui` parameter is available in extension factory functions. ([#732](https://github.com/badlogic/pi-mono/issues/732)) + ### Added - New `input` event in extension system for intercepting, transforming, or handling user input before the agent processes it. Supports three result types: `continue` (pass through), `transform` (modify text/images), `handled` (respond without LLM). Handlers chain transforms and short-circuit on handled. ([#761](https://github.com/badlogic/pi-mono/pull/761) by [@nicobailon](https://github.com/nicobailon)) @@ -13,6 +17,7 @@ ### Fixed +- Editor no longer corrupts terminal display when loading large prompts via `setEditorText`. Content now scrolls vertically with indicators showing lines above/below the viewport. ([#732](https://github.com/badlogic/pi-mono/issues/732)) - Piped stdin now works correctly: `echo foo | pi` is equivalent to `pi -p foo`. When stdin is piped, print mode is automatically enabled since interactive mode requires a TTY ([#708](https://github.com/badlogic/pi-mono/issues/708)) - Session tree now preserves branch connectors and indentation when filters hide intermediate entries so descendants attach to the nearest visible ancestor and sibling branches align. Fixed in both TUI and HTML export ([#739](https://github.com/badlogic/pi-mono/pull/739) by [@w-winter](https://github.com/w-winter)) - Added `upstream connect`, `connection refused`, and `reset before headers` patterns to auto-retry error detection ([#733](https://github.com/badlogic/pi-mono/issues/733)) diff --git a/packages/coding-agent/examples/extensions/load-file.ts b/packages/coding-agent/examples/extensions/load-file.ts new file mode 100644 index 00000000..80dcfc23 --- /dev/null +++ b/packages/coding-agent/examples/extensions/load-file.ts @@ -0,0 +1,35 @@ +/** + * Load file into editor - for testing editor scrolling + * + * Usage: pi --extension ./examples/extensions/load-file.ts + * + * Commands: + * /load [path] - Load file into editor (defaults to README.md) + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("load", { + description: "Load file into editor (defaults to README.md)", + handler: async (args, ctx) => { + const filePath = args.trim() || "README.md"; + const fullPath = path.resolve(filePath); + + if (!fs.existsSync(fullPath)) { + ctx.ui.notify(`File not found: ${fullPath}`, "error"); + return; + } + + try { + const content = fs.readFileSync(fullPath, "utf-8"); + ctx.ui.setEditorText(content); + ctx.ui.notify(`Loaded ${filePath} (${content.split("\n").length} lines)`); + } catch (err) { + ctx.ui.notify(`Failed to read file: ${err}`, "error"); + } + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/modal-editor.ts b/packages/coding-agent/examples/extensions/modal-editor.ts index ad060269..c1b9d73f 100644 --- a/packages/coding-agent/examples/extensions/modal-editor.ts +++ b/packages/coding-agent/examples/extensions/modal-editor.ts @@ -80,6 +80,6 @@ class ModalEditor extends CustomEditor { export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { - ctx.ui.setEditorComponent((_tui, theme, kb) => new ModalEditor(theme, kb)); + ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb)); }); } diff --git a/packages/coding-agent/examples/extensions/question.ts b/packages/coding-agent/examples/extensions/question.ts index 517329d4..6a59f165 100644 --- a/packages/coding-agent/examples/extensions/question.ts +++ b/packages/coding-agent/examples/extensions/question.ts @@ -90,7 +90,7 @@ export default function question(pi: ExtensionAPI) { noMatch: (t) => theme.fg("warning", t), }, }; - const editor = new Editor(editorTheme); + const editor = new Editor(tui, editorTheme); editor.onSubmit = (value) => { const trimmed = value.trim(); diff --git a/packages/coding-agent/examples/extensions/questionnaire.ts b/packages/coding-agent/examples/extensions/questionnaire.ts index af8b7758..4bb92af8 100644 --- a/packages/coding-agent/examples/extensions/questionnaire.ts +++ b/packages/coding-agent/examples/extensions/questionnaire.ts @@ -119,7 +119,7 @@ export default function questionnaire(pi: ExtensionAPI) { noMatch: (t) => theme.fg("warning", t), }, }; - const editor = new Editor(editorTheme); + const editor = new Editor(tui, editorTheme); // Helpers function refresh() { diff --git a/packages/coding-agent/examples/extensions/rainbow-editor.ts b/packages/coding-agent/examples/extensions/rainbow-editor.ts index 060a393c..f54c9888 100644 --- a/packages/coding-agent/examples/extensions/rainbow-editor.ts +++ b/packages/coding-agent/examples/extensions/rainbow-editor.ts @@ -4,8 +4,7 @@ * Usage: pi --extension ./examples/extensions/rainbow-editor.ts */ -import { CustomEditor, type ExtensionAPI, type KeybindingsManager } from "@mariozechner/pi-coding-agent"; -import type { EditorTheme, TUI } from "@mariozechner/pi-tui"; +import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; // Base colors (coral → yellow → green → teal → blue → purple → pink) const COLORS: [number, number, number][] = [ @@ -44,14 +43,8 @@ function colorize(text: string, shinePos: number): string { class RainbowEditor extends CustomEditor { private animationTimer?: ReturnType; - private tui: TUI; private frame = 0; - constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) { - super(theme, keybindings); - this.tui = tui; - } - private hasUltrathink(): boolean { return /ultrathink/i.test(this.getText()); } diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 6bf08dec..6587a632 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,4 +1,4 @@ -import { Editor, type EditorTheme } from "@mariozechner/pi-tui"; +import { Editor, type EditorTheme, type TUI } from "@mariozechner/pi-tui"; import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js"; /** @@ -15,8 +15,8 @@ export class CustomEditor extends Editor { /** Handler for extension-registered shortcuts. Returns true if handled. */ public onExtensionShortcut?: (data: string) => boolean; - constructor(theme: EditorTheme, keybindings: KeybindingsManager) { - super(theme); + constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) { + super(tui, theme); this.keybindings = keybindings; } diff --git a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts index b4f737bb..b8c70b6a 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts @@ -44,7 +44,7 @@ export class ExtensionEditorComponent extends Container { this.addChild(new Spacer(1)); // Create editor - this.editor = new Editor(getEditorTheme()); + this.editor = new Editor(tui, getEditorTheme()); if (prefill) { this.editor.setText(prefill); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 6e18fe21..268bfccb 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -240,7 +240,7 @@ export class InteractiveMode { this.statusContainer = new Container(); this.widgetContainer = new Container(); this.keybindings = KeybindingsManager.create(); - this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings); + this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings); this.editor = this.defaultEditor; this.editorContainer = new Container(); this.editorContainer.addChild(this.editor as Component); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1e1430d0..9d1f183d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### Breaking Changes + +- `Editor` constructor now requires `TUI` as first parameter: `new Editor(tui, theme)`. This enables automatic vertical scrolling when content exceeds terminal height. ([#732](https://github.com/badlogic/pi-mono/issues/732)) + +### Added + +- Editor now supports Page Up/Down keys (Fn+Up/Down on MacBook) for scrolling through large content ([#732](https://github.com/badlogic/pi-mono/issues/732)) + +### Fixed + +- Editor no longer corrupts terminal display when text exceeds screen height. Content now scrolls vertically with indicators showing lines above/below the viewport. Max height is 30% of terminal (minimum 5 lines). ([#732](https://github.com/badlogic/pi-mono/issues/732)) + ## [0.46.0] - 2026-01-15 ### Fixed diff --git a/packages/tui/README.md b/packages/tui/README.md index d1633eee..1bc9df46 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -27,7 +27,7 @@ const tui = new TUI(terminal); // Add components tui.addChild(new Text("Welcome to my app!")); -const editor = new Editor(editorTheme); +const editor = new Editor(tui, editorTheme); editor.onSubmit = (text) => { console.log("Submitted:", text); tui.addChild(new Text(`You said: ${text}`)); @@ -212,7 +212,7 @@ input.getValue(); ### Editor -Multi-line text editor with autocomplete, file completion, and paste handling. +Multi-line text editor with autocomplete, file completion, paste handling, and vertical scrolling when content exceeds terminal height. ```typescript interface EditorTheme { @@ -220,7 +220,7 @@ interface EditorTheme { selectList: SelectListTheme; } -const editor = new Editor(theme); +const editor = new Editor(tui, theme); // tui is required for height-aware scrolling editor.onSubmit = (text) => console.log(text); editor.onChange = (text) => console.log("Changed:", text); editor.disableSubmit = true; // Disable submit temporarily diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index f19b7288..3182e04c 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,7 +1,7 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import { getEditorKeybindings } from "../keybindings.js"; import { matchesKey } from "../keys.js"; -import type { Component } from "../tui.js"; +import type { Component, TUI } from "../tui.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; @@ -211,11 +211,15 @@ export class Editor implements Component { cursorCol: 0, }; + protected tui: TUI; private theme: EditorTheme; // Store last render width for cursor navigation private lastWidth: number = 80; + // Vertical scrolling support + private scrollOffset: number = 0; + // Border color (can be changed dynamically) public borderColor: (str: string) => string; @@ -242,7 +246,8 @@ export class Editor implements Component { public onChange?: (text: string) => void; public disableSubmit: boolean = false; - constructor(theme: EditorTheme) { + constructor(tui: TUI, theme: EditorTheme) { + this.tui = tui; this.theme = theme; this.borderColor = theme.borderColor; } @@ -305,6 +310,8 @@ export class Editor implements Component { this.state.lines = lines.length === 0 ? [""] : lines; this.state.cursorLine = this.state.lines.length - 1; this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; + // Reset scroll - render() will adjust to show cursor + this.scrollOffset = 0; if (this.onChange) { this.onChange(this.getText()); @@ -324,13 +331,41 @@ export class Editor implements Component { // Layout the text - use full width const layoutLines = this.layoutText(width); + // Calculate max visible lines: 30% of terminal height, minimum 5 lines + const terminalRows = this.tui.terminal.rows; + const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3)); + + // Find the cursor line index in layoutLines + let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor); + if (cursorLineIndex === -1) cursorLineIndex = 0; + + // Adjust scroll offset to keep cursor visible + if (cursorLineIndex < this.scrollOffset) { + this.scrollOffset = cursorLineIndex; + } else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) { + this.scrollOffset = cursorLineIndex - maxVisibleLines + 1; + } + + // Clamp scroll offset to valid range + const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines); + this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset)); + + // Get visible lines slice + const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines); + const result: string[] = []; - // Render top border - result.push(horizontal.repeat(width)); + // Render top border (with scroll indicator if scrolled down) + if (this.scrollOffset > 0) { + const indicator = `─── ↑ ${this.scrollOffset} more `; + const remaining = width - visibleWidth(indicator); + result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); + } else { + result.push(horizontal.repeat(width)); + } - // Render each layout line - for (const layoutLine of layoutLines) { + // Render each visible layout line + for (const layoutLine of visibleLines) { let displayText = layoutLine.text; let lineVisibleWidth = visibleWidth(layoutLine.text); @@ -382,8 +417,15 @@ export class Editor implements Component { result.push(displayText + padding); } - // Render bottom border - result.push(horizontal.repeat(width)); + // Render bottom border (with scroll indicator if more content below) + const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); + if (linesBelow > 0) { + const indicator = `─── ↓ ${linesBelow} more `; + const remaining = width - visibleWidth(indicator); + result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); + } else { + result.push(horizontal.repeat(width)); + } // Add autocomplete list if active if (this.isAutocompleting && this.autocompleteList) { @@ -574,6 +616,7 @@ export class Editor implements Component { this.pastes.clear(); this.pasteCounter = 0; this.historyIndex = -1; + this.scrollOffset = 0; if (this.onChange) this.onChange(""); if (this.onSubmit) this.onSubmit(result); @@ -608,6 +651,16 @@ export class Editor implements Component { return; } + // Page up/down - scroll by page and move cursor + if (kb.matches(data, "pageUp")) { + this.pageScroll(-1); + return; + } + if (kb.matches(data, "pageDown")) { + this.pageScroll(1); + return; + } + // Shift+Space - insert regular space if (matchesKey(data, "shift+space")) { this.insertCharacter(" "); @@ -1215,6 +1268,36 @@ export class Editor implements Component { } } + /** + * Scroll by a page (direction: -1 for up, 1 for down). + * Moves cursor by the page size while keeping it in bounds. + */ + private pageScroll(direction: -1 | 1): void { + const width = this.lastWidth; + const terminalRows = this.tui.terminal.rows; + const pageSize = Math.max(5, Math.floor(terminalRows * 0.3)); + + // Build visual line map + const visualLines = this.buildVisualLineMap(width); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + + // Calculate target visual line + const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize)); + + // Move cursor to target visual line + const targetVL = visualLines[targetVisualLine]; + if (targetVL) { + // Preserve column position within the line + const currentVL = visualLines[currentVisualLine]; + const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0; + + this.state.cursorLine = targetVL.logicalLine; + const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length); + const logicalLine = this.state.lines[targetVL.logicalLine] || ""; + this.state.cursorCol = Math.min(targetCol, logicalLine.length); + } + } + private moveWordBackwards(): void { const currentLine = this.state.lines[this.state.cursorLine] || ""; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 9e440090..e83e0a5d 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -13,6 +13,8 @@ export type EditorAction = | "cursorWordRight" | "cursorLineStart" | "cursorLineEnd" + | "pageUp" + | "pageDown" // Deletion | "deleteCharBackward" | "deleteCharForward" @@ -58,6 +60,8 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required = { cursorWordRight: ["alt+right", "ctrl+right"], cursorLineStart: ["home", "ctrl+a"], cursorLineEnd: ["end", "ctrl+e"], + pageUp: "pageUp", + pageDown: "pageDown", // Deletion deleteCharBackward: "backspace", deleteCharForward: "delete", diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index af7653c6..f997ffb9 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -835,7 +835,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier); - case "pageUp": + case "pageup": if (modifier === 0) { return ( matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) || @@ -847,7 +847,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier); - case "pageDown": + case "pagedown": if (modifier === 0) { return ( matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) || diff --git a/packages/tui/test/chat-simple.ts b/packages/tui/test/chat-simple.ts index 5f5fd5cf..836d1f53 100644 --- a/packages/tui/test/chat-simple.ts +++ b/packages/tui/test/chat-simple.ts @@ -24,7 +24,7 @@ tui.addChild( ); // Create editor with autocomplete -const editor = new Editor(defaultEditorTheme); +const editor = new Editor(tui, defaultEditorTheme); // Set up autocomplete provider with slash commands and file completion const autocompleteProvider = new CombinedAutocompleteProvider( diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 3cbcbc00..6db84bff 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2,13 +2,20 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { Editor } from "../src/components/editor.js"; +import { TUI } from "../src/tui.js"; import { visibleWidth } from "../src/utils.js"; import { defaultEditorTheme } from "./test-themes.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; + +/** Create a TUI with a virtual terminal for testing */ +function createTestTUI(cols = 80, rows = 24): TUI { + return new TUI(new VirtualTerminal(cols, rows)); +} describe("Editor component", () => { describe("Prompt history navigation", () => { it("does nothing on Up arrow when history is empty", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("\x1b[A"); // Up arrow @@ -16,7 +23,7 @@ describe("Editor component", () => { }); it("shows most recent history entry on Up arrow when editor is empty", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("first prompt"); editor.addToHistory("second prompt"); @@ -27,7 +34,7 @@ describe("Editor component", () => { }); it("cycles through history entries on repeated Up arrow", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); @@ -47,7 +54,7 @@ describe("Editor component", () => { }); it("returns to empty editor on Down arrow after browsing history", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("prompt"); @@ -59,7 +66,7 @@ describe("Editor component", () => { }); it("navigates forward through history with Down arrow", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); @@ -82,7 +89,7 @@ describe("Editor component", () => { }); it("exits history mode when typing a character", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("old prompt"); @@ -93,7 +100,7 @@ describe("Editor component", () => { }); it("exits history mode on setText", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); @@ -107,7 +114,7 @@ describe("Editor component", () => { }); it("does not add empty strings to history", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory(""); editor.addToHistory(" "); @@ -122,7 +129,7 @@ describe("Editor component", () => { }); it("does not add consecutive duplicates to history", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("same"); editor.addToHistory("same"); @@ -136,7 +143,7 @@ describe("Editor component", () => { }); it("allows non-consecutive duplicates in history", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); @@ -153,7 +160,7 @@ describe("Editor component", () => { }); it("uses cursor movement instead of history when editor has content", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("history item"); editor.setText("line1\nline2"); @@ -169,7 +176,7 @@ describe("Editor component", () => { }); it("limits history to 100 entries", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); // Add 105 entries for (let i = 0; i < 105; i++) { @@ -190,7 +197,7 @@ describe("Editor component", () => { }); it("allows cursor movement within multi-line history entry with Down", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("line1\nline2\nline3"); @@ -204,7 +211,7 @@ describe("Editor component", () => { }); it("allows cursor movement within multi-line history entry with Up", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("older entry"); editor.addToHistory("line1\nline2\nline3"); @@ -225,7 +232,7 @@ describe("Editor component", () => { }); it("navigates from multi-line entry back to newer via Down after cursor movement", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("line1\nline2\nline3"); @@ -249,7 +256,7 @@ describe("Editor component", () => { describe("public state accessors", () => { it("returns cursor position", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); @@ -264,7 +271,7 @@ describe("Editor component", () => { }); it("returns lines as a defensive copy", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.setText("a\nb"); const lines = editor.getLines(); @@ -277,7 +284,7 @@ describe("Editor component", () => { describe("Shift+Enter handling", () => { it("treats split VS Code Shift+Enter as a newline", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("\\"); editor.handleInput("\r"); @@ -286,7 +293,7 @@ describe("Editor component", () => { }); it("inserts a literal backslash when not followed by Enter", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("\\"); editor.handleInput("x"); @@ -297,7 +304,7 @@ describe("Editor component", () => { describe("Unicode text editing behavior", () => { it("inserts mixed ASCII, umlauts, and emojis as literal text", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("H"); editor.handleInput("e"); @@ -316,7 +323,7 @@ describe("Editor component", () => { }); it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("ä"); editor.handleInput("ö"); @@ -330,7 +337,7 @@ describe("Editor component", () => { }); it("deletes multi-code-unit emojis with single Backspace", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("😀"); editor.handleInput("👍"); @@ -343,7 +350,7 @@ describe("Editor component", () => { }); it("inserts characters at the correct position after cursor movement over umlauts", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("ä"); editor.handleInput("ö"); @@ -361,7 +368,7 @@ describe("Editor component", () => { }); it("moves cursor across multi-code-unit emojis with single arrow key", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("😀"); editor.handleInput("👍"); @@ -381,7 +388,7 @@ describe("Editor component", () => { }); it("preserves umlauts across line breaks", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("ä"); editor.handleInput("ö"); @@ -396,7 +403,7 @@ describe("Editor component", () => { }); it("replaces the entire document with unicode text via setText (paste simulation)", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); // Simulate bracketed paste / programmatic replacement editor.setText("Hällö Wörld! 😀 äöüÄÖÜß"); @@ -406,7 +413,7 @@ describe("Editor component", () => { }); it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.handleInput("a"); editor.handleInput("b"); @@ -418,7 +425,7 @@ describe("Editor component", () => { }); it("deletes words correctly with Ctrl+W and Alt+Backspace", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); // Basic word deletion editor.setText("foo bar baz"); @@ -459,7 +466,7 @@ describe("Editor component", () => { }); it("navigates words correctly with Ctrl+Left/Right", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.setText("foo bar... baz"); // Cursor at end @@ -498,7 +505,7 @@ describe("Editor component", () => { describe("Grapheme-aware text wrapping", () => { it("wraps lines correctly when text contains wide emojis", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 20; // ✅ is 2 columns wide, so "Hello ✅ World" is 14 columns @@ -513,7 +520,7 @@ describe("Editor component", () => { }); it("wraps long text with emojis at correct positions", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 10; // Each ✅ is 2 columns. "✅✅✅✅✅" = 10 columns, fits exactly @@ -530,7 +537,7 @@ describe("Editor component", () => { }); it("wraps CJK characters correctly (each is 2 columns wide)", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 10; // Each CJK char is 2 columns. "日本語テスト" = 6 chars = 12 columns @@ -550,7 +557,7 @@ describe("Editor component", () => { }); it("handles mixed ASCII and wide characters in wrapping", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 15; // "Test ✅ OK 日本" = 4 + 1 + 2 + 1 + 2 + 1 + 4 = 15 columns (fits exactly) @@ -566,7 +573,7 @@ describe("Editor component", () => { }); it("renders cursor correctly on wide characters", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 20; editor.setText("A✅B"); @@ -582,7 +589,7 @@ describe("Editor component", () => { }); it("does not exceed terminal width with emoji at wrap boundary", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 11; // "0123456789✅" = 10 ASCII + 2-wide emoji = 12 columns @@ -599,7 +606,7 @@ describe("Editor component", () => { describe("Word wrapping", () => { it("wraps at word boundaries instead of mid-word", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 40; editor.setText("Hello world this is a test of word wrapping functionality"); @@ -621,7 +628,7 @@ describe("Editor component", () => { }); it("does not start lines with leading whitespace after word wrap", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 20; editor.setText("Word1 Word2 Word3 Word4 Word5 Word6"); @@ -642,7 +649,7 @@ describe("Editor component", () => { }); it("breaks long words (URLs) at character level", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 30; editor.setText("Check https://example.com/very/long/path/that/exceeds/width here"); @@ -656,7 +663,7 @@ describe("Editor component", () => { }); it("preserves multiple spaces within words on same line", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 50; editor.setText("Word1 Word2 Word3"); @@ -668,7 +675,7 @@ describe("Editor component", () => { }); it("handles empty string", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 40; editor.setText(""); @@ -679,7 +686,7 @@ describe("Editor component", () => { }); it("handles single word that fits exactly", () => { - const editor = new Editor(defaultEditorTheme); + const editor = new Editor(createTestTUI(), defaultEditorTheme); const width = 10; editor.setText("1234567890");