feat(tui): expose copy shortcut in keymap config

Add `global.copy` to the TUI keymap schema, runtime keymap,
and interactive remap action list so the Copy shortcut can be
remapped or unbound like other global shortcuts.

Move the existing `ctrl-o` default into a new `v3` preset while
preserving older presets, and update docs plus coverage for the
remapped copy behavior.
This commit is contained in:
Felipe Coury
2026-04-23 10:35:34 -03:00
parent 7ff22c969e
commit 62922b712b
10 changed files with 131 additions and 30 deletions
+5 -1
View File
@@ -29,13 +29,15 @@ use std::collections::BTreeMap;
pub enum TuiKeymapPreset {
/// Pointer alias to the latest shipped preset.
///
/// Today this resolves to `v2`.
/// Today this resolves to `v3`.
#[default]
Latest,
/// Frozen initial configurable keymap defaults.
V1,
/// Current defaults with post-`v1` compatibility aliases.
V2,
/// Current defaults with the copy shortcut exposed through keymap config.
V3,
}
/// Normalized string representation of a keybinding (for example `ctrl-a`).
@@ -100,6 +102,8 @@ pub struct TuiGlobalKeymap {
pub open_transcript: Option<KeybindingsSpec>,
/// Open the external editor for the current draft.
pub open_external_editor: Option<KeybindingsSpec>,
/// Copy the last agent response to the clipboard.
pub copy: Option<KeybindingsSpec>,
/// In an empty composer, begin or advance "edit previous message" flow.
pub edit_previous_message: Option<KeybindingsSpec>,
/// Confirm editing the selected previous message.
+18 -1
View File
@@ -2231,6 +2231,7 @@
},
"global": {
"confirm_edit_previous_message": null,
"copy": null,
"edit_previous_message": null,
"open_external_editor": null,
"open_transcript": null,
@@ -2639,6 +2640,14 @@
],
"description": "Confirm editing the selected previous message."
},
"copy": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Copy the last agent response to the clipboard."
},
"edit_previous_message": {
"allOf": [
{
@@ -2773,6 +2782,7 @@
],
"default": {
"confirm_edit_previous_message": null,
"copy": null,
"edit_previous_message": null,
"open_external_editor": null,
"open_transcript": null,
@@ -2902,7 +2912,7 @@
"description": "Versioned keymap defaults.",
"oneOf": [
{
"description": "Pointer alias to the latest shipped preset.\n\nToday this resolves to `v2`.",
"description": "Pointer alias to the latest shipped preset.\n\nToday this resolves to `v3`.",
"enum": [
"latest"
],
@@ -2921,6 +2931,13 @@
"v2"
],
"type": "string"
},
{
"description": "Current defaults with the copy shortcut exposed through keymap config.",
"enum": [
"v3"
],
"type": "string"
}
]
},
+21 -14
View File
@@ -354,6 +354,7 @@ use crate::history_cell::PlainHistoryCell;
use crate::history_cell::WebSearchCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::RuntimeKeymap;
use crate::keymap_setup;
#[cfg(test)]
@@ -822,6 +823,7 @@ pub(crate) struct ChatWidget {
plan_stream_controller: Option<PlanStreamController>,
/// Holds the platform clipboard lease so copied text remains available while supported.
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
copy_last_response_binding: Vec<KeyBinding>,
/// Raw markdown of the most recently completed agent response that
/// survived any local thread rollback.
last_agent_markdown: Option<String>,
@@ -5155,6 +5157,12 @@ impl ChatWidget {
let current_cwd = Some(config.cwd.to_path_buf());
let effective_service_tier = config.service_tier;
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
let copy_last_response_binding = runtime_keymap
.as_ref()
.map(|keymap| keymap.app.copy.clone())
.unwrap_or_else(|| RuntimeKeymap::defaults().app.copy);
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
@@ -5196,6 +5204,7 @@ impl ChatWidget {
stream_controller: None,
plan_stream_controller: None,
clipboard_lease: None,
copy_last_response_binding,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
pending_collab_spawn_requests: HashMap::new(),
@@ -5293,7 +5302,7 @@ impl ChatWidget {
};
widget.prefetch_rate_limits();
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
if let Some(keymap) = runtime_keymap {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
@@ -5336,6 +5345,16 @@ impl ChatWidget {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Press
&& self.copy_last_response_binding.is_pressed(key_event)
{
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.copy_last_agent_markdown();
return;
}
if self.handle_reasoning_shortcut(key_event) {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
@@ -5344,19 +5363,6 @@ impl ChatWidget {
}
match key_event {
// Ctrl+O - copy last agent response from the main view.
KeyEvent {
code: KeyCode::Char('o'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.copy_last_agent_markdown();
return;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
@@ -8291,6 +8297,7 @@ impl ChatWidget {
runtime_keymap: &RuntimeKeymap,
) {
self.config.tui_keymap = keymap_config;
self.copy_last_response_binding = runtime_keymap.app.copy.clone();
self.bottom_pane.set_keymap_bindings(runtime_keymap);
self.request_redraw();
}
@@ -882,6 +882,33 @@ async fn ctrl_o_copy_reports_when_no_agent_response_exists() {
);
}
#[tokio::test]
async fn copy_shortcut_can_be_remapped() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut keymap_config = chat.config_ref().tui_keymap.clone();
keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("ctrl-u".to_string()),
));
let runtime_keymap =
RuntimeKeymap::from_config(&keymap_config).expect("copy remap should be valid");
chat.apply_keymap_update(keymap_config, &runtime_keymap);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
assert!(
drain_insert_history(&mut rx).is_empty(),
"old copy shortcut should no longer copy"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("No agent response to copy"),
"expected remapped copy shortcut to run, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+38 -2
View File
@@ -33,7 +33,7 @@ use std::collections::HashMap;
///
/// 1. Context-specific binding (`tui.keymap.<context>`).
/// 2. `tui.keymap.global` for actions that support global fallback.
/// 3. Built-in preset defaults (`latest` currently points to `v1`).
/// 3. Built-in preset defaults (`latest` currently points to `v3`).
#[derive(Clone, Debug)]
pub(crate) struct RuntimeKeymap {
pub(crate) app: AppKeymap,
@@ -54,6 +54,8 @@ pub(crate) struct AppKeymap {
pub(crate) open_transcript: Vec<KeyBinding>,
/// Open external editor for the current draft.
pub(crate) open_external_editor: Vec<KeyBinding>,
/// Copy the last agent response to the clipboard.
pub(crate) copy: Vec<KeyBinding>,
/// Toggle Vim mode for the composer input.
pub(crate) toggle_vim_mode: Vec<KeyBinding>,
}
@@ -349,6 +351,11 @@ impl RuntimeKeymap {
&defaults.app.open_external_editor,
"tui.keymap.global.open_external_editor",
)?,
copy: resolve_bindings(
keymap.global.copy.as_ref(),
&defaults.app.copy,
"tui.keymap.global.copy",
)?,
toggle_vim_mode: resolve_bindings(
keymap.global.toggle_vim_mode.as_ref(),
&defaults.app.toggle_vim_mode,
@@ -514,7 +521,8 @@ impl RuntimeKeymap {
fn defaults_for_preset(preset: TuiKeymapPreset) -> Self {
match preset {
TuiKeymapPreset::Latest | TuiKeymapPreset::V2 => Self::defaults_v2(),
TuiKeymapPreset::Latest | TuiKeymapPreset::V3 => Self::defaults_v3(),
TuiKeymapPreset::V2 => Self::defaults_v2(),
TuiKeymapPreset::V1 => Self::defaults_v1(),
}
}
@@ -529,6 +537,7 @@ impl RuntimeKeymap {
app: AppKeymap {
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))],
copy: default_bindings![],
toggle_vim_mode: default_bindings![],
},
chat: ChatKeymap {
@@ -732,6 +741,16 @@ impl RuntimeKeymap {
defaults
}
/// Current keymap defaults for preset `v3`.
///
/// This keeps earlier presets intact while moving the existing Copy
/// shortcut into configurable keymap defaults.
fn defaults_v3() -> Self {
let mut defaults = Self::defaults_v2();
defaults.app.copy = default_bindings![ctrl(KeyCode::Char('o'))];
defaults
}
/// Reject ambiguous bindings in scopes that are evaluated together.
///
/// We validate in multiple passes because runtime handling has mixed
@@ -753,6 +772,7 @@ impl RuntimeKeymap {
"open_external_editor",
self.app.open_external_editor.as_slice(),
),
("copy", self.app.copy.as_slice()),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
(
"edit_previous_message",
@@ -773,6 +793,7 @@ impl RuntimeKeymap {
"open_external_editor",
self.app.open_external_editor.as_slice(),
),
("copy", self.app.copy.as_slice()),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
("composer.submit", self.composer.submit.as_slice()),
("composer.queue", self.composer.queue.as_slice()),
@@ -1254,6 +1275,21 @@ mod tests {
assert!(err.contains("tui.keymap.global.open_external_editor"));
}
#[test]
fn default_latest_copy_binding_is_ctrl_o() {
let runtime = RuntimeKeymap::defaults();
assert_eq!(runtime.app.copy, vec![key_hint::ctrl(KeyCode::Char('o'))]);
}
#[test]
fn invalid_global_copy_binding_reports_global_path() {
let mut keymap = TuiKeymap::default();
keymap.global.copy = Some(one("meta-o"));
let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error");
assert!(err.contains("tui.keymap.global.copy"));
}
#[test]
fn rejects_conflicting_chat_bindings() {
let mut keymap = TuiKeymap::default();
+3
View File
@@ -34,6 +34,7 @@ const fn action(
pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("global", "Global", "open_transcript", "Open the transcript overlay."),
action("global", "Global", "open_external_editor", "Open the current draft in an external editor."),
action("global", "Global", "copy", "Copy the last agent response to the clipboard."),
action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."),
action("chat", "Chat", "edit_previous_message", "Begin or advance edit-previous-message when the composer is empty."),
action("chat", "Chat", "confirm_edit_previous_message", "Confirm the selected previous message to edit."),
@@ -147,6 +148,7 @@ pub(super) fn binding_slot<'a>(
match (context, action) {
("global", "open_transcript") => Some(&mut keymap.global.open_transcript),
("global", "open_external_editor") => Some(&mut keymap.global.open_external_editor),
("global", "copy") => Some(&mut keymap.global.copy),
("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode),
("chat", "edit_previous_message") => Some(&mut keymap.chat.edit_previous_message),
("chat", "confirm_edit_previous_message") => Some(&mut keymap.chat.confirm_edit_previous_message),
@@ -248,6 +250,7 @@ pub(super) fn bindings_for_action<'a>(
match (context, action) {
("global", "open_transcript") => Some(runtime_keymap.app.open_transcript.as_slice()),
("global", "open_external_editor") => Some(runtime_keymap.app.open_external_editor.as_slice()),
("global", "copy") => Some(runtime_keymap.app.copy.as_slice()),
("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()),
("chat", "edit_previous_message") => Some(runtime_keymap.chat.edit_previous_message.as_slice()),
("chat", "confirm_edit_previous_message") => Some(runtime_keymap.chat.confirm_edit_previous_message.as_slice()),
+5 -4
View File
@@ -36,18 +36,19 @@ Precedence is applied in this order (highest first):
### Presets
- `latest`: moving alias for the newest preset; today `latest -> v2`
- `v2`: current defaults, restoring `alt-d` as a `delete_forward_word` alias
- `latest`: moving alias for the newest preset; today `latest -> v3`
- `v3`: current defaults, exposing `copy = "ctrl-o"` in the keymap
- `v2`: restores `alt-d` as a `delete_forward_word` alias
- `v1`: frozen initial configurable keymap defaults
When defaults change in the future, a new version (for example `v3`) is added and
When defaults change in the future, a new version (for example `v4`) is added and
`latest` may move to it. Pin to `v1` if you want stable historical behavior.
TODO(docs): mirror this preset migration note on developers.openai.com.
### Supported actions
- `global`: `open_transcript`, `open_external_editor`, `edit_previous_message`,
- `global`: `open_transcript`, `open_external_editor`, `copy`, `edit_previous_message`,
`confirm_edit_previous_message`, `submit`, `queue`, `toggle_shortcuts`,
`toggle_vim_mode`
- `chat`: `edit_previous_message`, `confirm_edit_previous_message`
+4 -2
View File
@@ -1,4 +1,4 @@
# Codex TUI keymap template (v1 defaults)
# Codex TUI keymap template (latest defaults)
#
# Copy the sections you need into ~/.codex/config.toml.
#
@@ -18,7 +18,7 @@
[tui.keymap]
# Preset alias for built-in defaults.
# "latest" currently points to "v1".
# "latest" currently points to "v3".
preset = "latest"
[tui.keymap.global]
@@ -26,6 +26,8 @@ preset = "latest"
open_transcript = "ctrl-t"
# Open external editor for current draft.
open_external_editor = "ctrl-g"
# Copy the last agent response to the clipboard.
copy = "ctrl-o"
# Begin/advance "edit previous message" when composer is empty.
edit_previous_message = "esc"
# Confirm selected message in edit-previous flow.
+4 -3
View File
@@ -1,20 +1,20 @@
# TUI Keymap Action Matrix
This file defines the supported keymap actions and their default `v1` bindings.
This file defines the supported keymap actions and their current default bindings.
For runtime behavior, safety invariants, and testing guidance, see
`docs/tui-keymap.md`.
## Preset behavior
- `latest` is an alias to the newest shipped preset.
- Today, `latest -> v1`.
- Today, `latest -> v3`.
- To keep stable behavior over time, pin `preset = "v1"`.
## Precedence
1. `tui.keymap.<context>.<action>`
2. `tui.keymap.global.<action>` (chat/composer fallback actions only)
3. Preset default (`v1` today)
3. Preset default (`v3` today)
## Default `v1` Compatibility Notes
@@ -36,6 +36,7 @@ For runtime behavior, safety invariants, and testing guidance, see
- `open_transcript`: open transcript overlay
- `open_external_editor`: open external editor for current draft
- `copy`: copy last agent response to the clipboard
- `edit_previous_message`: begin/advance edit-previous flow when composer is empty
- `confirm_edit_previous_message`: confirm selected previous message for editing
- `submit`: submit current draft
+6 -3
View File
@@ -85,8 +85,8 @@ at `https://github.com/openai/codex/blob/main/docs/default-keymap.toml`.
Preset semantics:
1. `latest` is an alias to the newest shipped preset.
2. `v2` is the current baseline; `v1` is frozen for historical behavior.
3. Today, `latest -> v2`.
2. `v3` is the current baseline; `v1` and `v2` are frozen for historical behavior.
3. Today, `latest -> v3`.
User guidance:
@@ -96,7 +96,7 @@ User guidance:
Developer policy:
1. Do not mutate old preset defaults after release.
2. Add a new version (for example `v3`) for behavior changes.
2. Add a new version (for example `v4`) for behavior changes.
3. Update docs and migration notes whenever `latest` changes.
Migration notes:
@@ -104,6 +104,9 @@ Migration notes:
`v2` restores `alt-d` as a `delete_forward_word` alias while preserving
`alt-delete` from `v1`.
`v3` exposes the Copy shortcut as `global.copy = "ctrl-o"` so it can be
remapped or unbound through `[tui.keymap]`.
TODO(docs): mirror this preset migration note on developers.openai.com.
Compatibility detail: