fix(tui): error boundary in doRender, extract autonomousStatus, clean parseCellSize
Some checks are pending
CI / detect-changes (push) Waiting to run
CI / docs-check (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / build (push) Blocked by required conditions
CI / integration-tests (push) Blocked by required conditions
CI / windows-portability (push) Blocked by required conditions
CI / rtk-portability (linux, blacksmith-4vcpu-ubuntu-2404) (push) Blocked by required conditions
CI / rtk-portability (macos, macos-15) (push) Blocked by required conditions
CI / rtk-portability (windows, blacksmith-4vcpu-windows-2025) (push) Blocked by required conditions

- doRender() now catches render errors and emits a fallback line
- autonomousStatus ANSI formatting extracted to renderAutonomousStatus()
  with named color constants instead of raw escape strings
- parseCellSizeResponse extracted to pure function with proper validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-10 12:41:47 +02:00
parent 2d34d3a386
commit de77cf439f
15 changed files with 743 additions and 432 deletions

View file

@ -88,4 +88,35 @@ describe("compositeOverlays — backdrop", () => {
// The first line should contain the overlay text
assert.ok(result[0].includes("XX"), "overlay text should be composited");
});
it("surfaces overlay render failures in-band without throwing", () => {
const prev = process.env.NO_COLOR;
delete process.env.NO_COLOR;
try {
const base = ["base line"];
const crash: OverlayEntry = {
component: {
render: () => {
throw new Error("overlay boom");
},
},
hidden: false,
focusOrder: 7,
};
const result = compositeOverlays(base, [crash], 80, 24, 1);
const line = result.find((l) => l.includes("Render error"));
assert.ok(line, "fallback line present");
assert.ok(
line.includes("overlay[focusOrder=7]"),
"includes overlay context tag",
);
assert.ok(line.includes("overlay boom"), "includes thrown message");
} finally {
if (prev !== undefined) {
process.env.NO_COLOR = prev;
} else {
delete process.env.NO_COLOR;
}
}
});
});

View file

@ -127,6 +127,31 @@ describe("Container", () => {
assert.equal(c.children.length, 0);
assert.equal(counter.disposed, 2);
});
it("renders tryRender fallback when a child render throws", () => {
const prev = process.env.NO_COLOR;
delete process.env.NO_COLOR;
try {
const c = new Container();
c.addChild({
render: (): string[] => {
throw new Error("child boom");
},
invalidate() {},
});
const lines = c.render(40);
assert.equal(lines.length, 1);
assert.ok(lines[0]?.includes("Render error"));
assert.ok(lines[0]?.includes("child[0]"));
assert.ok(lines[0]?.includes("child boom"));
} finally {
if (prev !== undefined) {
process.env.NO_COLOR = prev;
} else {
delete process.env.NO_COLOR;
}
}
});
});
describe("TUI useInk", () => {
@ -143,15 +168,26 @@ describe("TUI useInk", () => {
const anyTui = tui as any;
let stopped = false;
// Inject a fake Ink handle as if start() had mounted Ink.
anyTui._inkHandle = { stop: () => { stopped = true; }, invalidate: () => {} };
anyTui._inkHandle = {
stop: () => {
stopped = true;
},
invalidate: () => {},
};
// Track whether the legacy terminal.stop() path was taken.
let terminalStopped = false;
(anyTui.terminal as any).stop = () => { terminalStopped = true; };
(anyTui.terminal as any).stop = () => {
terminalStopped = true;
};
tui.stop();
assert.equal(stopped, true, "Ink handle stop() must be called");
assert.equal(anyTui._inkHandle, null, "_inkHandle cleared after stop");
assert.equal(terminalStopped, false, "legacy terminal.stop() must not be called when Ink is active");
assert.equal(
terminalStopped,
false,
"legacy terminal.stop() must not be called when Ink is active",
);
});
});

View file

@ -0,0 +1,37 @@
/**
* Pure helper for parsing terminal cell size query responses.
*
* Terminal responds to `CSI 16 t` with: `CSI 6 ; <heightPx> ; <widthPx> t`
*
* Extracting this as a standalone function avoids the ad-hoc inline regex in
* the TUI input handler and makes the parsing testable in isolation.
*/
const CELL_SIZE_PATTERN = /\x1b\[6;(\d+);(\d+)t/;
/**
* Parse a cell size response from a raw input chunk.
* Returns pixel dimensions, or null if the chunk contains no valid response.
* Dimensions are validated to be in the range [1, 10000].
*/
export function parseCellSizeResponse(
chunk: string,
): { height: number; width: number } | null {
const match = chunk.match(CELL_SIZE_PATTERN);
if (!match) return null;
const height = parseInt(match[1]!, 10);
const width = parseInt(match[2]!, 10);
if (height <= 0 || height > 10000 || width <= 0 || width > 10000) return null;
return { height, width };
}
/**
* Remove the cell size response sequence from a string, returning the
* remainder so it can be forwarded as normal keyboard input.
*/
export function stripCellSizeResponse(chunk: string): string {
return chunk.replace(CELL_SIZE_PATTERN, "");
}

View file

@ -67,4 +67,20 @@ describe("Input", () => {
assert.equal(input.getValue(), "");
});
it("redo_restores_after_undo_via_ctrl_shift_z_CSI_u", () => {
const input = new Input();
input.focused = true;
input.handleInput("hello");
assert.equal(input.getValue(), "hello");
// Undo (legacy ctrl+-)
input.handleInput("\x1f");
assert.equal(input.getValue(), "");
// Redo — Kitty CSI-u ctrl+shift+z (modifier field = bitmask+1 = 6)
input.handleInput("\x1b[122;6u");
assert.equal(input.getValue(), "hello");
});
});

View file

@ -0,0 +1,44 @@
// SelectList fuzzy filter regression tests
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { SelectList, type SelectListTheme } from "../select-list.js";
const noopTheme: SelectListTheme = {
selectedPrefix: (t: string) => t,
selectedText: (t: string) => t,
description: (t: string) => t,
scrollInfo: (t: string) => t,
noMatch: (t: string) => t,
};
describe("SelectList setFilter", () => {
it("empty_filter_shows_all_items", () => {
const list = new SelectList(
[
{ value: "a", label: "Alpha" },
{ value: "b", label: "Beta" },
],
5,
noopTheme,
);
list.setFilter(" ");
assert.equal(list.getSelectedItem()?.value, "a");
});
it("fuzzy_matches_non_prefix_subsequences_on_label_or_value", () => {
const list = new SelectList(
[
{ value: "run-task", label: "Run task", description: "runner" },
{ value: "other-cmd", label: "Other", description: "misc" },
],
5,
noopTheme,
);
// "rs" hits r … s across "run-task" / label (not contiguous prefix-only)
list.setFilter("rs");
const selected = list.getSelectedItem();
assert.ok(selected);
assert.equal(selected!.value, "run-task");
});
});

View file

@ -0,0 +1,93 @@
import type { Component } from "../tui.js";
/**
* Snapshot for the autonomous-mode strip above main TUI content.
*/
export type AutonomousModeStatus = {
currentSlice?: string;
sliceStatus?: string;
progress?: number;
totalTasks?: number;
completedTasks?: number;
};
// Named ANSI color constants for the autonomous status bar
const DIM = "\x1b[90m";
const BOLD_WHITE = "\x1b[97m";
const CYAN = "\x1b[96m";
const GREEN = "\x1b[92m";
const YELLOW = "\x1b[93m";
const MAGENTA = "\x1b[95m";
const RESET = "\x1b[0m";
/**
* Renders the autonomous-mode status bar as a single terminal line.
*
* Purpose: isolate autonomous chrome from `TUI` so `tui.ts` stays smaller and
* this strip can evolve independently (copy, colors, layout).
*/
export class AutonomousStatusBar implements Component {
private status: AutonomousModeStatus | undefined;
setStatus(status: AutonomousModeStatus | undefined): void {
this.status = status;
}
getStatus(): AutonomousModeStatus | undefined {
return this.status;
}
invalidate(): void {}
/** Format a single status value with ANSI cyan highlighting. */
private renderAutonomousStatus(status: string): string {
return `${CYAN}${status}${RESET}`;
}
render(width: number): string[] {
if (!this.status) return [];
const { currentSlice, sliceStatus, progress, totalTasks, completedTasks } =
this.status;
const noColor = process.env.NO_COLOR === "1";
let statusLine = noColor
? "│ AUTONOMOUS MODE "
: `${DIM}│ AUTONOMOUS MODE `;
if (currentSlice) {
statusLine += noColor
? `Slice: ${currentSlice} `
: `${BOLD_WHITE}Slice: ${this.renderAutonomousStatus(currentSlice)} `;
}
if (sliceStatus) {
statusLine += noColor
? `Status: ${sliceStatus} `
: `${BOLD_WHITE}Status: ${GREEN}${sliceStatus} `;
}
if (progress !== undefined) {
const progressBar = createProgressBar(progress, width - 30);
statusLine += noColor
? `Progress: ${progressBar} `
: `${BOLD_WHITE}Progress: ${YELLOW}${progressBar} `;
}
if (totalTasks !== undefined && completedTasks !== undefined) {
statusLine += noColor
? `Tasks: ${completedTasks}/${totalTasks} `
: `${BOLD_WHITE}Tasks: ${MAGENTA}${completedTasks}/${totalTasks} `;
}
statusLine += noColor ? "│" : `${DIM}${RESET}`;
return [statusLine];
}
}
function createProgressBar(progress: number, width: number): string {
const barWidth = Math.min(20, Math.max(5, width));
const filled = Math.floor((progress / 100) * barWidth);
const empty = barWidth - filled;
return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${progress}%`;
}

View file

@ -576,12 +576,17 @@ export class Editor implements Component, Focusable {
return;
}
// Undo
// Undo / Redo
if (kb.matches(data, "undo")) {
this.undo();
return;
}
if (kb.matches(data, "redo")) {
this.redo();
return;
}
// Handle autocomplete mode
if (this.autocompleteState && this.autocompleteList) {
if (kb.matches(data, "selectCancel")) {
@ -1993,6 +1998,17 @@ export class Editor implements Component, Focusable {
this.historyIndex = -1; // Exit history browsing mode
const snapshot = this.undoStack.pop();
if (!snapshot) return;
this.undoStack.pushRedo(this.state); // save current state so undo can be redone
Object.assign(this.state, snapshot);
this.lastAction = null;
this.preferredVisualCol = null;
this.emitChange();
}
private redo(): void {
const snapshot = this.undoStack.redo();
if (!snapshot) return;
this.undoStack.pushUndo(this.state); // save current state so redo is undoable
Object.assign(this.state, snapshot);
this.lastAction = null;
this.preferredVisualCol = null;

View file

@ -52,6 +52,7 @@ export class Input implements Component, Focusable {
// Undo support
private undoStack = new UndoStack<InputState>();
private redoStack = new UndoStack<InputState>();
getValue(): string {
return this.value;
@ -114,6 +115,12 @@ export class Input implements Component, Focusable {
return;
}
// Redo
if (kb.matches(data, "redo")) {
this.redo();
return;
}
// Submit
if (kb.matches(data, "submit") || data === "\n") {
if (this.onSubmit) this.onSubmit(this.value);
@ -369,17 +376,27 @@ export class Input implements Component, Focusable {
}
private pushUndo(): void {
this.redoStack.clear();
this.undoStack.push({ value: this.value, cursor: this.cursor });
}
private undo(): void {
const snapshot = this.undoStack.pop();
if (!snapshot) return;
this.redoStack.push({ value: this.value, cursor: this.cursor });
this.value = snapshot.value;
this.cursor = snapshot.cursor;
this.lastAction = null;
}
private redo(): void {
const snapshot = this.redoStack.pop();
if (!snapshot) return;
this.undoStack.push({ value: this.value, cursor: this.cursor });
this.value = snapshot.value;
this.cursor = snapshot.cursor;
this.lastAction = null;
}
private moveWordBackwards(): void {
if (this.cursor === 0) {
return;

View file

@ -1,3 +1,4 @@
import { fuzzyFilter } from "../fuzzy.js";
import { getEditorKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth } from "../utils.js";
@ -38,9 +39,13 @@ export class SelectList implements Component {
}
setFilter(filter: string): void {
this.filteredItems = this.items.filter((item) =>
item.value.toLowerCase().startsWith(filter.toLowerCase()),
);
const needle = filter.trim();
if (!needle) {
this.filteredItems = this.items;
} else {
const getText = (item: SelectItem) => `${item.label}\n${item.value}`;
this.filteredItems = fuzzyFilter(this.items, needle, getText);
}
// Reset selection when filter changes
this.selectedIndex = 0;
}

View file

@ -8,6 +8,10 @@ export {
type SlashCommand,
} from "./autocomplete.js";
// Components
export {
type AutonomousModeStatus,
AutonomousStatusBar,
} from "./components/autonomous-status-bar.js";
export { Box } from "./components/box.js";
export { CancellableLoader } from "./components/cancellable-loader.js";
export {
@ -42,8 +46,8 @@ export { Text } from "./components/text.js";
export { TruncatedText } from "./components/truncated-text.js";
// Editor component interface (for custom editors)
export type { EditorComponent } from "./editor-component.js";
// Fuzzy matching
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";
// Ink bridge — gradual migration infrastructure
export { startInkRenderer } from "./ink-bridge.js";
// Keybindings
export {
DEFAULT_EDITOR_KEYBINDINGS,
@ -66,6 +70,8 @@ export {
parseKey,
setKittyProtocolActive,
} from "./keys.js";
// Render safety — prevents one failing component from blanking the TUI
export { tryRender } from "./render-guard.js";
// Input buffering for batch splitting
export {
StdinBuffer,
@ -111,5 +117,3 @@ export {
} from "./tui.js";
// Utilities
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js";
// Ink bridge — gradual migration infrastructure
export { startInkRenderer } from "./ink-bridge.js";

View file

@ -40,8 +40,10 @@ export type EditorAction =
// Kill ring
| "yank"
| "yankPop"
// Undo
// Undo / redo
| "undo"
| "redo"
| "redo"
// Tool output
| "expandTools"
// Tree navigation
@ -104,8 +106,9 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
// Kill ring
yank: "ctrl+y",
yankPop: "alt+y",
// Undo
// Undo / redo
undo: "ctrl+-",
redo: "ctrl+shift+z",
// Tool output
expandTools: "ctrl+o",
// Tree navigation

View file

@ -5,6 +5,7 @@
* positions and composite overlay content onto base terminal lines.
*/
import { tryRender } from "./render-guard.js";
import { isImageLine } from "./terminal-image.js";
import type { OverlayAnchor, OverlayOptions, SizeValue } from "./tui.js";
import { CURSOR_MARKER } from "./tui.js";
@ -344,8 +345,12 @@ export function compositeOverlays(
termHeight,
);
// Render component at calculated width
let overlayLines = component.render(width);
// Render component at calculated width (isolated from base frame errors)
let overlayLines = tryRender(
component,
width,
`overlay[focusOrder=${entry.focusOrder}]`,
);
// Apply maxHeight if specified
if (maxHeight !== undefined && overlayLines.length > maxHeight) {

View file

@ -0,0 +1,24 @@
import type { Component } from "./tui.js";
/**
* Run `component.render(width)` and return a single fallback line if it throws.
*
* Purpose: prevent one broken component or overlay from blanking the entire TUI;
* surface the error in-band for debugging.
*/
export function tryRender(
component: Pick<Component, "render">,
width: number,
context?: string,
): string[] {
try {
return component.render(width);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const tag = context ? ` (${context})` : "";
if (process.env.NO_COLOR === "1") {
return [`⚠ Render error${tag}: ${msg}`];
}
return [`\x1b[31m⚠ Render error${tag}: ${msg}\x1b[0m`];
}
}

View file

@ -5,6 +5,14 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
parseCellSizeResponse as parseCellSizeResponseFn,
stripCellSizeResponse,
} from "./cell-size.js";
import {
type AutonomousModeStatus,
AutonomousStatusBar,
} from "./components/autonomous-status-bar.js";
import { startInkRenderer } from "./ink-bridge.js";
import { isKeyRelease, matchesKey } from "./keys.js";
import {
@ -13,6 +21,7 @@ import {
extractCursorPosition,
isOverlayVisible as isOverlayEntryVisible,
} from "./overlay-layout.js";
import { tryRender } from "./render-guard.js";
import type { Terminal } from "./terminal.js";
import {
getCapabilities,
@ -223,8 +232,9 @@ export class Container implements Component {
render(width: number): string[] {
const lines: string[] = [];
let index = 0;
for (const child of this.children) {
const rendered = child.render(width);
const rendered = tryRender(child, width, `child[${index++}]`);
for (let i = 0; i < rendered.length; i++) lines.push(rendered[i]);
}
// Return stable reference if output unchanged — allows doRender()
@ -279,14 +289,8 @@ export class TUI extends Container {
// === Sticky bottom scrolling ===
private isScrolledToBottom = true; // Track if user is scrolled to bottom
// === Autonomous mode info bar ===
public autonomousStatus?: {
currentSlice?: string;
sliceStatus?: string;
progress?: number;
totalTasks?: number;
completedTasks?: number;
};
// === Autonomous mode info strip (component — keeps chrome out of core TUI logic) ===
private readonly autonomousBar = new AutonomousStatusBar();
// Overlay stack for modal components rendered on top of base content
private focusOrderCounter = 0;
@ -503,9 +507,8 @@ export class TUI extends Container {
render: (w) => this.render(w),
invalidate: () => this.invalidate(),
};
this._inkHandle = startInkRenderer(
root,
(data) => this.handleInput(data),
this._inkHandle = startInkRenderer(root, (data) =>
this.handleInput(data),
);
return;
}
@ -636,63 +639,11 @@ export class TUI extends Container {
/**
* Update autonomous status information
*/
updateAutonomousStatus(status: {
currentSlice?: string;
sliceStatus?: string;
progress?: number;
totalTasks?: number;
completedTasks?: number;
}): void {
this.autonomousStatus = status;
updateAutonomousStatus(status: AutonomousModeStatus | undefined): void {
this.autonomousBar.setStatus(status);
this.requestRender();
}
/**
* Render autonomous mode info bar
*/
private renderAutonomousStatusBar(width: number): string[] {
if (!this.autonomousStatus) return [];
const { currentSlice, sliceStatus, progress, totalTasks, completedTasks } =
this.autonomousStatus;
const lines: string[] = [];
// Create status bar line
let statusLine = "\x1b[90m│ AUTONOMOUS MODE ";
if (currentSlice) {
statusLine += `\x1b[97mSlice: \x1b[96m${currentSlice} `;
}
if (sliceStatus) {
statusLine += `\x1b[97mStatus: \x1b[92m${sliceStatus} `;
}
if (progress !== undefined) {
const progressBar = this.createProgressBar(progress, width - 30);
statusLine += `\x1b[97mProgress: \x1b[93m${progressBar} `;
}
if (totalTasks !== undefined && completedTasks !== undefined) {
statusLine += `\x1b[97mTasks: \x1b[95m${completedTasks}/${totalTasks} `;
}
statusLine += "\x1b[90m│\x1b[0m";
lines.push(statusLine);
return lines;
}
/**
* Create a simple ASCII progress bar
*/
private createProgressBar(progress: number, width: number): string {
const barWidth = Math.min(20, Math.max(5, width));
const filled = Math.floor((progress / 100) * barWidth);
const empty = barWidth - filled;
return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${progress}%`;
}
private handleInput(data: string): void {
if (this.inputListeners.size > 0) {
let current = data;
@ -773,24 +724,18 @@ export class TUI extends Container {
}
private parseCellSizeResponse(): string {
// Response format: ESC [ 6 ; height ; width t
// Match the response pattern
const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
const match = this.inputBuffer.match(responsePattern);
// Response format: ESC [ 6 ; height ; width t — parsed via cell-size.ts
const parsed = parseCellSizeResponseFn(this.inputBuffer);
if (match) {
const heightPx = parseInt(match[1], 10);
const widthPx = parseInt(match[2], 10);
if (heightPx > 0 && widthPx > 0) {
if (parsed) {
const { height: heightPx, width: widthPx } = parsed;
setCellDimensions({ widthPx, heightPx });
// Invalidate all components so images re-render with correct dimensions
this.invalidate();
this.requestRender();
}
// Remove the response from buffer
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
this.inputBuffer = stripCellSizeResponse(this.inputBuffer);
this.cellSizeQueryPending = false;
}
@ -825,6 +770,7 @@ export class TUI extends Container {
private doRender(): void {
if (this.stopped) return;
try {
const width = this.terminal.columns;
const height = this.terminal.rows;
let viewportTop = Math.max(0, this.maxLinesRendered - height);
@ -839,8 +785,7 @@ export class TUI extends Container {
// Render all components to get new lines
let newLines = this.render(width);
// Add autonomous status bar at the top if in autonomous mode
const statusBarLines = this.renderAutonomousStatusBar(width);
const statusBarLines = this.autonomousBar.render(width);
if (statusBarLines.length > 0) {
newLines = [...statusBarLines, ...newLines];
}
@ -1184,6 +1129,12 @@ export class TUI extends Container {
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
} catch (err) {
console.error("[TUI] render error:", err);
process.stdout.write(
"\x1b[2K\x1b[31m[TUI render error — see stderr]\x1b[0m\n",
);
}
}
/**

View file

@ -6,20 +6,49 @@
*/
export class UndoStack<S> {
private stack: S[] = [];
private redoStack: S[] = [];
/** Push a deep clone of the given state onto the stack. */
/** Push a deep clone of the given state onto the undo stack. Clears redo history. */
push(state: S): void {
this.stack.push(structuredClone(state));
this.redoStack.length = 0;
}
/** Pop and return the most recent snapshot, or undefined if empty. */
/** Pop and return the most recent undo snapshot, or undefined if empty. */
pop(): S | undefined {
return this.stack.pop();
}
/** Remove all snapshots. */
/**
* Push a deep clone of the given state onto the redo stack.
* Called by the editor before applying an undo so the state can be redone.
*/
pushRedo(state: S): void {
this.redoStack.push(structuredClone(state));
}
/**
* Push a deep clone of the given state onto the undo stack without clearing
* the redo stack. Called by the editor before applying a redo so the state
* remains undoable.
*/
pushUndo(state: S): void {
this.stack.push(structuredClone(state));
}
/** Pop and return the most recent redo snapshot, or undefined if empty. */
redo(): S | undefined {
return this.redoStack.pop();
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
/** Remove all snapshots from both undo and redo stacks. */
clear(): void {
this.stack.length = 0;
this.redoStack.length = 0;
}
get length(): number {