mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+2
-3
@@ -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 "
|
||||
" "
|
||||
"› / "
|
||||
" "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
-1
@@ -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
|
||||
|
||||
|
||||
+19
@@ -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
|
||||
+11
@@ -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
|
||||
+13
@@ -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
|
||||
+12
@@ -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
|
||||
+10
@@ -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
|
||||
+23
@@ -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
|
||||
+20
@@ -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
|
||||
@@ -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.")
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user