@kolisachint/hoocode-tui 0.5.69 → 0.5.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/input.d.ts +12 -0
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +18 -4
- package/dist/components/input.js.map +1 -1
- package/dist/components/select-list.d.ts +6 -2
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +6 -2
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +3 -0
- package/dist/components/settings-list.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tui.d.ts +24 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +37 -3
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
package/dist/tui.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Minimal TUI implementation with differential rendering\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { performance } from \"node:perf_hooks\";\nimport { stripVTControlCharacters } from \"node:util\";\nimport type { FlexSpacer } from \"./components/spacer.js\";\nimport { isKeyRelease, matchesKey } from \"./keys.js\";\nimport { type MouseEvent, mouseSequenceLength, parseMouseEvent } from \"./mouse.js\";\nimport type { Terminal } from \"./terminal.js\";\nimport { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from \"./terminal-image.js\";\nimport {\n\textractSegments,\n\tnormalizeTerminalOutput,\n\tsliceByColumn,\n\tsliceWithWidth,\n\ttruncateToWidth,\n\tvisibleWidth,\n} from \"./utils.js\";\n\nconst KITTY_SEQUENCE_PREFIX = \"\\x1b_G\";\n\nfunction extractKittyImageIds(line: string): number[] {\n\tconst sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);\n\tif (sequenceStart === -1) return [];\n\n\tconst paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;\n\tconst paramsEnd = line.indexOf(\";\", paramsStart);\n\tif (paramsEnd === -1) return [];\n\n\tconst params = line.slice(paramsStart, paramsEnd);\n\tfor (const param of params.split(\",\")) {\n\t\tconst [key, value] = param.split(\"=\", 2);\n\t\tif (key !== \"i\" || value === undefined) continue;\n\t\tconst id = Number(value);\n\t\tif (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {\n\t\t\treturn [id];\n\t\t}\n\t}\n\treturn [];\n}\n\n/**\n * Component interface - all components must implement this\n */\nexport interface Component {\n\t/**\n\t * Render the component to lines for the given viewport width\n\t * @param width - Current viewport width\n\t * @returns Array of strings, each representing a line\n\t */\n\trender(width: number): string[];\n\n\t/**\n\t * Optional handler for keyboard input when component has focus\n\t */\n\thandleInput?(data: string): void;\n\n\t/**\n\t * If true, component receives key release events (Kitty protocol).\n\t * Default is false - release events are filtered out.\n\t */\n\twantsKeyRelease?: boolean;\n\n\t/**\n\t * Invalidate any cached rendering state.\n\t * Called when theme changes or when component needs to re-render from scratch.\n\t */\n\tinvalidate(): void;\n}\n\ntype InputListenerResult = { consume?: boolean; data?: string } | undefined;\ntype InputListener = (data: string) => InputListenerResult;\n\n/**\n * Interface for components that can receive focus and display a hardware cursor.\n * When focused, the component should emit CURSOR_MARKER at the cursor position\n * in its render output. TUI will find this marker and position the hardware\n * cursor there for proper IME candidate window positioning.\n */\nexport interface Focusable {\n\t/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */\n\tfocused: boolean;\n}\n\n/** Type guard to check if a component implements Focusable */\nexport function isFocusable(component: Component | null): component is Component & Focusable {\n\treturn component !== null && \"focused\" in component;\n}\n\n/**\n * Cursor position marker - APC (Application Program Command) sequence.\n * This is a zero-width escape sequence that terminals ignore.\n * Components emit this at the cursor position when focused.\n * TUI finds and strips this marker, then positions the hardware cursor there.\n */\nexport const CURSOR_MARKER = \"\\x1b_pi:c\\x07\";\n\n/**\n * DECTCEM cursor visibility, as strings rather than Terminal calls so they can\n * be folded into a frame's synchronized-output buffer instead of racing it as a\n * separate write.\n */\nconst HIDE_CURSOR = \"\\x1b[?25l\";\nconst SHOW_CURSOR = \"\\x1b[?25h\";\n\n/**\n * How far one wheel notch moves the pinned view.\n *\n * Three lines is what terminals, pagers and browsers have settled on, and the\n * agreement is the point: a wheel that moves a different distance here than in\n * every other window is the kind of wrongness people feel without being able to\n * name it.\n */\nconst WHEEL_LINES = 3;\n\n/** What the scroll indicator is told about the pinned view. */\nexport interface ScrollStatus {\n\t/** 1-based transcript row at the top of the view. */\n\ttop: number;\n\t/** 1-based transcript row at the bottom of the view. */\n\tbottom: number;\n\t/** Rows in the whole transcript. */\n\ttotal: number;\n\t/** Rows the view shows at once. */\n\tviewHeight: number;\n\tatTop: boolean;\n\t/** True only when the very last row is in view — the point where the pin lets go. */\n\tatBottom: boolean;\n\t/** Columns the indicator may fill. */\n\twidth: number;\n\t/** Present while a search is running; the indicator becomes its query line. */\n\tsearch?: ScrollSearchStatus;\n}\n\nexport interface ScrollSearchStatus {\n\tquery: string;\n\t/** Rows containing a match. */\n\tcount: number;\n\t/** 1-based position among the matches, or 0 when there are none. */\n\tindex: number;\n\t/** True while the query is still being typed. */\n\ttyping: boolean;\n}\n\nexport type ScrollStatusFormatter = (status: ScrollStatus) => string;\n\n/**\n * The indicator the tui draws when the app has not supplied its own.\n *\n * Reverse video rather than a colour, because this package has no theme and a\n * hard-coded colour is the one thing guaranteed to clash with whichever one the\n * app is using. It leads with the position — the question a pinned reader\n * actually has — and spends what is left on the keys, dropping them on a narrow\n * terminal rather than truncating the numbers.\n */\nfunction defaultScrollStatus(status: ScrollStatus): string {\n\tconst position = `${status.top}–${status.bottom}/${status.total}`;\n\tconst where = status.atTop ? \" top\" : \"\";\n\tconst keys = \"↑↓ line · PgUp/PgDn page · esc live\";\n\tconst left = ` ${position}${where} `;\n\t// Measured in columns, not characters: the arrows and the separator are one\n\t// cell each but a rebind could put anything in here, and a row that is one\n\t// cell too wide wraps into the window above it.\n\tconst body = visibleWidth(left) + visibleWidth(keys) + 1 <= status.width ? `${left}${keys} ` : left;\n\treturn `\\x1b[7m${truncateToWidth(body, status.width, \"\", true)}\\x1b[0m`;\n}\n\n/**\n * Anchor position for overlays\n */\nexport type OverlayAnchor =\n\t| \"center\"\n\t| \"top-left\"\n\t| \"top-right\"\n\t| \"bottom-left\"\n\t| \"bottom-right\"\n\t| \"top-center\"\n\t| \"bottom-center\"\n\t| \"left-center\"\n\t| \"right-center\";\n\n/**\n * Margin configuration for overlays\n */\nexport interface OverlayMargin {\n\ttop?: number;\n\tright?: number;\n\tbottom?: number;\n\tleft?: number;\n}\n\n/** Value that can be absolute (number) or percentage (string like \"50%\") */\nexport type SizeValue = number | `${number}%`;\n\n/** Parse a SizeValue into absolute value given a reference size */\nfunction parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {\n\tif (value === undefined) return undefined;\n\tif (typeof value === \"number\") return value;\n\t// Parse percentage string like \"50%\"\n\tconst match = value.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\tif (match) {\n\t\treturn Math.floor((referenceSize * parseFloat(match[1])) / 100);\n\t}\n\treturn undefined;\n}\n\nfunction isTermuxSession(): boolean {\n\treturn Boolean(process.env.TERMUX_VERSION);\n}\n\n/**\n * Options for overlay positioning and sizing.\n * Values can be absolute numbers or percentage strings (e.g., \"50%\").\n */\nexport interface OverlayOptions {\n\t// === Sizing ===\n\t/** Width in columns, or percentage of terminal width (e.g., \"50%\") */\n\twidth?: SizeValue;\n\t/** Minimum width in columns */\n\tminWidth?: number;\n\t/** Maximum height in rows, or percentage of terminal height (e.g., \"50%\") */\n\tmaxHeight?: SizeValue;\n\n\t// === Positioning - anchor-based ===\n\t/** Anchor point for positioning (default: 'center') */\n\tanchor?: OverlayAnchor;\n\t/** Horizontal offset from anchor position (positive = right) */\n\toffsetX?: number;\n\t/** Vertical offset from anchor position (positive = down) */\n\toffsetY?: number;\n\n\t// === Positioning - percentage or absolute ===\n\t/** Row position: absolute number, or percentage (e.g., \"25%\" = 25% from top) */\n\trow?: SizeValue;\n\t/** Column position: absolute number, or percentage (e.g., \"50%\" = centered horizontally) */\n\tcol?: SizeValue;\n\n\t// === Margin from terminal edges ===\n\t/** Margin from terminal edges. Number applies to all sides. */\n\tmargin?: OverlayMargin | number;\n\n\t// === Visibility ===\n\t/**\n\t * Control overlay visibility based on terminal dimensions.\n\t * If provided, overlay is only rendered when this returns true.\n\t * Called each render cycle with current terminal dimensions.\n\t */\n\tvisible?: (termWidth: number, termHeight: number) => boolean;\n\t/** If true, don't capture keyboard focus when shown */\n\tnonCapturing?: boolean;\n}\n\n/**\n * Handle returned by showOverlay for controlling the overlay\n */\nexport interface OverlayHandle {\n\t/** Permanently remove the overlay (cannot be shown again) */\n\thide(): void;\n\t/** Temporarily hide or show the overlay */\n\tsetHidden(hidden: boolean): void;\n\t/** Check if overlay is temporarily hidden */\n\tisHidden(): boolean;\n\t/** Focus this overlay and bring it to the visual front */\n\tfocus(): void;\n\t/** Release focus to the previous target */\n\tunfocus(): void;\n\t/** Check if this overlay currently has focus */\n\tisFocused(): boolean;\n}\n\n/**\n * Container - a component that contains other components\n */\nexport class Container implements Component {\n\tchildren: Component[] = [];\n\t// Flatten memo: children are always render()ed (side effects and their own\n\t// caches must run), but when every child returns the same array reference as\n\t// last time, the previously flattened array is returned as-is. Unchanged\n\t// subtrees thus stay reference-stable all the way up, which lets the TUI\n\t// root diff whole regions by identity instead of re-flattening the world.\n\tprivate renderMemo?: { width: number; refs: string[][]; lines: string[] };\n\n\taddChild(component: Component): void {\n\t\tthis.children.push(component);\n\t\tthis.renderMemo = undefined;\n\t}\n\n\tremoveChild(component: Component): void {\n\t\tconst index = this.children.indexOf(component);\n\t\tif (index !== -1) {\n\t\t\tthis.children.splice(index, 1);\n\t\t\tthis.renderMemo = undefined;\n\t\t}\n\t}\n\n\tclear(): void {\n\t\tthis.children = [];\n\t\tthis.renderMemo = undefined;\n\t}\n\n\tinvalidate(): void {\n\t\tthis.renderMemo = undefined;\n\t\tfor (const child of this.children) {\n\t\t\tchild.invalidate?.();\n\t\t}\n\t}\n\n\t/**\n\t * Where each direct child's output starts, in rows, from the last render.\n\t *\n\t * Read off the memo rather than recomputed, so asking is a walk over the\n\t * children's cached line arrays and never a re-render. Undefined before the\n\t * first render, or at a different width than the caller has in mind — both\n\t * cases mean \"no answer\", not \"zero\".\n\t *\n\t * This is what lets something outside the tree point at a row inside it: a\n\t * component's offset within its container, plus that container's offset at\n\t * the root, is its absolute row in the buffer the viewport windows over.\n\t */\n\tchildRowOffsets(width: number): number[] | undefined {\n\t\tconst memo = this.renderMemo;\n\t\tif (!memo || memo.width !== width) return undefined;\n\t\tconst offsets: number[] = new Array(memo.refs.length);\n\t\tlet row = 0;\n\t\tfor (let i = 0; i < memo.refs.length; i++) {\n\t\t\toffsets[i] = row;\n\t\t\trow += memo.refs[i].length;\n\t\t}\n\t\treturn offsets;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst n = this.children.length;\n\t\tconst memo = this.renderMemo;\n\t\tconst refs: string[][] = new Array(n);\n\t\tlet unchanged = memo !== undefined && memo.width === width && memo.refs.length === n;\n\t\tfor (let i = 0; i < n; i++) {\n\t\t\trefs[i] = this.children[i].render(width);\n\t\t\tif (unchanged && refs[i] !== (memo as { refs: string[][] }).refs[i]) {\n\t\t\t\tunchanged = false;\n\t\t\t}\n\t\t}\n\t\tif (unchanged) {\n\t\t\treturn (memo as { lines: string[] }).lines;\n\t\t}\n\t\tconst lines: string[] = [];\n\t\tfor (const childLines of refs) {\n\t\t\tfor (const line of childLines) {\n\t\t\t\tlines.push(line);\n\t\t\t}\n\t\t}\n\t\tthis.renderMemo = { width, refs, lines };\n\t\treturn lines;\n\t}\n}\n\n/**\n * A child that can be taken off screen without disturbing the diff.\n *\n * Hiding a component naively — returning `[]` from its render — is one of the\n * more expensive things you can do to this renderer. `Container.render` and the\n * root's flat cache both decide \"did this subtree change\" by **array identity**,\n * so a fresh `[]` every frame reads as a change every frame: the memo is\n * dropped, the buffer is re-flattened, and a dirty range is reported for a\n * component that is not even drawn. One frozen array, returned every time,\n * makes a hidden slot free instead.\n *\n * The child is not rendered at all while hidden, which is the other half of the\n * saving — a hidden footer costs nothing to keep hidden. That means a child\n * whose `render` advances an animation or maintains a cache will be paused, not\n * merely invisible; it catches up when shown again. Chrome (footers, panels,\n * status rows) is fine with that. A spinner is not, so do not wrap one.\n */\nexport class Slot implements Component {\n\t/** Shared across every hidden slot: identity is all the caches compare. */\n\tprivate static readonly EMPTY: string[] = Object.freeze([]) as unknown as string[];\n\tprivate hidden = false;\n\n\tconstructor(private component: Component) {}\n\n\t/** Whoever is in the slot right now. */\n\tget child(): Component {\n\t\treturn this.component;\n\t}\n\n\t/**\n\t * Swap the occupant, keeping the slot itself in place.\n\t *\n\t * An extension replacing the footer used to remove one root child and append\n\t * another, which both moved the footer to the end of the tree — behind the\n\t * widgets meant to sit below it — and handed the root's per-child cache a\n\t * changed child list every time. The slot is the stable root child; only what\n\t * is inside it changes.\n\t */\n\tsetChild(component: Component): boolean {\n\t\tif (this.component === component) return false;\n\t\tthis.component = component;\n\t\treturn true;\n\t}\n\n\tget visible(): boolean {\n\t\treturn !this.hidden;\n\t}\n\n\t/** Returns whether this changed anything, so callers can skip a render. */\n\tsetVisible(visible: boolean): boolean {\n\t\tconst hidden = !visible;\n\t\tif (this.hidden === hidden) return false;\n\t\tthis.hidden = hidden;\n\t\treturn true;\n\t}\n\n\tinvalidate(): void {\n\t\tthis.child.invalidate?.();\n\t}\n\n\trender(width: number): string[] {\n\t\tif (this.hidden) return Slot.EMPTY;\n\t\treturn this.child.render(width);\n\t}\n}\n\n/**\n * TUI - Main class for managing terminal UI with differential rendering\n */\nexport class TUI extends Container {\n\tpublic terminal: Terminal;\n\tprivate previousLines: string[] = [];\n\t// Root flat-line cache (see the render() override): per-child line arrays,\n\t// their offsets into the flat buffer, and the flat buffer itself. Active\n\t// only when no overlays are up and no image has been drawn; otherwise the\n\t// legacy full-flatten + full-diff path runs.\n\tprivate flatCache?: { width: number; refs: string[][]; offsets: number[] };\n\tprivate flatLines?: string[];\n\t/** What the last render() call changed: \"full\" = unknown (legacy diff must\n\t * scan), null = nothing, otherwise the dirty row range + previous length. */\n\tprivate lastPatch: { low: number; high: number; prevLength: number } | null | \"full\" = \"full\";\n\t/** Cursor position extracted on the last frame; reused when the dirty range\n\t * shows the marker's row untouched (the marker was already stripped). */\n\tprivate lastCursorPos: { row: number; col: number } | null | undefined = undefined;\n\t/** Set by render() when this frame's patches invalidate lastCursorPos: the\n\t * marker's row was overwritten, or a patched-in line carries a marker. */\n\tprivate cursorRowOverwritten = false;\n\tprivate previousKittyImageIds = new Set<number>();\n\t/** Flips true the first time an image line is emitted. While false no image\n\t * has ever been drawn, so there are no kitty ids on screen to track and the\n\t * per-frame full-buffer scan (collectKittyImageIds) is skipped entirely —\n\t * the common case for a pure-text session. */\n\tprivate sawImageLine = false;\n\tprivate static readonly EMPTY_KITTY_IDS: ReadonlySet<number> = new Set<number>();\n\tprivate previousWidth = 0;\n\tprivate previousHeight = 0;\n\tprivate focusedComponent: Component | null = null;\n\tprivate inputListeners = new Set<InputListener>();\n\n\t/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */\n\tpublic onDebug?: () => void;\n\tprivate renderRequested = false;\n\tprivate renderTimer: NodeJS.Timeout | undefined;\n\tprivate lastRenderAt = 0;\n\tprivate static readonly MIN_RENDER_INTERVAL_MS = 16;\n\tprivate cursorRow = 0; // Logical cursor row (end of rendered content)\n\tprivate hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)\n\tprivate showHardwareCursor = process.env.HOOCODE_HARDWARE_CURSOR === \"1\";\n\tprivate clearOnShrink = process.env.HOOCODE_CLEAR_ON_SHRINK === \"1\"; // Clear empty rows when content shrinks (default: off)\n\tprivate maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)\n\tprivate previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves\n\tprivate fullRedrawCount = 0;\n\tprivate stopped = false;\n\n\t/**\n\t * The filler that keeps the app the size of the screen.\n\t *\n\t * ## Why the app is full-screen at all\n\t *\n\t * This renderer appends: a frame is the whole component tree flattened into\n\t * a line buffer, written from wherever the cursor happens to be. On a fresh\n\t * session that buffer is a dozen rows, so the banner sat halfway up a\n\t * terminal with the prompt under it and forty rows of the user's shell\n\t * history above — and the prompt walked down the screen as the conversation\n\t * grew, only reaching the bottom row once the session was long enough to\n\t * scroll. Two different layouts for the same app, and the one you meet first\n\t * is the one that does not look like an app.\n\t *\n\t * ## What this does\n\t *\n\t * Before the frame is diffed, the root measures it and gives the leftover\n\t * rows to one designated child. The buffer is therefore never shorter than\n\t * the terminal, so the terminal's last row is always the buffer's last row:\n\t * the header stays at the top, the prompt and the footer stay on the bottom,\n\t * and everything between them is conversation. Nothing else changes — this\n\t * is still the normal screen, so scrollback, selection and search all still\n\t * work, and the session is still on screen after you quit.\n\t *\n\t * Set to `undefined` (no flex child) and the old append-only behaviour is\n\t * exactly what you get back, which is what the tests that predate this and\n\t * any embedder outside the app rely on.\n\t */\n\tprivate flexSpacer?: FlexSpacer;\n\n\t/**\n\t * The pinned viewport.\n\t *\n\t * ## What is wrong with letting the terminal do it\n\t *\n\t * This renderer keeps the entire transcript in its line buffer and writes it\n\t * to the normal screen, so \"scrolling\" has always meant the terminal's own\n\t * scrollback. That works exactly as long as the app does not repaint — and\n\t * this one repaints the whole buffer whenever a line *above* the viewport\n\t * changes, because a positional diff cannot address a row that has scrolled\n\t * out of reach. The repaint is `\\x1b[2J\\x1b[H\\x1b[3J` followed by the\n\t * transcript again, and the `\\x1b[3J` throws away the scrollback the reader\n\t * was sitting in. From the reader's side the screen simply jumps to the\n\t * bottom, for no reason they can see, at a moment they did not choose.\n\t *\n\t * ## What this does instead\n\t *\n\t * `scrollOffset` is the transcript row drawn at the top of the screen, and\n\t * `null` means \"follow the tail\", which is the normal live behaviour and the\n\t * path everything else in this file was written for. The moment it is a\n\t * number the TUI switches to the alternate screen and paints a window of the\n\t * buffer itself: a fixed grid, addressed row by row, with no scrollback for\n\t * anything to fight over. New output still arrives and still lands in the\n\t * buffer — it just does not move the window, which is the whole point. The\n\t * indicator on the last row says how far down the transcript the window is,\n\t * because a view that cannot move on its own needs to say where it stopped.\n\t *\n\t * Going back to live leaves the alternate screen, which restores the normal\n\t * screen *and its scrollback* exactly as they were, and the next frame is an\n\t * ordinary differential one that writes only what arrived while the reader\n\t * was away — see `scrollToLive` for what has to be true for that to be safe.\n\t * Not a clear-and-replay: on a long session that is a visible flash, a burst\n\t * of output, and the loss of the scrollback that had just been handed back.\n\t */\n\tprivate scrollOffset: number | null = null;\n\t/** Transcript length measured by the last pinned paint; what clamping uses. */\n\tprivate scrollTotalLines = 0;\n\tprivate scrollStatusFormatter: ScrollStatusFormatter = defaultScrollStatus;\n\t/** The running search: its query, the rows it matched, and where in them. */\n\tprivate scrollSearch: {\n\t\tquery: string;\n\t\tmatches: number[];\n\t\tindex: number;\n\t\t/** Buffer length the matches were measured at, so growth can re-run them. */\n\t\tmeasuredAt: number;\n\t\ttyping: boolean;\n\t} | null = null;\n\t/**\n\t * Whether the view may pin right now.\n\t *\n\t * The wheel is answered wherever it is turned, including with a picker on\n\t * screen — and a picker that lost its arrow keys to a pinned view it did not\n\t * know about would be far worse than a wheel that did nothing. The app sets\n\t * this because only the app knows which of its surfaces is asking a question.\n\t * Unset means always.\n\t */\n\tpublic canPinScroll?: () => boolean;\n\n\t// Overlay stack for modal components rendered on top of base content\n\tprivate focusOrderCounter = 0;\n\tprivate overlayStack: {\n\t\tcomponent: Component;\n\t\toptions?: OverlayOptions;\n\t\tpreFocus: Component | null;\n\t\thidden: boolean;\n\t\tfocusOrder: number;\n\t}[] = [];\n\n\tconstructor(terminal: Terminal, showHardwareCursor?: boolean) {\n\t\tsuper();\n\t\tthis.terminal = terminal;\n\t\tif (showHardwareCursor !== undefined) {\n\t\t\tthis.showHardwareCursor = showHardwareCursor;\n\t\t}\n\t}\n\n\tget fullRedraws(): number {\n\t\treturn this.fullRedrawCount;\n\t}\n\n\t// ── The pinned viewport ─────────────────────────────────────────────────\n\n\t/** True while the view is pinned rather than following the tail. */\n\tget scrollPinned(): boolean {\n\t\treturn this.scrollOffset !== null;\n\t}\n\n\t/** Where the pinned window sits, or null while live. */\n\tgetScrollPosition(): { top: number; total: number; viewHeight: number } | null {\n\t\tif (this.scrollOffset === null) return null;\n\t\treturn { top: this.scrollOffset, total: this.scrollTotalLines, viewHeight: this.scrollViewHeight() };\n\t}\n\n\t/** Let the app paint the indicator in its own theme. */\n\tsetScrollStatusFormatter(formatter: ScrollStatusFormatter): void {\n\t\tthis.scrollStatusFormatter = formatter;\n\t}\n\n\t/**\n\t * Nominate the child that absorbs the leftover rows (see `flexSpacer`).\n\t *\n\t * It must already be a child of the root, and it should sit between the part\n\t * of the tree that flows from the top and the chrome that hangs off the\n\t * bottom — everything after it is what gets pinned to the foot of the screen.\n\t */\n\tsetFlexSpacer(spacer: FlexSpacer | undefined): void {\n\t\tthis.flexSpacer = spacer;\n\t}\n\n\t/**\n\t * Give the flex child whatever the frame did not use, at `height` rows.\n\t *\n\t * Returns true when the height moved, meaning the caller has to flatten\n\t * again — the measurement can only be made from a finished frame, so the\n\t * frame that answers it is always the second one. Both passes are cheap\n\t * after the first: every other child returns its memoized array untouched.\n\t */\n\tprivate fitFlexSpacer(lines: string[], height: number): boolean {\n\t\tconst spacer = this.flexSpacer;\n\t\tif (!spacer) return false;\n\t\treturn spacer.setHeight(height - (lines.length - spacer.currentHeight));\n\t}\n\n\t/**\n\t * The screen rows a pinned window shows, the last one being the indicator.\n\t *\n\t * The indicator is not optional: a pinned view looks exactly like a live one\n\t * that has gone quiet, and a reader who cannot tell the two apart will wait\n\t * for output that is arriving perfectly well just out of sight.\n\t */\n\tprivate scrollViewHeight(): number {\n\t\treturn Math.max(1, this.terminal.rows - 1);\n\t}\n\n\t/** Rows available to scroll through — the live buffer while live, the\n\t * measured one while pinned. The filler is not transcript: counting it would\n\t * let a session with nothing above the fold pin itself one row off the\n\t * bottom and paint a screen of blanks. */\n\tprivate transcriptLength(): number {\n\t\tif (this.scrollOffset !== null) return this.scrollTotalLines;\n\t\treturn this.previousLines.length - (this.flexSpacer?.currentHeight ?? 0);\n\t}\n\n\t/**\n\t * Move the view by `delta` rows; negative is towards the start.\n\t *\n\t * Returns whether anything moved, so a caller can let the key fall through\n\t * to whatever else wants it when there is nothing to scroll.\n\t */\n\tscrollByLines(delta: number): boolean {\n\t\tif (delta === 0) return false;\n\t\t// Scrolling down while already live is not \"scroll to somewhere\", it is a\n\t\t// request for content that does not exist yet. Doing nothing is right, and\n\t\t// cheap: treating it as a move would drop out of scroll mode and force a\n\t\t// full repaint on every wheel notch at the bottom of the transcript.\n\t\tif (this.scrollOffset === null && delta > 0) return false;\n\n\t\tconst viewHeight = this.scrollViewHeight();\n\t\tconst maxOffset = Math.max(0, this.transcriptLength() - viewHeight);\n\t\tif (maxOffset === 0) return false;\n\n\t\tconst next = (this.scrollOffset ?? maxOffset) + delta;\n\t\t// Reaching the end is how the pin lets go: the reader has caught up, so\n\t\t// give them the live screen back rather than a pinned view of the tail\n\t\t// that silently stops following.\n\t\tif (next >= maxOffset) return this.scrollToLive();\n\t\tthis.setScrollOffset(next);\n\t\treturn true;\n\t}\n\n\t/**\n\t * Move by pages, keeping two rows of overlap.\n\t *\n\t * A page that moves a full screen leaves nothing in common between before\n\t * and after, and the reader has to find their place again on every press.\n\t * The two kept rows are what makes the jump readable.\n\t */\n\tscrollByPages(delta: number): boolean {\n\t\tconst page = Math.max(1, this.scrollViewHeight() - 2);\n\t\treturn this.scrollByLines(delta * page);\n\t}\n\n\t/** Pin the view to the very start of the transcript. */\n\tscrollToTop(): boolean {\n\t\tconst maxOffset = Math.max(0, this.transcriptLength() - this.scrollViewHeight());\n\t\tif (maxOffset === 0) return false;\n\t\tif (this.scrollOffset === 0) return false;\n\t\tthis.setScrollOffset(0);\n\t\treturn true;\n\t}\n\n\t/** Release the pin and follow the tail again. */\n\tscrollToLive(): boolean {\n\t\tif (this.scrollOffset === null) return false;\n\t\tthis.scrollOffset = null;\n\t\tthis.scrollSearch = null;\n\t\tthis.terminal.setAlternateScreen(false);\n\t\t// `?1049l` restores the normal screen, its scrollback and the cursor\n\t\t// exactly as they were at `?1049h`, and the snapshot taken on the way in\n\t\t// says what that screen holds — so the next frame can be an ordinary\n\t\t// differential one that writes only what arrived while we were reading.\n\t\t// Dropping the flat cache is what makes it honest: the cache has been\n\t\t// patched on every pinned frame, and a patch report describing rows that\n\t\t// were painted to the *alternate* screen would leave the diff addressing\n\t\t// the wrong ones.\n\t\tthis.flatCache = undefined;\n\t\tthis.lastCursorPos = undefined;\n\t\tthis.requestRender();\n\t\treturn true;\n\t}\n\n\t// ── Searching the pinned view ───────────────────────────────────────────\n\n\t/**\n\t * Find rows containing `query`, and pin the view to the nearest one above.\n\t *\n\t * Searching *backwards* first is the `ctrl+r` convention and it is the right\n\t * one here: what you are looking for in a session is nearly always behind\n\t * you, and the most recent occurrence is nearly always the one you meant.\n\t *\n\t * Matching is over the visible text, so it finds what the screen shows\n\t * rather than the escape sequences underneath it — a query containing a\n\t * colour code is not something anyone ever means.\n\t *\n\t * Returns how many rows matched.\n\t */\n\tsetScrollSearch(query: string, options: { typing?: boolean } = {}): number {\n\t\tif (query.length === 0) {\n\t\t\tthis.scrollSearch = { query, matches: [], index: -1, measuredAt: -1, typing: options.typing ?? true };\n\t\t\tthis.requestRender();\n\t\t\tthis.expediteRender();\n\t\t\treturn 0;\n\t\t}\n\n\t\tconst from = this.scrollOffset ?? Math.max(0, this.transcriptLength() - 1);\n\t\tconst matches = this.findScrollMatches(query);\n\t\t// The nearest match at or above where the eye is, else wrap to the last.\n\t\tlet index = -1;\n\t\tfor (let i = matches.length - 1; i >= 0; i--) {\n\t\t\tif (matches[i] <= from) {\n\t\t\t\tindex = i;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (index === -1 && matches.length > 0) index = matches.length - 1;\n\n\t\tthis.scrollSearch = {\n\t\t\tquery,\n\t\t\tmatches,\n\t\t\tindex,\n\t\t\tmeasuredAt: this.flatLines?.length ?? this.previousLines.length,\n\t\t\ttyping: options.typing ?? true,\n\t\t};\n\t\tif (index >= 0) this.scrollToRow(matches[index]);\n\t\tthis.requestRender();\n\t\tthis.expediteRender();\n\t\treturn matches.length;\n\t}\n\n\t/**\n\t * Step to the next match, `-1` being further back through the session.\n\t *\n\t * Wraps, because a search that stops dead at the last match makes you\n\t * retype it to get back to the first.\n\t */\n\tscrollSearchStep(direction: 1 | -1): boolean {\n\t\tconst search = this.scrollSearch;\n\t\tif (!search || search.matches.length === 0) return false;\n\t\tconst next = (search.index + direction + search.matches.length) % search.matches.length;\n\t\tsearch.index = next;\n\t\tsearch.typing = false;\n\t\tthis.scrollToRow(search.matches[next]);\n\t\tthis.requestRender();\n\t\tthis.expediteRender();\n\t\treturn true;\n\t}\n\n\t/** Stop typing the query but keep the matches, so n/N can step them. */\n\tcommitScrollSearch(): void {\n\t\tif (!this.scrollSearch) return;\n\t\tthis.scrollSearch.typing = false;\n\t\tthis.requestRender();\n\t\tthis.expediteRender();\n\t}\n\n\t/** Drop the search, leaving the view where it is. */\n\tclearScrollSearch(): void {\n\t\tif (!this.scrollSearch) return;\n\t\tthis.scrollSearch = null;\n\t\tthis.requestRender();\n\t\tthis.expediteRender();\n\t}\n\n\tget scrollSearchActive(): boolean {\n\t\treturn this.scrollSearch !== null;\n\t}\n\n\t/** The query being searched for, or \"\" when there is no search. */\n\tget scrollSearchQuery(): string {\n\t\treturn this.scrollSearch?.query ?? \"\";\n\t}\n\n\tprivate scrollSearchStatus(): ScrollSearchStatus | undefined {\n\t\tconst search = this.scrollSearch;\n\t\tif (!search) return undefined;\n\t\treturn {\n\t\t\tquery: search.query,\n\t\t\tcount: search.matches.length,\n\t\t\tindex: search.index >= 0 ? search.index + 1 : 0,\n\t\t\ttyping: search.typing,\n\t\t};\n\t}\n\n\t/**\n\t * Rows whose visible text contains `query`, case-insensitively.\n\t *\n\t * One pass over the buffer, run when the query changes rather than per\n\t * frame. The results are re-measured if the transcript has grown since —\n\t * rare while someone is reading, and wrong in a way people notice if skipped.\n\t */\n\tprivate findScrollMatches(query: string): number[] {\n\t\tconst lines = this.flatLines ?? this.previousLines;\n\t\tconst needle = query.toLowerCase();\n\t\tconst matches: number[] = [];\n\t\tfor (let row = 0; row < lines.length; row++) {\n\t\t\tconst line = lines[row];\n\t\t\tif (line.length === 0) continue;\n\t\t\tif (stripVTControlCharacters(line).toLowerCase().includes(needle)) matches.push(row);\n\t\t}\n\t\treturn matches;\n\t}\n\n\t/** Re-run the search if the buffer grew under it. */\n\tprivate refreshScrollSearch(total: number): void {\n\t\tconst search = this.scrollSearch;\n\t\tif (!search || search.query.length === 0 || search.measuredAt === total) return;\n\t\tsearch.matches = this.findScrollMatches(search.query);\n\t\tsearch.measuredAt = total;\n\t\tif (search.index >= search.matches.length) search.index = search.matches.length - 1;\n\t}\n\n\t/**\n\t * Mark the query where it appears in a row about to be painted.\n\t *\n\t * Done at paint time, over the handful of rows on screen, rather than stored\n\t * per match — highlighting the whole buffer to show a screenful would be the\n\t * same work multiplied by the session's length.\n\t *\n\t * The row is sliced by display column so the styling already in it survives:\n\t * a match inside a coloured span keeps its colour and gains the marker.\n\t */\n\tprivate highlightScrollMatches(line: string, query: string): string {\n\t\tconst plain = stripVTControlCharacters(line);\n\t\tconst needle = query.toLowerCase();\n\t\tconst haystack = plain.toLowerCase();\n\t\tlet at = haystack.indexOf(needle);\n\t\tif (at === -1) return line;\n\n\t\tlet out = \"\";\n\t\tlet cursor = 0;\n\t\twhile (at !== -1) {\n\t\t\tconst startCol = visibleWidth(plain.slice(0, at));\n\t\t\tconst endCol = startCol + visibleWidth(plain.slice(at, at + query.length));\n\t\t\tconst fromCol = visibleWidth(plain.slice(0, cursor));\n\t\t\tout += sliceByColumn(line, fromCol, startCol - fromCol);\n\t\t\t// 27 turns reverse off on its own, so whatever colour the row was\n\t\t\t// wearing underneath the match carries on afterwards.\n\t\t\tout += `\\x1b[7m${sliceByColumn(line, startCol, endCol - startCol)}\\x1b[27m`;\n\t\t\tcursor = at + query.length;\n\t\t\tat = haystack.indexOf(needle, cursor);\n\t\t}\n\t\tconst tailCol = visibleWidth(plain.slice(0, cursor));\n\t\tout += sliceByColumn(line, tailCol, Number.MAX_SAFE_INTEGER - tailCol);\n\t\treturn out;\n\t}\n\n\tprivate setScrollOffset(offset: number): void {\n\t\tconst entering = this.scrollOffset === null;\n\t\t// Only entry is gated. A view that is already pinned keeps responding, so\n\t\t// a surface opening underneath cannot strand the reader somewhere they\n\t\t// have no key to leave.\n\t\tif (entering && this.canPinScroll && !this.canPinScroll()) return;\n\t\tthis.scrollOffset = Math.max(0, offset);\n\t\tif (entering) {\n\t\t\t// What the normal screen is left showing, frozen. Without the copy this\n\t\t\t// stays the same array the patching render() mutates in place, so on the\n\t\t\t// way back out it would be diffed against itself and report that nothing\n\t\t\t// arrived while the reader was away.\n\t\t\tthis.previousLines = this.previousLines.slice();\n\t\t\tthis.terminal.setAlternateScreen(true);\n\t\t\tthis.terminal.hideCursor();\n\t\t}\n\t\tthis.requestRender();\n\t\t// Scrolling is a direct manipulation: the view has to move under the\n\t\t// gesture, not one animation frame behind it.\n\t\tthis.expediteRender();\n\t}\n\n\tgetShowHardwareCursor(): boolean {\n\t\treturn this.showHardwareCursor;\n\t}\n\n\tsetShowHardwareCursor(enabled: boolean): void {\n\t\tif (this.showHardwareCursor === enabled) return;\n\t\tthis.showHardwareCursor = enabled;\n\t\tif (!enabled) {\n\t\t\tthis.terminal.hideCursor();\n\t\t}\n\t\tthis.requestRender();\n\t}\n\n\tgetClearOnShrink(): boolean {\n\t\treturn this.clearOnShrink;\n\t}\n\n\t/**\n\t * Set whether to trigger full re-render when content shrinks.\n\t * When true (default), empty rows are cleared when content shrinks.\n\t * When false, empty rows remain (reduces redraws on slower terminals).\n\t */\n\tsetClearOnShrink(enabled: boolean): void {\n\t\tthis.clearOnShrink = enabled;\n\t}\n\n\t/** The component keystrokes are currently going to. */\n\tget focused(): Component | null {\n\t\treturn this.focusedComponent;\n\t}\n\n\t/**\n\t * The root's own child offsets, which the flat cache already tracks.\n\t *\n\t * The base implementation reads `Container.renderMemo`, which the root does\n\t * not keep — it has `flatCache` instead, holding exactly this. Falling\n\t * through to the base would quietly return undefined forever.\n\t */\n\toverride childRowOffsets(width: number): number[] | undefined {\n\t\tconst cache = this.flatCache;\n\t\tif (!cache || cache.width !== width) return undefined;\n\t\treturn cache.offsets;\n\t}\n\n\t/**\n\t * Pin the view so `row` is on screen, without demanding it be at the top.\n\t *\n\t * A jump that always parks its target on the first row throws away whatever\n\t * led up to it, and what led up to a message is most of what makes it\n\t * readable. A row already comfortably in view is left where it is, so\n\t * stepping through nearby landmarks does not make the screen lurch for each\n\t * one.\n\t */\n\tscrollToRow(row: number, options: { context?: number } = {}): boolean {\n\t\tconst viewHeight = this.scrollViewHeight();\n\t\tconst maxOffset = Math.max(0, this.transcriptLength() - viewHeight);\n\t\tif (maxOffset === 0) return false;\n\n\t\tconst context = options.context ?? Math.min(3, Math.max(0, viewHeight - 1));\n\t\tconst target = Math.max(0, Math.min(row - context, maxOffset));\n\n\t\tif (this.scrollOffset !== null) {\n\t\t\tconst top = this.scrollOffset;\n\t\t\t// Already visible with room to read above it: leave it alone.\n\t\t\tif (row >= top + context && row < top + viewHeight) return false;\n\t\t}\n\t\tthis.setScrollOffset(target);\n\t\treturn this.scrollOffset === target;\n\t}\n\n\tsetFocus(component: Component | null): void {\n\t\t// Clear focused flag on old component\n\t\tif (isFocusable(this.focusedComponent)) {\n\t\t\tthis.focusedComponent.focused = false;\n\t\t}\n\n\t\tthis.focusedComponent = component;\n\n\t\t// Set focused flag on new component\n\t\tif (isFocusable(component)) {\n\t\t\tcomponent.focused = true;\n\t\t}\n\t}\n\n\t/**\n\t * Show an overlay component with configurable positioning and sizing.\n\t * Returns a handle to control the overlay's visibility.\n\t */\n\tshowOverlay(component: Component, options?: OverlayOptions): OverlayHandle {\n\t\tconst entry = {\n\t\t\tcomponent,\n\t\t\toptions,\n\t\t\tpreFocus: this.focusedComponent,\n\t\t\thidden: false,\n\t\t\tfocusOrder: ++this.focusOrderCounter,\n\t\t};\n\t\tthis.overlayStack.push(entry);\n\t\t// Only focus if overlay is actually visible\n\t\tif (!options?.nonCapturing && this.isOverlayVisible(entry)) {\n\t\t\tthis.setFocus(component);\n\t\t}\n\t\tthis.terminal.hideCursor();\n\t\tthis.requestRender();\n\n\t\t// Return handle for controlling this overlay\n\t\treturn {\n\t\t\thide: () => {\n\t\t\t\tconst index = this.overlayStack.indexOf(entry);\n\t\t\t\tif (index !== -1) {\n\t\t\t\t\tthis.overlayStack.splice(index, 1);\n\t\t\t\t\t// Restore focus if this overlay had focus\n\t\t\t\t\tif (this.focusedComponent === component) {\n\t\t\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\t\t\tthis.setFocus(topVisible?.component ?? entry.preFocus);\n\t\t\t\t\t}\n\t\t\t\t\tif (this.overlayStack.length === 0) this.terminal.hideCursor();\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t}\n\t\t\t},\n\t\t\tsetHidden: (hidden: boolean) => {\n\t\t\t\tif (entry.hidden === hidden) return;\n\t\t\t\tentry.hidden = hidden;\n\t\t\t\t// Update focus when hiding/showing\n\t\t\t\tif (hidden) {\n\t\t\t\t\t// If this overlay had focus, move focus to next visible or preFocus\n\t\t\t\t\tif (this.focusedComponent === component) {\n\t\t\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\t\t\tthis.setFocus(topVisible?.component ?? entry.preFocus);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Restore focus to this overlay when showing (if it's actually visible)\n\t\t\t\t\tif (!options?.nonCapturing && this.isOverlayVisible(entry)) {\n\t\t\t\t\t\tentry.focusOrder = ++this.focusOrderCounter;\n\t\t\t\t\t\tthis.setFocus(component);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tisHidden: () => entry.hidden,\n\t\t\tfocus: () => {\n\t\t\t\tif (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;\n\t\t\t\tif (this.focusedComponent !== component) {\n\t\t\t\t\tthis.setFocus(component);\n\t\t\t\t}\n\t\t\t\tentry.focusOrder = ++this.focusOrderCounter;\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tunfocus: () => {\n\t\t\t\tif (this.focusedComponent !== component) return;\n\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\tthis.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tisFocused: () => this.focusedComponent === component,\n\t\t};\n\t}\n\n\t/** Hide the topmost overlay and restore previous focus. */\n\thideOverlay(): void {\n\t\tconst overlay = this.overlayStack.pop();\n\t\tif (!overlay) return;\n\t\tif (this.focusedComponent === overlay.component) {\n\t\t\t// Find topmost visible overlay, or fall back to preFocus\n\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\tthis.setFocus(topVisible?.component ?? overlay.preFocus);\n\t\t}\n\t\tif (this.overlayStack.length === 0) this.terminal.hideCursor();\n\t\tthis.requestRender();\n\t}\n\n\t/** Check if there are any visible overlays */\n\thasOverlay(): boolean {\n\t\treturn this.overlayStack.some((o) => this.isOverlayVisible(o));\n\t}\n\n\t/** Check if an overlay entry is currently visible */\n\tprivate isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {\n\t\tif (entry.hidden) return false;\n\t\tif (entry.options?.visible) {\n\t\t\treturn entry.options.visible(this.terminal.columns, this.terminal.rows);\n\t\t}\n\t\treturn true;\n\t}\n\n\t/** Find the topmost visible capturing overlay, if any */\n\tprivate getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {\n\t\tfor (let i = this.overlayStack.length - 1; i >= 0; i--) {\n\t\t\tif (this.overlayStack[i].options?.nonCapturing) continue;\n\t\t\tif (this.isOverlayVisible(this.overlayStack[i])) {\n\t\t\t\treturn this.overlayStack[i];\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\toverride invalidate(): void {\n\t\tsuper.invalidate();\n\t\tfor (const overlay of this.overlayStack) overlay.component.invalidate?.();\n\t}\n\n\tstart(): void {\n\t\tthis.stopped = false;\n\t\tthis.terminal.start(\n\t\t\t(data) => this.handleInput(data),\n\t\t\t() => this.requestRender(),\n\t\t);\n\t\tthis.terminal.hideCursor();\n\t\tthis.queryCellSize();\n\t\tthis.requestRender();\n\t}\n\n\taddInputListener(listener: InputListener): () => void {\n\t\tthis.inputListeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.inputListeners.delete(listener);\n\t\t};\n\t}\n\n\tremoveInputListener(listener: InputListener): void {\n\t\tthis.inputListeners.delete(listener);\n\t}\n\n\tprivate queryCellSize(): void {\n\t\t// Only query if terminal supports images (cell size is only used for image rendering)\n\t\tif (!getCapabilities().images) {\n\t\t\treturn;\n\t\t}\n\t\t// Query terminal for cell size in pixels: CSI 16 t\n\t\t// Response format: CSI 6 ; height ; width t\n\t\tthis.terminal.write(\"\\x1b[16t\");\n\t}\n\n\tstop(): void {\n\t\t// Off the alternate screen before the exit bookkeeping below, which moves\n\t\t// the cursor relative to content that lives on the normal screen.\n\t\tif (this.scrollOffset !== null) {\n\t\t\tthis.scrollOffset = null;\n\t\t\tthis.terminal.setAlternateScreen(false);\n\t\t}\n\t\tthis.stopped = true;\n\t\tif (this.renderTimer) {\n\t\t\tclearTimeout(this.renderTimer);\n\t\t\tthis.renderTimer = undefined;\n\t\t}\n\t\t// Move cursor to the end of the content to prevent overwriting/artifacts on exit\n\t\tif (this.previousLines.length > 0) {\n\t\t\tconst targetRow = this.previousLines.length; // Line after the last content\n\t\t\tconst lineDiff = targetRow - this.hardwareCursorRow;\n\t\t\tif (lineDiff > 0) {\n\t\t\t\tthis.terminal.write(`\\x1b[${lineDiff}B`);\n\t\t\t} else if (lineDiff < 0) {\n\t\t\t\tthis.terminal.write(`\\x1b[${-lineDiff}A`);\n\t\t\t}\n\t\t\tthis.terminal.write(\"\\r\\n\");\n\t\t}\n\n\t\tthis.terminal.showCursor();\n\t\tthis.terminal.stop();\n\t}\n\n\trequestRender(force = false): void {\n\t\tif (force) {\n\t\t\tthis.previousLines = [];\n\t\t\tthis.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear\n\t\t\tthis.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear\n\t\t\tthis.cursorRow = 0;\n\t\t\tthis.hardwareCursorRow = 0;\n\t\t\tthis.maxLinesRendered = 0;\n\t\t\tthis.previousViewportTop = 0;\n\t\t\tif (this.renderTimer) {\n\t\t\t\tclearTimeout(this.renderTimer);\n\t\t\t\tthis.renderTimer = undefined;\n\t\t\t}\n\t\t\tthis.renderRequested = true;\n\t\t\tprocess.nextTick(() => {\n\t\t\t\tif (this.stopped || !this.renderRequested) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.renderRequested = false;\n\t\t\t\tthis.lastRenderAt = performance.now();\n\t\t\t\tthis.doRender();\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tif (this.renderRequested) return;\n\t\tthis.renderRequested = true;\n\t\tprocess.nextTick(() => this.scheduleRender());\n\t}\n\n\tprivate scheduleRender(): void {\n\t\tif (this.stopped || this.renderTimer || !this.renderRequested) {\n\t\t\treturn;\n\t\t}\n\t\tconst elapsed = performance.now() - this.lastRenderAt;\n\t\tconst delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);\n\t\tthis.renderTimer = setTimeout(() => {\n\t\t\tthis.renderTimer = undefined;\n\t\t\tif (this.stopped || !this.renderRequested) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.renderRequested = false;\n\t\t\tthis.lastRenderAt = performance.now();\n\t\t\tthis.doRender();\n\t\t\tif (this.renderRequested) {\n\t\t\t\tthis.scheduleRender();\n\t\t\t}\n\t\t}, delay);\n\t}\n\n\tprivate handleInput(data: string): void {\n\t\t// Ahead of the listeners: a mouse report that reaches a text field is\n\t\t// typed into it, and a paste-detecting listener has no reason to see one.\n\t\tif (this.terminal.mouseReporting) {\n\t\t\tconst remaining = this.consumeMouseReports(data);\n\t\t\tif (remaining === null) return;\n\t\t\tdata = remaining;\n\t\t}\n\n\t\tif (this.inputListeners.size > 0) {\n\t\t\tlet current = data;\n\t\t\tfor (const listener of this.inputListeners) {\n\t\t\t\tconst result = listener(current);\n\t\t\t\tif (result?.consume) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (result?.data !== undefined) {\n\t\t\t\t\tcurrent = result.data;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (current.length === 0) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tdata = current;\n\t\t}\n\n\t\t// Consume terminal cell size responses without blocking unrelated input.\n\t\tif (this.consumeCellSizeResponse(data)) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Global debug key handler (Shift+Ctrl+D)\n\t\tif (matchesKey(data, \"shift+ctrl+d\") && this.onDebug) {\n\t\t\tthis.onDebug();\n\t\t\treturn;\n\t\t}\n\n\t\t// If focused component is an overlay, verify it's still visible\n\t\t// (visibility can change due to terminal resize or visible() callback)\n\t\tconst focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);\n\t\tif (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {\n\t\t\t// Focused overlay is no longer visible, redirect to topmost visible overlay\n\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\tif (topVisible) {\n\t\t\t\tthis.setFocus(topVisible.component);\n\t\t\t} else {\n\t\t\t\t// No visible overlays, restore to preFocus\n\t\t\t\tthis.setFocus(focusedOverlay.preFocus);\n\t\t\t}\n\t\t}\n\n\t\t// Pass input to focused component (including Ctrl+C)\n\t\t// The focused component can decide how to handle Ctrl+C\n\t\tif (this.focusedComponent?.handleInput) {\n\t\t\t// Filter out key release events unless component opts in\n\t\t\tif (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.focusedComponent.handleInput(data);\n\t\t\tthis.requestRender();\n\t\t\t// Keystroke echo should not queue behind the animation coalescing\n\t\t\t// window: render the input's effect immediately instead of waiting out\n\t\t\t// MIN_RENDER_INTERVAL_MS behind spinner/streaming frames.\n\t\t\tthis.expediteRender();\n\t\t}\n\t}\n\n\t/**\n\t * Act on every mouse report in `data` and return what is left of it.\n\t *\n\t * Returns null when the chunk was nothing but reports. Reports arrive\n\t * coalesced — a flick of the wheel delivers a run of them in one read, and a\n\t * keystroke pressed during the flick rides along behind — so they are peeled\n\t * off one at a time instead of the chunk being classified as a whole.\n\t */\n\tprivate consumeMouseReports(data: string): string | null {\n\t\t// Neither introducer present is the overwhelmingly common case (every\n\t\t// ordinary keystroke), and it costs one scan of a very short string.\n\t\tif (!data.includes(\"\\x1b[<\") && !data.includes(\"\\x1b[M\")) return data;\n\n\t\tlet rest = data;\n\t\tlet out = \"\";\n\t\tlet sawReport = false;\n\t\twhile (rest.length > 0) {\n\t\t\tconst length = mouseSequenceLength(rest);\n\t\t\tif (length === 0) {\n\t\t\t\tout += rest[0];\n\t\t\t\trest = rest.slice(1);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst event = parseMouseEvent(rest.slice(0, length));\n\t\t\tif (event) this.handleMouseEvent(event);\n\t\t\tsawReport = true;\n\t\t\trest = rest.slice(length);\n\t\t}\n\n\t\tif (!sawReport) return data;\n\t\treturn out.length > 0 ? out : null;\n\t}\n\n\t/**\n\t * What the mouse does.\n\t *\n\t * Only the wheel is acted on. Clicks are swallowed rather than handled:\n\t * reporting is on for the wheel's sake, and a click that fell through to the\n\t * focused component would arrive as the raw report text in whatever field\n\t * has focus.\n\t */\n\tprivate handleMouseEvent(event: MouseEvent): void {\n\t\tif (event.kind === \"wheelUp\") {\n\t\t\tthis.scrollByLines(-WHEEL_LINES);\n\t\t\treturn;\n\t\t}\n\t\tif (event.kind === \"wheelDown\") {\n\t\t\tthis.scrollByLines(WHEEL_LINES);\n\t\t}\n\t}\n\n\t/** Run a requested render now, bypassing the coalescing delay. Used for\n\t * input-driven frames where echo latency matters more than batching. */\n\tprivate expediteRender(): void {\n\t\tif (this.stopped || !this.renderRequested) return;\n\t\tif (this.renderTimer) {\n\t\t\tclearTimeout(this.renderTimer);\n\t\t\tthis.renderTimer = undefined;\n\t\t}\n\t\tthis.renderRequested = false;\n\t\tthis.lastRenderAt = performance.now();\n\t\tthis.doRender();\n\t}\n\n\tprivate consumeCellSizeResponse(data: string): boolean {\n\t\t// Response format: ESC [ 6 ; height ; width t\n\t\tconst match = data.match(/^\\x1b\\[6;(\\d+);(\\d+)t$/);\n\t\tif (!match) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst heightPx = parseInt(match[1], 10);\n\t\tconst widthPx = parseInt(match[2], 10);\n\t\tif (heightPx <= 0 || widthPx <= 0) {\n\t\t\treturn true;\n\t\t}\n\n\t\tsetCellDimensions({ widthPx, heightPx });\n\t\t// Invalidate all components so images re-render with correct dimensions.\n\t\tthis.invalidate();\n\t\tthis.requestRender();\n\t\treturn true;\n\t}\n\n\t/**\n\t * Resolve overlay layout from options.\n\t * Returns { width, row, col, maxHeight } for rendering.\n\t */\n\tprivate resolveOverlayLayout(\n\t\toptions: OverlayOptions | undefined,\n\t\toverlayHeight: number,\n\t\ttermWidth: number,\n\t\ttermHeight: number,\n\t): { width: number; row: number; col: number; maxHeight: number | undefined } {\n\t\tconst opt = options ?? {};\n\n\t\t// Parse margin (clamp to non-negative)\n\t\tconst margin =\n\t\t\ttypeof opt.margin === \"number\"\n\t\t\t\t? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }\n\t\t\t\t: (opt.margin ?? {});\n\t\tconst marginTop = Math.max(0, margin.top ?? 0);\n\t\tconst marginRight = Math.max(0, margin.right ?? 0);\n\t\tconst marginBottom = Math.max(0, margin.bottom ?? 0);\n\t\tconst marginLeft = Math.max(0, margin.left ?? 0);\n\n\t\t// Available space after margins\n\t\tconst availWidth = Math.max(1, termWidth - marginLeft - marginRight);\n\t\tconst availHeight = Math.max(1, termHeight - marginTop - marginBottom);\n\n\t\t// === Resolve width ===\n\t\tlet width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);\n\t\t// Apply minWidth\n\t\tif (opt.minWidth !== undefined) {\n\t\t\twidth = Math.max(width, opt.minWidth);\n\t\t}\n\t\t// Clamp to available space\n\t\twidth = Math.max(1, Math.min(width, availWidth));\n\n\t\t// === Resolve maxHeight ===\n\t\tlet maxHeight = parseSizeValue(opt.maxHeight, termHeight);\n\t\t// Clamp to available space\n\t\tif (maxHeight !== undefined) {\n\t\t\tmaxHeight = Math.max(1, Math.min(maxHeight, availHeight));\n\t\t}\n\n\t\t// Effective overlay height (may be clamped by maxHeight)\n\t\tconst effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;\n\n\t\t// === Resolve position ===\n\t\tlet row: number;\n\t\tlet col: number;\n\n\t\tif (opt.row !== undefined) {\n\t\t\tif (typeof opt.row === \"string\") {\n\t\t\t\t// Percentage: 0% = top, 100% = bottom (overlay stays within bounds)\n\t\t\t\tconst match = opt.row.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\t\t\t\tif (match) {\n\t\t\t\t\tconst maxRow = Math.max(0, availHeight - effectiveHeight);\n\t\t\t\t\tconst percent = parseFloat(match[1]) / 100;\n\t\t\t\t\trow = marginTop + Math.floor(maxRow * percent);\n\t\t\t\t} else {\n\t\t\t\t\t// Invalid format, fall back to center\n\t\t\t\t\trow = this.resolveAnchorRow(\"center\", effectiveHeight, availHeight, marginTop);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Absolute row position\n\t\t\t\trow = opt.row;\n\t\t\t}\n\t\t} else {\n\t\t\t// Anchor-based (default: center)\n\t\t\tconst anchor = opt.anchor ?? \"center\";\n\t\t\trow = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);\n\t\t}\n\n\t\tif (opt.col !== undefined) {\n\t\t\tif (typeof opt.col === \"string\") {\n\t\t\t\t// Percentage: 0% = left, 100% = right (overlay stays within bounds)\n\t\t\t\tconst match = opt.col.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\t\t\t\tif (match) {\n\t\t\t\t\tconst maxCol = Math.max(0, availWidth - width);\n\t\t\t\t\tconst percent = parseFloat(match[1]) / 100;\n\t\t\t\t\tcol = marginLeft + Math.floor(maxCol * percent);\n\t\t\t\t} else {\n\t\t\t\t\t// Invalid format, fall back to center\n\t\t\t\t\tcol = this.resolveAnchorCol(\"center\", width, availWidth, marginLeft);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Absolute column position\n\t\t\t\tcol = opt.col;\n\t\t\t}\n\t\t} else {\n\t\t\t// Anchor-based (default: center)\n\t\t\tconst anchor = opt.anchor ?? \"center\";\n\t\t\tcol = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);\n\t\t}\n\n\t\t// Apply offsets\n\t\tif (opt.offsetY !== undefined) row += opt.offsetY;\n\t\tif (opt.offsetX !== undefined) col += opt.offsetX;\n\n\t\t// Clamp to terminal bounds (respecting margins)\n\t\trow = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));\n\t\tcol = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));\n\n\t\treturn { width, row, col, maxHeight };\n\t}\n\n\tprivate resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {\n\t\tswitch (anchor) {\n\t\t\tcase \"top-left\":\n\t\t\tcase \"top-center\":\n\t\t\tcase \"top-right\":\n\t\t\t\treturn marginTop;\n\t\t\tcase \"bottom-left\":\n\t\t\tcase \"bottom-center\":\n\t\t\tcase \"bottom-right\":\n\t\t\t\treturn marginTop + availHeight - height;\n\t\t\tcase \"left-center\":\n\t\t\tcase \"center\":\n\t\t\tcase \"right-center\":\n\t\t\t\treturn marginTop + Math.floor((availHeight - height) / 2);\n\t\t}\n\t}\n\n\tprivate resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {\n\t\tswitch (anchor) {\n\t\t\tcase \"top-left\":\n\t\t\tcase \"left-center\":\n\t\t\tcase \"bottom-left\":\n\t\t\t\treturn marginLeft;\n\t\t\tcase \"top-right\":\n\t\t\tcase \"right-center\":\n\t\t\tcase \"bottom-right\":\n\t\t\t\treturn marginLeft + availWidth - width;\n\t\t\tcase \"top-center\":\n\t\t\tcase \"center\":\n\t\t\tcase \"bottom-center\":\n\t\t\t\treturn marginLeft + Math.floor((availWidth - width) / 2);\n\t\t}\n\t}\n\n\t/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */\n\tprivate compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {\n\t\tif (this.overlayStack.length === 0) return lines;\n\t\tconst result = [...lines];\n\n\t\t// Pre-render all visible overlays and calculate positions\n\t\tconst rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];\n\t\tlet minLinesNeeded = result.length;\n\n\t\tconst visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));\n\t\tvisibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);\n\t\tfor (const entry of visibleEntries) {\n\t\t\tconst { component, options } = entry;\n\n\t\t\t// Get layout with height=0 first to determine width and maxHeight\n\t\t\t// (width and maxHeight don't depend on overlay height)\n\t\t\tconst { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);\n\n\t\t\t// Render component at calculated width\n\t\t\tlet overlayLines = component.render(width);\n\n\t\t\t// Apply maxHeight if specified\n\t\t\tif (maxHeight !== undefined && overlayLines.length > maxHeight) {\n\t\t\t\toverlayLines = overlayLines.slice(0, maxHeight);\n\t\t\t}\n\n\t\t\t// Get final row/col with actual overlay height\n\t\t\tconst { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);\n\n\t\t\trendered.push({ overlayLines, row, col, w: width });\n\t\t\tminLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);\n\t\t}\n\n\t\t// Pad to at least terminal height so overlays have screen-relative positions.\n\t\t// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing\n\t\t// inflation that pushed content into scrollback on terminal widen.\n\t\tconst workingHeight = Math.max(result.length, termHeight, minLinesNeeded);\n\n\t\t// Extend result with empty lines if content is too short for overlay placement or working area\n\t\twhile (result.length < workingHeight) {\n\t\t\tresult.push(\"\");\n\t\t}\n\n\t\tconst viewportStart = Math.max(0, workingHeight - termHeight);\n\n\t\t// Composite each overlay\n\t\tfor (const { overlayLines, row, col, w } of rendered) {\n\t\t\tfor (let i = 0; i < overlayLines.length; i++) {\n\t\t\t\tconst idx = viewportStart + row + i;\n\t\t\t\tif (idx >= 0 && idx < result.length) {\n\t\t\t\t\t// Defensive: truncate overlay line to declared width before compositing\n\t\t\t\t\t// (components should already respect width, but this ensures it)\n\t\t\t\t\tconst truncatedOverlayLine =\n\t\t\t\t\t\tvisibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];\n\t\t\t\t\tresult[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t}\n\n\tprivate static readonly SEGMENT_RESET = \"\\x1b[0m\\x1b]8;;\\x07\";\n\n\t/**\n\t * Append the per-line style/hyperlink reset (and normalize Thai/Lao AM\n\t * vowels) at the moment a line is written to the terminal. This is\n\t * deliberately kept OFF the cached/diffed line arrays: leaf components cache\n\t * their lines without the reset, so leaving `newLines`/`previousLines`\n\t * un-reset keeps unchanged lines reference-stable frame to frame. The\n\t * differential compare then short-circuits on identity for every unchanged\n\t * line instead of allocating a fresh reset-appended string per line and\n\t * doing a full content compare across the whole transcript every frame.\n\t * Image lines carry no trailing style and are emitted verbatim.\n\t */\n\tprivate emitLine(line: string): string {\n\t\tif (isImageLine(line)) {\n\t\t\tthis.sawImageLine = true;\n\t\t\treturn line;\n\t\t}\n\t\treturn normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;\n\t}\n\n\tprivate collectKittyImageIds(lines: string[]): Set<number> {\n\t\t// No image has ever been drawn: nothing on screen carries a kitty id, so\n\t\t// skip the full-buffer scan and the Set allocation.\n\t\tif (!this.sawImageLine) return TUI.EMPTY_KITTY_IDS as Set<number>;\n\t\tconst ids = new Set<number>();\n\t\tfor (const line of lines) {\n\t\t\tfor (const id of extractKittyImageIds(line)) {\n\t\t\t\tids.add(id);\n\t\t\t}\n\t\t}\n\t\treturn ids;\n\t}\n\n\tprivate deleteKittyImages(ids: Iterable<number>): string {\n\t\tlet buffer = \"\";\n\t\tfor (const id of ids) {\n\t\t\tbuffer += deleteKittyImage(id);\n\t\t}\n\t\treturn buffer;\n\t}\n\n\tprivate expandLastChangedForKittyImages(firstChanged: number, lastChanged: number): number {\n\t\t// No image ever drawn: nothing to expand over, skip the scan (also,\n\t\t// on patched frames previousLines is not the previous content).\n\t\tif (!this.sawImageLine) return lastChanged;\n\t\tlet expandedLastChanged = lastChanged;\n\t\tfor (let i = firstChanged; i < this.previousLines.length; i++) {\n\t\t\tif (extractKittyImageIds(this.previousLines[i]).length > 0) {\n\t\t\t\texpandedLastChanged = Math.max(expandedLastChanged, i);\n\t\t\t}\n\t\t}\n\t\treturn expandedLastChanged;\n\t}\n\n\tprivate deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {\n\t\tif (firstChanged < 0 || lastChanged < firstChanged) return \"\";\n\n\t\tconst ids = new Set<number>();\n\t\tconst maxLine = Math.min(lastChanged, this.previousLines.length - 1);\n\t\tfor (let i = firstChanged; i <= maxLine; i++) {\n\t\t\tfor (const id of extractKittyImageIds(this.previousLines[i] ?? \"\")) {\n\t\t\t\tids.add(id);\n\t\t\t}\n\t\t}\n\n\t\treturn this.deleteKittyImages(ids);\n\t}\n\n\t/** Splice overlay content into a base line at a specific column. Single-pass optimized. */\n\tprivate compositeLineAt(\n\t\tbaseLine: string,\n\t\toverlayLine: string,\n\t\tstartCol: number,\n\t\toverlayWidth: number,\n\t\ttotalWidth: number,\n\t): string {\n\t\tif (isImageLine(baseLine)) return baseLine;\n\n\t\t// Single pass through baseLine extracts both before and after segments\n\t\tconst afterStart = startCol + overlayWidth;\n\t\tconst base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);\n\n\t\t// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)\n\t\tconst overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);\n\n\t\t// Pad segments to target widths\n\t\tconst beforePad = Math.max(0, startCol - base.beforeWidth);\n\t\tconst overlayPad = Math.max(0, overlayWidth - overlay.width);\n\t\tconst actualBeforeWidth = Math.max(startCol, base.beforeWidth);\n\t\tconst actualOverlayWidth = Math.max(overlayWidth, overlay.width);\n\t\tconst afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);\n\t\tconst afterPad = Math.max(0, afterTarget - base.afterWidth);\n\n\t\t// Compose result\n\t\tconst r = TUI.SEGMENT_RESET;\n\t\tconst result =\n\t\t\tbase.before +\n\t\t\t\" \".repeat(beforePad) +\n\t\t\tr +\n\t\t\toverlay.text +\n\t\t\t\" \".repeat(overlayPad) +\n\t\t\tr +\n\t\t\tbase.after +\n\t\t\t\" \".repeat(afterPad);\n\n\t\t// CRITICAL: Always verify and truncate to terminal width.\n\t\t// This is the final safeguard against width overflow which would crash the TUI.\n\t\t// Width tracking can drift from actual visible width due to:\n\t\t// - Complex ANSI/OSC sequences (hyperlinks, colors)\n\t\t// - Wide characters at segment boundaries\n\t\t// - Edge cases in segment extraction\n\t\tconst resultWidth = visibleWidth(result);\n\t\tif (resultWidth <= totalWidth) {\n\t\t\treturn result;\n\t\t}\n\t\t// Truncate with strict=true to ensure we don't exceed totalWidth\n\t\treturn sliceByColumn(result, 0, totalWidth, true);\n\t}\n\n\t/**\n\t * Find and extract cursor position from rendered lines.\n\t * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.\n\t * Only scans the bottom terminal height lines (visible viewport).\n\t * @param lines - Rendered lines to search\n\t * @param height - Terminal height (visible viewport size)\n\t * @returns Cursor position { row, col } or null if no marker found\n\t */\n\tprivate extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {\n\t\t// Only scan the bottom `height` lines (visible viewport)\n\t\tconst viewportTop = Math.max(0, lines.length - height);\n\t\tfor (let row = lines.length - 1; row >= viewportTop; row--) {\n\t\t\tconst line = lines[row];\n\t\t\tconst markerIndex = line.indexOf(CURSOR_MARKER);\n\t\t\tif (markerIndex !== -1) {\n\t\t\t\t// Calculate visual column (width of text before marker)\n\t\t\t\tconst beforeMarker = line.slice(0, markerIndex);\n\t\t\t\tconst col = visibleWidth(beforeMarker);\n\n\t\t\t\t// Strip marker from the line\n\t\t\t\tlines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);\n\n\t\t\t\treturn { row, col };\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Root flatten with patch tracking. Children stay memoized (Container), so\n\t * a frame where only one region changed patches that region into the\n\t * persistent flat buffer and reports the dirty row range via lastPatch —\n\t * doRender then skips the whole-transcript diff. Falls back to a fresh\n\t * flatten (lastPatch = \"full\") when overlays are up, an image has been\n\t * drawn (kitty bookkeeping needs true previous content), the width changed,\n\t * or the child list changed.\n\t */\n\toverride render(width: number): string[] {\n\t\tconst cacheAllowed = this.overlayStack.length === 0 && !this.sawImageLine;\n\t\tconst cache = this.flatCache;\n\t\tif (!cacheAllowed || !cache || cache.width !== width || cache.refs.length !== this.children.length) {\n\t\t\tconst n = this.children.length;\n\t\t\tconst refs: string[][] = new Array(n);\n\t\t\tconst offsets: number[] = new Array(n);\n\t\t\tconst flat: string[] = [];\n\t\t\tfor (let i = 0; i < n; i++) {\n\t\t\t\trefs[i] = this.children[i].render(width);\n\t\t\t\toffsets[i] = flat.length;\n\t\t\t\tfor (const line of refs[i]) flat.push(line);\n\t\t\t}\n\t\t\tif (cacheAllowed) {\n\t\t\t\tthis.flatCache = { width, refs, offsets };\n\t\t\t\tthis.flatLines = flat;\n\t\t\t} else {\n\t\t\t\tthis.flatCache = undefined;\n\t\t\t\tthis.flatLines = undefined;\n\t\t\t}\n\t\t\tthis.lastPatch = \"full\";\n\t\t\treturn flat;\n\t\t}\n\n\t\tlet flat = this.flatLines as string[];\n\t\tconst prevLength = flat.length;\n\t\tlet low = Infinity;\n\t\tlet high = -1;\n\t\tlet delta = 0;\n\t\t// Cursor bookkeeping: the marker was stripped out of the persistent flat\n\t\t// when last extracted, so the cached position stays valid until the row\n\t\t// it lives on is overwritten by re-imported child content — and it\n\t\t// shifts when content above it grows or shrinks.\n\t\tlet cp = this.lastCursorPos ?? null;\n\t\tthis.cursorRowOverwritten = false;\n\t\tfor (let i = 0; i < this.children.length; i++) {\n\t\t\tconst r = this.children[i].render(width);\n\t\t\tconst old = cache.refs[i];\n\t\t\tif (r === old) continue;\n\t\t\tconst off = cache.offsets[i] + delta;\n\t\t\tif (r.length === old.length) {\n\t\t\t\tfor (let k = 0; k < r.length; k++) {\n\t\t\t\t\tif (old[k] !== r[k]) {\n\t\t\t\t\t\tconst row = off + k;\n\t\t\t\t\t\tflat[row] = r[k];\n\t\t\t\t\t\tif (row < low) low = row;\n\t\t\t\t\t\tif (row > high) high = row;\n\t\t\t\t\t\t// Overwrote the marker's row, or imported a line carrying a\n\t\t\t\t\t\t// (possibly relocated) marker: position must be re-extracted.\n\t\t\t\t\t\tif (cp && cp.row === row) this.cursorRowOverwritten = true;\n\t\t\t\t\t\tif (r[k].includes(CURSOR_MARKER)) this.cursorRowOverwritten = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Length changed: find the first differing line, then splice the\n\t\t\t\t// child's new lines in. Everything from there down shifts rows, so\n\t\t\t\t// the dirty range extends to the end (positional diff semantics).\n\t\t\t\tlet p = 0;\n\t\t\t\tconst minLen = Math.min(old.length, r.length);\n\t\t\t\twhile (p < minLen && old[p] === r[p]) p++;\n\t\t\t\tflat = flat.slice(0, off + p).concat(r.slice(p), flat.slice(off + old.length));\n\t\t\t\tif (off + p < low) low = off + p;\n\t\t\t\tif (cp) {\n\t\t\t\t\tif (cp.row >= off + old.length) {\n\t\t\t\t\t\t// Below the replaced region: shifts with it.\n\t\t\t\t\t\tcp = { row: cp.row + (r.length - old.length), col: cp.col };\n\t\t\t\t\t} else if (cp.row >= off + p) {\n\t\t\t\t\t\t// Inside the replaced region: fresh content, re-extract.\n\t\t\t\t\t\tthis.cursorRowOverwritten = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (!this.cursorRowOverwritten) {\n\t\t\t\t\tfor (let k = p; k < r.length; k++) {\n\t\t\t\t\t\tif (r[k].includes(CURSOR_MARKER)) {\n\t\t\t\t\t\t\tthis.cursorRowOverwritten = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdelta += r.length - old.length;\n\t\t\t}\n\t\t\tcache.refs[i] = r;\n\t\t}\n\t\tthis.lastCursorPos = cp;\n\t\tconst spliced = flat !== this.flatLines;\n\t\tif (spliced) {\n\t\t\tlet acc = 0;\n\t\t\tfor (let i = 0; i < cache.refs.length; i++) {\n\t\t\t\tcache.offsets[i] = acc;\n\t\t\t\tacc += cache.refs[i].length;\n\t\t\t}\n\t\t\tthis.flatLines = flat;\n\t\t\t// Rows below the first splice all shifted; positional diff semantics\n\t\t\t// mean everything from there to the end must be treated as dirty.\n\t\t\thigh = Math.max(prevLength - 1, flat.length - 1);\n\t\t}\n\t\tthis.lastPatch = low === Infinity && high === -1 ? null : { low: low === Infinity ? 0 : low, high, prevLength };\n\t\treturn flat;\n\t}\n\n\t/**\n\t * Paint the pinned window onto the alternate screen.\n\t *\n\t * Deliberately not differential. The alternate screen is `rows` tall and\n\t * nothing else writes to it, so a whole frame is at most a screenful of\n\t * cells inside one synchronized-output pair — cheaper to emit than the\n\t * bookkeeping a diff would need, and with nothing to get out of step with.\n\t * The differential renderer's state is left exactly as the last live frame\n\t * left it, because `scrollToLive` throws it away rather than resuming from it.\n\t */\n\tprivate renderScrollView(): void {\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\n\t\t// No fill while pinned: the window is already the height of the screen,\n\t\t// and blank rows in the buffer would be rows of the transcript the reader\n\t\t// has to scroll past. The next live frame puts it back.\n\t\tthis.flexSpacer?.setHeight(0);\n\t\tlet lines = this.render(width);\n\t\t// A patch computed while pinned describes rows nothing painted to the\n\t\t// normal screen, so the live path must never be handed it.\n\t\tthis.lastPatch = \"full\";\n\t\tif (this.overlayStack.length > 0) {\n\t\t\tlines = this.compositeOverlays(lines, width, height);\n\t\t}\n\n\t\tconst viewHeight = this.scrollViewHeight();\n\t\tthis.scrollTotalLines = lines.length;\n\t\tconst maxOffset = Math.max(0, lines.length - viewHeight);\n\t\t// The transcript can shrink under a pinned view — a pane closing, a tool\n\t\t// block collapsing — so the offset is re-clamped every frame rather than\n\t\t// only where it is set.\n\t\tconst top = Math.min(Math.max(0, this.scrollOffset ?? 0), maxOffset);\n\t\tthis.scrollOffset = top;\n\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\tbuffer += HIDE_CURSOR;\n\t\t// Autowrap off for the paint: a full-width row would otherwise wrap into\n\t\t// the row below it and shift the rest of the window down by one.\n\t\tbuffer += \"\\x1b[?7l\";\n\n\t\tthis.refreshScrollSearch(lines.length);\n\t\tconst query = this.scrollSearch?.query ?? \"\";\n\t\tfor (let row = 0; row < viewHeight; row++) {\n\t\t\tbuffer += `\\x1b[${row + 1};1H\\x1b[2K`;\n\t\t\tconst line = lines[top + row];\n\t\t\tif (line !== undefined) buffer += this.emitScrollLine(line, query);\n\t\t}\n\n\t\tbuffer += `\\x1b[${height};1H\\x1b[2K`;\n\t\tbuffer += this.scrollStatusFormatter({\n\t\t\ttop: top + 1,\n\t\t\tbottom: Math.min(top + viewHeight, lines.length),\n\t\t\ttotal: lines.length,\n\t\t\tviewHeight,\n\t\t\tatTop: top === 0,\n\t\t\tatBottom: top >= maxOffset,\n\t\t\twidth,\n\t\t\tsearch: this.scrollSearchStatus(),\n\t\t});\n\n\t\tbuffer += \"\\x1b[?7h\";\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\tthis.terminal.write(buffer);\n\n\t\t// `previousWidth` / `previousHeight` are deliberately left describing the\n\t\t// last *live* frame. If the terminal was resized while pinned they will\n\t\t// disagree with the real size on the way out, and the live path will take\n\t\t// its full-redraw branch — which is exactly right, because the normal\n\t\t// screen `?1049l` restored was drawn at the old size.\n\t}\n\n\t/**\n\t * One transcript row, ready for the pinned window.\n\t *\n\t * Images are named rather than drawn. A kitty or iTerm image is placed by\n\t * the cursor and sized in pixels, so the same escape replayed at a different\n\t * screen row lands somewhere the window did not ask for and survives the\n\t * frame that was supposed to replace it — a smear across the view that no\n\t * later repaint can clear.\n\t */\n\tprivate emitScrollLine(line: string, query = \"\"): string {\n\t\tif (isImageLine(line)) return \"\\x1b[2m[image]\\x1b[0m\";\n\t\tconst marker = line.indexOf(CURSOR_MARKER);\n\t\tlet text = marker === -1 ? line : line.slice(0, marker) + line.slice(marker + CURSOR_MARKER.length);\n\t\tif (query.length > 0) text = this.highlightScrollMatches(text, query);\n\t\treturn normalizeTerminalOutput(text) + TUI.SEGMENT_RESET;\n\t}\n\n\tprivate doRender(): void {\n\t\tif (this.stopped) return;\n\t\tif (this.scrollOffset !== null) {\n\t\t\tthis.renderScrollView();\n\t\t\treturn;\n\t\t}\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\t\tconst widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;\n\t\tconst heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;\n\t\tconst previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;\n\t\tlet prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;\n\t\tlet viewportTop = prevViewportTop;\n\t\tlet hardwareCursorRow = this.hardwareCursorRow;\n\t\tconst computeLineDiff = (targetRow: number): number => {\n\t\t\tconst currentScreenRow = hardwareCursorRow - prevViewportTop;\n\t\t\tconst targetScreenRow = targetRow - viewportTop;\n\t\t\treturn targetScreenRow - currentScreenRow;\n\t\t};\n\n\t\t// Render all components to get new lines. The root render() reports what\n\t\t// it changed via lastPatch; consume it here (it is per-frame state).\n\t\tlet newLines = this.render(width);\n\t\t// The frame has to exist before its leftover rows can be counted, so the\n\t\t// fill is settled by re-flattening rather than predicted. The second pass\n\t\t// invalidates the first's patch — it describes the buffer from before the\n\t\t// splice — so fall back to the full scan, which is always correct and only\n\t\t// runs on frames where the content height actually changed.\n\t\tif (this.fitFlexSpacer(newLines, height)) {\n\t\t\tnewLines = this.render(width);\n\t\t\tthis.lastPatch = \"full\";\n\t\t}\n\t\tconst patch = this.lastPatch;\n\t\tthis.lastPatch = \"full\";\n\n\t\t// Composite overlays into the rendered lines (before differential compare)\n\t\tif (this.overlayStack.length > 0) {\n\t\t\tnewLines = this.compositeOverlays(newLines, width, height);\n\t\t}\n\n\t\t// Extract cursor position before the marker could be obscured. The reset\n\t\t// is applied per-line at write time (see emitLine), so newLines stays the\n\t\t// un-reset, reference-stable output of the component tree from here on.\n\t\t// On patched frames the persistent flat buffer already had the marker\n\t\t// stripped; the cached position (row-shifted by render()) stays valid\n\t\t// unless its row was overwritten by re-imported child content, or a\n\t\t// marker could have newly appeared in changed content.\n\t\tlet cursorPos: { row: number; col: number } | null;\n\t\tif (patch !== \"full\" && this.lastCursorPos !== undefined) {\n\t\t\tconst cp = this.lastCursorPos;\n\t\t\tif (cp !== null && !this.cursorRowOverwritten) {\n\t\t\t\tcursorPos = cp;\n\t\t\t} else if (patch === null) {\n\t\t\t\tcursorPos = cp;\n\t\t\t} else {\n\t\t\t\tcursorPos = this.extractCursorPosition(newLines, height);\n\t\t\t}\n\t\t} else {\n\t\t\tcursorPos = this.extractCursorPosition(newLines, height);\n\t\t}\n\t\tthis.lastCursorPos = cursorPos;\n\n\t\t// Helper to clear scrollback and viewport and render all new lines\n\t\tconst fullRender = (clear: boolean): void => {\n\t\t\tthis.fullRedrawCount += 1;\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tif (clear) {\n\t\t\t\tbuffer += this.deleteKittyImages(this.previousKittyImageIds);\n\t\t\t\tbuffer += \"\\x1b[2J\\x1b[H\\x1b[3J\"; // Clear screen, home, then clear scrollback\n\t\t\t}\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += this.emitLine(newLines[i]);\n\t\t\t}\n\t\t\tthis.cursorRow = Math.max(0, newLines.length - 1);\n\t\t\tthis.hardwareCursorRow = this.cursorRow;\n\t\t\tbuffer += this.buildHardwareCursorMove(cursorPos, newLines.length);\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\t// Reset max lines when clearing, otherwise track growth\n\t\t\tif (clear) {\n\t\t\t\tthis.maxLinesRendered = newLines.length;\n\t\t\t} else {\n\t\t\t\tthis.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);\n\t\t\t}\n\t\t\tconst bufferLength = Math.max(height, newLines.length);\n\t\t\tthis.previousViewportTop = Math.max(0, bufferLength - height);\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\t\tthis.previousWidth = width;\n\t\t\tthis.previousHeight = height;\n\t\t};\n\n\t\tconst debugRedraw = process.env.HOOCODE_DEBUG_REDRAW === \"1\";\n\t\tconst logRedraw = (reason: string): void => {\n\t\t\tif (!debugRedraw) return;\n\t\t\tconst agentDir = process.env.HOOCODE_CODING_AGENT_DIR ?? path.join(os.homedir(), \".hoocode\", \"agent\");\n\t\t\tconst logPath = path.join(agentDir, \"hoocode-debug.log\");\n\t\t\tconst msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\\n`;\n\t\t\tfs.appendFileSync(logPath, msg);\n\t\t};\n\n\t\t// First render - just output everything without clearing (assumes clean screen)\n\t\tif (this.previousLines.length === 0 && !widthChanged && !heightChanged) {\n\t\t\tlogRedraw(\"first render\");\n\t\t\tfullRender(false);\n\t\t\treturn;\n\t\t}\n\n\t\t// Width changes always need a full re-render because wrapping changes.\n\t\tif (widthChanged) {\n\t\t\tlogRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Height changes normally need a full re-render to keep the visible viewport aligned,\n\t\t// but Termux changes height when the software keyboard shows or hides.\n\t\t// In that environment, a full redraw causes the entire history to replay on every toggle.\n\t\tif (heightChanged && !isTermuxSession()) {\n\t\t\tlogRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Content shrunk below the working area and no overlays - re-render to clear empty rows\n\t\t// (overlays need the padding, so only do this when no overlays are active)\n\t\t// Configurable via setClearOnShrink() or HOOCODE_CLEAR_ON_SHRINK=0 env var\n\t\tif (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {\n\t\t\tlogRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Find first and last changed lines. When the root render() produced a\n\t\t// patch report the dirty range is already known and the whole-buffer scan\n\t\t// is skipped. On patched frames previousLines is the same in-place-updated\n\t\t// array as newLines, so the previous length must come from the report.\n\t\tlet firstChanged: number;\n\t\tlet lastChanged: number;\n\t\tlet prevLineCount: number;\n\t\tif (patch !== \"full\") {\n\t\t\tif (patch === null) {\n\t\t\t\tprevLineCount = newLines.length;\n\t\t\t\tfirstChanged = -1;\n\t\t\t\tlastChanged = -1;\n\t\t\t} else {\n\t\t\t\tprevLineCount = patch.prevLength;\n\t\t\t\tfirstChanged = patch.low;\n\t\t\t\tlastChanged = patch.high;\n\t\t\t}\n\t\t} else {\n\t\t\tprevLineCount = this.previousLines.length;\n\t\t\tfirstChanged = -1;\n\t\t\tlastChanged = -1;\n\t\t\tconst maxLines = Math.max(newLines.length, prevLineCount);\n\t\t\tfor (let i = 0; i < maxLines; i++) {\n\t\t\t\tconst oldLine = i < prevLineCount ? this.previousLines[i] : \"\";\n\t\t\t\tconst newLine = i < newLines.length ? newLines[i] : \"\";\n\n\t\t\t\tif (oldLine !== newLine) {\n\t\t\t\t\tif (firstChanged === -1) {\n\t\t\t\t\t\tfirstChanged = i;\n\t\t\t\t\t}\n\t\t\t\t\tlastChanged = i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst appendedLines = newLines.length > prevLineCount;\n\t\tif (appendedLines) {\n\t\t\tif (firstChanged === -1) {\n\t\t\t\tfirstChanged = prevLineCount;\n\t\t\t}\n\t\t\tlastChanged = newLines.length - 1;\n\t\t}\n\t\tif (firstChanged !== -1) {\n\t\t\tlastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);\n\t\t}\n\t\tconst appendStart = appendedLines && firstChanged === prevLineCount && firstChanged > 0;\n\n\t\t// No changes - but still need to update hardware cursor position if it moved\n\t\tif (firstChanged === -1) {\n\t\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\t\t\tthis.previousViewportTop = prevViewportTop;\n\t\t\tthis.previousHeight = height;\n\t\t\treturn;\n\t\t}\n\n\t\t// All changes are in deleted lines (nothing to render, just clear)\n\t\tif (firstChanged >= newLines.length) {\n\t\t\tif (prevLineCount > newLines.length) {\n\t\t\t\tlet buffer = \"\\x1b[?2026h\";\n\t\t\t\tbuffer += this.deleteChangedKittyImages(firstChanged, lastChanged);\n\t\t\t\t// Move to end of new content (clamp to 0 for empty content)\n\t\t\t\tconst targetRow = Math.max(0, newLines.length - 1);\n\t\t\t\tif (targetRow < prevViewportTop) {\n\t\t\t\t\tlogRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);\n\t\t\t\t\tfullRender(true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst lineDiff = computeLineDiff(targetRow);\n\t\t\t\tif (lineDiff > 0) buffer += `\\x1b[${lineDiff}B`;\n\t\t\t\telse if (lineDiff < 0) buffer += `\\x1b[${-lineDiff}A`;\n\t\t\t\tbuffer += \"\\r\";\n\t\t\t\t// Clear extra lines without scrolling\n\t\t\t\tconst extraLines = prevLineCount - newLines.length;\n\t\t\t\tif (extraLines > height) {\n\t\t\t\t\tlogRedraw(`extraLines > height (${extraLines} > ${height})`);\n\t\t\t\t\tfullRender(true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (extraLines > 0) {\n\t\t\t\t\tbuffer += \"\\x1b[1B\";\n\t\t\t\t}\n\t\t\t\tfor (let i = 0; i < extraLines; i++) {\n\t\t\t\t\tbuffer += \"\\r\\x1b[2K\";\n\t\t\t\t\tif (i < extraLines - 1) buffer += \"\\x1b[1B\";\n\t\t\t\t}\n\t\t\t\tif (extraLines > 0) {\n\t\t\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t\t\t}\n\t\t\t\tthis.cursorRow = targetRow;\n\t\t\t\tthis.hardwareCursorRow = targetRow;\n\t\t\t\tbuffer += this.buildHardwareCursorMove(cursorPos, newLines.length);\n\t\t\t\tbuffer += \"\\x1b[?2026l\";\n\t\t\t\tthis.terminal.write(buffer);\n\t\t\t} else {\n\t\t\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\t\t\t}\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\t\tthis.previousWidth = width;\n\t\t\tthis.previousHeight = height;\n\t\t\tthis.previousViewportTop = prevViewportTop;\n\t\t\treturn;\n\t\t}\n\n\t\t// Differential rendering can only touch what was actually visible.\n\t\t// If the first changed line is above the previous viewport, we need a full redraw.\n\t\tif (firstChanged < prevViewportTop) {\n\t\t\tlogRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Render from first changed line to end\n\t\t// Build buffer with all updates wrapped in synchronized output\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\tbuffer += this.deleteChangedKittyImages(firstChanged, lastChanged);\n\t\tconst prevViewportBottom = prevViewportTop + height - 1;\n\t\tconst moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;\n\t\tif (moveTargetRow > prevViewportBottom) {\n\t\t\tconst currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));\n\t\t\tconst moveToBottom = height - 1 - currentScreenRow;\n\t\t\tif (moveToBottom > 0) {\n\t\t\t\tbuffer += `\\x1b[${moveToBottom}B`;\n\t\t\t}\n\t\t\tconst scroll = moveTargetRow - prevViewportBottom;\n\t\t\tbuffer += \"\\r\\n\".repeat(scroll);\n\t\t\tprevViewportTop += scroll;\n\t\t\tviewportTop += scroll;\n\t\t\thardwareCursorRow = moveTargetRow;\n\t\t}\n\n\t\t// Move cursor to first changed line (use hardwareCursorRow for actual position)\n\t\tconst lineDiff = computeLineDiff(moveTargetRow);\n\t\tif (lineDiff > 0) {\n\t\t\tbuffer += `\\x1b[${lineDiff}B`; // Move down\n\t\t} else if (lineDiff < 0) {\n\t\t\tbuffer += `\\x1b[${-lineDiff}A`; // Move up\n\t\t}\n\n\t\tbuffer += appendStart ? \"\\r\\n\" : \"\\r\"; // Move to column 0\n\n\t\t// Only render changed lines (firstChanged to lastChanged), not all lines to end\n\t\t// This reduces flicker when only a single line changes (e.g., spinner animation)\n\t\tconst renderEnd = Math.min(lastChanged, newLines.length - 1);\n\t\tfor (let i = firstChanged; i <= renderEnd; i++) {\n\t\t\tif (i > firstChanged) buffer += \"\\r\\n\";\n\t\t\tbuffer += \"\\x1b[2K\"; // Clear current line\n\t\t\tconst line = newLines[i];\n\t\t\tconst isImage = isImageLine(line);\n\t\t\tif (!isImage && visibleWidth(line) > width) {\n\t\t\t\t// Log all lines to crash file for debugging\n\t\t\t\tconst agentDir = process.env.HOOCODE_CODING_AGENT_DIR ?? path.join(os.homedir(), \".hoocode\", \"agent\");\n\t\t\t\tconst crashLogPath = path.join(agentDir, \"hoocode-crash.log\");\n\t\t\t\tconst crashData = [\n\t\t\t\t\t`Crash at ${new Date().toISOString()}`,\n\t\t\t\t\t`Terminal width: ${width}`,\n\t\t\t\t\t`Line ${i} visible width: ${visibleWidth(line)}`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"=== All rendered lines ===\",\n\t\t\t\t\t...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tfs.mkdirSync(path.dirname(crashLogPath), { recursive: true });\n\t\t\t\tfs.writeFileSync(crashLogPath, crashData);\n\n\t\t\t\t// Clean up terminal state before throwing\n\t\t\t\tthis.stop();\n\n\t\t\t\tconst errorMsg = [\n\t\t\t\t\t`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"This is likely caused by a custom TUI component not truncating its output.\",\n\t\t\t\t\t\"Use visibleWidth() to measure and truncateToWidth() to truncate lines.\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t`Debug log written to: ${crashLogPath}`,\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tthrow new Error(errorMsg);\n\t\t\t}\n\t\t\tif (isImage) this.sawImageLine = true;\n\t\t\tbuffer += isImage ? line : normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;\n\t\t}\n\n\t\t// Track where cursor ended up after rendering\n\t\tlet finalCursorRow = renderEnd;\n\n\t\t// If we had more lines before, clear them and move cursor back\n\t\tif (prevLineCount > newLines.length) {\n\t\t\t// Move to end of new content first if we stopped before it\n\t\t\tif (renderEnd < newLines.length - 1) {\n\t\t\t\tconst moveDown = newLines.length - 1 - renderEnd;\n\t\t\t\tbuffer += `\\x1b[${moveDown}B`;\n\t\t\t\tfinalCursorRow = newLines.length - 1;\n\t\t\t}\n\t\t\tconst extraLines = prevLineCount - newLines.length;\n\t\t\tfor (let i = newLines.length; i < prevLineCount; i++) {\n\t\t\t\tbuffer += \"\\r\\n\\x1b[2K\";\n\t\t\t}\n\t\t\t// Move cursor back to end of new content\n\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t}\n\n\t\t// Track cursor position for next render\n\t\t// cursorRow tracks end of content (for viewport calculation)\n\t\t// hardwareCursorRow tracks actual terminal cursor position (for movement)\n\t\tthis.cursorRow = Math.max(0, newLines.length - 1);\n\t\tthis.hardwareCursorRow = finalCursorRow;\n\n\t\t// Position hardware cursor for IME. Inside the synchronized block, so the\n\t\t// frame is never presented with the cursor still parked at the end of the\n\t\t// last redrawn line.\n\t\tbuffer += this.buildHardwareCursorMove(cursorPos, newLines.length);\n\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\n\t\tif (process.env.HOOCODE_TUI_DEBUG === \"1\") {\n\t\t\tconst debugDir = \"/tmp/tui\";\n\t\t\tfs.mkdirSync(debugDir, { recursive: true });\n\t\t\tconst debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);\n\t\t\tconst debugData = [\n\t\t\t\t`firstChanged: ${firstChanged}`,\n\t\t\t\t`viewportTop: ${viewportTop}`,\n\t\t\t\t`cursorRow: ${this.cursorRow}`,\n\t\t\t\t`height: ${height}`,\n\t\t\t\t`lineDiff: ${lineDiff}`,\n\t\t\t\t`hardwareCursorRow: ${hardwareCursorRow}`,\n\t\t\t\t`renderEnd: ${renderEnd}`,\n\t\t\t\t`finalCursorRow: ${finalCursorRow}`,\n\t\t\t\t`cursorPos: ${JSON.stringify(cursorPos)}`,\n\t\t\t\t`newLines.length: ${newLines.length}`,\n\t\t\t\t`previousLines.length: ${this.previousLines.length}`,\n\t\t\t\t\"\",\n\t\t\t\t\"=== newLines ===\",\n\t\t\t\tJSON.stringify(newLines, null, 2),\n\t\t\t\t\"\",\n\t\t\t\t\"=== previousLines ===\",\n\t\t\t\tJSON.stringify(this.previousLines, null, 2),\n\t\t\t\t\"\",\n\t\t\t\t\"=== buffer ===\",\n\t\t\t\tJSON.stringify(buffer),\n\t\t\t].join(\"\\n\");\n\t\t\tfs.writeFileSync(debugPath, debugData);\n\t\t}\n\n\t\t// Write entire buffer at once\n\t\tthis.terminal.write(buffer);\n\n\t\t// Track terminal's working area (grows but doesn't shrink unless cleared)\n\t\tthis.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);\n\t\tthis.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);\n\n\t\tthis.previousLines = newLines;\n\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\tthis.previousWidth = width;\n\t\tthis.previousHeight = height;\n\t}\n\n\t/**\n\t * Build the escape sequence that parks the hardware cursor for this frame.\n\t *\n\t * Callers must append the result to the frame buffer *inside* the\n\t * synchronized-output block. Emitting it as a separate write leaves the\n\t * cursor wherever the last redrawn line ended for the gap between the two\n\t * writes, which on an animated status line shows up as a cursor flickering\n\t * at the end of that line at the animation's cadence.\n\t *\n\t * Updates `hardwareCursorRow` to where the sequence leaves the cursor.\n\t *\n\t * @param cursorPos The cursor position extracted from rendered output, or null\n\t * @param totalLines Total number of rendered lines\n\t */\n\tprivate buildHardwareCursorMove(cursorPos: { row: number; col: number } | null, totalLines: number): string {\n\t\tconst visibility = this.showHardwareCursor ? SHOW_CURSOR : HIDE_CURSOR;\n\n\t\tif (!cursorPos || totalLines <= 0) {\n\t\t\t// Nothing focused, so there is no position to honor - but the cursor\n\t\t\t// still has to land somewhere known. Lines are padded to the full\n\t\t\t// terminal width, so a frame that ends after the last emitted line\n\t\t\t// leaves the cursor in the terminal's pending-wrap state at the right\n\t\t\t// margin, where it renders on the following row on some terminals.\n\t\t\t// Returning to column 0 keeps it on the row we think it is on.\n\t\t\treturn `\\r${HIDE_CURSOR}`;\n\t\t}\n\n\t\t// Clamp cursor position to valid range\n\t\tconst targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));\n\t\tconst targetCol = Math.max(0, cursorPos.col);\n\n\t\t// Move cursor from current position to target\n\t\tconst rowDelta = targetRow - this.hardwareCursorRow;\n\t\tlet buffer = \"\";\n\t\tif (rowDelta > 0) {\n\t\t\tbuffer += `\\x1b[${rowDelta}B`; // Move down\n\t\t} else if (rowDelta < 0) {\n\t\t\tbuffer += `\\x1b[${-rowDelta}A`; // Move up\n\t\t}\n\t\t// Move to absolute column (1-indexed)\n\t\tbuffer += `\\x1b[${targetCol + 1}G`;\n\n\t\tthis.hardwareCursorRow = targetRow;\n\t\treturn buffer + visibility;\n\t}\n\n\t/**\n\t * Position the hardware cursor for IME candidate window, as a standalone\n\t * write. Only for frames that emit no content of their own; frames that\n\t * build a buffer must fold `buildHardwareCursorMove` into it instead.\n\t */\n\tprivate positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {\n\t\tthis.terminal.write(this.buildHardwareCursorMove(cursorPos, totalLines));\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"AAAA;;GAEG;AAOH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAGzD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAiC9C;;GAEG;AACH,MAAM,WAAW,SAAS;IACzB;;;;OAIG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAEhC;;OAEG;IACH,WAAW,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAEjC;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI,CAAC;CACnB;AAED,KAAK,mBAAmB,GAAG;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC;AAC5E,KAAK,aAAa,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,mBAAmB,CAAC;AAE3D;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACzB,oFAAoF;IACpF,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,8DAA8D;AAC9D,wBAAgB,WAAW,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,GAAG,SAAS,IAAI,SAAS,GAAG,SAAS,CAE3F;AAED;;;;;GAKG;AACH,eAAO,MAAM,aAAa,sBAAkB,CAAC;AAoB7C,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IAC5B,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAC;IACZ,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;IACf,uFAAqF;IACrF,QAAQ,EAAE,OAAO,CAAC;IAClB,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,MAAM,CAAC,EAAE,kBAAkB,CAAC;CAC5B;AAED,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,iDAAiD;IACjD,MAAM,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,MAAM,qBAAqB,GAAG,CAAC,MAAM,EAAE,YAAY,KAAK,MAAM,CAAC;AAuBrE;;GAEG;AACH,MAAM,MAAM,aAAa,GACtB,QAAQ,GACR,UAAU,GACV,WAAW,GACX,aAAa,GACb,cAAc,GACd,YAAY,GACZ,eAAe,GACf,aAAa,GACb,cAAc,CAAC;AAElB;;GAEG;AACH,MAAM,WAAW,aAAa;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,4EAA4E;AAC5E,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC;AAkB9C;;;GAGG;AACH,MAAM,WAAW,cAAc;IAE9B,sEAAsE;IACtE,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,+BAA+B;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,SAAS,CAAC;IAGtB,uDAAuD;IACvD,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;IAGjB,gFAAgF;IAChF,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,4FAA4F;IAC5F,GAAG,CAAC,EAAE,SAAS,CAAC;IAGhB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,aAAa,GAAG,MAAM,CAAC;IAGhC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7D,uDAAuD;IACvD,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC7B,6DAA6D;IAC7D,IAAI,IAAI,IAAI,CAAC;IACb,2CAA2C;IAC3C,SAAS,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC;IACjC,6CAA6C;IAC7C,QAAQ,IAAI,OAAO,CAAC;IACpB,0DAA0D;IAC1D,KAAK,IAAI,IAAI,CAAC;IACd,2CAA2C;IAC3C,OAAO,IAAI,IAAI,CAAC;IAChB,gDAAgD;IAChD,SAAS,IAAI,OAAO,CAAC;CACrB;AAED;;GAEG;AACH,qBAAa,SAAU,YAAW,SAAS;IAC1C,QAAQ,EAAE,SAAS,EAAE,CAAM;IAM3B,OAAO,CAAC,UAAU,CAAC,CAAuD;IAE1E,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAGnC;IAED,WAAW,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAMtC;IAED,KAAK,IAAI,IAAI,CAGZ;IAED,UAAU,IAAI,IAAI,CAKjB;IAED;;;;;;;;;;;OAWG;IACH,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAUnD;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAsB9B;CACD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,IAAK,YAAW,SAAS;IAKzB,OAAO,CAAC,SAAS;IAJ7B,2EAA2E;IAC3E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAsD;IACnF,OAAO,CAAC,MAAM,CAAS;IAEvB,YAAoB,SAAS,EAAE,SAAS,EAAI;IAE5C,wCAAwC;IACxC,IAAI,KAAK,IAAI,SAAS,CAErB;IAED;;;;;;;;OAQG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,OAAO,CAItC;IAED,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,2EAA2E;IAC3E,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAKpC;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAG9B;CACD;AAED;;GAEG;AACH,qBAAa,GAAI,SAAQ,SAAS;IAC1B,QAAQ,EAAE,QAAQ,CAAC;IAC1B,OAAO,CAAC,aAAa,CAAgB;IAKrC,OAAO,CAAC,SAAS,CAAC,CAAyD;IAC3E,OAAO,CAAC,SAAS,CAAC,CAAW;IAC7B;iFAC6E;IAC7E,OAAO,CAAC,SAAS,CAA6E;IAC9F;6EACyE;IACzE,OAAO,CAAC,aAAa,CAA8D;IACnF;8EAC0E;IAC1E,OAAO,CAAC,oBAAoB,CAAS;IACrC,OAAO,CAAC,qBAAqB,CAAqB;IAClD;;;kDAG8C;IAC9C,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAA0C;IACjF,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,cAAc,CAA4B;IAElD,2GAA2G;IACpG,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,sBAAsB,CAAM;IACpD,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,kBAAkB,CAA+C;IACzE,OAAO,CAAC,aAAa,CAA+C;IACpE,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,mBAAmB,CAAK;IAChC,OAAO,CAAC,eAAe,CAAK;IAC5B,OAAO,CAAC,OAAO,CAAS;IAExB;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,OAAO,CAAC,UAAU,CAAC,CAAa;IAEhC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,OAAO,CAAC,YAAY,CAAuB;IAC3C,+EAA+E;IAC/E,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,qBAAqB,CAA8C;IAC3E,6EAA6E;IAC7E,OAAO,CAAC,YAAY,CAOJ;IAChB;;;;;;;;OAQG;IACI,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC;IAGpC,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,YAAY,CAMX;IAET,YAAY,QAAQ,EAAE,QAAQ,EAAE,kBAAkB,CAAC,EAAE,OAAO,EAM3D;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAID,oEAAoE;IACpE,IAAI,YAAY,IAAI,OAAO,CAE1B;IAED,wDAAwD;IACxD,iBAAiB,IAAI;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAG7E;IAED,wDAAwD;IACxD,wBAAwB,CAAC,SAAS,EAAE,qBAAqB,GAAG,IAAI,CAE/D;IAED;;;;;;OAMG;IACH,aAAa,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,GAAG,IAAI,CAElD;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA+BG;IACH,OAAO,CAAC,aAAa;IAcrB;;;;;;OAMG;IACH,OAAO,CAAC,gBAAgB;IAIxB;;;8CAG0C;IAC1C,OAAO,CAAC,gBAAgB;IAKxB;;;;;OAKG;IACH,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAmBpC;IAED;;;;;;OAMG;IACH,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAGpC;IAED,wDAAwD;IACxD,WAAW,IAAI,OAAO,CAMrB;IAED,iDAAiD;IACjD,YAAY,IAAI,OAAO,CAiBtB;IAID;;;;;;;;;;;;OAYG;IACH,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,MAAM,CA+BzE;IAED;;;;;OAKG;IACH,gBAAgB,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,OAAO,CAU3C;IAED,wEAAwE;IACxE,kBAAkB,IAAI,IAAI,CAKzB;IAED,qDAAqD;IACrD,iBAAiB,IAAI,IAAI,CAKxB;IAED,IAAI,kBAAkB,IAAI,OAAO,CAEhC;IAED,mEAAmE;IACnE,IAAI,iBAAiB,IAAI,MAAM,CAE9B;IAED,OAAO,CAAC,kBAAkB;IAW1B;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAYzB,qDAAqD;IACrD,OAAO,CAAC,mBAAmB;IAQ3B;;;;;;;;;OASG;IACH,OAAO,CAAC,sBAAsB;IAyB9B,OAAO,CAAC,eAAe;IAsBvB,qBAAqB,IAAI,OAAO,CAE/B;IAED,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAO5C;IAED,gBAAgB,IAAI,OAAO,CAE1B;IAED;;;;OAIG;IACH,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAEvC;IAED,uDAAuD;IACvD,IAAI,OAAO,IAAI,SAAS,GAAG,IAAI,CAE9B;IAED;;;;;;OAMG;IACM,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAI5D;IAED;;;;;;;;OAQG;IACH,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAepE;IAED,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,GAAG,IAAI,CAY1C;IAED;;;OAGG;IACH,WAAW,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,aAAa,CAmEzE;IAED,2DAA2D;IAC3D,WAAW,IAAI,IAAI,CAUlB;IAED,8CAA8C;IAC9C,UAAU,IAAI,OAAO,CAEpB;IAED,qDAAqD;IACrD,OAAO,CAAC,gBAAgB;IAQxB,yDAAyD;IACzD,OAAO,CAAC,wBAAwB;IAUvB,UAAU,IAAI,IAAI,CAG1B;IAED,KAAK,IAAI,IAAI,CASZ;IAED,gBAAgB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,IAAI,CAKpD;IAED,mBAAmB,CAAC,QAAQ,EAAE,aAAa,GAAG,IAAI,CAEjD;IAED,OAAO,CAAC,aAAa;IAUrB,IAAI,IAAI,IAAI,CA0BX;IAED,aAAa,CAAC,KAAK,UAAQ,GAAG,IAAI,CA2BjC;IAED,OAAO,CAAC,cAAc;IAoBtB,OAAO,CAAC,WAAW;IAmEnB;;;;;;;OAOG;IACH,OAAO,CAAC,mBAAmB;IAyB3B;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IAUxB;4EACwE;IACxE,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,uBAAuB;IAoB/B;;;OAGG;IACH,OAAO,CAAC,oBAAoB;IAoG5B,OAAO,CAAC,gBAAgB;IAiBxB,OAAO,CAAC,gBAAgB;IAiBxB,yFAAyF;IACzF,OAAO,CAAC,iBAAiB;IA6DzB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAyB;IAE9D;;;;;;;;;;OAUG;IACH,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,oBAAoB;IAa5B,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,+BAA+B;IAavC,OAAO,CAAC,wBAAwB;IAchC,2FAA2F;IAC3F,OAAO,CAAC,eAAe;IAkDvB;;;;;;;OAOG;IACH,OAAO,CAAC,qBAAqB;IAoB7B;;;;;;;;OAQG;IACM,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAkGvC;IAED;;;;;;;;;OASG;IACH,OAAO,CAAC,gBAAgB;IA8DxB;;;;;;;;OAQG;IACH,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,QAAQ;IAqYhB;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,uBAAuB;IAgC/B;;;;OAIG;IACH,OAAO,CAAC,sBAAsB;CAG9B","sourcesContent":["/**\n * Minimal TUI implementation with differential rendering\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { performance } from \"node:perf_hooks\";\nimport { stripVTControlCharacters } from \"node:util\";\nimport type { FlexSpacer } from \"./components/spacer.js\";\nimport { isKeyRelease, matchesKey } from \"./keys.js\";\nimport { type MouseEvent, mouseSequenceLength, parseMouseEvent } from \"./mouse.js\";\nimport type { Terminal } from \"./terminal.js\";\nimport { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from \"./terminal-image.js\";\nimport {\n\textractSegments,\n\tnormalizeTerminalOutput,\n\tsliceByColumn,\n\tsliceWithWidth,\n\ttruncateToWidth,\n\tvisibleWidth,\n} from \"./utils.js\";\n\nconst KITTY_SEQUENCE_PREFIX = \"\\x1b_G\";\n\nfunction extractKittyImageIds(line: string): number[] {\n\tconst sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);\n\tif (sequenceStart === -1) return [];\n\n\tconst paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;\n\tconst paramsEnd = line.indexOf(\";\", paramsStart);\n\tif (paramsEnd === -1) return [];\n\n\tconst params = line.slice(paramsStart, paramsEnd);\n\tfor (const param of params.split(\",\")) {\n\t\tconst [key, value] = param.split(\"=\", 2);\n\t\tif (key !== \"i\" || value === undefined) continue;\n\t\tconst id = Number(value);\n\t\tif (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {\n\t\t\treturn [id];\n\t\t}\n\t}\n\treturn [];\n}\n\n/**\n * Component interface - all components must implement this\n */\nexport interface Component {\n\t/**\n\t * Render the component to lines for the given viewport width\n\t * @param width - Current viewport width\n\t * @returns Array of strings, each representing a line\n\t */\n\trender(width: number): string[];\n\n\t/**\n\t * Optional handler for keyboard input when component has focus\n\t */\n\thandleInput?(data: string): void;\n\n\t/**\n\t * If true, component receives key release events (Kitty protocol).\n\t * Default is false - release events are filtered out.\n\t */\n\twantsKeyRelease?: boolean;\n\n\t/**\n\t * Invalidate any cached rendering state.\n\t * Called when theme changes or when component needs to re-render from scratch.\n\t */\n\tinvalidate(): void;\n}\n\ntype InputListenerResult = { consume?: boolean; data?: string } | undefined;\ntype InputListener = (data: string) => InputListenerResult;\n\n/**\n * Interface for components that can receive focus and display a hardware cursor.\n * When focused, the component should emit CURSOR_MARKER at the cursor position\n * in its render output. TUI will find this marker and position the hardware\n * cursor there for proper IME candidate window positioning.\n */\nexport interface Focusable {\n\t/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */\n\tfocused: boolean;\n}\n\n/** Type guard to check if a component implements Focusable */\nexport function isFocusable(component: Component | null): component is Component & Focusable {\n\treturn component !== null && \"focused\" in component;\n}\n\n/**\n * Cursor position marker - APC (Application Program Command) sequence.\n * This is a zero-width escape sequence that terminals ignore.\n * Components emit this at the cursor position when focused.\n * TUI finds and strips this marker, then positions the hardware cursor there.\n */\nexport const CURSOR_MARKER = \"\\x1b_pi:c\\x07\";\n\n/**\n * DECTCEM cursor visibility, as strings rather than Terminal calls so they can\n * be folded into a frame's synchronized-output buffer instead of racing it as a\n * separate write.\n */\nconst HIDE_CURSOR = \"\\x1b[?25l\";\nconst SHOW_CURSOR = \"\\x1b[?25h\";\n\n/**\n * How far one wheel notch moves the pinned view.\n *\n * Three lines is what terminals, pagers and browsers have settled on, and the\n * agreement is the point: a wheel that moves a different distance here than in\n * every other window is the kind of wrongness people feel without being able to\n * name it.\n */\nconst WHEEL_LINES = 3;\n\n/** What the scroll indicator is told about the pinned view. */\nexport interface ScrollStatus {\n\t/** 1-based transcript row at the top of the view. */\n\ttop: number;\n\t/** 1-based transcript row at the bottom of the view. */\n\tbottom: number;\n\t/** Rows in the whole transcript. */\n\ttotal: number;\n\t/** Rows the view shows at once. */\n\tviewHeight: number;\n\tatTop: boolean;\n\t/** True only when the very last row is in view — the point where the pin lets go. */\n\tatBottom: boolean;\n\t/** Columns the indicator may fill. */\n\twidth: number;\n\t/** Present while a search is running; the indicator becomes its query line. */\n\tsearch?: ScrollSearchStatus;\n}\n\nexport interface ScrollSearchStatus {\n\tquery: string;\n\t/** Rows containing a match. */\n\tcount: number;\n\t/** 1-based position among the matches, or 0 when there are none. */\n\tindex: number;\n\t/** True while the query is still being typed. */\n\ttyping: boolean;\n}\n\nexport type ScrollStatusFormatter = (status: ScrollStatus) => string;\n\n/**\n * The indicator the tui draws when the app has not supplied its own.\n *\n * Reverse video rather than a colour, because this package has no theme and a\n * hard-coded colour is the one thing guaranteed to clash with whichever one the\n * app is using. It leads with the position — the question a pinned reader\n * actually has — and spends what is left on the keys, dropping them on a narrow\n * terminal rather than truncating the numbers.\n */\nfunction defaultScrollStatus(status: ScrollStatus): string {\n\tconst position = `${status.top}–${status.bottom}/${status.total}`;\n\tconst where = status.atTop ? \" top\" : \"\";\n\tconst keys = \"↑↓ line · PgUp/PgDn page · esc live\";\n\tconst left = ` ${position}${where} `;\n\t// Measured in columns, not characters: the arrows and the separator are one\n\t// cell each but a rebind could put anything in here, and a row that is one\n\t// cell too wide wraps into the window above it.\n\tconst body = visibleWidth(left) + visibleWidth(keys) + 1 <= status.width ? `${left}${keys} ` : left;\n\treturn `\\x1b[7m${truncateToWidth(body, status.width, \"\", true)}\\x1b[0m`;\n}\n\n/**\n * Anchor position for overlays\n */\nexport type OverlayAnchor =\n\t| \"center\"\n\t| \"top-left\"\n\t| \"top-right\"\n\t| \"bottom-left\"\n\t| \"bottom-right\"\n\t| \"top-center\"\n\t| \"bottom-center\"\n\t| \"left-center\"\n\t| \"right-center\";\n\n/**\n * Margin configuration for overlays\n */\nexport interface OverlayMargin {\n\ttop?: number;\n\tright?: number;\n\tbottom?: number;\n\tleft?: number;\n}\n\n/** Value that can be absolute (number) or percentage (string like \"50%\") */\nexport type SizeValue = number | `${number}%`;\n\n/** Parse a SizeValue into absolute value given a reference size */\nfunction parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {\n\tif (value === undefined) return undefined;\n\tif (typeof value === \"number\") return value;\n\t// Parse percentage string like \"50%\"\n\tconst match = value.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\tif (match) {\n\t\treturn Math.floor((referenceSize * parseFloat(match[1])) / 100);\n\t}\n\treturn undefined;\n}\n\nfunction isTermuxSession(): boolean {\n\treturn Boolean(process.env.TERMUX_VERSION);\n}\n\n/**\n * Options for overlay positioning and sizing.\n * Values can be absolute numbers or percentage strings (e.g., \"50%\").\n */\nexport interface OverlayOptions {\n\t// === Sizing ===\n\t/** Width in columns, or percentage of terminal width (e.g., \"50%\") */\n\twidth?: SizeValue;\n\t/** Minimum width in columns */\n\tminWidth?: number;\n\t/** Maximum height in rows, or percentage of terminal height (e.g., \"50%\") */\n\tmaxHeight?: SizeValue;\n\n\t// === Positioning - anchor-based ===\n\t/** Anchor point for positioning (default: 'center') */\n\tanchor?: OverlayAnchor;\n\t/** Horizontal offset from anchor position (positive = right) */\n\toffsetX?: number;\n\t/** Vertical offset from anchor position (positive = down) */\n\toffsetY?: number;\n\n\t// === Positioning - percentage or absolute ===\n\t/** Row position: absolute number, or percentage (e.g., \"25%\" = 25% from top) */\n\trow?: SizeValue;\n\t/** Column position: absolute number, or percentage (e.g., \"50%\" = centered horizontally) */\n\tcol?: SizeValue;\n\n\t// === Margin from terminal edges ===\n\t/** Margin from terminal edges. Number applies to all sides. */\n\tmargin?: OverlayMargin | number;\n\n\t// === Visibility ===\n\t/**\n\t * Control overlay visibility based on terminal dimensions.\n\t * If provided, overlay is only rendered when this returns true.\n\t * Called each render cycle with current terminal dimensions.\n\t */\n\tvisible?: (termWidth: number, termHeight: number) => boolean;\n\t/** If true, don't capture keyboard focus when shown */\n\tnonCapturing?: boolean;\n}\n\n/**\n * Handle returned by showOverlay for controlling the overlay\n */\nexport interface OverlayHandle {\n\t/** Permanently remove the overlay (cannot be shown again) */\n\thide(): void;\n\t/** Temporarily hide or show the overlay */\n\tsetHidden(hidden: boolean): void;\n\t/** Check if overlay is temporarily hidden */\n\tisHidden(): boolean;\n\t/** Focus this overlay and bring it to the visual front */\n\tfocus(): void;\n\t/** Release focus to the previous target */\n\tunfocus(): void;\n\t/** Check if this overlay currently has focus */\n\tisFocused(): boolean;\n}\n\n/**\n * Container - a component that contains other components\n */\nexport class Container implements Component {\n\tchildren: Component[] = [];\n\t// Flatten memo: children are always render()ed (side effects and their own\n\t// caches must run), but when every child returns the same array reference as\n\t// last time, the previously flattened array is returned as-is. Unchanged\n\t// subtrees thus stay reference-stable all the way up, which lets the TUI\n\t// root diff whole regions by identity instead of re-flattening the world.\n\tprivate renderMemo?: { width: number; refs: string[][]; lines: string[] };\n\n\taddChild(component: Component): void {\n\t\tthis.children.push(component);\n\t\tthis.renderMemo = undefined;\n\t}\n\n\tremoveChild(component: Component): void {\n\t\tconst index = this.children.indexOf(component);\n\t\tif (index !== -1) {\n\t\t\tthis.children.splice(index, 1);\n\t\t\tthis.renderMemo = undefined;\n\t\t}\n\t}\n\n\tclear(): void {\n\t\tthis.children = [];\n\t\tthis.renderMemo = undefined;\n\t}\n\n\tinvalidate(): void {\n\t\tthis.renderMemo = undefined;\n\t\tfor (const child of this.children) {\n\t\t\tchild.invalidate?.();\n\t\t}\n\t}\n\n\t/**\n\t * Where each direct child's output starts, in rows, from the last render.\n\t *\n\t * Read off the memo rather than recomputed, so asking is a walk over the\n\t * children's cached line arrays and never a re-render. Undefined before the\n\t * first render, or at a different width than the caller has in mind — both\n\t * cases mean \"no answer\", not \"zero\".\n\t *\n\t * This is what lets something outside the tree point at a row inside it: a\n\t * component's offset within its container, plus that container's offset at\n\t * the root, is its absolute row in the buffer the viewport windows over.\n\t */\n\tchildRowOffsets(width: number): number[] | undefined {\n\t\tconst memo = this.renderMemo;\n\t\tif (!memo || memo.width !== width) return undefined;\n\t\tconst offsets: number[] = new Array(memo.refs.length);\n\t\tlet row = 0;\n\t\tfor (let i = 0; i < memo.refs.length; i++) {\n\t\t\toffsets[i] = row;\n\t\t\trow += memo.refs[i].length;\n\t\t}\n\t\treturn offsets;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst n = this.children.length;\n\t\tconst memo = this.renderMemo;\n\t\tconst refs: string[][] = new Array(n);\n\t\tlet unchanged = memo !== undefined && memo.width === width && memo.refs.length === n;\n\t\tfor (let i = 0; i < n; i++) {\n\t\t\trefs[i] = this.children[i].render(width);\n\t\t\tif (unchanged && refs[i] !== (memo as { refs: string[][] }).refs[i]) {\n\t\t\t\tunchanged = false;\n\t\t\t}\n\t\t}\n\t\tif (unchanged) {\n\t\t\treturn (memo as { lines: string[] }).lines;\n\t\t}\n\t\tconst lines: string[] = [];\n\t\tfor (const childLines of refs) {\n\t\t\tfor (const line of childLines) {\n\t\t\t\tlines.push(line);\n\t\t\t}\n\t\t}\n\t\tthis.renderMemo = { width, refs, lines };\n\t\treturn lines;\n\t}\n}\n\n/**\n * A child that can be taken off screen without disturbing the diff.\n *\n * Hiding a component naively — returning `[]` from its render — is one of the\n * more expensive things you can do to this renderer. `Container.render` and the\n * root's flat cache both decide \"did this subtree change\" by **array identity**,\n * so a fresh `[]` every frame reads as a change every frame: the memo is\n * dropped, the buffer is re-flattened, and a dirty range is reported for a\n * component that is not even drawn. One frozen array, returned every time,\n * makes a hidden slot free instead.\n *\n * The child is not rendered at all while hidden, which is the other half of the\n * saving — a hidden footer costs nothing to keep hidden. That means a child\n * whose `render` advances an animation or maintains a cache will be paused, not\n * merely invisible; it catches up when shown again. Chrome (footers, panels,\n * status rows) is fine with that. A spinner is not, so do not wrap one.\n */\nexport class Slot implements Component {\n\t/** Shared across every hidden slot: identity is all the caches compare. */\n\tprivate static readonly EMPTY: string[] = Object.freeze([]) as unknown as string[];\n\tprivate hidden = false;\n\n\tconstructor(private component: Component) {}\n\n\t/** Whoever is in the slot right now. */\n\tget child(): Component {\n\t\treturn this.component;\n\t}\n\n\t/**\n\t * Swap the occupant, keeping the slot itself in place.\n\t *\n\t * An extension replacing the footer used to remove one root child and append\n\t * another, which both moved the footer to the end of the tree — behind the\n\t * widgets meant to sit below it — and handed the root's per-child cache a\n\t * changed child list every time. The slot is the stable root child; only what\n\t * is inside it changes.\n\t */\n\tsetChild(component: Component): boolean {\n\t\tif (this.component === component) return false;\n\t\tthis.component = component;\n\t\treturn true;\n\t}\n\n\tget visible(): boolean {\n\t\treturn !this.hidden;\n\t}\n\n\t/** Returns whether this changed anything, so callers can skip a render. */\n\tsetVisible(visible: boolean): boolean {\n\t\tconst hidden = !visible;\n\t\tif (this.hidden === hidden) return false;\n\t\tthis.hidden = hidden;\n\t\treturn true;\n\t}\n\n\tinvalidate(): void {\n\t\tthis.child.invalidate?.();\n\t}\n\n\trender(width: number): string[] {\n\t\tif (this.hidden) return Slot.EMPTY;\n\t\treturn this.child.render(width);\n\t}\n}\n\n/**\n * TUI - Main class for managing terminal UI with differential rendering\n */\nexport class TUI extends Container {\n\tpublic terminal: Terminal;\n\tprivate previousLines: string[] = [];\n\t// Root flat-line cache (see the render() override): per-child line arrays,\n\t// their offsets into the flat buffer, and the flat buffer itself. Active\n\t// only when no overlays are up and no image has been drawn; otherwise the\n\t// legacy full-flatten + full-diff path runs.\n\tprivate flatCache?: { width: number; refs: string[][]; offsets: number[] };\n\tprivate flatLines?: string[];\n\t/** What the last render() call changed: \"full\" = unknown (legacy diff must\n\t * scan), null = nothing, otherwise the dirty row range + previous length. */\n\tprivate lastPatch: { low: number; high: number; prevLength: number } | null | \"full\" = \"full\";\n\t/** Cursor position extracted on the last frame; reused when the dirty range\n\t * shows the marker's row untouched (the marker was already stripped). */\n\tprivate lastCursorPos: { row: number; col: number } | null | undefined = undefined;\n\t/** Set by render() when this frame's patches invalidate lastCursorPos: the\n\t * marker's row was overwritten, or a patched-in line carries a marker. */\n\tprivate cursorRowOverwritten = false;\n\tprivate previousKittyImageIds = new Set<number>();\n\t/** Flips true the first time an image line is emitted. While false no image\n\t * has ever been drawn, so there are no kitty ids on screen to track and the\n\t * per-frame full-buffer scan (collectKittyImageIds) is skipped entirely —\n\t * the common case for a pure-text session. */\n\tprivate sawImageLine = false;\n\tprivate static readonly EMPTY_KITTY_IDS: ReadonlySet<number> = new Set<number>();\n\tprivate previousWidth = 0;\n\tprivate previousHeight = 0;\n\tprivate focusedComponent: Component | null = null;\n\tprivate inputListeners = new Set<InputListener>();\n\n\t/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */\n\tpublic onDebug?: () => void;\n\tprivate renderRequested = false;\n\tprivate renderTimer: NodeJS.Timeout | undefined;\n\tprivate lastRenderAt = 0;\n\tprivate static readonly MIN_RENDER_INTERVAL_MS = 16;\n\tprivate cursorRow = 0; // Logical cursor row (end of rendered content)\n\tprivate hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)\n\tprivate showHardwareCursor = process.env.HOOCODE_HARDWARE_CURSOR === \"1\";\n\tprivate clearOnShrink = process.env.HOOCODE_CLEAR_ON_SHRINK === \"1\"; // Clear empty rows when content shrinks (default: off)\n\tprivate maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)\n\tprivate previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves\n\tprivate fullRedrawCount = 0;\n\tprivate stopped = false;\n\n\t/**\n\t * The filler that keeps the app the size of the screen.\n\t *\n\t * ## Why the app is full-screen at all\n\t *\n\t * This renderer appends: a frame is the whole component tree flattened into\n\t * a line buffer, written from wherever the cursor happens to be. On a fresh\n\t * session that buffer is a dozen rows, so the banner sat halfway up a\n\t * terminal with the prompt under it and forty rows of the user's shell\n\t * history above — and the prompt walked down the screen as the conversation\n\t * grew, only reaching the bottom row once the session was long enough to\n\t * scroll. Two different layouts for the same app, and the one you meet first\n\t * is the one that does not look like an app.\n\t *\n\t * ## What this does\n\t *\n\t * Before the frame is diffed, the root measures it and gives the leftover\n\t * rows to one designated child. The buffer is therefore never shorter than\n\t * the terminal, so the terminal's last row is always the buffer's last row:\n\t * the header stays at the top, the prompt and the footer stay on the bottom,\n\t * and everything between them is conversation. Nothing else changes — this\n\t * is still the normal screen, so scrollback, selection and search all still\n\t * work, and the session is still on screen after you quit.\n\t *\n\t * Set to `undefined` (no flex child) and the old append-only behaviour is\n\t * exactly what you get back, which is what the tests that predate this and\n\t * any embedder outside the app rely on.\n\t */\n\tprivate flexSpacer?: FlexSpacer;\n\n\t/**\n\t * The pinned viewport.\n\t *\n\t * ## What is wrong with letting the terminal do it\n\t *\n\t * This renderer keeps the entire transcript in its line buffer and writes it\n\t * to the normal screen, so \"scrolling\" has always meant the terminal's own\n\t * scrollback. That works exactly as long as the app does not repaint — and\n\t * this one repaints the whole buffer whenever a line *above* the viewport\n\t * changes, because a positional diff cannot address a row that has scrolled\n\t * out of reach. The repaint is `\\x1b[2J\\x1b[H\\x1b[3J` followed by the\n\t * transcript again, and the `\\x1b[3J` throws away the scrollback the reader\n\t * was sitting in. From the reader's side the screen simply jumps to the\n\t * bottom, for no reason they can see, at a moment they did not choose.\n\t *\n\t * ## What this does instead\n\t *\n\t * `scrollOffset` is the transcript row drawn at the top of the screen, and\n\t * `null` means \"follow the tail\", which is the normal live behaviour and the\n\t * path everything else in this file was written for. The moment it is a\n\t * number the TUI switches to the alternate screen and paints a window of the\n\t * buffer itself: a fixed grid, addressed row by row, with no scrollback for\n\t * anything to fight over. New output still arrives and still lands in the\n\t * buffer — it just does not move the window, which is the whole point. The\n\t * indicator on the last row says how far down the transcript the window is,\n\t * because a view that cannot move on its own needs to say where it stopped.\n\t *\n\t * Going back to live leaves the alternate screen, which restores the normal\n\t * screen *and its scrollback* exactly as they were, and the next frame is an\n\t * ordinary differential one that writes only what arrived while the reader\n\t * was away — see `scrollToLive` for what has to be true for that to be safe.\n\t * Not a clear-and-replay: on a long session that is a visible flash, a burst\n\t * of output, and the loss of the scrollback that had just been handed back.\n\t */\n\tprivate scrollOffset: number | null = null;\n\t/** Transcript length measured by the last pinned paint; what clamping uses. */\n\tprivate scrollTotalLines = 0;\n\tprivate scrollStatusFormatter: ScrollStatusFormatter = defaultScrollStatus;\n\t/** The running search: its query, the rows it matched, and where in them. */\n\tprivate scrollSearch: {\n\t\tquery: string;\n\t\tmatches: number[];\n\t\tindex: number;\n\t\t/** Buffer length the matches were measured at, so growth can re-run them. */\n\t\tmeasuredAt: number;\n\t\ttyping: boolean;\n\t} | null = null;\n\t/**\n\t * Whether the view may pin right now.\n\t *\n\t * The wheel is answered wherever it is turned, including with a picker on\n\t * screen — and a picker that lost its arrow keys to a pinned view it did not\n\t * know about would be far worse than a wheel that did nothing. The app sets\n\t * this because only the app knows which of its surfaces is asking a question.\n\t * Unset means always.\n\t */\n\tpublic canPinScroll?: () => boolean;\n\n\t// Overlay stack for modal components rendered on top of base content\n\tprivate focusOrderCounter = 0;\n\tprivate overlayStack: {\n\t\tcomponent: Component;\n\t\toptions?: OverlayOptions;\n\t\tpreFocus: Component | null;\n\t\thidden: boolean;\n\t\tfocusOrder: number;\n\t}[] = [];\n\n\tconstructor(terminal: Terminal, showHardwareCursor?: boolean) {\n\t\tsuper();\n\t\tthis.terminal = terminal;\n\t\tif (showHardwareCursor !== undefined) {\n\t\t\tthis.showHardwareCursor = showHardwareCursor;\n\t\t}\n\t}\n\n\tget fullRedraws(): number {\n\t\treturn this.fullRedrawCount;\n\t}\n\n\t// ── The pinned viewport ─────────────────────────────────────────────────\n\n\t/** True while the view is pinned rather than following the tail. */\n\tget scrollPinned(): boolean {\n\t\treturn this.scrollOffset !== null;\n\t}\n\n\t/** Where the pinned window sits, or null while live. */\n\tgetScrollPosition(): { top: number; total: number; viewHeight: number } | null {\n\t\tif (this.scrollOffset === null) return null;\n\t\treturn { top: this.scrollOffset, total: this.scrollTotalLines, viewHeight: this.scrollViewHeight() };\n\t}\n\n\t/** Let the app paint the indicator in its own theme. */\n\tsetScrollStatusFormatter(formatter: ScrollStatusFormatter): void {\n\t\tthis.scrollStatusFormatter = formatter;\n\t}\n\n\t/**\n\t * Nominate the child that absorbs the leftover rows (see `flexSpacer`).\n\t *\n\t * It must already be a child of the root, and it should sit between the part\n\t * of the tree that flows from the top and the chrome that hangs off the\n\t * bottom — everything after it is what gets pinned to the foot of the screen.\n\t */\n\tsetFlexSpacer(spacer: FlexSpacer | undefined): void {\n\t\tthis.flexSpacer = spacer;\n\t}\n\n\t/**\n\t * Give the flex child whatever the frame did not use, at `height` rows.\n\t *\n\t * Returns true when the height moved, meaning the caller has to flatten\n\t * again — the measurement can only be made from a finished frame, so the\n\t * frame that answers it is always the second one. Both passes are cheap\n\t * after the first: every other child returns its memoized array untouched.\n\t *\n\t * Two cases, and the second one is the one with a bug behind it.\n\t *\n\t * **The frame fits on the screen.** Fill it out to the screen's height and\n\t * the frame starts on the first row, which is the whole point of the fill.\n\t *\n\t * **The frame is taller than the screen.** Then the fill is not what puts\n\t * the prompt on the floor — the terminal's own scroll is, and the buffer's\n\t * last row *is* the screen's last row. So a buffer that gets *shorter*\n\t * takes the prompt up the screen with it: the renderer clears the rows that\n\t * came off the end and there is nothing it can do to scroll the transcript\n\t * back down into them, because those rows are in the terminal's scrollback\n\t * and only the terminal can move them. That is a picker closing, a\n\t * notification fading, a task ledger emptying — the prompt stranded\n\t * mid-screen with a band of blank rows under it, and it stays stranded\n\t * until enough output arrives to push it back down.\n\t *\n\t * So the fill takes what the shrinking content gave up, which keeps the\n\t * buffer the length it already was and the last row where it already is.\n\t * The blank band ends up *above* the chrome instead of below it, where it\n\t * reads as room rather than as a layout that came apart, and the next\n\t * output to arrive lands in it rather than scrolling the screen. Capped at\n\t * a screenful: a fill longer than the screen is rows nobody can see, and it\n\t * is given up altogether on the frames that repaint the whole screen anyway.\n\t */\n\tprivate fitFlexSpacer(lines: string[], height: number, repaint = false): boolean {\n\t\tconst spacer = this.flexSpacer;\n\t\tif (!spacer) return false;\n\t\tconst content = lines.length - spacer.currentHeight;\n\t\tif (content < height) return spacer.setHeight(height - content);\n\t\t// A frame that is about to be repainted from the top of a cleared screen\n\t\t// has no floor to hold: the resize already threw the old screen away, and\n\t\t// holding its length would only bank a band of blank rows into the middle\n\t\t// of the new one.\n\t\tif (repaint) return spacer.setHeight(0);\n\t\tconst held = Math.min(this.previousLines.length - content, height);\n\t\treturn spacer.setHeight(Math.max(0, held));\n\t}\n\n\t/**\n\t * The screen rows a pinned window shows, the last one being the indicator.\n\t *\n\t * The indicator is not optional: a pinned view looks exactly like a live one\n\t * that has gone quiet, and a reader who cannot tell the two apart will wait\n\t * for output that is arriving perfectly well just out of sight.\n\t */\n\tprivate scrollViewHeight(): number {\n\t\treturn Math.max(1, this.terminal.rows - 1);\n\t}\n\n\t/** Rows available to scroll through — the live buffer while live, the\n\t * measured one while pinned. The filler is not transcript: counting it would\n\t * let a session with nothing above the fold pin itself one row off the\n\t * bottom and paint a screen of blanks. */\n\tprivate transcriptLength(): number {\n\t\tif (this.scrollOffset !== null) return this.scrollTotalLines;\n\t\treturn this.previousLines.length - (this.flexSpacer?.currentHeight ?? 0);\n\t}\n\n\t/**\n\t * Move the view by `delta` rows; negative is towards the start.\n\t *\n\t * Returns whether anything moved, so a caller can let the key fall through\n\t * to whatever else wants it when there is nothing to scroll.\n\t */\n\tscrollByLines(delta: number): boolean {\n\t\tif (delta === 0) return false;\n\t\t// Scrolling down while already live is not \"scroll to somewhere\", it is a\n\t\t// request for content that does not exist yet. Doing nothing is right, and\n\t\t// cheap: treating it as a move would drop out of scroll mode and force a\n\t\t// full repaint on every wheel notch at the bottom of the transcript.\n\t\tif (this.scrollOffset === null && delta > 0) return false;\n\n\t\tconst viewHeight = this.scrollViewHeight();\n\t\tconst maxOffset = Math.max(0, this.transcriptLength() - viewHeight);\n\t\tif (maxOffset === 0) return false;\n\n\t\tconst next = (this.scrollOffset ?? maxOffset) + delta;\n\t\t// Reaching the end is how the pin lets go: the reader has caught up, so\n\t\t// give them the live screen back rather than a pinned view of the tail\n\t\t// that silently stops following.\n\t\tif (next >= maxOffset) return this.scrollToLive();\n\t\tthis.setScrollOffset(next);\n\t\treturn true;\n\t}\n\n\t/**\n\t * Move by pages, keeping two rows of overlap.\n\t *\n\t * A page that moves a full screen leaves nothing in common between before\n\t * and after, and the reader has to find their place again on every press.\n\t * The two kept rows are what makes the jump readable.\n\t */\n\tscrollByPages(delta: number): boolean {\n\t\tconst page = Math.max(1, this.scrollViewHeight() - 2);\n\t\treturn this.scrollByLines(delta * page);\n\t}\n\n\t/** Pin the view to the very start of the transcript. */\n\tscrollToTop(): boolean {\n\t\tconst maxOffset = Math.max(0, this.transcriptLength() - this.scrollViewHeight());\n\t\tif (maxOffset === 0) return false;\n\t\tif (this.scrollOffset === 0) return false;\n\t\tthis.setScrollOffset(0);\n\t\treturn true;\n\t}\n\n\t/** Release the pin and follow the tail again. */\n\tscrollToLive(): boolean {\n\t\tif (this.scrollOffset === null) return false;\n\t\tthis.scrollOffset = null;\n\t\tthis.scrollSearch = null;\n\t\tthis.terminal.setAlternateScreen(false);\n\t\t// `?1049l` restores the normal screen, its scrollback and the cursor\n\t\t// exactly as they were at `?1049h`, and the snapshot taken on the way in\n\t\t// says what that screen holds — so the next frame can be an ordinary\n\t\t// differential one that writes only what arrived while we were reading.\n\t\t// Dropping the flat cache is what makes it honest: the cache has been\n\t\t// patched on every pinned frame, and a patch report describing rows that\n\t\t// were painted to the *alternate* screen would leave the diff addressing\n\t\t// the wrong ones.\n\t\tthis.flatCache = undefined;\n\t\tthis.lastCursorPos = undefined;\n\t\tthis.requestRender();\n\t\treturn true;\n\t}\n\n\t// ── Searching the pinned view ───────────────────────────────────────────\n\n\t/**\n\t * Find rows containing `query`, and pin the view to the nearest one above.\n\t *\n\t * Searching *backwards* first is the `ctrl+r` convention and it is the right\n\t * one here: what you are looking for in a session is nearly always behind\n\t * you, and the most recent occurrence is nearly always the one you meant.\n\t *\n\t * Matching is over the visible text, so it finds what the screen shows\n\t * rather than the escape sequences underneath it — a query containing a\n\t * colour code is not something anyone ever means.\n\t *\n\t * Returns how many rows matched.\n\t */\n\tsetScrollSearch(query: string, options: { typing?: boolean } = {}): number {\n\t\tif (query.length === 0) {\n\t\t\tthis.scrollSearch = { query, matches: [], index: -1, measuredAt: -1, typing: options.typing ?? true };\n\t\t\tthis.requestRender();\n\t\t\tthis.expediteRender();\n\t\t\treturn 0;\n\t\t}\n\n\t\tconst from = this.scrollOffset ?? Math.max(0, this.transcriptLength() - 1);\n\t\tconst matches = this.findScrollMatches(query);\n\t\t// The nearest match at or above where the eye is, else wrap to the last.\n\t\tlet index = -1;\n\t\tfor (let i = matches.length - 1; i >= 0; i--) {\n\t\t\tif (matches[i] <= from) {\n\t\t\t\tindex = i;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (index === -1 && matches.length > 0) index = matches.length - 1;\n\n\t\tthis.scrollSearch = {\n\t\t\tquery,\n\t\t\tmatches,\n\t\t\tindex,\n\t\t\tmeasuredAt: this.flatLines?.length ?? this.previousLines.length,\n\t\t\ttyping: options.typing ?? true,\n\t\t};\n\t\tif (index >= 0) this.scrollToRow(matches[index]);\n\t\tthis.requestRender();\n\t\tthis.expediteRender();\n\t\treturn matches.length;\n\t}\n\n\t/**\n\t * Step to the next match, `-1` being further back through the session.\n\t *\n\t * Wraps, because a search that stops dead at the last match makes you\n\t * retype it to get back to the first.\n\t */\n\tscrollSearchStep(direction: 1 | -1): boolean {\n\t\tconst search = this.scrollSearch;\n\t\tif (!search || search.matches.length === 0) return false;\n\t\tconst next = (search.index + direction + search.matches.length) % search.matches.length;\n\t\tsearch.index = next;\n\t\tsearch.typing = false;\n\t\tthis.scrollToRow(search.matches[next]);\n\t\tthis.requestRender();\n\t\tthis.expediteRender();\n\t\treturn true;\n\t}\n\n\t/** Stop typing the query but keep the matches, so n/N can step them. */\n\tcommitScrollSearch(): void {\n\t\tif (!this.scrollSearch) return;\n\t\tthis.scrollSearch.typing = false;\n\t\tthis.requestRender();\n\t\tthis.expediteRender();\n\t}\n\n\t/** Drop the search, leaving the view where it is. */\n\tclearScrollSearch(): void {\n\t\tif (!this.scrollSearch) return;\n\t\tthis.scrollSearch = null;\n\t\tthis.requestRender();\n\t\tthis.expediteRender();\n\t}\n\n\tget scrollSearchActive(): boolean {\n\t\treturn this.scrollSearch !== null;\n\t}\n\n\t/** The query being searched for, or \"\" when there is no search. */\n\tget scrollSearchQuery(): string {\n\t\treturn this.scrollSearch?.query ?? \"\";\n\t}\n\n\tprivate scrollSearchStatus(): ScrollSearchStatus | undefined {\n\t\tconst search = this.scrollSearch;\n\t\tif (!search) return undefined;\n\t\treturn {\n\t\t\tquery: search.query,\n\t\t\tcount: search.matches.length,\n\t\t\tindex: search.index >= 0 ? search.index + 1 : 0,\n\t\t\ttyping: search.typing,\n\t\t};\n\t}\n\n\t/**\n\t * Rows whose visible text contains `query`, case-insensitively.\n\t *\n\t * One pass over the buffer, run when the query changes rather than per\n\t * frame. The results are re-measured if the transcript has grown since —\n\t * rare while someone is reading, and wrong in a way people notice if skipped.\n\t */\n\tprivate findScrollMatches(query: string): number[] {\n\t\tconst lines = this.flatLines ?? this.previousLines;\n\t\tconst needle = query.toLowerCase();\n\t\tconst matches: number[] = [];\n\t\tfor (let row = 0; row < lines.length; row++) {\n\t\t\tconst line = lines[row];\n\t\t\tif (line.length === 0) continue;\n\t\t\tif (stripVTControlCharacters(line).toLowerCase().includes(needle)) matches.push(row);\n\t\t}\n\t\treturn matches;\n\t}\n\n\t/** Re-run the search if the buffer grew under it. */\n\tprivate refreshScrollSearch(total: number): void {\n\t\tconst search = this.scrollSearch;\n\t\tif (!search || search.query.length === 0 || search.measuredAt === total) return;\n\t\tsearch.matches = this.findScrollMatches(search.query);\n\t\tsearch.measuredAt = total;\n\t\tif (search.index >= search.matches.length) search.index = search.matches.length - 1;\n\t}\n\n\t/**\n\t * Mark the query where it appears in a row about to be painted.\n\t *\n\t * Done at paint time, over the handful of rows on screen, rather than stored\n\t * per match — highlighting the whole buffer to show a screenful would be the\n\t * same work multiplied by the session's length.\n\t *\n\t * The row is sliced by display column so the styling already in it survives:\n\t * a match inside a coloured span keeps its colour and gains the marker.\n\t */\n\tprivate highlightScrollMatches(line: string, query: string): string {\n\t\tconst plain = stripVTControlCharacters(line);\n\t\tconst needle = query.toLowerCase();\n\t\tconst haystack = plain.toLowerCase();\n\t\tlet at = haystack.indexOf(needle);\n\t\tif (at === -1) return line;\n\n\t\tlet out = \"\";\n\t\tlet cursor = 0;\n\t\twhile (at !== -1) {\n\t\t\tconst startCol = visibleWidth(plain.slice(0, at));\n\t\t\tconst endCol = startCol + visibleWidth(plain.slice(at, at + query.length));\n\t\t\tconst fromCol = visibleWidth(plain.slice(0, cursor));\n\t\t\tout += sliceByColumn(line, fromCol, startCol - fromCol);\n\t\t\t// 27 turns reverse off on its own, so whatever colour the row was\n\t\t\t// wearing underneath the match carries on afterwards.\n\t\t\tout += `\\x1b[7m${sliceByColumn(line, startCol, endCol - startCol)}\\x1b[27m`;\n\t\t\tcursor = at + query.length;\n\t\t\tat = haystack.indexOf(needle, cursor);\n\t\t}\n\t\tconst tailCol = visibleWidth(plain.slice(0, cursor));\n\t\tout += sliceByColumn(line, tailCol, Number.MAX_SAFE_INTEGER - tailCol);\n\t\treturn out;\n\t}\n\n\tprivate setScrollOffset(offset: number): void {\n\t\tconst entering = this.scrollOffset === null;\n\t\t// Only entry is gated. A view that is already pinned keeps responding, so\n\t\t// a surface opening underneath cannot strand the reader somewhere they\n\t\t// have no key to leave.\n\t\tif (entering && this.canPinScroll && !this.canPinScroll()) return;\n\t\tthis.scrollOffset = Math.max(0, offset);\n\t\tif (entering) {\n\t\t\t// What the normal screen is left showing, frozen. Without the copy this\n\t\t\t// stays the same array the patching render() mutates in place, so on the\n\t\t\t// way back out it would be diffed against itself and report that nothing\n\t\t\t// arrived while the reader was away.\n\t\t\tthis.previousLines = this.previousLines.slice();\n\t\t\tthis.terminal.setAlternateScreen(true);\n\t\t\tthis.terminal.hideCursor();\n\t\t}\n\t\tthis.requestRender();\n\t\t// Scrolling is a direct manipulation: the view has to move under the\n\t\t// gesture, not one animation frame behind it.\n\t\tthis.expediteRender();\n\t}\n\n\tgetShowHardwareCursor(): boolean {\n\t\treturn this.showHardwareCursor;\n\t}\n\n\tsetShowHardwareCursor(enabled: boolean): void {\n\t\tif (this.showHardwareCursor === enabled) return;\n\t\tthis.showHardwareCursor = enabled;\n\t\tif (!enabled) {\n\t\t\tthis.terminal.hideCursor();\n\t\t}\n\t\tthis.requestRender();\n\t}\n\n\tgetClearOnShrink(): boolean {\n\t\treturn this.clearOnShrink;\n\t}\n\n\t/**\n\t * Set whether to trigger full re-render when content shrinks.\n\t * When true (default), empty rows are cleared when content shrinks.\n\t * When false, empty rows remain (reduces redraws on slower terminals).\n\t */\n\tsetClearOnShrink(enabled: boolean): void {\n\t\tthis.clearOnShrink = enabled;\n\t}\n\n\t/** The component keystrokes are currently going to. */\n\tget focused(): Component | null {\n\t\treturn this.focusedComponent;\n\t}\n\n\t/**\n\t * The root's own child offsets, which the flat cache already tracks.\n\t *\n\t * The base implementation reads `Container.renderMemo`, which the root does\n\t * not keep — it has `flatCache` instead, holding exactly this. Falling\n\t * through to the base would quietly return undefined forever.\n\t */\n\toverride childRowOffsets(width: number): number[] | undefined {\n\t\tconst cache = this.flatCache;\n\t\tif (!cache || cache.width !== width) return undefined;\n\t\treturn cache.offsets;\n\t}\n\n\t/**\n\t * Pin the view so `row` is on screen, without demanding it be at the top.\n\t *\n\t * A jump that always parks its target on the first row throws away whatever\n\t * led up to it, and what led up to a message is most of what makes it\n\t * readable. A row already comfortably in view is left where it is, so\n\t * stepping through nearby landmarks does not make the screen lurch for each\n\t * one.\n\t */\n\tscrollToRow(row: number, options: { context?: number } = {}): boolean {\n\t\tconst viewHeight = this.scrollViewHeight();\n\t\tconst maxOffset = Math.max(0, this.transcriptLength() - viewHeight);\n\t\tif (maxOffset === 0) return false;\n\n\t\tconst context = options.context ?? Math.min(3, Math.max(0, viewHeight - 1));\n\t\tconst target = Math.max(0, Math.min(row - context, maxOffset));\n\n\t\tif (this.scrollOffset !== null) {\n\t\t\tconst top = this.scrollOffset;\n\t\t\t// Already visible with room to read above it: leave it alone.\n\t\t\tif (row >= top + context && row < top + viewHeight) return false;\n\t\t}\n\t\tthis.setScrollOffset(target);\n\t\treturn this.scrollOffset === target;\n\t}\n\n\tsetFocus(component: Component | null): void {\n\t\t// Clear focused flag on old component\n\t\tif (isFocusable(this.focusedComponent)) {\n\t\t\tthis.focusedComponent.focused = false;\n\t\t}\n\n\t\tthis.focusedComponent = component;\n\n\t\t// Set focused flag on new component\n\t\tif (isFocusable(component)) {\n\t\t\tcomponent.focused = true;\n\t\t}\n\t}\n\n\t/**\n\t * Show an overlay component with configurable positioning and sizing.\n\t * Returns a handle to control the overlay's visibility.\n\t */\n\tshowOverlay(component: Component, options?: OverlayOptions): OverlayHandle {\n\t\tconst entry = {\n\t\t\tcomponent,\n\t\t\toptions,\n\t\t\tpreFocus: this.focusedComponent,\n\t\t\thidden: false,\n\t\t\tfocusOrder: ++this.focusOrderCounter,\n\t\t};\n\t\tthis.overlayStack.push(entry);\n\t\t// Only focus if overlay is actually visible\n\t\tif (!options?.nonCapturing && this.isOverlayVisible(entry)) {\n\t\t\tthis.setFocus(component);\n\t\t}\n\t\tthis.terminal.hideCursor();\n\t\tthis.requestRender();\n\n\t\t// Return handle for controlling this overlay\n\t\treturn {\n\t\t\thide: () => {\n\t\t\t\tconst index = this.overlayStack.indexOf(entry);\n\t\t\t\tif (index !== -1) {\n\t\t\t\t\tthis.overlayStack.splice(index, 1);\n\t\t\t\t\t// Restore focus if this overlay had focus\n\t\t\t\t\tif (this.focusedComponent === component) {\n\t\t\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\t\t\tthis.setFocus(topVisible?.component ?? entry.preFocus);\n\t\t\t\t\t}\n\t\t\t\t\tif (this.overlayStack.length === 0) this.terminal.hideCursor();\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t}\n\t\t\t},\n\t\t\tsetHidden: (hidden: boolean) => {\n\t\t\t\tif (entry.hidden === hidden) return;\n\t\t\t\tentry.hidden = hidden;\n\t\t\t\t// Update focus when hiding/showing\n\t\t\t\tif (hidden) {\n\t\t\t\t\t// If this overlay had focus, move focus to next visible or preFocus\n\t\t\t\t\tif (this.focusedComponent === component) {\n\t\t\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\t\t\tthis.setFocus(topVisible?.component ?? entry.preFocus);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Restore focus to this overlay when showing (if it's actually visible)\n\t\t\t\t\tif (!options?.nonCapturing && this.isOverlayVisible(entry)) {\n\t\t\t\t\t\tentry.focusOrder = ++this.focusOrderCounter;\n\t\t\t\t\t\tthis.setFocus(component);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tisHidden: () => entry.hidden,\n\t\t\tfocus: () => {\n\t\t\t\tif (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;\n\t\t\t\tif (this.focusedComponent !== component) {\n\t\t\t\t\tthis.setFocus(component);\n\t\t\t\t}\n\t\t\t\tentry.focusOrder = ++this.focusOrderCounter;\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tunfocus: () => {\n\t\t\t\tif (this.focusedComponent !== component) return;\n\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\tthis.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tisFocused: () => this.focusedComponent === component,\n\t\t};\n\t}\n\n\t/** Hide the topmost overlay and restore previous focus. */\n\thideOverlay(): void {\n\t\tconst overlay = this.overlayStack.pop();\n\t\tif (!overlay) return;\n\t\tif (this.focusedComponent === overlay.component) {\n\t\t\t// Find topmost visible overlay, or fall back to preFocus\n\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\tthis.setFocus(topVisible?.component ?? overlay.preFocus);\n\t\t}\n\t\tif (this.overlayStack.length === 0) this.terminal.hideCursor();\n\t\tthis.requestRender();\n\t}\n\n\t/** Check if there are any visible overlays */\n\thasOverlay(): boolean {\n\t\treturn this.overlayStack.some((o) => this.isOverlayVisible(o));\n\t}\n\n\t/** Check if an overlay entry is currently visible */\n\tprivate isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {\n\t\tif (entry.hidden) return false;\n\t\tif (entry.options?.visible) {\n\t\t\treturn entry.options.visible(this.terminal.columns, this.terminal.rows);\n\t\t}\n\t\treturn true;\n\t}\n\n\t/** Find the topmost visible capturing overlay, if any */\n\tprivate getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {\n\t\tfor (let i = this.overlayStack.length - 1; i >= 0; i--) {\n\t\t\tif (this.overlayStack[i].options?.nonCapturing) continue;\n\t\t\tif (this.isOverlayVisible(this.overlayStack[i])) {\n\t\t\t\treturn this.overlayStack[i];\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\toverride invalidate(): void {\n\t\tsuper.invalidate();\n\t\tfor (const overlay of this.overlayStack) overlay.component.invalidate?.();\n\t}\n\n\tstart(): void {\n\t\tthis.stopped = false;\n\t\tthis.terminal.start(\n\t\t\t(data) => this.handleInput(data),\n\t\t\t() => this.requestRender(),\n\t\t);\n\t\tthis.terminal.hideCursor();\n\t\tthis.queryCellSize();\n\t\tthis.requestRender();\n\t}\n\n\taddInputListener(listener: InputListener): () => void {\n\t\tthis.inputListeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.inputListeners.delete(listener);\n\t\t};\n\t}\n\n\tremoveInputListener(listener: InputListener): void {\n\t\tthis.inputListeners.delete(listener);\n\t}\n\n\tprivate queryCellSize(): void {\n\t\t// Only query if terminal supports images (cell size is only used for image rendering)\n\t\tif (!getCapabilities().images) {\n\t\t\treturn;\n\t\t}\n\t\t// Query terminal for cell size in pixels: CSI 16 t\n\t\t// Response format: CSI 6 ; height ; width t\n\t\tthis.terminal.write(\"\\x1b[16t\");\n\t}\n\n\tstop(): void {\n\t\t// Off the alternate screen before the exit bookkeeping below, which moves\n\t\t// the cursor relative to content that lives on the normal screen.\n\t\tif (this.scrollOffset !== null) {\n\t\t\tthis.scrollOffset = null;\n\t\t\tthis.terminal.setAlternateScreen(false);\n\t\t}\n\t\tthis.stopped = true;\n\t\tif (this.renderTimer) {\n\t\t\tclearTimeout(this.renderTimer);\n\t\t\tthis.renderTimer = undefined;\n\t\t}\n\t\t// Move cursor to the end of the content to prevent overwriting/artifacts on exit\n\t\tif (this.previousLines.length > 0) {\n\t\t\tconst targetRow = this.previousLines.length; // Line after the last content\n\t\t\tconst lineDiff = targetRow - this.hardwareCursorRow;\n\t\t\tif (lineDiff > 0) {\n\t\t\t\tthis.terminal.write(`\\x1b[${lineDiff}B`);\n\t\t\t} else if (lineDiff < 0) {\n\t\t\t\tthis.terminal.write(`\\x1b[${-lineDiff}A`);\n\t\t\t}\n\t\t\tthis.terminal.write(\"\\r\\n\");\n\t\t}\n\n\t\tthis.terminal.showCursor();\n\t\tthis.terminal.stop();\n\t}\n\n\trequestRender(force = false): void {\n\t\tif (force) {\n\t\t\tthis.previousLines = [];\n\t\t\tthis.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear\n\t\t\tthis.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear\n\t\t\tthis.cursorRow = 0;\n\t\t\tthis.hardwareCursorRow = 0;\n\t\t\tthis.maxLinesRendered = 0;\n\t\t\tthis.previousViewportTop = 0;\n\t\t\tif (this.renderTimer) {\n\t\t\t\tclearTimeout(this.renderTimer);\n\t\t\t\tthis.renderTimer = undefined;\n\t\t\t}\n\t\t\tthis.renderRequested = true;\n\t\t\tprocess.nextTick(() => {\n\t\t\t\tif (this.stopped || !this.renderRequested) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.renderRequested = false;\n\t\t\t\tthis.lastRenderAt = performance.now();\n\t\t\t\tthis.doRender();\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tif (this.renderRequested) return;\n\t\tthis.renderRequested = true;\n\t\tprocess.nextTick(() => this.scheduleRender());\n\t}\n\n\tprivate scheduleRender(): void {\n\t\tif (this.stopped || this.renderTimer || !this.renderRequested) {\n\t\t\treturn;\n\t\t}\n\t\tconst elapsed = performance.now() - this.lastRenderAt;\n\t\tconst delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);\n\t\tthis.renderTimer = setTimeout(() => {\n\t\t\tthis.renderTimer = undefined;\n\t\t\tif (this.stopped || !this.renderRequested) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.renderRequested = false;\n\t\t\tthis.lastRenderAt = performance.now();\n\t\t\tthis.doRender();\n\t\t\tif (this.renderRequested) {\n\t\t\t\tthis.scheduleRender();\n\t\t\t}\n\t\t}, delay);\n\t}\n\n\tprivate handleInput(data: string): void {\n\t\t// Ahead of the listeners: a mouse report that reaches a text field is\n\t\t// typed into it, and a paste-detecting listener has no reason to see one.\n\t\tif (this.terminal.mouseReporting) {\n\t\t\tconst remaining = this.consumeMouseReports(data);\n\t\t\tif (remaining === null) return;\n\t\t\tdata = remaining;\n\t\t}\n\n\t\tif (this.inputListeners.size > 0) {\n\t\t\tlet current = data;\n\t\t\tfor (const listener of this.inputListeners) {\n\t\t\t\tconst result = listener(current);\n\t\t\t\tif (result?.consume) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (result?.data !== undefined) {\n\t\t\t\t\tcurrent = result.data;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (current.length === 0) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tdata = current;\n\t\t}\n\n\t\t// Consume terminal cell size responses without blocking unrelated input.\n\t\tif (this.consumeCellSizeResponse(data)) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Global debug key handler (Shift+Ctrl+D)\n\t\tif (matchesKey(data, \"shift+ctrl+d\") && this.onDebug) {\n\t\t\tthis.onDebug();\n\t\t\treturn;\n\t\t}\n\n\t\t// If focused component is an overlay, verify it's still visible\n\t\t// (visibility can change due to terminal resize or visible() callback)\n\t\tconst focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);\n\t\tif (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {\n\t\t\t// Focused overlay is no longer visible, redirect to topmost visible overlay\n\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\tif (topVisible) {\n\t\t\t\tthis.setFocus(topVisible.component);\n\t\t\t} else {\n\t\t\t\t// No visible overlays, restore to preFocus\n\t\t\t\tthis.setFocus(focusedOverlay.preFocus);\n\t\t\t}\n\t\t}\n\n\t\t// Pass input to focused component (including Ctrl+C)\n\t\t// The focused component can decide how to handle Ctrl+C\n\t\tif (this.focusedComponent?.handleInput) {\n\t\t\t// Filter out key release events unless component opts in\n\t\t\tif (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.focusedComponent.handleInput(data);\n\t\t\tthis.requestRender();\n\t\t\t// Keystroke echo should not queue behind the animation coalescing\n\t\t\t// window: render the input's effect immediately instead of waiting out\n\t\t\t// MIN_RENDER_INTERVAL_MS behind spinner/streaming frames.\n\t\t\tthis.expediteRender();\n\t\t}\n\t}\n\n\t/**\n\t * Act on every mouse report in `data` and return what is left of it.\n\t *\n\t * Returns null when the chunk was nothing but reports. Reports arrive\n\t * coalesced — a flick of the wheel delivers a run of them in one read, and a\n\t * keystroke pressed during the flick rides along behind — so they are peeled\n\t * off one at a time instead of the chunk being classified as a whole.\n\t */\n\tprivate consumeMouseReports(data: string): string | null {\n\t\t// Neither introducer present is the overwhelmingly common case (every\n\t\t// ordinary keystroke), and it costs one scan of a very short string.\n\t\tif (!data.includes(\"\\x1b[<\") && !data.includes(\"\\x1b[M\")) return data;\n\n\t\tlet rest = data;\n\t\tlet out = \"\";\n\t\tlet sawReport = false;\n\t\twhile (rest.length > 0) {\n\t\t\tconst length = mouseSequenceLength(rest);\n\t\t\tif (length === 0) {\n\t\t\t\tout += rest[0];\n\t\t\t\trest = rest.slice(1);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst event = parseMouseEvent(rest.slice(0, length));\n\t\t\tif (event) this.handleMouseEvent(event);\n\t\t\tsawReport = true;\n\t\t\trest = rest.slice(length);\n\t\t}\n\n\t\tif (!sawReport) return data;\n\t\treturn out.length > 0 ? out : null;\n\t}\n\n\t/**\n\t * What the mouse does.\n\t *\n\t * Only the wheel is acted on. Clicks are swallowed rather than handled:\n\t * reporting is on for the wheel's sake, and a click that fell through to the\n\t * focused component would arrive as the raw report text in whatever field\n\t * has focus.\n\t */\n\tprivate handleMouseEvent(event: MouseEvent): void {\n\t\tif (event.kind === \"wheelUp\") {\n\t\t\tthis.scrollByLines(-WHEEL_LINES);\n\t\t\treturn;\n\t\t}\n\t\tif (event.kind === \"wheelDown\") {\n\t\t\tthis.scrollByLines(WHEEL_LINES);\n\t\t}\n\t}\n\n\t/** Run a requested render now, bypassing the coalescing delay. Used for\n\t * input-driven frames where echo latency matters more than batching. */\n\tprivate expediteRender(): void {\n\t\tif (this.stopped || !this.renderRequested) return;\n\t\tif (this.renderTimer) {\n\t\t\tclearTimeout(this.renderTimer);\n\t\t\tthis.renderTimer = undefined;\n\t\t}\n\t\tthis.renderRequested = false;\n\t\tthis.lastRenderAt = performance.now();\n\t\tthis.doRender();\n\t}\n\n\tprivate consumeCellSizeResponse(data: string): boolean {\n\t\t// Response format: ESC [ 6 ; height ; width t\n\t\tconst match = data.match(/^\\x1b\\[6;(\\d+);(\\d+)t$/);\n\t\tif (!match) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst heightPx = parseInt(match[1], 10);\n\t\tconst widthPx = parseInt(match[2], 10);\n\t\tif (heightPx <= 0 || widthPx <= 0) {\n\t\t\treturn true;\n\t\t}\n\n\t\tsetCellDimensions({ widthPx, heightPx });\n\t\t// Invalidate all components so images re-render with correct dimensions.\n\t\tthis.invalidate();\n\t\tthis.requestRender();\n\t\treturn true;\n\t}\n\n\t/**\n\t * Resolve overlay layout from options.\n\t * Returns { width, row, col, maxHeight } for rendering.\n\t */\n\tprivate resolveOverlayLayout(\n\t\toptions: OverlayOptions | undefined,\n\t\toverlayHeight: number,\n\t\ttermWidth: number,\n\t\ttermHeight: number,\n\t): { width: number; row: number; col: number; maxHeight: number | undefined } {\n\t\tconst opt = options ?? {};\n\n\t\t// Parse margin (clamp to non-negative)\n\t\tconst margin =\n\t\t\ttypeof opt.margin === \"number\"\n\t\t\t\t? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }\n\t\t\t\t: (opt.margin ?? {});\n\t\tconst marginTop = Math.max(0, margin.top ?? 0);\n\t\tconst marginRight = Math.max(0, margin.right ?? 0);\n\t\tconst marginBottom = Math.max(0, margin.bottom ?? 0);\n\t\tconst marginLeft = Math.max(0, margin.left ?? 0);\n\n\t\t// Available space after margins\n\t\tconst availWidth = Math.max(1, termWidth - marginLeft - marginRight);\n\t\tconst availHeight = Math.max(1, termHeight - marginTop - marginBottom);\n\n\t\t// === Resolve width ===\n\t\tlet width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);\n\t\t// Apply minWidth\n\t\tif (opt.minWidth !== undefined) {\n\t\t\twidth = Math.max(width, opt.minWidth);\n\t\t}\n\t\t// Clamp to available space\n\t\twidth = Math.max(1, Math.min(width, availWidth));\n\n\t\t// === Resolve maxHeight ===\n\t\tlet maxHeight = parseSizeValue(opt.maxHeight, termHeight);\n\t\t// Clamp to available space\n\t\tif (maxHeight !== undefined) {\n\t\t\tmaxHeight = Math.max(1, Math.min(maxHeight, availHeight));\n\t\t}\n\n\t\t// Effective overlay height (may be clamped by maxHeight)\n\t\tconst effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;\n\n\t\t// === Resolve position ===\n\t\tlet row: number;\n\t\tlet col: number;\n\n\t\tif (opt.row !== undefined) {\n\t\t\tif (typeof opt.row === \"string\") {\n\t\t\t\t// Percentage: 0% = top, 100% = bottom (overlay stays within bounds)\n\t\t\t\tconst match = opt.row.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\t\t\t\tif (match) {\n\t\t\t\t\tconst maxRow = Math.max(0, availHeight - effectiveHeight);\n\t\t\t\t\tconst percent = parseFloat(match[1]) / 100;\n\t\t\t\t\trow = marginTop + Math.floor(maxRow * percent);\n\t\t\t\t} else {\n\t\t\t\t\t// Invalid format, fall back to center\n\t\t\t\t\trow = this.resolveAnchorRow(\"center\", effectiveHeight, availHeight, marginTop);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Absolute row position\n\t\t\t\trow = opt.row;\n\t\t\t}\n\t\t} else {\n\t\t\t// Anchor-based (default: center)\n\t\t\tconst anchor = opt.anchor ?? \"center\";\n\t\t\trow = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);\n\t\t}\n\n\t\tif (opt.col !== undefined) {\n\t\t\tif (typeof opt.col === \"string\") {\n\t\t\t\t// Percentage: 0% = left, 100% = right (overlay stays within bounds)\n\t\t\t\tconst match = opt.col.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\t\t\t\tif (match) {\n\t\t\t\t\tconst maxCol = Math.max(0, availWidth - width);\n\t\t\t\t\tconst percent = parseFloat(match[1]) / 100;\n\t\t\t\t\tcol = marginLeft + Math.floor(maxCol * percent);\n\t\t\t\t} else {\n\t\t\t\t\t// Invalid format, fall back to center\n\t\t\t\t\tcol = this.resolveAnchorCol(\"center\", width, availWidth, marginLeft);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Absolute column position\n\t\t\t\tcol = opt.col;\n\t\t\t}\n\t\t} else {\n\t\t\t// Anchor-based (default: center)\n\t\t\tconst anchor = opt.anchor ?? \"center\";\n\t\t\tcol = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);\n\t\t}\n\n\t\t// Apply offsets\n\t\tif (opt.offsetY !== undefined) row += opt.offsetY;\n\t\tif (opt.offsetX !== undefined) col += opt.offsetX;\n\n\t\t// Clamp to terminal bounds (respecting margins)\n\t\trow = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));\n\t\tcol = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));\n\n\t\treturn { width, row, col, maxHeight };\n\t}\n\n\tprivate resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {\n\t\tswitch (anchor) {\n\t\t\tcase \"top-left\":\n\t\t\tcase \"top-center\":\n\t\t\tcase \"top-right\":\n\t\t\t\treturn marginTop;\n\t\t\tcase \"bottom-left\":\n\t\t\tcase \"bottom-center\":\n\t\t\tcase \"bottom-right\":\n\t\t\t\treturn marginTop + availHeight - height;\n\t\t\tcase \"left-center\":\n\t\t\tcase \"center\":\n\t\t\tcase \"right-center\":\n\t\t\t\treturn marginTop + Math.floor((availHeight - height) / 2);\n\t\t}\n\t}\n\n\tprivate resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {\n\t\tswitch (anchor) {\n\t\t\tcase \"top-left\":\n\t\t\tcase \"left-center\":\n\t\t\tcase \"bottom-left\":\n\t\t\t\treturn marginLeft;\n\t\t\tcase \"top-right\":\n\t\t\tcase \"right-center\":\n\t\t\tcase \"bottom-right\":\n\t\t\t\treturn marginLeft + availWidth - width;\n\t\t\tcase \"top-center\":\n\t\t\tcase \"center\":\n\t\t\tcase \"bottom-center\":\n\t\t\t\treturn marginLeft + Math.floor((availWidth - width) / 2);\n\t\t}\n\t}\n\n\t/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */\n\tprivate compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {\n\t\tif (this.overlayStack.length === 0) return lines;\n\t\tconst result = [...lines];\n\n\t\t// Pre-render all visible overlays and calculate positions\n\t\tconst rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];\n\t\tlet minLinesNeeded = result.length;\n\n\t\tconst visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));\n\t\tvisibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);\n\t\tfor (const entry of visibleEntries) {\n\t\t\tconst { component, options } = entry;\n\n\t\t\t// Get layout with height=0 first to determine width and maxHeight\n\t\t\t// (width and maxHeight don't depend on overlay height)\n\t\t\tconst { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);\n\n\t\t\t// Render component at calculated width\n\t\t\tlet overlayLines = component.render(width);\n\n\t\t\t// Apply maxHeight if specified\n\t\t\tif (maxHeight !== undefined && overlayLines.length > maxHeight) {\n\t\t\t\toverlayLines = overlayLines.slice(0, maxHeight);\n\t\t\t}\n\n\t\t\t// Get final row/col with actual overlay height\n\t\t\tconst { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);\n\n\t\t\trendered.push({ overlayLines, row, col, w: width });\n\t\t\tminLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);\n\t\t}\n\n\t\t// Pad to at least terminal height so overlays have screen-relative positions.\n\t\t// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing\n\t\t// inflation that pushed content into scrollback on terminal widen.\n\t\tconst workingHeight = Math.max(result.length, termHeight, minLinesNeeded);\n\n\t\t// Extend result with empty lines if content is too short for overlay placement or working area\n\t\twhile (result.length < workingHeight) {\n\t\t\tresult.push(\"\");\n\t\t}\n\n\t\tconst viewportStart = Math.max(0, workingHeight - termHeight);\n\n\t\t// Composite each overlay\n\t\tfor (const { overlayLines, row, col, w } of rendered) {\n\t\t\tfor (let i = 0; i < overlayLines.length; i++) {\n\t\t\t\tconst idx = viewportStart + row + i;\n\t\t\t\tif (idx >= 0 && idx < result.length) {\n\t\t\t\t\t// Defensive: truncate overlay line to declared width before compositing\n\t\t\t\t\t// (components should already respect width, but this ensures it)\n\t\t\t\t\tconst truncatedOverlayLine =\n\t\t\t\t\t\tvisibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];\n\t\t\t\t\tresult[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t}\n\n\tprivate static readonly SEGMENT_RESET = \"\\x1b[0m\\x1b]8;;\\x07\";\n\n\t/**\n\t * Append the per-line style/hyperlink reset (and normalize Thai/Lao AM\n\t * vowels) at the moment a line is written to the terminal. This is\n\t * deliberately kept OFF the cached/diffed line arrays: leaf components cache\n\t * their lines without the reset, so leaving `newLines`/`previousLines`\n\t * un-reset keeps unchanged lines reference-stable frame to frame. The\n\t * differential compare then short-circuits on identity for every unchanged\n\t * line instead of allocating a fresh reset-appended string per line and\n\t * doing a full content compare across the whole transcript every frame.\n\t * Image lines carry no trailing style and are emitted verbatim.\n\t */\n\tprivate emitLine(line: string): string {\n\t\tif (isImageLine(line)) {\n\t\t\tthis.sawImageLine = true;\n\t\t\treturn line;\n\t\t}\n\t\treturn normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;\n\t}\n\n\tprivate collectKittyImageIds(lines: string[]): Set<number> {\n\t\t// No image has ever been drawn: nothing on screen carries a kitty id, so\n\t\t// skip the full-buffer scan and the Set allocation.\n\t\tif (!this.sawImageLine) return TUI.EMPTY_KITTY_IDS as Set<number>;\n\t\tconst ids = new Set<number>();\n\t\tfor (const line of lines) {\n\t\t\tfor (const id of extractKittyImageIds(line)) {\n\t\t\t\tids.add(id);\n\t\t\t}\n\t\t}\n\t\treturn ids;\n\t}\n\n\tprivate deleteKittyImages(ids: Iterable<number>): string {\n\t\tlet buffer = \"\";\n\t\tfor (const id of ids) {\n\t\t\tbuffer += deleteKittyImage(id);\n\t\t}\n\t\treturn buffer;\n\t}\n\n\tprivate expandLastChangedForKittyImages(firstChanged: number, lastChanged: number): number {\n\t\t// No image ever drawn: nothing to expand over, skip the scan (also,\n\t\t// on patched frames previousLines is not the previous content).\n\t\tif (!this.sawImageLine) return lastChanged;\n\t\tlet expandedLastChanged = lastChanged;\n\t\tfor (let i = firstChanged; i < this.previousLines.length; i++) {\n\t\t\tif (extractKittyImageIds(this.previousLines[i]).length > 0) {\n\t\t\t\texpandedLastChanged = Math.max(expandedLastChanged, i);\n\t\t\t}\n\t\t}\n\t\treturn expandedLastChanged;\n\t}\n\n\tprivate deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {\n\t\tif (firstChanged < 0 || lastChanged < firstChanged) return \"\";\n\n\t\tconst ids = new Set<number>();\n\t\tconst maxLine = Math.min(lastChanged, this.previousLines.length - 1);\n\t\tfor (let i = firstChanged; i <= maxLine; i++) {\n\t\t\tfor (const id of extractKittyImageIds(this.previousLines[i] ?? \"\")) {\n\t\t\t\tids.add(id);\n\t\t\t}\n\t\t}\n\n\t\treturn this.deleteKittyImages(ids);\n\t}\n\n\t/** Splice overlay content into a base line at a specific column. Single-pass optimized. */\n\tprivate compositeLineAt(\n\t\tbaseLine: string,\n\t\toverlayLine: string,\n\t\tstartCol: number,\n\t\toverlayWidth: number,\n\t\ttotalWidth: number,\n\t): string {\n\t\tif (isImageLine(baseLine)) return baseLine;\n\n\t\t// Single pass through baseLine extracts both before and after segments\n\t\tconst afterStart = startCol + overlayWidth;\n\t\tconst base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);\n\n\t\t// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)\n\t\tconst overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);\n\n\t\t// Pad segments to target widths\n\t\tconst beforePad = Math.max(0, startCol - base.beforeWidth);\n\t\tconst overlayPad = Math.max(0, overlayWidth - overlay.width);\n\t\tconst actualBeforeWidth = Math.max(startCol, base.beforeWidth);\n\t\tconst actualOverlayWidth = Math.max(overlayWidth, overlay.width);\n\t\tconst afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);\n\t\tconst afterPad = Math.max(0, afterTarget - base.afterWidth);\n\n\t\t// Compose result\n\t\tconst r = TUI.SEGMENT_RESET;\n\t\tconst result =\n\t\t\tbase.before +\n\t\t\t\" \".repeat(beforePad) +\n\t\t\tr +\n\t\t\toverlay.text +\n\t\t\t\" \".repeat(overlayPad) +\n\t\t\tr +\n\t\t\tbase.after +\n\t\t\t\" \".repeat(afterPad);\n\n\t\t// CRITICAL: Always verify and truncate to terminal width.\n\t\t// This is the final safeguard against width overflow which would crash the TUI.\n\t\t// Width tracking can drift from actual visible width due to:\n\t\t// - Complex ANSI/OSC sequences (hyperlinks, colors)\n\t\t// - Wide characters at segment boundaries\n\t\t// - Edge cases in segment extraction\n\t\tconst resultWidth = visibleWidth(result);\n\t\tif (resultWidth <= totalWidth) {\n\t\t\treturn result;\n\t\t}\n\t\t// Truncate with strict=true to ensure we don't exceed totalWidth\n\t\treturn sliceByColumn(result, 0, totalWidth, true);\n\t}\n\n\t/**\n\t * Find and extract cursor position from rendered lines.\n\t * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.\n\t * Only scans the bottom terminal height lines (visible viewport).\n\t * @param lines - Rendered lines to search\n\t * @param height - Terminal height (visible viewport size)\n\t * @returns Cursor position { row, col } or null if no marker found\n\t */\n\tprivate extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {\n\t\t// Only scan the bottom `height` lines (visible viewport)\n\t\tconst viewportTop = Math.max(0, lines.length - height);\n\t\tfor (let row = lines.length - 1; row >= viewportTop; row--) {\n\t\t\tconst line = lines[row];\n\t\t\tconst markerIndex = line.indexOf(CURSOR_MARKER);\n\t\t\tif (markerIndex !== -1) {\n\t\t\t\t// Calculate visual column (width of text before marker)\n\t\t\t\tconst beforeMarker = line.slice(0, markerIndex);\n\t\t\t\tconst col = visibleWidth(beforeMarker);\n\n\t\t\t\t// Strip marker from the line\n\t\t\t\tlines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);\n\n\t\t\t\treturn { row, col };\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\t/**\n\t * Root flatten with patch tracking. Children stay memoized (Container), so\n\t * a frame where only one region changed patches that region into the\n\t * persistent flat buffer and reports the dirty row range via lastPatch —\n\t * doRender then skips the whole-transcript diff. Falls back to a fresh\n\t * flatten (lastPatch = \"full\") when overlays are up, an image has been\n\t * drawn (kitty bookkeeping needs true previous content), the width changed,\n\t * or the child list changed.\n\t */\n\toverride render(width: number): string[] {\n\t\tconst cacheAllowed = this.overlayStack.length === 0 && !this.sawImageLine;\n\t\tconst cache = this.flatCache;\n\t\tif (!cacheAllowed || !cache || cache.width !== width || cache.refs.length !== this.children.length) {\n\t\t\tconst n = this.children.length;\n\t\t\tconst refs: string[][] = new Array(n);\n\t\t\tconst offsets: number[] = new Array(n);\n\t\t\tconst flat: string[] = [];\n\t\t\tfor (let i = 0; i < n; i++) {\n\t\t\t\trefs[i] = this.children[i].render(width);\n\t\t\t\toffsets[i] = flat.length;\n\t\t\t\tfor (const line of refs[i]) flat.push(line);\n\t\t\t}\n\t\t\tif (cacheAllowed) {\n\t\t\t\tthis.flatCache = { width, refs, offsets };\n\t\t\t\tthis.flatLines = flat;\n\t\t\t} else {\n\t\t\t\tthis.flatCache = undefined;\n\t\t\t\tthis.flatLines = undefined;\n\t\t\t}\n\t\t\tthis.lastPatch = \"full\";\n\t\t\treturn flat;\n\t\t}\n\n\t\tlet flat = this.flatLines as string[];\n\t\tconst prevLength = flat.length;\n\t\tlet low = Infinity;\n\t\tlet high = -1;\n\t\tlet delta = 0;\n\t\t// Cursor bookkeeping: the marker was stripped out of the persistent flat\n\t\t// when last extracted, so the cached position stays valid until the row\n\t\t// it lives on is overwritten by re-imported child content — and it\n\t\t// shifts when content above it grows or shrinks.\n\t\tlet cp = this.lastCursorPos ?? null;\n\t\tthis.cursorRowOverwritten = false;\n\t\tfor (let i = 0; i < this.children.length; i++) {\n\t\t\tconst r = this.children[i].render(width);\n\t\t\tconst old = cache.refs[i];\n\t\t\tif (r === old) continue;\n\t\t\tconst off = cache.offsets[i] + delta;\n\t\t\tif (r.length === old.length) {\n\t\t\t\tfor (let k = 0; k < r.length; k++) {\n\t\t\t\t\tif (old[k] !== r[k]) {\n\t\t\t\t\t\tconst row = off + k;\n\t\t\t\t\t\tflat[row] = r[k];\n\t\t\t\t\t\tif (row < low) low = row;\n\t\t\t\t\t\tif (row > high) high = row;\n\t\t\t\t\t\t// Overwrote the marker's row, or imported a line carrying a\n\t\t\t\t\t\t// (possibly relocated) marker: position must be re-extracted.\n\t\t\t\t\t\tif (cp && cp.row === row) this.cursorRowOverwritten = true;\n\t\t\t\t\t\tif (r[k].includes(CURSOR_MARKER)) this.cursorRowOverwritten = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Length changed: find the first differing line, then splice the\n\t\t\t\t// child's new lines in. Everything from there down shifts rows, so\n\t\t\t\t// the dirty range extends to the end (positional diff semantics).\n\t\t\t\tlet p = 0;\n\t\t\t\tconst minLen = Math.min(old.length, r.length);\n\t\t\t\twhile (p < minLen && old[p] === r[p]) p++;\n\t\t\t\tflat = flat.slice(0, off + p).concat(r.slice(p), flat.slice(off + old.length));\n\t\t\t\tif (off + p < low) low = off + p;\n\t\t\t\tif (cp) {\n\t\t\t\t\tif (cp.row >= off + old.length) {\n\t\t\t\t\t\t// Below the replaced region: shifts with it.\n\t\t\t\t\t\tcp = { row: cp.row + (r.length - old.length), col: cp.col };\n\t\t\t\t\t} else if (cp.row >= off + p) {\n\t\t\t\t\t\t// Inside the replaced region: fresh content, re-extract.\n\t\t\t\t\t\tthis.cursorRowOverwritten = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (!this.cursorRowOverwritten) {\n\t\t\t\t\tfor (let k = p; k < r.length; k++) {\n\t\t\t\t\t\tif (r[k].includes(CURSOR_MARKER)) {\n\t\t\t\t\t\t\tthis.cursorRowOverwritten = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdelta += r.length - old.length;\n\t\t\t}\n\t\t\tcache.refs[i] = r;\n\t\t}\n\t\tthis.lastCursorPos = cp;\n\t\tconst spliced = flat !== this.flatLines;\n\t\tif (spliced) {\n\t\t\tlet acc = 0;\n\t\t\tfor (let i = 0; i < cache.refs.length; i++) {\n\t\t\t\tcache.offsets[i] = acc;\n\t\t\t\tacc += cache.refs[i].length;\n\t\t\t}\n\t\t\tthis.flatLines = flat;\n\t\t\t// Rows below the first splice all shifted; positional diff semantics\n\t\t\t// mean everything from there to the end must be treated as dirty.\n\t\t\thigh = Math.max(prevLength - 1, flat.length - 1);\n\t\t}\n\t\tthis.lastPatch = low === Infinity && high === -1 ? null : { low: low === Infinity ? 0 : low, high, prevLength };\n\t\treturn flat;\n\t}\n\n\t/**\n\t * Paint the pinned window onto the alternate screen.\n\t *\n\t * Deliberately not differential. The alternate screen is `rows` tall and\n\t * nothing else writes to it, so a whole frame is at most a screenful of\n\t * cells inside one synchronized-output pair — cheaper to emit than the\n\t * bookkeeping a diff would need, and with nothing to get out of step with.\n\t * The differential renderer's state is left exactly as the last live frame\n\t * left it, because `scrollToLive` throws it away rather than resuming from it.\n\t */\n\tprivate renderScrollView(): void {\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\n\t\t// No fill while pinned: the window is already the height of the screen,\n\t\t// and blank rows in the buffer would be rows of the transcript the reader\n\t\t// has to scroll past. The next live frame puts it back.\n\t\tthis.flexSpacer?.setHeight(0);\n\t\tlet lines = this.render(width);\n\t\t// A patch computed while pinned describes rows nothing painted to the\n\t\t// normal screen, so the live path must never be handed it.\n\t\tthis.lastPatch = \"full\";\n\t\tif (this.overlayStack.length > 0) {\n\t\t\tlines = this.compositeOverlays(lines, width, height);\n\t\t}\n\n\t\tconst viewHeight = this.scrollViewHeight();\n\t\tthis.scrollTotalLines = lines.length;\n\t\tconst maxOffset = Math.max(0, lines.length - viewHeight);\n\t\t// The transcript can shrink under a pinned view — a pane closing, a tool\n\t\t// block collapsing — so the offset is re-clamped every frame rather than\n\t\t// only where it is set.\n\t\tconst top = Math.min(Math.max(0, this.scrollOffset ?? 0), maxOffset);\n\t\tthis.scrollOffset = top;\n\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\tbuffer += HIDE_CURSOR;\n\t\t// Autowrap off for the paint: a full-width row would otherwise wrap into\n\t\t// the row below it and shift the rest of the window down by one.\n\t\tbuffer += \"\\x1b[?7l\";\n\n\t\tthis.refreshScrollSearch(lines.length);\n\t\tconst query = this.scrollSearch?.query ?? \"\";\n\t\tfor (let row = 0; row < viewHeight; row++) {\n\t\t\tbuffer += `\\x1b[${row + 1};1H\\x1b[2K`;\n\t\t\tconst line = lines[top + row];\n\t\t\tif (line !== undefined) buffer += this.emitScrollLine(line, query);\n\t\t}\n\n\t\tbuffer += `\\x1b[${height};1H\\x1b[2K`;\n\t\tbuffer += this.scrollStatusFormatter({\n\t\t\ttop: top + 1,\n\t\t\tbottom: Math.min(top + viewHeight, lines.length),\n\t\t\ttotal: lines.length,\n\t\t\tviewHeight,\n\t\t\tatTop: top === 0,\n\t\t\tatBottom: top >= maxOffset,\n\t\t\twidth,\n\t\t\tsearch: this.scrollSearchStatus(),\n\t\t});\n\n\t\tbuffer += \"\\x1b[?7h\";\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\tthis.terminal.write(buffer);\n\n\t\t// `previousWidth` / `previousHeight` are deliberately left describing the\n\t\t// last *live* frame. If the terminal was resized while pinned they will\n\t\t// disagree with the real size on the way out, and the live path will take\n\t\t// its full-redraw branch — which is exactly right, because the normal\n\t\t// screen `?1049l` restored was drawn at the old size.\n\t}\n\n\t/**\n\t * One transcript row, ready for the pinned window.\n\t *\n\t * Images are named rather than drawn. A kitty or iTerm image is placed by\n\t * the cursor and sized in pixels, so the same escape replayed at a different\n\t * screen row lands somewhere the window did not ask for and survives the\n\t * frame that was supposed to replace it — a smear across the view that no\n\t * later repaint can clear.\n\t */\n\tprivate emitScrollLine(line: string, query = \"\"): string {\n\t\tif (isImageLine(line)) return \"\\x1b[2m[image]\\x1b[0m\";\n\t\tconst marker = line.indexOf(CURSOR_MARKER);\n\t\tlet text = marker === -1 ? line : line.slice(0, marker) + line.slice(marker + CURSOR_MARKER.length);\n\t\tif (query.length > 0) text = this.highlightScrollMatches(text, query);\n\t\treturn normalizeTerminalOutput(text) + TUI.SEGMENT_RESET;\n\t}\n\n\tprivate doRender(): void {\n\t\tif (this.stopped) return;\n\t\tif (this.scrollOffset !== null) {\n\t\t\tthis.renderScrollView();\n\t\t\treturn;\n\t\t}\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\t\tconst widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;\n\t\tconst heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;\n\t\tconst previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;\n\t\tlet prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;\n\t\tlet viewportTop = prevViewportTop;\n\t\tlet hardwareCursorRow = this.hardwareCursorRow;\n\t\tconst computeLineDiff = (targetRow: number): number => {\n\t\t\tconst currentScreenRow = hardwareCursorRow - prevViewportTop;\n\t\t\tconst targetScreenRow = targetRow - viewportTop;\n\t\t\treturn targetScreenRow - currentScreenRow;\n\t\t};\n\n\t\t// Render all components to get new lines. The root render() reports what\n\t\t// it changed via lastPatch; consume it here (it is per-frame state).\n\t\tlet newLines = this.render(width);\n\t\t// The frame has to exist before its leftover rows can be counted, so the\n\t\t// fill is settled by re-flattening rather than predicted. The second pass\n\t\t// invalidates the first's patch — it describes the buffer from before the\n\t\t// splice — so fall back to the full scan, which is always correct and only\n\t\t// runs on frames where the content height actually changed.\n\t\tif (this.fitFlexSpacer(newLines, height, widthChanged || heightChanged)) {\n\t\t\tnewLines = this.render(width);\n\t\t\tthis.lastPatch = \"full\";\n\t\t}\n\t\tconst patch = this.lastPatch;\n\t\tthis.lastPatch = \"full\";\n\n\t\t// Composite overlays into the rendered lines (before differential compare)\n\t\tif (this.overlayStack.length > 0) {\n\t\t\tnewLines = this.compositeOverlays(newLines, width, height);\n\t\t}\n\n\t\t// Extract cursor position before the marker could be obscured. The reset\n\t\t// is applied per-line at write time (see emitLine), so newLines stays the\n\t\t// un-reset, reference-stable output of the component tree from here on.\n\t\t// On patched frames the persistent flat buffer already had the marker\n\t\t// stripped; the cached position (row-shifted by render()) stays valid\n\t\t// unless its row was overwritten by re-imported child content, or a\n\t\t// marker could have newly appeared in changed content.\n\t\tlet cursorPos: { row: number; col: number } | null;\n\t\tif (patch !== \"full\" && this.lastCursorPos !== undefined) {\n\t\t\tconst cp = this.lastCursorPos;\n\t\t\tif (cp !== null && !this.cursorRowOverwritten) {\n\t\t\t\tcursorPos = cp;\n\t\t\t} else if (patch === null) {\n\t\t\t\tcursorPos = cp;\n\t\t\t} else {\n\t\t\t\tcursorPos = this.extractCursorPosition(newLines, height);\n\t\t\t}\n\t\t} else {\n\t\t\tcursorPos = this.extractCursorPosition(newLines, height);\n\t\t}\n\t\tthis.lastCursorPos = cursorPos;\n\n\t\t// Helper to clear scrollback and viewport and render all new lines\n\t\tconst fullRender = (clear: boolean): void => {\n\t\t\tthis.fullRedrawCount += 1;\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tif (clear) {\n\t\t\t\tbuffer += this.deleteKittyImages(this.previousKittyImageIds);\n\t\t\t\tbuffer += \"\\x1b[2J\\x1b[H\\x1b[3J\"; // Clear screen, home, then clear scrollback\n\t\t\t}\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += this.emitLine(newLines[i]);\n\t\t\t}\n\t\t\tthis.cursorRow = Math.max(0, newLines.length - 1);\n\t\t\tthis.hardwareCursorRow = this.cursorRow;\n\t\t\tbuffer += this.buildHardwareCursorMove(cursorPos, newLines.length);\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\t// Reset max lines when clearing, otherwise track growth\n\t\t\tif (clear) {\n\t\t\t\tthis.maxLinesRendered = newLines.length;\n\t\t\t} else {\n\t\t\t\tthis.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);\n\t\t\t}\n\t\t\tconst bufferLength = Math.max(height, newLines.length);\n\t\t\tthis.previousViewportTop = Math.max(0, bufferLength - height);\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\t\tthis.previousWidth = width;\n\t\t\tthis.previousHeight = height;\n\t\t};\n\n\t\tconst debugRedraw = process.env.HOOCODE_DEBUG_REDRAW === \"1\";\n\t\tconst logRedraw = (reason: string): void => {\n\t\t\tif (!debugRedraw) return;\n\t\t\tconst agentDir = process.env.HOOCODE_CODING_AGENT_DIR ?? path.join(os.homedir(), \".hoocode\", \"agent\");\n\t\t\tconst logPath = path.join(agentDir, \"hoocode-debug.log\");\n\t\t\tconst msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\\n`;\n\t\t\tfs.appendFileSync(logPath, msg);\n\t\t};\n\n\t\t// First render - just output everything without clearing (assumes clean screen)\n\t\tif (this.previousLines.length === 0 && !widthChanged && !heightChanged) {\n\t\t\tlogRedraw(\"first render\");\n\t\t\tfullRender(false);\n\t\t\treturn;\n\t\t}\n\n\t\t// Width changes always need a full re-render because wrapping changes.\n\t\tif (widthChanged) {\n\t\t\tlogRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Height changes normally need a full re-render to keep the visible viewport aligned,\n\t\t// but Termux changes height when the software keyboard shows or hides.\n\t\t// In that environment, a full redraw causes the entire history to replay on every toggle.\n\t\tif (heightChanged && !isTermuxSession()) {\n\t\t\tlogRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Content shrunk below the working area and no overlays - re-render to clear empty rows\n\t\t// (overlays need the padding, so only do this when no overlays are active)\n\t\t// Configurable via setClearOnShrink() or HOOCODE_CLEAR_ON_SHRINK=0 env var\n\t\tif (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {\n\t\t\tlogRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Find first and last changed lines. When the root render() produced a\n\t\t// patch report the dirty range is already known and the whole-buffer scan\n\t\t// is skipped. On patched frames previousLines is the same in-place-updated\n\t\t// array as newLines, so the previous length must come from the report.\n\t\tlet firstChanged: number;\n\t\tlet lastChanged: number;\n\t\tlet prevLineCount: number;\n\t\tif (patch !== \"full\") {\n\t\t\tif (patch === null) {\n\t\t\t\tprevLineCount = newLines.length;\n\t\t\t\tfirstChanged = -1;\n\t\t\t\tlastChanged = -1;\n\t\t\t} else {\n\t\t\t\tprevLineCount = patch.prevLength;\n\t\t\t\tfirstChanged = patch.low;\n\t\t\t\tlastChanged = patch.high;\n\t\t\t}\n\t\t} else {\n\t\t\tprevLineCount = this.previousLines.length;\n\t\t\tfirstChanged = -1;\n\t\t\tlastChanged = -1;\n\t\t\tconst maxLines = Math.max(newLines.length, prevLineCount);\n\t\t\tfor (let i = 0; i < maxLines; i++) {\n\t\t\t\tconst oldLine = i < prevLineCount ? this.previousLines[i] : \"\";\n\t\t\t\tconst newLine = i < newLines.length ? newLines[i] : \"\";\n\n\t\t\t\tif (oldLine !== newLine) {\n\t\t\t\t\tif (firstChanged === -1) {\n\t\t\t\t\t\tfirstChanged = i;\n\t\t\t\t\t}\n\t\t\t\t\tlastChanged = i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst appendedLines = newLines.length > prevLineCount;\n\t\tif (appendedLines) {\n\t\t\tif (firstChanged === -1) {\n\t\t\t\tfirstChanged = prevLineCount;\n\t\t\t}\n\t\t\tlastChanged = newLines.length - 1;\n\t\t}\n\t\tif (firstChanged !== -1) {\n\t\t\tlastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);\n\t\t}\n\t\tconst appendStart = appendedLines && firstChanged === prevLineCount && firstChanged > 0;\n\n\t\t// No changes - but still need to update hardware cursor position if it moved\n\t\tif (firstChanged === -1) {\n\t\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\t\t\tthis.previousViewportTop = prevViewportTop;\n\t\t\tthis.previousHeight = height;\n\t\t\treturn;\n\t\t}\n\n\t\t// All changes are in deleted lines (nothing to render, just clear)\n\t\tif (firstChanged >= newLines.length) {\n\t\t\tif (prevLineCount > newLines.length) {\n\t\t\t\tlet buffer = \"\\x1b[?2026h\";\n\t\t\t\tbuffer += this.deleteChangedKittyImages(firstChanged, lastChanged);\n\t\t\t\t// Move to end of new content (clamp to 0 for empty content)\n\t\t\t\tconst targetRow = Math.max(0, newLines.length - 1);\n\t\t\t\tif (targetRow < prevViewportTop) {\n\t\t\t\t\tlogRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);\n\t\t\t\t\tfullRender(true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst lineDiff = computeLineDiff(targetRow);\n\t\t\t\tif (lineDiff > 0) buffer += `\\x1b[${lineDiff}B`;\n\t\t\t\telse if (lineDiff < 0) buffer += `\\x1b[${-lineDiff}A`;\n\t\t\t\tbuffer += \"\\r\";\n\t\t\t\t// Clear extra lines without scrolling\n\t\t\t\tconst extraLines = prevLineCount - newLines.length;\n\t\t\t\tif (extraLines > height) {\n\t\t\t\t\tlogRedraw(`extraLines > height (${extraLines} > ${height})`);\n\t\t\t\t\tfullRender(true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (extraLines > 0) {\n\t\t\t\t\tbuffer += \"\\x1b[1B\";\n\t\t\t\t}\n\t\t\t\tfor (let i = 0; i < extraLines; i++) {\n\t\t\t\t\tbuffer += \"\\r\\x1b[2K\";\n\t\t\t\t\tif (i < extraLines - 1) buffer += \"\\x1b[1B\";\n\t\t\t\t}\n\t\t\t\tif (extraLines > 0) {\n\t\t\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t\t\t}\n\t\t\t\tthis.cursorRow = targetRow;\n\t\t\t\tthis.hardwareCursorRow = targetRow;\n\t\t\t\tbuffer += this.buildHardwareCursorMove(cursorPos, newLines.length);\n\t\t\t\tbuffer += \"\\x1b[?2026l\";\n\t\t\t\tthis.terminal.write(buffer);\n\t\t\t} else {\n\t\t\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\t\t\t}\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\t\tthis.previousWidth = width;\n\t\t\tthis.previousHeight = height;\n\t\t\tthis.previousViewportTop = prevViewportTop;\n\t\t\treturn;\n\t\t}\n\n\t\t// Differential rendering can only touch what was actually visible.\n\t\t// If the first changed line is above the previous viewport, we need a full redraw.\n\t\tif (firstChanged < prevViewportTop) {\n\t\t\tlogRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Render from first changed line to end\n\t\t// Build buffer with all updates wrapped in synchronized output\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\tbuffer += this.deleteChangedKittyImages(firstChanged, lastChanged);\n\t\tconst prevViewportBottom = prevViewportTop + height - 1;\n\t\tconst moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;\n\t\tif (moveTargetRow > prevViewportBottom) {\n\t\t\tconst currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));\n\t\t\tconst moveToBottom = height - 1 - currentScreenRow;\n\t\t\tif (moveToBottom > 0) {\n\t\t\t\tbuffer += `\\x1b[${moveToBottom}B`;\n\t\t\t}\n\t\t\tconst scroll = moveTargetRow - prevViewportBottom;\n\t\t\tbuffer += \"\\r\\n\".repeat(scroll);\n\t\t\tprevViewportTop += scroll;\n\t\t\tviewportTop += scroll;\n\t\t\thardwareCursorRow = moveTargetRow;\n\t\t}\n\n\t\t// Move cursor to first changed line (use hardwareCursorRow for actual position)\n\t\tconst lineDiff = computeLineDiff(moveTargetRow);\n\t\tif (lineDiff > 0) {\n\t\t\tbuffer += `\\x1b[${lineDiff}B`; // Move down\n\t\t} else if (lineDiff < 0) {\n\t\t\tbuffer += `\\x1b[${-lineDiff}A`; // Move up\n\t\t}\n\n\t\tbuffer += appendStart ? \"\\r\\n\" : \"\\r\"; // Move to column 0\n\n\t\t// Only render changed lines (firstChanged to lastChanged), not all lines to end\n\t\t// This reduces flicker when only a single line changes (e.g., spinner animation)\n\t\tconst renderEnd = Math.min(lastChanged, newLines.length - 1);\n\t\tfor (let i = firstChanged; i <= renderEnd; i++) {\n\t\t\tif (i > firstChanged) buffer += \"\\r\\n\";\n\t\t\tbuffer += \"\\x1b[2K\"; // Clear current line\n\t\t\tconst line = newLines[i];\n\t\t\tconst isImage = isImageLine(line);\n\t\t\tif (!isImage && visibleWidth(line) > width) {\n\t\t\t\t// Log all lines to crash file for debugging\n\t\t\t\tconst agentDir = process.env.HOOCODE_CODING_AGENT_DIR ?? path.join(os.homedir(), \".hoocode\", \"agent\");\n\t\t\t\tconst crashLogPath = path.join(agentDir, \"hoocode-crash.log\");\n\t\t\t\tconst crashData = [\n\t\t\t\t\t`Crash at ${new Date().toISOString()}`,\n\t\t\t\t\t`Terminal width: ${width}`,\n\t\t\t\t\t`Line ${i} visible width: ${visibleWidth(line)}`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"=== All rendered lines ===\",\n\t\t\t\t\t...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tfs.mkdirSync(path.dirname(crashLogPath), { recursive: true });\n\t\t\t\tfs.writeFileSync(crashLogPath, crashData);\n\n\t\t\t\t// Clean up terminal state before throwing\n\t\t\t\tthis.stop();\n\n\t\t\t\tconst errorMsg = [\n\t\t\t\t\t`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"This is likely caused by a custom TUI component not truncating its output.\",\n\t\t\t\t\t\"Use visibleWidth() to measure and truncateToWidth() to truncate lines.\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t`Debug log written to: ${crashLogPath}`,\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tthrow new Error(errorMsg);\n\t\t\t}\n\t\t\tif (isImage) this.sawImageLine = true;\n\t\t\tbuffer += isImage ? line : normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;\n\t\t}\n\n\t\t// Track where cursor ended up after rendering\n\t\tlet finalCursorRow = renderEnd;\n\n\t\t// If we had more lines before, clear them and move cursor back\n\t\tif (prevLineCount > newLines.length) {\n\t\t\t// Move to end of new content first if we stopped before it\n\t\t\tif (renderEnd < newLines.length - 1) {\n\t\t\t\tconst moveDown = newLines.length - 1 - renderEnd;\n\t\t\t\tbuffer += `\\x1b[${moveDown}B`;\n\t\t\t\tfinalCursorRow = newLines.length - 1;\n\t\t\t}\n\t\t\tconst extraLines = prevLineCount - newLines.length;\n\t\t\tfor (let i = newLines.length; i < prevLineCount; i++) {\n\t\t\t\tbuffer += \"\\r\\n\\x1b[2K\";\n\t\t\t}\n\t\t\t// Move cursor back to end of new content\n\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t}\n\n\t\t// Track cursor position for next render\n\t\t// cursorRow tracks end of content (for viewport calculation)\n\t\t// hardwareCursorRow tracks actual terminal cursor position (for movement)\n\t\tthis.cursorRow = Math.max(0, newLines.length - 1);\n\t\tthis.hardwareCursorRow = finalCursorRow;\n\n\t\t// Position hardware cursor for IME. Inside the synchronized block, so the\n\t\t// frame is never presented with the cursor still parked at the end of the\n\t\t// last redrawn line.\n\t\tbuffer += this.buildHardwareCursorMove(cursorPos, newLines.length);\n\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\n\t\tif (process.env.HOOCODE_TUI_DEBUG === \"1\") {\n\t\t\tconst debugDir = \"/tmp/tui\";\n\t\t\tfs.mkdirSync(debugDir, { recursive: true });\n\t\t\tconst debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);\n\t\t\tconst debugData = [\n\t\t\t\t`firstChanged: ${firstChanged}`,\n\t\t\t\t`viewportTop: ${viewportTop}`,\n\t\t\t\t`cursorRow: ${this.cursorRow}`,\n\t\t\t\t`height: ${height}`,\n\t\t\t\t`lineDiff: ${lineDiff}`,\n\t\t\t\t`hardwareCursorRow: ${hardwareCursorRow}`,\n\t\t\t\t`renderEnd: ${renderEnd}`,\n\t\t\t\t`finalCursorRow: ${finalCursorRow}`,\n\t\t\t\t`cursorPos: ${JSON.stringify(cursorPos)}`,\n\t\t\t\t`newLines.length: ${newLines.length}`,\n\t\t\t\t`previousLines.length: ${this.previousLines.length}`,\n\t\t\t\t\"\",\n\t\t\t\t\"=== newLines ===\",\n\t\t\t\tJSON.stringify(newLines, null, 2),\n\t\t\t\t\"\",\n\t\t\t\t\"=== previousLines ===\",\n\t\t\t\tJSON.stringify(this.previousLines, null, 2),\n\t\t\t\t\"\",\n\t\t\t\t\"=== buffer ===\",\n\t\t\t\tJSON.stringify(buffer),\n\t\t\t].join(\"\\n\");\n\t\t\tfs.writeFileSync(debugPath, debugData);\n\t\t}\n\n\t\t// Write entire buffer at once\n\t\tthis.terminal.write(buffer);\n\n\t\t// Track terminal's working area (grows but doesn't shrink unless cleared)\n\t\tthis.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);\n\t\tthis.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);\n\n\t\tthis.previousLines = newLines;\n\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\tthis.previousWidth = width;\n\t\tthis.previousHeight = height;\n\t}\n\n\t/**\n\t * Build the escape sequence that parks the hardware cursor for this frame.\n\t *\n\t * Callers must append the result to the frame buffer *inside* the\n\t * synchronized-output block. Emitting it as a separate write leaves the\n\t * cursor wherever the last redrawn line ended for the gap between the two\n\t * writes, which on an animated status line shows up as a cursor flickering\n\t * at the end of that line at the animation's cadence.\n\t *\n\t * Updates `hardwareCursorRow` to where the sequence leaves the cursor.\n\t *\n\t * @param cursorPos The cursor position extracted from rendered output, or null\n\t * @param totalLines Total number of rendered lines\n\t */\n\tprivate buildHardwareCursorMove(cursorPos: { row: number; col: number } | null, totalLines: number): string {\n\t\tconst visibility = this.showHardwareCursor ? SHOW_CURSOR : HIDE_CURSOR;\n\n\t\tif (!cursorPos || totalLines <= 0) {\n\t\t\t// Nothing focused, so there is no position to honor - but the cursor\n\t\t\t// still has to land somewhere known. Lines are padded to the full\n\t\t\t// terminal width, so a frame that ends after the last emitted line\n\t\t\t// leaves the cursor in the terminal's pending-wrap state at the right\n\t\t\t// margin, where it renders on the following row on some terminals.\n\t\t\t// Returning to column 0 keeps it on the row we think it is on.\n\t\t\treturn `\\r${HIDE_CURSOR}`;\n\t\t}\n\n\t\t// Clamp cursor position to valid range\n\t\tconst targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));\n\t\tconst targetCol = Math.max(0, cursorPos.col);\n\n\t\t// Move cursor from current position to target\n\t\tconst rowDelta = targetRow - this.hardwareCursorRow;\n\t\tlet buffer = \"\";\n\t\tif (rowDelta > 0) {\n\t\t\tbuffer += `\\x1b[${rowDelta}B`; // Move down\n\t\t} else if (rowDelta < 0) {\n\t\t\tbuffer += `\\x1b[${-rowDelta}A`; // Move up\n\t\t}\n\t\t// Move to absolute column (1-indexed)\n\t\tbuffer += `\\x1b[${targetCol + 1}G`;\n\n\t\tthis.hardwareCursorRow = targetRow;\n\t\treturn buffer + visibility;\n\t}\n\n\t/**\n\t * Position the hardware cursor for IME candidate window, as a standalone\n\t * write. Only for frames that emit no content of their own; frames that\n\t * build a buffer must fold `buildHardwareCursorMove` into it instead.\n\t */\n\tprivate positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {\n\t\tthis.terminal.write(this.buildHardwareCursorMove(cursorPos, totalLines));\n\t}\n}\n"]}
|
package/dist/tui.js
CHANGED
|
@@ -408,12 +408,46 @@ export class TUI extends Container {
|
|
|
408
408
|
* again — the measurement can only be made from a finished frame, so the
|
|
409
409
|
* frame that answers it is always the second one. Both passes are cheap
|
|
410
410
|
* after the first: every other child returns its memoized array untouched.
|
|
411
|
+
*
|
|
412
|
+
* Two cases, and the second one is the one with a bug behind it.
|
|
413
|
+
*
|
|
414
|
+
* **The frame fits on the screen.** Fill it out to the screen's height and
|
|
415
|
+
* the frame starts on the first row, which is the whole point of the fill.
|
|
416
|
+
*
|
|
417
|
+
* **The frame is taller than the screen.** Then the fill is not what puts
|
|
418
|
+
* the prompt on the floor — the terminal's own scroll is, and the buffer's
|
|
419
|
+
* last row *is* the screen's last row. So a buffer that gets *shorter*
|
|
420
|
+
* takes the prompt up the screen with it: the renderer clears the rows that
|
|
421
|
+
* came off the end and there is nothing it can do to scroll the transcript
|
|
422
|
+
* back down into them, because those rows are in the terminal's scrollback
|
|
423
|
+
* and only the terminal can move them. That is a picker closing, a
|
|
424
|
+
* notification fading, a task ledger emptying — the prompt stranded
|
|
425
|
+
* mid-screen with a band of blank rows under it, and it stays stranded
|
|
426
|
+
* until enough output arrives to push it back down.
|
|
427
|
+
*
|
|
428
|
+
* So the fill takes what the shrinking content gave up, which keeps the
|
|
429
|
+
* buffer the length it already was and the last row where it already is.
|
|
430
|
+
* The blank band ends up *above* the chrome instead of below it, where it
|
|
431
|
+
* reads as room rather than as a layout that came apart, and the next
|
|
432
|
+
* output to arrive lands in it rather than scrolling the screen. Capped at
|
|
433
|
+
* a screenful: a fill longer than the screen is rows nobody can see, and it
|
|
434
|
+
* is given up altogether on the frames that repaint the whole screen anyway.
|
|
411
435
|
*/
|
|
412
|
-
fitFlexSpacer(lines, height) {
|
|
436
|
+
fitFlexSpacer(lines, height, repaint = false) {
|
|
413
437
|
const spacer = this.flexSpacer;
|
|
414
438
|
if (!spacer)
|
|
415
439
|
return false;
|
|
416
|
-
|
|
440
|
+
const content = lines.length - spacer.currentHeight;
|
|
441
|
+
if (content < height)
|
|
442
|
+
return spacer.setHeight(height - content);
|
|
443
|
+
// A frame that is about to be repainted from the top of a cleared screen
|
|
444
|
+
// has no floor to hold: the resize already threw the old screen away, and
|
|
445
|
+
// holding its length would only bank a band of blank rows into the middle
|
|
446
|
+
// of the new one.
|
|
447
|
+
if (repaint)
|
|
448
|
+
return spacer.setHeight(0);
|
|
449
|
+
const held = Math.min(this.previousLines.length - content, height);
|
|
450
|
+
return spacer.setHeight(Math.max(0, held));
|
|
417
451
|
}
|
|
418
452
|
/**
|
|
419
453
|
* The screen rows a pinned window shows, the last one being the indicator.
|
|
@@ -1657,7 +1691,7 @@ export class TUI extends Container {
|
|
|
1657
1691
|
// invalidates the first's patch — it describes the buffer from before the
|
|
1658
1692
|
// splice — so fall back to the full scan, which is always correct and only
|
|
1659
1693
|
// runs on frames where the content height actually changed.
|
|
1660
|
-
if (this.fitFlexSpacer(newLines, height)) {
|
|
1694
|
+
if (this.fitFlexSpacer(newLines, height, widthChanged || heightChanged)) {
|
|
1661
1695
|
newLines = this.render(width);
|
|
1662
1696
|
this.lastPatch = "full";
|
|
1663
1697
|
}
|