mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
53468b97f6
## Why On light terminal backgrounds, selected rows in several TUI pickers were rendered with the same bright cyan accent used on dark themes. Against the light menu surface, that made the current selection hard to distinguish at a glance. <table><tr> <td> <p align="center">Before</p> <img width="1109" height="864" alt="SCR-20260509-nmtz" src="https://github.com/user-attachments/assets/b31ce0d0-19c2-4bdd-a220-7acc77bd8e8e" /> </td> <td> <p align="center">After</p> <img width="1164" height="844" alt="SCR-20260509-nmox" src="https://github.com/user-attachments/assets/7b3fede0-4739-4a9f-a979-cdbb7451841f" /> </td> </tr></table> ## What changed - Added a shared background-aware accent style for active/selected TUI controls. - Use a darker cyan-family accent on light backgrounds while preserving the existing bright cyan accent on dark or unknown backgrounds. - Reused that accent across shared picker rows and the custom selection-like surfaces that had drifted separately: picker tabs, hooks browsing, external-agent migration choices, and /keymap affordances. - Added focused tests for the light/dark accent rule and rendered selected-row styling. ## How to Test 1. Start Codex in a terminal using a light background theme. 2. Type `/` to open the slash-command picker and move the selection through a few rows. 3. Confirm that the selected row is visibly colored with strong contrast instead of blending into the popup surface. 4. Open `/keymap` and confirm the active tab, selected rows, and picker hint accents use the same light-theme accent treatment. 5. In a dark terminal theme, repeat the slash-picker check and confirm the existing bright cyan selection styling is preserved. Targeted tests: - `cargo test -p codex-tui accent_style_uses_` - `cargo test -p codex-tui selected_rows_use_the_shared_accent_style` - `cargo test -p codex-tui selected_event_rows_use_the_shared_accent_style` Notes: - A full `cargo test -p codex-tui` run reached the end of the suite but hit an unrelated existing stack overflow in `tests::fork_last_filters_latest_session_by_cwd_unless_show_all`.
479 lines
14 KiB
Rust
479 lines
14 KiB
Rust
//! Shortcut picker construction for `/keymap`.
|
|
|
|
use codex_config::types::TuiKeymap;
|
|
use ratatui::style::Styled;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::app_event::AppEvent;
|
|
use crate::bottom_pane::ColumnWidthMode;
|
|
use crate::bottom_pane::SelectionItem;
|
|
use crate::bottom_pane::SelectionRowDisplay;
|
|
use crate::bottom_pane::SelectionTab;
|
|
use crate::bottom_pane::SelectionViewParams;
|
|
use crate::keymap::RuntimeKeymap;
|
|
use crate::render::renderable::ColumnRenderable;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::style::accent_style;
|
|
|
|
use super::actions::KEYMAP_ACTIONS;
|
|
use super::actions::KeymapActionFilter;
|
|
use super::actions::action_label;
|
|
use super::actions::bindings_for_action;
|
|
use super::actions::format_binding_summary;
|
|
use super::has_custom_binding;
|
|
|
|
pub(crate) const KEYMAP_PICKER_VIEW_ID: &str = "keymap-picker";
|
|
pub(super) const KEYMAP_ALL_TAB_ID: &str = "all-shortcuts";
|
|
pub(super) const KEYMAP_COMMON_TAB_ID: &str = "common-shortcuts";
|
|
pub(super) const KEYMAP_CUSTOM_TAB_ID: &str = "custom-shortcuts";
|
|
pub(super) const KEYMAP_UNBOUND_TAB_ID: &str = "unbound-shortcuts";
|
|
pub(super) const KEYMAP_DEBUG_TAB_ID: &str = "debug-shortcuts";
|
|
const KEYMAP_CONTEXT_LABEL_WIDTH: usize = 12;
|
|
const KEYMAP_ROW_PREFIX_WIDTH: usize = KEYMAP_CONTEXT_LABEL_WIDTH + 3;
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct KeymapActionRow {
|
|
context: &'static str,
|
|
context_label: &'static str,
|
|
action: &'static str,
|
|
label: String,
|
|
description: &'static str,
|
|
binding_summary: String,
|
|
custom_binding: bool,
|
|
}
|
|
|
|
impl KeymapActionRow {
|
|
fn is_unbound(&self) -> bool {
|
|
self.binding_summary == "unbound"
|
|
}
|
|
}
|
|
|
|
struct KeymapContextTab {
|
|
id: &'static str,
|
|
label: &'static str,
|
|
description: &'static str,
|
|
contexts: &'static [&'static str],
|
|
}
|
|
|
|
const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[
|
|
("composer", "submit"),
|
|
("editor", "insert_newline"),
|
|
("composer", "queue"),
|
|
("global", "toggle_fast_mode"),
|
|
("global", "open_external_editor"),
|
|
("global", "copy"),
|
|
("global", "toggle_vim_mode"),
|
|
("editor", "delete_backward_word"),
|
|
("editor", "delete_forward_word"),
|
|
("editor", "move_word_left"),
|
|
("editor", "move_word_right"),
|
|
("global", "open_transcript"),
|
|
("pager", "close"),
|
|
("pager", "page_up"),
|
|
("pager", "page_down"),
|
|
("approval", "open_fullscreen"),
|
|
("approval", "approve"),
|
|
("approval", "approve_for_session"),
|
|
("approval", "decline"),
|
|
("approval", "cancel"),
|
|
];
|
|
|
|
const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
|
|
KeymapContextTab {
|
|
id: "app-shortcuts",
|
|
label: "App",
|
|
description: "Global and chat-level shortcuts.",
|
|
contexts: &["global", "chat"],
|
|
},
|
|
KeymapContextTab {
|
|
id: "composer-shortcuts",
|
|
label: "Composer",
|
|
description: "Composer submission and queue shortcuts.",
|
|
contexts: &["composer"],
|
|
},
|
|
KeymapContextTab {
|
|
id: "editor-shortcuts",
|
|
label: "Editor",
|
|
description: "Inline editor movement and editing shortcuts.",
|
|
contexts: &["editor"],
|
|
},
|
|
KeymapContextTab {
|
|
id: "vim-shortcuts",
|
|
label: "Vim",
|
|
description: "Vim normal-mode and operator shortcuts.",
|
|
contexts: &["vim_normal", "vim_operator"],
|
|
},
|
|
KeymapContextTab {
|
|
id: "navigation-shortcuts",
|
|
label: "Navigation",
|
|
description: "Pager and selection-list navigation shortcuts.",
|
|
contexts: &["pager", "list"],
|
|
},
|
|
KeymapContextTab {
|
|
id: "approval-shortcuts",
|
|
label: "Approval",
|
|
description: "Approval prompt shortcuts.",
|
|
contexts: &["approval"],
|
|
},
|
|
];
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn build_keymap_picker_params(
|
|
runtime_keymap: &RuntimeKeymap,
|
|
keymap_config: &TuiKeymap,
|
|
) -> SelectionViewParams {
|
|
build_keymap_picker_params_with_filter(
|
|
runtime_keymap,
|
|
keymap_config,
|
|
KeymapActionFilter::default(),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn build_keymap_picker_params_with_filter(
|
|
runtime_keymap: &RuntimeKeymap,
|
|
keymap_config: &TuiKeymap,
|
|
action_filter: KeymapActionFilter,
|
|
) -> SelectionViewParams {
|
|
build_keymap_picker_params_for_action(
|
|
runtime_keymap,
|
|
keymap_config,
|
|
action_filter,
|
|
/*selected_action*/ None,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn build_keymap_picker_params_for_selected_action(
|
|
runtime_keymap: &RuntimeKeymap,
|
|
keymap_config: &TuiKeymap,
|
|
context: &str,
|
|
action: &str,
|
|
) -> SelectionViewParams {
|
|
build_keymap_picker_params_for_selected_action_with_filter(
|
|
runtime_keymap,
|
|
keymap_config,
|
|
KeymapActionFilter::default(),
|
|
context,
|
|
action,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn build_keymap_picker_params_for_selected_action_with_filter(
|
|
runtime_keymap: &RuntimeKeymap,
|
|
keymap_config: &TuiKeymap,
|
|
action_filter: KeymapActionFilter,
|
|
context: &str,
|
|
action: &str,
|
|
) -> SelectionViewParams {
|
|
build_keymap_picker_params_for_action(
|
|
runtime_keymap,
|
|
keymap_config,
|
|
action_filter,
|
|
Some((context, action)),
|
|
)
|
|
}
|
|
|
|
fn build_keymap_picker_params_for_action(
|
|
runtime_keymap: &RuntimeKeymap,
|
|
keymap_config: &TuiKeymap,
|
|
action_filter: KeymapActionFilter,
|
|
selected_action: Option<(&str, &str)>,
|
|
) -> SelectionViewParams {
|
|
let rows = build_keymap_rows(runtime_keymap, keymap_config, action_filter);
|
|
let total = rows.len();
|
|
let custom_count = rows.iter().filter(|row| row.custom_binding).count();
|
|
let unbound_count = rows.iter().filter(|row| row.is_unbound()).count();
|
|
let initial_selected_idx = selected_action.and_then(|(context, action)| {
|
|
rows.iter()
|
|
.position(|row| row.context == context && row.action == action)
|
|
});
|
|
let name_column_width = rows
|
|
.iter()
|
|
.map(|row| KEYMAP_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(row.label.as_str()))
|
|
.max();
|
|
|
|
let mut tabs = Vec::new();
|
|
tabs.push(SelectionTab {
|
|
id: KEYMAP_ALL_TAB_ID.to_string(),
|
|
label: "All".to_string(),
|
|
header: keymap_header(
|
|
"All configurable shortcuts.".to_string(),
|
|
format!("{total} actions, {custom_count} customized, {unbound_count} unbound."),
|
|
),
|
|
items: keymap_selection_items(
|
|
rows.iter(),
|
|
"No shortcuts available",
|
|
"No configurable shortcuts are available.",
|
|
),
|
|
});
|
|
|
|
let common_rows = keymap_common_rows(&rows);
|
|
let common_count = common_rows.len();
|
|
tabs.push(SelectionTab {
|
|
id: KEYMAP_COMMON_TAB_ID.to_string(),
|
|
label: "Common".to_string(),
|
|
header: keymap_header(
|
|
"Frequently customized shortcuts.".to_string(),
|
|
action_count_line(common_count),
|
|
),
|
|
items: keymap_selection_items(
|
|
common_rows,
|
|
"No common shortcuts",
|
|
"No common shortcut actions are available.",
|
|
),
|
|
});
|
|
|
|
let custom_rows = rows
|
|
.iter()
|
|
.filter(|row| row.custom_binding)
|
|
.collect::<Vec<_>>();
|
|
tabs.push(SelectionTab {
|
|
id: KEYMAP_CUSTOM_TAB_ID.to_string(),
|
|
label: format!("Customized ({custom_count})"),
|
|
header: keymap_header(
|
|
"Root-level shortcut overrides.".to_string(),
|
|
action_count_line(custom_count),
|
|
),
|
|
items: keymap_selection_items(
|
|
custom_rows,
|
|
"No customized shortcuts",
|
|
"No root-level keymap overrides have been configured.",
|
|
),
|
|
});
|
|
|
|
let unbound_rows = rows
|
|
.iter()
|
|
.filter(|row| row.is_unbound())
|
|
.collect::<Vec<_>>();
|
|
tabs.push(SelectionTab {
|
|
id: KEYMAP_UNBOUND_TAB_ID.to_string(),
|
|
label: format!("Unbound ({unbound_count})"),
|
|
header: keymap_header(
|
|
"Actions without an active shortcut.".to_string(),
|
|
action_count_line(unbound_count),
|
|
),
|
|
items: keymap_selection_items(
|
|
unbound_rows,
|
|
"No unbound shortcuts",
|
|
"Every configurable action currently has a shortcut.",
|
|
),
|
|
});
|
|
|
|
for tab in KEYMAP_CONTEXT_TABS {
|
|
let tab_rows = rows
|
|
.iter()
|
|
.filter(|row| tab.contexts.contains(&row.context))
|
|
.collect::<Vec<_>>();
|
|
let count = tab_rows.len();
|
|
tabs.push(SelectionTab {
|
|
id: tab.id.to_string(),
|
|
label: tab.label.to_string(),
|
|
header: keymap_header(tab.description.to_string(), action_count_line(count)),
|
|
items: keymap_selection_items(
|
|
tab_rows,
|
|
"No shortcuts in this group",
|
|
"No configurable actions are available in this group.",
|
|
),
|
|
});
|
|
}
|
|
tabs.push(keymap_debug_tab());
|
|
|
|
SelectionViewParams {
|
|
view_id: Some(KEYMAP_PICKER_VIEW_ID),
|
|
header: Box::new(()),
|
|
footer_hint: Some(keymap_picker_hint_line()),
|
|
tab_footer_hints: vec![(KEYMAP_DEBUG_TAB_ID.to_string(), keymap_debug_hint_line())],
|
|
tabs,
|
|
initial_tab_id: Some(KEYMAP_ALL_TAB_ID.to_string()),
|
|
is_searchable: true,
|
|
search_placeholder: Some("Type to search shortcuts".to_string()),
|
|
col_width_mode: ColumnWidthMode::AutoAllRows,
|
|
row_display: SelectionRowDisplay::SingleLine,
|
|
name_column_width,
|
|
initial_selected_idx,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn keymap_debug_tab() -> SelectionTab {
|
|
SelectionTab {
|
|
id: KEYMAP_DEBUG_TAB_ID.to_string(),
|
|
label: "Debug".to_string(),
|
|
header: keymap_header(
|
|
"Inspect keypresses from your terminal.".to_string(),
|
|
"See the key Codex detects and any shortcuts assigned to it.".to_string(),
|
|
),
|
|
items: vec![SelectionItem {
|
|
name: "Inspect keypresses".to_string(),
|
|
description: Some(
|
|
"Press Enter to start. Then press any key to inspect it; Ctrl+C exits."
|
|
.to_string(),
|
|
),
|
|
selected_description: Some(
|
|
"Open a live inspector that shows the detected key, config key, and matching actions."
|
|
.to_string(),
|
|
),
|
|
actions: vec![Box::new(|tx| {
|
|
tx.send(AppEvent::OpenKeymapDebug);
|
|
})],
|
|
search_value: Some("debug inspect keypress key terminal detected actions".to_string()),
|
|
..Default::default()
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn build_keymap_rows(
|
|
runtime_keymap: &RuntimeKeymap,
|
|
keymap_config: &TuiKeymap,
|
|
action_filter: KeymapActionFilter,
|
|
) -> Vec<KeymapActionRow> {
|
|
KEYMAP_ACTIONS
|
|
.iter()
|
|
.copied()
|
|
.filter(|descriptor| descriptor.is_visible(action_filter))
|
|
.map(|descriptor| {
|
|
let bindings =
|
|
bindings_for_action(runtime_keymap, descriptor.context, descriptor.action)
|
|
.unwrap_or(&[]);
|
|
KeymapActionRow {
|
|
context: descriptor.context,
|
|
context_label: descriptor.context_label,
|
|
action: descriptor.action,
|
|
label: action_label(descriptor.action),
|
|
description: descriptor.description,
|
|
binding_summary: format_binding_summary(bindings),
|
|
custom_binding: has_custom_binding(
|
|
keymap_config,
|
|
descriptor.context,
|
|
descriptor.action,
|
|
)
|
|
.unwrap_or(false),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn keymap_common_rows(rows: &[KeymapActionRow]) -> Vec<&KeymapActionRow> {
|
|
KEYMAP_COMMON_ACTIONS
|
|
.iter()
|
|
.filter_map(|(context, action)| {
|
|
rows.iter()
|
|
.find(|row| row.context == *context && row.action == *action)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn keymap_selection_items<'a>(
|
|
rows: impl IntoIterator<Item = &'a KeymapActionRow>,
|
|
empty_name: &str,
|
|
empty_description: &str,
|
|
) -> Vec<SelectionItem> {
|
|
let items = rows
|
|
.into_iter()
|
|
.map(keymap_selection_item)
|
|
.collect::<Vec<_>>();
|
|
if items.is_empty() {
|
|
return vec![SelectionItem {
|
|
name: empty_name.to_string(),
|
|
description: Some(empty_description.to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}];
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
fn keymap_selection_item(row: &KeymapActionRow) -> SelectionItem {
|
|
let context = row.context.to_string();
|
|
let action = row.action.to_string();
|
|
let source = if row.custom_binding {
|
|
"Custom"
|
|
} else {
|
|
"Default"
|
|
};
|
|
let search_value = format!(
|
|
"{} {} {} {} {} {}",
|
|
row.context_label, row.action, row.label, row.description, row.binding_summary, source
|
|
);
|
|
|
|
SelectionItem {
|
|
name: row.label.clone(),
|
|
name_prefix_spans: keymap_row_prefix(row),
|
|
description: Some(row.binding_summary.clone()),
|
|
actions: vec![Box::new(move |tx| {
|
|
tx.send(AppEvent::OpenKeymapActionMenu {
|
|
context: context.clone(),
|
|
action: action.clone(),
|
|
});
|
|
})],
|
|
search_value: Some(search_value),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn keymap_row_prefix(row: &KeymapActionRow) -> Vec<Span<'static>> {
|
|
let indicator = if row.custom_binding {
|
|
"*".set_style(accent_style())
|
|
} else if row.is_unbound() {
|
|
"-".dim()
|
|
} else {
|
|
" ".into()
|
|
};
|
|
|
|
vec![
|
|
format!(
|
|
"{:<width$} ",
|
|
row.context_label,
|
|
width = KEYMAP_CONTEXT_LABEL_WIDTH
|
|
)
|
|
.dim(),
|
|
indicator,
|
|
" ".dim(),
|
|
]
|
|
}
|
|
|
|
fn keymap_header(description: String, summary: String) -> Box<dyn Renderable> {
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Keymap".bold()));
|
|
header.push(Line::from(description.dim()));
|
|
header.push(Line::from(summary.dim()));
|
|
Box::new(header)
|
|
}
|
|
|
|
fn action_count_line(count: usize) -> String {
|
|
match count {
|
|
1 => "1 action.".to_string(),
|
|
_ => format!("{count} actions."),
|
|
}
|
|
}
|
|
|
|
fn keymap_picker_hint_line() -> Line<'static> {
|
|
let style = accent_style();
|
|
Line::from(vec![
|
|
"left/right".set_style(style),
|
|
" group · ".dim(),
|
|
"enter".set_style(style),
|
|
" edit shortcut · ".dim(),
|
|
"*".set_style(style),
|
|
" custom · ".dim(),
|
|
"-".set_style(style),
|
|
" unbound · ".dim(),
|
|
"esc".set_style(style),
|
|
" close".dim(),
|
|
])
|
|
}
|
|
|
|
fn keymap_debug_hint_line() -> Line<'static> {
|
|
let style = accent_style();
|
|
Line::from(vec![
|
|
"enter".set_style(style),
|
|
" start inspector · ".dim(),
|
|
"esc".set_style(style),
|
|
" close".dim(),
|
|
])
|
|
}
|