feat(coding-agent): share fullscreen TUI with experimental client

Reuse the stable renderer, viewport layout, theme detection, and terminal appearance updates. Keep successful operation status derived from replicated lane state.
This commit is contained in:
Armin Ronacher 2026-08-27 19:09:26 +02:00
parent ecd31642d7
commit b6c8797459
10 changed files with 331 additions and 136 deletions

View file

@ -18,14 +18,14 @@ function userMessageText(message: AgentMessage): string {
}
/** Snapshot-driven transcript used by the service-only experimental presentation. */
export class ExperimentalChatView extends Container {
export class ExperimentalChatView {
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
readonly transcript = new Container();
readonly pendingMessages = new Container();
readonly status = new Container();
readonly #ui: TUI;
readonly #cwd: string;
readonly #chat = new Container();
readonly #queue = new Container();
readonly #status = new Container();
readonly #tools = new Map<string, ToolExecutionComponent>();
#renderedEntryIds: string[] = [];
#streaming: AssistantMessageComponent | undefined;
@ -33,12 +33,8 @@ export class ExperimentalChatView extends Container {
#working = false;
constructor(ui: TUI, cwd: string) {
super();
this.#ui = ui;
this.#cwd = cwd;
this.addChild(this.#chat);
this.addChild(this.#queue);
this.addChild(this.#status);
}
apply(snapshot: LaneSnapshot): void {
@ -56,7 +52,22 @@ export class ExperimentalChatView extends Container {
}
this.#syncQueues(snapshot.queues);
this.#setWorking(snapshot.operation !== null);
this.invalidate();
this.transcript.invalidate();
this.pendingMessages.invalidate();
this.status.invalidate();
}
refreshTheme(snapshot: LaneSnapshot): void {
this.#indicator?.dispose();
this.#indicator = undefined;
this.#working = false;
this.transcript.clear();
this.pendingMessages.clear();
this.status.clear();
this.#tools.clear();
this.#renderedEntryIds = [];
this.#streaming = undefined;
this.apply(snapshot);
}
dispose(): void {
@ -64,18 +75,18 @@ export class ExperimentalChatView extends Container {
}
#syncQueues(queues: LaneSnapshot["queues"]): void {
this.#queue.clear();
this.pendingMessages.clear();
for (const item of queues) {
const text =
item.type === "message" ? userMessageText(item.message).replace(/\s+/g, " ") : `<${item.customType}>`;
this.#queue.addChild(new TruncatedText(theme.fg("muted", `[${item.kind}] ${text}`), 1, 0));
this.pendingMessages.addChild(new TruncatedText(theme.fg("muted", `[${item.kind}] ${text}`), 1, 0));
}
}
#syncTranscript(transcript: readonly Entry[]): void {
const diverged = this.#renderedEntryIds.some((id, index) => transcript[index]?.id !== id);
if (diverged) {
this.#chat.clear();
this.transcript.clear();
this.#tools.clear();
this.#renderedEntryIds = [];
this.#streaming = undefined;
@ -106,13 +117,13 @@ export class ExperimentalChatView extends Container {
#addMessage(message: AgentMessage): void {
if (message.role === "user") {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new UserMessageComponent(userMessageText(message)));
this.transcript.addChild(new Spacer(1));
this.transcript.addChild(new UserMessageComponent(userMessageText(message)));
return;
}
if (message.role === "assistant") {
const component = this.#streaming ?? new AssistantMessageComponent();
if (!this.#streaming) this.#chat.addChild(component);
if (!this.#streaming) this.transcript.addChild(component);
this.#streaming = undefined;
component.updateContent(message, false);
for (const content of message.content) {
@ -127,7 +138,7 @@ export class ExperimentalChatView extends Container {
if (!message) return;
if (!this.#streaming) {
this.#streaming = new AssistantMessageComponent();
this.#chat.addChild(this.#streaming);
this.transcript.addChild(this.#streaming);
}
this.#streaming.updateContent(message, true);
for (const content of message.content) {
@ -150,14 +161,14 @@ export class ExperimentalChatView extends Container {
this.#ui,
this.#cwd,
);
this.#chat.addChild(component);
this.transcript.addChild(component);
this.#tools.set(toolCallId, component);
return component;
}
#addText(text: string): void {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new Text(text, 1, 0));
this.transcript.addChild(new Spacer(1));
this.transcript.addChild(new Text(text, 1, 0));
}
#setWorking(working: boolean): void {
@ -165,10 +176,10 @@ export class ExperimentalChatView extends Container {
this.#working = working;
this.#indicator?.dispose();
this.#indicator = undefined;
this.#status.clear();
this.status.clear();
if (working) {
this.#indicator = new WorkingStatusIndicator(this.#ui, "Working... (esc to abort)");
this.#status.addChild(this.#indicator);
this.status.addChild(this.#indicator);
}
}
}

View file

@ -3,20 +3,22 @@ import type { SessionSummary } from "@earendil-works/pi-protocol";
import {
type Component,
Container,
ProcessTerminal,
type SelectItem,
SelectList,
setKeybindings,
Text,
type TUI,
TuiMainScreen,
} from "@earendil-works/pi-tui";
import chalk from "chalk";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import { getAgentDir } from "../config.ts";
import { KeybindingsManager } from "../core/keybindings.ts";
import { DefaultResourceLoader } from "../core/resource-loader.ts";
import { SettingsManager } from "../core/settings-manager.ts";
import { createChatViewport } from "../modes/interactive/chat-viewport.ts";
import { CustomEditor } from "../modes/interactive/components/custom-editor.ts";
import { getEditorTheme, initTheme, theme } from "../modes/interactive/theme/theme.ts";
import { getEditorTheme, setRegisteredThemes, stopThemeWatcher, theme } from "../modes/interactive/theme/theme.ts";
import { InteractiveThemeController } from "../modes/interactive/theme/theme-controller.ts";
import { createInteractiveTui } from "../modes/interactive/tui-renderer.ts";
import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts";
import { ExperimentalChatView } from "./client-tui-chat.ts";
import { combineFacetLoaders, type FacetLoader, type LoadedFacets } from "./facet-loader.ts";
@ -62,11 +64,11 @@ interface ModelAction {
}
const selectTheme = {
selectedPrefix: (text: string) => chalk.cyan(text),
selectedText: (text: string) => chalk.cyan(text),
description: (text: string) => chalk.dim(text),
scrollInfo: (text: string) => chalk.dim(text),
noMatch: (text: string) => chalk.yellow(text),
selectedPrefix: (text: string) => theme.fg("accent", text),
selectedText: (text: string) => theme.fg("accent", text),
description: (text: string) => theme.fg("muted", text),
scrollInfo: (text: string) => theme.fg("dim", text),
noMatch: (text: string) => theme.fg("warning", text),
};
/** Service-only presentation driven by a replicated main-lane snapshot. */
@ -74,7 +76,13 @@ export class ExperimentalClientTui implements Component {
readonly #ui: TUI;
readonly #requestRender: () => void;
readonly #finish: () => void;
readonly #container = new Container();
readonly #documentContainer = new Container();
readonly #sessionHeading = new Text("", 1, 0);
readonly #pendingMessagesContainer = new Container();
readonly #statusContainer = new Container();
readonly #editorContainer = new Container();
readonly #footerComponent = new Text("", 1, 0);
readonly #layoutRoot: Component;
readonly #loadedFacets: LoadedFacets;
readonly #facetHosts: FacetHost[] = [];
readonly #sessions = new Map<string, SessionFeature>();
@ -85,6 +93,7 @@ export class ExperimentalClientTui implements Component {
#selectList: SelectList | undefined;
#screen: "models" | "chat" = "chat";
#selectedServerId: string | undefined;
#sessionId: string | undefined;
#status = "Starting Session…";
#busy = false;
#closePromise: Promise<void> | undefined;
@ -111,6 +120,16 @@ export class ExperimentalClientTui implements Component {
this.#chatInput.setText("");
void this.#queueFollowUp(text);
});
this.#editorContainer.addChild(this.#chatInput);
this.#layoutRoot = createChatViewport({
document: this.#documentContainer,
pendingMessages: this.#pendingMessagesContainer,
status: this.#statusContainer,
editor: this.#editorContainer,
footer: this.#footerComponent,
scrollbarStyle: (text) => theme.bg("scrollbarThumb", text),
}).root;
this.#rebuild();
}
static async create(options: {
@ -121,7 +140,6 @@ export class ExperimentalClientTui implements Component {
requestRender(): void;
finish(): void;
}): Promise<ExperimentalClientTui> {
initTheme();
const loadedFacets = await combineFacetLoaders(
options.facetLoader === undefined ? [] : [options.facetLoader],
).load();
@ -140,8 +158,18 @@ export class ExperimentalClientTui implements Component {
}
}
get layoutRoot(): Component {
return this.#layoutRoot;
}
render(width: number): string[] {
return this.#container.render(width);
return [
...this.#documentContainer.render(width),
...this.#pendingMessagesContainer.render(width),
...this.#statusContainer.render(width),
...this.#editorContainer.render(width),
...this.#footerComponent.render(width),
];
}
handleInput(data: string): void {
@ -155,13 +183,24 @@ export class ExperimentalClientTui implements Component {
}
invalidate(): void {
this.#container.invalidate();
this.#layoutRoot.invalidate();
}
dispose(): void {
void this.close().catch(() => {});
}
refreshTheme(): void {
const snapshot = this.#laneReplica?.state();
if (snapshot !== undefined) this.#chatView?.refreshTheme(snapshot);
this.#rebuild();
}
showError(error: string): void {
this.#status = `Error: ${error}`;
this.#rebuild();
}
close(): Promise<void> {
this.#closePromise ??= this.#close();
return this.#closePromise;
@ -248,10 +287,11 @@ export class ExperimentalClientTui implements Component {
this.#rebuild();
await feature.management.attach(summary.sessionId, BACKGROUND_CONTEXT);
await feature.session.whenAttached(summary.sessionId, BACKGROUND_CONTEXT);
await this.#openLane(feature, summary.sessionId);
this.#selectedServerId = feature.serverId;
this.#sessionId = summary.sessionId;
await this.#openLane(feature, summary.sessionId);
this.#screen = "chat";
this.#status = summary.sessionId;
this.#status = "";
this.#rebuild();
}
@ -289,11 +329,19 @@ export class ExperimentalClientTui implements Component {
}
#rebuild(): void {
this.#container.clear();
this.#sessionHeading.setText(this.#sessionId === undefined ? "" : theme.fg("dim", this.#sessionId));
this.#statusContainer.clear();
if (this.#status.length > 0) {
this.#statusContainer.addChild(new Text(theme.fg("dim", this.#status), 1, 0));
}
if (this.#chatView !== undefined) this.#statusContainer.addChild(this.#chatView.status);
this.#footerComponent.setText(theme.fg("dim", this.#footer()));
this.#editorContainer.clear();
this.#modelActions.clear();
if (this.#screen === "models") {
this.#chatInput.focused = false;
this.#container.addChild(new Text(chalk.bold("Select model:"), 1, 1));
const selector = new Container();
selector.addChild(new Text(theme.bold("Select model:"), 1, 1));
const items = this.#modelItems();
this.#selectList = new SelectList(items, Math.min(Math.max(items.length, 1), 12), selectTheme);
this.#selectList.onSelect = (item) => {
@ -304,17 +352,14 @@ export class ExperimentalClientTui implements Component {
this.#screen = "chat";
this.#rebuild();
};
this.#container.addChild(this.#selectList);
this.#requestRender();
return;
selector.addChild(this.#selectList);
this.#editorContainer.addChild(selector);
} else {
this.#selectList = undefined;
this.#chatInput.focused = !this.#busy;
this.#editorContainer.addChild(this.#chatInput);
}
this.#selectList = undefined;
this.#chatInput.focused = !this.#busy;
if (this.#status.length > 0) this.#container.addChild(new Text(theme.fg("dim", this.#status), 1, 0));
if (this.#chatView) this.#container.addChild(this.#chatView);
this.#container.addChild(this.#chatInput);
this.#container.addChild(new Text(theme.fg("dim", this.#footer()), 1, 1));
this.#layoutRoot.invalidate();
this.#requestRender();
}
@ -362,6 +407,9 @@ export class ExperimentalClientTui implements Component {
view.apply(replica.state());
this.#laneReplica = replica;
this.#chatView = view;
this.#documentContainer.addChild(this.#sessionHeading);
this.#documentContainer.addChild(view.transcript);
this.#pendingMessagesContainer.addChild(view.pendingMessages);
this.#laneUnsubscribe = replica.subscribe(() => {
view.apply(replica.state());
this.#rebuild();
@ -373,6 +421,9 @@ export class ExperimentalClientTui implements Component {
this.#laneUnsubscribe = undefined;
this.#chatView?.dispose();
this.#chatView = undefined;
this.#documentContainer.clear();
this.#pendingMessagesContainer.clear();
this.#statusContainer.clear();
const replica = this.#laneReplica;
this.#laneReplica = undefined;
await replica?.close();
@ -433,7 +484,7 @@ export class ExperimentalClientTui implements Component {
#reportOperation(response: AgentOperationResponse): void {
this.#status = response.accepted
? response.error === null
? `Operation ${response.operationId} completed.`
? ""
: `Operation failed: ${response.error.message}`
: `Operation rejected: ${response.error.message}`;
this.#rebuild();
@ -473,15 +524,43 @@ export class ExperimentalClientTui implements Component {
}
export async function runClientTui(command: ClientCommand, options: RunClientTuiOptions = {}): Promise<void> {
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noContextFiles: true,
});
await resourceLoader.reload();
setRegisteredThemes(resourceLoader.getThemes().themes);
const runtime = await openClientRuntime(command, options);
const terminal = new ProcessTerminal();
const tui = new TuiMainScreen(terminal, undefined, getAgentDir());
const tui = createInteractiveTui({
tuiMode: "fullscreen",
showHardwareCursor: settingsManager.getShowHardwareCursor(),
logDirectory: agentDir,
});
tui.setClearOnShrink(settingsManager.getClearOnShrink());
let component: ExperimentalClientTui | undefined;
let tuiStarted = false;
const themeController = new InteractiveThemeController(tui, {
getSettingsManager: () => settingsManager,
showError: (error) => component?.showError(error),
onChanged: () => component?.refreshTheme(),
});
try {
let finish!: () => void;
const finished = new Promise<void>((resolve) => {
finish = () => {
tui.stop();
themeController.disableAutoSync();
if (tuiStarted) {
tui.stop();
tuiStarted = false;
}
resolve();
};
});
@ -499,10 +578,16 @@ export async function runClientTui(command: ClientCommand, options: RunClientTui
finish,
});
tui.addChild(component);
tui.setLayoutRoot(component.layoutRoot);
tui.setFocus(component);
tuiStarted = true;
tui.start();
await themeController.applyFromSettings();
await finished;
} finally {
themeController.dispose();
stopThemeWatcher();
if (tuiStarted) tui.stop();
await component?.close();
await runtime.dispose();
}

View file

@ -13,7 +13,7 @@ Facet setup generates each host's RPC service catalogue from its provided RPC-ca
`ServerServiceConnection.connection` and `SessionServiceConnection.attachment` are implemented local control states.
With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation drives the worker-owned main lane through `AgentController` and renders a coherent lane-watch replica with the Harness reducer while controller calls are pending. `Transcript` remains reserved for a later service-owned live-delta replacement of that compatibility watch.
With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders a coherent lane-watch replica with the Harness reducer while controller calls are pending. `Transcript` remains reserved for a later service-owned live-delta replacement of that compatibility watch.
The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. There is no local TUI service until the presentation host has a real command, modal, and renderer contribution API worth exposing to independently loaded facets.

View file

@ -0,0 +1,44 @@
import { type Component, ScrollView, type ScrollViewScrollbar, VStack } from "@earendil-works/pi-tui";
export interface ChatViewportOptions {
readonly document: Component;
readonly pendingMessages: Component;
readonly status: Component;
readonly editor: Component;
readonly footer: Component;
readonly widgetsAbove?: Component;
readonly widgetsBelow?: Component;
readonly scrollbar?: ScrollViewScrollbar;
readonly scrollbarStyle?: (text: string) => string;
}
export interface ChatViewport {
readonly root: Component;
readonly transcript: ScrollView;
}
/** Shared fullscreen transcript and fixed input-dock layout. */
export function createChatViewport(options: ChatViewportOptions): ChatViewport {
const transcript = new ScrollView(options.document, {
follow: "end",
primary: true,
overscroll: "chain",
scrollbar: options.scrollbar ?? "auto",
...(options.scrollbarStyle === undefined ? {} : { scrollbarStyle: options.scrollbarStyle }),
});
const dock = new VStack([
{ component: options.pendingMessages, shrink: 1, minSize: 0 },
{ component: options.status, shrink: 1, minSize: 0 },
...(options.widgetsAbove === undefined ? [] : [{ component: options.widgetsAbove, shrink: 1, minSize: 0 }]),
{ component: options.editor, shrink: 1, minSize: 3 },
...(options.widgetsBelow === undefined ? [] : [{ component: options.widgetsBelow, shrink: 1, minSize: 0 }]),
{ component: options.footer, shrink: 1, minSize: 1 },
]);
return {
transcript,
root: new VStack([
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]),
};
}

View file

@ -20,7 +20,6 @@ import type {
OverlayHandle,
OverlayOptions,
SlashCommand,
Terminal,
TuiMainScreenRenderState,
} from "@earendil-works/pi-tui";
import * as TuiLayouts from "@earendil-works/pi-tui";
@ -33,7 +32,6 @@ import {
hyperlink,
Markdown,
matchesKey,
ProcessTerminal,
Spacer,
setKeybindings,
Text,
@ -105,13 +103,13 @@ import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelo
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts";
import { openBrowser } from "../../utils/open-browser.ts";
import { getCwdRelativePath } from "../../utils/paths.ts";
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts";
import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts";
import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts";
import { createChatViewport } from "./chat-viewport.ts";
import { ArminComponent } from "./components/armin.ts";
import { AssistantMessageComponent } from "./components/assistant-message.ts";
import { BashExecutionComponent } from "./components/bash-execution.ts";
@ -172,6 +170,9 @@ import {
theme,
} from "./theme/theme.ts";
import { InteractiveThemeController } from "./theme/theme-controller.ts";
import { createInteractiveTui, createInteractiveTuiReference } from "./tui-renderer.ts";
export { createInteractiveTui, createInteractiveTuiReference } from "./tui-renderer.ts";
/** Interface for components that can be expanded/collapsed */
interface Expandable {
@ -356,68 +357,6 @@ export interface InteractiveModeOptions {
initialThemeSetting?: string;
}
interface InteractiveTuiOptions {
tuiMode: TuiMode;
showHardwareCursor: boolean;
logDirectory: string;
terminal?: Terminal;
onRightClickPaste?: () => void;
}
/** Composition root for selecting the interactive terminal renderer. */
export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen {
const terminal = options.terminal ?? new ProcessTerminal();
if (options.tuiMode === "fullscreen") {
const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, {
searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
openUrl: openBrowser,
onRightClickPaste: options.onRightClickPaste,
copySelection: async (text) => {
try {
await copyToClipboard(text);
return true;
} catch {
return false;
}
},
});
}
return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
}
/** Stable reference for components while InteractiveMode replaces the active renderer. */
export function createInteractiveTuiReference(getTui: () => TUI): TUI {
return new Proxy({} as TUI, {
get: (_target, property) => {
const tui = getTui();
const value = Reflect.get(tui, property, tui);
if (typeof value !== "function") return value;
let methodTui = tui;
let method = value;
return (...args: unknown[]) => {
const currentTui = getTui();
if (currentTui !== methodTui) {
const currentMethod = Reflect.get(currentTui, property, currentTui);
if (typeof currentMethod !== "function") {
throw new TypeError(`TUI property ${String(property)} is not callable`);
}
methodTui = currentTui;
method = currentMethod;
}
return Reflect.apply(method, methodTui, args);
};
},
set: (_target, property, value) => {
const tui = getTui();
return Reflect.set(tui, property, value, tui);
},
has: (_target, property) => Reflect.has(getTui(), property),
getPrototypeOf: () => Reflect.getPrototypeOf(getTui()),
});
}
export class InteractiveMode {
private runtimeHost: AgentSessionRuntime;
private renderer: TuiMainScreen | TuiAltScreen;
@ -914,25 +853,19 @@ export class InteractiveMode {
// Keep one component tree and remount it when changing renderers.
this.renderWidgets(); // Initialize with default spacer
this.transcriptScrollView = new TuiLayouts.ScrollView(this.documentContainer, {
follow: "end",
primary: true,
overscroll: "chain",
const viewport = createChatViewport({
document: this.documentContainer,
pendingMessages: this.pendingMessagesContainer,
status: this.statusContainer,
widgetsAbove: this.widgetContainerAbove,
editor: this.editorContainer,
widgetsBelow: this.widgetContainerBelow,
footer: this.footerContainer,
scrollbar: this.settingsManager.getFullscreenScrollbar(),
scrollbarStyle: (text) => theme.bg("scrollbarThumb", text),
});
const dock = new TuiLayouts.VStack([
{ component: this.pendingMessagesContainer, shrink: 1, minSize: 0 },
{ component: this.statusContainer, shrink: 1, minSize: 0 },
{ component: this.widgetContainerAbove, shrink: 1, minSize: 0 },
{ component: this.editorContainer, shrink: 1, minSize: 3 },
{ component: this.widgetContainerBelow, shrink: 1, minSize: 0 },
{ component: this.footerContainer, shrink: 1, minSize: 1 },
]);
this.fullscreenLayoutRoot = new TuiLayouts.VStack([
{ component: this.transcriptScrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]);
this.transcriptScrollView = viewport.transcript;
this.fullscreenLayoutRoot = viewport.root;
this.mountInteractiveTui(this.renderer, [
this.documentContainer,
this.pendingMessagesContainer,

View file

@ -119,6 +119,12 @@ export class InteractiveThemeController {
this.setAutoSync(false);
}
dispose(): void {
this.setAutoSync(false);
this.terminalColorSchemeUnsubscribe?.();
this.terminalColorSchemeUnsubscribe = undefined;
}
getTerminalTheme(): TerminalTheme {
return this.terminalTheme;
}

View file

@ -0,0 +1,70 @@
import type { Terminal } from "@earendil-works/pi-tui";
import { ProcessTerminal, type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
import { copyToClipboard } from "../../utils/clipboard.ts";
import { openBrowser } from "../../utils/open-browser.ts";
import { theme } from "./theme/theme.ts";
export interface InteractiveTuiOptions {
readonly tuiMode: "regular" | "fullscreen";
readonly showHardwareCursor: boolean;
readonly logDirectory: string;
readonly terminal?: Terminal;
readonly onRightClickPaste?: () => void;
}
/** Composition root shared by coding-agent presentations. */
export function createInteractiveTui(options: InteractiveTuiOptions & { readonly tuiMode: "fullscreen" }): TuiAltScreen;
export function createInteractiveTui(options: InteractiveTuiOptions & { readonly tuiMode: "regular" }): TuiMainScreen;
export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen;
export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen {
const terminal = options.terminal ?? new ProcessTerminal();
if (options.tuiMode === "fullscreen") {
const styleSearchMatch = (text: string) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, {
searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
openUrl: openBrowser,
onRightClickPaste: options.onRightClickPaste,
copySelection: async (text) => {
try {
await copyToClipboard(text);
return true;
} catch {
return false;
}
},
});
}
return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
}
/** Stable reference for components while InteractiveMode replaces the active renderer. */
export function createInteractiveTuiReference(getTui: () => TUI): TUI {
return new Proxy({} as TUI, {
get: (_target, property) => {
const tui = getTui();
const value = Reflect.get(tui, property, tui);
if (typeof value !== "function") return value;
let methodTui = tui;
let method = value;
return (...args: unknown[]) => {
const currentTui = getTui();
if (currentTui !== methodTui) {
const currentMethod = Reflect.get(currentTui, property, currentTui);
if (typeof currentMethod !== "function") {
throw new TypeError(`TUI property ${String(property)} is not callable`);
}
methodTui = currentTui;
method = currentMethod;
}
return Reflect.apply(method, methodTui, args);
};
},
set: (_target, property, value) => {
const tui = getTui();
return Reflect.set(tui, property, value, tui);
},
has: (_target, property) => Reflect.has(getTui(), property),
getPrototypeOf: () => Reflect.getPrototypeOf(getTui()),
});
}

View file

@ -0,0 +1,26 @@
import { Container } from "@earendil-works/pi-tui";
import { describe, expect, test } from "vitest";
import { createChatViewport } from "../src/modes/interactive/chat-viewport.ts";
describe("chat viewport", () => {
test("defaults the transcript scrollbar to auto and accepts overrides", () => {
const automatic = createChatViewport({
document: new Container(),
pendingMessages: new Container(),
status: new Container(),
editor: new Container(),
footer: new Container(),
});
const hidden = createChatViewport({
document: new Container(),
pendingMessages: new Container(),
status: new Container(),
editor: new Container(),
footer: new Container(),
scrollbar: "hidden",
});
expect(automatic.transcript.scrollbar).toBe("auto");
expect(hidden.transcript.scrollbar).toBe("hidden");
});
});

View file

@ -8,7 +8,7 @@ import {
} from "@earendil-works/pi-agent-core";
import type { LaneEvent, LaneSnapshot, SessionSummary } from "@earendil-works/pi-protocol";
import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
import { describe, expect, test, vi } from "vitest";
import { beforeAll, describe, expect, test, vi } from "vitest";
import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts";
import type { FacetLoader } from "../src/experimental/facet-loader.ts";
import { defineFacet } from "../src/experimental/facets.ts";
@ -26,6 +26,7 @@ import {
type SessionDirectoryState,
SessionManagement,
} from "../src/experimental/services/sessions.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
const serverId = "00000000-0000-4000-8000-000000000001";
@ -61,6 +62,8 @@ function laneSnapshot(): LaneSnapshot {
}
describe("experimental client TUI", () => {
beforeAll(() => initTheme("dark"));
test.each([
["new", { command: "client" as const }, "two", 1],
["continued", { command: "client" as const, continue: true }, "one", 0],
@ -273,6 +276,7 @@ describe("experimental client TUI", () => {
await vi.waitFor(() => expect(prompt).toHaveBeenCalledWith("hello", undefined, expect.anything()));
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("remote answer"));
expect(component.render(80).join("\n")).toContain("hello");
expect(component.render(80).join("\n")).not.toContain("Operation run-1 completed");
component.handleInput("/model");
component.handleInput("\r");

View file

@ -9,13 +9,14 @@ function createUi() {
const queryTerminalColorScheme = vi.fn();
const setTerminalColorSchemeNotifications = vi.fn();
let terminalColorSchemeListener: ((terminalTheme: TerminalTheme) => void) | undefined;
const unsubscribeTerminalColorScheme = vi.fn();
const ui = {
invalidate: vi.fn(),
requestRender: vi.fn(),
setTerminalColorSchemeNotifications,
onTerminalColorSchemeChange: vi.fn((listener: (terminalTheme: TerminalTheme) => void) => {
terminalColorSchemeListener = listener;
return vi.fn();
return unsubscribeTerminalColorScheme;
}),
queryTerminalBackgroundColor,
queryTerminalColorScheme,
@ -25,6 +26,7 @@ function createUi() {
queryTerminalBackgroundColor,
queryTerminalColorScheme,
setTerminalColorSchemeNotifications,
unsubscribeTerminalColorScheme,
emitTerminalColorScheme: (terminalTheme: TerminalTheme) => terminalColorSchemeListener?.(terminalTheme),
};
}
@ -76,6 +78,20 @@ describe("InteractiveThemeController", () => {
expect(theme.name).toBe("dark");
});
it("disables terminal appearance updates when disposed", async () => {
const { ui, queryTerminalColorScheme, setTerminalColorSchemeNotifications, unsubscribeTerminalColorScheme } =
createUi();
queryTerminalColorScheme.mockResolvedValue("light");
const manager = SettingsManager.inMemory({ theme: "light/dark" });
const controller = createController(ui, () => manager);
await controller.applyFromSettings();
controller.dispose();
expect(setTerminalColorSchemeNotifications).toHaveBeenLastCalledWith(false);
expect(unsubscribeTerminalColorScheme).toHaveBeenCalledOnce();
});
it("detects the current terminal appearance when selecting a theme pair", async () => {
vi.stubEnv("COLORFGBG", "");
const { ui, queryTerminalColorScheme } = createUi();