mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Add a keyboard shortcut menu for presets (#54381)
## What changed Replace the `/presets` recall picker with a responsive menu above the composer. Press `0`–`9` to run a saved preset or create one in an empty slot; use `n`, `s`, `e`, and `d` to create, start from current settings, edit, and delete presets. Add `tui.keymap.chat.open_presets` with an implicit `leader p` shortcut that yields to existing bindings and explicit configuration. Show the action in `/keymap` and add a shortcut tooltip. Preserve the composer draft, respect popup ownership, and consume held-key repeats after menu actions. ## Testing Add snapshots for empty, populated, and narrow preset menus, plus coverage for draft preservation, cancellation, held-key handling, transcript search precedence, and default shortcut conflicts. GitOrigin-RevId: f438c7015dbd416c90cdeb479d2a15d9d6d24954
This commit is contained in:
@@ -171,6 +171,8 @@ pub struct TuiGlobalKeymap {
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiChatKeymap {
|
||||
/// Open saved prompts and slash commands.
|
||||
pub open_presets: Option<KeybindingsSpec>,
|
||||
/// Start or stop a voice conversation.
|
||||
pub toggle_voice: Option<KeybindingsSpec>,
|
||||
/// Toggle the microphone in an active voice conversation.
|
||||
|
||||
@@ -4770,6 +4770,7 @@
|
||||
"increase_reasoning_effort": null,
|
||||
"interrupt_turn": null,
|
||||
"next_permission_mode": null,
|
||||
"open_presets": null,
|
||||
"previous_permission_mode": null,
|
||||
"prompt_stack_back": null,
|
||||
"skip_question": null,
|
||||
@@ -5313,6 +5314,14 @@
|
||||
],
|
||||
"description": "Switch to the next available permission mode."
|
||||
},
|
||||
"open_presets": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Open saved prompts and slash commands."
|
||||
},
|
||||
"previous_permission_mode": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -5764,6 +5773,7 @@
|
||||
"increase_reasoning_effort": null,
|
||||
"interrupt_turn": null,
|
||||
"next_permission_mode": null,
|
||||
"open_presets": null,
|
||||
"previous_permission_mode": null,
|
||||
"prompt_stack_back": null,
|
||||
"skip_question": null,
|
||||
|
||||
@@ -44,3 +44,4 @@ Press {key:global.find_transcript} to search this conversation.
|
||||
Press {key:global.open_external_editor} to edit your current draft in an external editor.
|
||||
Press {key:composer.history_search_previous} to search previously entered prompts.
|
||||
For models with adjustable reasoning, press {key:chat.increase_reasoning_effort} to increase reasoning effort or {key:chat.decrease_reasoning_effort} to decrease it.
|
||||
Use {key:chat.open_presets}, then 0–9 to run a preset; Enter browses, s saves, and d deletes.
|
||||
|
||||
@@ -2540,13 +2540,9 @@ impl App {
|
||||
self.app_event_tx.send(AppEvent::FollowTranscript);
|
||||
self.select_session_model(app_server, model, effort, service_tier).await;
|
||||
}
|
||||
AppEvent::OpenQuickDials(mode) => self.chat_widget.open_quick_dials_picker(mode),
|
||||
AppEvent::OpenQuickDialEditor { target, dial } => {
|
||||
self.chat_widget.open_quick_dial_editor(target, dial);
|
||||
}
|
||||
AppEvent::OpenQuickDialPrefills(target) => {
|
||||
self.chat_widget.open_quick_dial_prefills(target);
|
||||
}
|
||||
AppEvent::OpenQuickDialSavePicker(dial) => {
|
||||
self.chat_widget.open_quick_dial_save_picker(dial);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ impl App {
|
||||
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.set_keymap_bindings(&self.keymap);
|
||||
}
|
||||
if self.overlay.is_none() && self.chat_widget.handle_quick_dial_menu(key_event) {
|
||||
return None;
|
||||
}
|
||||
if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event))
|
||||
|| (tui.is_owned_screen()
|
||||
&& self.overlay.is_none()
|
||||
@@ -200,6 +203,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn cancel_pending_key_chord(&mut self) {
|
||||
self.chat_widget.cancel_quick_dial_menu();
|
||||
if self.key_chord_matcher.cancel() {
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ use super::App;
|
||||
use super::AppServerSession;
|
||||
use super::Result;
|
||||
use super::RuntimeKeymap;
|
||||
use super::ThreadId;
|
||||
use super::TuiEvent;
|
||||
use super::make_test_app;
|
||||
use super::make_test_app_with_channels;
|
||||
use super::start_config_write_test_app_server;
|
||||
use super::test_thread_session;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::chatwidget::tests::helpers::render_bottom_popup;
|
||||
@@ -835,6 +837,33 @@ async fn command_center_chords_do_not_capture_search_text() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preset_menu_keys_take_precedence_over_transcript_find() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.chat_widget.handle_thread_session(test_thread_session(
|
||||
ThreadId::new(),
|
||||
app.config.cwd.to_path_buf(),
|
||||
));
|
||||
app.transcript_view.begin_search();
|
||||
|
||||
app.chat_widget.open_quick_dial_menu();
|
||||
assert_eq!(
|
||||
app.route_key_chord_event(&mut tui, KeyCode::Esc.into()),
|
||||
None
|
||||
);
|
||||
assert!(app.transcript_view.is_search_editing());
|
||||
|
||||
app.chat_widget.open_quick_dial_menu();
|
||||
assert_eq!(
|
||||
app.route_key_chord_event(&mut tui, KeyCode::Char('n').into()),
|
||||
None
|
||||
);
|
||||
assert!(app.transcript_view.is_search_editing());
|
||||
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("New preset"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Result<()> {
|
||||
let (mut app, mut tui, mut app_server) = chord_app().await?;
|
||||
|
||||
@@ -1276,12 +1276,10 @@ pub(crate) enum AppEvent {
|
||||
service_tier: Option<String>,
|
||||
},
|
||||
|
||||
OpenQuickDials(crate::chatwidget::quick_dials::QuickDialPickerMode),
|
||||
OpenQuickDialEditor {
|
||||
target: crate::chatwidget::quick_dials::QuickDialEditorTarget,
|
||||
dial: Option<codex_config::types::QuickDial>,
|
||||
},
|
||||
OpenQuickDialPrefills(crate::chatwidget::quick_dials::QuickDialEditorTarget),
|
||||
OpenQuickDialSavePicker(codex_config::types::QuickDial),
|
||||
ConfirmQuickDial {
|
||||
slot: u8,
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
//! Transcript interactions borrow the hint footer through `ComposerRenderOptions`;
|
||||
//! its resolved presentation drives height, painting, and the focused cursor together.
|
||||
//!
|
||||
//! Preset submenu hints borrow slots and reflow with the composer.
|
||||
//!
|
||||
//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab
|
||||
//! submit ordinary text without enabling shell mode.
|
||||
//!
|
||||
|
||||
@@ -158,9 +158,11 @@ mod mentions_v2;
|
||||
mod picker_presets;
|
||||
mod picker_rows;
|
||||
mod picker_style;
|
||||
mod shortcut_hud;
|
||||
mod shortcut_overlay;
|
||||
pub(crate) use picker_style::active_tab_style;
|
||||
pub(crate) use picker_style::selection_style;
|
||||
pub(crate) use shortcut_hud::ShortcutHud;
|
||||
pub(crate) mod prompt_args;
|
||||
mod selection_picker_layout;
|
||||
mod skill_popup;
|
||||
@@ -1232,6 +1234,11 @@ impl BottomPane {
|
||||
self.composer.current_text_with_pending()
|
||||
}
|
||||
|
||||
/// Include buffered typing before a shortcut takes ownership of composer input.
|
||||
pub(crate) fn flush_pending_composer_input(&mut self) {
|
||||
self.composer.flush_pending_input();
|
||||
}
|
||||
|
||||
/// Returns whether the composer currently accepts interactive draft edits.
|
||||
pub(crate) fn composer_input_enabled(&self) -> bool {
|
||||
self.composer.input_enabled()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Passive shortcut hints arranged in responsive columns above the composer.
|
||||
|
||||
use super::selection_popup_common::menu_surface_inset;
|
||||
use super::selection_popup_common::menu_surface_padding_height;
|
||||
use super::selection_popup_common::render_menu_surface;
|
||||
use crate::line_truncation::line_width;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::accent_color;
|
||||
use crate::style::secondary_text_style;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Styled;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
const COLUMN_GAP: usize = 2;
|
||||
const MIN_COLUMN_WIDTH: usize = 32;
|
||||
const COMMAND_SEPARATOR: &str = " ";
|
||||
const SINGLE_COLUMN_ENTRY_LIMIT: usize = 5;
|
||||
|
||||
pub(crate) struct ShortcutHud {
|
||||
title: String,
|
||||
entries: Vec<Line<'static>>,
|
||||
commands: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl ShortcutHud {
|
||||
pub(crate) fn new(
|
||||
title: impl Into<String>,
|
||||
entries: Vec<Line<'static>>,
|
||||
commands: Vec<Line<'static>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
entries,
|
||||
commands,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn item(key: impl Into<String>, label: Span<'static>) -> Line<'static> {
|
||||
vec![
|
||||
key.into().fg(accent_color()),
|
||||
" · ".set_style(secondary_text_style()),
|
||||
label,
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
fn body_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines = vec![self.title.clone().bold().into(), Line::default()];
|
||||
let max_columns = if self.entries.len() > SINGLE_COLUMN_ENTRY_LIMIT {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
lines.extend(grid_lines(&self.entries, width, max_columns));
|
||||
lines
|
||||
}
|
||||
|
||||
fn command_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
crate::footer_hint::wrap_hint_rows(
|
||||
self.commands.iter().cloned(),
|
||||
width,
|
||||
COMMAND_SEPARATOR.len(),
|
||||
Line::width,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let mut spans = Vec::new();
|
||||
for (index, command) in row.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
spans.push(COMMAND_SEPARATOR.into());
|
||||
}
|
||||
spans.extend(command.spans);
|
||||
}
|
||||
spans.into()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ShortcutHud {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let content = render_menu_surface(area, buf);
|
||||
let commands = self.command_lines(content.width);
|
||||
let command_height = u16::try_from(commands.len())
|
||||
.unwrap_or(u16::MAX)
|
||||
.min(content.height);
|
||||
let gap = content
|
||||
.height
|
||||
.saturating_sub(command_height)
|
||||
.min(/*other*/ 1);
|
||||
let body = Rect {
|
||||
height: content.height.saturating_sub(command_height + gap),
|
||||
..content
|
||||
};
|
||||
let command_area = Rect {
|
||||
y: content.bottom().saturating_sub(command_height),
|
||||
height: command_height,
|
||||
..content
|
||||
};
|
||||
render_clipped(self.body_lines(content.width), body, buf);
|
||||
Paragraph::new(commands).render(command_area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let content_width = menu_surface_inset(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
/*height*/ u16::MAX,
|
||||
))
|
||||
.width;
|
||||
let body_height = self.body_lines(content_width).len();
|
||||
let command_height = self.command_lines(content_width).len();
|
||||
u16::try_from(body_height + 1 + command_height)
|
||||
.unwrap_or(u16::MAX)
|
||||
.saturating_add(menu_surface_padding_height())
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_lines(items: &[Line<'static>], width: u16, max_columns: usize) -> Vec<Line<'static>> {
|
||||
let width = usize::from(width);
|
||||
let columns = ((width + COLUMN_GAP) / (MIN_COLUMN_WIDTH + COLUMN_GAP))
|
||||
.clamp(/*min*/ 1, max_columns)
|
||||
.min(items.len().max(/*other*/ 1));
|
||||
let rows = items.len().div_ceil(columns);
|
||||
let cell_width = width.saturating_sub(COLUMN_GAP * (columns - 1)) / columns;
|
||||
(0..rows)
|
||||
.map(|row| {
|
||||
let mut line = Line::default();
|
||||
for column in 0..columns {
|
||||
let item = items.get(column * rows + row).cloned().unwrap_or_default();
|
||||
let item = truncate_line_with_ellipsis_if_overflow(item, cell_width);
|
||||
let padding = cell_width.saturating_sub(line_width(&item)) + COLUMN_GAP;
|
||||
line.extend(item.spans);
|
||||
if column + 1 < columns {
|
||||
line.push_span(" ".repeat(padding));
|
||||
}
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_clipped(mut lines: Vec<Line<'static>>, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
let height = usize::from(area.height);
|
||||
if lines.len() > height {
|
||||
lines.truncate(height);
|
||||
lines[height - 1] = "… resize to see all".dim().into();
|
||||
}
|
||||
Paragraph::new(lines).render(area, buf);
|
||||
}
|
||||
@@ -701,6 +701,10 @@ pub(crate) struct ChatWidget {
|
||||
safety_buffering_source: UserMessageSource,
|
||||
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
|
||||
chat_keymap: ChatKeymap,
|
||||
/// Whether the preset submenu owns the next key.
|
||||
quick_dial_menu: bool,
|
||||
/// Ignore a held key after it dismisses the preset submenu.
|
||||
quick_dial_menu_key_guard: Option<crate::activation_key_guard::ActivationKeyGuard>,
|
||||
permission_shortcut_pending: bool,
|
||||
// Pending notification to show when unfocused on next Draw
|
||||
pub(crate) pending_notification: Option<Notification>,
|
||||
|
||||
@@ -253,6 +253,8 @@ impl ChatWidget {
|
||||
suppress_initial_user_message_submit: false,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quick_dial_menu: false,
|
||||
quick_dial_menu_key_guard: None,
|
||||
quit_shortcut_key: None,
|
||||
turn_runtime_metrics: RuntimeMetricsSummary::default(),
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
|
||||
@@ -96,6 +96,9 @@ impl ChatWidget {
|
||||
key_event: KeyEvent,
|
||||
activation_key: Option<crate::key_hint::KeyBinding>,
|
||||
) -> KeyEventAction {
|
||||
if self.handle_quick_dial_menu(key_event) {
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
if self.handle_startup_submission_key(key_event) {
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
@@ -136,6 +139,14 @@ impl ChatWidget {
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& self.chat_keymap.open_presets.is_pressed(key_event)
|
||||
&& self.bottom_pane.no_modal_or_popup_active()
|
||||
{
|
||||
self.open_quick_dial_menu();
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
|
||||
if (self.chat_keymap.interrupt_turn.is_pressed(key_event)
|
||||
|| key_hint::ctrl(KeyCode::Char('c')).is_press(key_event))
|
||||
&& self.bottom_pane.no_modal_or_popup_active()
|
||||
@@ -353,6 +364,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||||
self.quick_dial_menu = false;
|
||||
if items.is_some() {
|
||||
// Active input instructions supersede transient feedback from a previous action.
|
||||
self.bottom_pane
|
||||
@@ -529,6 +541,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
self.cancel_quick_dial_menu();
|
||||
if self.external_writer_view && !self.bottom_pane.has_active_view() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Numbered reusable inputs using the existing picker and queued submission path.
|
||||
//! Numbered reusable inputs with one HUD and pickers for editing, saving, and deleting.
|
||||
//! Saving leaves the draft untouched; recall preserves drafts and obeys normal command gates.
|
||||
//! The shared-leader submenu respects popup ownership, cancellation, and held-key repeats.
|
||||
|
||||
use super::*;
|
||||
use crate::bottom_pane::ShortcutHud;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use codex_config::types::QuickDial;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
@@ -22,8 +24,7 @@ impl Renderable for QuickDialConfirmationHeader {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum QuickDialPickerMode {
|
||||
Recall,
|
||||
enum QuickDialPickerMode {
|
||||
Save,
|
||||
Edit,
|
||||
Clear,
|
||||
@@ -35,14 +36,6 @@ pub(crate) enum QuickDialEditorTarget {
|
||||
Slot(u8),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum QuickDialMenuAction {
|
||||
New,
|
||||
Edit,
|
||||
Prefill,
|
||||
Clear,
|
||||
}
|
||||
|
||||
fn quick_dial_label(dial: &QuickDial) -> String {
|
||||
dial.label
|
||||
.as_deref()
|
||||
@@ -56,6 +49,34 @@ fn quick_dial_label(dial: &QuickDial) -> String {
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn quick_dial_hud(&self) -> ShortcutHud {
|
||||
let mut entries: Vec<_> = (1..=9)
|
||||
.chain(std::iter::once(/*value*/ 0))
|
||||
.filter_map(|slot| {
|
||||
let key = slot.to_string();
|
||||
self.local_settings
|
||||
.tui
|
||||
.quick_dials
|
||||
.get(&key)
|
||||
.map(|dial| ShortcutHud::item(key, quick_dial_label(dial).into()))
|
||||
})
|
||||
.collect();
|
||||
if entries.is_empty() {
|
||||
entries.push("No presets saved. Press n to create one.".dim().into());
|
||||
}
|
||||
let commands = [
|
||||
("n", "new"),
|
||||
("s", "from settings"),
|
||||
("e", "edit"),
|
||||
("d", "delete"),
|
||||
("esc", "close"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, label)| ShortcutHud::item(key, label.into()))
|
||||
.collect();
|
||||
ShortcutHud::new("Presets", entries, commands)
|
||||
}
|
||||
|
||||
fn composer_quick_dial_text(&mut self) -> String {
|
||||
if !self.bottom_pane.composer_local_images().is_empty()
|
||||
|| !self.bottom_pane.remote_image_urls().is_empty()
|
||||
@@ -167,7 +188,7 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn open_quick_dials_picker(&mut self, mode: QuickDialPickerMode) {
|
||||
fn open_quick_dials_picker(&mut self, mode: QuickDialPickerMode) {
|
||||
if !self.quick_dials_available() {
|
||||
return;
|
||||
}
|
||||
@@ -183,21 +204,18 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn show_quick_dials_picker(&mut self, mode: QuickDialPickerMode, current: Option<QuickDial>) {
|
||||
let thread_id = self.thread_id();
|
||||
let mut items = Vec::new();
|
||||
for slot in (1..=9).chain(std::iter::once(/*value*/ 0)) {
|
||||
let saved = self.local_settings.tui.quick_dials.get(&slot.to_string());
|
||||
if mode == QuickDialPickerMode::Edit && saved.is_none() {
|
||||
if mode != QuickDialPickerMode::Save && saved.is_none() {
|
||||
continue;
|
||||
}
|
||||
let dial = current.clone();
|
||||
let saved_for_edit = saved.cloned();
|
||||
let has_saved = saved.is_some();
|
||||
let (is_disabled, dismiss_on_select, dismiss_parent_on_child_accept) = match mode {
|
||||
QuickDialPickerMode::Recall => (!has_saved, true, false),
|
||||
QuickDialPickerMode::Save => (dial.is_none(), !has_saved, has_saved),
|
||||
QuickDialPickerMode::Edit => (false, false, true),
|
||||
QuickDialPickerMode::Clear => (!has_saved, false, true),
|
||||
QuickDialPickerMode::Edit | QuickDialPickerMode::Clear => (false, false, true),
|
||||
};
|
||||
items.push(SelectionItem {
|
||||
name: saved.map(quick_dial_label).unwrap_or_default(),
|
||||
@@ -219,9 +237,6 @@ impl ChatWidget {
|
||||
is_disabled,
|
||||
actions: vec![Box::new(move |tx| {
|
||||
let event = match mode {
|
||||
QuickDialPickerMode::Recall => {
|
||||
AppEvent::RecallQuickDial { slot, thread_id }
|
||||
}
|
||||
QuickDialPickerMode::Save if has_saved => AppEvent::ConfirmQuickDial {
|
||||
slot,
|
||||
dial: dial.clone(),
|
||||
@@ -245,49 +260,7 @@ impl ChatWidget {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if mode == QuickDialPickerMode::Recall {
|
||||
for (key, name, action) in [
|
||||
(Some('s'), "New preset…", QuickDialMenuAction::New),
|
||||
(Some('e'), "Edit a preset…", QuickDialMenuAction::Edit),
|
||||
(
|
||||
None,
|
||||
"Start from current settings…",
|
||||
QuickDialMenuAction::Prefill,
|
||||
),
|
||||
(Some('d'), "Delete a preset…", QuickDialMenuAction::Clear),
|
||||
] {
|
||||
let is_disabled = matches!(
|
||||
action,
|
||||
QuickDialMenuAction::Edit | QuickDialMenuAction::Clear
|
||||
) && self.local_settings.tui.quick_dials.is_empty();
|
||||
items.push(SelectionItem {
|
||||
name: name.into(),
|
||||
display_shortcut: key.map(|key| key_hint::plain(KeyCode::Char(key)).into()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(match action {
|
||||
QuickDialMenuAction::New => AppEvent::OpenQuickDialEditor {
|
||||
target: QuickDialEditorTarget::PickSlot,
|
||||
dial: None,
|
||||
},
|
||||
QuickDialMenuAction::Edit => {
|
||||
AppEvent::OpenQuickDials(QuickDialPickerMode::Edit)
|
||||
}
|
||||
QuickDialMenuAction::Prefill => {
|
||||
AppEvent::OpenQuickDialPrefills(QuickDialEditorTarget::PickSlot)
|
||||
}
|
||||
QuickDialMenuAction::Clear => {
|
||||
AppEvent::OpenQuickDials(QuickDialPickerMode::Clear)
|
||||
}
|
||||
})
|
||||
})],
|
||||
is_disabled,
|
||||
dismiss_parent_on_child_accept: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
let title = match mode {
|
||||
QuickDialPickerMode::Recall => "Presets",
|
||||
QuickDialPickerMode::Save => "Save preset to slot",
|
||||
QuickDialPickerMode::Edit => "Edit a preset",
|
||||
QuickDialPickerMode::Clear => "Delete a preset",
|
||||
@@ -308,7 +281,7 @@ impl ChatWidget {
|
||||
view_id: Some("quick-dials"),
|
||||
auto_number_shortcuts: false,
|
||||
initial_selected_idx,
|
||||
max_visible_rows: 14,
|
||||
max_visible_rows: 10,
|
||||
title: Some(title.into()),
|
||||
subtitle: (mode == QuickDialPickerMode::Save)
|
||||
.then(|| current.as_ref().map(quick_dial_label))
|
||||
@@ -378,7 +351,7 @@ impl ChatWidget {
|
||||
self.defer_input_until_settings_applied();
|
||||
}
|
||||
|
||||
pub(crate) fn open_quick_dial_prefills(&mut self, target: QuickDialEditorTarget) {
|
||||
fn open_quick_dial_prefills(&mut self) {
|
||||
if !self.quick_dials_available() {
|
||||
return;
|
||||
}
|
||||
@@ -388,7 +361,7 @@ impl ChatWidget {
|
||||
let description = Some(dial.input.clone());
|
||||
let actions = vec![Box::new(move |tx: &AppEventSender| {
|
||||
tx.send(AppEvent::OpenQuickDialEditor {
|
||||
target,
|
||||
target: QuickDialEditorTarget::PickSlot,
|
||||
dial: Some(dial.clone()),
|
||||
})
|
||||
}) as SelectionAction];
|
||||
@@ -455,4 +428,104 @@ impl ChatWidget {
|
||||
..SelectionViewParams::picker()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_quick_dial_menu(&mut self) {
|
||||
if self.quick_dial_menu {
|
||||
self.set_footer_hint_override(/*items*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_quick_dial_menu(&mut self) {
|
||||
if self.quick_dials_available() {
|
||||
self.bottom_pane.flush_pending_composer_input();
|
||||
if !self.bottom_pane.no_modal_or_popup_active() {
|
||||
return;
|
||||
}
|
||||
self.set_footer_hint_override(Some(Vec::new()));
|
||||
self.quick_dial_menu = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_quick_dial_menu(&mut self, key: KeyEvent) -> bool {
|
||||
if let Some(guard) = self.quick_dial_menu_key_guard.as_mut() {
|
||||
if guard.consume(KeyBinding::from_event(key), key.kind) {
|
||||
return true;
|
||||
}
|
||||
if !guard.is_active() {
|
||||
self.quick_dial_menu_key_guard = None;
|
||||
}
|
||||
}
|
||||
if !self.quick_dial_menu {
|
||||
return false;
|
||||
}
|
||||
if !self.bottom_pane.no_modal_or_popup_active() {
|
||||
self.cancel_quick_dial_menu();
|
||||
return false;
|
||||
}
|
||||
if key.kind != KeyEventKind::Press || key.code == KeyCode::Enter {
|
||||
return true;
|
||||
}
|
||||
self.cancel_quick_dial_menu();
|
||||
if self.chat_keymap.chord_hints.leader.is_pressed(key)
|
||||
|| self.chat_keymap.open_presets.is_pressed(key)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if key.modifiers != KeyModifiers::NONE {
|
||||
return false;
|
||||
}
|
||||
let consumed = match key.code {
|
||||
KeyCode::Esc => true,
|
||||
KeyCode::Char('n') => {
|
||||
self.open_quick_dial_editor(QuickDialEditorTarget::PickSlot, /*dial*/ None);
|
||||
true
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
self.open_quick_dial_prefills();
|
||||
true
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
self.open_quick_dials_picker(QuickDialPickerMode::Edit);
|
||||
true
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
self.open_quick_dials_picker(QuickDialPickerMode::Clear);
|
||||
true
|
||||
}
|
||||
KeyCode::Char(c @ '0'..='9') => {
|
||||
if self.quick_dials_available() {
|
||||
let slot = c as u8 - b'0';
|
||||
if self
|
||||
.local_settings
|
||||
.tui
|
||||
.quick_dials
|
||||
.contains_key(&slot.to_string())
|
||||
{
|
||||
self.input_queue.suppress_queue_autosend = true;
|
||||
self.app_event_tx.send(AppEvent::RecallQuickDial {
|
||||
slot,
|
||||
thread_id: self.thread_id(),
|
||||
});
|
||||
self.on_modal_or_popup_closed();
|
||||
} else {
|
||||
self.open_quick_dial_editor(
|
||||
QuickDialEditorTarget::Slot(slot),
|
||||
/*dial*/ None,
|
||||
);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if consumed {
|
||||
self.quick_dial_menu_key_guard =
|
||||
Some(crate::activation_key_guard::ActivationKeyGuard::new_at(
|
||||
KeyBinding::from_event(key),
|
||||
Instant::now(),
|
||||
/*key_releases_reported*/ false,
|
||||
));
|
||||
}
|
||||
consumed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,17 @@ impl ChatWidget {
|
||||
options.textarea_right_reserve = right_reserve;
|
||||
options.separate_status_line = options.command_popup_placement
|
||||
!= crate::bottom_pane::CommandPopupPlacement::AboveComposer;
|
||||
self.bottom_pane.backdrop_with_options(options)
|
||||
let bottom_pane = self.bottom_pane.backdrop_with_options(options);
|
||||
if !self.quick_dial_menu {
|
||||
return bottom_pane;
|
||||
}
|
||||
let mut column = FlexRenderable::new();
|
||||
column.push(
|
||||
/*flex*/ 1,
|
||||
RenderableItem::Owned(Box::new(self.quick_dial_hud())),
|
||||
);
|
||||
column.push(/*flex*/ 0, bottom_pane);
|
||||
RenderableItem::Owned(Box::new(column))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ impl ChatWidget {
|
||||
self.defer_input_until_settings_applied();
|
||||
}
|
||||
SlashCommand::Presets => {
|
||||
self.open_quick_dials_picker(quick_dials::QuickDialPickerMode::Recall);
|
||||
self.open_quick_dial_menu();
|
||||
}
|
||||
SlashCommand::Plan => {
|
||||
self.apply_plan_slash_command();
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/quick_dials_tests.rs
|
||||
expression: "render_bottom_popup(&chat, 100)"
|
||||
---
|
||||
Presets
|
||||
|
||||
1 · coding 5 · /skills simplify-code
|
||||
2 · /permissions workspace-write 7 · Plan the requested change
|
||||
3 · Review the current changes and explain eve… 0 · /status
|
||||
|
||||
n · new s · from settings e · edit d · delete esc · close
|
||||
|
||||
• Queued follow-up inputs
|
||||
↳ /model gpt-5.5 --reasoning=high --speed=fast
|
||||
⇧← edit last queued message
|
||||
|
||||
› Review the current changes
|
||||
for safety
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/quick_dials_tests.rs
|
||||
expression: "render_bottom_popup(&chat, 100)"
|
||||
---
|
||||
Presets
|
||||
|
||||
No presets saved. Press n to create one.
|
||||
|
||||
n · new s · from settings e · edit d · delete esc · close
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/quick_dials_tests.rs
|
||||
expression: "render_bottom_popup(&chat, 48)"
|
||||
---
|
||||
Presets
|
||||
|
||||
1 · coding
|
||||
2 · /permissions workspace-write
|
||||
3 · Review the current changes and explain …
|
||||
5 · /skills simplify-code
|
||||
7 · Plan the requested change
|
||||
0 · /status
|
||||
|
||||
n · new s · from settings e · edit
|
||||
d · delete esc · close
|
||||
|
||||
• Queued follow-up inputs
|
||||
↳ /model gpt-5.5 --reasoning=high --speed=fast
|
||||
⇧← edit last queued message
|
||||
|
||||
› Review the current changes
|
||||
for safety
|
||||
-9
@@ -6,14 +6,5 @@ expression: "render_bottom_popup(&chat, 80)"
|
||||
|
||||
|
||||
› 1. coding /model gpt-5.5 --reasoning=high --speed=fast
|
||||
2. empty
|
||||
3. empty
|
||||
4. empty
|
||||
5. empty
|
||||
6. empty
|
||||
7. empty
|
||||
8. empty
|
||||
9. empty
|
||||
0. empty
|
||||
|
||||
enter select · esc back
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/quick_dials_tests.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
Presets
|
||||
|
||||
|
||||
› 1. coding /model gpt-5.5 --reasoning=high --speed=fast
|
||||
2. empty
|
||||
3. empty
|
||||
4. empty
|
||||
5. empty
|
||||
6. empty
|
||||
7. empty
|
||||
8. empty
|
||||
9. empty
|
||||
0. empty
|
||||
s. New preset…
|
||||
e. Edit a preset…
|
||||
Start from current settings…
|
||||
d. Delete a preset…
|
||||
|
||||
enter select · esc back
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Save, Clear, and confirmation layouts keep destructive preset actions explicit.
|
||||
//! Preset HUD, editor, and save/delete flows preserve drafts and explicit confirmation.
|
||||
|
||||
use super::*;
|
||||
use crate::chatwidget::quick_dials::QuickDialEditorTarget;
|
||||
use crate::chatwidget::quick_dials::QuickDialPickerMode;
|
||||
use codex_config::types::QuickDial;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -11,6 +10,19 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() {
|
||||
let (mut chat, mut events, _ops) =
|
||||
make_chatwidget_manual(/*model_override*/ Some("gpt-5.5")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.open_quick_dial_menu();
|
||||
assert_chatwidget_snapshot!(
|
||||
"quick_dial_shortcuts_empty",
|
||||
render_bottom_popup(&chat, /*width*/ 100)
|
||||
);
|
||||
let escape = KeyCode::Esc.into();
|
||||
assert!(chat.handle_quick_dial_menu(escape));
|
||||
assert!(chat.handle_quick_dial_menu(escape));
|
||||
assert!(chat.handle_quick_dial_menu(KeyEvent::new_with_kind(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::NONE,
|
||||
crossterm::event::KeyEventKind::Release,
|
||||
)));
|
||||
chat.local_settings.tui.quick_dials = [(
|
||||
"1".into(),
|
||||
QuickDial {
|
||||
@@ -21,10 +33,14 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() {
|
||||
.into();
|
||||
chat.bottom_pane.apply_external_edit(DRAFT.into());
|
||||
chat.dispatch_command(SlashCommand::Presets);
|
||||
assert_chatwidget_snapshot!(
|
||||
"saved_quick_dials_recall",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
let hud = render_bottom_popup(&chat, /*width*/ 80);
|
||||
chat.handle_key_event(KeyCode::Enter.into());
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), hud);
|
||||
assert_eq!(chat.bottom_pane.composer_text(), DRAFT);
|
||||
assert!(chat.queued_user_message_texts().is_empty());
|
||||
chat.cancel_quick_dial_menu();
|
||||
chat.open_quick_dial_menu();
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), hud);
|
||||
chat.handle_key_event(KeyCode::Esc.into());
|
||||
while events.try_recv().is_ok() {}
|
||||
chat.open_quick_dial_editor(QuickDialEditorTarget::PickSlot, /*dial*/ None);
|
||||
@@ -66,7 +82,8 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() {
|
||||
);
|
||||
chat.handle_key_event(KeyCode::Esc.into());
|
||||
|
||||
chat.open_quick_dials_picker(QuickDialPickerMode::Clear);
|
||||
chat.open_quick_dial_menu();
|
||||
chat.handle_key_event(KeyCode::Char('d').into());
|
||||
assert_chatwidget_snapshot!(
|
||||
"saved_quick_dials_clear",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
@@ -120,6 +137,38 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() {
|
||||
["/model gpt-5.5 --reasoning=high --speed=fast"]
|
||||
);
|
||||
|
||||
chat.local_settings.tui.quick_dials.extend(
|
||||
[
|
||||
("2", "/permissions workspace-write"),
|
||||
(
|
||||
"3",
|
||||
"Review the current changes and explain every important behavior change",
|
||||
),
|
||||
("5", "/skills simplify-code"),
|
||||
("7", "Plan the requested change"),
|
||||
("0", "/status"),
|
||||
]
|
||||
.map(|(slot, input)| {
|
||||
(
|
||||
slot.into(),
|
||||
QuickDial {
|
||||
input: input.into(),
|
||||
label: None,
|
||||
},
|
||||
)
|
||||
}),
|
||||
);
|
||||
chat.open_quick_dial_menu();
|
||||
assert_chatwidget_snapshot!(
|
||||
"quick_dial_shortcuts",
|
||||
render_bottom_popup(&chat, /*width*/ 100)
|
||||
);
|
||||
assert_chatwidget_snapshot!(
|
||||
"quick_dial_shortcuts_narrow",
|
||||
render_bottom_popup(&chat, /*width*/ 48)
|
||||
);
|
||||
chat.handle_key_event(KeyCode::Esc.into());
|
||||
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||||
chat.effective_service_tier = Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.into());
|
||||
assert_eq!(
|
||||
@@ -130,7 +179,8 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() {
|
||||
})
|
||||
);
|
||||
let expected_model = chat.current_model_quick_dial().expect("model command");
|
||||
chat.open_quick_dial_prefills(QuickDialEditorTarget::PickSlot);
|
||||
chat.open_quick_dial_menu();
|
||||
chat.handle_key_event(KeyCode::Char('s').into());
|
||||
chat.handle_key_event(KeyCode::Enter.into());
|
||||
assert_matches!(
|
||||
std::iter::from_fn(|| events.try_recv().ok())
|
||||
|
||||
@@ -62,6 +62,7 @@ pub(crate) use chords::KeyChordMatch;
|
||||
pub(crate) use chords::KeyChordMatcher;
|
||||
pub(crate) use chords::KeymapContextSet;
|
||||
pub(crate) use chords::RuntimeChordKeymap;
|
||||
pub(crate) use chords::is_dispatch_token;
|
||||
pub(crate) use chords::is_dispatch_token_event;
|
||||
|
||||
/// Runtime keymap used by TUI input handlers.
|
||||
@@ -129,11 +130,13 @@ pub(crate) struct AppKeymap {
|
||||
/// handler code, not here.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChatKeymap {
|
||||
/// Open the preset submenu.
|
||||
pub(crate) open_presets: Vec<KeyBinding>,
|
||||
/// Start or stop a voice conversation.
|
||||
pub(crate) toggle_voice: Vec<KeyBinding>,
|
||||
/// Toggle capture in the active voice session.
|
||||
pub(crate) toggle_voice_mute: Vec<KeyBinding>,
|
||||
chord_hints: Arc<RuntimeChordKeymap>,
|
||||
pub(crate) chord_hints: Arc<RuntimeChordKeymap>,
|
||||
/// Interrupt the active turn.
|
||||
pub(crate) interrupt_turn: Vec<KeyBinding>,
|
||||
/// Decrease the active reasoning effort.
|
||||
@@ -777,6 +780,7 @@ impl RuntimeKeymap {
|
||||
}));
|
||||
|
||||
let mut chat = ChatKeymap {
|
||||
open_presets: resolve_local!(keymap, defaults, chat, open_presets),
|
||||
toggle_voice: if voice_toggle_default_is_shadowed {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -1643,6 +1647,7 @@ impl RuntimeKeymap {
|
||||
}
|
||||
}
|
||||
resolved.configure_search(keymap)?;
|
||||
chords::quick_dial_defaults::register(keymap, &mut resolved);
|
||||
resolved.validate_conflicts()?;
|
||||
chords::validate_chord_conflicts(&resolved)?;
|
||||
chords::install_dispatch_bindings(&mut resolved)?;
|
||||
@@ -1683,6 +1688,7 @@ impl RuntimeKeymap {
|
||||
},
|
||||
chords: Arc::default(),
|
||||
chat: ChatKeymap {
|
||||
open_presets: Vec::new(),
|
||||
toggle_voice: default_bindings![plain(KeyCode::F(8))],
|
||||
toggle_voice_mute: default_bindings![plain(KeyCode::F(9))],
|
||||
chord_hints: Arc::default(),
|
||||
@@ -1992,6 +1998,7 @@ impl RuntimeKeymap {
|
||||
/// backtracking, intentionally stay outside this configurable keymap.
|
||||
fn validate_conflicts(&self) -> Result<(), String> {
|
||||
for (action, bindings) in [
|
||||
("open_presets", &self.chat.open_presets),
|
||||
("toggle_voice", &self.chat.toggle_voice),
|
||||
(
|
||||
"previous_permission_mode",
|
||||
@@ -2014,6 +2021,7 @@ impl RuntimeKeymap {
|
||||
#[cfg(unix)]
|
||||
for (action, bindings) in [
|
||||
("global.open_agents", &self.app.open_agents),
|
||||
("chat.open_presets", &self.chat.open_presets),
|
||||
("chat.toggle_voice", &self.chat.toggle_voice),
|
||||
] {
|
||||
if bindings.contains(&key_hint::ctrl(KeyCode::Char('z'))) {
|
||||
@@ -2056,6 +2064,7 @@ impl RuntimeKeymap {
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
||||
("chat.open_presets", self.chat.open_presets.as_slice()),
|
||||
("chat.toggle_voice", self.chat.toggle_voice.as_slice()),
|
||||
(
|
||||
"chat.toggle_voice_mute",
|
||||
@@ -2217,6 +2226,7 @@ impl RuntimeKeymap {
|
||||
),
|
||||
("copy", self.app.copy.as_slice()),
|
||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||
("chat.open_presets", self.chat.open_presets.as_slice()),
|
||||
("chat.toggle_voice", self.chat.toggle_voice.as_slice()),
|
||||
(
|
||||
"chat.toggle_voice_mute",
|
||||
@@ -4122,5 +4132,35 @@ mod tests {
|
||||
RuntimeKeymap::defaults()
|
||||
.validate_conflicts()
|
||||
.expect("default keymap should be conflict free");
|
||||
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.global.leader = Some(one("ctrl-r"));
|
||||
let runtime = RuntimeKeymap::from_config(&keymap)
|
||||
.expect("an unused leader must preserve single-key shortcuts");
|
||||
assert_eq!(
|
||||
(
|
||||
runtime.composer.history_search_previous,
|
||||
runtime.chat.open_presets
|
||||
),
|
||||
(vec![key_hint::ctrl(KeyCode::Char('r'))], Vec::new())
|
||||
);
|
||||
let defaults = RuntimeKeymap::defaults();
|
||||
for leader in ["page-up", "page-down"] {
|
||||
keymap.global.leader = Some(one(leader));
|
||||
let runtime = RuntimeKeymap::from_config(&keymap)
|
||||
.expect("an unused leader must preserve transcript paging");
|
||||
assert_eq!(
|
||||
(
|
||||
runtime.pager.page_up,
|
||||
runtime.pager.page_down,
|
||||
runtime.chat.open_presets
|
||||
),
|
||||
(
|
||||
defaults.pager.page_up.clone(),
|
||||
defaults.pager.page_down.clone(),
|
||||
Vec::new()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,15 +103,23 @@ pub(crate) struct KeymapActionId {
|
||||
}
|
||||
|
||||
impl KeymapActionId {
|
||||
/// Activity focus and Find remain active while transcript groups use list navigation.
|
||||
/// Whether two actions can be active together, including list-specific exceptions.
|
||||
pub(super) fn overlaps(self, other: Self) -> bool {
|
||||
self.context.overlaps(other.context)
|
||||
|| (self.context == KeymapContext::Global
|
||||
&& matches!(self.action, "focus_activity" | "find_transcript")
|
||||
&& other.context == KeymapContext::List)
|
||||
|| (other.context == KeymapContext::Global
|
||||
&& matches!(other.action, "focus_activity" | "find_transcript")
|
||||
&& self.context == KeymapContext::List)
|
||||
use KeymapContext::Chat;
|
||||
use KeymapContext::Global;
|
||||
use KeymapContext::List;
|
||||
|
||||
match (self.context, other.context) {
|
||||
// Presets cannot open while a list owns input.
|
||||
(Chat, List) => self.action != "open_presets",
|
||||
(List, Chat) => other.action != "open_presets",
|
||||
|
||||
// Activity focus and Find remain available during list navigation.
|
||||
(Global, List) => matches!(self.action, "focus_activity" | "find_transcript"),
|
||||
(List, Global) => matches!(other.action, "focus_activity" | "find_transcript"),
|
||||
|
||||
_ => self.context.overlaps(other.context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn config_path(self) -> String {
|
||||
@@ -284,6 +292,7 @@ define_runtime_action_bindings! {
|
||||
toggle_side_conversation,
|
||||
],
|
||||
"chat" => Chat, chat, chat [
|
||||
open_presets,
|
||||
toggle_voice,
|
||||
interrupt_turn,
|
||||
decrease_reasoning_effort,
|
||||
|
||||
@@ -33,6 +33,8 @@ pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1);
|
||||
const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY + 1;
|
||||
const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX;
|
||||
const MAX_LEADER_CHORD_BINDINGS: usize = 1024;
|
||||
|
||||
pub(super) mod quick_dial_defaults;
|
||||
const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
|
||||
("cancel", ctrl(KeyCode::Char('c'))),
|
||||
("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))),
|
||||
@@ -140,6 +142,11 @@ impl KeymapContextSet {
|
||||
|
||||
/// Whether this input path can dispatch the action, including focus-specific exclusions.
|
||||
pub(crate) fn contains_action(self, action: KeymapActionId) -> bool {
|
||||
if action.context == KeymapContext::Chat && action.action == "open_presets" {
|
||||
return self.contains(KeymapContext::Chat)
|
||||
&& self.contains(KeymapContext::Global)
|
||||
&& !self.contains(KeymapContext::List);
|
||||
}
|
||||
if action.context == KeymapContext::Chat && action.action == "toggle_voice" {
|
||||
return self.0 & VOICE_TOGGLE_BLOCKED == 0
|
||||
&& (self.0 & VOICE_TOGGLE != 0 || self.contains(KeymapContext::Chat));
|
||||
@@ -199,7 +206,7 @@ pub(crate) struct KeyChord {
|
||||
pub(crate) struct RuntimeChordBinding {
|
||||
pub(crate) action: KeymapActionId,
|
||||
pub(crate) chord: KeyChord,
|
||||
spec: String,
|
||||
pub(crate) spec: String,
|
||||
}
|
||||
|
||||
impl RuntimeChordBinding {
|
||||
@@ -325,11 +332,10 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.",
|
||||
});
|
||||
}
|
||||
super::leader::resolve(keymap, &mut keymap_chords)?;
|
||||
if leader_bindings
|
||||
let explicit_count = leader_bindings
|
||||
.len()
|
||||
.saturating_mul(keymap_chords.leader.len())
|
||||
> MAX_LEADER_CHORD_BINDINGS
|
||||
{
|
||||
.saturating_mul(keymap_chords.leader.len());
|
||||
if explicit_count > MAX_LEADER_CHORD_BINDINGS {
|
||||
return Err(format!(
|
||||
"Too many leader shortcut combinations (maximum {MAX_LEADER_CHORD_BINDINGS}). \
|
||||
Reduce `tui.keymap.global.leader` alternatives or `leader ...` bindings."
|
||||
@@ -486,6 +492,7 @@ impl KeyChordMatcher {
|
||||
.take(/*n*/ 6)
|
||||
{
|
||||
let label = match binding.action.action {
|
||||
"open_presets" if pending.started_at.is_none() => "presets",
|
||||
"focus_activity" => "activity",
|
||||
"find_transcript" => "find",
|
||||
"open_warnings" => "warnings",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Install the implicit preset shortcut only for free leader chords.
|
||||
//! Explicit bindings, including an explicit unbind, always take priority.
|
||||
//! Implicit chords fit within the shared leader expansion limit.
|
||||
|
||||
use super::KeyChord;
|
||||
use super::KeymapActionId;
|
||||
use super::KeymapContext;
|
||||
use super::MAX_LEADER_CHORD_BINDINGS;
|
||||
use super::RuntimeChordBinding;
|
||||
use super::RuntimeKeymap;
|
||||
use super::normalize_chord_binding;
|
||||
use super::runtime_action_bindings;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) {
|
||||
let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub(
|
||||
resolved
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.is_leader())
|
||||
.count(),
|
||||
);
|
||||
if keymap.chat.open_presets.is_some() || remaining == 0 {
|
||||
return;
|
||||
}
|
||||
let action = KeymapActionId {
|
||||
context: KeymapContext::Chat,
|
||||
action: "open_presets",
|
||||
};
|
||||
for prefix in resolved.chords.leader.clone() {
|
||||
let binding = RuntimeChordBinding {
|
||||
action,
|
||||
chord: KeyChord {
|
||||
prefix,
|
||||
completion: crate::key_hint::plain(KeyCode::Char('p')),
|
||||
},
|
||||
spec: "leader p".into(),
|
||||
};
|
||||
if !resolved
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|existing| binding.overlaps(existing.action) && existing.chord == binding.chord)
|
||||
&& !runtime_action_bindings(resolved)
|
||||
// Owned transcript navigation also runs beside the composer.
|
||||
.filter(|single| {
|
||||
binding.overlaps(single.id) || single.id.context == KeymapContext::Pager
|
||||
})
|
||||
.any(|single| {
|
||||
single
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|key| normalize_chord_binding(*key) == prefix)
|
||||
})
|
||||
{
|
||||
std::sync::Arc::make_mut(&mut resolved.chords)
|
||||
.bindings
|
||||
.push(binding);
|
||||
remaining -= 1;
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved.chat.chord_hints = std::sync::Arc::clone(&resolved.chords);
|
||||
}
|
||||
@@ -601,20 +601,43 @@ pub(crate) fn active_binding_specs(
|
||||
let bindings = bindings_for_action(runtime_keymap, context, action).ok_or_else(|| {
|
||||
format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.")
|
||||
})?;
|
||||
if let Some(action_id) = action_id
|
||||
&& let Some(crate::key_hint::ShortcutHint::Chord { prefix, completion }) =
|
||||
runtime_keymap.chords.primary_hint(action_id, bindings)
|
||||
{
|
||||
return Ok(vec![format!(
|
||||
"{} {}",
|
||||
binding_to_config_key_spec(prefix)?,
|
||||
binding_to_config_key_spec(completion)?
|
||||
)]);
|
||||
}
|
||||
bindings
|
||||
let mut specs = bindings
|
||||
.iter()
|
||||
.filter(|binding| !crate::keymap::is_dispatch_token(**binding))
|
||||
.map(|binding| binding_to_config_key_spec(*binding))
|
||||
.collect()
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if let Some(action_id) = action_id {
|
||||
let chords: Vec<_> = runtime_keymap
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.action == action_id)
|
||||
.collect();
|
||||
let available: std::collections::HashSet<_> = chords
|
||||
.iter()
|
||||
.map(|binding| (binding.chord.prefix, binding.chord.completion))
|
||||
.collect();
|
||||
for binding in &chords {
|
||||
// An implicit default can yield to a different action on some leader aliases.
|
||||
// Only persist the symbolic form when all aliases remain available.
|
||||
let partial_leader = binding.spec.starts_with("leader ")
|
||||
&& runtime_keymap
|
||||
.chords
|
||||
.leader
|
||||
.iter()
|
||||
.any(|prefix| !available.contains(&(*prefix, binding.chord.completion)));
|
||||
specs.push(if partial_leader {
|
||||
format!(
|
||||
"{} {}",
|
||||
binding_to_config_key_spec(binding.chord.prefix)?,
|
||||
binding_to_config_key_spec(binding.chord.completion)?
|
||||
)
|
||||
} else {
|
||||
binding.spec.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(dedup_bindings(specs))
|
||||
}
|
||||
|
||||
fn dedup_bindings(bindings: Vec<String>) -> Vec<String> {
|
||||
|
||||
@@ -136,6 +136,7 @@ define_keymap_actions! {
|
||||
prompt_stack_back => "Move back through questions toward the composer.",
|
||||
skip_question => "Skip the focused question.",
|
||||
toggle_voice => "Start or stop a voice conversation.",
|
||||
open_presets => "Run or save reusable inputs.",
|
||||
toggle_voice_mute => "Mute or unmute the voice microphone.",
|
||||
}
|
||||
composer "Composer" {
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
152 actions, 1 customized, 5 unbound.
|
||||
153 actions, 1 customized, 5 unbound.
|
||||
|
||||
All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -31,9 +31,9 @@ expression: "render_picker(params, 120)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat Open Presets leader p (ctrl-x p)
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer * Submit ctrl-enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
153 actions, 0 customized, 6 unbound.
|
||||
154 actions, 0 customized, 6 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -32,8 +32,8 @@ expression: "render_picker(params, 120)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat Open Presets leader p (ctrl-x p)
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+2
-2
@@ -2,11 +2,11 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (152 selectable)
|
||||
tab: All (153 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (5) (5 selectable)
|
||||
tab: App (21 selectable)
|
||||
tab: App (22 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (69 selectable)
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
152 actions, 1 customized, 6 unbound.
|
||||
153 actions, 1 customized, 7 unbound.
|
||||
|
||||
All Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
|
||||
All Common Customized (1) Unbound (7) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
Type to search shortcuts
|
||||
|
||||
@@ -31,9 +31,9 @@ expression: "render_picker(params, 120)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat - Open Presets unbound
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
152 actions, 0 customized, 5 unbound.
|
||||
153 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor ›
|
||||
|
||||
@@ -31,9 +31,9 @@ expression: "render_picker(params, 78)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat Open Presets leader p (ctrl-x p)
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
152 actions, 0 customized, 5 unbound.
|
||||
153 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -31,9 +31,9 @@ expression: "render_picker(params, 120)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat Open Presets leader p (ctrl-x p)
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -19,6 +19,7 @@ fn configured_shortcut_tips_render_at_narrow_width() {
|
||||
"queue": "ctrl-x q"
|
||||
},
|
||||
"chat": {
|
||||
"open_presets": "ctrl-x p",
|
||||
"increase_reasoning_effort": "ctrl-x +",
|
||||
"decrease_reasoning_effort": "ctrl-x minus"
|
||||
}
|
||||
|
||||
+6
@@ -26,6 +26,9 @@ Line::from_iter([Span::from("Tip:").fg(Color::Reset).bold(), Span::from(" For mo
|
||||
Line::from_iter([Span::from("reasoning, press "), Span::from("⌥.").fg(Color::Rgb(166, 227, 161)), Span::from(" to increase")])
|
||||
Line::from_iter([Span::from("reasoning effort or "), Span::from("⌥,").fg(Color::Rgb(166, 227, 161)), Span::from(" to decrease it.")])
|
||||
Line::default()
|
||||
Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use "), Span::from("⌃x p").fg(Color::Rgb(166, 227, 161)), Span::from(", then 0–9 to run a preset;")])
|
||||
Line::from("Enter browses, s saves, and d deletes.")
|
||||
Line::default()
|
||||
Line::from("Remapped")
|
||||
Line::from_iter([Span::from("Tip:").fg(Color::Reset).bold(), Span::from(" Press "), Span::from("⌃x q").fg(Color::Rgb(166, 227, 161)), Span::from(" to queue a message when")])
|
||||
Line::from("a task is running; otherwise it sends")
|
||||
@@ -50,3 +53,6 @@ Line::from_iter([Span::from("Tip:").fg(Color::Reset).bold(), Span::from(" For mo
|
||||
Line::from_iter([Span::from("reasoning, press "), Span::from("⌃x +").fg(Color::Rgb(166, 227, 161)), Span::from(" to increase")])
|
||||
Line::from_iter([Span::from("reasoning effort or "), Span::from("⌃x -").fg(Color::Rgb(166, 227, 161)), Span::from(" to decrease it.")])
|
||||
Line::default()
|
||||
Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use "), Span::from("⌃x p").fg(Color::Rgb(166, 227, 161)), Span::from(", then 0–9 to run a preset;")])
|
||||
Line::from("Enter browses, s saves, and d deletes.")
|
||||
Line::default()
|
||||
|
||||
Reference in New Issue
Block a user