fix(tui): add vertical scrolling to Editor when content exceeds terminal height

The Editor component now accepts TUI as the first constructor parameter,
enabling it to query terminal dimensions. When content exceeds available
height, the editor scrolls vertically keeping the cursor visible.

Features:
- Max editor height is 30% of terminal rows (minimum 5 lines)
- Page Up/Down keys scroll by page size
- Scroll indicators show lines above/below: ─── ↑ 5 more ───

Breaking change: Editor constructor signature changed from
  new Editor(theme)
to
  new Editor(tui, theme)

fixes #732
This commit is contained in:
Mario Zechner 2026-01-16 03:50:55 +01:00
parent d30f6460fa
commit 356a482527
17 changed files with 210 additions and 88 deletions

View file

@ -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",

View file

@ -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))

View file

@ -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");
}
},
});
}

View file

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

View file

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

View file

@ -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() {

View file

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

View file

@ -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;
}

View file

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

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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
// 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
// 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] || "";

View file

@ -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<EditorKeybindingsConfig> = {
cursorWordRight: ["alt+right", "ctrl+right"],
cursorLineStart: ["home", "ctrl+a"],
cursorLineEnd: ["end", "ctrl+e"],
pageUp: "pageUp",
pageDown: "pageDown",
// Deletion
deleteCharBackward: "backspace",
deleteCharForward: "delete",

View file

@ -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) ||

View file

@ -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(

View file

@ -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");