Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3104a6f
refactor(core): extract stripTerminalControl into shared module
bezhermoso May 8, 2026
49756e7
feat(core): add streaming stdin line reader and patch sniffer
bezhermoso May 8, 2026
7c605cc
refactor(startup): route pager input through streaming sniffer
bezhermoso May 8, 2026
d45872e
feat(core): add streaming patch chunker
bezhermoso May 8, 2026
72d8f0e
feat(core): add streaming changeset producer with batched append
bezhermoso May 8, 2026
920bdc1
feat(ui): subscribe AppHost to streaming changeset
bezhermoso May 8, 2026
fd65ad0
feat(startup): wire streaming pager pipeline (MVP)
bezhermoso May 8, 2026
ec9ec13
feat(pager-stream): capture commit metadata as inline file headers
bezhermoso May 8, 2026
f179b9b
fix(pager-stream): replay buffered events to late subscribers
bezhermoso May 8, 2026
af697bd
feat(pager): add HUNK_PAGER_STREAM kill switch
bezhermoso May 8, 2026
84bbafc
chore(pager-stream): abort the changeset stream on AppHost unmount
bezhermoso May 8, 2026
1259271
feat(layout): reserve preamble rows for commit metadata blocks
bezhermoso May 8, 2026
dbebd99
feat(diff): render verbatim commit metadata above each commit's first…
bezhermoso May 8, 2026
cb43a54
feat(pager-stream): pull-paced reading with hybrid commit/file waterm…
bezhermoso May 8, 2026
076e2b2
feat(pager): split timeline-scroll and review modes via --no-review
bezhermoso May 8, 2026
585cece
feat(pager): commit-by-commit review for git log -p
bezhermoso May 8, 2026
a71f44c
feat(pager): enable full review chrome in commit-review mode
bezhermoso May 8, 2026
36d55cf
feat(pager): toggle for detailed commit message in commit-review mode
bezhermoso May 8, 2026
ab6a75b
feat(pager): hoist commit metadata above the file list and add Naviga…
bezhermoso May 8, 2026
0e7277d
refactor(pager): tighten commit-review chrome
bezhermoso May 8, 2026
5a8b923
feat(pager): tri-state commit details (full / oneLine / hidden) and d…
bezhermoso May 8, 2026
cb2b13d
refactor(pager): rename oneLine to compact and render as two rows
bezhermoso May 8, 2026
fcdf0f4
fix(pager): persist commitDetailsMode across commit-cursor moves
bezhermoso May 8, 2026
6c8375b
fix(pager): rebind cycle commit details to shift-C
bezhermoso May 8, 2026
62aa674
feat(pager): persist live comments per commit across cursor moves
bezhermoso May 8, 2026
7043e82
fix(pager): key commit-buffer dedup off changeset.id
bezhermoso May 8, 2026
f40a925
chore(pager): drop obsolete scaffolding and clarify legacy-path comment
bezhermoso May 8, 2026
778ebbb
fix(pager): key live-comment bucket off changeset.id
bezhermoso May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ function buildCommonOptions(
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"),
// --no-review forces the timeline-scrolling streaming pager (no daemon, no agent
// surface). --review forces the legacy buffered path with full review. Default is
// undefined, which lets the pager startup auto-detect log-style input.
noReview: resolveBooleanFlag(argv, "--no-review", "--review"),
};
}

Expand Down Expand Up @@ -157,6 +161,10 @@ function renderCliHelp() {
" --staged, --cached review staged changes",
" --exclude-untracked hide untracked files in working tree reviews",
"",
"Pager options:",
" --no-review scroll-only timeline mode for `git log -p` style input",
" --review force the buffered review path with daemon registration",
"",
"Notes:",
" Run `hunk <command> --help` for command-specific syntax and options.",
' "target" refers to a generic set of changes; it can be a ref (git) or revset (jj)',
Expand Down Expand Up @@ -505,7 +513,12 @@ async function parsePagerCommand(
tokens: string[],
argv: string[],
): Promise<PagerCommandInput | HelpCommandInput> {
const command = createCommand("pager", "general Git pager wrapper with diff detection");
const command = createCommand("pager", "general Git pager wrapper with diff detection")
.option(
"--no-review",
"scroll-only timeline mode: streaming, no daemon registration, no agent surface",
)
.option("--review", "force the legacy buffered path with full review surface");
let parsedOptions: Record<string, unknown> = {};

command.action((options: Record<string, unknown>) => {
Expand Down
11 changes: 11 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
wrapLines: false,
showHunkHeaders: true,
showAgentNotes: false,
commitDetailsMode: "full",
};

interface ConfigResolutionOptions {
Expand Down Expand Up @@ -52,6 +53,11 @@ function normalizeString(value: unknown) {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

/** Accept only the three valid commit-details modes. */
function normalizeCommitDetailsMode(value: unknown): "full" | "compact" | "hidden" | undefined {
return value === "full" || value === "compact" || value === "hidden" ? value : undefined;
}

/** Read the view preferences stored at one TOML object level. */
function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
return {
Expand All @@ -63,6 +69,7 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
wrapLines: normalizeBoolean(source.wrap_lines),
hunkHeaders: normalizeBoolean(source.hunk_headers),
agentNotes: normalizeBoolean(source.agent_notes),
commitDetailsMode: normalizeCommitDetailsMode(source.commit_details_mode),
};
}

Expand All @@ -81,6 +88,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
wrapLines: overrides.wrapLines ?? base.wrapLines,
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
agentNotes: overrides.agentNotes ?? base.agentNotes,
commitDetailsMode: overrides.commitDetailsMode ?? base.commitDetailsMode,
};
}

Expand Down Expand Up @@ -156,6 +164,7 @@ export function resolveConfiguredCliInput(
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes,
commitDetailsMode: DEFAULT_VIEW_PREFERENCES.commitDetailsMode,
};

if (userConfigPath) {
Expand Down Expand Up @@ -185,6 +194,8 @@ export function resolveConfiguredCliInput(
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes,
commitDetailsMode:
resolvedOptions.commitDetailsMode ?? DEFAULT_VIEW_PREFERENCES.commitDetailsMode,
};

return {
Expand Down
14 changes: 4 additions & 10 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { findAgentFileContext, loadAgentContext } from "./agent";
import { createSkippedBinaryMetadata, isProbablyBinaryFile, patchLooksBinary } from "./binary";
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths";
import { HunkUserError } from "./errors";
import { stripTerminalControl } from "./streaming/ansi";
import {
buildGitDiffArgs,
buildGitShowArgs,
Expand Down Expand Up @@ -55,15 +56,6 @@ function stripPrefixes(path: string) {
return path.replace(/^[ab]\//, "");
}

/** Remove terminal escape sequences so Git-colored pager input still parses as plain patch text. */
function stripTerminalControl(text: string) {
return text
.replace(/\x1bP[\s\S]*?\x1b\\/g, "")
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/\x1b[@-_]/g, "");
}

/**
* Strip `git log -p` / `git show -p` commit metadata so the surviving text
* is a plain patch stream that `@pierre/diffs` can parse without spamming
Expand Down Expand Up @@ -122,6 +114,7 @@ export function stripGitLogMetadata(text: string) {
return out.join("\n");
}


/** Split a multi-file patch into per-file chunks so each diff file keeps its original patch text. */
function splitPatchIntoFileChunks(rawPatch: string) {
const patch = rawPatch.replaceAll("\r\n", "\n");
Expand Down Expand Up @@ -208,7 +201,7 @@ interface BuildDiffFileOptions {
}

/** Build the normalized per-file model used by the UI regardless of input mode. */
function buildDiffFile(
export function buildDiffFile(
metadata: FileDiffMetadata,
patch: string,
index: number,
Expand Down Expand Up @@ -709,5 +702,6 @@ export async function loadAppBootstrap(
initialWrapLines: input.options.wrapLines ?? false,
initialShowHunkHeaders: input.options.hunkHeaders ?? true,
initialShowAgentNotes: input.options.agentNotes ?? false,
initialCommitDetailsMode: input.options.commitDetailsMode ?? "full",
};
}
10 changes: 1 addition & 9 deletions src/core/pager.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { once } from "node:events";

/** Remove terminal escape sequences before deciding whether stdin looks like a patch. */
function stripTerminalControl(text: string) {
return text
.replace(/\x1bP[\s\S]*?\x1b\\/g, "")
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/\x1b[@-_]/g, "");
}
import { stripTerminalControl } from "./streaming/ansi";

/** Detect whether generic pager stdin looks like a diff/patch that Hunk should review. */
export function looksLikePatchInput(text: string) {
Expand Down
144 changes: 124 additions & 20 deletions src/core/startup.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { describe, expect, test } from "bun:test";
import { HunkUserError } from "./errors";
import { prepareStartupPlan } from "./startup";
import type { LineSource } from "./streaming/stdinLines";
import type { AppBootstrap, CliInput, ParsedCliInput } from "./types";

function lineSourceFromString(text: string): LineSource {
// Mirror real stdinLines: split on \n and drop a trailing empty line so a final newline
// does not produce a phantom empty line.
const lines = text.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return {
async *[Symbol.asyncIterator]() {
for (const line of lines) yield line;
},
};
}

function createBootstrap(input: CliInput): AppBootstrap {
return {
input,
Expand Down Expand Up @@ -70,8 +83,7 @@ describe("startup planning", () => {

const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: { theme: "paper" } }),
readStdinText: async () => "* main\n feature/demo\n",
looksLikePatchInputImpl: () => false,
readStdinLines: () => lineSourceFromString("* main\n feature/demo\n"),
loadAppBootstrapImpl: async () => {
loaded = true;
throw new Error("unreachable");
Expand All @@ -82,21 +94,18 @@ describe("startup planning", () => {
expect(loaded).toBe(false);
});

test("normalizes diff-like pager stdin into patch app startup", async () => {
test("routes single-changeset pager input through the buffered review path", async () => {
// Plain `diff --git` input with no commit headers: auto-detect should NOT trigger,
// so the pager runs the legacy buffered path and registers with the daemon as
// before. This preserves the agent review surface for `git diff | hunk pager`.
const seenInputs: CliInput[] = [];

const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: { theme: "paper" } }),
readStdinText: async () => "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
looksLikePatchInputImpl: () => true,
resolveRuntimeCliInputImpl(input) {
seenInputs.push(input);
return input;
},
resolveConfiguredCliInputImpl(input) {
seenInputs.push(input);
return { input } as never;
},
readStdinLines: () =>
lineSourceFromString("diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n"),
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
loadAppBootstrapImpl: async (input) => {
seenInputs.push(input);
return createBootstrap(input);
Expand All @@ -105,20 +114,115 @@ describe("startup planning", () => {
});

expect(plan.kind).toBe("app");
if (plan.kind !== "app") {
throw new Error("Expected app startup plan.");
}
if (plan.kind !== "app") throw new Error("Expected app startup plan.");

expect(plan.cliInput).toMatchObject({
kind: "patch",
file: "-",
text: "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
options: {
theme: "paper",
pager: true,
options: { theme: "paper", pager: true, noReview: undefined },
});
expect(plan.bootstrap.stream).toBeUndefined();
expect(seenInputs).toHaveLength(1);
});

test("auto-routes log-style pager input to commit-by-commit review mode", async () => {
// Presence of a `commit <sha>` header in the prefix flips auto-detect to commit-
// review: streaming pipeline, daemon-registered, one commit at a time. The agent
// surface stays alive on the active commit.
const stdin = [
"commit abc1234567",
"Author: Alice <alice@example.com>",
"Date: 2026-01-01",
"",
" first commit",
"",
"diff --git a/a.ts b/a.ts",
"@@ -1 +1 @@",
"-old",
"+new",
"",
].join("\n");

const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: {} }),
readStdinLines: () => lineSourceFromString(stdin),
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
// Streaming pager bypasses loadAppBootstrap.
loadAppBootstrapImpl: async () => {
throw new Error("loadAppBootstrap should not be called for log-style pager input");
},
usesPipedPatchInputImpl: () => false,
});
expect(seenInputs).toHaveLength(3);

expect(plan.kind).toBe("app");
if (plan.kind !== "app") throw new Error("Expected app startup plan.");

// Commit-review default: NOT noReview; review surface stays alive.
expect(plan.cliInput.options.noReview).toBeUndefined();
expect(plan.cliInput.options.pager).toBe(true);
expect(plan.bootstrap.commitReviewStream).toBeDefined();
expect(plan.bootstrap.stream).toBeUndefined();
expect(plan.bootstrap.changeset.files).toEqual([]);
expect(plan.bootstrap.changeset.isStreaming).toBe(true);

plan.bootstrap.commitReviewStream?.abort();
});

test("--no-review flag forces streaming even on non-log-style input", async () => {
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: { noReview: true } }),
readStdinLines: () =>
lineSourceFromString("diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n"),
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
loadAppBootstrapImpl: async () => {
throw new Error("loadAppBootstrap should not be called when --no-review is set");
},
usesPipedPatchInputImpl: () => false,
});

expect(plan.kind).toBe("app");
if (plan.kind !== "app") throw new Error("Expected app startup plan.");
expect(plan.cliInput.options.noReview).toBe(true);
expect(plan.bootstrap.stream).toBeDefined();

plan.bootstrap.stream?.abort();
});

test("--review flag forces buffered review path on log-style input", async () => {
const stdin = [
"commit abc1234567",
"Author: A <a@a>",
"",
" msg",
"",
"diff --git a/a.ts b/a.ts",
"@@ -1 +1 @@",
"-old",
"+new",
"",
].join("\n");

const seenInputs: CliInput[] = [];
const plan = await prepareStartupPlan(["bun", "hunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: { noReview: false } }),
readStdinLines: () => lineSourceFromString(stdin),
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
loadAppBootstrapImpl: async (input) => {
seenInputs.push(input);
return createBootstrap(input);
},
usesPipedPatchInputImpl: () => false,
});

expect(plan.kind).toBe("app");
if (plan.kind !== "app") throw new Error("Expected app startup plan.");
expect(plan.cliInput.options.noReview).toBeUndefined();
expect(plan.bootstrap.stream).toBeUndefined();
expect(seenInputs).toHaveLength(1);
});

test("rejects watch mode for stdin-backed patch inputs", async () => {
Expand Down
Loading