diff --git a/packages/tui/src/__tests__/overlay-layout.test.ts b/packages/tui/src/__tests__/overlay-layout.test.ts index f2efb14b1..d207fb347 100644 --- a/packages/tui/src/__tests__/overlay-layout.test.ts +++ b/packages/tui/src/__tests__/overlay-layout.test.ts @@ -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; + } + } + }); }); diff --git a/packages/tui/src/__tests__/tui.test.ts b/packages/tui/src/__tests__/tui.test.ts index 228727703..6248dc52b 100644 --- a/packages/tui/src/__tests__/tui.test.ts +++ b/packages/tui/src/__tests__/tui.test.ts @@ -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", + ); }); }); diff --git a/packages/tui/src/cell-size.ts b/packages/tui/src/cell-size.ts new file mode 100644 index 000000000..b4a6c621c --- /dev/null +++ b/packages/tui/src/cell-size.ts @@ -0,0 +1,37 @@ +/** + * Pure helper for parsing terminal cell size query responses. + * + * Terminal responds to `CSI 16 t` with: `CSI 6 ; ; 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, ""); +} diff --git a/packages/tui/src/components/__tests__/input.test.ts b/packages/tui/src/components/__tests__/input.test.ts index 8c4eefefa..079e94020 100644 --- a/packages/tui/src/components/__tests__/input.test.ts +++ b/packages/tui/src/components/__tests__/input.test.ts @@ -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"); + }); }); diff --git a/packages/tui/src/components/__tests__/select-list.test.ts b/packages/tui/src/components/__tests__/select-list.test.ts new file mode 100644 index 000000000..f663cf2a5 --- /dev/null +++ b/packages/tui/src/components/__tests__/select-list.test.ts @@ -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"); + }); +}); diff --git a/packages/tui/src/components/autonomous-status-bar.ts b/packages/tui/src/components/autonomous-status-bar.ts new file mode 100644 index 000000000..bcc51081a --- /dev/null +++ b/packages/tui/src/components/autonomous-status-bar.ts @@ -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}%`; +} diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 9bd6f9d04..f75b9c3b4 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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; diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 26d9ea6f9..2975626f3 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -52,6 +52,7 @@ export class Input implements Component, Focusable { // Undo support private undoStack = new UndoStack(); + private redoStack = new UndoStack(); 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; diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 0c924c108..edb39c104 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -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; } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 4b7f45080..62627adc2 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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"; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index cafc9e235..d6566b0b3 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -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 = { // Kill ring yank: "ctrl+y", yankPop: "alt+y", - // Undo + // Undo / redo undo: "ctrl+-", + redo: "ctrl+shift+z", // Tool output expandTools: "ctrl+o", // Tree navigation diff --git a/packages/tui/src/overlay-layout.ts b/packages/tui/src/overlay-layout.ts index 0c93df961..e01520f0a 100644 --- a/packages/tui/src/overlay-layout.ts +++ b/packages/tui/src/overlay-layout.ts @@ -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) { diff --git a/packages/tui/src/render-guard.ts b/packages/tui/src/render-guard.ts new file mode 100644 index 000000000..4c8faffed --- /dev/null +++ b/packages/tui/src/render-guard.ts @@ -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, + 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`]; + } +} diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index fd67d1d9d..593a079e2 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5,6 +5,14 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { + parseCellSizeResponse as parseCellSizeResponseFn, + stripCellSizeResponse, +} from "./cell-size.js"; +import { + type AutonomousModeStatus, + AutonomousStatusBar, +} from "./components/autonomous-status-bar.js"; import { startInkRenderer } from "./ink-bridge.js"; import { isKeyRelease, matchesKey } from "./keys.js"; import { @@ -13,6 +21,7 @@ import { extractCursorPosition, isOverlayVisible as isOverlayEntryVisible, } from "./overlay-layout.js"; +import { tryRender } from "./render-guard.js"; import type { Terminal } from "./terminal.js"; import { getCapabilities, @@ -223,8 +232,9 @@ export class Container implements Component { render(width: number): string[] { const lines: string[] = []; + let index = 0; for (const child of this.children) { - const rendered = child.render(width); + const rendered = tryRender(child, width, `child[${index++}]`); for (let i = 0; i < rendered.length; i++) lines.push(rendered[i]); } // Return stable reference if output unchanged — allows doRender() @@ -279,14 +289,8 @@ export class TUI extends Container { // === Sticky bottom scrolling === private isScrolledToBottom = true; // Track if user is scrolled to bottom - // === Autonomous mode info bar === - public autonomousStatus?: { - currentSlice?: string; - sliceStatus?: string; - progress?: number; - totalTasks?: number; - completedTasks?: number; - }; + // === Autonomous mode info strip (component — keeps chrome out of core TUI logic) === + private readonly autonomousBar = new AutonomousStatusBar(); // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; @@ -503,9 +507,8 @@ export class TUI extends Container { render: (w) => this.render(w), invalidate: () => this.invalidate(), }; - this._inkHandle = startInkRenderer( - root, - (data) => this.handleInput(data), + this._inkHandle = startInkRenderer(root, (data) => + this.handleInput(data), ); return; } @@ -636,63 +639,11 @@ export class TUI extends Container { /** * Update autonomous status information */ - updateAutonomousStatus(status: { - currentSlice?: string; - sliceStatus?: string; - progress?: number; - totalTasks?: number; - completedTasks?: number; - }): void { - this.autonomousStatus = status; + updateAutonomousStatus(status: AutonomousModeStatus | undefined): void { + this.autonomousBar.setStatus(status); this.requestRender(); } - /** - * Render autonomous mode info bar - */ - private renderAutonomousStatusBar(width: number): string[] { - if (!this.autonomousStatus) return []; - - const { currentSlice, sliceStatus, progress, totalTasks, completedTasks } = - this.autonomousStatus; - const lines: string[] = []; - - // Create status bar line - let statusLine = "\x1b[90m│ AUTONOMOUS MODE "; - - if (currentSlice) { - statusLine += `\x1b[97mSlice: \x1b[96m${currentSlice} `; - } - - if (sliceStatus) { - statusLine += `\x1b[97mStatus: \x1b[92m${sliceStatus} `; - } - - if (progress !== undefined) { - const progressBar = this.createProgressBar(progress, width - 30); - statusLine += `\x1b[97mProgress: \x1b[93m${progressBar} `; - } - - if (totalTasks !== undefined && completedTasks !== undefined) { - statusLine += `\x1b[97mTasks: \x1b[95m${completedTasks}/${totalTasks} `; - } - - statusLine += "\x1b[90m│\x1b[0m"; - lines.push(statusLine); - - return lines; - } - - /** - * Create a simple ASCII progress bar - */ - private createProgressBar(progress: number, width: number): string { - const barWidth = Math.min(20, Math.max(5, width)); - const filled = Math.floor((progress / 100) * barWidth); - const empty = barWidth - filled; - return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${progress}%`; - } - private handleInput(data: string): void { if (this.inputListeners.size > 0) { let current = data; @@ -773,24 +724,18 @@ export class TUI extends Container { } private parseCellSizeResponse(): string { - // Response format: ESC [ 6 ; height ; width t - // Match the response pattern - const responsePattern = /\x1b\[6;(\d+);(\d+)t/; - const match = this.inputBuffer.match(responsePattern); + // Response format: ESC [ 6 ; height ; width t — parsed via cell-size.ts + const parsed = parseCellSizeResponseFn(this.inputBuffer); - if (match) { - const heightPx = parseInt(match[1], 10); - const widthPx = parseInt(match[2], 10); - - if (heightPx > 0 && widthPx > 0) { - setCellDimensions({ widthPx, heightPx }); - // Invalidate all components so images re-render with correct dimensions - this.invalidate(); - this.requestRender(); - } + if (parsed) { + const { height: heightPx, width: widthPx } = parsed; + setCellDimensions({ widthPx, heightPx }); + // Invalidate all components so images re-render with correct dimensions + this.invalidate(); + this.requestRender(); // Remove the response from buffer - this.inputBuffer = this.inputBuffer.replace(responsePattern, ""); + this.inputBuffer = stripCellSizeResponse(this.inputBuffer); this.cellSizeQueryPending = false; } @@ -825,365 +770,371 @@ export class TUI extends Container { private doRender(): void { if (this.stopped) return; - const width = this.terminal.columns; - const height = this.terminal.rows; - let viewportTop = Math.max(0, this.maxLinesRendered - height); - let prevViewportTop = this.previousViewportTop; - let hardwareCursorRow = this.hardwareCursorRow; - const computeLineDiff = (targetRow: number): number => { - const currentScreenRow = hardwareCursorRow - prevViewportTop; - const targetScreenRow = targetRow - viewportTop; - return targetScreenRow - currentScreenRow; - }; + try { + const width = this.terminal.columns; + const height = this.terminal.rows; + let viewportTop = Math.max(0, this.maxLinesRendered - height); + let prevViewportTop = this.previousViewportTop; + let hardwareCursorRow = this.hardwareCursorRow; + const computeLineDiff = (targetRow: number): number => { + const currentScreenRow = hardwareCursorRow - prevViewportTop; + const targetScreenRow = targetRow - viewportTop; + return targetScreenRow - currentScreenRow; + }; - // Render all components to get new lines - let newLines = this.render(width); + // Render all components to get new lines + let newLines = this.render(width); - // Add autonomous status bar at the top if in autonomous mode - const statusBarLines = this.renderAutonomousStatusBar(width); - if (statusBarLines.length > 0) { - newLines = [...statusBarLines, ...newLines]; - } - - // Check if content grew and we should scroll to bottom (sticky bottom behavior) - const contentGrew = newLines.length > this.previousLines.length; - const shouldScrollToBottom = contentGrew && this.isScrolledToBottom; - - // Skip ALL post-processing if component output is unchanged. - // Container.render() returns the same array reference when stable. - if ( - newLines === this._lastRenderedComponents && - this.overlayStack.length === 0 && - !shouldScrollToBottom - ) { - return; - } - this._lastRenderedComponents = newLines; - - // Composite overlays into the rendered lines (before differential compare) - if (this.overlayStack.length > 0) { - newLines = compositeOverlays( - newLines, - this.overlayStack, - width, - height, - this.maxLinesRendered, - ); - } - - // Extract cursor position before applying line resets (marker must be found first) - const cursorPos = extractCursorPosition(newLines, height); - - newLines = applyLineResets(newLines); - - // Width or height changed - need full re-render - const widthChanged = - this.previousWidth !== 0 && this.previousWidth !== width; - const heightChanged = - this.previousHeight !== 0 && this.previousHeight !== height; - - // Helper to clear scrollback and viewport and render all new lines - const fullRender = (clear: boolean): void => { - this.fullRedrawCount += 1; - let buffer = "\x1b[?2026h"; // Begin synchronized output - if (clear) buffer += "\x1b[2J\x1b[H"; // Clear screen and home (no scrollback clear — preserves view position) - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; - let line = newLines[i]; - if (!isImageLine(line) && visibleWidth(line) > width) { - line = truncateToWidth(line, width); - } - buffer += line; + const statusBarLines = this.autonomousBar.render(width); + if (statusBarLines.length > 0) { + newLines = [...statusBarLines, ...newLines]; } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - this.cursorRow = Math.max(0, newLines.length - 1); - this.hardwareCursorRow = this.cursorRow; - // Reset max lines when clearing, otherwise track growth - if (clear) { - this.maxLinesRendered = newLines.length; - } else { - this.maxLinesRendered = Math.max( + + // Check if content grew and we should scroll to bottom (sticky bottom behavior) + const contentGrew = newLines.length > this.previousLines.length; + const shouldScrollToBottom = contentGrew && this.isScrolledToBottom; + + // Skip ALL post-processing if component output is unchanged. + // Container.render() returns the same array reference when stable. + if ( + newLines === this._lastRenderedComponents && + this.overlayStack.length === 0 && + !shouldScrollToBottom + ) { + return; + } + this._lastRenderedComponents = newLines; + + // Composite overlays into the rendered lines (before differential compare) + if (this.overlayStack.length > 0) { + newLines = compositeOverlays( + newLines, + this.overlayStack, + width, + height, this.maxLinesRendered, - newLines.length, ); } - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; - this.previousWidth = width; - this.previousHeight = height; - }; - const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; - const logRedraw = (reason: string): void => { - if (!debugRedraw) return; - const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log"); - const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; - fs.appendFileSync(logPath, msg); - }; + // Extract cursor position before applying line resets (marker must be found first) + const cursorPos = extractCursorPosition(newLines, height); - // First render - just output everything without clearing (assumes clean screen) - if (this.previousLines.length === 0 && !widthChanged && !heightChanged) { - logRedraw("first render"); - fullRender(false); - return; - } + newLines = applyLineResets(newLines); - // Width or height changed - full re-render - if (widthChanged || heightChanged) { - logRedraw( - `terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`, - ); - fullRender(true); - return; - } + // Width or height changed - need full re-render + const widthChanged = + this.previousWidth !== 0 && this.previousWidth !== width; + const heightChanged = + this.previousHeight !== 0 && this.previousHeight !== height; - // Content shrunk below the working area and no overlays - re-render to clear empty rows - // (overlays need the padding, so only do this when no overlays are active) - // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var - if ( - this.clearOnShrink && - newLines.length < this.maxLinesRendered && - this.overlayStack.length === 0 - ) { - if (!this._shrinkDebounceActive) { - // First shrink detection: defer the full redraw by one tick. - // If content grows back immediately (pinned clear → new streaming), - // the full redraw is avoided. - this._shrinkDebounceActive = true; - // Do NOT update maxLinesRendered here — keep the old value so the - // condition `newLines.length < maxLinesRendered` still triggers on - // the next render if content stays shrunk. + // Helper to clear scrollback and viewport and render all new lines + const fullRender = (clear: boolean): void => { + this.fullRedrawCount += 1; + let buffer = "\x1b[?2026h"; // Begin synchronized output + if (clear) buffer += "\x1b[2J\x1b[H"; // Clear screen and home (no scrollback clear — preserves view position) + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + let line = newLines[i]; + if (!isImageLine(line) && visibleWidth(line) > width) { + line = truncateToWidth(line, width); + } + buffer += line; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = this.cursorRow; + // Reset max lines when clearing, otherwise track growth + if (clear) { + this.maxLinesRendered = newLines.length; + } else { + this.maxLinesRendered = Math.max( + this.maxLinesRendered, + newLines.length, + ); + } + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousWidth = width; + this.previousHeight = height; + }; + + const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; + const logRedraw = (reason: string): void => { + if (!debugRedraw) return; + const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log"); + const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; + fs.appendFileSync(logPath, msg); + }; + + // First render - just output everything without clearing (assumes clean screen) + if (this.previousLines.length === 0 && !widthChanged && !heightChanged) { + logRedraw("first render"); + fullRender(false); + return; + } + + // Width or height changed - full re-render + if (widthChanged || heightChanged) { logRedraw( - `clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`, - ); - // Fall through to differential render for this frame - } else { - // Still shrunk on second render — commit the full redraw - this._shrinkDebounceActive = false; - logRedraw( - `clearOnShrink committed (maxLinesRendered=${this.maxLinesRendered})`, + `terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`, ); fullRender(true); return; } - } else { - this._shrinkDebounceActive = false; - } - // Find first and last changed lines - let firstChanged = -1; - let lastChanged = -1; - const maxLines = Math.max(newLines.length, this.previousLines.length); - for (let i = 0; i < maxLines; i++) { - const oldLine = - i < this.previousLines.length ? this.previousLines[i] : ""; - const newLine = i < newLines.length ? newLines[i] : ""; - - if (oldLine !== newLine) { - if (firstChanged === -1) { - firstChanged = i; - } - lastChanged = i; - } - } - const appendedLines = newLines.length > this.previousLines.length; - if (appendedLines) { - if (firstChanged === -1) { - firstChanged = this.previousLines.length; - } - lastChanged = newLines.length - 1; - } - const appendStart = - appendedLines && - firstChanged === this.previousLines.length && - firstChanged > 0; - - // No changes - but still need to update hardware cursor position if it moved - if (firstChanged === -1) { - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); - this.previousHeight = height; - return; - } - - // All changes are in deleted lines (nothing to render, just clear) - if (firstChanged >= newLines.length) { - if (this.previousLines.length > newLines.length) { - let buffer = "\x1b[?2026h"; - // Move to end of new content (clamp to 0 for empty content) - const targetRow = Math.max(0, newLines.length - 1); - const lineDiff = computeLineDiff(targetRow); - if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; - else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; - buffer += "\r"; - // Clear extra lines without scrolling - const extraLines = this.previousLines.length - newLines.length; - if (extraLines > height) { - logRedraw(`extraLines > height (${extraLines} > ${height})`); + // Content shrunk below the working area and no overlays - re-render to clear empty rows + // (overlays need the padding, so only do this when no overlays are active) + // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var + if ( + this.clearOnShrink && + newLines.length < this.maxLinesRendered && + this.overlayStack.length === 0 + ) { + if (!this._shrinkDebounceActive) { + // First shrink detection: defer the full redraw by one tick. + // If content grows back immediately (pinned clear → new streaming), + // the full redraw is avoided. + this._shrinkDebounceActive = true; + // Do NOT update maxLinesRendered here — keep the old value so the + // condition `newLines.length < maxLinesRendered` still triggers on + // the next render if content stays shrunk. + logRedraw( + `clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`, + ); + // Fall through to differential render for this frame + } else { + // Still shrunk on second render — commit the full redraw + this._shrinkDebounceActive = false; + logRedraw( + `clearOnShrink committed (maxLinesRendered=${this.maxLinesRendered})`, + ); fullRender(true); return; } - if (extraLines > 0) { - buffer += "\x1b[1B"; - } - for (let i = 0; i < extraLines; i++) { - buffer += "\r\x1b[2K"; - if (i < extraLines - 1) buffer += "\x1b[1B"; - } - if (extraLines > 0) { - buffer += `\x1b[${extraLines}A`; - } - buffer += "\x1b[?2026l"; - this.terminal.write(buffer); - this.cursorRow = targetRow; - this.hardwareCursorRow = targetRow; + } else { + this._shrinkDebounceActive = false; } + + // Find first and last changed lines + let firstChanged = -1; + let lastChanged = -1; + const maxLines = Math.max(newLines.length, this.previousLines.length); + for (let i = 0; i < maxLines; i++) { + const oldLine = + i < this.previousLines.length ? this.previousLines[i] : ""; + const newLine = i < newLines.length ? newLines[i] : ""; + + if (oldLine !== newLine) { + if (firstChanged === -1) { + firstChanged = i; + } + lastChanged = i; + } + } + const appendedLines = newLines.length > this.previousLines.length; + if (appendedLines) { + if (firstChanged === -1) { + firstChanged = this.previousLines.length; + } + lastChanged = newLines.length - 1; + } + const appendStart = + appendedLines && + firstChanged === this.previousLines.length && + firstChanged > 0; + + // No changes - but still need to update hardware cursor position if it moved + if (firstChanged === -1) { + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.previousHeight = height; + return; + } + + // All changes are in deleted lines (nothing to render, just clear) + if (firstChanged >= newLines.length) { + if (this.previousLines.length > newLines.length) { + let buffer = "\x1b[?2026h"; + // Move to end of new content (clamp to 0 for empty content) + const targetRow = Math.max(0, newLines.length - 1); + const lineDiff = computeLineDiff(targetRow); + if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; + else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; + buffer += "\r"; + // Clear extra lines without scrolling + const extraLines = this.previousLines.length - newLines.length; + if (extraLines > height) { + logRedraw(`extraLines > height (${extraLines} > ${height})`); + fullRender(true); + return; + } + if (extraLines > 0) { + buffer += "\x1b[1B"; + } + for (let i = 0; i < extraLines; i++) { + buffer += "\r\x1b[2K"; + if (i < extraLines - 1) buffer += "\x1b[1B"; + } + if (extraLines > 0) { + buffer += `\x1b[${extraLines}A`; + } + buffer += "\x1b[?2026l"; + this.terminal.write(buffer); + this.cursorRow = targetRow; + this.hardwareCursorRow = targetRow; + } + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousWidth = width; + this.previousHeight = height; + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + return; + } + + // Check if firstChanged is above what was previously visible + // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks + const previousContentViewportTop = Math.max( + 0, + this.previousLines.length - height, + ); + if (firstChanged < previousContentViewportTop) { + // First change is above previous viewport - need full re-render + logRedraw( + `firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`, + ); + fullRender(true); + return; + } + + // Render from first changed line to end + // Build buffer with all updates wrapped in synchronized output + let buffer = "\x1b[?2026h"; // Begin synchronized output + const prevViewportBottom = prevViewportTop + height - 1; + const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged; + if (moveTargetRow > prevViewportBottom) { + const currentScreenRow = Math.max( + 0, + Math.min(height - 1, hardwareCursorRow - prevViewportTop), + ); + const moveToBottom = height - 1 - currentScreenRow; + if (moveToBottom > 0) { + buffer += `\x1b[${moveToBottom}B`; + } + const scroll = moveTargetRow - prevViewportBottom; + buffer += "\r\n".repeat(scroll); + prevViewportTop += scroll; + viewportTop += scroll; + hardwareCursorRow = moveTargetRow; + } + + // Move cursor to first changed line (use hardwareCursorRow for actual position) + const lineDiff = computeLineDiff(moveTargetRow); + if (lineDiff > 0) { + buffer += `\x1b[${lineDiff}B`; // Move down + } else if (lineDiff < 0) { + buffer += `\x1b[${-lineDiff}A`; // Move up + } + + buffer += appendStart ? "\r\n" : "\r"; // Move to column 0 + + // Only render changed lines (firstChanged to lastChanged), not all lines to end + // This reduces flicker when only a single line changes (e.g., spinner animation) + const renderEnd = Math.min(lastChanged, newLines.length - 1); + for (let i = firstChanged; i <= renderEnd; i++) { + if (i > firstChanged) buffer += "\r\n"; + buffer += "\x1b[2K"; // Clear current line + let line = newLines[i]; + const isImage = isImageLine(line); + if (!isImage && visibleWidth(line) > width) { + line = truncateToWidth(line, width); + } + buffer += line; + } + + // Track where cursor ended up after rendering + let finalCursorRow = renderEnd; + + // If we had more lines before, clear them and move cursor back + if (this.previousLines.length > newLines.length) { + // Move to end of new content first if we stopped before it + if (renderEnd < newLines.length - 1) { + const moveDown = newLines.length - 1 - renderEnd; + buffer += `\x1b[${moveDown}B`; + finalCursorRow = newLines.length - 1; + } + const extraLines = this.previousLines.length - newLines.length; + for (let i = newLines.length; i < this.previousLines.length; i++) { + buffer += "\r\n\x1b[2K"; + } + // Move cursor back to end of new content + buffer += `\x1b[${extraLines}A`; + } + + buffer += "\x1b[?2026l"; // End synchronized output + + if (process.env.PI_TUI_DEBUG === "1") { + const debugDir = path.join(os.tmpdir(), "tui"); + fs.mkdirSync(debugDir, { recursive: true }); + const debugPath = path.join( + debugDir, + `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`, + ); + const debugData = [ + `firstChanged: ${firstChanged}`, + `viewportTop: ${viewportTop}`, + `cursorRow: ${this.cursorRow}`, + `height: ${height}`, + `lineDiff: ${lineDiff}`, + `hardwareCursorRow: ${hardwareCursorRow}`, + `renderEnd: ${renderEnd}`, + `finalCursorRow: ${finalCursorRow}`, + `cursorPos: ${JSON.stringify(cursorPos)}`, + `newLines.length: ${newLines.length}`, + `previousLines.length: ${this.previousLines.length}`, + "", + "=== newLines ===", + JSON.stringify(newLines, null, 2), + "", + "=== previousLines ===", + JSON.stringify(this.previousLines, null, 2), + "", + "=== buffer ===", + JSON.stringify(buffer), + ].join("\n"); + fs.writeFileSync(debugPath, debugData); + } + + // Write entire buffer at once + this.terminal.write(buffer); + + // Track cursor position for next render + // cursorRow tracks end of content (for viewport calculation) + // hardwareCursorRow tracks actual terminal cursor position (for movement) + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = finalCursorRow; + // Track terminal's working area (grows but doesn't shrink unless cleared) + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + + // Apply sticky bottom behavior if content grew and user was at bottom + if (shouldScrollToBottom) { + this.scrollToBottom(); + } + + // Position hardware cursor for IME this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; this.previousWidth = width; this.previousHeight = height; - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); - return; - } - - // Check if firstChanged is above what was previously visible - // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks - const previousContentViewportTop = Math.max( - 0, - this.previousLines.length - height, - ); - if (firstChanged < previousContentViewportTop) { - // First change is above previous viewport - need full re-render - logRedraw( - `firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`, + } catch (err) { + console.error("[TUI] render error:", err); + process.stdout.write( + "\x1b[2K\x1b[31m[TUI render error — see stderr]\x1b[0m\n", ); - fullRender(true); - return; } - - // Render from first changed line to end - // Build buffer with all updates wrapped in synchronized output - let buffer = "\x1b[?2026h"; // Begin synchronized output - const prevViewportBottom = prevViewportTop + height - 1; - const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged; - if (moveTargetRow > prevViewportBottom) { - const currentScreenRow = Math.max( - 0, - Math.min(height - 1, hardwareCursorRow - prevViewportTop), - ); - const moveToBottom = height - 1 - currentScreenRow; - if (moveToBottom > 0) { - buffer += `\x1b[${moveToBottom}B`; - } - const scroll = moveTargetRow - prevViewportBottom; - buffer += "\r\n".repeat(scroll); - prevViewportTop += scroll; - viewportTop += scroll; - hardwareCursorRow = moveTargetRow; - } - - // Move cursor to first changed line (use hardwareCursorRow for actual position) - const lineDiff = computeLineDiff(moveTargetRow); - if (lineDiff > 0) { - buffer += `\x1b[${lineDiff}B`; // Move down - } else if (lineDiff < 0) { - buffer += `\x1b[${-lineDiff}A`; // Move up - } - - buffer += appendStart ? "\r\n" : "\r"; // Move to column 0 - - // Only render changed lines (firstChanged to lastChanged), not all lines to end - // This reduces flicker when only a single line changes (e.g., spinner animation) - const renderEnd = Math.min(lastChanged, newLines.length - 1); - for (let i = firstChanged; i <= renderEnd; i++) { - if (i > firstChanged) buffer += "\r\n"; - buffer += "\x1b[2K"; // Clear current line - let line = newLines[i]; - const isImage = isImageLine(line); - if (!isImage && visibleWidth(line) > width) { - line = truncateToWidth(line, width); - } - buffer += line; - } - - // Track where cursor ended up after rendering - let finalCursorRow = renderEnd; - - // If we had more lines before, clear them and move cursor back - if (this.previousLines.length > newLines.length) { - // Move to end of new content first if we stopped before it - if (renderEnd < newLines.length - 1) { - const moveDown = newLines.length - 1 - renderEnd; - buffer += `\x1b[${moveDown}B`; - finalCursorRow = newLines.length - 1; - } - const extraLines = this.previousLines.length - newLines.length; - for (let i = newLines.length; i < this.previousLines.length; i++) { - buffer += "\r\n\x1b[2K"; - } - // Move cursor back to end of new content - buffer += `\x1b[${extraLines}A`; - } - - buffer += "\x1b[?2026l"; // End synchronized output - - if (process.env.PI_TUI_DEBUG === "1") { - const debugDir = path.join(os.tmpdir(), "tui"); - fs.mkdirSync(debugDir, { recursive: true }); - const debugPath = path.join( - debugDir, - `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`, - ); - const debugData = [ - `firstChanged: ${firstChanged}`, - `viewportTop: ${viewportTop}`, - `cursorRow: ${this.cursorRow}`, - `height: ${height}`, - `lineDiff: ${lineDiff}`, - `hardwareCursorRow: ${hardwareCursorRow}`, - `renderEnd: ${renderEnd}`, - `finalCursorRow: ${finalCursorRow}`, - `cursorPos: ${JSON.stringify(cursorPos)}`, - `newLines.length: ${newLines.length}`, - `previousLines.length: ${this.previousLines.length}`, - "", - "=== newLines ===", - JSON.stringify(newLines, null, 2), - "", - "=== previousLines ===", - JSON.stringify(this.previousLines, null, 2), - "", - "=== buffer ===", - JSON.stringify(buffer), - ].join("\n"); - fs.writeFileSync(debugPath, debugData); - } - - // Write entire buffer at once - this.terminal.write(buffer); - - // Track cursor position for next render - // cursorRow tracks end of content (for viewport calculation) - // hardwareCursorRow tracks actual terminal cursor position (for movement) - this.cursorRow = Math.max(0, newLines.length - 1); - this.hardwareCursorRow = finalCursorRow; - // Track terminal's working area (grows but doesn't shrink unless cleared) - this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); - - // Apply sticky bottom behavior if content grew and user was at bottom - if (shouldScrollToBottom) { - this.scrollToBottom(); - } - - // Position hardware cursor for IME - this.positionHardwareCursor(cursorPos, newLines.length); - - this.previousLines = newLines; - this.previousWidth = width; - this.previousHeight = height; } /** diff --git a/packages/tui/src/undo-stack.ts b/packages/tui/src/undo-stack.ts index 5b9a7e9ce..b36e259be 100644 --- a/packages/tui/src/undo-stack.ts +++ b/packages/tui/src/undo-stack.ts @@ -6,20 +6,49 @@ */ export class UndoStack { 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 {