use std::path::PathBuf;
use super::{bg_runtime, save_and_reload, LoopCtx};
use crate::i18n::{t, Msg};
use crate::modals::{DirPicker, IssueWizard, LanguagePicker, Modal, ModelPicker, ProviderWizard, SessionPicker};
use crate::render::{Renderer, UiLine};
use crate::state::{AgentMode, UiState};
use anyhow::Result;
use atomcode_core::agent::AgentCommand;
use atomcode_core::config::provider::ProviderConfig;
use atomcode_core::config::Config;
use atomcode_core::conversation::Conversation;
use atomcode_core::session::{Session, SessionId, SessionManager};
const MAX_RECENT_DIRS: usize = 5;
fn build_oauth_provider() -> ProviderConfig {
ProviderConfig {
provider_type: "openai".to_string(),
api_key: None,
model: "MiniMax-M2.7".to_string(),
base_url: Some("https://llm-api.atomgit.com/v1".to_string()),
system_prompt: None,
user_agent: None,
context_window: 64_000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
}
}
fn foreground_state_from_ui(state: &UiState) -> bg_runtime::RuntimeState {
if matches!(
state.phase,
crate::state::UiPhase::Streaming | crate::state::UiPhase::Approval
) {
bg_runtime::RuntimeState::Running
} else {
bg_runtime::RuntimeState::Idle
}
}
fn render_welcome(renderer: &mut dyn Renderer, ctx: &LoopCtx) {
let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
renderer.render(UiLine::Welcome {
model: ctx.model_name.clone(),
working_dir: dir_display,
});
}
fn bind_telemetry_to_session(ctx: &LoopCtx, session: &Session) {
if let Ok(uuid) = uuid::Uuid::parse_str(session.id.as_str()) {
ctx.telemetry.set_session_id(uuid);
}
}
fn find_pending_approval(session: &Session) -> Option<(String, String)> {
use atomcode_core::conversation::message::{MessageContent, Role};
use crate::event_loop::format_tool_detail;
let mut answered_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for m in &session.messages {
if let (Role::Tool, MessageContent::ToolResult(r)) = (&m.role, &m.content) {
answered_ids.insert(r.call_id.clone());
}
}
for m in session.messages.iter().rev() {
if let (
Role::Assistant,
MessageContent::AssistantWithToolCalls { tool_calls, .. },
) = (&m.role, &m.content)
{
for tc in tool_calls.iter().rev() {
if !answered_ids.contains(&tc.id) {
let display = super::display_tool_name(&tc.name);
let detail = format_tool_detail(&tc.name, &tc.arguments);
return Some((display, detail));
}
}
}
}
None
}
fn short_task_name(task: &str) -> String {
let first_line = task.lines().next().unwrap_or(task).trim();
let mut out: String = first_line.chars().take(80).collect();
if out.is_empty() {
out = "background task".to_string();
}
out
}
fn spawn_runtime(
ctx: &mut LoopCtx,
session: Session,
) -> (
bg_runtime::RuntimeId,
atomcode_core::agent::AgentClient,
Session,
) {
let runtime_id = ctx.bg_manager.allocate_runtime_id();
let (client, event_rx) = ctx.runtime_factory.spawn_runtime(Conversation::new());
bg_runtime::spawn_event_forwarder(runtime_id, event_rx, ctx.runtime_event_tx.clone());
(runtime_id, client, session)
}
fn sync_bg_foreground(ctx: &mut LoopCtx) {
ctx.bg_manager.set_foreground_runtime(
ctx.foreground_runtime_id,
ctx.agent.clone(),
ctx.current_session.clone(),
);
}
pub const MAX_SESSION_NAME_LEN: usize = 100;
pub fn validate_session_name(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Some(t(Msg::SessionNameEmpty).into_owned());
}
if trimmed.chars().count() > MAX_SESSION_NAME_LEN {
return Some(t(Msg::SessionNameTooLong { max: MAX_SESSION_NAME_LEN }).into_owned());
}
if trimmed.chars().any(char::is_control) {
return Some(t(Msg::SessionNameControlChars).into_owned());
}
None
}
pub fn perform_session_rename(
session_manager: &SessionManager,
session_id: &SessionId,
new_name: &str,
) -> Result<(String, String), String> {
if let Some(err) = validate_session_name(new_name) {
return Err(err);
}
let new_name = new_name.trim().to_string();
let session = session_manager
.load(session_id)
.map_err(|e| t(Msg::SessionLoadFailed { error: &e.to_string() }).into_owned())?;
let old_name = session.name.clone();
let renamed_session = atomcode_core::session::Session {
name: new_name.clone(),
updated_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(session.updated_at),
user_renamed: true,
..session
};
session_manager
.save(&renamed_session)
.map_err(|e| t(Msg::SessionSaveFailed { error: &e.to_string() }).into_owned())?;
Ok((old_name, new_name))
}
fn render_instruction_status_block(working_dir: &std::path::Path) -> String {
use atomcode_core::config::instructions::LayeredInstructions;
let instructions = LayeredInstructions::load(working_dir);
let mut out = t(Msg::StatusInstructionFilesHeader).into_owned();
for (level, path) in instructions.status_lines() {
match path {
Some(p) => out.push_str(&t(Msg::StatusInstructionPresent {
path: &p.display().to_string(),
label: level.label(),
})),
None => out.push_str(&t(Msg::StatusInstructionMissing { label: level.label() })),
}
}
out
}
pub(super) fn execute_slash_command(
cmd: &str,
arg: &str,
state: &mut UiState,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
active_modal: &mut Option<Box<dyn Modal>>,
fixissue_pending: &mut Option<atomcode_core::atomgit::IssueRef>,
fixissue_buffer: &mut String,
) -> Result<()> {
let _ = (&fixissue_pending, &fixissue_buffer);
let cmd_lower = cmd.to_ascii_lowercase();
let cmd = cmd_lower.as_str();
{
use atomcode_telemetry::Event;
let cmd_name = cmd.trim_start_matches('/').to_string();
ctx.telemetry.track(Event::UseCommand { type_: cmd_name, success: Some(true), error_kind: None, error_data: None });
}
match cmd {
"quit" | "exit" => {
ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
}
"help" => {
if arg.trim() == "commands" {
let config_dir = Config::config_dir();
let cmds = ctx.custom_commands.list();
let mut out = t(Msg::HelpCustomCommandsHeader).into_owned();
for cmd in &cmds {
let source_label = if cmd.source.starts_with(&config_dir) {
t(Msg::HelpSourceGlobal)
} else {
t(Msg::HelpSourceProject)
};
out.push_str(&format!(
" /{} — {} ({})\n",
cmd.name, cmd.description, source_label
));
}
if cmds.is_empty() {
out.push_str(&t(Msg::HelpCustomNone));
out.push_str(&t(Msg::HelpCustomCreateHint));
}
renderer.render(UiLine::CommandOutput(out));
} else {
renderer.render(UiLine::CommandOutput(ctx.commands.help_text()));
}
renderer.flush();
}
"keys" => {
renderer.render(UiLine::CommandOutput(
t(Msg::KeybindingsHelp).into_owned(),
));
renderer.flush();
}
"plan" => {
state.agent_mode = AgentMode::Plan;
ctx.agent.cmd_tx.send(AgentCommand::SetPlanMode(true)).ok();
renderer.render(UiLine::CommandOutput(
t(Msg::CmdSwitchedPlanMode).into_owned(),
));
renderer.flush();
}
"build" => {
state.agent_mode = AgentMode::Build;
ctx.agent.cmd_tx.send(AgentCommand::SetPlanMode(false)).ok();
renderer.render(UiLine::CommandOutput(
t(Msg::CmdSwitchedBuildMode).into_owned(),
));
renderer.flush();
}
"config" => {
let config_path = Config::default_path().display().to_string();
let mut txt = t(Msg::ConfigProviderLabel {
provider: &ctx.config.default_provider,
path: &config_path,
}).into_owned();
txt.push_str(
" Example:\n\
\n\
```toml\n\
default_provider = \"deepseek\"\n\
\n\
[providers.deepseek]\n\
type = \"openai\"\n\
api_key = \"sk-...\"\n\
model = \"deepseek-chat\"\n\
base_url = \"https://api.deepseek.com/v1\"\n\
context_window = 64000\n\
```\n\
\n\
Full reference: docs/config.example.toml (every field, every provider flavour).\n\
Edit the file, then run /reload — no restart needed.\n",
);
renderer.render(UiLine::CommandOutput(txt));
renderer.flush();
}
"reload" => {
let path = Config::default_path();
match Config::load(&path) {
Ok(new_cfg) => {
let new_default = new_cfg.default_provider.clone();
let new_model = new_cfg
.providers
.get(&new_default)
.map(|p| p.model.clone())
.unwrap_or_else(|| new_default.clone());
ctx.config = new_cfg.clone();
ctx.runtime_factory.set_config(new_cfg.clone());
ctx.model_name = new_model.clone();
ctx.agent
.cmd_tx
.send(AgentCommand::ReloadConfig(new_cfg))
.ok();
renderer.render(UiLine::CommandOutput(
t(Msg::CmdReloadDone {
provider: &new_default, model: &new_model,
}).into_owned(),
));
}
Err(e) => {
let msg = format!("{}", e);
renderer.render(UiLine::Error(
t(Msg::CmdReloadFailed { error: &msg }).into_owned(),
));
}
}
renderer.flush();
}
"clear" => {
renderer.clear_screen();
let dir_display = ctx.working_dir.to_string_lossy().to_string();
renderer.render(UiLine::Welcome {
model: ctx.model_name.clone(),
working_dir: dir_display,
});
renderer.flush();
}
"session" => {
ctx.agent.cmd_tx.send(AgentCommand::ClearConversation).ok();
ctx.current_session_id = None;
state.total_tokens = 0;
state.prompt_tokens = 0;
state.completion_tokens = 0;
state.cached_tokens = 0;
state.last_context = None;
state.pending_context_render = None;
state.thinking_idx = 0;
state.on_turn_complete();
ctx.current_session =
atomcode_core::session::Session::default_session(ctx.working_dir.clone());
ctx.bg_manager
.set_foreground_session(ctx.current_session.clone());
if let Ok(uuid) = uuid::Uuid::parse_str(ctx.current_session.id.as_str()) {
ctx.telemetry.set_session_id(uuid);
}
renderer.reset();
let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
renderer.render(UiLine::Welcome {
model: ctx.model_name.clone(),
working_dir: dir_display,
});
renderer.render(UiLine::CommandOutput(
t(Msg::CmdNewSession).into_owned(),
));
renderer.flush();
}
"model" => {
if ctx.config.providers.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::CmdNoProviders).into_owned(),
));
renderer.flush();
} else {
*active_modal = Some(Box::new(ModelPicker::open(&ctx.config)));
}
}
"language" => {
if arg.is_empty() {
*active_modal = Some(Box::new(LanguagePicker::open()));
} else {
match arg.parse::<atomcode_core::locale::Locale>() {
Ok(locale) => {
crate::i18n::set_locale(locale);
ctx.config.language = Some(locale);
let config_path = atomcode_core::config::Config::default_path();
if let Err(e) = ctx.config.save(&config_path) {
eprintln!("[language] failed to save config: {e}");
}
let label = match locale {
atomcode_core::locale::Locale::En => "English",
atomcode_core::locale::Locale::ZhCn => "简体中文",
};
renderer.render(UiLine::CommandOutput(
t(Msg::LanguageSwitched {
label,
locale: &locale.to_string(),
})
.into_owned(),
));
renderer.flush();
}
Err(_) => {
let msg = t(Msg::ErrUnsupportedLocale { input: arg });
renderer.render(UiLine::CommandOutput(format!(" {msg}\n")));
renderer.flush();
}
}
}
}
"resume" => match ctx.session_manager.list() {
Ok(all) => {
let sessions: Vec<_> = all.into_iter().filter(|s| s.message_count > 0).collect();
if sessions.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::CmdNoSessions).into_owned(),
));
renderer.flush();
} else {
*active_modal = Some(Box::new(SessionPicker::open(sessions)));
}
}
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::SessionListFailed { error: &e.to_string() }).into_owned(),
));
renderer.flush();
}
},
"rename" => {
if let Some(err) = validate_session_name(arg) {
renderer.render(UiLine::Error(err));
renderer.flush();
} else {
let old_name = ctx.current_session.name.clone();
let new_name = arg.trim().to_string();
ctx.current_session.rename(new_name.clone());
match ctx.session_manager.save(&ctx.current_session) {
Ok(()) => {
renderer.render(UiLine::CommandOutput(
t(Msg::SessionRenamed { old: &old_name, new: &new_name })
.into_owned(),
));
renderer.flush();
}
Err(e) => {
ctx.current_session.name = old_name;
renderer.render(UiLine::Error(
t(Msg::SessionSaveFailed { error: &e.to_string() })
.into_owned(),
));
renderer.flush();
}
}
}
}
"provider" => {
*active_modal = Some(Box::new(ProviderWizard::MainMenu { selected: 0 }));
renderer.render(UiLine::CommandOutput(
t(Msg::ProviderWizardHeader).into_owned(),
));
renderer.flush();
}
"status" => {
let mut txt = t(Msg::StatusBody {
model: &ctx.model_name,
dir: &ctx.working_dir.display().to_string(),
config: &Config::default_path().display().to_string(),
tokens: state.total_tokens,
}).into_owned();
txt.push_str(&render_codingplan_status_for_status_cmd());
txt.push('\n');
txt.push_str(&render_instruction_status_block(&ctx.working_dir));
renderer.render(UiLine::CommandOutput(txt));
renderer.flush();
}
"diff" => {
let out = std::process::Command::new("git")
.args(["diff", "--stat"])
.current_dir(&ctx.working_dir)
.output();
match out {
Ok(o) => {
let s = String::from_utf8_lossy(&o.stdout).to_string();
renderer.render(UiLine::CommandOutput(if s.is_empty() {
t(Msg::CmdNoChanges).into_owned()
} else {
s
}));
}
Err(e) => {
renderer.render(UiLine::Error(t(Msg::DiffFailed { error: &format!("{}", e) }).into_owned()));
}
}
renderer.flush();
}
"undo" => {
renderer.render(UiLine::CommandOutput(
t(Msg::CmdUndoNotSupported).into_owned(),
));
renderer.flush();
}
"cost" => {
let total = state.prompt_tokens + state.completion_tokens;
let cache_rate = if state.prompt_tokens > 0 {
((state.cached_tokens as f64 / state.prompt_tokens as f64 * 100.0) + 0.5) as usize
} else {
0
};
let cost = atomcode_core::pricing::calculate_cost(
&ctx.model_name,
state.prompt_tokens,
state.completion_tokens,
state.cached_tokens,
);
let cost_str = atomcode_core::pricing::format_cost(cost);
renderer.render(UiLine::CommandOutput(
t(Msg::CostReport {
prompt: state.prompt_tokens,
completion: state.completion_tokens,
cached: state.cached_tokens,
cache_rate,
total,
cost: &cost_str,
}).into_owned(),
));
renderer.flush();
}
"context" => {
let show_prompt = arg.trim().eq_ignore_ascii_case("prompt");
state.pending_context_render = Some(show_prompt);
ctx.agent
.cmd_tx
.send(AgentCommand::RefreshContextStats)
.ok();
}
"compact" => {
let prompt = (!arg.trim().is_empty()).then(|| arg.trim().to_string());
ctx.agent.cmd_tx.send(AgentCommand::Compact { prompt }).ok();
}
"remember" => {
let text = arg.trim();
if text.is_empty() {
renderer.render(UiLine::Error(t(Msg::RememberUsage).into_owned()));
renderer.flush();
} else {
let (content, global) = if text.starts_with("--global ") {
(text[9..].trim().to_string(), true)
} else {
(text.to_string(), false)
};
if content.is_empty() {
renderer.render(UiLine::Error(t(Msg::RememberUsage).into_owned()));
renderer.flush();
} else {
ctx.agent
.cmd_tx
.send(AgentCommand::Remember { content, global })
.ok();
}
}
}
"forget" => {
let keyword = arg.trim();
if keyword.is_empty() {
renderer.render(UiLine::Error(t(Msg::ForgetUsage).into_owned()));
renderer.flush();
} else {
ctx.agent
.cmd_tx
.send(AgentCommand::Forget {
keyword: keyword.to_string(),
})
.ok();
}
}
"memory" => {
ctx.agent.cmd_tx.send(AgentCommand::ShowMemory).ok();
}
"login" => {
run_login_flow(renderer, ctx)?;
}
"codingplan" => {
run_codingplan_flow(renderer, ctx)?;
}
"logout" => {
match atomcode_core::auth::logout() {
Ok(()) => {
ctx.telemetry.set_account_id(None);
let _ = ctx
.agent
.cmd_tx
.send(AgentCommand::ReloadConfig(ctx.config.clone()));
renderer.render(UiLine::CommandOutput(
t(Msg::CmdLogoutDone).into_owned(),
));
}
Err(e) => {
let msg = format!("{}", e);
renderer.render(UiLine::Error(
t(Msg::CmdLogoutFailed { error: &msg }).into_owned(),
));
}
}
renderer.flush();
}
"whoami" => {
let txt = if let Some(auth) = atomcode_core::auth::get_stored_auth() {
let email = auth.user.email.as_deref().unwrap_or("—");
let name = auth.user.name.as_deref().unwrap_or(&auth.user.username);
format!(
" {} ({})\n {}\n auth: {}\n",
name,
auth.user.username,
email,
atomcode_core::auth::auth_file_path().display(),
)
} else {
t(Msg::CmdWhoamiNotSignedIn).into_owned()
};
renderer.render(UiLine::CommandOutput(txt));
renderer.flush();
}
"upgrade" => {
let arg_norm = arg.trim().to_ascii_lowercase();
if arg_norm == "rollback" {
match atomcode_core::self_update::run_rollback() {
Ok(sum) => {
let _ = ctx.upgrade_tx.send(
atomcode_core::self_update::UpgradeEvent::RolledBack {
exe: sum.exe,
backup: sum.backup,
},
);
}
Err(e) => {
let _ =
ctx.upgrade_tx
.send(atomcode_core::self_update::UpgradeEvent::Failed(format!(
"{:#}",
e
)));
}
}
} else {
let force = arg_norm == "--force" || arg_norm == "-f";
if !force && !arg_norm.is_empty() {
renderer.render(UiLine::Error(
t(Msg::UpgradeUnknownArg { arg }).into_owned(),
));
renderer.flush();
return Ok(());
}
renderer.render(UiLine::CommandOutput(
t(Msg::CmdCheckingUpdate).into_owned(),
));
renderer.flush();
let current = format!("v{}", env!("CARGO_PKG_VERSION"));
let tx = ctx.upgrade_tx.clone();
tokio::spawn(async move {
if let Err(e) =
atomcode_core::self_update::run_upgrade(current, force, tx.clone()).await
{
let _ = tx.send(atomcode_core::self_update::UpgradeEvent::Failed(format!(
"{:#}",
e
)));
}
});
}
}
"issue" => {
let _ = arg; let mut wiz = IssueWizard::open(
atomcode_core::atomgit::UPSTREAM_OWNER.to_string(),
atomcode_core::atomgit::UPSTREAM_REPO.to_string(),
);
wiz.emit_prompt(renderer);
*active_modal = Some(Box::new(wiz));
}
"cd" => {
if arg.is_empty() {
if ctx.recent_dirs.is_empty() {
let cwd = ctx.working_dir.display().to_string();
renderer.render(UiLine::CommandOutput(
t(Msg::CdWorkingDir { cwd: &cwd }).into_owned(),
));
renderer.flush();
} else {
*active_modal = Some(Box::new(DirPicker::open(
ctx.recent_dirs.clone(),
ctx.working_dir.clone(),
)));
}
return Ok(());
}
let new_dir = resolve_cd(arg, &ctx.working_dir, ctx.previous_dir.as_deref());
match new_dir {
Ok(path) => {
apply_cd(ctx, path.clone());
let p = path.display().to_string();
renderer.render(UiLine::CommandOutput(
t(Msg::DirChanged { path: &p }).into_owned(),
));
}
Err(e) => {
renderer.render(UiLine::Error(e));
}
}
renderer.flush();
}
"bg" => {
match bg_runtime::parse_bg_command(arg) {
bg_runtime::BgCommand::Help => {
renderer.render(UiLine::CommandOutput(bg_runtime::render_bg_help()));
}
bg_runtime::BgCommand::List => {
renderer.render(UiLine::CommandOutput(bg_runtime::render_bg_list(
ctx.bg_manager.backgrounds(),
)));
}
bg_runtime::BgCommand::BackgroundCurrent => {
sync_bg_foreground(ctx);
if !ctx.bg_manager.has_capacity() {
renderer.render(UiLine::Error(
t(Msg::BgSlotLimitReached { max: bg_runtime::MAX_BACKGROUND_SLOTS }).into_owned(),
));
renderer.flush();
return Ok(());
}
let old_short_id = ctx.current_session.short_id().to_string();
let new_session = Session::default_session(ctx.working_dir.clone());
let new_short_id = new_session.short_id().to_string();
let (runtime_id, client, new_session) = spawn_runtime(ctx, new_session);
let old_state = foreground_state_from_ui(state);
let slot = match ctx.bg_manager.background_current(
client.clone(),
new_session.clone(),
runtime_id,
old_state,
) {
Ok(slot) => slot,
Err(bg_runtime::BgError::SlotLimit { max }) => {
renderer.render(UiLine::Error(
t(Msg::BgSlotLimitReached { max }).into_owned(),
));
renderer.flush();
return Ok(());
}
Err(bg_runtime::BgError::InvalidSlot { .. }) => unreachable!(),
};
ctx.agent = client;
ctx.foreground_runtime_id = runtime_id;
ctx.current_session = new_session;
bind_telemetry_to_session(ctx, &ctx.current_session);
state.on_turn_complete();
renderer.reset();
render_welcome(renderer, ctx);
renderer.render(UiLine::CommandOutput(
t(Msg::BgBackgroundCurrent {
new_id: &new_short_id,
slot,
old_id: &old_short_id,
state: &old_state.localised(),
}).into_owned(),
));
}
bg_runtime::BgCommand::Resume(slot) => {
sync_bg_foreground(ctx);
let outcome = match ctx
.bg_manager
.resume_slot(slot, foreground_state_from_ui(state))
{
Ok(outcome) => outcome,
Err(bg_runtime::BgError::InvalidSlot { slot, len }) => {
renderer.render(UiLine::Error(
t(Msg::BgInvalidSlot { slot, available: len }).into_owned(),
));
renderer.flush();
return Ok(());
}
Err(bg_runtime::BgError::SlotLimit { max }) => {
renderer.render(UiLine::Error(
t(Msg::BgSlotLimitReached { max }).into_owned(),
));
renderer.flush();
return Ok(());
}
};
let Some(client) = outcome.resumed_client else {
renderer.render(UiLine::Error(
t(Msg::BgNoRuntimeClient).into_owned(),
));
renderer.flush();
return Ok(());
};
ctx.agent = client;
ctx.foreground_runtime_id = outcome.resumed_runtime_id;
ctx.current_session = outcome.resumed_session;
bind_telemetry_to_session(ctx, &ctx.current_session);
state.on_turn_complete();
crate::modals::session_picker::replay_session(
renderer,
&ctx.current_session,
true,
);
let pending_approval = find_pending_approval(&ctx.current_session);
if let Some((tool_name, detail)) = pending_approval {
renderer.render(UiLine::ApprovalPrompt { tool: tool_name, detail });
state.on_approval_needed("");
}
let short_id = ctx.current_session.short_id().to_string();
let mut msg = t(Msg::BgResumed { slot, short_id: &short_id }).into_owned();
if let Some(previous_slot) = outcome.previous_foreground_slot {
msg.push_str(&t(Msg::BgPreviousForegroundMoved { slot: previous_slot }).into_owned());
}
renderer.render(UiLine::CommandOutput(msg));
}
bg_runtime::BgCommand::Drop(slot) => {
let dropped = match ctx.bg_manager.drop_slot(slot) {
Ok(dropped) => dropped,
Err(bg_runtime::BgError::InvalidSlot { slot, len }) => {
renderer.render(UiLine::Error(
t(Msg::BgInvalidSlot { slot, available: len }).into_owned(),
));
renderer.flush();
return Ok(());
}
Err(bg_runtime::BgError::SlotLimit { .. }) => unreachable!(),
};
if matches!(dropped.state, bg_runtime::RuntimeState::Running) {
if let Some(client) = dropped.client.as_ref() {
client.cmd_tx.send(AgentCommand::Cancel).ok();
}
}
if !dropped.session.messages.is_empty() {
let _ = ctx.session_manager.save(&dropped.session);
}
let short_id = dropped.session.short_id().to_string();
renderer.render(UiLine::CommandOutput(
t(Msg::BgDropped { slot, short_id: &short_id }).into_owned(),
));
}
}
renderer.flush();
}
"background" => {
let task = arg.trim();
if task.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::BackgroundUsage).into_owned(),
));
renderer.flush();
return Ok(());
}
if !ctx.bg_manager.has_capacity() {
renderer.render(UiLine::Error(
t(Msg::BgSlotLimitReached { max: bg_runtime::MAX_BACKGROUND_SLOTS }).into_owned(),
));
renderer.flush();
return Ok(());
}
let mut session = Session::default_session(ctx.working_dir.clone());
session.name = short_task_name(task);
let short_id = session.short_id().to_string();
let (runtime_id, client, session) = spawn_runtime(ctx, session);
let slot = match ctx.bg_manager.push_background_runtime(
runtime_id,
client.clone(),
session,
bg_runtime::RuntimeState::Running,
) {
Ok(slot) => slot,
Err(bg_runtime::BgError::SlotLimit { max }) => {
renderer.render(UiLine::Error(
t(Msg::BgSlotLimitReached { max }).into_owned(),
));
renderer.flush();
return Ok(());
}
Err(bg_runtime::BgError::InvalidSlot { .. }) => unreachable!(),
};
client
.cmd_tx
.send(AgentCommand::SendMessage { text: task.to_string(), images: Vec::new(), image_markers: Vec::new() })
.ok();
renderer.render(UiLine::CommandOutput(
t(Msg::BgTaskStarted { slot, short_id: &short_id }).into_owned(),
));
renderer.flush();
}
"init" => {
let target = ctx.working_dir.join(".atomcode.md");
let force = matches!(arg.trim(), "--force" | "force");
if target.exists() && !force {
let path_str = target.display().to_string();
renderer.render(UiLine::CommandOutput(
t(Msg::InitAlreadyExists { path: &path_str }).into_owned(),
));
renderer.flush();
return Ok(());
}
let content = atomcode_core::init::generate_project_instructions(&ctx.working_dir);
match std::fs::write(&target, &content) {
Ok(()) => {
let path_str = target.display().to_string();
renderer.render(UiLine::CommandOutput(
t(Msg::InitWrote { path: &path_str, bytes: content.len() }).into_owned(),
));
renderer.render(UiLine::CommandOutput(
render_instruction_status_block(&ctx.working_dir),
));
}
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::InitFailed { error: &format!("{}", e) }).into_owned(),
));
}
}
renderer.flush();
}
"mcp" => {
let sub = arg.trim();
if let Some(rest) = sub.strip_prefix("login") {
let server = rest.trim();
if server.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::McpOAuthLoginUsage).into_owned(),
));
renderer.flush();
return Ok(());
}
let configs = match atomcode_core::mcp::load_mcp_config(&ctx.working_dir) {
Ok(configs) => configs,
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::McpOAuthLoadConfigFailed { error: &format!("{:#}", e) }).into_owned(),
));
renderer.flush();
return Ok(());
}
};
let Some(config) = configs.into_iter().find(|config| config.name == server) else {
renderer.render(UiLine::Error(
t(Msg::McpOAuthServerNotFound { server }).into_owned(),
));
renderer.flush();
return Ok(());
};
renderer.render(UiLine::CommandOutput(
t(Msg::McpOAuthStarting { server }).into_owned(),
));
renderer.flush();
let is_github_server = matches!(
&config.config,
atomcode_core::mcp::McpTransportConfig::Http {
auth: Some(atomcode_core::mcp::McpHttpAuthConfig::OAuth(auth)),
..
} if auth.provider.as_deref() == Some("github")
);
let result = tokio::task::block_in_place(|| {
atomcode_core::mcp::login_mcp_oauth(
&config,
atomcode_core::mcp::McpOAuthLoginOptions {
client_id: if is_github_server {
std::env::var("ATOMCODE_GITHUB_MCP_CLIENT_ID").ok()
} else {
None
},
client_secret_env: None,
scopes: Vec::new(),
},
)
});
match result {
Ok(token) => renderer.render(UiLine::CommandOutput(
t(Msg::McpOAuthSaved { provider: &token.provider, server }).into_owned(),
)),
Err(e) => renderer.render(UiLine::Error(
t(Msg::McpOAuthFailed { error: &format!("{:#}", e) }).into_owned(),
)),
}
renderer.flush();
return Ok(());
}
if let Some(rest) = sub.strip_prefix("logout") {
let server = rest.trim();
if server.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::McpOAuthLogoutUsage).into_owned(),
));
renderer.flush();
return Ok(());
}
match atomcode_core::mcp::McpTokenStore::default().delete_token(server) {
Ok(true) => renderer.render(UiLine::CommandOutput(
t(Msg::McpOAuthTokenRemoved { server }).into_owned(),
)),
Ok(false) => renderer.render(UiLine::CommandOutput(
t(Msg::McpOAuthNoToken { server }).into_owned(),
)),
Err(e) => renderer.render(UiLine::Error(
t(Msg::McpOAuthLogoutFailed { error: &format!("{:#}", e) }).into_owned(),
)),
}
renderer.flush();
return Ok(());
}
if sub.eq_ignore_ascii_case("reload") {
let configs = match atomcode_core::mcp::load_mcp_config(&ctx.working_dir) {
Ok(c) => c,
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::McpReloadFailed { error: &format!("{:#}", e) }).into_owned(),
));
renderer.flush();
return Ok(());
}
};
let mut header = t(Msg::McpReloading { count: configs.len() }).into_owned();
if !configs.is_empty() {
header.push_str(&t(Msg::McpConnecting));
for c in &configs {
header.push_str(&t(Msg::McpConnectingServer { name: &c.name }));
}
} else {
header.push_str(&t(Msg::McpNoServersConfigured));
}
renderer.render(UiLine::CommandOutput(header));
renderer.flush();
let removed = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
ctx.agent.tool_registry.unregister_prefix("mcp__").await
})
});
ctx.mcp_connect_rx = None;
ctx.mcp_registry = None;
ctx.mcp_reload = None;
if configs.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::McpClearedNoServers { removed }).into_owned(),
));
renderer.flush();
return Ok(());
}
ctx.mcp_reload = Some(super::McpReloadProgress {
total: configs.len(),
done: 0,
connected: 0,
failed: 0,
started_at: std::time::Instant::now(),
});
use atomcode_core::mcp::McpConnectEvent;
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<McpConnectEvent>();
let registry = atomcode_core::mcp::McpRegistry::from_config_background_with_events(
&ctx.working_dir,
Some(tx),
);
ctx.mcp_registry = Some(std::sync::Arc::new(registry));
ctx.mcp_connect_rx = Some(rx);
renderer.render(UiLine::CommandOutput(
t(Msg::McpClearedReconnecting { removed }).into_owned(),
));
renderer.flush();
return Ok(());
}
if let Some(rest) = sub.strip_prefix("tools") {
let server = rest.trim();
if server.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::McpToolsUsage).into_owned(),
));
renderer.flush();
return Ok(());
}
if let Some(registry) = &ctx.mcp_registry {
let server = server.to_string();
let server_for_msg = server.clone();
let registry = registry.clone();
let tx = registry.event_sender();
tokio::spawn(async move {
let list_timeout = registry.list_tools_timeout(&server).await;
let tools = match tokio::time::timeout(
list_timeout,
registry.list_tools_for_server(&server),
)
.await
{
Ok(v) => v,
Err(_) => {
if let Some(tx) = &tx {
let _ = tx.send(atomcode_core::mcp::McpConnectEvent::Warning {
name: server.clone(),
message: format!(
"tools/list timed out after {}s (server connected but tools not listed yet)",
list_timeout.as_secs()
),
});
}
return;
}
};
let mut msg = format!("tools:\n");
if tools.is_empty() {
msg.push_str(" (none — tools/list may have failed, timed out, or returned empty)\n");
} else {
for t in tools {
msg.push_str(&format!(" - mcp__{}__{}\n", server, t.tool_name));
}
}
if let Some(tx) = tx {
let _ = tx.send(atomcode_core::mcp::McpConnectEvent::Warning {
name: server,
message: msg.trim_end().to_string(),
});
}
});
renderer.render(UiLine::CommandOutput(
t(Msg::McpToolsListing { server: &server_for_msg }).into_owned(),
));
} else {
renderer.render(UiLine::CommandOutput(
t(Msg::McpNoRegistry).into_owned(),
));
}
renderer.flush();
return Ok(());
}
if let Some(registry) = &ctx.mcp_registry {
let statuses = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(registry.server_statuses())
});
if statuses.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::McpNoServersConfigured).into_owned(),
));
} else {
let mut txt = t(Msg::McpServersHeader).into_owned();
for (name, status) in statuses {
txt.push_str(&format!(" {} {}\n", name, status));
}
renderer.render(UiLine::CommandOutput(txt));
}
} else {
renderer.render(UiLine::CommandOutput(
t(Msg::McpNoServersConfigured).into_owned(),
));
}
renderer.flush();
}
"welcome" => {
let _ = arg;
*active_modal = Some(Box::new(
crate::modals::OnboardingWizard::new_with_confirm()
.with_initial_language(ctx.config.language),
));
}
"worktree" => {
handle_worktree(arg, ctx, renderer)?;
}
"think" => {
let sub = arg.trim().to_ascii_lowercase();
let provider_name = ctx.config.default_provider.clone();
let provider = ctx.config.providers.get_mut(&provider_name);
match provider {
None => {
renderer.render(UiLine::Error(
t(Msg::CmdNoActiveProvider).into_owned(),
));
renderer.flush();
}
Some(p) => {
if sub.is_empty() {
let enabled = p.thinking_enabled.unwrap_or(false);
let budget = p.thinking_budget.unwrap_or(10_000);
let status = if enabled { "enabled" } else { "disabled" };
renderer.render(UiLine::CommandOutput(
t(Msg::ThinkStatus { status, budget, provider: &provider_name }).into_owned(),
));
renderer.flush();
} else if sub == "on" {
p.thinking_enabled = Some(true);
let budget = p.thinking_budget.unwrap_or(10_000);
save_and_reload(ctx, renderer);
renderer.render(UiLine::CommandOutput(
t(Msg::ThinkEnabled { budget }).into_owned(),
));
renderer.flush();
} else if sub == "off" {
p.thinking_enabled = Some(false);
save_and_reload(ctx, renderer);
renderer.render(UiLine::CommandOutput(
t(Msg::ThinkDisabled).into_owned(),
));
renderer.flush();
} else if let Some(rest) = sub.strip_prefix("budget") {
let num_str = rest.trim();
match num_str.parse::<u32>() {
Ok(n) if n >= 1024 => {
p.thinking_budget = Some(n);
save_and_reload(ctx, renderer);
renderer.render(UiLine::CommandOutput(
t(Msg::ThinkBudgetSet { n }).into_owned(),
));
renderer.flush();
}
Ok(n) => {
renderer.render(UiLine::Error(
t(Msg::ThinkBudgetTooSmall { n }).into_owned(),
));
renderer.flush();
}
Err(_) => {
renderer.render(UiLine::Error(
t(Msg::ThinkBudgetUsage).into_owned(),
));
renderer.flush();
}
}
} else {
renderer.render(UiLine::CommandOutput(
t(Msg::ThinkUsage).into_owned(),
));
renderer.flush();
}
}
}
}
"plugin" => {
handle_plugin(arg, ctx, renderer);
}
"skills" => {
let arg_trim = arg.trim();
if arg_trim.is_empty() {
let lines: Vec<String> = ctx
.skill_registry
.read()
.ok()
.map(|r| {
let mut v: Vec<String> = r
.user_invocable()
.map(|s| format!(" /skills {:<48} {}", s.name, s.description))
.collect();
v.sort();
v
})
.unwrap_or_default();
if lines.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::SkillsNone).into_owned(),
));
} else {
renderer.render(UiLine::CommandOutput(format!(
"{}{}\n",
t(Msg::SkillsAvailable),
lines.join("\n")
)));
}
renderer.flush();
} else {
let mut parts = arg_trim.splitn(2, char::is_whitespace);
let skill_name = parts.next().unwrap_or("");
let skill_args = parts.next().unwrap_or("").trim_start();
if let Some(rendered) = expand_skill(ctx, skill_name, skill_args) {
ctx.agent
.cmd_tx
.send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
.ok();
state.on_submit();
} else {
renderer.render(UiLine::Error(
t(Msg::SkillUnknown { name: skill_name }).into_owned(),
));
renderer.flush();
}
}
}
"setup" => {
let skill_already_installed = {
let reg = ctx.skill_registry.read().ok();
reg.as_ref().map_or(false, |r| r.get("setup").is_some())
};
if skill_already_installed {
if let Some(rendered) = expand_skill(ctx, "setup", arg) {
renderer.render(UiLine::CommandOutput(
t(Msg::CmdSetupRunningSkill).into_owned(),
));
renderer.flush();
ctx.agent
.cmd_tx
.send(AgentCommand::SendMessage {
text: rendered,
images: vec![],
image_markers: vec![],
})
.ok();
state.on_submit();
} else {
renderer.render(UiLine::Error(
t(Msg::CmdSetupSkillMissing).into_owned(),
));
renderer.flush();
}
} else {
renderer.render(UiLine::CommandOutput(
t(Msg::CmdSetupRunning).into_owned(),
));
renderer.flush();
let project_root = ctx.working_dir.clone();
let opts = atomcode_core::setup::RunOptions::new(project_root);
let result = tokio::task::block_in_place(|| {
atomcode_core::setup::run(opts)
});
match result {
Ok(report) => {
for line in report.render_cli().lines() {
renderer.render(UiLine::CommandOutput(line.to_string()));
}
let (skills_loaded, _) = super::reload_plugins(ctx);
renderer.render(UiLine::CommandOutput(
t(Msg::CmdSetupSkillsReloaded { count: skills_loaded }).into_owned(),
));
renderer.flush();
if let Some(rendered) = expand_skill(ctx, "setup", arg) {
renderer.render(UiLine::CommandOutput(
t(Msg::CmdSetupRunningSkill).into_owned(),
));
renderer.flush();
ctx.agent
.cmd_tx
.send(AgentCommand::SendMessage {
text: rendered,
images: vec![],
image_markers: vec![],
})
.ok();
state.on_submit();
} else {
renderer.render(UiLine::Error(
t(Msg::CmdSetupSkillMissing).into_owned(),
));
renderer.flush();
}
}
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::CmdSetupError { error: &e.to_string() }).into_owned(),
));
}
}
renderer.flush();
}
}
other => {
if let Some(rendered) = ctx.custom_commands.render(other, arg) {
ctx.agent
.cmd_tx
.send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
.ok();
state.on_submit();
} else if let Some(rendered) = expand_skill(ctx, other, arg) {
ctx.agent
.cmd_tx
.send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
.ok();
state.on_submit();
} else {
let available_commands: Vec<&str> = vec![
"help", "quit", "exit", "clear", "compact", "reload", "config",
"plan", "build", "session", "model", "language", "resume",
"rename", "provider", "status", "diff", "undo", "cost",
"context", "remember", "forget", "memory", "login", "logout",
"whoami", "upgrade", "issue", "cd", "bg", "codingplan",
];
ctx.telemetry.track(atomcode_telemetry::Event::UseCommand {
type_: other.to_string(),
success: Some(false),
error_kind: Some(atomcode_telemetry::UseCommandErrorKind::NotFound),
error_data: Some(serde_json::json!({
"command": other,
"duration_ms": 0,
"message": format!("Unknown command: {}", other),
"reason": "用户输入了不存在的斜杠命令",
"resolution": "使用 /help 查看所有可用命令",
"available_commands": available_commands,
}).to_string()),
});
renderer.render(UiLine::Error(
t(Msg::CmdUnknownCommand { name: other }).into_owned(),
));
renderer.flush();
}
}
}
Ok(())
}
fn expand_skill(ctx: &LoopCtx, name: &str, arg: &str) -> Option<String> {
let reg = ctx.skill_registry.read().ok()?;
let skill = reg.get(name)?;
if !skill.user_invocable {
return None;
}
Some(skill.expand(arg, ctx.current_session.id.as_str()))
}
fn handle_plugin(arg: &str, ctx: &mut super::LoopCtx, renderer: &mut dyn Renderer) {
let rest = arg.trim();
let mut parts = rest.splitn(3, char::is_whitespace);
let sub = parts.next().unwrap_or("");
let ok = |renderer: &mut dyn Renderer, msg: String| {
renderer.render(UiLine::CommandOutput(format!(" {}\n", msg)));
renderer.flush();
};
let err = |renderer: &mut dyn Renderer, msg: String| {
renderer.render(UiLine::Error(msg));
renderer.flush();
};
match sub {
"marketplace" => {
let action = parts.next().unwrap_or("");
let arg = parts.next().unwrap_or("").trim();
match action {
"add" => {
let url = arg.to_string();
let tx = ctx.plugin_job_tx.clone();
ok(renderer, t(Msg::PluginMarketplaceCloning { url: &url }).into_owned());
tokio::task::spawn_blocking(move || {
let ev = match atomcode_core::plugin::marketplace::add_marketplace(&url) {
Ok(info) => atomcode_core::plugin::PluginJobEvent::MarketplaceAdded(info),
Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
op: "add marketplace".into(),
msg: format!("{:#}", e),
},
};
let _ = tx.send(ev);
});
}
"remove" => match atomcode_core::plugin::marketplace::remove_marketplace(arg) {
Ok(()) => {
super::reload_plugins(ctx);
ok(renderer, t(Msg::PluginMarketplaceRemoved { name: arg }).into_owned());
}
Err(e) => err(renderer, t(Msg::PluginMarketplaceRemoveFailed { error: &e.to_string() }).into_owned()),
},
"update" => {
let name = arg.to_string();
let tx = ctx.plugin_job_tx.clone();
ok(renderer, t(Msg::PluginMarketplaceUpdating { name: &name }).into_owned());
tokio::task::spawn_blocking(move || {
let ev = match atomcode_core::plugin::marketplace::update_marketplace(&name) {
Ok(info) => atomcode_core::plugin::PluginJobEvent::MarketplaceUpdated(info),
Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
op: "update marketplace".into(),
msg: format!("{:#}", e),
},
};
let _ = tx.send(ev);
});
}
"list" => match atomcode_core::plugin::marketplace::list_marketplaces() {
Ok(items) if items.is_empty() => {
ok(renderer, t(Msg::PluginNoMarketplaces).into_owned());
}
Ok(items) => {
let mut lines = vec![t(Msg::PluginMarketplacesHeader).into_owned()];
for m in items {
lines.push(format!(
" {} {} {} ({} plugins)",
m.name,
m.source,
&m.git_commit[..7.min(m.git_commit.len())],
m.plugins.len()
));
}
renderer.render(UiLine::CommandOutput(format!(
" {}\n",
lines.join("\n ")
)));
renderer.flush();
}
Err(e) => err(renderer, t(Msg::PluginMarketplaceListFailed { error: &e.to_string() }).into_owned()),
},
_ => err(
renderer,
t(Msg::PluginMarketplaceUsage).into_owned(),
),
}
}
"install" => match parse_plugin_at_marketplace(parts.next().unwrap_or("").trim()) {
Some((plugin, mp)) => {
let tx = ctx.plugin_job_tx.clone();
ok(renderer, t(Msg::PluginInstalling { plugin: &plugin, marketplace: &mp }).into_owned());
tokio::task::spawn_blocking(move || {
let ev = match atomcode_core::plugin::installer::install(&plugin, &mp) {
Ok(info) => atomcode_core::plugin::PluginJobEvent::PluginInstalled(info),
Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
op: "install".into(),
msg: format!("{:#}", e),
},
};
let _ = tx.send(ev);
});
}
None => err(renderer, t(Msg::PluginInstallUsage).into_owned()),
},
"uninstall" => match parse_plugin_at_marketplace(parts.next().unwrap_or("").trim()) {
Some((plugin, mp)) => match atomcode_core::plugin::installer::uninstall(&plugin, &mp) {
Ok(()) => {
super::reload_plugins(ctx);
ok(renderer, t(Msg::PluginUninstalled { plugin: &plugin, marketplace: &mp }).into_owned());
}
Err(e) => err(renderer, t(Msg::PluginUninstallFailed { error: &e.to_string() }).into_owned()),
},
None => err(
renderer,
t(Msg::PluginUninstallUsage).into_owned(),
),
},
"list" => match atomcode_core::plugin::installer::list_installed() {
Ok(items) if items.is_empty() => {
ok(renderer, t(Msg::PluginNoInstalled).into_owned());
}
Ok(items) => {
let mut lines = vec![t(Msg::PluginInstalledHeader).into_owned()];
for p in items {
lines.push(format!(" {}@{} {}", p.plugin, p.marketplace, p.plugin_dir));
}
renderer.render(UiLine::CommandOutput(format!(
" {}\n",
lines.join("\n ")
)));
renderer.flush();
}
Err(e) => err(renderer, t(Msg::PluginListFailed { error: &e.to_string() }).into_owned()),
},
_ => err(
renderer,
t(Msg::PluginUsage).into_owned(),
),
}
}
fn parse_plugin_at_marketplace(s: &str) -> Option<(String, String)> {
let (plugin, mp) = s.split_once('@')?;
if plugin.is_empty() || mp.is_empty() {
return None;
}
Some((plugin.to_string(), mp.to_string()))
}
fn handle_worktree(arg: &str, ctx: &mut LoopCtx, renderer: &mut dyn Renderer) -> Result<()> {
use atomcode_core::git::worktree::WorktreeManager;
let parts: Vec<&str> = arg.split_whitespace().collect();
let sub = parts.first().map(|s| s.to_ascii_lowercase());
match sub.as_deref() {
Some("create") => {
let branch = match parts.get(1) {
Some(b) => *b,
None => {
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeCreateUsage).into_owned(),
));
renderer.flush();
return Ok(());
}
};
let base = parts
.get(2)
.map(|s| (*s).to_string())
.or_else(|| detect_current_branch(&ctx.working_dir))
.unwrap_or_else(|| "HEAD".to_string());
let mgr = match WorktreeManager::from_dir(ctx.working_dir.clone()) {
Ok(mgr) => mgr,
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::WorktreeCreateFailed { error: &format!("{:#}", e) }).into_owned(),
));
renderer.flush();
return Ok(());
}
};
match mgr.create(branch, &base) {
Ok(wt) => {
ctx.worktree_original_dir = Some(ctx.working_dir.clone());
apply_cd(ctx, wt.path.clone());
let path_str = wt.path.display().to_string();
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeCreated { branch: &wt.branch, base: &wt.base_branch, path: &path_str }).into_owned(),
));
}
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::WorktreeCreateFailed { error: &format!("{:#}", e) }).into_owned(),
));
}
}
renderer.flush();
}
Some("list") => {
let mgr = match WorktreeManager::from_dir(ctx.working_dir.clone()) {
Ok(mgr) => mgr,
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::WorktreeListFailed { error: &format!("{:#}", e) }).into_owned(),
));
renderer.flush();
return Ok(());
}
};
match mgr.list() {
Ok(worktrees) => {
if worktrees.is_empty() {
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeNoActive).into_owned(),
));
} else {
let mut txt = t(Msg::WorktreeActiveHeader).into_owned();
for (branch, path, has_changes) in &worktrees {
let is_current = path == &ctx.working_dir;
let marker = if is_current { "\u{25cf}" } else { "\u{25cb}" };
let change_label = if *has_changes {
t(Msg::WorktreeHasChanges)
} else {
t(Msg::WorktreeClean)
};
let current_hint = if is_current {
t(Msg::WorktreeCurrent)
} else {
"".into()
};
txt.push_str(&format!(
" {} {:<16} {} {}{}\n",
marker,
branch,
path.display(),
change_label,
current_hint,
));
}
renderer.render(UiLine::CommandOutput(txt));
}
}
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::WorktreeListFailed { error: &format!("{:#}", e) }).into_owned(),
));
}
}
renderer.flush();
}
Some("done") => {
if let Some(original) = ctx.worktree_original_dir.take() {
let current_branch = detect_current_branch(&ctx.working_dir);
apply_cd(ctx, original.clone());
let path_str = original.display().to_string();
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeDoneBack { path: &path_str }).into_owned(),
));
if let Some(branch) = current_branch {
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeDoneMergeHint { branch: &branch }).into_owned(),
));
}
} else {
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeNoSession).into_owned(),
));
}
renderer.flush();
}
Some("cleanup") => {
let branch = match parts.get(1) {
Some(b) => *b,
None => {
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeCleanupUsage).into_owned(),
));
renderer.flush();
return Ok(());
}
};
let force = parts
.get(2)
.map(|s| *s == "--force" || *s == "-f")
.unwrap_or(false);
let manager_dir = ctx
.worktree_original_dir
.as_ref()
.cloned()
.unwrap_or_else(|| ctx.working_dir.clone());
let mgr = match WorktreeManager::from_dir(manager_dir) {
Ok(mgr) => mgr,
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::WorktreeCleanupFailed { error: &format!("{:#}", e) }).into_owned(),
));
renderer.flush();
return Ok(());
}
};
let cleanup_path = mgr
.find_worktree_path(branch)
.unwrap_or_else(|_| None)
.unwrap_or_else(|| mgr.worktree_path(branch));
let removing_current = paths_same(&cleanup_path, &ctx.working_dir);
match mgr.remove(branch, force) {
Ok(()) => {
let switched_to = if removing_current {
let target = ctx
.worktree_original_dir
.take()
.unwrap_or_else(|| mgr.repo_root().to_path_buf());
apply_cd(ctx, target.clone());
Some(target)
} else {
None
};
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeCleaned { branch }).into_owned(),
));
if let Some(target) = switched_to {
let path_str = target.display().to_string();
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeCleanedSwitched { path: &path_str }).into_owned(),
));
}
}
Err(e) => {
let err_msg = format!("{:#}", e);
if !force
&& (err_msg.contains("untracked")
|| err_msg.contains("modified")
|| err_msg.contains("changes"))
{
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeCleanupUncommitted { branch }).into_owned(),
));
} else {
renderer.render(UiLine::Error(
t(Msg::WorktreeCleanupFailed { error: &err_msg }).into_owned(),
));
}
}
}
renderer.flush();
}
_ => {
renderer.render(UiLine::CommandOutput(
t(Msg::WorktreeUsage).into_owned(),
));
renderer.flush();
}
}
Ok(())
}
fn detect_current_branch(dir: &std::path::Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(dir)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
fn paths_same(a: &std::path::Path, b: &std::path::Path) -> bool {
if a == b {
return true;
}
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
pub(super) fn render_context_report(state: &UiState, ctx: &LoopCtx, show_prompt: bool) -> String {
format_context_report(state.last_context.as_ref(), &ctx.model_name, show_prompt)
}
fn render_codingplan_status_for_status_cmd() -> String {
use atomcode_core::coding_plan::client::Client;
let client = match Client::from_stored_auth() {
Ok(c) => c,
Err(_) => {
return t(Msg::StatusCpNotSignedIn).into_owned();
}
};
let status = match client.status_v2() {
Ok(s) => s,
Err(e) => {
return t(Msg::StatusCpFetchFailed { error: &format!("{:#}", e) }).into_owned();
}
};
let plan = match &status.codingplan_free {
Some(p) => p,
None => {
return t(Msg::StatusCpNoActive).into_owned();
}
};
let mut out = t(Msg::StatusCpLine {
plan: &plan.plan_name,
expires_at: &plan.expires_at,
remaining_days: plan.remaining_days,
total_days: plan.total_days,
}).into_owned();
if let Some(u) = &status.current_usage {
out.push_str(&t(Msg::StatusCpUsage {
usage: &u.display_desc(),
reset_at: &u.reset_at_display,
seconds: u.seconds_until_reset,
}));
}
if status.window_quota_exhausted {
if let Some(hint) = &status.window_quota_hint {
out.push_str(&t(Msg::StatusCpWindowHint { hint }));
} else {
out.push_str(&t(Msg::StatusCpWindowExhausted));
}
}
out
}
fn format_context_report(
snapshot: Option<&crate::state::ContextSnapshot>,
model_name: &str,
show_prompt: bool,
) -> String {
let header = t(Msg::CtxUsageHeader);
let Some(snap) = snapshot else {
return format!(" {}\n \n {}\n", header, t(Msg::CtxUsageNoTurns));
};
if snap.ctx_window == 0 {
return format!(" {}\n \n {}\n", header, t(Msg::CtxUsageWaiting));
}
let window = snap.ctx_window;
let sys = snap.system_tokens;
let tools = snap.tool_defs_tokens;
let cold = snap.cold_zone_tokens;
let messages = snap.sent_tokens.saturating_sub(cold);
let total_used = sys
.saturating_add(tools)
.saturating_add(cold)
.saturating_add(messages);
let free = window.saturating_sub(total_used);
const BAR_WIDTH: usize = 40;
let cells = |tokens: usize| -> usize {
if window == 0 {
return 0;
}
(tokens as u128 * BAR_WIDTH as u128 / window as u128) as usize
};
let sys_cells = cells(sys);
let tools_cells = cells(tools);
let cold_cells = cells(cold);
let msg_cells = cells(messages);
let used_cells = sys_cells + tools_cells + cold_cells + msg_cells;
let free_cells = BAR_WIDTH.saturating_sub(used_cells.min(BAR_WIDTH));
let mut bar = String::with_capacity(BAR_WIDTH * 3);
bar.push_str(&"▒".repeat(sys_cells)); bar.push_str(&"▓".repeat(tools_cells)); bar.push_str(&"░".repeat(cold_cells)); bar.push_str(&"█".repeat(msg_cells)); bar.push_str(&"·".repeat(free_cells));
let pct = |t: usize| -> String {
if window == 0 {
return " —".to_string();
}
format!("{:>4.1}%", (t as f64 * 100.0) / window as f64)
};
let k = |t: usize| -> String {
if t >= 1000 {
format!("{:.1}K", t as f64 / 1000.0)
} else {
format!("{}", t)
}
};
let used_pct = pct(total_used);
let l_sys = t(Msg::CtxLabelSystemPrompt).into_owned();
let l_tools = t(Msg::CtxLabelToolDefs).into_owned();
let l_cold = t(Msg::CtxLabelColdZone).into_owned();
let l_msgs = t(Msg::CtxLabelMessages).into_owned();
let l_free = t(Msg::CtxLabelFree).into_owned();
let max_label = [&l_sys, &l_tools, &l_cold, &l_msgs, &l_free]
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.as_str()))
.max()
.unwrap_or(0);
let pad_label = |label: &str| -> String {
let w = unicode_width::UnicodeWidthStr::width(label);
format!("{}{}", label, " ".repeat(max_label.saturating_sub(w)))
};
let ctx_name = if snap.ctx_name.is_empty() {
"default"
} else {
snap.ctx_name.as_str()
};
let mut out = format!(
" {header}\n \
\n \
{bar}\n \
{used}/{window} {tokens} ({used_pct})\n \
\n \
{provider}: {model} · {ctx_label}: {ctx_name}\n \
\n \
▒ {l_sys} : {sys_s:>7} ({sys_p})\n \
▓ {l_tools} : {tools_s:>7} ({tools_p})\n \
░ {l_cold} : {cold_s:>7} ({cold_p})\n \
█ {l_msgs} : {msgs_s:>7} ({msgs_p})\n \
· {l_free} : {free_s:>7} ({free_p})\n \
\n \
{msg_count}\n",
header = t(Msg::CtxUsageHeader),
bar = bar,
used = k(total_used),
window = k(window),
tokens = t(Msg::CtxTokensSuffix),
used_pct = used_pct,
provider = t(Msg::CtxProvider),
ctx_label = t(Msg::CtxCtxName),
model = model_name,
ctx_name = ctx_name,
l_sys = pad_label(&l_sys),
l_tools = pad_label(&l_tools),
l_cold = pad_label(&l_cold),
l_msgs = pad_label(&l_msgs),
l_free = pad_label(&l_free),
sys_s = k(sys),
sys_p = pct(sys),
tools_s = k(tools),
tools_p = pct(tools),
cold_s = k(cold),
cold_p = pct(cold),
msgs_s = k(messages),
msgs_p = pct(messages),
free_s = k(free),
free_p = pct(free),
msg_count = t(Msg::CtxMessagesInWindow { n: snap.total_messages }),
);
if show_prompt {
out.push('\n');
out.push_str(&format!(" {}\n", t(Msg::CtxSystemPromptHeader)));
if snap.system_prompt.is_empty() {
out.push_str(&format!(" {}\n", t(Msg::CtxSystemPromptEmpty)));
} else {
for line in snap.system_prompt.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
out
}
#[allow(dead_code)]
pub(crate) fn launch_fixissue(
url: &str,
state: &mut UiState,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
fixissue_pending: &mut Option<atomcode_core::atomgit::IssueRef>,
fixissue_buffer: &mut String,
) {
match atomcode_core::atomgit::fixissue::prepare(url, &ctx.working_dir) {
Ok(atomcode_core::atomgit::fixissue::Prepared::Run {
prompt,
issue_title,
issue_number,
issue_ref,
}) => {
renderer.render(UiLine::CommandOutput(format!(
" [fixissue] issue #{}: {}\n Handing off to agent... (will post summary + 'fixed' label on completion)\n",
issue_number, issue_title,
)));
renderer.flush();
*fixissue_pending = Some(issue_ref);
fixissue_buffer.clear();
ctx.agent
.cmd_tx
.send(AgentCommand::SendMessage { text: prompt, images: vec![], image_markers: vec![] })
.ok();
state.on_submit();
}
Ok(atomcode_core::atomgit::fixissue::Prepared::Skip { reason }) => {
renderer.render(UiLine::CommandOutput(format!(" {}\n", reason)));
renderer.flush();
}
Err(e) => {
renderer.render(UiLine::CommandOutput(format!(
" fixissue failed: {:#}\n",
e
)));
renderer.flush();
}
}
}
pub(crate) fn apply_cd(ctx: &mut LoopCtx, path: PathBuf) {
ctx.agent
.cmd_tx
.send(AgentCommand::ChangeDir(path.to_string_lossy().to_string()))
.ok();
ctx.previous_dir = Some(std::mem::replace(&mut ctx.working_dir, path.clone()));
ctx.runtime_factory.set_working_dir(path.clone());
push_recent_dir(&mut ctx.recent_dirs, path);
save_recent_dirs(&ctx.recent_dirs);
}
pub(crate) fn push_recent_dir(dirs: &mut Vec<PathBuf>, new: PathBuf) {
dirs.retain(|d| d != &new);
dirs.insert(0, new);
dirs.truncate(MAX_RECENT_DIRS);
}
pub(crate) fn load_recent_dirs() -> Vec<PathBuf> {
let path = atomcode_core::config::Config::config_dir().join("recent_dirs.txt");
std::fs::read_to_string(&path)
.ok()
.map(|s| {
s.lines()
.filter(|l| !l.trim().is_empty())
.map(PathBuf::from)
.filter(|p| p.is_dir())
.take(MAX_RECENT_DIRS)
.collect()
})
.unwrap_or_default()
}
pub(crate) fn save_recent_dirs(dirs: &[PathBuf]) {
let path = atomcode_core::config::Config::config_dir().join("recent_dirs.txt");
let content = dirs
.iter()
.map(|d| d.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("\n");
let _ = std::fs::write(&path, content);
}
fn resolve_cd(
arg: &str,
cwd: &std::path::Path,
prev: Option<&std::path::Path>,
) -> std::result::Result<PathBuf, String> {
let home = crate::platform::home_dir();
let target = if arg.is_empty() {
home.ok_or_else(|| "home directory not known".to_string())?
} else if arg == "-" {
prev.map(|p| p.to_path_buf())
.ok_or_else(|| "No previous directory".to_string())?
} else if let Some(rest) = arg.strip_prefix('~') {
let home = home.ok_or_else(|| "home directory not known".to_string())?;
let rest = rest.strip_prefix('/').unwrap_or(rest);
if rest.is_empty() {
home
} else {
home.join(rest)
}
} else {
let p = PathBuf::from(arg);
if p.is_absolute() {
p
} else {
cwd.join(p)
}
};
let canon = target
.canonicalize()
.map_err(|e| format!("{}: {}", target.display(), e))?;
if !canon.is_dir() {
return Err(t(Msg::DirNotADirectory { path: &canon.display().to_string() }).into_owned());
}
Ok(canon)
}
fn compose_login_chrome(url: &str, unicode: bool) -> String {
compose_login_chrome_inner(url, unicode, cfg!(target_env = "ohos"))
}
fn compose_login_chrome_inner(url: &str, unicode: bool, omit_url: bool) -> String {
let qr_block = pick_qr_style(unicode).and_then(|style| {
let s = crate::render::qr::render_login_qr(url, style)?;
let cols = crate::render::qr::block_cols(&s);
let term_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
if (cols as u16).saturating_add(4) <= term_cols {
Some(
s.lines()
.map(|l| format!(" {}", l))
.collect::<Vec<_>>()
.join("\n"),
)
} else {
None
}
});
let mut out = String::new();
if let Some(block) = qr_block {
out.push_str(&t(Msg::LoginQrHeader));
out.push_str(&block);
if !omit_url {
out.push_str(&t(Msg::LoginUrlAfterQr));
out.push_str(url);
}
} else if omit_url {
out.push_str(&t(Msg::LoginNoQrNoUrl));
} else {
out.push_str(&t(Msg::LoginUrlOnly));
out.push_str(url);
}
out.push_str(&t(Msg::LoginCancelHint));
out
}
fn pick_qr_style(unicode: bool) -> Option<crate::render::qr::QrStyle> {
let env_flag = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()).is_some();
let is_jediterm = std::env::var("TERMINAL_EMULATOR")
.map(|v| v == "JetBrains-JediTerm")
.unwrap_or(false);
decide_qr_style(
unicode,
env_flag("ATOMCODE_QR_DENSE"),
env_flag("ATOMCODE_QR_BRAILLE"),
is_jediterm,
)
}
fn decide_qr_style(
unicode: bool,
force_dense: bool,
force_braille: bool,
is_jediterm: bool,
) -> Option<crate::render::qr::QrStyle> {
use crate::render::qr::QrStyle;
if !unicode {
return None;
}
if force_dense {
return Some(QrStyle::Dense1x2);
}
if force_braille {
return Some(QrStyle::Braille);
}
if is_jediterm {
return None;
}
Some(QrStyle::Dense1x2)
}
#[cfg(test)]
mod qr_style_tests {
use super::*;
use crate::render::qr::QrStyle;
#[test]
fn no_unicode_means_no_qr() {
assert_eq!(decide_qr_style(false, false, false, false), None);
assert_eq!(decide_qr_style(false, true, false, false), None);
assert_eq!(decide_qr_style(false, false, true, false), None);
}
#[test]
fn jediterm_default_skips_qr() {
assert_eq!(decide_qr_style(true, false, false, true), None);
}
#[test]
fn jediterm_with_braille_override_renders_braille() {
assert_eq!(
decide_qr_style(true, false, true, true),
Some(QrStyle::Braille)
);
}
#[test]
fn jediterm_with_dense_override_renders_dense() {
assert_eq!(
decide_qr_style(true, true, false, true),
Some(QrStyle::Dense1x2)
);
}
#[test]
fn dense_override_wins_over_braille_override() {
assert_eq!(
decide_qr_style(true, true, true, false),
Some(QrStyle::Dense1x2)
);
}
#[test]
fn braille_override_picks_braille_outside_jediterm() {
assert_eq!(
decide_qr_style(true, false, true, false),
Some(QrStyle::Braille)
);
}
#[test]
fn default_is_dense1x2() {
assert_eq!(
decide_qr_style(true, false, false, false),
Some(QrStyle::Dense1x2)
);
}
}
#[cfg(test)]
mod compose_login_chrome_tests {
use super::*;
const URL: &str = "https://acs.atomgit.com/login?client_id=test";
#[test]
fn omit_url_false_keeps_url_block_alongside_qr() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let s = compose_login_chrome_inner(URL, true, false);
assert!(s.contains("scan the QR code"), "QR header missing:\n{s}");
assert!(
s.contains("OR open the URL below"),
"URL fallback header missing on non-OH build:\n{s}"
);
assert!(s.contains(URL), "URL itself missing on non-OH build:\n{s}");
}
#[test]
fn omit_url_true_drops_url_block_when_qr_present() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let s = compose_login_chrome_inner(URL, true, true);
assert!(s.contains("scan the QR code"), "QR header missing:\n{s}");
assert!(
!s.contains("OR open the URL below"),
"URL fallback header must NOT appear when omit_url:\n{s}"
);
assert!(
!s.contains(URL),
"URL itself must NOT appear when omit_url:\n{s}"
);
}
#[test]
fn omit_url_true_without_qr_explains_dead_end() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let s = compose_login_chrome_inner(URL, false, true);
assert!(
!s.contains(URL),
"URL must not appear when omit_url:\n{s}"
);
assert!(
s.contains("Unicode-capable terminal"),
"must guide the user to a unicode terminal:\n{s}"
);
}
#[test]
fn omit_url_false_without_qr_shows_url_fallback() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let s = compose_login_chrome_inner(URL, false, false);
assert!(
s.contains("Open this URL in any browser"),
"URL fallback header missing on non-OH terminal-without-unicode:\n{s}"
);
assert!(s.contains(URL));
}
}
fn run_oauth_with_renderer(
renderer: &mut dyn Renderer,
ctx: &mut LoopCtx,
) -> Result<atomcode_core::auth::AuthInfo> {
use crossterm::event::KeyCode;
use std::time::{Duration, Instant};
use tokio::sync::mpsc::error::TryRecvError;
let session = atomcode_core::auth::start_login()?;
renderer.render(UiLine::CommandOutput(compose_login_chrome(
session.url(),
ctx.caps.unicode_symbols,
)));
renderer.flush();
session.open_browser_best_effort();
loop {
match session.poll_once()? {
atomcode_core::auth::PollOutcome::Authorized => break,
atomcode_core::auth::PollOutcome::Pending => {}
}
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if Instant::now() >= deadline {
break;
}
match ctx.input_rx.try_recv() {
Ok(crate::input::InputEvent::Key(k)) if k.code == KeyCode::Esc => {
anyhow::bail!("login cancelled by user");
}
Ok(_) => {
continue;
}
Err(TryRecvError::Empty) => {
std::thread::sleep(Duration::from_millis(50));
}
Err(TryRecvError::Disconnected) => {
anyhow::bail!("input channel closed");
}
}
}
}
session.finish(Some(&ctx.telemetry))
}
pub(crate) fn run_login_flow(renderer: &mut dyn Renderer, ctx: &mut LoopCtx) -> Result<()> {
let result = run_oauth_with_renderer(renderer, ctx)
.and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|()| auth));
match result {
Ok(auth) => {
let name = auth
.user
.name
.as_deref()
.unwrap_or(&auth.user.username)
.to_string();
let had_provider = !ctx.config.providers.is_empty()
&& ctx
.config
.providers
.contains_key(&ctx.config.default_provider);
if !had_provider {
let provider_name = "AtomGit".to_string();
let provider = build_oauth_provider();
ctx.model_name = provider.model.clone();
ctx.config.providers.insert(provider_name.clone(), provider);
ctx.config.default_provider = provider_name;
save_and_reload(ctx, renderer);
} else {
if let Some(provider) = ctx.config.providers.get(&ctx.config.default_provider) {
ctx.model_name = provider.model.clone();
}
let _ = ctx
.agent
.cmd_tx
.send(AgentCommand::ReloadConfig(ctx.config.clone()));
}
renderer.render(UiLine::CommandOutput(
t(Msg::LoginSignedInWithCpHint {
name: &name,
username: &auth.user.username,
}).into_owned(),
));
renderer.flush();
}
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::CmdLoginFailed { error: &e.to_string() }).into_owned(),
));
renderer.flush();
}
}
Ok(())
}
pub(crate) fn run_codingplan_flow(renderer: &mut dyn Renderer, ctx: &mut LoopCtx) -> Result<()> {
if !atomcode_core::auth::is_logged_in() {
if let Err(e) = run_oauth_with_renderer(renderer, ctx)
.and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|_| auth))
{
renderer.render(UiLine::Error(
t(Msg::CodingPlanSetupFailed { error: &e.to_string() }).into_owned(),
));
renderer.flush();
return Ok(());
}
}
let mut report = atomcode_core::coding_plan::run(&mut ctx.config, Some(&ctx.telemetry));
if matches!(&report, Ok(r) if r.auth_expired) {
renderer.render(UiLine::CommandOutput(
t(Msg::CpReauthAfter401).into_owned(),
));
renderer.flush();
match run_oauth_with_renderer(renderer, ctx)
.and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|_| auth))
{
Ok(_) => {
report = atomcode_core::coding_plan::run(&mut ctx.config, Some(&ctx.telemetry));
}
Err(e) => {
if let Ok(r) = &report {
renderer.render(UiLine::CommandOutput(r.render()));
}
renderer.render(UiLine::Error(
t(Msg::CodingPlanSetupFailed { error: &e.to_string() }).into_owned(),
));
renderer.flush();
return Ok(());
}
}
}
match report {
Ok(report) => {
if report.should_persist_config() {
save_and_reload(ctx, renderer);
let _ = atomcode_core::coding_plan::write_last_sync_now();
ctx.monitor_last_sync_seen = atomcode_core::coding_plan::read_last_sync();
if let Some(p) = ctx.config.providers.get(&ctx.config.default_provider) {
ctx.model_name = p.model.clone();
}
if let Ok(mut g) = ctx.monitor_warning.lock() {
*g = None;
}
ctx.monitor_last_check_at = None;
if let Ok(mut g) = ctx.usage_slot.lock() {
*g = None;
}
ctx.usage_last_check_at = None;
}
renderer.render(UiLine::CommandOutput(report.render()));
renderer.flush();
}
Err(e) => {
renderer.render(UiLine::Error(
t(Msg::CodingPlanSetupFailed { error: &format!("{:#}", e) }).into_owned(),
));
renderer.flush();
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_dirs() -> (tempfile::TempDir, PathBuf, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let cwd = tmp.path().canonicalize().expect("canon cwd");
let sub = cwd.join("sub");
std::fs::create_dir(&sub).expect("mkdir sub");
let sub = sub.canonicalize().expect("canon sub");
(tmp, cwd, sub)
}
#[test]
fn relative_path_resolves_against_cwd() {
let (_tmp, cwd, sub) = make_dirs();
let got = resolve_cd("sub", &cwd, None).expect("relative resolves");
assert_eq!(got, sub);
}
#[test]
fn absolute_path_ignores_cwd() {
let (_tmp, _cwd, sub) = make_dirs();
let alt_cwd = PathBuf::from("/"); let got = resolve_cd(sub.to_str().unwrap(), &alt_cwd, None).expect("absolute resolves");
assert_eq!(got, sub);
}
#[test]
fn dash_uses_previous_dir() {
let (_tmp, cwd, sub) = make_dirs();
let got = resolve_cd("-", &sub, Some(&cwd)).expect("dash uses prev");
assert_eq!(got, cwd);
}
#[test]
fn dash_without_previous_errors() {
let (_tmp, cwd, _sub) = make_dirs();
let err = resolve_cd("-", &cwd, None).expect_err("dash w/o prev");
assert!(err.contains("No previous directory"), "got: {}", err);
}
#[test]
fn nonexistent_path_errors() {
let (_tmp, cwd, _sub) = make_dirs();
let err = resolve_cd("nope-does-not-exist", &cwd, None).expect_err("nonexistent errors");
assert!(err.contains("nope-does-not-exist"), "got: {}", err);
}
#[test]
fn file_path_rejected_with_not_a_directory() {
let (_tmp, cwd, _sub) = make_dirs();
let file = cwd.join("a.txt");
std::fs::write(&file, "hi").expect("write");
let err = resolve_cd(file.to_str().unwrap(), &cwd, None).expect_err("file is not a dir");
assert!(err.contains("Not a directory"), "got: {}", err);
}
#[test]
fn tilde_expands_to_home() {
let Some(home) = crate::platform::home_dir() else {
return;
};
let Ok(canon_home) = home.canonicalize() else {
return;
};
let (_tmp, cwd, _sub) = make_dirs();
let got = resolve_cd("~", &cwd, None).expect("~ resolves");
assert_eq!(got, canon_home);
}
#[test]
fn paths_same_accepts_canonical_equivalents() {
let (_tmp, cwd, sub) = make_dirs();
let via_parent = sub.join("..").join("sub");
assert!(paths_same(&sub, &via_parent));
assert!(!paths_same(&cwd, &sub));
}
#[test]
fn context_report_without_snapshot_prompts_to_run_turn() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let out = format_context_report(None, "claude-opus-4-7", false);
assert!(out.contains("run at least one turn"));
assert!(!out.contains("tokens ("));
}
#[test]
fn context_report_with_zero_window_flags_partial_stats() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let snap = crate::state::ContextSnapshot {
system_tokens: 100,
sent_tokens: 200,
tool_defs_tokens: 0,
cold_zone_tokens: 0,
total_messages: 5,
ctx_window: 0,
ctx_name: String::new(),
system_prompt: String::new(),
};
let out = format_context_report(Some(&snap), "test-model", false);
assert!(out.contains("waiting for first complete turn"));
}
#[test]
fn context_report_renders_full_breakdown() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let snap = crate::state::ContextSnapshot {
system_tokens: 8_000,
sent_tokens: 30_000, tool_defs_tokens: 14_500,
cold_zone_tokens: 2_000,
total_messages: 42,
ctx_window: 128_000,
ctx_name: "default".into(),
system_prompt: String::new(),
};
let out = format_context_report(Some(&snap), "claude-opus-4-7", false);
assert!(out.contains("Context Usage"));
assert!(out.contains("▒") || out.contains("█"));
assert!(out.contains("System prompt"));
assert!(out.contains("Tool defs"));
assert!(out.contains("Cold zone"));
assert!(out.contains("Messages"));
assert!(out.contains("Free"));
assert!(out.contains("8.0K")); assert!(out.contains("14.5K")); assert!(out.contains("2.0K")); assert!(out.contains("128.0K")); assert!(out.contains("42"));
assert!(out.contains("default"));
assert!(out.contains("claude-opus-4-7"));
}
#[test]
fn context_report_messages_excludes_cold_zone() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let snap = crate::state::ContextSnapshot {
system_tokens: 1_000,
sent_tokens: 10_000,
tool_defs_tokens: 0,
cold_zone_tokens: 3_000,
total_messages: 10,
ctx_window: 100_000,
ctx_name: "default".into(),
system_prompt: String::new(),
};
let out = format_context_report(Some(&snap), "m", false);
let messages_line = out
.lines()
.find(|l| l.contains("Messages"))
.expect("messages line must exist");
assert!(
messages_line.contains("7.0K"),
"expected Messages=7.0K (sent-cold), got line: {}",
messages_line
);
}
#[test]
fn context_report_free_is_nonneg_under_rounding() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let snap = crate::state::ContextSnapshot {
system_tokens: 20_000,
sent_tokens: 80_000,
tool_defs_tokens: 20_000,
cold_zone_tokens: 0,
total_messages: 50,
ctx_window: 120_000,
ctx_name: "default".into(),
system_prompt: String::new(),
};
let out = format_context_report(Some(&snap), "m", false);
assert!(out.contains("Free"));
let free_line = out
.lines()
.find(|l| l.contains("Free"))
.expect("free line must exist");
assert!(free_line.contains("0"), "free line: {}", free_line);
}
#[test]
fn context_report_without_show_prompt_omits_system_prompt_section() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let snap = crate::state::ContextSnapshot {
system_tokens: 1_000,
sent_tokens: 5_000,
tool_defs_tokens: 500,
cold_zone_tokens: 0,
total_messages: 8,
ctx_window: 100_000,
ctx_name: "default".into(),
system_prompt: "You are AtomCode.\nSOME SENTINEL BYTES".into(),
};
let out = format_context_report(Some(&snap), "m", false);
assert!(
!out.contains("SYSTEM PROMPT"),
"SYSTEM PROMPT header must not appear in default /context output"
);
assert!(
!out.contains("SOME SENTINEL BYTES"),
"raw prompt body must not leak into default /context output"
);
}
#[test]
fn context_report_with_show_prompt_appends_cached_prompt() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let snap = crate::state::ContextSnapshot {
system_tokens: 1_000,
sent_tokens: 5_000,
tool_defs_tokens: 500,
cold_zone_tokens: 0,
total_messages: 8,
ctx_window: 100_000,
ctx_name: "default".into(),
system_prompt: "You are AtomCode.\nRULE_LINE_ABC\nEND".into(),
};
let out = format_context_report(Some(&snap), "m", true);
assert!(out.contains("=== SYSTEM PROMPT ==="));
assert!(
out.contains(" RULE_LINE_ABC"),
"prompt lines should keep content after 2-space indent"
);
assert!(out.contains("Context Usage"));
assert!(out.contains("System prompt"));
}
#[test]
fn context_report_show_prompt_with_empty_cached_prompt_shows_hint() {
let snap = crate::state::ContextSnapshot {
system_tokens: 100,
sent_tokens: 200,
tool_defs_tokens: 0,
cold_zone_tokens: 0,
total_messages: 3,
ctx_window: 100_000,
ctx_name: "default".into(),
system_prompt: String::new(),
};
let out = format_context_report(Some(&snap), "m", true);
assert!(out.contains("=== SYSTEM PROMPT ==="));
assert!(
out.contains("(empty"),
"empty cached prompt must show an explanation, got: {}",
out
);
}
}