mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 07:04:45 +00:00
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:
parent
d30f6460fa
commit
356a482527
17 changed files with 210 additions and 88 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
35
packages/coding-agent/examples/extensions/load-file.ts
Normal file
35
packages/coding-agent/examples/extensions/load-file.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] || "";
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue