From b6c87974591ff677920d53236762740050fa2057 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 27 Aug 2026 19:09:26 +0200 Subject: [PATCH] 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. --- .../src/experimental/client-tui-chat.ts | 53 ++++--- .../src/experimental/client-tui.ts | 147 ++++++++++++++---- .../src/experimental/services/README.md | 2 +- .../src/modes/interactive/chat-viewport.ts | 44 ++++++ .../src/modes/interactive/interactive-mode.ts | 95 ++--------- .../interactive/theme/theme-controller.ts | 6 + .../src/modes/interactive/tui-renderer.ts | 70 +++++++++ .../coding-agent/test/chat-viewport.test.ts | 26 ++++ .../test/experimental-client-tui.test.ts | 6 +- .../test/theme-controller.test.ts | 18 ++- 10 files changed, 331 insertions(+), 136 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/chat-viewport.ts create mode 100644 packages/coding-agent/src/modes/interactive/tui-renderer.ts create mode 100644 packages/coding-agent/test/chat-viewport.test.ts diff --git a/packages/coding-agent/src/experimental/client-tui-chat.ts b/packages/coding-agent/src/experimental/client-tui-chat.ts index 338f060ac..f452314c9 100644 --- a/packages/coding-agent/src/experimental/client-tui-chat.ts +++ b/packages/coding-agent/src/experimental/client-tui-chat.ts @@ -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 = 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(); #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); } } } diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index 3a5a98e2d..d8d48dfdf 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -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(); @@ -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 | 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 { - 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 { 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 { + 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((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(); } diff --git a/packages/coding-agent/src/experimental/services/README.md b/packages/coding-agent/src/experimental/services/README.md index 9d0afd948..ac8cedf08 100644 --- a/packages/coding-agent/src/experimental/services/README.md +++ b/packages/coding-agent/src/experimental/services/README.md @@ -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. diff --git a/packages/coding-agent/src/modes/interactive/chat-viewport.ts b/packages/coding-agent/src/modes/interactive/chat-viewport.ts new file mode 100644 index 000000000..9af0528ae --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/chat-viewport.ts @@ -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 }, + ]), + }; +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ac358c420..cfbb41d2c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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, diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts index 0684ecaee..b5cf06b48 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -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; } diff --git a/packages/coding-agent/src/modes/interactive/tui-renderer.ts b/packages/coding-agent/src/modes/interactive/tui-renderer.ts new file mode 100644 index 000000000..2ed2d9905 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/tui-renderer.ts @@ -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()), + }); +} diff --git a/packages/coding-agent/test/chat-viewport.test.ts b/packages/coding-agent/test/chat-viewport.test.ts new file mode 100644 index 000000000..c87215d70 --- /dev/null +++ b/packages/coding-agent/test/chat-viewport.test.ts @@ -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"); + }); +}); diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts index 22d9a93ca..eb0bd5b24 100644 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ b/packages/coding-agent/test/experimental-client-tui.test.ts @@ -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"); diff --git a/packages/coding-agent/test/theme-controller.test.ts b/packages/coding-agent/test/theme-controller.test.ts index a20009d57..16db1fbab 100644 --- a/packages/coding-agent/test/theme-controller.test.ts +++ b/packages/coding-agent/test/theme-controller.test.ts @@ -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();