From e92e6b3796fde40f00ffbaeca88ab75cdbaac281 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Fri, 16 Oct 2026 00:02:38 +0800 Subject: [PATCH] fix(tui): load deferred runtime startup options before editor initialization --- docs/DEVELOPMENT.md | 25 +++++++++++ package.json | 2 +- packages/zcode-tui/src/index.ts | 4 +- packages/zcode-tui/src/startup.ts | 71 +++++++++++++++++++++++++++++++ packages/zcode-tui/src/types.ts | 14 ++++++ scripts/smoke-tui.ts | 5 ++- test/tui/fixtures/startup.ts | 63 +++++++++++++++++++++++++++ test/tui/startup.test.ts | 71 +++++++++++++++++++++++++++++++ 8 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 packages/zcode-tui/src/startup.ts create mode 100644 test/tui/fixtures/startup.ts create mode 100644 test/tui/startup.test.ts diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8dfdb3c..d0695ba 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -58,6 +58,31 @@ Ctrl+C cancellation remain responsive during rapid Bash progress output. The scenarios advance from observed terminal output instead of fixed timers and do not make model API calls. +## TUI startup compatibility + +Recent upstream runtimes pass session metadata and slash commands through +`loadStartupOptions()` rather than top-level TUI options. The local TUI paints a +cancellable startup screen, calls this loader once, and merges its result before +constructing the editor and session state. Older runtimes that pass top-level +options continue to work. Startup failures propagate after terminal cleanup; +Ctrl+C exits without creating the session UI when the loader finishes later. + +Run the isolated startup regression tests and synchronize the built TUI: + +```bash +bun test test/tui/startup.test.ts +bun run sync:local # macOS with /Applications/ZCode.app; otherwise use bun run sync:locked +bun scripts/smoke-tui.ts +``` + +The tests cover lazy and legacy command lists, custom commands, MCP picker +dispatch, cancellation, late rejection, and raw-mode restoration on failure. The +runtime smoke test also checks `/mc` suggestions from the real upstream loader. +For manual verification, launch `zcode`, type `/mc` without submitting, and check +that `mcp` appears. Clear the draft and repeat with `/he` and `/mod`; submitting +`/mcp` should open the configured server picker (or show that none are configured). +Restart the CLI after syncing; an already running process keeps its loaded TUI. + ## Reproduce invalid models in resumed sessions (#160) Build the current CLI, then start an isolated reproduction: diff --git a/package.json b/package.json index 4b992fa..4a518ff 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test:node": "node --test test/node/*.test.cjs", "test:tui": "bun run test:tui:component && bun run test:tui:e2e", "test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts", - "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-rename.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", + "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", "test:tui:host": "bun test test/tui/scenario-mountx.test.ts", "test:tui:manual": "bun scripts/tui-scenario.ts --manual", "test:tui-scenario": "bun scripts/tui-scenario.ts", diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index e81d2fa..82539de 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 { loadTuiStartupOptions } from "./startup.ts"; import { preflightSubmission } from "./prompt-preflight.ts"; import { clearSetupPending, @@ -6081,5 +6082,6 @@ class ZCodeTui { export async function runTui(options: TuiOptions): Promise { suppressTuiAiSdkWarnings(); - await new ZCodeTui(options).run(); + const readyOptions = await loadTuiStartupOptions(options); + if (readyOptions) await new ZCodeTui(readyOptions).run(); } diff --git a/packages/zcode-tui/src/startup.ts b/packages/zcode-tui/src/startup.ts new file mode 100644 index 0000000..f824c98 --- /dev/null +++ b/packages/zcode-tui/src/startup.ts @@ -0,0 +1,71 @@ +import { constants as osConstants } from "node:os"; +import { isKeyRelease, matchesKey, ProcessTerminal, Text, TuiMainScreen } from "@earendil-works/pi-tui"; + +import { watchStreamErrors } from "./stream-error-guard.ts"; +import type { TuiOptions } from "./types.ts"; + +export async function loadTuiStartupOptions(options: TuiOptions): Promise { + const loadStartupOptions = options.loadStartupOptions; + if (!loadStartupOptions) return options; + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error("ZCode TUI requires an interactive terminal."); + } + + const ui = new TuiMainScreen(new ProcessTerminal(), true); + const chinese = options.locale?.startsWith("zh"); + ui.addChild(new Text(chinese ? "正在启动 ZCode…" : "Starting ZCode…", 1, 0)); + ui.addChild(new Text(chinese ? "按 Ctrl+C 取消" : "Press Ctrl+C to cancel", 1, 0)); + + let cancelled = false; + let resolveCancelled!: () => void; + const cancellation = new Promise((resolve) => { + resolveCancelled = () => resolve(undefined); + }); + const cancel = (exitCode: number) => { + if (cancelled) return; + 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 removeStreamErrorGuards = watchStreamErrors([process.stdout, process.stderr], () => cancel(1)); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + if (process.platform !== "win32") process.once("SIGHUP", onSighup); + + try { + ui.start(); + ui.renderNow(); + await new Promise((resolve) => setImmediate(resolve)); + if (cancelled) return undefined; + return await Promise.race([ + Promise.resolve().then(async () => { + if (cancelled) return undefined; + const startup = await loadStartupOptions(); + return cancelled ? undefined : { ...options, ...startup }; + }), + cancellation + ]); + } finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + if (process.platform !== "win32") process.off("SIGHUP", onSighup); + try { + ui.clear(); + ui.renderNow(); + } finally { + try { + ui.stop(); + } finally { + await new Promise((resolve) => setImmediate(resolve)); + removeStreamErrorGuards(); + } + } + } +} diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index 69550ee..3be3f9f 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -127,6 +127,7 @@ export interface RuntimeAdapter { } export interface TuiOptions extends RuntimeAdapter { + loadStartupOptions?: () => Promise; initialMode?: string; initialPlanEnabled?: boolean; initialModel?: unknown; @@ -151,6 +152,19 @@ export interface TuiOptions extends RuntimeAdapter { readClipboardImage?: (options?: { abortSignal?: AbortSignal }) => Promise; } +export type TuiStartupOptions = Pick; + export function isRecord(value: unknown): value is UnknownRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/scripts/smoke-tui.ts b/scripts/smoke-tui.ts index 28fa3cd..3b417fc 100755 --- a/scripts/smoke-tui.ts +++ b/scripts/smoke-tui.ts @@ -163,7 +163,7 @@ const timeout = setTimeout(() => { let interactionError: unknown; try { - await waitFor("welcome screen", /ZCode/i); + await waitFor("welcome screen", /Ask a task about this workspace/i); if (!plainText(output).includes("Ask a task about this workspace")) { throw new Error("Regular TUI did not render the session welcome intro."); } @@ -246,6 +246,9 @@ try { await sendAndWait("/plan on\r", "plan enabled", /Plan enabled · permission mode: build/i); await sendAndWait("/mode edit\r", "edit permission mode", /mode switched to edit/i); await sendAndWait("/plan off\r", "plan disabled with permissions preserved", /Plan disabled · permission mode: edit/i); + await sendAndWait("/mc", "runtime slash command suggestions", /mcp\s+\/mcp \[list\|status\|connect /i); + terminal.write("\x03"); + await Bun.sleep(50); terminal.write("/exit\r"); } catch (error) { interactionError = error; diff --git a/test/tui/fixtures/startup.ts b/test/tui/fixtures/startup.ts new file mode 100644 index 0000000..73a78e4 --- /dev/null +++ b/test/tui/fixtures/startup.ts @@ -0,0 +1,63 @@ +import { appendFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { runTui } from "../../../packages/zcode-tui/src/index.ts"; +import type { TuiOptions, TuiStartupOptions } from "../../../packages/zcode-tui/src/types.ts"; + +const mode = process.env.ZCODE_TEST_STARTUP_MODE ?? "lazy"; +const record = (kind: string) => appendFileSync( + process.env.ZCODE_TUI_SCENARIO_RUNTIME_JOURNAL!, `${JSON.stringify({ kind })}\n` +); +const startup: TuiStartupOptions = { + initialMode: "guarded", + initialModel: "startup/ready-model", + initialThoughtLevel: "high", + loginRequired: false, + locale: "en-US", + theme: "dark", + modelOptions: [{ id: "startup/ready-model", label: "Ready model" }], + effortOptions: [{ id: "high", label: "High" }], + workspaceGitBranch: "startup-branch", + slashCommands: [ + { name: "mcp", summary: "STARTUP_MCP_COMPLETION" }, + { name: "help", summary: "STARTUP_HELP_COMPLETION" }, + { name: "custom-startup", summary: "STARTUP_CUSTOM_COMPLETION" } + ] +}; +const options: TuiOptions = { + initialModel: "fallback/model", + loginRequired: true, + workspaceDirectory: process.cwd(), + loadSessionTranscript: async () => { + record("transcript-read"); + return [{ role: "agent", content: "STARTUP_READY" }]; + }, + listMcpServers: async () => { + record("mcp-read"); + return { fixture_server: { status: "disconnected", transport: "stdio", toolCount: 0 } }; + }, + submitPrompt: async (input) => ({ response: `STARTUP_SENT ${String(input)}` }) +}; + +if (mode === "legacy") Object.assign(options, startup); +else options.loadStartupOptions = async () => { + record("loader-called"); + while (!existsSync(join(process.cwd(), "release-startup"))) await Bun.sleep(10); + if (mode.startsWith("cancel")) { + record("loader-settled"); + if (mode === "cancel-reject") throw new Error("LATE_STARTUP_FAILURE"); + } + if (mode === "failure") throw new Error("STORAGE_STARTUP_FAILURE"); + return startup; +}; + +try { + await runTui(options); + if (mode.startsWith("cancel")) { + console.log("STARTUP_CANCELLED"); + console.log(`STARTUP_RAW_MODE ${Boolean(process.stdin.isRaw)}`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error(`STARTUP_RAW_MODE ${Boolean(process.stdin.isRaw)}`); + process.exitCode = 1; +} diff --git a/test/tui/startup.test.ts b/test/tui/startup.test.ts new file mode 100644 index 0000000..2b1f3e5 --- /dev/null +++ b/test/tui/startup.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; +import { TerminalSession } from "./harness/terminal-session.ts"; + +const command = [process.execPath, join(import.meta.dir, "fixtures/startup.ts")]; + +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 } }); + if (mode === "lazy") { + await session.waitForScreen("startup screen", /Starting ZCode/u); + session.send("must not submit\r"); + await session.settle(); + const journal = await workspace.readRuntimeJournal(); + expect(journal.match(/loader-called/g)?.length).toBe(1); + expect(journal).not.toMatch(/transcript-read|mcp-read/u); + await workspace.write("release-startup", "ready"); + } + await session.waitForScreen("ready", /STARTUP_READY/u); + await session.waitForScreen("startup model", /ready-model/u); + await session.waitForScreen("startup permission mode", /guarded/u); + await session.assertScreenExcludes("startup input not submitted", /STARTUP_SENT/u); + for (const [prefix, description] of [ + ["/mc", "STARTUP_MCP_COMPLETION"], + ["/he", "STARTUP_HELP_COMPLETION"], + ["/custom-st", "STARTUP_CUSTOM_COMPLETION"] + ] as const) { + await session.sendAndWait(prefix, "startup command completion", new RegExp(description)); + session.send("\x03"); + await session.settle(); + } + await session.sendAndWait("/mcp\r", "MCP picker", /fixture_server/u); + session.send("\r"); + await session.waitForHistory("connect dispatched", /STARTUP_SENT \/mcp connect fixture_server/u); + await session.exit(); + const journal = await workspace.readRuntimeJournal(); + expect(journal.match(/loader-called/g)?.length ?? 0).toBe(mode === "lazy" ? 1 : 0); + }, 20_000); +} + +for (const mode of ["cancel-resolve", "cancel-reject"]) { + test(`TUI cancels pending startup and ignores ${mode} completion`, async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-startup-cancel-" }); + await using session = TerminalSession.start({ command, workspace, env: { ZCODE_TEST_STARTUP_MODE: mode } }); + await session.waitForScreen("startup screen", /Starting ZCode/u); + await session.settle(); + session.send("\x03"); + await session.waitForHistory("startup cancelled without waiting for loader", /STARTUP_CANCELLED/u); + await session.waitForHistory("raw mode restored after cancellation", /STARTUP_RAW_MODE false/u); + await workspace.write("release-startup", "settle after cancellation"); + await expect(session.exit()).rejects.toThrow("code 130"); + const journal = await workspace.readRuntimeJournal(); + expect(journal.match(/loader-called/g)?.length).toBe(1); + expect(journal).toContain("loader-settled"); + expect(journal).not.toMatch(/transcript-read|mcp-read/u); + expect(session.historyText()).not.toContain("LATE_STARTUP_FAILURE"); + }, 10_000); +} + +test("TUI propagates startup failures after restoring terminal raw mode", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-startup-error-" }); + await using session = TerminalSession.start({ command, workspace, env: { ZCODE_TEST_STARTUP_MODE: "failure" } }); + await session.waitForScreen("startup screen", /Starting ZCode/u); + await workspace.write("release-startup", "fail"); + await session.waitForHistory("original failure", /STORAGE_STARTUP_FAILURE/u); + await session.waitForHistory("raw mode restored", /STARTUP_RAW_MODE false/u); + await expect(session.exit()).rejects.toThrow("code 1"); + expect(await workspace.readRuntimeJournal()).not.toMatch(/transcript-read|mcp-read/u); +}, 10_000);