From 5c3a18c73df7856aafec5e166b17a9d9686766ee Mon Sep 17 00:00:00 2001 From: Umputun Date: Wed, 13 May 2026 03:59:43 -0500 Subject: [PATCH] feat: make open-editor binding configurable via keymap (#190) route the Ctrl+E editor key in annotation mode through the keymap system as the open_editor action so users can remap it via keybindings file. - add ActionOpenEditor to keymap with ctrl+e default binding - handleAnnotateKey resolves through keymap instead of hardcoded tea.KeyCtrlE - placeholder text dynamically reflects the bound key, omits hint when unbound - filter chord bindings from placeholder (chords do not fire during text input) - displayKeyName uppercases only first char after ctrl+ to preserve chord case - add open_editor to available-actions lists across all doc surfaces - document chord limitation during modal text input - add tests for remapped/unbound editor key, displayKeyName, action validity --- .../skills/revdiff/references/config.md | 4 +- .../skills/revdiff/references/usage.md | 4 +- README.md | 10 +-- app/keymap/keymap.go | 8 +- app/keymap/keymap_test.go | 25 +++++- app/ui/annotate.go | 85 +++++++++++++------ app/ui/annotate_test.go | 80 +++++++++++++++++ app/ui/handlers.go | 6 +- app/ui/handlers_test.go | 18 ++++ .../codex/skills/revdiff/references/config.md | 4 +- .../codex/skills/revdiff/references/usage.md | 4 +- site/docs.html | 10 +-- 12 files changed, 209 insertions(+), 49 deletions(-) diff --git a/.claude-plugin/skills/revdiff/references/config.md b/.claude-plugin/skills/revdiff/references/config.md index b99f698d..a697abdf 100644 --- a/.claude-plugin/skills/revdiff/references/config.md +++ b/.claude-plugin/skills/revdiff/references/config.md @@ -141,6 +141,6 @@ unmap q map ctrl+d half_page_down ``` -Available actions: `down`, `up`, `page_down`, `page_up`, `half_page_down`, `half_page_up`, `home`, `end`, `scroll_left`, `scroll_right`, `scroll_center`, `scroll_top`, `scroll_bottom`, `next_item`, `prev_item`, `next_hunk`, `prev_hunk`, `toggle_pane`, `focus_tree`, `focus_diff`, `search`, `confirm`, `annotate_file`, `delete_annotation`, `annot_list`, `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `quit`, `discard_quit`, `help`, `dismiss` +Available actions: `down`, `up`, `page_down`, `page_up`, `half_page_down`, `half_page_up`, `home`, `end`, `scroll_left`, `scroll_right`, `scroll_center`, `scroll_top`, `scroll_bottom`, `next_item`, `prev_item`, `next_hunk`, `prev_hunk`, `toggle_pane`, `focus_tree`, `focus_diff`, `search`, `confirm`, `annotate_file`, `delete_annotation`, `annot_list`, `open_editor`, `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `quit`, `discard_quit`, `help`, `dismiss` -Modal keys (annotation input, search input, confirm discard) are not remappable. +Fixed modal keys (Enter, Esc in annotation/search input, confirm discard) are not remappable. Keymap-resolved actions like `open_editor` work during annotation input and can be rebound. Chord bindings do not fire during text input — use single-key `ctrl+*` bindings for actions that need to work during annotation input. diff --git a/.claude-plugin/skills/revdiff/references/usage.md b/.claude-plugin/skills/revdiff/references/usage.md index cb60b408..ded07eab 100644 --- a/.claude-plugin/skills/revdiff/references/usage.md +++ b/.claude-plugin/skills/revdiff/references/usage.md @@ -142,10 +142,10 @@ Use `--stdin` to review arbitrary piped or redirected text as one synthetic file | `@` | Toggle annotation list popup (navigate and jump to any annotation) | | `}` / `{` | Jump to next/previous annotation (always crosses file boundaries; silent no-op at the first/last annotation) | | `d` | Delete annotation under cursor | -| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation | +| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation (`open_editor` — rebindable) | | `Esc` | Cancel annotation input | -While the annotation input is active, press `Ctrl+E` to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output. +While the annotation input is active, press `Ctrl+E` (or whatever key is bound to `open_editor`) to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output. **View:** diff --git a/README.md b/README.md index deccda56..b593c2c1 100644 --- a/README.md +++ b/README.md @@ -659,10 +659,10 @@ In the Claude Code and Codex plugins, you can also tell the agent to use a past | `@` | Toggle annotation list popup (navigate and jump to any annotation) | | `}` / `{` | Jump to next/previous annotation (always crosses file boundaries; silent no-op at the first/last annotation) | | `d` | Delete annotation under cursor | -| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation | +| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation (`open_editor` — rebindable) | | `Esc` | Cancel annotation input | -While the annotation input is active, press `Ctrl+E` to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output. +While the annotation input is active, press `Ctrl+E` (or whatever key is bound to `open_editor`) to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output. **View:** @@ -753,7 +753,7 @@ mkdir -p ~/.config/revdiff revdiff --dump-keys > ~/.config/revdiff/keybindings ``` -Then edit to taste. Modal keys (annotation input, search input, confirm discard) are not remappable. +Then edit to taste. Fixed modal keys (Enter, Esc in annotation/search input, confirm discard) are not remappable. Keymap-resolved actions like `open_editor` work during annotation input and can be rebound. **Chord bindings (ctrl/alt leader):** bind a two-stage chord by joining the leader and second key with `>`. The leader must be a `ctrl+*` or `alt+*` combo; the second stage is any single key. Only two stages are supported. @@ -762,7 +762,7 @@ map ctrl+w>x mark_reviewed map alt+t>n theme_select ``` -When the leader is pressed, the status bar shows `Pending: ctrl+w, esc to cancel`; press the second key to dispatch, or `esc` to cancel silently. Binding a key as both a standalone action and a chord prefix drops the standalone binding (the chord wins, with a warning). Chord bindings work under non-Latin keyboard layouts — the second-stage key is translated via the same layout-resolve fallback as single-key bindings. +When the leader is pressed, the status bar shows `Pending: ctrl+w, esc to cancel`; press the second key to dispatch, or `esc` to cancel silently. Binding a key as both a standalone action and a chord prefix drops the standalone binding (the chord wins, with a warning). Chord bindings work under non-Latin keyboard layouts — the second-stage key is translated via the same layout-resolve fallback as single-key bindings. Note: chord bindings do not fire during text input (annotation and search prompts) — the leader key is consumed by the text input widget. Use single-key `ctrl+*` bindings for actions like `open_editor` that need to work during annotation input. **macOS note:** `alt+*` leaders require your terminal to send Option as Meta/Alt. Most terminals default to "Option composes special characters" (e.g. `Option+T` → `†`), in which case Alt chords silently won't fire. To enable: iTerm2 → *Profiles → Keys → Left/Right Option key → `Esc+`*; Terminal.app → *Profiles → Keyboard → Use Option as Meta key*; Kitty → `macos_option_as_alt yes`; Ghostty → `macos-option-as-alt = true`. If you'd rather not touch terminal settings, use `ctrl+*` leaders — those work everywhere with no configuration. @@ -777,7 +777,7 @@ When the leader is pressed, the status bar shows `Pending: ctrl+w, esc to cancel **Search:** `search` -**Annotations:** `confirm` (annotate line / select file), `annotate_file`, `delete_annotation`, `annot_list`, `next_annotation`, `prev_annotation` +**Annotations:** `confirm` (annotate line / select file), `annotate_file`, `delete_annotation`, `annot_list`, `open_editor`, `next_annotation`, `prev_annotation` **View:** `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_word_diff`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `reload` diff --git a/app/keymap/keymap.go b/app/keymap/keymap.go index 7cb174da..caee5cee 100644 --- a/app/keymap/keymap.go +++ b/app/keymap/keymap.go @@ -64,6 +64,7 @@ const ( ActionThemeSelect Action = "theme_select" ActionInfo Action = "info" ActionReload Action = "reload" + ActionOpenEditor Action = "open_editor" ) // SectionPane is the help section name for pane-related keybindings. @@ -84,8 +85,9 @@ var validActions = map[Action]bool{ ActionToggleLineNums: true, ActionToggleBlame: true, ActionToggleWordDiff: true, ActionToggleHunk: true, ActionMarkReviewed: true, ActionFilter: true, ActionToggleUntracked: true, ActionQuit: true, ActionDiscardQuit: true, ActionHelp: true, ActionDismiss: true, ActionThemeSelect: true, - ActionInfo: true, - ActionReload: true, + ActionInfo: true, + ActionReload: true, + ActionOpenEditor: true, } // deprecatedActionAliases maps obsolete action names parsed from user @@ -207,6 +209,7 @@ func defaultDescriptions() []HelpEntry { {ActionAnnotateFile, "annotate file", "Annotations"}, {ActionDeleteAnnotation, "delete annotation", "Annotations"}, {ActionAnnotList, "annotation list", "Annotations"}, + {ActionOpenEditor, "open annotation in $EDITOR", "Annotations"}, {ActionNextAnnotation, "next annotation (across files)", "Annotations"}, {ActionPrevAnnotation, "previous annotation (across files)", "Annotations"}, @@ -263,6 +266,7 @@ func defaultBindings() map[string]Action { "A": ActionAnnotateFile, "d": ActionDeleteAnnotation, "@": ActionAnnotList, + "ctrl+e": ActionOpenEditor, "}": ActionNextAnnotation, "{": ActionPrevAnnotation, "v": ActionToggleCollapsed, diff --git a/app/keymap/keymap_test.go b/app/keymap/keymap_test.go index 55a80136..4abcd5b9 100644 --- a/app/keymap/keymap_test.go +++ b/app/keymap/keymap_test.go @@ -34,7 +34,7 @@ func TestDefault_allExpectedBindings(t *testing.T) { {"tab", ActionTogglePane}, {"h", ActionFocusTree}, {"l", ActionFocusDiff}, {"/", ActionSearch}, {"a", ActionConfirm}, {"enter", ActionConfirm}, - {"A", ActionAnnotateFile}, {"d", ActionDeleteAnnotation}, {"@", ActionAnnotList}, + {"A", ActionAnnotateFile}, {"d", ActionDeleteAnnotation}, {"@", ActionAnnotList}, {"ctrl+e", ActionOpenEditor}, {"}", ActionNextAnnotation}, {"{", ActionPrevAnnotation}, {"v", ActionToggleCollapsed}, {"C", ActionToggleCompact}, {"w", ActionToggleWrap}, {"t", ActionToggleTree}, {"L", ActionToggleLineNums}, {"B", ActionToggleBlame}, {"W", ActionToggleWordDiff}, @@ -245,6 +245,29 @@ func TestActionToggleCompact_HelpEntry(t *testing.T) { assert.True(t, found, "ActionToggleCompact should have a help entry") } +func TestActionOpenEditor_IsValid(t *testing.T) { + assert.True(t, IsValidAction(ActionOpenEditor)) +} + +func TestActionOpenEditor_DefaultBinding(t *testing.T) { + km := Default() + assert.Equal(t, ActionOpenEditor, km.Resolve("ctrl+e")) +} + +func TestActionOpenEditor_HelpEntry(t *testing.T) { + entries := defaultDescriptions() + var found bool + for _, e := range entries { + if e.Action == ActionOpenEditor { + assert.Equal(t, "open annotation in $EDITOR", e.Description) + assert.Equal(t, "Annotations", e.Section) + found = true + break + } + } + assert.True(t, found, "ActionOpenEditor should have a help entry") +} + func TestActionScrollConstants_InNavigationActions(t *testing.T) { // the three scroll-align actions must be recognized as valid so keybindings // files can map custom keys to them (e.g. "map z scroll_center"). diff --git a/app/ui/annotate.go b/app/ui/annotate.go index bfbc23eb..c0f9f04a 100644 --- a/app/ui/annotate.go +++ b/app/ui/annotate.go @@ -12,6 +12,7 @@ import ( "github.com/umputun/revdiff/app/annotation" "github.com/umputun/revdiff/app/diff" + "github.com/umputun/revdiff/app/keymap" "github.com/umputun/revdiff/app/ui/style" ) @@ -76,25 +77,30 @@ func (m *Model) startAnnotation() tea.Cmd { return nil } - placeholder := "annotation... (Ctrl+E for editor)" + editorKey := m.editorKeyDisplay() + placeholder := "annotation..." + if editorKey != "" { + placeholder = fmt.Sprintf("annotation... (%s for editor)", editorKey) + } // pre-fill with existing annotation if one exists. multi-line comments are // NOT set via ti.SetValue because textinput's sanitizer collapses \n to // space; instead, stash the original in existingMultiline and hint at it - // via the placeholder so Ctrl+E can seed the editor from it and Enter with - // empty input preserves it unchanged. + // via the placeholder so the editor key can seed the editor from it and + // Enter with empty input preserves it unchanged. lineNum := m.diffLineNum(dl) var preFill, existingMultiline string for _, a := range m.store.Get(m.file.name) { - if a.Line == lineNum && a.Type == string(dl.ChangeType) { - if strings.Contains(a.Comment, "\n") { - existingMultiline = a.Comment - placeholder = "[existing multi-line — Ctrl+E to edit]" - } else { - preFill = a.Comment - } - break + if a.Line != lineNum || a.Type != string(dl.ChangeType) { + continue + } + if strings.Contains(a.Comment, "\n") { + existingMultiline = a.Comment + placeholder = m.multiLinePlaceholder() + } else { + preFill = a.Comment } + break } ti, cmd := m.newAnnotationInput(placeholder, 3+lipgloss.Width(m.annotPrefix())) // cursor col + annotation prefix + border margin @@ -138,23 +144,28 @@ func (m *Model) startFileAnnotation() tea.Cmd { return nil } - placeholder := "file-level annotation... (Ctrl+E for editor)" + editorKey := m.editorKeyDisplay() + placeholder := "file-level annotation..." + if editorKey != "" { + placeholder = fmt.Sprintf("file-level annotation... (%s for editor)", editorKey) + } // pre-fill with existing file-level annotation if one exists. multi-line // comments bypass ti.SetValue (textinput sanitizer flattens \n to space); - // instead stash in existingMultiline so Ctrl+E can seed and Enter with empty - // input preserves it unchanged. + // instead stash in existingMultiline so the editor key can seed and Enter + // with empty input preserves it unchanged. var preFill, existingMultiline string for _, a := range m.store.Get(m.file.name) { - if a.Line == 0 { - if strings.Contains(a.Comment, "\n") { - existingMultiline = a.Comment - placeholder = "[existing multi-line — Ctrl+E to edit]" - } else { - preFill = a.Comment - } - break + if a.Line != 0 { + continue } + if strings.Contains(a.Comment, "\n") { + existingMultiline = a.Comment + placeholder = m.multiLinePlaceholder() + } else { + preFill = a.Comment + } + break } ti, cmd := m.newAnnotationInput(placeholder, 3+lipgloss.Width(m.annotFilePrefix())) // cursor col + file annotation prefix + border margin @@ -312,6 +323,27 @@ func (m *Model) deleteAnnotation() tea.Cmd { return nil } +// editorKeyDisplay returns the display name for the open_editor binding +// (e.g. "Ctrl+E") for use in placeholder text. Returns empty when unbound. +// Filters out chord bindings since they don't fire during annotation input. +func (m Model) editorKeyDisplay() string { + keys := m.keymap.KeysFor(keymap.ActionOpenEditor) + var single []string + for _, k := range keys { + if strings.Index(k, ">") <= 0 { + single = append(single, m.displayKeyName(k)) + } + } + return strings.Join(single, " / ") +} + +func (m Model) multiLinePlaceholder() string { + if key := m.editorKeyDisplay(); key != "" { + return fmt.Sprintf("[existing multi-line — %s to edit]", key) + } + return "[existing multi-line]" +} + // handleAnnotateKey handles key messages during annotation input mode. func (m Model) handleAnnotateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { @@ -321,12 +353,11 @@ func (m Model) handleAnnotateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEsc: m.cancelAnnotation() return m, nil - case tea.KeyCtrlE: - // hand off to $EDITOR for multi-line annotation input. - // keep annotating=true so editorFinishedMsg routes back through the annotation flow. - cmd := m.openEditor() - return m, cmd default: + if m.keymap.Resolve(msg.String()) == keymap.ActionOpenEditor { + cmd := m.openEditor() + return m, cmd + } var cmd tea.Cmd m.annot.input, cmd = m.annot.input.Update(msg) m.layout.viewport.SetContent(m.renderDiff()) // re-render so typed characters are visible immediately diff --git a/app/ui/annotate_test.go b/app/ui/annotate_test.go index 78968c2c..32706161 100644 --- a/app/ui/annotate_test.go +++ b/app/ui/annotate_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/umputun/revdiff/app/annotation" "github.com/umputun/revdiff/app/diff" + "github.com/umputun/revdiff/app/keymap" "github.com/umputun/revdiff/app/ui/mocks" "github.com/umputun/revdiff/app/ui/overlay" "github.com/umputun/revdiff/app/ui/sidepane" @@ -2510,6 +2511,85 @@ func TestModel_AnnotationPlaceholderMentionsEditor(t *testing.T) { assert.Contains(t, m2.annot.input.Placeholder, "Ctrl+E", "file-level placeholder must mention Ctrl+E") } +func TestModel_AnnotationPlaceholderRemappedEditor(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "x", ChangeType: diff.ChangeAdd}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = testNewFileTree([]string{"a.go"}) + m.layout.focus = paneDiff + m.file.name = "a.go" + m.file.lines = lines + m.nav.diffCursor = 0 + + m.keymap.Unbind("ctrl+e") + m.keymap.Bind("ctrl+g", keymap.ActionOpenEditor) + + m.startAnnotation() + assert.Contains(t, m.annot.input.Placeholder, "Ctrl+G", "placeholder must reflect remapped key") + assert.NotContains(t, m.annot.input.Placeholder, "Ctrl+E", "placeholder must not mention old key") +} + +func TestModel_AnnotationPlaceholderUnboundEditor(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "x", ChangeType: diff.ChangeAdd}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = testNewFileTree([]string{"a.go"}) + m.layout.focus = paneDiff + m.file.name = "a.go" + m.file.lines = lines + m.nav.diffCursor = 0 + + m.keymap.Unbind("ctrl+e") + + m.startAnnotation() + assert.NotContains(t, m.annot.input.Placeholder, "Ctrl+E", "placeholder must not mention editor when unbound") + assert.NotContains(t, m.annot.input.Placeholder, "for editor", "placeholder must not mention editor when unbound") +} + +func TestModel_RemappedEditorKeyOpensEditor(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = testNewFileTree([]string{"a.go"}) + m.layout.focus = paneDiff + m.file.name = "a.go" + m.file.lines = lines + m.nav.diffCursor = 0 + + m.keymap.Unbind("ctrl+e") + m.keymap.Bind("ctrl+g", keymap.ActionOpenEditor) + + fake := mockEditor("edited", nil) + m.editor = fake + + m.startAnnotation() + m.annot.input.SetValue("seed") + + result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlG}) + model := result.(Model) + require.NotNil(t, cmd, "remapped ctrl+g should trigger editor") + require.Len(t, fake.CommandCalls(), 1, "editor.Command called once") + assert.Equal(t, "seed", fake.CommandCalls()[0].Content) + assert.True(t, model.annot.annotating) +} + +func TestModel_UnboundEditorKeyFallsThrough(t *testing.T) { + lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext}} + m := testModel([]string{"a.go"}, map[string][]diff.DiffLine{"a.go": lines}) + m.tree = testNewFileTree([]string{"a.go"}) + m.layout.focus = paneDiff + m.file.name = "a.go" + m.file.lines = lines + m.nav.diffCursor = 0 + + m.keymap.Unbind("ctrl+e") + + fake := mockEditor("edited", nil) + m.editor = fake + + m.startAnnotation() + + _, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlE}) + assert.Empty(t, fake.CommandCalls(), "unbound ctrl+e must not open editor") +} + func TestModel_VisualRowToDiffLine_EmptyFile(t *testing.T) { m := testModel(nil, nil) m.file.name = "a.go" diff --git a/app/ui/handlers.go b/app/ui/handlers.go index ea846579..450e3914 100644 --- a/app/ui/handlers.go +++ b/app/ui/handlers.go @@ -32,7 +32,11 @@ func (m Model) displayKeyName(key string) string { return d } if strings.HasPrefix(key, "ctrl+") { - return "Ctrl+" + key[5:] + suffix := key[5:] + if suffix != "" { + return "Ctrl+" + strings.ToUpper(suffix[:1]) + suffix[1:] + } + return "Ctrl+" } return key } diff --git a/app/ui/handlers_test.go b/app/ui/handlers_test.go index 1149bac8..1fdd0613 100644 --- a/app/ui/handlers_test.go +++ b/app/ui/handlers_test.go @@ -738,6 +738,24 @@ func TestModel_ToggleCompactMode_CursorResetsAfterReload(t *testing.T) { assert.Equal(t, 1, model.nav.diffCursor, "cursor must reset to first non-divider line after compact re-fetch") } +func TestDisplayKeyName(t *testing.T) { + m := testModel(nil, nil) + tests := []struct{ input, want string }{ + {"ctrl+e", "Ctrl+E"}, + {"ctrl+d", "Ctrl+D"}, + {"ctrl+u", "Ctrl+U"}, + {"ctrl+w>x", "Ctrl+W>x"}, + {"ctrl+", "Ctrl+"}, + {"j", "j"}, + {"pgdown", "PgDn"}, + {"enter", "Enter"}, + {" ", "Space"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, m.displayKeyName(tt.input), "displayKeyName(%q)", tt.input) + } +} + func TestBuildHelpSpec_SearchPromptHistoryEntries(t *testing.T) { m := testModel([]string{"a.go"}, nil) diff --git a/plugins/codex/skills/revdiff/references/config.md b/plugins/codex/skills/revdiff/references/config.md index b99f698d..a697abdf 100644 --- a/plugins/codex/skills/revdiff/references/config.md +++ b/plugins/codex/skills/revdiff/references/config.md @@ -141,6 +141,6 @@ unmap q map ctrl+d half_page_down ``` -Available actions: `down`, `up`, `page_down`, `page_up`, `half_page_down`, `half_page_up`, `home`, `end`, `scroll_left`, `scroll_right`, `scroll_center`, `scroll_top`, `scroll_bottom`, `next_item`, `prev_item`, `next_hunk`, `prev_hunk`, `toggle_pane`, `focus_tree`, `focus_diff`, `search`, `confirm`, `annotate_file`, `delete_annotation`, `annot_list`, `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `quit`, `discard_quit`, `help`, `dismiss` +Available actions: `down`, `up`, `page_down`, `page_up`, `half_page_down`, `half_page_up`, `home`, `end`, `scroll_left`, `scroll_right`, `scroll_center`, `scroll_top`, `scroll_bottom`, `next_item`, `prev_item`, `next_hunk`, `prev_hunk`, `toggle_pane`, `focus_tree`, `focus_diff`, `search`, `confirm`, `annotate_file`, `delete_annotation`, `annot_list`, `open_editor`, `toggle_collapsed`, `toggle_compact`, `toggle_wrap`, `toggle_tree`, `toggle_line_numbers`, `toggle_blame`, `toggle_hunk`, `toggle_untracked`, `mark_reviewed`, `theme_select`, `filter`, `info`, `quit`, `discard_quit`, `help`, `dismiss` -Modal keys (annotation input, search input, confirm discard) are not remappable. +Fixed modal keys (Enter, Esc in annotation/search input, confirm discard) are not remappable. Keymap-resolved actions like `open_editor` work during annotation input and can be rebound. Chord bindings do not fire during text input — use single-key `ctrl+*` bindings for actions that need to work during annotation input. diff --git a/plugins/codex/skills/revdiff/references/usage.md b/plugins/codex/skills/revdiff/references/usage.md index cb60b408..ded07eab 100644 --- a/plugins/codex/skills/revdiff/references/usage.md +++ b/plugins/codex/skills/revdiff/references/usage.md @@ -142,10 +142,10 @@ Use `--stdin` to review arbitrary piped or redirected text as one synthetic file | `@` | Toggle annotation list popup (navigate and jump to any annotation) | | `}` / `{` | Jump to next/previous annotation (always crosses file boundaries; silent no-op at the first/last annotation) | | `d` | Delete annotation under cursor | -| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation | +| `Ctrl+E` (during annotation input) | Open `$EDITOR` for multi-line annotation (`open_editor` — rebindable) | | `Esc` | Cancel annotation input | -While the annotation input is active, press `Ctrl+E` to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output. +While the annotation input is active, press `Ctrl+E` (or whatever key is bound to `open_editor`) to hand off the current text to an external editor for multi-line comments. Editor resolution: `$EDITOR` → `$VISUAL` → `vi`. Values with arguments work (e.g. `EDITOR="code --wait"`). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (`@`), and emitted with embedded newlines in the structured output. **View:** diff --git a/site/docs.html b/site/docs.html index 3794edf1..4dcc862b 100644 --- a/site/docs.html +++ b/site/docs.html @@ -508,11 +508,11 @@

Annotations

@Toggle annotation list popup } / {Jump to next/previous annotation (always crosses file boundaries; silent no-op at the first/last annotation) dDelete annotation under cursor - Ctrl+EOpen $EDITOR for multi-line annotation (during annotation input) + Ctrl+EOpen $EDITOR for multi-line annotation (open_editor — rebindable, during annotation input) EscCancel annotation input -

While the annotation input is active, press Ctrl+E to hand off the current text to an external editor for multi-line comments. Editor resolution: $EDITOR$VISUALvi. Values with arguments work (e.g. EDITOR="code --wait"). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (@), and emitted with embedded newlines in the structured output.

+

While the annotation input is active, press Ctrl+E (or whatever key is bound to open_editor) to hand off the current text to an external editor for multi-line comments. Editor resolution: $EDITOR$VISUALvi. Values with arguments work (e.g. EDITOR="code --wait"). On editor save and quit, the full file contents (including newlines) become the annotation. Quitting the editor with an empty file cancels the annotation and preserves any previously stored note on that line. Multi-line annotations are rendered line-by-line in the diff view, shown flattened in the annotation list popup (@), and emitted with embedded newlines in the structured output.

View toggles

@@ -586,19 +586,19 @@

Custom keybindings

map x quit unmap q

Generate a template: revdiff --dump-keys > ~/.config/revdiff/keybindings

-

Modal keys (annotation input, search input, confirm discard) are not remappable.

+

Fixed modal keys (Enter, Esc in annotation/search input, confirm discard) are not remappable. Keymap-resolved actions like open_editor work during annotation input and can be rebound.

Chord bindings (ctrl/alt leader)

Bind a two-stage chord by joining the leader and second key with >. The leader must be a ctrl+* or alt+* combo; the second stage is any single key. Only two stages are supported.

map ctrl+w>x mark_reviewed map alt+t>n theme_select
-

When the leader is pressed, the status bar shows Pending: ctrl+w, esc to cancel; press the second key to dispatch, or esc to cancel silently. Binding a key as both a standalone action and a chord prefix drops the standalone binding (the chord wins, with a warning). Chord bindings work under non-Latin keyboard layouts — the second-stage key is translated via the same layout-resolve fallback as single-key bindings.

+

When the leader is pressed, the status bar shows Pending: ctrl+w, esc to cancel; press the second key to dispatch, or esc to cancel silently. Binding a key as both a standalone action and a chord prefix drops the standalone binding (the chord wins, with a warning). Chord bindings work under non-Latin keyboard layouts — the second-stage key is translated via the same layout-resolve fallback as single-key bindings. Note: chord bindings do not fire during text input (annotation and search prompts) — use single-key ctrl+* bindings for actions like open_editor that need to work during annotation input.

macOS note: alt+* leaders require your terminal to send Option as Meta/Alt. Most terminals default to "Option composes special characters" (e.g. Option+T), in which case Alt chords silently won't fire. To enable: iTerm2 → Profiles → Keys → Left/Right Option key → Esc+; Terminal.app → Profiles → Keyboard → Use Option as Meta key; Kitty → macos_option_as_alt yes; Ghostty → macos-option-as-alt = true. If you'd rather not touch terminal settings, use ctrl+* leaders — those work everywhere with no configuration.

Available actions

Navigation: down, up, page_down, page_up, half_page_down, half_page_up, home, end, scroll_left, scroll_right, scroll_center, scroll_top, scroll_bottom

File/Hunk: next_item, prev_item, next_hunk, prev_hunk

Pane: toggle_pane, focus_tree, focus_diff

Search: search

-

Annotations: confirm, annotate_file, delete_annotation, annot_list, next_annotation, prev_annotation

+

Annotations: confirm, annotate_file, delete_annotation, annot_list, open_editor, next_annotation, prev_annotation

View: toggle_collapsed, toggle_compact, toggle_wrap, toggle_tree, toggle_line_numbers, toggle_blame, toggle_word_diff, toggle_hunk, toggle_untracked, mark_reviewed, theme_select, filter, info, reload

Quit: quit, discard_quit, help, dismiss