Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -6081,5 +6082,6 @@ class ZCodeTui {

export async function runTui(options: TuiOptions): Promise<void> {
suppressTuiAiSdkWarnings();
await new ZCodeTui(options).run();
const readyOptions = await loadTuiStartupOptions(options);
if (readyOptions) await new ZCodeTui(readyOptions).run();
}
71 changes: 71 additions & 0 deletions packages/zcode-tui/src/startup.ts
Original file line number Diff line number Diff line change
@@ -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<TuiOptions | undefined> {
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<undefined>((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<void>((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<void>((resolve) => setImmediate(resolve));
removeStreamErrorGuards();
}
}
}
}
14 changes: 14 additions & 0 deletions packages/zcode-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export interface RuntimeAdapter {
}

export interface TuiOptions extends RuntimeAdapter {
loadStartupOptions?: () => Promise<TuiStartupOptions>;
initialMode?: string;
initialPlanEnabled?: boolean;
initialModel?: unknown;
Expand All @@ -151,6 +152,19 @@ export interface TuiOptions extends RuntimeAdapter {
readClipboardImage?: (options?: { abortSignal?: AbortSignal }) => Promise<unknown>;
}

export type TuiStartupOptions = Pick<TuiOptions,
| "initialMode"
| "initialModel"
| "initialThoughtLevel"
| "loginRequired"
| "locale"
| "theme"
| "modelOptions"
| "effortOptions"
| "slashCommands"
| "workspaceGitBranch"
>;

export function isRecord(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down
5 changes: 4 additions & 1 deletion scripts/smoke-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down Expand Up @@ -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 <server>/i);
terminal.write("\x03");
await Bun.sleep(50);
terminal.write("/exit\r");
} catch (error) {
interactionError = error;
Expand Down
63 changes: 63 additions & 0 deletions test/tui/fixtures/startup.ts
Original file line number Diff line number Diff line change
@@ -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;
}
71 changes: 71 additions & 0 deletions test/tui/startup.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading