diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 45871f151d..55326030f0 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -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, +} + +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, + /// Startup tooltip availability NUX state persisted by the TUI. #[serde(default)] pub model_availability_nux: ModelAvailabilityNuxConfig, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7c6da97d0f..82f03f6f7d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 5826e93c01..1799330505 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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, } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b21aa349a7..20939cf80b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + /// 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()) { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 0d53be0050..af50bd5f44 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -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, diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index a9bed99796..1d0d7eb84b 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -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,