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
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:
parent
2d34d3a386
commit
de77cf439f
15 changed files with 743 additions and 432 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
37
packages/tui/src/cell-size.ts
Normal file
37
packages/tui/src/cell-size.ts
Normal 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, "");
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
44
packages/tui/src/components/__tests__/select-list.test.ts
Normal file
44
packages/tui/src/components/__tests__/select-list.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
93
packages/tui/src/components/autonomous-status-bar.ts
Normal file
93
packages/tui/src/components/autonomous-status-bar.ts
Normal 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}%`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
24
packages/tui/src/render-guard.ts
Normal file
24
packages/tui/src/render-guard.ts
Normal 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`];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue