= {};
@@ -351,12 +374,14 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise = {};
- command.action((left: string, right: string, path: string | undefined, options: Record) => {
- parsedLeft = left;
- parsedRight = right;
- parsedPath = path;
- parsedOptions = options;
- });
+ command.action(
+ (left: string, right: string, path: string | undefined, options: Record) => {
+ parsedLeft = left;
+ parsedRight = right;
+ parsedPath = path;
+ parsedOptions = options;
+ },
+ );
if (tokens.includes("--help") || tokens.includes("-h")) {
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
@@ -379,28 +404,31 @@ async function parseSessionCommand(tokens: string[]): Promise {
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
return {
kind: "help",
- text: [
- "Usage: hunk session [options]",
- "",
- "Inspect and control live Hunk review sessions through the local daemon.",
- "",
- "Commands:",
- " hunk session list",
- " hunk session get ",
- " hunk session get --repo ",
- " hunk session context ",
- " hunk session context --repo ",
- " hunk session navigate --file (--hunk | --old-line | --new-line )",
- " hunk session comment add --file (--old-line | --new-line ) --summary ",
- " hunk session comment list ",
- " hunk session comment rm ",
- " hunk session comment clear --yes",
- ].join("\n") + "\n",
+ text:
+ [
+ "Usage: hunk session [options]",
+ "",
+ "Inspect and control live Hunk review sessions through the local daemon.",
+ "",
+ "Commands:",
+ " hunk session list",
+ " hunk session get ",
+ " hunk session get --repo ",
+ " hunk session context ",
+ " hunk session context --repo ",
+ " hunk session navigate --file (--hunk | --old-line | --new-line )",
+ " hunk session comment add --file (--old-line | --new-line ) --summary ",
+ " hunk session comment list ",
+ " hunk session comment rm ",
+ " hunk session comment clear --yes",
+ ].join("\n") + "\n",
};
}
if (subcommand === "list") {
- const command = new Command("session list").description("list live Hunk sessions").option("--json", "emit structured JSON");
+ const command = new Command("session list")
+ .description("list live Hunk sessions")
+ .option("--json", "emit structured JSON");
let parsedOptions: { json?: boolean } = {};
command.action((options: { json?: boolean }) => {
@@ -421,7 +449,11 @@ async function parseSessionCommand(tokens: string[]): Promise {
if (subcommand === "get" || subcommand === "context") {
const command = new Command(`session ${subcommand}`)
- .description(subcommand === "get" ? "show one live Hunk session" : "show the selected file and hunk for one live Hunk session")
+ .description(
+ subcommand === "get"
+ ? "show one live Hunk session"
+ : "show the selected file and hunk for one live Hunk session",
+ )
.argument("[sessionId]")
.option("--repo ", "target the live session whose repo root matches this path")
.option("--json", "emit structured JSON");
@@ -459,12 +491,31 @@ async function parseSessionCommand(tokens: string[]): Promise {
.option("--json", "emit structured JSON");
let parsedSessionId: string | undefined;
- let parsedOptions: { repo?: string; file: string; hunk?: number; oldLine?: number; newLine?: number; json?: boolean } = { file: "" };
-
- command.action((sessionId: string | undefined, options: { repo?: string; file: string; hunk?: number; oldLine?: number; newLine?: number; json?: boolean }) => {
- parsedSessionId = sessionId;
- parsedOptions = options;
- });
+ let parsedOptions: {
+ repo?: string;
+ file: string;
+ hunk?: number;
+ oldLine?: number;
+ newLine?: number;
+ json?: boolean;
+ } = { file: "" };
+
+ command.action(
+ (
+ sessionId: string | undefined,
+ options: {
+ repo?: string;
+ file: string;
+ hunk?: number;
+ oldLine?: number;
+ newLine?: number;
+ json?: boolean;
+ },
+ ) => {
+ parsedSessionId = sessionId;
+ parsedOptions = options;
+ },
+ );
if (rest.includes("--help") || rest.includes("-h")) {
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
@@ -472,9 +523,15 @@ async function parseSessionCommand(tokens: string[]): Promise {
await parseStandaloneCommand(command, rest);
- const selectors = [parsedOptions.hunk !== undefined, parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean);
+ const selectors = [
+ parsedOptions.hunk !== undefined,
+ parsedOptions.oldLine !== undefined,
+ parsedOptions.newLine !== undefined,
+ ].filter(Boolean);
if (selectors.length !== 1) {
- throw new Error("Specify exactly one navigation target: --hunk , --old-line , or --new-line .");
+ throw new Error(
+ "Specify exactly one navigation target: --hunk , --old-line , or --new-line .",
+ );
}
return {
@@ -484,7 +541,12 @@ async function parseSessionCommand(tokens: string[]): Promise {
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
filePath: parsedOptions.file,
hunkNumber: parsedOptions.hunk,
- side: parsedOptions.oldLine !== undefined ? "old" : parsedOptions.newLine !== undefined ? "new" : undefined,
+ side:
+ parsedOptions.oldLine !== undefined
+ ? "old"
+ : parsedOptions.newLine !== undefined
+ ? "new"
+ : undefined,
line: parsedOptions.oldLine ?? parsedOptions.newLine,
};
}
@@ -494,13 +556,14 @@ async function parseSessionCommand(tokens: string[]): Promise {
if (!commentSubcommand || commentSubcommand === "--help" || commentSubcommand === "-h") {
return {
kind: "help",
- text: [
- "Usage:",
- " hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary ",
- " hunk session comment list ( | --repo ) [--file ]",
- " hunk session comment rm ( | --repo ) ",
- " hunk session comment clear ( | --repo ) [--file ] --yes",
- ].join("\n") + "\n",
+ text:
+ [
+ "Usage:",
+ " hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary ",
+ " hunk session comment list ( | --repo ) [--file ]",
+ " hunk session comment rm ( | --repo ) ",
+ " hunk session comment clear ( | --repo ) [--file ] --yes",
+ ].join("\n") + "\n",
};
}
@@ -535,20 +598,25 @@ async function parseSessionCommand(tokens: string[]): Promise {
summary: "",
};
- command.action((sessionId: string | undefined, options: {
- repo?: string;
- file: string;
- summary: string;
- oldLine?: number;
- newLine?: number;
- rationale?: string;
- author?: string;
- reveal?: boolean;
- json?: boolean;
- }) => {
- parsedSessionId = sessionId;
- parsedOptions = options;
- });
+ command.action(
+ (
+ sessionId: string | undefined,
+ options: {
+ repo?: string;
+ file: string;
+ summary: string;
+ oldLine?: number;
+ newLine?: number;
+ rationale?: string;
+ author?: string;
+ reveal?: boolean;
+ json?: boolean;
+ },
+ ) => {
+ parsedSessionId = sessionId;
+ parsedOptions = options;
+ },
+ );
if (commentRest.includes("--help") || commentRest.includes("-h")) {
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
@@ -556,7 +624,10 @@ async function parseSessionCommand(tokens: string[]): Promise {
await parseStandaloneCommand(command, commentRest);
- const selectors = [parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean);
+ const selectors = [
+ parsedOptions.oldLine !== undefined,
+ parsedOptions.newLine !== undefined,
+ ].filter(Boolean);
if (selectors.length !== 1) {
throw new Error("Specify exactly one comment target: --old-line or --new-line .");
}
@@ -587,10 +658,15 @@ async function parseSessionCommand(tokens: string[]): Promise {
let parsedSessionId: string | undefined;
let parsedOptions: { repo?: string; file?: string; json?: boolean } = {};
- command.action((sessionId: string | undefined, options: { repo?: string; file?: string; json?: boolean }) => {
- parsedSessionId = sessionId;
- parsedOptions = options;
- });
+ command.action(
+ (
+ sessionId: string | undefined,
+ options: { repo?: string; file?: string; json?: boolean },
+ ) => {
+ parsedSessionId = sessionId;
+ parsedOptions = options;
+ },
+ );
if (commentRest.includes("--help") || commentRest.includes("-h")) {
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
@@ -619,11 +695,17 @@ async function parseSessionCommand(tokens: string[]): Promise {
let parsedCommentId = "";
let parsedOptions: { repo?: string; json?: boolean } = {};
- command.action((sessionId: string | undefined, commentId: string, options: { repo?: string; json?: boolean }) => {
- parsedSessionId = sessionId;
- parsedCommentId = commentId;
- parsedOptions = options;
- });
+ command.action(
+ (
+ sessionId: string | undefined,
+ commentId: string,
+ options: { repo?: string; json?: boolean },
+ ) => {
+ parsedSessionId = sessionId;
+ parsedCommentId = commentId;
+ parsedOptions = options;
+ },
+ );
if (commentRest.includes("--help") || commentRest.includes("-h")) {
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
@@ -652,10 +734,15 @@ async function parseSessionCommand(tokens: string[]): Promise {
let parsedSessionId: string | undefined;
let parsedOptions: { repo?: string; file?: string; yes?: boolean; json?: boolean } = {};
- command.action((sessionId: string | undefined, options: { repo?: string; file?: string; yes?: boolean; json?: boolean }) => {
- parsedSessionId = sessionId;
- parsedOptions = options;
- });
+ command.action(
+ (
+ sessionId: string | undefined,
+ options: { repo?: string; file?: string; yes?: boolean; json?: boolean },
+ ) => {
+ parsedSessionId = sessionId;
+ parsedOptions = options;
+ },
+ );
if (commentRest.includes("--help") || commentRest.includes("-h")) {
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
@@ -688,16 +775,17 @@ async function parseMcpCommand(tokens: string[]): Promise {
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
return {
kind: "help",
- text: [
- "Usage: hunk mcp serve",
- "",
- "Run the local Hunk session daemon and websocket session broker.",
- "",
- "Environment:",
- " HUNK_MCP_HOST bind host (default 127.0.0.1; loopback only unless explicitly overridden)",
- " HUNK_MCP_PORT bind port (default 47657)",
- " HUNK_MCP_UNSAFE_ALLOW_REMOTE set to 1 to allow non-loopback binding (unsafe)",
- ].join("\n") + "\n",
+ text:
+ [
+ "Usage: hunk mcp serve",
+ "",
+ "Run the local Hunk session daemon and websocket session broker.",
+ "",
+ "Environment:",
+ " HUNK_MCP_HOST bind host (default 127.0.0.1; loopback only unless explicitly overridden)",
+ " HUNK_MCP_PORT bind port (default 47657)",
+ " HUNK_MCP_UNSAFE_ALLOW_REMOTE set to 1 to allow non-loopback binding (unsafe)",
+ ].join("\n") + "\n",
};
}
@@ -708,11 +796,12 @@ async function parseMcpCommand(tokens: string[]): Promise {
if (rest.includes("--help") || rest.includes("-h")) {
return {
kind: "help",
- text: [
- "Usage: hunk mcp serve",
- "",
- "Run the local Hunk session daemon and websocket session broker.",
- ].join("\n") + "\n",
+ text:
+ [
+ "Usage: hunk mcp serve",
+ "",
+ "Run the local Hunk session daemon and websocket session broker.",
+ ].join("\n") + "\n",
};
}
@@ -727,15 +816,16 @@ async function parseStashCommand(tokens: string[], argv: string[]): Promise = {};
diff --git a/src/core/config.ts b/src/core/config.ts
index 7167b2c6..ec4ce640 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -2,7 +2,15 @@ import fs from "node:fs";
import { dirname, join, resolve } from "node:path";
import type { CliInput, CommonOptions, LayoutMode, PersistedViewPreferences } from "./types";
-const CONFIG_SECTION_NAMES = ["pager", "git", "diff", "show", "stash-show", "patch", "difftool"] as const;
+const CONFIG_SECTION_NAMES = [
+ "pager",
+ "git",
+ "diff",
+ "show",
+ "stash-show",
+ "patch",
+ "difftool",
+] as const;
const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
mode: "auto",
showLineNumbers: true,
@@ -20,7 +28,6 @@ interface HunkConfigResolution {
input: CliInput;
globalConfigPath?: string;
repoConfigPath?: string;
-
}
function isRecord(value: unknown): value is Record {
@@ -152,11 +159,17 @@ export function resolveConfiguredCliInput(
};
if (userConfigPath) {
- resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(readTomlRecord(userConfigPath), input));
+ resolvedOptions = mergeOptions(
+ resolvedOptions,
+ resolveConfigLayer(readTomlRecord(userConfigPath), input),
+ );
}
if (repoConfigPath) {
- resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(readTomlRecord(repoConfigPath), input));
+ resolvedOptions = mergeOptions(
+ resolvedOptions,
+ resolveConfigLayer(readTomlRecord(repoConfigPath), input),
+ );
}
resolvedOptions = mergeOptions(resolvedOptions, input.options);
@@ -180,4 +193,3 @@ export function resolveConfiguredCliInput(
repoConfigPath,
};
}
-
diff --git a/src/core/liveComments.ts b/src/core/liveComments.ts
index 0c483974..f1712d22 100644
--- a/src/core/liveComments.ts
+++ b/src/core/liveComments.ts
@@ -4,8 +4,14 @@ import type { CommentToolInput, DiffSide, LiveComment } from "../mcp/types";
/** Compute the inclusive old/new line spans touched by one hunk. */
export function hunkLineRange(hunk: Hunk) {
- const newEnd = Math.max(hunk.additionStart, hunk.additionStart + Math.max(hunk.additionLines, 1) - 1);
- const oldEnd = Math.max(hunk.deletionStart, hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1);
+ const newEnd = Math.max(
+ hunk.additionStart,
+ hunk.additionStart + Math.max(hunk.additionLines, 1) - 1,
+ );
+ const oldEnd = Math.max(
+ hunk.deletionStart,
+ hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1,
+ );
return {
oldRange: [hunk.deletionStart, oldEnd] as [number, number],
diff --git a/src/core/loaders.ts b/src/core/loaders.ts
index 6da387cb..f11c6867 100644
--- a/src/core/loaders.ts
+++ b/src/core/loaders.ts
@@ -129,7 +129,10 @@ function findPatchChunk(metadata: FileDiffMetadata, chunks: string[], index: num
[metadata.name, metadata.prevName]
.filter((value): value is string => Boolean(value))
.map(stripPrefixes)
- .some((path) => chunk.includes(`a/${path}`) || chunk.includes(`b/${path}`) || chunk.includes(path)),
+ .some(
+ (path) =>
+ chunk.includes(`a/${path}`) || chunk.includes(`b/${path}`) || chunk.includes(path),
+ ),
) ?? ""
);
}
@@ -222,19 +225,33 @@ function normalizePatchChangeset(
id: `changeset:${Date.now()}`,
sourceLabel,
title,
- summary: parsedPatches.map((entry) => entry.patchMetadata).filter(Boolean).join("\n\n") || undefined,
+ summary:
+ parsedPatches
+ .map((entry) => entry.patchMetadata)
+ .filter(Boolean)
+ .join("\n\n") || undefined,
agentSummary: agentContext?.summary,
files: metadataFiles.map((metadata, index) =>
- buildDiffFile(metadata, findPatchChunk(metadata, chunks, index), index, sourceLabel, agentContext),
+ buildDiffFile(
+ metadata,
+ findPatchChunk(metadata, chunks, index),
+ index,
+ sourceLabel,
+ agentContext,
+ ),
),
};
}
/** Build a changeset by diffing two concrete files on disk. */
-async function loadFileDiffChangeset(input: FileCommandInput | DiffToolCommandInput, agentContext: AgentContext | null) {
+async function loadFileDiffChangeset(
+ input: FileCommandInput | DiffToolCommandInput,
+ agentContext: AgentContext | null,
+) {
const leftText = await Bun.file(input.left).text();
const rightText = await Bun.file(input.right).text();
- const displayPath = input.kind === "difftool" ? input.path ?? basename(input.right) : basename(input.right);
+ const displayPath =
+ input.kind === "difftool" ? (input.path ?? basename(input.right)) : basename(input.right);
const title =
input.kind === "difftool"
? `git difftool: ${displayPath}`
@@ -323,7 +340,10 @@ async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentCon
}
/** Build a changeset from `git stash show -p`, which naturally maps to one reviewable patch. */
-async function loadStashShowChangeset(input: StashShowCommandInput, agentContext: AgentContext | null) {
+async function loadStashShowChangeset(
+ input: StashShowCommandInput,
+ agentContext: AgentContext | null,
+) {
const repoRoot = spawnText(["git", "rev-parse", "--show-toplevel"]).trim();
const repoName = basename(repoRoot);
const args = ["git", "stash", "show", "-p", "--find-renames", "--no-color"];
@@ -349,7 +369,12 @@ async function loadPatchChangeset(input: PatchCommandInput, agentContext: AgentC
: await Bun.file(input.file).text());
const label = input.file && input.file !== "-" ? input.file : "stdin patch";
- return normalizePatchChangeset(patchText, `Patch review: ${basename(label)}`, label, agentContext);
+ return normalizePatchChangeset(
+ patchText,
+ `Patch review: ${basename(label)}`,
+ label,
+ agentContext,
+ );
}
/** Resolve CLI input into the fully loaded app bootstrap state. */
diff --git a/src/core/startup.ts b/src/core/startup.ts
index d6d36de8..72d0079a 100644
--- a/src/core/startup.ts
+++ b/src/core/startup.ts
@@ -1,7 +1,12 @@
import { resolveConfiguredCliInput } from "./config";
import { loadAppBootstrap } from "./loaders";
import { looksLikePatchInput } from "./pager";
-import { openControllingTerminal, resolveRuntimeCliInput, usesPipedPatchInput, type ControllingTerminal } from "./terminal";
+import {
+ openControllingTerminal,
+ resolveRuntimeCliInput,
+ usesPipedPatchInput,
+ type ControllingTerminal,
+} from "./terminal";
import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types";
import { parseCli } from "./cli";
@@ -48,7 +53,8 @@ export async function prepareStartupPlan(
const readStdinText = deps.readStdinText ?? (() => new Response(Bun.stdin.stream()).text());
const looksLikePatchInputImpl = deps.looksLikePatchInputImpl ?? looksLikePatchInput;
const resolveRuntimeCliInputImpl = deps.resolveRuntimeCliInputImpl ?? resolveRuntimeCliInput;
- const resolveConfiguredCliInputImpl = deps.resolveConfiguredCliInputImpl ?? resolveConfiguredCliInput;
+ const resolveConfiguredCliInputImpl =
+ deps.resolveConfiguredCliInputImpl ?? resolveConfiguredCliInput;
const loadAppBootstrapImpl = deps.loadAppBootstrapImpl ?? loadAppBootstrap;
const usesPipedPatchInputImpl = deps.usesPipedPatchInputImpl ?? usesPipedPatchInput;
const openControllingTerminalImpl = deps.openControllingTerminalImpl ?? openControllingTerminal;
@@ -100,7 +106,9 @@ export async function prepareStartupPlan(
const configured = resolveConfiguredCliInputImpl(runtimeCliInput);
const cliInput = configured.input;
const bootstrap = await loadAppBootstrapImpl(cliInput);
- const controllingTerminal = usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null;
+ const controllingTerminal = usesPipedPatchInputImpl(cliInput)
+ ? openControllingTerminalImpl()
+ : null;
return {
kind: "app",
diff --git a/src/core/terminal.ts b/src/core/terminal.ts
index 5e99a143..8e4da0d8 100644
--- a/src/core/terminal.ts
+++ b/src/core/terminal.ts
@@ -13,7 +13,10 @@ export function shouldUsePagerMode(input: CliInput, stdinIsTTY = Boolean(process
}
/** Apply runtime CLI defaults that depend on whether stdin is an interactive terminal. */
-export function resolveRuntimeCliInput(input: CliInput, stdinIsTTY = Boolean(process.stdin.isTTY)): CliInput {
+export function resolveRuntimeCliInput(
+ input: CliInput,
+ stdinIsTTY = Boolean(process.stdin.isTTY),
+): CliInput {
return {
...input,
options: {
diff --git a/src/core/types.ts b/src/core/types.ts
index ff04beaf..53e08167 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -214,7 +214,12 @@ export type CliInput =
| PatchCommandInput
| DiffToolCommandInput;
-export type ParsedCliInput = CliInput | HelpCommandInput | PagerCommandInput | McpServeCommandInput | SessionCommandInput;
+export type ParsedCliInput =
+ | CliInput
+ | HelpCommandInput
+ | PagerCommandInput
+ | McpServeCommandInput
+ | SessionCommandInput;
export interface AppBootstrap {
input: CliInput;
diff --git a/src/main.tsx b/src/main.tsx
index be4bcf79..8427878b 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -38,7 +38,10 @@ if (startupPlan.kind !== "app") {
}
const { bootstrap, cliInput, controllingTerminal } = startupPlan;
-const hostClient = new HunkHostClient(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap));
+const hostClient = new HunkHostClient(
+ createSessionRegistration(bootstrap),
+ createInitialSessionSnapshot(bootstrap),
+);
hostClient.start();
const renderer = await createCliRenderer({
@@ -66,10 +69,4 @@ function shutdown() {
}
// The app owns the full alternate screen session from this point on.
-root.render(
- ,
-);
+root.render();
diff --git a/src/mcp/client.ts b/src/mcp/client.ts
index 779c6933..6879a477 100644
--- a/src/mcp/client.ts
+++ b/src/mcp/client.ts
@@ -9,8 +9,17 @@ import type {
SessionCommandResult,
SessionServerMessage,
} from "./types";
-import { HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig, type ResolvedHunkMcpConfig } from "./config";
-import { isHunkDaemonHealthy, isLoopbackPortReachable, launchHunkDaemon, waitForHunkDaemonHealth } from "./daemonLauncher";
+import {
+ HUNK_SESSION_SOCKET_PATH,
+ resolveHunkMcpConfig,
+ type ResolvedHunkMcpConfig,
+} from "./config";
+import {
+ isHunkDaemonHealthy,
+ isLoopbackPortReachable,
+ launchHunkDaemon,
+ waitForHunkDaemonHealth,
+} from "./daemonLauncher";
const DAEMON_LAUNCH_COOLDOWN_MS = 5_000;
const DAEMON_STARTUP_TIMEOUT_MS = 3_000;
@@ -18,10 +27,18 @@ const RECONNECT_DELAY_MS = 3_000;
const HEARTBEAT_INTERVAL_MS = 10_000;
interface HunkAppBridge {
- applyComment: (message: Extract) => Promise;
- navigateToHunk: (message: Extract) => Promise;
- removeComment: (message: Extract) => Promise;
- clearComments: (message: Extract) => Promise;
+ applyComment: (
+ message: Extract,
+ ) => Promise;
+ navigateToHunk: (
+ message: Extract,
+ ) => Promise;
+ removeComment: (
+ message: Extract,
+ ) => Promise;
+ clearComments: (
+ message: Extract,
+ ) => Promise;
}
/** Keep one running Hunk TUI session registered with the local MCP daemon. */
diff --git a/src/mcp/daemonLauncher.ts b/src/mcp/daemonLauncher.ts
index 4ca604ee..adf9f6e8 100644
--- a/src/mcp/daemonLauncher.ts
+++ b/src/mcp/daemonLauncher.ts
@@ -11,7 +11,10 @@ export interface DaemonLaunchCommand {
}
/** Resolve how the current Hunk process should launch a sibling `hunk mcp serve` daemon. */
-export function resolveDaemonLaunchCommand(argv = process.argv, execPath = process.execPath): DaemonLaunchCommand {
+export function resolveDaemonLaunchCommand(
+ argv = process.argv,
+ execPath = process.execPath,
+): DaemonLaunchCommand {
const entrypoint = argv[1];
if (entrypoint && !entrypoint.startsWith("-") && SCRIPT_ENTRYPOINT_PATTERN.test(entrypoint)) {
@@ -28,7 +31,10 @@ export function resolveDaemonLaunchCommand(argv = process.argv, execPath = proce
}
/** Check whether the loopback Hunk daemon already answers health probes. */
-export async function isHunkDaemonHealthy(config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(), timeoutMs = 500) {
+export async function isHunkDaemonHealthy(
+ config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
+ timeoutMs = 500,
+) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
timeout.unref?.();
diff --git a/src/mcp/daemonState.ts b/src/mcp/daemonState.ts
index 2dae9118..2b3fb629 100644
--- a/src/mcp/daemonState.ts
+++ b/src/mcp/daemonState.ts
@@ -48,11 +48,14 @@ function describeSessionChoices(sessions: ListedSession[]) {
}
function findSelectedFile(session: ListedSession) {
- return session.files.find(
- (file) => file.id === session.snapshot.selectedFileId
- || file.path === session.snapshot.selectedFilePath
- || file.previousPath === session.snapshot.selectedFilePath,
- ) ?? null;
+ return (
+ session.files.find(
+ (file) =>
+ file.id === session.snapshot.selectedFileId ||
+ file.path === session.snapshot.selectedFilePath ||
+ file.previousPath === session.snapshot.selectedFilePath,
+ ) ?? null
+ );
}
/** Resolve which live Hunk session one external command should target. */
@@ -87,7 +90,9 @@ export function resolveSessionTarget(sessions: ListedSession[], selector: Sessio
}
if (sessions.length === 0) {
- throw new Error("No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect.");
+ throw new Error(
+ "No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect.",
+ );
}
throw new Error(
@@ -162,7 +167,11 @@ export class HunkDaemonState {
return this.pendingCommands.size;
}
- registerSession(socket: DaemonSessionSocket, registration: HunkSessionRegistration, snapshot: HunkSessionSnapshot) {
+ registerSession(
+ socket: DaemonSessionSocket,
+ registration: HunkSessionRegistration,
+ snapshot: HunkSessionSnapshot,
+ ) {
const previousSessionId = this.sessionIdsBySocket.get(socket);
if (previousSessionId && previousSessionId !== registration.sessionId) {
this.unregisterSocket(socket);
@@ -171,7 +180,10 @@ export class HunkDaemonState {
const existing = this.sessions.get(registration.sessionId);
if (existing && existing.socket !== socket) {
this.sessionIdsBySocket.delete(existing.socket);
- this.rejectPendingCommandsForSession(registration.sessionId, new Error("Hunk session reconnected before the command completed."));
+ this.rejectPendingCommandsForSession(
+ registration.sessionId,
+ new Error("Hunk session reconnected before the command completed."),
+ );
}
const now = new Date().toISOString();
@@ -219,13 +231,7 @@ export class HunkDaemonState {
this.removeSession(sessionId, "The targeted Hunk session disconnected.");
}
- pruneStaleSessions({
- ttlMs,
- now = Date.now(),
- }: {
- ttlMs: number;
- now?: number;
- }) {
+ pruneStaleSessions({ ttlMs, now = Date.now() }: { ttlMs: number; now?: number }) {
let removed = 0;
const cutoff = now - ttlMs;
@@ -235,7 +241,10 @@ export class HunkDaemonState {
continue;
}
- this.removeSession(sessionId, "The targeted Hunk session became stale and was removed from the MCP daemon.");
+ this.removeSession(
+ sessionId,
+ "The targeted Hunk session became stale and was removed from the MCP daemon.",
+ );
removed += 1;
}
@@ -278,7 +287,12 @@ export class HunkDaemonState {
);
}
- handleCommandResult(message: { requestId: string; ok: boolean; result?: SessionCommandResult; error?: string }) {
+ handleCommandResult(message: {
+ requestId: string;
+ ok: boolean;
+ result?: SessionCommandResult;
+ error?: string;
+ }) {
const pending = this.pendingCommands.get(message.requestId);
if (!pending) {
return;
@@ -306,7 +320,10 @@ export class HunkDaemonState {
this.sessions.clear();
}
- private sendCommand(
+ private sendCommand<
+ ResultType extends SessionCommandResult,
+ CommandName extends SessionServerMessage["command"],
+ >(
selector: SessionTargetInput,
command: CommandName,
input: Extract["input"],
@@ -348,7 +365,11 @@ export class HunkDaemonState {
} catch (error) {
clearTimeout(timeout);
this.pendingCommands.delete(requestId);
- reject(error instanceof Error ? error : new Error("The targeted Hunk session could not receive the command."));
+ reject(
+ error instanceof Error
+ ? error
+ : new Error("The targeted Hunk session could not receive the command."),
+ );
}
});
}
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index 8673860f..4cd69d64 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -29,9 +29,9 @@ function formatDaemonServeError(error: unknown, host: string, port: number) {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
if (
- normalized.includes("eaddrinuse")
- || normalized.includes("address already in use")
- || normalized.includes(`is port ${port} in use?`)
+ normalized.includes("eaddrinuse") ||
+ normalized.includes("address already in use") ||
+ normalized.includes(`is port ${port} in use?`)
) {
return new Error(
`Hunk MCP daemon could not bind ${host}:${port} because the port is already in use. ` +
@@ -81,7 +81,10 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request)
response = { context: state.getSelectedContext(input.selector) };
break;
case "navigate": {
- if (input.hunkNumber === undefined && (input.side === undefined || input.line === undefined)) {
+ if (
+ input.hunkNumber === undefined &&
+ (input.side === undefined || input.line === undefined)
+ ) {
throw new Error("navigate requires either hunkNumber or both side and line.");
}
@@ -186,7 +189,10 @@ export function serveHunkMcpServer() {
}
if (url.pathname === "/mcp") {
- return jsonError("Hunk no longer exposes agent-facing MCP tools. Use `hunk session ...` instead.", 410);
+ return jsonError(
+ "Hunk no longer exposes agent-facing MCP tools. Use `hunk session ...` instead.",
+ 410,
+ );
}
if (url.pathname === HUNK_SESSION_SOCKET_PATH) {
diff --git a/src/mcp/sessionRegistration.ts b/src/mcp/sessionRegistration.ts
index 4868c827..573ff248 100644
--- a/src/mcp/sessionRegistration.ts
+++ b/src/mcp/sessionRegistration.ts
@@ -4,7 +4,9 @@ import { hunkLineRange } from "../core/liveComments";
import type { HunkSessionRegistration, HunkSessionSnapshot } from "./types";
function inferRepoRoot(bootstrap: AppBootstrap) {
- return bootstrap.input.kind === "git" || bootstrap.input.kind === "show" || bootstrap.input.kind === "stash-show"
+ return bootstrap.input.kind === "git" ||
+ bootstrap.input.kind === "show" ||
+ bootstrap.input.kind === "stash-show"
? bootstrap.changeset.sourceLabel
: undefined;
}
diff --git a/src/mcp/types.ts b/src/mcp/types.ts
index 4ad247dc..50b7bb27 100644
--- a/src/mcp/types.ts
+++ b/src/mcp/types.ts
@@ -135,7 +135,11 @@ export interface SelectedSessionContext {
liveCommentCount: number;
}
-export type SessionCommandResult = AppliedCommentResult | NavigatedSelectionResult | RemovedCommentResult | ClearedCommentsResult;
+export type SessionCommandResult =
+ | AppliedCommentResult
+ | NavigatedSelectionResult
+ | RemovedCommentResult
+ | ClearedCommentsResult;
export type SessionClientMessage =
| {
diff --git a/src/session/commands.ts b/src/session/commands.ts
index 31485ec5..e51ba6fd 100644
--- a/src/session/commands.ts
+++ b/src/session/commands.ts
@@ -9,7 +9,12 @@ import type {
SessionNavigateCommandInput,
SessionSelectorInput,
} from "../core/types";
-import { isHunkDaemonHealthy, isLoopbackPortReachable, launchHunkDaemon, waitForHunkDaemonHealth } from "../mcp/daemonLauncher";
+import {
+ isHunkDaemonHealthy,
+ isLoopbackPortReachable,
+ launchHunkDaemon,
+ waitForHunkDaemonHealth,
+} from "../mcp/daemonLauncher";
import { resolveHunkMcpConfig } from "../mcp/config";
import type {
AppliedCommentResult,
@@ -55,7 +60,10 @@ const REQUIRED_ACTION_BY_COMMAND: Record HunkDaemonCliClient;
resolveDaemonAvailability?: (action: SessionCommandInput["action"]) => Promise;
- restartDaemonForMissingAction?: (action: SessionDaemonAction, selector?: SessionSelectorInput) => Promise;
+ restartDaemonForMissingAction?: (
+ action: SessionDaemonAction,
+ selector?: SessionSelectorInput,
+ ) => Promise;
}
let sessionCommandTestHooks: SessionCommandTestHooks | null = null;
@@ -127,7 +135,9 @@ class HttpHunkDaemonCliClient implements HunkDaemonCliClient {
}
async getSelectedContext(selector: SessionSelectorInput) {
- return (await this.request<{ context: SelectedSessionContext }>({ action: "context", selector })).context;
+ return (
+ await this.request<{ context: SelectedSessionContext }>({ action: "context", selector })
+ ).context;
}
async navigateToHunk(input: SessionNavigateCommandInput) {
@@ -261,7 +271,10 @@ async function waitForSessionRegistration(selector?: SessionSelectorInput, timeo
return false;
}
-async function restartDaemonForMissingAction(action: SessionDaemonAction, selector?: SessionSelectorInput) {
+async function restartDaemonForMissingAction(
+ action: SessionDaemonAction,
+ selector?: SessionSelectorInput,
+) {
const health = await readDaemonHealth();
const pid = health?.pid;
const hadSessions = (health?.sessions ?? 0) > 0;
@@ -276,7 +289,9 @@ async function restartDaemonForMissingAction(action: SessionDaemonAction, select
const shutDown = await waitForDaemonShutdown();
if (!shutDown) {
- throw new Error(`Stopped waiting for the old Hunk session daemon to exit after it was found missing ${action}.`);
+ throw new Error(
+ `Stopped waiting for the old Hunk session daemon to exit after it was found missing ${action}.`,
+ );
}
launchHunkDaemon();
@@ -290,7 +305,9 @@ async function restartDaemonForMissingAction(action: SessionDaemonAction, select
if (selector || hadSessions) {
const registered = await waitForSessionRegistration(selector);
if (!registered) {
- throw new Error("Timed out waiting for the live Hunk session to reconnect after refreshing the session daemon.");
+ throw new Error(
+ "Timed out waiting for the live Hunk session to reconnect after refreshing the session daemon.",
+ );
}
}
}
@@ -302,10 +319,8 @@ async function ensureRequiredAction(action: SessionDaemonAction, selector?: Sess
return;
}
- await (
- sessionCommandTestHooks?.restartDaemonForMissingAction?.(action, selector)
- ?? restartDaemonForMissingAction(action, selector)
- );
+ await (sessionCommandTestHooks?.restartDaemonForMissingAction?.(action, selector) ??
+ restartDaemonForMissingAction(action, selector));
}
function stringifyJson(value: unknown) {
@@ -336,13 +351,15 @@ function formatListOutput(sessions: ListedSession[]) {
}
return `${sessions
- .map((session) => [
- `${session.sessionId} ${session.title}`,
- ` repo: ${session.repoRoot ?? session.cwd}`,
- ` focus: ${formatSelectedSummary(session)}`,
- ` files: ${session.fileCount}`,
- ` comments: ${session.snapshot.liveCommentCount}`,
- ].join("\n"))
+ .map((session) =>
+ [
+ `${session.sessionId} ${session.title}`,
+ ` repo: ${session.repoRoot ?? session.cwd}`,
+ ` focus: ${formatSelectedSummary(session)}`,
+ ` files: ${session.fileCount}`,
+ ` comments: ${session.snapshot.liveCommentCount}`,
+ ].join("\n"),
+ )
.join("\n\n")}\n`;
}
@@ -358,7 +375,10 @@ function formatSessionOutput(session: ListedSession) {
`Agent notes visible: ${session.snapshot.showAgentNotes ? "yes" : "no"}`,
`Live comments: ${session.snapshot.liveCommentCount}`,
"Files:",
- ...session.files.map((file) => ` - ${file.path} (+${file.additions} -${file.deletions}, hunks: ${file.hunkCount})`),
+ ...session.files.map(
+ (file) =>
+ ` - ${file.path} (+${file.additions} -${file.deletions}, hunks: ${file.hunkCount})`,
+ ),
"",
].join("\n");
}
@@ -366,8 +386,12 @@ function formatSessionOutput(session: ListedSession) {
function formatContextOutput(context: SelectedSessionContext) {
const selectedFile = context.selectedFile?.path ?? "(none)";
const hunkNumber = context.selectedHunk ? context.selectedHunk.index + 1 : 0;
- const oldRange = context.selectedHunk?.oldRange ? `${context.selectedHunk.oldRange[0]}..${context.selectedHunk.oldRange[1]}` : "-";
- const newRange = context.selectedHunk?.newRange ? `${context.selectedHunk.newRange[0]}..${context.selectedHunk.newRange[1]}` : "-";
+ const oldRange = context.selectedHunk?.oldRange
+ ? `${context.selectedHunk.oldRange[0]}..${context.selectedHunk.oldRange[1]}`
+ : "-";
+ const newRange = context.selectedHunk?.newRange
+ ? `${context.selectedHunk.newRange[0]}..${context.selectedHunk.newRange[1]}`
+ : "-";
return [
`Session: ${context.sessionId}`,
@@ -391,18 +415,23 @@ function formatCommentOutput(selector: SessionSelectorInput, result: AppliedComm
return `Added live comment ${result.commentId} on ${result.filePath}:${result.line} (${result.side}) in hunk ${result.hunkIndex + 1} for ${formatSelector(selector)}.\n`;
}
-function formatCommentListOutput(selector: SessionSelectorInput, comments: SessionLiveCommentSummary[]) {
+function formatCommentListOutput(
+ selector: SessionSelectorInput,
+ comments: SessionLiveCommentSummary[],
+) {
if (comments.length === 0) {
return `No live comments for ${formatSelector(selector)}.\n`;
}
return `${comments
- .map((comment) => [
- `${comment.commentId} ${comment.filePath}:${comment.line} (${comment.side})`,
- ` hunk: ${comment.hunkIndex + 1}`,
- ` summary: ${comment.summary}`,
- ...(comment.author ? [` author: ${comment.author}`] : []),
- ].join("\n"))
+ .map((comment) =>
+ [
+ `${comment.commentId} ${comment.filePath}:${comment.line} (${comment.side})`,
+ ` hunk: ${comment.hunkIndex + 1}`,
+ ` summary: ${comment.summary}`,
+ ...(comment.author ? [` author: ${comment.author}`] : []),
+ ].join("\n"),
+ )
.join("\n\n")}\n`;
}
@@ -411,7 +440,9 @@ function formatRemoveCommentOutput(selector: SessionSelectorInput, result: Remov
}
function formatClearCommentsOutput(selector: SessionSelectorInput, result: ClearedCommentsResult) {
- const scope = result.filePath ? `${result.filePath} in ${formatSelector(selector)}` : formatSelector(selector);
+ const scope = result.filePath
+ ? `${result.filePath} in ${formatSelector(selector)}`
+ : formatSelector(selector);
return `Cleared ${result.removedCount} live comments from ${scope}. Remaining comments: ${result.remainingCommentCount}.\n`;
}
@@ -445,7 +476,9 @@ async function resolveDaemonAvailability(action: SessionCommandInput["action"])
return false;
}
- throw new Error("No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect.");
+ throw new Error(
+ "No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect.",
+ );
}
function renderOutput(output: SessionCommandOutput, value: unknown, formatText: () => string) {
@@ -453,13 +486,18 @@ function renderOutput(output: SessionCommandOutput, value: unknown, formatText:
}
export async function runSessionCommand(input: SessionCommandInput) {
- const daemonAvailable = await (sessionCommandTestHooks?.resolveDaemonAvailability?.(input.action) ?? resolveDaemonAvailability(input.action));
+ const daemonAvailable = await (sessionCommandTestHooks?.resolveDaemonAvailability?.(
+ input.action,
+ ) ?? resolveDaemonAvailability(input.action));
if (!daemonAvailable && input.action === "list") {
return renderOutput(input.output, { sessions: [] }, () => formatListOutput([]));
}
const normalizedSelector = "selector" in input ? normalizeRepoRoot(input.selector) : null;
- await ensureRequiredAction(REQUIRED_ACTION_BY_COMMAND[input.action], normalizedSelector ?? undefined);
+ await ensureRequiredAction(
+ REQUIRED_ACTION_BY_COMMAND[input.action],
+ normalizedSelector ?? undefined,
+ );
const client = createDaemonCliClient();
@@ -481,35 +519,45 @@ export async function runSessionCommand(input: SessionCommandInput) {
...input,
selector: normalizedSelector!,
});
- return renderOutput(input.output, { result }, () => formatNavigationOutput(input.selector, result));
+ return renderOutput(input.output, { result }, () =>
+ formatNavigationOutput(input.selector, result),
+ );
}
case "comment-add": {
const result = await client.addComment({
...input,
selector: normalizedSelector!,
});
- return renderOutput(input.output, { result }, () => formatCommentOutput(input.selector, result));
+ return renderOutput(input.output, { result }, () =>
+ formatCommentOutput(input.selector, result),
+ );
}
case "comment-list": {
const comments = await client.listComments({
...input,
selector: normalizedSelector!,
});
- return renderOutput(input.output, { comments }, () => formatCommentListOutput(input.selector, comments));
+ return renderOutput(input.output, { comments }, () =>
+ formatCommentListOutput(input.selector, comments),
+ );
}
case "comment-rm": {
const result = await client.removeComment({
...input,
selector: normalizedSelector!,
});
- return renderOutput(input.output, { result }, () => formatRemoveCommentOutput(input.selector, result));
+ return renderOutput(input.output, { result }, () =>
+ formatRemoveCommentOutput(input.selector, result),
+ );
}
case "comment-clear": {
const result = await client.clearComments({
...input,
selector: normalizedSelector!,
});
- return renderOutput(input.output, { result }, () => formatClearCommentsOutput(input.selector, result));
+ return renderOutput(input.output, { result }, () =>
+ formatClearCommentsOutput(input.selector, result),
+ );
}
}
}
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 00df7255..2dfbfe5f 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -1,6 +1,21 @@
-import { MouseButton, type KeyEvent, type MouseEvent as TuiMouseEvent, type ScrollBoxRenderable } from "@opentui/core";
+import {
+ MouseButton,
+ type KeyEvent,
+ type MouseEvent as TuiMouseEvent,
+ type ScrollBoxRenderable,
+} from "@opentui/core";
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
-import { Suspense, lazy, startTransition, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Suspense,
+ lazy,
+ startTransition,
+ useCallback,
+ useDeferredValue,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import type { AppBootstrap, LayoutMode } from "../core/types";
import { HunkHostClient } from "../mcp/client";
import { MenuBar } from "./components/chrome/MenuBar";
@@ -21,8 +36,12 @@ import { resolveTheme, THEMES } from "./themes";
type FocusArea = "files" | "filter";
-const LazyHelpDialog = lazy(async () => ({ default: (await import("./components/chrome/HelpDialog")).HelpDialog }));
-const LazyMenuDropdown = lazy(async () => ({ default: (await import("./components/chrome/MenuDropdown")).MenuDropdown }));
+const LazyHelpDialog = lazy(async () => ({
+ default: (await import("./components/chrome/HelpDialog")).HelpDialog,
+}));
+const LazyMenuDropdown = lazy(async () => ({
+ default: (await import("./components/chrome/MenuDropdown")).MenuDropdown,
+}));
/** Clamp a value into an inclusive range. */
function clamp(value: number, min: number, max: number) {
@@ -50,7 +69,9 @@ export function App({
const filesScrollRef = useRef(null);
const diffScrollRef = useRef(null);
const [layoutMode, setLayoutMode] = useState(bootstrap.initialMode);
- const [themeId, setThemeId] = useState(() => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id);
+ const [themeId, setThemeId] = useState(
+ () => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id,
+ );
const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false);
const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true);
const [wrapLines, setWrapLines] = useState(bootstrap.initialWrapLines ?? false);
@@ -82,7 +103,9 @@ export function App({
setShowAgentNotes(true);
}, []);
- const baseSelectedFile = bootstrap.changeset.files.find((file) => file.id === selectedFileId) ?? bootstrap.changeset.files[0];
+ const baseSelectedFile =
+ bootstrap.changeset.files.find((file) => file.id === selectedFileId) ??
+ bootstrap.changeset.files[0];
const { liveCommentsByFileId } = useHunkSessionBridge({
currentHunk: baseSelectedFile?.metadata.hunks[selectedHunkIndex],
files: bootstrap.changeset.files,
@@ -119,11 +142,17 @@ export function App({
return true;
}
- const haystack = [file.path, file.previousPath, file.agent?.summary].filter(Boolean).join(" ").toLowerCase();
+ const haystack = [file.path, file.previousPath, file.agent?.summary]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
return haystack.includes(deferredFilter.trim().toLowerCase());
});
- const selectedFile = filteredFiles.find((file) => file.id === selectedFileId) ?? allFiles.find((file) => file.id === selectedFileId) ?? filteredFiles[0];
+ const selectedFile =
+ filteredFiles.find((file) => file.id === selectedFileId) ??
+ allFiles.find((file) => file.id === selectedFileId) ??
+ filteredFiles[0];
const hunkCursors = buildHunkCursors(filteredFiles);
const bodyPadding = pagerMode ? 0 : BODY_PADDING;
@@ -132,14 +161,21 @@ export function App({
const canForceShowFilesPane = bodyWidth >= FILES_MIN_WIDTH + DIVIDER_WIDTH + DIFF_MIN_WIDTH;
const showFilesPane = pagerMode
? false
- : sidebarVisible && (responsiveLayout.showFilesPane || (forceSidebarOpen && canForceShowFilesPane));
+ : sidebarVisible &&
+ (responsiveLayout.showFilesPane || (forceSidebarOpen && canForceShowFilesPane));
const centerWidth = bodyWidth;
const resolvedLayout = responsiveLayout.layout;
const currentHunk = selectedFile?.metadata.hunks[selectedHunkIndex];
const activeAnnotations = getSelectedAnnotations(selectedFile, currentHunk);
- const availableCenterWidth = showFilesPane ? Math.max(0, centerWidth - DIVIDER_WIDTH) : Math.max(0, centerWidth);
- const maxFilesPaneWidth = showFilesPane ? Math.max(FILES_MIN_WIDTH, availableCenterWidth - DIFF_MIN_WIDTH) : FILES_MIN_WIDTH;
- const clampedFilesPaneWidth = showFilesPane ? clamp(filesPaneWidth, FILES_MIN_WIDTH, maxFilesPaneWidth) : 0;
+ const availableCenterWidth = showFilesPane
+ ? Math.max(0, centerWidth - DIVIDER_WIDTH)
+ : Math.max(0, centerWidth);
+ const maxFilesPaneWidth = showFilesPane
+ ? Math.max(FILES_MIN_WIDTH, availableCenterWidth - DIFF_MIN_WIDTH)
+ : FILES_MIN_WIDTH;
+ const clampedFilesPaneWidth = showFilesPane
+ ? clamp(filesPaneWidth, FILES_MIN_WIDTH, maxFilesPaneWidth)
+ : 0;
const diffPaneWidth = showFilesPane
? Math.max(DIFF_MIN_WIDTH, availableCenterWidth - clampedFilesPaneWidth)
: Math.max(0, availableCenterWidth);
@@ -171,7 +207,11 @@ export function App({
return;
}
- if (selectedFile && !filteredFiles.some((file) => file.id === selectedFile.id) && filteredFiles[0]) {
+ if (
+ selectedFile &&
+ !filteredFiles.some((file) => file.id === selectedFile.id) &&
+ filteredFiles[0]
+ ) {
startTransition(() => {
setSelectedFileId(filteredFiles[0]!.id);
setSelectedHunkIndex(0);
@@ -378,7 +418,13 @@ export function App({
}
setFilesPaneWidth(
- resizeSidebarWidth(resizeStartWidth, resizeDragOriginX, event.x, FILES_MIN_WIDTH, maxFilesPaneWidth),
+ resizeSidebarWidth(
+ resizeStartWidth,
+ resizeDragOriginX,
+ event.x,
+ FILES_MIN_WIDTH,
+ maxFilesPaneWidth,
+ ),
);
event.preventDefault();
event.stopPropagation();
@@ -397,8 +443,14 @@ export function App({
};
const fileEntries = filteredFiles.map(buildFileListEntry);
- const totalAdditions = bootstrap.changeset.files.reduce((sum, file) => sum + file.stats.additions, 0);
- const totalDeletions = bootstrap.changeset.files.reduce((sum, file) => sum + file.stats.deletions, 0);
+ const totalAdditions = bootstrap.changeset.files.reduce(
+ (sum, file) => sum + file.stats.additions,
+ 0,
+ );
+ const totalDeletions = bootstrap.changeset.files.reduce(
+ (sum, file) => sum + file.stats.deletions,
+ 0,
+ );
const topTitle = `${bootstrap.changeset.title} +${totalAdditions} -${totalDeletions}`;
const helpWidth = Math.min(68, Math.max(44, terminal.width - 8));
const helpLeft = Math.max(1, Math.floor((terminal.width - helpWidth) / 2));
@@ -409,7 +461,8 @@ export function App({
const diffSeparatorWidth = Math.max(4, diffContentWidth - 2);
useKeyboard((key: KeyEvent) => {
- const pageDownKey = key.name === "pagedown" || key.name === "space" || key.name === " " || key.sequence === " ";
+ const pageDownKey =
+ key.name === "pagedown" || key.name === "space" || key.name === " " || key.sequence === " ";
const pageUpKey = key.name === "pageup" || key.name === "b" || key.sequence === "b";
const stepDownKey = key.name === "down" || key.name === "j" || key.sequence === "j";
const stepUpKey = key.name === "up" || key.name === "k" || key.sequence === "k";
@@ -777,7 +830,12 @@ export function App({
{!pagerMode && showHelp ? (
- setShowHelp(false)} />
+ setShowHelp(false)}
+ />
) : null}
diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx
index 6b74de75..d5ee0363 100644
--- a/src/ui/components/chrome/HelpDialog.tsx
+++ b/src/ui/components/chrome/HelpDialog.tsx
@@ -31,11 +31,13 @@ export function HelpDialog({
onMouseUp={onClose}
>
Keyboard
- F10 menus arrows navigate menus Enter select Esc close menu
- 1 split 2 stack 0 auto t theme a notes l lines w wrap m meta
- ↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk
+ F10 menus arrows navigate menus Enter select Esc close menu
+ 1 split 2 stack 0 auto t theme a notes l lines w wrap m meta
+
+ ↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk
+
drag the Files/Diff divider with the mouse to resize the columns
- / focus filter Tab swap files/filter q quit
+ / focus filter Tab swap files/filter q quit
click anywhere on this panel to close
);
diff --git a/src/ui/components/chrome/MenuDropdown.tsx b/src/ui/components/chrome/MenuDropdown.tsx
index 293343db..a885b660 100644
--- a/src/ui/components/chrome/MenuDropdown.tsx
+++ b/src/ui/components/chrome/MenuDropdown.tsx
@@ -9,12 +9,17 @@ function renderMenuLine(
theme: AppTheme,
selected: boolean,
) {
- const text = entry.checked === undefined ? ` ${entry.label}` : `${entry.checked ? "[x]" : "[ ]"} ${entry.label}`;
+ const text =
+ entry.checked === undefined
+ ? ` ${entry.label}`
+ : `${entry.checked ? "[x]" : "[ ]"} ${entry.label}`;
const hint = entry.hint ? entry.hint : "";
const leftWidth = Math.max(0, width - hint.length - (hint.length > 0 ? 1 : 0));
return (
-
+
{padText(text, leftWidth)}
@@ -64,8 +69,13 @@ export function MenuDropdown({
>
{activeMenuEntries.map((entry, index) =>
entry.kind === "separator" ? (
-
- {padText("-".repeat(activeMenuWidth - 4), activeMenuWidth - 2)}
+
+
+ {padText("-".repeat(activeMenuWidth - 4), activeMenuWidth - 2)}
+
) : (
) : (
- {fitText(`${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`, terminalWidth - 2)}
+ {fitText(
+ `${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`,
+ terminalWidth - 2,
+ )}
)}
diff --git a/src/ui/components/panes/AgentCard.tsx b/src/ui/components/panes/AgentCard.tsx
index 5c4151b4..b996059d 100644
--- a/src/ui/components/panes/AgentCard.tsx
+++ b/src/ui/components/panes/AgentCard.tsx
@@ -65,7 +65,10 @@ export function AgentCard({
{popover.summaryLines.map((line, index) => (
-
+
{padText(line, popover.innerWidth)}
))}
@@ -76,7 +79,10 @@ export function AgentCard({
{" ".repeat(popover.innerWidth)}
{popover.rationaleLines.map((line, index) => (
-
+
{padText(line, popover.innerWidth)}
))}
diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx
index fe1d1b8e..19740594 100644
--- a/src/ui/components/panes/DiffPane.tsx
+++ b/src/ui/components/panes/DiffPane.tsx
@@ -24,7 +24,9 @@ function noteAnchorColumn(
note: VisibleAgentNote,
) {
if (layout === "split") {
- return note.annotation.oldRange && !note.annotation.newRange ? 1 : Math.max(2, Math.floor(width * 0.58));
+ return note.annotation.oldRange && !note.annotation.newRange
+ ? 1
+ : Math.max(2, Math.floor(width * 0.58));
}
return showLineNumbers ? Math.max(2, String(maxLineNumber(file)).length + 4) : 2;
@@ -151,7 +153,9 @@ export function DiffPane({
const nextHeight = scrollRef.current?.viewport.height ?? 0;
setScrollViewport((current) =>
- current.top === nextTop && current.height === nextHeight ? current : { top: nextTop, height: nextHeight },
+ current.top === nextTop && current.height === nextHeight
+ ? current
+ : { top: nextTop, height: nextHeight },
);
};
@@ -187,9 +191,19 @@ export function DiffPane({
}
sectionTop += (selectedFileIndex > 0 ? 1 : 0) + 1;
- const anchorRowTop = sectionTop + estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex);
- const anchorColumn = noteAnchorColumn(selectedFile, layout, diffContentWidth, showLineNumbers, note);
- const noteWidth = Math.min(Math.max(34, Math.floor(diffContentWidth * 0.42)), Math.max(12, diffContentWidth - 2));
+ const anchorRowTop =
+ sectionTop + estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex);
+ const anchorColumn = noteAnchorColumn(
+ selectedFile,
+ layout,
+ diffContentWidth,
+ showLineNumbers,
+ note,
+ );
+ const noteWidth = Math.min(
+ Math.max(34, Math.floor(diffContentWidth * 0.42)),
+ Math.max(12, diffContentWidth - 2),
+ );
const locationLabel = annotationLocationLabel(selectedFile, note.annotation);
const popover = buildAgentPopoverContent({
summary: note.annotation.summary,
@@ -222,7 +236,17 @@ export function DiffPane({
top: placement.top,
locationLabel,
};
- }, [diffContentWidth, estimatedBodyHeights, files, layout, selectedFileId, selectedHunkIndex, showHunkHeaders, showLineNumbers, visibleAgentNotesByFile]);
+ }, [
+ diffContentWidth,
+ estimatedBodyHeights,
+ files,
+ layout,
+ selectedFileId,
+ selectedHunkIndex,
+ showHunkHeaders,
+ showLineNumbers,
+ visibleAgentNotesByFile,
+ ]);
const visibleViewportFileIds = useMemo(() => {
const overscanRows = 8;
@@ -264,10 +288,14 @@ export function DiffPane({
return next;
}, [adjacentPrefetchFileIds, selectedFileId, visibleViewportFileIds, windowingEnabled]);
- const selectedFileIndex = selectedFileId ? files.findIndex((file) => file.id === selectedFileId) : -1;
+ const selectedFileIndex = selectedFileId
+ ? files.findIndex((file) => file.id === selectedFileId)
+ : -1;
const selectedFile = selectedFileIndex >= 0 ? files[selectedFileIndex] : undefined;
const selectedAnchorId = selectedFile
- ? (selectedFile.metadata.hunks[selectedHunkIndex] ? diffHunkId(selectedFile.id, selectedHunkIndex) : diffSectionId(selectedFile.id))
+ ? selectedFile.metadata.hunks[selectedHunkIndex]
+ ? diffHunkId(selectedFile.id, selectedHunkIndex)
+ : diffSectionId(selectedFile.id)
: null;
const selectedEstimatedScrollTop = useMemo(() => {
if (!selectedFile || selectedFileIndex < 0) {
@@ -286,7 +314,15 @@ export function DiffPane({
top += 1;
top += estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex);
return top;
- }, [estimatedBodyHeights, files, layout, selectedFile, selectedFileIndex, selectedHunkIndex, showHunkHeaders]);
+ }, [
+ estimatedBodyHeights,
+ files,
+ layout,
+ selectedFile,
+ selectedFileIndex,
+ selectedHunkIndex,
+ showHunkHeaders,
+ ]);
useLayoutEffect(() => {
if (!selectedAnchorId) {
@@ -317,7 +353,14 @@ export function DiffPane({
return () => {
timeouts.forEach((timeout) => clearTimeout(timeout));
};
- }, [scrollRef, scrollViewport.height, selectedAnchorId, selectedEstimatedScrollTop, visibleAgentNotesByFile.size, wrapLines]);
+ }, [
+ scrollRef,
+ scrollViewport.height,
+ selectedAnchorId,
+ selectedEstimatedScrollTop,
+ visibleAgentNotesByFile.size,
+ wrapLines,
+ ]);
return (
-
+
{files.map((file, index) => {
const shouldRenderSection = visibleWindowedFileIds?.has(file.id) ?? true;
const shouldPrefetchVisibleHighlight =
@@ -364,9 +414,13 @@ export function DiffPane({
selected={file.id === selectedFileId}
selectedHunkIndex={file.id === selectedFileId ? selectedHunkIndex : -1}
shouldLoadHighlight={
- file.id === selectedFileId || adjacentPrefetchFileIds.has(file.id) || shouldPrefetchVisibleHighlight
+ file.id === selectedFileId ||
+ adjacentPrefetchFileIds.has(file.id) ||
+ shouldPrefetchVisibleHighlight
+ }
+ onHighlightReady={
+ file.id === selectedFileId ? handleSelectedHighlightReady : undefined
}
- onHighlightReady={file.id === selectedFileId ? handleSelectedHighlightReady : undefined}
separatorWidth={separatorWidth}
showSeparator={index > 0}
showLineNumbers={showLineNumbers}
@@ -394,7 +448,14 @@ export function DiffPane({
);
})}
{selectedFileId && selectedOverlayNote ? (
-
+
{/* Clicking the file header jumps the main stream selection without collapsing to a single-file view. */}
{fitText(fileLabel(file), headerLabelWidth)}
-
+
{additionsText}
{deletionsText}
diff --git a/src/ui/components/panes/DiffSectionPlaceholder.tsx b/src/ui/components/panes/DiffSectionPlaceholder.tsx
index 9c66afb5..52660e67 100644
--- a/src/ui/components/panes/DiffSectionPlaceholder.tsx
+++ b/src/ui/components/panes/DiffSectionPlaceholder.tsx
@@ -65,7 +65,14 @@ export function DiffSectionPlaceholder({
onMouseUp={onSelect}
>
{fitText(fileLabel(file), headerLabelWidth)}
-
+
{additionsText}
{deletionsText}
diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx
index 34097631..c7176b57 100644
--- a/src/ui/diff/PierreDiffView.tsx
+++ b/src/ui/diff/PierreDiffView.tsx
@@ -5,7 +5,13 @@ import { diffHunkId } from "../lib/ids";
import type { AppTheme } from "../themes";
import { buildSelectedOverlayNote, renderAgentPopover } from "./agentNoteOverlay";
import { buildSplitRows, buildStackRows } from "./pierre";
-import { diffMessage, DiffRowView, findMaxLineNumber, fitText, measureRenderedRowHeight } from "./renderRows";
+import {
+ diffMessage,
+ DiffRowView,
+ findMaxLineNumber,
+ fitText,
+ measureRenderedRowHeight,
+} from "./renderRows";
import { useHighlightedDiff } from "./useHighlightedDiff";
const EMPTY_ANNOTATED_HUNK_INDICES = new Set();
@@ -53,7 +59,12 @@ export function PierreDiffView({
});
const rows = useMemo(
- () => (file ? (layout === "split" ? buildSplitRows(file, resolvedHighlighted, theme) : buildStackRows(file, resolvedHighlighted, theme)) : []),
+ () =>
+ file
+ ? layout === "split"
+ ? buildSplitRows(file, resolvedHighlighted, theme)
+ : buildStackRows(file, resolvedHighlighted, theme)
+ : [],
[file, layout, resolvedHighlighted, theme],
);
const hunkAnchorIds = useMemo(() => {
@@ -85,7 +96,15 @@ export function PierreDiffView({
let offset = 0;
for (const row of rows) {
- const height = measureRenderedRowHeight(row, width, lineNumberDigits, showLineNumbers, showHunkHeaders, wrapLines, theme);
+ const height = measureRenderedRowHeight(
+ row,
+ width,
+ lineNumberDigits,
+ showLineNumbers,
+ showHunkHeaders,
+ wrapLines,
+ theme,
+ );
metrics.set(row.key, { top: offset, height });
offset += height;
}
@@ -96,8 +115,25 @@ export function PierreDiffView({
};
}, [lineNumberDigits, rows, showHunkHeaders, showLineNumbers, theme, width, wrapLines]);
const selectedOverlayNote = useMemo(
- () => buildSelectedOverlayNote(rows, visibleAgentNotes, selectedHunkIndex, showHunkHeaders, width, lineNumberDigits, showLineNumbers),
- [lineNumberDigits, rows, selectedHunkIndex, showHunkHeaders, showLineNumbers, visibleAgentNotes, width],
+ () =>
+ buildSelectedOverlayNote(
+ rows,
+ visibleAgentNotes,
+ selectedHunkIndex,
+ showHunkHeaders,
+ width,
+ lineNumberDigits,
+ showLineNumbers,
+ ),
+ [
+ lineNumberDigits,
+ rows,
+ selectedHunkIndex,
+ showHunkHeaders,
+ showLineNumbers,
+ visibleAgentNotes,
+ width,
+ ],
);
if (!file) {
@@ -139,7 +175,15 @@ export function PierreDiffView({
const contentWithOverlay = (
{content}
- {renderAgentPopover(selectedOverlayNote, file, width, rowMetrics.contentHeight, rowMetrics.metrics, theme, onDismissAgentNote)}
+ {renderAgentPopover(
+ selectedOverlayNote,
+ file,
+ width,
+ rowMetrics.contentHeight,
+ rowMetrics.metrics,
+ theme,
+ onDismissAgentNote,
+ )}
);
diff --git a/src/ui/diff/agentNoteOverlay.tsx b/src/ui/diff/agentNoteOverlay.tsx
index f7e7ce70..aed731cb 100644
--- a/src/ui/diff/agentNoteOverlay.tsx
+++ b/src/ui/diff/agentNoteOverlay.tsx
@@ -33,27 +33,40 @@ function noteAnchor(annotation: AgentAnnotation) {
}
/** Check whether a rendered row is the visual anchor for a note. */
-function rowMatchesNote(row: Extract, note: VisibleAgentNote) {
+function rowMatchesNote(
+ row: Extract,
+ note: VisibleAgentNote,
+) {
const anchor = noteAnchor(note.annotation);
if (!anchor) {
return false;
}
if (row.type === "split-line") {
- return anchor.side === "new" ? row.right.lineNumber === anchor.lineNumber : row.left.lineNumber === anchor.lineNumber;
+ return anchor.side === "new"
+ ? row.right.lineNumber === anchor.lineNumber
+ : row.left.lineNumber === anchor.lineNumber;
}
- return anchor.side === "new" ? row.cell.newLineNumber === anchor.lineNumber : row.cell.oldLineNumber === anchor.lineNumber;
+ return anchor.side === "new"
+ ? row.cell.newLineNumber === anchor.lineNumber
+ : row.cell.oldLineNumber === anchor.lineNumber;
}
/** Resolve the rendered row for the currently visible popover note. */
-function findNoteAnchorRow(rows: DiffRow[], note: VisibleAgentNote, selectedHunkIndex: number, showHunkHeaders: boolean) {
+function findNoteAnchorRow(
+ rows: DiffRow[],
+ note: VisibleAgentNote,
+ selectedHunkIndex: number,
+ showHunkHeaders: boolean,
+) {
const selectedHunkRows = rows.filter((row) => row.hunkIndex === selectedHunkIndex);
const lineRows = selectedHunkRows.filter(
- (row): row is Extract => row.type === "split-line" || row.type === "stack-line",
+ (row): row is Extract =>
+ row.type === "split-line" || row.type === "stack-line",
);
const headerRow = selectedHunkRows.find((row) => row.type === "hunk-header");
- const firstVisibleRow = showHunkHeaders ? headerRow ?? lineRows[0] : lineRows[0] ?? headerRow;
+ const firstVisibleRow = showHunkHeaders ? (headerRow ?? lineRows[0]) : (lineRows[0] ?? headerRow);
return lineRows.find((row) => rowMatchesNote(row, note)) ?? firstVisibleRow;
}
@@ -160,7 +173,9 @@ export function renderAgentPopover(
summary={selectedOverlayNote.note.annotation.summary}
theme={theme}
width={noteWidth}
- onClose={onDismissAgentNote ? () => onDismissAgentNote(selectedOverlayNote.note.id) : undefined}
+ onClose={
+ onDismissAgentNote ? () => onDismissAgentNote(selectedOverlayNote.note.id) : undefined
+ }
/>
);
diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts
index 8a5828d0..1fa7b4df 100644
--- a/src/ui/diff/pierre.ts
+++ b/src/ui/diff/pierre.ts
@@ -111,7 +111,6 @@ function tabify(text: string) {
return text.replaceAll("\t", " ");
}
-
/** Parse an inline CSS style string from Pierre's highlighted HAST output. */
function parseStyleValue(styleValue: unknown) {
const styles = new Map();
@@ -153,7 +152,10 @@ function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) {
}
const normalized = color.trim().toLowerCase();
- const reserved = RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance]];
+ const reserved =
+ RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][
+ normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance]
+ ];
if (!reserved) {
return color;
}
@@ -204,7 +206,10 @@ function flattenHighlightedLine(
const styles = parseStyleValue(properties.style);
const nextStyle: Pick = {
// Newer Pierre output can emit direct `color:#...` styles instead of theme CSS variables.
- fg: normalizeHighlightedColor(styles.get(colorVariable) ?? styles.get("color") ?? inherited.fg, theme),
+ fg: normalizeHighlightedColor(
+ styles.get(colorVariable) ?? styles.get("color") ?? inherited.fg,
+ theme,
+ ),
// Pierre marks inline word-diff emphasis spans with a data attribute rather than a separate row kind.
bg: Object.hasOwn(properties, "data-diff-span") ? emphasisBg : inherited.bg,
};
@@ -249,11 +254,17 @@ function makeSplitCell(
// Startup renders often build rows before highlighted HAST exists, so keep that plain-text path cheap.
const spans =
highlightedLine === undefined
- ? (fallbackText.length > 0 ? [{ text: fallbackText }] : [])
+ ? fallbackText.length > 0
+ ? [{ text: fallbackText }]
+ : []
: flattenHighlightedLine(
highlightedLine,
theme,
- kind === "addition" ? theme.addedContentBg : kind === "deletion" ? theme.removedContentBg : theme.contextContentBg,
+ kind === "addition"
+ ? theme.addedContentBg
+ : kind === "deletion"
+ ? theme.removedContentBg
+ : theme.contextContentBg,
fallbackText,
);
@@ -279,11 +290,17 @@ function makeStackCell(
// Startup renders often build rows before highlighted HAST exists, so keep that plain-text path cheap.
const spans =
highlightedLine === undefined
- ? (fallbackText.length > 0 ? [{ text: fallbackText }] : [])
+ ? fallbackText.length > 0
+ ? [{ text: fallbackText }]
+ : []
: flattenHighlightedLine(
highlightedLine,
theme,
- kind === "addition" ? theme.addedContentBg : kind === "deletion" ? theme.removedContentBg : theme.contextContentBg,
+ kind === "addition"
+ ? theme.addedContentBg
+ : kind === "deletion"
+ ? theme.removedContentBg
+ : theme.contextContentBg,
fallbackText,
);
@@ -299,7 +316,8 @@ function makeStackCell(
/** Format a hunk header exactly as the review stream should display it. */
function hunkHeader(hunk: Hunk) {
const specs =
- hunk.hunkSpecs ?? `@@ -${hunk.deletionStart},${hunk.deletionLines} +${hunk.additionStart},${hunk.additionLines} @@`;
+ hunk.hunkSpecs ??
+ `@@ -${hunk.deletionStart},${hunk.deletionLines} +${hunk.additionStart},${hunk.additionLines} @@`;
return hunk.hunkContext ? `${specs} ${hunk.hunkContext}` : specs;
}
@@ -315,8 +333,10 @@ function trailingCollapsedLines(metadata: FileDiffMetadata) {
return 0;
}
- const additionRemaining = metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount);
- const deletionRemaining = metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount);
+ const additionRemaining =
+ metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount);
+ const deletionRemaining =
+ metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount);
if (additionRemaining !== deletionRemaining) {
return 0;
@@ -326,7 +346,10 @@ function trailingCollapsedLines(metadata: FileDiffMetadata) {
}
/** Prepare syntax highlighting for one language/appearance pair using Pierre's shared highlighter. */
-async function prepareHighlighter(language: string | undefined, appearance: AppTheme["appearance"]) {
+async function prepareHighlighter(
+ language: string | undefined,
+ appearance: AppTheme["appearance"],
+) {
const resolvedLanguage = language ?? "text";
const cacheKey = `${appearance}:${resolvedLanguage}`;
const options =
@@ -376,7 +399,11 @@ export async function loadHighlightedDiff(
try {
const highlighter = await prepareHighlighter(file.language, appearance);
return queueHighlightedDiff(() => {
- const highlighted = renderDiffWithHighlighter(file.metadata, highlighter, pierreRenderOptions(appearance));
+ const highlighted = renderDiffWithHighlighter(
+ file.metadata,
+ highlighter,
+ pierreRenderOptions(appearance),
+ );
return {
deletionLines: highlighted.code.deletionLines as Array,
additionLines: highlighted.code.additionLines as Array,
@@ -399,7 +426,11 @@ export async function loadHighlightedDiff(
}
/** Expand Pierre metadata into the flat split-view row stream consumed by the renderer. */
-export function buildSplitRows(file: DiffFile, highlighted: HighlightedDiffCode | null, theme: AppTheme): DiffRow[] {
+export function buildSplitRows(
+ file: DiffFile,
+ highlighted: HighlightedDiffCode | null,
+ theme: AppTheme,
+): DiffRow[] {
const rows: DiffRow[] = [];
const deletionLines = highlighted?.deletionLines ?? [];
const additionLines = highlighted?.additionLines ?? [];
@@ -514,7 +545,11 @@ export function buildSplitRows(file: DiffFile, highlighted: HighlightedDiffCode
}
/** Expand Pierre metadata into the flat stack-view row stream consumed by the renderer. */
-export function buildStackRows(file: DiffFile, highlighted: HighlightedDiffCode | null, theme: AppTheme): DiffRow[] {
+export function buildStackRows(
+ file: DiffFile,
+ highlighted: HighlightedDiffCode | null,
+ theme: AppTheme,
+): DiffRow[] {
const rows: DiffRow[] = [];
const deletionLines = highlighted?.deletionLines ?? [];
const additionLines = highlighted?.additionLines ?? [];
diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx
index c980e36f..40401f1b 100644
--- a/src/ui/diff/renderRows.tsx
+++ b/src/ui/diff/renderRows.tsx
@@ -207,7 +207,11 @@ function renderInlineSpans(
// Fold trailing padding into the last span when the colors already match.
// That keeps the output identical while avoiding one extra rendered span.
- if (lastSpan && (lastSpan.fg ?? fallbackColor) === fallbackColor && (lastSpan.bg ?? fallbackBg) === fallbackBg) {
+ if (
+ lastSpan &&
+ (lastSpan.fg ?? fallbackColor) === fallbackColor &&
+ (lastSpan.bg ?? fallbackBg) === fallbackBg
+ ) {
lastSpan.text += " ".repeat(padding);
padding = 0;
}
@@ -216,11 +220,21 @@ function renderInlineSpans(
return (
<>
{trimmed.map((span, index) => (
-
+
{span.text}
))}
- {padding > 0 ? {`${" ".repeat(padding)}`} : null}
+ {padding > 0 ? (
+ {`${" ".repeat(padding)}`}
+ ) : null}
>
);
}
@@ -294,7 +308,9 @@ function buildWrappedSplitCell(
const gutterWidth = Math.min(availableWidth, showLineNumbers ? lineNumberDigits + 3 : 2);
const contentWidth = Math.max(0, availableWidth - gutterWidth);
const firstGutterText = showLineNumbers
- ? `${cell.lineNumber ? String(cell.lineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits)} ${cell.sign}`.padEnd(gutterWidth)
+ ? `${cell.lineNumber ? String(cell.lineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits)} ${cell.sign}`.padEnd(
+ gutterWidth,
+ )
: `${cell.sign} `.padEnd(gutterWidth);
const wrappedSpans = wrapSpans(cell.spans, contentWidth);
@@ -321,9 +337,15 @@ function buildWrappedStackCell(
const availableWidth = Math.max(0, width - prefixWidth);
const gutterWidth = Math.min(availableWidth, showLineNumbers ? lineNumberDigits * 2 + 5 : 2);
const contentWidth = Math.max(0, availableWidth - gutterWidth);
- const oldNumber = cell.oldLineNumber ? String(cell.oldLineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits);
- const newNumber = cell.newLineNumber ? String(cell.newLineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits);
- const firstGutterText = (showLineNumbers ? `${oldNumber} ${newNumber} ${cell.sign}` : `${cell.sign} `).padEnd(gutterWidth);
+ const oldNumber = cell.oldLineNumber
+ ? String(cell.oldLineNumber).padStart(lineNumberDigits, " ")
+ : " ".repeat(lineNumberDigits);
+ const newNumber = cell.newLineNumber
+ ? String(cell.newLineNumber).padStart(lineNumberDigits, " ")
+ : " ".repeat(lineNumberDigits);
+ const firstGutterText = (
+ showLineNumbers ? `${oldNumber} ${newNumber} ${cell.sign}` : `${cell.sign} `
+ ).padEnd(gutterWidth);
const wrappedSpans = wrapSpans(cell.spans, contentWidth);
return {
@@ -356,7 +378,9 @@ function renderSplitCell(
const gutterWidth = Math.min(availableWidth, showLineNumbers ? lineNumberDigits + 3 : 2);
const contentWidth = Math.max(0, availableWidth - gutterWidth);
const gutterText = showLineNumbers
- ? `${cell.lineNumber ? String(cell.lineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits)} ${cell.sign}`.padEnd(gutterWidth)
+ ? `${cell.lineNumber ? String(cell.lineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits)} ${cell.sign}`.padEnd(
+ gutterWidth,
+ )
: `${cell.sign} `.padEnd(gutterWidth);
return (
@@ -369,7 +393,13 @@ function renderSplitCell(
{gutterText}
- {renderInlineSpans(cell.spans, contentWidth, theme.text, palette.contentBg, `${keyPrefix}:content`)}
+ {renderInlineSpans(
+ cell.spans,
+ contentWidth,
+ theme.text,
+ palette.contentBg,
+ `${keyPrefix}:content`,
+ )}
>
);
}
@@ -394,8 +424,12 @@ function renderStackCell(
const gutterWidth = Math.min(availableWidth, showLineNumbers ? lineNumberDigits * 2 + 5 : 2);
const contentWidth = Math.max(0, availableWidth - gutterWidth);
- const oldNumber = cell.oldLineNumber ? String(cell.oldLineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits);
- const newNumber = cell.newLineNumber ? String(cell.newLineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits);
+ const oldNumber = cell.oldLineNumber
+ ? String(cell.oldLineNumber).padStart(lineNumberDigits, " ")
+ : " ".repeat(lineNumberDigits);
+ const newNumber = cell.newLineNumber
+ ? String(cell.newLineNumber).padStart(lineNumberDigits, " ")
+ : " ".repeat(lineNumberDigits);
return (
<>
@@ -405,9 +439,17 @@ function renderStackCell(
) : null}
- {(showLineNumbers ? `${oldNumber} ${newNumber} ${cell.sign}` : `${cell.sign} `).padEnd(gutterWidth)}
+ {(showLineNumbers ? `${oldNumber} ${newNumber} ${cell.sign}` : `${cell.sign} `).padEnd(
+ gutterWidth,
+ )}
- {renderInlineSpans(cell.spans, contentWidth, theme.text, palette.contentBg, `${keyPrefix}:content`)}
+ {renderInlineSpans(
+ cell.spans,
+ contentWidth,
+ theme.text,
+ palette.contentBg,
+ `${keyPrefix}:content`,
+ )}
>
);
}
@@ -433,7 +475,13 @@ function renderWrappedSplitCellLine(
{line.gutterText}
- {renderInlineSpans(line.spans, contentWidth, theme.text, palette.contentBg, `${keyPrefix}:content`)}
+ {renderInlineSpans(
+ line.spans,
+ contentWidth,
+ theme.text,
+ palette.contentBg,
+ `${keyPrefix}:content`,
+ )}
>
);
}
@@ -459,7 +507,13 @@ function renderWrappedStackCellLine(
{line.gutterText}
- {renderInlineSpans(line.spans, contentWidth, theme.text, palette.contentBg, `${keyPrefix}:content`)}
+ {renderInlineSpans(
+ line.spans,
+ contentWidth,
+ theme.text,
+ palette.contentBg,
+ `${keyPrefix}:content`,
+ )}
>
);
}
@@ -486,7 +540,11 @@ export function findMaxLineNumber(file: DiffFile) {
let highest = 0;
for (const hunk of file.metadata.hunks) {
- highest = Math.max(highest, hunk.deletionStart + hunk.deletionCount, hunk.additionStart + hunk.additionCount);
+ highest = Math.max(
+ highest,
+ hunk.deletionStart + hunk.deletionCount,
+ hunk.additionStart + hunk.additionCount,
+ );
}
return Math.max(highest, 1);
@@ -521,10 +579,16 @@ function renderHeaderRow(
}}
>
-
+
{marker()}
-
+
{label}
@@ -545,15 +609,24 @@ function renderHeaderRow(
>
-
+
{marker()}
-
+
{label}
- onOpenAgentNotesAtHunk?.(row.hunkIndex)}>
+ onOpenAgentNotesAtHunk?.(row.hunkIndex)}
+ >
{badgeText}
@@ -588,8 +661,22 @@ export function measureRenderedRowHeight(
const usableWidth = Math.max(0, width - markerWidth - separatorWidth);
const leftWidth = Math.max(0, markerWidth + Math.floor(usableWidth / 2));
const rightWidth = Math.max(0, separatorWidth + usableWidth - Math.floor(usableWidth / 2));
- const leftLayout = buildWrappedSplitCell(row.left, leftWidth, lineNumberDigits, showLineNumbers, markerWidth, theme);
- const rightLayout = buildWrappedSplitCell(row.right, rightWidth, lineNumberDigits, showLineNumbers, markerWidth, theme);
+ const leftLayout = buildWrappedSplitCell(
+ row.left,
+ leftWidth,
+ lineNumberDigits,
+ showLineNumbers,
+ markerWidth,
+ theme,
+ );
+ const rightLayout = buildWrappedSplitCell(
+ row.right,
+ rightWidth,
+ lineNumberDigits,
+ showLineNumbers,
+ markerWidth,
+ theme,
+ );
return Math.max(leftLayout.lines.length, rightLayout.lines.length);
}
@@ -602,7 +689,14 @@ export function measureRenderedRowHeight(
return 1;
}
- const layout = buildWrappedStackCell(row.cell, width, lineNumberDigits, showLineNumbers, marker().length, theme);
+ const layout = buildWrappedStackCell(
+ row.cell,
+ width,
+ lineNumberDigits,
+ showLineNumbers,
+ marker().length,
+ theme,
+ );
return layout.lines.length;
}
@@ -623,9 +717,19 @@ function renderRow(
let baseRow: ReactNode;
if (row.type === "collapsed") {
- baseRow = renderHeaderRow(row, width, theme, selected, annotated, anchorId, onOpenAgentNotesAtHunk);
+ baseRow = renderHeaderRow(
+ row,
+ width,
+ theme,
+ selected,
+ annotated,
+ anchorId,
+ onOpenAgentNotesAtHunk,
+ );
} else if (row.type === "hunk-header") {
- baseRow = showHunkHeaders ? renderHeaderRow(row, width, theme, selected, annotated, anchorId, onOpenAgentNotesAtHunk) : null;
+ baseRow = showHunkHeaders
+ ? renderHeaderRow(row, width, theme, selected, annotated, anchorId, onOpenAgentNotesAtHunk)
+ : null;
} else if (row.type === "split-line") {
const markerWidth = 1;
const separatorWidth = 1;
@@ -650,23 +754,65 @@ function renderRow(
baseRow = (
- {renderSplitCell(row.left, leftWidth, lineNumberDigits, showLineNumbers, theme, `${row.key}:left`, leftPrefix)}
- {renderSplitCell(row.right, rightWidth, lineNumberDigits, showLineNumbers, theme, `${row.key}:right`, rightPrefix)}
+ {renderSplitCell(
+ row.left,
+ leftWidth,
+ lineNumberDigits,
+ showLineNumbers,
+ theme,
+ `${row.key}:left`,
+ leftPrefix,
+ )}
+ {renderSplitCell(
+ row.right,
+ rightWidth,
+ lineNumberDigits,
+ showLineNumbers,
+ theme,
+ `${row.key}:right`,
+ rightPrefix,
+ )}
);
} else {
- const leftLayout = buildWrappedSplitCell(row.left, leftWidth, lineNumberDigits, showLineNumbers, leftPrefix.text.length, theme);
- const rightLayout = buildWrappedSplitCell(row.right, rightWidth, lineNumberDigits, showLineNumbers, rightPrefix.text.length, theme);
- const leftContentWidth = Math.max(0, leftWidth - leftPrefix.text.length - leftLayout.gutterWidth);
- const rightContentWidth = Math.max(0, rightWidth - rightPrefix.text.length - rightLayout.gutterWidth);
+ const leftLayout = buildWrappedSplitCell(
+ row.left,
+ leftWidth,
+ lineNumberDigits,
+ showLineNumbers,
+ leftPrefix.text.length,
+ theme,
+ );
+ const rightLayout = buildWrappedSplitCell(
+ row.right,
+ rightWidth,
+ lineNumberDigits,
+ showLineNumbers,
+ rightPrefix.text.length,
+ theme,
+ );
+ const leftContentWidth = Math.max(
+ 0,
+ leftWidth - leftPrefix.text.length - leftLayout.gutterWidth,
+ );
+ const rightContentWidth = Math.max(
+ 0,
+ rightWidth - rightPrefix.text.length - rightLayout.gutterWidth,
+ );
const visualLineCount = Math.max(leftLayout.lines.length, rightLayout.lines.length);
baseRow = (
{Array.from({ length: visualLineCount }, (_, index) => {
- const leftLine = leftLayout.lines[index] ?? { gutterText: " ".repeat(leftLayout.gutterWidth), spans: [] };
- const rightLine = rightLayout.lines[index] ?? { gutterText: " ".repeat(rightLayout.gutterWidth), spans: [] };
+ const leftLine = leftLayout.lines[index] ?? {
+ gutterText: " ".repeat(leftLayout.gutterWidth),
+ spans: [],
+ };
+ const rightLine = rightLayout.lines[index] ?? {
+ gutterText: " ".repeat(rightLayout.gutterWidth),
+ spans: [],
+ };
return (
@@ -704,18 +850,44 @@ function renderRow(
if (!wrapLines) {
baseRow = (
- {renderStackCell(row.cell, width, lineNumberDigits, showLineNumbers, theme, `${row.key}:stack`, prefix)}
+
+ {renderStackCell(
+ row.cell,
+ width,
+ lineNumberDigits,
+ showLineNumbers,
+ theme,
+ `${row.key}:stack`,
+ prefix,
+ )}
+
);
} else {
- const layout = buildWrappedStackCell(row.cell, width, lineNumberDigits, showLineNumbers, prefix.text.length, theme);
+ const layout = buildWrappedStackCell(
+ row.cell,
+ width,
+ lineNumberDigits,
+ showLineNumbers,
+ prefix.text.length,
+ theme,
+ );
const contentWidth = Math.max(0, width - prefix.text.length - layout.gutterWidth);
baseRow = (
{layout.lines.map((line, index) => (
- {renderWrappedStackCellLine(line, layout.palette, contentWidth, theme, `${row.key}:stack:${index}`, prefix)}
+
+ {renderWrappedStackCellLine(
+ line,
+ layout.palette,
+ contentWidth,
+ theme,
+ `${row.key}:stack:${index}`,
+ prefix,
+ )}
+
))}
diff --git a/src/ui/diff/useHighlightedDiff.ts b/src/ui/diff/useHighlightedDiff.ts
index e123bc53..c8c5667a 100644
--- a/src/ui/diff/useHighlightedDiff.ts
+++ b/src/ui/diff/useHighlightedDiff.ts
@@ -23,7 +23,12 @@ export function useHighlightedDiff({
// Selected files load immediately; background prefetch can opt neighboring files in later.
const pendingHighlight = useMemo(() => {
- if (!shouldLoadHighlight || !file || !appearanceCacheKey || SHARED_HIGHLIGHTED_DIFF_CACHE.has(appearanceCacheKey)) {
+ if (
+ !shouldLoadHighlight ||
+ !file ||
+ !appearanceCacheKey ||
+ SHARED_HIGHLIGHTED_DIFF_CACHE.has(appearanceCacheKey)
+ ) {
return null;
}
diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/src/ui/hooks/useHunkSessionBridge.ts
index 595c487e..0759d4b0 100644
--- a/src/ui/hooks/useHunkSessionBridge.ts
+++ b/src/ui/hooks/useHunkSessionBridge.ts
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DiffFile } from "../../core/types";
-import { buildLiveComment, findDiffFileByPath, findHunkIndexForLine, hunkLineRange } from "../../core/liveComments";
+import {
+ buildLiveComment,
+ findDiffFileByPath,
+ findHunkIndexForLine,
+ hunkLineRange,
+} from "../../core/liveComments";
import { HunkHostClient } from "../../mcp/client";
import type { LiveComment, SessionLiveCommentSummary, SessionServerMessage } from "../../mcp/types";
@@ -24,7 +29,9 @@ export function useHunkSessionBridge({
selectedHunkIndex: number;
showAgentNotes: boolean;
}) {
- const [liveCommentsByFileId, setLiveCommentsByFileId] = useState>({});
+ const [liveCommentsByFileId, setLiveCommentsByFileId] = useState>(
+ {},
+ );
const liveCommentsByFileIdRef = useRef>({});
const buildSelectedHunkSummary = useCallback((file: DiffFile, hunkIndex: number) => {
@@ -85,7 +92,12 @@ export function useHunkSessionBridge({
}
const commentId = `mcp:${message.requestId}`;
- const liveComment = buildLiveComment(message.input, commentId, new Date().toISOString(), hunkIndex);
+ const liveComment = buildLiveComment(
+ message.input,
+ commentId,
+ new Date().toISOString(),
+ hunkIndex,
+ );
setLiveCommentsByFileId((current) => ({
...current,
@@ -203,7 +215,13 @@ export function useHunkSessionBridge({
return () => {
hostClient.setBridge(null);
};
- }, [applyIncomingComment, clearIncomingComments, hostClient, navigateToHunkSelection, removeIncomingComment]);
+ }, [
+ applyIncomingComment,
+ clearIncomingComments,
+ hostClient,
+ navigateToHunkSelection,
+ removeIncomingComment,
+ ]);
const liveCommentSummaries = useMemo(
() =>
@@ -242,7 +260,16 @@ export function useHunkSessionBridge({
liveComments: liveCommentSummaries,
updatedAt: new Date().toISOString(),
});
- }, [currentHunk, hostClient, liveCommentCount, liveCommentSummaries, selectedFile?.id, selectedFile?.path, selectedHunkIndex, showAgentNotes]);
+ }, [
+ currentHunk,
+ hostClient,
+ liveCommentCount,
+ liveCommentSummaries,
+ selectedFile?.id,
+ selectedFile?.path,
+ selectedHunkIndex,
+ showAgentNotes,
+ ]);
return {
liveCommentsByFileId,
diff --git a/src/ui/hooks/useMenuController.ts b/src/ui/hooks/useMenuController.ts
index e599e7d9..f65d8288 100644
--- a/src/ui/hooks/useMenuController.ts
+++ b/src/ui/hooks/useMenuController.ts
@@ -1,5 +1,12 @@
import { useMemo, useState } from "react";
-import { MENU_ORDER, buildMenuSpecs, menuWidth, nextMenuItemIndex, type MenuEntry, type MenuId } from "../components/chrome/menu";
+import {
+ MENU_ORDER,
+ buildMenuSpecs,
+ menuWidth,
+ nextMenuItemIndex,
+ type MenuEntry,
+ type MenuId,
+} from "../components/chrome/menu";
/** Drive menu selection/open state for the desktop-style top menu bar. */
export function useMenuController(menus: Record) {
diff --git a/src/ui/lib/agentAnnotations.ts b/src/ui/lib/agentAnnotations.ts
index d0a2eee6..daf3a4da 100644
--- a/src/ui/lib/agentAnnotations.ts
+++ b/src/ui/lib/agentAnnotations.ts
@@ -14,8 +14,14 @@ function overlap(rangeA: [number, number], rangeB: [number, number]) {
/** Compute the old/new line ranges covered by a hunk, including single-line edge cases. */
function hunkLineRange(hunk: Hunk) {
- const newEnd = Math.max(hunk.additionStart, hunk.additionStart + Math.max(hunk.additionLines, 1) - 1);
- const oldEnd = Math.max(hunk.deletionStart, hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1);
+ const newEnd = Math.max(
+ hunk.additionStart,
+ hunk.additionStart + Math.max(hunk.additionLines, 1) - 1,
+ );
+ const oldEnd = Math.max(
+ hunk.deletionStart,
+ hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1,
+ );
return {
oldRange: [hunk.deletionStart, oldEnd] as [number, number],
diff --git a/src/ui/lib/agentPopover.ts b/src/ui/lib/agentPopover.ts
index 4add9300..9b7ea8c1 100644
--- a/src/ui/lib/agentPopover.ts
+++ b/src/ui/lib/agentPopover.ts
@@ -74,7 +74,8 @@ export function buildAgentPopoverContent({
const summaryLines = wrapText(summary, innerWidth);
const rationaleLines = rationale ? wrapText(rationale, innerWidth) : [];
const footer = fitText(locationLabel, innerWidth);
- const contentLineCount = 1 + summaryLines.length + (rationaleLines.length > 0 ? 1 + rationaleLines.length : 0) + 1 + 1;
+ const contentLineCount =
+ 1 + summaryLines.length + (rationaleLines.length > 0 ? 1 + rationaleLines.length : 0) + 1 + 1;
return {
title: agentPopoverTitle(noteIndex, noteCount),
diff --git a/src/ui/lib/files.ts b/src/ui/lib/files.ts
index 1804b2cd..470ac873 100644
--- a/src/ui/lib/files.ts
+++ b/src/ui/lib/files.ts
@@ -17,7 +17,10 @@ export function buildFileListEntry(file: DiffFile): FileListEntry {
? "R"
: "M";
- const pathLabel = file.previousPath && file.previousPath !== file.path ? `${file.previousPath} -> ${file.path}` : file.path;
+ const pathLabel =
+ file.previousPath && file.previousPath !== file.path
+ ? `${file.previousPath} -> ${file.path}`
+ : file.path;
return {
id: file.id,
@@ -32,5 +35,7 @@ export function fileLabel(file: DiffFile | undefined) {
return "No file selected";
}
- return file.previousPath && file.previousPath !== file.path ? `${file.previousPath} -> ${file.path}` : file.path;
+ return file.previousPath && file.previousPath !== file.path
+ ? `${file.previousPath} -> ${file.path}`
+ : file.path;
}
diff --git a/src/ui/lib/hunks.ts b/src/ui/lib/hunks.ts
index 39c6609f..bf45b1d8 100644
--- a/src/ui/lib/hunks.ts
+++ b/src/ui/lib/hunks.ts
@@ -7,7 +7,9 @@ export interface HunkCursor {
/** Flatten the visible files into one review-stream hunk cursor list. */
export function buildHunkCursors(files: DiffFile[]): HunkCursor[] {
- return files.flatMap((file) => file.metadata.hunks.map((_, hunkIndex) => ({ fileId: file.id, hunkIndex })));
+ return files.flatMap((file) =>
+ file.metadata.hunks.map((_, hunkIndex) => ({ fileId: file.id, hunkIndex })),
+ );
}
/** Move forward or backward through the review-stream hunk cursor list. */
@@ -21,7 +23,9 @@ export function findNextHunkCursor(
return null;
}
- const currentIndex = cursors.findIndex((cursor) => cursor.fileId === currentFileId && cursor.hunkIndex === currentHunkIndex);
+ const currentIndex = cursors.findIndex(
+ (cursor) => cursor.fileId === currentFileId && cursor.hunkIndex === currentHunkIndex,
+ );
const nextIndex =
currentIndex >= 0
? Math.min(Math.max(currentIndex + delta, 0), cursors.length - 1)
diff --git a/src/ui/lib/responsive.ts b/src/ui/lib/responsive.ts
index 3568cdf1..949c3ea3 100644
--- a/src/ui/lib/responsive.ts
+++ b/src/ui/lib/responsive.ts
@@ -25,7 +25,10 @@ function resolveResponsiveViewport(viewportWidth: number): ResponsiveViewport {
}
/** Resolve the effective layout after combining the explicit mode with viewport size. */
-export function resolveResponsiveLayout(requestedLayout: LayoutMode, viewportWidth: number): ResponsiveLayout {
+export function resolveResponsiveLayout(
+ requestedLayout: LayoutMode,
+ viewportWidth: number,
+): ResponsiveLayout {
const viewport = resolveResponsiveViewport(viewportWidth);
if (requestedLayout === "split") {
diff --git a/src/ui/lib/sectionHeights.ts b/src/ui/lib/sectionHeights.ts
index cb18ba01..aee0369b 100644
--- a/src/ui/lib/sectionHeights.ts
+++ b/src/ui/lib/sectionHeights.ts
@@ -8,8 +8,10 @@ function trailingCollapsedLines(metadata: FileDiffMetadata) {
return 0;
}
- const additionRemaining = metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount);
- const deletionRemaining = metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount);
+ const additionRemaining =
+ metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount);
+ const deletionRemaining =
+ metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount);
if (additionRemaining !== deletionRemaining) {
return 0;
@@ -46,7 +48,10 @@ function estimateHunkRows(
continue;
}
- rows += layout === "split" ? Math.max(content.deletions, content.additions) : content.deletions + content.additions;
+ rows +=
+ layout === "split"
+ ? Math.max(content.deletions, content.additions)
+ : content.deletions + content.additions;
}
return rows;
diff --git a/src/ui/themes.ts b/src/ui/themes.ts
index c85e9eee..160ffcad 100644
--- a/src/ui/themes.ts
+++ b/src/ui/themes.ts
@@ -62,7 +62,10 @@ function createSyntaxStyle(colors: SyntaxColors) {
}
/** Lazily attach syntax colors so startup only pays for the active theme's token style. */
-function withLazySyntaxStyle(theme: Omit, syntaxColors: SyntaxColors): AppTheme {
+function withLazySyntaxStyle(
+ theme: Omit,
+ syntaxColors: SyntaxColors,
+): AppTheme {
let syntaxStyle: SyntaxStyle | null = null;
return {
@@ -76,33 +79,35 @@ function withLazySyntaxStyle(theme: Omit span.text.includes(marker) && span.text.trim().length < text.trim().length);
+ return line.spans.some(
+ (span) => span.text.includes(marker) && span.text.trim().length < text.trim().length,
+ );
});
}
-const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), { width: 240, height: 24 });
+const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), {
+ width: 240,
+ height: 24,
+});
const start = performance.now();
let iterations = 0;
let selectedStartupMs = 0;
diff --git a/test/agent.test.ts b/test/agent.test.ts
index c4ef4948..36d97f35 100644
--- a/test/agent.test.ts
+++ b/test/agent.test.ts
@@ -34,7 +34,14 @@ describe("agent context", () => {
{
path: "src/example.ts",
summary: "Explains the file change",
- annotations: [{ newRange: [4, 8], summary: "Added a helper", confidence: "high", tags: ["review", 7] }],
+ annotations: [
+ {
+ newRange: [4, 8],
+ summary: "Added a helper",
+ confidence: "high",
+ tags: ["review", 7],
+ },
+ ],
},
],
}),
@@ -44,7 +51,9 @@ describe("agent context", () => {
expect(context?.summary).toBe("Agent summary");
expect(findAgentFileContext(context, "src/example.ts")?.annotations).toHaveLength(1);
- expect(findAgentFileContext(context, "src/example.ts")?.annotations[0]?.tags).toEqual(["review"]);
+ expect(findAgentFileContext(context, "src/example.ts")?.annotations[0]?.tags).toEqual([
+ "review",
+ ]);
expect(findAgentFileContext(context, "src/renamed.ts", "src/example.ts")?.summary).toBe(
"Explains the file change",
);
@@ -63,7 +72,9 @@ describe("agent context", () => {
}),
);
- await expect(loadAgentContext(invalidFilePath)).rejects.toThrow("Agent context file entries require a non-empty path.");
+ await expect(loadAgentContext(invalidFilePath)).rejects.toThrow(
+ "Agent context file entries require a non-empty path.",
+ );
const invalidRangePath = join(dir, "invalid-range.json");
writeFileSync(
@@ -79,7 +90,9 @@ describe("agent context", () => {
}),
);
- await expect(loadAgentContext(invalidRangePath)).rejects.toThrow("Annotation ranges must be integer tuples.");
+ await expect(loadAgentContext(invalidRangePath)).rejects.toThrow(
+ "Annotation ranges must be integer tuples.",
+ );
const negativeRangePath = join(dir, "negative-range.json");
writeFileSync(
@@ -113,6 +126,8 @@ describe("agent context", () => {
}),
);
- await expect(loadAgentContext(reversedRangePath)).rejects.toThrow("Annotation ranges must be ordered start..end tuples.");
+ await expect(loadAgentContext(reversedRangePath)).rejects.toThrow(
+ "Annotation ranges must be ordered start..end tuples.",
+ );
});
});
diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx
index 51017b74..cf85ab12 100644
--- a/test/app-interactions.test.tsx
+++ b/test/app-interactions.test.tsx
@@ -6,7 +6,13 @@ import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types";
const { App } = await import("../src/ui/App");
-function createDiffFile(id: string, path: string, before: string, after: string, withAgent = false): DiffFile {
+function createDiffFile(
+ id: string,
+ path: string,
+ before: string,
+ after: string,
+ withAgent = false,
+): DiffFile {
const metadata = parseDiffFromFile(
{
name: path,
@@ -44,7 +50,13 @@ function createDiffFile(id: string, path: string, before: string, after: string,
? {
path,
summary: `${path} note`,
- annotations: [{ newRange: [2, 2], summary: `Annotation for ${path}`, rationale: `Why ${path} changed` }],
+ annotations: [
+ {
+ newRange: [2, 2],
+ summary: `Annotation for ${path}`,
+ rationale: `Why ${path} changed`,
+ },
+ ],
}
: null,
};
@@ -65,8 +77,20 @@ function createBootstrap(initialMode: LayoutMode = "split", pager = false): AppB
sourceLabel: "repo",
title: "repo working tree",
files: [
- createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\nexport const add = true;\n", true),
- createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const betaValue = 1;\n", false),
+ createDiffFile(
+ "alpha",
+ "alpha.ts",
+ "export const alpha = 1;\n",
+ "export const alpha = 2;\nexport const add = true;\n",
+ true,
+ ),
+ createDiffFile(
+ "beta",
+ "beta.ts",
+ "export const beta = 1;\n",
+ "export const betaValue = 1;\n",
+ false,
+ ),
],
},
initialMode,
@@ -87,7 +111,15 @@ function createSingleFileBootstrap(): AppBootstrap {
id: "changeset:app-single-file",
sourceLabel: "repo",
title: "repo working tree",
- files: [createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\nexport const add = true;\n", true)],
+ files: [
+ createDiffFile(
+ "alpha",
+ "alpha.ts",
+ "export const alpha = 1;\n",
+ "export const alpha = 2;\nexport const add = true;\n",
+ true,
+ ),
+ ],
},
initialMode: "split",
initialTheme: "midnight",
@@ -123,8 +155,16 @@ function createWrapBootstrap(): AppBootstrap {
}
function createLineScrollBootstrap(pager = false): AppBootstrap {
- const before = Array.from({ length: 18 }, (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`).join("\n") + "\n";
- const after = Array.from({ length: 18 }, (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 101};`).join("\n") + "\n";
+ const before =
+ Array.from(
+ { length: 18 },
+ (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`,
+ ).join("\n") + "\n";
+ const after =
+ Array.from(
+ { length: 18 },
+ (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 101};`,
+ ).join("\n") + "\n";
return {
input: {
@@ -156,7 +196,10 @@ async function flush(setup: Awaited>) {
describe("App interactions", () => {
test("keyboard shortcuts toggle notes, line numbers, and hunk metadata", async () => {
- const setup = await testRender(, { width: 240, height: 24 });
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
try {
await flush(setup);
@@ -195,7 +238,10 @@ describe("App interactions", () => {
});
test("keyboard shortcut can wrap long lines in the app shell", async () => {
- const setup = await testRender(, { width: 140, height: 20 });
+ const setup = await testRender(, {
+ width: 140,
+ height: 20,
+ });
try {
await flush(setup);
@@ -273,7 +319,10 @@ describe("App interactions", () => {
});
test("menu navigation can switch layouts and activate view actions", async () => {
- const setup = await testRender(, { width: 220, height: 24 });
+ const setup = await testRender(, {
+ width: 220,
+ height: 24,
+ });
try {
await flush(setup);
@@ -311,7 +360,10 @@ describe("App interactions", () => {
});
test("arrow keys keep the current file selected for agent notes", async () => {
- const setup = await testRender(, { width: 240, height: 24 });
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
try {
await flush(setup);
@@ -336,7 +388,10 @@ describe("App interactions", () => {
});
test("arrow keys scroll the review pane line by line", async () => {
- const setup = await testRender(, { width: 220, height: 12 });
+ const setup = await testRender(, {
+ width: 220,
+ height: 12,
+ });
try {
await flush(setup);
@@ -380,7 +435,10 @@ describe("App interactions", () => {
});
test("pager mode arrow keys also scroll line by line", async () => {
- const setup = await testRender(, { width: 220, height: 8 });
+ const setup = await testRender(, {
+ width: 220,
+ height: 8,
+ });
try {
await flush(setup);
@@ -424,7 +482,10 @@ describe("App interactions", () => {
});
test("filter focus accepts typed input and narrows the visible file set", async () => {
- const setup = await testRender(, { width: 240, height: 24 });
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
try {
await flush(setup);
@@ -450,7 +511,10 @@ describe("App interactions", () => {
});
test("filtering away the selected file reselects the first visible match", async () => {
- const setup = await testRender(, { width: 240, height: 24 });
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
try {
await flush(setup);
@@ -488,7 +552,10 @@ describe("App interactions", () => {
});
test("menu navigation wraps across the first and last top-level menus", async () => {
- const setup = await testRender(, { width: 220, height: 24 });
+ const setup = await testRender(, {
+ width: 220,
+ height: 24,
+ });
try {
await flush(setup);
@@ -527,7 +594,10 @@ describe("App interactions", () => {
});
test("sidebar visibility can toggle off and back on", async () => {
- const setup = await testRender(, { width: 240, height: 24 });
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
try {
await flush(setup);
@@ -561,7 +631,10 @@ describe("App interactions", () => {
});
test("sidebar shortcut can force the files pane open when responsive layout hides it", async () => {
- const setup = await testRender(, { width: 160, height: 24 });
+ const setup = await testRender(, {
+ width: 160,
+ height: 24,
+ });
try {
await flush(setup);
@@ -596,7 +669,10 @@ describe("App interactions", () => {
test("quit shortcuts route through the provided onQuit handler in regular and pager modes", async () => {
const regularQuit = mock(() => undefined);
- const regularSetup = await testRender(, { width: 220, height: 24 });
+ const regularSetup = await testRender(
+ ,
+ { width: 220, height: 24 },
+ );
try {
await flush(regularSetup);
@@ -613,7 +689,10 @@ describe("App interactions", () => {
}
const pagerQuit = mock(() => undefined);
- const pagerSetup = await testRender(, { width: 180, height: 20 });
+ const pagerSetup = await testRender(
+ ,
+ { width: 180, height: 20 },
+ );
try {
await flush(pagerSetup);
@@ -629,5 +708,4 @@ describe("App interactions", () => {
});
}
});
-
});
diff --git a/test/app-responsive.test.tsx b/test/app-responsive.test.tsx
index ba020722..74f85df0 100644
--- a/test/app-responsive.test.tsx
+++ b/test/app-responsive.test.tsx
@@ -6,7 +6,13 @@ import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types";
const { App } = await import("../src/ui/App");
-function createDiffFile(id: string, path: string, before: string, after: string, withAgent = false): DiffFile {
+function createDiffFile(
+ id: string,
+ path: string,
+ before: string,
+ after: string,
+ withAgent = false,
+): DiffFile {
const metadata = parseDiffFromFile(
{
name: path,
@@ -70,8 +76,20 @@ function createBootstrap(initialMode: LayoutMode = "auto", pager = false): AppBo
summary: "Patch summary",
agentSummary: "Changeset summary",
files: [
- createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\nexport const add = true;\n", true),
- createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const betaValue = 1;\n", false),
+ createDiffFile(
+ "alpha",
+ "alpha.ts",
+ "export const alpha = 1;\n",
+ "export const alpha = 2;\nexport const add = true;\n",
+ true,
+ ),
+ createDiffFile(
+ "beta",
+ "beta.ts",
+ "export const beta = 1;\n",
+ "export const betaValue = 1;\n",
+ false,
+ ),
],
},
initialMode,
@@ -188,7 +206,10 @@ describe("responsive shell", () => {
const exitMock = mock(() => undefined as never);
(process as typeof process & { exit: typeof exitMock }).exit = exitMock;
- const setup = await testRender(, { width: 240, height: 24 });
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
try {
await act(async () => {
@@ -213,5 +234,4 @@ describe("responsive shell", () => {
});
}
});
-
});
diff --git a/test/cli.test.ts b/test/cli.test.ts
index 567b82e3..495e97f5 100644
--- a/test/cli.test.ts
+++ b/test/cli.test.ts
@@ -114,7 +114,15 @@ describe("parseCli", () => {
});
test("parses pathspec-limited git diffs", async () => {
- const parsed = await parseCli(["bun", "hunk", "diff", "main", "--", "src/app.ts", "test/app.test.ts"]);
+ const parsed = await parseCli([
+ "bun",
+ "hunk",
+ "diff",
+ "main",
+ "--",
+ "src/app.ts",
+ "test/app.test.ts",
+ ]);
expect(parsed).toMatchObject({
kind: "git",
@@ -258,7 +266,15 @@ describe("parseCli", () => {
});
test("parses session comment rm", async () => {
- const parsed = await parseCli(["bun", "hunk", "session", "comment", "rm", "session-1", "comment-1"]);
+ const parsed = await parseCli([
+ "bun",
+ "hunk",
+ "session",
+ "comment",
+ "rm",
+ "session-1",
+ "comment-1",
+ ]);
expect(parsed).toEqual({
kind: "session",
@@ -353,7 +369,16 @@ describe("parseCli", () => {
});
test("parses difftool mode with display path", async () => {
- const parsed = await parseCli(["bun", "hunk", "difftool", "left.ts", "right.ts", "src/example.ts", "--mode", "stack"]);
+ const parsed = await parseCli([
+ "bun",
+ "hunk",
+ "difftool",
+ "left.ts",
+ "right.ts",
+ "src/example.ts",
+ "--mode",
+ "stack",
+ ]);
expect(parsed).toMatchObject({
kind: "difftool",
diff --git a/test/config.test.ts b/test/config.test.ts
index 7ef2617e..3621a375 100644
--- a/test/config.test.ts
+++ b/test/config.test.ts
@@ -66,13 +66,7 @@ describe("config resolution", () => {
mkdirSync(join(repo, ".hunk"), { recursive: true });
writeFileSync(
join(repo, ".hunk", "config.toml"),
- [
- 'theme = "paper"',
- "wrap_lines = true",
- "",
- "[pager]",
- "hunk_headers = false",
- ].join("\n"),
+ ['theme = "paper"', "wrap_lines = true", "", "[pager]", "hunk_headers = false"].join("\n"),
);
const resolved = resolveConfiguredCliInput(createPatchPagerInput({ agentNotes: true }), {
@@ -109,11 +103,7 @@ describe("config resolution", () => {
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
- [
- '[show]',
- 'mode = "stack"',
- 'line_numbers = false',
- ].join('\n'),
+ ["[show]", 'mode = "stack"', "line_numbers = false"].join("\n"),
);
const resolved = resolveConfiguredCliInput(
@@ -139,11 +129,11 @@ describe("config resolution", () => {
join(home, ".config", "hunk", "config.toml"),
[
'theme = "paper"',
- 'line_numbers = false',
- 'wrap_lines = true',
- 'hunk_headers = false',
- 'agent_notes = true',
- ].join('\n'),
+ "line_numbers = false",
+ "wrap_lines = true",
+ "hunk_headers = false",
+ "agent_notes = true",
+ ].join("\n"),
);
const before = join(repo, "before.ts");
diff --git a/test/daemon-launcher.test.ts b/test/daemon-launcher.test.ts
index cedc9f59..54f754e4 100644
--- a/test/daemon-launcher.test.ts
+++ b/test/daemon-launcher.test.ts
@@ -9,14 +9,18 @@ describe("MCP daemon launcher", () => {
args: ["src/main.tsx", "mcp", "serve"],
});
- expect(resolveDaemonLaunchCommand(["node", "/app/bin/hunk.cjs", "diff"], "/usr/bin/node")).toEqual({
+ expect(
+ resolveDaemonLaunchCommand(["node", "/app/bin/hunk.cjs", "diff"], "/usr/bin/node"),
+ ).toEqual({
command: "/usr/bin/node",
args: ["/app/bin/hunk.cjs", "mcp", "serve"],
});
});
test("falls back to relaunching the current executable when no script entrypoint is present", () => {
- expect(resolveDaemonLaunchCommand(["/usr/local/bin/hunk", "diff"], "/usr/local/bin/hunk")).toEqual({
+ expect(
+ resolveDaemonLaunchCommand(["/usr/local/bin/hunk", "diff"], "/usr/local/bin/hunk"),
+ ).toEqual({
command: "/usr/local/bin/hunk",
args: ["mcp", "serve"],
});
diff --git a/test/help-output.test.ts b/test/help-output.test.ts
index 4407827d..9cb33435 100644
--- a/test/help-output.test.ts
+++ b/test/help-output.test.ts
@@ -43,13 +43,20 @@ describe("CLI help output", () => {
});
test("general pager mode falls back to plain text for non-diff stdin", () => {
- const proc = Bun.spawnSync(["bash", "-lc", "printf '* main\\n feature/demo\\n' | HUNK_TEXT_PAGER=cat bun run src/main.tsx pager"], {
- cwd: process.cwd(),
- stdin: "ignore",
- stdout: "pipe",
- stderr: "pipe",
- env: process.env,
- });
+ const proc = Bun.spawnSync(
+ [
+ "bash",
+ "-lc",
+ "printf '* main\\n feature/demo\\n' | HUNK_TEXT_PAGER=cat bun run src/main.tsx pager",
+ ],
+ {
+ cwd: process.cwd(),
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ env: process.env,
+ },
+ );
const stdout = Buffer.from(proc.stdout).toString("utf8");
const stderr = Buffer.from(proc.stderr).toString("utf8");
diff --git a/test/large-stream-windowing-benchmark.ts b/test/large-stream-windowing-benchmark.ts
index 9ecb7ae3..ec0f8db2 100644
--- a/test/large-stream-windowing-benchmark.ts
+++ b/test/large-stream-windowing-benchmark.ts
@@ -73,7 +73,10 @@ function createBootstrap(): AppBootstrap {
}
const start = performance.now();
-const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), { width: 240, height: 28 });
+const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), {
+ width: 240,
+ height: 28,
+});
try {
await act(async () => {
diff --git a/test/live-comments.test.ts b/test/live-comments.test.ts
index f55ec90b..09530dc2 100644
--- a/test/live-comments.test.ts
+++ b/test/live-comments.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test";
import { parseDiffFromFile } from "@pierre/diffs";
-import { buildLiveComment, findDiffFileByPath, findHunkIndexForLine, hunkLineRange } from "../src/core/liveComments";
+import {
+ buildLiveComment,
+ findDiffFileByPath,
+ findHunkIndexForLine,
+ hunkLineRange,
+} from "../src/core/liveComments";
import type { DiffFile } from "../src/core/types";
function createDiffFile(): DiffFile {
diff --git a/test/loaders.test.ts b/test/loaders.test.ts
index 03ee7a93..1cc00cb8 100644
--- a/test/loaders.test.ts
+++ b/test/loaders.test.ts
@@ -305,12 +305,15 @@ describe("loadAppBootstrap", () => {
writeFileSync(before, "export const answer = 41;\n");
writeFileSync(after, "export const answer = 42;\nexport const added = true;\n");
- const diffProc = Bun.spawnSync(["git", "diff", "--no-index", "--color=always", "--", before, after], {
- cwd: dir,
- stdin: "ignore",
- stdout: "pipe",
- stderr: "pipe",
- });
+ const diffProc = Bun.spawnSync(
+ ["git", "diff", "--no-index", "--color=always", "--", before, after],
+ {
+ cwd: dir,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+ );
if (diffProc.exitCode !== 0 && diffProc.exitCode !== 1) {
const stderr = Buffer.from(diffProc.stderr).toString("utf8");
diff --git a/test/mcp-client.test.ts b/test/mcp-client.test.ts
index 58705145..2f2ce721 100644
--- a/test/mcp-client.test.ts
+++ b/test/mcp-client.test.ts
@@ -103,7 +103,9 @@ describe("Hunk MCP client", () => {
client.start();
await waitUntil("non-loopback MCP warning", () => messages.length === 1);
- expect(messages[0]).toContain("[hunk:mcp] Hunk MCP refuses to bind 0.0.0.0:47657 because the daemon is local-only by default.");
+ expect(messages[0]).toContain(
+ "[hunk:mcp] Hunk MCP refuses to bind 0.0.0.0:47657 because the daemon is local-only by default.",
+ );
expect(messages[0]).toContain("HUNK_MCP_UNSAFE_ALLOW_REMOTE=1");
} finally {
client.stop();
@@ -141,8 +143,12 @@ describe("Hunk MCP client", () => {
await Bun.sleep(2_000);
expect(messages).toHaveLength(1);
- expect(messages[0]).toContain(`[hunk:mcp] Hunk MCP port 127.0.0.1:${port} is already in use by another process.`);
- expect(messages[0]).toContain("Stop the conflicting process or set HUNK_MCP_PORT to a different loopback port.");
+ expect(messages[0]).toContain(
+ `[hunk:mcp] Hunk MCP port 127.0.0.1:${port} is already in use by another process.`,
+ );
+ expect(messages[0]).toContain(
+ "Stop the conflicting process or set HUNK_MCP_PORT to a different loopback port.",
+ );
} finally {
client.stop();
await new Promise((resolve) => conflictingListener.close(() => resolve()));
diff --git a/test/mcp-daemon.test.ts b/test/mcp-daemon.test.ts
index a6bd4760..c884685d 100644
--- a/test/mcp-daemon.test.ts
+++ b/test/mcp-daemon.test.ts
@@ -44,7 +44,9 @@ function createListedSession(overrides: Partial = {}): ListedSess
};
}
-function createRegistration(overrides: Partial = {}): HunkSessionRegistration {
+function createRegistration(
+ overrides: Partial = {},
+): HunkSessionRegistration {
return {
sessionId: "session-1",
pid: 123,
@@ -80,7 +82,9 @@ function createSnapshot(overrides: Partial = {}): HunkSessi
};
}
-function createLiveComment(overrides: Partial = {}): SessionLiveCommentSummary {
+function createLiveComment(
+ overrides: Partial = {},
+): SessionLiveCommentSummary {
return {
commentId: "comment-1",
filePath: "src/example.ts",
@@ -96,13 +100,21 @@ function createLiveComment(overrides: Partial = {}):
describe("Hunk MCP daemon state", () => {
test("resolves one target session by session id, repo root, or sole-session fallback", () => {
const one = [createListedSession()];
- const two = [createListedSession(), createListedSession({ sessionId: "session-2", snapshot: { ...createSnapshot(), updatedAt: "2026-03-22T00:00:01.000Z" } })];
+ const two = [
+ createListedSession(),
+ createListedSession({
+ sessionId: "session-2",
+ snapshot: { ...createSnapshot(), updatedAt: "2026-03-22T00:00:01.000Z" },
+ }),
+ ];
expect(resolveSessionTarget(one, {}).sessionId).toBe("session-1");
expect(resolveSessionTarget(one, { repoRoot: "/repo" }).sessionId).toBe("session-1");
expect(resolveSessionTarget(two, { sessionId: "session-2" }).sessionId).toBe("session-2");
expect(() => resolveSessionTarget(two, {})).toThrow("specify sessionId or repoRoot");
- expect(() => resolveSessionTarget(two, { repoRoot: "/repo" })).toThrow("specify sessionId instead");
+ expect(() => resolveSessionTarget(two, { repoRoot: "/repo" })).toThrow(
+ "specify sessionId instead",
+ );
});
test("exposes the selected session context from snapshot state", () => {
@@ -147,7 +159,12 @@ describe("Hunk MCP daemon state", () => {
liveCommentCount: 2,
liveComments: [
createLiveComment(),
- createLiveComment({ commentId: "comment-2", filePath: "src/other.ts", line: 9, summary: "Other" }),
+ createLiveComment({
+ commentId: "comment-2",
+ filePath: "src/other.ts",
+ line: 9,
+ summary: "Other",
+ }),
],
}),
);
@@ -359,7 +376,11 @@ describe("Hunk MCP daemon state", () => {
summary: "Review note",
});
- state.registerSession(replacementSocket, createRegistration(), createSnapshot({ updatedAt: "2026-03-22T00:00:01.000Z" }));
+ state.registerSession(
+ replacementSocket,
+ createRegistration(),
+ createSnapshot({ updatedAt: "2026-03-22T00:00:01.000Z" }),
+ );
await expect(pending).rejects.toThrow("reconnected before the command completed");
expect(state.listSessions()).toHaveLength(1);
diff --git a/test/mcp-e2e.test.ts b/test/mcp-e2e.test.ts
index 1461013b..0d02802b 100644
--- a/test/mcp-e2e.test.ts
+++ b/test/mcp-e2e.test.ts
@@ -7,11 +7,12 @@ import { join } from "node:path";
const repoRoot = process.cwd();
const sourceEntrypoint = join(repoRoot, "src/main.tsx");
const tempDirs: string[] = [];
-const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], {
- stdin: "ignore",
- stdout: "ignore",
- stderr: "ignore",
-}).exitCode === 0;
+const ttyToolsAvailable =
+ Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], {
+ stdin: "ignore",
+ stdout: "ignore",
+ stderr: "ignore",
+ }).exitCode === 0;
interface HealthResponse {
ok: boolean;
@@ -59,7 +60,11 @@ function stripTerminalControl(text: string) {
.replace(/\x1b[@-_]/g, "");
}
-function createFixtureFiles(name: string, beforeLines: string[], afterLines: string[]): FixtureFiles {
+function createFixtureFiles(
+ name: string,
+ beforeLines: string[],
+ afterLines: string[],
+): FixtureFiles {
const dir = mkdtempSync(join(tmpdir(), `hunk-session-e2e-${name}-`));
tempDirs.push(dir);
@@ -126,7 +131,12 @@ function runSessionCli(args: string[], port: number) {
return { proc, stdout, stderr };
}
-async function waitUntil(label: string, fn: () => Promise | T | null, timeoutMs = 10_000, intervalMs = 150) {
+async function waitUntil(
+ label: string,
+ fn: () => Promise | T | null,
+ timeoutMs = 10_000,
+ intervalMs = 150,
+) {
const deadline = Date.now() + timeoutMs;
for (;;) {
@@ -193,7 +203,9 @@ describe("live session end-to-end", () => {
return parsed.sessions.length > 0 ? parsed.sessions : null;
});
- const targetSession = listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? listed[0]!;
+ const targetSession =
+ listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ??
+ listed[0]!;
const comment = runSessionCli(
[
"comment",
@@ -293,7 +305,9 @@ describe("live session end-to-end", () => {
const parsed = JSON.parse(stdout) as SessionListJson;
return parsed.sessions.length > 0 ? parsed.sessions : null;
});
- const targetSession = listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? listed[0]!;
+ const targetSession =
+ listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ??
+ listed[0]!;
const initialContext = runSessionCli(["context", targetSession.sessionId, "--json"], port);
expect(initialContext.proc.exitCode).toBe(0);
@@ -326,7 +340,9 @@ describe("live session end-to-end", () => {
return null;
}
- const parsed = JSON.parse(context.stdout) as { context?: { selectedHunk?: { index: number } } };
+ const parsed = JSON.parse(context.stdout) as {
+ context?: { selectedHunk?: { index: number } };
+ };
return parsed.context?.selectedHunk?.index === 1 ? parsed : null;
});
@@ -362,8 +378,16 @@ describe("live session end-to-end", () => {
["export const beta = 2;", "export const shared = true;", "export const onlyBeta = true;"],
);
const port = 49000 + Math.floor(Math.random() * 1000);
- const hunkProcA = spawnHunkSession(fixtureA, { port, quitAfterSeconds: 10, timeoutSeconds: 12 });
- const hunkProcB = spawnHunkSession(fixtureB, { port, quitAfterSeconds: 10, timeoutSeconds: 12 });
+ const hunkProcA = spawnHunkSession(fixtureA, {
+ port,
+ quitAfterSeconds: 10,
+ timeoutSeconds: 12,
+ });
+ const hunkProcB = spawnHunkSession(fixtureB, {
+ port,
+ quitAfterSeconds: 10,
+ timeoutSeconds: 12,
+ });
let daemonPid: number | null = null;
@@ -382,8 +406,12 @@ describe("live session end-to-end", () => {
return parsed.sessions.length === 2 ? parsed.sessions : null;
});
- const sessionA = sessions.find((session) => session.files.some((file) => file.path === fixtureA.afterName));
- const sessionB = sessions.find((session) => session.files.some((file) => file.path === fixtureB.afterName));
+ const sessionA = sessions.find((session) =>
+ session.files.some((file) => file.path === fixtureA.afterName),
+ );
+ const sessionB = sessions.find((session) =>
+ session.files.some((file) => file.path === fixtureB.afterName),
+ );
expect(sessionA).toBeDefined();
expect(sessionB).toBeDefined();
diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts
index 79b27beb..b1a68b36 100644
--- a/test/mcp-server.test.ts
+++ b/test/mcp-server.test.ts
@@ -79,7 +79,16 @@ describe("Hunk session daemon server", () => {
expect(capabilities.status).toBe(200);
await expect(capabilities.json()).resolves.toMatchObject({
version: 1,
- actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"],
+ actions: [
+ "list",
+ "get",
+ "context",
+ "navigate",
+ "comment-add",
+ "comment-list",
+ "comment-rm",
+ "comment-clear",
+ ],
});
const legacyMcp = await fetch(`http://127.0.0.1:${port}/mcp`, {
diff --git a/test/pager.test.ts b/test/pager.test.ts
index 132f4cb3..42978d10 100644
--- a/test/pager.test.ts
+++ b/test/pager.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
-import { looksLikePatchInput, pagePlainText, resolveTextPagerCommand, type PlainTextPagerDeps } from "../src/core/pager";
+import {
+ looksLikePatchInput,
+ pagePlainText,
+ resolveTextPagerCommand,
+ type PlainTextPagerDeps,
+} from "../src/core/pager";
function createPagerDeps(overrides: Partial = {}): PlainTextPagerDeps {
return {
@@ -69,7 +74,9 @@ describe("general pager detection", () => {
for (const newline of ["\n", "\r\n"]) {
const patch = lines.join(newline);
expect(looksLikePatchInput(patch)).toBe(true);
- expect(looksLikePatchInput(`\u001b]0;title\u0007${patch}\u001bPignored\u001b\\`)).toBe(true);
+ expect(looksLikePatchInput(`\u001b]0;title\u0007${patch}\u001bPignored\u001b\\`)).toBe(
+ true,
+ );
}
}
});
@@ -95,7 +102,9 @@ describe("plain text pager fallback", () => {
});
test("prefers HUNK_TEXT_PAGER and avoids recursive hunk launches", () => {
- expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "bat --paging=always" })).toBe("bat --paging=always");
+ expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "bat --paging=always" })).toBe(
+ "bat --paging=always",
+ );
expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "hunk pager" })).toBe("less -R");
expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe("less -R");
});
diff --git a/test/pierre.test.ts b/test/pierre.test.ts
index fd6dc331..ac5e7c9d 100644
--- a/test/pierre.test.ts
+++ b/test/pierre.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test";
import { parseDiffFromFile } from "@pierre/diffs";
import type { DiffFile } from "../src/core/types";
-import { buildSplitRows, buildStackRows, loadHighlightedDiff, type DiffRow } from "../src/ui/diff/pierre";
+import {
+ buildSplitRows,
+ buildStackRows,
+ loadHighlightedDiff,
+ type DiffRow,
+} from "../src/ui/diff/pierre";
import { resolveTheme } from "../src/ui/themes";
function createDiffFile(): DiffFile {
@@ -13,7 +18,8 @@ function createDiffFile(): DiffFile {
},
{
name: "example.ts",
- contents: "export const answer = 42;\nexport const stable = true;\nexport const added = true;\n",
+ contents:
+ "export const answer = 42;\nexport const stable = true;\nexport const added = true;\n",
cacheKey: "after",
},
{ context: 3 },
@@ -74,7 +80,8 @@ describe("Pierre diff rows", () => {
expect(rows.some((row) => row.type === "hunk-header")).toBe(true);
const changedRow = rows.find(
- (row) => row.type === "split-line" && row.left.kind === "deletion" && row.right.kind === "addition",
+ (row) =>
+ row.type === "split-line" && row.left.kind === "deletion" && row.right.kind === "addition",
);
expect(changedRow).toBeDefined();
@@ -87,7 +94,11 @@ describe("Pierre diff rows", () => {
expect(changedRow.right.spans.some((span) => span.text.includes("42"))).toBe(true);
expect(changedRow.left.spans.some((span) => span.bg === theme.removedContentBg)).toBe(true);
expect(changedRow.right.spans.some((span) => span.bg === theme.addedContentBg)).toBe(true);
- expect(changedRow.right.spans.some((span) => span.text.includes("export") && typeof span.fg === "string")).toBe(true);
+ expect(
+ changedRow.right.spans.some(
+ (span) => span.text.includes("export") && typeof span.fg === "string",
+ ),
+ ).toBe(true);
});
test("builds stacked rows with separate deletion and addition lines", () => {
@@ -95,8 +106,12 @@ describe("Pierre diff rows", () => {
const theme = resolveTheme("paper", null);
const rows = buildStackRows(file, null, theme);
- const deletionRow = rows.find((row) => row.type === "stack-line" && row.cell.kind === "deletion");
- const additionRow = rows.find((row) => row.type === "stack-line" && row.cell.kind === "addition");
+ const deletionRow = rows.find(
+ (row) => row.type === "stack-line" && row.cell.kind === "deletion",
+ );
+ const additionRow = rows.find(
+ (row) => row.type === "stack-line" && row.cell.kind === "addition",
+ );
expect(deletionRow).toBeDefined();
expect(additionRow).toBeDefined();
@@ -122,11 +137,16 @@ describe("Pierre diff rows", () => {
const theme = resolveTheme(themeId, null);
const highlighted = await loadHighlightedDiff(file, theme.appearance);
const rows = buildStackRows(file, highlighted, theme).filter(
- (row): row is Extract => row.type === "stack-line" && row.cell.kind === "addition",
+ (row): row is Extract =>
+ row.type === "stack-line" && row.cell.kind === "addition",
);
- const headingRow = rows.find((row) => row.cell.spans.some((span) => span.text.includes("Heading")));
- const inlineCodeRow = rows.find((row) => row.cell.spans.some((span) => span.text.includes("inline code")));
+ const headingRow = rows.find((row) =>
+ row.cell.spans.some((span) => span.text.includes("Heading")),
+ );
+ const inlineCodeRow = rows.find((row) =>
+ row.cell.spans.some((span) => span.text.includes("inline code")),
+ );
expect(headingRow).toBeDefined();
expect(inlineCodeRow).toBeDefined();
@@ -135,10 +155,22 @@ describe("Pierre diff rows", () => {
throw new Error("Expected highlighted markdown rows");
}
- expect(headingRow.cell.spans.some((span) => span.text.includes("Heading") && span.fg === theme.syntaxColors.keyword)).toBe(true);
- expect(inlineCodeRow.cell.spans.some((span) => span.text.includes("inline code") && span.fg === theme.syntaxColors.string)).toBe(true);
- expect(headingRow.cell.spans.some((span) => span.fg === "#ff6762" || span.fg === "#d52c36")).toBe(false);
- expect(inlineCodeRow.cell.spans.some((span) => span.fg === "#5ecc71" || span.fg === "#199f43")).toBe(false);
+ expect(
+ headingRow.cell.spans.some(
+ (span) => span.text.includes("Heading") && span.fg === theme.syntaxColors.keyword,
+ ),
+ ).toBe(true);
+ expect(
+ inlineCodeRow.cell.spans.some(
+ (span) => span.text.includes("inline code") && span.fg === theme.syntaxColors.string,
+ ),
+ ).toBe(true);
+ expect(
+ headingRow.cell.spans.some((span) => span.fg === "#ff6762" || span.fg === "#d52c36"),
+ ).toBe(false);
+ expect(
+ inlineCodeRow.cell.spans.some((span) => span.fg === "#5ecc71" || span.fg === "#199f43"),
+ ).toBe(false);
}
});
});
diff --git a/test/prebuilt-package-helpers.test.ts b/test/prebuilt-package-helpers.test.ts
index 1a75e324..6fdfe3cd 100644
--- a/test/prebuilt-package-helpers.test.ts
+++ b/test/prebuilt-package-helpers.test.ts
@@ -17,7 +17,9 @@ describe("prebuilt package helpers", () => {
const version = "9.9.9";
const dependencies = buildOptionalDependencyMap(version);
- expect(Object.keys(dependencies).sort()).toEqual(PLATFORM_PACKAGE_MATRIX.map((spec) => spec.packageName).sort());
+ expect(Object.keys(dependencies).sort()).toEqual(
+ PLATFORM_PACKAGE_MATRIX.map((spec) => spec.packageName).sort(),
+ );
expect(new Set(Object.values(dependencies))).toEqual(new Set([version]));
});
@@ -57,18 +59,24 @@ describe("prebuilt package helpers", () => {
test("getPlatformPackageSpecForHost resolves supported combinations and rejects unsupported ones", () => {
expect(getPlatformPackageSpecForHost("linux", "x64").packageName).toBe("hunkdiff-linux-x64");
- expect(getPlatformPackageSpecForHost("darwin", "arm64").packageName).toBe("hunkdiff-darwin-arm64");
+ expect(getPlatformPackageSpecForHost("darwin", "arm64").packageName).toBe(
+ "hunkdiff-darwin-arm64",
+ );
expect(() => getPlatformPackageSpecForHost("freebsd" as NodeJS.Platform, "x64")).toThrow(
"Unsupported host platform for prebuilt packaging: freebsd",
);
expect(() => getPlatformPackageSpecForHost("linux", "ia32" as NodeJS.Architecture)).toThrow(
"Unsupported host architecture for prebuilt packaging: ia32",
);
- expect(() => getPlatformPackageSpecForHost("linux", "arm64")).toThrow("No published prebuilt package spec matches linux/arm64");
+ expect(() => getPlatformPackageSpecForHost("linux", "arm64")).toThrow(
+ "No published prebuilt package spec matches linux/arm64",
+ );
});
test("getHostPlatformPackageSpec resolves the current machine", () => {
- expect(getHostPlatformPackageSpec()).toEqual(getPlatformPackageSpecForHost(process.platform, process.arch));
+ expect(getHostPlatformPackageSpec()).toEqual(
+ getPlatformPackageSpecForHost(process.platform, process.arch),
+ );
});
test("sortPlatformPackageSpecs keeps package publish order stable", () => {
diff --git a/test/session-cli.test.ts b/test/session-cli.test.ts
index b60d452d..4e514263 100644
--- a/test/session-cli.test.ts
+++ b/test/session-cli.test.ts
@@ -6,11 +6,12 @@ import { join } from "node:path";
const repoRoot = process.cwd();
const sourceEntrypoint = join(repoRoot, "src/main.tsx");
const tempDirs: string[] = [];
-const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], {
- stdin: "ignore",
- stdout: "ignore",
- stderr: "ignore",
-}).exitCode === 0;
+const ttyToolsAvailable =
+ Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], {
+ stdin: "ignore",
+ stdout: "ignore",
+ stderr: "ignore",
+ }).exitCode === 0;
interface SessionListJson {
sessions: Array<{
@@ -34,7 +35,12 @@ function shellQuote(value: string) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
-function waitUntil(label: string, poll: () => T | null | Promise, timeoutMs = 10_000, intervalMs = 100) {
+function waitUntil(
+ label: string,
+ poll: () => T | null | Promise,
+ timeoutMs = 10_000,
+ intervalMs = 100,
+) {
const deadline = Date.now() + timeoutMs;
return new Promise(async (resolve, reject) => {
@@ -253,7 +259,9 @@ describe("session CLI", () => {
return null;
}
- const parsed = JSON.parse(context.stdout) as { context?: { selectedHunk?: { index: number } } };
+ const parsed = JSON.parse(context.stdout) as {
+ context?: { selectedHunk?: { index: number } };
+ };
return parsed.context?.selectedHunk?.index === 1 ? parsed : null;
});
@@ -278,7 +286,15 @@ describe("session CLI", () => {
);
expect(comment.proc.exitCode).toBe(0);
expect(comment.stderr).toBe("");
- const addedComment = JSON.parse(comment.stdout) as { result?: { commentId?: string; filePath?: string; hunkIndex?: number; side?: string; line?: number } };
+ const addedComment = JSON.parse(comment.stdout) as {
+ result?: {
+ commentId?: string;
+ filePath?: string;
+ hunkIndex?: number;
+ side?: string;
+ line?: number;
+ };
+ };
expect(addedComment).toMatchObject({
result: {
filePath: fixture.afterName,
@@ -289,7 +305,6 @@ describe("session CLI", () => {
});
expect(typeof addedComment.result?.commentId).toBe("string");
-
} finally {
session.kill();
await session.exited;
diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts
index a1419b61..72cd4598 100644
--- a/test/session-commands.test.ts
+++ b/test/session-commands.test.ts
@@ -1,6 +1,10 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { SessionCommandInput, SessionSelectorInput } from "../src/core/types";
-import { runSessionCommand, setSessionCommandTestHooks, type HunkDaemonCliClient } from "../src/session/commands";
+import {
+ runSessionCommand,
+ setSessionCommandTestHooks,
+ type HunkDaemonCliClient,
+} from "../src/session/commands";
function createListedSession(sessionId: string) {
return {
@@ -40,7 +44,16 @@ function createClient(overrides: Partial): HunkDaemonCliCli
return {
getCapabilities: async () => ({
version: 1,
- actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"],
+ actions: [
+ "list",
+ "get",
+ "context",
+ "navigate",
+ "comment-add",
+ "comment-list",
+ "comment-rm",
+ "comment-clear",
+ ],
}),
listSessions: async () => [],
getSession: async () => createListedSession("session-1"),
@@ -188,7 +201,16 @@ describe("session command compatibility checks", () => {
createClient({
getCapabilities: async () => ({
version: 1,
- actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"],
+ actions: [
+ "list",
+ "get",
+ "context",
+ "navigate",
+ "comment-add",
+ "comment-list",
+ "comment-rm",
+ "comment-clear",
+ ],
}),
}),
resolveDaemonAvailability: async () => true,
diff --git a/test/terminal.test.ts b/test/terminal.test.ts
index eceb3e91..56f8c603 100644
--- a/test/terminal.test.ts
+++ b/test/terminal.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test";
import type { CliInput } from "../src/core/types";
-import { openControllingTerminal, resolveRuntimeCliInput, shouldUsePagerMode, usesPipedPatchInput } from "../src/core/terminal";
+import {
+ openControllingTerminal,
+ resolveRuntimeCliInput,
+ shouldUsePagerMode,
+ usesPipedPatchInput,
+} from "../src/core/terminal";
function createPatchInput(file?: string, pager = false): CliInput {
return {
diff --git a/test/tty-render-smoke.test.ts b/test/tty-render-smoke.test.ts
index 7e0e9d0d..f2198696 100644
--- a/test/tty-render-smoke.test.ts
+++ b/test/tty-render-smoke.test.ts
@@ -11,11 +11,12 @@ if (enableTtySmokeTests) {
setDefaultTimeout(15000);
}
-const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], {
- stdin: "ignore",
- stdout: "ignore",
- stderr: "ignore",
-}).exitCode === 0;
+const ttyToolsAvailable =
+ Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], {
+ stdin: "ignore",
+ stdout: "ignore",
+ stderr: "ignore",
+ }).exitCode === 0;
function cleanupTempDirs() {
while (tempDirs.length > 0) {
@@ -56,13 +57,17 @@ function createFixtureFiles(lines = 1) {
} else {
writeFileSync(
before,
- Array.from({ length: lines }, (_, index) => `export const before_${String(index + 1).padStart(2, "0")} = ${index + 1};`).join("\n") +
- "\n",
+ Array.from(
+ { length: lines },
+ (_, index) => `export const before_${String(index + 1).padStart(2, "0")} = ${index + 1};`,
+ ).join("\n") + "\n",
);
writeFileSync(
after,
- Array.from({ length: lines }, (_, index) => `export const after_${String(index + 1).padStart(2, "0")} = ${index + 101};`).join("\n") +
- "\n",
+ Array.from(
+ { length: lines },
+ (_, index) => `export const after_${String(index + 1).padStart(2, "0")} = ${index + 101};`,
+ ).join("\n") + "\n",
);
}
writeFileSync(
@@ -78,18 +83,24 @@ function createFixtureFiles(lines = 1) {
}),
);
- const patchProc = Bun.spawnSync(["git", "diff", "--no-index", "--no-color", "--", before, after], {
- cwd: dir,
- stdin: "ignore",
- stdout: "pipe",
- stderr: "pipe",
- });
- const coloredPatchProc = Bun.spawnSync(["git", "diff", "--no-index", "--color=always", "--", before, after], {
- cwd: dir,
- stdin: "ignore",
- stdout: "pipe",
- stderr: "pipe",
- });
+ const patchProc = Bun.spawnSync(
+ ["git", "diff", "--no-index", "--no-color", "--", before, after],
+ {
+ cwd: dir,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+ );
+ const coloredPatchProc = Bun.spawnSync(
+ ["git", "diff", "--no-index", "--color=always", "--", before, after],
+ {
+ cwd: dir,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+ );
if (patchProc.exitCode !== 0 && patchProc.exitCode !== 1) {
const stderr = Buffer.from(patchProc.stderr).toString("utf8");
@@ -98,7 +109,9 @@ function createFixtureFiles(lines = 1) {
if (coloredPatchProc.exitCode !== 0 && coloredPatchProc.exitCode !== 1) {
const stderr = Buffer.from(coloredPatchProc.stderr).toString("utf8");
- throw new Error(stderr.trim() || `failed to build colored fixture patch: ${coloredPatchProc.exitCode}`);
+ throw new Error(
+ stderr.trim() || `failed to build colored fixture patch: ${coloredPatchProc.exitCode}`,
+ );
}
writeFileSync(patch, Buffer.from(patchProc.stdout).toString("utf8"));
@@ -107,7 +120,11 @@ function createFixtureFiles(lines = 1) {
return { dir, before, after, agent, patch, coloredPatch };
}
-async function runTtySmoke(options: { mode?: "split" | "stack"; pager?: boolean; agentContext?: boolean }) {
+async function runTtySmoke(options: {
+ mode?: "split" | "stack";
+ pager?: boolean;
+ agentContext?: boolean;
+}) {
const fixture = createFixtureFiles();
const transcript = join(fixture.dir, "transcript.txt");
const args = ["diff", fixture.before, fixture.after];
@@ -147,13 +164,19 @@ async function runTtySmoke(options: { mode?: "split" | "stack"; pager?: boolean;
return stripTerminalControl(await Bun.file(transcript).text());
}
-async function runStdinPagerSmoke(options?: { input?: string; inputCommand?: string; lines?: number; command?: "patch" | "pager" }) {
+async function runStdinPagerSmoke(options?: {
+ input?: string;
+ inputCommand?: string;
+ lines?: number;
+ command?: "patch" | "pager";
+}) {
const fixture = createFixtureFiles(options?.lines ?? 1);
const transcript = join(fixture.dir, "stdin-pager-transcript.txt");
const subcommand = options?.command === "pager" ? "pager" : "patch -";
const patchCommand = `cat ${shellQuote(fixture.coloredPatch)} | bun run ${shellQuote(sourceEntrypoint)} ${subcommand}`;
const scriptCommand = `timeout 7 script -q -f -e -c ${shellQuote(patchCommand)} ${shellQuote(transcript)}`;
- const inputCommand = options?.inputCommand ?? `(sleep 2; printf ${shellQuote(options?.input ?? "q")})`;
+ const inputCommand =
+ options?.inputCommand ?? `(sleep 2; printf ${shellQuote(options?.input ?? "q")})`;
const proc = Bun.spawnSync(["bash", "-lc", `${inputCommand} | ${scriptCommand}`], {
cwd: fixture.dir,
stdin: "ignore",
@@ -196,18 +219,21 @@ describe("TTY render smoke", () => {
expect(output).toContain("▌1 + export const answer = 42;");
});
- ttyTest("stack mode keeps the terminal-native stacked rows without split separators", async () => {
- if (!ttyToolsAvailable) {
- return;
- }
+ ttyTest(
+ "stack mode keeps the terminal-native stacked rows without split separators",
+ async () => {
+ if (!ttyToolsAvailable) {
+ return;
+ }
- const output = await runTtySmoke({ mode: "stack" });
+ const output = await runTtySmoke({ mode: "stack" });
- expect(output).toContain("View Navigate Theme Agent Help");
- expect(output).toContain("▌1 - export const answer = 41;");
- expect(output).toContain("▌ 1 + export const answer = 42;");
- expect(output).not.toContain("│1 + export const answer = 42;");
- });
+ expect(output).toContain("View Navigate Theme Agent Help");
+ expect(output).toContain("▌1 - export const answer = 41;");
+ expect(output).toContain("▌ 1 + export const answer = 42;");
+ expect(output).not.toContain("│1 + export const answer = 42;");
+ },
+ );
ttyTest("pager mode hides chrome while still rendering the diff transcript", async () => {
if (!ttyToolsAvailable) {
diff --git a/test/ui-components.test.tsx b/test/ui-components.test.tsx
index cd09486f..1a2904a5 100644
--- a/test/ui-components.test.tsx
+++ b/test/ui-components.test.tsx
@@ -12,10 +12,17 @@ const { AgentCard } = await import("../src/ui/components/panes/AgentCard");
const { DiffPane } = await import("../src/ui/components/panes/DiffPane");
const { MenuDropdown } = await import("../src/ui/components/chrome/MenuDropdown");
const { StatusBar } = await import("../src/ui/components/chrome/StatusBar");
-const { DiffSectionPlaceholder } = await import("../src/ui/components/panes/DiffSectionPlaceholder");
+const { DiffSectionPlaceholder } =
+ await import("../src/ui/components/panes/DiffSectionPlaceholder");
const { PierreDiffView } = await import("../src/ui/diff/PierreDiffView");
-function createDiffFile(id: string, path: string, before: string, after: string, withAgent = false): DiffFile {
+function createDiffFile(
+ id: string,
+ path: string,
+ before: string,
+ after: string,
+ withAgent = false,
+): DiffFile {
const metadata = parseDiffFromFile(
{
name: path,
@@ -86,8 +93,20 @@ function createBootstrap(): AppBootstrap {
summary: "Patch summary",
agentSummary: "Changeset summary",
files: [
- createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\nexport const add = true;\n", true),
- createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const betaValue = 1;\n", false),
+ createDiffFile(
+ "alpha",
+ "alpha.ts",
+ "export const alpha = 1;\n",
+ "export const alpha = 2;\nexport const add = true;\n",
+ true,
+ ),
+ createDiffFile(
+ "beta",
+ "beta.ts",
+ "export const beta = 1;\n",
+ "export const betaValue = 1;\n",
+ false,
+ ),
],
},
initialMode: "split",
@@ -167,7 +186,9 @@ function frameHasHighlightedMarker(
return false;
}
- return line.spans.some((span) => span.text.includes(marker) && span.text.trim().length < text.trim().length);
+ return line.spans.some(
+ (span) => span.text.includes(marker) && span.text.trim().length < text.trim().length,
+ );
});
}
@@ -252,7 +273,10 @@ describe("UI components", () => {
12,
);
- const lines = frame.split("\n").slice(0, 8).map((line) => line.trimEnd());
+ const lines = frame
+ .split("\n")
+ .slice(0, 8)
+ .map((line) => line.trimEnd());
expect(lines[0]).toBe("┌────────────────────────────────┐");
expect(lines[1]).toContain("AI note");
expect(lines[2]).toContain("Annotation for alpha.ts");
@@ -412,7 +436,11 @@ describe("UI components", () => {
test("HelpDialog renders the keyboard help copy", async () => {
const theme = resolveTheme("midnight", null);
- const frame = await captureFrame( {}} />, 76, 14);
+ const frame = await captureFrame(
+ {}} />,
+ 76,
+ 14,
+ );
expect(frame).toContain("Keyboard");
expect(frame).toContain("F10 menus");
@@ -602,7 +630,10 @@ describe("UI components", () => {
const addedLines = frame
.split("\n")
- .filter((line) => line.includes("export const message = 'this is a very") || /^▌\s{6,}\S/.test(line));
+ .filter(
+ (line) =>
+ line.includes("export const message = 'this is a very") || /^▌\s{6,}\S/.test(line),
+ );
expect(frame).toContain("1 - export const message = 'short';");
expect(addedLines[0]).toContain("1 + export const message = 'this is a very l");
@@ -655,7 +686,14 @@ describe("UI components", () => {
const theme = resolveTheme("midnight", null);
const noFileFrame = await captureFrame(
- ,
+ ,
76,
6,
);
@@ -676,14 +714,28 @@ describe("UI components", () => {
expect(renameOnlyFrame).toContain("This change only renames the file.");
const newFileFrame = await captureFrame(
- ,
+ ,
76,
6,
);
expect(newFileFrame).toContain("The file is marked as new.");
const deletedFileFrame = await captureFrame(
- ,
+ ,
76,
6,
);
@@ -700,7 +752,14 @@ describe("UI components", () => {
const theme = resolveTheme("midnight", null);
const firstSetup = await testRender(
- ,
+ ,
{ width: 184, height: 10 },
);
diff --git a/test/ui-lib.test.ts b/test/ui-lib.test.ts
index 3c23bc7b..1c5218ec 100644
--- a/test/ui-lib.test.ts
+++ b/test/ui-lib.test.ts
@@ -1,8 +1,18 @@
import { describe, expect, test } from "bun:test";
import { parseDiffFromFile } from "@pierre/diffs";
import type { DiffFile } from "../src/core/types";
-import { buildMenuSpecs, menuBoxHeight, menuWidth, nextMenuItemIndex, type MenuEntry } from "../src/ui/components/chrome/menu";
-import { buildAgentPopoverContent, resolveAgentPopoverPlacement, wrapText } from "../src/ui/lib/agentPopover";
+import {
+ buildMenuSpecs,
+ menuBoxHeight,
+ menuWidth,
+ nextMenuItemIndex,
+ type MenuEntry,
+} from "../src/ui/components/chrome/menu";
+import {
+ buildAgentPopoverContent,
+ resolveAgentPopoverPlacement,
+ wrapText,
+} from "../src/ui/lib/agentPopover";
import { buildAppMenus } from "../src/ui/lib/appMenus";
import { fitText, padText } from "../src/ui/lib/text";
import { estimateDiffBodyRows } from "../src/ui/lib/sectionHeights";
@@ -40,7 +50,14 @@ describe("ui helpers", () => {
test("buildMenuSpecs lays out the fixed top-level order", () => {
const specs = buildMenuSpecs();
- expect(specs.map((spec) => spec.id)).toEqual(["file", "view", "navigate", "theme", "agent", "help"]);
+ expect(specs.map((spec) => spec.id)).toEqual([
+ "file",
+ "view",
+ "navigate",
+ "theme",
+ "agent",
+ "help",
+ ]);
expect(specs[0]).toMatchObject({ id: "file", left: 1, width: 6, label: "File" });
expect(specs[1]?.left).toBe(specs[0]!.left + specs[0]!.width + 1);
});
@@ -97,20 +114,22 @@ describe("ui helpers", () => {
expect(
menus.view
- .filter((entry): entry is Extract => entry.kind === "item" && Boolean(entry.checked))
+ .filter(
+ (entry): entry is Extract =>
+ entry.kind === "item" && Boolean(entry.checked),
+ )
.map((entry) => entry.label),
- ).toEqual([
- "Stacked view",
- "Agent notes",
- "Line numbers",
- "Line wrapping",
- ]);
+ ).toEqual(["Stacked view", "Agent notes", "Line numbers", "Line wrapping"]);
expect(
menus.theme
.filter((entry): entry is Extract => entry.kind === "item")
.map((entry) => entry.label),
).toEqual(["Graphite", "Midnight", "Paper", "Ember"]);
- expect(menus.theme.some((entry) => entry.kind === "item" && entry.label === "Graphite" && entry.checked)).toBe(true);
+ expect(
+ menus.theme.some(
+ (entry) => entry.kind === "item" && entry.label === "Graphite" && entry.checked,
+ ),
+ ).toBe(true);
});
test("fitText and padText clamp using the terminal fallback marker", () => {
@@ -174,8 +193,12 @@ describe("ui helpers", () => {
const file = createDiffFile();
expect(estimateDiffBodyRows(file, "split", true)).toBeGreaterThan(0);
- expect(estimateDiffBodyRows(file, "stack", true)).toBeGreaterThan(estimateDiffBodyRows(file, "split", true));
- expect(estimateDiffBodyRows(file, "split", false)).toBe(estimateDiffBodyRows(file, "split", true) - file.metadata.hunks.length);
+ expect(estimateDiffBodyRows(file, "stack", true)).toBeGreaterThan(
+ estimateDiffBodyRows(file, "split", true),
+ );
+ expect(estimateDiffBodyRows(file, "split", false)).toBe(
+ estimateDiffBodyRows(file, "split", true) - file.metadata.hunks.length,
+ );
});
test("resolveTheme falls back by requested id and renderer mode while lazily exposing syntax styles", () => {
diff --git a/test/ui-scroll-regression.test.tsx b/test/ui-scroll-regression.test.tsx
index b2c09e49..5f012f12 100644
--- a/test/ui-scroll-regression.test.tsx
+++ b/test/ui-scroll-regression.test.tsx
@@ -9,7 +9,10 @@ mock.restore();
const { App } = await import("../src/ui/App");
function createScrollBootstrap(): AppBootstrap {
- const before = Array.from({ length: 80 }, (_, index) => `line ${String(index + 1).padStart(2, "0")} old value\n`).join("");
+ const before = Array.from(
+ { length: 80 },
+ (_, index) => `line ${String(index + 1).padStart(2, "0")} old value\n`,
+ ).join("");
const after = Array.from({ length: 80 }, (_, index) =>
index === 35
? `line ${String(index + 1).padStart(2, "0")} new value with long long text abcdefghijklmnopqrstuvwxyz\n`
@@ -65,7 +68,10 @@ function createScrollBootstrap(): AppBootstrap {
describe("UI scroll regression", () => {
test("keeps split diff lines intact after a wheel scroll repaint", async () => {
- const setup = await testRender(, { width: 160, height: 20 });
+ const setup = await testRender(, {
+ width: 160,
+ height: 20,
+ });
try {
await act(async () => {