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

Make the note composer save shortcut remappable via `hunk.review.saveNote` (default `ctrl+s`).
5 changes: 3 additions & 2 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1835,8 +1835,9 @@ it, whether the user reached it through a key, a menu, an old command alias, or
may still have detached async work in flight; this event observes the accepted user action, not
promise settlement. Listen for ids rather than key chords so behavior follows the user's live
`[keybindings]` table. Browser/session actions lower to shared review intents rather than terminal
commands and do not emit this event. Modal widget keys such as Escape, Enter, note-editor Ctrl-S,
and F10 menu navigation are also not commands.
commands and do not emit this event. Modal widget keys such as Escape, Enter,
and F10 menu navigation are also not commands. The note composer's save shortcut
is `hunk.review.saveNote` and does emit this event.

`session_reload`'s `reason` is `"watch"` (the watcher saw the source change),
`"daemon"` (an agent command through the session broker), or `"manual"` (the
Expand Down
16 changes: 12 additions & 4 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ The built-in commands and the keys they ship with:
| `hunk.review.previousFile` | Previous file | `,` |
| `hunk.review.previousHunk` | Previous hunk | `[` |
| `hunk.review.replyToActiveNote` | Reply to the active review note | `R` |
| `hunk.review.saveNote` | Save a draft review note | `ctrl+s` |
| `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.startNote` | Add a review note | `c` |
Expand Down Expand Up @@ -122,10 +123,17 @@ invoke these same public `hunk.*` commands.
Routing precedence is host prompts and dialogs, menus/overlays, focused text
inputs, an interactive file-view mode, a session extension keyboard mode, then
the command table and focused review widget. Keys that belong to a dialog,
menu, or focused text input — `Esc`, `Enter`, `Ctrl-S` while writing a note —
are part of those widgets rather than commands, and are not remappable. Escape
is also the reserved exit from each active extension mode, so an extension
cannot trap the keyboard.
menu, or focused text input — `Esc`, `Enter` — are part of those widgets rather
than commands, and are not remappable. The note composer's save shortcut is the
command `hunk.review.saveNote` (default `ctrl+s`) and is remappable; while the
composer is focused it still wins over the command table, using the resolved
chord. Escape is also the reserved exit from each active extension mode, so an
extension cannot trap the keyboard.

```toml
[keybindings]
"hunk.review.saveNote" = "ctrl+enter" # Zellij-friendly; default is ctrl+s
```

`[keybindings]` is read from your user config only — never from a repository's
`.hunk/config.toml`. Which keys do what is a property of your keyboard and your
Expand Down
3 changes: 3 additions & 0 deletions src/core/run/commandCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ describe("app command catalog", () => {
expect(
lowerAppCommandToReviewIntent(entry("hunk.app.quit"), { count: 1, state }),
).toBeUndefined();
expect(
lowerAppCommandToReviewIntent(entry("hunk.review.saveNote"), { count: 1, state }),
).toBeUndefined();
});

test("lowers a new note at the current selection, with an optional measured line", () => {
Expand Down
10 changes: 10 additions & 0 deletions src/core/run/commandCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ const BUILTIN_COMMANDS = [
publicToExtensions: true,
closesMenu: true,
},
{
id: "hunk.review.saveNote",
title: "Save review note",
category: "review",
defaultKeys: ["ctrl+s"],
// The TUI draft buffer is this client's; persist already goes through
// `notes/create-user` / `notes/update-user` inside the save handler.
locus: "client-local",
publicToExtensions: true,
},
{
id: "hunk.review.pageDown",
title: "Scroll down one page",
Expand Down
5 changes: 4 additions & 1 deletion src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
buildAppCommands,
builtinCommandKeyDefaults,
builtinCommandMatchProbes,
findAppCommandById,
observeAppCommandDispatch,
} from "./lib/appCommands";
import { buildAppMenus } from "./lib/appMenus";
Expand Down Expand Up @@ -1046,6 +1047,7 @@ export function App({
stepDiffLine,
selectCursorLine: setCursorLine,
selectLayoutMode,
saveDraftNote,
startUserNote: () => startUserNote(),
toggleAgentNotes,
toggleCopyDecorations,
Expand All @@ -1064,6 +1066,7 @@ export function App({
],
publishCommandExecuted,
);
const draftSaveKeyLabel = findAppCommandById(appCommands, "hunk.review.saveNote")?.keyLabels[0];
useExtensionRuntimeBindings({
commands: appCommands,
navigation: extensionNavigationBindings,
Expand Down Expand Up @@ -1151,7 +1154,6 @@ export function App({
discardViewPreferencesAndQuit,
neverAskToSaveViewPreferencesAndQuit,
closeSaveConfigPrompt,
saveDraftNote,
showAgentSkill,
showHelp,
switchMenu,
Expand Down Expand Up @@ -1367,6 +1369,7 @@ export function App({
onRemoveLiveNote={review.removeLiveComment}
onRemoveUserNote={review.removeUserNote}
onSaveDraftNote={saveDraftNote}
draftSaveKeyLabel={draftSaveKeyLabel}
onStartUserNoteAtHunk={startUserNote}
onUpdateDraftNote={updateDraftNote}
onBlurDraftNote={blurDraftNote}
Expand Down
129 changes: 128 additions & 1 deletion src/ui/AppHost.keybindings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ async function withAppHost(
bootstrap: AppBootstrap,
body: (setup: Awaited<ReturnType<typeof testRender>>, quits: () => number) => Promise<void>,
externalQuitSignal?: AbortSignal,
renderOptions?: { kittyKeyboard?: boolean },
) {
let quitCount = 0;
const setup = await testRender(
Expand All @@ -105,7 +106,7 @@ async function withAppHost(
externalQuitSignal={externalQuitSignal}
onQuit={() => (quitCount += 1)}
/>,
{ width: 120, height: 24 },
{ width: 120, height: 24, ...renderOptions },
);

try {
Expand Down Expand Up @@ -375,4 +376,130 @@ describe("user keybindings", () => {
expect(seen).toEqual(["hunk.app.toggleFocusArea"]);
});
});

test("a remapped save-note chord saves a draft and emits command_executed", async () => {
const repo = createTestRepo("hunk-keybindings-save-note-remap-");
const bootstrap = await launchWithConfig(
repo,
'[keybindings]\n"hunk.review.saveNote" = "ctrl+enter"\n',
);
const extensions = createEmptyExtensionLoadResult(repo);
const seen: string[] = [];
extensions.registry.eventHandlers.command_executed.push({
extensionId: "coach",
handler: ({ commandId }) => {
seen.push(commandId);
},
});
bootstrap.extensions = extensions;

// Kitty encodes Ctrl+Enter as CSI-u; legacy mock input would emit a bare
// return and drop the ctrl flag.
await withAppHost(
bootstrap,
async (setup) => {
await act(async () => {
await setup.mockInput.typeText("c");
});
await flush(setup);
await act(async () => {
await setup.mockInput.typeText("Remapped save.");
});
await flush(setup);
expect(setup.captureCharFrame()).toContain("Ctrl+Enter save");

await act(async () => {
setup.mockInput.pressKey("s", { ctrl: true });
});
await flush(setup);
expect(setup.captureCharFrame()).toContain("Draft note");
expect(setup.captureCharFrame()).not.toContain("Your note");

await act(async () => {
await setup.mockInput.pressKeys(["\u001b[115;5u"]);
});
await flush(setup);
expect(setup.captureCharFrame()).toContain("Draft note");
expect(setup.captureCharFrame()).not.toContain("Your note");

seen.length = 0;
await act(async () => {
setup.mockInput.pressEnter({ ctrl: true });
});
await flush(setup);
expect(seen).toEqual(["hunk.review.saveNote"]);
const saved = setup.captureCharFrame();
expect(saved).toContain("Your note");
expect(saved).toContain("Remapped save.");
expect(saved).not.toContain("Draft note");
},
undefined,
{ kittyKeyboard: true },
);
});

test("unbinding save-note leaves Ctrl-S doing nothing in the composer", async () => {
const repo = createTestRepo("hunk-keybindings-save-note-unbind-");
const bootstrap = await launchWithConfig(
repo,
'[keybindings]\n"hunk.review.saveNote" = false\n',
);

await withAppHost(bootstrap, async (setup) => {
await act(async () => {
await setup.mockInput.typeText("c");
});
await flush(setup);
await act(async () => {
await setup.mockInput.typeText("Still a draft.");
});
await flush(setup);

await act(async () => {
setup.mockInput.pressKey("s", { ctrl: true });
});
await flush(setup);
let frame = setup.captureCharFrame();
expect(frame).toContain("Draft note");
expect(frame).toContain("Still a draft.");
expect(frame).not.toContain("Your note");

await act(async () => {
await setup.mockInput.pressKeys(["\u001b[115;5u"]);
});
await flush(setup);
frame = setup.captureCharFrame();
expect(frame).toContain("Draft note");
expect(frame).toContain("Still a draft.");
expect(frame).not.toContain("Your note");
});
});

test("CSI-u Ctrl-S does not save after save-note is remapped away", async () => {
const repo = createTestRepo("hunk-keybindings-save-note-csiu-remap-");
const bootstrap = await launchWithConfig(
repo,
'[keybindings]\n"hunk.review.saveNote" = "ctrl+enter"\n',
);

await withAppHost(bootstrap, async (setup) => {
await act(async () => {
await setup.mockInput.typeText("c");
});
await flush(setup);
await act(async () => {
await setup.mockInput.typeText("Encoding net off.");
});
await flush(setup);

await act(async () => {
await setup.mockInput.pressKeys(["\u001b[115;5u"]);
});
await flush(setup);
const frame = setup.captureCharFrame();
expect(frame).toContain("Draft note");
expect(frame).toContain("Encoding net off.");
expect(frame).not.toContain("Your note");
});
});
});
59 changes: 59 additions & 0 deletions src/ui/components/panes/AgentInlineNote.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,4 +362,63 @@ describe("AgentInlineNote draft composer", () => {
}
}
});

test("draft footer shows the resolved save chord and omits it when unbound", async () => {
const labeled = await testRender(
<AgentInlineNote
annotation={draftAnnotation("body")}
anchorSide="new"
layout="split"
theme={theme}
width={96}
draft={{
body: "body",
focused: true,
onInput: () => {},
onCancel: () => {},
onSave: () => {},
saveKeyLabel: "Ctrl+Enter",
}}
/>,
{ width: 120, height: 12 },
);

try {
await flush(labeled);
expect(labeled.captureCharFrame()).toContain("Ctrl+Enter save Esc cancel");
} finally {
await act(async () => {
labeled.renderer.destroy();
});
}

const unbound = await testRender(
<AgentInlineNote
annotation={draftAnnotation("body")}
anchorSide="new"
layout="split"
theme={theme}
width={96}
draft={{
body: "body",
focused: true,
onInput: () => {},
onCancel: () => {},
onSave: () => {},
}}
/>,
{ width: 120, height: 12 },
);

try {
await flush(unbound);
const frame = unbound.captureCharFrame();
expect(frame).toContain("save Esc cancel");
expect(frame).not.toContain("Ctrl+S save");
} finally {
await act(async () => {
unbound.renderer.destroy();
});
}
});
});
Loading
Loading