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
25 changes: 19 additions & 6 deletions src/core/pager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
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. */
Expand Down Expand Up @@ -32,22 +32,35 @@ export function resolveTextPagerCommand(env: NodeJS.ProcessEnv = process.env) {
return candidate;
}

/** Minimal dependencies for testing pager behavior without spawning a real subprocess. */
export interface PlainTextPagerDeps {
stdout: Pick<NodeJS.WriteStream, "isTTY" | "write">;
spawnImpl: (command: string, options: SpawnOptions) => ChildProcess;
}

/** Stream plain text through a normal pager, or write directly when not attached to a terminal. */
export async function pagePlainText(text: string, env: NodeJS.ProcessEnv = process.env) {
if (!process.stdout.isTTY) {
process.stdout.write(text);
export async function pagePlainText(
text: string,
env: NodeJS.ProcessEnv = process.env,
deps: PlainTextPagerDeps = {
stdout: process.stdout,
spawnImpl: spawn,
},
) {
if (!deps.stdout.isTTY) {
deps.stdout.write(text);
return;
}

const pagerCommand = resolveTextPagerCommand(env);
const pager = spawn(pagerCommand, {
const pager = deps.spawnImpl(pagerCommand, {
shell: true,
stdio: ["pipe", "inherit", "inherit"],
env,
});

pager.stdin?.end(text);
const [, code] = await once(pager, "close");
const [code] = await once(pager, "close");

if (typeof code === "number" && code !== 0) {
throw new Error(`Pager command failed: ${pagerCommand}`);
Expand Down
89 changes: 88 additions & 1 deletion test/pager.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { describe, expect, test } from "bun:test";
import { looksLikePatchInput } from "../src/core/pager";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { looksLikePatchInput, pagePlainText, resolveTextPagerCommand, type PlainTextPagerDeps } from "../src/core/pager";

function createPagerDeps(overrides: Partial<PlainTextPagerDeps> = {}): PlainTextPagerDeps {
return {
stdout: {
isTTY: true,
write() {
return true;
},
},
spawnImpl() {
const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough };
pager.stdin = new PassThrough();
queueMicrotask(() => {
pager.emit("close", 0);
});
return pager as never;
},
...overrides,
};
}

describe("general pager detection", () => {
test("detects git-style patch input even when ANSI-colored", () => {
Expand All @@ -23,3 +45,68 @@ describe("general pager detection", () => {
expect(looksLikePatchInput(branchOutput)).toBe(false);
});
});

describe("plain text pager fallback", () => {
test("falls back to less when no pager is configured", () => {
expect(resolveTextPagerCommand({})).toBe("less -R");
});

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: "hunk pager" })).toBe("less -R");
expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe("less -R");
});

test("writes directly to stdout when not attached to a terminal", async () => {
let written = "";
let spawnCalled = false;

await pagePlainText(
"plain text output",
{},
createPagerDeps({
stdout: {
isTTY: false,
write(chunk) {
written += String(chunk);
return true;
},
},
spawnImpl() {
spawnCalled = true;
throw new Error("spawn should not be called");
},
}),
);

expect(written).toBe("plain text output");
expect(spawnCalled).toBe(false);
});

test("throws when the pager exits with a non-zero status", async () => {
const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough };
pager.stdin = new PassThrough();
let written = "";
pager.stdin.on("data", (chunk) => {
written += String(chunk);
});

const promise = pagePlainText(
"needs pager",
{ PAGER: "less -R" },
createPagerDeps({
spawnImpl(command, options) {
expect(command).toBe("less -R");
expect(options.shell).toBe(true);
queueMicrotask(() => {
pager.emit("close", 1);
});
return pager as never;
},
}),
);

await expect(promise).rejects.toThrow("Pager command failed: less -R");
expect(written).toBe("needs pager");
});
});
Loading