Keyboard shortcuts#
PacketBench binds keys on several surfaces at once: an app-shell listener on
window, a per-view listener, a per-pane listener, and — when you opt in —
three accelerators registered with the operating system. Most of the time only
one of them can plausibly want a keypress. The interesting cases are the ones
where two can, and those are decided by explicit checks in the code rather
than by listener order.
This page lists every binding PacketBench ships, grouped by the surface that owns it, and then states the precedence rules that decide which surface consumes a key when more than one is listening.
Ordering. Within each table, rows appear in source order — the order
the owning handler tests them — so the table reads in the same sequence the
code executes. The view-switch table follows declaration order in
src/lib/routeRegistry.ts. Nothing here is alphabetical.
Global#
Bound on window at the app shell, so they are live on every view.
src/hooks/useGlobalShortcuts.ts:29-88 owns the first four;
src/hooks/useAgentTabHoists.ts:37-70 and
src/hooks/useSideChatHotkey.ts:11-24 own the rest, each in its own listener.
| Keys | Action | Yields to | Source |
|---|---|---|---|
| Ctrl+K | Toggle the command palette | Terminals and every editable control — but only while the palette is closed. While it is open, Ctrl+K always closes it | src/hooks/useGlobalShortcuts.ts:39-44 |
| Esc | Close the command palette | Nothing. Deliberately unguarded — the palette owns focus whenever it is open | src/hooks/useGlobalShortcuts.ts:48-52 |
| Esc | Cancel an in-flight dictation recording | Terminals only. Editable controls keep the cancel, because they are the usual dictation target | src/hooks/useGlobalShortcuts.ts:57-66 |
| Ctrl+Shift+chord | Switch view — see the next table | Terminals, and any open ui/Modal. Not text fields: they do not bind Ctrl+Shift chords |
src/hooks/useGlobalShortcuts.ts:70-83 |
| Ctrl/Cmd+N | On Agents, clear the active conversation and expose the launch composer. Everywhere else, create a new auto-named workspace | Inputs, textareas and contenteditable regions | src/hooks/useAgentTabHoists.ts:38-53 |
| Ctrl/Cmd+Shift+O | Cycle the global transcript view mode: Summary → Normal → Verbose | Inputs, textareas and contenteditable regions | src/hooks/useAgentTabHoists.ts:61-69 |
| Ctrl/Cmd+; | Toggle the side-chat overlay. Ctrl/Cmd+: is accepted too, for layouts that report the shifted glyph | Nothing — this listener has no focus guard | src/hooks/useSideChatHotkey.ts:12-20 |
The transcript-mode cycler used to sit on Ctrl+Shift+V. That
chord is also a plausible push-to-talk binding, and both handlers fired on
one press — so the transcript view mode flipped every time dictation
started. It was moved to Ctrl+Shift+O, which is otherwise unbound
(src/hooks/useAgentTabHoists.ts:26-30).
View switching#
Every view-switch chord is declared once, in the route registry, and every
consumer — the left rail, the status strip, the command palette — derives its
label from the same row. Adding a shell route without a registry entry is a
compile error (src/lib/routeRegistry.ts:104-110).
| Keys | Physical key matched | Goes to | Source |
|---|---|---|---|
| Ctrl+Shift+W | KeyW |
Workspace | src/lib/routeRegistry.ts:125 |
| Ctrl+Shift+1 | Digit1 |
Agents | src/lib/routeRegistry.ts:138 |
| Ctrl+Shift+6 | Digit6 |
PacketCode (ACP) | src/lib/routeRegistry.ts:152 |
| Ctrl+Shift+2 | Digit2 |
Flight Deck | src/lib/routeRegistry.ts:165 |
| Ctrl+Shift+3 | Digit3 |
Issues | src/lib/routeRegistry.ts:180 |
| Ctrl+Shift+4 | Digit4 |
Session History | src/lib/routeRegistry.ts:221 |
| Ctrl+Shift+D | KeyD |
Dictation | src/lib/routeRegistry.ts:238 |
| Ctrl+Shift+5 | Digit5 |
Settings | src/lib/routeRegistry.ts:259 |
Three shell routes carry no chord and are reachable only from the rail or
the palette: Memory (src/lib/routeRegistry.ts:182), Git Hosts
(:197) and the Welcome screen (:106). That is a deliberate absence,
not an oversight — the number row ran out at six and the registry makes an
unbound route a one-line row rather than a missing case.
Workspace and terminal panes#
| Keys | Action | Notes | Source |
|---|---|---|---|
| Esc | Exit pane zoom | The lowest-priority Escape consumer in the app. Only the visible, active workspace's container listens | src/components/workspace/WorkspaceMosaicContainer.tsx:98-131 |
| y | Approve a terminal approval prompt — writes y\n into the PTY |
Bare key, no modifier | src/hooks/useApprovalShortcuts.ts:80-82 |
| n | Deny — writes n\n |
Bare key, no modifier | src/hooks/useApprovalShortcuts.ts:83-85 |
| Esc | Abort — writes \x03 (Ctrl-C) |
Bare key, no modifier | src/hooks/useApprovalShortcuts.ts:86-88 |
| Ctrl/Cmd+S | Save the file open in the Editor pane | Bound on window per mounted EditorPane, not on the textarea |
src/components/editor/EditorPane.tsx:86-94 |
| ← / → | Resize the right dock by 8 px, or 32 px with Shift | Only while the dock's splitter handle has focus | src/components/layout/RightDock.tsx:198-206 |
The bare y / n / Esc approval keys are bound
on window by every pane that is waiting, so a single press reaches all of
them and preventDefault cannot stop a sibling. Rather than fight that, each
handler decides whether the press is addressed to it, in this order
(src/hooks/useApprovalShortcuts.ts:36-50):
| Panes waiting | Who answers |
|---|---|
| Exactly one | That pane, focused or not |
| Several, one active | Only the active pane |
| Several, none active | Nobody — click a pane, or use its on-screen buttons |
The ownership rule exists because the earlier version had none:
every waiting pane bound the same window keys, so one y wrote y\n
into every waiting agent's stdin — silently approving actions in panes nobody
had looked at.
The composer#
One handler serves both composer variants — the chat composer and the launch
composer
(src/components/agents/composer/buildComposerKeyboardHandler.ts:51-188).
Rows are in the order the handler tests them, which is also their precedence.
| Keys | Action | Condition | Source |
|---|---|---|---|
| Esc | Dismiss the / slash-command popover |
Slash popover open | buildComposerKeyboardHandler.ts:69-73 |
| ↑ / ↓ | Move the slash highlight | Slash popover open | buildComposerKeyboardHandler.ts:74-83 |
| Enter / Tab | Pick the highlighted slash command, or dismiss if the list is empty | Slash popover open | buildComposerKeyboardHandler.ts:84-93 |
| Esc | Dismiss the @ file-mention popover |
Mention popover open | buildComposerKeyboardHandler.ts:98-102 |
| ↑ / ↓ | Move the mention highlight | Mention popover open | buildComposerKeyboardHandler.ts:103-112 |
| Enter / Tab | Insert the highlighted path, or dismiss if the list is empty | Mention popover open | buildComposerKeyboardHandler.ts:113-121 |
| ↑ | Recall the previous prompt, shell style | Chat composer only; no popover open, no modifier, and the composer is empty or the caret is at position 0 | buildComposerKeyboardHandler.ts:146-155 |
| ↓ | Walk forward through recalled prompts | As above, and history recall is active | buildComposerKeyboardHandler.ts:156-163 |
| Esc | Leave history recall and clear the composer | As above, and history recall is active | buildComposerKeyboardHandler.ts:164-170 |
| Shift+Tab | Cycle the mode chip: default → plan → manual → deny → yolo | Chat composer only | buildComposerKeyboardHandler.ts:173-177, cycle order at src/components/agents/composer/slashCommandHandlers.ts:25 |
| Ctrl+Enter | Send | — | buildComposerKeyboardHandler.ts:179-183 |
| Enter | Send | Without Shift. Shift+Enter falls through and inserts a newline | buildComposerKeyboardHandler.ts:184-187 |
| Tab | — | Deliberately not handled outside the popovers, so it keeps native focus navigation | buildComposerKeyboardHandler.ts:40-42 |
While either popover is open, Enter and Tab are always
swallowed. A half-typed @query whose directory scan has not landed, or an
unmatched /xyz, is never sent as a literal message by the same keystroke
that was meant to complete it; an empty list dismisses the popover so the
next Enter submits.
Agent approvals and review#
These listen on document, not window, and each is gated by a per-tile
focus flag so only one conversation tile in a workspace mosaic answers a
press.
| Keys | Action | Arming condition | Source |
|---|---|---|---|
| y | Allow the head-of-queue permission once | A permission is pending, the palette is closed, this tile holds keyboard scope, and focus is not in an editable control | src/components/agents/chat/PendingApprovalsSection.tsx:81-102 |
| n | Deny it | As above | src/components/agents/chat/PendingApprovalsSection.tsx:81-102 |
| y | Apply the head-of-queue pending edit | An edit is pending, no permission is pending, and this tile holds keyboard scope | src/components/agents/review/ReviewBar.tsx:74-94 |
| n | Reject it | As above | src/components/agents/review/ReviewBar.tsx:74-94 |
| Esc | Close the expanded review surface | Not while focus is in an input, textarea, select or contenteditable, and not if the press was already handled | src/components/agents/review/ReviewSurface.tsx:119-132 |
| Ctrl/Cmd+Enter | Submit an edited user message | While editing a transcript message | src/components/agents/chat/MessageList.tsx:410-417 |
| Esc | Cancel that edit | As above | src/components/agents/chat/MessageList.tsx:414-416 |
| Enter / Esc | Confirm / cancel a permission prompt's inline field | — | src/components/agents/PermissionPrompt.tsx:378-383 |
| Esc | Close the side-chat overlay | Overlay open | src/components/agents/SideChatOverlay.tsx:46-52 |
| Enter | Ask the side chat | Without Shift | src/components/agents/SideChatOverlay.tsx:63-67 |
| F2 | Rename a conversation or an adoptable session row in the agent sidebar | Row focused | src/components/agents/AgentSidebar.tsx:432-440, :592-598, :611-617 |
The edit handler stays passive while any permission is pending
(PendingApprovalsSection.tsx:73, ReviewBar.tsx:72): both bind y
and n, so permissions are answered first and only then do the same
two keys mean apply/reject.
Dictation#
Three accelerators are registered with the operating system, and only when
Settings → Integrations & Data → Dictation → Global dictation shortcuts is
on. PacketBench unregisters only what it successfully registered itself; an
existing OS or application binding is reported as a conflict, never taken over
(src/hooks/useDictationGlobalShortcuts.ts:43-53, :147-153).
| Keys | Action | Scope | Rebindable | Source |
|---|---|---|---|---|
| Ctrl/Cmd+Alt+Space | Hold to record, release to transcribe | OS-global | Yes; must include a modifier | src/types/dictation.ts:193 |
| Ctrl/Cmd+Alt+R | Start or stop recording | OS-global | Yes; must include a modifier | src/types/dictation.ts:194 |
| Ctrl/Cmd+Shift+D | Open the Dictation view | OS-global | No — fixed | src/types/dictation.ts:195 |
| Esc | Cancel and discard the recording | In-app only | No — fixed | src/hooks/useGlobalShortcuts.ts:57-66 |
| Ctrl+V | — | Not a binding you press: it is the synthetic keystroke PacketBench sends to the foreground application when auto-paste is on | — | See Dictation & analytics |
All three accelerators are stored as @tauri-apps/plugin-global-shortcut
strings using CommandOrControl, which resolves to Cmd on macOS
and Ctrl elsewhere
(src/components/views/tools/KeyboardShortcutsCard.tsx:271-277). They must
all differ from each other, including the fixed open shortcut
(src/types/dictation.ts:253-265).
The toggle carries a 400 ms repeat guard. Win32 RegisterHotKey delivers
repeated WM_HOTKEY messages while a key is held with no intervening release,
so an unguarded toggle would start a capture and stop it again on the next
repeat, transcribing a fraction of a second
(src/hooks/useDictationGlobalShortcuts.ts:35-41).
Modals, palettes and lists#
| Keys | Action | Notes | Source |
|---|---|---|---|
| Esc | Close the top-most dialog | Skipped while an unbreakable operation is in flight, when the caller opts out, mid-IME composition, or when an inner layer already handled the press | src/components/ui/Modal.tsx:82-101 |
| ↑ / ↓ | Move the command-palette selection | — | src/components/common/CommandPalette.tsx:147-152 |
| Enter | Run the selected palette command | — | src/components/common/CommandPalette.tsx:153-157 |
| Esc | Close a dropdown or popover | Each owns its own Escape and marks the press handled | src/components/ui/Dropdown.tsx:133, src/components/ui/Popover.tsx:124 |
| ← / → / ↑ / ↓ | Move between segmented-control options | — | src/components/ui/SegmentedControl.tsx:48-53 |
| Ctrl+Enter | Launch a Flight from the launch dialog | Ctrl only — see Gaps below | src/components/flights/LaunchAsyncFlightModal.tsx:505-510 |
| Enter | Send a follow-up to an attempt | Without Shift | src/components/flights/AttemptTile.tsx:179-184 |
| Enter / Space | Activate a flight card | Cards are role="button" |
src/components/views/FlightsView.tsx:582, :641 |
| Enter / Esc | Commit / cancel an inline issue field edit | Assignee, title, criteria | src/components/issues/IssueDetail.tsx:411-418, :585-588 |
| Enter | Add an acceptance criterion | New-issue form | src/components/issues/NewIssueForm.tsx:294-297 |
| Ctrl/Cmd+R | Re-run the code-quality analyser | Code Quality module view only; no typing guard | src/components/views/QualityView.tsx:167-171 |
| Ctrl/Cmd+F | Focus and select the quality filter box | Code Quality module view only | src/components/views/QualityView.tsx:172-179 |
Rebinding#
Settings → Integrations & Data → Keyboard Shortcuts owns the global dictation accelerators and nothing else. Every other binding on this page is in-app and is not editable.
| Control | Behaviour | Source |
|---|---|---|
| Pencil icon | Enters capture. The row focuses itself and swallows keystrokes until a valid combination arrives | src/components/views/tools/KeyboardShortcutsCard.tsx:156-195 |
| Modifier-only press | Ignored — capture waits for a real key | KeyboardShortcutsCard.tsx:163-170 |
| Esc during capture | Cancels, leaving the binding unchanged | KeyboardShortcutsCard.tsx:176-179 |
| A bare key with no modifier | Rejected with "Shortcut must include at least one modifier (Ctrl/Alt/Shift/Cmd)." — otherwise a could become push-to-talk |
KeyboardShortcutsCard.tsx:182-186 |
| Clicking away | Cancels capture on blur | KeyboardShortcutsCard.tsx:206-209 |
| Rotate icon | Appears only when the binding differs from its default; restores it | KeyboardShortcutsCard.tsx:235-244 |
Capture listens on the row's own DOM node in the capture phase, not on
window, so a simultaneous interaction elsewhere in Settings cannot rebind
the shortcut by accident (KeyboardShortcutsCard.tsx:153-155).
Precedence#
Four mechanisms decide who consumes a key. They are listed here in the order they take effect.
The handled flag unwinds one layer at a time#
Every window-level handler returns early on e.defaultPrevented
(src/hooks/useGlobalShortcuts.ts:34, src/components/ui/Modal.tsx:92,
src/components/workspace/WorkspaceMosaicContainer.tsx:107,
src/hooks/useApprovalShortcuts.ts:69). An inner surface that has already
acted — xterm, an open dropdown, an inline field editor, the palette's own
list — marks the press handled, and one press unwinds exactly one layer.
The flag alone is not enough, and the code says why. Listeners fire in
registration order, and the zoom-exit listener registers when a pane is
zoomed, which is before any dialog opened afterwards registers its own. Zoom
therefore ran first, called preventDefault, and Modal then bailed on
exactly the flag zoom had just set: zooming a pane and opening any dialog
meant Esc closed the zoom and left the dialog open
(src/components/workspace/WorkspaceMosaicContainer.tsx:88-97). The explicit
checks in the next two sections are what actually order the layers.
Focus classification decides who yields#
src/lib/keyboardTarget.ts is the single definition of "the user is typing",
and every global listener routes its guard through it rather than re-deriving
a tag check.
| Predicate | True when the event originated in | Source |
|---|---|---|
isTerminalTarget |
.xterm, .xterm-helper-textarea, or [data-dictation-pty-session] |
src/lib/keyboardTarget.ts:28, :38-41 |
isEditableTarget |
A terminal, or an INPUT / TEXTAREA / SELECT, or any contenteditable region |
src/lib/keyboardTarget.ts:48-55 |
xterm renders a focus-holding <textarea> inside its container, so a focused
terminal trips the tag check on its own; the container lookup additionally
covers keydowns raised on the viewport or canvas before that textarea takes
focus (src/lib/keyboardTarget.ts:21-28).
| Binding | Guard | Consequence |
|---|---|---|
| Ctrl+K | isEditableTarget, and only while the palette is closed |
Suppressed in a terminal and in every text field. In a terminal this is the point: Ctrl+K is readline's kill-line, and a user killing a line at their shell was getting the palette dropped over the terminal |
| Ctrl+Shift+chord | isTerminalTarget |
Suppressed in a terminal only. Text fields do not bind Ctrl+Shift chords, so navigation stays available while typing in a composer |
| Esc (dictation cancel) | isTerminalTarget |
Suppressed in a terminal, where Escape belongs to vim, readline's meta prefix or a TUI's back action. Editable controls keep the cancel |
| y / n / Esc (pane approvals) | INPUT / TEXTAREA check |
Never steals a literal keystroke from a field, which covers terminals via xterm's focus holder |
Escape has a fixed ladder#
More surfaces want Esc than want any other key. From highest priority to lowest:
| Rank | Consumer | Condition | Source |
|---|---|---|---|
| 1 | Whatever already handled the press | defaultPrevented set by an inner layer |
All handlers |
| 2 | A focused terminal | The PTY owns every key, Escape included | src/components/workspace/WorkspaceMosaicContainer.tsx:113 |
| 3 | The command palette | Unguarded — it owns focus whenever it is open | src/hooks/useGlobalShortcuts.ts:48-52 |
| 4 | The top-most ui/Modal |
Ordered by React nesting depth, then registration order | src/components/ui/Modal.tsx:96, src/lib/modalStack.ts:32-55 |
| 5 | An active dictation recording | Skipped inside a terminal | src/hooks/useGlobalShortcuts.ts:57-66 |
| 6 | The review surface, if the review belongs to the zoomed pane | Scoped by conversation id | src/components/workspace/WorkspaceMosaicContainer.tsx:114-126 |
| 7 | Pane zoom | Only the visible, active workspace's container listens | src/components/workspace/WorkspaceMosaicContainer.tsx:99-104 |
Rank 6 is scoped for a reason worth knowing: reviewStore.open is a global
flag that nothing resets on a view change, so an unscoped read left
Esc permanently dead after opening a review in Agents and switching
to Workspace — with the un-exitable zoom and the on-screen "Press Esc" hint
both still showing
(src/components/workspace/WorkspaceMosaicContainer.tsx:114-121).
A focused terminal outranks pane zoom, so Esc will
not un-zoom a shell. The tile's own zoom button is the way out, and the
on-screen hint reads "Press Esc — or the tile's zoom button — to exit"
(src/components/workspace/WorkspaceMosaicContainer.tsx:300).
Nested dialogs order by React nesting depth first and registration order
second. Depth has to lead: React runs child effects before parent effects, so
a dialog rendered inside another dialog's children registers first, and raw
ordering would get that case exactly backwards
(src/lib/modalStack.ts:15-18).
Chords match the physical key, not the character#
resolveViewHotkey compares KeyboardEvent.code against the registry's
code field before it falls back to the shifted glyph
(src/lib/routeRegistry.ts:356-365). Physical Digit1 resolves to Agents on
AZERTY, QWERTZ and Dvorak, where the same position reports & or 1 rather
than !. The shifted-glyph map survives only as a fallback for events that do
not carry code, and only for the number-row chords — the mnemonic letter
chords never had glyph entries (src/lib/viewHotkeys.ts:16-22).
Chords also yield to an open dialog, which focus classification alone would
not catch: switching view unmounts the outgoing view, and takes any open
dialog and its half-typed form down with it
(src/hooks/useGlobalShortcuts.ts:77-79, src/lib/modalStack.ts:57-59).
Two tiles, one keypress#
Approval keys are bound at document level, so every mounted conversation
tile would answer a single press. Exactly one handler may be live per tile,
and it is armed by keyboardScopeActive: undefined means there is no pane
context — the standalone Agents view — and the handler arms as before; defined
means it arms only for the focused tile
(src/components/agents/chat/PendingApprovalsSection.tsx:36-43,
src/components/workspace/ConversationTile.tsx:244). The cards themselves
moved into the transcript; the handler deliberately did not move with them
(src/components/agents/chat/InlineApprovals.tsx:42-53).
Gaps and asymmetries#
These are real, present in 0.13.1, and documented rather than smoothed over.
| What | Detail |
|---|---|
| The palette is Ctrl-only | useGlobalShortcuts.ts:39 tests e.ctrlKey and never e.metaKey, so Cmd+K does not open the palette on macOS. Ctrl+K does |
| View chords are Ctrl-only | resolveViewHotkey requires e.ctrlKey (src/lib/routeRegistry.ts:357). There is no Cmd equivalent |
| The Flight launch chord is Ctrl-only | LaunchAsyncFlightModal.tsx:506 tests e.ctrlKey alone, and the dialog footer advertises "Ctrl+Enter to launch". The composer's send chord has the same shape (buildComposerKeyboardHandler.ts:179), but there a bare Enter already submits, so the asymmetry only bites in the dialog |
| The side-chat toggle has no focus guard | useSideChatHotkey.ts:12-20 binds Ctrl/Cmd+; on window with no isEditableTarget check, so it fires while you are typing in a composer or a terminal |
| The Code Quality shortcuts have no typing guard | QualityView.tsx:167-179 binds Ctrl/Cmd+R and Ctrl/Cmd+F on window with no editable check. They are only live while that module view is mounted |
| Ctrl+Shift+D is bound twice | It is the Dictation route chord (src/lib/routeRegistry.ts:238) and the fixed OS-global open accelerator (src/types/dictation.ts:195). Both open the Dictation view, so the collision is invisible in practice — but with global shortcuts on, the OS handler runs even when PacketBench is not focused |
| No in-app rebinding | Only the two dictation capture shortcuts can be changed. Everything else is compiled in |
Settings states the Cmd-on-macOS substitution for
push-to-talk only. It applies to the toggle as well: both defaults are stored
as CommandOrControl accelerators (src/types/dictation.ts:193-194), and the
code is authoritative.
Related#
- Workspaces & terminals — pane zoom, the approval overlay and the ownership rule in context
- Dictation & analytics — what the three accelerators actually do once recording starts
- Settings — the Keyboard Shortcuts card and where it lives
- Flight Deck — the launch dialog that owns Ctrl+Enter
- Issues & git hosts — the board reached by Ctrl+Shift+3
- Architecture internals — where the app shell's listeners are mounted