Skip to content
Draft
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/goto-line-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Go to a line by typing its number: `:` opens a line prompt in the status bar, and Enter jumps the current line to it in the selected file, so `c` can annotate that line directly. Numbers use the current file's numbering (the note labels' `R` side); prefix with `l` (e.g. `:l42`) to target the source file's numbering (the `L` side).
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ The built-in commands and the keys they ship with:
| `hunk.review.alignCurrentLineTop` | Align current line to viewport top | _(none)_ |
| `hunk.review.editSelectedFile` | Open the selected file in your editor | `e` |
| `hunk.review.focusFilter` | Focus the file filter | `/` |
| `hunk.review.gotoLine` | Go to line | `:` |
| `hunk.review.halfPageDown` | Scroll down half a page | `d` |
| `hunk.review.halfPageUp` | Scroll up half a page | `u` |
| `hunk.review.jumpToBottom` | Jump to end | `G`, `end` |
Expand Down
4 changes: 2 additions & 2 deletions examples/extensions/vim-navigation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ cp -R examples/extensions/vim-navigation ~/.config/hunk/extensions/
| `zt` / `zz` / `zb` | Align the current line at the top/center/bottom |
| `Ctrl-D` / `Ctrl-U` | Move down/up by half pages |
| positive digits | Prefix the next relative motion, for example `5j` or `3]` |
| `:` | Open the host-rendered Vim command line |
| `;` | Open the host-rendered Vim command line |
| `Esc` | Exit the mode (host-owned; the extension never receives it) |
| everything else | Pass through to normal Hunk routing |

Counts are parsed by the extension and capped at 10,000. Once a normal-mode sequence resolves, the extension calls `ctx.commands.execute(id, { count })` exactly once, so Hunk applies movement atomically. A bare `0` passes to Hunk's normal layout shortcut; `0` can extend a count that already began with `1`–`9`.

Pressing `:` passes the key to the example's registered command, which opens `ctx.dialogs.input()`. That focused host dialog captures typed keys ahead of the still-active session mode until Enter submits or Escape cancels. The deliberately small Ex-style command set is:
Pressing `;` passes the key to the example's registered command, which opens `ctx.dialogs.input()`. That focused host dialog captures typed keys ahead of the still-active session mode until Enter submits or Escape cancels. The deliberately small Ex-style command set is:

| Command | Action |
| --------- | ------------------------------- |
Expand Down
4 changes: 2 additions & 2 deletions examples/extensions/vim-navigation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ export default function (hunk: HunkExtensionAPI) {
});

hunk.registerCommand(
{ id: "command-line", title: "Open Vim command line", key: ":" },
{ id: "command-line", title: "Open Vim command line", key: ";" },
async (ctx) => {
if (!ctx.keyboardModes.isActive("normal")) {
ctx.notify("Enter Vim navigation before opening its command line", "info");
return;
}

const input = await ctx.dialogs.input({
title: "Vim command (:)",
title: "Vim command (;)",
placeholder: "top or bottom",
});
if (input === null || !ctx.keyboardModes.isActive("normal")) return;
Expand Down
4 changes: 2 additions & 2 deletions examples/extensions/vim-navigation/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ export function createVimNavigationState(commands: VimNavigationCommands) {
return "handled";
}

// The registered `:` command opens a host dialog after this mode passes the key onward.
if (text === ":") {
// The registered `;` command opens a host dialog after this mode passes the key onward.
if (text === ";") {
reset();
return "pass";
}
Expand Down
4 changes: 2 additions & 2 deletions scripts/vim-navigation-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ describe("vim navigation example state", () => {
expect(calls).toEqual([{ id: "hunk.review.stepDown", options: { count: 10_000 } }]);
});

test("passes colon to the registered command line and ignores unsupported modifiers", () => {
test("passes semicolon and unsupported modifiers through, resetting pending counts", () => {
const { calls, state } = recordingState();

expect(state.handleKey({ sequence: "4" })).toBe("handled");
expect(state.handleKey({ sequence: ":" })).toBe("pass");
expect(state.handleKey({ sequence: ";" })).toBe("pass");
expect(state.handleKey({ meta: true, name: "j" })).toBe("pass");
expect(state.handleKey({ option: true, name: "k" })).toBe("pass");
expect(state.handleKey({ ctrl: true, option: true, name: "d" })).toBe("pass");
Expand Down
11 changes: 11 additions & 0 deletions src/core/run/commandCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,17 @@ const BUILTIN_COMMANDS = [
locus: "client-local",
publicToExtensions: true,
},
{
id: "hunk.review.gotoLine",
title: "Go to line",
category: "review",
defaultKeys: [":"],
// The command only opens this client's line-number prompt; submitting it
// resolves through the shared reveal path like any line navigation.
locus: "client-local",
publicToExtensions: true,
closesMenu: true,
},
{
id: "hunk.review.startNote",
title: "Add a review note",
Expand Down
100 changes: 92 additions & 8 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
type MouseEvent as TuiMouseEvent,
type ScrollBoxRenderable,
} from "@opentui/core";
import { useRenderer, useTerminalDimensions } from "@opentui/react";
import { flushSync, useRenderer, useTerminalDimensions } from "@opentui/react";
import { writeFile } from "node:fs/promises";
import {
Suspense,
Expand Down Expand Up @@ -140,7 +140,7 @@ import { resolveResponsiveLayout } from "./lib/responsive";
import { resizeSidebarWidth } from "./lib/sidebar";
import { availableThemes, resolveTheme, withTransparentSurfaces } from "./themes";

type FocusArea = "files" | "filter" | "note";
type FocusArea = "files" | "filter" | "goto" | "note";
type ActiveAddNoteTarget = ActiveAddNoteAffordance & { fileId: string };
type ThemeSelectorState = {
open: boolean;
Expand Down Expand Up @@ -331,6 +331,8 @@ export function App({
const [showAgentSkill, setShowAgentSkill] = useState(false);
const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false);
const [focusArea, setFocusArea] = useState<FocusArea>("files");
const [gotoLineText, setGotoLineText] = useState("");
const gotoLineTextRef = useRef("");
const [activeAddNoteTarget, setActiveAddNoteTarget] = useState<ActiveAddNoteTarget | null>(null);
const [paneSizes, setPaneSizes] = useState<Record<string, number>>({});
const [paneResize, setPaneResize] = useState<{
Expand Down Expand Up @@ -1170,6 +1172,7 @@ export function App({
}, [extensions, layoutMode, resolvedLayout]);
const statusBarVisible =
focusArea === "filter" ||
focusArea === "goto" ||
Boolean(review.filter) ||
Boolean(
sessionNoticeText ??
Expand Down Expand Up @@ -1404,7 +1407,9 @@ export function App({

/** Step one line: move the current line, or scroll the viewport when there is no marker. */
const stepDiffLine = (delta: number) => {
if (!activeLineCursor) {
// Cursor stepping needs the stop list; with `cursor_line = "off"` or a file view
// replacing the diff there is none, so fall back to plain scrolling.
if (!activeLineCursor || lineCursors.length === 0) {
scrollDiff(delta, "step");
return;
}
Expand Down Expand Up @@ -1904,6 +1909,63 @@ export function App({
setFocusArea("filter");
}, []);

/** Focus the goto-line input in the status bar. */
const focusGotoLine = useCallback(() => {
gotoLineTextRef.current = "";
setGotoLineText("");
// Keys of the same input chunk as the opener must already route to the
// prompt: without a synchronous flush the next character would still see
// the file-list focus and fire a command binding instead.
flushSync(() => setFocusArea("goto"));
}, []);

/** Close the goto-line input without jumping. */
const cancelGotoLine = useCallback(() => {
gotoLineTextRef.current = "";
setGotoLineText("");
setFocusArea("files");
}, []);

/** Keep the goto-line input to digits plus one leading side letter (l/r). */
const handleGotoLineInput = useCallback((value: string) => {
const cleaned = value.replace(/[^0-9lr]/g, "");
const next = /^[lr]/.test(cleaned)
? cleaned[0]! + cleaned.slice(1).replace(/[lr]/g, "")
: cleaned.replace(/[lr]/g, "");
gotoLineTextRef.current = next;
setGotoLineText(next);
}, []);

/** Jump the current line to the typed line of the selected file; "l" targets the old side. */
const submitGotoLine = useCallback(() => {
const text = gotoLineTextRef.current;
const side = text.startsWith("l") ? "old" : "new";
const line = Number.parseInt(text.replace(/^[lr]/, ""), 10);
gotoLineTextRef.current = "";
setGotoLineText("");
setFocusArea("files");
if (!Number.isInteger(line) || line <= 0) {
return;
}

const fileId = selectedFileId ?? filteredFiles[0]?.id;
if (!fileId) {
showSessionNotice("No file selected");
return;
}

const result = review.revealLine(fileId, side, line);
if (result === "none") {
showSessionNotice(
`Line ${line}${side === "old" ? " (old side)" : ""} is not part of the diff`,
);
} else if (result === "hunk") {
// The line cursor list is empty with `cursor_line = "off"`, so a line can
// degrade to its hunk — say so instead of presenting it as a precise jump.
showSessionNotice(`Line ${line} is not visible; jumped to its hunk`);
}
}, [review.revealLine, selectedFileId, filteredFiles, showSessionNotice]);

// Command-handler navigation lands here each render: the same focus and jump
// semantics the sidebar's onSelect handlers use, so a command's navigation is
// indistinguishable from a sidebar row click. Read through a ref because the
Expand Down Expand Up @@ -1933,7 +1995,10 @@ export function App({
const startUserNote = useCallback(
(fileId?: string, hunkIndex?: number, target?: UserNoteLineTarget) => {
const hoverTarget = fileId === undefined ? activeAddNoteTarget : null;
const keyboardTarget = hoverTarget ?? (fileId === undefined ? activeLineCursor : null);
// Read the review cursor directly, not the display-gated active one: with
// `cursor_line = "off"` the marker stays hidden, but a line a goto reveal
// placed is still the note's intended target.
const keyboardTarget = hoverTarget ?? (fileId === undefined ? review.lineCursor : null);
const draft = review.startUserNote(
fileId ?? keyboardTarget?.fileId,
hunkIndex ?? keyboardTarget?.hunkIndex,
Expand All @@ -1945,7 +2010,7 @@ export function App({
setFocusArea("note");
}
},
[activeAddNoteTarget, activeLineCursor, review.startUserNote],
[activeAddNoteTarget, review.lineCursor, review.startUserNote],
);

/** Mark the inline draft note textarea as the active keyboard input. */
Expand Down Expand Up @@ -2029,6 +2094,7 @@ export function App({
alignCurrentLine,
applyFilePresentationToAllMatching,
focusFilter,
focusGotoLine,
moveSelection: review.moveSelection,
openAgentSkill,
openThemeSelector,
Expand Down Expand Up @@ -2436,16 +2502,34 @@ export function App({
{statusBarVisible ? (
<StatusBar
filter={review.filter}
filterFocused={focusArea === "filter"}
promptInput={
focusArea === "filter"
? {
label: "filter:",
value: review.filter,
placeholder: "type to filter files",
onInput: review.setFilter,
onSubmit: focusFiles,
onEscape: () => (review.filter.length > 0 ? review.setFilter("") : focusFiles()),
}
: focusArea === "goto"
? {
label: "goto line:",
value: gotoLineText,
placeholder: "42 or l42 (old side)",
onInput: handleGotoLineInput,
onSubmit: submitGotoLine,
onEscape: cancelGotoLine,
}
: null
}
modeText={keyboardModeHint ?? undefined}
noticeText={
sessionNoticeText ?? transientNoticeText ?? noticeText ?? fileViewModeHint ?? undefined
}
terminalWidth={terminal.width}
theme={activeTheme}
onCloseMenu={closeMenu}
onFilterInput={review.setFilter}
onFilterSubmit={focusFiles}
onExitMode={exitKeyboardMode}
/>
) : null}
Expand Down
Loading
Loading