Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ CLI input

- CLI entrypoints: `diff`, `show`, `stash show`, `patch`, `pager`, `difftool`.
- All input sources normalize into one internal changeset model.
- Pager mode has two paths: full diff UI for patch-like stdin, plain-text fallback for non-diff pager content.
- Pager mode resolves to one of four `StartupPlan` outcomes (the single enumeration of pager behavior): full diff UI for patch-like stdin on a usable TTY, plain-text fallback for non-diff pager content, a static ANSI diff for captured pager hosts (LazyGit etc.: `TERM=dumb` + a non-TTY pipe), and raw passthrough for any other non-TTY/dumb pipe.
- View defaults are layered through built-ins, user config, repo `.dunk/config.toml`, command sections, pager sections, and CLI flags.
- Review comments live in one committed `.dunk/comments.json` per repo. Watch mode picks up changes so a coding agent and a human reviewer can ping-pong on the same review.
- Prefer one source of truth for each user-visible behavior. When rendering, navigation, scrolling, or note placement share the same model, derive them from the same planning layer rather than maintaining parallel implementations.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
- `watch = true` in `.dunk/config.toml` (or `~/.config/dunk/config.toml`) now enables watch mode without passing `--watch` every time. The `--watch`/absence of it on the command line still overrides the config value.
- Ctrl-Z suspends `dunk` as a normal shell job (the terminal is restored, the process group gets SIGTSTP, and `fg` resumes the renderer). Previously Ctrl-Z was swallowed in raw mode and `dunk` could not be suspended. No-op on Windows.
- `,` and `.` jump to the previous / next file in the review stream (clamped at the ends), so you can skip whole files without stepping through every hunk. Shown in `?` help.
- Captured pager hosts (LazyGit, `git -c core.pager="dunk pager" log -p`, anything that pipes a custom pager into its own panel with `TERM=dumb`) now get a static, colored ANSI diff instead of a broken alt-screen attempt. Other non-TTY / dumb-terminal pipes echo the raw patch verbatim so the pager pipeline keeps working.

### Removed

Expand Down
81 changes: 81 additions & 0 deletions src/core/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ describe("startup planning", () => {
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,
// The interactive TUI path requires a usable TTY and a non-captured,
// non-dumb terminal; without these the pager now falls back instead.
stdoutIsTTY: true,
env: { TERM: "xterm-256color" },
resolveRuntimeCliInputImpl(input) {
seenInputs.push(input);
return input;
Expand Down Expand Up @@ -88,6 +92,83 @@ describe("startup planning", () => {
expect(seenInputs).toHaveLength(3);
});

test("renders a static diff for a captured pager host", async () => {
const plan = await prepareStartupPlan(["bun", "dunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: {} }),
readStdinText: async () => "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
looksLikePatchInputImpl: () => true,
// LazyGit-style: TERM=dumb + GIT_PAGER, stdout is a non-TTY pipe. This
// must beat the generic non-TTY passthrough below.
stdoutIsTTY: false,
env: { TERM: "dumb", GIT_PAGER: "dunk" },
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
loadAppBootstrapImpl: async (input) => createBootstrap(input),
});

expect(plan.kind).toBe("static-diff-pager");
if (plan.kind !== "static-diff-pager") {
throw new Error("Expected static-diff-pager plan.");
}
expect(plan.bootstrap.input).toMatchObject({ kind: "patch", file: "-" });
});

test.each([
{ name: "LAZYGIT marker", env: { TERM: "dumb", LAZYGIT_STATE: "1" } },
{ name: "GIT_PAGER", env: { TERM: "dumb", GIT_PAGER: "dunk" } },
{ name: "lv filter mode", env: { TERM: "dumb", LV: "-c" } },
])("captured-host detection fires for $name", async ({ env }) => {
const plan = await prepareStartupPlan(["bun", "dunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: {} }),
readStdinText: async () => "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
looksLikePatchInputImpl: () => true,
stdoutIsTTY: false,
env,
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
loadAppBootstrapImpl: async (input) => createBootstrap(input),
});

expect(plan.kind).toBe("static-diff-pager");
});

test("GIT_PAGER without TERM=dumb is not a captured host (no false positive)", async () => {
// A globally-exported GIT_PAGER must not force static output for a normal
// pager pipe; the TERM=dumb gate keeps interactive use on the TUI path.
const plan = await prepareStartupPlan(["bun", "dunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: {} }),
readStdinText: async () => "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
looksLikePatchInputImpl: () => true,
stdoutIsTTY: false,
env: { TERM: "xterm-256color", GIT_PAGER: "dunk" },
loadAppBootstrapImpl: async () => {
throw new Error("should not bootstrap");
},
});

expect(plan.kind).toBe("passthrough");
});

test("echoes the raw patch when there is no usable TTY and no captured host", async () => {
const stdinText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n";
let bootstrapped = false;

const plan = await prepareStartupPlan(["bun", "dunk", "pager"], {
parseCliImpl: async () => ({ kind: "pager", options: {} }),
readStdinText: async () => stdinText,
looksLikePatchInputImpl: () => true,
stdoutIsTTY: false,
env: { TERM: "xterm-256color" },
loadAppBootstrapImpl: async () => {
bootstrapped = true;
throw new Error("should not bootstrap for passthrough");
},
});

expect(plan).toEqual({ kind: "passthrough", text: stdinText });
expect(bootstrapped).toBe(false);
});

test("rejects watch mode for stdin-backed patch inputs", async () => {
const cliInput: CliInput = {
kind: "patch",
Expand Down
55 changes: 55 additions & 0 deletions src/core/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export type StartupPlan =
kind: "plain-text-pager";
text: string;
}
| {
kind: "passthrough";
text: string;
}
| {
kind: "static-diff-pager";
bootstrap: AppBootstrap;
}
| {
kind: "app";
bootstrap: AppBootstrap;
Expand All @@ -36,6 +44,28 @@ export interface StartupDeps {
loadAppBootstrapImpl?: typeof loadAppBootstrap;
usesPipedPatchInputImpl?: typeof usesPipedPatchInput;
openControllingTerminalImpl?: typeof openControllingTerminal;
stdoutIsTTY?: boolean;
env?: NodeJS.ProcessEnv;
}

/**
* Detect a captured pager host (LazyGit, `git -c core.pager=dunk log -p`)
* that pipes our output into its own panel while advertising `TERM=dumb`.
* Such hosts give a non-TTY stdout, so this must be checked before the
* generic non-TTY passthrough or the static path would never fire for them.
*/
function isCapturedPagerHost(env: NodeJS.ProcessEnv) {
// Mirrors upstream hunk's captured-host signal set, all gated on TERM=dumb:
// `LV=-c` (the `lv` pager invoked in filtered/captured mode), `GIT_PAGER`
// (git driving us as a custom pager, e.g. `git -c core.pager=dunk log -p`),
// and any `LAZYGIT*` var (LazyGit's diff panel). Misses degrade safely to
// passthrough; a false positive would need TERM=dumb on a real TTY.
return (
env.TERM === "dumb" &&
(env.LV === "-c" ||
Boolean(env.GIT_PAGER) ||
Object.keys(env).some((key) => key.startsWith("LAZYGIT")))
);
}

/**
Expand All @@ -51,6 +81,7 @@ export async function prepareStartupPlan(
): Promise<StartupPlan> {
const parseCliImpl = deps.parseCliImpl ?? parseCli;
let parsedCliInput = await parseCliImpl(argv);
let staticPager = false;

if (parsedCliInput.kind === "help") {
return {
Expand All @@ -73,6 +104,22 @@ export async function prepareStartupPlan(
};
}

const env = deps.env ?? process.env;
const stdoutIsTTY = deps.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
const capturedPagerHost = isCapturedPagerHost(env);

// Known captured hosts get a static ANSI render even though their stdout
// is a non-TTY pipe — so this check must precede the generic passthrough.
// Anything else without a usable TTY (or a dumb terminal) just echoes the
// raw patch so the pager pipeline keeps working.
if (!capturedPagerHost && (!stdoutIsTTY || env.TERM === "dumb")) {
return {
kind: "passthrough",
text: stdinText,
};
}
staticPager = capturedPagerHost;

parsedCliInput = {
kind: "patch",
file: "-",
Expand Down Expand Up @@ -116,6 +163,14 @@ export async function prepareStartupPlan(
}

const bootstrap = await loadAppBootstrapImpl(cliInput);

if (staticPager) {
return {
kind: "static-diff-pager",
bootstrap,
};
}

const controllingTerminal = usesPipedPatchInputImpl(cliInput)
? openControllingTerminalImpl()
: null;
Expand Down
16 changes: 16 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ async function main() {
process.exit(0);
}

if (startupPlan.kind === "passthrough") {
// Captured non-TTY pipe we can't usefully render in: echo the patch back
// verbatim so the host's pager pipeline keeps working. (UTF-8 text — the
// same assumption as the plain-text pager path.)
await Bun.write(Bun.stdout, startupPlan.text);
process.exit(0);
}

if (startupPlan.kind === "static-diff-pager") {
const { renderStaticDiffPager } = await import("./ui/staticDiffPager");
// Bun.write drains before resolving, so the full diff reaches the host
// before exit (process.exit would otherwise truncate a large diff).
await Bun.write(Bun.stdout, renderStaticDiffPager(startupPlan.bootstrap));
process.exit(0);
}

if (startupPlan.kind !== "app") {
throw new Error("Unreachable startup plan.");
}
Expand Down
116 changes: 116 additions & 0 deletions src/ui/staticDiffPager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test";
import type { AppBootstrap, CliInput, DiffFile } from "../core/types";
import { createTestDiffFile, createTestAnnotations } from "../../test/helpers/diff-helpers";
import { renderStaticDiffPager, staticStackPalette } from "./staticDiffPager";
import { resolveTheme, THEMES } from "./themes";

const PATCH_INPUT: CliInput = { kind: "patch", file: "-", options: {} };

function ansiBg(hex: string) {
const value = hex.replace(/^#/, "");
const r = Number.parseInt(value.slice(0, 2), 16);
const g = Number.parseInt(value.slice(2, 4), 16);
const b = Number.parseInt(value.slice(4, 6), 16);
return `\x1b[48;2;${r};${g};${b}m`;
}

function bootstrapFor(files: DiffFile[], overrides: Partial<AppBootstrap> = {}): AppBootstrap {
return {
input: PATCH_INPUT,
changeset: { id: "test", sourceLabel: "test", title: "test", files },
initialMode: "stack",
initialTheme: "graphite",
...overrides,
};
}

describe("renderStaticDiffPager", () => {
test("renders the file path, both diff sides, and the hunk header", () => {
const file = createTestDiffFile({ context: 1 });
const output = renderStaticDiffPager(bootstrapFor([file]));

expect(output).toContain("example.ts");
expect(output).toContain("@@");
expect(output).toContain("alpha = 10"); // added side
expect(output).toContain("alpha = 1;"); // removed side
});

test("colors come from the resolved theme, not hardcoded values", () => {
const file = createTestDiffFile({ context: 0 });
const output = renderStaticDiffPager(bootstrapFor([file]));
const theme = resolveTheme("graphite", null);

// An added line must carry the theme's addition background, proving the
// static mapping reads the same AppTheme fields as the interactive
// renderer's stackCellPalette rather than inventing its own palette.
expect(output).toContain(ansiBg(theme.addedBg));
expect(output).toContain(ansiBg(theme.removedBg));
});

test("omits hunk headers when disabled", () => {
const file = createTestDiffFile({ context: 0 });
const withHeaders = renderStaticDiffPager(bootstrapFor([file]));
const withoutHeaders = renderStaticDiffPager(
bootstrapFor([file], { initialShowHunkHeaders: false }),
);

expect(withHeaders).toContain("@@");
expect(withoutHeaders).not.toContain("@@");
});

test("never renders the comment overlay (pager content is transient)", () => {
const file = createTestDiffFile({ annotations: createTestAnnotations("example.ts") });
const output = renderStaticDiffPager(bootstrapFor([file]));

expect(file.annotations.length).toBeGreaterThan(0);
expect(output).not.toContain("Annotation for example.ts");
});

test("renders an empty changeset without throwing", () => {
expect(renderStaticDiffPager(bootstrapFor([]))).toBe("\n");
});

// Enforces the "mirrors stackCellPalette" invariant as a test, not just a
// comment: the static palette must select the exact AppTheme fields the
// interactive renderer uses, for every built-in theme.
test("the static palette reads the same theme fields across all built-in themes", () => {
for (const theme of THEMES) {
expect(staticStackPalette("addition", theme)).toEqual({
contentBg: theme.addedBg,
signColor: theme.addedSignColor,
});
expect(staticStackPalette("deletion", theme)).toEqual({
contentBg: theme.removedBg,
signColor: theme.removedSignColor,
});
expect(staticStackPalette("context", theme)).toEqual({
contentBg: theme.contextBg,
signColor: theme.muted,
});
}
});

// The ANSI serializer only colors strict 6-hex values; a theme using
// shorthand/8-digit would silently render colorless. Pin every consumed
// field across all built-in themes.
test("every theme color the static pager consumes is a 6-digit hex value", () => {
const sixHex = /^#[0-9a-f]{6}$/i;
for (const theme of THEMES) {
for (const field of [
theme.addedBg,
theme.removedBg,
theme.contextBg,
theme.addedSignColor,
theme.removedSignColor,
theme.muted,
theme.lineNumberFg,
theme.lineNumberBg,
theme.badgeNeutral,
theme.panelAlt,
theme.text,
]) {
expect(field).toMatch(sixHex);
}
}
});
});
Loading
Loading