diff --git a/docs/tui.md b/docs/tui.md index a62b5f0fe..cba829fbe 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -28,7 +28,7 @@ export interface Component { render(width: number): string[]; handleInput?(data: string): void; wantsKeyRelease?: boolean; - invalidate(): void; + invalidate?(): void; } ``` diff --git a/packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts b/packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts index 72176a870..0d39020de 100644 --- a/packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts +++ b/packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts @@ -82,7 +82,7 @@ export class SetupWizardComponent implements Component { } invalidate(): void { - this.#activeScene?.invalidate(); + this.#activeScene?.invalidate?.(); } handleInput(data: string): void { diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index 0176d7bdb..1ed78cb89 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -3,7 +3,7 @@ */ import type { Component } from "@oh-my-pi/pi-tui"; import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; -import type { Theme } from "../modes/theme/theme"; +import type { Theme, ThemeColor } from "../modes/theme/theme"; import { getSixelLineMask } from "../utils/sixel"; import type { State } from "./types"; import type { RenderCache } from "./utils"; @@ -18,6 +18,9 @@ export interface OutputBlockOptions { applyBg?: boolean; /** Animate the border with a sweeping dark segment (pending/running state). */ animate?: boolean; + /** Override the state-derived border color. Used for muted "legacy" tool + * frames that should not visually compete with framed-output tools. */ + borderColor?: ThemeColor; } const FRAMED_BLOCK_COMPONENT = Symbol("framedBlockComponent"); @@ -99,14 +102,15 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st const cap = h.repeat(3); const lineWidth = Math.max(0, width); // Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors - const borderColor: "error" | "warning" | "accent" | "dim" = - state === "error" + const borderColor: ThemeColor = + options.borderColor ?? + (state === "error" ? "error" : state === "warning" ? "warning" : state === "running" || state === "pending" ? "accent" - : "dim"; + : "dim"); const border = (text: string) => theme.fg(borderColor, text); const bgFn = (() => { if (!state || !applyBg) return undefined; @@ -202,7 +206,12 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st const rightGlyph = row.rightChar; if (lineWidth <= 0) return border(leftGlyphs) + border(rightGlyph); const labelText = [row.label, row.meta].filter(Boolean).join(theme.sep.dot); - const rawLabel = labelText ? ` ${labelText} ` : " "; + if (!labelText) { + // No header: draw a clean, continuous top/separator bar (no 1-col gap). + const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph)); + return `${border(leftGlyphs)}${border(h.repeat(fillCount))}${border(rightGlyph)}`; + } + const rawLabel = ` ${labelText} `; const leftWidth = visibleWidth(leftGlyphs); const rightWidth = visibleWidth(rightGlyph); const maxLabelWidth = Math.max(0, lineWidth - leftWidth - rightWidth); @@ -272,6 +281,7 @@ export class CachedOutputBlock { h.optional(options.header); h.optional(options.headerMeta); h.optional(options.state); + h.optional(options.borderColor); h.bool(options.applyBg ?? true); h.bool(options.animate ?? false); if (options.animate) h.u32(borderShimmerTick()); @@ -286,3 +296,17 @@ export class CachedOutputBlock { return h.digest(); } } + +/** + * Build a self-framing tool component backed by a cached output block. The + * `build` callback returns the block options for a given width; the cache + * dedupes re-renders. Pass `borderColor: "borderMuted"` for the dim "legacy" + * look that does not compete with the state-colored framed tools. + */ +export function framedBlock(theme: Theme, build: (width: number) => OutputBlockOptions): Component { + const block = new CachedOutputBlock(); + return { + render: (width: number): string[] => block.render(build(width), theme), + invalidate: () => block.invalidate(), + }; +} diff --git a/packages/coding-agent/src/tui/status-line.ts b/packages/coding-agent/src/tui/status-line.ts index 043b78e41..0a227a28b 100644 --- a/packages/coding-agent/src/tui/status-line.ts +++ b/packages/coding-agent/src/tui/status-line.ts @@ -7,6 +7,9 @@ import { formatStatusIcon } from "../tools/render-utils"; export interface StatusLineOptions { icon?: ToolUIStatus; + /** Pre-rendered glyph that replaces the status icon (e.g. a magnifier for + * search-family tools). Takes precedence over `icon`. */ + iconOverride?: string; spinnerFrame?: number; title: string; titleColor?: ThemeColor; @@ -27,7 +30,8 @@ function flattenForHeader(text: string): string { } export function renderStatusLine(options: StatusLineOptions, theme: Theme): string { - const icon = options.icon ? formatStatusIcon(options.icon, theme, options.spinnerFrame) : ""; + const icon = + options.iconOverride ?? (options.icon ? formatStatusIcon(options.icon, theme, options.spinnerFrame) : ""); const titleColor = options.titleColor ?? "accent"; const title = theme.fg(titleColor, flattenForHeader(options.title)); let line = icon ? `${icon} ${title}` : title; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 74e23d959..475340401 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -14,6 +14,10 @@ - Added `ScrollView.handleScrollKey()` plus a `fastScrollLines` option so every scroll view gets shared navigation keys, including Shift+Arrow to scroll faster. - Added `OverlayOptions.fullscreen`: while the topmost visible overlay sets it, the engine borrows the terminal's alternate screen buffer for the overlay's lifetime and paints only the modal there — no ED3, no transcript re-commit — so the transcript stays untouched on the normal screen and is not scrollable behind the modal. Mouse tracking (`?1000h`/`?1006h`) is enabled for the modal's lifetime and disabled on exit, so the rest of the app keeps the terminal's native text selection. +### Changed + +- Made `Component.invalidate()` optional so leaf components without render caches no longer need no-op invalidation hooks. + ## [15.10.0] - 2026-06-06 ### Changed diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index de9d6665f..599fa81fb 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -465,8 +465,7 @@ export const TERMINAL: RuntimeTerminal = (() => { // reconciliation checkpoint may ED3-rebuild on an unprobeable viewport there. // Forced off under the test runtime (like deccara) so checkpoint tests stay // deterministic and opt in through setTerminalSubmitPinsViewportToTail. - resolved.submitPinsViewportToTail = - detectSubmitPinsViewportToTail(Bun.env, process.platform) && !isBunTestRuntime(); + resolved.submitPinsViewportToTail = detectSubmitPinsViewportToTail(Bun.env, process.platform) && !isBunTestRuntime(); return resolved; })(); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 3d8441564..a2e18d20e 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -129,10 +129,10 @@ export interface Component { wantsKeyRelease?: boolean; /** - * Invalidate any cached rendering state. + * Optional hook to invalidate any cached rendering state. * Called when theme changes or when component needs to re-render from scratch. */ - invalidate(): void; + invalidate?(): void; /** * Optional teardown. Called when the component is permanently removed from diff --git a/packages/tui/test/submit-checkpoint-reconcile.test.ts b/packages/tui/test/submit-checkpoint-reconcile.test.ts new file mode 100644 index 000000000..7c10e1ef9 --- /dev/null +++ b/packages/tui/test/submit-checkpoint-reconcile.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { type Component, setTerminalSubmitPinsViewportToTail, TERMINAL, TUI } from "@oh-my-pi/pi-tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +// The prompt-submit reconciliation checkpoint (`refreshNativeScrollbackIfDirty`) +// must ED3-rebuild deferred-dirty native scrollback on genuine local terminals, +// where the submit keystroke pins the host to its tail, even though their viewport +// position is unprobeable (ghostty/kitty/iTerm report `undefined`). Without this, +// every offscreen shrink/edit that defers during streaming leaves stale rows above +// the viewport that never clear until Ctrl+L or a resize. Hosts that cannot prove +// at-tail (Windows console/Terminal, SSH, multiplexers — modeled here by +// submitPinsViewportToTail=false) keep deferring so a scrolled reader is never +// yanked by ED3 (#1610/#1682/#1746). + +class LineList implements Component { + #lines: string[]; + constructor(lines: string[]) { + this.#lines = [...lines]; + } + invalidate(): void {} + render(width: number): string[] { + return this.#lines.map(line => line.slice(0, width)); + } + setLines(lines: string[]): void { + this.#lines = [...lines]; + } +} + +async function settle(term: VirtualTerminal): Promise { + const tick = Promise.withResolvers(); + process.nextTick(tick.resolve); + await tick.promise; + await Bun.sleep(20); + await term.flush(); +} + +function capture(term: VirtualTerminal): string[] { + const writes: string[] = []; + const realWrite = term.write.bind(term); + (term as unknown as { write: (s: string) => void }).write = (data: string) => { + writes.push(data); + realWrite(data); + }; + return writes; +} + +function overrideProbe(term: VirtualTerminal, answer: boolean | undefined): void { + (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => answer; +} + +const eraseScrollbackCount = (writes: string[]): number => (writes.join("").match(/\x1b\[3J/g) ?? []).length; + +interface CheckpointResult { + deferredErases: number; + reconciled: boolean; + checkpointErases: number; + viewport: string[]; +} + +// Drive an ED3-risk terminal with an unprobeable viewport through an eager-stream +// offscreen shrink (which defers, marking native scrollback dirty without erasing) +// and then the prompt-submit checkpoint, returning what each step emitted. +async function deferThenCheckpoint(submitPinsViewportToTail: boolean): Promise { + const savedRisk = TERMINAL.eagerEraseScrollbackRisk; + const savedPins = TERMINAL.submitPinsViewportToTail; + // RuntimeTerminal exposes these as writable — no cast needed. + TERMINAL.eagerEraseScrollbackRisk = true; + setTerminalSubmitPinsViewportToTail(submitPinsViewportToTail); + const term = new VirtualTerminal(80, 12); + overrideProbe(term, undefined); + const tui = new TUI(term); + const component = new LineList(Array.from({ length: 60 }, (_value, index) => `init-${index}`)); + tui.addChild(component); + try { + tui.start(); + await settle(term); + const writes = capture(term); + tui.setEagerNativeScrollbackRebuild(true); + + // Offscreen shrink: repaints the visible window in place and marks native + // scrollback dirty instead of erasing (the deferral that strands stale rows). + component.setLines(Array.from({ length: 4 }, (_value, index) => `done-${index}`)); + tui.requestRender(); + await settle(term); + const deferredErases = eraseScrollbackCount(writes); + + const reconciled = tui.refreshNativeScrollbackIfDirty(); + await settle(term); + return { + deferredErases, + reconciled, + checkpointErases: eraseScrollbackCount(writes), + viewport: term.getViewport().map(line => line.trim()), + }; + } finally { + tui.stop(); + TERMINAL.eagerEraseScrollbackRisk = savedRisk; + setTerminalSubmitPinsViewportToTail(savedPins); + } +} + +describe("submit-checkpoint native scrollback reconciliation", () => { + it("reconciles deferred scrollback at the checkpoint when submit pins the host to its tail", async () => { + const result = await deferThenCheckpoint(true); + expect(result.deferredErases).toBe(0); + expect(result.reconciled).toBe(true); + expect(result.checkpointErases).toBeGreaterThan(0); + expect(result.viewport).toContain("done-3"); + }); + + it("keeps deferring at the checkpoint when the host cannot prove at-tail", async () => { + const result = await deferThenCheckpoint(false); + expect(result.deferredErases).toBe(0); + expect(result.reconciled).toBe(false); + expect(result.checkpointErases).toBe(0); + }); +});