mirror of
https://github.com/openai/codex.git
synced 2026-03-03 05:03:20 +00:00
Compare commits
5 Commits
fix/notify
...
joshka/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56a2295863 | ||
|
|
6970958332 | ||
|
|
dc1d5a0831 | ||
|
|
c267dd07bb | ||
|
|
70b281bb37 |
@@ -20,6 +20,7 @@ In the codex-rs folder where the rust code lives:
|
||||
- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught
|
||||
locally before CI.
|
||||
- Do not create small helper methods that are referenced only once.
|
||||
- For TUI keybind changes, do not mutate old preset defaults; add a new preset version, keep `latest` as an explicit pointer (currently to `v1`), and update `docs/default-keymap.toml` plus `docs/config.md` with migration notes (`TODO(docs): mirror this on developers.openai.com`).
|
||||
|
||||
Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests:
|
||||
|
||||
|
||||
@@ -500,6 +500,20 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"KeybindingsSpec": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope."
|
||||
},
|
||||
"MemoriesToml": {
|
||||
"additionalProperties": false,
|
||||
"description": "Memories settings loaded from config.toml.",
|
||||
@@ -1279,6 +1293,93 @@
|
||||
"description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"keymap": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"approval": {
|
||||
"approve": null,
|
||||
"approve_for_prefix": null,
|
||||
"approve_for_session": null,
|
||||
"cancel": null,
|
||||
"decline": null,
|
||||
"open_fullscreen": null
|
||||
},
|
||||
"chat": {
|
||||
"confirm_edit_previous_message": null,
|
||||
"edit_previous_message": null
|
||||
},
|
||||
"composer": {
|
||||
"queue": null,
|
||||
"submit": null,
|
||||
"toggle_shortcuts": null
|
||||
},
|
||||
"editor": {
|
||||
"delete_backward": null,
|
||||
"delete_backward_word": null,
|
||||
"delete_forward": null,
|
||||
"delete_forward_word": null,
|
||||
"insert_newline": null,
|
||||
"kill_line_end": null,
|
||||
"kill_line_start": null,
|
||||
"move_down": null,
|
||||
"move_left": null,
|
||||
"move_line_end": null,
|
||||
"move_line_start": null,
|
||||
"move_right": null,
|
||||
"move_up": null,
|
||||
"move_word_left": null,
|
||||
"move_word_right": null,
|
||||
"yank": null
|
||||
},
|
||||
"global": {
|
||||
"confirm_edit_previous_message": null,
|
||||
"edit_previous_message": null,
|
||||
"open_external_editor": null,
|
||||
"open_transcript": null,
|
||||
"queue": null,
|
||||
"submit": null,
|
||||
"toggle_shortcuts": null
|
||||
},
|
||||
"list": {
|
||||
"accept": null,
|
||||
"cancel": null,
|
||||
"move_down": null,
|
||||
"move_up": null
|
||||
},
|
||||
"onboarding": {
|
||||
"cancel": null,
|
||||
"confirm": null,
|
||||
"move_down": null,
|
||||
"move_up": null,
|
||||
"quit": null,
|
||||
"select_first": null,
|
||||
"select_second": null,
|
||||
"select_third": null,
|
||||
"toggle_animation": null
|
||||
},
|
||||
"pager": {
|
||||
"close": null,
|
||||
"close_transcript": null,
|
||||
"confirm_edit_message": null,
|
||||
"edit_next_message": null,
|
||||
"edit_previous_message": null,
|
||||
"half_page_down": null,
|
||||
"half_page_up": null,
|
||||
"jump_bottom": null,
|
||||
"jump_top": null,
|
||||
"page_down": null,
|
||||
"page_up": null,
|
||||
"scroll_down": null,
|
||||
"scroll_up": null
|
||||
},
|
||||
"preset": "latest"
|
||||
},
|
||||
"description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings."
|
||||
},
|
||||
"notification_method": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1313,6 +1414,708 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiApprovalKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Approval overlay keybindings.",
|
||||
"properties": {
|
||||
"approve": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Approve the primary option."
|
||||
},
|
||||
"approve_for_prefix": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Approve with exec-policy prefix when that option exists."
|
||||
},
|
||||
"approve_for_session": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Approve for session when that option exists."
|
||||
},
|
||||
"cancel": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Cancel an elicitation request."
|
||||
},
|
||||
"decline": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Decline and provide corrective guidance."
|
||||
},
|
||||
"open_fullscreen": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Open the full-screen approval details view."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiChatKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Chat context keybindings. These override corresponding `global` actions.",
|
||||
"properties": {
|
||||
"confirm_edit_previous_message": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Confirm editing the selected previous message."
|
||||
},
|
||||
"edit_previous_message": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "In an empty composer, begin or advance \"edit previous message\" flow."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiComposerKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Composer context keybindings. These override corresponding `global` actions.",
|
||||
"properties": {
|
||||
"queue": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Queue the current composer draft while a task is running."
|
||||
},
|
||||
"submit": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Submit the current composer draft."
|
||||
},
|
||||
"toggle_shortcuts": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Toggle the composer shortcut overlay."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiEditorKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Editor context keybindings for text editing inside text areas.",
|
||||
"properties": {
|
||||
"delete_backward": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Delete one grapheme to the left."
|
||||
},
|
||||
"delete_backward_word": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Delete the previous word."
|
||||
},
|
||||
"delete_forward": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Delete one grapheme to the right."
|
||||
},
|
||||
"delete_forward_word": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Delete the next word."
|
||||
},
|
||||
"insert_newline": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Insert a newline in the editor."
|
||||
},
|
||||
"kill_line_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Kill text from cursor to line end."
|
||||
},
|
||||
"kill_line_start": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Kill text from cursor to line start."
|
||||
},
|
||||
"move_down": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor down one visual line."
|
||||
},
|
||||
"move_left": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor left by one grapheme."
|
||||
},
|
||||
"move_line_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to end of line."
|
||||
},
|
||||
"move_line_start": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to beginning of line."
|
||||
},
|
||||
"move_right": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor right by one grapheme."
|
||||
},
|
||||
"move_up": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor up one visual line."
|
||||
},
|
||||
"move_word_left": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to beginning of previous word."
|
||||
},
|
||||
"move_word_right": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to end of next word."
|
||||
},
|
||||
"yank": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Yank the kill buffer."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiGlobalKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Global keybindings. These are used when a context does not define an override.",
|
||||
"properties": {
|
||||
"confirm_edit_previous_message": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Confirm editing the selected previous message."
|
||||
},
|
||||
"edit_previous_message": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "In an empty composer, begin or advance \"edit previous message\" flow."
|
||||
},
|
||||
"open_external_editor": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Open the external editor for the current draft."
|
||||
},
|
||||
"open_transcript": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Open the transcript overlay."
|
||||
},
|
||||
"queue": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Queue the current composer draft while a task is running."
|
||||
},
|
||||
"submit": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Submit the current composer draft."
|
||||
},
|
||||
"toggle_shortcuts": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Toggle the composer shortcut overlay."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Raw keymap configuration from `[tui.keymap]`.\n\nEach context contains action-level overrides. Missing actions inherit from runtime preset defaults, and selected chat/composer actions can fall back through `global` during runtime resolution.",
|
||||
"properties": {
|
||||
"approval": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiApprovalKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"approve": null,
|
||||
"approve_for_prefix": null,
|
||||
"approve_for_session": null,
|
||||
"cancel": null,
|
||||
"decline": null,
|
||||
"open_fullscreen": null
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiChatKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"confirm_edit_previous_message": null,
|
||||
"edit_previous_message": null
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiComposerKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"queue": null,
|
||||
"submit": null,
|
||||
"toggle_shortcuts": null
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiEditorKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"delete_backward": null,
|
||||
"delete_backward_word": null,
|
||||
"delete_forward": null,
|
||||
"delete_forward_word": null,
|
||||
"insert_newline": null,
|
||||
"kill_line_end": null,
|
||||
"kill_line_start": null,
|
||||
"move_down": null,
|
||||
"move_left": null,
|
||||
"move_line_end": null,
|
||||
"move_line_start": null,
|
||||
"move_right": null,
|
||||
"move_up": null,
|
||||
"move_word_left": null,
|
||||
"move_word_right": null,
|
||||
"yank": null
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiGlobalKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"confirm_edit_previous_message": null,
|
||||
"edit_previous_message": null,
|
||||
"open_external_editor": null,
|
||||
"open_transcript": null,
|
||||
"queue": null,
|
||||
"submit": null,
|
||||
"toggle_shortcuts": null
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiListKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"accept": null,
|
||||
"cancel": null,
|
||||
"move_down": null,
|
||||
"move_up": null
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiOnboardingKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"cancel": null,
|
||||
"confirm": null,
|
||||
"move_down": null,
|
||||
"move_up": null,
|
||||
"quit": null,
|
||||
"select_first": null,
|
||||
"select_second": null,
|
||||
"select_third": null,
|
||||
"toggle_animation": null
|
||||
}
|
||||
},
|
||||
"pager": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiPagerKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"close": null,
|
||||
"close_transcript": null,
|
||||
"confirm_edit_message": null,
|
||||
"edit_next_message": null,
|
||||
"edit_previous_message": null,
|
||||
"half_page_down": null,
|
||||
"half_page_up": null,
|
||||
"jump_bottom": null,
|
||||
"jump_top": null,
|
||||
"page_down": null,
|
||||
"page_up": null,
|
||||
"scroll_down": null,
|
||||
"scroll_up": null
|
||||
}
|
||||
},
|
||||
"preset": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiKeymapPreset"
|
||||
}
|
||||
],
|
||||
"default": "latest"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiKeymapPreset": {
|
||||
"description": "Versioned keymap defaults.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Pointer alias to the latest shipped preset.\n\nToday this resolves to `v1`.",
|
||||
"enum": [
|
||||
"latest"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Frozen keymap defaults that preserve legacy/current shortcut behavior.",
|
||||
"enum": [
|
||||
"v1"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"TuiListKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "List selection context keybindings for popup-style selectable lists.",
|
||||
"properties": {
|
||||
"accept": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Accept current selection."
|
||||
},
|
||||
"cancel": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Cancel and close selection view."
|
||||
},
|
||||
"move_down": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move list selection down."
|
||||
},
|
||||
"move_up": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move list selection up."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiOnboardingKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Onboarding keybindings.",
|
||||
"properties": {
|
||||
"cancel": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Cancel current screen action."
|
||||
},
|
||||
"confirm": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Confirm current selection."
|
||||
},
|
||||
"move_down": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move selection down."
|
||||
},
|
||||
"move_up": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move selection up."
|
||||
},
|
||||
"quit": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Quit onboarding flow."
|
||||
},
|
||||
"select_first": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Pick first option."
|
||||
},
|
||||
"select_second": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Pick second option."
|
||||
},
|
||||
"select_third": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Pick third option."
|
||||
},
|
||||
"toggle_animation": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Cycle welcome animation variant."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiPagerKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Pager context keybindings for transcript and static overlays.",
|
||||
"properties": {
|
||||
"close": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Close the pager overlay."
|
||||
},
|
||||
"close_transcript": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Close the transcript overlay via its dedicated toggle key."
|
||||
},
|
||||
"confirm_edit_message": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "In backtrack preview mode, confirm selected message."
|
||||
},
|
||||
"edit_next_message": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "In backtrack preview mode, step to newer message."
|
||||
},
|
||||
"edit_previous_message": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "In backtrack preview mode, step to older message."
|
||||
},
|
||||
"half_page_down": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Scroll down by half a page."
|
||||
},
|
||||
"half_page_up": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Scroll up by half a page."
|
||||
},
|
||||
"jump_bottom": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Jump to the end."
|
||||
},
|
||||
"jump_top": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Jump to the beginning."
|
||||
},
|
||||
"page_down": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Scroll down by one page."
|
||||
},
|
||||
"page_up": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Scroll up by one page."
|
||||
},
|
||||
"scroll_down": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Scroll down by one row."
|
||||
},
|
||||
"scroll_up": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Scroll up by one row."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UriBasedFileOpener": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::config::types::ShellEnvironmentPolicyToml;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config::types::Tui;
|
||||
use crate::config::types::TuiKeymap;
|
||||
use crate::config::types::UriBasedFileOpener;
|
||||
use crate::config::types::WindowsSandboxModeToml;
|
||||
use crate::config::types::WindowsToml;
|
||||
@@ -96,6 +97,7 @@ mod permissions;
|
||||
pub mod profile;
|
||||
pub mod schema;
|
||||
pub mod service;
|
||||
pub mod tui_keymap;
|
||||
pub mod types;
|
||||
pub use codex_config::Constrained;
|
||||
pub use codex_config::ConstraintError;
|
||||
@@ -264,13 +266,20 @@ pub struct Config {
|
||||
/// - `always`: Always use alternate screen (original behavior).
|
||||
/// - `never`: Never use alternate screen (inline mode, preserves scrollback).
|
||||
pub tui_alternate_screen: AltScreenMode,
|
||||
|
||||
/// Ordered list of status line item identifiers for the TUI.
|
||||
///
|
||||
/// When unset, the TUI defaults to: `model-with-reasoning`, `context-remaining`, and
|
||||
/// `current-dir`.
|
||||
pub tui_status_line: Option<Vec<String>>,
|
||||
|
||||
/// Keybinding overrides for the TUI.
|
||||
///
|
||||
/// Precedence is:
|
||||
///
|
||||
/// 1. context table (`tui.keymap.chat`, `tui.keymap.composer`, etc.)
|
||||
/// 2. `tui.keymap.global`
|
||||
/// 3. built-in preset defaults (`latest` currently points to `v1`)
|
||||
pub tui_keymap: TuiKeymap,
|
||||
/// The directory that should be treated as the current working directory
|
||||
/// for the session. All relative paths inside the business-logic layer are
|
||||
/// resolved against this path.
|
||||
@@ -2067,6 +2076,11 @@ impl Config {
|
||||
.map(|t| t.alternate_screen)
|
||||
.unwrap_or_default(),
|
||||
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
|
||||
tui_keymap: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
.map(|t| t.keymap.clone())
|
||||
.unwrap_or_default(),
|
||||
otel: {
|
||||
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
|
||||
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
|
||||
@@ -2482,6 +2496,7 @@ allowed_domains = ["openai.com"]
|
||||
show_tooltips: true,
|
||||
alternate_screen: AltScreenMode::Auto,
|
||||
status_line: None,
|
||||
keymap: TuiKeymap::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -4587,6 +4602,7 @@ model_verbosity = "high"
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
},
|
||||
o3_profile_config
|
||||
@@ -4705,6 +4721,7 @@ model_verbosity = "high"
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -4821,6 +4838,7 @@ model_verbosity = "high"
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -4923,6 +4941,7 @@ model_verbosity = "high"
|
||||
feedback_enabled: true,
|
||||
tui_alternate_screen: AltScreenMode::Auto,
|
||||
tui_status_line: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
|
||||
426
codex-rs/core/src/config/tui_keymap.rs
Normal file
426
codex-rs/core/src/config/tui_keymap.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
//! TUI keymap config schema and canonical key-spec normalization.
|
||||
//!
|
||||
//! This module defines the on-disk `[tui.keymap]` contract used by
|
||||
//! `~/.codex/config.toml` and normalizes user-entered key specs into canonical
|
||||
//! forms consumed by runtime keymap resolution in `codex-rs/tui/src/keymap.rs`.
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//!
|
||||
//! 1. Define strongly typed config contexts/actions with unknown-field
|
||||
//! rejection.
|
||||
//! 2. Normalize accepted key aliases into canonical names.
|
||||
//! 3. Reject malformed bindings early with user-facing diagnostics.
|
||||
//!
|
||||
//! Non-responsibilities:
|
||||
//!
|
||||
//! 1. Dispatch precedence and conflict validation.
|
||||
//! 2. Input event matching at runtime.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::de::Error as SerdeError;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Versioned keymap defaults.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TuiKeymapPreset {
|
||||
/// Pointer alias to the latest shipped preset.
|
||||
///
|
||||
/// Today this resolves to `v1`.
|
||||
#[default]
|
||||
Latest,
|
||||
/// Frozen keymap defaults that preserve legacy/current shortcut behavior.
|
||||
V1,
|
||||
}
|
||||
|
||||
/// Normalized string representation of a keybinding (for example `ctrl-a`).
|
||||
///
|
||||
/// The parser accepts a small alias set (for example `escape` -> `esc`,
|
||||
/// `pageup` -> `page-up`) and stores the canonical form.
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(transparent)]
|
||||
pub struct KeybindingSpec(#[schemars(with = "String")] pub String);
|
||||
|
||||
impl KeybindingSpec {
|
||||
/// Returns the canonical key-spec string (for example `ctrl-a`).
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for KeybindingSpec {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
let normalized = normalize_keybinding_spec(&raw).map_err(SerdeError::custom)?;
|
||||
Ok(Self(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
/// One action binding value in config.
|
||||
///
|
||||
/// This accepts either:
|
||||
///
|
||||
/// 1. A single key spec string (`"ctrl-a"`).
|
||||
/// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`).
|
||||
///
|
||||
/// An empty list explicitly unbinds the action in that scope.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum KeybindingsSpec {
|
||||
One(KeybindingSpec),
|
||||
Many(Vec<KeybindingSpec>),
|
||||
}
|
||||
|
||||
impl KeybindingsSpec {
|
||||
/// Returns all configured key specs for one action in declaration order.
|
||||
///
|
||||
/// Callers should preserve this ordering when deriving UI hints so the
|
||||
/// first binding remains the primary affordance shown to users.
|
||||
pub fn specs(&self) -> Vec<&KeybindingSpec> {
|
||||
match self {
|
||||
Self::One(spec) => vec![spec],
|
||||
Self::Many(specs) => specs.iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global keybindings. These are used when a context does not define an override.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiGlobalKeymap {
|
||||
/// Open the transcript overlay.
|
||||
pub open_transcript: Option<KeybindingsSpec>,
|
||||
/// Open the external editor for the current draft.
|
||||
pub open_external_editor: 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.
|
||||
pub confirm_edit_previous_message: Option<KeybindingsSpec>,
|
||||
/// Submit the current composer draft.
|
||||
pub submit: Option<KeybindingsSpec>,
|
||||
/// Queue the current composer draft while a task is running.
|
||||
pub queue: Option<KeybindingsSpec>,
|
||||
/// Toggle the composer shortcut overlay.
|
||||
pub toggle_shortcuts: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Chat context keybindings. These override corresponding `global` actions.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiChatKeymap {
|
||||
/// In an empty composer, begin or advance "edit previous message" flow.
|
||||
pub edit_previous_message: Option<KeybindingsSpec>,
|
||||
/// Confirm editing the selected previous message.
|
||||
pub confirm_edit_previous_message: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Composer context keybindings. These override corresponding `global` actions.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiComposerKeymap {
|
||||
/// Submit the current composer draft.
|
||||
pub submit: Option<KeybindingsSpec>,
|
||||
/// Queue the current composer draft while a task is running.
|
||||
pub queue: Option<KeybindingsSpec>,
|
||||
/// Toggle the composer shortcut overlay.
|
||||
pub toggle_shortcuts: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Editor context keybindings for text editing inside text areas.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiEditorKeymap {
|
||||
/// Insert a newline in the editor.
|
||||
pub insert_newline: Option<KeybindingsSpec>,
|
||||
/// Move cursor left by one grapheme.
|
||||
pub move_left: Option<KeybindingsSpec>,
|
||||
/// Move cursor right by one grapheme.
|
||||
pub move_right: Option<KeybindingsSpec>,
|
||||
/// Move cursor up one visual line.
|
||||
pub move_up: Option<KeybindingsSpec>,
|
||||
/// Move cursor down one visual line.
|
||||
pub move_down: Option<KeybindingsSpec>,
|
||||
/// Move cursor to beginning of previous word.
|
||||
pub move_word_left: Option<KeybindingsSpec>,
|
||||
/// Move cursor to end of next word.
|
||||
pub move_word_right: Option<KeybindingsSpec>,
|
||||
/// Move cursor to beginning of line.
|
||||
pub move_line_start: Option<KeybindingsSpec>,
|
||||
/// Move cursor to end of line.
|
||||
pub move_line_end: Option<KeybindingsSpec>,
|
||||
/// Delete one grapheme to the left.
|
||||
pub delete_backward: Option<KeybindingsSpec>,
|
||||
/// Delete one grapheme to the right.
|
||||
pub delete_forward: Option<KeybindingsSpec>,
|
||||
/// Delete the previous word.
|
||||
pub delete_backward_word: Option<KeybindingsSpec>,
|
||||
/// Delete the next word.
|
||||
pub delete_forward_word: Option<KeybindingsSpec>,
|
||||
/// Kill text from cursor to line start.
|
||||
pub kill_line_start: Option<KeybindingsSpec>,
|
||||
/// Kill text from cursor to line end.
|
||||
pub kill_line_end: Option<KeybindingsSpec>,
|
||||
/// Yank the kill buffer.
|
||||
pub yank: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Pager context keybindings for transcript and static overlays.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiPagerKeymap {
|
||||
/// Scroll up by one row.
|
||||
pub scroll_up: Option<KeybindingsSpec>,
|
||||
/// Scroll down by one row.
|
||||
pub scroll_down: Option<KeybindingsSpec>,
|
||||
/// Scroll up by one page.
|
||||
pub page_up: Option<KeybindingsSpec>,
|
||||
/// Scroll down by one page.
|
||||
pub page_down: Option<KeybindingsSpec>,
|
||||
/// Scroll up by half a page.
|
||||
pub half_page_up: Option<KeybindingsSpec>,
|
||||
/// Scroll down by half a page.
|
||||
pub half_page_down: Option<KeybindingsSpec>,
|
||||
/// Jump to the beginning.
|
||||
pub jump_top: Option<KeybindingsSpec>,
|
||||
/// Jump to the end.
|
||||
pub jump_bottom: Option<KeybindingsSpec>,
|
||||
/// Close the pager overlay.
|
||||
pub close: Option<KeybindingsSpec>,
|
||||
/// Close the transcript overlay via its dedicated toggle key.
|
||||
pub close_transcript: Option<KeybindingsSpec>,
|
||||
/// In backtrack preview mode, step to older message.
|
||||
pub edit_previous_message: Option<KeybindingsSpec>,
|
||||
/// In backtrack preview mode, step to newer message.
|
||||
pub edit_next_message: Option<KeybindingsSpec>,
|
||||
/// In backtrack preview mode, confirm selected message.
|
||||
pub confirm_edit_message: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// List selection context keybindings for popup-style selectable lists.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiListKeymap {
|
||||
/// Move list selection up.
|
||||
pub move_up: Option<KeybindingsSpec>,
|
||||
/// Move list selection down.
|
||||
pub move_down: Option<KeybindingsSpec>,
|
||||
/// Accept current selection.
|
||||
pub accept: Option<KeybindingsSpec>,
|
||||
/// Cancel and close selection view.
|
||||
pub cancel: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Approval overlay keybindings.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiApprovalKeymap {
|
||||
/// Open the full-screen approval details view.
|
||||
pub open_fullscreen: Option<KeybindingsSpec>,
|
||||
/// Approve the primary option.
|
||||
pub approve: Option<KeybindingsSpec>,
|
||||
/// Approve for session when that option exists.
|
||||
pub approve_for_session: Option<KeybindingsSpec>,
|
||||
/// Approve with exec-policy prefix when that option exists.
|
||||
pub approve_for_prefix: Option<KeybindingsSpec>,
|
||||
/// Decline and provide corrective guidance.
|
||||
pub decline: Option<KeybindingsSpec>,
|
||||
/// Cancel an elicitation request.
|
||||
pub cancel: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Onboarding keybindings.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiOnboardingKeymap {
|
||||
/// Move selection up.
|
||||
pub move_up: Option<KeybindingsSpec>,
|
||||
/// Move selection down.
|
||||
pub move_down: Option<KeybindingsSpec>,
|
||||
/// Pick first option.
|
||||
pub select_first: Option<KeybindingsSpec>,
|
||||
/// Pick second option.
|
||||
pub select_second: Option<KeybindingsSpec>,
|
||||
/// Pick third option.
|
||||
pub select_third: Option<KeybindingsSpec>,
|
||||
/// Confirm current selection.
|
||||
pub confirm: Option<KeybindingsSpec>,
|
||||
/// Cancel current screen action.
|
||||
pub cancel: Option<KeybindingsSpec>,
|
||||
/// Quit onboarding flow.
|
||||
pub quit: Option<KeybindingsSpec>,
|
||||
/// Cycle welcome animation variant.
|
||||
pub toggle_animation: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Raw keymap configuration from `[tui.keymap]`.
|
||||
///
|
||||
/// Each context contains action-level overrides. Missing actions inherit from
|
||||
/// runtime preset defaults, and selected chat/composer actions can fall back
|
||||
/// through `global` during runtime resolution.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiKeymap {
|
||||
#[serde(default)]
|
||||
pub preset: TuiKeymapPreset,
|
||||
#[serde(default)]
|
||||
pub global: TuiGlobalKeymap,
|
||||
#[serde(default)]
|
||||
pub chat: TuiChatKeymap,
|
||||
#[serde(default)]
|
||||
pub composer: TuiComposerKeymap,
|
||||
#[serde(default)]
|
||||
pub editor: TuiEditorKeymap,
|
||||
#[serde(default)]
|
||||
pub pager: TuiPagerKeymap,
|
||||
#[serde(default)]
|
||||
pub list: TuiListKeymap,
|
||||
#[serde(default)]
|
||||
pub approval: TuiApprovalKeymap,
|
||||
#[serde(default)]
|
||||
pub onboarding: TuiOnboardingKeymap,
|
||||
}
|
||||
|
||||
/// Normalize one user-entered key spec into canonical storage format.
|
||||
///
|
||||
/// The output always orders modifiers as `ctrl-alt-shift-<key>` when present
|
||||
/// and applies accepted aliases (`escape` -> `esc`, `pageup` -> `page-up`).
|
||||
/// Inputs that cannot be represented unambiguously are rejected.
|
||||
fn normalize_keybinding_spec(raw: &str) -> Result<String, String> {
|
||||
let lower = raw.trim().to_ascii_lowercase();
|
||||
if lower.is_empty() {
|
||||
return Err(
|
||||
"keybinding cannot be empty. Use values like `ctrl-a` or `shift-enter`.\n\
|
||||
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let segments: Vec<&str> = lower
|
||||
.split('-')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect();
|
||||
if segments.is_empty() {
|
||||
return Err(format!(
|
||||
"invalid keybinding `{raw}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`."
|
||||
));
|
||||
}
|
||||
|
||||
let mut modifiers =
|
||||
BTreeMap::<&str, bool>::from([("ctrl", false), ("alt", false), ("shift", false)]);
|
||||
let mut key_segments = Vec::new();
|
||||
let mut saw_key = false;
|
||||
|
||||
for segment in segments {
|
||||
let canonical_mod = match segment {
|
||||
"ctrl" | "control" => Some("ctrl"),
|
||||
"alt" | "option" => Some("alt"),
|
||||
"shift" => Some("shift"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if !saw_key && let Some(modifier) = canonical_mod {
|
||||
if modifiers.get(modifier).copied().unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"duplicate modifier in keybinding `{raw}`. Use each modifier at most once."
|
||||
));
|
||||
}
|
||||
modifiers.insert(modifier, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
saw_key = true;
|
||||
key_segments.push(segment);
|
||||
}
|
||||
|
||||
if key_segments.is_empty() {
|
||||
return Err(format!(
|
||||
"missing key in keybinding `{raw}`. Add a key name like `a`, `enter`, or `page-down`."
|
||||
));
|
||||
}
|
||||
|
||||
if key_segments
|
||||
.iter()
|
||||
.any(|segment| matches!(*segment, "ctrl" | "control" | "alt" | "option" | "shift"))
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid keybinding `{raw}`: modifiers must come before the key (for example `ctrl-a`)."
|
||||
));
|
||||
}
|
||||
|
||||
let key = normalize_key_name(&key_segments.join("-"), raw)?;
|
||||
let mut normalized = Vec::new();
|
||||
if modifiers.get("ctrl").copied().unwrap_or(false) {
|
||||
normalized.push("ctrl".to_string());
|
||||
}
|
||||
if modifiers.get("alt").copied().unwrap_or(false) {
|
||||
normalized.push("alt".to_string());
|
||||
}
|
||||
if modifiers.get("shift").copied().unwrap_or(false) {
|
||||
normalized.push("shift".to_string());
|
||||
}
|
||||
normalized.push(key);
|
||||
Ok(normalized.join("-"))
|
||||
}
|
||||
|
||||
/// Normalize and validate one key name segment.
|
||||
///
|
||||
/// This accepts a constrained key vocabulary to keep runtime parser behavior
|
||||
/// deterministic across platforms.
|
||||
fn normalize_key_name(key: &str, original: &str) -> Result<String, String> {
|
||||
let alias = match key {
|
||||
"escape" => "esc",
|
||||
"return" => "enter",
|
||||
"spacebar" => "space",
|
||||
"pgup" | "pageup" => "page-up",
|
||||
"pgdn" | "pagedown" => "page-down",
|
||||
"del" => "delete",
|
||||
other => other,
|
||||
};
|
||||
|
||||
if alias.len() == 1 {
|
||||
let ch = alias.chars().next().unwrap_or_default();
|
||||
if ch.is_ascii() && !ch.is_ascii_control() && ch != '-' {
|
||||
return Ok(alias.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(
|
||||
alias,
|
||||
"enter"
|
||||
| "tab"
|
||||
| "backspace"
|
||||
| "esc"
|
||||
| "delete"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "home"
|
||||
| "end"
|
||||
| "page-up"
|
||||
| "page-down"
|
||||
| "space"
|
||||
) {
|
||||
return Ok(alias.to_string());
|
||||
}
|
||||
|
||||
if let Some(number) = alias.strip_prefix('f')
|
||||
&& let Ok(number) = number.parse::<u8>()
|
||||
&& (1..=12).contains(&number)
|
||||
{
|
||||
return Ok(alias.to_string());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"unknown key `{key}` in keybinding `{original}`. \
|
||||
Use a printable character (for example `a`), function keys (`f1`-`f12`), \
|
||||
or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space.\n\
|
||||
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml"
|
||||
))
|
||||
}
|
||||
@@ -623,12 +623,32 @@ pub struct Tui {
|
||||
/// `current-dir`.
|
||||
#[serde(default)]
|
||||
pub status_line: Option<Vec<String>>,
|
||||
|
||||
/// Keybinding overrides for the TUI.
|
||||
///
|
||||
/// This supports rebinding selected actions globally and by context.
|
||||
/// Context bindings take precedence over `global` bindings.
|
||||
#[serde(default)]
|
||||
pub keymap: TuiKeymap,
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub use super::tui_keymap::KeybindingSpec;
|
||||
pub use super::tui_keymap::KeybindingsSpec;
|
||||
pub use super::tui_keymap::TuiApprovalKeymap;
|
||||
pub use super::tui_keymap::TuiChatKeymap;
|
||||
pub use super::tui_keymap::TuiComposerKeymap;
|
||||
pub use super::tui_keymap::TuiEditorKeymap;
|
||||
pub use super::tui_keymap::TuiGlobalKeymap;
|
||||
pub use super::tui_keymap::TuiKeymap;
|
||||
pub use super::tui_keymap::TuiKeymapPreset;
|
||||
pub use super::tui_keymap::TuiListKeymap;
|
||||
pub use super::tui_keymap::TuiOnboardingKeymap;
|
||||
pub use super::tui_keymap::TuiPagerKeymap;
|
||||
|
||||
/// Settings for notices we display to users via the tui and app-server clients
|
||||
/// (primarily the Codex IDE extension). NOTE: these are different from
|
||||
/// notifications - notices are warnings, NUX screens, acknowledgements, etc.
|
||||
|
||||
@@ -20,6 +20,8 @@ use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::history_cell::UpdateAvailableHistoryCell;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::model_migration::ModelMigrationOutcome;
|
||||
use crate::model_migration::migration_copy_for_models;
|
||||
use crate::model_migration::run_model_migration_prompt;
|
||||
@@ -71,7 +73,6 @@ use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::style::Stylize;
|
||||
@@ -538,6 +539,7 @@ pub(crate) struct App {
|
||||
has_emitted_history_lines: bool,
|
||||
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) keymap: RuntimeKeymap,
|
||||
|
||||
/// Controls the animation thread that sends CommitTick events.
|
||||
pub(crate) commit_anim_running: Arc<AtomicBool>,
|
||||
@@ -1183,6 +1185,13 @@ impl App {
|
||||
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
|
||||
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"Invalid `tui.keymap` configuration: {err}\n\
|
||||
Fix the config and retry.\n\
|
||||
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml"
|
||||
)
|
||||
})?;
|
||||
#[cfg(not(debug_assertions))]
|
||||
let upgrade_version = crate::updates::get_upgrade_version(&config);
|
||||
|
||||
@@ -1200,6 +1209,7 @@ impl App {
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
keymap: runtime_keymap,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
@@ -1686,6 +1696,7 @@ impl App {
|
||||
self.overlay = Some(Overlay::new_static_with_lines(
|
||||
pager_lines,
|
||||
"D I F F".to_string(),
|
||||
self.keymap.pager.clone(),
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
@@ -2479,6 +2490,7 @@ impl App {
|
||||
self.overlay = Some(Overlay::new_static_with_renderables(
|
||||
vec![diff_summary.into()],
|
||||
"P A T C H".to_string(),
|
||||
self.keymap.pager.clone(),
|
||||
));
|
||||
}
|
||||
ApprovalRequest::Exec { command, .. } => {
|
||||
@@ -2488,6 +2500,7 @@ impl App {
|
||||
self.overlay = Some(Overlay::new_static_with_lines(
|
||||
full_cmd_lines,
|
||||
"E X E C".to_string(),
|
||||
self.keymap.pager.clone(),
|
||||
));
|
||||
}
|
||||
ApprovalRequest::McpElicitation {
|
||||
@@ -2505,6 +2518,7 @@ impl App {
|
||||
self.overlay = Some(Overlay::new_static_with_renderables(
|
||||
vec![Box::new(paragraph)],
|
||||
"E L I C I T A T I O N".to_string(),
|
||||
self.keymap.pager.clone(),
|
||||
));
|
||||
}
|
||||
},
|
||||
@@ -2784,63 +2798,60 @@ impl App {
|
||||
}
|
||||
|
||||
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
// Enter alternate screen and set viewport to full size.
|
||||
let _ = tui.enter_alt_screen();
|
||||
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('g'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
|
||||
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
|
||||
if self.overlay.is_none()
|
||||
&& self.chat_widget.can_launch_external_editor()
|
||||
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
|
||||
{
|
||||
self.request_external_editor_launch(tui);
|
||||
}
|
||||
if self.keymap.app.open_transcript.is_pressed(key_event) {
|
||||
// Enter alternate screen and set viewport to full size.
|
||||
let _ = tui.enter_alt_screen();
|
||||
self.overlay = Some(Overlay::new_transcript(
|
||||
self.transcript_cells.clone(),
|
||||
self.keymap.pager.clone(),
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.keymap.app.open_external_editor.is_pressed(key_event) {
|
||||
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
|
||||
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
|
||||
if self.overlay.is_none()
|
||||
&& self.chat_widget.can_launch_external_editor()
|
||||
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
|
||||
{
|
||||
self.request_external_editor_launch(tui);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.keymap.chat.edit_previous_message.is_pressed(key_event) {
|
||||
// Esc primes/advances backtracking only in normal (not working) mode
|
||||
// with the composer focused and empty. In any other state, forward
|
||||
// Esc so the active UI (e.g. status indicator, modals, popups)
|
||||
// handles it.
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
if self.chat_widget.is_normal_backtrack_mode()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
{
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
} else {
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
if self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() {
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
} else {
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.backtrack.primed
|
||||
&& self.backtrack.nth_user_message != usize::MAX
|
||||
&& self.chat_widget.composer_is_empty() =>
|
||||
{
|
||||
if let Some(selection) = self.confirm_backtrack_from_main() {
|
||||
self.apply_backtrack_selection(tui, selection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& self
|
||||
.keymap
|
||||
.chat
|
||||
.confirm_edit_previous_message
|
||||
.is_pressed(key_event)
|
||||
&& self.backtrack.primed
|
||||
&& self.backtrack.nth_user_message != usize::MAX
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
{
|
||||
// Confirm backtrack when primed + count > 0.
|
||||
if let Some(selection) = self.confirm_backtrack_from_main() {
|
||||
self.apply_backtrack_selection(tui, selection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
@@ -2848,7 +2859,9 @@ impl App {
|
||||
// Any non-Esc key press should cancel a primed backtrack.
|
||||
// This avoids stale "Esc-primed" state after the user starts typing
|
||||
// (even if they later backspace to empty).
|
||||
if key_event.code != KeyCode::Esc && self.backtrack.primed {
|
||||
if !self.keymap.chat.edit_previous_message.is_pressed(key_event)
|
||||
&& self.backtrack.primed
|
||||
{
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
@@ -2919,6 +2932,7 @@ mod tests {
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -3166,6 +3180,8 @@ mod tests {
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
|
||||
let otel_manager = test_otel_manager(&config, model.as_str());
|
||||
let keymap = RuntimeKeymap::from_config(&config.tui_keymap)
|
||||
.expect("test config should always produce a valid runtime keymap");
|
||||
|
||||
App {
|
||||
server,
|
||||
@@ -3180,6 +3196,7 @@ mod tests {
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
keymap,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
@@ -3223,6 +3240,8 @@ mod tests {
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
|
||||
let otel_manager = test_otel_manager(&config, model.as_str());
|
||||
let keymap = RuntimeKeymap::from_config(&config.tui_keymap)
|
||||
.expect("test config should always produce a valid runtime keymap");
|
||||
|
||||
(
|
||||
App {
|
||||
@@ -3238,6 +3257,7 @@ mod tests {
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
keymap,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
@@ -3934,7 +3954,10 @@ mod tests {
|
||||
false,
|
||||
)) as Arc<dyn HistoryCell>,
|
||||
];
|
||||
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
|
||||
app.overlay = Some(Overlay::new_transcript(
|
||||
app.transcript_cells.clone(),
|
||||
app.keymap.pager.clone(),
|
||||
));
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
||||
app.backtrack.overlay_preview_active = true;
|
||||
app.backtrack.nth_user_message = 1;
|
||||
|
||||
@@ -31,6 +31,7 @@ use crate::app::App;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
@@ -41,8 +42,6 @@ use codex_core::protocol::Op;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
|
||||
/// Aggregates all backtrack-related state used by the App.
|
||||
@@ -110,60 +109,51 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<bool> {
|
||||
if self.backtrack.overlay_preview_active {
|
||||
match event {
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
if let TuiEvent::Key(key_event) = event {
|
||||
let is_repeat = matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat);
|
||||
if self.backtrack.overlay_preview_active && is_repeat {
|
||||
if self
|
||||
.keymap
|
||||
.pager
|
||||
.edit_previous_message
|
||||
.is_pressed(key_event)
|
||||
{
|
||||
self.overlay_step_backtrack(tui, TuiEvent::Key(key_event))?;
|
||||
return Ok(true);
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
if self.keymap.pager.edit_next_message.is_pressed(key_event) {
|
||||
self.overlay_step_backtrack_forward(tui, TuiEvent::Key(key_event))?;
|
||||
return Ok(true);
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack_forward(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => {
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& self.keymap.pager.confirm_edit_message.is_pressed(key_event)
|
||||
{
|
||||
self.overlay_confirm_backtrack(tui);
|
||||
Ok(true)
|
||||
}
|
||||
// Catchall: forward any other events to the overlay widget.
|
||||
_ => {
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
Ok(true)
|
||||
return Ok(true);
|
||||
}
|
||||
} else if !self.backtrack.overlay_preview_active
|
||||
&& is_repeat
|
||||
&& self
|
||||
.keymap
|
||||
.pager
|
||||
.edit_previous_message
|
||||
.is_pressed(key_event)
|
||||
{
|
||||
// First press of the edit-previous key in transcript overlay:
|
||||
// begin backtrack preview at latest user message.
|
||||
self.begin_overlay_backtrack_preview(tui);
|
||||
return Ok(true);
|
||||
}
|
||||
} else if let TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) = event
|
||||
{
|
||||
// First Esc in transcript overlay: begin backtrack preview at latest user message.
|
||||
self.begin_overlay_backtrack_preview(tui);
|
||||
Ok(true)
|
||||
} else {
|
||||
// Not in backtrack mode: forward events to the overlay widget.
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
if self.backtrack.overlay_preview_active {
|
||||
// In backtrack mode, any non-matching key is forwarded to overlay.
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
return Ok(true);
|
||||
}
|
||||
// Not in backtrack mode: forward events to the overlay widget.
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Handle global Esc presses for backtracking when no overlay is present.
|
||||
@@ -230,7 +220,10 @@ impl App {
|
||||
/// Open transcript overlay (enters alternate screen and shows full transcript).
|
||||
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
let _ = tui.enter_alt_screen();
|
||||
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
|
||||
self.overlay = Some(Overlay::new_transcript(
|
||||
self.transcript_cells.clone(),
|
||||
self.keymap.pager.clone(),
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
//! Approval modal rendering and decision routing for high-risk operations.
|
||||
//!
|
||||
//! This module converts agent approval requests (exec/apply-patch/MCP
|
||||
//! elicitation) into a list-selection view with action-specific options and
|
||||
//! shortcuts. It owns two important contracts:
|
||||
//!
|
||||
//! 1. Selection always emits an explicit decision event back to the app.
|
||||
//! 2. MCP elicitation keeps `Esc` mapped to `Cancel`, even with custom
|
||||
//! keybindings, so dismissal never silently becomes "continue without info".
|
||||
//!
|
||||
//! This module does not evaluate whether an action is safe to run; it only
|
||||
//! presents choices and routes user decisions.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -13,6 +26,9 @@ use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::ApprovalKeymap;
|
||||
use crate::keymap::ListKeymap;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
@@ -27,7 +43,6 @@ use codex_protocol::mcp::RequestId;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
@@ -70,20 +85,30 @@ pub(crate) struct ApprovalOverlay {
|
||||
current_complete: bool,
|
||||
done: bool,
|
||||
features: Features,
|
||||
approval_keymap: ApprovalKeymap,
|
||||
list_keymap: ListKeymap,
|
||||
}
|
||||
|
||||
impl ApprovalOverlay {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self {
|
||||
pub fn new(
|
||||
request: ApprovalRequest,
|
||||
app_event_tx: AppEventSender,
|
||||
features: Features,
|
||||
approval_keymap: ApprovalKeymap,
|
||||
list_keymap: ListKeymap,
|
||||
) -> Self {
|
||||
let mut view = Self {
|
||||
current_request: None,
|
||||
current_variant: None,
|
||||
queue: Vec::new(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
list: ListSelectionView::new(Default::default(), app_event_tx),
|
||||
list: ListSelectionView::new(Default::default(), app_event_tx, list_keymap.clone()),
|
||||
options: Vec::new(),
|
||||
current_complete: false,
|
||||
done: false,
|
||||
features,
|
||||
approval_keymap,
|
||||
list_keymap,
|
||||
};
|
||||
view.set_current(request);
|
||||
view
|
||||
@@ -98,15 +123,18 @@ impl ApprovalOverlay {
|
||||
let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request);
|
||||
self.current_variant = Some(variant.clone());
|
||||
self.current_complete = false;
|
||||
let (options, params) = Self::build_options(variant, header, &self.features);
|
||||
let (options, params) =
|
||||
Self::build_options(variant, header, &self.features, &self.approval_keymap);
|
||||
self.options = options;
|
||||
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
self.list =
|
||||
ListSelectionView::new(params, self.app_event_tx.clone(), self.list_keymap.clone());
|
||||
}
|
||||
|
||||
fn build_options(
|
||||
variant: ApprovalVariant,
|
||||
header: Box<dyn Renderable>,
|
||||
_features: &Features,
|
||||
features: &Features,
|
||||
approval_keymap: &ApprovalKeymap,
|
||||
) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
||||
let (options, title) = match &variant {
|
||||
ApprovalVariant::Exec {
|
||||
@@ -117,6 +145,8 @@ impl ApprovalOverlay {
|
||||
exec_options(
|
||||
proposed_execpolicy_amendment.clone(),
|
||||
network_approval_context.as_ref(),
|
||||
features,
|
||||
approval_keymap,
|
||||
),
|
||||
network_approval_context.as_ref().map_or_else(
|
||||
|| "Would you like to run the following command?".to_string(),
|
||||
@@ -129,11 +159,11 @@ impl ApprovalOverlay {
|
||||
),
|
||||
),
|
||||
ApprovalVariant::ApplyPatch { .. } => (
|
||||
patch_options(),
|
||||
patch_options(approval_keymap),
|
||||
"Would you like to make the following edits?".to_string(),
|
||||
),
|
||||
ApprovalVariant::McpElicitation { server_name, .. } => (
|
||||
elicitation_options(),
|
||||
elicitation_options(approval_keymap),
|
||||
format!("{server_name} needs your approval."),
|
||||
),
|
||||
};
|
||||
@@ -148,9 +178,7 @@ impl ApprovalOverlay {
|
||||
.iter()
|
||||
.map(|opt| SelectionItem {
|
||||
name: opt.label.clone(),
|
||||
display_shortcut: opt
|
||||
.display_shortcut
|
||||
.or_else(|| opt.additional_shortcuts.first().copied()),
|
||||
display_shortcut: opt.shortcuts.first().copied(),
|
||||
dismiss_on_select: false,
|
||||
..Default::default()
|
||||
})
|
||||
@@ -243,34 +271,29 @@ impl ApprovalOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply approval-specific shortcuts before delegating to list navigation.
|
||||
///
|
||||
/// `open_fullscreen` is handled here because it is orthogonal to list item
|
||||
/// selection and should work regardless of current highlighted row.
|
||||
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press,
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Some(request) = self.current_request.as_ref() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
e => {
|
||||
if let Some(idx) = self
|
||||
.options
|
||||
.iter()
|
||||
.position(|opt| opt.shortcuts().any(|s| s.is_press(*e)))
|
||||
{
|
||||
self.apply_selection(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& self.approval_keymap.open_fullscreen.is_pressed(*key_event)
|
||||
&& let Some(request) = self.current_request.as_ref()
|
||||
{
|
||||
self.app_event_tx
|
||||
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(idx) = self
|
||||
.options
|
||||
.iter()
|
||||
.position(|opt| opt.shortcuts.iter().any(|s| s.is_press(*key_event)))
|
||||
{
|
||||
self.apply_selection(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,41 +475,31 @@ enum ApprovalDecision {
|
||||
struct ApprovalOption {
|
||||
label: String,
|
||||
decision: ApprovalDecision,
|
||||
display_shortcut: Option<KeyBinding>,
|
||||
additional_shortcuts: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
impl ApprovalOption {
|
||||
fn shortcuts(&self) -> impl Iterator<Item = KeyBinding> + '_ {
|
||||
self.display_shortcut
|
||||
.into_iter()
|
||||
.chain(self.additional_shortcuts.iter().copied())
|
||||
}
|
||||
shortcuts: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
fn exec_options(
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
network_approval_context: Option<&NetworkApprovalContext>,
|
||||
_features: &Features,
|
||||
keymap: &ApprovalKeymap,
|
||||
) -> Vec<ApprovalOption> {
|
||||
if network_approval_context.is_some() {
|
||||
return vec![
|
||||
ApprovalOption {
|
||||
label: "Yes, just this once".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Approved),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
|
||||
shortcuts: keymap.approve.clone(),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Yes, and allow this host for this session".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
|
||||
shortcuts: keymap.approve_for_session.clone(),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "No, and tell Codex what to do differently".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Abort),
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
shortcuts: keymap.decline.clone(),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -494,8 +507,7 @@ fn exec_options(
|
||||
vec![ApprovalOption {
|
||||
label: "Yes, proceed".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Approved),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
|
||||
shortcuts: keymap.approve.clone(),
|
||||
}]
|
||||
.into_iter()
|
||||
.chain(proposed_execpolicy_amendment.and_then(|prefix| {
|
||||
@@ -511,61 +523,74 @@ fn exec_options(
|
||||
decision: ApprovalDecision::Review(ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment: prefix,
|
||||
}),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
|
||||
shortcuts: keymap.approve_for_prefix.clone(),
|
||||
})
|
||||
}))
|
||||
.chain([ApprovalOption {
|
||||
label: "No, and tell Codex what to do differently".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Abort),
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
shortcuts: keymap.decline.clone(),
|
||||
}])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn patch_options() -> Vec<ApprovalOption> {
|
||||
fn patch_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Yes, proceed".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Approved),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
|
||||
shortcuts: keymap.approve.clone(),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Yes, and don't ask again for these files".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
|
||||
shortcuts: keymap.approve_for_session.clone(),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "No, and tell Codex what to do differently".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Abort),
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
shortcuts: keymap.decline.clone(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn elicitation_options() -> Vec<ApprovalOption> {
|
||||
/// Build MCP elicitation options with stable cancellation semantics.
|
||||
///
|
||||
/// `Esc` is always treated as cancel for elicitation prompts, even if users
|
||||
/// customize `decline`/`cancel` bindings. We keep this as a hard contract so
|
||||
/// dismissal remains a safe abort path and never silently maps to "continue
|
||||
/// without requested info." Any decline/cancel overlap is removed from the
|
||||
/// decline option in elicitation mode to preserve this invariant.
|
||||
fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
|
||||
let mut cancel_shortcuts = vec![key_hint::plain(KeyCode::Esc)];
|
||||
for shortcut in &keymap.cancel {
|
||||
if !cancel_shortcuts.contains(shortcut) {
|
||||
cancel_shortcuts.push(*shortcut);
|
||||
}
|
||||
}
|
||||
|
||||
let decline_shortcuts: Vec<KeyBinding> = keymap
|
||||
.decline
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|shortcut| !cancel_shortcuts.contains(shortcut))
|
||||
.collect();
|
||||
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Yes, provide the requested info".to_string(),
|
||||
decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
|
||||
shortcuts: keymap.approve.clone(),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "No, but continue without it".to_string(),
|
||||
decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
shortcuts: decline_shortcuts,
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Cancel this request".to_string(),
|
||||
decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel),
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('c'))],
|
||||
shortcuts: cancel_shortcuts,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -575,9 +600,41 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use codex_core::protocol::NetworkApprovalProtocol;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_overlay(
|
||||
request: ApprovalRequest,
|
||||
app_event_tx: AppEventSender,
|
||||
features: Features,
|
||||
) -> ApprovalOverlay {
|
||||
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
make_overlay_with_keymap(
|
||||
request,
|
||||
app_event_tx,
|
||||
features,
|
||||
keymap.approval,
|
||||
keymap.list,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_overlay_with_keymap(
|
||||
request: ApprovalRequest,
|
||||
app_event_tx: AppEventSender,
|
||||
features: Features,
|
||||
approval_keymap: ApprovalKeymap,
|
||||
list_keymap: ListKeymap,
|
||||
) -> ApprovalOverlay {
|
||||
ApprovalOverlay::new(
|
||||
request,
|
||||
app_event_tx,
|
||||
features,
|
||||
approval_keymap,
|
||||
list_keymap,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
id: "test".to_string(),
|
||||
@@ -588,11 +645,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_elicitation_request() -> ApprovalRequest {
|
||||
ApprovalRequest::McpElicitation {
|
||||
server_name: "test-server".to_string(),
|
||||
request_id: RequestId::String("request-1".to_string()),
|
||||
message: "Need more information".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_aborts_and_clears_queue() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults());
|
||||
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
|
||||
view.enqueue_request(make_exec_request());
|
||||
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
|
||||
assert!(view.queue.is_empty());
|
||||
@@ -603,7 +668,7 @@ mod tests {
|
||||
fn shortcut_triggers_selection() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults());
|
||||
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
|
||||
assert!(!view.is_complete());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
// We expect at least one CodexOp message in the queue.
|
||||
@@ -621,7 +686,7 @@ mod tests {
|
||||
fn exec_prefix_option_emits_execpolicy_amendment() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view = ApprovalOverlay::new(
|
||||
let mut view = make_overlay(
|
||||
ApprovalRequest::Exec {
|
||||
id: "test".to_string(),
|
||||
command: vec!["echo".to_string()],
|
||||
@@ -669,7 +734,7 @@ mod tests {
|
||||
proposed_execpolicy_amendment: None,
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
|
||||
let view = make_overlay(exec_request, tx, Features::with_defaults());
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
|
||||
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
|
||||
|
||||
@@ -694,9 +759,12 @@ mod tests {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
let options = exec_options(
|
||||
Some(ExecPolicyAmendment::new(vec!["curl".to_string()])),
|
||||
Some(&network_context),
|
||||
&Features::with_defaults(),
|
||||
&keymap.approval,
|
||||
);
|
||||
|
||||
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
|
||||
@@ -725,7 +793,7 @@ mod tests {
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec!["curl".into()])),
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
|
||||
let view = make_overlay(exec_request, tx, Features::with_defaults());
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100)));
|
||||
view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf);
|
||||
|
||||
@@ -749,6 +817,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_a_opens_fullscreen() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
|
||||
|
||||
view.handle_key_event(KeyEvent::new(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
));
|
||||
|
||||
let mut saw_fullscreen = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if matches!(ev, AppEvent::FullScreenApprovalRequest(_)) {
|
||||
saw_fullscreen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_fullscreen, "expected ctrl+shift+a to open fullscreen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_cell_wraps_with_two_space_indent() {
|
||||
let command = vec![
|
||||
@@ -776,11 +865,85 @@ mod tests {
|
||||
assert_eq!(rendered, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_cancels_mcp_elicitation() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = make_overlay(make_elicitation_request(), tx, Features::with_defaults());
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
let mut decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::CodexOp(Op::ResolveElicitation { decision: d, .. }) = ev {
|
||||
decision = Some(d);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(decision, Some(ElicitationAction::Cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_still_cancels_elicitation_with_custom_overlap() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
keymap.approval.decline = vec![
|
||||
key_hint::plain(KeyCode::Esc),
|
||||
key_hint::plain(KeyCode::Char('n')),
|
||||
];
|
||||
keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))];
|
||||
|
||||
let mut view = make_overlay_with_keymap(
|
||||
make_elicitation_request(),
|
||||
tx,
|
||||
Features::with_defaults(),
|
||||
keymap.approval,
|
||||
keymap.list,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let mut esc_decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::CodexOp(Op::ResolveElicitation { decision, .. }) = ev {
|
||||
esc_decision = Some(decision);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(esc_decision, Some(ElicitationAction::Cancel));
|
||||
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
keymap.approval.decline = vec![
|
||||
key_hint::plain(KeyCode::Esc),
|
||||
key_hint::plain(KeyCode::Char('n')),
|
||||
];
|
||||
keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))];
|
||||
|
||||
let mut view = make_overlay_with_keymap(
|
||||
make_elicitation_request(),
|
||||
tx,
|
||||
Features::with_defaults(),
|
||||
keymap.approval,
|
||||
keymap.list,
|
||||
);
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||
let mut n_decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::CodexOp(Op::ResolveElicitation { decision, .. }) = ev {
|
||||
n_decision = Some(decision);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(n_decision, Some(ElicitationAction::Decline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_sets_last_selected_index_without_dismissing() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults());
|
||||
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -169,6 +169,10 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::bottom_pane::prompt_args::prompt_argument_names;
|
||||
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
|
||||
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::EditorKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::keymap::primary_binding;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::render::renderable::Renderable;
|
||||
@@ -336,6 +340,13 @@ pub(crate) struct ChatComposer {
|
||||
windows_degraded_sandbox_active: bool,
|
||||
status_line_value: Option<Line<'static>>,
|
||||
status_line_enabled: bool,
|
||||
submit_keys: Vec<KeyBinding>,
|
||||
queue_keys: Vec<KeyBinding>,
|
||||
toggle_shortcuts_keys: Vec<KeyBinding>,
|
||||
editor_keymap: EditorKeymap,
|
||||
footer_external_editor_key: Option<KeyBinding>,
|
||||
footer_edit_previous_key: Option<KeyBinding>,
|
||||
footer_show_transcript_key: Option<KeyBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -436,6 +447,16 @@ impl ChatComposer {
|
||||
windows_degraded_sandbox_active: false,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
submit_keys: vec![key_hint::plain(KeyCode::Enter)],
|
||||
queue_keys: vec![key_hint::plain(KeyCode::Tab)],
|
||||
toggle_shortcuts_keys: vec![
|
||||
key_hint::plain(KeyCode::Char('?')),
|
||||
key_hint::shift(KeyCode::Char('?')),
|
||||
],
|
||||
editor_keymap: RuntimeKeymap::defaults().editor,
|
||||
footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
|
||||
footer_edit_previous_key: Some(key_hint::plain(KeyCode::Esc)),
|
||||
footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -495,6 +516,17 @@ impl ChatComposer {
|
||||
self.connectors_enabled = enabled;
|
||||
}
|
||||
|
||||
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||
self.submit_keys = keymap.composer.submit.clone();
|
||||
self.queue_keys = keymap.composer.queue.clone();
|
||||
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
|
||||
self.editor_keymap = keymap.editor.clone();
|
||||
self.textarea.set_keymap_bindings(&self.editor_keymap);
|
||||
self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor);
|
||||
self.footer_edit_previous_key = primary_binding(&keymap.chat.edit_previous_message);
|
||||
self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript);
|
||||
}
|
||||
|
||||
pub fn set_collaboration_mode_indicator(
|
||||
&mut self,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
@@ -1369,7 +1401,7 @@ impl ChatComposer {
|
||||
if self.disable_paste_burst {
|
||||
// When burst detection is disabled, treat IME/non-ASCII input as normal typing.
|
||||
// In particular, do not retro-capture or buffer already-inserted prefix text.
|
||||
self.textarea.input(input);
|
||||
self.textarea.input_with_keymap(input, &self.editor_keymap);
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
@@ -1426,7 +1458,7 @@ impl ChatComposer {
|
||||
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
self.textarea.input(input);
|
||||
self.textarea.input_with_keymap(input, &self.editor_keymap);
|
||||
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
@@ -2521,6 +2553,15 @@ impl ChatComposer {
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
if self.queue_keys.is_pressed(key_event) && self.is_task_running {
|
||||
return self.handle_submission(true);
|
||||
}
|
||||
|
||||
if self.submit_keys.is_pressed(key_event) {
|
||||
let should_queue = !self.steer_enabled;
|
||||
return self.handle_submission(should_queue);
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
@@ -2738,7 +2779,7 @@ impl ChatComposer {
|
||||
Some(self.textarea.element_payloads())
|
||||
};
|
||||
|
||||
self.textarea.input(input);
|
||||
self.textarea.input_with_keymap(input, &self.editor_keymap);
|
||||
|
||||
if let Some(elements_before) = elements_before {
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
@@ -2806,13 +2847,18 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the dedicated shortcut-overlay toggle key(s).
|
||||
///
|
||||
/// This only toggles when the composer is empty and no paste burst is in
|
||||
/// progress, so typing/pasting `?` still inserts text instead of opening
|
||||
/// help. The bound key list intentionally supports terminal-variant
|
||||
/// modifier reporting (for example `?` vs `shift-?`).
|
||||
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
|
||||
if key_event.kind != KeyEventKind::Press {
|
||||
return false;
|
||||
}
|
||||
|
||||
let toggles = matches!(key_event.code, KeyCode::Char('?'))
|
||||
&& !has_ctrl_or_alt(key_event.modifiers)
|
||||
let toggles = self.toggle_shortcuts_keys.is_pressed(*key_event)
|
||||
&& self.is_empty()
|
||||
&& !self.is_in_paste_burst();
|
||||
|
||||
@@ -4535,6 +4581,30 @@ mod tests {
|
||||
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_question_mark_toggles_shortcut_overlay_when_empty() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let (result, needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT));
|
||||
assert_eq!(result, InputResult::None);
|
||||
assert!(needs_redraw, "toggling overlay should request redraw");
|
||||
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
|
||||
}
|
||||
|
||||
/// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut
|
||||
/// overlay; it should be treated as part of the pasted content.
|
||||
#[test]
|
||||
|
||||
@@ -723,10 +723,14 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
if change_mode.width() > 0 {
|
||||
ordered.push(change_mode);
|
||||
}
|
||||
ordered.push(Line::from(""));
|
||||
ordered.push(show_transcript);
|
||||
|
||||
build_columns(ordered)
|
||||
let mut lines = build_columns(ordered);
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(
|
||||
"[tui.keymap] customize shortcuts in ~/.codex/config.toml",
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
|
||||
@@ -16,6 +16,8 @@ use super::selection_popup_common::render_menu_surface;
|
||||
use super::selection_popup_common::wrap_styled_line;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::ListKeymap;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
@@ -120,6 +122,7 @@ pub(crate) struct ListSelectionView {
|
||||
last_selected_actual_idx: Option<usize>,
|
||||
header: Box<dyn Renderable>,
|
||||
initial_selected_idx: Option<usize>,
|
||||
keymap: ListKeymap,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
@@ -130,7 +133,11 @@ impl ListSelectionView {
|
||||
/// When search is enabled, rows without `search_value` will disappear as
|
||||
/// soon as the query is non-empty, which can look like dropped data unless
|
||||
/// callers intentionally populate that field.
|
||||
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
pub fn new(
|
||||
params: SelectionViewParams,
|
||||
app_event_tx: AppEventSender,
|
||||
keymap: ListKeymap,
|
||||
) -> Self {
|
||||
let mut header = params.header;
|
||||
if params.title.is_some() || params.subtitle.is_some() {
|
||||
let title = params.title.map(|title| Line::from(title.bold()));
|
||||
@@ -161,6 +168,7 @@ impl ListSelectionView {
|
||||
last_selected_actual_idx: None,
|
||||
header,
|
||||
initial_selected_idx: params.initial_selected_idx,
|
||||
keymap,
|
||||
};
|
||||
s.apply_filter();
|
||||
s
|
||||
@@ -365,47 +373,46 @@ impl ListSelectionView {
|
||||
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
let is_plain_text_char = matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(_),
|
||||
modifiers,
|
||||
..
|
||||
} if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT)
|
||||
);
|
||||
let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char;
|
||||
let c0_ctrl_p = matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0010}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
);
|
||||
let c0_ctrl_n = matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{000e}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
);
|
||||
|
||||
match key_event {
|
||||
// Some terminals (or configurations) send Control key chords as
|
||||
// C0 control characters without reporting the CONTROL modifier.
|
||||
// Handle fallbacks for Ctrl-P/N here so navigation works everywhere.
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
_ if c0_ctrl_p
|
||||
|| (allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event)) =>
|
||||
{
|
||||
self.move_up()
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
_ if c0_ctrl_n
|
||||
|| (allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event)) =>
|
||||
{
|
||||
self.move_down()
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('\u{0010}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} /* ^P */ => self.move_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} if !self.is_searchable => self.move_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('\u{000e}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} /* ^N */ => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} if !self.is_searchable => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
@@ -413,9 +420,7 @@ impl BottomPaneView for ListSelectionView {
|
||||
self.search_query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
_ if self.keymap.cancel.is_pressed(key_event) => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
@@ -451,11 +456,7 @@ impl BottomPaneView for ListSelectionView {
|
||||
self.accept();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.accept(),
|
||||
_ if self.keymap.accept.is_pressed(key_event) => self.accept(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -686,6 +687,10 @@ mod tests {
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn new_view(params: SelectionViewParams, tx: AppEventSender) -> ListSelectionView {
|
||||
ListSelectionView::new(params, tx, crate::keymap::RuntimeKeymap::defaults().list)
|
||||
}
|
||||
|
||||
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
@@ -705,7 +710,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
ListSelectionView::new(
|
||||
new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
subtitle: subtitle.map(str::to_string),
|
||||
@@ -782,6 +787,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
@@ -824,7 +830,7 @@ mod tests {
|
||||
"Use /setup-default-sandbox".cyan(),
|
||||
" to allow network access.".dim(),
|
||||
]);
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_note: Some(footer_note),
|
||||
@@ -851,7 +857,7 @@ mod tests {
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}];
|
||||
let mut view = ListSelectionView::new(
|
||||
let mut view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
@@ -887,7 +893,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Approval".to_string()),
|
||||
items,
|
||||
@@ -945,7 +951,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Model and Effort".to_string()),
|
||||
items,
|
||||
@@ -979,7 +985,7 @@ mod tests {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items,
|
||||
@@ -1027,7 +1033,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Model and Effort".to_string()),
|
||||
items,
|
||||
@@ -1054,7 +1060,7 @@ mod tests {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items,
|
||||
@@ -1105,6 +1111,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, 96);
|
||||
@@ -1139,6 +1146,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
|
||||
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
@@ -168,6 +169,7 @@ pub(crate) struct BottomPane {
|
||||
queued_user_messages: QueuedUserMessages,
|
||||
context_window_percent: Option<i64>,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
keymap: RuntimeKeymap,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
@@ -200,6 +202,8 @@ impl BottomPane {
|
||||
placeholder_text,
|
||||
disable_paste_burst,
|
||||
);
|
||||
let keymap = RuntimeKeymap::defaults();
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
composer.set_skill_mentions(skills);
|
||||
|
||||
Self {
|
||||
@@ -218,6 +222,7 @@ impl BottomPane {
|
||||
animations_enabled,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
keymap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +252,12 @@ impl BottomPane {
|
||||
self.composer.take_recent_submission_mention_bindings()
|
||||
}
|
||||
|
||||
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||
self.keymap = keymap.clone();
|
||||
self.composer.set_keymap_bindings(keymap);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
|
||||
pub(crate) fn drain_pending_submission_state(&mut self) {
|
||||
let _ = self.take_recent_submission_images_with_placeholders();
|
||||
@@ -676,7 +687,11 @@ impl BottomPane {
|
||||
|
||||
/// Show a generic list selection view with the provided items.
|
||||
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
|
||||
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
|
||||
@@ -695,7 +710,11 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
self.view_stack.pop();
|
||||
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
true
|
||||
}
|
||||
@@ -781,7 +800,13 @@ impl BottomPane {
|
||||
};
|
||||
|
||||
// Otherwise create a new approval modal overlay.
|
||||
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone());
|
||||
let modal = ApprovalOverlay::new(
|
||||
request,
|
||||
self.app_event_tx.clone(),
|
||||
features.clone(),
|
||||
self.keymap.approval.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.pause_status_timer_for_modal();
|
||||
self.push_view(Box::new(modal));
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ expression: terminal.backend()
|
||||
" shift + enter for newline tab to queue message "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + c to exit ctrl + t to view transcript "
|
||||
" "
|
||||
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
assertion_line: 535
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" / for commands ! for shell commands "
|
||||
@@ -8,4 +7,6 @@ expression: terminal.backend()
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc esc to edit previous message "
|
||||
" ctrl + c to exit shift + tab to change mode "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + t to view transcript "
|
||||
" "
|
||||
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "
|
||||
|
||||
@@ -6,5 +6,6 @@ expression: terminal.backend()
|
||||
" shift + enter for newline tab to queue message "
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + c to exit ctrl + t to view transcript "
|
||||
" "
|
||||
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::is_altgr;
|
||||
use crate::keymap::EditorKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement as UserTextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -45,6 +48,7 @@ pub(crate) struct TextArea {
|
||||
elements: Vec<TextElement>,
|
||||
next_element_id: u64,
|
||||
kill_buffer: String,
|
||||
editor_keymap: EditorKeymap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -69,9 +73,14 @@ impl TextArea {
|
||||
elements: Vec::new(),
|
||||
next_element_id: 1,
|
||||
kill_buffer: String::new(),
|
||||
editor_keymap: RuntimeKeymap::defaults().editor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) {
|
||||
self.editor_keymap = keymap.clone();
|
||||
}
|
||||
|
||||
/// Replace the textarea text and clear any existing text elements.
|
||||
pub fn set_text_clearing_elements(&mut self, text: &str) {
|
||||
self.set_text_inner(text, None);
|
||||
@@ -256,241 +265,160 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: KeyEvent) {
|
||||
let keymap = self.editor_keymap.clone();
|
||||
self.input_with_keymap(event, &keymap);
|
||||
}
|
||||
|
||||
pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
|
||||
match event {
|
||||
// Some terminals (or configurations) send Control key chords as
|
||||
// C0 control characters without reporting the CONTROL modifier.
|
||||
// Handle common fallbacks for Ctrl-B/F/P/N here so they don't get
|
||||
// inserted as literal control bytes.
|
||||
KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => {
|
||||
self.move_cursor_up();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => {
|
||||
self.move_cursor_down();
|
||||
}
|
||||
// Keep these as compatibility fallbacks.
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
|
||||
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
|
||||
// for word navigation. Those are handled explicitly below.
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||||
..
|
||||
} => self.insert_str(&c.to_string()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('j' | 'm'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
..
|
||||
} => self.insert_str("\n"),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
self.delete_backward_word()
|
||||
},
|
||||
// Windows AltGr generates ALT|CONTROL; treat as a plain character input unless
|
||||
// we match a specific Control+Alt binding above.
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if is_altgr(modifiers) => self.insert_str(&c.to_string()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => self.delete_backward_word(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.delete_backward(1),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => self.delete_forward_word(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.delete_forward(1),
|
||||
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('w'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.delete_backward_word();
|
||||
}
|
||||
// Meta-b -> move to beginning of previous word
|
||||
// Meta-f -> move to end of next word
|
||||
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('u'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.kill_to_beginning_of_line();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.kill_to_end_of_line();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('y'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.yank();
|
||||
}
|
||||
|
||||
// Cursor movement
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
code: KeyCode::Char('\u{0002}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
} /* ^B */ => {
|
||||
self.move_cursor_left();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
code: KeyCode::Char('\u{0006}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
} /* ^F */ => {
|
||||
self.move_cursor_right();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('\u{0010}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
} /* ^P */ => {
|
||||
self.move_cursor_up();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('\u{000e}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
} /* ^N */ => {
|
||||
self.move_cursor_down();
|
||||
return;
|
||||
}
|
||||
// Some terminals send Alt+Arrow for word-wise movement:
|
||||
// Option/Left -> Alt+Left (previous word start)
|
||||
// Option/Right -> Alt+Right (next word end)
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => {
|
||||
self.move_cursor_up();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_down();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Home,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_beginning_of_line(false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_beginning_of_line(true);
|
||||
}
|
||||
|
||||
KeyEvent {
|
||||
code: KeyCode::End, ..
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(true);
|
||||
}
|
||||
_o => {
|
||||
#[cfg(feature = "debug-logs")]
|
||||
tracing::debug!("Unhandled key event in TextArea: {:?}", _o);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if keymap.insert_newline.is_pressed(event) {
|
||||
self.insert_str("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if keymap.delete_backward_word.is_pressed(event) {
|
||||
self.delete_backward_word();
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows AltGr generates ALT|CONTROL. Preserve typed characters for AltGr users
|
||||
// unless a specific shortcut already matched above.
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} = event
|
||||
&& is_altgr(modifiers)
|
||||
{
|
||||
self.insert_str(&c.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
if keymap.delete_backward.is_pressed(event) {
|
||||
self.delete_backward(1);
|
||||
return;
|
||||
}
|
||||
if keymap.delete_forward_word.is_pressed(event) {
|
||||
self.delete_forward_word();
|
||||
return;
|
||||
}
|
||||
if keymap.delete_forward.is_pressed(event) {
|
||||
self.delete_forward(1);
|
||||
return;
|
||||
}
|
||||
if keymap.kill_line_start.is_pressed(event) {
|
||||
self.kill_to_beginning_of_line();
|
||||
return;
|
||||
}
|
||||
if keymap.kill_line_end.is_pressed(event) {
|
||||
self.kill_to_end_of_line();
|
||||
return;
|
||||
}
|
||||
if keymap.yank.is_pressed(event) {
|
||||
self.yank();
|
||||
return;
|
||||
}
|
||||
if keymap.move_word_left.is_pressed(event) {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
return;
|
||||
}
|
||||
if keymap.move_word_right.is_pressed(event) {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
return;
|
||||
}
|
||||
if keymap.move_left.is_pressed(event) {
|
||||
self.move_cursor_left();
|
||||
return;
|
||||
}
|
||||
if keymap.move_right.is_pressed(event) {
|
||||
self.move_cursor_right();
|
||||
return;
|
||||
}
|
||||
if keymap.move_up.is_pressed(event) {
|
||||
self.move_cursor_up();
|
||||
return;
|
||||
}
|
||||
if keymap.move_down.is_pressed(event) {
|
||||
self.move_cursor_down();
|
||||
return;
|
||||
}
|
||||
if keymap.move_line_start.is_pressed(event) {
|
||||
let move_up_at_bol = matches!(
|
||||
event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
);
|
||||
self.move_cursor_to_beginning_of_line(move_up_at_bol);
|
||||
return;
|
||||
}
|
||||
if keymap.move_line_end.is_pressed(event) {
|
||||
let move_down_at_eol = matches!(
|
||||
event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
);
|
||||
self.move_cursor_to_end_of_line(move_down_at_eol);
|
||||
return;
|
||||
}
|
||||
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
// Insert plain characters (and Shift-modified). Do not insert when ALT is held,
|
||||
// because many terminals map Option/Meta combos to ALT+<char>.
|
||||
self.insert_str(&c.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "debug-logs")]
|
||||
tracing::debug!("Unhandled key event in TextArea: {:?}", event);
|
||||
}
|
||||
|
||||
// ####### Input Functions #######
|
||||
|
||||
@@ -199,6 +199,7 @@ use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::WebSearchCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::multi_agents;
|
||||
use crate::render::Insets;
|
||||
@@ -2690,6 +2691,9 @@ impl ChatWidget {
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
|
||||
widget.bottom_pane.set_keymap_bindings(&keymap);
|
||||
}
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||||
@@ -2853,6 +2857,9 @@ impl ChatWidget {
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
|
||||
widget.bottom_pane.set_keymap_bindings(&keymap);
|
||||
}
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||||
@@ -3005,6 +3012,9 @@ impl ChatWidget {
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
|
||||
widget.bottom_pane.set_keymap_bindings(&keymap);
|
||||
}
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
|
||||
|
||||
@@ -15,7 +15,7 @@ const ALT_PREFIX: &str = "alt + ";
|
||||
const CTRL_PREFIX: &str = "ctrl + ";
|
||||
const SHIFT_PREFIX: &str = "shift + ";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) struct KeyBinding {
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
@@ -31,6 +31,22 @@ impl KeyBinding {
|
||||
&& self.modifiers == event.modifiers
|
||||
&& (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
|
||||
}
|
||||
|
||||
pub(crate) const fn parts(&self) -> (KeyCode, KeyModifiers) {
|
||||
(self.key, self.modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching helpers for one action's keybinding set.
|
||||
pub(crate) trait KeyBindingListExt {
|
||||
/// True when any binding in this set matches `event`.
|
||||
fn is_pressed(&self, event: KeyEvent) -> bool;
|
||||
}
|
||||
|
||||
impl KeyBindingListExt for [KeyBinding] {
|
||||
fn is_pressed(&self, event: KeyEvent) -> bool {
|
||||
self.iter().any(|binding| binding.is_press(event))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn plain(key: KeyCode) -> KeyBinding {
|
||||
@@ -110,3 +126,60 @@ pub(crate) fn is_altgr(mods: KeyModifiers) -> bool {
|
||||
pub(crate) fn is_altgr(_mods: KeyModifiers) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_press_accepts_press_and_repeat_but_rejects_release() {
|
||||
let binding = ctrl(KeyCode::Char('k'));
|
||||
let press = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
|
||||
let repeat = KeyEvent {
|
||||
kind: KeyEventKind::Repeat,
|
||||
..press
|
||||
};
|
||||
let release = KeyEvent {
|
||||
kind: KeyEventKind::Release,
|
||||
..press
|
||||
};
|
||||
let wrong_modifiers = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
|
||||
|
||||
assert!(binding.is_press(press));
|
||||
assert!(binding.is_press(repeat));
|
||||
assert!(!binding.is_press(release));
|
||||
assert!(!binding.is_press(wrong_modifiers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keybinding_list_ext_matches_any_binding() {
|
||||
let bindings = [plain(KeyCode::Char('a')), ctrl(KeyCode::Char('b'))];
|
||||
|
||||
assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
|
||||
assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)));
|
||||
assert!(!bindings.is_pressed(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_sets_both_modifiers() {
|
||||
assert_eq!(
|
||||
ctrl_alt(KeyCode::Char('v')).parts(),
|
||||
(
|
||||
KeyCode::Char('v'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_ctrl_or_alt_checks_supported_modifier_combinations() {
|
||||
assert!(!has_ctrl_or_alt(KeyModifiers::NONE));
|
||||
assert!(has_ctrl_or_alt(KeyModifiers::CONTROL));
|
||||
assert!(has_ctrl_or_alt(KeyModifiers::ALT));
|
||||
|
||||
#[cfg(windows)]
|
||||
assert!(!has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
|
||||
#[cfg(not(windows))]
|
||||
assert!(has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
|
||||
}
|
||||
}
|
||||
|
||||
1128
codex-rs/tui/src/keymap.rs
Normal file
1128
codex-rs/tui/src/keymap.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,7 @@ mod get_git_diff;
|
||||
mod history_cell;
|
||||
pub mod insert_history;
|
||||
mod key_hint;
|
||||
mod keymap;
|
||||
pub mod live_wrap;
|
||||
mod markdown;
|
||||
mod markdown_render;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
//! Authentication step UI and state transitions used by onboarding.
|
||||
//!
|
||||
//! This module owns the auth-step state machine (ChatGPT login/device-code/API
|
||||
//! key), renders the corresponding UI, and handles auth-scoped keyboard input.
|
||||
//! It intentionally does not decide onboarding flow completion; the enclosing
|
||||
//! onboarding screen coordinates step progression.
|
||||
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use codex_core::AuthManager;
|
||||
@@ -35,6 +42,11 @@ use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::OnboardingKeymap;
|
||||
use crate::keymap::primary_binding;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
@@ -100,55 +112,58 @@ impl KeyboardHandler for AuthModeWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.move_highlight(-1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.move_highlight(1);
|
||||
}
|
||||
KeyCode::Char('1') => {
|
||||
self.select_option_by_index(0);
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.select_option_by_index(1);
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
self.select_option_by_index(2);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
||||
match sign_in_state {
|
||||
SignInState::PickMode => {
|
||||
self.handle_sign_in_option(self.highlighted_mode);
|
||||
}
|
||||
SignInState::ChatGptSuccessMessage => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
}
|
||||
_ => {}
|
||||
if self.onboarding_keymap.move_up.is_pressed(key_event) {
|
||||
self.move_highlight(-1);
|
||||
return;
|
||||
}
|
||||
if self.onboarding_keymap.move_down.is_pressed(key_event) {
|
||||
self.move_highlight(1);
|
||||
return;
|
||||
}
|
||||
if self.onboarding_keymap.select_first.is_pressed(key_event) {
|
||||
self.select_option_by_index(0);
|
||||
return;
|
||||
}
|
||||
if self.onboarding_keymap.select_second.is_pressed(key_event) {
|
||||
self.select_option_by_index(1);
|
||||
return;
|
||||
}
|
||||
if self.onboarding_keymap.select_third.is_pressed(key_event) {
|
||||
self.select_option_by_index(2);
|
||||
return;
|
||||
}
|
||||
if self.onboarding_keymap.confirm.is_pressed(key_event) {
|
||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
||||
match sign_in_state {
|
||||
SignInState::PickMode => {
|
||||
self.handle_sign_in_option(self.highlighted_mode);
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
tracing::info!("Esc pressed");
|
||||
let mut sign_in_state = self.sign_in_state.write().unwrap();
|
||||
match &*sign_in_state {
|
||||
SignInState::ChatGptContinueInBrowser(_) => {
|
||||
*sign_in_state = SignInState::PickMode;
|
||||
drop(sign_in_state);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
SignInState::ChatGptDeviceCode(state) => {
|
||||
if let Some(cancel) = &state.cancel {
|
||||
cancel.notify_one();
|
||||
}
|
||||
*sign_in_state = SignInState::PickMode;
|
||||
drop(sign_in_state);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
_ => {}
|
||||
SignInState::ChatGptSuccessMessage => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.onboarding_keymap.cancel.is_pressed(key_event) {
|
||||
tracing::info!("Cancel onboarding auth step");
|
||||
let mut sign_in_state = self.sign_in_state.write().unwrap();
|
||||
match &*sign_in_state {
|
||||
SignInState::ChatGptContinueInBrowser(_) => {
|
||||
*sign_in_state = SignInState::PickMode;
|
||||
drop(sign_in_state);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
SignInState::ChatGptDeviceCode(state) => {
|
||||
if let Some(cancel) = &state.cancel {
|
||||
cancel.notify_one();
|
||||
}
|
||||
*sign_in_state = SignInState::PickMode;
|
||||
drop(sign_in_state);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,9 +185,32 @@ pub(crate) struct AuthModeWidget {
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
pub forced_login_method: Option<ForcedLoginMethod>,
|
||||
pub animations_enabled: bool,
|
||||
pub onboarding_keymap: OnboardingKeymap,
|
||||
}
|
||||
|
||||
impl AuthModeWidget {
|
||||
/// Returns whether the auth flow is currently in API-key entry mode.
|
||||
pub(crate) fn is_api_key_entry_active(&self) -> bool {
|
||||
self.sign_in_state
|
||||
.read()
|
||||
.is_ok_and(|guard| matches!(&*guard, SignInState::ApiKeyEntry(_)))
|
||||
}
|
||||
|
||||
/// Returns whether the API-key entry field currently contains any text.
|
||||
pub(crate) fn api_key_entry_has_text(&self) -> bool {
|
||||
self.sign_in_state.read().is_ok_and(
|
||||
|guard| matches!(&*guard, SignInState::ApiKeyEntry(state) if !state.value.is_empty()),
|
||||
)
|
||||
}
|
||||
|
||||
fn confirm_binding(&self) -> KeyBinding {
|
||||
primary_binding(&self.onboarding_keymap.confirm).unwrap_or(key_hint::plain(KeyCode::Enter))
|
||||
}
|
||||
|
||||
fn cancel_binding(&self) -> KeyBinding {
|
||||
primary_binding(&self.onboarding_keymap.cancel).unwrap_or(key_hint::plain(KeyCode::Esc))
|
||||
}
|
||||
|
||||
fn is_api_login_allowed(&self) -> bool {
|
||||
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt))
|
||||
}
|
||||
@@ -342,11 +380,11 @@ impl AuthModeWidget {
|
||||
);
|
||||
lines.push("".into());
|
||||
}
|
||||
lines.push(
|
||||
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
|
||||
// But leaving this for a future cleanup.
|
||||
" Press Enter to continue".dim().into(),
|
||||
);
|
||||
lines.push(Line::from(vec![
|
||||
" Press ".dim(),
|
||||
self.confirm_binding().into(),
|
||||
" to continue".dim(),
|
||||
]));
|
||||
if let Some(err) = &self.error {
|
||||
lines.push("".into());
|
||||
lines.push(err.as_str().red().into());
|
||||
@@ -381,14 +419,20 @@ impl AuthModeWidget {
|
||||
]));
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(vec![
|
||||
" On a remote or headless machine? Press Esc and choose ".into(),
|
||||
" On a remote or headless machine? Press ".into(),
|
||||
self.cancel_binding().into(),
|
||||
" and choose ".into(),
|
||||
"Sign in with Device Code".cyan(),
|
||||
".".into(),
|
||||
]));
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
lines.push(" Press Esc to cancel".dim().into());
|
||||
lines.push(Line::from(vec![
|
||||
" Press ".dim(),
|
||||
self.cancel_binding().into(),
|
||||
" to cancel".dim(),
|
||||
]));
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
@@ -417,7 +461,11 @@ impl AuthModeWidget {
|
||||
])
|
||||
.dim(),
|
||||
"".into(),
|
||||
" Press Enter to continue".fg(Color::Cyan).into(),
|
||||
Line::from(vec![
|
||||
" Press ".fg(Color::Cyan),
|
||||
self.confirm_binding().into(),
|
||||
" to continue".fg(Color::Cyan),
|
||||
]),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
@@ -496,8 +544,16 @@ impl AuthModeWidget {
|
||||
.render(input_area, buf);
|
||||
|
||||
let mut footer_lines: Vec<Line> = vec![
|
||||
" Press Enter to save".dim().into(),
|
||||
" Press Esc to go back".dim().into(),
|
||||
Line::from(vec![
|
||||
" Press ".dim(),
|
||||
self.confirm_binding().into(),
|
||||
" to save".dim(),
|
||||
]),
|
||||
Line::from(vec![
|
||||
" Press ".dim(),
|
||||
self.cancel_binding().into(),
|
||||
" to go back".dim(),
|
||||
]),
|
||||
];
|
||||
if let Some(error) = &self.error {
|
||||
footer_lines.push("".into());
|
||||
@@ -515,46 +571,46 @@ impl AuthModeWidget {
|
||||
{
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
if let SignInState::ApiKeyEntry(state) = &mut *guard {
|
||||
match key_event.code {
|
||||
KeyCode::Esc => {
|
||||
*guard = SignInState::PickMode;
|
||||
self.error = None;
|
||||
if self.onboarding_keymap.cancel.is_pressed(*key_event) {
|
||||
*guard = SignInState::PickMode;
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
} else if self.onboarding_keymap.confirm.is_pressed(*key_event) {
|
||||
let trimmed = state.value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
self.error = Some("API key cannot be empty".to_string());
|
||||
should_request_frame = true;
|
||||
} else {
|
||||
should_save = Some(trimmed);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let trimmed = state.value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
self.error = Some("API key cannot be empty".to_string());
|
||||
} else {
|
||||
match key_event.code {
|
||||
KeyCode::Backspace => {
|
||||
if state.prepopulated_from_env {
|
||||
state.value.clear();
|
||||
state.prepopulated_from_env = false;
|
||||
} else {
|
||||
state.value.pop();
|
||||
}
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
} else {
|
||||
should_save = Some(trimmed);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.prepopulated_from_env {
|
||||
state.value.clear();
|
||||
state.prepopulated_from_env = false;
|
||||
} else {
|
||||
state.value.pop();
|
||||
KeyCode::Char(c)
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
if state.prepopulated_from_env {
|
||||
state.value.clear();
|
||||
state.prepopulated_from_env = false;
|
||||
}
|
||||
state.value.push(c);
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
}
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
_ => {}
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
if state.prepopulated_from_env {
|
||||
state.value.clear();
|
||||
state.prepopulated_from_env = false;
|
||||
}
|
||||
state.value.push(c);
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// handled; let guard drop before potential save
|
||||
} else {
|
||||
@@ -812,6 +868,7 @@ mod tests {
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
|
||||
animations_enabled: true,
|
||||
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
};
|
||||
(widget, codex_home)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,11 @@ pub(super) fn render_device_code_login(
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
lines.push(" Press Esc to cancel".dim().into());
|
||||
lines.push(Line::from(vec![
|
||||
" Press ".dim(),
|
||||
widget.cancel_binding().into(),
|
||||
" to cancel".dim(),
|
||||
]));
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
//! Onboarding screen orchestration and top-level keyboard routing.
|
||||
//!
|
||||
//! The onboarding flow is a small state machine over visible steps
|
||||
//! (welcome/auth/trust). This module decides which step receives key/paste
|
||||
//! events and enforces flow-level safety rules that cut across individual step
|
||||
//! widgets.
|
||||
//!
|
||||
//! In particular, onboarding quit handling has a text-entry guard for API-key
|
||||
//! input: printable quit bindings are treated as text input while the user is
|
||||
//! editing a non-empty API-key field, while control/alt chords remain available
|
||||
//! as explicit exit shortcuts.
|
||||
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::config::Config;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -7,6 +19,7 @@ use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
@@ -17,6 +30,9 @@ use ratatui::widgets::WidgetRef;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::OnboardingKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::onboarding::auth::AuthModeWidget;
|
||||
use crate::onboarding::auth::SignInOption;
|
||||
use crate::onboarding::auth::SignInState;
|
||||
@@ -56,6 +72,7 @@ pub(crate) trait StepStateProvider {
|
||||
pub(crate) struct OnboardingScreen {
|
||||
request_frame: FrameRequester,
|
||||
steps: Vec<Step>,
|
||||
onboarding_keymap: OnboardingKeymap,
|
||||
is_done: bool,
|
||||
should_exit: bool,
|
||||
}
|
||||
@@ -73,6 +90,14 @@ pub(crate) struct OnboardingResult {
|
||||
pub should_exit: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct ApiKeyEntryContext {
|
||||
/// True when onboarding is currently rendering the API-key entry state.
|
||||
active: bool,
|
||||
/// True when the API-key input field currently contains user text.
|
||||
has_text: bool,
|
||||
}
|
||||
|
||||
impl OnboardingScreen {
|
||||
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
|
||||
let OnboardingScreenArgs {
|
||||
@@ -87,11 +112,22 @@ impl OnboardingScreen {
|
||||
let forced_login_method = config.forced_login_method;
|
||||
let codex_home = config.codex_home.clone();
|
||||
let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode;
|
||||
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).unwrap_or_else(|err| {
|
||||
tracing::warn!(
|
||||
"Invalid `tui.keymap` configuration during onboarding: {err}. \
|
||||
Falling back to default onboarding keymap values. \
|
||||
Fix `~/.codex/config.toml`.\n\
|
||||
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml"
|
||||
);
|
||||
RuntimeKeymap::defaults()
|
||||
});
|
||||
let onboarding_keymap = runtime_keymap.onboarding;
|
||||
let mut steps: Vec<Step> = Vec::new();
|
||||
steps.push(Step::Welcome(WelcomeWidget::new(
|
||||
!matches!(login_status, LoginStatus::NotAuthenticated),
|
||||
tui.frame_requester(),
|
||||
config.animations,
|
||||
onboarding_keymap.clone(),
|
||||
)));
|
||||
if show_login_screen {
|
||||
let highlighted_mode = match forced_login_method {
|
||||
@@ -110,6 +146,7 @@ impl OnboardingScreen {
|
||||
forced_chatgpt_workspace_id,
|
||||
forced_login_method,
|
||||
animations_enabled: config.animations,
|
||||
onboarding_keymap: onboarding_keymap.clone(),
|
||||
}))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -127,12 +164,14 @@ impl OnboardingScreen {
|
||||
selection: None,
|
||||
highlighted,
|
||||
error: None,
|
||||
onboarding_keymap: onboarding_keymap.clone(),
|
||||
}))
|
||||
}
|
||||
// TODO: add git warning.
|
||||
Self {
|
||||
request_frame: tui.frame_requester(),
|
||||
steps,
|
||||
onboarding_keymap,
|
||||
is_done: false,
|
||||
should_exit: false,
|
||||
}
|
||||
@@ -199,45 +238,39 @@ impl OnboardingScreen {
|
||||
self.should_exit
|
||||
}
|
||||
|
||||
fn is_api_key_entry_active(&self) -> bool {
|
||||
self.steps.iter().any(|step| {
|
||||
if let Step::Auth(widget) = step {
|
||||
return widget
|
||||
.sign_in_state
|
||||
.read()
|
||||
.is_ok_and(|g| matches!(&*g, SignInState::ApiKeyEntry(_)));
|
||||
}
|
||||
false
|
||||
})
|
||||
fn api_key_entry_context(&self) -> ApiKeyEntryContext {
|
||||
self.steps
|
||||
.iter()
|
||||
.find_map(|step| {
|
||||
if let Step::Auth(widget) = step {
|
||||
Some(ApiKeyEntryContext {
|
||||
active: widget.is_api_key_entry_active(),
|
||||
has_text: widget.api_key_entry_has_text(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for OnboardingScreen {
|
||||
/// Route key events to onboarding steps while preserving text-entry safety.
|
||||
///
|
||||
/// In API-key entry mode, printable quit bindings are suppressed only after
|
||||
/// the user has started typing in the API-key field. This keeps custom
|
||||
/// printable quit keys usable on an empty field while protecting in-progress
|
||||
/// text entry from accidental exits. Control/alt quit chords still work as
|
||||
/// emergency exits.
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
let is_api_key_entry_active = self.is_api_key_entry_active();
|
||||
let should_quit = match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => true,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => !is_api_key_entry_active,
|
||||
_ => false,
|
||||
};
|
||||
let api_key_entry_context = self.api_key_entry_context();
|
||||
let should_quit = key_event.kind == KeyEventKind::Press
|
||||
&& self.onboarding_keymap.quit.is_pressed(key_event)
|
||||
&& !suppress_quit_while_typing_api_key(key_event, api_key_entry_context);
|
||||
if should_quit {
|
||||
if self.is_auth_in_progress() {
|
||||
// If the user cancels the auth menu, exit the app rather than
|
||||
@@ -282,6 +315,24 @@ impl KeyboardHandler for OnboardingScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when a quit shortcut should be ignored as text input.
|
||||
///
|
||||
/// This only applies while API-key entry is active and the key is a printable
|
||||
/// character without control/alt modifiers and there is already text in the
|
||||
/// input field. Empty input intentionally does not trigger suppression so
|
||||
/// printable custom quit bindings can still exit onboarding.
|
||||
fn suppress_quit_while_typing_api_key(
|
||||
key_event: KeyEvent,
|
||||
api_key_entry_context: ApiKeyEntryContext,
|
||||
) -> bool {
|
||||
api_key_entry_context.active
|
||||
&& api_key_entry_context.has_text
|
||||
&& matches!(key_event.code, KeyCode::Char(_))
|
||||
&& !key_event
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
}
|
||||
|
||||
impl WidgetRef for &OnboardingScreen {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
@@ -461,3 +512,60 @@ pub(crate) async fn run_onboarding_app(
|
||||
should_exit: onboarding_screen.should_exit(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ApiKeyEntryContext;
|
||||
use super::suppress_quit_while_typing_api_key;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
#[test]
|
||||
fn suppresses_printable_custom_quit_key_during_api_key_entry() {
|
||||
let suppressed = suppress_quit_while_typing_api_key(
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
|
||||
ApiKeyEntryContext {
|
||||
active: true,
|
||||
has_text: true,
|
||||
},
|
||||
);
|
||||
assert!(suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_suppress_printable_quit_key_when_api_key_input_is_empty() {
|
||||
let suppressed = suppress_quit_while_typing_api_key(
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
|
||||
ApiKeyEntryContext {
|
||||
active: true,
|
||||
has_text: false,
|
||||
},
|
||||
);
|
||||
assert!(!suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_suppress_control_quit_key_during_api_key_entry() {
|
||||
let suppressed = suppress_quit_while_typing_api_key(
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
ApiKeyEntryContext {
|
||||
active: true,
|
||||
has_text: true,
|
||||
},
|
||||
);
|
||||
assert!(!suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_suppress_when_not_in_api_key_entry() {
|
||||
let suppressed = suppress_quit_while_typing_api_key(
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
|
||||
ApiKeyEntryContext {
|
||||
active: false,
|
||||
has_text: true,
|
||||
},
|
||||
);
|
||||
assert!(!suppressed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::OnboardingKeymap;
|
||||
use crate::keymap::primary_binding;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::render::Insets;
|
||||
@@ -32,6 +35,7 @@ pub(crate) struct TrustDirectoryWidget {
|
||||
pub selection: Option<TrustDirectorySelection>,
|
||||
pub highlighted: TrustDirectorySelection,
|
||||
pub error: Option<String>,
|
||||
pub onboarding_keymap: OnboardingKeymap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -88,7 +92,9 @@ impl WidgetRef for &TrustDirectoryWidget {
|
||||
column.push(
|
||||
Line::from(vec![
|
||||
"Press ".dim(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
primary_binding(&self.onboarding_keymap.confirm)
|
||||
.unwrap_or(key_hint::plain(KeyCode::Enter))
|
||||
.into(),
|
||||
if self.show_windows_create_sandbox_hint {
|
||||
" to continue and create a sandbox...".dim()
|
||||
} else {
|
||||
@@ -108,20 +114,22 @@ impl KeyboardHandler for TrustDirectoryWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.highlighted = TrustDirectorySelection::Trust;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.highlighted = TrustDirectorySelection::Quit;
|
||||
}
|
||||
KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(),
|
||||
KeyCode::Char('2') | KeyCode::Char('n') => self.handle_quit(),
|
||||
KeyCode::Enter => match self.highlighted {
|
||||
if self.onboarding_keymap.move_up.is_pressed(key_event) {
|
||||
self.highlighted = TrustDirectorySelection::Trust;
|
||||
} else if self.onboarding_keymap.move_down.is_pressed(key_event) {
|
||||
self.highlighted = TrustDirectorySelection::Quit;
|
||||
} else if self.onboarding_keymap.select_first.is_pressed(key_event) {
|
||||
self.handle_trust();
|
||||
} else if self.onboarding_keymap.select_second.is_pressed(key_event)
|
||||
|| self.onboarding_keymap.quit.is_pressed(key_event)
|
||||
|| self.onboarding_keymap.cancel.is_pressed(key_event)
|
||||
{
|
||||
self.handle_quit();
|
||||
} else if self.onboarding_keymap.confirm.is_pressed(key_event) {
|
||||
match self.highlighted {
|
||||
TrustDirectorySelection::Trust => self.handle_trust(),
|
||||
TrustDirectorySelection::Quit => self.handle_quit(),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +191,7 @@ mod tests {
|
||||
selection: None,
|
||||
highlighted: TrustDirectorySelection::Quit,
|
||||
error: None,
|
||||
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
};
|
||||
|
||||
let release = KeyEvent {
|
||||
@@ -208,6 +217,7 @@ mod tests {
|
||||
selection: None,
|
||||
highlighted: TrustDirectorySelection::Trust,
|
||||
error: None,
|
||||
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
};
|
||||
|
||||
let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal");
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
@@ -14,6 +12,9 @@ use ratatui::widgets::Wrap;
|
||||
use std::cell::Cell;
|
||||
|
||||
use crate::ascii_animation::AsciiAnimation;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::OnboardingKeymap;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::tui::FrameRequester;
|
||||
@@ -27,17 +28,21 @@ pub(crate) struct WelcomeWidget {
|
||||
pub is_logged_in: bool,
|
||||
animation: AsciiAnimation,
|
||||
animations_enabled: bool,
|
||||
toggle_animation_keys: Vec<KeyBinding>,
|
||||
layout_area: Cell<Option<Rect>>,
|
||||
}
|
||||
|
||||
impl KeyboardHandler for WelcomeWidget {
|
||||
/// Rotate the welcome animation when the configured toggle shortcut fires.
|
||||
///
|
||||
/// The key list comes from runtime keymap defaults/overrides and can
|
||||
/// include compatibility variants for terminals that report modifier bits
|
||||
/// differently.
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if !self.animations_enabled {
|
||||
return;
|
||||
}
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& key_event.code == KeyCode::Char('.')
|
||||
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
if key_event.kind == KeyEventKind::Press && self.toggle_animation_keys.is_pressed(key_event)
|
||||
{
|
||||
tracing::warn!("Welcome background to press '.'");
|
||||
let _ = self.animation.pick_random_variant();
|
||||
@@ -50,11 +55,13 @@ impl WelcomeWidget {
|
||||
is_logged_in: bool,
|
||||
request_frame: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
onboarding_keymap: OnboardingKeymap,
|
||||
) -> Self {
|
||||
Self {
|
||||
is_logged_in,
|
||||
animation: AsciiAnimation::new(request_frame),
|
||||
animations_enabled,
|
||||
toggle_animation_keys: onboarding_keymap.toggle_animation,
|
||||
layout_area: Cell::new(None),
|
||||
}
|
||||
}
|
||||
@@ -108,6 +115,8 @@ impl StepStateProvider for WelcomeWidget {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -128,7 +137,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn welcome_renders_animation_on_first_draw() {
|
||||
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
|
||||
let widget = WelcomeWidget::new(
|
||||
false,
|
||||
FrameRequester::test_dummy(),
|
||||
true,
|
||||
crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
);
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let frame_lines = widget.animation.current_frame().lines().count() as u16;
|
||||
@@ -140,7 +154,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn welcome_skips_animation_below_height_breakpoint() {
|
||||
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
|
||||
let widget = WelcomeWidget::new(
|
||||
false,
|
||||
FrameRequester::test_dummy(),
|
||||
true,
|
||||
crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
);
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&widget).render(area, &mut buf);
|
||||
@@ -155,6 +174,9 @@ mod tests {
|
||||
is_logged_in: false,
|
||||
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
|
||||
animations_enabled: true,
|
||||
toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults()
|
||||
.onboarding
|
||||
.toggle_animation,
|
||||
layout_area: Cell::new(None),
|
||||
};
|
||||
|
||||
@@ -167,4 +189,29 @@ mod tests {
|
||||
"expected ctrl+. to switch welcome animation variant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_dot_changes_animation_variant() {
|
||||
let mut widget = WelcomeWidget {
|
||||
is_logged_in: false,
|
||||
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
|
||||
animations_enabled: true,
|
||||
toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults()
|
||||
.onboarding
|
||||
.toggle_animation,
|
||||
layout_area: Cell::new(None),
|
||||
};
|
||||
|
||||
let before = widget.animation.current_frame();
|
||||
widget.handle_key_event(KeyEvent::new(
|
||||
KeyCode::Char('.'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
));
|
||||
let after = widget.animation.current_frame();
|
||||
|
||||
assert_ne!(
|
||||
before, after,
|
||||
"expected ctrl+shift+. to switch welcome animation variant"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,15 @@ use std::sync::Arc;
|
||||
use crate::chatwidget::ActiveCellTranscriptKey;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::PagerKeymap;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::Cell;
|
||||
@@ -51,19 +51,24 @@ pub(crate) enum Overlay {
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>) -> Self {
|
||||
Self::Transcript(TranscriptOverlay::new(cells))
|
||||
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
|
||||
Self::Transcript(TranscriptOverlay::new(cells, keymap))
|
||||
}
|
||||
|
||||
pub(crate) fn new_static_with_lines(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||
Self::Static(StaticOverlay::with_title(lines, title))
|
||||
pub(crate) fn new_static_with_lines(
|
||||
lines: Vec<Line<'static>>,
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
) -> Self {
|
||||
Self::Static(StaticOverlay::with_title(lines, title, keymap))
|
||||
}
|
||||
|
||||
pub(crate) fn new_static_with_renderables(
|
||||
renderables: Vec<Box<dyn Renderable>>,
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
) -> Self {
|
||||
Self::Static(StaticOverlay::with_renderables(renderables, title))
|
||||
Self::Static(StaticOverlay::with_renderables(renderables, title, keymap))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
@@ -81,37 +86,12 @@ impl Overlay {
|
||||
}
|
||||
}
|
||||
|
||||
const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up);
|
||||
const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down);
|
||||
const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k'));
|
||||
const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j'));
|
||||
const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp);
|
||||
const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown);
|
||||
const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' '));
|
||||
const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' '));
|
||||
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
|
||||
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
|
||||
const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left);
|
||||
const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right);
|
||||
const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f'));
|
||||
const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d'));
|
||||
const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b'));
|
||||
const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u'));
|
||||
const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q'));
|
||||
const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc);
|
||||
const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter);
|
||||
const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t'));
|
||||
const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c'));
|
||||
|
||||
// Common pager navigation hints rendered on the first line
|
||||
const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[
|
||||
(&[KEY_UP, KEY_DOWN], "to scroll"),
|
||||
(&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"),
|
||||
(&[KEY_HOME, KEY_END], "to jump"),
|
||||
];
|
||||
fn first_or_empty(bindings: &[KeyBinding]) -> Vec<KeyBinding> {
|
||||
bindings.first().copied().into_iter().collect()
|
||||
}
|
||||
|
||||
// Render a single line of key hints from (key(s), description) pairs.
|
||||
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) {
|
||||
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &str)]) {
|
||||
let mut spans: Vec<Span<'static>> = vec![" ".into()];
|
||||
let mut first = true;
|
||||
for (keys, desc) in pairs {
|
||||
@@ -136,6 +116,7 @@ struct PagerView {
|
||||
renderables: Vec<Box<dyn Renderable>>,
|
||||
scroll_offset: usize,
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
last_content_height: Option<usize>,
|
||||
last_rendered_height: Option<usize>,
|
||||
/// If set, on next render ensure this chunk is visible.
|
||||
@@ -143,11 +124,17 @@ struct PagerView {
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
fn new(renderables: Vec<Box<dyn Renderable>>, title: String, scroll_offset: usize) -> Self {
|
||||
fn new(
|
||||
renderables: Vec<Box<dyn Renderable>>,
|
||||
title: String,
|
||||
scroll_offset: usize,
|
||||
keymap: PagerKeymap,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderables,
|
||||
scroll_offset,
|
||||
title,
|
||||
keymap,
|
||||
last_content_height: None,
|
||||
last_rendered_height: None,
|
||||
pending_scroll_chunk: None,
|
||||
@@ -260,37 +247,34 @@ impl PagerView {
|
||||
|
||||
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
|
||||
match key_event {
|
||||
e if KEY_UP.is_press(e) || KEY_K.is_press(e) => {
|
||||
e if self.keymap.scroll_up.is_pressed(e) => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => {
|
||||
e if self.keymap.scroll_down.is_pressed(e) => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
}
|
||||
e if KEY_PAGE_UP.is_press(e)
|
||||
|| KEY_SHIFT_SPACE.is_press(e)
|
||||
|| KEY_CTRL_B.is_press(e) =>
|
||||
{
|
||||
e if self.keymap.page_up.is_pressed(e) => {
|
||||
let page_height = self.page_height(tui.terminal.viewport_area);
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(page_height);
|
||||
}
|
||||
e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) || KEY_CTRL_F.is_press(e) => {
|
||||
e if self.keymap.page_down.is_pressed(e) => {
|
||||
let page_height = self.page_height(tui.terminal.viewport_area);
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(page_height);
|
||||
}
|
||||
e if KEY_CTRL_D.is_press(e) => {
|
||||
e if self.keymap.half_page_down.is_pressed(e) => {
|
||||
let area = self.content_area(tui.terminal.viewport_area);
|
||||
let half_page = (area.height as usize).saturating_add(1) / 2;
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(half_page);
|
||||
}
|
||||
e if KEY_CTRL_U.is_press(e) => {
|
||||
e if self.keymap.half_page_up.is_pressed(e) => {
|
||||
let area = self.content_area(tui.terminal.viewport_area);
|
||||
let half_page = (area.height as usize).saturating_add(1) / 2;
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(half_page);
|
||||
}
|
||||
e if KEY_HOME.is_press(e) => {
|
||||
e if self.keymap.jump_top.is_pressed(e) => {
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
e if KEY_END.is_press(e) => {
|
||||
e if self.keymap.jump_bottom.is_pressed(e) => {
|
||||
self.scroll_offset = usize::MAX;
|
||||
}
|
||||
_ => {
|
||||
@@ -453,12 +437,13 @@ impl TranscriptOverlay {
|
||||
///
|
||||
/// This overlay does not own the "active cell"; callers may optionally append a live tail via
|
||||
/// `sync_live_tail` during draws to reflect in-flight activity.
|
||||
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
|
||||
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(
|
||||
Self::render_cells(&transcript_cells, None),
|
||||
"T R A N S C R I P T".to_string(),
|
||||
usize::MAX,
|
||||
keymap,
|
||||
),
|
||||
cells: transcript_cells,
|
||||
highlight_cell: None,
|
||||
@@ -656,15 +641,51 @@ impl TranscriptOverlay {
|
||||
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
|
||||
let line1 = Rect::new(area.x, area.y, area.width, 1);
|
||||
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
|
||||
render_key_hints(line1, buf, PAGER_KEY_HINTS);
|
||||
render_key_hints(
|
||||
line1,
|
||||
buf,
|
||||
&[
|
||||
(
|
||||
first_or_empty(&self.view.keymap.scroll_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.scroll_down))
|
||||
.collect(),
|
||||
"to scroll",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.page_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.page_down))
|
||||
.collect(),
|
||||
"to page",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.jump_top)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.jump_bottom))
|
||||
.collect(),
|
||||
"to jump",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
|
||||
let mut pairs: Vec<(Vec<KeyBinding>, &str)> =
|
||||
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
|
||||
if self.highlight_cell.is_some() {
|
||||
pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev"));
|
||||
pairs.push((&[KEY_RIGHT], "to edit next"));
|
||||
pairs.push((&[KEY_ENTER], "to edit message"));
|
||||
pairs.push((
|
||||
self.view.keymap.edit_previous_message.clone(),
|
||||
"to edit prev",
|
||||
));
|
||||
pairs.push((self.view.keymap.edit_next_message.clone(), "to edit next"));
|
||||
pairs.push((
|
||||
self.view.keymap.confirm_edit_message.clone(),
|
||||
"to edit message",
|
||||
));
|
||||
} else {
|
||||
pairs.push((&[KEY_ESC], "to edit prev"));
|
||||
pairs.push((
|
||||
first_or_empty(&self.view.keymap.edit_previous_message),
|
||||
"to edit prev",
|
||||
));
|
||||
}
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
@@ -682,7 +703,9 @@ impl TranscriptOverlay {
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => match key_event {
|
||||
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => {
|
||||
e if self.view.keymap.close.is_pressed(e)
|
||||
|| self.view.keymap.close_transcript.is_pressed(e) =>
|
||||
{
|
||||
self.is_done = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -713,14 +736,26 @@ pub(crate) struct StaticOverlay {
|
||||
}
|
||||
|
||||
impl StaticOverlay {
|
||||
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||
pub(crate) fn with_title(
|
||||
lines: Vec<Line<'static>>,
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
) -> Self {
|
||||
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
||||
Self::with_renderables(vec![Box::new(CachedRenderable::new(paragraph))], title)
|
||||
Self::with_renderables(
|
||||
vec![Box::new(CachedRenderable::new(paragraph))],
|
||||
title,
|
||||
keymap,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn with_renderables(renderables: Vec<Box<dyn Renderable>>, title: String) -> Self {
|
||||
pub(crate) fn with_renderables(
|
||||
renderables: Vec<Box<dyn Renderable>>,
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(renderables, title, 0),
|
||||
view: PagerView::new(renderables, title, 0, keymap),
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
@@ -728,8 +763,35 @@ impl StaticOverlay {
|
||||
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
|
||||
let line1 = Rect::new(area.x, area.y, area.width, 1);
|
||||
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
|
||||
render_key_hints(line1, buf, PAGER_KEY_HINTS);
|
||||
let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
|
||||
render_key_hints(
|
||||
line1,
|
||||
buf,
|
||||
&[
|
||||
(
|
||||
first_or_empty(&self.view.keymap.scroll_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.scroll_down))
|
||||
.collect(),
|
||||
"to scroll",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.page_up)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.page_down))
|
||||
.collect(),
|
||||
"to page",
|
||||
),
|
||||
(
|
||||
first_or_empty(&self.view.keymap.jump_top)
|
||||
.into_iter()
|
||||
.chain(first_or_empty(&self.view.keymap.jump_bottom))
|
||||
.collect(),
|
||||
"to jump",
|
||||
),
|
||||
],
|
||||
);
|
||||
let pairs: Vec<(Vec<KeyBinding>, &str)> =
|
||||
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
|
||||
@@ -746,7 +808,7 @@ impl StaticOverlay {
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => match key_event {
|
||||
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => {
|
||||
e if self.view.keymap.close.is_pressed(e) => {
|
||||
self.is_done = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -839,9 +901,34 @@ mod tests {
|
||||
Box::new(Paragraph::new(text)) as Box<dyn Renderable>
|
||||
}
|
||||
|
||||
fn default_pager_keymap() -> crate::keymap::PagerKeymap {
|
||||
crate::keymap::RuntimeKeymap::defaults().pager
|
||||
}
|
||||
|
||||
fn transcript_overlay(cells: Vec<Arc<dyn HistoryCell>>) -> TranscriptOverlay {
|
||||
TranscriptOverlay::new(cells, default_pager_keymap())
|
||||
}
|
||||
|
||||
fn static_overlay(lines: Vec<Line<'static>>, title: &str) -> StaticOverlay {
|
||||
StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap())
|
||||
}
|
||||
|
||||
fn pager_view(
|
||||
renderables: Vec<Box<dyn Renderable>>,
|
||||
title: &str,
|
||||
scroll_offset: usize,
|
||||
) -> PagerView {
|
||||
PagerView::new(
|
||||
renderables,
|
||||
title.to_string(),
|
||||
scroll_offset,
|
||||
default_pager_keymap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_prev_hint_is_visible() {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("hello")],
|
||||
})]);
|
||||
|
||||
@@ -859,7 +946,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn edit_next_hint_is_visible_when_highlighted() {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("hello")],
|
||||
})]);
|
||||
overlay.set_highlight_cell(Some(0));
|
||||
@@ -879,7 +966,7 @@ mod tests {
|
||||
#[test]
|
||||
fn transcript_overlay_snapshot_basic() {
|
||||
// Prepare a transcript overlay with a few lines
|
||||
let mut overlay = TranscriptOverlay::new(vec![
|
||||
let mut overlay = transcript_overlay(vec![
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("alpha")],
|
||||
}),
|
||||
@@ -898,7 +985,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_renders_live_tail() {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("alpha")],
|
||||
})]);
|
||||
overlay.sync_live_tail(
|
||||
@@ -919,7 +1006,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("alpha")],
|
||||
})]);
|
||||
|
||||
@@ -1012,7 +1099,7 @@ mod tests {
|
||||
let exec_cell: Arc<dyn HistoryCell> = Arc::new(exec_cell);
|
||||
cells.push(exec_cell);
|
||||
|
||||
let mut overlay = TranscriptOverlay::new(cells);
|
||||
let mut overlay = transcript_overlay(cells);
|
||||
let area = Rect::new(0, 0, 80, 12);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
@@ -1026,7 +1113,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..20)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
@@ -1053,7 +1140,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_preserves_manual_scroll_position() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..20)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
@@ -1078,9 +1165,9 @@ mod tests {
|
||||
#[test]
|
||||
fn static_overlay_snapshot_basic() {
|
||||
// Prepare a static overlay with a few lines and a title
|
||||
let mut overlay = StaticOverlay::with_title(
|
||||
let mut overlay = static_overlay(
|
||||
vec!["one".into(), "two".into(), "three".into()],
|
||||
"S T A T I C".to_string(),
|
||||
"S T A T I C",
|
||||
);
|
||||
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
@@ -1116,7 +1203,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_paging_is_continuous_and_round_trips() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..50)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
@@ -1184,9 +1271,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn static_overlay_wraps_long_lines() {
|
||||
let mut overlay = StaticOverlay::with_title(
|
||||
let mut overlay = static_overlay(
|
||||
vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()],
|
||||
"S T A T I C".to_string(),
|
||||
"S T A T I C",
|
||||
);
|
||||
let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term");
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
@@ -1196,9 +1283,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_content_height_counts_renderables() {
|
||||
let pv = PagerView::new(
|
||||
let pv = pager_view(
|
||||
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
|
||||
"T".to_string(),
|
||||
"T",
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -1207,13 +1294,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
|
||||
let mut pv = PagerView::new(
|
||||
let mut pv = pager_view(
|
||||
vec![
|
||||
paragraph_block("a", 1),
|
||||
paragraph_block("b", 3),
|
||||
paragraph_block("c", 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
"T",
|
||||
0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 8);
|
||||
@@ -1242,13 +1329,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
|
||||
let mut pv = PagerView::new(
|
||||
let mut pv = pager_view(
|
||||
vec![
|
||||
paragraph_block("a", 2),
|
||||
paragraph_block("b", 3),
|
||||
paragraph_block("c", 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
"T",
|
||||
0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 3);
|
||||
@@ -1261,7 +1348,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
|
||||
let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 0);
|
||||
let mut pv = pager_view(vec![paragraph_block("a", 10)], "T", 0);
|
||||
let area = Rect::new(0, 0, 20, 8);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
|
||||
@@ -20,5 +20,7 @@ You can run any shell command from Codex using `!` (e.g. `!ls`)
|
||||
Type / to open the command popup; Tab autocompletes slash commands.
|
||||
When the composer is empty, press Esc to step back and edit your last message; Enter confirms.
|
||||
Press Tab to queue a message when a task is running; otherwise it sends immediately (except `!`).
|
||||
[tui.keymap] in ~/.codex/config.toml lets you rebind supported shortcuts.
|
||||
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml
|
||||
Paste an image with Ctrl+V to attach it to your next message.
|
||||
You can resume a previous conversation by running `codex resume`
|
||||
|
||||
@@ -6,6 +6,77 @@ For advanced configuration instructions, see [this documentation](https://develo
|
||||
|
||||
For a full configuration reference, see [this documentation](https://developers.openai.com/codex/config-reference).
|
||||
|
||||
## TUI keymap
|
||||
|
||||
The TUI supports rebinding shortcuts via `[tui.keymap]` in `~/.codex/config.toml`.
|
||||
|
||||
Use this complete, commented defaults template.
|
||||
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml
|
||||
For implementation details, safety contracts, and testing notes, see `docs/tui-keymap.md`.
|
||||
|
||||
### Precedence
|
||||
|
||||
Precedence is applied in this order (highest first):
|
||||
|
||||
1. Context-specific binding (`[tui.keymap.<context>]`)
|
||||
2. Global binding (`[tui.keymap.global]`) for chat/composer fallback actions
|
||||
3. Built-in preset defaults (`preset`)
|
||||
|
||||
### Presets
|
||||
|
||||
- `latest`: moving alias for the newest preset; today `latest -> v1`
|
||||
- `v1`: frozen legacy/current defaults
|
||||
|
||||
When defaults change in the future, a new version (for example `v2`) is added and
|
||||
`latest` may move to it. Pin to `v1` if you want stable historical behavior.
|
||||
|
||||
### Supported actions
|
||||
|
||||
- `global`: `open_transcript`, `open_external_editor`, `edit_previous_message`,
|
||||
`confirm_edit_previous_message`, `submit`, `queue`, `toggle_shortcuts`
|
||||
- `chat`: `edit_previous_message`, `confirm_edit_previous_message`
|
||||
- `composer`: `submit`, `queue`, `toggle_shortcuts`
|
||||
- `editor`: `insert_newline`, `move_left`, `move_right`, `move_up`, `move_down`,
|
||||
`move_word_left`, `move_word_right`, `move_line_start`, `move_line_end`,
|
||||
`delete_backward`, `delete_forward`, `delete_backward_word`, `delete_forward_word`,
|
||||
`kill_line_start`, `kill_line_end`, `yank`
|
||||
- `pager`: `scroll_up`, `scroll_down`, `page_up`, `page_down`, `half_page_up`,
|
||||
`half_page_down`, `jump_top`, `jump_bottom`, `close`, `close_transcript`,
|
||||
`edit_previous_message`, `edit_next_message`, `confirm_edit_message`
|
||||
- `list`: `move_up`, `move_down`, `accept`, `cancel`
|
||||
- `approval`: `open_fullscreen`, `approve`, `approve_for_session`,
|
||||
`approve_for_prefix`, `decline`, `cancel`
|
||||
- `onboarding`: `move_up`, `move_down`, `select_first`, `select_second`,
|
||||
`select_third`, `confirm`, `cancel`, `quit`, `toggle_animation`
|
||||
|
||||
For long-term behavior and evolution guidance, see `docs/tui-keymap.md`.
|
||||
For a quick action inventory, see `docs/keymap-action-matrix.md`.
|
||||
On onboarding API-key entry, printable `quit` bindings are treated as text input
|
||||
once the field contains text; use control/alt chords for always-available quit
|
||||
shortcuts.
|
||||
|
||||
### Key format
|
||||
|
||||
Use lowercase key identifiers with `-` separators, for example:
|
||||
|
||||
- `ctrl-a`
|
||||
- `shift-enter`
|
||||
- `alt-page-down`
|
||||
- `?`
|
||||
|
||||
Actions accept a single key or multiple keys:
|
||||
|
||||
- `submit = "enter"`
|
||||
- `submit = ["enter", "ctrl-j"]`
|
||||
- `submit = []` (explicitly unbind)
|
||||
|
||||
Some defaults intentionally include multiple variants for one logical shortcut
|
||||
because terminal modifier reporting can differ by platform/emulator. For
|
||||
example, `?` may arrive as plain `?` or `shift-?`, and control chords may
|
||||
arrive with or without `SHIFT`.
|
||||
|
||||
Aliases like `escape`, `pageup`, and `pgdn` are normalized.
|
||||
|
||||
## Connecting to MCP servers
|
||||
|
||||
Codex can connect to MCP servers configured in `~/.codex/config.toml`. See the configuration reference for the latest MCP server options:
|
||||
|
||||
116
docs/default-keymap.toml
Normal file
116
docs/default-keymap.toml
Normal file
@@ -0,0 +1,116 @@
|
||||
# Codex TUI keymap template (v1 defaults)
|
||||
#
|
||||
# Copy the sections you need into ~/.codex/config.toml.
|
||||
#
|
||||
# Canonical source (Codex repo):
|
||||
# https://github.com/openai/codex/blob/main/docs/default-keymap.toml
|
||||
# Implementation reference: docs/tui-keymap.md
|
||||
#
|
||||
# Value format:
|
||||
# - action = "ctrl-a" # one key
|
||||
# - action = ["ctrl-a", "alt-a"] # multiple keys for one action
|
||||
# - action = [] # explicit unbind for that action
|
||||
#
|
||||
# Precedence (highest first):
|
||||
# 1. [tui.keymap.<context>] override
|
||||
# 2. [tui.keymap.global] fallback (only for chat/composer actions)
|
||||
# 3. preset defaults
|
||||
|
||||
[tui.keymap]
|
||||
# Preset alias for built-in defaults.
|
||||
# "latest" currently points to "v1".
|
||||
preset = "latest"
|
||||
|
||||
[tui.keymap.global]
|
||||
# Open transcript overlay.
|
||||
open_transcript = "ctrl-t"
|
||||
# Open external editor for current draft.
|
||||
open_external_editor = "ctrl-g"
|
||||
# Begin/advance "edit previous message" when composer is empty.
|
||||
edit_previous_message = "esc"
|
||||
# Confirm selected message in edit-previous flow.
|
||||
confirm_edit_previous_message = "enter"
|
||||
# Submit current draft.
|
||||
submit = "enter"
|
||||
# Queue current draft while a task is running.
|
||||
queue = "tab"
|
||||
# Toggle composer shortcut overlay.
|
||||
# Include both forms because some terminals report `?` with SHIFT and others don't.
|
||||
toggle_shortcuts = ["?", "shift-?"]
|
||||
|
||||
[tui.keymap.chat]
|
||||
# Overrides [tui.keymap.global] for chat-only behavior.
|
||||
edit_previous_message = "esc"
|
||||
confirm_edit_previous_message = "enter"
|
||||
|
||||
[tui.keymap.composer]
|
||||
# Overrides [tui.keymap.global] for composer behavior.
|
||||
submit = "enter"
|
||||
queue = "tab"
|
||||
toggle_shortcuts = ["?", "shift-?"]
|
||||
|
||||
[tui.keymap.editor]
|
||||
# Text input editor bindings.
|
||||
insert_newline = ["ctrl-j", "ctrl-m", "enter", "shift-enter"]
|
||||
move_left = ["left", "ctrl-b"]
|
||||
move_right = ["right", "ctrl-f"]
|
||||
move_up = ["up", "ctrl-p"]
|
||||
move_down = ["down", "ctrl-n"]
|
||||
move_word_left = ["alt-b", "alt-left", "ctrl-left"]
|
||||
move_word_right = ["alt-f", "alt-right", "ctrl-right"]
|
||||
move_line_start = ["home", "ctrl-a"]
|
||||
move_line_end = ["end", "ctrl-e"]
|
||||
delete_backward = ["backspace", "ctrl-h"]
|
||||
delete_forward = ["delete", "ctrl-d"]
|
||||
delete_backward_word = ["alt-backspace", "ctrl-w", "ctrl-alt-h"]
|
||||
delete_forward_word = ["alt-delete"]
|
||||
kill_line_start = ["ctrl-u"]
|
||||
kill_line_end = ["ctrl-k"]
|
||||
yank = ["ctrl-y"]
|
||||
|
||||
[tui.keymap.pager]
|
||||
# Transcript/static overlay navigation.
|
||||
scroll_up = ["up", "k"]
|
||||
scroll_down = ["down", "j"]
|
||||
page_up = ["page-up", "shift-space", "ctrl-b"]
|
||||
page_down = ["page-down", "space", "ctrl-f"]
|
||||
half_page_up = ["ctrl-u"]
|
||||
half_page_down = ["ctrl-d"]
|
||||
jump_top = ["home"]
|
||||
jump_bottom = ["end"]
|
||||
close = ["q", "ctrl-c"]
|
||||
close_transcript = ["ctrl-t"]
|
||||
# Backtrack controls inside transcript overlay.
|
||||
edit_previous_message = ["esc", "left"]
|
||||
edit_next_message = ["right"]
|
||||
confirm_edit_message = ["enter"]
|
||||
|
||||
[tui.keymap.list]
|
||||
# Generic list/picker navigation.
|
||||
move_up = ["up", "ctrl-p", "k"]
|
||||
move_down = ["down", "ctrl-n", "j"]
|
||||
accept = ["enter"]
|
||||
cancel = ["esc"]
|
||||
|
||||
[tui.keymap.approval]
|
||||
# Approval modal actions.
|
||||
# Include ctrl+shift fallback for terminals that preserve SHIFT on Ctrl letter chords.
|
||||
open_fullscreen = ["ctrl-a", "ctrl-shift-a"]
|
||||
approve = ["y"]
|
||||
approve_for_session = ["a"]
|
||||
approve_for_prefix = ["p"]
|
||||
decline = ["esc", "n"]
|
||||
cancel = ["c"]
|
||||
|
||||
[tui.keymap.onboarding]
|
||||
# Onboarding/auth/trust screens.
|
||||
move_up = ["up", "k"]
|
||||
move_down = ["down", "j"]
|
||||
select_first = ["1", "y"]
|
||||
select_second = ["2", "n"]
|
||||
select_third = ["3"]
|
||||
confirm = ["enter"]
|
||||
cancel = ["esc"]
|
||||
quit = ["q", "ctrl-c", "ctrl-d"]
|
||||
# Include ctrl+shift fallback for terminals that preserve SHIFT on Ctrl punctuation chords.
|
||||
toggle_animation = ["ctrl-.", "ctrl-shift-."]
|
||||
@@ -1,3 +1,42 @@
|
||||
# Sample configuration
|
||||
|
||||
For a sample configuration file, see [this documentation](https://developers.openai.com/codex/config-sample).
|
||||
For a full sample configuration, see [this documentation](https://developers.openai.com/codex/config-sample).
|
||||
|
||||
## Keymap snippet
|
||||
|
||||
```toml
|
||||
[tui.keymap]
|
||||
preset = "latest" # currently points to v1 defaults
|
||||
|
||||
[tui.keymap.global]
|
||||
open_transcript = "ctrl-t"
|
||||
open_external_editor = "ctrl-g"
|
||||
|
||||
[tui.keymap.chat]
|
||||
edit_previous_message = "esc"
|
||||
confirm_edit_previous_message = "enter"
|
||||
|
||||
[tui.keymap.composer]
|
||||
submit = "enter"
|
||||
queue = "tab"
|
||||
toggle_shortcuts = ["?", "shift-?"]
|
||||
|
||||
[tui.keymap.pager]
|
||||
close = ["q", "ctrl-c"]
|
||||
close_transcript = ["ctrl-t"]
|
||||
|
||||
[tui.keymap.list]
|
||||
accept = "enter"
|
||||
cancel = "esc"
|
||||
|
||||
[tui.keymap.approval]
|
||||
approve = "y"
|
||||
decline = ["esc", "n"]
|
||||
|
||||
[tui.keymap.onboarding]
|
||||
quit = ["q", "ctrl-c", "ctrl-d"]
|
||||
```
|
||||
|
||||
For a complete, commented template:
|
||||
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml
|
||||
For precedence, safety invariants, and testing notes, see `docs/tui-keymap.md`.
|
||||
|
||||
101
docs/keymap-action-matrix.md
Normal file
101
docs/keymap-action-matrix.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# TUI Keymap Action Matrix
|
||||
|
||||
This file defines the supported keymap actions and their default `v1` 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`.
|
||||
- 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)
|
||||
|
||||
## Default `v1` Compatibility Notes
|
||||
|
||||
- Some actions intentionally ship with multiple bindings for the same logical
|
||||
shortcut because terminals differ in modifier reporting.
|
||||
- Today this includes:
|
||||
- `composer.toggle_shortcuts`: `?` and `shift-?`
|
||||
- `approval.open_fullscreen`: `ctrl-a` and `ctrl-shift-a`
|
||||
- `onboarding.toggle_animation`: `ctrl-.` and `ctrl-shift-.`
|
||||
- Keep these paired defaults unless/until key-event normalization is made
|
||||
platform-consistent at a lower layer.
|
||||
|
||||
## Action Definitions
|
||||
|
||||
### `global`
|
||||
|
||||
- `open_transcript`: open transcript overlay
|
||||
- `open_external_editor`: open external editor for current draft
|
||||
- `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
|
||||
- `queue`: queue current draft while a task is running
|
||||
- `toggle_shortcuts`: toggle composer shortcut overlay
|
||||
|
||||
### `chat`
|
||||
|
||||
- `edit_previous_message`: chat override for edit-previous flow
|
||||
- `confirm_edit_previous_message`: chat override for edit confirmation
|
||||
|
||||
### `composer`
|
||||
|
||||
- `submit`: composer override for submit
|
||||
- `queue`: composer override for queue
|
||||
- `toggle_shortcuts`: composer override for shortcut overlay toggle
|
||||
|
||||
### `editor`
|
||||
|
||||
- `insert_newline`: insert newline in text editor
|
||||
- `move_left` / `move_right` / `move_up` / `move_down`: cursor movement
|
||||
- `move_word_left` / `move_word_right`: word movement
|
||||
- `move_line_start` / `move_line_end`: line boundary movement
|
||||
- `delete_backward` / `delete_forward`: single-char deletion
|
||||
- `delete_backward_word` / `delete_forward_word`: word deletion
|
||||
- `kill_line_start` / `kill_line_end`: kill to line boundary
|
||||
- `yank`: paste kill-buffer contents
|
||||
|
||||
### `pager`
|
||||
|
||||
- `scroll_up` / `scroll_down`: row scroll
|
||||
- `page_up` / `page_down`: page scroll
|
||||
- `half_page_up` / `half_page_down`: half-page scroll
|
||||
- `jump_top` / `jump_bottom`: jump to top/bottom
|
||||
- `close`: close pager overlay
|
||||
- `close_transcript`: close transcript via transcript toggle binding
|
||||
- `edit_previous_message` / `edit_next_message`: backtrack navigation in transcript
|
||||
- `confirm_edit_message`: confirm selected backtrack message
|
||||
|
||||
### `list`
|
||||
|
||||
- `move_up` / `move_down`: list navigation
|
||||
- `accept`: select current item
|
||||
- `cancel`: close list view
|
||||
|
||||
### `approval`
|
||||
|
||||
- `open_fullscreen`: open full-screen approval details
|
||||
- `approve`: approve primary request
|
||||
- `approve_for_session`: approve-for-session option
|
||||
- `approve_for_prefix`: approve-for-prefix option
|
||||
- `decline`: decline request
|
||||
- `cancel`: cancel elicitation request
|
||||
- MCP elicitation safety rule: `Esc` is always treated as `cancel` (never
|
||||
`decline`) so dismissal cannot accidentally continue execution.
|
||||
|
||||
### `onboarding`
|
||||
|
||||
- `move_up` / `move_down`: onboarding list navigation
|
||||
- `select_first` / `select_second` / `select_third`: numeric selection shortcuts
|
||||
- `confirm`: confirm highlighted onboarding selection
|
||||
- `cancel`: cancel current onboarding sub-flow
|
||||
- `quit`: quit onboarding flow
|
||||
- `toggle_animation`: switch welcome animation variant
|
||||
- API-key entry guard: printable `quit` bindings are treated as text input once
|
||||
the API-key field has text; control/alt quit chords are not suppressed.
|
||||
208
docs/keymap-rewrite-handoff.md
Normal file
208
docs/keymap-rewrite-handoff.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Keymap Rewrite Handoff
|
||||
|
||||
Updated: February 19, 2026
|
||||
Repo: `codex-config-keybinds`
|
||||
Branch: `joshka/keymap-rewrite-start`
|
||||
|
||||
## Purpose
|
||||
|
||||
This handoff is the authoritative context for finishing and shipping the keymap rewrite commit
|
||||
stack.
|
||||
|
||||
The original implementation existed as a single large commit (`0f03e76c`). The current stack
|
||||
reorders that work into additive, review-friendly commits while preserving behavioral intent.
|
||||
|
||||
Key decisions that still apply:
|
||||
|
||||
1. Preserve end-state behavior equivalent to `0f03e76c`.
|
||||
2. Keep `AGENTS.md` keymap guidance in the stack.
|
||||
3. Treat keybinding coverage as a keymap-focused gate (not whole-crate perfection).
|
||||
|
||||
## Quick Orientation
|
||||
|
||||
Summary for incoming implementer:
|
||||
|
||||
1. The keymap feature is mostly implemented and wired.
|
||||
2. The remaining technical gate is branch coverage closure on keybinding branches.
|
||||
3. Preserve behavior while tightening internals and documentation quality.
|
||||
4. Keep help affordances succinct and clearly visible.
|
||||
|
||||
Read these in order:
|
||||
|
||||
1. `docs/keymap-rewrite-handoff.md`
|
||||
- Overall constraints, current status, and concrete next steps.
|
||||
2. `docs/tui-keymap.md`
|
||||
- Long-term architecture and behavior model for configurable keymaps.
|
||||
3. `docs/keymap-action-matrix.md`
|
||||
- Action-by-action reference mapping and expected command behavior.
|
||||
4. `docs/config.md`
|
||||
- User-facing config contract and migration notes for presets.
|
||||
5. `docs/default-keymap.toml`
|
||||
- Canonical template that should match the actual runtime/default behavior.
|
||||
6. `codex-rs/tui/src/keymap.rs`
|
||||
- Core resolver and matching logic.
|
||||
7. `codex-rs/tui/src/bottom_pane/footer.rs`
|
||||
- Help/affordance rendering and keymap customization guidance text.
|
||||
|
||||
## Keymap Implementation Constraints (Must Hold)
|
||||
|
||||
1. Behavioral contract:
|
||||
- Preserve existing runtime behavior first, then refactor.
|
||||
- End-state behavior should remain equivalent to `0f03e76c`.
|
||||
2. Keymap type organization:
|
||||
- Keep `TuiKeymap`, `TuiKeymapPreset`, and related keymap types out of oversized umbrella files
|
||||
(for example `types.rs`) and in dedicated module files.
|
||||
3. Preset/versioning policy:
|
||||
- Do not mutate historical preset defaults.
|
||||
- Add new preset versions for behavior changes and keep `latest` as an explicit pointer.
|
||||
- Update both `docs/default-keymap.toml` and `docs/config.md` with migration notes whenever
|
||||
preset behavior changes.
|
||||
4. Simplification policy:
|
||||
- Prefer reducing keymap repetition with small declarative macros where readability improves.
|
||||
- Centralize key-event matching logic on `KeyBinding`/keybinding helpers rather than repeating
|
||||
ad-hoc comparisons across callsites.
|
||||
- Document new macros and helper abstractions with rustdoc/doc comments.
|
||||
- If `key_hint::plain...` helpers are macroized, document invocation patterns and generated
|
||||
behavior with rustdoc and examples.
|
||||
5. Help and affordance UX:
|
||||
- Keep keymap customization affordances succinct.
|
||||
- Show the keymap affordance on its own line; avoid wrapped wording that buries the action.
|
||||
6. Documentation expectations:
|
||||
- Write long-term "how this works" documentation, not process diary notes.
|
||||
- Keep details that affect behavior, contracts, or extension points.
|
||||
- Keep docs self-contained for a new engineer without depending on external notes.
|
||||
- Run a documentation pass mindset on changed modules: explain invariants and decision
|
||||
boundaries, not just mechanics.
|
||||
7. Template/docs link policy:
|
||||
- `docs/default-keymap.toml` must point to a public URL for canonical docs (GitHub for now;
|
||||
later `developers.openai.com`).
|
||||
8. Test and coverage gates:
|
||||
- Add characterization tests for pre-existing event behavior before swapping bindings.
|
||||
- Maintain compile correctness across all callsites when APIs change (for example, constructor
|
||||
arity updates).
|
||||
- Use `cargo llvm-cov` branch coverage and push toward 100% on keybinding-related branches.
|
||||
|
||||
## Context And Rationale (Self-Contained)
|
||||
|
||||
This document is intentionally self-contained for implementation handoff.
|
||||
|
||||
Source of truth for intent and behavior:
|
||||
|
||||
1. Existing long-term docs in this repo:
|
||||
`docs/tui-keymap.md`, `docs/keymap-action-matrix.md`, and `docs/keymap-rollout-plan.md`.
|
||||
2. Commit stack and diffs listed below.
|
||||
3. Test and coverage commands listed in this document.
|
||||
|
||||
## Current Additive Stack (Authoritative)
|
||||
|
||||
1. `b455e26a4a3e`
|
||||
`docs(keymap): establish long-term keymap documentation`
|
||||
|
||||
Scope: long-term docs/spec (`docs/tui-keymap.md`, action matrix, template, config docs,
|
||||
rollout plan).
|
||||
|
||||
2. `1913b55afb75`
|
||||
`feat(core): add keymap config schema and types`
|
||||
|
||||
Scope: additive config surface only (`core/src/config/tui_keymap.rs`, schema, config exports).
|
||||
|
||||
3. `187aa6969e03`
|
||||
`test(tui): add runtime keymap resolver characterization suite`
|
||||
|
||||
Scope: `tui/src/keymap.rs` and `key_hint` helper expansion, with resolver tests and conflict
|
||||
guards, but no broad runtime wiring yet.
|
||||
|
||||
4. `aeb6caaecbbc`
|
||||
`feat(tui): wire runtime keymap into event handling`
|
||||
|
||||
Scope: replace hardcoded key checks across app/composer/pager/approval/onboarding/list/textarea
|
||||
routing with runtime keymap usage.
|
||||
|
||||
5. `HEAD`
|
||||
`feat(tui): surface keymap customization hints`
|
||||
|
||||
Scope: UX/help affordances (`footer.rs`, tooltip text, snapshot updates) plus this handoff doc.
|
||||
|
||||
## Validation Evidence (Current)
|
||||
|
||||
All commands below were run in this workspace.
|
||||
|
||||
1. Config commit (`1913b55afb75`)
|
||||
- `cd codex-rs && just fmt`
|
||||
- `cd codex-rs && cargo test -p codex-core --lib`
|
||||
- Result: pass (`996 passed; 0 failed; 4 ignored`).
|
||||
|
||||
2. Resolver/characterization commit (`187aa6969e03`)
|
||||
- `cd codex-rs && just fmt`
|
||||
- `cd codex-rs && cargo test -p codex-tui`
|
||||
- Result: pass (`797 passed; 0 failed; 2 ignored` + integration/doctest pass).
|
||||
|
||||
3. Wiring commit (`aeb6caaecbbc`)
|
||||
- `cd codex-rs && just fmt`
|
||||
- `cd codex-rs && cargo test -p codex-tui`
|
||||
- Result: pass (`806 passed; 0 failed; 2 ignored` + integration/doctest pass).
|
||||
|
||||
4. UX/hints commit (`HEAD`)
|
||||
- `cd codex-rs && just fmt`
|
||||
- `cd codex-rs && cargo test -p codex-tui`
|
||||
- Result: pass (`806 passed; 0 failed; 2 ignored` + integration/doctest pass).
|
||||
|
||||
5. Post-rebase integration validation (current workspace)
|
||||
- `cd codex-rs && just fmt`
|
||||
- `cd codex-rs && cargo test -p codex-tui`
|
||||
- Result: pass (`932 passed; 0 failed; 2 ignored` + integration/doctest pass).
|
||||
|
||||
## Coverage Status (Important)
|
||||
|
||||
Branch-coverage closure is still pending and is the main remaining technical gate.
|
||||
|
||||
What happened:
|
||||
|
||||
1. `cargo llvm-cov --branch` on stable failed as expected (needs nightly `-Z` support).
|
||||
2. Nightly toolchain exists locally, and `llvm-tools-preview` is installed for nightly.
|
||||
3. A nightly branch-coverage run was started but interrupted before artifact emission.
|
||||
4. No `/tmp/codex_tui_cov.json` was produced in the latest attempt.
|
||||
|
||||
Implication:
|
||||
|
||||
- Do not claim branch-coverage completion yet.
|
||||
- Next dev should run and record a successful branch report before declaring done.
|
||||
|
||||
## Remaining Work To Finish This Effort
|
||||
|
||||
1. Complete keybinding branch coverage run and record results.
|
||||
2. Add missing tests if branch gaps remain in `tui/src/keymap.rs` or key-routing branches.
|
||||
3. Re-run `cargo test -p codex-tui` after any coverage-driven test additions.
|
||||
4. Decide whether `docs/keymap-rollout-plan.md` remains in the final PR scope or is dropped as
|
||||
implementation scaffolding.
|
||||
5. Final review pass on commit descriptions and stack readability, then push/open PR.
|
||||
|
||||
## Recommended Resume Commands
|
||||
|
||||
Run these first to rehydrate state:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git status --short
|
||||
git log --oneline --decorate --graph origin/main..HEAD
|
||||
git diff --stat origin/main...HEAD
|
||||
git diff origin/main...HEAD
|
||||
```
|
||||
|
||||
Then run coverage work:
|
||||
|
||||
```bash
|
||||
cd codex-rs
|
||||
cargo +nightly llvm-cov -p codex-tui --tests --branch --json --output-path /tmp/codex_tui_cov.json
|
||||
cargo +nightly llvm-cov report --summary-only
|
||||
```
|
||||
|
||||
If needed, inspect key files directly in the coverage JSON and add tests before re-running.
|
||||
|
||||
## Notes For Reviewer/Implementer Handoff
|
||||
|
||||
1. The stack is intentionally additive and readable now; avoid collapsing commits unless explicitly
|
||||
requested.
|
||||
2. Commit 4 contains the behavioral swap; commit 5 should stay UX-only.
|
||||
3. Keep this file in-tree until PR merge so reviewers and follow-up implementers share one
|
||||
authoritative source of context.
|
||||
69
docs/keymap-rollout-plan.md
Normal file
69
docs/keymap-rollout-plan.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# TUI Keymap Additive Rollout Plan
|
||||
|
||||
This plan restructures the existing keymap implementation into a reviewable
|
||||
additive stack while preserving the final behavior from revision `0f03e76c`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the final behavior effectively identical to `0f03e76c`.
|
||||
- Reorder history to show intent and risk reduction clearly.
|
||||
- Make each change compile, format cleanly, and pass scoped tests.
|
||||
- Establish high-confidence behavior characterization before binding rewrites.
|
||||
|
||||
## Commit Sequence
|
||||
|
||||
1. **Docs and specification foundation**
|
||||
- Add/reshape long-term docs that explain how the keymap system works.
|
||||
- Include:
|
||||
- conceptual model
|
||||
- action matrix
|
||||
- default keymap template guidance with explicit URL
|
||||
- testing strategy notes
|
||||
- No functional code change.
|
||||
|
||||
2. **Additive config primitives (not wired)**
|
||||
- Introduce keymap config types and schema support in `core`.
|
||||
- Keep runtime behavior unchanged by not consuming these config values yet.
|
||||
- Ensure parsing/serialization paths are complete and documented.
|
||||
|
||||
3. **Behavior characterization tests (pre-rewrite)**
|
||||
- Add tests that lock down existing event behavior before switching bindings.
|
||||
- Cover key event matching and context-sensitive behaviors that must stay stable.
|
||||
- Use these tests as the safety net for subsequent rewiring.
|
||||
- Run `cargo llvm-cov` and target full branch coverage for keybinding logic.
|
||||
|
||||
4. **Binding replacement using characterized behavior**
|
||||
- Introduce keymap-driven binding resolution and wire call sites.
|
||||
- Replace legacy binding checks while preserving characterized behavior.
|
||||
- Keep docs in sync with any semantics that became explicit.
|
||||
|
||||
5. **UX polish and affordances**
|
||||
- Apply help/footer/tooltip/key-hint refinements once behavior is stable.
|
||||
- Keep affordance text concise and formatted for readability.
|
||||
- Update snapshots as needed.
|
||||
|
||||
## Validation Gates Per Commit
|
||||
|
||||
For each commit in the sequence:
|
||||
|
||||
1. Run formatting (`just fmt` in `codex-rs`).
|
||||
2. Run scoped tests for affected crates (minimum `cargo test -p codex-tui` for TUI
|
||||
changes and relevant `core` tests when `core` changes).
|
||||
3. Ensure branch compiles cleanly (`cargo test` path used as compile gate).
|
||||
4. For characterization and binding commits, run coverage and track branch
|
||||
coverage for keybinding logic (`cargo llvm-cov`).
|
||||
|
||||
## JJ Workflow
|
||||
|
||||
- Build stack from `trunk()` with explicit ordered changes.
|
||||
- Use `jj new -A` / `jj new -B` to place changes relative to target revisions.
|
||||
- Rebase existing implementation changes into the new stack as needed.
|
||||
- Resolve conflicts immediately and keep each change coherent.
|
||||
- Use clear `jj describe` messages with title + explanatory body.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Final tip behavior matches `0f03e76c` in practice.
|
||||
- Stack reads top-to-bottom as: docs -> config -> characterization -> rewiring ->
|
||||
UX polish.
|
||||
- Each commit is independently reviewable with passing checks.
|
||||
277
docs/tui-keymap.md
Normal file
277
docs/tui-keymap.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# TUI Keymap Implementation Reference
|
||||
|
||||
This document is the long-term implementation reference for Codex TUI keybindings.
|
||||
It describes how keymap configuration is resolved at runtime, which safety
|
||||
contracts are intentionally strict, and how to test behavior end to end.
|
||||
|
||||
## Scope and boundaries
|
||||
|
||||
This keymap system is action-based and context-aware. It supports user rebinding
|
||||
for the TUI without requiring source code edits.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Resolve config values into runtime key bindings.
|
||||
2. Apply deterministic precedence.
|
||||
3. Reject ambiguous bindings in dispatch scopes where collisions are unsafe.
|
||||
4. Preserve explicit safety semantics for approval elicitation and onboarding.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
1. It does not choose which screen should handle an event.
|
||||
2. It does not persist config.
|
||||
3. It does not guarantee terminal modifier reporting consistency; defaults may
|
||||
include compatibility variants for that reason.
|
||||
|
||||
## Source-of-truth map
|
||||
|
||||
- Runtime resolution: `codex-rs/tui/src/keymap.rs`
|
||||
- Onboarding flow-level routing and quit guard:
|
||||
`codex-rs/tui/src/onboarding/onboarding_screen.rs`
|
||||
- Approval/MCP elicitation option semantics:
|
||||
`codex-rs/tui/src/bottom_pane/approval_overlay.rs`
|
||||
- Generic list popup navigation semantics:
|
||||
`codex-rs/tui/src/bottom_pane/list_selection_view.rs`
|
||||
- User-facing default template:
|
||||
`https://github.com/openai/codex/blob/main/docs/default-keymap.toml`
|
||||
- User-facing config overview: `docs/config.md`
|
||||
|
||||
## Config contract
|
||||
|
||||
`[tui.keymap]` is action-to-binding mapping by context.
|
||||
|
||||
```toml
|
||||
[tui.keymap]
|
||||
preset = "latest"
|
||||
|
||||
[tui.keymap.global]
|
||||
submit = "enter"
|
||||
|
||||
[tui.keymap.composer]
|
||||
submit = ["enter", "ctrl-j"]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. Mapping direction is `action -> key_or_keys`.
|
||||
2. Values support:
|
||||
1. `action = "key-spec"`
|
||||
2. `action = ["key-spec-1", "key-spec-2"]`
|
||||
3. `action = []` for explicit unbind.
|
||||
3. Unknown contexts, actions, or key identifiers fail validation.
|
||||
4. Aliases are normalized (for example `escape -> esc`, `pgdn -> page-down`).
|
||||
5. Key identifiers are lowercase and use `-` separators.
|
||||
|
||||
## Contexts and actions
|
||||
|
||||
Supported contexts:
|
||||
|
||||
1. `global`
|
||||
2. `chat`
|
||||
3. `composer`
|
||||
4. `editor`
|
||||
5. `pager`
|
||||
6. `list`
|
||||
7. `approval`
|
||||
8. `onboarding`
|
||||
|
||||
Action inventory by context is documented in `docs/config.md` and the template
|
||||
at `https://github.com/openai/codex/blob/main/docs/default-keymap.toml`.
|
||||
|
||||
## Presets and compatibility policy
|
||||
|
||||
Preset semantics:
|
||||
|
||||
1. `latest` is an alias to the newest shipped preset.
|
||||
2. `v1` is the current frozen baseline.
|
||||
3. Today, `latest -> v1`.
|
||||
|
||||
User guidance:
|
||||
|
||||
1. Pin `preset = "v1"` for stable behavior over time.
|
||||
2. Use `preset = "latest"` to adopt new defaults when `latest` moves.
|
||||
|
||||
Developer policy:
|
||||
|
||||
1. Do not mutate old preset defaults after release.
|
||||
2. Add a new version (for example `v2`) for behavior changes.
|
||||
3. Update docs and migration notes whenever `latest` changes.
|
||||
|
||||
Compatibility detail:
|
||||
|
||||
Some actions intentionally ship with multiple default bindings because terminals
|
||||
can report modifier combinations differently. Examples include `?` vs `shift-?`
|
||||
and certain `ctrl` chords with optional `shift`.
|
||||
|
||||
## Resolution and precedence
|
||||
|
||||
Resolution order (highest first):
|
||||
|
||||
1. Context binding (`tui.keymap.<context>.<action>`)
|
||||
2. Global fallback (`tui.keymap.global.<action>`) for chat/composer fallback
|
||||
actions only
|
||||
3. Preset default binding
|
||||
|
||||
If no binding matches, normal unhandled-key fallback behavior applies.
|
||||
|
||||
## Conflict validation model
|
||||
|
||||
Validation is dispatch-order aware, not globally uniform.
|
||||
|
||||
Current conflict passes in `RuntimeKeymap::validate_conflicts` enforce:
|
||||
|
||||
1. App-level uniqueness for app actions and app-level chat controls.
|
||||
2. App/composer shadowing prevention, because app handlers execute before
|
||||
forwarding to composer handlers.
|
||||
3. Composer-local uniqueness for submit/queue/shortcut-toggle.
|
||||
4. Context-local uniqueness in editor, pager, list, approval, and onboarding.
|
||||
|
||||
Intentionally allowed:
|
||||
|
||||
1. Same key across different contexts that are not co-evaluated in a way that
|
||||
can cause unsafe shadowing.
|
||||
2. Shared defaults where runtime context gating keeps semantics unambiguous.
|
||||
|
||||
## Safety invariants
|
||||
|
||||
### MCP elicitation cancel semantics
|
||||
|
||||
For MCP elicitation prompts, `Esc` is always treated as `cancel`.
|
||||
|
||||
Implementation contract:
|
||||
|
||||
1. `Esc` is always included in cancel shortcuts.
|
||||
2. User-defined `approval.cancel` shortcuts are merged into cancel.
|
||||
3. Any overlap is removed from `approval.decline` in elicitation mode.
|
||||
|
||||
Rationale: dismissal must remain a safe abort path and never silently map to
|
||||
"continue without requested info".
|
||||
|
||||
### Onboarding API-key text-entry guard
|
||||
|
||||
During API-key entry, printable `onboarding.quit` bindings are suppressed only
|
||||
when the API-key field already has text.
|
||||
|
||||
Implementation contract:
|
||||
|
||||
1. Guard applies only when API-key entry mode is active.
|
||||
2. Guard applies only to printable char keys without control/alt modifiers.
|
||||
3. Guard applies only when input is non-empty.
|
||||
4. Control/alt quit chords are never suppressed by this guard.
|
||||
|
||||
Rationale: keep text-entry safe once typing has begun while preserving an
|
||||
intentional printable-quit path on empty input.
|
||||
|
||||
## Dispatch model and handler boundaries
|
||||
|
||||
High-level behavior:
|
||||
|
||||
1. App-level event handling runs before some lower-level handlers.
|
||||
2. Composer behavior depends on both app routing and composer-local checks.
|
||||
3. Onboarding screen routing applies flow-level rules before delegating to step
|
||||
widgets.
|
||||
4. Approval overlay and list selection use context-specific bindings resolved by
|
||||
`RuntimeKeymap`.
|
||||
|
||||
When changing dispatch order, re-evaluate conflict validation scopes in
|
||||
`keymap.rs` and associated tests.
|
||||
|
||||
## Diagnostics contract
|
||||
|
||||
Validation errors should be actionable and include:
|
||||
|
||||
1. Problem summary.
|
||||
2. Exact config path.
|
||||
3. Why the value is invalid or ambiguous.
|
||||
4. Concrete remediation step.
|
||||
|
||||
Categories currently covered:
|
||||
|
||||
1. Invalid key specification.
|
||||
2. Unknown action/context mapping.
|
||||
3. Same-scope ambiguity.
|
||||
4. Shadowing collisions in dispatch-coupled scopes.
|
||||
|
||||
## Debug path
|
||||
|
||||
When keybindings do not behave as expected, trace in this order:
|
||||
|
||||
1. Verify config normalization and schema validation in
|
||||
`codex-rs/core/src/config/tui_keymap.rs`.
|
||||
2. Verify resolved runtime bindings and conflict checks in
|
||||
`codex-rs/tui/src/keymap.rs` (`from_config`, `validate_conflicts`).
|
||||
3. Verify handler-level dispatch order in:
|
||||
1. `codex-rs/tui/src/app.rs` for app/chat/composer routing.
|
||||
2. `codex-rs/tui/src/pager_overlay.rs` for pager controls.
|
||||
3. `codex-rs/tui/src/bottom_pane/approval_overlay.rs` for approval safety
|
||||
behavior.
|
||||
4. `codex-rs/tui/src/onboarding/onboarding_screen.rs` for onboarding quit
|
||||
guard behavior.
|
||||
4. Reproduce with explicit bindings in `~/.codex/config.toml` and compare
|
||||
against:
|
||||
1. `docs/default-keymap.toml`
|
||||
2. `docs/keymap-action-matrix.md`
|
||||
|
||||
## Testing notes
|
||||
|
||||
### Commands
|
||||
|
||||
Run from `codex-rs/`:
|
||||
|
||||
1. `just fmt`
|
||||
2. `cargo test -p codex-tui --lib`
|
||||
3. Optional full crate run (includes integration tests):
|
||||
`cargo test -p codex-tui`
|
||||
4. Optional focused runs while iterating:
|
||||
`cargo test -p codex-tui --lib keymap::tests`
|
||||
|
||||
If `cargo test -p codex-tui` fails because the `codex` binary cannot be found
|
||||
in local `target/`, run `--lib` for keymap behavior checks and then validate the
|
||||
integration target in an environment where workspace binaries are available.
|
||||
|
||||
For intentional UI/text output changes in `codex-tui`:
|
||||
|
||||
1. `cargo insta pending-snapshots -p codex-tui`
|
||||
2. `cargo insta show -p codex-tui <path/to/snapshot.snap.new>`
|
||||
3. `cargo insta accept -p codex-tui` only when the full snapshot set is
|
||||
expected.
|
||||
|
||||
### Behavior coverage checklist
|
||||
|
||||
Use this checklist before landing keymap behavior changes:
|
||||
|
||||
1. Precedence: context override beats global fallback and preset defaults.
|
||||
2. Unbind behavior: `action = []` actually removes the binding.
|
||||
3. Conflict rejection:
|
||||
1. Same-context duplicates fail.
|
||||
2. App/composer shadowing fails for submit, queue, and toggle-shortcuts.
|
||||
4. Approval safety:
|
||||
1. `Esc` resolves elicitation to cancel.
|
||||
2. Decline shortcuts never contain cancel overlaps in elicitation mode.
|
||||
5. Onboarding safety:
|
||||
1. Printable quit key is suppressed when API-key input is active and
|
||||
non-empty.
|
||||
2. Printable quit key is not suppressed when input is empty.
|
||||
3. Control/alt quit chords are not suppressed.
|
||||
6. Footer/help hints continue to reflect effective primary bindings.
|
||||
7. `https://github.com/openai/codex/blob/main/docs/default-keymap.toml`,
|
||||
`docs/config.md`, and `docs/example-config.md` stay aligned with runtime
|
||||
action names and defaults.
|
||||
|
||||
### Manual sanity checks
|
||||
|
||||
1. Start onboarding and enter API-key mode.
|
||||
2. Bind `onboarding.quit` to a printable key.
|
||||
3. Verify that key quits when input is empty, then types once text exists.
|
||||
4. Verify `ctrl-c` or another control quit chord still exits.
|
||||
5. Trigger an MCP elicitation request and verify `Esc` cancels, not declines.
|
||||
|
||||
## Documentation maintenance
|
||||
|
||||
When adding/changing keymap API surface:
|
||||
|
||||
1. Update runtime definitions and defaults in `codex-rs/tui/src/keymap.rs`.
|
||||
2. Update `docs/default-keymap.toml`.
|
||||
3. Update `docs/config.md` and `docs/example-config.md` snippets.
|
||||
4. Update this file with behavioral or safety contract changes.
|
||||
5. Add/update regression tests in `codex-rs/tui`.
|
||||
Reference in New Issue
Block a user