feat(packages/tui-and-shared-tooling-primitives): added TUI terminal support

- Made `Component.invalidate` optional in `packages/tui/src/tui.ts` and callsites.
- Added `iconOverride` support to status-line rendering in `status-line.ts`.
- Added `borderColor` and `framedBlock()` primitives in `output-block.ts` for framed output styling.
- Adjusted `renderOutputBlock()` to draw a continuous border when no header text is present.
- Added terminal checkpoint-reconciliation tests in `submit-checkpoint-reconcile.test.ts` for pinned-tail behavior.
This commit is contained in:
can1357
2026-06-07 05:13:58 +02:00
parent d18cc3c717
commit 1ebe2c7484
8 changed files with 160 additions and 12 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ export interface Component {
render(width: number): string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate(): void;
invalidate?(): void;
}
```
@@ -82,7 +82,7 @@ export class SetupWizardComponent implements Component {
}
invalidate(): void {
this.#activeScene?.invalidate();
this.#activeScene?.invalidate?.();
}
handleInput(data: string): void {
+29 -5
View File
@@ -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(),
};
}
+5 -1
View File
@@ -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;
+4
View File
@@ -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
+1 -2
View File
@@ -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;
})();
+2 -2
View File
@@ -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
@@ -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<void> {
const tick = Promise.withResolvers<void>();
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<CheckpointResult> {
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);
});
});