From a4561cc940d11bc49ca977f3476cf8c3cebb11ff Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 1 Nov 2026 12:29:23 +0800 Subject: [PATCH] perf(tui): decouple first paint from runtime startup --- docs/DEVELOPMENT.md | 46 ++++ package.json | 1 + packages/zcode-tui/src/ai-sdk-warnings.ts | 9 + packages/zcode-tui/src/dynamic-workflows.ts | 37 +++- packages/zcode-tui/src/entry.ts | 33 +++ packages/zcode-tui/src/foreground.ts | 195 +++++++++++++++++ packages/zcode-tui/src/fullscreen-header.ts | 2 +- packages/zcode-tui/src/index.ts | 227 +++++++------------- packages/zcode-tui/src/runtime-host.ts | 73 +++++++ packages/zcode-tui/src/runtime-rpc.ts | 225 +++++++++++++++++++ packages/zcode-tui/src/startup.ts | 169 +++++++++++++-- packages/zcode-tui/src/theme-styles.ts | 198 +++++++++++++++++ packages/zcode-tui/src/theme.ts | 192 +---------------- packages/zcode-tui/src/tui-screen.ts | 120 +++++++++++ packages/zcode-tui/src/types.ts | 4 + scripts/bench-startup.ts | 89 ++++++++ scripts/check-package.ts | 1 + scripts/check-runtime.ts | 1 + scripts/pack-release.ts | 1 + scripts/smoke-tui.ts | 3 +- src/launcher.ts | 118 +++++++--- test/release-contract.test.ts | 1 + test/runtime-rpc.test.ts | 138 ++++++++++++ test/tui/fixtures/foreground-runtime.mjs | 35 +++ test/tui/fixtures/foreground.mjs | 19 ++ test/tui/fixtures/startup.ts | 2 +- test/tui/fixtures/terminal-exit.ts | 2 +- test/tui/foreground.test.ts | 127 +++++++++++ test/tui/harness/terminal-session.ts | 6 +- test/tui/startup.test.ts | 2 +- tsdown.config.ts | 5 +- 31 files changed, 1675 insertions(+), 406 deletions(-) create mode 100644 packages/zcode-tui/src/ai-sdk-warnings.ts create mode 100644 packages/zcode-tui/src/entry.ts create mode 100644 packages/zcode-tui/src/foreground.ts create mode 100644 packages/zcode-tui/src/runtime-host.ts create mode 100644 packages/zcode-tui/src/runtime-rpc.ts create mode 100644 packages/zcode-tui/src/theme-styles.ts create mode 100644 packages/zcode-tui/src/tui-screen.ts create mode 100644 scripts/bench-startup.ts create mode 100644 test/runtime-rpc.test.ts create mode 100644 test/tui/fixtures/foreground-runtime.mjs create mode 100644 test/tui/fixtures/foreground.mjs create mode 100644 test/tui/foreground.test.ts diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6a29bab4..eebe6eec 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -72,6 +72,52 @@ Dependency security fixes and their regression commands are documented in [`patches/README.md`](../patches/README.md). Source installs must use Bun so the recorded `sprintf-js` patch is applied. +## Startup profiling + +After `bun run sync:local`, run `bun run bench:startup 5` to measure the built +launcher and official runtime in a real PTY. The report separates first visible +output, the loading state, and the ready model/mode status. It uses a temporary +home with isolated credentials and disables catalog refresh and update checks; +it does not submit prompts or consume inference quota. The first sample also +includes runtime data initialization; later samples reuse that temporary home. +These are process-start measurements, not OS disk-cache cold-start measurements. + +Interactive launches use a foreground TUI process and one runtime child process. +The lightweight `foreground.js` entry renders the real welcome frame, theme, and +composer before config bootstrap or runtime loading, so synchronous runtime +initialization cannot block typing or terminal rendering. A read-only appearance +lookup honors the saved regular/fullscreen mode and palette from the first frame; +it does not run settings bootstrap or migration. Syntax highlighting stays outside +this lightweight dependency graph. The ready UI adopts the existing terminal +instance rather than clearing, stopping, and restarting it. Startup input is a +draft: Enter never submits it until initialization completes, and the latest draft +is transferred at terminal handoff, including edits made while UI settings load. +Model access, slash commands, and resumed-session validation remain authoritative +in the runtime. Headless and app-server invocations keep their existing paths. + +The child loads the same local TUI adapter, but its IPC entry exposes the existing +`TuiOptions` callbacks rather than owning the terminal. This requires no additional +patch to the official runtime and does not assume the public app-server API covers +all interactive features. Versioned, inherited Node IPC carries requests, streaming +events, permission callbacks, abort signals, and collections. It opens no network +listener, passes no account credentials over IPC, and starts no daemon. +Workflow reductions are serialized asynchronously; session IDs are refreshed with +runtime messages. The frontend owns terminal modes, local shells, login suspension, +and clipboard escape output; ordinary backend stdout/stderr goes to diagnostics. + +Runtime death cancels pending calls and restores the terminal. Frontend shutdown +asks the child to finish and allows seven seconds for native cleanup before forced +termination (500 ms if the IPC host never became ready). A disconnected +child exits through the native TUI cleanup path. Source launches under Bun hand off +the foreground process to Node, avoiding incompatible Node/Bun IPC framing. + +The build produces multiple chunks, all of which must be copied with the TUI `dist` +folder. Both `index.js` and `foreground.js` are required release entry points. +`test/tui/foreground.test.ts` verifies independent typing during a blocked runtime, +draft preservation without accidental submission, saved appearance, uninterrupted +regular/fullscreen terminal handoff, cancellation, and backend death; +`test/runtime-rpc.test.ts` covers callback, permission-abort, and transport behavior. + ## pi-tui compatibility The root package and TUI adapter use pi-tui 1.3.0. Theme detection uses diff --git a/package.json b/package.json index 92f9f963..26e75b10 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "build": "tsdown", "build:launcher": "tsdown --filter launcher", "build:tui": "tsdown --filter tui", + "bench:startup": "bun scripts/bench-startup.ts", "bench:tui-memory": "bun --expose-gc scripts/bench-tui-memory.ts", "bench:tui-stream": "bun scripts/bench-tui-stream.ts", "bench:tui-thinking": "bun scripts/bench-tui-thinking.ts", diff --git a/packages/zcode-tui/src/ai-sdk-warnings.ts b/packages/zcode-tui/src/ai-sdk-warnings.ts new file mode 100644 index 00000000..39751392 --- /dev/null +++ b/packages/zcode-tui/src/ai-sdk-warnings.ts @@ -0,0 +1,9 @@ +export function suppressTuiAiSdkWarnings(): void { + const global = globalThis as typeof globalThis & { + AI_SDK_LOG_WARNINGS?: unknown; + }; + if (typeof global.AI_SDK_LOG_WARNINGS === "function") return; + try { + global.AI_SDK_LOG_WARNINGS = false; + } catch {} +} diff --git a/packages/zcode-tui/src/dynamic-workflows.ts b/packages/zcode-tui/src/dynamic-workflows.ts index 7586abe7..53479fac 100644 --- a/packages/zcode-tui/src/dynamic-workflows.ts +++ b/packages/zcode-tui/src/dynamic-workflows.ts @@ -9,6 +9,7 @@ export class DynamicWorkflows { private summaries: UnknownRecord[] = []; private epoch = 0; private loading?: Promise; + private pending: Promise = Promise.resolve(); private buffered: UnknownRecord[] = []; private watermarks = new Map(); private lastEvents = new Map(); @@ -22,6 +23,7 @@ export class DynamicWorkflows { this.state = undefined; this.summaries = []; this.loading = undefined; + this.pending = Promise.resolve(); this.buffered = []; this.watermarks.clear(); this.lastEvents.clear(); @@ -41,20 +43,23 @@ export class DynamicWorkflows { .map((run) => this.incomplete ? { ...run, progressIncomplete: true } : run); } - accept(event: unknown): boolean { - if (!isRecord(event) || typeof event.runId !== "string" || typeof event.eventType !== "string") return false; + accept(event: unknown): Promise { + if (!isRecord(event) || typeof event.runId !== "string" || typeof event.eventType !== "string") return Promise.resolve(false); if (this.loading) { if (this.buffered.length < maximumBufferedEvents) this.buffered.push(event); else { this.incomplete = true; this.error = "Some workflow progress was omitted. Live runs cannot be fully replayed in this process; use /dwf list for runtime status."; } - return false; + return Promise.resolve(false); } - return this.apply(event); + const epoch = this.epoch; + const operation = this.pending.then(() => epoch === this.epoch ? this.apply(event, epoch) : false); + this.pending = operation; + return operation; } - private apply(event: UnknownRecord): boolean { + private async apply(event: UnknownRecord, epoch: number): Promise { if (!this.adapter.reduceWorkflowRuns) return false; const id = String(event.runId); const sequence = typeof event.sequence === "number" ? event.sequence : undefined; @@ -63,7 +68,8 @@ export class DynamicWorkflows { if (sequence !== undefined && (sequence < watermark || sequence === watermark && this.lastEvents.get(id) === fingerprint)) return false; try { - const state = this.adapter.reduceWorkflowRuns(this.state, event); + const state = await this.adapter.reduceWorkflowRuns(this.state, event); + if (epoch !== this.epoch) return false; if (sequence !== undefined) this.watermarks.set(id, sequence); this.lastEvents.set(id, fingerprint); if (!state) return false; @@ -78,6 +84,7 @@ export class DynamicWorkflows { } return true; } catch (error) { + if (epoch !== this.epoch) return false; this.error = `Workflow progress could not be read: ${error instanceof Error ? error.message : String(error)}`; return false; } @@ -87,7 +94,7 @@ export class DynamicWorkflows { if (this.loading) return this.loading; const epoch = this.epoch; if (!this.incomplete) this.error = undefined; - const operation = Promise.resolve().then(async () => { + const operation = this.pending.then(async () => { // Read replay before summaries. Live events are buffered until replay is // applied, then sequence watermarks discard duplicate deliveries. const results = await Promise.allSettled([ @@ -97,21 +104,29 @@ export class DynamicWorkflows { if (epoch !== this.epoch) return; const [replay, summaries] = results; if (replay.status === "fulfilled" && Array.isArray(replay.value)) { - for (const event of replay.value) if (isRecord(event)) this.apply(event); + for (const event of replay.value) { + if (epoch !== this.epoch) return; + if (isRecord(event)) await this.apply(event, epoch); + } } + if (epoch !== this.epoch) return; if (summaries.status === "fulfilled" && Array.isArray(summaries.value)) { this.summaries = summaries.value.filter((value) => isRecord(value) && typeof value.runId === "string").slice(0, maximumRuns); } for (const result of results) if (result.status === "rejected") { this.error = `Workflow history could not be loaded: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}. Run /workflows to retry.`; } - }).finally(() => { + }).finally(async () => { + if (epoch !== this.epoch) return; + while (this.buffered.length && epoch === this.epoch) { + const event = this.buffered.shift()!; + await this.apply(event, epoch); + } if (epoch !== this.epoch) return; this.loading = undefined; - for (const event of this.buffered) this.apply(event); - this.buffered = []; }); this.loading = operation; + this.pending = operation; return operation; } } diff --git a/packages/zcode-tui/src/entry.ts b/packages/zcode-tui/src/entry.ts new file mode 100644 index 00000000..96f438af --- /dev/null +++ b/packages/zcode-tui/src/entry.ts @@ -0,0 +1,33 @@ +import { suppressTuiAiSdkWarnings } from "./ai-sdk-warnings.ts"; +import { loadTuiStartupOptions } from "./startup.ts"; +import type { TuiOptions } from "./types.ts"; + +suppressTuiAiSdkWarnings(); + +export async function runTui(options: TuiOptions): Promise { + if (process.env.ZCODE_CLI_TUI_IPC === "1" && process.send) { + await (await import("./runtime-host.ts")).runRuntimeTuiHost(options); + return; + } + const loadStartupOptions = options.loadStartupOptions; + if (!loadStartupOptions) { + await (await import("./index.ts")).runTui(options); + return; + } + + let implementation: typeof import("./index.ts") | undefined; + const readyOptions = await loadTuiStartupOptions({ + ...options, + loadStartupOptions: async () => { + const [startup, loaded] = await Promise.all([ + loadStartupOptions(), + import("./index.ts") + ]); + implementation = loaded; + return startup; + } + }); + if (readyOptions && implementation) { + await implementation.runTui({ ...readyOptions, loadStartupOptions: undefined }); + } +} diff --git a/packages/zcode-tui/src/foreground.ts b/packages/zcode-tui/src/foreground.ts new file mode 100644 index 00000000..76119fd5 --- /dev/null +++ b/packages/zcode-tui/src/foreground.ts @@ -0,0 +1,195 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { constants as osConstants } from "node:os"; + +import { suppressTuiAiSdkWarnings } from "./ai-sdk-warnings.ts"; +import { RuntimeRpc } from "./runtime-rpc.ts"; +import { loadTuiStartupOptions } from "./startup.ts"; +import { isRecord, type TuiOptions } from "./types.ts"; + +export interface ForegroundRuntimeTransport { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; +} + +export interface ForegroundTuiOptions { + version?: string; + workspaceDirectory?: string; + prepare: (signal: AbortSignal) => Promise; + onDiagnostic: (chunk: Buffer | string) => void; +} + +export async function runForegroundTui(options: ForegroundTuiOptions): Promise { + suppressTuiAiSdkWarnings(); + const lifecycle = new AbortController(); + let child: ChildProcess | undefined; + let peer: RuntimeRpc | undefined; + let exited: Promise | undefined; + let failure: Error | undefined; + let closing = false; + let signalCode: number | undefined; + let forceTermination: ReturnType | undefined; + let backendReady = false; + let completedNormally = false; + const inheritedEnvironment = new Map(); + const fail = (error: Error) => { + if (closing || lifecycle.signal.aborted) return; + failure = error; + lifecycle.abort(error); + }; + const onSignal = (signal: NodeJS.Signals) => { + signalCode = 128 + (osConstants.signals[signal] ?? 0); + lifecycle.abort(signal); + if (child && child.exitCode === null && child.signalCode === null) child.kill(signal); + }; + const onSigint = () => onSignal("SIGINT"); + const onSigterm = () => onSignal("SIGTERM"); + const onSighup = () => onSignal("SIGHUP"); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + if (process.platform !== "win32") process.on("SIGHUP", onSighup); + + let implementation: typeof import("./index.ts") | undefined; + let readyOptions: TuiOptions | undefined; + try { + readyOptions = await loadTuiStartupOptions({ + version: options.version, + workspaceDirectory: options.workspaceDirectory, + startupEditor: true, + runtimeSignal: lifecycle.signal, + submitPrompt: async () => { throw new Error("The runtime is not ready."); }, + loadStartupOptions: async () => { + const backend = async (): Promise => { + const transport = await options.prepare(lifecycle.signal); + lifecycle.signal.throwIfAborted(); + for (const name of ["ZCODE_APP_CLI_EXECUTABLE", "ZCODE_APP_CLI_ENTRY", "ZCODE_APP_CLI_VERSION"]) { + const value = transport.env[name]; + if (value !== undefined) { + inheritedEnvironment.set(name, process.env[name]); + process.env[name] = value; + } + } + child = spawn(transport.command, transport.args, { + cwd: process.cwd(), + env: { ...transport.env, ZCODE_CLI_TUI_IPC: "1" }, + stdio: ["ignore", "pipe", "pipe", "ipc"], + serialization: "advanced", + windowsHide: true + }); + child.stdout!.on("data", options.onDiagnostic); + child.stderr!.on("data", options.onDiagnostic); + exited = new Promise(resolve => { + child!.once("error", error => { fail(error); resolve(1); }); + child!.once("close", (code, signal) => { + const exitCode = code ?? (signal ? 128 + (osConstants.signals[signal] ?? 0) : 1); + peer?.close(new Error(`ZCode runtime exited with status ${exitCode}.`)); + fail(new Error(`ZCode runtime exited with status ${exitCode}.`)); + resolve(exitCode); + }); + }); + const runtimeChild = child; + peer = new RuntimeRpc(message => { + runtimeChild.send(message as object, error => { if (error) peer?.close(error); }); + }); + peer.onFailure = fail; + let mainSessionId: string | undefined; + const sessionListeners = new Set<(event: unknown) => void>(); + const workflowListeners = new Set<(event: unknown) => void>(); + const connection = new Promise((resolve, reject) => { + const onAbort = () => reject(failure ?? new Error("TUI startup cancelled.")); + lifecycle.signal.addEventListener("abort", onAbort, { once: true }); + peer!.onNotification = (name, value) => { + if (name === "ready") { + if (!isRecord(value) || !isRecord(value.fields) || !Array.isArray(value.methods) + || !value.methods.every(method => typeof method === "string" && !method.startsWith("$"))) { + reject(new Error("Invalid TUI runtime handshake.")); + return; + } + lifecycle.signal.removeEventListener("abort", onAbort); + backendReady = true; + const remote = { + ...value.fields, + runtimeSignal: lifecycle.signal, + startupEditor: true + } as unknown as TuiOptions; + for (const method of value.methods as string[]) { + Object.defineProperty(remote, method, { + value: (...args: unknown[]) => peer!.call(method, ...args), enumerable: true + }); + } + remote.getMainSessionId = () => mainSessionId; + remote.subscribeSessionEvents = listener => { + sessionListeners.add(listener); + return () => { sessionListeners.delete(listener); }; + }; + remote.subscribeWorkflowEvents = listener => { + workflowListeners.add(listener); + return () => { workflowListeners.delete(listener); }; + }; + resolve(remote); + } else if (name === "clipboard" && typeof value === "string" + && value.length <= 1_400_000 && /^\x1b\]52;c;[A-Za-z0-9+/=]*\x07$/u.test(value)) { + process.stdout.write(value); + } else { + const listeners = name === "session-event" ? sessionListeners + : name === "workflow-event" ? workflowListeners : undefined; + for (const listener of listeners ?? []) listener(value); + } + }; + }); + child.on("message", message => { + if (isRecord(message)) mainSessionId = typeof message.sessionId === "string" ? message.sessionId : undefined; + peer!.receive(message); + }); + child.once("disconnect", () => peer?.close(new Error("ZCode runtime disconnected."))); + const remote = await connection; + const startup = await remote.loadStartupOptions?.(); + return { ...remote, ...startup, loadStartupOptions: undefined }; + }; + const [remote, loaded] = await Promise.all([backend(), import("./index.ts")]); + implementation = loaded; + return remote; + } + }); + if (readyOptions && implementation && !lifecycle.signal.aborted) { + await implementation.runTui({ ...readyOptions, loadStartupOptions: undefined }); + } + if (failure) throw failure; + const exitCode = signalCode ?? (typeof process.exitCode === "number" ? process.exitCode : 0); + completedNormally = exitCode === 0; + return exitCode; + } finally { + closing = true; + await readyOptions?.startupScreen?.dispose(); + lifecycle.abort(); + let runtimeExitCode: number | undefined; + if (child) { + const runtimeChild = child; + forceTermination = setTimeout(() => { + runtimeChild.kill("SIGKILL"); + runtimeChild.stdout?.destroy(); + runtimeChild.stderr?.destroy(); + if (runtimeChild.connected) runtimeChild.disconnect(); + }, backendReady ? 7_000 : 500); + if (backendReady && peer && runtimeChild.connected && !signalCode) { + try { await peer.call("$finish"); } catch {} + } else if (runtimeChild.exitCode === null && runtimeChild.signalCode === null) runtimeChild.kill("SIGTERM"); + if (exited) runtimeExitCode = await exited; + runtimeChild.stdout?.destroy(); + runtimeChild.stderr?.destroy(); + if (runtimeChild.connected) runtimeChild.disconnect(); + } + if (forceTermination) clearTimeout(forceTermination); + peer?.close(); + for (const [name, value] of inheritedEnvironment) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + if (process.platform !== "win32") process.off("SIGHUP", onSighup); + if (completedNormally && runtimeExitCode !== undefined && runtimeExitCode !== 0) { + throw new Error(`ZCode runtime exited with status ${runtimeExitCode} during cleanup.`); + } + } +} diff --git a/packages/zcode-tui/src/fullscreen-header.ts b/packages/zcode-tui/src/fullscreen-header.ts index 357f9f6b..49f57919 100644 --- a/packages/zcode-tui/src/fullscreen-header.ts +++ b/packages/zcode-tui/src/fullscreen-header.ts @@ -7,7 +7,7 @@ import { } from "@earendil-works/pi-tui"; import { sanitizeTerminalText } from "./terminal-text.ts"; -import type { ZCodeTheme } from "./theme.ts"; +import type { ZCodeThemeStyles as ZCodeTheme } from "./theme-styles.ts"; export type FullscreenHeaderPhase = "welcome" | "transition" | "rail"; diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 02c522be..d1cf1257 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -4,6 +4,7 @@ import { constants as osConstants } from "node:os"; import { basename } from "node:path"; import { missingCodingPlanKey } from "../../../src/prompt-preflight.ts"; +import { suppressTuiAiSdkWarnings } from "./ai-sdk-warnings.ts"; import { loadTuiStartupOptions } from "./startup.ts"; import { preflightSubmission } from "./prompt-preflight.ts"; import { @@ -27,21 +28,15 @@ import { isViewportTUI, Markdown, matchesKey, - ProcessTerminal, ScrollView, Spacer, Text, - TuiAltScreen, - TuiMainScreen, VStack, type TUI, type TuiMode, type Component, - type OverlayHandle, - type OverlayOptions, type SlashCommand } from "@earendil-works/pi-tui"; -import { AltScreenSearchComponent } from "@earendil-works/pi-tui/dist/alt-screen-search.js"; import { attachmentSummary, @@ -239,7 +234,8 @@ import { import { createTheme, type ZCodeTheme } from "./theme.ts"; import { StatusLine, type StatusLineField } from "./status-line.ts"; import { SystemEventView, type SystemEventData } from "./system-event-view.ts"; -import { sanitizeTerminalText, stripCaretHighlight } from "./terminal-text.ts"; +import { sanitizeTerminalText } from "./terminal-text.ts"; +import { createTuiScreen, NotifyingProcessTerminal, ZCodeAltScreen } from "./tui-screen.ts"; import { ThinkingView } from "./thinking-view.ts"; import { ToolGroupView } from "./tool-group-view.ts"; import { ToolTreeView } from "./tool-tree-view.ts"; @@ -320,7 +316,6 @@ const runtimeCommandSummaries = new Map([ const terminalThemeQueryTimeoutMs = 100; const exitUsageQueryTimeoutMs = 250; const fullscreenWelcomeTransitionMs = 180; -const terminalFocusIn = "\x1b[I"; const updateAvailableBlockId = "update_available"; const modelStatusBlockIdPrefix = "model_status"; const questionBackValue = "__back__"; @@ -330,24 +325,6 @@ const backgroundTaskAttentionStatuses = new Set(["failed", "timed_out", "spawn_e const defaultBackgroundResumeMessage = "Continue the assigned task from the last completed step. Re-check the current workspace state and finish the remaining work."; -function isMouseMultiplexer(): boolean { - const term = process.env.TERM?.toLowerCase() ?? ""; - return process.env.TMUX !== undefined - || process.env.ZELLIJ !== undefined - || process.env.STY !== undefined - || term.startsWith("tmux") - || term.startsWith("screen"); -} - -function scrollbarHoverProbe(data: string): string | undefined { - if (!isMouseMultiplexer()) return undefined; - const match = /^\x1b\[<(\d+);(\d+);(\d+)M$/u.exec(data); - if (!match) return undefined; - const button = Number(match[1]); - if ((button & 32) !== 0 || (button & 3) !== 0) return undefined; - return `\x1b[<35;${match[2]};${match[3]}M`; -} - function backgroundTaskKindLabel(job: RuntimeBackgroundJob): string { switch (job.taskKind) { case "local_agent": return job.agentType ? `Agent (${job.agentType})` : "Agent"; @@ -516,106 +493,10 @@ class ConditionalContainer extends Container { } } -/** - * AI SDK emits its warning banner through console.info when no handler is - * installed. In the interactive TUI that stdout write is terminal content, - * so suppress only the default banner while preserving a runtime-provided - * structured warning handler. - */ -export function suppressTuiAiSdkWarnings(): void { - const global = globalThis as typeof globalThis & { - AI_SDK_LOG_WARNINGS?: unknown; - }; - if (typeof global.AI_SDK_LOG_WARNINGS === "function") return; - try { - global.AI_SDK_LOG_WARNINGS = false; - } catch { - // A host may expose a read-only global; warning suppression is optional. - } -} +export { suppressTuiAiSdkWarnings } from "./ai-sdk-warnings.ts"; -// The runtime loads this module before it initializes the interactive app. -// Install the TUI-safe default early enough to cover startup model discovery. suppressTuiAiSdkWarnings(); -/** - * Give the composer priority for page navigation and modified Home/End keys - * while editing. Bare Home/End are already editor keys in pi-tui 1.x. - */ -class ZCodeAltScreen extends TuiAltScreen { - private viewportInputDeferral?: (data: string) => boolean; - private currentInput?: string; - - setViewportInputDeferral(deferral: (data: string) => boolean): void { - this.viewportInputDeferral = deferral; - } - - setCurrentInput(data: string | undefined): void { - this.currentInput = data; - } - - override showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { - // pi-tui constructs its search Input internally. Decorate only that - // overlay, before compositing, so transcript matches and selections retain - // their inverse styles. The caret is also drawn when the search has lost - // keyboard focus to the editor and no longer emits a cursor marker. - if (component instanceof AltScreenSearchComponent) { - const render = component.render.bind(component); - component.render = (width) => { - const lines = render(width); - return this.getShowHardwareCursor() ? lines : lines.map(stripCaretHighlight); - }; - } - return super.showOverlay(component, options); - } - - /** - * The terminal can lose visible rows while this process is unfocused even - * though pi-tui's logical frame remains unchanged. Invalidate its - * differential cache so the next frame repaints every row. - */ - forceFullRedraw(): void { - // Resetting also clears the layout used by mouse events in this input batch. - process.nextTick(() => this.requestRender(true)); - } - - protected override isOverlayFocused(): boolean { - if (super.isOverlayFocused()) return true; - return this.viewportInputDeferral?.(this.currentInput ?? "") === true; - } -} - -/** Feed focus reports to the notifier before the TUI's own input listeners. */ -class NotifyingProcessTerminal extends ProcessTerminal { - constructor( - private readonly beforeInput: (data: string) => void, - private readonly afterInput?: () => void, - private readonly onFocusIn?: () => void, - private readonly onTerminalResize?: () => void - ) { - super(); - } - - override start(onInput: (data: string) => void, onResize: () => void): void { - super.start((data) => { - this.beforeInput(data); - try { - // Multiplexers use button-motion tracking and do not report passive - // pointer movement. Probe hover immediately before a press so a - // hidden auto scrollbar can still claim a direct drag gesture. - const hoverProbe = scrollbarHoverProbe(data); - if (hoverProbe) onInput(hoverProbe); - onInput(data); - } finally { - this.afterInput?.(); - if (data === terminalFocusIn) this.onFocusIn?.(); - } - }, () => { - onResize(); - this.onTerminalResize?.(); - }); - } -} function signalExitCode(signal: NodeJS.Signals): number { const number = (osConstants.signals as Record)[signal]; @@ -768,7 +649,9 @@ class ZCodeTui { process.env.ZCODE_APP_CLI_VERSION?.trim() ?? "", { preserveSgr: false } ) || undefined; - this.theme = createTheme(this.colorsEnabled, initialColorScheme(this.themePreference)); + this.theme = createTheme(this.colorsEnabled, + (this.themePreference === "auto" ? options.startupScreen?.colorScheme : undefined) + ?? initialColorScheme(this.themePreference)); this.transcript = new Transcript(this.theme.searchMatch); const workspace = options.workspaceDirectory ?? process.cwd(); const runtimeVersion = sanitizeTerminalText(options.version ?? "unknown", { preserveSgr: false }); @@ -792,8 +675,10 @@ class ZCodeTui { this.effortOptions = [...(options.effortOptions ?? [])]; this.loginRequired = options.loginRequired === true; this.skillCatalog = new SkillCatalog(options.listSkills); - this.tuiMode = resolveTuiMode(process.env, { ui: { tuiMode: options.initialTuiMode } }); - this.ui = this.createTui(this.tuiMode); + this.tuiMode = options.startupScreen?.mode + ?? resolveTuiMode(process.env, { ui: { tuiMode: options.initialTuiMode } }); + this.ui = options.startupScreen?.ui ?? createTuiScreen(this.tuiMode); + this.configureTui(this.ui); this.notifications = new TurnNotifier({ writeTerminal: (data) => this.ui.terminal.write(data), onFocusChange: (state) => { @@ -887,24 +772,49 @@ class ZCodeTui { const onSigint = () => this.handleSignal("SIGINT"); const onSigterm = () => this.handleSignal("SIGTERM"); const onSighup = () => this.handleSignal("SIGHUP"); + const onRuntimeAbort = () => this.stop(); this.installStreamErrorGuards(); process.on("SIGINT", onSigint); process.on("SIGTERM", onSigterm); if (process.platform !== "win32") process.on("SIGHUP", onSighup); + this.options.runtimeSignal?.addEventListener("abort", onRuntimeAbort, { once: true }); let startAttempted = false; + let removeStartupInput: (() => void) | undefined; try { + const startupScreen = this.options.startupScreen; + if (this.options.runtimeSignal?.aborted || startupScreen?.cancelled) this.stop(); if (this.stopped) { await this.done; return; } startAttempted = true; - this.ui.start(); + const reuseScreen = startupScreen?.ui === this.ui; + if (reuseScreen) { + this.editor.setText(startupScreen.editor?.getText() ?? ""); + this.editor.disableSubmit = true; + removeStartupInput = this.ui.addInputListener(data => { + if (!isKeyRelease(data) && matchesKey(data, "ctrl+c")) { + this.handleSignal("SIGINT"); + return { consume: true }; + } + return undefined; + }); + startupScreen.claim(); + } else { + const draft = startupScreen?.editor?.getText() ?? this.options.initialInputDraft; + await startupScreen?.dispose(); + if (draft) this.editor.setText(draft); + this.ui.start(); + } if (this.stopped) { await this.done; return; } - await this.resolveTerminalColorScheme(); + this.editor.disableSubmit = true; this.buildLayout(); + if (!startupScreen && this.options.initialInputDraft) this.editor.setText(this.options.initialInputDraft); + this.focusEditor(); + await this.resolveTerminalColorScheme(); if (notificationConfigError) { this.addNotice(`Unable to load notification settings: ${notificationConfigError}`, "warning"); } @@ -915,11 +825,14 @@ class ZCodeTui { this.addUpdateAvailable(this.distributionVersion, updateCheck.availableVersion); } this.bindInput(); + this.editor.disableSubmit = false; + removeStartupInput?.(); + removeStartupInput = undefined; this.notifications.start(); this.focusEditor(); this.updateMetadata(); this.updateTurnStatus(); - this.ui.requestRender(true); + this.ui.requestRender(!reuseScreen); this.startUpdateRefresh(updateCheck); if (!this.loginRequired) void this.refreshGoal(); if (!this.loginRequired) void this.refreshSessionUsage(); @@ -962,8 +875,10 @@ class ZCodeTui { process.off("SIGINT", onSigint); process.off("SIGTERM", onSigterm); if (process.platform !== "win32") process.off("SIGHUP", onSighup); + this.options.runtimeSignal?.removeEventListener("abort", onRuntimeAbort); this.removeStreamErrorGuards?.(); this.removeStreamErrorGuards = undefined; + removeStartupInput?.(); } } } @@ -1026,16 +941,22 @@ class ZCodeTui { } private createTui(mode: TuiMode): TUI { - let fullscreenTui: ZCodeAltScreen | undefined; - const terminal = new NotifyingProcessTerminal((data) => { - fullscreenTui?.setCurrentInput(data); - this.notifications?.handleInput(data); - }, () => fullscreenTui?.setCurrentInput(undefined), - () => fullscreenTui?.forceFullRedraw(), - () => fullscreenTui?.forceFullRedraw()); - if (mode !== "fullscreen") return new TuiMainScreen(terminal, true); - - const tui = new ZCodeAltScreen(terminal, true, undefined, { + const ui = createTuiScreen(mode); + this.configureTui(ui); + return ui; + } + + private configureTui(ui: TUI): void { + const fullscreenTui = ui instanceof ZCodeAltScreen ? ui : undefined; + const terminal = ui.terminal as NotifyingProcessTerminal; + terminal.handlers = { + beforeInput: (data) => { + fullscreenTui?.setCurrentInput(data); + this.notifications?.handleInput(data); + }, + afterInput: () => fullscreenTui?.setCurrentInput(undefined), + onFocusIn: () => fullscreenTui?.forceFullRedraw(), + onResize: () => fullscreenTui?.forceFullRedraw(), copySelection: async (text) => { const writeClipboardText = this.options.writeClipboardText; if (!writeClipboardText) return false; @@ -1045,17 +966,12 @@ class ZCodeTui { } catch { return false; } - }, - // pi-tui copies the selection on mouse release by default; ui.copyOnSelect - // opts out so a drag only highlights and copying stays manual (/copy, - // native terminal selection). - copyOnSelect: this.copyOnSelect, - mouse: true, - wheelScrollLines: 3 - }); - fullscreenTui = tui; - tui.setViewportInputDeferral((data) => { - const focused = tui.getFocusedComponent(); + } + }; + if (!fullscreenTui) return; + fullscreenTui.setCopyOnSelect(this.copyOnSelect); + fullscreenTui.setViewportInputDeferral((data) => { + const focused = fullscreenTui.getFocusedComponent(); if (focused === this.attachmentBar) { return matchesKey(data, "home") || matchesKey(data, "end"); } @@ -1065,7 +981,6 @@ class ZCodeTui { return matchesKey(data, "pageUp") || matchesKey(data, "pageDown") || matchesKey(data, "ctrl+pageUp") || matchesKey(data, "ctrl+pageDown"); }); - return tui; } private buildLayout(): void { @@ -2529,7 +2444,9 @@ class ZCodeTui { private handleDynamicWorkflowEvent(value: unknown, type: string | undefined): boolean { if (type !== "dynamic_workflow_run_progress" || !isRecord(value)) return false; - if (this.dynamicWorkflows.accept(value.payload)) this.renderDynamicWorkflow(); + void this.dynamicWorkflows.accept(value.payload).then(changed => { + if (changed) this.renderDynamicWorkflow(); + }); return true; } @@ -6160,5 +6077,9 @@ class ZCodeTui { export async function runTui(options: TuiOptions): Promise { suppressTuiAiSdkWarnings(); const readyOptions = await loadTuiStartupOptions(options); - if (readyOptions) await new ZCodeTui(readyOptions).run(); + try { + if (readyOptions) await new ZCodeTui(readyOptions).run(); + } finally { + await readyOptions?.startupScreen?.dispose(); + } } diff --git a/packages/zcode-tui/src/runtime-host.ts b/packages/zcode-tui/src/runtime-host.ts new file mode 100644 index 00000000..711ff50f --- /dev/null +++ b/packages/zcode-tui/src/runtime-host.ts @@ -0,0 +1,73 @@ +import { RuntimeRpc } from "./runtime-rpc.ts"; +import { createRuntimePluginReferenceLister } from "./plugin-references.ts"; +import type { TuiOptions } from "./types.ts"; + +const localFields = new Set(["stdin", "stdout", "stderr", "runtimeSignal", "initialInputDraft", "startupEditor", "startupScreen"]); +const specialMethods = new Set(["getMainSessionId", "subscribeSessionEvents", "subscribeWorkflowEvents"]); + +export async function runRuntimeTuiHost(options: TuiOptions): Promise { + if (!process.send) throw new Error("The TUI runtime host requires a local IPC connection."); + let finish!: () => void; + const done = new Promise(resolve => { finish = resolve; }); + const subscriptions: Array<() => void> = []; + const peer = new RuntimeRpc(message => { + process.send!({ ...message as object, sessionId: options.getMainSessionId?.() }, error => { + if (error) peer.close(error); + }); + }); + const onMessage = (message: unknown) => peer.receive(message); + const onDisconnect = () => peer.close(); + peer.onFailure = finish; + process.on("message", onMessage); + process.once("disconnect", onDisconnect); + const stdout = options.stdout; + const originalWrite = stdout?.write; + if (stdout && originalWrite) { + stdout.write = function (...args: unknown[]): boolean { + const text = typeof args[0] === "string" ? args[0] : undefined; + if (text && text.length <= 1_400_000 && /^\x1b\]52;c;[A-Za-z0-9+/=]*\x07$/u.test(text)) { + peer.notify("clipboard", text); + const callback = args.findLast(value => typeof value === "function"); + if (typeof callback === "function") queueMicrotask(() => callback()); + return true; + } + return Reflect.apply(originalWrite, stdout, args) as boolean; + } as typeof stdout.write; + } + try { + const fields: Record = {}; + const methods: string[] = []; + const runtimeOptions: TuiOptions = { + ...options, + listPluginReferences: options.listPluginReferences + ?? createRuntimePluginReferenceLister(options.workspaceDirectory ?? process.cwd()) + }; + for (const [name, value] of Object.entries(runtimeOptions)) { + if (localFields.has(name) || specialMethods.has(name)) continue; + if (typeof value === "function") { + methods.push(name); + peer.register(name, (...args) => value.apply(options, args)); + } else fields[name] = value; + } + peer.register("$finish", () => { setImmediate(finish); }); + for (const [name, subscribe] of [ + ["session-event", options.subscribeSessionEvents], + ["workflow-event", options.subscribeWorkflowEvents] + ] as const) { + const unsubscribe = subscribe?.(event => { + try { peer.notify(name, event); } + catch (error) { peer.close(error instanceof Error ? error : new Error("Runtime event delivery failed.")); } + }); + if (unsubscribe) subscriptions.push(unsubscribe); + } + peer.notify("ready", { fields, methods }); + await done; + } finally { + if (stdout && originalWrite) stdout.write = originalWrite; + for (const unsubscribe of subscriptions) unsubscribe(); + peer.close(); + process.off("message", onMessage); + process.off("disconnect", onDisconnect); + if (process.connected) process.disconnect?.(); + } +} diff --git a/packages/zcode-tui/src/runtime-rpc.ts b/packages/zcode-tui/src/runtime-rpc.ts new file mode 100644 index 00000000..02e2582e --- /dev/null +++ b/packages/zcode-tui/src/runtime-rpc.ts @@ -0,0 +1,225 @@ +import { isRecord } from "./types.ts"; + +const protocol = "zcode-tui/1"; +const maximumPendingCalls = 2_048; +const maximumValueNodes = 200_000; + +type Handler = (...args: unknown[]) => unknown; +type WireValue = unknown; + +interface Scope { + cleanup: Array<() => void>; + nodes: number; +} + +interface PendingCall { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + scope: Scope; +} + +export class RuntimeRpc { + private sequence = 0; + private readonly handlers = new Map(); + private readonly callbacks = new Map(); + private readonly pending = new Map(); + private readonly incoming = new Set(); + private readonly signals = new Map(); + private failure?: Error; + onNotification?: (name: string, value: unknown) => void; + onFailure?: (error: Error) => void; + + constructor(private readonly send: (message: unknown) => void) {} + + register(name: string, handler: Handler): void { + if (name.startsWith("@") || this.handlers.has(name)) throw new Error("Invalid runtime RPC method."); + this.handlers.set(name, handler); + } + + call(name: string, ...args: unknown[]): Promise { + if (this.failure) return Promise.reject(this.failure); + if (this.pending.size >= maximumPendingCalls) return Promise.reject(new Error("Too many pending runtime requests.")); + const id = ++this.sequence; + const scope: Scope = { cleanup: [], nodes: 0 }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject, scope }); + try { + this.write({ type: "call", id, name, value: this.encode(args, scope, true) }); + } catch (error) { + this.pending.delete(id); + this.release(scope); + reject(error); + } + }); + } + + notify(name: string, value: unknown): void { + if (this.failure) return; + this.write({ type: "notify", name, value: this.encode(value, { cleanup: [], nodes: 0 }, false) }); + } + + receive(message: unknown): void { + if (this.failure || !isRecord(message) || message.protocol !== protocol) return; + try { + if (message.type === "abort" && typeof message.id === "number") { + this.signals.get(message.id)?.abort(this.decode(message.reason, { cleanup: [], nodes: 0 })); + } else if (message.type === "notify" && typeof message.name === "string") { + this.onNotification?.(message.name, this.decode(message.value, { cleanup: [], nodes: 0 })); + } else if (message.type === "result" && typeof message.id === "number") { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + try { + const value = this.decode(message.value, pending.scope); + if (message.failed) pending.reject(value instanceof Error ? value : new Error("Runtime request failed.")); + else pending.resolve(value); + } catch (error) { + pending.reject(error instanceof Error ? error : new Error("Invalid runtime response.")); + throw error; + } finally { + this.release(pending.scope); + } + } else if (message.type === "call" && typeof message.id === "number" && typeof message.name === "string") { + void this.dispatch(message.id, message.name, message.value); + } else { + throw new Error("Invalid runtime RPC message."); + } + } catch (error) { + this.close(error instanceof Error ? error : new Error("Invalid runtime RPC message.")); + } + } + + close(error = new Error("Runtime connection closed.")): void { + if (this.failure) return; + this.failure = error; + for (const controller of this.signals.values()) controller.abort(error); + for (const pending of this.pending.values()) { + this.release(pending.scope); + pending.reject(error); + } + for (const scope of this.incoming) this.release(scope); + this.pending.clear(); + this.incoming.clear(); + this.signals.clear(); + this.callbacks.clear(); + this.handlers.clear(); + this.onFailure?.(error); + } + + private write(message: Record): void { + if (this.failure) throw this.failure; + this.send({ protocol, ...message }); + } + + private async dispatch(id: number, name: string, value: WireValue): Promise { + const scope: Scope = { cleanup: [], nodes: 0 }; + this.incoming.add(scope); + try { + const handler = name.startsWith("@") ? this.callbacks.get(name) : this.handlers.get(name); + if (!handler) throw new Error("Runtime method is unavailable."); + const args = this.decode(value, scope); + if (!Array.isArray(args)) throw new Error("Invalid runtime request arguments."); + const result = await handler(...args); + if (!this.failure) this.write({ type: "result", id, value: this.encode(result, scope, false) }); + } catch (error) { + if (!this.failure) { + try { + this.write({ type: "result", id, failed: true, value: this.encode( + error instanceof Error ? error : new Error(String(error)), { cleanup: [], nodes: 0 }, false + ) }); + } catch (failure) { + this.close(failure instanceof Error ? failure : new Error("Runtime response failed.")); + } + } + } finally { + this.incoming.delete(scope); + this.release(scope); + } + } + + private release(scope: Scope): void { + for (const cleanup of scope.cleanup.splice(0)) cleanup(); + } + + private checkValue(scope: Scope, depth: number): void { + if (++scope.nodes > maximumValueNodes || depth > 64) throw new Error("Runtime value is too large or deeply nested."); + } + + private encode(value: unknown, scope: Scope, references: boolean, depth = 0): WireValue { + this.checkValue(scope, depth); + if (typeof value === "function") { + if (!references) throw new Error("Runtime results must not contain functions."); + const name = `@${++this.sequence}`; + this.callbacks.set(name, value as Handler); + scope.cleanup.push(() => this.callbacks.delete(name)); + return { kind: "callback", name }; + } + if (value instanceof AbortSignal) { + if (!references) throw new Error("Runtime results must not contain abort signals."); + const id = ++this.sequence; + const reason = () => this.encode(value.reason, { cleanup: [], nodes: 0 }, false); + const onAbort = () => { + if (!this.failure) this.write({ type: "abort", id, reason: reason() }); + }; + value.addEventListener("abort", onAbort, { once: true }); + scope.cleanup.push(() => value.removeEventListener("abort", onAbort)); + return { kind: "signal", id, aborted: value.aborted, reason: value.aborted ? reason() : undefined }; + } + if (value instanceof Error) { + const fields = Object.fromEntries(Object.entries(value).filter(([, entry]) => typeof entry !== "function")); + return { kind: "error", name: value.name, message: value.message, fields: this.encode(fields, scope, false, depth + 1) }; + } + if (Array.isArray(value)) return { kind: "array", value: value.map(entry => this.encode(entry, scope, references, depth + 1)) }; + if (value instanceof Set) return { kind: "set", value: [...value].map(entry => this.encode(entry, scope, references, depth + 1)) }; + if (value instanceof Map) return { + kind: "map", value: [...value].map(([key, entry]) => [this.encode(key, scope, references, depth + 1), this.encode(entry, scope, references, depth + 1)]) + }; + if (value instanceof Date || value instanceof Uint8Array || value instanceof ArrayBuffer) return { kind: "native", value }; + if (isRecord(value)) return { + kind: "object", value: Object.entries(value).map(([key, entry]) => [key, this.encode(entry, scope, references, depth + 1)]) + }; + if (value === null || value === undefined || ["string", "number", "boolean", "bigint"].includes(typeof value)) { + return { kind: "scalar", value }; + } + throw new Error("Unsupported runtime value."); + } + + private decode(value: WireValue, scope: Scope, depth = 0): unknown { + this.checkValue(scope, depth); + if (!isRecord(value)) throw new Error("Invalid runtime value."); + if (value.kind === "scalar" || value.kind === "native") return value.value; + if (value.kind === "callback" && typeof value.name === "string") { + const name = value.name; + return (...args: unknown[]) => this.call(name, ...args); + } + if (value.kind === "signal" && typeof value.id === "number") { + const id = value.id; + const controller = new AbortController(); + this.signals.set(id, controller); + scope.cleanup.push(() => this.signals.delete(id)); + if (value.aborted) controller.abort(this.decode(value.reason, scope, depth + 1)); + return controller.signal; + } + if (value.kind === "error" && typeof value.message === "string") { + const error = new Error(value.message); + error.name = typeof value.name === "string" ? value.name : "Error"; + Object.assign(error, this.decode(value.fields, scope, depth + 1)); + return error; + } + if (!Array.isArray(value.value)) throw new Error("Invalid runtime collection."); + if (value.kind === "array" || value.kind === "set") { + const entries = value.value.map(entry => this.decode(entry, scope, depth + 1)); + return value.kind === "array" ? entries : new Set(entries); + } + if (value.kind === "object" || value.kind === "map") { + const entries = value.value.map((entry: unknown) => { + if (!Array.isArray(entry) || entry.length !== 2) throw new Error("Invalid runtime entry."); + const key = value.kind === "object" ? entry[0] : this.decode(entry[0], scope, depth + 1); + if (value.kind === "object" && typeof key !== "string") throw new Error("Invalid runtime object key."); + return [key, this.decode(entry[1], scope, depth + 1)] as const; + }); + return value.kind === "object" ? Object.fromEntries(entries) : new Map(entries); + } + throw new Error("Invalid runtime value kind."); + } +} diff --git a/packages/zcode-tui/src/startup.ts b/packages/zcode-tui/src/startup.ts index 807fc290..66167d72 100644 --- a/packages/zcode-tui/src/startup.ts +++ b/packages/zcode-tui/src/startup.ts @@ -1,10 +1,77 @@ +import { readFile } from "node:fs/promises"; import { constants as osConstants } from "node:os"; -import { isKeyRelease, matchesKey, ProcessTerminal, Text, TuiMainScreen } from "@earendil-works/pi-tui"; +import { + Container, + isKeyRelease, + isViewportTUI, + matchesKey, + Spacer, + Text, + VStack, + type TUI, + type TuiMode +} from "@earendil-works/pi-tui"; +import { cliSettingsPath } from "../../../src/config-paths.ts"; +import { initialColorScheme, queryTerminalColorScheme, themePreference, type ZCodeColorScheme } from "./color-scheme.ts"; +import { FullscreenHeader, SessionWelcome } from "./fullscreen-header.ts"; +import { PlanEditor } from "./plan-editor.ts"; +import { StatusLine } from "./status-line.ts"; import { watchStreamErrors } from "./stream-error-guard.ts"; import { stopTui } from "./terminal-cleanup.ts"; +import { createThemeStyles } from "./theme-styles.ts"; +import { createTuiScreen } from "./tui-screen.ts"; import type { TuiOptions } from "./types.ts"; +export class TuiStartupScreen { + cancelled = false; + colorScheme?: ZCodeColorScheme; + private claimed = false; + + constructor( + readonly ui: TUI, + readonly mode: TuiMode, + readonly editor: PlanEditor | undefined, + private readonly cleanup: () => void + ) {} + + claim(): void { + this.claimed = true; + this.cleanup(); + } + + async dispose(): Promise { + if (this.claimed) return; + this.claimed = true; + try { + try { + this.ui.clear(); + this.ui.renderNow(); + } finally { + await stopTui(this.ui); + } + } finally { + await new Promise((resolve) => setImmediate(resolve)); + this.cleanup(); + } + } +} + +async function startupAppearance(options: TuiOptions): Promise<{ mode: TuiMode; theme?: string }> { + let appearance: Record | undefined; + try { + appearance = JSON.parse(await readFile(cliSettingsPath(), "utf8"))?.ui; + } catch {} + const mode = process.env.ZCODE_TUI_MODE?.trim().toLowerCase(); + const configured = options.initialTuiMode ?? appearance?.tuiMode; + const normalized = typeof configured === "string" ? configured.trim().toLowerCase() : undefined; + return { + mode: (mode === "regular" || mode === "fullscreen" ? mode : normalized) === "fullscreen" + ? "fullscreen" : "regular", + theme: options.theme ?? (typeof appearance?.theme === "string" ? appearance.theme : undefined) + }; +} + export async function loadTuiStartupOptions(options: TuiOptions): Promise { const loadStartupOptions = options.loadStartupOptions; if (!loadStartupOptions) return options; @@ -12,10 +79,53 @@ export async function loadTuiStartupOptions(options: TuiOptions): Promise header.location(width), width => header.identity(width), { + includeIdentity: appearance.mode !== "fullscreen" + }); + const transcript = new Container(); + transcript.addChild(welcome); + transcript.addChild(new Spacer(1)); + const status = new StatusLine(); + status.setFields([ + { text: theme.muted(chinese ? "正在启动 ZCode…" : "Starting ZCode…"), priority: 100, required: true }, + { text: theme.muted(chinese ? "可以先输入,准备就绪后按 Enter 发送 · Ctrl+C 取消" + : "Type now · Enter to send when ready · Ctrl+C to cancel"), priority: 50 } + ], theme.muted(" ─ ")); + const composer = new Container(); + composer.addChild(editor!); + composer.addChild(status); + if (isViewportTUI(ui)) { + ui.setLayoutRoot(new VStack([ + { component: header, basis: 1, shrink: 0, minSize: 1 }, + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: composer, basis: "auto", shrink: 1, minSize: 1 } + ])); + } else { + ui.addChild(transcript); + ui.addChild(composer); + } + } else { + ui.addChild(new Text(chinese ? "正在启动 ZCode…" : "Starting ZCode…", 1, 0)); + ui.addChild(new Text(chinese ? "按 Ctrl+C 取消" : "Press Ctrl+C to cancel", 1, 0)); + } + if (editor) { + editor.disableSubmit = true; + ui.setFocus(editor); + } let cancelled = false; let resolveCancelled!: () => void; @@ -25,50 +135,63 @@ export async function loadTuiStartupOptions(options: TuiOptions): Promise { if (cancelled) return; cancelled = true; + screen.cancelled = true; process.exitCode = exitCode; resolveCancelled(); }; const onSigint = () => cancel(128 + osConstants.signals.SIGINT); const onSigterm = () => cancel(128 + osConstants.signals.SIGTERM); const onSighup = () => cancel(128 + osConstants.signals.SIGHUP); - ui.addInputListener((data) => { - if (!isKeyRelease(data) && matchesKey(data, "ctrl+c")) onSigint(); - return { consume: true }; + const onRuntimeAbort = () => cancel(1); + const removeInputListener = ui.addInputListener((data) => { + if (!isKeyRelease(data) && matchesKey(data, "ctrl+c")) { + onSigint(); + return { consume: true }; + } + return editor ? undefined : { consume: true }; }); const removeStreamErrorGuards = watchStreamErrors([process.stdout, process.stderr], () => cancel(1)); process.on("SIGINT", onSigint); process.on("SIGTERM", onSigterm); if (process.platform !== "win32") process.on("SIGHUP", onSighup); + options.runtimeSignal?.addEventListener("abort", onRuntimeAbort, { once: true }); + const screen = new TuiStartupScreen(ui, appearance.mode, editor, () => { + removeInputListener(); + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + if (process.platform !== "win32") process.off("SIGHUP", onSighup); + options.runtimeSignal?.removeEventListener("abort", onRuntimeAbort); + removeStreamErrorGuards(); + }); let readyOptions: TuiOptions | undefined; try { + if (options.runtimeSignal?.aborted) return undefined; ui.start(); ui.renderNow(); + if (preference === "auto" && !options.noColor && !process.env.NO_COLOR) { + void queryTerminalColorScheme(ui, 100).then(scheme => { + if (!scheme || cancelled) return; + screen.colorScheme = scheme; + theme.setColorScheme(scheme); + ui.requestRender(); + }).catch(() => {}); + } await new Promise((resolve) => setImmediate(resolve)); if (cancelled) return undefined; readyOptions = await Promise.race([ Promise.resolve().then(async () => { if (cancelled) return undefined; const startup = await loadStartupOptions(); - return cancelled ? undefined : { ...options, ...startup }; + return cancelled ? undefined : { ...options, theme: appearance.theme, ...startup }; }), cancellation ]); } finally { - try { - ui.clear(); - ui.renderNow(); - } finally { - try { - await stopTui(ui); - } finally { - await new Promise((resolve) => setImmediate(resolve)); - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - if (process.platform !== "win32") process.off("SIGHUP", onSighup); - removeStreamErrorGuards(); - } - } + if (cancelled || !readyOptions || !editor) await screen.dispose(); } - return cancelled ? undefined : readyOptions; + return cancelled || !readyOptions ? undefined : { + ...readyOptions, + ...(editor ? { startupScreen: screen } : {}) + }; } diff --git a/packages/zcode-tui/src/theme-styles.ts b/packages/zcode-tui/src/theme-styles.ts new file mode 100644 index 00000000..c5a8d36b --- /dev/null +++ b/packages/zcode-tui/src/theme-styles.ts @@ -0,0 +1,198 @@ +import type { + EditorTheme, + MarkdownTheme, + SelectListTheme +} from "@earendil-works/pi-tui"; + +import type { ZCodeColorScheme } from "./color-scheme.ts"; + +const RESET = "\x1b[0m"; + +function ansi(code: () => string, enabled: boolean): (text: string) => string { + if (!enabled) return (text) => text; + return (text) => { + const open = `\x1b[${code()}m`; + return `${open}${text.replaceAll(RESET, `${RESET}${open}`)}${RESET}`; + }; +} + +interface ThemePalette { + accent: string; + success: string; + warning: string; + error: string; + muted: string; + strong: string; + code: string; + quote: string; + toolPendingSurface: string; + toolErrorSurface: string; + scrollbarTrack: string; + scrollbarThumb: string; + diffAddedLine: string; + diffRemovedLine: string; + diffHunkLine: string; + diffAddedWord: string; + diffRemovedWord: string; +} + +const palettes: Record = { + dark: { + accent: "38;5;75", + success: "38;5;78", + warning: "38;5;221", + error: "38;5;203", + muted: "38;5;247", + strong: "1;38;5;252", + code: "38;5;117", + quote: "38;5;109", + toolPendingSurface: "38;5;252;48;5;236", + toolErrorSurface: "38;5;252;48;5;52", + scrollbarTrack: "38;5;238", + scrollbarThumb: "38;5;250", + diffAddedLine: "38;5;120;48;5;22", + diffRemovedLine: "38;5;210;48;5;52", + diffHunkLine: "38;5;159;48;5;24", + diffAddedWord: "1;38;5;231;48;5;28", + diffRemovedWord: "1;38;5;231;48;5;88" + }, + light: { + accent: "38;5;25", + success: "38;5;22", + warning: "38;5;94", + error: "38;5;160", + muted: "38;5;242", + strong: "1;38;5;236", + code: "38;5;25", + quote: "38;5;24", + toolPendingSurface: "38;5;58;48;5;230", + toolErrorSurface: "38;5;236;48;5;224", + scrollbarTrack: "38;5;250", + scrollbarThumb: "38;5;240", + diffAddedLine: "38;5;22;48;5;194", + diffRemovedLine: "38;5;88;48;5;224", + diffHunkLine: "38;5;24;48;5;189", + diffAddedWord: "1;38;5;22;48;5;157", + diffRemovedWord: "1;38;5;88;48;5;217" + } +}; + +type PaletteKey = keyof ThemePalette; + +function paletteStyle( + state: { colorScheme: ZCodeColorScheme }, + key: PaletteKey, + enabled: boolean +): (text: string) => string { + return ansi(() => palettes[state.colorScheme][key], enabled); +} + +export interface ZCodeThemeStyles { + accent: (text: string) => string; + success: (text: string) => string; + warning: (text: string) => string; + error: (text: string) => string; + muted: (text: string) => string; + bold: (text: string) => string; + toolPendingBackground: (text: string) => string; + toolErrorBackground: (text: string) => string; + scrollbarTrack: (text: string) => string; + scrollbarThumb: (text: string) => string; + diffAddedLine: (text: string) => string; + diffRemovedLine: (text: string) => string; + diffHunkLine: (text: string) => string; + diffAddedWord: (text: string) => string; + diffRemovedWord: (text: string) => string; + searchMatch: (text: string) => string; + editor: EditorTheme; + markdown: MarkdownTheme; + select: SelectListTheme; + setColorScheme: (colorScheme: ZCodeColorScheme) => void; +} + +export function createThemeStyles(enabled: boolean, initialColorScheme: ZCodeColorScheme = "dark"): ZCodeThemeStyles { + const state = { colorScheme: initialColorScheme }; + const accent = paletteStyle(state, "accent", enabled); + const success = paletteStyle(state, "success", enabled); + const warning = paletteStyle(state, "warning", enabled); + const error = paletteStyle(state, "error", enabled); + const muted = paletteStyle(state, "muted", enabled); + const bold = paletteStyle(state, "strong", enabled); + const italic = ansi(() => "3", enabled); + const underline = ansi(() => "4", enabled); + const strikethrough = ansi(() => "9", enabled); + const code = paletteStyle(state, "code", enabled); + const quote = paletteStyle(state, "quote", enabled); + const toolPendingBackground = paletteStyle(state, "toolPendingSurface", enabled); + const toolErrorBackground = paletteStyle(state, "toolErrorSurface", enabled); + const scrollbarTrack = paletteStyle(state, "scrollbarTrack", enabled); + const scrollbarThumb = paletteStyle(state, "scrollbarThumb", enabled); + const diffAddedLine = paletteStyle(state, "diffAddedLine", enabled); + const diffRemovedLine = paletteStyle(state, "diffRemovedLine", enabled); + const diffHunkLine = paletteStyle(state, "diffHunkLine", enabled); + const diffAddedWord = paletteStyle(state, "diffAddedWord", enabled); + const diffRemovedWord = paletteStyle(state, "diffRemovedWord", enabled); + const searchMatch = enabled + ? (text: string) => `\x1b[7m${text}\x1b[27m` + : (text: string) => `⟦${text}⟧`; + const accentBold = (text: string): string => bold(accent(text)); + const markdownHeading = (text: string): string => { + if (!enabled) return text; + // pi-tui applies bold before heading. Remove only bold's neutral foreground + // so the semantic heading color remains active while retaining SGR 1. + const strongOpen = `\x1b[${palettes[state.colorScheme].strong}m`; + return accent(text.replaceAll(strongOpen, "\x1b[1m")); + }; + + const select: SelectListTheme = { + selectedPrefix: accent, + selectedText: accentBold, + description: muted, + scrollInfo: muted, + noMatch: muted + }; + + return { + accent, + success, + warning, + error, + muted, + bold, + toolPendingBackground, + toolErrorBackground, + scrollbarTrack, + scrollbarThumb, + diffAddedLine, + diffRemovedLine, + diffHunkLine, + diffAddedWord, + diffRemovedWord, + searchMatch, + setColorScheme: (colorScheme) => { + if (state.colorScheme === colorScheme) return; + state.colorScheme = colorScheme; + }, + select, + editor: { + borderColor: accent, + selectList: select + }, + markdown: { + heading: markdownHeading, + link: underline, + linkUrl: muted, + code, + codeBlock: code, + codeBlockBorder: muted, + quote, + quoteBorder: muted, + hr: muted, + listBullet: accent, + bold, + italic, + strikethrough, + underline + } + }; +} diff --git a/packages/zcode-tui/src/theme.ts b/packages/zcode-tui/src/theme.ts index 98b39fd3..a6d75b1c 100644 --- a/packages/zcode-tui/src/theme.ts +++ b/packages/zcode-tui/src/theme.ts @@ -1,203 +1,23 @@ -import type { - EditorTheme, - MarkdownTheme, - SelectListTheme -} from "@earendil-works/pi-tui"; - import { CodeHighlighter } from "./code-highlighter.ts"; import type { ZCodeColorScheme } from "./color-scheme.ts"; +import { createThemeStyles, type ZCodeThemeStyles } from "./theme-styles.ts"; -const RESET = "\x1b[0m"; - -function ansi(code: () => string, enabled: boolean): (text: string) => string { - if (!enabled) return (text) => text; - return (text) => { - const open = `\x1b[${code()}m`; - return `${open}${text.replaceAll(RESET, `${RESET}${open}`)}${RESET}`; - }; -} - -interface ThemePalette { - accent: string; - success: string; - warning: string; - error: string; - muted: string; - strong: string; - code: string; - quote: string; - toolPendingSurface: string; - toolErrorSurface: string; - scrollbarTrack: string; - scrollbarThumb: string; - diffAddedLine: string; - diffRemovedLine: string; - diffHunkLine: string; - diffAddedWord: string; - diffRemovedWord: string; -} - -const palettes: Record = { - dark: { - accent: "38;5;75", - success: "38;5;78", - warning: "38;5;221", - error: "38;5;203", - muted: "38;5;247", - strong: "1;38;5;252", - code: "38;5;117", - quote: "38;5;109", - toolPendingSurface: "38;5;252;48;5;236", - toolErrorSurface: "38;5;252;48;5;52", - scrollbarTrack: "38;5;238", - scrollbarThumb: "38;5;250", - diffAddedLine: "38;5;120;48;5;22", - diffRemovedLine: "38;5;210;48;5;52", - diffHunkLine: "38;5;159;48;5;24", - diffAddedWord: "1;38;5;231;48;5;28", - diffRemovedWord: "1;38;5;231;48;5;88" - }, - light: { - accent: "38;5;25", - success: "38;5;22", - warning: "38;5;94", - error: "38;5;160", - muted: "38;5;242", - strong: "1;38;5;236", - code: "38;5;25", - quote: "38;5;24", - toolPendingSurface: "38;5;58;48;5;230", - toolErrorSurface: "38;5;236;48;5;224", - scrollbarTrack: "38;5;250", - scrollbarThumb: "38;5;240", - diffAddedLine: "38;5;22;48;5;194", - diffRemovedLine: "38;5;88;48;5;224", - diffHunkLine: "38;5;24;48;5;189", - diffAddedWord: "1;38;5;22;48;5;157", - diffRemovedWord: "1;38;5;88;48;5;217" - } -}; - -type PaletteKey = keyof ThemePalette; - -function paletteStyle( - state: { colorScheme: ZCodeColorScheme }, - key: PaletteKey, - enabled: boolean -): (text: string) => string { - return ansi(() => palettes[state.colorScheme][key], enabled); -} - -export interface ZCodeTheme { - accent: (text: string) => string; - success: (text: string) => string; - warning: (text: string) => string; - error: (text: string) => string; - muted: (text: string) => string; - bold: (text: string) => string; - toolPendingBackground: (text: string) => string; - toolErrorBackground: (text: string) => string; - scrollbarTrack: (text: string) => string; - scrollbarThumb: (text: string) => string; - diffAddedLine: (text: string) => string; - diffRemovedLine: (text: string) => string; - diffHunkLine: (text: string) => string; - diffAddedWord: (text: string) => string; - diffRemovedWord: (text: string) => string; - searchMatch: (text: string) => string; +export interface ZCodeTheme extends ZCodeThemeStyles { codeHighlighter: CodeHighlighter; - editor: EditorTheme; - markdown: MarkdownTheme; - select: SelectListTheme; - setColorScheme: (colorScheme: ZCodeColorScheme) => void; } export function createTheme(enabled: boolean, initialColorScheme: ZCodeColorScheme = "dark"): ZCodeTheme { - const state = { colorScheme: initialColorScheme }; + const styles = createThemeStyles(enabled, initialColorScheme); const codeHighlighter = new CodeHighlighter(enabled, initialColorScheme); - const accent = paletteStyle(state, "accent", enabled); - const success = paletteStyle(state, "success", enabled); - const warning = paletteStyle(state, "warning", enabled); - const error = paletteStyle(state, "error", enabled); - const muted = paletteStyle(state, "muted", enabled); - const bold = paletteStyle(state, "strong", enabled); - const italic = ansi(() => "3", enabled); - const underline = ansi(() => "4", enabled); - const strikethrough = ansi(() => "9", enabled); - const code = paletteStyle(state, "code", enabled); - const quote = paletteStyle(state, "quote", enabled); - const toolPendingBackground = paletteStyle(state, "toolPendingSurface", enabled); - const toolErrorBackground = paletteStyle(state, "toolErrorSurface", enabled); - const scrollbarTrack = paletteStyle(state, "scrollbarTrack", enabled); - const scrollbarThumb = paletteStyle(state, "scrollbarThumb", enabled); - const diffAddedLine = paletteStyle(state, "diffAddedLine", enabled); - const diffRemovedLine = paletteStyle(state, "diffRemovedLine", enabled); - const diffHunkLine = paletteStyle(state, "diffHunkLine", enabled); - const diffAddedWord = paletteStyle(state, "diffAddedWord", enabled); - const diffRemovedWord = paletteStyle(state, "diffRemovedWord", enabled); - const searchMatch = enabled - ? (text: string) => `\x1b[7m${text}\x1b[27m` - : (text: string) => `⟦${text}⟧`; - const accentBold = (text: string): string => bold(accent(text)); - const markdownHeading = (text: string): string => { - if (!enabled) return text; - // pi-tui applies bold before heading. Remove only bold's neutral foreground - // so the semantic heading color remains active while retaining SGR 1. - const strongOpen = `\x1b[${palettes[state.colorScheme].strong}m`; - return accent(text.replaceAll(strongOpen, "\x1b[1m")); - }; - - const select: SelectListTheme = { - selectedPrefix: accent, - selectedText: accentBold, - description: muted, - scrollInfo: muted, - noMatch: muted - }; - return { - accent, - success, - warning, - error, - muted, - bold, - toolPendingBackground, - toolErrorBackground, - scrollbarTrack, - scrollbarThumb, - diffAddedLine, - diffRemovedLine, - diffHunkLine, - diffAddedWord, - diffRemovedWord, - searchMatch, + ...styles, codeHighlighter, setColorScheme: (colorScheme) => { - if (state.colorScheme === colorScheme) return; - state.colorScheme = colorScheme; + styles.setColorScheme(colorScheme); codeHighlighter.setColorScheme(colorScheme); }, - select, - editor: { - borderColor: accent, - selectList: select - }, markdown: { - heading: markdownHeading, - link: underline, - linkUrl: muted, - code, - codeBlock: code, - codeBlockBorder: muted, - quote, - quoteBorder: muted, - hr: muted, - listBullet: accent, - bold, - italic, - strikethrough, - underline, + ...styles.markdown, highlightCode: (source, language) => codeHighlighter.highlight(source, language) } }; diff --git a/packages/zcode-tui/src/tui-screen.ts b/packages/zcode-tui/src/tui-screen.ts new file mode 100644 index 00000000..dde22ab4 --- /dev/null +++ b/packages/zcode-tui/src/tui-screen.ts @@ -0,0 +1,120 @@ +import { + ProcessTerminal, + TuiAltScreen, + TuiMainScreen, + type Component, + type OverlayHandle, + type OverlayOptions, + type TUI, + type TuiMode +} from "@earendil-works/pi-tui"; +import { AltScreenSearchComponent } from "@earendil-works/pi-tui/dist/alt-screen-search.js"; + +import { stripCaretHighlight } from "./terminal-text.ts"; + +export interface TerminalInputHandlers { + beforeInput?: (data: string) => void; + afterInput?: () => void; + onFocusIn?: () => void; + onResize?: () => void; + copySelection?: (text: string) => Promise; +} + +const terminalFocusIn = "\x1b[I"; + +function isMouseMultiplexer(): boolean { + const term = process.env.TERM?.toLowerCase() ?? ""; + return process.env.TMUX !== undefined + || process.env.ZELLIJ !== undefined + || process.env.STY !== undefined + || term.startsWith("tmux") + || term.startsWith("screen"); +} + +function scrollbarHoverProbe(data: string): string | undefined { + if (!isMouseMultiplexer()) return undefined; + const match = /^\x1b\[<(\d+);(\d+);(\d+)M$/u.exec(data); + if (!match) return undefined; + const button = Number(match[1]); + if ((button & 32) !== 0 || (button & 3) !== 0) return undefined; + return `\x1b[<35;${match[2]};${match[3]}M`; +} + +export class ZCodeAltScreen extends TuiAltScreen { + private viewportInputDeferral?: (data: string) => boolean; + private currentInput?: string; + + setViewportInputDeferral(deferral: (data: string) => boolean): void { + this.viewportInputDeferral = deferral; + } + + setCurrentInput(data: string | undefined): void { + this.currentInput = data; + } + + override showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { + // pi-tui constructs its search Input internally. Decorate only that + // overlay, before compositing, so transcript matches and selections retain + // their inverse styles. The caret is also drawn when the search has lost + // keyboard focus to the editor and no longer emits a cursor marker. + if (component instanceof AltScreenSearchComponent) { + const render = component.render.bind(component); + component.render = (width) => { + const lines = render(width); + return this.getShowHardwareCursor() ? lines : lines.map(stripCaretHighlight); + }; + } + return super.showOverlay(component, options); + } + + /** + * The terminal can lose visible rows while this process is unfocused even + * though pi-tui's logical frame remains unchanged. Invalidate its + * differential cache so the next frame repaints every row. + */ + forceFullRedraw(): void { + // Resetting also clears the layout used by mouse events in this input batch. + process.nextTick(() => this.requestRender(true)); + } + + protected override isOverlayFocused(): boolean { + if (super.isOverlayFocused()) return true; + return this.viewportInputDeferral?.(this.currentInput ?? "") === true; + } +} + +/** Feed focus reports to the notifier before the TUI's own input listeners. */ +export class NotifyingProcessTerminal extends ProcessTerminal { + handlers: TerminalInputHandlers = {}; + + override start(onInput: (data: string) => void, onResize: () => void): void { + super.start((data) => { + this.handlers.beforeInput?.(data); + try { + // Multiplexers use button-motion tracking and do not report passive + // pointer movement. Probe hover immediately before a press so a + // hidden auto scrollbar can still claim a direct drag gesture. + const hoverProbe = scrollbarHoverProbe(data); + if (hoverProbe) onInput(hoverProbe); + onInput(data); + } finally { + this.handlers.afterInput?.(); + if (data === terminalFocusIn) this.handlers.onFocusIn?.(); + } + }, () => { + onResize(); + this.handlers.onResize?.(); + }); + } +} + +export function createTuiScreen(mode: TuiMode): TUI { + const terminal = new NotifyingProcessTerminal(); + if (mode !== "fullscreen") return new TuiMainScreen(terminal, true); + return new ZCodeAltScreen(terminal, true, undefined, { + copySelection: (text) => terminal.handlers.copySelection?.(text) ?? Promise.resolve(false), + copyOnSelect: true, + mouse: true, + wheelScrollLines: 3 + }); +} diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index 1b09e269..8c5a00ac 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -130,6 +130,10 @@ export interface RuntimeAdapter { export interface TuiOptions extends RuntimeAdapter { loadStartupOptions?: () => Promise; + startupEditor?: boolean; + startupScreen?: import("./startup.ts").TuiStartupScreen; + initialInputDraft?: string; + runtimeSignal?: AbortSignal; initialMode?: string; initialPlanEnabled?: boolean; initialModel?: unknown; diff --git a/scripts/bench-startup.ts b/scripts/bench-startup.ts new file mode 100644 index 00000000..4beaf7d4 --- /dev/null +++ b/scripts/bench-startup.ts @@ -0,0 +1,89 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { ensureCliSettings } from "../src/model-access.ts"; +import { runtimeTestEnv } from "./runtime-test-env.ts"; +import { terminalOutputText } from "./terminal-output.ts"; + +interface StartupSample { + firstPaintMs?: number; + loadingMs?: number; + readyMs?: number; +} + +const root = join(import.meta.dir, ".."); +const node = process.env.ZCODE_NODE || Bun.which("node"); +if (!node) throw new Error("Node.js is required to benchmark runtime startup."); +const runs = Number(process.argv[2] ?? 5); +if (!Number.isInteger(runs) || runs < 1 || runs > 50) { + throw new Error("Usage: bun scripts/bench-startup.ts [runs: 1..50]"); +} +const home = await mkdtemp(join(tmpdir(), "zcode-startup-bench-")); +const env = { ...runtimeTestEnv(home, root), TERM: "xterm-256color" }; +const samples: StartupSample[] = []; + +try { + await ensureCliSettings(env); + for (let run = 0; run < runs; run++) { + const sample: StartupSample = {}; + const started = performance.now(); + const decoder = new TextDecoder(); + let output = ""; + const terminal = new Bun.Terminal({ + cols: 100, + rows: 32, + data(_terminal, data) { + output += decoder.decode(data, { stream: true }); + const text = terminalOutputText(output); + const elapsed = Math.round(performance.now() - started); + if (text.trim()) sample.firstPaintMs ??= elapsed; + if (/Starting ZCode|正在启动 ZCode/u.test(text)) sample.loadingMs ??= elapsed; + if (/◈ .*◉ /u.test(text)) sample.readyMs ??= elapsed; + } + }); + const child = Bun.spawn([node, join(root, "bin", "zcode.js")], { + cwd: root, + env, + terminal + }); + try { + const deadline = performance.now() + 20_000; + while (sample.readyMs === undefined && child.exitCode === null && performance.now() < deadline) { + await Bun.sleep(10); + } + if (sample.readyMs === undefined) { + throw new Error(`TUI did not become ready.\n${terminalOutputText(output).slice(-2_000)}`); + } + samples.push(sample); + } finally { + child.kill("SIGTERM"); + const timeout = setTimeout(() => child.kill("SIGKILL"), 5_000); + try { + await child.exited; + } finally { + clearTimeout(timeout); + terminal.close(); + } + } + } + const median = (key: keyof StartupSample) => { + const values = samples.map(sample => sample[key]).filter((value): value is number => value !== undefined); + values.sort((left, right) => left - right); + const middle = Math.floor(values.length / 2); + return values.length === 0 ? undefined : values.length % 2 + ? values[middle] : (values[middle - 1]! + values[middle]!) / 2; + }; + console.log(JSON.stringify({ + node: Bun.spawnSync([node, "--version"]).stdout.toString().trim(), + runs, + samples, + median: { + firstPaintMs: median("firstPaintMs"), + loadingMs: median("loadingMs"), + readyMs: median("readyMs") + } + }, null, 2)); +} finally { + await rm(home, { recursive: true, force: true }); +} diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 89a31f5e..1d048777 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -95,6 +95,7 @@ export async function validatePackageTree(base = root): Promise { "tsdown.config.ts", "vendor/extraction.json", "vendor/node_modules/@zcode/tui/dist/index.js", + "vendor/node_modules/@zcode/tui/dist/foreground.js", "vendor/node_modules/@zcode/tui/package.json", "vendor/zcode.cjs", "vendor/cli-config.cjs", diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 50057f77..c156247c 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -53,6 +53,7 @@ const require = createRequire(import.meta.url); if (!existsSync(runtime)) throw new Error("vendor/zcode.cjs is missing; run `bun run sync` first."); if (!existsSync(tui)) throw new Error("The local @zcode/tui adapter is missing; run `bun run sync` first."); +if (!existsSync(join(dirname(tui), "foreground.js"))) throw new Error("The foreground TUI entry is missing; run `bun run sync` first."); if (!node) throw new Error("Node.js >=22.19 is required by the official ZCode runtime."); const packageManifest = await Bun.file(join(root, "package.json")).json() as { diff --git a/scripts/pack-release.ts b/scripts/pack-release.ts index c11be33c..a7678977 100644 --- a/scripts/pack-release.ts +++ b/scripts/pack-release.ts @@ -48,6 +48,7 @@ const requiredFiles = [ "package.json", "vendor/extraction.json", "vendor/node_modules/@zcode/tui/dist/index.js", + "vendor/node_modules/@zcode/tui/dist/foreground.js", "vendor/node_modules/@zcode/tui/package.json", "vendor/zcode.cjs", "vendor/cli-config.cjs", diff --git a/scripts/smoke-tui.ts b/scripts/smoke-tui.ts index 88fa1d14..0e55e659 100755 --- a/scripts/smoke-tui.ts +++ b/scripts/smoke-tui.ts @@ -164,8 +164,9 @@ try { if (!plainText(output).includes("╭─ ◆ ZCODE")) { throw new Error("Regular TUI did not render the framed session header."); } + await waitFor("ready runtime status", /◈ .*◉ /u, 0, runtimeStartupTimeoutMs); if (!await Bun.file(configPath).exists()) { - throw new Error("The launcher did not create setting.json before starting the TUI."); + throw new Error("The launcher did not create setting.json before the runtime became ready."); } const initialConfig = await Bun.file(configPath).json() as { model?: { main?: string }; diff --git a/src/launcher.ts b/src/launcher.ts index c8b05861..0b30a931 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -11,7 +11,7 @@ import { } from "node:fs"; import { constants as osConstants, homedir } from "node:os"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { clearSetupPending, @@ -291,6 +291,7 @@ function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}, inspectConfiguration delete env.ZCODE_CLI_MIGRATE_CONFIG; delete env.ZCODE_CLI_PROMPT_MODEL; delete env.ZCODE_CLI_PROMPT_EFFORT; + delete env.ZCODE_CLI_TUI_IPC; const distributionVersion = readDistributionVersion(); const appVersion = readJsonVersion(extractionMetadataPath, "appVersion"); const inherited: NodeJS.ProcessEnv = { @@ -469,6 +470,44 @@ async function completeOfficialZaiLogin( } } +async function prepareCliSettings(): Promise { + const bootstrap = await ensureCliSettings(); + await readCliSettings(); + sharedDataBaseDir(); + if (bootstrap.created && !bootstrap.migrated) { + await markSetupPending(); + return true; + } + return await readSetupPending(); +} + +async function migrateProviderConfiguration(node: string, signal?: AbortSignal): Promise { + if (!providerMigrationNeeded()) return; + signal?.throwIfAborted(); + await new Promise((resolve, reject) => { + const child = spawnChild(node, [runtimePath, "--version"], { + env: runtimeEnvironment({ ZCODE_CLI_MIGRATE_CONFIG: "1" }), + stdio: ["ignore", "ignore", "pipe"] + }); + let error = ""; + let timeout: ReturnType | undefined; + const onAbort = () => { + child.kill("SIGTERM"); + timeout = setTimeout(() => child.kill("SIGKILL"), 500); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + child.stderr?.on("data", chunk => { error = `${error}${chunk}`.slice(-4096); }); + child.once("error", reject); + child.once("close", code => { + if (timeout) clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + if (signal?.aborted) reject(signal.reason); + else if (code === 0) resolve(); + else reject(new Error(error.trim() || "Provider migration failed.")); + }); + }); +} + export async function main(args: string[]): Promise { if (!existsSync(runtimePath)) { console.error( @@ -500,19 +539,57 @@ export async function main(args: string[]): Promise { return 1; } + if (isTuiRuntimeInvocation(args) && process.stdin.isTTY && process.stdout.isTTY) { + if (process.versions.bun) { + const node = process.env.ZCODE_NODE?.trim() || "node"; + const child = spawnChild(node, [launcherPath, ...args], { stdio: "inherit", env: process.env }); + const onSigint = () => { child.kill("SIGINT"); }; + const onSigterm = () => { child.kill("SIGTERM"); }; + const onSighup = () => { child.kill("SIGHUP"); }; + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + if (process.platform !== "win32") process.on("SIGHUP", onSighup); + try { return await waitForChild(child); } + finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + if (process.platform !== "win32") process.off("SIGHUP", onSighup); + } + } + const diagnosticState: TuiRuntimeDiagnosticState = { bytes: 0, initialized: false, writeFailed: false }; + try { + const entry = join(packageRoot, "vendor", "node_modules", "@zcode", "tui", "dist", "foreground.js"); + const foreground = await import(pathToFileURL(entry).href) as typeof import("../packages/zcode-tui/src/foreground.ts"); + return await foreground.runForegroundTui({ + version: readDistributionVersion(), + workspaceDirectory: inspectRuntimeInvocation(args, readRuntimeCliOptionTypes()).workingDirectory ?? process.cwd(), + onDiagnostic: chunk => appendTuiRuntimeDiagnostic(chunk, diagnosticState), + prepare: async signal => { + const setupPending = await prepareCliSettings(); + signal.throwIfAborted(); + const node = resolveNodeExecutable(); + await migrateProviderConfiguration(node, signal); + signal.throwIfAborted(); + const runtimeArgs = withDefaultBrowserUse(args); + return { + command: node, + args: [runtimePath, ...runtimeArgs], + env: runtimeEnvironment(firstRunSetupEnv(setupPending, runtimeArgs)) + }; + } + }); + } catch (error) { + const message = diagnosticState.sessionError ?? (error instanceof Error ? error.message : String(error)); + console.error(`Error: ${message}`); + return 1; + } + } + let setupPending = false; try { // doctor 必须能检查损坏的配置,不能先被普通启动校验或 provider 迁移拦住。 if (isDoctorRuntimeInvocation(args)) return await runRuntime(resolveNodeExecutable(), args); - const bootstrap = await ensureCliSettings(); - await readCliSettings(); - sharedDataBaseDir(); - if (bootstrap.created && !bootstrap.migrated) { - await markSetupPending(); - setupPending = true; - } else { - setupPending = await readSetupPending(); - } + setupPending = await prepareCliSettings(); } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); console.error("Run `zcode doctor --json` for configuration diagnostics."); @@ -520,22 +597,11 @@ export async function main(args: string[]): Promise { } const node = resolveNodeExecutable(); - if (providerMigrationNeeded()) { - try { - await new Promise((resolve, reject) => { - const child = spawnChild(node, [runtimePath, "--version"], { - env: runtimeEnvironment({ ZCODE_CLI_MIGRATE_CONFIG: "1" }), - stdio: ["ignore", "ignore", "pipe"] - }); - let error = ""; - child.stderr?.on("data", chunk => { error = `${error}${chunk}`.slice(-4096); }); - child.once("error", reject); - child.once("close", code => code === 0 ? resolve() : reject(new Error(error.trim() || "Provider migration failed."))); - }); - } catch (error) { - console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); - return 1; - } + try { + await migrateProviderConfiguration(node); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + return 1; } const pluginAbortController = new AbortController(); const cancelPluginCommand = (signal: NodeJS.Signals) => () => pluginAbortController.abort(signal); diff --git a/test/release-contract.test.ts b/test/release-contract.test.ts index fffba9b5..6c001c50 100644 --- a/test/release-contract.test.ts +++ b/test/release-contract.test.ts @@ -32,6 +32,7 @@ test("the tarball contract accepts published documentation and rejects missing o "package.json", "vendor/extraction.json", "vendor/node_modules/@zcode/tui/dist/index.js", + "vendor/node_modules/@zcode/tui/dist/foreground.js", "vendor/node_modules/@zcode/tui/package.json", "vendor/zcode.cjs", "vendor/cli-config.cjs", diff --git a/test/runtime-rpc.test.ts b/test/runtime-rpc.test.ts new file mode 100644 index 00000000..4c5ea7fd --- /dev/null +++ b/test/runtime-rpc.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test"; + +import { RuntimeRpc } from "../packages/zcode-tui/src/runtime-rpc.ts"; +import { DynamicWorkflows } from "../packages/zcode-tui/src/dynamic-workflows.ts"; + +function connection() { + const frontend = new RuntimeRpc(message => queueMicrotask(() => backend.receive(structuredClone(message)))); + const backend = new RuntimeRpc(message => queueMicrotask(() => frontend.receive(structuredClone(message)))); + return { frontend, backend, close: () => { frontend.close(); backend.close(); } }; +} + +test("local runtime IPC preserves collections and awaits streaming and permission callbacks", async () => { + const peers = connection(); + const events: unknown[] = []; + try { + peers.backend.register("submit", async (input, options) => { + const call = options as { onEvent: (event: unknown) => Promise; requestPermission: (request: unknown) => Promise }; + await call.onEvent({ input, bytes: new Uint8Array([1, 2]), absent: undefined }); + const permission = await call.requestPermission({ tool: "Write" }); + return { permission, set: new Set(["one"]), map: new Map([["key", 3]]), large: "output".repeat(50_000) }; + }); + const result = await peers.frontend.call("submit", "hello", { + onEvent: async (event: unknown) => { await Promise.resolve(); events.push(event); }, + requestPermission: async () => ({ decision: "deny" }) + }) as Record; + expect(events).toEqual([{ input: "hello", bytes: new Uint8Array([1, 2]), absent: undefined }]); + expect(result.permission).toEqual({ decision: "deny" }); + expect(result.set).toEqual(new Set(["one"])); + expect(result.map).toEqual(new Map([["key", 3]])); + expect(result.large).toBe("output".repeat(50_000)); + } finally { peers.close(); } +}); + +test("local runtime IPC delivers live and already-aborted signals to permission dialogs", async () => { + const peers = connection(); + try { + peers.backend.register("run", async (callback) => { + const controller = new AbortController(); + const pending = (callback as (context: unknown) => Promise)({ signal: controller.signal }); + queueMicrotask(() => controller.abort("permission settled elsewhere")); + return await pending; + }); + const result = await peers.frontend.call("run", async (context: { signal: AbortSignal }) => { + expect(context.signal).toBeInstanceOf(AbortSignal); + if (!context.signal.aborted) await new Promise(resolve => context.signal.addEventListener("abort", () => resolve(), { once: true })); + return context.signal.reason; + }); + expect(result).toBe("permission settled elsewhere"); + peers.backend.register("aborted", context => (context as { signal: AbortSignal }).signal.aborted); + expect(await peers.frontend.call("aborted", { signal: AbortSignal.abort("already cancelled") })).toBe(true); + } finally { peers.close(); } +}); + +test("local runtime IPC keeps error names and fails pending calls and signals on disconnect", async () => { + const peers = connection(); + let remoteSignal: AbortSignal | undefined; + try { + peers.backend.register("invalid", () => { const error = new Error("migration failed"); error.name = "SqliteSessionMigrationError"; throw error; }); + await expect(peers.frontend.call("invalid")).rejects.toMatchObject({ name: "SqliteSessionMigrationError", message: "migration failed" }); + peers.backend.register("wait", context => { + remoteSignal = (context as { signal: AbortSignal }).signal; + return new Promise(resolve => remoteSignal!.addEventListener("abort", () => resolve(undefined), { once: true })); + }); + const pending = peers.frontend.call("wait", { signal: new AbortController().signal }); + await Promise.resolve(); + peers.backend.close(new Error("backend gone")); + peers.frontend.close(new Error("backend gone")); + await expect(pending).rejects.toThrow("backend gone"); + expect(remoteSignal?.aborted).toBe(true); + await expect(peers.frontend.call("invalid")).rejects.toThrow("backend gone"); + } finally { peers.close(); } +}); + +test("local runtime IPC rejects unavailable methods, cycles and oversized request depth", async () => { + const peers = connection(); + try { + await expect(peers.frontend.call("__proto__")).rejects.toThrow("unavailable"); + peers.backend.register("identity", value => value); + const circular: Record = {}; + circular.self = circular; + await expect(peers.frontend.call("identity", circular)).rejects.toThrow("deeply nested"); + expect(await peers.frontend.call("identity", "still works")).toBe("still works"); + } finally { peers.close(); } +}); + +test("local runtime IPC rejects a malformed response instead of leaving its caller pending", async () => { + let request: Record | undefined; + const peer = new RuntimeRpc(message => { request = message as Record; }); + const pending = peer.call("read"); + peer.receive({ protocol: "zcode-tui/1", type: "result", id: request!.id, value: { kind: "invalid" } }); + await expect(pending).rejects.toThrow("Invalid runtime collection"); +}); + +test("workflow reducers serialize asynchronous IPC reductions and ignore reset-era results", async () => { + const releases: Array<() => void> = []; + const workflows = new DynamicWorkflows({ + reduceWorkflowRuns: async (_state, event) => { + await new Promise(resolve => releases.push(resolve)); + return { runs: [{ runId: (event as { runId: string }).runId }] }; + } + }); + const first = workflows.accept({ runId: "old", eventType: "start", sequence: 1 }); + await Promise.resolve(); + workflows.reset(); + releases.shift()!(); + expect(await first).toBe(false); + expect(workflows.runs()).toEqual([]); + const second = workflows.accept({ runId: "new", eventType: "start", sequence: 1 }); + await Promise.resolve(); + releases.shift()!(); + expect(await second).toBe(true); + expect(workflows.runs()).toEqual([{ runId: "new" }]); +}); + +test("workflow replay and concurrent live progress keep asynchronous reducer order", async () => { + const order: number[] = []; + const workflows = new DynamicWorkflows({ + replayWorkflowRuns: async () => [{ runId: "run", eventType: "start", sequence: 1 }], + listWorkflowRuns: async () => [], + reduceWorkflowRuns: async (state, event) => { + await Promise.resolve(); + const sequence = (event as { sequence: number }).sequence; + const previous = (state as { runs: Array<{ sequence: number }> } | undefined)?.runs[0]?.sequence ?? 0; + expect(sequence).toBe(previous + 1); + order.push(sequence); + return { runs: [{ runId: "run", sequence }] }; + } + }); + const hydration = workflows.hydrate(); + await workflows.accept({ runId: "run", eventType: "progress", sequence: 2 }); + await hydration; + await Promise.all([ + workflows.accept({ runId: "run", eventType: "progress", sequence: 3 }), + workflows.accept({ runId: "run", eventType: "finish", sequence: 4 }) + ]); + expect(order).toEqual([1, 2, 3, 4]); + expect(workflows.runs()).toEqual([{ runId: "run", sequence: 4 }]); +}); diff --git a/test/tui/fixtures/foreground-runtime.mjs b/test/tui/fixtures/foreground-runtime.mjs new file mode 100644 index 00000000..b370f66a --- /dev/null +++ b/test/tui/fixtures/foreground-runtime.mjs @@ -0,0 +1,35 @@ +import { appendFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { runTui } from "../../../vendor/node_modules/@zcode/tui/dist/index.js"; + +const record = text => appendFileSync(process.env.ZCODE_TUI_SCENARIO_RUNTIME_JOURNAL, `${text}\n`); +record(`RUNTIME_PID ${process.pid}`); +while (!existsSync(join(process.cwd(), "release-runtime"))) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); +} +if (process.env.ZCODE_TEST_RUNTIME_FAILURE === "1") process.exit(7); +await runTui({ + version: "foreground-scenario", + workspaceDirectory: process.cwd(), + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + writeClipboardText: async text => { + process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); + }, + loadStartupOptions: async () => ({ + initialModel: "scenario/model", + initialMode: "guarded", + loginRequired: false, + theme: process.env.ZCODE_TEST_THEME ?? "dark", + slashCommands: [{ name: "echo", summary: "FOREGROUND_ECHO" }] + }), + loadSessionTranscript: async () => [{ role: "agent", content: "FOREGROUND_READY" }], + submitPrompt: async input => { + record(`RUNTIME_INPUT ${input}`); + if (input === "/crash") process.exit(9); + return { response: `FOREGROUND_REPLY ${input}` }; + } +}); +record("RUNTIME_CLOSED"); +if (process.env.ZCODE_TEST_RUNTIME_CLEANUP_FAILURE === "1") process.exitCode = 9; diff --git a/test/tui/fixtures/foreground.mjs b/test/tui/fixtures/foreground.mjs new file mode 100644 index 00000000..67db53e7 --- /dev/null +++ b/test/tui/fixtures/foreground.mjs @@ -0,0 +1,19 @@ +import { appendFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { runForegroundTui } from "../../../vendor/node_modules/@zcode/tui/dist/foreground.js"; + +const code = await runForegroundTui({ + version: "foreground-scenario", + workspaceDirectory: process.cwd(), + prepare: async () => ({ + command: process.execPath, + args: [fileURLToPath(new URL("foreground-runtime.mjs", import.meta.url))], + env: process.env + }), + onDiagnostic: chunk => appendFileSync(process.env.ZCODE_TUI_SCENARIO_RUNTIME_JOURNAL, chunk) +}).catch(error => { + console.error(`FOREGROUND_FAILURE ${error.message}`); + return 1; +}); +console.log(`FOREGROUND_RAW_MODE ${Boolean(process.stdin.isRaw)}`); +process.exitCode = code; diff --git a/test/tui/fixtures/startup.ts b/test/tui/fixtures/startup.ts index 3937e8ef..37531bf0 100644 --- a/test/tui/fixtures/startup.ts +++ b/test/tui/fixtures/startup.ts @@ -1,6 +1,6 @@ import { appendFileSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { runTui } from "../../../packages/zcode-tui/src/index.ts"; +import { runTui } from "../../../packages/zcode-tui/src/entry.ts"; import type { TuiOptions, TuiStartupOptions } from "../../../packages/zcode-tui/src/types.ts"; const mode = process.env.ZCODE_TEST_STARTUP_MODE ?? "lazy"; diff --git a/test/tui/fixtures/terminal-exit.ts b/test/tui/fixtures/terminal-exit.ts index 17478e79..5272074e 100644 --- a/test/tui/fixtures/terminal-exit.ts +++ b/test/tui/fixtures/terminal-exit.ts @@ -1,4 +1,4 @@ -import { runTui } from "../../../packages/zcode-tui/src/index.ts"; +import { runTui } from "../../../packages/zcode-tui/src/entry.ts"; if (process.argv[2] === "parent") { // A fresh process reads the same PTY after the TUI exits, like the parent shell. diff --git a/test/tui/foreground.test.ts b/test/tui/foreground.test.ts new file mode 100644 index 00000000..e09df9c9 --- /dev/null +++ b/test/tui/foreground.test.ts @@ -0,0 +1,127 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; + +import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; +import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; +import { TerminalSession } from "./harness/terminal-session.ts"; + +const command = [Bun.which("node")!, join(import.meta.dir, "fixtures/foreground.mjs")]; + +function environment(home: string, overrides: Record = {}): Record { + return Object.fromEntries(Object.entries({ ...runtimeTestEnv(home), ...overrides }) + .filter((entry): entry is [string, string] => typeof entry[1] === "string")); +} + +test("foreground accepts a draft while runtime is blocked and never sends it before readiness", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-foreground-" }); + await using session = TerminalSession.start({ command, workspace, env: environment(workspace.home) }); + await session.waitForScreen("independent startup editor", /Starting ZCode/u); + expect(session.screenText()).toContain("╭─ ◆ ZCODE"); + expect(session.screenText()).toContain("Ask a task about this workspace"); + expect(session.screenText()).not.toContain("You can type now"); + session.send("/echo draft-before-runtime\r"); + await session.waitForScreen("editable draft during blocked runtime", /draft-before-runtime/u); + expect(await workspace.readRuntimeJournal()).not.toContain("RUNTIME_INPUT"); + const handoffCheckpoint = session.historyCheckpoint(); + await workspace.write("release-runtime", "ready"); + await session.waitForScreen("ready backend", /FOREGROUND_READY/u); + await session.waitForScreen("runtime controls ready", /◉ guarded/u); + await session.waitForScreen("preserved draft", /draft-before-runtime/u); + expect(session.rawHistory(handoffCheckpoint)).not.toContain("\x1b[?2004l"); + expect(session.screenText()).not.toContain("Starting ZCode"); + expect(await workspace.readRuntimeJournal()).not.toContain("RUNTIME_INPUT"); + await session.sendAndWait("\r", "explicitly submitted draft", /FOREGROUND_REPLY \/echo draft-before-runtime/u); + await session.sendAndWait("/copy\r", "foreground clipboard delivery", /Copied the latest assistant response/u); + expect(await workspace.readRuntimeJournal()).not.toContain("]52;c;"); + await session.exit(); + expect(await workspace.readRuntimeJournal()).toContain("RUNTIME_CLOSED"); +}, 20_000); + +test("foreground uses the saved fullscreen shell and light palette from its first frame without restarting it", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-foreground-appearance-" }); + const settings = join(workspace.directory, "startup-settings.json"); + await workspace.write("startup-settings.json", JSON.stringify({ ui: { tuiMode: "fullscreen", theme: "light" } })); + await using session = TerminalSession.start({ + command, + workspace, + env: environment(workspace.home, { + ZCODE_CLI_SETTINGS_FILE: settings, + ZCODE_TUI_MODE: "", + ZCODE_TEST_THEME: "light", + NO_COLOR: "" + }) + }); + await session.waitForScreen("themed fullscreen startup", /Starting ZCode/u); + expect(session.screenText()).toContain("Ask a task about this workspace"); + expect(session.screenText()).toContain("Workspace"); + expect(session.rawHistory()).toContain("\x1b[?1049h"); + expect(session.rawHistory()).toContain("\x1b[38;5;25m"); + session.send("/echo fullscreen-draft"); + await session.waitForScreen("fullscreen startup draft", /fullscreen-draft/u); + const handoffCheckpoint = session.historyCheckpoint(); + await workspace.write("release-runtime", "ready"); + await session.waitForScreen("fullscreen runtime ready", /◉ guarded/u); + await session.waitForScreen("fullscreen preserved draft", /fullscreen-draft/u); + const handoff = session.rawHistory(handoffCheckpoint); + expect(handoff).not.toContain("\x1b[?1049l"); + expect(handoff).not.toContain("\x1b[?1049h"); + expect(handoff).not.toContain("\x1b[?2004l"); + await session.sendAndWait("\r", "fullscreen draft submission", /FOREGROUND_REPLY \/echo fullscreen-draft/u); + await session.exit(); + expect(session.rawHistory()).toContain("\x1b[?1049l"); + expect(session.historyText()).toContain("FOREGROUND_RAW_MODE false"); +}, 20_000); + +test("foreground cancellation terminates a blocked runtime and restores the terminal", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-foreground-cancel-" }); + await using session = TerminalSession.start({ command, workspace, env: environment(workspace.home) }); + await session.waitForScreen("startup editor", /Starting ZCode/u); + session.send("\x03"); + await session.waitForHistory("terminal restored", /FOREGROUND_RAW_MODE false/u); + await expect(session.exit()).rejects.toThrow("code 130"); + const journal = await workspace.readRuntimeJournal(); + expect(journal).not.toContain("RUNTIME_INPUT"); + const pid = /RUNTIME_PID (\d+)/u.exec(journal)?.[1]; + if (pid) expect(() => process.kill(Number(pid), 0)).toThrow(); +}, 10_000); + +for (const phase of ["startup", "ready"]) { + test(`foreground handles runtime death during ${phase} without stranding the terminal`, async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-foreground-failure-" }); + await using session = TerminalSession.start({ + command, workspace, env: environment(workspace.home, { ZCODE_TEST_RUNTIME_FAILURE: phase === "startup" ? "1" : "0" }) + }); + await session.waitForScreen("startup editor", /Starting ZCode/u); + await workspace.write("release-runtime", "ready"); + if (phase === "ready") { + await session.waitForScreen("ready backend", /FOREGROUND_READY/u); + session.send("/crash\r"); + } + await session.waitForHistory("explicit backend failure", /FOREGROUND_FAILURE/u); + await session.waitForHistory("terminal restored after failure", /FOREGROUND_RAW_MODE false/u); + await expect(session.exit()).rejects.toThrow("code 1"); + }, 10_000); +} + +test("Bun source launches hand off to a Node foreground and cancel without hanging IPC", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-foreground-source-" }); + await using session = TerminalSession.start({ + command: [process.execPath, join(import.meta.dir, "../../bin/zcode.ts")], + workspace, + env: environment(workspace.home, { ZCODE_NODE: Bun.which("node")! }) + }); + await session.waitForScreen("Node foreground editor", /Starting ZCode/u); + session.send("\x03"); + await expect(session.exit()).rejects.toThrow("code 130"); +}, 10_000); + +test("foreground does not report success when native runtime cleanup fails", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-foreground-cleanup-" }); + await workspace.write("release-runtime", "ready"); + await using session = TerminalSession.start({ + command, workspace, env: environment(workspace.home, { ZCODE_TEST_RUNTIME_CLEANUP_FAILURE: "1" }) + }); + await session.waitForScreen("ready backend", /FOREGROUND_READY/u); + await expect(session.exit()).rejects.toThrow("code 1"); + expect(session.historyText()).toContain("status 9 during cleanup"); +}, 10_000); diff --git a/test/tui/harness/terminal-session.ts b/test/tui/harness/terminal-session.ts index 19d3b873..909f1ef2 100644 --- a/test/tui/harness/terminal-session.ts +++ b/test/tui/harness/terminal-session.ts @@ -65,7 +65,7 @@ export class TerminalSession implements AsyncDisposable { CI: "1", TERM: "xterm-256color", ZCODE_DISABLE_UPDATE_CHECK: "1", - ZCODE_TUI_MODE: "regular" + ZCODE_TUI_MODE: options.env?.ZCODE_TUI_MODE ?? "regular" }, terminal }); @@ -100,6 +100,10 @@ export class TerminalSession implements AsyncDisposable { return terminalPlainText(this.#historyOutput.slice(start)); } + rawHistory(start = 0): string { + return this.#historyOutput.slice(start); + } + screenText(): string { this.throwScreenError(); return this.#screen.screenText(); diff --git a/test/tui/startup.test.ts b/test/tui/startup.test.ts index f5028bc3..0c32bf9a 100644 --- a/test/tui/startup.test.ts +++ b/test/tui/startup.test.ts @@ -5,7 +5,7 @@ import { TerminalSession } from "./harness/terminal-session.ts"; const command = [process.execPath, join(import.meta.dir, "fixtures/startup.ts")]; -for (const mode of ["lazy"]) { +for (const mode of ["lazy", "legacy"]) { test(`TUI loads ${mode} startup commands before constructing the editor`, async () => { await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-startup-" }); await using session = TerminalSession.start({ command, workspace, env: { ZCODE_TEST_STARTUP_MODE: mode } }); diff --git a/tsdown.config.ts b/tsdown.config.ts index 8099e7d0..80b6997e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -28,7 +28,10 @@ export default defineConfig([ }, { name: "tui", - entry: { index: "packages/zcode-tui/src/index.ts" }, + entry: { + index: "packages/zcode-tui/src/entry.ts", + foreground: "packages/zcode-tui/src/foreground.ts" + }, outDir: "packages/zcode-tui/dist", outExtensions: () => ({ js: ".js" }), format: "esm",