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
5 changes: 5 additions & 0 deletions .changeset/responsive-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Open `hunk log` as an auto-responsive terminal browser with GitHub-inspired rows, right-aligned commit links and copy actions, while preserving static output for pipes and `--static`.
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,19 @@ hunk --fast # experimentally offload eligible syntax highligh
hunk diff --watch # auto-reload as the working tree changes
hunk show # review the latest commit
hunk show HEAD~1 # review an earlier commit
hunk log # print the selected provider's repository history
hunk log --interactive # browse history; Enter opens a commit in Hunk
hunk log # browse history on a terminal; print when redirected
hunk log --static # force static output, paging when needed
```

`hunk log` is a static-first, read-only history surface, not a repository manager. The selected VCS
adapter owns traversal, filtering, refs, and how a history item opens for review; the bundled Git
and Jujutsu adapters both implement that public capability. Default output keeps full commit,
author, date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact rows, and `--theme`
uses the same palette as Hunk review. Static output remains safe for pipes, redirects, and normal
terminal scrollback. The explicit interactive mode stays a single history list; after opening a
commit, quit its normal Hunk review to return to the same selection.
`hunk log` is one auto-responsive, read-only history surface, not a repository manager. On a
terminal it opens the desktop history browser; pipes and redirects receive shell-native static
records automatically, and `--static` forces static output that pages only when needed. The selected VCS adapter
owns traversal, filtering, refs, and how a history item opens for review; the bundled Git and
Jujutsu adapters both implement that public capability. Static output keeps full commit, author,
date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact records, and
`--theme` uses the same palette as Hunk review. Interactive rows adapt their information density to
the available width and keep commit ids right-aligned and clickable. After opening a commit, quit
its normal Hunk review to return to the same selection.

### Working with Jujutsu and Sapling

Expand Down
7 changes: 4 additions & 3 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ deleted until its replies are removed.

The built-in commands and the keys they ship with:

`hunk log --interactive` is a separate, fixed read-only history entry point rather than part of
the configurable review command table. `F10` opens its File, View, Navigate, Commit, and Help menus;
On a terminal, `hunk log` opens its read-only history browser automatically. Its controls are
separate from the configurable review command table. `F10` opens File, View, Navigate, Commit, and Help menus;
View includes Hunk's shared theme selector. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`,
`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `r` to refresh, `y` to copy
the full commit id, `Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit
id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it.
id to open it immediately, click the adjacent copy icon to copy its full immutable id, click elsewhere
on a row to select it, or double-click a row to open it.
Quitting the opened review returns to the retained history selection and viewport. The Commit menu's
**Compare with first parent** and **Compare with parent…** actions compare the selected commit against
an ordered provider-owned parent; they do not navigate the history selection to that parent.
Expand Down
12 changes: 10 additions & 2 deletions src/app/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ describe("parseCli", () => {
});
});

test("parses static and interactive log options without review flags", async () => {
test("parses automatic and forced-static log options without review flags", async () => {
expect(
await parseCli([
"bun",
Expand Down Expand Up @@ -520,10 +520,14 @@ describe("parseCli", () => {
color: "never",
format: "medium",
ascii: true,
interactive: true,
static: false,
extensionsEnabled: true,
extensionPaths: [],
});
expect(await parseCli(["bun", "hunk", "log", "--static"])).toMatchObject({
kind: "history",
static: true,
});
});

test("parses compact aliases, themes, and command-local extension disabling", async () => {
Expand Down Expand Up @@ -1757,6 +1761,10 @@ describe("parseCli command help text", () => {
expect(await expectHelp(["patch", "--help"])).toContain("review a patch file");
expect(await expectHelp(["pager", "--help"])).toContain("general Git pager wrapper");
expect(await expectHelp(["difftool", "--help"])).toContain("review Git difftool file pairs");
const logHelp = await expectHelp(["log", "--help"]);
expect(logHelp).toContain("browse an attractive repository history");
expect(logHelp).toContain("--static");
expect(logHelp).not.toContain("--interactive");
});

test("renders the stash command overview and the stash show command help", async () => {
Expand Down
22 changes: 13 additions & 9 deletions src/app/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,11 @@ export const CLI_REFERENCE_COMMANDS = {
path: "log",
// Release preparation enables this when an installable build contains history browsing.
publicDocs: false,
summary: "print an attractive repository history",
summary: "browse an attractive repository history",
synopsis: ["hunk log [revision-expression] [-- <pathspec...>]"],
details: [
"Static output is the default. Use --interactive for the experimental history browser.",
"A terminal opens the responsive browser; pipes and redirects receive static output.",
"Use --static to force static output, paging when it exceeds the terminal.",
"The selected VCS provider defines revision, filtering, and review semantics.",
],
options: [
Expand All @@ -232,13 +233,13 @@ export const CLI_REFERENCE_COMMANDS = {
},
{
flag: "--format <format>",
description: "record format: medium or compact",
description: "static record format: medium or compact",
commanderDefault: "medium",
},
{ flag: "--oneline", description: "alias for --format compact" },
{ flag: "--oneline", description: "alias for static --format compact" },
{ flag: "--theme <id>", description: "use the same theme as Hunk review" },
{ flag: "--ascii", description: "use an ASCII graph" },
{ flag: "--interactive", description: "browse history and open commits in Hunk" },
{ flag: "--static", description: "print static output, paging when needed" },
{ flag: "--vcs <id>", description: "select a VCS history provider" },
{
flag: "--extension <path>",
Expand Down Expand Up @@ -576,7 +577,7 @@ function renderCliHelp() {
" hunk diff --staged [-- <pathspec...>] review staged changes",
" hunk diff --files <left> <right> compare two concrete files",
" hunk show [target] [-- <pathspec...>] review the last commit or a given target",
" hunk log [target] [-- <pathspec...>] print an attractive repository history",
" hunk log [target] [-- <pathspec...>] browse an attractive repository history",
" hunk stash show [ref] review a stash entry (git only)",
" hunk patch [file] review a patch file or stdin",
" hunk pager general Git pager wrapper with diff detection",
Expand Down Expand Up @@ -1003,13 +1004,16 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise<Parse
};
}

/** Parse the deliberately small static-first `hunk log` grammar. */
/** Parse the deliberately small auto-responsive `hunk log` grammar. */
async function parseHistoryCommand(
tokens: string[],
extensionsEnabled: boolean,
): Promise<ParsedCliInput> {
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
const command = createCliReferenceCommand("log").argument("[revision]");
const command = createCliReferenceCommand("log")
.argument("[revision]")
// Accept the former opt-in spelling during migration without presenting two experiences.
.addOption(new Option("--interactive").hideHelp());
let revision: string | undefined;
let options: Record<string, unknown> = {};

Expand Down Expand Up @@ -1052,7 +1056,7 @@ async function parseHistoryCommand(
color,
format,
ascii: Boolean(options.ascii),
interactive: Boolean(options.interactive),
static: Boolean(options.static),
...(typeof options.theme === "string" ? { theme: options.theme } : {}),
...(typeof options.vcs === "string" ? { vcs: options.vcs } : {}),
extensionsEnabled: extensionsEnabled && options.extensions !== false,
Expand Down
2 changes: 1 addition & 1 deletion src/app/historyBootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const input: HistoryCommandInput = {
color: "never",
format: "compact",
ascii: false,
interactive: true,
static: false,
vcs: "test",
extensionsEnabled: false,
extensionPaths: [],
Expand Down
19 changes: 18 additions & 1 deletion src/app/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createEmptyExtensionLoadResult } from "../extensions/types";
import { resolveExtensionCliCommands } from "../extensions/cliCommands";
import type { HunkConfigResolution } from "../core/run/config";
import { HunkUserError } from "../core/run/errors";
import { prepareStartupPlan } from "./startup";
import { prepareStartupPlan, shouldUseInteractiveHistory } from "./startup";
import type { AppBootstrap } from "../core/bootstrap";
import type { CliInput, ParsedCliInput } from "../core/run/commandInputs";
import type { NamedCustomThemeConfig } from "../extension-api/types";
Expand Down Expand Up @@ -41,6 +41,23 @@ function createBootstrap(input: CliInput): AppBootstrap {
};
}

describe("history surface selection", () => {
test("opens one automatic browser only when both standard streams are terminals", () => {
expect(
shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: true, stdoutIsTTY: true }),
).toBe(true);
expect(
shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: false, stdoutIsTTY: true }),
).toBe(false);
expect(
shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: true, stdoutIsTTY: false }),
).toBe(false);
expect(
shouldUseInteractiveHistory({ forceStatic: true, stdinIsTTY: true, stdoutIsTTY: true }),
).toBe(false);
});
});

describe("startup planning", () => {
test("runs an extension CLI command and retires its registry before returning", async () => {
const invocation = {
Expand Down
20 changes: 19 additions & 1 deletion src/app/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,19 @@ function applyDelegatedExtensionFlags(
} as ParsedCliInput;
}

/** Choose the history surface from terminal ownership unless static output was forced. */
export function shouldUseInteractiveHistory({
forceStatic,
stdinIsTTY,
stdoutIsTTY,
}: {
forceStatic: boolean;
stdinIsTTY: boolean;
stdoutIsTTY: boolean;
}) {
return !forceStatic && stdinIsTTY && stdoutIsTTY;
}

/** Normalize startup work so help, pager, and app-bootstrap paths can be tested directly. */
export async function prepareStartupPlan(
argv: string[] = process.argv,
Expand Down Expand Up @@ -399,8 +412,13 @@ export async function prepareStartupPlan(
// The runner owns source/extension retirement; unlike ordinary headless plans, history must
// retain its provider cursor until every page has been consumed.
preloadedExtensions = undefined;
const useInteractiveHistory = shouldUseInteractiveHistory({
forceStatic: parsedCliInput.static,
stdinIsTTY,
stdoutIsTTY,
});
return {
kind: parsedCliInput.interactive ? "history-interactive" : "history-static",
kind: useInteractiveHistory ? "history-interactive" : "history-static",
bootstrap,
input: parsedCliInput,
};
Expand Down
5 changes: 3 additions & 2 deletions src/core/run/commandInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export type SessionCommentListType = "live" | "all" | ReviewNoteSource;
export type HistoryColorMode = "auto" | "always" | "never";
export type HistoryFormat = "medium" | "compact";

/** Static-first VCS history invocation, deliberately separate from review view options. */
/** Auto-responsive VCS history invocation, deliberately separate from review view options. */
export interface HistoryCommandInput {
kind: "history";
revision?: string;
Expand All @@ -128,7 +128,8 @@ export interface HistoryCommandInput {
color: HistoryColorMode;
format: HistoryFormat;
ascii: boolean;
interactive: boolean;
/** Force scrollback output even when stdin and stdout are terminals. */
static: boolean;
theme?: string;
vcs?: string;
extensionsEnabled: boolean;
Expand Down
2 changes: 1 addition & 1 deletion src/ui/history/runStaticHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function runtime(commits: HistoryCommit[], maxCount?: number) {
color: "never",
format: "medium",
ascii: false,
interactive: false,
static: true,
extensionsEnabled: true,
extensionPaths: [],
...(maxCount !== undefined ? { maxCount } : {}),
Expand Down
Loading
Loading