Skip to content

feat(extensions): keyboard commands and additive multi-sidebar views - #611

Merged
benvinegar merged 6 commits into
mainfrom
claude/hunk-extension-sidebar-override-jzf60c
Jul 28, 2026
Merged

feat(extensions): keyboard commands and additive multi-sidebar views#611
benvinegar merged 6 commits into
mainfrom
claude/hunk-extension-sidebar-override-jzf60c

Conversation

@benvinegar

Copy link
Copy Markdown
Member

Follow-up to #609. Extensions can now register named keyboard commands and any number of sidebar views, so a user can keep the built-in file navigation, replace it, or open extra panes — left or right of the review stream — from keys their extensions registered.

Keyboard command system

Not a sidebar one-off: every app-level shortcut moved out of the keyboard hook's if-cascade into one named-command dispatch table (src/ui/lib/appCommands.tsreview.nextHunk, view.toggleSidebar, app.quit, ...), and extension registerCommand entries join that same table.

  • hunk.registerCommand({ id, title, key }, handler) — chord syntax "ctrl+m", "F2", "G", "["; an unparsable chord fails registration loudly.
  • Built-ins keep every key they ship with: conflicts are detected by synthesizing the key event a chord describes and probing every matcher (built-ins match with predicates, so chord comparison alone can't answer), then refused with a warning naming both sides. Between extensions, load order wins.
  • Modal surfaces (dialogs, menus, focused inputs) still own their keys first — those are widget structure, not shortcuts.
  • Handlers receive ctx.sidebars (open/close/toggle/isOpen), which is how a registered key opens the sidebar its extension contributed.

Additive multi-sidebar

  • registerSidebarView now contributes a view instead of replacing: { id, title, placement: "left" | "right", defaultOpen, replacesDefault, component }. replacesDefault stands a view in for the built-in file navigation, which stays available (just closed).
  • One pane model (src/ui/lib/sidebarPanes.ts) plans which open panes fit and at what width — narrow terminals drop latest-registered panes first, the review stream keeps its minimum — with per-pane divider resize (right-side dividers invert the drag) and open/closed choices that survive extension reloads.
  • A view that fails rendering closes with a warning naming the extension; the built-in file navigation reopens if nothing else is showing.

Coverage

  • Chord parse/match/synthesize round-trips (src/lib/commandKeys.test.ts)
  • Conflict refusal incl. the built-in s case and between-extension load order (extensionCommands.test.ts)
  • Pane planning, open-state reconciliation across reloads, and control-id resolution (sidebarPanes.test.ts)
  • registerCommand validation and rollback (runExtension.test.ts)
  • AppHost end-to-end: press y → command → sidebar opens beside files → its actions drive selection_changedy closes it; replacesDefault; crash-close-and-restore
  • Live PTY test doing the command-toggle flow against the real app, with the extension pane on the right

check:pack verifies the new docs examples typecheck for consumers. Deliberately not in this pass (the table is built for them — ids, titles, key labels all exist): user-remappable keybindings and menu listings for extension commands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JML2m7oy2sUbFftN7QoHhi


Generated by Claude Code

Extensions can now register named keyboard commands and any number of sidebar
views, so a user can keep the built-in file navigation, replace it, or open
extra panes — left or right of the review stream — from keys they registered.

The command system is not a sidebar one-off: every app-level shortcut moved
out of the keyboard hook's if-cascade into one named-command dispatch table
(src/ui/lib/appCommands.ts), and hunk.registerCommand entries join that same
table. Built-ins keep every key they ship with — conflicts are detected by
probing matchers with a synthesized key event and refused with a warning that
names both sides — and modal surfaces (dialogs, menus, focused inputs) still
own their keys first, since those are widget structure, not shortcuts.
Command handlers receive ctx.sidebars (open/close/toggle/isOpen), which is
how a registered key opens the sidebar its extension contributed.

registerSidebarView is now additive: views declare title, placement, and
defaultOpen or replacesDefault, the pane model (src/ui/lib/sidebarPanes.ts)
plans which open panes fit and at what width, per-pane dividers resize
independently, and open/closed choices survive extension reloads. A view that
fails rendering closes with a warning, reopening the built-in file navigation
when nothing else is showing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JML2m7oy2sUbFftN7QoHhi
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hunk-web Ready Ready Preview Jul 28, 2026 2:17am

Request Review

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a unified named-command dispatch system and additive multi-pane sidebars for extensions.

  • Exposes command registration, key-chord parsing, conflict detection, and sidebar controls through the extension API.
  • Refactors built-in keyboard shortcuts into the shared command table while preserving modal and focused-input precedence.
  • Adds left/right sidebar planning, per-pane resizing, reload reconciliation, and render-failure recovery.
  • Expands unit, AppHost, packaging, and PTY integration coverage.

Confidence Score: 5/5

The PR appears safe to merge with no concrete blocking or independently actionable non-blocking issues identified.

The command dispatch preserves modal and focused-input ownership, sidebar state is reconciled across extension reloads, pane planning protects the review stream minimum width, and extension failures are contained with fallback behavior.

Important Files Changed

Filename Overview
src/ui/App.tsx Integrates extension commands, sidebar state, pane planning, resizing, and render-failure recovery into the application shell.
src/ui/hooks/useAppKeyboardShortcuts.ts Replaces the app-shortcut cascade with shared command dispatch while retaining modal and focused-input precedence.
src/ui/lib/appCommands.ts Defines the named built-in command table and first-match dispatch behavior.
src/ui/lib/extensionCommands.ts Converts registered extension commands into review-scoped bindings and rejects conflicts by load order.
src/ui/lib/sidebarPanes.ts Centralizes sidebar identity, open-state reconciliation, placement, and width planning.
src/lib/commandKeys.ts Implements validated chord parsing, exact extension-key matching, and synthetic events for conflict probing.
src/extensions/runExtension.ts Adds validated command registration and includes commands in extension-factory rollback.
src/extension-api/types.ts Publishes additive sidebar metadata, command registration, and command-context sidebar controls.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  K[Keyboard event] --> P{Modal or focused input owns key?}
  P -->|Yes| W[Widget handling]
  P -->|No| D[Shared command dispatcher]
  D --> B[Built-in commands]
  D --> E[Extension commands]
  E --> C[Extension command context]
  C --> S[Sidebar controls]
  S --> O[Open / close / toggle view]
  O --> R[Sidebar open-state reconciliation]
  R --> L[Multi-pane layout planner]
  L --> LP[Left panes]
  L --> V[Review stream]
  L --> RP[Right panes]
Loading

Reviews (1): Last reviewed commit: "feat(extensions): keyboard commands and ..." | Re-trigger Greptile

…chitecture map

The extensions bullet had grown to a quarter of the file, mostly restating
what module headers already document. It now carries only the trip-wire
invariants an agent cannot infer from any single file — import-free contract
module, renderer-free VCS load path, trust gating, bundled tier surviving
--no-extensions — and points at docs/extension-architecture.md, a short map
that names which module owns each part of the system so fresh-context agents
discover depth on demand instead of paying for it up front.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JML2m7oy2sUbFftN7QoHhi
…ted symbol chords

Two review fixes. ctx.sidebars.open/toggle only updated the logical open set,
so opening a view while the sidebar area was hidden with s changed nothing on
screen; opening now also reveals the area, mirroring toggleSidebar's reveal
(including the forced-open path on responsive-hidden layouts with room), and
an AppHost test covers hide-then-command-open. And parseKeyChord accepted
shift on symbol and digit bases that matching then ignored, so shift+1 bound
plain 1; shifted symbols have no layout-independent identity, so the parser
now refuses the form and points at binding the shifted character itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JML2m7oy2sUbFftN7QoHhi
Shortcuts were matchers written in code: the keys a command answered to were
spelled out in its predicate, which meant nobody outside the source could
change them, nothing could report what a command was bound to, and an
extension had exactly one chord to ask for. This turns bindings into an
indirection — commands declare ids and default chords, and one resolution
step decides what each id actually answers to.

- Built-in commands now declare `defaultKeys` instead of hand-written
  predicates; matchers and key labels are both derived from the resolved
  chords, so a rebound command answers to its new key and advertises it.
- The new `[keybindings]` table (user config only — keys belong to the
  machine a person types on, not to a repository under review) maps command
  ids to chords, or `false` to unbind. A user-bound chord is exclusive:
  whatever held it by default gives it up, keeping its other chords. Without
  that rule, moving a key would leave its old owner still swallowing it and
  the user hunting down every default that collides.
- `ExtensionCommand.key` takes a list, and conflicts are refused one chord at
  a time, so a command bound to three keys that loses one keeps the other two.
- The chord grammar moves to `src/extension-api/keys.ts` and ships as part of
  `hunkdiff/extension` (`matchesKey`, `parseKeyChord`, `matchesKeyChord`).
  Extension sidebar components with internal keys were the last place left
  hand-reading modifier flags; now they match the way the host does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JML2m7oy2sUbFftN7QoHhi
…indings.md

The full command-id table and remapping rules were outgrowing the README's
config section; the README keeps a short summary and a link, and the
reference — rules, chord grammar, built-in command table — lives in its own
doc beside the other guides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JML2m7oy2sUbFftN7QoHhi
…t load

Extension ids are user-controlled file stems, and an id is the namespace it
owns for commands, sidebar views, and config. Nothing kept those two id spaces
apart: `app.ts` could mint `app.quit` and shadow a built-in, `my.ext.ts` made
`<extensionId>.<commandId>` unsplittable, `sidebar.ts` could mint the bundled
view key `sidebar:files`, and two `notes.ts` files from different sources
loaded as one shared id.

Close it structurally rather than case by case. Every built-in command id now
lives under the reserved `hunk.` vendor namespace and the bundled sidebar
registers as `hunk` (`hunk:files`), so any built-in grouping Hunk adds later is
also safe from an id somebody already installed. `host.ts` is the single gate
where candidates become extensions: it refuses reserved ids (`hunk` plus the
bundled backends, via `isVcsId`), ids outside `[A-Za-z0-9][A-Za-z0-9_-]*`, and
the later of two sources claiming one id. Discovery stays a pure filesystem
walk with no issue channel, and every derivation path -- stem, folder name,
manifest -- arrives there as `candidate.id`, so one gate covers all of them.
Each refusal is a load issue that skips that extension only.

Keymap classification follows the same structure: a `hunk.` id that is not a
known built-in can only be a typo, while an unknown owner segment is an
extension that is simply not loaded. `resolveCommandKeys` also keeps the first
of two defaults entries sharing an id instead of silently merging their keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JML2m7oy2sUbFftN7QoHhi
@benvinegar
benvinegar merged commit 449b328 into main Jul 28, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants