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) {
|
||||
if (parsed) {
|
||||
const { height: heightPx, width: widthPx } = parsed;
|
||||
setCellDimensions({ widthPx, heightPx });
|
||||
// Invalidate all components so images re-render with correct dimensions
|
||||
this.invalidate();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
// Remove the response from buffer
|
||||
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
|
||||
this.inputBuffer = stripCellSizeResponse(this.inputBuffer);
|
||||
this.cellSizeQueryPending = false;
|
||||
}
|
||||
|
||||
|
|
@ -825,6 +770,7 @@ export class TUI extends Container {
|
|||
|
||||
private doRender(): void {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
const width = this.terminal.columns;
|
||||
const height = this.terminal.rows;
|
||||
let viewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||
|
|
@ -839,8 +785,7 @@ export class TUI extends Container {
|
|||
// Render all components to get new lines
|
||||
let newLines = this.render(width);
|
||||
|
||||
// Add autonomous status bar at the top if in autonomous mode
|
||||
const statusBarLines = this.renderAutonomousStatusBar(width);
|
||||
const statusBarLines = this.autonomousBar.render(width);
|
||||
if (statusBarLines.length > 0) {
|
||||
newLines = [...statusBarLines, ...newLines];
|
||||
}
|
||||
|
|
@ -1184,6 +1129,12 @@ export class TUI extends Container {
|
|||
this.previousLines = newLines;
|
||||
this.previousWidth = width;
|
||||
this.previousHeight = height;
|
||||
} catch (err) {
|
||||
console.error("[TUI] render error:", err);
|
||||
process.stdout.write(
|
||||
"\x1b[2K\x1b[31m[TUI render error — see stderr]\x1b[0m\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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