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

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

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