Skip to content

feat(tui): dispatch user keybindings - #511

Merged
emal-avala merged 5 commits into
mainfrom
feat/keybinding-customization
Jul 27, 2026
Merged

feat(tui): dispatch user keybindings#511
emal-avala merged 5 commits into
mainfrom
feat/keybinding-customization

Conversation

@emal-avala

Copy link
Copy Markdown
Member

Summary

Closes D5-08. keybindings.json was loaded, and /keybindings printed its contents with an "Overrides file:" line — but KeybindingRegistry::lookup had zero callers.

$ grep -rn '\.lookup(' crates/cli/src
(nothing)

So a user could write a binding, run /keybindings, see it listed as active, and have the key do nothing at all. That is worse than the feature being absent — it reported success.

Note this is a correction to the register row, which described it as "plumbing only, no config loader". The loader exists (load_keybindings_file, keybindings_path) and so does the listing; what was missing is the one line that consults them on a keypress.

What changed

  • chord_string(code, mods) renders a key event the way the config file spells it (ctrl+k, alt+shift+up, f5).
  • The registry is consulted on every keypress, before the built-in chord dispatch.
  • A bound chord runs its command or prompt through the normal submit path, so slash dispatch, queueing and mid-turn behaviour are identical to typing it — no second code path to keep in sync.
  • toggle actions map onto the slash commands that own each setting rather than reaching into state directly.

Three deliberate limits

Only user-written chords are dispatched. The built-in defaults in the registry (ctrl+c, ctrl+d, ctrl+l) describe chords the hardcoded handler already owns; routing those through the registry as well would run them twice.

ctrl+c and esc are reserved. They are how you get out of a stuck state — including out of a binding that turned out to be a mistake. a_binding_cannot_steal_ctrl_c asserts a hostile binding on ctrl+c still cancels the turn and does not submit its prompt.

A bare printable character is not a chord. Binding a would make the composer unusable, so chord_string returns None unless Ctrl or Alt is held.

Verified by mutation

With the apply_user_keybinding call disabled, both end-to-end tests fail; restoring it passes them.

That check earned its keep: a_user_bound_chord_runs_its_command originally asserted the composer was empty after the keypress — which is true whether or not the binding fires, so it passed with the dispatch disabled. It now asserts the effect of the bound command (/tasks toggling the pane), which is the only thing that distinguishes the two states.

Also covered: chord rendering across letters/modifiers/function keys/arrows, plain characters not being chords, defaults not being treated as user-defined, and an unbound chord still reaching the built-in handler (Ctrl+P still opens the palette).

577 bin tests pass; clippy --all-targets -- -D warnings and fmt --check clean. docs/tui/KEYBINDINGS.md documents the file format, the chord syntax, and the reserved chords.

`keybindings.json` was loaded and `/keybindings` listed its contents, but
`KeybindingRegistry::lookup` had no callers — so a user could write a
binding, see it listed as active, and have the key do nothing. Worse than
missing: it reported success.

The registry is now consulted on every keypress. A bound chord runs its
command or prompt through the normal submit path, so slash dispatch,
queueing and mid-turn behaviour are identical to typing it.

Only chords the user actually wrote are dispatched. The built-in defaults
in the registry describe chords the hardcoded handler already owns, and
routing those through the registry too would run them twice.

`ctrl+c` and `esc` cannot be rebound. They are how you get out of a stuck
state, including out of a binding that turned out to be a mistake.

A bare printable character is not a bindable chord — binding `a` would
make the composer unusable.

Verified by mutation: with the dispatch call disabled, both end-to-end
tests fail. The command test originally asserted the composer was empty,
which is true whether or not the binding fires; it now asserts the effect
of the bound command, which is the only thing that distinguishes the two.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b5b136704

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1364 to +1366
app.input = format!("/{}", command.trim_start_matches('/'));
app.cursor = app.input.len();
app.submit();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the current draft when dispatching commands

When the composer contains a partially written prompt, invoking a command binding replaces app.input and submit() then clears it, silently discarding the draft. This also occurs in the prompt and toggle branches; for example, binding ctrl+k to tasks loses anything the user was composing merely to open the tasks pane. Preserve and restore the existing composer around bound actions that do not intentionally submit it.

Useful? React with 👍 / 👎.

Comment thread crates/cli/src/ui/keybindings.rs Outdated
Comment on lines +152 to +156
// Shift is already encoded in the character the terminal
// reports, so it is not repeated as a modifier for letters.
let lower = c.to_ascii_lowercase().to_string();
if mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
lower

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Shift in character chord names

When a terminal reports Ctrl+Shift+P as Char('P') with the SHIFT modifier, lowercasing the character while suppressing the modifier renders it as ctrl+p. Consequently a configured ctrl+shift+p binding can never fire, and the shifted chord can unexpectedly trigger a ctrl+p binding instead. Include shift+ for modified character events before normalizing the character's case.

Useful? React with 👍 / 👎.

Comment on lines +589 to +591
keybindings: std::sync::Arc::new(
crate::ui::keybindings::KeybindingRegistry::load(),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report the active keybinding registry

After a user edits keybindings.json during a running session, the TUI continues dispatching this startup-cached registry, while /keybindings independently calls KeybindingRegistry::load() in commands/mod.rs and displays the newly edited file. The command therefore reports bindings as loaded even though they remain inactive until restart—the same misleading state this change is intended to eliminate. Make the listing use the active registry, reload the app registry, or clearly require a restart.

Useful? React with 👍 / 👎.

Comment thread crates/cli/src/ui/keybindings.rs Outdated
Comment on lines +308 to +310
#[test]
fn built_in_defaults_are_not_user_defined() {
let registry = KeybindingRegistry::load();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate default-registry tests from user config

This test invokes the production loader, so a developer whose real ~/.config/agent-code/keybindings.json overrides ctrl+c, ctrl+d, or ctrl+l gets a failing unit test even though the implementation is correct. from_user_bindings() similarly loads ambient configuration and can affect the new run-loop tests. Construct an in-memory defaults-only registry so the test suite remains independent of machine state.

AGENTS.md reference: AGENTS.md:L65-L65

Useful? React with 👍 / 👎.

@mintlify

mintlify Bot commented Jul 26, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
agentcode 🟡 Building Jul 26, 2026, 12:24 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

…ermetic tests

- restore the composer draft around user keybinding dispatch instead of discarding it
- keep an explicitly reported SHIFT in character chords (ctrl+shift+p no longer collapses to ctrl+p)
- list /keybindings in the TUI from the session-active registry and note that file edits apply on restart
- build registry tests on a defaults-only constructor so the machine's real keybindings.json cannot affect them
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 779326d9b9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/cli/src/ui/modern/app.rs Outdated
command_palette: None,
model_picker: None,
keybindings: std::sync::Arc::new(
crate::ui::keybindings::KeybindingRegistry::load(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inject keybindings instead of reading them in App::new

When tests run under an account that already has a keybindings.json, every App::new reads those real bindings; for example, a local ctrl+p override changes the behavior of existing tests that expect the command palette to open. With more than a hundred test constructions using this constructor, results now depend on host configuration, so startup should inject the loaded registry while test construction uses deterministic defaults.

AGENTS.md reference: AGENTS.md:L63-L67

Useful? React with 👍 / 👎.

Comment thread docs/tui/KEYBINDINGS.md Outdated

## Custom keybindings

Put a `keybindings.json` in your config directory (`~/.config/agent-code/`):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the platform-specific configuration directory

On macOS and Windows, following this path creates a file the loader never reads: agent_config_dir() uses ~/Library/Application Support/agent-code on macOS and %APPDATA%\agent-code on Windows (crates/lib/src/config/mod.rs, lines 357–370), not ~/.config/agent-code. Qualify this as the Linux default and document the other platform locations or direct users to a command that prints the resolved path.

Useful? React with 👍 / 👎.

Comment on lines +104 to +105
self.user_defined.insert(key.clone());
self.bindings.insert(key, binding);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject bindings the dispatcher can never execute

When the file contains ctrl+c or esc, these entries are still stored and /keybindings labels their actions as active user bindings, even though action_for always rejects them. Modified variants are also inconsistent: ctrl+alt+c is accepted here but the earlier is_cancel_chord branch consumes it before user dispatch. Validate or drop every reserved/preempted chord while loading, or explicitly mark it inactive, so the listing does not report a binding that can never run.

Useful? React with 👍 / 👎.

- App construction installs built-in defaults only; the run loop loads the user file once, so tests no longer read machine config
- reserved and preempted chords (esc with any modifier, ctrl+c variants without shift) are dropped at load with a warning instead of being listed as active
- /keybindings prints the resolved file path; docs give per-platform config locations
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 27ad22c266

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// dispatch below, which is the point of customization. Reserved
// chords (Ctrl+C, Esc) are filtered out inside `action_for`, and
// they are handled above this line anyway.
if apply_user_keybinding(app, &key) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dispatch user bindings only on initial key presses

When a terminal emits KeyEventKind::Repeat for a held shortcut, handle_key accepts it and this call executes the binding again on every repeat. For a prompt action, the first event starts a turn and subsequent repeats enqueue duplicate prompts that are automatically sent later, potentially triggering multiple unintended model calls and costs from one held key. Gate user-defined command/prompt dispatch to Press events while preserving the existing repeat behavior needed by built-in handlers.

Useful? React with 👍 / 👎.

A held key emits Repeat events; dispatching a binding per repeat queued duplicate prompt turns from one hold. Built-in handlers keep their repeat behavior.
…mization

# Conflicts:
#	crates/cli/src/ui/modern/app.rs
@emal-avala

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 7993bfc7e3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@emal-avala
emal-avala merged commit 043eca5 into main Jul 27, 2026
22 of 23 checks passed
@emal-avala
emal-avala deleted the feat/keybinding-customization branch July 27, 2026 04:48
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.

1 participant