mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
feat(tui): add configurable keymap support
Add typed `tui.keymap` config, schema coverage, default keymap docs, and runtime keymap resolution for existing TUI shortcuts. Wire the resolved bindings through app, composer, list, pager, approval, and onboarding input handlers so users can reassign built-in keys.
This commit is contained in:
@@ -18,6 +18,7 @@ pub mod schema;
|
||||
pub mod shell_environment;
|
||||
mod skills_config;
|
||||
mod state;
|
||||
mod tui_keymap;
|
||||
pub mod types;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
//! 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>,
|
||||
/// Copy the last agent response to the clipboard.
|
||||
pub copy: Option<KeybindingsSpec>,
|
||||
/// In an empty composer, begin or advance "edit previous message" flow.
|
||||
pub edit_previous_message: Option<KeybindingsSpec>,
|
||||
/// Confirm editing the selected previous message.
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[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\
|
||||
See the Codex keymap documentation for supported actions and examples."
|
||||
.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\
|
||||
See the Codex keymap documentation for supported actions and examples."
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn misplaced_action_at_keymap_root_is_rejected() {
|
||||
// Actions placed directly under [tui.keymap] instead of a context
|
||||
// sub-table (e.g. [tui.keymap.global]) must produce a parse error,
|
||||
// not be silently ignored.
|
||||
let toml_input = r#"
|
||||
open_transcript = "ctrl-s"
|
||||
"#;
|
||||
let result = toml::from_str::<TuiKeymap>(toml_input);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected error for action at keymap root, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_under_global_context_is_accepted() {
|
||||
let toml_input = r#"
|
||||
[global]
|
||||
open_transcript = "ctrl-s"
|
||||
"#;
|
||||
let keymap: TuiKeymap = toml::from_str(toml_input).expect("valid config");
|
||||
assert!(keymap.global.open_transcript.is_some());
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,19 @@ use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use crate::tui_keymap::KeybindingSpec;
|
||||
pub use crate::tui_keymap::KeybindingsSpec;
|
||||
pub use crate::tui_keymap::TuiApprovalKeymap;
|
||||
pub use crate::tui_keymap::TuiChatKeymap;
|
||||
pub use crate::tui_keymap::TuiComposerKeymap;
|
||||
pub use crate::tui_keymap::TuiEditorKeymap;
|
||||
pub use crate::tui_keymap::TuiGlobalKeymap;
|
||||
pub use crate::tui_keymap::TuiKeymap;
|
||||
pub use crate::tui_keymap::TuiKeymapPreset;
|
||||
pub use crate::tui_keymap::TuiListKeymap;
|
||||
pub use crate::tui_keymap::TuiOnboardingKeymap;
|
||||
pub use crate::tui_keymap::TuiPagerKeymap;
|
||||
|
||||
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
|
||||
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 16;
|
||||
pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
|
||||
@@ -581,6 +594,13 @@ pub struct Tui {
|
||||
#[serde(default)]
|
||||
pub theme: Option<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,
|
||||
|
||||
/// Startup tooltip availability NUX state persisted by the TUI.
|
||||
#[serde(default)]
|
||||
pub model_availability_nux: ModelAvailabilityNuxConfig,
|
||||
|
||||
@@ -832,6 +832,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."
|
||||
},
|
||||
"MarketplaceConfig": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -1997,6 +2011,94 @@
|
||||
"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,
|
||||
"copy": 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."
|
||||
},
|
||||
"model_availability_nux": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -2062,6 +2164,717 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"copy": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Copy the last agent response to the clipboard."
|
||||
},
|
||||
"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,
|
||||
"copy": 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": [
|
||||
{
|
||||
@@ -2957,4 +3770,4 @@
|
||||
},
|
||||
"title": "ConfigToml",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ use codex_config::types::SandboxWorkspaceWrite;
|
||||
use codex_config::types::SkillsConfig;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_config::types::Tui;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use codex_config::types::TuiNotificationSettings;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_features::Feature;
|
||||
@@ -389,6 +390,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
status_line: None,
|
||||
terminal_title: None,
|
||||
theme: None,
|
||||
keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig {
|
||||
shown_count: HashMap::from([
|
||||
("gpt-bar".to_string(), 4),
|
||||
@@ -1192,6 +1194,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
|
||||
status_line: None,
|
||||
terminal_title: None,
|
||||
theme: None,
|
||||
keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
}
|
||||
);
|
||||
@@ -4924,6 +4927,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
},
|
||||
o3_profile_config
|
||||
@@ -5075,6 +5079,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -5224,6 +5229,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -5358,6 +5364,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ use codex_config::types::OtelExporterKind;
|
||||
use codex_config::types::ShellEnvironmentPolicy;
|
||||
use codex_config::types::ToolSuggestConfig;
|
||||
use codex_config::types::ToolSuggestDiscoverable;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use codex_config::types::TuiNotificationSettings;
|
||||
use codex_config::types::UriBasedFileOpener;
|
||||
use codex_config::types::WindowsSandboxModeToml;
|
||||
@@ -352,7 +353,6 @@ 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` and `current-dir`.
|
||||
@@ -366,6 +366,15 @@ pub struct Config {
|
||||
/// Syntax highlighting theme override (kebab-case name).
|
||||
pub tui_theme: Option<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 absolute 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.
|
||||
@@ -2323,6 +2332,11 @@ impl Config {
|
||||
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
|
||||
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
|
||||
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.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);
|
||||
|
||||
+89
-59
@@ -36,6 +36,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::legacy_core::append_message_history_entry;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
@@ -1045,6 +1047,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>,
|
||||
@@ -4274,6 +4277,13 @@ impl App {
|
||||
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
|
||||
|
||||
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), 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\
|
||||
See the Codex keymap documentation for supported actions and examples."
|
||||
)
|
||||
})?;
|
||||
#[cfg(not(debug_assertions))]
|
||||
let upgrade_version = crate::updates::get_upgrade_version(&config);
|
||||
|
||||
@@ -4290,6 +4300,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(),
|
||||
@@ -4912,6 +4923,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();
|
||||
}
|
||||
@@ -6119,6 +6131,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, .. } => {
|
||||
@@ -6128,6 +6141,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::Permissions {
|
||||
@@ -6152,6 +6166,7 @@ impl App {
|
||||
self.overlay = Some(Overlay::new_static_with_renderables(
|
||||
vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))],
|
||||
"P E R M I S S I O N S".to_string(),
|
||||
self.keymap.pager.clone(),
|
||||
));
|
||||
}
|
||||
ApprovalRequest::McpElicitation {
|
||||
@@ -6169,6 +6184,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(),
|
||||
));
|
||||
}
|
||||
},
|
||||
@@ -6638,18 +6654,62 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: 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();
|
||||
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.
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
@@ -6669,51 +6729,6 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('g'),
|
||||
modifiers: 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);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
@@ -6721,7 +6736,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);
|
||||
@@ -7140,6 +7157,7 @@ mod tests {
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -10860,6 +10878,8 @@ guardian_approval = true
|
||||
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
|
||||
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
|
||||
let session_telemetry = test_session_telemetry(&config, model.as_str());
|
||||
let keymap = RuntimeKeymap::from_config(&config.tui_keymap)
|
||||
.expect("test config should always produce a valid runtime keymap");
|
||||
|
||||
App {
|
||||
model_catalog: chat_widget.model_catalog(),
|
||||
@@ -10873,6 +10893,7 @@ guardian_approval = true
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
keymap,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
@@ -10916,6 +10937,8 @@ guardian_approval = true
|
||||
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
|
||||
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
|
||||
let session_telemetry = test_session_telemetry(&config, model.as_str());
|
||||
let keymap = RuntimeKeymap::from_config(&config.tui_keymap)
|
||||
.expect("test config should always produce a valid runtime keymap");
|
||||
|
||||
(
|
||||
App {
|
||||
@@ -10930,6 +10953,7 @@ guardian_approval = true
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
keymap,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
@@ -12487,7 +12511,10 @@ guardian_approval = true
|
||||
/*is_first_line*/ 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;
|
||||
@@ -12715,7 +12742,10 @@ guardian_approval = true
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>];
|
||||
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
|
||||
app.overlay = Some(Overlay::new_transcript(
|
||||
app.transcript_cells.clone(),
|
||||
crate::keymap::RuntimeKeymap::defaults().pager,
|
||||
));
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
||||
app.has_emitted_history_lines = true;
|
||||
app.backtrack.primed = true;
|
||||
|
||||
@@ -34,14 +34,13 @@ use crate::app_event::AppEvent;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
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;
|
||||
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.
|
||||
@@ -109,60 +108,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;
|
||||
|
||||
@@ -14,6 +27,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;
|
||||
@@ -139,19 +155,29 @@ 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,
|
||||
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
|
||||
@@ -181,16 +207,19 @@ impl ApprovalOverlay {
|
||||
fn set_current(&mut self, request: ApprovalRequest) {
|
||||
self.current_complete = false;
|
||||
let header = build_header(&request);
|
||||
let (options, params) = Self::build_options(&request, header, &self.features);
|
||||
let (options, params) =
|
||||
Self::build_options(&request, header, &self.features, &self.approval_keymap);
|
||||
self.current_request = Some(request);
|
||||
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(
|
||||
request: &ApprovalRequest,
|
||||
header: Box<dyn Renderable>,
|
||||
_features: &Features,
|
||||
approval_keymap: &ApprovalKeymap,
|
||||
) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
||||
let (options, title) = match request {
|
||||
ApprovalRequest::Exec {
|
||||
@@ -203,6 +232,7 @@ impl ApprovalOverlay {
|
||||
available_decisions,
|
||||
network_approval_context.as_ref(),
|
||||
additional_permissions.as_ref(),
|
||||
approval_keymap,
|
||||
),
|
||||
network_approval_context.as_ref().map_or_else(
|
||||
|| "Would you like to run the following command?".to_string(),
|
||||
@@ -215,15 +245,15 @@ impl ApprovalOverlay {
|
||||
),
|
||||
),
|
||||
ApprovalRequest::Permissions { .. } => (
|
||||
permissions_options(),
|
||||
permissions_options(approval_keymap),
|
||||
"Would you like to grant these permissions?".to_string(),
|
||||
),
|
||||
ApprovalRequest::ApplyPatch { .. } => (
|
||||
patch_options(),
|
||||
patch_options(approval_keymap),
|
||||
"Would you like to make the following edits?".to_string(),
|
||||
),
|
||||
ApprovalRequest::McpElicitation { server_name, .. } => (
|
||||
elicitation_options(),
|
||||
elicitation_options(approval_keymap),
|
||||
format!("{server_name} needs your approval."),
|
||||
),
|
||||
};
|
||||
@@ -238,9 +268,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()
|
||||
})
|
||||
@@ -402,51 +430,39 @@ 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
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press,
|
||||
code: KeyCode::Char('o'),
|
||||
..
|
||||
} => {
|
||||
if let Some(request) = self.current_request.as_ref() {
|
||||
if request.thread_label().is_some() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::SelectAgentThread(request.thread_id()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} 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 key_event.kind == KeyEventKind::Press
|
||||
&& matches!(key_event.code, KeyCode::Char('o'))
|
||||
&& let Some(request) = self.current_request.as_ref()
|
||||
&& request.thread_label().is_some()
|
||||
{
|
||||
self.app_event_tx
|
||||
.send(AppEvent::SelectAgentThread(request.thread_id()));
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -682,22 +698,14 @@ 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(
|
||||
available_decisions: &[ReviewDecision],
|
||||
network_approval_context: Option<&NetworkApprovalContext>,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
keymap: &ApprovalKeymap,
|
||||
) -> Vec<ApprovalOption> {
|
||||
available_decisions
|
||||
.iter()
|
||||
@@ -709,8 +717,7 @@ fn exec_options(
|
||||
"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(),
|
||||
}),
|
||||
ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment,
|
||||
@@ -730,8 +737,7 @@ fn exec_options(
|
||||
proposed_execpolicy_amendment: proposed_execpolicy_amendment.clone(),
|
||||
},
|
||||
),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
|
||||
shortcuts: keymap.approve_for_prefix.clone(),
|
||||
})
|
||||
}
|
||||
ReviewDecision::ApprovedForSession => Some(ApprovalOption {
|
||||
@@ -743,20 +749,19 @@ fn exec_options(
|
||||
"Yes, and don't ask again for this command in 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(),
|
||||
}),
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => {
|
||||
let (label, shortcut) = match network_policy_amendment.action {
|
||||
let (label, shortcuts) = match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => (
|
||||
"Yes, and allow this host in the future".to_string(),
|
||||
KeyCode::Char('p'),
|
||||
keymap.approve_for_prefix.clone(),
|
||||
),
|
||||
NetworkPolicyRuleAction::Deny => (
|
||||
"No, and block this host in the future".to_string(),
|
||||
KeyCode::Char('d'),
|
||||
Vec::new(),
|
||||
),
|
||||
};
|
||||
Some(ApprovalOption {
|
||||
@@ -764,22 +769,19 @@ fn exec_options(
|
||||
decision: ApprovalDecision::Review(ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment: network_policy_amendment.clone(),
|
||||
}),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(shortcut)],
|
||||
shortcuts,
|
||||
})
|
||||
}
|
||||
ReviewDecision::Denied => Some(ApprovalOption {
|
||||
label: "No, continue without running it".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Denied),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('d'))],
|
||||
shortcuts: Vec::new(),
|
||||
}),
|
||||
ReviewDecision::TimedOut => None,
|
||||
ReviewDecision::Abort => Some(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()
|
||||
@@ -828,71 +830,90 @@ pub(crate) fn format_requested_permissions_rule(
|
||||
format_additional_permissions_rule(&permissions.clone().into())
|
||||
}
|
||||
|
||||
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 permissions_options() -> Vec<ApprovalOption> {
|
||||
fn permissions_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
|
||||
let deny_shortcuts = keymap
|
||||
.decline
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|shortcut| shortcut.parts() != (KeyCode::Esc, KeyModifiers::NONE))
|
||||
.collect();
|
||||
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Yes, grant these permissions".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, grant these permissions 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, continue without permissions".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Denied),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
shortcuts: deny_shortcuts,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -907,6 +928,7 @@ mod tests {
|
||||
use codex_protocol::protocol::NetworkApprovalProtocol;
|
||||
use codex_protocol::protocol::NetworkPolicyAmendment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -942,6 +964,37 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
thread_id: ThreadId::new(),
|
||||
@@ -973,11 +1026,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_elicitation_request() -> ApprovalRequest {
|
||||
ApprovalRequest::McpElicitation {
|
||||
thread_id: ThreadId::new(),
|
||||
thread_label: None,
|
||||
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());
|
||||
@@ -988,7 +1051,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 thread-scoped approval op message in the queue.
|
||||
@@ -1006,7 +1069,7 @@ mod tests {
|
||||
fn resolved_request_dismisses_overlay_without_emitting_abort() {
|
||||
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.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval {
|
||||
@@ -1028,7 +1091,7 @@ mod tests {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let thread_id = ThreadId::new();
|
||||
let mut view = ApprovalOverlay::new(
|
||||
let mut view = make_overlay(
|
||||
ApprovalRequest::Exec {
|
||||
thread_id,
|
||||
thread_label: Some("Robie [explorer]".to_string()),
|
||||
@@ -1056,7 +1119,7 @@ mod tests {
|
||||
fn cross_thread_footer_hint_mentions_o_shortcut() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let view = ApprovalOverlay::new(
|
||||
let view = make_overlay(
|
||||
ApprovalRequest::Exec {
|
||||
thread_id: ThreadId::new(),
|
||||
thread_label: Some("Robie [explorer]".to_string()),
|
||||
@@ -1081,7 +1144,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 {
|
||||
thread_id: ThreadId::new(),
|
||||
thread_label: None,
|
||||
@@ -1133,7 +1196,7 @@ mod tests {
|
||||
fn network_deny_forever_shortcut_is_not_bound() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view = ApprovalOverlay::new(
|
||||
let mut view = make_overlay(
|
||||
ApprovalRequest::Exec {
|
||||
thread_id: ThreadId::new(),
|
||||
thread_label: None,
|
||||
@@ -1184,12 +1247,9 @@ mod tests {
|
||||
additional_permissions: None,
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)));
|
||||
view.render(
|
||||
Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)),
|
||||
&mut buf,
|
||||
);
|
||||
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);
|
||||
|
||||
let rendered: Vec<String> = (0..buf.area.height)
|
||||
.map(|row| {
|
||||
@@ -1212,6 +1272,7 @@ mod tests {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
let options = exec_options(
|
||||
&[
|
||||
ReviewDecision::Approved,
|
||||
@@ -1226,6 +1287,7 @@ mod tests {
|
||||
],
|
||||
Some(&network_context),
|
||||
/*additional_permissions*/ None,
|
||||
&keymap.approval,
|
||||
);
|
||||
|
||||
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
|
||||
@@ -1242,6 +1304,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generic_exec_options_can_offer_allow_for_session() {
|
||||
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
let options = exec_options(
|
||||
&[
|
||||
ReviewDecision::Approved,
|
||||
@@ -1250,6 +1313,7 @@ mod tests {
|
||||
],
|
||||
/*network_approval_context*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
&keymap.approval,
|
||||
);
|
||||
|
||||
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
|
||||
@@ -1265,6 +1329,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn additional_permissions_exec_options_hide_execpolicy_amendment() {
|
||||
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
let additional_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![absolute_path("/tmp/readme.txt")]),
|
||||
@@ -1276,6 +1341,7 @@ mod tests {
|
||||
&[ReviewDecision::Approved, ReviewDecision::Abort],
|
||||
/*network_approval_context*/ None,
|
||||
Some(&additional_permissions),
|
||||
&keymap.approval,
|
||||
);
|
||||
|
||||
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
|
||||
@@ -1290,7 +1356,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn permissions_options_use_expected_labels() {
|
||||
let labels: Vec<String> = permissions_options()
|
||||
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
let labels: Vec<String> = permissions_options(&keymap.approval)
|
||||
.into_iter()
|
||||
.map(|option| option.label)
|
||||
.collect();
|
||||
@@ -1308,8 +1375,7 @@ mod tests {
|
||||
fn permissions_session_shortcut_submits_session_scope() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view =
|
||||
ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults());
|
||||
let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
|
||||
@@ -1354,12 +1420,9 @@ mod tests {
|
||||
}),
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 120, view.desired_height(/*width*/ 120)));
|
||||
view.render(
|
||||
Rect::new(0, 0, 120, view.desired_height(/*width*/ 120)),
|
||||
&mut buf,
|
||||
);
|
||||
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);
|
||||
|
||||
let rendered: Vec<String> = (0..buf.area.height)
|
||||
.map(|row| {
|
||||
@@ -1404,7 +1467,7 @@ mod tests {
|
||||
}),
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
|
||||
let view = make_overlay(exec_request, tx, Features::with_defaults());
|
||||
assert_snapshot!(
|
||||
"approval_overlay_additional_permissions_prompt",
|
||||
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
|
||||
@@ -1415,7 +1478,7 @@ mod tests {
|
||||
fn permissions_prompt_snapshot() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let view = ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults());
|
||||
let view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
|
||||
assert_snapshot!(
|
||||
"approval_overlay_permissions_prompt",
|
||||
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
|
||||
@@ -1450,7 +1513,7 @@ mod tests {
|
||||
additional_permissions: 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, 100, view.desired_height(/*width*/ 100)));
|
||||
view.render(
|
||||
Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)),
|
||||
@@ -1482,6 +1545,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![
|
||||
@@ -1513,11 +1597,97 @@ 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::SubmitThreadOp {
|
||||
op: 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::SubmitThreadOp {
|
||||
op: 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::SubmitThreadOp {
|
||||
op: 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!(
|
||||
|
||||
@@ -183,6 +183,10 @@ use super::slash_commands;
|
||||
use super::slash_commands::BuiltinCommandFlags;
|
||||
use crate::bottom_pane::paste_burst::FlushResult;
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
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;
|
||||
@@ -380,6 +384,13 @@ pub(crate) struct ChatComposer {
|
||||
// Agent label injected into the footer's contextual row when multi-agent mode is active.
|
||||
active_agent_label: Option<String>,
|
||||
history_search: Option<HistorySearchSession>,
|
||||
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)]
|
||||
@@ -526,6 +537,16 @@ impl ChatComposer {
|
||||
side_conversation_context_label: None,
|
||||
active_agent_label: None,
|
||||
history_search: None,
|
||||
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);
|
||||
@@ -599,6 +620,17 @@ impl ChatComposer {
|
||||
self.fast_command_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>,
|
||||
@@ -1523,7 +1555,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));
|
||||
@@ -1580,7 +1612,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
|
||||
@@ -2760,6 +2792,14 @@ impl ChatComposer {
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
if self.queue_keys.is_pressed(key_event) && !self.is_bang_shell_command() {
|
||||
return self.handle_submission(self.is_task_running);
|
||||
}
|
||||
|
||||
if self.submit_keys.is_pressed(key_event) {
|
||||
return self.handle_submission(/*should_queue*/ false);
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
@@ -2971,7 +3011,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);
|
||||
@@ -3039,13 +3079,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();
|
||||
|
||||
@@ -5035,6 +5080,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]
|
||||
|
||||
@@ -810,10 +810,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;
|
||||
|
||||
@@ -265,6 +267,7 @@ pub(crate) struct ListSelectionView {
|
||||
|
||||
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
|
||||
on_cancel: OnCancelCallback,
|
||||
keymap: ListKeymap,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
@@ -275,7 +278,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()));
|
||||
@@ -330,6 +337,7 @@ impl ListSelectionView {
|
||||
preserve_side_content_bg: params.preserve_side_content_bg,
|
||||
on_selection_changed: params.on_selection_changed,
|
||||
on_cancel: params.on_cancel,
|
||||
keymap,
|
||||
};
|
||||
s.apply_filter();
|
||||
if s.tabs_enabled() && !has_initial_selected_idx && s.state.selected_idx.is_none() {
|
||||
@@ -746,23 +754,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::Left,
|
||||
..
|
||||
@@ -771,30 +802,6 @@ impl BottomPaneView for ListSelectionView {
|
||||
code: KeyCode::Right,
|
||||
..
|
||||
} if self.tabs_enabled() => self.switch_tab(/*step*/ 1),
|
||||
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,
|
||||
..
|
||||
@@ -818,9 +825,7 @@ impl BottomPaneView for ListSelectionView {
|
||||
} if self.is_searchable
|
||||
&& self.search_query.is_empty()
|
||||
&& self.selected_item_has_toggle_placeholder() => {}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
_ if self.keymap.cancel.is_pressed(key_event) => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
@@ -866,11 +871,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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1255,6 +1256,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1274,7 +1279,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
ListSelectionView::new(
|
||||
new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
subtitle: subtitle.map(str::to_string),
|
||||
@@ -1354,6 +1359,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
@@ -1391,7 +1397,7 @@ mod tests {
|
||||
Some(&codex_home),
|
||||
Some(94),
|
||||
);
|
||||
let view = ListSelectionView::new(params, tx);
|
||||
let view = new_view(params, tx);
|
||||
|
||||
let rendered = render_lines_in_area(&view, /*width*/ 94, /*height*/ 35);
|
||||
assert!(rendered.contains("Move up/down to live preview themes"));
|
||||
@@ -1433,6 +1439,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let area = Rect::new(0, 0, 120, 35);
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -1469,7 +1476,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),
|
||||
@@ -1496,7 +1503,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()),
|
||||
@@ -1549,6 +1556,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("beta".to_string());
|
||||
|
||||
@@ -1611,6 +1619,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.active_tab_id(), Some("beta"));
|
||||
@@ -1642,6 +1651,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("plugin".to_string());
|
||||
|
||||
@@ -1681,6 +1691,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let (wrapped_tx_raw, _wrapped_rx) = unbounded_channel::<AppEvent>();
|
||||
let wrapped_tx = AppEventSender::new(wrapped_tx_raw);
|
||||
@@ -1699,6 +1710,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
wrapped_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&single_line_view, /*width*/ 36);
|
||||
@@ -1754,6 +1766,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
auto_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let (widened_tx_raw, _widened_rx) = unbounded_channel::<AppEvent>();
|
||||
let widened_tx = AppEventSender::new(widened_tx_raw);
|
||||
@@ -1766,6 +1779,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
widened_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let auto_rendered = render_lines_with_width(&auto_view, /*width*/ 48);
|
||||
@@ -1797,6 +1811,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("no-matches".to_string());
|
||||
|
||||
@@ -1827,6 +1842,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
@@ -1855,7 +1871,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Approval".to_string()),
|
||||
items,
|
||||
@@ -1913,7 +1929,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Model and Effort".to_string()),
|
||||
items,
|
||||
@@ -1947,7 +1963,7 @@ mod tests {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items,
|
||||
@@ -1995,7 +2011,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Model and Effort".to_string()),
|
||||
items,
|
||||
@@ -2022,7 +2038,7 @@ mod tests {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items,
|
||||
@@ -2073,6 +2089,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, /*width*/ 96);
|
||||
@@ -2107,6 +2124,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
@@ -2148,6 +2166,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let content_width: u16 = 120;
|
||||
@@ -2175,6 +2194,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.side_layout_width(/*content_width*/ 80), None);
|
||||
@@ -2205,6 +2225,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&view, /*width*/ 70);
|
||||
@@ -2239,6 +2260,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
@@ -2298,6 +2320,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
|
||||
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;
|
||||
@@ -199,6 +200,7 @@ pub(crate) struct BottomPane {
|
||||
pending_thread_approvals: PendingThreadApprovals,
|
||||
context_window_percent: Option<i64>,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
keymap: RuntimeKeymap,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
@@ -232,6 +234,8 @@ impl BottomPane {
|
||||
disable_paste_burst,
|
||||
);
|
||||
composer.set_frame_requester(frame_requester.clone());
|
||||
let keymap = RuntimeKeymap::defaults();
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
composer.set_skill_mentions(skills);
|
||||
Self {
|
||||
composer,
|
||||
@@ -250,6 +254,7 @@ impl BottomPane {
|
||||
animations_enabled,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
keymap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +302,12 @@ impl BottomPane {
|
||||
self.composer.record_pending_slash_command_history();
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -836,7 +847,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));
|
||||
}
|
||||
|
||||
@@ -855,7 +870,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
|
||||
}
|
||||
@@ -973,7 +992,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));
|
||||
}
|
||||
|
||||
+3
-1
@@ -15,4 +15,6 @@ expression: terminal.backend()
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + t to view transcript "
|
||||
" "
|
||||
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "
|
||||
|
||||
+3
-2
@@ -7,5 +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 + r search history ctrl + c to exit "
|
||||
" shift + tab to change mode "
|
||||
" ctrl + t to view transcript "
|
||||
" shift + tab to change mode ctrl + t to view transcript "
|
||||
" "
|
||||
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "
|
||||
|
||||
+3
-1
@@ -7,4 +7,6 @@ expression: terminal.backend()
|
||||
" @ for file paths ctrl + v to paste images "
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + t to view transcript "
|
||||
" "
|
||||
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
//! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most
|
||||
//! recent killed span.
|
||||
|
||||
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;
|
||||
@@ -92,6 +95,7 @@ pub(crate) struct TextArea {
|
||||
elements: Vec<TextElement>,
|
||||
next_element_id: u64,
|
||||
kill_buffer: String,
|
||||
editor_keymap: EditorKeymap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -116,9 +120,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 visible textarea text and clear any existing text elements.
|
||||
///
|
||||
/// This is the "fresh buffer" path for callers that want plain text with no placeholder
|
||||
@@ -319,243 +328,159 @@ impl TextArea {
|
||||
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
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(/*n*/ 1),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => self.delete_forward_word(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.delete_forward(/*n*/ 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();
|
||||
}
|
||||
// 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(/*move_up_at_bol*/ false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true);
|
||||
}
|
||||
|
||||
KeyEvent {
|
||||
code: KeyCode::End, ..
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(/*move_down_at_eol*/ false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let _ = event;
|
||||
}
|
||||
|
||||
// ####### Input Functions #######
|
||||
|
||||
@@ -348,8 +348,8 @@ use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::WebSearchCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
#[cfg(test)]
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
@@ -795,6 +795,7 @@ pub(crate) struct ChatWidget {
|
||||
plan_stream_controller: Option<PlanStreamController>,
|
||||
/// Holds the platform clipboard lease so copied text remains available while supported.
|
||||
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
|
||||
copy_last_response_binding: Vec<KeyBinding>,
|
||||
/// Raw markdown of the most recently completed agent response.
|
||||
///
|
||||
/// This cache is intentionally best-effort: if the user rolls back the
|
||||
@@ -5012,6 +5013,11 @@ impl ChatWidget {
|
||||
|
||||
let current_cwd = Some(config.cwd.to_path_buf());
|
||||
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
|
||||
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
|
||||
let copy_last_response_binding = runtime_keymap
|
||||
.as_ref()
|
||||
.map(|keymap| keymap.app.copy.clone())
|
||||
.unwrap_or_else(|| RuntimeKeymap::defaults().app.copy);
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
@@ -5052,6 +5058,7 @@ impl ChatWidget {
|
||||
stream_controller: None,
|
||||
plan_stream_controller: None,
|
||||
clipboard_lease: None,
|
||||
copy_last_response_binding,
|
||||
running_commands: HashMap::new(),
|
||||
collab_agent_metadata: HashMap::new(),
|
||||
pending_collab_spawn_requests: HashMap::new(),
|
||||
@@ -5145,6 +5152,10 @@ impl ChatWidget {
|
||||
last_non_retry_error: None,
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
if let Some(keymap) = runtime_keymap {
|
||||
widget.bottom_pane.set_keymap_bindings(&keymap);
|
||||
}
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
|
||||
@@ -5182,20 +5193,17 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& self.copy_last_response_binding.is_pressed(key_event)
|
||||
{
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.copy_last_agent_markdown();
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
// Ctrl+O - copy last agent response from the main view.
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('o'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.copy_last_agent_markdown();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
@@ -7178,7 +7186,7 @@ impl ChatWidget {
|
||||
} else {
|
||||
// Show explanation when there are no structured findings.
|
||||
let mut rendered: Vec<ratatui::text::Line<'static>> = vec!["".into()];
|
||||
append_markdown(
|
||||
crate::markdown::append_markdown(
|
||||
&explanation,
|
||||
/*width*/ None,
|
||||
Some(self.config.cwd.as_path()),
|
||||
@@ -11078,6 +11086,18 @@ impl ChatWidget {
|
||||
&self.config
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_keymap_update(
|
||||
&mut self,
|
||||
keymap_config: codex_config::types::TuiKeymap,
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
) {
|
||||
self.config.tui_keymap = keymap_config;
|
||||
self.copy_last_response_binding = runtime_keymap.app.copy.clone();
|
||||
self.bottom_pane.set_keymap_bindings(runtime_keymap);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn status_line_text(&self) -> Option<String> {
|
||||
self.bottom_pane.status_line_text()
|
||||
|
||||
@@ -209,6 +209,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
stream_controller: None,
|
||||
plan_stream_controller: None,
|
||||
clipboard_lease: None,
|
||||
copy_last_response_binding: crate::keymap::RuntimeKeymap::defaults().app.copy,
|
||||
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
|
||||
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
||||
last_agent_markdown: None,
|
||||
|
||||
@@ -880,6 +880,33 @@ async fn ctrl_o_copy_reports_when_no_agent_response_exists() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_shortcut_can_be_remapped() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut keymap_config = chat.config_ref().tui_keymap.clone();
|
||||
keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One(
|
||||
codex_config::types::KeybindingSpec("ctrl-u".to_string()),
|
||||
));
|
||||
let runtime_keymap =
|
||||
crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid copy remap");
|
||||
chat.apply_keymap_update(keymap_config, &runtime_keymap);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"old copy shortcut should no longer copy"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected one info message");
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
rendered.contains("No agent response to copy"),
|
||||
"expected remapped copy shortcut to run, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,7 @@ mod history_cell;
|
||||
pub(crate) mod insert_history;
|
||||
pub use insert_history::insert_history_lines;
|
||||
mod key_hint;
|
||||
mod keymap;
|
||||
mod line_truncation;
|
||||
pub(crate) mod live_wrap;
|
||||
pub use live_wrap::RowBuilder;
|
||||
|
||||
@@ -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_app_server_client::AppServerRequestHandle;
|
||||
@@ -37,6 +44,11 @@ use std::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
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;
|
||||
@@ -185,39 +197,42 @@ impl KeyboardHandler for AuthModeWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.move_highlight(/*delta*/ -1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.move_highlight(/*delta*/ 1);
|
||||
}
|
||||
KeyCode::Char('1') => {
|
||||
self.select_option_by_index(/*index*/ 0);
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.select_option_by_index(/*index*/ 1);
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
self.select_option_by_index(/*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);
|
||||
}
|
||||
SignInState::ChatGptSuccessMessage => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
tracing::info!("Esc pressed");
|
||||
self.cancel_active_attempt();
|
||||
}
|
||||
_ => {}
|
||||
return;
|
||||
}
|
||||
if self.onboarding_keymap.cancel.is_pressed(key_event) {
|
||||
tracing::info!("Cancel onboarding auth step");
|
||||
self.cancel_active_attempt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +253,7 @@ pub(crate) struct AuthModeWidget {
|
||||
pub forced_login_method: Option<ForcedLoginMethod>,
|
||||
pub animations_enabled: bool,
|
||||
pub animations_suppressed: Cell<bool>,
|
||||
pub onboarding_keymap: OnboardingKeymap,
|
||||
}
|
||||
|
||||
impl AuthModeWidget {
|
||||
@@ -286,6 +302,28 @@ impl AuthModeWidget {
|
||||
self.error.read().unwrap().clone()
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
@@ -455,11 +493,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_message() {
|
||||
lines.push("".into());
|
||||
lines.push(err.red().into());
|
||||
@@ -494,7 +532,9 @@ 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(),
|
||||
]));
|
||||
@@ -504,7 +544,11 @@ impl AuthModeWidget {
|
||||
None
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -539,7 +583,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)
|
||||
@@ -618,8 +666,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_message() {
|
||||
footer_lines.push("".into());
|
||||
@@ -637,46 +693,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.set_error(/*message*/ None);
|
||||
if self.onboarding_keymap.cancel.is_pressed(*key_event) {
|
||||
*guard = SignInState::PickMode;
|
||||
self.set_error(/*message*/ 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.set_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.set_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.set_error(/*message*/ 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.set_error(/*message*/ None);
|
||||
should_request_frame = true;
|
||||
}
|
||||
self.set_error(/*message*/ 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.set_error(/*message*/ None);
|
||||
should_request_frame = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// handled; let guard drop before potential save
|
||||
} else {
|
||||
@@ -1013,6 +1069,7 @@ mod tests {
|
||||
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
|
||||
animations_enabled: true,
|
||||
animations_suppressed: std::cell::Cell::new(false),
|
||||
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
};
|
||||
(widget, codex_home)
|
||||
}
|
||||
|
||||
@@ -137,7 +137,11 @@ pub(super) fn render_device_code_login(
|
||||
None
|
||||
};
|
||||
|
||||
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,6 +1,15 @@
|
||||
use crate::legacy_core::config::Config;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
|
||||
//! 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_app_server_client::AppServerEvent;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
@@ -11,6 +20,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;
|
||||
@@ -22,6 +32,12 @@ use codex_protocol::config_types::ForcedLoginMethod;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::OnboardingKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::legacy_core::config::Config;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use crate::onboarding::auth::AuthModeWidget;
|
||||
use crate::onboarding::auth::SignInOption;
|
||||
use crate::onboarding::auth::SignInState;
|
||||
@@ -61,6 +77,7 @@ pub(crate) trait StepStateProvider {
|
||||
pub(crate) struct OnboardingScreen {
|
||||
request_frame: FrameRequester,
|
||||
steps: Vec<Step>,
|
||||
onboarding_keymap: OnboardingKeymap,
|
||||
is_done: bool,
|
||||
should_exit: bool,
|
||||
}
|
||||
@@ -78,6 +95,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) async fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
|
||||
let OnboardingScreenArgs {
|
||||
@@ -90,11 +115,22 @@ impl OnboardingScreen {
|
||||
let cwd = config.cwd.to_path_buf();
|
||||
let codex_home = config.codex_home.to_path_buf();
|
||||
let forced_login_method = config.forced_login_method;
|
||||
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\
|
||||
See the Codex keymap documentation for supported actions and examples."
|
||||
);
|
||||
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 {
|
||||
@@ -112,6 +148,7 @@ impl OnboardingScreen {
|
||||
forced_login_method,
|
||||
animations_enabled: config.animations,
|
||||
animations_suppressed: std::cell::Cell::new(false),
|
||||
onboarding_keymap: onboarding_keymap.clone(),
|
||||
}));
|
||||
} else {
|
||||
tracing::warn!("skipping onboarding login step without app-server request handle");
|
||||
@@ -137,12 +174,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,
|
||||
}
|
||||
@@ -249,45 +288,39 @@ impl OnboardingScreen {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
self.cancel_auth_if_active();
|
||||
@@ -333,6 +366,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) {
|
||||
let suppress_animations = self.should_suppress_animations();
|
||||
@@ -545,3 +596,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,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)]
|
||||
@@ -95,7 +99,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 {
|
||||
@@ -117,20 +123,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(),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +200,7 @@ mod tests {
|
||||
selection: None,
|
||||
highlighted: TrustDirectorySelection::Quit,
|
||||
error: None,
|
||||
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
};
|
||||
|
||||
let release = KeyEvent {
|
||||
@@ -218,6 +227,7 @@ mod tests {
|
||||
selection: None,
|
||||
highlighted: TrustDirectorySelection::Trust,
|
||||
error: None,
|
||||
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
};
|
||||
|
||||
let mut 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;
|
||||
@@ -28,17 +29,21 @@ pub(crate) struct WelcomeWidget {
|
||||
animation: AsciiAnimation,
|
||||
animations_enabled: bool,
|
||||
animations_suppressed: Cell<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();
|
||||
@@ -51,12 +56,14 @@ 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,
|
||||
animations_suppressed: Cell::new(false),
|
||||
toggle_animation_keys: onboarding_keymap.toggle_animation,
|
||||
layout_area: Cell::new(None),
|
||||
}
|
||||
}
|
||||
@@ -115,6 +122,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;
|
||||
@@ -139,6 +148,7 @@ mod tests {
|
||||
/*is_logged_in*/ false,
|
||||
FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ true,
|
||||
crate::keymap::RuntimeKeymap::defaults().onboarding,
|
||||
);
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -155,6 +165,7 @@ mod tests {
|
||||
/*is_logged_in*/ false,
|
||||
FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ 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);
|
||||
@@ -175,6 +186,9 @@ mod tests {
|
||||
),
|
||||
animations_enabled: true,
|
||||
animations_suppressed: Cell::new(false),
|
||||
toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults()
|
||||
.onboarding
|
||||
.toggle_animation,
|
||||
layout_area: Cell::new(None),
|
||||
};
|
||||
|
||||
@@ -187,4 +201,30 @@ 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,
|
||||
animations_suppressed: Cell::new(false),
|
||||
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;
|
||||
}
|
||||
_ => {
|
||||
@@ -454,12 +438,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, /*highlight_cell*/ None),
|
||||
"T R A N S C R I P T".to_string(),
|
||||
usize::MAX,
|
||||
keymap,
|
||||
),
|
||||
cells: transcript_cells,
|
||||
highlight_cell: None,
|
||||
@@ -668,15 +653,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);
|
||||
}
|
||||
@@ -694,7 +715,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(())
|
||||
}
|
||||
@@ -725,14 +748,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, /*scroll_offset*/ 0),
|
||||
view: PagerView::new(renderables, title, 0, keymap),
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
@@ -740,8 +775,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);
|
||||
}
|
||||
|
||||
@@ -758,7 +820,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(())
|
||||
}
|
||||
@@ -851,9 +913,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")],
|
||||
})]);
|
||||
|
||||
@@ -871,7 +958,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));
|
||||
@@ -891,7 +978,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")],
|
||||
}),
|
||||
@@ -910,7 +997,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(
|
||||
@@ -931,7 +1018,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")],
|
||||
})]);
|
||||
|
||||
@@ -1027,7 +1114,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);
|
||||
|
||||
@@ -1041,7 +1128,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 {
|
||||
@@ -1068,7 +1155,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 {
|
||||
@@ -1093,9 +1180,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()))
|
||||
@@ -1131,7 +1218,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 {
|
||||
@@ -1199,9 +1286,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()))
|
||||
@@ -1211,13 +1298,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_content_height_counts_renderables() {
|
||||
let pv = PagerView::new(
|
||||
vec![
|
||||
paragraph_block("a", /*lines*/ 2),
|
||||
paragraph_block("b", /*lines*/ 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
/*scroll_offset*/ 0,
|
||||
let pv = pager_view(
|
||||
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
|
||||
"T",
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(pv.content_height(/*width*/ 80), 5);
|
||||
@@ -1225,14 +1309,14 @@ 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", /*lines*/ 1),
|
||||
paragraph_block("b", /*lines*/ 3),
|
||||
paragraph_block("c", /*lines*/ 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
/*scroll_offset*/ 0,
|
||||
"T",
|
||||
0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 8);
|
||||
|
||||
@@ -1260,14 +1344,14 @@ 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", /*lines*/ 2),
|
||||
paragraph_block("b", /*lines*/ 3),
|
||||
paragraph_block("c", /*lines*/ 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
/*scroll_offset*/ 0,
|
||||
"T",
|
||||
0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 3);
|
||||
|
||||
@@ -1279,11 +1363,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
|
||||
let mut pv = PagerView::new(
|
||||
vec![paragraph_block("a", /*lines*/ 10)],
|
||||
"T".to_string(),
|
||||
/*scroll_offset*/ 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);
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ 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.
|
||||
See the Codex keymap documentation for supported actions and examples.
|
||||
Paste an image with Ctrl+V to attach it to your next message.
|
||||
You can resume a previous conversation by running `codex resume`
|
||||
Use /copy or press Ctrl+O to copy the latest agent response as Markdown.
|
||||
|
||||
Reference in New Issue
Block a user