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
|
// The first line should contain the overlay text
|
||||||
assert.ok(result[0].includes("XX"), "overlay text should be composited");
|
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(c.children.length, 0);
|
||||||
assert.equal(counter.disposed, 2);
|
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", () => {
|
describe("TUI useInk", () => {
|
||||||
|
|
@ -143,15 +168,26 @@ describe("TUI useInk", () => {
|
||||||
const anyTui = tui as any;
|
const anyTui = tui as any;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
// Inject a fake Ink handle as if start() had mounted Ink.
|
// 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.
|
// Track whether the legacy terminal.stop() path was taken.
|
||||||
let terminalStopped = false;
|
let terminalStopped = false;
|
||||||
(anyTui.terminal as any).stop = () => { terminalStopped = true; };
|
(anyTui.terminal as any).stop = () => {
|
||||||
|
terminalStopped = true;
|
||||||
|
};
|
||||||
|
|
||||||
tui.stop();
|
tui.stop();
|
||||||
|
|
||||||
assert.equal(stopped, true, "Ink handle stop() must be called");
|
assert.equal(stopped, true, "Ink handle stop() must be called");
|
||||||
assert.equal(anyTui._inkHandle, null, "_inkHandle cleared after stop");
|
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(), "");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo
|
// Undo / Redo
|
||||||
if (kb.matches(data, "undo")) {
|
if (kb.matches(data, "undo")) {
|
||||||
this.undo();
|
this.undo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kb.matches(data, "redo")) {
|
||||||
|
this.redo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle autocomplete mode
|
// Handle autocomplete mode
|
||||||
if (this.autocompleteState && this.autocompleteList) {
|
if (this.autocompleteState && this.autocompleteList) {
|
||||||
if (kb.matches(data, "selectCancel")) {
|
if (kb.matches(data, "selectCancel")) {
|
||||||
|
|
@ -1993,6 +1998,17 @@ export class Editor implements Component, Focusable {
|
||||||
this.historyIndex = -1; // Exit history browsing mode
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
const snapshot = this.undoStack.pop();
|
const snapshot = this.undoStack.pop();
|
||||||
if (!snapshot) return;
|
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);
|
Object.assign(this.state, snapshot);
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
this.preferredVisualCol = null;
|
this.preferredVisualCol = null;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export class Input implements Component, Focusable {
|
||||||
|
|
||||||
// Undo support
|
// Undo support
|
||||||
private undoStack = new UndoStack<InputState>();
|
private undoStack = new UndoStack<InputState>();
|
||||||
|
private redoStack = new UndoStack<InputState>();
|
||||||
|
|
||||||
getValue(): string {
|
getValue(): string {
|
||||||
return this.value;
|
return this.value;
|
||||||
|
|
@ -114,6 +115,12 @@ export class Input implements Component, Focusable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redo
|
||||||
|
if (kb.matches(data, "redo")) {
|
||||||
|
this.redo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Submit
|
// Submit
|
||||||
if (kb.matches(data, "submit") || data === "\n") {
|
if (kb.matches(data, "submit") || data === "\n") {
|
||||||
if (this.onSubmit) this.onSubmit(this.value);
|
if (this.onSubmit) this.onSubmit(this.value);
|
||||||
|
|
@ -369,17 +376,27 @@ export class Input implements Component, Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushUndo(): void {
|
private pushUndo(): void {
|
||||||
|
this.redoStack.clear();
|
||||||
this.undoStack.push({ value: this.value, cursor: this.cursor });
|
this.undoStack.push({ value: this.value, cursor: this.cursor });
|
||||||
}
|
}
|
||||||
|
|
||||||
private undo(): void {
|
private undo(): void {
|
||||||
const snapshot = this.undoStack.pop();
|
const snapshot = this.undoStack.pop();
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
|
this.redoStack.push({ value: this.value, cursor: this.cursor });
|
||||||
this.value = snapshot.value;
|
this.value = snapshot.value;
|
||||||
this.cursor = snapshot.cursor;
|
this.cursor = snapshot.cursor;
|
||||||
this.lastAction = null;
|
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 {
|
private moveWordBackwards(): void {
|
||||||
if (this.cursor === 0) {
|
if (this.cursor === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { fuzzyFilter } from "../fuzzy.js";
|
||||||
import { getEditorKeybindings } from "../keybindings.js";
|
import { getEditorKeybindings } from "../keybindings.js";
|
||||||
import type { Component } from "../tui.js";
|
import type { Component } from "../tui.js";
|
||||||
import { truncateToWidth } from "../utils.js";
|
import { truncateToWidth } from "../utils.js";
|
||||||
|
|
@ -38,9 +39,13 @@ export class SelectList implements Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilter(filter: string): void {
|
setFilter(filter: string): void {
|
||||||
this.filteredItems = this.items.filter((item) =>
|
const needle = filter.trim();
|
||||||
item.value.toLowerCase().startsWith(filter.toLowerCase()),
|
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
|
// Reset selection when filter changes
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ export {
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
} from "./autocomplete.js";
|
} from "./autocomplete.js";
|
||||||
// Components
|
// Components
|
||||||
|
export {
|
||||||
|
type AutonomousModeStatus,
|
||||||
|
AutonomousStatusBar,
|
||||||
|
} from "./components/autonomous-status-bar.js";
|
||||||
export { Box } from "./components/box.js";
|
export { Box } from "./components/box.js";
|
||||||
export { CancellableLoader } from "./components/cancellable-loader.js";
|
export { CancellableLoader } from "./components/cancellable-loader.js";
|
||||||
export {
|
export {
|
||||||
|
|
@ -42,8 +46,8 @@ export { Text } from "./components/text.js";
|
||||||
export { TruncatedText } from "./components/truncated-text.js";
|
export { TruncatedText } from "./components/truncated-text.js";
|
||||||
// Editor component interface (for custom editors)
|
// Editor component interface (for custom editors)
|
||||||
export type { EditorComponent } from "./editor-component.js";
|
export type { EditorComponent } from "./editor-component.js";
|
||||||
// Fuzzy matching
|
// Ink bridge — gradual migration infrastructure
|
||||||
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";
|
export { startInkRenderer } from "./ink-bridge.js";
|
||||||
// Keybindings
|
// Keybindings
|
||||||
export {
|
export {
|
||||||
DEFAULT_EDITOR_KEYBINDINGS,
|
DEFAULT_EDITOR_KEYBINDINGS,
|
||||||
|
|
@ -66,6 +70,8 @@ export {
|
||||||
parseKey,
|
parseKey,
|
||||||
setKittyProtocolActive,
|
setKittyProtocolActive,
|
||||||
} from "./keys.js";
|
} from "./keys.js";
|
||||||
|
// Render safety — prevents one failing component from blanking the TUI
|
||||||
|
export { tryRender } from "./render-guard.js";
|
||||||
// Input buffering for batch splitting
|
// Input buffering for batch splitting
|
||||||
export {
|
export {
|
||||||
StdinBuffer,
|
StdinBuffer,
|
||||||
|
|
@ -111,5 +117,3 @@ export {
|
||||||
} from "./tui.js";
|
} from "./tui.js";
|
||||||
// Utilities
|
// Utilities
|
||||||
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js";
|
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
|
// Kill ring
|
||||||
| "yank"
|
| "yank"
|
||||||
| "yankPop"
|
| "yankPop"
|
||||||
// Undo
|
// Undo / redo
|
||||||
| "undo"
|
| "undo"
|
||||||
|
| "redo"
|
||||||
|
| "redo"
|
||||||
// Tool output
|
// Tool output
|
||||||
| "expandTools"
|
| "expandTools"
|
||||||
// Tree navigation
|
// Tree navigation
|
||||||
|
|
@ -104,8 +106,9 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
|
||||||
// Kill ring
|
// Kill ring
|
||||||
yank: "ctrl+y",
|
yank: "ctrl+y",
|
||||||
yankPop: "alt+y",
|
yankPop: "alt+y",
|
||||||
// Undo
|
// Undo / redo
|
||||||
undo: "ctrl+-",
|
undo: "ctrl+-",
|
||||||
|
redo: "ctrl+shift+z",
|
||||||
// Tool output
|
// Tool output
|
||||||
expandTools: "ctrl+o",
|
expandTools: "ctrl+o",
|
||||||
// Tree navigation
|
// Tree navigation
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* positions and composite overlay content onto base terminal lines.
|
* positions and composite overlay content onto base terminal lines.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { tryRender } from "./render-guard.js";
|
||||||
import { isImageLine } from "./terminal-image.js";
|
import { isImageLine } from "./terminal-image.js";
|
||||||
import type { OverlayAnchor, OverlayOptions, SizeValue } from "./tui.js";
|
import type { OverlayAnchor, OverlayOptions, SizeValue } from "./tui.js";
|
||||||
import { CURSOR_MARKER } from "./tui.js";
|
import { CURSOR_MARKER } from "./tui.js";
|
||||||
|
|
@ -344,8 +345,12 @@ export function compositeOverlays(
|
||||||
termHeight,
|
termHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render component at calculated width
|
// Render component at calculated width (isolated from base frame errors)
|
||||||
let overlayLines = component.render(width);
|
let overlayLines = tryRender(
|
||||||
|
component,
|
||||||
|
width,
|
||||||
|
`overlay[focusOrder=${entry.focusOrder}]`,
|
||||||
|
);
|
||||||
|
|
||||||
// Apply maxHeight if specified
|
// Apply maxHeight if specified
|
||||||
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
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 fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
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 { startInkRenderer } from "./ink-bridge.js";
|
||||||
import { isKeyRelease, matchesKey } from "./keys.js";
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +21,7 @@ import {
|
||||||
extractCursorPosition,
|
extractCursorPosition,
|
||||||
isOverlayVisible as isOverlayEntryVisible,
|
isOverlayVisible as isOverlayEntryVisible,
|
||||||
} from "./overlay-layout.js";
|
} from "./overlay-layout.js";
|
||||||
|
import { tryRender } from "./render-guard.js";
|
||||||
import type { Terminal } from "./terminal.js";
|
import type { Terminal } from "./terminal.js";
|
||||||
import {
|
import {
|
||||||
getCapabilities,
|
getCapabilities,
|
||||||
|
|
@ -223,8 +232,9 @@ export class Container implements Component {
|
||||||
|
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
let index = 0;
|
||||||
for (const child of this.children) {
|
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]);
|
for (let i = 0; i < rendered.length; i++) lines.push(rendered[i]);
|
||||||
}
|
}
|
||||||
// Return stable reference if output unchanged — allows doRender()
|
// Return stable reference if output unchanged — allows doRender()
|
||||||
|
|
@ -279,14 +289,8 @@ export class TUI extends Container {
|
||||||
// === Sticky bottom scrolling ===
|
// === Sticky bottom scrolling ===
|
||||||
private isScrolledToBottom = true; // Track if user is scrolled to bottom
|
private isScrolledToBottom = true; // Track if user is scrolled to bottom
|
||||||
|
|
||||||
// === Autonomous mode info bar ===
|
// === Autonomous mode info strip (component — keeps chrome out of core TUI logic) ===
|
||||||
public autonomousStatus?: {
|
private readonly autonomousBar = new AutonomousStatusBar();
|
||||||
currentSlice?: string;
|
|
||||||
sliceStatus?: string;
|
|
||||||
progress?: number;
|
|
||||||
totalTasks?: number;
|
|
||||||
completedTasks?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Overlay stack for modal components rendered on top of base content
|
// Overlay stack for modal components rendered on top of base content
|
||||||
private focusOrderCounter = 0;
|
private focusOrderCounter = 0;
|
||||||
|
|
@ -503,9 +507,8 @@ export class TUI extends Container {
|
||||||
render: (w) => this.render(w),
|
render: (w) => this.render(w),
|
||||||
invalidate: () => this.invalidate(),
|
invalidate: () => this.invalidate(),
|
||||||
};
|
};
|
||||||
this._inkHandle = startInkRenderer(
|
this._inkHandle = startInkRenderer(root, (data) =>
|
||||||
root,
|
this.handleInput(data),
|
||||||
(data) => this.handleInput(data),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -636,63 +639,11 @@ export class TUI extends Container {
|
||||||
/**
|
/**
|
||||||
* Update autonomous status information
|
* Update autonomous status information
|
||||||
*/
|
*/
|
||||||
updateAutonomousStatus(status: {
|
updateAutonomousStatus(status: AutonomousModeStatus | undefined): void {
|
||||||
currentSlice?: string;
|
this.autonomousBar.setStatus(status);
|
||||||
sliceStatus?: string;
|
|
||||||
progress?: number;
|
|
||||||
totalTasks?: number;
|
|
||||||
completedTasks?: number;
|
|
||||||
}): void {
|
|
||||||
this.autonomousStatus = status;
|
|
||||||
this.requestRender();
|
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 {
|
private handleInput(data: string): void {
|
||||||
if (this.inputListeners.size > 0) {
|
if (this.inputListeners.size > 0) {
|
||||||
let current = data;
|
let current = data;
|
||||||
|
|
@ -773,24 +724,18 @@ export class TUI extends Container {
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseCellSizeResponse(): string {
|
private parseCellSizeResponse(): string {
|
||||||
// Response format: ESC [ 6 ; height ; width t
|
// Response format: ESC [ 6 ; height ; width t — parsed via cell-size.ts
|
||||||
// Match the response pattern
|
const parsed = parseCellSizeResponseFn(this.inputBuffer);
|
||||||
const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
|
|
||||||
const match = this.inputBuffer.match(responsePattern);
|
|
||||||
|
|
||||||
if (match) {
|
if (parsed) {
|
||||||
const heightPx = parseInt(match[1], 10);
|
const { height: heightPx, width: widthPx } = parsed;
|
||||||
const widthPx = parseInt(match[2], 10);
|
setCellDimensions({ widthPx, heightPx });
|
||||||
|
// Invalidate all components so images re-render with correct dimensions
|
||||||
if (heightPx > 0 && widthPx > 0) {
|
this.invalidate();
|
||||||
setCellDimensions({ widthPx, heightPx });
|
this.requestRender();
|
||||||
// Invalidate all components so images re-render with correct dimensions
|
|
||||||
this.invalidate();
|
|
||||||
this.requestRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the response from buffer
|
// Remove the response from buffer
|
||||||
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
|
this.inputBuffer = stripCellSizeResponse(this.inputBuffer);
|
||||||
this.cellSizeQueryPending = false;
|
this.cellSizeQueryPending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -825,365 +770,371 @@ export class TUI extends Container {
|
||||||
|
|
||||||
private doRender(): void {
|
private doRender(): void {
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
const width = this.terminal.columns;
|
try {
|
||||||
const height = this.terminal.rows;
|
const width = this.terminal.columns;
|
||||||
let viewportTop = Math.max(0, this.maxLinesRendered - height);
|
const height = this.terminal.rows;
|
||||||
let prevViewportTop = this.previousViewportTop;
|
let viewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
let hardwareCursorRow = this.hardwareCursorRow;
|
let prevViewportTop = this.previousViewportTop;
|
||||||
const computeLineDiff = (targetRow: number): number => {
|
let hardwareCursorRow = this.hardwareCursorRow;
|
||||||
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
const computeLineDiff = (targetRow: number): number => {
|
||||||
const targetScreenRow = targetRow - viewportTop;
|
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
||||||
return targetScreenRow - currentScreenRow;
|
const targetScreenRow = targetRow - viewportTop;
|
||||||
};
|
return targetScreenRow - currentScreenRow;
|
||||||
|
};
|
||||||
|
|
||||||
// Render all components to get new lines
|
// Render all components to get new lines
|
||||||
let newLines = this.render(width);
|
let newLines = this.render(width);
|
||||||
|
|
||||||
// Add autonomous status bar at the top if in autonomous mode
|
const statusBarLines = this.autonomousBar.render(width);
|
||||||
const statusBarLines = this.renderAutonomousStatusBar(width);
|
if (statusBarLines.length > 0) {
|
||||||
if (statusBarLines.length > 0) {
|
newLines = [...statusBarLines, ...newLines];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
buffer += "\x1b[?2026l"; // End synchronized output
|
|
||||||
this.terminal.write(buffer);
|
// Check if content grew and we should scroll to bottom (sticky bottom behavior)
|
||||||
this.cursorRow = Math.max(0, newLines.length - 1);
|
const contentGrew = newLines.length > this.previousLines.length;
|
||||||
this.hardwareCursorRow = this.cursorRow;
|
const shouldScrollToBottom = contentGrew && this.isScrolledToBottom;
|
||||||
// Reset max lines when clearing, otherwise track growth
|
|
||||||
if (clear) {
|
// Skip ALL post-processing if component output is unchanged.
|
||||||
this.maxLinesRendered = newLines.length;
|
// Container.render() returns the same array reference when stable.
|
||||||
} else {
|
if (
|
||||||
this.maxLinesRendered = Math.max(
|
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,
|
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";
|
// Extract cursor position before applying line resets (marker must be found first)
|
||||||
const logRedraw = (reason: string): void => {
|
const cursorPos = extractCursorPosition(newLines, height);
|
||||||
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)
|
newLines = applyLineResets(newLines);
|
||||||
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
|
|
||||||
logRedraw("first render");
|
|
||||||
fullRender(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Width or height changed - full re-render
|
// Width or height changed - need full re-render
|
||||||
if (widthChanged || heightChanged) {
|
const widthChanged =
|
||||||
logRedraw(
|
this.previousWidth !== 0 && this.previousWidth !== width;
|
||||||
`terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`,
|
const heightChanged =
|
||||||
);
|
this.previousHeight !== 0 && this.previousHeight !== height;
|
||||||
fullRender(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content shrunk below the working area and no overlays - re-render to clear empty rows
|
// Helper to clear scrollback and viewport and render all new lines
|
||||||
// (overlays need the padding, so only do this when no overlays are active)
|
const fullRender = (clear: boolean): void => {
|
||||||
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
this.fullRedrawCount += 1;
|
||||||
if (
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||||
this.clearOnShrink &&
|
if (clear) buffer += "\x1b[2J\x1b[H"; // Clear screen and home (no scrollback clear — preserves view position)
|
||||||
newLines.length < this.maxLinesRendered &&
|
for (let i = 0; i < newLines.length; i++) {
|
||||||
this.overlayStack.length === 0
|
if (i > 0) buffer += "\r\n";
|
||||||
) {
|
let line = newLines[i];
|
||||||
if (!this._shrinkDebounceActive) {
|
if (!isImageLine(line) && visibleWidth(line) > width) {
|
||||||
// First shrink detection: defer the full redraw by one tick.
|
line = truncateToWidth(line, width);
|
||||||
// If content grows back immediately (pinned clear → new streaming),
|
}
|
||||||
// the full redraw is avoided.
|
buffer += line;
|
||||||
this._shrinkDebounceActive = true;
|
}
|
||||||
// Do NOT update maxLinesRendered here — keep the old value so the
|
buffer += "\x1b[?2026l"; // End synchronized output
|
||||||
// condition `newLines.length < maxLinesRendered` still triggers on
|
this.terminal.write(buffer);
|
||||||
// the next render if content stays shrunk.
|
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(
|
logRedraw(
|
||||||
`clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`,
|
`terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`,
|
||||||
);
|
|
||||||
// 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);
|
fullRender(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
this._shrinkDebounceActive = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find first and last changed lines
|
// Content shrunk below the working area and no overlays - re-render to clear empty rows
|
||||||
let firstChanged = -1;
|
// (overlays need the padding, so only do this when no overlays are active)
|
||||||
let lastChanged = -1;
|
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
||||||
const maxLines = Math.max(newLines.length, this.previousLines.length);
|
if (
|
||||||
for (let i = 0; i < maxLines; i++) {
|
this.clearOnShrink &&
|
||||||
const oldLine =
|
newLines.length < this.maxLinesRendered &&
|
||||||
i < this.previousLines.length ? this.previousLines[i] : "";
|
this.overlayStack.length === 0
|
||||||
const newLine = i < newLines.length ? newLines[i] : "";
|
) {
|
||||||
|
if (!this._shrinkDebounceActive) {
|
||||||
if (oldLine !== newLine) {
|
// First shrink detection: defer the full redraw by one tick.
|
||||||
if (firstChanged === -1) {
|
// If content grows back immediately (pinned clear → new streaming),
|
||||||
firstChanged = i;
|
// the full redraw is avoided.
|
||||||
}
|
this._shrinkDebounceActive = true;
|
||||||
lastChanged = i;
|
// 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.
|
||||||
const appendedLines = newLines.length > this.previousLines.length;
|
logRedraw(
|
||||||
if (appendedLines) {
|
`clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`,
|
||||||
if (firstChanged === -1) {
|
);
|
||||||
firstChanged = this.previousLines.length;
|
// Fall through to differential render for this frame
|
||||||
}
|
} else {
|
||||||
lastChanged = newLines.length - 1;
|
// Still shrunk on second render — commit the full redraw
|
||||||
}
|
this._shrinkDebounceActive = false;
|
||||||
const appendStart =
|
logRedraw(
|
||||||
appendedLines &&
|
`clearOnShrink committed (maxLinesRendered=${this.maxLinesRendered})`,
|
||||||
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);
|
fullRender(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (extraLines > 0) {
|
} else {
|
||||||
buffer += "\x1b[1B";
|
this._shrinkDebounceActive = false;
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
|
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
this.previousHeight = height;
|
this.previousHeight = height;
|
||||||
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
} catch (err) {
|
||||||
return;
|
console.error("[TUI] render error:", err);
|
||||||
}
|
process.stdout.write(
|
||||||
|
"\x1b[2K\x1b[31m[TUI render error — see stderr]\x1b[0m\n",
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,49 @@
|
||||||
*/
|
*/
|
||||||
export class UndoStack<S> {
|
export class UndoStack<S> {
|
||||||
private stack: 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 {
|
push(state: S): void {
|
||||||
this.stack.push(structuredClone(state));
|
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 {
|
pop(): S | undefined {
|
||||||
return this.stack.pop();
|
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 {
|
clear(): void {
|
||||||
this.stack.length = 0;
|
this.stack.length = 0;
|
||||||
|
this.redoStack.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get length(): number {
|
get length(): number {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue