- TUI.useInk() opts into Ink-backed rendering (call before start()) - In start(): if _useInk || process.stdout.isTTY, mount Ink renderer via startInkRenderer() and skip the legacy differential render path entirely - In stop(): unmount Ink handle and return early; legacy terminal cleanup (cursor repositioning, showCursor, terminal.stop) is skipped since Ink handles terminal restoration itself - Passes this.render()/invalidate() via a plain Component wrapper to avoid the private handleInput TypeScript conflict - Two new contract tests: useInk() flag and stop() Ink handle teardown - 80/80 tests pass; legacy path unchanged for non-TTY (CI/tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1229 lines
38 KiB
TypeScript
1229 lines
38 KiB
TypeScript
/**
|
|
* Minimal TUI implementation with differential rendering
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { startInkRenderer } from "./ink-bridge.js";
|
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
import {
|
|
applyLineResets,
|
|
compositeOverlays,
|
|
extractCursorPosition,
|
|
isOverlayVisible as isOverlayEntryVisible,
|
|
} from "./overlay-layout.js";
|
|
import type { Terminal } from "./terminal.js";
|
|
import {
|
|
getCapabilities,
|
|
isImageLine,
|
|
setCellDimensions,
|
|
} from "./terminal-image.js";
|
|
import { truncateToWidth, visibleWidth } from "./utils.js";
|
|
|
|
/**
|
|
* Component interface - all components must implement this
|
|
*/
|
|
export interface Component {
|
|
/**
|
|
* Render the component to lines for the given viewport width
|
|
* @param width - Current viewport width
|
|
* @returns Array of strings, each representing a line
|
|
*/
|
|
render(width: number): string[];
|
|
|
|
/**
|
|
* Optional handler for keyboard input when component has focus
|
|
*/
|
|
handleInput?(data: string): void;
|
|
|
|
/**
|
|
* If true, component receives key release events (Kitty protocol).
|
|
* Default is false - release events are filtered out.
|
|
*/
|
|
wantsKeyRelease?: boolean;
|
|
|
|
/**
|
|
* Invalidate any cached rendering state.
|
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
*/
|
|
invalidate(): void;
|
|
}
|
|
|
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
type InputListener = (data: string) => InputListenerResult;
|
|
|
|
/**
|
|
* Interface for components that can receive focus and display a hardware cursor.
|
|
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
* in its render output. TUI will find this marker and position the hardware
|
|
* cursor there for proper IME candidate window positioning.
|
|
*/
|
|
export interface Focusable {
|
|
/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
|
|
focused: boolean;
|
|
}
|
|
|
|
/** Type guard to check if a component implements Focusable */
|
|
export function isFocusable(
|
|
component: Component | null,
|
|
): component is Component & Focusable {
|
|
return component !== null && "focused" in component;
|
|
}
|
|
|
|
/**
|
|
* Cursor position marker - APC (Application Program Command) sequence.
|
|
* This is a zero-width escape sequence that terminals ignore.
|
|
* Components emit this at the cursor position when focused.
|
|
* TUI finds and strips this marker, then positions the hardware cursor there.
|
|
*/
|
|
export const CURSOR_MARKER = "\x1b_sf:c\x07";
|
|
|
|
export { visibleWidth };
|
|
|
|
/**
|
|
* Anchor position for overlays
|
|
*/
|
|
export type OverlayAnchor =
|
|
| "center"
|
|
| "top-left"
|
|
| "top-right"
|
|
| "bottom-left"
|
|
| "bottom-right"
|
|
| "top-center"
|
|
| "bottom-center"
|
|
| "left-center"
|
|
| "right-center";
|
|
|
|
/**
|
|
* Margin configuration for overlays
|
|
*/
|
|
export interface OverlayMargin {
|
|
top?: number;
|
|
right?: number;
|
|
bottom?: number;
|
|
left?: number;
|
|
}
|
|
|
|
/** Value that can be absolute (number) or percentage (string like "50%") */
|
|
export type SizeValue = number | `${number}%`;
|
|
|
|
/**
|
|
* Options for overlay positioning and sizing.
|
|
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
*/
|
|
export interface OverlayOptions {
|
|
// === Sizing ===
|
|
/** Width in columns, or percentage of terminal width (e.g., "50%") */
|
|
width?: SizeValue;
|
|
/** Minimum width in columns */
|
|
minWidth?: number;
|
|
/** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
|
|
maxHeight?: SizeValue;
|
|
|
|
// === Positioning - anchor-based ===
|
|
/** Anchor point for positioning (default: 'center') */
|
|
anchor?: OverlayAnchor;
|
|
/** Horizontal offset from anchor position (positive = right) */
|
|
offsetX?: number;
|
|
/** Vertical offset from anchor position (positive = down) */
|
|
offsetY?: number;
|
|
|
|
// === Positioning - percentage or absolute ===
|
|
/** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
|
|
row?: SizeValue;
|
|
/** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
|
|
col?: SizeValue;
|
|
|
|
// === Margin from terminal edges ===
|
|
/** Margin from terminal edges. Number applies to all sides. */
|
|
margin?: OverlayMargin | number;
|
|
|
|
// === Visibility ===
|
|
/**
|
|
* Control overlay visibility based on terminal dimensions.
|
|
* If provided, overlay is only rendered when this returns true.
|
|
* Called each render cycle with current terminal dimensions.
|
|
*/
|
|
visible?: (termWidth: number, termHeight: number) => boolean;
|
|
/** If true, don't capture keyboard focus when shown */
|
|
nonCapturing?: boolean;
|
|
/** If true, dim the background behind the overlay */
|
|
backdrop?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Handle returned by showOverlay for controlling the overlay
|
|
*/
|
|
export interface OverlayHandle {
|
|
/** Permanently remove the overlay (cannot be shown again) */
|
|
hide(): void;
|
|
/** Temporarily hide or show the overlay */
|
|
setHidden(hidden: boolean): void;
|
|
/** Check if overlay is temporarily hidden */
|
|
isHidden(): boolean;
|
|
/** Focus this overlay and bring it to the visual front */
|
|
focus(): void;
|
|
/** Release focus to the previous target */
|
|
unfocus(): void;
|
|
/** Check if this overlay currently has focus */
|
|
isFocused(): boolean;
|
|
}
|
|
|
|
/**
|
|
* Container - a component that contains other components
|
|
*/
|
|
export class Container implements Component {
|
|
children: Component[] = [];
|
|
private _prevRender: string[] | null = null;
|
|
|
|
addChild(component: Component): void {
|
|
this.children.push(component);
|
|
this._prevRender = null;
|
|
}
|
|
|
|
removeChild(component: Component): void {
|
|
const index = this.children.indexOf(component);
|
|
if (index !== -1) {
|
|
const child = this.children[index];
|
|
this.children.splice(index, 1);
|
|
if ("dispose" in child && typeof (child as any).dispose === "function") {
|
|
(child as any).dispose();
|
|
}
|
|
this._prevRender = null;
|
|
}
|
|
}
|
|
|
|
clear(): void {
|
|
for (const child of this.children) {
|
|
if ("dispose" in child && typeof (child as any).dispose === "function") {
|
|
(child as any).dispose();
|
|
}
|
|
}
|
|
this.children = [];
|
|
this._prevRender = null;
|
|
}
|
|
|
|
/**
|
|
* Remove all children without calling dispose on them.
|
|
* Use when child lifecycle is owned elsewhere and the container is only a
|
|
* render mount (e.g. extension widget containers in InteractiveMode, where
|
|
* the extensionWidgets* maps own disposal).
|
|
*/
|
|
detachChildren(): void {
|
|
this.children = [];
|
|
this._prevRender = null;
|
|
}
|
|
|
|
invalidate(): void {
|
|
for (const child of this.children) {
|
|
child.invalidate?.();
|
|
}
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
const lines: string[] = [];
|
|
for (const child of this.children) {
|
|
const rendered = child.render(width);
|
|
for (let i = 0; i < rendered.length; i++) lines.push(rendered[i]);
|
|
}
|
|
// Return stable reference if output unchanged — allows doRender()
|
|
// to skip ALL post-processing (isImageLine, applyLineResets, diffs)
|
|
const prev = this._prevRender;
|
|
if (prev && prev.length === lines.length) {
|
|
let same = true;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i] !== prev[i]) {
|
|
same = false;
|
|
break;
|
|
}
|
|
}
|
|
if (same) return prev;
|
|
}
|
|
this._prevRender = lines;
|
|
return lines;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
*/
|
|
export class TUI extends Container {
|
|
public terminal: Terminal;
|
|
private previousLines: string[] = [];
|
|
private previousWidth = 0;
|
|
private previousHeight = 0;
|
|
private focusedComponent: Component | null = null;
|
|
private inputListeners = new Set<InputListener>();
|
|
|
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
public onDebug?: () => void;
|
|
private renderRequested = false;
|
|
private cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
private inputBuffer = ""; // Buffer for parsing terminal responses
|
|
private cellSizeQueryPending = false;
|
|
private showHardwareCursor =
|
|
process.env.PI_HARDWARE_CURSOR === "1" ||
|
|
process.env.TERM_PROGRAM === "WarpTerminal";
|
|
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
|
private _shrinkDebounceActive = false;
|
|
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
private fullRedrawCount = 0;
|
|
private stopped = false;
|
|
private _lastRenderedComponents: string[] | null = null;
|
|
private _useInk = false;
|
|
private _inkHandle: { stop(): void; invalidate(): void } | null = null;
|
|
|
|
// === 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;
|
|
};
|
|
|
|
// Overlay stack for modal components rendered on top of base content
|
|
private focusOrderCounter = 0;
|
|
private overlayStack: {
|
|
component: Component;
|
|
options?: OverlayOptions;
|
|
preFocus: Component | null;
|
|
hidden: boolean;
|
|
focusOrder: number;
|
|
}[] = [];
|
|
|
|
constructor(terminal: Terminal, showHardwareCursor?: boolean) {
|
|
super();
|
|
this.terminal = terminal;
|
|
if (showHardwareCursor !== undefined) {
|
|
this.showHardwareCursor = showHardwareCursor;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opt into the Ink-backed render loop.
|
|
*
|
|
* Purpose: switch the TUI from the hand-rolled differential renderer to Ink's
|
|
* React render loop so components can be migrated to native Ink nodes
|
|
* incrementally. Must be called before start().
|
|
*
|
|
* Consumer: interactive-mode and any callers that want Ink layout.
|
|
*/
|
|
useInk(): void {
|
|
this._useInk = true;
|
|
}
|
|
|
|
get fullRedraws(): number {
|
|
return this.fullRedrawCount;
|
|
}
|
|
|
|
getShowHardwareCursor(): boolean {
|
|
return this.showHardwareCursor;
|
|
}
|
|
|
|
setShowHardwareCursor(enabled: boolean): void {
|
|
if (this.showHardwareCursor === enabled) return;
|
|
this.showHardwareCursor = enabled;
|
|
if (!enabled) {
|
|
this.terminal.hideCursor();
|
|
}
|
|
this.requestRender();
|
|
}
|
|
|
|
getClearOnShrink(): boolean {
|
|
return this.clearOnShrink;
|
|
}
|
|
|
|
/**
|
|
* Set whether to trigger full re-render when content shrinks.
|
|
* When true (default), empty rows are cleared when content shrinks.
|
|
* When false, empty rows remain (reduces redraws on slower terminals).
|
|
*/
|
|
setClearOnShrink(enabled: boolean): void {
|
|
this.clearOnShrink = enabled;
|
|
}
|
|
|
|
setFocus(component: Component | null): void {
|
|
// Clear focused flag on old component
|
|
if (isFocusable(this.focusedComponent)) {
|
|
this.focusedComponent.focused = false;
|
|
}
|
|
|
|
this.focusedComponent = component;
|
|
|
|
// Set focused flag on new component
|
|
if (isFocusable(component)) {
|
|
component.focused = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show an overlay component with configurable positioning and sizing.
|
|
* Returns a handle to control the overlay's visibility.
|
|
*/
|
|
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
|
const entry = {
|
|
component,
|
|
options,
|
|
preFocus: this.focusedComponent,
|
|
hidden: false,
|
|
focusOrder: ++this.focusOrderCounter,
|
|
};
|
|
this.overlayStack.push(entry);
|
|
// Only focus if overlay is actually visible
|
|
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
this.setFocus(component);
|
|
}
|
|
this.terminal.hideCursor();
|
|
this.requestRender();
|
|
|
|
// Return handle for controlling this overlay
|
|
return {
|
|
hide: () => {
|
|
const index = this.overlayStack.indexOf(entry);
|
|
if (index !== -1) {
|
|
this.overlayStack.splice(index, 1);
|
|
// Restore focus if this overlay had focus
|
|
if (this.focusedComponent === component) {
|
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
}
|
|
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
|
this.requestRender();
|
|
}
|
|
},
|
|
setHidden: (hidden: boolean) => {
|
|
if (entry.hidden === hidden) return;
|
|
entry.hidden = hidden;
|
|
// Update focus when hiding/showing
|
|
if (hidden) {
|
|
// If this overlay had focus, move focus to next visible or preFocus
|
|
if (this.focusedComponent === component) {
|
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
}
|
|
} else {
|
|
// Restore focus to this overlay when showing (if it's actually visible)
|
|
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
entry.focusOrder = ++this.focusOrderCounter;
|
|
this.setFocus(component);
|
|
}
|
|
}
|
|
this.requestRender();
|
|
},
|
|
isHidden: () => entry.hidden,
|
|
focus: () => {
|
|
if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
|
|
return;
|
|
if (this.focusedComponent !== component) {
|
|
this.setFocus(component);
|
|
}
|
|
entry.focusOrder = ++this.focusOrderCounter;
|
|
this.requestRender();
|
|
},
|
|
unfocus: () => {
|
|
if (this.focusedComponent !== component) return;
|
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
this.setFocus(
|
|
topVisible && topVisible !== entry
|
|
? topVisible.component
|
|
: entry.preFocus,
|
|
);
|
|
this.requestRender();
|
|
},
|
|
isFocused: () => this.focusedComponent === component,
|
|
};
|
|
}
|
|
|
|
/** Hide the topmost overlay and restore previous focus. */
|
|
hideOverlay(): void {
|
|
const overlay = this.overlayStack.pop();
|
|
if (!overlay) return;
|
|
if (this.focusedComponent === overlay.component) {
|
|
// Find topmost visible overlay, or fall back to preFocus
|
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
}
|
|
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
|
this.requestRender();
|
|
}
|
|
|
|
/** Check if there are any visible overlays */
|
|
hasOverlay(): boolean {
|
|
return this.overlayStack.some((o) => this.isOverlayVisible(o));
|
|
}
|
|
|
|
/** Check if an overlay entry is currently visible */
|
|
private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
|
|
return isOverlayEntryVisible(
|
|
entry,
|
|
this.terminal.columns,
|
|
this.terminal.rows,
|
|
);
|
|
}
|
|
|
|
/** Find the topmost visible capturing overlay, if any */
|
|
private getTopmostVisibleOverlay():
|
|
| (typeof this.overlayStack)[number]
|
|
| undefined {
|
|
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
if (this.overlayStack[i].options?.nonCapturing) continue;
|
|
if (this.isOverlayVisible(this.overlayStack[i])) {
|
|
return this.overlayStack[i];
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
override invalidate(): void {
|
|
super.invalidate();
|
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
}
|
|
|
|
start(): void {
|
|
this.stopped = false;
|
|
// Non-TTY stdout (pipe) — skip TUI entirely to avoid burning CPU.
|
|
// RPC bridge processes have piped stdio; rendering ANSI escape codes
|
|
// to a pipe is pure waste and causes a runaway render loop. (issue #3095)
|
|
if (!this.terminal.isTTY) {
|
|
return;
|
|
}
|
|
// Ink-backed render path: Ink manages raw mode and input; the legacy
|
|
// differential renderer is bypassed entirely.
|
|
if (this._useInk || process.stdout.isTTY) {
|
|
// Wrap `this` in a plain Component so the private handleInput doesn't
|
|
// conflict with the public Component.handleInput? signature.
|
|
const root: Component = {
|
|
render: (w) => this.render(w),
|
|
invalidate: () => this.invalidate(),
|
|
};
|
|
this._inkHandle = startInkRenderer(
|
|
root,
|
|
(data) => this.handleInput(data),
|
|
);
|
|
return;
|
|
}
|
|
this.terminal.start(
|
|
(data) => this.handleInput(data),
|
|
() => this.requestRender(),
|
|
);
|
|
this.terminal.hideCursor();
|
|
this.queryCellSize();
|
|
this.requestRender();
|
|
}
|
|
|
|
addInputListener(listener: InputListener): () => void {
|
|
this.inputListeners.add(listener);
|
|
return () => {
|
|
this.inputListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
removeInputListener(listener: InputListener): void {
|
|
this.inputListeners.delete(listener);
|
|
}
|
|
|
|
private queryCellSize(): void {
|
|
// Only query if terminal supports images (cell size is only used for image rendering)
|
|
if (!getCapabilities().images) {
|
|
return;
|
|
}
|
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
// Response format: CSI 6 ; height ; width t
|
|
this.cellSizeQueryPending = true;
|
|
this.terminal.write("\x1b[16t");
|
|
}
|
|
|
|
stop(): void {
|
|
this.stopped = true;
|
|
|
|
// Dispose all overlays to stop any running timers
|
|
for (const entry of this.overlayStack) {
|
|
if (
|
|
"dispose" in entry.component &&
|
|
typeof (entry.component as any).dispose === "function"
|
|
) {
|
|
(entry.component as any).dispose();
|
|
}
|
|
}
|
|
this.overlayStack = [];
|
|
|
|
// Ink-backed path: unmount the Ink renderer and return; Ink restores the
|
|
// terminal to cooked mode and shows the cursor itself.
|
|
if (this._inkHandle) {
|
|
this._inkHandle.stop();
|
|
this._inkHandle = null;
|
|
return;
|
|
}
|
|
|
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
if (this.previousLines.length > 0) {
|
|
const targetRow = this.previousLines.length; // Line after the last content
|
|
const lineDiff = targetRow - this.hardwareCursorRow;
|
|
if (lineDiff > 0) {
|
|
this.terminal.write(`\x1b[${lineDiff}B`);
|
|
} else if (lineDiff < 0) {
|
|
this.terminal.write(`\x1b[${-lineDiff}A`);
|
|
}
|
|
this.terminal.write("\r\n");
|
|
}
|
|
|
|
this.terminal.showCursor();
|
|
this.terminal.stop();
|
|
}
|
|
|
|
requestRender(force = false): void {
|
|
// Skip rendering on non-TTY stdout to prevent CPU burn (issue #3095)
|
|
if (!this.terminal.isTTY) return;
|
|
if (force) {
|
|
this.previousLines = [];
|
|
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
this.cursorRow = 0;
|
|
this.hardwareCursorRow = 0;
|
|
this.maxLinesRendered = 0;
|
|
this.previousViewportTop = 0;
|
|
}
|
|
if (this.renderRequested) return;
|
|
this.renderRequested = true;
|
|
process.nextTick(() => {
|
|
this.renderRequested = false;
|
|
this.doRender();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if user is scrolled to the bottom of the content
|
|
*/
|
|
private isAtBottom(): boolean {
|
|
const height = this.terminal.rows;
|
|
const viewportTop = Math.max(0, this.maxLinesRendered - height);
|
|
const viewportBottom = viewportTop + height;
|
|
return viewportBottom >= this.previousLines.length;
|
|
}
|
|
|
|
/**
|
|
* Scroll to bottom of content (sticky bottom)
|
|
*/
|
|
private scrollToBottom(): void {
|
|
const height = this.terminal.rows;
|
|
const contentHeight = this.previousLines.length;
|
|
if (contentHeight <= height) return; // No scrolling needed if content fits in viewport
|
|
|
|
// For terminal scrolling, we can use cursor movement or scroll sequences
|
|
// The simplest approach is to move the cursor to the bottom line
|
|
const viewportTop = Math.max(0, contentHeight - height);
|
|
const targetScreenRow = contentHeight - 1;
|
|
const currentScreenRow = this.hardwareCursorRow - this.previousViewportTop;
|
|
const lineDiff = targetScreenRow - currentScreenRow;
|
|
|
|
if (lineDiff > 0) {
|
|
this.terminal.write(`\x1b[${lineDiff}B`); // Move cursor down
|
|
} else if (lineDiff < 0) {
|
|
this.terminal.write(`\x1b[${-lineDiff}A`); // Move cursor up
|
|
}
|
|
|
|
this.previousViewportTop = viewportTop;
|
|
this.isScrolledToBottom = true;
|
|
}
|
|
|
|
/**
|
|
* Update autonomous status information
|
|
*/
|
|
updateAutonomousStatus(status: {
|
|
currentSlice?: string;
|
|
sliceStatus?: string;
|
|
progress?: number;
|
|
totalTasks?: number;
|
|
completedTasks?: number;
|
|
}): void {
|
|
this.autonomousStatus = 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;
|
|
for (const listener of this.inputListeners) {
|
|
const result = listener(current);
|
|
if (result?.consume) {
|
|
return;
|
|
}
|
|
if (result?.data !== undefined) {
|
|
current = result.data;
|
|
}
|
|
}
|
|
if (current.length === 0) {
|
|
return;
|
|
}
|
|
data = current;
|
|
}
|
|
|
|
// If we're waiting for cell size response, buffer input and parse
|
|
if (this.cellSizeQueryPending) {
|
|
this.inputBuffer += data;
|
|
const filtered = this.parseCellSizeResponse();
|
|
if (filtered.length === 0) return;
|
|
data = filtered;
|
|
}
|
|
|
|
// Global debug key handler (Shift+Ctrl+D)
|
|
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
|
this.onDebug();
|
|
return;
|
|
}
|
|
|
|
// Detect scrolling keys (Page Up/Down, arrow keys) to break sticky bottom
|
|
if (
|
|
this.isScrolledToBottom &&
|
|
(matchesKey(data, "pageUp") || matchesKey(data, "up"))
|
|
) {
|
|
this.isScrolledToBottom = false;
|
|
}
|
|
|
|
// If focused component is an overlay, verify it's still visible
|
|
// (visibility can change due to terminal resize or visible() callback)
|
|
const focusedOverlay = this.overlayStack.find(
|
|
(o) => o.component === this.focusedComponent,
|
|
);
|
|
if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
|
|
// Focused overlay is no longer visible, redirect to topmost visible overlay
|
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
if (topVisible) {
|
|
this.setFocus(topVisible.component);
|
|
} else {
|
|
// No visible overlays, restore to preFocus
|
|
this.setFocus(focusedOverlay.preFocus);
|
|
}
|
|
}
|
|
|
|
// Enter key scrolling behavior: if not at bottom, scroll down instead of sending input
|
|
if (data === "\r" || data === "\n") {
|
|
// Enter key
|
|
if (!this.isAtBottom()) {
|
|
// Scroll down one page or to bottom
|
|
this.scrollToBottom();
|
|
return;
|
|
}
|
|
// If we're at bottom, let Enter pass through to focused component
|
|
}
|
|
|
|
// Pass input to focused component (including Ctrl+C)
|
|
// The focused component can decide how to handle Ctrl+C
|
|
if (this.focusedComponent?.handleInput) {
|
|
// Filter out key release events unless component opts in
|
|
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
|
|
return;
|
|
}
|
|
this.focusedComponent.handleInput(data);
|
|
this.requestRender();
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
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();
|
|
}
|
|
|
|
// Remove the response from buffer
|
|
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
|
|
this.cellSizeQueryPending = false;
|
|
}
|
|
|
|
// Don't hold a bare Escape keypress hostage while waiting for the
|
|
// optional cell-size response. This is the most common early input race.
|
|
if (this.inputBuffer === "\x1b") {
|
|
const result = this.inputBuffer;
|
|
this.inputBuffer = "";
|
|
this.cellSizeQueryPending = false;
|
|
return result;
|
|
}
|
|
|
|
// Check if we have a partial cell size response starting (wait for more data)
|
|
// Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
|
|
const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
|
|
if (partialCellSizePattern.test(this.inputBuffer)) {
|
|
// Check if it's actually a complete different escape sequence (ends with a letter)
|
|
// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
|
|
const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
|
|
if (!/[a-zA-Z~]/.test(lastChar)) {
|
|
// Doesn't end with a terminator, might be incomplete - wait for more
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// No cell size response found, return buffered data as user input
|
|
const result = this.inputBuffer;
|
|
this.inputBuffer = "";
|
|
this.cellSizeQueryPending = false; // Give up waiting
|
|
return result;
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
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(
|
|
`terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`,
|
|
);
|
|
fullRender(true);
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
} 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;
|
|
}
|
|
|
|
/**
|
|
* Position the hardware cursor for IME candidate window.
|
|
* @param cursorPos The cursor position extracted from rendered output, or null
|
|
* @param totalLines Total number of rendered lines
|
|
*/
|
|
private positionHardwareCursor(
|
|
cursorPos: { row: number; col: number } | null,
|
|
totalLines: number,
|
|
): void {
|
|
if (!cursorPos || totalLines <= 0) {
|
|
this.terminal.hideCursor();
|
|
return;
|
|
}
|
|
|
|
// Clamp cursor position to valid range
|
|
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
|
|
const targetCol = Math.max(0, cursorPos.col);
|
|
|
|
// Move cursor from current position to target
|
|
const rowDelta = targetRow - this.hardwareCursorRow;
|
|
let buffer = "";
|
|
if (rowDelta > 0) {
|
|
buffer += `\x1b[${rowDelta}B`; // Move down
|
|
} else if (rowDelta < 0) {
|
|
buffer += `\x1b[${-rowDelta}A`; // Move up
|
|
}
|
|
// Move to absolute column (1-indexed)
|
|
buffer += `\x1b[${targetCol + 1}G`;
|
|
|
|
if (buffer) {
|
|
this.terminal.write(buffer);
|
|
}
|
|
|
|
this.hardwareCursorRow = targetRow;
|
|
if (this.showHardwareCursor) {
|
|
this.terminal.showCursor();
|
|
} else {
|
|
this.terminal.hideCursor();
|
|
}
|
|
}
|
|
}
|