Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6c96eae
feat(review): enumerate line-granular navigation targets
loganthomas Aug 3, 2026
2cb096f
feat(review): add a minimum-move reveal scroll target
loganthomas Aug 3, 2026
4a8e5f1
feat(diff): mark the current line in the review stream
loganthomas Aug 3, 2026
52924a5
feat(review): track the current line beside the selection
loganthomas Aug 3, 2026
444646b
feat(config): add the cursor_line setting
loganthomas Aug 3, 2026
22fc67e
feat(review): move the current line with j/k and anchor notes to it
loganthomas Aug 3, 2026
ad9fd55
test(review): cover the current line end to end
loganthomas Aug 3, 2026
e2c1df7
docs(review): document the current-line marker
loganthomas Aug 3, 2026
66b289b
feat(diff): resolve source lines from stable row anchors
loganthomas Aug 3, 2026
3f72f1b
refactor(review): derive the current line from the measured render plan
loganthomas Aug 3, 2026
1b7576e
feat(review): keep the current line on screen while paging and scrolling
loganthomas Aug 3, 2026
4e17ec2
feat(review): mark and reveal the current line in alternate presentat…
loganthomas Aug 3, 2026
c93cb5c
fix(review): reveal the note being drafted, not the hunk's first note
loganthomas Aug 3, 2026
a41bd43
feat(review): render inline notes below the line they annotate
loganthomas Aug 3, 2026
b1441a0
docs: restore the shortcut and note wording this branch had rewritten
loganthomas Aug 3, 2026
3944f4a
perf(review): skip enumerating stops while the current line is off
loganthomas Aug 3, 2026
960283f
feat(review): take the current line into a gap you expand
loganthomas Aug 3, 2026
f737d85
refactor(review): dedupe the current-line helpers and thin the comments
loganthomas Aug 3, 2026
3aaf1c5
feat(ui): add configurable sidebar display
skaragianis Jul 31, 2026
0d27fa8
feat(review): add keyboard side selection and sidebar policies
rupert648 Aug 4, 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
5 changes: 5 additions & 0 deletions .changeset/current-line-cursor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Highlight the current line and move it with `j`/`k`, use `h`/`l` to select the old or new side in split mode, and press `c` to add a note exactly where the cursor sits. Set `cursor_line` to `number` for a quieter line-number marker, or `off` to restore plain row scrolling.
5 changes: 5 additions & 0 deletions .changeset/sunny-ads-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add `sidebar = "auto" | "shown" | "hidden"` configuration and CLI flags to control the sidebar in non-pager mode.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,10 @@ vcs = "git" # git, jj, sl
watch = false
exclude_untracked = false
line_numbers = true
tab_width = 4 # tab stops, 1-16
tab_width = 4 # tab stops, 1-16
wrap_lines = false
menu_bar = true
sidebar = "auto" # "auto", "shown", "hidden"
agent_notes = false
prompt_save_view_preferences = true
transparent_background = false
Expand Down
7 changes: 6 additions & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,22 @@ The built-in commands and the keys they ship with:
| `hunk.review.jumpToBottom` | Jump to end | `G`, `end` |
| `hunk.review.scrollCodeLeft` | Scroll code left (shifted scrolls fast) | `left`, `shift+left` |
| `hunk.review.scrollCodeRight` | Scroll code right (shifted scrolls fast) | `right`, `shift+right` |
| `hunk.review.selectOldSide` | Select old side of current line | `h` |
| `hunk.review.selectNewSide` | Select new side of current line | `l` |
| `hunk.view.toggleSidebar` | Toggle sidebar | `s` |
| `hunk.view.toggleMenuBar` | Toggle menu bar | `M` |
| `hunk.view.toggleHunkHeaders` | Toggle hunk headers | `m` |
| `hunk.view.toggleLineNumbers` | Toggle line numbers | `l` |
| `hunk.view.toggleLineNumbers` | Toggle line numbers | _(none)_ |
| `hunk.view.toggleLineWrap` | Toggle line wrapping | `w` |
| `hunk.view.toggleAgentNotes` | Toggle agent notes | `a` |
| `hunk.view.toggleCopyDecorations` | Toggle copy decorations | _(none)_ |
| `hunk.view.openThemeSelector` | Choose theme | `t` |
| `hunk.view.layoutSplit` | Split layout | `1` |
| `hunk.view.layoutStack` | Stack layout | `2` |
| `hunk.view.layoutAuto` | Auto layout | `0` |
| `hunk.view.cursorLineRow` | Highlight the current row | _(none)_ |
| `hunk.view.cursorLineNumber` | Mark the current line number | _(none)_ |
| `hunk.view.cursorLineOff` | Hide the current-line marker | _(none)_ |

Commands marked _(none)_ ship without a key: they are menu items today, and
binding one gives it a shortcut like any other.
Expand Down
23 changes: 23 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,19 @@ describe("parseCli", () => {
});
});

test("parses the current-line style and rejects an unknown one", async () => {
const parsed = await parseCli(["bun", "hunk", "diff", "--cursor-line", "number"]);

expect(parsed).toMatchObject({
kind: "vcs",
options: { cursorLine: "number" },
});

await expect(parseCli(["bun", "hunk", "diff", "--cursor-line", "sparkles"])).rejects.toThrow(
"Invalid cursor line style: sparkles",
);
});

test("accepts --experimental before the review command", async () => {
const parsed = await parseCli(["bun", "hunk", "--experimental", "diff"]);

Expand Down Expand Up @@ -178,6 +191,16 @@ describe("parseCli", () => {
});
});

test("parses sidebar toggles", async () => {
const shown = await parseCli(["bun", "hunk", "diff", "--sidebar"]);
const hidden = await parseCli(["bun", "hunk", "diff", "--no-sidebar"]);
const unset = await parseCli(["bun", "hunk", "diff"]);

expect(shown).toMatchObject({ kind: "vcs", options: { sidebar: "shown" } });
expect(hidden).toMatchObject({ kind: "vcs", options: { sidebar: "hidden" } });
expect(unset.kind === "vcs" ? unset.options.sidebar : "unset").toBeUndefined();
});

test("parses staged git-style diff aliases", async () => {
const staged = await parseCli(["bun", "hunk", "diff", "--staged"]);
const cached = await parseCli(["bun", "hunk", "diff", "--cached"]);
Expand Down
26 changes: 25 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Command, Option } from "commander";
import type {
CliInput,
CommonOptions,
CursorLine,
HelpCommandInput,
LayoutMode,
PagerCommandInput,
Expand Down Expand Up @@ -38,7 +39,7 @@ import { resolveCliVersion } from "./version";
export interface CliReferenceOption {
readonly flag: string;
readonly description: string;
readonly parse?: "layout" | "positiveInt" | "tabWidth" | "collect";
readonly parse?: "layout" | "cursorLine" | "positiveInt" | "tabWidth" | "collect";
readonly defaultValue?: string;
/** Default applied directly by Commander (as opposed to a config-resolved default). */
readonly commanderDefault?: string;
Expand All @@ -59,6 +60,11 @@ export interface CliReferenceCommand {
/** Review flags registered on every full-screen review command. */
export const COMMON_REVIEW_OPTIONS = [
{ flag: "--mode <mode>", description: "layout mode: auto, split, stack", parse: "layout" },
{
flag: "--cursor-line <style>",
description: "current-line marker: row, number, off",
parse: "cursorLine",
},
{ flag: "--theme <theme>", description: "named theme override" },
AUXILIARY_AGENT_OPTIONS.agentContext,
{ flag: "--pager", description: "use pager-style chrome" },
Expand All @@ -75,6 +81,8 @@ export const COMMON_REVIEW_OPTIONS = [
{ flag: "--no-wrap", description: "truncate long diff lines to one row" },
{ flag: "--hunk-headers", description: "show hunk metadata rows" },
{ flag: "--no-hunk-headers", description: "hide hunk metadata rows" },
{ flag: "--sidebar", description: "show sidebar" },
{ flag: "--no-sidebar", description: "hide sidebar" },
{ flag: "--agent-notes", description: "show agent notes by default" },
{ flag: "--no-agent-notes", description: "hide agent notes by default" },
{ flag: "--transparent-bg", description: "let terminal background show through Hunk surfaces" },
Expand Down Expand Up @@ -195,6 +203,15 @@ function parseLayoutMode(value: string): LayoutMode {
throw new Error(`Invalid layout mode: ${value}`);
}

/** Validate one requested current-line style from CLI input. */
function parseCursorLine(value: string): CursorLine {
if (value === "row" || value === "number" || value === "off") {
return value;
}

throw new Error(`Invalid cursor line style: ${value}`);
}

/** Parse one required positive integer CLI value. */
function parsePositiveInt(value: string) {
if (!/^[1-9]\d*$/.test(value)) {
Expand Down Expand Up @@ -236,6 +253,7 @@ function collectRepeatedValue(value: string, previous: string[] = []) {
function buildCommonOptions(
options: {
mode?: LayoutMode;
cursorLine?: CursorLine;
theme?: string;
agentContext?: string;
pager?: boolean;
Expand All @@ -247,8 +265,10 @@ function buildCommonOptions(
},
argv: string[],
): CommonOptions {
const sidebarFlag = resolveBooleanFlag(argv, "--sidebar", "--no-sidebar");
return {
mode: options.mode,
cursorLine: options.cursorLine,
theme: options.theme,
agentContext: options.agentContext,
pager: options.pager ? true : undefined,
Expand All @@ -266,6 +286,7 @@ function buildCommonOptions(
tabWidth: options.tabWidth,
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
sidebar: sidebarFlag === undefined ? undefined : sidebarFlag ? "shown" : "hidden",
agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"),
transparentBackground: resolveBooleanFlag(argv, "--transparent-bg", "--no-transparent-bg"),
// Read straight from argv so the absence of the flag stays undefined rather than
Expand All @@ -281,6 +302,8 @@ function applyReferenceOption(command: Command, option: CliReferenceOption) {
const commanderOption = new Option(option.flag, option.description);
if (option.parse === "layout") {
commanderOption.argParser(parseLayoutMode);
} else if (option.parse === "cursorLine") {
commanderOption.argParser(parseCursorLine);
} else if (option.parse === "positiveInt") {
commanderOption.argParser(parsePositiveInt);
} else if (option.parse === "tabWidth") {
Expand Down Expand Up @@ -375,6 +398,7 @@ function renderCliHelp() {
" -x, --tab-width <columns> tab stop width: 1-16 (default: 4)",
" --wrap / --no-wrap wrap or truncate long diff lines",
" --hunk-headers / --no-hunk-headers show or hide hunk metadata rows",
" --sidebar / --no-sidebar show or hide sidebar by default",
" --agent-notes / --no-agent-notes show or hide agent notes by default",
" --transparent-bg / --no-transparent-bg let terminal background show through Hunk surfaces",
" --theme <theme> named theme override",
Expand Down
69 changes: 69 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ describe("config persistence", () => {
showMenuBar: false,
showAgentNotes: true,
copyDecorations: true,
cursorLine: "row",
},
{ env: { HOME: home } },
);
Expand All @@ -95,6 +96,7 @@ describe("config persistence", () => {
"menu_bar = false",
"agent_notes = true",
"copy_decorations = true",
'cursor_line = "row"',
"",
"[custom_theme]",
'label = "Keep me"',
Expand Down Expand Up @@ -133,6 +135,7 @@ describe("config persistence", () => {
showMenuBar: true,
showAgentNotes: true,
copyDecorations: false,
cursorLine: "row",
} as const;

expect(diffPersistedViewPreferences(initial, { ...initial })).toEqual([]);
Expand Down Expand Up @@ -219,6 +222,43 @@ describe("config resolution", () => {
});
});

test("reads the current-line style from config and lets CLI flags outrank it", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
createRepo(repo);

mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), 'cursor_line = "number"');

const fromConfig = resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: repo,
env: { HOME: home },
});
expect(fromConfig.input.options.cursorLine).toBe("number");

const fromFlag = resolveConfiguredCliInput(createPatchPagerInput({ cursorLine: "off" }), {
cwd: repo,
env: { HOME: home },
});
expect(fromFlag.input.options.cursorLine).toBe("off");
});

test("falls back to the built-in current-line style when config names an unknown one", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
createRepo(repo);

mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), 'cursor_line = "sparkles"');

const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: repo,
env: { HOME: home },
});

expect(resolved.input.options.cursorLine).toBe("row");
});

test("starts pager mode with the menu bar hidden unless a later layer asks for it", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
Expand Down Expand Up @@ -263,6 +303,33 @@ describe("config resolution", () => {
}
});

test("resolves the sidebar preference from config, CLI flags, and the auto default", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
createRepo(repo);

const resolveSidebar = (input: CliInput) =>
resolveConfiguredCliInput(input, { cwd: repo, env: { HOME: home } }).input.options.sidebar;

expect(resolveSidebar(createPatchPagerInput())).toBe("auto");

mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), "sidebar = false\n");
expect(resolveSidebar(createPatchPagerInput())).toBe("hidden");
// `--sidebar` outranks the config layer.
expect(resolveSidebar(createPatchPagerInput({ sidebar: "shown" }))).toBe("shown");

writeFileSync(join(home, ".config", "hunk", "config.toml"), 'sidebar = "shown"\n');
expect(resolveSidebar(createPatchPagerInput())).toBe("shown");

writeFileSync(join(home, ".config", "hunk", "config.toml"), 'sidebar = "hidden"\n');
expect(resolveSidebar(createPatchPagerInput())).toBe("hidden");

// Values outside the named policies fall back to the built-in default.
writeFileSync(join(home, ".config", "hunk", "config.toml"), 'sidebar = "always"\n');
expect(resolveSidebar(createPatchPagerInput())).toBe("auto");
});

test("merges custom theme overrides from global and repo config", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
Expand Down Expand Up @@ -959,6 +1026,7 @@ describe("config resolution", () => {
"tab_width = 8",
"wrap_lines = true",
"menu_bar = false",
"sidebar = true",
"hunk_headers = false",
"agent_notes = true",
"copy_decorations = false",
Expand Down Expand Up @@ -987,6 +1055,7 @@ describe("config resolution", () => {
expect(bootstrap.initialTabWidth).toBe(8);
expect(bootstrap.initialWrapLines).toBe(true);
expect(bootstrap.initialShowMenuBar).toBe(false);
expect(bootstrap.initialSidebar).toBe("shown");
expect(bootstrap.initialShowHunkHeaders).toBe(false);
expect(bootstrap.initialShowAgentNotes).toBe(true);
expect(bootstrap.initialCopyDecorations).toBe(false);
Expand Down
Loading