diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 127651bd28..258944b15c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -237,6 +237,7 @@ mod managed_worktree_creation; mod misalignment_policy; mod model_defaults; mod new_session; +mod quick_dials; mod turn_tips; pub(crate) use new_session::has_launch_setting; mod clipboard; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 8efc33bdbc..ac4cff843a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2540,6 +2540,26 @@ 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); + } + AppEvent::ConfirmQuickDial { slot, dial } => self.chat_widget.confirm_quick_dial(slot, dial), + AppEvent::SaveQuickDial { slot, dial } => { + self.app_event_tx.send(AppEvent::FollowTranscript); + self.save_quick_dial(slot, dial).await; + } + AppEvent::RecallQuickDial { slot, thread_id } => { + if self.chat_widget.thread_id() == thread_id { + self.chat_widget.recall_quick_dial(slot); + } + } AppEvent::CyberModelAutoReviewNotice => { self.chat_widget.add_warning_message( "Cyber models default to \"Approve for me\" for safety reasons.".to_string(), diff --git a/codex-rs/tui/src/app/model_defaults.rs b/codex-rs/tui/src/app/model_defaults.rs index 21373a1489..c384559a89 100644 --- a/codex-rs/tui/src/app/model_defaults.rs +++ b/codex-rs/tui/src/app/model_defaults.rs @@ -23,8 +23,8 @@ impl App { effort: Option, service_tier: Option, ) { - let model_changed = self.chat_widget.current_model() != model - || self.chat_widget.current_collaboration_mode().model() != model; + // Changing effort or speed for the effective model must preserve permissions. + let model_changed = self.chat_widget.current_model() != model; let params = if model_changed { self.active_thread_model_setting_update_params(model.clone()) } else { @@ -57,7 +57,10 @@ impl App { if app_server.thread_settings_update_supported { return; } - if service_tier.is_some() { + if service_tier + .as_deref() + .is_some_and(|tier| tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE) + { self.chat_widget.add_error_message( "Model selection requires an app server with thread/settings/update support." .into(), @@ -77,8 +80,13 @@ impl App { let clear_plan_ultra = self.chat_widget.config_ref().plan_mode_reasoning_effort == Some(ReasoningEffort::Ultra) && self.config.plan_mode_reasoning_effort != Some(ReasoningEffort::Ultra); - self.chat_widget.set_model(&model); - if !in_plan_mode || ultra || clear_default_ultra { + if model_changed { + self.chat_widget.set_model(&model); + } + if !in_plan_mode + || (self.chat_widget.current_collaboration_mode().model() == model + && (ultra || clear_default_ultra)) + { self.chat_widget.set_reasoning_effort(effort.clone()); } if in_plan_mode || ultra || clear_plan_ultra { diff --git a/codex-rs/tui/src/app/quick_dials.rs b/codex-rs/tui/src/app/quick_dials.rs new file mode 100644 index 0000000000..41414d5e69 --- /dev/null +++ b/codex-rs/tui/src/app/quick_dials.rs @@ -0,0 +1,101 @@ +//! Persist numbered prompts and slash commands in the active user configuration. +//! Replace slots owned by the active user config. Clearing must not reveal a +//! shadowed slot from another configuration layer. + +use super::App; +use crate::chatwidget::quick_dials::QuickDialEditorTarget; +use crate::config_update::format_config_error; +use crate::legacy_core::config::edit::ConfigEdit; +use crate::legacy_core::config::edit::ConfigEditsBuilder; +use codex_config::ConfigLayerSource; +use codex_config::types::QuickDial; + +impl App { + pub(super) async fn save_quick_dial(&mut self, slot: u8, dial: Option) { + let key = slot.to_string(); + let read_only = { + let mut slot_sources = self + .config + .config_layer_stack + .layers_high_to_low() + .filter(|layer| { + layer + .config + .get("tui") + .and_then(|tui| tui.get("presets")) + .and_then(|dials| dials.get(&key)) + .is_some() + }) + .map(|layer| &layer.name); + let outside_user_config = |source: &ConfigLayerSource| { + !matches!(source, ConfigLayerSource::User { file, .. } + if file == &self.local_settings.user_config_path) + }; + if dial.is_some() { + slot_sources.next().is_some_and(outside_user_config) + } else { + slot_sources.any(outside_user_config) + } + }; + if read_only { + self.chat_widget.add_error_message(format!( + "Preset {slot} is defined outside your user config. Edit its source configuration instead." + )); + self.restore_quick_dial_editor(slot, &dial); + return; + } + let segments = vec!["tui".into(), "presets".into(), key.clone()]; + let edit = match &dial { + Some(candidate) => { + if let Err(reason) = candidate.validate() { + self.chat_widget.add_error_message(reason.into()); + self.restore_quick_dial_editor(slot, &dial); + return; + } + let mut table = toml_edit::Table::new(); + table["input"] = toml_edit::value(&candidate.input); + // An explicit empty label masks labels inherited from lower layers. + table["label"] = toml_edit::value(candidate.label.as_deref().unwrap_or_default()); + ConfigEdit::SetPath { + segments, + value: toml_edit::Item::Table(table), + } + } + None => ConfigEdit::ClearPath { segments }, + }; + if let Err(error) = + ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) + .with_edits([edit]) + .apply() + .await + { + self.chat_widget.add_error_message(format!( + "Failed to update preset {slot}: {}", + format_config_error(&error) + )); + self.restore_quick_dial_editor(slot, &dial); + return; + } + let message = match dial { + Some(mut dial) => { + dial.label.get_or_insert_default(); + self.local_settings.tui.quick_dials.insert(key, dial); + format!("Saved preset {slot}.") + } + None => { + self.local_settings.tui.quick_dials.remove(&key); + format!("Deleted preset {slot}.") + } + }; + self.chat_widget.local_settings.tui.quick_dials = + self.local_settings.tui.quick_dials.clone(); + self.chat_widget.add_info_message(message, /*hint*/ None); + } + + fn restore_quick_dial_editor(&mut self, slot: u8, dial: &Option) { + if let Some(dial) = dial { + self.chat_widget + .open_quick_dial_editor(QuickDialEditorTarget::Slot(slot), Some(dial.clone())); + } + } +} diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 5d85e384e5..1c9f52cd85 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -66,6 +66,8 @@ mod patch_approval_tests; mod permission_selection_tests; #[path = "tests/projectless_tests.rs"] mod projectless_tests; +#[path = "tests/quick_dials_tests.rs"] +mod quick_dials_tests; #[path = "tests/unavailable_commands_tests.rs"] mod unavailable_commands; @@ -9722,7 +9724,20 @@ async fn changing_cyber_model_reasoning_preserves_selected_permissions() { (ReasoningEffortConfig::High, true), (ReasoningEffortConfig::Ultra, true), ] { + app.chat_widget.set_model(&model_name); if session_only { + app.chat_widget.set_model("different-base-model"); + let default_mode = app.chat_widget.current_collaboration_mode().clone(); + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some(model_name.clone()), + reasoning_effort: Some(Some(effort.clone())), + developer_instructions: None, + }); app.handle_event( &mut tui, &mut app_server, @@ -9734,6 +9749,7 @@ async fn changing_cyber_model_reasoning_preserves_selected_permissions() { ) .await .expect("session-only reasoning selection should succeed"); + assert_eq!(app.chat_widget.current_collaboration_mode(), &default_mode); } else if effort == ReasoningEffortConfig::Ultra { app.chat_widget .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); diff --git a/codex-rs/tui/src/app/tests/model_defaults_tests.rs b/codex-rs/tui/src/app/tests/model_defaults_tests.rs index 5841fd45bf..cdd5ac657b 100644 --- a/codex-rs/tui/src/app/tests/model_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::collaboration_modes; use codex_app_server_client::AppServerClient; use codex_config::LoaderOverrides; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use crossterm::event::KeyCode; use pretty_assertions::assert_eq; @@ -115,6 +116,44 @@ async fn session_model_selection_preserves_defaults_and_updates_active_thread() Ok(()) } +#[tokio::test] +async fn standard_speed_selection_falls_back_for_older_app_servers() -> Result<()> { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + app.config.service_tier = Some("flex".into()); + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let started = server.start_thread(&app.config).await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.chat_widget.set_service_tier(Some("flex".into())); + server.thread_settings_update_supported = false; + while events.try_recv().is_ok() {} + while ops.try_recv().is_ok() {} + + app.select_session_model( + &mut server, + "gpt-5.6-terra".into(), + Some(ReasoningEffortConfig::High), + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.into()), + ) + .await; + + assert_eq!( + app.chat_widget.current_service_tier(), + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE) + ); + assert_eq!(app.config.service_tier.as_deref(), Some("flex")); + app.chat_widget.apply_external_edit("continue".into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert_matches!( + next_user_turn_op(&mut ops), + Op::UserTurn { service_tier, .. } + if service_tier == Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.into())) + ); + server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn model_default_saves_report_server_outcomes_and_target_server_profile() -> Result<()> { for outcome in ["saved", "overridden", "rejected"] { diff --git a/codex-rs/tui/src/app/tests/quick_dials_tests.rs b/codex-rs/tui/src/app/tests/quick_dials_tests.rs new file mode 100644 index 0000000000..c18e16e858 --- /dev/null +++ b/codex-rs/tui/src/app/tests/quick_dials_tests.rs @@ -0,0 +1,195 @@ +//! Preset persistence tests for file writes, layer ownership, and failure atomicity. + +use super::*; +use codex_config::LoaderOverrides; +use codex_config::types::QuickDial; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn quick_dials_persist_save_replace_and_delete() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let config_path = app.local_settings.user_config_path.clone(); + std::fs::write( + config_path.as_path(), + "# Keep this setting.\n[tui]\nshow_tooltips = false\n", + )?; + + let first = QuickDial { + input: "Review the current changes".into(), + label: None, + }; + app.save_quick_dial(/*slot*/ 3, Some(first)).await; + let saved = QuickDial { + input: "Review the current changes".into(), + label: Some(String::new()), + }; + assert_eq!( + ( + app.local_settings.tui.quick_dials.get("3"), + app.chat_widget.local_settings.tui.quick_dials.get("3"), + ), + (Some(&saved), Some(&saved)) + ); + + let replacement = QuickDial { + input: "/model gpt-5.6-terra --reasoning=high --speed=fast".into(), + label: Some("deep review".into()), + }; + app.save_quick_dial(/*slot*/ 3, Some(replacement.clone())) + .await; + let reloaded = ConfigBuilder::default() + .codex_home(app.config.codex_home.to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + assert_eq!(reloaded.tui_quick_dials, [("3".into(), replacement)].into()); + assert!(!reloaded.show_tooltips); + + app.save_quick_dial(/*slot*/ 3, /*dial*/ None).await; + let reloaded = ConfigBuilder::default() + .codex_home(app.config.codex_home.to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + assert!(reloaded.tui_quick_dials.is_empty()); + assert!(app.local_settings.tui.quick_dials.is_empty()); + assert!(app.chat_widget.local_settings.tui.quick_dials.is_empty()); + assert!(!reloaded.show_tooltips); + Ok(()) +} + +#[tokio::test] +async fn quick_dials_respect_profile_and_external_ownership() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let codex_home = app.config.codex_home.to_path_buf(); + let base_path = app.local_settings.user_config_path.clone(); + let profile_path = AbsolutePathBuf::from_absolute_path(codex_home.join("work.config.toml"))?; + std::fs::write( + base_path.as_path(), + "[tui.presets.1]\ninput = 'base prompt'\nlabel = 'base label'\n", + )?; + std::fs::write( + profile_path.as_path(), + "[tui.presets.1]\ninput = 'profile prompt'\n", + )?; + let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + loader_overrides.user_config_path = Some(profile_path.clone()); + loader_overrides.user_config_profile = Some("work".parse()?); + let external = + toml::from_str::("input = 'externally managed prompt'\nlabel = 'external'\n")?; + let config = ConfigBuilder::default() + .codex_home(codex_home) + .loader_overrides(loader_overrides) + .cli_overrides(vec![("tui.presets.2".into(), external)]) + .build() + .await?; + app.config.config_layer_stack = config.config_layer_stack.clone(); + app.local_settings = crate::local_settings::LocalSettings::from(&config); + app.chat_widget.local_settings = app.local_settings.clone(); + + let replacement = QuickDial { + input: "replacement prompt".into(), + label: None, + }; + app.save_quick_dial(/*slot*/ 1, Some(replacement.clone())) + .await; + assert_eq!( + toml::from_str::(&std::fs::read_to_string(profile_path.as_path())?)?, + toml::from_str::( + "[tui.presets.1]\ninput = 'replacement prompt'\nlabel = ''\n" + )? + ); + assert_eq!( + std::fs::read_to_string(base_path.as_path())?, + "[tui.presets.1]\ninput = 'base prompt'\nlabel = 'base label'\n" + ); + + while events.try_recv().is_ok() {} + let profile_before_rejections = std::fs::read(profile_path.as_path())?; + let dials_before_rejections = app.local_settings.tui.quick_dials.clone(); + app.save_quick_dial(/*slot*/ 1, /*dial*/ None).await; + app.save_quick_dial( + /*slot*/ 2, + Some(QuickDial { + input: "local replacement".into(), + label: None, + }), + ) + .await; + assert_eq!( + ( + std::fs::read(profile_path.as_path())?, + &app.local_settings.tui.quick_dials, + &app.chat_widget.local_settings.tui.quick_dials, + ), + ( + profile_before_rejections, + &dials_before_rejections, + &dials_before_rejections, + ) + ); + let errors = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string( + &cell.transcript_lines(/*width*/ 200), + )) + } + _ => None, + }) + .filter(|message| message.contains("defined outside your user config")) + .count(); + assert_eq!(errors, 2); + Ok(()) +} + +#[tokio::test] +async fn quick_dial_write_failure_leaves_file_and_memory_unchanged() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let config_path = app.local_settings.user_config_path.clone(); + let existing = QuickDial { + input: "existing prompt".into(), + label: Some("existing".into()), + }; + app.local_settings + .tui + .quick_dials + .insert("4".into(), existing); + app.chat_widget.local_settings = app.local_settings.clone(); + std::fs::write(config_path.as_path(), "[broken")?; + let file_before = std::fs::read(config_path.as_path())?; + let dials_before = app.local_settings.tui.quick_dials.clone(); + while events.try_recv().is_ok() {} + + app.save_quick_dial( + /*slot*/ 4, + Some(QuickDial { + input: "replacement prompt".into(), + label: None, + }), + ) + .await; + + assert_eq!( + ( + std::fs::read(config_path.as_path())?, + &app.local_settings.tui.quick_dials, + &app.chat_widget.local_settings.tui.quick_dials, + ), + (file_before, &dials_before, &dials_before) + ); + let messages = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string( + &cell.transcript_lines(/*width*/ 200), + )) + } + _ => None, + }) + .collect::>() + .join("\n"); + assert!(messages.contains("Failed to update preset 4")); + assert!(!messages.contains("Saved preset 4")); + Ok(()) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 613178d0c1..04af70635f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1276,6 +1276,26 @@ pub(crate) enum AppEvent { service_tier: Option, }, + OpenQuickDials(crate::chatwidget::quick_dials::QuickDialPickerMode), + OpenQuickDialEditor { + target: crate::chatwidget::quick_dials::QuickDialEditorTarget, + dial: Option, + }, + OpenQuickDialPrefills(crate::chatwidget::quick_dials::QuickDialEditorTarget), + OpenQuickDialSavePicker(codex_config::types::QuickDial), + ConfirmQuickDial { + slot: u8, + dial: Option, + }, + SaveQuickDial { + slot: u8, + dial: Option, + }, + RecallQuickDial { + slot: u8, + thread_id: Option, + }, + /// Show the cyber auto-review notice after the model selection confirmation. CyberModelAutoReviewNotice, diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index 061fec4ff4..4c53bb2481 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -49,6 +49,9 @@ pub(crate) struct CustomPromptView { placeholder: String, context_label: Option, on_submit: PromptSubmitted, + submit_label: &'static str, + preserve_whitespace: bool, + dismiss_after_child_accept: bool, // UI state textarea: TextArea, @@ -78,6 +81,9 @@ impl CustomPromptView { placeholder, context_label, on_submit, + submit_label: "submit", + preserve_whitespace: false, + dismiss_after_child_accept: false, textarea, textarea_state: RefCell::new(TextAreaState::default()), paste_burst: PasteBurst::default(), @@ -87,6 +93,19 @@ impl CustomPromptView { } } + /// Keep the prompt open while its submission opens a child view. + pub(crate) fn with_child_flow(mut self, submit_label: &'static str) -> Self { + self.submit_label = submit_label; + self.dismiss_after_child_accept = true; + self + } + + /// Submit the editor contents without trimming their surrounding whitespace. + pub(crate) fn preserve_whitespace(mut self) -> Self { + self.preserve_whitespace = true; + self + } + /// Apply the same editor and Vim bindings used by the main composer. pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { self.textarea.set_keymap_bindings(keymap); @@ -151,10 +170,17 @@ impl CustomPromptView { return; } if modifiers == KeyModifiers::NONE { - let text = self.textarea.text().trim().to_string(); - if !text.is_empty() { + let text = self.textarea.text(); + if !text.trim().is_empty() { + let text = if self.preserve_whitespace { + text.to_string() + } else { + text.trim().to_string() + }; (self.on_submit)(text); - self.completion = Some(ViewCompletion::Accepted); + if !self.dismiss_after_child_accept { + self.completion = Some(ViewCompletion::Accepted); + } } } else { self.textarea.input(key_event); @@ -219,6 +245,10 @@ impl BottomPaneView for CustomPromptView { self.completion } + fn dismiss_after_child_accept(&self) -> bool { + self.dismiss_after_child_accept + } + fn handle_paste(&mut self, pasted: String) -> bool { if pasted.is_empty() { return false; diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view/picker.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view/picker.rs index 947e864d58..5d5f734b14 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view/picker.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view/picker.rs @@ -100,7 +100,7 @@ impl CustomPromptView { }; let hint = Line::from(vec![ key_hint::plain(KeyCode::Enter).into(), - " submit · ".dim(), + format!(" {} · ", self.submit_label).dim(), key_hint::plain(KeyCode::Esc).into(), cancel_label.dim(), ]); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 19f365028c..a5f5458ed3 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -235,6 +235,8 @@ pub(crate) struct SelectionViewParams { /// Optional non-text key that can toggle a row while printable keys edit the search query. pub search_toggle_key: Option, pub search_mode: SearchMode, + /// Use ordinal digits in the gutter; when false, use only each row's explicit shortcut. + pub auto_number_shortcuts: bool, pub search_placeholder: Option, pub col_width_mode: ColumnWidthMode, pub row_display: SelectionRowDisplay, @@ -301,6 +303,7 @@ impl Default for SelectionViewParams { is_searchable: false, search_toggle_key: None, search_mode: SearchMode::default(), + auto_number_shortcuts: true, search_placeholder: None, col_width_mode: ColumnWidthMode::AutoVisible, row_display: SelectionRowDisplay::Wrapped, @@ -358,6 +361,7 @@ pub(crate) struct ListSelectionView { search_toggle_key: Option, search_mode: SearchMode, search_active: bool, + auto_number_shortcuts: bool, search_query: String, search_placeholder: Option, col_width_mode: ColumnWidthMode, @@ -515,6 +519,7 @@ impl ListSelectionView { search_mode: params.search_mode, search_active, search_query, + auto_number_shortcuts: params.auto_number_shortcuts, search_placeholder: if params.is_searchable { params.search_placeholder } else { @@ -764,6 +769,11 @@ impl ListSelectionView { // The number keys don't work when search is enabled (since we let the // numbers be used for the search query). format!("{prefix} ") + } else if !self.auto_number_shortcuts { + item.display_shortcut.map_or_else( + || format!("{prefix} "), + |shortcut| format!("{prefix} {}. ", shortcut.display_label()), + ) } else if is_disabled { if let Some(disabled_gutter_marker) = item.disabled_gutter_marker { let marker_width = UnicodeWidthStr::width(disabled_gutter_marker); @@ -798,7 +808,10 @@ impl ListSelectionView { selection_style: row_selection_style, name: name_with_marker, name_prefix_spans, - display_shortcut: item.display_shortcut, + display_shortcut: self + .auto_number_shortcuts + .then_some(item.display_shortcut) + .flatten(), match_indices: None, description, wrap_indent, @@ -1419,10 +1432,11 @@ impl BottomPaneView for ListSelectionView { self.select_shortcut(idx); return; } - if let Some(idx) = c - .to_digit(10) - .map(|d| d as usize) - .and_then(|number| self.actual_idx_for_enabled_number(number)) + if self.auto_number_shortcuts + && let Some(idx) = c + .to_digit(/*radix*/ 10) + .map(|d| d as usize) + .and_then(|number| self.actual_idx_for_enabled_number(number)) { self.select_shortcut(idx); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_normal_slash.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_normal_slash.snap index d7125e250d..2ad183893b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_normal_slash.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_normal_slash.snap @@ -1,13 +1,12 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 5217 expression: terminal.backend() --- "› /model choose what model and reasoning effort to " " use " +" /presets run or save reusable prompts and slash " +" commands " " /ide include current selection, open files, and" -" other context from your IDE " -" /permissions choose what Codex is allowed to do " " " "› / " " " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ab13bba827..06aa1abc66 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -326,6 +326,7 @@ mod model_command; mod model_popup_state; mod model_popups; mod notifications; +pub(crate) mod quick_dials; mod session_model_selection; use self::notifications::Notification; pub(crate) use self::session_model_selection::AstraModelPickerAction; diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 5b44cac1b0..6a089a0039 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -28,6 +28,7 @@ impl ChatWidget { // Inline commands still follow so their output (including usage errors) is visible. InputResult::Command( SlashCommand::Model + | SlashCommand::Presets | SlashCommand::Keymap | SlashCommand::Memories | SlashCommand::Config diff --git a/codex-rs/tui/src/chatwidget/quick_dials.rs b/codex-rs/tui/src/chatwidget/quick_dials.rs new file mode 100644 index 0000000000..e731623a50 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/quick_dials.rs @@ -0,0 +1,458 @@ +//! Numbered reusable inputs using the existing picker and queued submission path. +//! Saving leaves the draft untouched; recall preserves drafts and obeys normal command gates. + +use super::*; +use crate::wrapping::word_wrap_lines; +use codex_config::types::QuickDial; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; + +struct QuickDialConfirmationHeader(Vec>); + +impl Renderable for QuickDialConfirmationHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + Renderable::render( + &Paragraph::new(word_wrap_lines(&self.0, usize::from(area.width))), + area, + buf, + ); + } + fn desired_height(&self, width: u16) -> u16 { + word_wrap_lines(&self.0, usize::from(width)).len() as u16 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QuickDialPickerMode { + Recall, + Save, + Edit, + Clear, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QuickDialEditorTarget { + PickSlot, + Slot(u8), +} + +#[derive(Clone, Copy)] +enum QuickDialMenuAction { + New, + Edit, + Prefill, + Clear, +} + +fn quick_dial_label(dial: &QuickDial) -> String { + dial.label + .as_deref() + .filter(|label| !label.trim().is_empty()) + .unwrap_or(&dial.input) + .trim_start() + .lines() + .next() + .unwrap_or_default() + .to_string() +} + +impl ChatWidget { + 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() + { + self.add_info_message( + "Images cannot be copied into a preset. Starting with empty text.".into(), + /*hint*/ None, + ); + return String::new(); + } + if !self + .bottom_pane + .composer_draft_snapshot() + .mention_bindings + .is_empty() + { + self.add_info_message( + "Linked mentions cannot be copied into a preset. Starting with empty text.".into(), + /*hint*/ None, + ); + return String::new(); + } + self.bottom_pane.composer_text_with_pending() + } + + pub(super) fn current_model_quick_dial(&self) -> Result { + let Some(model) = self + .model_catalog + .models + .iter() + .find(|model| model.model == self.current_model() && model.show_in_picker) + .filter(|_| !self.restrict_model_picker_to_luna_reserve()) + else { + return Err("The current model cannot be recalled with /model."); + }; + let mut input = format!("/model {}", self.current_model()); + if let Some(effort) = self.current_reasoning_effort() { + let supported = if model.supported_reasoning_efforts.is_empty() { + effort == model.default_reasoning_effort + } else { + model + .supported_reasoning_efforts + .iter() + .any(|option| option.effort == effort) + }; + if !supported { + return Err("Choose an available reasoning effort with /model before saving."); + } + input.push_str(&format!(" --reasoning={effort}")); + } + let speed = self + .current_service_tier() + .filter(|tier| *tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE) + .unwrap_or("standard"); + if !matches!(speed, "standard" | "flex") + && !model + .service_tiers + .iter() + .any(|tier| tier.id == speed && self.config.features.service_tier_enabled(&tier.id)) + { + return Err("Choose an available speed with /model before saving."); + } + input.push_str(&format!(" --speed={speed}")); + let dial = QuickDial { input, label: None }; + dial.validate()?; + Ok(dial) + } + + fn quick_dials_available(&mut self) -> bool { + if self.blocks_direct_input + || self.active_side_conversation + || !self.bottom_pane.composer_input_enabled() + { + self.add_error_message("Presets are unavailable in this conversation.".into()); + return false; + } + if !self.is_session_configured() { + self.add_info_message( + "Presets are available after startup completes.".into(), + /*hint*/ None, + ); + return false; + } + true + } + + pub(crate) fn recall_quick_dial(&mut self, slot: u8) { + if !self.quick_dials_available() { + return; + } + let Some(dial) = self + .local_settings + .tui + .quick_dials + .get(&slot.to_string()) + .cloned() + else { + return; + }; + if let Err(reason) = dial.validate() { + self.add_error_message(format!("Preset {slot}: {reason}")); + return; + } + self.app_event_tx.send(AppEvent::FollowTranscript); + self.queue_user_message_with_options( + dial.input.into(), + QueuedInputAction::ParseSlash, + Vec::new(), + ); + } + + pub(crate) fn open_quick_dials_picker(&mut self, mode: QuickDialPickerMode) { + if !self.quick_dials_available() { + return; + } + self.show_quick_dials_picker(mode, /*current*/ None); + } + + pub(crate) fn open_quick_dial_save_picker(&mut self, dial: QuickDial) { + if let Err(reason) = dial.validate() { + self.add_error_message(reason.into()); + return; + } + self.show_quick_dials_picker(QuickDialPickerMode::Save, Some(dial)); + } + + fn show_quick_dials_picker(&mut self, mode: QuickDialPickerMode, current: Option) { + 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() { + 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), + }; + items.push(SelectionItem { + name: saved.map(quick_dial_label).unwrap_or_default(), + name_prefix_spans: if saved.is_none() { + vec!["empty".dim()] + } else { + Vec::new() + }, + description: saved + .filter(|dial| { + dial.label + .as_deref() + .is_some_and(|label| !label.trim().is_empty()) + }) + .map(|dial| dial.input.clone()), + display_shortcut: Some( + key_hint::plain(KeyCode::Char(char::from(b'0' + slot))).into(), + ), + 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(), + }, + QuickDialPickerMode::Save => AppEvent::SaveQuickDial { + slot, + dial: dial.clone(), + }, + QuickDialPickerMode::Edit => AppEvent::OpenQuickDialEditor { + target: QuickDialEditorTarget::Slot(slot), + dial: saved_for_edit.clone(), + }, + QuickDialPickerMode::Clear => { + AppEvent::ConfirmQuickDial { slot, dial: None } + } + }; + tx.send(event); + })], + dismiss_on_select, + dismiss_parent_on_child_accept, + ..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", + }; + let initial_selected_idx = (mode == QuickDialPickerMode::Save).then(|| { + (1..=9) + .chain(std::iter::once(/*value*/ 0)) + .position(|slot| { + !self + .local_settings + .tui + .quick_dials + .contains_key(&slot.to_string()) + }) + .unwrap_or(/*default*/ 0) + }); + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some("quick-dials"), + auto_number_shortcuts: false, + initial_selected_idx, + max_visible_rows: 14, + title: Some(title.into()), + subtitle: (mode == QuickDialPickerMode::Save) + .then(|| current.as_ref().map(quick_dial_label)) + .flatten(), + items, + ..SelectionViewParams::picker() + }); + self.defer_input_until_settings_applied(); + } + + pub(crate) fn open_quick_dial_editor( + &mut self, + target: QuickDialEditorTarget, + dial: Option, + ) { + if !self.quick_dials_available() { + return; + } + let slot = match target { + QuickDialEditorTarget::PickSlot => None, + QuickDialEditorTarget::Slot(slot) => Some(slot), + }; + let dial = dial.unwrap_or_else(|| QuickDial { + input: self.composer_quick_dial_text(), + label: None, + }); + let title = slot + .filter(|slot| { + self.local_settings + .tui + .quick_dials + .contains_key(&slot.to_string()) + }) + .map_or_else( + || "New preset".to_string(), + |slot| format!("Edit preset {slot}"), + ); + let submit_label = if slot.is_some() { + "review changes" + } else { + "choose slot" + }; + let tx = self.app_event_tx.clone(); + let label = dial.label; + let view = CustomPromptView::new( + title, + "Write a prompt or slash command".to_string(), + dial.input, + /*context_label*/ None, + Box::new(move |input| { + let dial = QuickDial { + input, + label: label.clone(), + }; + tx.send(match target { + QuickDialEditorTarget::PickSlot => AppEvent::OpenQuickDialSavePicker(dial), + QuickDialEditorTarget::Slot(slot) => AppEvent::ConfirmQuickDial { + slot, + dial: Some(dial), + }, + }); + }), + ) + .with_child_flow(submit_label) + .preserve_whitespace(); + self.bottom_pane.show_text_prompt(view); + self.defer_input_until_settings_applied(); + } + + pub(crate) fn open_quick_dial_prefills(&mut self, target: QuickDialEditorTarget) { + if !self.quick_dials_available() { + return; + } + let model = self.current_model_quick_dial(); + let (description, is_disabled, actions) = match model { + Ok(dial) => { + let description = Some(dial.input.clone()); + let actions = vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::OpenQuickDialEditor { + target, + dial: Some(dial.clone()), + }) + }) as SelectionAction]; + (description, false, actions) + } + Err(reason) => (Some(reason.into()), true, Vec::new()), + }; + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some("quick-dial-prefills"), + max_visible_rows: 1, + title: Some("Start preset from current settings".into()), + items: vec![SelectionItem { + name: "Current model".into(), + description, + is_disabled, + actions, + dismiss_parent_on_child_accept: true, + ..Default::default() + }], + ..SelectionViewParams::picker() + }); + self.defer_input_until_settings_applied(); + } + + pub(crate) fn confirm_quick_dial(&mut self, slot: u8, dial: Option) { + if let Some(dial) = &dial + && let Err(reason) = dial.validate() + { + self.add_error_message(reason.into()); + return; + } + let saved = self.local_settings.tui.quick_dials.get(&slot.to_string()); + let (action, description) = match (saved, &dial) { + (Some(old), Some(new)) => ( + "Replace", + format!("{}\n→ {}", old.input.trim(), new.input.trim()), + ), + (Some(old), None) => ("Delete", quick_dial_label(old)), + (None, Some(new)) => ("Save", new.input.trim().to_string()), + (None, None) => return, + }; + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(QuickDialConfirmationHeader( + std::iter::once(Line::from(format!("{action} preset {slot}?").bold())) + .chain( + description + .lines() + .map(|line| Line::from(line.to_string().dim())), + ) + .collect(), + )), + items: vec![SelectionItem { + name: format!("{action} preset {slot}"), + require_explicit_confirmation: true, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveQuickDial { + slot, + dial: dial.clone(), + }) + })], + dismiss_on_select: true, + ..Default::default() + }], + ..SelectionViewParams::picker() + }); + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 1664e493eb..46fe775d17 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -378,6 +378,9 @@ impl ChatWidget { self.open_model_popup(); self.defer_input_until_settings_applied(); } + SlashCommand::Presets => { + self.open_quick_dials_picker(quick_dials::QuickDialPickerMode::Recall); + } SlashCommand::Plan => { self.apply_plan_slash_command(); } @@ -1318,6 +1321,7 @@ impl ChatWidget { | SlashCommand::Compact | SlashCommand::Review | SlashCommand::Model + | SlashCommand::Presets | SlashCommand::Plan | SlashCommand::Goal | SlashCommand::Side diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_all_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_all_models.snap index 509a75dd8a..dfe817667c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_all_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_all_models.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests/model_display_name_tests.rs -assertion_line: 40 expression: "render_bottom_popup(&chat, 80)" --- Select Model and Effort diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_quick_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_quick_picker.snap index 8ae33f16b5..0b466ec609 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_quick_picker.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__custom_model_display_name_quick_picker.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests/model_display_name_tests.rs -assertion_line: 33 expression: "render_bottom_popup(&chat, 80)" --- Select Model diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap index 31485205ed..a090ec159f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_auto_models.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs -assertion_line: 3393 expression: "render_bottom_popup(&chat, 80)" --- Select Model diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index 3f9740b88f..e048e7ff70 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs -expression: popup +expression: "render_bottom_popup(&chat, 80)" --- Select Model and Effort diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_clear.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_clear.snap new file mode 100644 index 0000000000..7b0955a079 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_clear.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Delete a preset + + +› 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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_delete.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_delete.snap new file mode 100644 index 0000000000..109c5f561d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_delete.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Delete preset 1? + coding + + +› 1. Delete preset 1 + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_replace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_replace.snap new file mode 100644 index 0000000000..9f5b01af0f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_replace.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: "render_bottom_popup(&chat, 52)" +--- + Replace preset 1? + /model gpt-5.5 --reasoning=high --speed=fast + → /model gpt-5.6-terra --reasoning=high + --speed=fast + + +› 1. Replace preset 1 + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_save.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_save.snap new file mode 100644 index 0000000000..a7615426b7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_confirm_save.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: "render_bottom_popup(&chat, 52)" +--- + Save preset 2? + /model gpt-5.6-terra --reasoning=high + --speed=fast + + +› 1. Save preset 2 + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_editor.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_editor.snap new file mode 100644 index 0000000000..3b9dc66e66 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_editor.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: editor.clone() +--- + New preset + +› Review the current changes + for safety + + enter choose slot · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_recall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_recall.snap new file mode 100644 index 0000000000..0a02e68f47 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_recall.snap @@ -0,0 +1,23 @@ +--- +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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_save.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_save.snap new file mode 100644 index 0000000000..eb3b518d9b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_save.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Save preset to slot + Review the current changes + + + 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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ee79aeffb4..1e5dade887 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -328,3 +328,6 @@ mod question_notifications_tests; #[path = "tests/security_setup_tests.rs"] mod security_setup_tests; + +#[path = "tests/quick_dials_tests.rs"] +mod quick_dials_tests; diff --git a/codex-rs/tui/src/chatwidget/tests/quick_dials_tests.rs b/codex-rs/tui/src/chatwidget/tests/quick_dials_tests.rs new file mode 100644 index 0000000000..649580621a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/quick_dials_tests.rs @@ -0,0 +1,155 @@ +//! Save, Clear, and confirmation layouts keep destructive preset actions explicit. + +use super::*; +use crate::chatwidget::quick_dials::QuickDialEditorTarget; +use crate::chatwidget::quick_dials::QuickDialPickerMode; +use codex_config::types::QuickDial; + +#[tokio::test] +async fn saved_quick_dials_save_and_clear_picker_snapshots() { + const DRAFT: &str = "Review the current changes\nfor safety "; + let (mut chat, mut events, _ops) = + make_chatwidget_manual(/*model_override*/ Some("gpt-5.5")).await; + chat.thread_id = Some(ThreadId::new()); + chat.local_settings.tui.quick_dials = [( + "1".into(), + QuickDial { + input: "/model gpt-5.5 --reasoning=high --speed=fast".into(), + label: Some("coding".into()), + }, + )] + .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) + ); + chat.handle_key_event(KeyCode::Esc.into()); + while events.try_recv().is_ok() {} + chat.open_quick_dial_editor(QuickDialEditorTarget::PickSlot, /*dial*/ None); + let editor = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("saved_quick_dials_editor", editor.clone()); + chat.handle_key_event(KeyCode::Enter.into()); + let edited = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::OpenQuickDialSavePicker(dial) => Some(dial), + _ => None, + }) + .expect("editor submits a preset draft"); + assert_eq!( + edited, + QuickDial { + input: DRAFT.into(), + label: None, + } + ); + chat.open_quick_dial_save_picker(edited); + assert_chatwidget_snapshot!( + "saved_quick_dials_save", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(KeyCode::Esc.into()); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), editor); + chat.handle_key_event(KeyCode::Esc.into()); + + let saved = chat.local_settings.tui.quick_dials["1"].clone(); + chat.open_quick_dial_editor(QuickDialEditorTarget::Slot(/*slot*/ 1), Some(saved.clone())); + chat.handle_key_event(KeyCode::Enter.into()); + assert_matches!( + std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::ConfirmQuickDial { .. })), + Some(AppEvent::ConfirmQuickDial { + slot: 1, + dial: Some(actual), + }) if actual == saved + ); + chat.handle_key_event(KeyCode::Esc.into()); + + chat.open_quick_dials_picker(QuickDialPickerMode::Clear); + assert_chatwidget_snapshot!( + "saved_quick_dials_clear", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(KeyCode::Esc.into()); + while events.try_recv().is_ok() {} + let replacement = QuickDial { + input: "/model gpt-5.6-terra --reasoning=high --speed=fast".into(), + label: None, + }; + chat.confirm_quick_dial(/*slot*/ 2, Some(replacement.clone())); + assert_chatwidget_snapshot!( + "saved_quick_dials_confirm_save", + render_bottom_popup(&chat, /*width*/ 52) + ); + chat.handle_key_event(KeyCode::Esc.into()); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::SaveQuickDial { .. })) + ); + + chat.confirm_quick_dial(/*slot*/ 1, Some(replacement.clone())); + assert_chatwidget_snapshot!( + "saved_quick_dials_confirm_replace", + render_bottom_popup(&chat, /*width*/ 52) + ); + chat.handle_key_event(KeyCode::Enter.into()); + assert_matches!( + events.try_recv(), + Ok(AppEvent::SaveQuickDial { + slot: 1, + dial: Some(actual), + }) if actual == replacement + ); + + chat.confirm_quick_dial(/*slot*/ 1, /*dial*/ None); + assert_chatwidget_snapshot!( + "saved_quick_dials_confirm_delete", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(KeyCode::Esc.into()); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::SaveQuickDial { .. })) + ); + + chat.recall_quick_dial(/*slot*/ 1); + assert_eq!(chat.bottom_pane.composer_text(), DRAFT); + assert_eq!( + chat.queued_user_message_texts(), + ["/model gpt-5.5 --reasoning=high --speed=fast"] + ); + + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + chat.effective_service_tier = Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.into()); + assert_eq!( + chat.current_model_quick_dial(), + Ok(QuickDial { + input: "/model gpt-5.5 --reasoning=high --speed=standard".into(), + label: None, + }) + ); + let expected_model = chat.current_model_quick_dial().expect("model command"); + chat.open_quick_dial_prefills(QuickDialEditorTarget::PickSlot); + chat.handle_key_event(KeyCode::Enter.into()); + assert_matches!( + std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::OpenQuickDialEditor { .. })), + Some(AppEvent::OpenQuickDialEditor { + target: QuickDialEditorTarget::PickSlot, + dial: Some(actual), + }) if actual == expected_model + ); + chat.handle_key_event(KeyCode::Esc.into()); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::Custom("removed-effort".into()))); + assert_eq!( + chat.current_model_quick_dial(), + Err("Choose an available reasoning effort with /model before saving.") + ); + chat.set_reasoning_effort(/*effort*/ None); + chat.effective_service_tier = Some("removed-speed".into()); + assert_eq!( + chat.current_model_quick_dial(), + Err("Choose an available speed with /model before saving.") + ); +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 65d7965236..d7b0e99d06 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,6 +13,7 @@ pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. Model, + Presets, Daybreak, Ide, Permissions, @@ -132,6 +133,7 @@ impl SlashCommand { SlashCommand::MemoryDrop => "DO NOT USE", SlashCommand::MemoryUpdate => "DO NOT USE", SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Presets => "run or save reusable prompts and slash commands", SlashCommand::Daybreak => "turn Daybreak on or off", SlashCommand::Ide => { "include current selection, open files, and other context from your IDE" @@ -269,6 +271,7 @@ impl SlashCommand { | SlashCommand::Archive | SlashCommand::Resume | SlashCommand::Model + | SlashCommand::Presets | SlashCommand::Daybreak | SlashCommand::Permissions | SlashCommand::Copy