Add configuration support for TUI input presets (#54379)

## What changed

Add `tui.presets` entries keyed by a single digit from `0` to `9`, each with an `input` prompt or slash command and an optional `label`. Expose presets in the configuration schema, load them into runtime configuration, and preserve them in TUI local settings.

Reject invalid slots, blank inputs, shell commands beginning with `!` after leading whitespace, and inputs exceeding 8,000 UTF-8 bytes.

## Testing

Extend the runtime configuration test to verify loading both a slash command with a label and a prompt without one.

GitOrigin-RevId: d2911e540bc50f05b4a5224abf17ce4ef5a18ea3
This commit is contained in:
Felipe Coury
2026-10-15 19:01:43 +00:00
committed by copyberry
parent 0884c8047a
commit 1544ffc79d
6 changed files with 110 additions and 0 deletions
+30
View File
@@ -822,6 +822,32 @@ pub struct ModelAvailabilityNuxConfig {
/// Fallback resize-reflow row cap when Codex cannot identify a terminal-specific scrollback size.
pub const DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS: usize = 1_000;
/// One reusable prompt or slash command submitted through the TUI composer.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields, rename = "Preset")]
pub struct QuickDial {
/// Plain-text input, limited to 8,000 UTF-8 bytes.
pub input: String,
/// Optional short name for the picker and shortcut hints.
pub label: Option<String>,
}
impl QuickDial {
/// Keep stored inputs nonempty and below the context-item limit even with byte tokenization.
pub fn validate(&self) -> Result<(), &'static str> {
if self.input.trim().is_empty() {
return Err("Preset input cannot be empty.");
}
if self.input.trim_start().starts_with('!') {
return Err("Presets support prompts and slash commands, not shell commands.");
}
if self.input.len() > 8_000 {
return Err("Preset input cannot exceed 8,000 UTF-8 bytes.");
}
Ok(())
}
}
/// Collection of settings that are specific to the TUI.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -978,6 +1004,10 @@ pub struct Tui {
#[serde(default)]
pub keymap: TuiKeymap,
/// Saved inputs keyed by a single digit (`0` through `9`).
#[serde(default, rename = "presets")]
pub quick_dials: BTreeMap<String, QuickDial>,
/// Startup tooltip availability NUX state persisted by the TUI.
#[serde(default)]
pub model_availability_nux: ModelAvailabilityNuxConfig,
+26
View File
@@ -3767,6 +3767,24 @@
},
"type": "object"
},
"Preset": {
"additionalProperties": false,
"description": "One reusable prompt or slash command submitted through the TUI composer.",
"properties": {
"input": {
"description": "Plain-text input, limited to 8,000 UTF-8 bytes.",
"type": "string"
},
"label": {
"description": "Optional short name for the picker and shortcut hints.",
"type": "string"
}
},
"required": [
"input"
],
"type": "object"
},
"ProfileTui": {
"additionalProperties": false,
"description": "TUI settings supported inside a named profile.",
@@ -4963,6 +4981,14 @@
"default": "composer",
"description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport."
},
"presets": {
"additionalProperties": {
"$ref": "#/definitions/Preset"
},
"default": {},
"description": "Saved inputs keyed by a single digit (`0` through `9`).",
"type": "object"
},
"question_esc_back": {
"default": true,
"description": "Escape returns from async questions to the composer, preserving the answer draft.",
+29
View File
@@ -1284,6 +1284,7 @@ fn config_toml_deserializes_model_availability_nux() {
agents_overview_grouping: Default::default(),
resume_cwd: None,
keymap: TuiKeymap::default(),
quick_dials: BTreeMap::new(),
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
("gpt-bar".to_string(), 4),
@@ -1364,6 +1365,13 @@ async fn runtime_config_uses_tui_raw_output_mode() {
let toml = r#"
[tui]
raw_output_mode = true
[tui.presets.1]
input = "/model gpt-5.6-sol high fast"
label = "fast coding"
[tui.presets.2]
input = "Review the current changes"
"#;
let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
let cfg = Config::load_from_base_config_with_overrides(
@@ -1375,6 +1383,26 @@ async fn runtime_config_uses_tui_raw_output_mode() {
.expect("load config");
assert!(cfg.tui_raw_output_mode);
assert_eq!(
cfg.tui_quick_dials,
[
(
"1".into(),
QuickDial {
input: "/model gpt-5.6-sol high fast".into(),
label: Some("fast coding".into()),
},
),
(
"2".into(),
QuickDial {
input: "Review the current changes".into(),
label: None,
},
),
]
.into(),
);
}
#[tokio::test]
@@ -4496,6 +4524,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
agents_overview_grouping: Default::default(),
resume_cwd: None,
keymap: TuiKeymap::default(),
quick_dials: BTreeMap::new(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow_max_rows: None,
}
+23
View File
@@ -48,6 +48,7 @@ use codex_config::types::MemoriesConfig;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::Notice;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_config::types::QuickDial;
use codex_config::types::ResumeCwdMode;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSuggestConfig;
@@ -892,6 +893,9 @@ pub struct Config {
/// 3. built-in defaults
pub tui_keymap: TuiKeymap,
/// Saved TUI inputs keyed by a single digit (`0` through `9`).
pub tui_quick_dials: BTreeMap<String, QuickDial>,
/// The absolute directory that should be treated as the current working
/// directory for the session. All relative paths inside the business-logic
/// layer are resolved against this path.
@@ -3307,6 +3311,20 @@ impl Config {
validate_model_providers(&cfg.model_providers)
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
for (slot, dial) in cfg.tui.iter().flat_map(|tui| &tui.quick_dials) {
if !matches!(slot.as_bytes(), [b'0'..=b'9']) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("tui.presets.{slot}: slot must be a single digit from 0 to 9"),
));
}
dial.validate().map_err(|reason| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("tui.presets.{slot}: {reason}"),
)
})?;
}
if cfg.model_post_turn_compact_threshold_percent.is_some_and(|percent| percent > 100) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
@@ -4705,6 +4723,11 @@ impl Config {
.as_ref()
.map(|t| t.keymap.clone())
.unwrap_or_default(),
tui_quick_dials: cfg
.tui
.as_ref()
.map(|t| t.quick_dials.clone())
.unwrap_or_default(),
otel,
};
if let Some(message) = config.credential_masking_warning(config.permissions.permission_profile()) {
@@ -308,6 +308,7 @@ async fn new_config(
tui_pet_anchor: TuiPetAnchor::Composer,
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
tui_keymap: TuiKeymap::default(),
tui_quick_dials: BTreeMap::new(),
tui_session_picker_view: SessionPickerViewMode::Dense,
tui_agents_overview_grouping: Default::default(),
tui_resume_cwd: None,
+1
View File
@@ -84,6 +84,7 @@ impl LocalSettings {
agents_overview_grouping: config.tui_agents_overview_grouping,
resume_cwd: config.tui_resume_cwd,
keymap: config.tui_keymap.clone(),
quick_dials: config.tui_quick_dials.clone(),
model_availability_nux: config.model_availability_nux.clone(),
terminal_resize_reflow_max_rows: match config.terminal_resize_reflow.max_rows {
TerminalResizeReflowMaxRows::Auto => None,