Add /presets for reusable prompts and slash commands (#54380)

## What changed

- Add a TUI picker with ten numbered preset slots, an editor, and confirmation before replacement or deletion.
- Create presets from composer text or current model, reasoning effort, and speed settings. Run saved inputs through the normal queued submission path while preserving the composer draft.
- Persist presets under `tui.presets` in the active user configuration, respecting configuration layer ownership and leaving in-memory settings unchanged when writes fail.
- Preserve permissions when changing effort or speed for the effective model, keep default collaboration settings intact in Plan mode, and allow standard-speed selection with older app servers.

## Testing

Add persistence tests for save, replace, delete, configuration ownership, and write failures; picker and confirmation snapshots; and regression coverage for model permissions and standard-speed fallback.

GitOrigin-RevId: 5f11699112cd404cc8e954872d53495c6edd0c0a
This commit is contained in:
Felipe Coury
2026-10-15 19:02:09 +00:00
committed by copyberry
parent 1544ffc79d
commit dcd9e95459
30 changed files with 1194 additions and 21 deletions
+1
View File
@@ -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;
+20
View File
@@ -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(),
+13 -5
View File
@@ -23,8 +23,8 @@ impl App {
effort: Option<ReasoningEffort>,
service_tier: Option<String>,
) {
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 {
+101
View File
@@ -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<QuickDial>) {
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<QuickDial>) {
if let Some(dial) = dial {
self.chat_widget
.open_quick_dial_editor(QuickDialEditorTarget::Slot(slot), Some(dial.clone()));
}
}
}
+16
View File
@@ -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);
@@ -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"] {
@@ -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::<toml::Value>("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::<toml::Value>(&std::fs::read_to_string(profile_path.as_path())?)?,
toml::from_str::<toml::Value>(
"[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::<Vec<_>>()
.join("\n");
assert!(messages.contains("Failed to update preset 4"));
assert!(!messages.contains("Saved preset 4"));
Ok(())
}
+20
View File
@@ -1276,6 +1276,26 @@ 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,
dial: Option<codex_config::types::QuickDial>,
},
SaveQuickDial {
slot: u8,
dial: Option<codex_config::types::QuickDial>,
},
RecallQuickDial {
slot: u8,
thread_id: Option<ThreadId>,
},
/// Show the cyber auto-review notice after the model selection confirmation.
CyberModelAutoReviewNotice,
@@ -49,6 +49,9 @@ pub(crate) struct CustomPromptView {
placeholder: String,
context_label: Option<String>,
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;
@@ -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(),
]);
@@ -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<KeyBinding>,
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<String>,
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<KeyBinding>,
search_mode: SearchMode,
search_active: bool,
auto_number_shortcuts: bool,
search_query: String,
search_placeholder: Option<String>,
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);
}
@@ -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 "
" "
"› / "
" "
+1
View File
@@ -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;
@@ -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
+458
View File
@@ -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<Line<'static>>);
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<QuickDial, &'static str> {
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<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() {
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<QuickDial>,
) {
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<QuickDial>) {
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()
});
}
}
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 3393
expression: "render_bottom_popup(&chat, 80)"
---
Select Model
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+3
View File
@@ -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;
@@ -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.")
);
}
+3
View File
@@ -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