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) {
setCellDimensions({ widthPx, heightPx });
// Invalidate all components so images re-render with correct dimensions
this.invalidate();
this.requestRender();
}
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,365 +770,371 @@ export class TUI extends Container {
private doRender(): void {
if (this.stopped) return;
const width = this.terminal.columns;
const height = this.terminal.rows;
let viewportTop = Math.max(0, this.maxLinesRendered - height);
let prevViewportTop = this.previousViewportTop;
let hardwareCursorRow = this.hardwareCursorRow;
const computeLineDiff = (targetRow: number): number => {
const currentScreenRow = hardwareCursorRow - prevViewportTop;
const targetScreenRow = targetRow - viewportTop;
return targetScreenRow - currentScreenRow;
};
try {
const width = this.terminal.columns;
const height = this.terminal.rows;
let viewportTop = Math.max(0, this.maxLinesRendered - height);
let prevViewportTop = this.previousViewportTop;
let hardwareCursorRow = this.hardwareCursorRow;
const computeLineDiff = (targetRow: number): number => {
const currentScreenRow = hardwareCursorRow - prevViewportTop;
const targetScreenRow = targetRow - viewportTop;
return targetScreenRow - currentScreenRow;
};
// Render all components to get new lines
let newLines = this.render(width);
// 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);
if (statusBarLines.length > 0) {
newLines = [...statusBarLines, ...newLines];
}
// Check if content grew and we should scroll to bottom (sticky bottom behavior)
const contentGrew = newLines.length > this.previousLines.length;
const shouldScrollToBottom = contentGrew && this.isScrolledToBottom;
// Skip ALL post-processing if component output is unchanged.
// Container.render() returns the same array reference when stable.
if (
newLines === this._lastRenderedComponents &&
this.overlayStack.length === 0 &&
!shouldScrollToBottom
) {
return;
}
this._lastRenderedComponents = newLines;
// Composite overlays into the rendered lines (before differential compare)
if (this.overlayStack.length > 0) {
newLines = compositeOverlays(
newLines,
this.overlayStack,
width,
height,
this.maxLinesRendered,
);
}
// Extract cursor position before applying line resets (marker must be found first)
const cursorPos = extractCursorPosition(newLines, height);
newLines = applyLineResets(newLines);
// Width or height changed - need full re-render
const widthChanged =
this.previousWidth !== 0 && this.previousWidth !== width;
const heightChanged =
this.previousHeight !== 0 && this.previousHeight !== height;
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
if (clear) buffer += "\x1b[2J\x1b[H"; // Clear screen and home (no scrollback clear — preserves view position)
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
let line = newLines[i];
if (!isImageLine(line) && visibleWidth(line) > width) {
line = truncateToWidth(line, width);
}
buffer += line;
const statusBarLines = this.autonomousBar.render(width);
if (statusBarLines.length > 0) {
newLines = [...statusBarLines, ...newLines];
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
// Reset max lines when clearing, otherwise track growth
if (clear) {
this.maxLinesRendered = newLines.length;
} else {
this.maxLinesRendered = Math.max(
// Check if content grew and we should scroll to bottom (sticky bottom behavior)
const contentGrew = newLines.length > this.previousLines.length;
const shouldScrollToBottom = contentGrew && this.isScrolledToBottom;
// Skip ALL post-processing if component output is unchanged.
// Container.render() returns the same array reference when stable.
if (
newLines === this._lastRenderedComponents &&
this.overlayStack.length === 0 &&
!shouldScrollToBottom
) {
return;
}
this._lastRenderedComponents = newLines;
// Composite overlays into the rendered lines (before differential compare)
if (this.overlayStack.length > 0) {
newLines = compositeOverlays(
newLines,
this.overlayStack,
width,
height,
this.maxLinesRendered,
newLines.length,
);
}
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
};
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
const logRedraw = (reason: string): void => {
if (!debugRedraw) return;
const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log");
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
fs.appendFileSync(logPath, msg);
};
// Extract cursor position before applying line resets (marker must be found first)
const cursorPos = extractCursorPosition(newLines, height);
// First render - just output everything without clearing (assumes clean screen)
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
logRedraw("first render");
fullRender(false);
return;
}
newLines = applyLineResets(newLines);
// Width or height changed - full re-render
if (widthChanged || heightChanged) {
logRedraw(
`terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`,
);
fullRender(true);
return;
}
// Width or height changed - need full re-render
const widthChanged =
this.previousWidth !== 0 && this.previousWidth !== width;
const heightChanged =
this.previousHeight !== 0 && this.previousHeight !== height;
// Content shrunk below the working area and no overlays - re-render to clear empty rows
// (overlays need the padding, so only do this when no overlays are active)
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
if (
this.clearOnShrink &&
newLines.length < this.maxLinesRendered &&
this.overlayStack.length === 0
) {
if (!this._shrinkDebounceActive) {
// First shrink detection: defer the full redraw by one tick.
// If content grows back immediately (pinned clear → new streaming),
// the full redraw is avoided.
this._shrinkDebounceActive = true;
// Do NOT update maxLinesRendered here — keep the old value so the
// condition `newLines.length < maxLinesRendered` still triggers on
// the next render if content stays shrunk.
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
if (clear) buffer += "\x1b[2J\x1b[H"; // Clear screen and home (no scrollback clear — preserves view position)
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
let line = newLines[i];
if (!isImageLine(line) && visibleWidth(line) > width) {
line = truncateToWidth(line, width);
}
buffer += line;
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
// Reset max lines when clearing, otherwise track growth
if (clear) {
this.maxLinesRendered = newLines.length;
} else {
this.maxLinesRendered = Math.max(
this.maxLinesRendered,
newLines.length,
);
}
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
};
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
const logRedraw = (reason: string): void => {
if (!debugRedraw) return;
const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log");
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
fs.appendFileSync(logPath, msg);
};
// First render - just output everything without clearing (assumes clean screen)
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
logRedraw("first render");
fullRender(false);
return;
}
// Width or height changed - full re-render
if (widthChanged || heightChanged) {
logRedraw(
`clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`,
);
// Fall through to differential render for this frame
} else {
// Still shrunk on second render — commit the full redraw
this._shrinkDebounceActive = false;
logRedraw(
`clearOnShrink committed (maxLinesRendered=${this.maxLinesRendered})`,
`terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`,
);
fullRender(true);
return;
}
} else {
this._shrinkDebounceActive = false;
}
// Find first and last changed lines
let firstChanged = -1;
let lastChanged = -1;
const maxLines = Math.max(newLines.length, this.previousLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine =
i < this.previousLines.length ? this.previousLines[i] : "";
const newLine = i < newLines.length ? newLines[i] : "";
if (oldLine !== newLine) {
if (firstChanged === -1) {
firstChanged = i;
}
lastChanged = i;
}
}
const appendedLines = newLines.length > this.previousLines.length;
if (appendedLines) {
if (firstChanged === -1) {
firstChanged = this.previousLines.length;
}
lastChanged = newLines.length - 1;
}
const appendStart =
appendedLines &&
firstChanged === this.previousLines.length &&
firstChanged > 0;
// No changes - but still need to update hardware cursor position if it moved
if (firstChanged === -1) {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
this.previousHeight = height;
return;
}
// All changes are in deleted lines (nothing to render, just clear)
if (firstChanged >= newLines.length) {
if (this.previousLines.length > newLines.length) {
let buffer = "\x1b[?2026h";
// Move to end of new content (clamp to 0 for empty content)
const targetRow = Math.max(0, newLines.length - 1);
const lineDiff = computeLineDiff(targetRow);
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
buffer += "\r";
// Clear extra lines without scrolling
const extraLines = this.previousLines.length - newLines.length;
if (extraLines > height) {
logRedraw(`extraLines > height (${extraLines} > ${height})`);
// Content shrunk below the working area and no overlays - re-render to clear empty rows
// (overlays need the padding, so only do this when no overlays are active)
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
if (
this.clearOnShrink &&
newLines.length < this.maxLinesRendered &&
this.overlayStack.length === 0
) {
if (!this._shrinkDebounceActive) {
// First shrink detection: defer the full redraw by one tick.
// If content grows back immediately (pinned clear → new streaming),
// the full redraw is avoided.
this._shrinkDebounceActive = true;
// Do NOT update maxLinesRendered here — keep the old value so the
// condition `newLines.length < maxLinesRendered` still triggers on
// the next render if content stays shrunk.
logRedraw(
`clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`,
);
// Fall through to differential render for this frame
} else {
// Still shrunk on second render — commit the full redraw
this._shrinkDebounceActive = false;
logRedraw(
`clearOnShrink committed (maxLinesRendered=${this.maxLinesRendered})`,
);
fullRender(true);
return;
}
if (extraLines > 0) {
buffer += "\x1b[1B";
}
for (let i = 0; i < extraLines; i++) {
buffer += "\r\x1b[2K";
if (i < extraLines - 1) buffer += "\x1b[1B";
}
if (extraLines > 0) {
buffer += `\x1b[${extraLines}A`;
}
buffer += "\x1b[?2026l";
this.terminal.write(buffer);
this.cursorRow = targetRow;
this.hardwareCursorRow = targetRow;
} else {
this._shrinkDebounceActive = false;
}
// Find first and last changed lines
let firstChanged = -1;
let lastChanged = -1;
const maxLines = Math.max(newLines.length, this.previousLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine =
i < this.previousLines.length ? this.previousLines[i] : "";
const newLine = i < newLines.length ? newLines[i] : "";
if (oldLine !== newLine) {
if (firstChanged === -1) {
firstChanged = i;
}
lastChanged = i;
}
}
const appendedLines = newLines.length > this.previousLines.length;
if (appendedLines) {
if (firstChanged === -1) {
firstChanged = this.previousLines.length;
}
lastChanged = newLines.length - 1;
}
const appendStart =
appendedLines &&
firstChanged === this.previousLines.length &&
firstChanged > 0;
// No changes - but still need to update hardware cursor position if it moved
if (firstChanged === -1) {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
this.previousHeight = height;
return;
}
// All changes are in deleted lines (nothing to render, just clear)
if (firstChanged >= newLines.length) {
if (this.previousLines.length > newLines.length) {
let buffer = "\x1b[?2026h";
// Move to end of new content (clamp to 0 for empty content)
const targetRow = Math.max(0, newLines.length - 1);
const lineDiff = computeLineDiff(targetRow);
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
buffer += "\r";
// Clear extra lines without scrolling
const extraLines = this.previousLines.length - newLines.length;
if (extraLines > height) {
logRedraw(`extraLines > height (${extraLines} > ${height})`);
fullRender(true);
return;
}
if (extraLines > 0) {
buffer += "\x1b[1B";
}
for (let i = 0; i < extraLines; i++) {
buffer += "\r\x1b[2K";
if (i < extraLines - 1) buffer += "\x1b[1B";
}
if (extraLines > 0) {
buffer += `\x1b[${extraLines}A`;
}
buffer += "\x1b[?2026l";
this.terminal.write(buffer);
this.cursorRow = targetRow;
this.hardwareCursorRow = targetRow;
}
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
return;
}
// Check if firstChanged is above what was previously visible
// Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
const previousContentViewportTop = Math.max(
0,
this.previousLines.length - height,
);
if (firstChanged < previousContentViewportTop) {
// First change is above previous viewport - need full re-render
logRedraw(
`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`,
);
fullRender(true);
return;
}
// Render from first changed line to end
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
const prevViewportBottom = prevViewportTop + height - 1;
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
if (moveTargetRow > prevViewportBottom) {
const currentScreenRow = Math.max(
0,
Math.min(height - 1, hardwareCursorRow - prevViewportTop),
);
const moveToBottom = height - 1 - currentScreenRow;
if (moveToBottom > 0) {
buffer += `\x1b[${moveToBottom}B`;
}
const scroll = moveTargetRow - prevViewportBottom;
buffer += "\r\n".repeat(scroll);
prevViewportTop += scroll;
viewportTop += scroll;
hardwareCursorRow = moveTargetRow;
}
// Move cursor to first changed line (use hardwareCursorRow for actual position)
const lineDiff = computeLineDiff(moveTargetRow);
if (lineDiff > 0) {
buffer += `\x1b[${lineDiff}B`; // Move down
} else if (lineDiff < 0) {
buffer += `\x1b[${-lineDiff}A`; // Move up
}
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
// Only render changed lines (firstChanged to lastChanged), not all lines to end
// This reduces flicker when only a single line changes (e.g., spinner animation)
const renderEnd = Math.min(lastChanged, newLines.length - 1);
for (let i = firstChanged; i <= renderEnd; i++) {
if (i > firstChanged) buffer += "\r\n";
buffer += "\x1b[2K"; // Clear current line
let line = newLines[i];
const isImage = isImageLine(line);
if (!isImage && visibleWidth(line) > width) {
line = truncateToWidth(line, width);
}
buffer += line;
}
// Track where cursor ended up after rendering
let finalCursorRow = renderEnd;
// If we had more lines before, clear them and move cursor back
if (this.previousLines.length > newLines.length) {
// Move to end of new content first if we stopped before it
if (renderEnd < newLines.length - 1) {
const moveDown = newLines.length - 1 - renderEnd;
buffer += `\x1b[${moveDown}B`;
finalCursorRow = newLines.length - 1;
}
const extraLines = this.previousLines.length - newLines.length;
for (let i = newLines.length; i < this.previousLines.length; i++) {
buffer += "\r\n\x1b[2K";
}
// Move cursor back to end of new content
buffer += `\x1b[${extraLines}A`;
}
buffer += "\x1b[?2026l"; // End synchronized output
if (process.env.PI_TUI_DEBUG === "1") {
const debugDir = path.join(os.tmpdir(), "tui");
fs.mkdirSync(debugDir, { recursive: true });
const debugPath = path.join(
debugDir,
`render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
);
const debugData = [
`firstChanged: ${firstChanged}`,
`viewportTop: ${viewportTop}`,
`cursorRow: ${this.cursorRow}`,
`height: ${height}`,
`lineDiff: ${lineDiff}`,
`hardwareCursorRow: ${hardwareCursorRow}`,
`renderEnd: ${renderEnd}`,
`finalCursorRow: ${finalCursorRow}`,
`cursorPos: ${JSON.stringify(cursorPos)}`,
`newLines.length: ${newLines.length}`,
`previousLines.length: ${this.previousLines.length}`,
"",
"=== newLines ===",
JSON.stringify(newLines, null, 2),
"",
"=== previousLines ===",
JSON.stringify(this.previousLines, null, 2),
"",
"=== buffer ===",
JSON.stringify(buffer),
].join("\n");
fs.writeFileSync(debugPath, debugData);
}
// Write entire buffer at once
this.terminal.write(buffer);
// Track cursor position for next render
// cursorRow tracks end of content (for viewport calculation)
// hardwareCursorRow tracks actual terminal cursor position (for movement)
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = finalCursorRow;
// Track terminal's working area (grows but doesn't shrink unless cleared)
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
// Apply sticky bottom behavior if content grew and user was at bottom
if (shouldScrollToBottom) {
this.scrollToBottom();
}
// Position hardware cursor for IME
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
return;
}
// Check if firstChanged is above what was previously visible
// Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
const previousContentViewportTop = Math.max(
0,
this.previousLines.length - height,
);
if (firstChanged < previousContentViewportTop) {
// First change is above previous viewport - need full re-render
logRedraw(
`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`,
} catch (err) {
console.error("[TUI] render error:", err);
process.stdout.write(
"\x1b[2K\x1b[31m[TUI render error — see stderr]\x1b[0m\n",
);
fullRender(true);
return;
}
// Render from first changed line to end
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
const prevViewportBottom = prevViewportTop + height - 1;
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
if (moveTargetRow > prevViewportBottom) {
const currentScreenRow = Math.max(
0,
Math.min(height - 1, hardwareCursorRow - prevViewportTop),
);
const moveToBottom = height - 1 - currentScreenRow;
if (moveToBottom > 0) {
buffer += `\x1b[${moveToBottom}B`;
}
const scroll = moveTargetRow - prevViewportBottom;
buffer += "\r\n".repeat(scroll);
prevViewportTop += scroll;
viewportTop += scroll;
hardwareCursorRow = moveTargetRow;
}
// Move cursor to first changed line (use hardwareCursorRow for actual position)
const lineDiff = computeLineDiff(moveTargetRow);
if (lineDiff > 0) {
buffer += `\x1b[${lineDiff}B`; // Move down
} else if (lineDiff < 0) {
buffer += `\x1b[${-lineDiff}A`; // Move up
}
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
// Only render changed lines (firstChanged to lastChanged), not all lines to end
// This reduces flicker when only a single line changes (e.g., spinner animation)
const renderEnd = Math.min(lastChanged, newLines.length - 1);
for (let i = firstChanged; i <= renderEnd; i++) {
if (i > firstChanged) buffer += "\r\n";
buffer += "\x1b[2K"; // Clear current line
let line = newLines[i];
const isImage = isImageLine(line);
if (!isImage && visibleWidth(line) > width) {
line = truncateToWidth(line, width);
}
buffer += line;
}
// Track where cursor ended up after rendering
let finalCursorRow = renderEnd;
// If we had more lines before, clear them and move cursor back
if (this.previousLines.length > newLines.length) {
// Move to end of new content first if we stopped before it
if (renderEnd < newLines.length - 1) {
const moveDown = newLines.length - 1 - renderEnd;
buffer += `\x1b[${moveDown}B`;
finalCursorRow = newLines.length - 1;
}
const extraLines = this.previousLines.length - newLines.length;
for (let i = newLines.length; i < this.previousLines.length; i++) {
buffer += "\r\n\x1b[2K";
}
// Move cursor back to end of new content
buffer += `\x1b[${extraLines}A`;
}
buffer += "\x1b[?2026l"; // End synchronized output
if (process.env.PI_TUI_DEBUG === "1") {
const debugDir = path.join(os.tmpdir(), "tui");
fs.mkdirSync(debugDir, { recursive: true });
const debugPath = path.join(
debugDir,
`render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
);
const debugData = [
`firstChanged: ${firstChanged}`,
`viewportTop: ${viewportTop}`,
`cursorRow: ${this.cursorRow}`,
`height: ${height}`,
`lineDiff: ${lineDiff}`,
`hardwareCursorRow: ${hardwareCursorRow}`,
`renderEnd: ${renderEnd}`,
`finalCursorRow: ${finalCursorRow}`,
`cursorPos: ${JSON.stringify(cursorPos)}`,
`newLines.length: ${newLines.length}`,
`previousLines.length: ${this.previousLines.length}`,
"",
"=== newLines ===",
JSON.stringify(newLines, null, 2),
"",
"=== previousLines ===",
JSON.stringify(this.previousLines, null, 2),
"",
"=== buffer ===",
JSON.stringify(buffer),
].join("\n");
fs.writeFileSync(debugPath, debugData);
}
// Write entire buffer at once
this.terminal.write(buffer);
// Track cursor position for next render
// cursorRow tracks end of content (for viewport calculation)
// hardwareCursorRow tracks actual terminal cursor position (for movement)
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = finalCursorRow;
// Track terminal's working area (grows but doesn't shrink unless cleared)
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
// Apply sticky bottom behavior if content grew and user was at bottom
if (shouldScrollToBottom) {
this.scrollToBottom();
}
// Position hardware cursor for IME
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
}
/**

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 {