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:
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user