mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user