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
5 changes: 5 additions & 0 deletions .changeset/flicker-free-history-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Keep the themed history loading screen visible until a selected commit review is ready to claim the terminal.
29 changes: 29 additions & 0 deletions src/app/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,35 @@ describe("startup planning", () => {
expect(opened).toBe(1);
});

test("inherits handoff theme mode without querying the parent-owned terminal", async () => {
const cliInput: CliInput = {
kind: "show",
ref: "opaque:id",
options: { theme: "auto" },
};
let detected = 0;

const plan = await prepareStartupPlan(["bun", "hunk", "show", "opaque:id"], {
parseCliImpl: async () => cliInput as ParsedCliInput,
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input),
loadAppBootstrapImpl: async (input) => createBootstrap(input),
detectTerminalThemeModeFromBackgroundImpl: async () => {
detected += 1;
return "light";
},
stdinIsTTY: true,
stdoutIsTTY: true,
env: {
HUNK_TERMINAL_HANDOFF: "1",
HUNK_TERMINAL_HANDOFF_THEME_MODE: "dark",
},
});

expect(plan).toMatchObject({ kind: "app", bootstrap: { initialThemeMode: "dark" } });
expect(detected).toBe(0);
});

test("opens the controlling terminal for piped patch startup", async () => {
const cliInput: CliInput = {
kind: "patch",
Expand Down
7 changes: 5 additions & 2 deletions src/app/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { resolveConfiguredCliInput } from "../core/run/config";
import { HunkUserError } from "../core/run/errors";
import type { loadAppBootstrap } from "../core/changeset/loaders";
import { looksLikePatchInput } from "../core/process/pager";
import { terminalHandoffThemeMode } from "../core/process/terminalHandoff";
import { sanitizeTerminalText } from "../lib/terminalText";
import { detectTerminalThemeModeFromBackground } from "../core/theme/detection";
import {
Expand Down Expand Up @@ -543,8 +544,10 @@ export async function prepareStartupPlan(
controllingTerminal = openControllingTerminalImpl();
}

let initialThemeMode: AppBootstrap["initialThemeMode"];
if (cliInput.options.theme === "auto" && stdoutIsTTY) {
// A handoff child inherits the parent's detected mode so bootstrap never queries a terminal
// whose input and renderer are still exclusively owned by the history process.
let initialThemeMode: AppBootstrap["initialThemeMode"] = terminalHandoffThemeMode(env);
if (!initialThemeMode && cliInput.options.theme === "auto" && stdoutIsTTY) {
const themeInput = controllingTerminal?.stdin ?? (stdinIsTTY ? process.stdin : null);
if (themeInput) {
initialThemeMode =
Expand Down
45 changes: 45 additions & 0 deletions src/core/process/terminalHandoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test";
import {
hasTerminalHandoff,
parseTerminalHandoffMessage,
terminalHandoffEnv,
terminalHandoffMessage,
terminalHandoffThemeMode,
} from "./terminalHandoff";

describe("terminal handoff protocol", () => {
test("uses a private marker and inherits only a valid terminal mode", () => {
const env = terminalHandoffEnv({ PATH: "/bin" }, "dark");
expect(hasTerminalHandoff(env)).toBe(true);
expect(terminalHandoffThemeMode(env)).toBe("dark");
expect(
terminalHandoffThemeMode({
HUNK_TERMINAL_HANDOFF: "1",
HUNK_TERMINAL_HANDOFF_THEME_MODE: "blue",
}),
).toBeUndefined();
expect(terminalHandoffThemeMode({ HUNK_TERMINAL_HANDOFF_THEME_MODE: "light" })).toBeUndefined();
});

test("accepts only versioned bounded messages", () => {
expect(parseTerminalHandoffMessage(terminalHandoffMessage("ready"))).toEqual({
protocol: "hunk-terminal-handoff-v1",
kind: "ready",
});
expect(parseTerminalHandoffMessage({ protocol: "wrong", kind: "ready" })).toBeUndefined();
expect(
parseTerminalHandoffMessage({ protocol: "hunk-terminal-handoff-v1", kind: "other" }),
).toBeUndefined();
expect(
parseTerminalHandoffMessage({
protocol: "hunk-terminal-handoff-v1",
kind: "failed",
message: "x".repeat(3_000),
}),
).toEqual({
protocol: "hunk-terminal-handoff-v1",
kind: "failed",
message: "x".repeat(2_000),
});
});
});
116 changes: 116 additions & 0 deletions src/core/process/terminalHandoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
const HANDOFF_ENV = "HUNK_TERMINAL_HANDOFF";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Filename violates dash-case rule

The new terminalHandoff.ts file uses camelCase, but the repository requires dash-case names for .ts and .tsx files. Rename it and its imports to terminal-handoff.ts; this repository requirement must be satisfied before merging.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/process/terminalHandoff.ts
Line: 1

Comment:
**Filename violates dash-case rule**

The new `terminalHandoff.ts` file uses camelCase, but the repository requires dash-case names for `.ts` and `.tsx` files. Rename it and its imports to `terminal-handoff.ts`; this repository requirement must be satisfied before merging.

**Context Used:** guidelines.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/guidelines.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change. This repository does not have a dash-case filename rule; the active project guidance and surrounding source use camelCase/PascalCase filenames extensively. In addition, terminalHandoff.ts is deleted by #992 when the cross-process handoff is replaced with the persistent-renderer architecture.

Responded by Pi using gpt-5.6-sol.

This comment was generated by Pi using gpt-5.6-sol

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — I incorrectly inferred a dash-case filename rule. The repository's active guidance and surrounding files support terminalHandoff.ts, and since this file is removed by #992 as part of the persistent-renderer follow-up, there is no change needed for this PR. I'm retracting this comment.

const HANDOFF_THEME_MODE_ENV = "HUNK_TERMINAL_HANDOFF_THEME_MODE";
const PROTOCOL = "hunk-terminal-handoff-v1";

export type TerminalHandoffMessage =
| { protocol: typeof PROTOCOL; kind: "ready" }
| { protocol: typeof PROTOCOL; kind: "release" }
| { protocol: typeof PROTOCOL; kind: "failed"; message: string };

/** Return whether this process was launched for a coordinated terminal handoff. */
export function hasTerminalHandoff(env: NodeJS.ProcessEnv = process.env) {
return env[HANDOFF_ENV] === "1";
}

/** Read the parent's already-detected terminal mode without querying the owned terminal again. */
export function terminalHandoffThemeMode(
env: NodeJS.ProcessEnv = process.env,
): "dark" | "light" | undefined {
if (!hasTerminalHandoff(env)) return undefined;
const value = env[HANDOFF_THEME_MODE_ENV];
Comment on lines +13 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Environment access bypasses Varlock

The new handoff helpers default directly to process.env, and the same direct access appears in the helpers at lines 70 and 100 and in reviewLaunch.ts at line 89. This violates the repository directive requiring type-safe, validated environment access through Varlock. Route these values through the prescribed environment boundary before merging.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/process/terminalHandoff.ts
Line: 13-20

Comment:
**Environment access bypasses Varlock**

The new handoff helpers default directly to `process.env`, and the same direct access appears in the helpers at lines 70 and 100 and in `reviewLaunch.ts` at line 89. This violates the repository directive requiring type-safe, validated environment access through Varlock. Route these values through the prescribed environment boundary before merging.

**Context Used:** guidelines.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/guidelines.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change. The cited Varlock directive belongs to a different repository and is not part of Hunk’s project guidance or environment architecture. The entire terminal-handoff module and its private environment protocol are also removed by #992.

Responded by Pi using gpt-5.6-sol.

This comment was generated by Pi using gpt-5.6-sol

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The Varlock directive isn't applicable to Hunk, and #992 removes terminalHandoff.ts and the private handoff environment protocol entirely. This comment is no longer actionable; no change is needed here.

return value === "dark" || value === "light" ? value : undefined;
}

/** Add the private one-shot handoff marker and terminal mode to a child environment. */
export function terminalHandoffEnv(
env: NodeJS.ProcessEnv,
themeMode: "dark" | "light" | undefined,
): NodeJS.ProcessEnv {
return {
...env,
[HANDOFF_ENV]: "1",
...(themeMode ? { [HANDOFF_THEME_MODE_ENV]: themeMode } : {}),
};
}

/** Narrow an IPC payload to one bounded handoff protocol message. */
export function parseTerminalHandoffMessage(value: unknown): TerminalHandoffMessage | undefined {
if (!value || typeof value !== "object") return undefined;
const message = value as Record<string, unknown>;
if (message.protocol !== PROTOCOL) return undefined;
if (message.kind === "ready" || message.kind === "release") {
return { protocol: PROTOCOL, kind: message.kind };
}
if (message.kind === "failed" && typeof message.message === "string") {
return { protocol: PROTOCOL, kind: "failed", message: message.message.slice(0, 2_000) };
}
return undefined;
}

/** Build one authenticated-by-inheritance IPC message for the handoff peer. */
export function terminalHandoffMessage(kind: "ready" | "release"): TerminalHandoffMessage {
return { protocol: PROTOCOL, kind };
}

/** Tell the parent startup succeeded, then wait boundedly for exclusive terminal ownership. */
export async function awaitTerminalHandoffRelease({
env = process.env,
timeoutMs = 10_000,
}: {
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
} = {}) {
if (!hasTerminalHandoff(env)) return;
if (typeof process.send !== "function" || !process.connected) {
throw new Error("The terminal handoff channel is unavailable.");
}

await new Promise<void>((resolve, reject) => {
let settled = false;
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
process.off("message", onMessage);
process.off("disconnect", onDisconnect);
if (error) reject(error);
else resolve();
};
const onMessage = (value: unknown) => {
const message = parseTerminalHandoffMessage(value);
if (message?.kind === "release") finish();
};
const onDisconnect = () => finish(new Error("The terminal handoff parent disconnected."));
const timeout = setTimeout(
() => finish(new Error("Timed out waiting for terminal ownership.")),
timeoutMs,
);
timeout.unref?.();
process.on("message", onMessage);
process.once("disconnect", onDisconnect);
process.send!(terminalHandoffMessage("ready"), (error) => {
if (error) finish(error);
});
});
process.disconnect?.();
delete env[HANDOFF_ENV];
delete env[HANDOFF_THEME_MODE_ENV];
}

/** Report a bounded pre-render startup failure to a waiting parent. */
export async function reportTerminalHandoffFailure(
error: unknown,
env: NodeJS.ProcessEnv = process.env,
) {
if (!hasTerminalHandoff(env) || typeof process.send !== "function" || !process.connected) {
return false;
}
const message = error instanceof Error ? error.message : String(error);
await new Promise<void>((resolve) => {
process.send!({ protocol: PROTOCOL, kind: "failed", message: message.slice(0, 2_000) }, () =>
resolve(),
);
});
process.disconnect?.();
return true;
}
25 changes: 18 additions & 7 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { prepareStartupPlan } from "./app/startup";
import { sanitizeTerminalText } from "./lib/terminalText";
import { serveSessionBrokerDaemon } from "./session/broker/brokerServer";
import { runSessionCommand } from "./session/agent/commands";
import {
awaitTerminalHandoffRelease,
reportTerminalHandoffFailure,
} from "./core/process/terminalHandoff";

async function main() {
const startupPlan = await prepareStartupPlan();
Expand Down Expand Up @@ -128,11 +132,18 @@ async function main() {
}

// OpenTUI stays behind the interactive plan so headless commands never materialize its embedded
// native library. The highlighting client starts the compiled worker only when an opted-in,
// eligible diff needs it, so normal sessions do not pay its startup cost. The interactive
// app owns that worker's disposal: this call returns once the app is mounted, not once it exits.
// native library. Load it before declaring a delegated review ready so terminal release is
// followed immediately by renderer creation rather than another module-loading gap.
const { runInteractiveApp } = await import("./ui/runInteractiveApp");

// A history parent keeps its loading frame mounted until review bootstrap and renderer code are
// ready. Wait for exclusive terminal ownership before mounting the child renderer.
await awaitTerminalHandoffRelease();

// The highlighting client starts the compiled worker only when an opted-in, eligible diff needs
// it, so normal sessions do not pay its startup cost. The interactive app owns that worker's
// disposal: this call returns once the app is mounted, not once it exits.
try {
const { runInteractiveApp } = await import("./ui/runInteractiveApp");
await runInteractiveApp(startupPlan);
} catch (error) {
startupPlan.controllingTerminal?.close();
Expand All @@ -143,7 +154,7 @@ async function main() {
}
}

await main().catch((error) => {
process.stderr.write(formatCliError(error));
process.exit(1);
await main().catch(async (error) => {
if (!(await reportTerminalHandoffFailure(error))) process.stderr.write(formatCliError(error));
process.exitCode = 1;
});
Loading
Loading