use super::super::*;
use crate::account_providers::AccountProvider;
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>,
source: ModelSelectionSource,
}
fn selected_model_location(tabs: &[ModelTab], current: Option<&str>) -> (usize, usize) {
current
.and_then(|current| {
tabs.iter().enumerate().find_map(|(tab_idx, tab)| {
let current = tab
.source
.account_provider()
.map(|provider| provider.canonical_model(current))
.unwrap_or_else(|| current.to_string());
tab.models
.iter()
.position(|model| {
tab.source
.account_provider()
.map(|provider| provider.canonical_model(model))
.unwrap_or_else(|| model.clone())
== 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;
const CODEBUDDY_COLOR: Color = TN_PURPLE;
const CODEX_MODEL_REFRESH_TTL: std::time::Duration = std::time::Duration::from_secs(300);
fn account_provider_color(provider: AccountProvider) -> Color {
match provider {
AccountProvider::Claude => CLAUDE_COLOR,
AccountProvider::Codex => CODEX_COLOR,
AccountProvider::CodeBuddy => CODEBUDDY_COLOR,
}
}
fn prompt_guideline_for_effort(
effort: usize,
has_native_reasoning_effort: bool,
) -> Option<&'static str> {
let effort = effort.min(EFFORT_LEVELS.len().saturating_sub(1));
if effort == ULTRACODE || !has_native_reasoning_effort {
EFFORT_LEVELS[effort].guideline
} else {
None
}
}
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(TN_FG, SURFACE_SELECTED)
}
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, rows_below: usize) -> u16 {
screen_height
.saturating_sub(rows_below)
.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.source == ModelSelectionSource::OsGateway)
&& signed_in
&& !loading
&& gateway_models.is_none_or(|models| models.is_empty())
}
fn should_fetch_account_models(
active_tab: Option<&ModelTab>,
cached: bool,
loading: bool,
failed: bool,
) -> bool {
active_tab
.and_then(|tab| tab.source.account_provider())
.is_some_and(|provider| provider != AccountProvider::Codex)
&& !cached
&& !loading
&& !failed
}
pub(crate) async fn rebuild_agent_session(
agent: Arc<Agent>,
workspace: String,
session_id: String,
with_thinking: SessionOptions,
without_thinking: SessionOptions,
mode: SessionRebuildMode,
) -> Result<(AgentSession, bool), String> {
let mut failures = Vec::new();
for (thinking, options) in [(true, with_thinking), (false, without_thinking)] {
let result = match mode {
SessionRebuildMode::ResumeExisting => agent
.resume_session_async(&session_id, options)
.await
.map_err(|error| format!("resume failed ({error})")),
SessionRebuildMode::CreateFresh => agent
.session_async(workspace.clone(), Some(options))
.await
.map_err(|error| format!("fresh session failed ({error})")),
};
match result {
Ok(session) => return Ok((session, !thinking)),
Err(error) => failures.push(format!(
"{}: {error}",
if thinking {
"with extended thinking"
} else {
"without extended thinking"
}
)),
}
}
Err(format!(
"could not rebuild the session: {}",
failures.join("; ")
))
}
pub(crate) enum SessionRebuildResult {
Success(AgentSession, bool),
Failed {
error: String,
recovered: Option<AgentSession>,
},
}
struct SessionRebuildOptions {
with_thinking: SessionOptions,
without_thinking: SessionOptions,
}
struct LiveSessionRebuildRequest {
agent: Arc<Agent>,
current_session: Arc<AgentSession>,
workspace: String,
session_id: String,
requested: SessionRebuildOptions,
recovery: SessionRebuildOptions,
}
async fn rebuild_live_agent_session(request: LiveSessionRebuildRequest) -> SessionRebuildResult {
let LiveSessionRebuildRequest {
agent,
current_session,
workspace,
session_id,
requested,
recovery,
} = request;
if let Err(error) = current_session.save().await {
return SessionRebuildResult::Failed {
error: format!("could not save the current session before rebuilding: {error}"),
recovered: None,
};
}
current_session.close().await;
match rebuild_agent_session(
Arc::clone(&agent),
workspace.clone(),
session_id.clone(),
requested.with_thinking,
requested.without_thinking,
SessionRebuildMode::ResumeExisting,
)
.await
{
Ok((session, thinking_dropped)) => SessionRebuildResult::Success(session, thinking_dropped),
Err(error) => {
let recovered = rebuild_agent_session(
agent,
workspace,
session_id,
recovery.with_thinking,
recovery.without_thinking,
SessionRebuildMode::ResumeExisting,
)
.await
.ok()
.map(|(session, _)| session);
SessionRebuildResult::Failed { error, recovered }
}
}
}
impl App {
fn model_tabs(&self) -> Vec<ModelTab> {
let mut tabs = vec![ModelTab {
label: "a3s-code",
color: A3S_COLOR,
models: self.models.clone(),
source: ModelSelectionSource::Config,
}];
for provider in AccountProvider::ALL {
if !provider.is_available() {
continue;
}
let models = if provider == AccountProvider::Codex {
self.codex_account_models
.iter()
.map(|model| model.slug.clone())
.collect()
} else {
self.account_models
.get(&provider)
.cloned()
.unwrap_or_else(|| provider.local_models())
};
tabs.push(ModelTab {
label: provider.label(),
color: account_provider_color(provider),
models,
source: ModelSelectionSource::from_account_provider(provider),
});
}
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,
source: ModelSelectionSource::OsGateway,
});
}
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_refresh_codex_models(&mut self) -> Option<Cmd<Msg>> {
if !AccountProvider::Codex.is_available()
|| self.codex_models_loading
|| self
.codex_models_refreshed_at
.is_some_and(|at| at.elapsed() < CODEX_MODEL_REFRESH_TTL)
{
return None;
}
self.codex_models_loading = true;
Some(cmd::cmd(|| async {
Msg::CodexModels(
crate::account_providers::codex::refresh_codex_models()
.await
.map_err(|error| error.to_string()),
)
}))
}
pub(crate) fn maybe_fetch_active_account_models(&mut self) -> Option<Cmd<Msg>> {
let tabs = self.model_tabs();
let active_tab = self.model_tab.min(tabs.len().saturating_sub(1));
let provider = tabs
.get(active_tab)
.and_then(|tab| tab.source.account_provider())?;
if !should_fetch_account_models(
tabs.get(active_tab),
self.account_models.contains_key(&provider),
self.account_models_loading.contains(&provider),
self.account_model_errors.contains_key(&provider),
) {
return None;
}
self.account_models_loading.insert(provider);
Some(cmd::cmd(move || async move {
Msg::AccountModels {
provider,
result: provider
.discover_models()
.await
.map_err(|error| error.to_string()),
}
}))
}
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 maybe_fetch_active_model_models(&mut self) -> Option<Cmd<Msg>> {
self.maybe_fetch_active_account_models()
.or_else(|| self.maybe_fetch_active_os_gateway_models())
}
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_model_models())
}
KeyCode::Right | KeyCode::Tab => {
self.model_tab = (t + 1).min(tab_count - 1);
self.model_menu = Some(0);
Some(self.maybe_fetch_active_model_models())
}
KeyCode::Enter => Some(self.activate_model_menu_item(&tabs[t], sel.min(last))),
KeyCode::Esc => {
self.model_menu = None;
Some(None)
}
_ => None,
}
}
pub(crate) fn handle_model_mouse(&mut self, mouse: &MouseEvent) -> Option<Cmd<Msg>> {
let sel = self.model_menu?;
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,
self.overlay_rows_below(),
));
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_model_models()
}
Some(TabbedMenuPanelMsg::Selected { tab, item }) => tabs
.get(tab)
.and_then(|tab| self.activate_model_menu_item(tab, item)),
Some(TabbedMenuPanelMsg::Cancelled) | None => None,
}
}
fn activate_model_menu_item(&mut self, tab: &ModelTab, item: usize) -> Option<Cmd<Msg>> {
let model = tab.models.get(item).cloned();
self.model_menu = None;
match tab.source {
ModelSelectionSource::Config => model.and_then(|model| self.switch_model(&model)),
ModelSelectionSource::Claude
| ModelSelectionSource::Codex
| ModelSelectionSource::CodeBuddy => model.and_then(|model| {
let provider = tab.source.account_provider()?;
self.sign_in_account(provider, &model)
}),
ModelSelectionSource::OsGateway => model.and_then(|model| self.use_os_gateway(&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_account(&mut self, provider: AccountProvider, model: &str) -> Option<Cmd<Msg>> {
if provider == AccountProvider::Codex {
return self.sign_in_codex(model);
}
let model = provider.canonical_model(model);
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before switching models"),
);
return None;
}
match provider.client(&model, &self.session_id) {
Ok(client) => {
let llm_override = Some(LlmOverride::Static(client));
let context_limit = self.active_context_limit_for(&model);
let mut profile = self.session_rebuild_profile();
profile.model = Some(model.clone());
profile.context_limit = context_limit;
profile.llm_override = llm_override.clone();
self.start_session_rebuild(
profile,
SessionRebuildAction::Model {
model,
source: ModelSelectionSource::from_account_provider(provider),
llm_override,
context_limit,
},
)
}
Err(error) => {
self.push_line(&Style::new().fg(TN_RED).render(&format!(
" {} account unavailable: {error}",
provider.label()
)));
None
}
}
}
fn sign_in_codex(&mut self, model: &str) -> Option<Cmd<Msg>> {
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before switching models"),
);
return None;
}
match crate::account_providers::codex::CodexClient::from_codex_login_with_effort(
model,
&self.session_id,
EFFORT_LEVELS[self.effort].id,
) {
Ok(client) => {
let llm_override = Some(LlmOverride::Codex(client));
let context_limit = self.active_context_limit_for(model);
let mut profile = self.session_rebuild_profile();
profile.model = Some(model.to_string());
profile.context_limit = context_limit;
profile.llm_override = llm_override.clone();
self.start_session_rebuild(
profile,
SessionRebuildAction::Model {
model: model.to_string(),
source: ModelSelectionSource::Codex,
llm_override,
context_limit,
},
)
}
Err(error) => {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" Codex sign-in failed: {error}")),
);
None
}
}
}
fn use_os_gateway(&mut self, model: &str) -> Option<Cmd<Msg>> {
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 None;
}
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before switching models"),
);
return None;
}
let session = self.os_session.clone()?;
let llm_override = Some(os_gateway_llm_override(&session, model));
let context_limit = self.active_context_limit_for(model);
let mut profile = self.session_rebuild_profile();
profile.model = Some(model.to_string());
profile.context_limit = context_limit;
profile.llm_override = llm_override.clone();
self.start_session_rebuild(
profile,
SessionRebuildAction::Model {
model: model.to_string(),
source: ModelSelectionSource::OsGateway,
llm_override,
context_limit,
},
)
}
pub(crate) fn effort_session_opts(&self, thinking: bool) -> SessionOptions {
let profile = SessionRebuildProfile {
session_id: self.session_id.clone(),
model: self.model.clone(),
effort: self.effort,
context_limit: self.context_limit,
llm_override: self.llm_override.clone(),
compact_summary: self.compact_summary.clone(),
};
self.session_options_for_profile(thinking, &profile)
}
fn session_options_for_profile(
&self,
thinking: bool,
profile: &SessionRebuildProfile,
) -> SessionOptions {
let budget = budget_plan_for_effort_index(
profile.effort,
Some(profile.context_limit),
BudgetWorkload::Interactive,
);
let automatic_delegation = effort_uses_automatic_delegation(profile.effort);
let mut opts = with_recent_workspace_context(
tui_session_options_with_gate(
self.confirmation.clone(),
self.deep_research_report_tool_gate.clone(),
)
.with_session_store(self.store.clone())
.with_session_id(profile.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_max_context_tokens(profile.context_limit as usize)
.with_auto_compact_threshold(AUTO_COMPACT_THRESHOLD as f32)
.with_file_memory(memory_dir())
.with_max_parallel_tasks(budget.max_parallel_tasks)
.with_auto_delegation_enabled(automatic_delegation)
.with_auto_parallel_delegation(automatic_delegation)
.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) = &profile.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 = profile.effort == ULTRACODE;
let effort_profile = &EFFORT_LEVELS[profile.effort];
let codex_effort = profile
.llm_override
.as_ref()
.and_then(|client| client.codex_effort_status(effort_profile.id));
let has_exact_codex_effort = codex_effort.as_ref().is_some_and(|status| !status.capped);
let guideline = prompt_guideline_for_effort(profile.effort, has_exact_codex_effort);
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 self.goal_run.is_some() {
opts = opts
.with_planning_mode(a3s_code_core::PlanningMode::Enabled)
.with_goal_tracking(true);
} else if ultra {
opts = opts
.with_planning_mode(a3s_code_core::PlanningMode::Auto)
.with_goal_tracking(true);
}
if let Some(model) = &profile.model {
opts = opts.with_model(model);
}
if let Some(client) = &profile.llm_override {
opts = opts.with_llm_client(client.client_for_effort(effort_profile.id));
} else if let Ok(client) = crate::session_llm::resolve_config_llm_client(
&self.code_config,
&opts,
profile.session_id.as_str(),
) {
opts = opts.with_llm_client(client);
}
opts
}
pub(crate) fn codex_effort_status_for_index(&self, effort: usize) -> Option<CodexEffortStatus> {
let profile = &EFFORT_LEVELS[effort.min(EFFORT_LEVELS.len().saturating_sub(1))];
self.llm_override
.as_ref()
.and_then(|client| client.codex_effort_status(profile.id))
}
pub(crate) fn start_session_rebuild(
&mut self,
profile: SessionRebuildProfile,
action: SessionRebuildAction,
) -> Option<Cmd<Msg>> {
if self.session_rebuild_pending.is_some() {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" wait for the current session change to finish"),
);
return None;
}
let with_thinking = self.session_options_for_profile(true, &profile);
let without_thinking = self.session_options_for_profile(false, &profile);
let recovery_profile = self.session_rebuild_profile();
let recovery_with_thinking = self.session_options_for_profile(true, &recovery_profile);
let recovery_without_thinking = self.session_options_for_profile(false, &recovery_profile);
let mode = match &action {
SessionRebuildAction::Compact { .. } | SessionRebuildAction::Clear { .. } => {
SessionRebuildMode::CreateFresh
}
_ => SessionRebuildMode::ResumeExisting,
};
self.session_rebuild_seq = self.session_rebuild_seq.wrapping_add(1);
let request_id = self.session_rebuild_seq;
self.session_rebuild_pending = Some(request_id);
let agent = Arc::clone(&self.agent);
let current_session = Arc::clone(&self.session);
let workspace = self.cwd.clone();
let session_id = profile.session_id;
Some(cmd::cmd(move || async move {
let result = if mode == SessionRebuildMode::ResumeExisting {
rebuild_live_agent_session(LiveSessionRebuildRequest {
agent,
current_session,
workspace,
session_id,
requested: SessionRebuildOptions {
with_thinking,
without_thinking,
},
recovery: SessionRebuildOptions {
with_thinking: recovery_with_thinking,
without_thinking: recovery_without_thinking,
},
})
.await
} else {
match rebuild_agent_session(
agent,
workspace,
session_id,
with_thinking,
without_thinking,
mode,
)
.await
{
Ok((session, thinking_dropped)) => {
SessionRebuildResult::Success(session, thinking_dropped)
}
Err(error) => SessionRebuildResult::Failed {
error,
recovered: None,
},
}
};
Msg::SessionRebuilt {
request_id,
action,
result: Box::new(result),
}
}))
}
pub(crate) fn session_rebuild_profile(&self) -> SessionRebuildProfile {
SessionRebuildProfile {
session_id: self.session_id.clone(),
model: self.model.clone(),
effort: self.effort,
context_limit: self.context_limit,
llm_override: self.llm_override.clone(),
compact_summary: self.compact_summary.clone(),
}
}
pub(crate) fn finish_session_rebuild(
&mut self,
request_id: u64,
action: SessionRebuildAction,
result: SessionRebuildResult,
) -> Option<Cmd<Msg>> {
if self.session_rebuild_pending != Some(request_id) {
return None;
}
self.session_rebuild_pending = None;
let (session, thinking_dropped) = match result {
SessionRebuildResult::Success(session, thinking_dropped) => (session, thinking_dropped),
SessionRebuildResult::Failed { error, recovered } => {
if let Some(session) = recovered {
self.replace_session(session);
}
match &action {
SessionRebuildAction::GoalStart {
generation,
previous_effort,
previous_goal,
previous_goal_since,
} => {
let still_active = self
.goal_run
.as_ref()
.is_some_and(|run| run.is_generation(*generation));
self.goal_run = None;
self.effort = *previous_effort;
if still_active {
self.goal = previous_goal.clone();
self.goal_since = *previous_goal_since;
self.push_line(
&Style::new().fg(TN_RED).render(&format!(
" /goal could not enable Ultracode: {error}"
)),
);
}
return self.drain_queue();
}
SessionRebuildAction::GoalRestore => {
self.push_line(&Style::new().fg(TN_YELLOW).render(&format!(
" goal stopped, but session mode refresh failed: {error}"
)));
return self.drain_queue();
}
_ => {}
}
if matches!(action, SessionRebuildAction::Compact { .. }) {
self.compacting = None;
}
let context = match action {
SessionRebuildAction::Model { .. } => "switch model",
SessionRebuildAction::Effort { .. } => "set effort",
SessionRebuildAction::GoalStart { .. } => unreachable!("handled above"),
SessionRebuildAction::GoalRestore => unreachable!("handled above"),
SessionRebuildAction::Compact { .. } => "compact context",
SessionRebuildAction::Fork { .. } => "fork session",
SessionRebuildAction::Clear { .. } => "clear session",
SessionRebuildAction::Reload { .. } => "reload session",
SessionRebuildAction::Refresh { failure_context } => failure_context?,
};
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" failed to {context}: {error}")),
);
return None;
}
};
match action {
SessionRebuildAction::Model {
model,
source,
llm_override,
context_limit,
} => {
self.llm_override = llm_override;
self.context_limit = context_limit;
self.commit_model_switch(session, model.clone(), source);
let label = match source {
ModelSelectionSource::Config => format!("switched to {model}"),
ModelSelectionSource::Claude => format!("Claude Code · {model}"),
ModelSelectionSource::Codex => format!("Codex · {model}"),
ModelSelectionSource::CodeBuddy => format!("WorkBuddy · {model}"),
ModelSelectionSource::OsGateway => format!("OS Gateway · {model}"),
};
self.push_line(&Style::new().fg(TN_GREEN).render(&format!(" ⇄ {label}")));
}
SessionRebuildAction::Effort {
selected,
codex_effort,
} => {
self.effort = selected;
self.replace_session(session);
if let Err(error) = save_tui_effort_preference(selected) {
self.push_line(&Style::new().fg(TN_YELLOW).render(&format!(
" effort changed, but preference was not saved: {error}"
)));
}
if selected == ULTRACODE {
self.mode = Mode::Auto;
self.gradient_until = Some(Instant::now());
self.gradient_frame = 0;
let native = codex_effort
.as_ref()
.map(|status| {
let cap = if status.capped { " (model limit)" } else { "" };
format!(" · Codex reasoning: {}{cap}", status.effective)
})
.unwrap_or_default();
self.push_line(&Style::new().fg(ACCENT).bold().render(&format!(
" ◆ ultracode — planning a dynamic workflow + parallel subagents (auto-approve on){native}",
)));
} else if let Some(status) = codex_effort {
let cap = if status.capped { " (model limit)" } else { "" };
self.push_line(&Style::new().fg(TN_GREEN).render(&format!(
" ◇ effort: {} · Codex reasoning: {}{cap}",
EFFORT_LEVELS[selected].label, status.effective
)));
} else if thinking_dropped {
let note = if EFFORT_LEVELS[selected].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[selected].label
)));
} else {
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ◇ effort: {}", EFFORT_LEVELS[selected].label)),
);
}
}
SessionRebuildAction::GoalStart { generation, .. } => {
self.replace_session(session);
self.effort = ULTRACODE;
if self
.goal_run
.as_ref()
.is_some_and(|run| run.is_generation(generation))
{
return self.finish_goal_start();
}
return self.restore_goal_planning_mode();
}
SessionRebuildAction::GoalRestore => {
self.replace_session(session);
return self.drain_queue();
}
SessionRebuildAction::Compact {
summary,
session_id,
} => {
self.compacting = None;
self.compact_summary = Some(summary);
self.session_id = session_id;
self.replace_session(session);
self.messages.clear();
self.output_tokens = 0;
self.last_prompt_tokens = 0;
self.ctx_warned_tier = 0;
self.push_line(
&Style::new()
.fg(TN_GREEN)
.bold()
.render(" ✦ context compacted — continuing from this summary:"),
);
self.push_line(&gutter(
TN_CYAN,
self.compact_summary.as_deref().unwrap_or(""),
));
self.rebuild_viewport();
}
SessionRebuildAction::Fork { session_id } => {
self.session_id = session_id;
self.replace_session(session);
let short: String = self.session_id.chars().take(8).collect();
self.push_line(&gutter(
TN_CYAN,
&format!("⑂ forked into a new session ({short}) — the original is kept"),
));
}
SessionRebuildAction::Clear { session_id } => {
self.restore_autonomy();
self.session_id = session_id;
self.replace_session(session);
self.compact_summary = None;
self.messages.clear();
self.plan.clear();
self.runtime.clear_turn_entities();
self.runtime.clear_subagent_entities();
self.queue.clear();
self.completed = 0;
self.review_pending = false;
self.sleep_pending = false;
self.goal = None;
self.goal_since = None;
self.goal_run = None;
self.pending_goal_failure = None;
self.pending_ctx = None;
self.ctx_hits.clear();
self.agent_dev = None;
self.pending_flow_subcommand = None;
self.pending_agent_subcommand = None;
self.mcp_dev = None;
self.pending_mcp_subcommand = None;
self.skill_dev = None;
self.pending_skill_subcommand = None;
self.okf_picker = None;
self.pending_okf_subcommand = None;
self.okf_dev = None;
self.asset_list = None;
self.runtime_activity = None;
self.kb = None;
self.output_tokens = 0;
self.last_prompt_tokens = 0;
self.ctx_warned_tier = 0;
self.relayout();
self.rebuild_viewport();
}
SessionRebuildAction::Reload { skill_count } => {
self.replace_session(session);
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ↻ reloaded — {skill_count} skills available")),
);
}
SessionRebuildAction::Refresh { .. } => self.replace_session(session),
}
None
}
pub(crate) fn switch_model(&mut self, model: &str) -> Option<Cmd<Msg>> {
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before switching models"),
);
return None;
}
let context_limit = self.active_context_limit_for(model);
let mut profile = self.session_rebuild_profile();
profile.model = Some(model.to_string());
profile.context_limit = context_limit;
profile.llm_override = None;
self.start_session_rebuild(
profile,
SessionRebuildAction::Model {
model: model.to_string(),
source: ModelSelectionSource::Config,
llm_override: None,
context_limit,
},
)
}
pub(crate) fn apply_effort(&mut self, selected: usize) -> Option<Cmd<Msg>> {
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before changing effort"),
);
return None;
}
let selected = selected.min(EFFORT_LEVELS.len().saturating_sub(1));
let codex_effort = self.codex_effort_status_for_index(selected);
let mut profile = self.session_rebuild_profile();
profile.effort = selected;
self.start_session_rebuild(
profile,
SessionRebuildAction::Effort {
selected,
codex_effort,
},
)
}
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::*;
#[tokio::test]
async fn async_rebuild_initializes_default_file_memory() {
let root = std::env::temp_dir().join(format!(
"a3s-async-rebuild-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let workspace = root.join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let config = root.join("config.acl");
std::fs::write(
&config,
"default_model = \"openai/x\"\n\
providers \"openai\" {\n apiKey = \"x\"\n baseUrl = \"http://127.0.0.1:1\"\n \
models \"x\" { name = \"x\" }\n}\n",
)
.unwrap();
let agent = Arc::new(
Agent::new(config.to_string_lossy().to_string())
.await
.unwrap(),
);
let options = SessionOptions::new().with_session_id("async-rebuild-memory");
let (session, thinking_dropped) = rebuild_agent_session(
agent,
workspace.to_string_lossy().to_string(),
"async-rebuild-memory".to_string(),
options.clone(),
options,
SessionRebuildMode::CreateFresh,
)
.await
.unwrap();
assert_eq!(session.session_id(), "async-rebuild-memory");
assert!(!thinking_dropped);
assert!(workspace.join(".a3s/memory").is_dir());
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn live_session_rebuild_closes_before_resuming_same_id() {
let root = std::env::temp_dir().join(format!(
"a3s-live-session-rebuild-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let workspace = root.join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let config = root.join("config.acl");
std::fs::write(
&config,
"default_model = \"openai/x\"\n\
providers \"openai\" {\n apiKey = \"x\"\n baseUrl = \"http://127.0.0.1:1\"\n \
models \"x\" { name = \"x\" }\n}\n",
)
.unwrap();
let agent = Arc::new(
Agent::new(config.to_string_lossy().to_string())
.await
.unwrap(),
);
let session_id = "live-session-rebuild";
let store: Arc<dyn a3s_code_core::store::SessionStore> = Arc::new(
a3s_code_core::store::FileSessionStore::new(root.join("sessions"))
.await
.unwrap(),
);
let options = SessionOptions::new()
.with_session_id(session_id)
.with_session_store(store);
let current = Arc::new(
agent
.session_async(
workspace.to_string_lossy().to_string(),
Some(options.clone()),
)
.await
.unwrap(),
);
let result = rebuild_live_agent_session(LiveSessionRebuildRequest {
agent: Arc::clone(&agent),
current_session: current,
workspace: workspace.to_string_lossy().to_string(),
session_id: session_id.to_string(),
requested: SessionRebuildOptions {
with_thinking: options.clone(),
without_thinking: options.clone(),
},
recovery: SessionRebuildOptions {
with_thinking: options.clone(),
without_thinking: options,
},
})
.await;
let session = match result {
SessionRebuildResult::Success(session, _) => session,
SessionRebuildResult::Failed { error, recovered } => panic!(
"same-id live session rebuild should succeed after closing the old session: {error}; recovered={}",
recovered.is_some()
),
};
assert_eq!(session.session_id(), session_id);
assert_eq!(agent.list_sessions().await, vec![session_id.to_string()]);
session.close().await;
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn native_codex_effort_replaces_only_simulated_depth_guidance() {
assert!(prompt_guideline_for_effort(2, true).is_none());
assert_eq!(
prompt_guideline_for_effort(2, false),
EFFORT_LEVELS[2].guideline
);
assert_eq!(
prompt_guideline_for_effort(ULTRACODE, true),
EFFORT_LEVELS[ULTRACODE].guideline
);
assert_eq!(
prompt_guideline_for_effort(4, false),
EFFORT_LEVELS[4].guideline
);
}
#[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()],
source: ModelSelectionSource::Config,
},
ModelTab {
label: "Claude Code",
color: CLAUDE_COLOR,
models: vec!["claude-sonnet-4".into()],
source: ModelSelectionSource::Claude,
},
];
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()],
source: ModelSelectionSource::Config,
};
let gateway_tab = ModelTab {
label: "OS Gateway",
color: TN_CYAN,
models: vec!["(loading…)".into()],
source: ModelSelectionSource::OsGateway,
};
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 account_models_fetch_once_for_cli_backed_account_tabs() {
let config_tab = ModelTab {
label: "a3s-code",
color: A3S_COLOR,
models: vec!["openai/gpt-5".into()],
source: ModelSelectionSource::Config,
};
let workbuddy_tab = ModelTab {
label: "WorkBuddy",
color: CODEBUDDY_COLOR,
models: vec!["auto".into()],
source: ModelSelectionSource::CodeBuddy,
};
let codex_tab = ModelTab {
label: "Codex",
color: CODEX_COLOR,
models: vec!["gpt-5.6-sol".into()],
source: ModelSelectionSource::Codex,
};
assert!(!should_fetch_account_models(
Some(&config_tab),
false,
false,
false
));
assert!(should_fetch_account_models(
Some(&workbuddy_tab),
false,
false,
false
));
assert!(!should_fetch_account_models(
Some(&workbuddy_tab),
true,
false,
false
));
assert!(!should_fetch_account_models(
Some(&workbuddy_tab),
false,
true,
false
));
assert!(!should_fetch_account_models(
Some(&workbuddy_tab),
false,
false,
true
));
assert!(!should_fetch_account_models(
Some(&codex_tab),
false,
false,
false
));
}
#[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(),
],
source: ModelSelectionSource::Codex,
}],
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()],
source: ModelSelectionSource::Config,
},
ModelTab {
label: "Claude Code",
color: CLAUDE_COLOR,
models: vec!["claude-sonnet-4".into()],
source: ModelSelectionSource::Claude,
},
];
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, 5);
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)));
}
#[test]
fn model_menu_mouse_offset_follows_dynamic_rows_below() {
assert_eq!(model_menu_overlay_y_offset(24, 6, 5), 13);
assert_eq!(model_menu_overlay_y_offset(24, 6, 9), 9);
}
}