Compare commits

...

3 Commits

Author SHA1 Message Date
Ahmed Ibrahim
5158d49d75 reorg 2025-09-14 17:20:35 -04:00
Ahmed Ibrahim
dcb75ca8e7 codex/add-api-model-visibility-with-disabled-selection 2025-09-14 17:17:14 -04:00
Ahmed Ibrahim
7cb7f07a5b Handle API-only model options in TUI 2025-09-14 16:45:39 -04:00
6 changed files with 238 additions and 32 deletions

View File

@@ -1,4 +1,5 @@
use codex_core::protocol_config_types::ReasoningEffort;
use codex_protocol::mcp_protocol::AuthMode;
/// A simple preset pairing a model slug with a reasoning effort.
#[derive(Debug, Clone, Copy)]
@@ -13,6 +14,8 @@ pub struct ModelPreset {
pub model: &'static str,
/// Reasoning effort to apply for this preset.
pub effort: Option<ReasoningEffort>,
/// Authentication modes this preset is enabled for.
pub enabled_for_auth: &'static [AuthMode],
}
/// Built-in list of model presets that pair a model with a reasoning effort.
@@ -27,6 +30,7 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
description: "",
model: "swiftfox-low",
effort: None,
enabled_for_auth: &[AuthMode::ChatGPT],
},
ModelPreset {
id: "swiftfox-medium",
@@ -34,6 +38,7 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
description: "",
model: "swiftfox-medium",
effort: None,
enabled_for_auth: &[AuthMode::ChatGPT],
},
ModelPreset {
id: "swiftfox-high",
@@ -41,6 +46,7 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
description: "",
model: "swiftfox-high",
effort: None,
enabled_for_auth: &[AuthMode::ChatGPT],
},
ModelPreset {
id: "gpt-5-minimal",
@@ -48,6 +54,7 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks",
model: "gpt-5",
effort: Some(ReasoningEffort::Minimal),
enabled_for_auth: &[AuthMode::ApiKey, AuthMode::ChatGPT],
},
ModelPreset {
id: "gpt-5-low",
@@ -55,6 +62,7 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
description: "— balances speed with some reasoning; useful for straightforward queries and short explanations",
model: "gpt-5",
effort: Some(ReasoningEffort::Low),
enabled_for_auth: &[AuthMode::ApiKey, AuthMode::ChatGPT],
},
ModelPreset {
id: "gpt-5-medium",
@@ -62,6 +70,7 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks",
model: "gpt-5",
effort: Some(ReasoningEffort::Medium),
enabled_for_auth: &[AuthMode::ApiKey, AuthMode::ChatGPT],
},
ModelPreset {
id: "gpt-5-high",
@@ -69,6 +78,7 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
description: "— maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5",
effort: Some(ReasoningEffort::High),
enabled_for_auth: &[AuthMode::ApiKey, AuthMode::ChatGPT],
},
];
PRESETS

View File

@@ -185,12 +185,14 @@ impl WidgetRef for CommandPopup {
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
description: Some(cmd.description().to_string()),
enabled: true,
},
CommandItem::UserPrompt(i) => GenericDisplayRow {
name: format!("/{}", self.prompts[i].name),
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
description: Some("send saved prompt".to_string()),
enabled: true,
},
})
.collect()

View File

@@ -128,6 +128,7 @@ impl WidgetRef for &FileSearchPopup {
.map(|v| v.iter().map(|&i| i as usize).collect()),
is_current: false,
description: None,
enabled: true,
})
.collect()
};

View File

@@ -27,6 +27,7 @@ pub(crate) struct SelectionItem {
pub description: Option<String>,
pub is_current: bool,
pub actions: Vec<SelectionAction>,
pub enabled: bool,
}
pub(crate) struct ListSelectionView {
@@ -48,6 +49,27 @@ impl ListSelectionView {
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
para.render(area, buf);
}
fn ensure_selected_is_enabled(&mut self) {
if self.items.is_empty() {
self.state.selected_idx = None;
self.state.scroll_top = 0;
return;
}
if self
.state
.selected_idx
.is_some_and(|idx| self.items.get(idx).is_some_and(|item| item.enabled))
{
return;
}
self.state.selected_idx = self.items.iter().position(|item| item.enabled);
if self.state.selected_idx.is_none() {
self.state.scroll_top = 0;
}
}
pub fn new(
title: String,
subtitle: Option<String>,
@@ -69,25 +91,64 @@ impl ListSelectionView {
s.state.selected_idx = Some(idx);
}
s.state.clamp_selection(len);
s.ensure_selected_is_enabled();
s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
s
}
fn move_up(&mut self) {
let len = self.items.len();
self.state.move_up_wrap(len);
if len == 0 {
self.state.selected_idx = None;
self.state.scroll_top = 0;
return;
}
self.ensure_selected_is_enabled();
let Some(mut idx) = self.state.selected_idx else {
return;
};
for _ in 0..len {
idx = if idx == 0 { len - 1 } else { idx - 1 };
if self.items[idx].enabled {
self.state.selected_idx = Some(idx);
break;
}
}
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn move_down(&mut self) {
let len = self.items.len();
self.state.move_down_wrap(len);
if len == 0 {
self.state.selected_idx = None;
self.state.scroll_top = 0;
return;
}
self.ensure_selected_is_enabled();
let Some(mut idx) = self.state.selected_idx else {
return;
};
for _ in 0..len {
idx = (idx + 1) % len;
if self.items[idx].enabled {
self.state.selected_idx = Some(idx);
break;
}
}
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn accept(&mut self) {
self.ensure_selected_is_enabled();
if let Some(idx) = self.state.selected_idx {
if let Some(item) = self.items.get(idx) {
if !item.enabled {
return;
}
for act in &item.actions {
act(&self.app_event_tx);
}
@@ -220,6 +281,7 @@ impl BottomPaneView for ListSelectionView {
match_indices: None,
is_current: it.is_current,
description: it.description.clone(),
enabled: it.enabled,
}
})
.collect();
@@ -266,12 +328,14 @@ mod tests {
description: Some("Codex can read files".to_string()),
is_current: true,
actions: vec![],
enabled: true,
},
SelectionItem {
name: "Full Access".to_string(),
description: Some("Codex can edit files".to_string()),
is_current: false,
actions: vec![],
enabled: true,
},
];
ListSelectionView::new(
@@ -321,4 +385,32 @@ mod tests {
let view = make_selection_view(Some("Switch between Codex approval presets"));
assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view));
}
#[test]
fn skips_disabled_items_when_initial_selection_is_disabled() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let items = vec![
SelectionItem {
name: "Swiftfox".to_string(),
description: None,
is_current: true,
actions: vec![],
enabled: false,
},
SelectionItem {
name: "GPT".to_string(),
description: None,
is_current: false,
actions: vec![],
enabled: true,
},
];
let view = ListSelectionView::new("Select Model".to_string(), None, None, items, tx);
let rendered = render_lines(&view);
assert!(
rendered.contains("> 2. GPT"),
"expected enabled item to be selected: {rendered}"
);
}
}

View File

@@ -23,6 +23,7 @@ pub(crate) struct GenericDisplayRow {
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub is_current: bool,
pub description: Option<String>, // optional grey text after the name
pub enabled: bool,
}
impl GenericDisplayRow {}
@@ -73,6 +74,7 @@ pub(crate) fn render_rows(
match_indices,
is_current: _is_current,
description,
enabled,
} = row;
// Highlight fuzzy indices when present.
@@ -97,12 +99,15 @@ pub(crate) fn render_rows(
}
let mut cell = Cell::from(Line::from(spans));
if Some(i) == state.selected_idx {
let is_selected = Some(i) == state.selected_idx;
if is_selected && *enabled {
cell = cell.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
} else if !*enabled || is_selected {
cell = cell.style(Style::default().add_modifier(Modifier::DIM));
}
rows.push(Row::new(vec![cell]));
}

View File

@@ -3,6 +3,7 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::auth::CodexAuth;
use codex_core::config::Config;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
@@ -85,7 +86,9 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_file_search::FileMatch;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::ConversationId;
use std::collections::HashSet;
// Track information about an in-flight exec command.
struct RunningCommand {
@@ -1173,41 +1176,46 @@ impl ChatWidget {
let current_effort = self.config.model_reasoning_effort;
let presets: &[ModelPreset] = builtin_model_presets();
let auth_mode = self.auth_mode_for_model_popup();
let mut items: Vec<SelectionItem> = Vec::new();
let mut disabled_families_shown: HashSet<String> = HashSet::new();
for preset in presets.iter() {
let name = preset.label.to_string();
let description = Some(preset.description.to_string());
let disabled_for_auth = Self::is_preset_disabled_for_auth(preset, auth_mode);
if disabled_for_auth {
let family = Self::model_family_key_from_slug(preset.model);
if disabled_families_shown.insert(family.clone()) {
let description =
Self::description_with_auth_hint(preset.description, auth_mode, true);
let is_current =
Self::is_current_for_family(&current_model, &family, preset.model);
items.push(SelectionItem {
name: family,
description,
is_current,
enabled: false,
actions: Vec::new(),
});
}
continue;
}
let description =
Self::description_with_auth_hint(preset.description, auth_mode, false);
let is_current = preset.model == current_model && preset.effort == current_effort;
let model_slug = preset.model.to_string();
let effort = preset.effort;
let current_model = current_model.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: Some(model_slug.clone()),
effort: Some(effort),
summary: None,
}));
tx.send(AppEvent::UpdateModel(model_slug.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort));
tracing::info!(
"New model: {}, New effort: {}, Current model: {}, Current effort: {}",
model_slug.clone(),
effort
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string()),
current_model,
current_effort
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string())
);
})];
let actions: Vec<SelectionAction> = vec![Self::build_model_selection_action(
preset.model.to_string(),
preset.effort,
current_model.clone(),
current_effort,
)];
items.push(SelectionItem {
name,
name: preset.label.to_string(),
description,
is_current,
enabled: true,
actions,
});
}
@@ -1249,6 +1257,7 @@ impl ChatWidget {
name,
description,
is_current,
enabled: true,
actions,
});
}
@@ -1402,6 +1411,93 @@ impl ChatWidget {
let [_, bottom_pane_area] = self.layout_areas(area);
self.bottom_pane.cursor_pos(bottom_pane_area)
}
fn auth_mode_for_model_popup(&self) -> Option<AuthMode> {
if self.config.model_provider.requires_openai_auth {
match CodexAuth::from_codex_home(&self.config.codex_home) {
Ok(Some(auth)) => Some(auth.mode),
Ok(None) => None,
Err(err) => {
debug!(?err, "failed to read auth state for model popup");
None
}
}
} else {
None
}
}
fn is_preset_disabled_for_auth(preset: &ModelPreset, auth_mode: Option<AuthMode>) -> bool {
match auth_mode {
Some(mode) => !preset.enabled_for_auth.contains(&mode),
None => false,
}
}
fn model_family_key_from_slug(slug: &str) -> String {
if let Some(s) = slug.strip_suffix("-low") {
return s.to_string();
}
if let Some(s) = slug.strip_suffix("-medium") {
return s.to_string();
}
if let Some(s) = slug.strip_suffix("-high") {
return s.to_string();
}
slug.to_string()
}
fn description_with_auth_hint(
base: &str,
auth_mode: Option<AuthMode>,
disabled_for_auth: bool,
) -> Option<String> {
let mut out = base.to_string();
if disabled_for_auth && matches!(auth_mode, Some(AuthMode::ApiKey)) {
if out.is_empty() {
out.push_str("(coming soon in API)");
} else {
out.push(' ');
out.push_str("(coming soon in API)");
}
}
if out.is_empty() { None } else { Some(out) }
}
fn is_current_for_family(current_model: &str, family: &str, preset_model: &str) -> bool {
current_model == preset_model || current_model.starts_with(&format!("{family}-"))
}
fn build_model_selection_action(
model_slug: String,
effort: Option<ReasoningEffortConfig>,
current_model_for_log: String,
current_effort: Option<ReasoningEffortConfig>,
) -> SelectionAction {
Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: Some(model_slug.clone()),
effort: Some(effort),
summary: None,
}));
tx.send(AppEvent::UpdateModel(model_slug.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort));
tracing::info!(
"New model: {}, New effort: {}, Current model: {}, Current effort: {}",
model_slug.clone(),
effort
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string()),
current_model_for_log,
current_effort
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string())
);
})
}
}
impl WidgetRef for &ChatWidget {