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
27 changes: 26 additions & 1 deletion src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe("config resolution", () => {
});
});

test("falls back to the global config path outside a repo", () => {
test("defaults unspecified themes to graphite, including piped pager-style patch input", () => {
const home = createTempDir("hunk-config-home-");
const cwd = createTempDir("hunk-config-cwd-");

Expand All @@ -96,6 +96,7 @@ describe("config resolution", () => {
});

expect(resolved.repoConfigPath).toBeUndefined();
expect(resolved.input.options.theme).toBe("graphite");
});

test("command-specific config sections also apply to show mode", () => {
Expand Down Expand Up @@ -196,4 +197,28 @@ describe("config resolution", () => {
expect(bootstrap.initialShowHunkHeaders).toBe(false);
expect(bootstrap.initialShowAgentNotes).toBe(true);
});

test("loadAppBootstrap exposes graphite when no theme is configured", async () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
createRepo(repo);

const before = join(repo, "before.ts");
const after = join(repo, "after.ts");
writeFileSync(before, "export const alpha = 1;\n");
writeFileSync(after, "export const alpha = 2;\n");

const resolved = resolveConfiguredCliInput(
{
kind: "diff",
left: before,
right: after,
options: {},
},
{ cwd: repo, env: { HOME: home } },
);
const bootstrap = await loadAppBootstrap(resolved.input);

expect(bootstrap.initialTheme).toBe("graphite");
});
});
4 changes: 3 additions & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export function resolveConfiguredCliInput(

let resolvedOptions: CommonOptions = {
mode: DEFAULT_VIEW_PREFERENCES.mode,
theme: undefined,
// Keep the built-in theme default explicit so stdin-backed startup paths do not depend on
// renderer theme-mode detection for their initial palette.
theme: "graphite",
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? false,
Expand Down
54 changes: 33 additions & 21 deletions src/core/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CliInput } from "./types";
import {
openControllingTerminal,
resolveRuntimeCliInput,
shouldUseMouseForApp,
shouldUsePagerMode,
usesPipedPatchInput,
} from "./terminal";
Expand Down Expand Up @@ -41,49 +42,63 @@ describe("terminal runtime defaults", () => {
});
});

describe("app mouse support", () => {
test("enables mouse for interactive stdin", () => {
expect(
shouldUseMouseForApp({
stdinIsTTY: true,
hasControllingTerminal: false,
}),
).toBe(true);
});

test("enables mouse when a controlling terminal is attached", () => {
expect(
shouldUseMouseForApp({
stdinIsTTY: false,
hasControllingTerminal: true,
}),
).toBe(true);
});

test("disables mouse when no interactive terminal is available", () => {
expect(
shouldUseMouseForApp({
stdinIsTTY: false,
hasControllingTerminal: false,
}),
).toBe(false);
});
});

describe("controlling terminal attachment", () => {
test("opens /dev/tty for read and write and closes both streams", () => {
test("opens /dev/tty for read and closes the input stream", () => {
const calls: Array<[string, string]> = [];
let stdinDestroyed = false;
let stdoutDestroyed = false;

const stdin = {
destroy() {
stdinDestroyed = true;
},
} as never;
const stdout = {
destroy() {
stdoutDestroyed = true;
},
} as never;

const controllingTerminal = openControllingTerminal({
openSync(path, flags) {
calls.push([String(path), String(flags)]);
return flags === "r" ? 11 : 12;
return 11;
},
createReadStream(fd) {
expect(fd).toBe(11);
return stdin;
},
createWriteStream(fd) {
expect(fd).toBe(12);
return stdout;
},
});

expect(controllingTerminal).not.toBeNull();
expect(calls).toEqual([
["/dev/tty", "r"],
["/dev/tty", "w"],
]);
expect(calls).toEqual([["/dev/tty", "r"]]);
expect(controllingTerminal?.stdin).toBe(stdin);
expect(controllingTerminal?.stdout).toBe(stdout);

controllingTerminal?.close();
expect(stdinDestroyed).toBe(true);
expect(stdoutDestroyed).toBe(true);
});

test("returns null when the controlling terminal cannot be opened", () => {
Expand All @@ -94,9 +109,6 @@ describe("controlling terminal attachment", () => {
createReadStream() {
throw new Error("unreachable");
},
createWriteStream() {
throw new Error("unreachable");
},
});

expect(controllingTerminal).toBeNull();
Expand Down
25 changes: 17 additions & 8 deletions src/core/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import fs from "node:fs";
import tty from "node:tty";
import type { CliInput } from "./types";

export interface AppMouseOptions {
stdinIsTTY?: boolean;
hasControllingTerminal?: boolean;
}

/** Detect the stdin-pipe patch workflow used by `git diff` pagers. */
export function usesPipedPatchInput(input: CliInput, stdinIsTTY = Boolean(process.stdin.isTTY)) {
return input.kind === "patch" && (!input.file || input.file === "-") && !stdinIsTTY;
Expand All @@ -26,39 +31,43 @@ export function resolveRuntimeCliInput(
} as CliInput;
}

/** Keep mouse support tied to terminal interactivity instead of pager chrome mode. */
export function shouldUseMouseForApp({
stdinIsTTY = Boolean(process.stdin.isTTY),
hasControllingTerminal = false,
}: AppMouseOptions = {}) {
return stdinIsTTY || hasControllingTerminal;
}

export interface ControllingTerminal {
stdin: tty.ReadStream;
stdout: tty.WriteStream;
close: () => void;
}

/** Minimal terminal construction hooks so tests can cover `/dev/tty` attach behavior. */
export interface ControllingTerminalDeps {
openSync: typeof fs.openSync;
createReadStream: (fd: number) => tty.ReadStream;
createWriteStream: (fd: number) => tty.WriteStream;
}

/** Open the controlling terminal so the UI can stay interactive while stdin carries patch data. */
/**
* Open the controlling terminal for input so the UI can stay interactive while stdin carries patch
* data. Rendering can continue through the existing stdout stream.
*/
export function openControllingTerminal(
deps: ControllingTerminalDeps = {
openSync: fs.openSync,
createReadStream: (fd) => new tty.ReadStream(fd),
createWriteStream: (fd) => new tty.WriteStream(fd),
},
): ControllingTerminal | null {
try {
const stdinFd = deps.openSync("/dev/tty", "r");
const stdoutFd = deps.openSync("/dev/tty", "w");
const stdin = deps.createReadStream(stdinFd);
const stdout = deps.createWriteStream(stdoutFd);

return {
stdin,
stdout,
close: () => {
stdin.destroy();
stdout.destroy();
},
};
} catch {
Expand Down
9 changes: 6 additions & 3 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { formatCliError } from "./core/errors";
import { pagePlainText } from "./core/pager";
import { shutdownSession } from "./core/shutdown";
import { prepareStartupPlan } from "./core/startup";
import { shouldUseMouseForApp } from "./core/terminal";
import { resolveStartupUpdateNotice } from "./core/updateNotice";
import { AppHost } from "./ui/AppHost";
import { SessionBrokerClient } from "./session-broker/brokerClient";
Expand Down Expand Up @@ -50,7 +51,7 @@ async function main() {
throw new Error("Unreachable startup plan.");
}

const { bootstrap, cliInput, controllingTerminal } = startupPlan;
const { bootstrap, controllingTerminal } = startupPlan;
const hostClient = new SessionBrokerClient<
HunkSessionInfo,
HunkSessionState,
Expand All @@ -61,8 +62,10 @@ async function main() {

const renderer = await createCliRenderer({
stdin: controllingTerminal?.stdin,
stdout: controllingTerminal?.stdout,
useMouse: !cliInput.options.pager,
stdout: process.stdout,
useMouse: shouldUseMouseForApp({
hasControllingTerminal: Boolean(controllingTerminal),
}),
useAlternateScreen: true,
exitOnCtrlC: true,
openConsoleOnError: true,
Expand Down
4 changes: 2 additions & 2 deletions src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,13 @@ describe("ui helpers", () => {
).toBe(16);
});

test("resolveTheme falls back by requested id and renderer mode while lazily exposing syntax styles", () => {
test("resolveTheme falls back by requested id to graphite while lazily exposing syntax styles", () => {
const midnight = resolveTheme("midnight", null);
const missingLight = resolveTheme("missing", "light");
const missingDark = resolveTheme("missing", "dark");

expect(midnight.id).toBe("midnight");
expect(missingLight.id).toBe("paper");
expect(missingLight.id).toBe("graphite");
expect(missingDark.id).toBe("graphite");
expect(resolveTheme("ember", null).syntaxStyle).toBeDefined();
});
Expand Down
8 changes: 2 additions & 6 deletions src/ui/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,16 +286,12 @@ export const THEMES: AppTheme[] = [
),
];

/** Resolve a named theme or fall back to a theme that matches the renderer mode. */
export function resolveTheme(requested: string | undefined, themeMode: ThemeMode | null) {
/** Resolve a named theme or fall back to Hunk's explicit built-in default. */
export function resolveTheme(requested: string | undefined, _themeMode: ThemeMode | null) {
const exact = THEMES.find((theme) => theme.id === requested);
if (exact) {
return exact;
}

if (themeMode === "light") {
return THEMES.find((theme) => theme.id === "paper") ?? THEMES[0]!;
}

return THEMES.find((theme) => theme.id === "graphite") ?? THEMES[0]!;
}
67 changes: 67 additions & 0 deletions test/pty/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ function writeText(path: string, content: string) {
writeFileSync(path, content);
}

/** Quote shell arguments so PTY helpers can safely launch piped commands through Bash. */
function shellQuote(value: string) {
return `'${value.replaceAll("'", `'\\''`)}'`;
}

/** Build numbered export lines so PTY fixtures can assert on stable visible content. */
function createNumberedExportLines(start: number, count: number, valueOffset = 0) {
return Array.from({ length: count }, (_, index) => {
Expand Down Expand Up @@ -357,6 +362,17 @@ export function createPtyHarness() {
return { dir, patchFile };
}

/** Build the source-run Hunk command so PTY tests can reuse it inside shell pipelines. */
function buildHunkCommand(args: string[]) {
return [
shellQuote(bunExecutable),
"run",
shellQuote(sourceEntrypoint),
"--",
...args.map(shellQuote),
].join(" ");
}

async function launchHunk(options: {
args: string[];
cwd?: string;
Expand All @@ -381,6 +397,53 @@ export function createPtyHarness() {
});
}

/** Launch an arbitrary shell command inside the PTY for pipeline-style integration tests. */
async function launchShellCommand(options: {
command: string;
cwd?: string;
cols?: number;
rows?: number;
env?: Record<string, string | undefined>;
}) {
const { launchTerminal } = await loadTuistory();

Comment thread
elucid marked this conversation as resolved.
return launchTerminal({
command: "/bin/bash",
args: ["-c", options.command],
cwd: options.cwd ?? repoRoot,
cols: options.cols ?? 140,
rows: options.rows ?? 24,
env: {
...process.env,
HUNK_MCP_DISABLE: "1",
HUNK_DISABLE_UPDATE_NOTICE: "1",
...options.env,
},
});
}

/**
* Launch Hunk with a file-backed stdin while keeping stdout/stderr attached to the PTY.
* Uses `exec cmd < file` so bash replaces itself with Hunk, preserving the PTY on stdout/stderr
* and the controlling terminal while giving the child a non-TTY stdin.
*/
async function launchHunkWithFileBackedStdin(options: {
stdinFile: string;
args: string[];
cwd?: string;
cols?: number;
rows?: number;
env?: Record<string, string | undefined>;
}) {
return launchShellCommand({
command: `exec ${buildHunkCommand(options.args)} < ${shellQuote(options.stdinFile)}`,
cwd: options.cwd,
cols: options.cols,
rows: options.rows,
env: options.env,
});
}

async function waitForSnapshot(
session: Session,
predicate: (text: string) => boolean,
Expand Down Expand Up @@ -422,6 +485,10 @@ export function createPtyHarness() {
createSidebarJumpRepoFixture,
createTwoFileRepoFixture,
launchHunk,
launchHunkWithFileBackedStdin,
launchShellCommand,
buildHunkCommand,
shellQuote,
waitForSnapshot,
};
}
Loading
Loading