use super::super::*;
use super::login::{claude_models, has_local_login, AuthProvider};
use crate::config::{
save_model_selection_preference, ModelSelectionPreference, ModelSelectionSource,
};
use a3s_tui::components::{TabbedMenuItem, TabbedMenuPanel, TabbedMenuPanelMsg, TabbedMenuTab};
use a3s_tui::event::MouseEvent;
struct ModelTab {
label: &'static str,
color: Color,
models: Vec<String>,
provider: Option<AuthProvider>, os_gateway: bool, }
fn selected_model_location(tabs: &[ModelTab], current: Option<&str>) -> (usize, usize) {
let current = current.map(crate::claude::canonical_model_name);
current
.as_deref()
.and_then(|current| {
tabs.iter().enumerate().find_map(|(tab_idx, tab)| {
tab.models
.iter()
.position(|model| model == current)
.map(|model_idx| (tab_idx, model_idx))
})
})
.unwrap_or((0, 0))
}
const A3S_COLOR: Color = ACCENT;
const CLAUDE_COLOR: Color = TN_ORANGE;
const CODEX_COLOR: Color = TN_CYAN;
fn model_menu_max_rows(height: usize) -> usize {
height.saturating_sub(8).clamp(3, 12)
}
fn model_menu_height(tabs: &[ModelTab], active_tab: usize, max_items: usize) -> usize {
if tabs.is_empty() {
return 0;
}
let active_tab = active_tab.min(tabs.len() - 1);
let active_items = tabs[active_tab].models.len();
let header_rows = 1 + usize::from(tabs.len() > 1) + 1;
let item_rows = active_items.max(1).min(max_items);
header_rows + item_rows + 1
}
fn model_menu_panel(
tabs: &[ModelTab],
active_tab: usize,
selected: usize,
current_model: Option<&str>,
max_items: usize,
) -> TabbedMenuPanel {
let active_tab = active_tab.min(tabs.len().saturating_sub(1));
if tabs.is_empty() {
return TabbedMenuPanel::new(Vec::new());
}
let panel_tabs = tabs
.iter()
.map(|tab| {
let items = tab
.models
.iter()
.map(|model| {
let prefix = if Some(model.as_str()) == current_model {
"●"
} else {
" "
};
TabbedMenuItem::new(model.clone()).prefix(prefix)
})
.collect::<Vec<_>>();
TabbedMenuTab::new(tab.label, tab.color)
.items(items)
.empty_text("(no models)")
})
.collect::<Vec<_>>();
TabbedMenuPanel::new(panel_tabs)
.title("Select model")
.hint("↑/↓ model · ←/→ account · Enter · Esc")
.active_tab(active_tab)
.selected(selected)
.max_items(max_items)
.indent(2)
.hint_color(TN_GRAY)
.text_color(TN_GRAY)
.muted_color(TN_GRAY)
.selected_colors(Color::BrightWhite, ACCENT)
}
fn model_menu_lines(
tabs: &[ModelTab],
active_tab: usize,
selected: usize,
current_model: Option<&str>,
width: usize,
max_items: usize,
) -> Vec<String> {
if tabs.is_empty() {
return Vec::new();
}
let height = model_menu_height(tabs, active_tab, max_items);
model_menu_panel(tabs, active_tab, selected, current_model, max_items)
.view(width.min(u16::MAX as usize) as u16, height)
.lines()
.map(str::to_string)
.collect()
}
fn model_menu_overlay_y_offset(screen_height: usize, row_count: usize) -> u16 {
screen_height
.saturating_sub(5)
.saturating_sub(row_count)
.min(u16::MAX as usize) as u16
}
fn should_fetch_os_gateway_models(
active_tab: Option<&ModelTab>,
gateway_models: Option<&[String]>,
loading: bool,
signed_in: bool,
) -> bool {
active_tab.is_some_and(|tab| tab.os_gateway)
&& signed_in
&& !loading
&& gateway_models.is_none_or(|models| models.is_empty())
}
impl App {
fn model_tabs(&self) -> Vec<ModelTab> {
let mut tabs = vec![ModelTab {
label: "a3s-code",
color: A3S_COLOR,
models: self.models.clone(),
provider: None,
os_gateway: false,
}];
if has_local_login(AuthProvider::Claude) {
tabs.push(ModelTab {
label: "Claude Code",
color: CLAUDE_COLOR,
models: claude_models(), provider: Some(AuthProvider::Claude),
os_gateway: false,
});
}
if has_local_login(AuthProvider::Codex) {
tabs.push(ModelTab {
label: "Codex",
color: CODEX_COLOR,
models: crate::codex::codex_models(), provider: Some(AuthProvider::Codex),
os_gateway: false,
});
}
if self.os_session.is_some() {
let models = match &self.os_gateway_models {
Some(m) if !m.is_empty() => m.clone(),
Some(_) => vec![if self.os_gateway_error.is_some() {
"(gateway unreachable)".to_string()
} else {
"(no models configured)".to_string()
}],
None => vec!["(loading…)".to_string()],
};
tabs.push(ModelTab {
label: "OS Gateway",
color: TN_CYAN,
models,
provider: None,
os_gateway: true,
});
}
tabs
}
pub(crate) fn open_model_menu(&mut self) {
let tabs = self.model_tabs();
if tabs.iter().all(|t| t.models.is_empty()) {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(" no models configured in config.acl"),
);
return;
}
let (tab, idx) = selected_model_location(&tabs, self.model.as_deref());
self.model_tab = tab;
self.model_menu = Some(idx);
}
pub(crate) fn maybe_fetch_active_os_gateway_models(&mut self) -> Option<Cmd<Msg>> {
let tabs = self.model_tabs();
let active_tab = self.model_tab.min(tabs.len().saturating_sub(1));
if !should_fetch_os_gateway_models(
tabs.get(active_tab),
self.os_gateway_models.as_deref(),
self.os_gateway_models_loading,
self.os_session.is_some(),
) {
return None;
}
let session = self.os_session.clone()?;
self.os_gateway_models_loading = true;
self.os_gateway_models = None;
self.os_gateway_error = None;
let addr = session.address.clone();
let token = session.access_token.clone();
let login_at_ms = session.login_at_ms;
Some(cmd::cmd(move || async move {
Msg::OsGatewayModels {
login_at_ms,
result: crate::a3s_os::fetch_gateway_models(&addr, &token).await,
}
}))
}
pub(crate) fn clamp_open_model_menu_selection(&mut self) {
let Some(sel) = self.model_menu else {
return;
};
let tabs = self.model_tabs();
if tabs.is_empty() {
return;
}
self.model_tab = self.model_tab.min(tabs.len() - 1);
let last = tabs[self.model_tab].models.len().saturating_sub(1);
self.model_menu = Some(sel.min(last));
}
pub(crate) fn handle_model_key(&mut self, key: &KeyEvent) -> Option<Option<Cmd<Msg>>> {
let sel = self.model_menu?;
let tabs = self.model_tabs();
let tab_count = tabs.len().max(1);
let t = self.model_tab.min(tab_count - 1);
let last = tabs[t].models.len().saturating_sub(1);
match key.code {
KeyCode::Up => {
self.model_menu = Some(sel.saturating_sub(1));
Some(None)
}
KeyCode::Down => {
self.model_menu = Some((sel + 1).min(last));
Some(None)
}
KeyCode::Left => {
self.model_tab = t.saturating_sub(1);
self.model_menu = Some(0);
Some(self.maybe_fetch_active_os_gateway_models())
}
KeyCode::Right | KeyCode::Tab => {
self.model_tab = (t + 1).min(tab_count - 1);
self.model_menu = Some(0);
Some(self.maybe_fetch_active_os_gateway_models())
}
KeyCode::Enter => {
self.activate_model_menu_item(&tabs[t], sel.min(last));
Some(None)
}
KeyCode::Esc => {
self.model_menu = None;
Some(None)
}
_ => None,
}
}
pub(crate) fn handle_model_mouse(&mut self, mouse: &MouseEvent) -> Option<Cmd<Msg>> {
let Some(sel) = self.model_menu else {
return None;
};
let tabs = self.model_tabs();
if tabs.is_empty() {
return None;
}
let active_tab = self.model_tab.min(tabs.len() - 1);
let max_rows = model_menu_max_rows(self.height as usize);
let selected = sel.min(tabs[active_tab].models.len().saturating_sub(1));
let width = (self.width as usize).min(u16::MAX as usize);
let height = model_menu_height(&tabs, active_tab, max_rows);
let mut panel =
model_menu_panel(&tabs, active_tab, selected, self.model.as_deref(), max_rows);
let row_count = panel.view(width as u16, height).lines().count();
if row_count == 0 {
return None;
}
panel.set_y_offset(model_menu_overlay_y_offset(self.height as usize, row_count));
match panel.handle_mouse(mouse) {
Some(TabbedMenuPanelMsg::TabChanged(tab)) => {
self.model_tab = tab.min(tabs.len() - 1);
self.model_menu = Some(0);
self.maybe_fetch_active_os_gateway_models()
}
Some(TabbedMenuPanelMsg::Selected { tab, item }) => {
if let Some(tab) = tabs.get(tab) {
self.activate_model_menu_item(tab, item);
}
None
}
Some(TabbedMenuPanelMsg::Cancelled) | None => None,
}
}
fn activate_model_menu_item(&mut self, tab: &ModelTab, item: usize) {
let model = tab.models.get(item).cloned();
self.model_menu = None;
if tab.os_gateway {
if let Some(model) = model {
self.use_os_gateway(&model);
}
return;
}
match tab.provider {
None => {
if let Some(model) = model {
self.switch_model(&model);
}
}
Some(AuthProvider::Claude) => {
if let Some(model) = model {
self.sign_in_claude(&model);
}
}
Some(AuthProvider::Codex) => {
if let Some(model) = model {
self.sign_in_codex(&model);
}
}
}
}
fn active_context_limit_for(&self, model: &str) -> u32 {
ctx_limit_for_model(&self.model_ctx, model)
}
fn commit_model_switch(
&mut self,
session: AgentSession,
model: String,
source: ModelSelectionSource,
) {
self.replace_session(session);
let preference = ModelSelectionPreference {
source,
model: model.clone(),
};
self.model = Some(model);
self.last_prompt_tokens = 0;
self.ctx_warned_tier = 0;
self.output_tokens = 0;
if let Err(error) = save_model_selection_preference(&preference) {
self.push_line(&Style::new().fg(TN_YELLOW).render(&format!(
" model switched, but preference was not saved: {error}"
)));
}
}
fn sign_in_claude(&mut self, model: &str) {
let model = crate::claude::canonical_model_name(model);
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before switching models"),
);
return;
}
match crate::claude::ClaudeClient::from_claude_login(&model) {
Ok(client) => {
let prev_override = self.llm_override.clone();
let prev_ctx = self.context_limit;
self.llm_override = Some(Arc::new(client));
self.context_limit = self.active_context_limit_for(&model);
match self.rebuild_session(Some(&model)) {
Ok((session, _)) => {
self.commit_model_switch(
session,
model.clone(),
ModelSelectionSource::Claude,
);
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ⇄ Claude Code · {model}")),
);
}
Err(error) => {
self.llm_override = prev_override;
self.context_limit = prev_ctx;
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" failed to switch: {error}")),
);
}
}
}
Err(error) => self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" Claude Code sign-in failed: {error}")),
),
}
}
fn sign_in_codex(&mut self, model: &str) {
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before switching models"),
);
return;
}
match crate::codex::CodexClient::from_codex_login(model, &self.session_id) {
Ok(client) => {
let prev_override = self.llm_override.clone();
let prev_ctx = self.context_limit;
self.llm_override = Some(Arc::new(client));
self.context_limit = self.active_context_limit_for(model);
match self.rebuild_session(Some(model)) {
Ok((s, _)) => {
self.commit_model_switch(s, model.to_string(), ModelSelectionSource::Codex);
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ⇄ Codex · {model}")),
);
}
Err(e) => {
self.llm_override = prev_override;
self.context_limit = prev_ctx;
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" failed to switch: {e}")),
);
}
}
}
Err(e) => self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" Codex sign-in failed: {e}")),
),
}
}
fn use_os_gateway(&mut self, model: &str) {
if model.starts_with('(') {
let reason = if self.os_gateway_models_loading || model.contains("loading") {
"model list is still loading — try again in a moment".to_string()
} else {
self.os_gateway_error.clone().unwrap_or_else(|| {
"no models configured — set up the unified AI gateway on OS, then reopen the OS Gateway tab"
.to_string()
})
};
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(&format!(" OS gateway unavailable: {reason}")),
);
return;
}
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before switching models"),
);
return;
}
let Some(session) = self.os_session.clone() else {
return;
};
let prev_override = self.llm_override.clone();
let prev_ctx = self.context_limit;
self.llm_override = Some(os_gateway_llm_override(&session, model));
self.context_limit = self.active_context_limit_for(model);
match self.rebuild_session(Some(model)) {
Ok((s, _)) => {
self.commit_model_switch(s, model.to_string(), ModelSelectionSource::OsGateway);
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ⇄ OS Gateway · {model}")),
);
}
Err(e) => {
self.llm_override = prev_override;
self.context_limit = prev_ctx;
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" failed to switch: {e}")),
);
}
}
}
pub(crate) fn effort_session_opts(&self, thinking: bool) -> SessionOptions {
let budget = budget_plan_for_effort_index(
self.effort,
Some(self.context_limit),
BudgetWorkload::Interactive,
);
let mut opts = with_recent_workspace_context(
tui_session_options(self.confirmation.clone())
.with_session_store(self.store.clone())
.with_session_id(self.session_id.as_str())
.with_workspace_backend(self.workspace_services.clone())
.with_skill_dirs(self.skill_dirs())
.with_auto_save(true)
.with_auto_compact(true)
.with_auto_compact_threshold(auto_compact_threshold_for(self.context_limit))
.with_file_memory(memory_dir())
.with_max_parallel_tasks(budget.max_parallel_tasks)
.with_auto_delegation_enabled(true)
.with_auto_parallel_delegation(true)
.with_manual_delegation_enabled(true)
.with_max_tool_rounds(budget.max_tool_rounds)
.with_max_continuation_turns(budget.max_continuation_turns),
&self.workspace_manifest,
);
let mut extra_parts: Vec<String> = Vec::new();
if let Some(i) = &self.instructions {
extra_parts.push(i.clone());
}
if let Some(s) = &self.compact_summary {
extra_parts.push(format!("# Earlier conversation (compacted)\n\n{s}"));
}
if let Some(s) = &self.os_session {
extra_parts.push(os_platform_guide(&s.address));
}
let extra = (!extra_parts.is_empty()).then(|| extra_parts.join("\n\n"));
let ultra = self.effort == ULTRACODE;
let guideline = EFFORT_LEVELS[self.effort].guideline;
if extra.is_some() || guideline.is_some() {
let mut slots = SystemPromptSlots::default();
if let Some(e) = extra {
slots = slots.with_extra(e);
}
if let Some(g) = guideline {
slots = slots.with_guidelines(g);
}
opts = opts.with_prompt_slots(slots);
}
if thinking {
opts = opts.with_thinking_budget(budget.thinking_budget);
}
if ultra {
opts = opts
.with_planning_mode(a3s_code_core::PlanningMode::Auto)
.with_goal_tracking(true);
}
if let Some(client) = &self.llm_override {
opts = opts.with_llm_client(client.clone());
}
opts
}
pub(crate) fn rebuild_session(
&self,
model: Option<&str>,
) -> Result<(AgentSession, bool), String> {
let build = |thinking: bool| {
let o = self.effort_session_opts(thinking);
match model {
Some(m) => o.with_model(m),
None => o,
}
};
for thinking in [true, false] {
if let Ok(s) = self
.agent
.resume_session(self.session_id.as_str(), build(thinking))
{
s.register_dynamic_workflow_runtime();
return Ok((s, !thinking));
}
if let Ok(s) = self.agent.session(self.cwd.clone(), Some(build(thinking))) {
s.register_dynamic_workflow_runtime();
return Ok((s, !thinking));
}
}
Err("could not rebuild the session".into())
}
pub(crate) fn switch_model(&mut self, model: &str) {
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before switching models"),
);
return;
}
let prev_override = self.llm_override.clone();
let prev_ctx = self.context_limit;
self.llm_override = None;
self.context_limit = self.active_context_limit_for(model);
match self.rebuild_session(Some(model)) {
Ok((s, _)) => {
self.commit_model_switch(s, model.to_string(), ModelSelectionSource::Config);
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ⇄ switched to {model}")),
);
}
Err(e) => {
self.llm_override = prev_override;
self.context_limit = prev_ctx;
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" failed to switch model: {e}")),
);
}
}
}
pub(crate) fn apply_effort(&mut self) {
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before changing effort"),
);
return;
}
let model = self.model.clone();
match self.rebuild_session(model.as_deref()) {
Ok((s, dropped)) => {
self.replace_session(s);
if self.effort == ULTRACODE {
self.mode = Mode::Auto;
self.gradient_until = Some(Instant::now()); self.gradient_frame = 0;
self.push_line(&Style::new().fg(ACCENT).bold().render(
" ◆ ultracode — planning a dynamic workflow + parallel subagents (auto-approve on)",
));
} else if dropped {
let note = if EFFORT_LEVELS[self.effort].guideline.is_some() {
"depth via reasoning guidance; no extended-thinking on this model"
} else {
"balanced baseline; no extended-thinking on this model"
};
self.push_line(&Style::new().fg(TN_GREEN).render(&format!(
" ◇ effort: {} ({note})",
EFFORT_LEVELS[self.effort].label
)));
} else {
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ◇ effort: {}", EFFORT_LEVELS[self.effort].label)),
);
}
}
Err(e) => self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" failed to set effort: {e}")),
),
}
}
pub(crate) fn overlay_model_menu(&self, composed: String) -> String {
let Some(sel) = self.model_menu else {
return composed;
};
let tabs = self.model_tabs();
if tabs.is_empty() {
return composed;
}
let t = self.model_tab.min(tabs.len() - 1);
let width = self.width as usize;
let max_rows = model_menu_max_rows(self.height as usize);
let sel = sel.min(tabs[t].models.len().saturating_sub(1));
let menu = model_menu_lines(&tabs, t, sel, self.model.as_deref(), width, max_rows);
self.overlay_list(composed, &menu)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selected_model_location_finds_account_tab_model() {
let tabs = vec![
ModelTab {
label: "a3s-code",
color: A3S_COLOR,
models: vec!["openai/gpt-5".into()],
provider: None,
os_gateway: false,
},
ModelTab {
label: "Claude Code",
color: CLAUDE_COLOR,
models: vec!["claude-sonnet-4".into()],
provider: Some(AuthProvider::Claude),
os_gateway: false,
},
];
assert_eq!(
selected_model_location(&tabs, Some("claude-sonnet-4")),
(1, 0)
);
assert_eq!(
selected_model_location(&tabs, Some("claude-sonnet-4[1m]")),
(1, 0)
);
assert_eq!(selected_model_location(&tabs, Some("missing")), (0, 0));
}
#[test]
fn os_gateway_models_fetch_only_when_gateway_tab_is_active() {
let config_tab = ModelTab {
label: "a3s-code",
color: A3S_COLOR,
models: vec!["openai/gpt-5".into()],
provider: None,
os_gateway: false,
};
let gateway_tab = ModelTab {
label: "OS Gateway",
color: TN_CYAN,
models: vec!["(loading…)".into()],
provider: None,
os_gateway: true,
};
let cached = vec!["gpt-5.1".to_string()];
assert!(!should_fetch_os_gateway_models(
Some(&config_tab),
None,
false,
true
));
assert!(!should_fetch_os_gateway_models(
Some(&gateway_tab),
None,
false,
false
));
assert!(!should_fetch_os_gateway_models(
Some(&gateway_tab),
None,
true,
true
));
assert!(!should_fetch_os_gateway_models(
Some(&gateway_tab),
Some(&cached),
false,
true
));
assert!(should_fetch_os_gateway_models(
Some(&gateway_tab),
None,
false,
true
));
assert!(should_fetch_os_gateway_models(
Some(&gateway_tab),
Some(&[]),
false,
true
));
}
#[test]
fn model_menu_lines_are_width_bounded_with_styles() {
let lines = model_menu_lines(
&[ModelTab {
label: "Codex",
color: CODEX_COLOR,
models: vec![
"openai-compatible/provider/model-name-with-a-very-long-context-window".into(),
"gpt-5-codex".into(),
],
provider: Some(AuthProvider::Codex),
os_gateway: false,
}],
0,
0,
Some("openai-compatible/provider/model-name-with-a-very-long-context-window"),
36,
3,
);
for line in lines {
assert!(
a3s_tui::style::visible_len(&line) <= 36,
"{}",
a3s_tui::style::strip_ansi(&line)
);
}
}
#[test]
fn model_menu_panel_handles_tab_mouse_with_overlay_offset() {
use a3s_tui::event::{MouseButton, MouseEventKind};
let tabs = vec![
ModelTab {
label: "a3s-code",
color: A3S_COLOR,
models: vec!["openai/gpt-5".into()],
provider: None,
os_gateway: false,
},
ModelTab {
label: "Claude Code",
color: CLAUDE_COLOR,
models: vec!["claude-sonnet-4".into()],
provider: Some(AuthProvider::Claude),
os_gateway: false,
},
];
let max_rows = model_menu_max_rows(24);
let row_count = model_menu_lines(&tabs, 0, 0, None, 48, max_rows).len();
let y_offset = model_menu_overlay_y_offset(24, row_count);
let mut panel = model_menu_panel(&tabs, 0, 0, None, max_rows);
panel.set_y_offset(y_offset);
let msg = panel.handle_mouse(&MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 15,
row: y_offset + 1,
modifiers: a3s_tui::KeyModifiers::NONE,
});
assert_eq!(msg, Some(TabbedMenuPanelMsg::TabChanged(1)));
}
}