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
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/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-lifecycle.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: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/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-lifecycle.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-exit.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
25 changes: 13 additions & 12 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import {
type TurnNotificationKind
} from "./notifications.ts";
import { watchStreamErrors } from "./stream-error-guard.ts";
import { stopTui } from "./terminal-cleanup.ts";
import {
readTuiMode,
resolveTuiMode,
Expand Down Expand Up @@ -883,9 +884,9 @@ class ZCodeTui {
const onSigterm = () => this.handleSignal("SIGTERM");
const onSighup = () => this.handleSignal("SIGHUP");
this.installStreamErrorGuards();
process.once("SIGINT", onSigint);
process.once("SIGTERM", onSigterm);
if (process.platform !== "win32") process.once("SIGHUP", onSighup);
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
if (process.platform !== "win32") process.on("SIGHUP", onSighup);
let startAttempted = false;
try {
if (this.stopped) {
Expand Down Expand Up @@ -945,18 +946,18 @@ class ZCodeTui {
if (this.sessionModelIssue) void this.recoverSessionModel();
await this.done;
} finally {
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
if (process.platform !== "win32") process.off("SIGHUP", onSighup);
try {
if (startAttempted && !this.stopped) {
if (startAttempted) {
this.stop();
await this.done;
}
} finally {
// Leave the guards installed through the I/O turn containing the final
// terminal restore writes, which can report stream errors asynchronously.
await new Promise<void>((resolve) => setImmediate(resolve));
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
if (process.platform !== "win32") process.off("SIGHUP", onSighup);
this.removeStreamErrorGuards?.();
this.removeStreamErrorGuards = undefined;
}
Expand Down Expand Up @@ -6105,16 +6106,16 @@ class ZCodeTui {
} catch {
// Notification cleanup must not prevent terminal restoration.
}
try {
this.ui.stop();
} catch {
// Keep resolving the run even if a terminal implementation fails.
}
void this.finishStop(elapsedMilliseconds);
}

private async finishStop(elapsedMilliseconds: number): Promise<void> {
try {
try {
await stopTui(this.ui);
} catch {
// Keep resolving the run even if a terminal implementation fails.
}
const [, resumeTarget] = await Promise.all([this.refreshExitUsage(), this.readExitResumeTarget()]);
const summary = buildExitSummary({
elapsedMilliseconds,
Expand Down
19 changes: 11 additions & 8 deletions packages/zcode-tui/src/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { stopTui } from "./terminal-cleanup.ts";
import type { TuiOptions } from "./types.ts";

export async function loadTuiStartupOptions(options: TuiOptions): Promise<TuiOptions | undefined> {
Expand Down Expand Up @@ -35,16 +36,17 @@ export async function loadTuiStartupOptions(options: TuiOptions): Promise<TuiOpt
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);
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
if (process.platform !== "win32") process.on("SIGHUP", onSighup);

let readyOptions: TuiOptions | undefined;
try {
ui.start();
ui.renderNow();
await new Promise<void>((resolve) => setImmediate(resolve));
if (cancelled) return undefined;
return await Promise.race([
readyOptions = await Promise.race([
Promise.resolve().then(async () => {
if (cancelled) return undefined;
const startup = await loadStartupOptions();
Expand All @@ -53,19 +55,20 @@ export async function loadTuiStartupOptions(options: TuiOptions): Promise<TuiOpt
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();
await stopTui(ui);
} finally {
await new Promise<void>((resolve) => setImmediate(resolve));
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
if (process.platform !== "win32") process.off("SIGHUP", onSighup);
removeStreamErrorGuards();
}
}
}
return cancelled ? undefined : readyOptions;
}
11 changes: 11 additions & 0 deletions packages/zcode-tui/src/terminal-cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { TUI } from "@earendil-works/pi-tui";

/** Disable enhanced keyboard reporting and consume in-flight releases before restoring the terminal. */
export async function stopTui(ui: TUI): Promise<void> {
try {
await ui.terminal.drainInput();
} catch {
// A disconnected terminal can fail to drain; still restore its modes and listeners.
}
ui.stop();
}
1 change: 1 addition & 0 deletions test/tui/fixtures/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ else options.loadStartupOptions = async () => {
if (mode === "cancel-reject") throw new Error("LATE_STARTUP_FAILURE");
}
if (mode === "failure") throw new Error("STORAGE_STARTUP_FAILURE");
if (mode === "cancel-after-load") setTimeout(() => process.kill(process.pid, "SIGTERM"), 10);
return startup;
};

Expand Down
37 changes: 37 additions & 0 deletions test/tui/fixtures/terminal-exit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { runTui } from "../../../packages/zcode-tui/src/index.ts";

if (process.argv[2] === "parent") {
// A fresh process reads the same PTY after the TUI exits, like the parent shell.
const input = await new Promise<string>((resolve) => {
let received = "";
process.stdin.setRawMode(true);
process.stdin.setEncoding("utf8");
const onData = (data: string) => {
received += data;
const end = received.indexOf("\r");
if (end === -1) return;
process.stdin.off("data", onData);
process.stdin.pause();
process.stdin.setRawMode(false);
resolve(received.slice(0, end));
};
process.stdin.on("data", onData);
process.stdin.resume();
console.log("EXIT_PARENT_READY");
});
console.log(`EXIT_PARENT_INPUT ${JSON.stringify(input)}`);
console.log(`EXIT_TUI_STATUS ${process.argv[3]}`);
} else {
console.log(`EXIT_TUI_PID ${process.pid}`);
await runTui({
initialModel: "scenario/model",
theme: "dark",
workspaceDirectory: process.cwd(),
loadStartupOptions: process.argv[2] === "startup"
? () => new Promise(() => {})
: undefined,
loadSessionTranscript: async () => [{ role: "agent", content: "EXIT_READY" }],
submitPrompt: async () => ({ response: "Unexpected submission" })
});
console.log(`EXIT_RAW_MODE ${Boolean(process.stdin.isRaw)}`);
}
13 changes: 13 additions & 0 deletions test/tui/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,16 @@ test("TUI propagates startup failures after restoring terminal raw mode", async
await expect(session.exit()).rejects.toThrow("code 1");
expect(await workspace.readRuntimeJournal()).not.toMatch(/transcript-read|mcp-read/u);
}, 10_000);

test("TUI honors cancellation while draining input after startup options resolve", async () => {
await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-startup-drain-cancel-" });
await using session = TerminalSession.start({
command, workspace, env: { ZCODE_TEST_STARTUP_MODE: "cancel-after-load" }
});
await session.waitForScreen("startup screen", /Starting ZCode/u);
await workspace.write("release-startup", "ready");
await session.waitForHistory("startup cancelled during input drain", /STARTUP_CANCELLED/u);
await session.waitForHistory("raw mode restored", /STARTUP_RAW_MODE false/u);
await expect(session.exit()).rejects.toThrow("code 143");
expect(await workspace.readRuntimeJournal()).not.toMatch(/transcript-read|mcp-read/u);
}, 10_000);
114 changes: 114 additions & 0 deletions test/tui/terminal-exit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { expect, test } from "bun:test";
import { join } from "node:path";

import { runtimeTestEnv } from "../../scripts/runtime-test-env.ts";
import { ScenarioWorkspace } from "./harness/scenario-workspace.ts";

const fixture = join(import.meta.dir, "fixtures/terminal-exit.ts");
const ctrlCRelease = "\x1b[99;5:3u";
const cases: {
name: string;
mode: "regular" | "fullscreen";
input?: string;
release: string;
status: number;
startup?: boolean;
signal?: NodeJS.Signals;
}[] = [
{ name: "Ctrl+C in regular mode", mode: "regular", input: "\x1b[99;5u", release: ctrlCRelease, status: 0 },
{ name: "Ctrl+C in fullscreen mode", mode: "fullscreen", input: "\x1b[99;5u", release: ctrlCRelease, status: 0 },
{ name: "Ctrl+D", mode: "regular", input: "\x1b[100;5u", release: "\x1b[100;5:3u", status: 0 },
{ name: "/exit", mode: "regular", input: "/exit\r", release: "\x1b[13;1:3u", status: 0 },
{ name: "startup cancellation", mode: "regular", startup: true, input: "\x1b[99;5u", release: ctrlCRelease, status: 130 },
{ name: "repeated SIGTERM", mode: "fullscreen", signal: "SIGTERM", release: ctrlCRelease, status: 143 },
{ name: "repeated SIGTERM during startup", mode: "regular", startup: true, signal: "SIGTERM", release: ctrlCRelease, status: 143 }
];

for (const scenario of cases) {
test.skipIf(process.platform === "win32")(`${scenario.name} drains late key releases before returning to the shell`, async () => {
await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-terminal-exit-" });
const decoder = new TextDecoder();
let output = "";
let negotiated = false;
let exiting = false;
let releaseSent = false;
let releaseTimer: ReturnType<typeof setTimeout> | undefined;
const terminal = new Bun.Terminal({
cols: 100,
rows: 32,
name: "xterm-256color",
data(pty, data) {
output += decoder.decode(data, { stream: true });
if (!negotiated && output.includes("\x1b[?u")) {
negotiated = true;
pty.write("\x1b[?7u\x1b[?1;2c");
}
if (exiting && !releaseTimer && output.includes("\x1b[<u")) {
// Model a release already in transit when the application disables Kitty.
releaseTimer = setTimeout(() => {
if (scenario.signal) process.kill(tuiPid(), scenario.signal);
pty.write(scenario.release);
releaseSent = true;
}, 10);
}
}
});
const child = Bun.spawn([
"/bin/sh", "-c",
'"$@"; tui_status=$?; exec "$1" "$2" parent "$tui_status"',
"zcode-terminal-exit", process.execPath, fixture,
scenario.startup ? "startup" : "ready"
], {
cwd: workspace.directory,
env: {
...runtimeTestEnv(workspace.home),
CI: "1",
NO_COLOR: "1",
TERM: "xterm-256color",
PI_PROGRAM_STATUS: "0",
ZCODE_TUI_MODE: scenario.mode,
ZCODE_TUI_NOTIFICATION_METHOD: "off"
},
terminal
});
const tuiPid = () => Number(/EXIT_TUI_PID (\d+)/u.exec(output)?.[1]);
const waitFor = async (label: string, ready: () => boolean) => {
const deadline = Date.now() + 5_000;
while (!ready()) {
if (Date.now() >= deadline || child.exitCode !== null) {
throw new Error(`Timed out waiting for ${label}.\n${JSON.stringify(output)}`);
}
await Bun.sleep(5);
}
};
try {
await waitFor("interactive TUI", () => negotiated && output.includes(
scenario.startup ? "Starting ZCode" : "EXIT_READY"
));
await Bun.sleep(30);
const exitStart = output.length;
exiting = true;
if (scenario.signal) process.kill(tuiPid(), scenario.signal);
else terminal.write(scenario.input!);
await waitFor("release delivered and parent reading", () => releaseSent && output.includes("EXIT_PARENT_READY"));
terminal.write("next-command\r");
await waitFor("parent input report", () => output.includes("EXIT_TUI_STATUS"));
expect(output).toContain('EXIT_PARENT_INPUT "next-command"');
expect(output).toContain("EXIT_RAW_MODE false");
expect(output).toContain(`EXIT_TUI_STATUS ${scenario.status}`);
expect(output.slice(exitStart).match(/\x1b\[<u/gu)).toHaveLength(1);
if (scenario.mode === "fullscreen") expect(output.slice(exitStart)).toContain("\x1b[?1049l");
expect(await child.exited).toBe(0);
} finally {
clearTimeout(releaseTimer);
if (child.exitCode === null) {
if (tuiPid()) {
try { process.kill(tuiPid(), "SIGKILL"); } catch {}
}
child.kill("SIGKILL");
}
await child.exited;
if (!terminal.closed) terminal.close();
}
}, 10_000);
}
Loading