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:
Felipe Coury
2026-10-15 19:02:34 +00:00
committed by copyberry
parent dcd9e95459
commit 111362a6a0
36 changed files with 688 additions and 152 deletions
+2
View File
@@ -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.
+10
View File
@@ -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,
+1
View File
@@ -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.
-4
View File
@@ -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);
}
+4
View File
@@ -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);
}
+29
View File
@@ -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?;
-2
View File
@@ -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.
//!
+7
View File
@@ -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);
}
+4
View File
@@ -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;
}
+138 -65
View File
@@ -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
}
}
+11 -1
View File
@@ -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();
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
+41 -1
View File
@@ -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()
)
);
}
}
}
+17 -8
View File
@@ -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,
+12 -5
View File
@@ -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);
}
+35 -12
View File
@@ -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> {
+1
View File
@@ -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" {
@@ -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
@@ -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,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)
@@ -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
@@ -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"
}
@@ -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()