diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index fe842e7deb..fd9061cd7f 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -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, /// Open the external editor for the current draft. pub open_external_editor: Option, + /// Copy the last agent response to the clipboard. + pub copy: Option, /// In an empty composer, begin or advance "edit previous message" flow. pub edit_previous_message: Option, /// Confirm editing the selected previous message. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a7998ee422..11a4f1a8a5 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" } ] }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bfdbacc03f..b6085c9986 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, + copy_last_response_binding: Vec, /// Raw markdown of the most recently completed agent response that /// survived any local thread rollback. last_agent_markdown: Option, @@ -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(); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 85b868ce76..7297a337c4 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index b954ae52fa..65a3f18b5c 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -33,7 +33,7 @@ use std::collections::HashMap; /// /// 1. Context-specific binding (`tui.keymap.`). /// 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, /// Open external editor for the current draft. pub(crate) open_external_editor: Vec, + /// Copy the last agent response to the clipboard. + pub(crate) copy: Vec, /// Toggle Vim mode for the composer input. pub(crate) toggle_vim_mode: Vec, } @@ -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(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 2e3176dc5a..defa9346a9 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -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()), diff --git a/docs/config.md b/docs/config.md index 4ee0e79709..9b55d8eab5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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` diff --git a/docs/default-keymap.toml b/docs/default-keymap.toml index 38c8b8fae7..66ac45c952 100644 --- a/docs/default-keymap.toml +++ b/docs/default-keymap.toml @@ -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. diff --git a/docs/keymap-action-matrix.md b/docs/keymap-action-matrix.md index b399bf135e..68353da9b4 100644 --- a/docs/keymap-action-matrix.md +++ b/docs/keymap-action-matrix.md @@ -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..` 2. `tui.keymap.global.` (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 diff --git a/docs/tui-keymap.md b/docs/tui-keymap.md index bf629f4fab..e34beb6cf6 100644 --- a/docs/tui-keymap.md +++ b/docs/tui-keymap.md @@ -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: