1use std::path::PathBuf;
16
17use super::{bg_runtime, save_and_reload, LoopCtx};
18use crate::i18n::{t, Msg};
19use crate::modals::{DirPicker, IssueWizard, LanguagePicker, Modal, ModelPicker, ProviderWizard, SessionPicker};
20use crate::render::{Renderer, UiLine};
21use crate::state::{AgentMode, UiState};
22use anyhow::Result;
23use atomcode_core::agent::AgentCommand;
24use atomcode_core::config::provider::ProviderConfig;
25use atomcode_core::config::Config;
26use atomcode_core::conversation::Conversation;
27use atomcode_core::session::{Session, SessionId, SessionManager};
28
29const MAX_RECENT_DIRS: usize = 5;
31
32fn build_oauth_provider() -> ProviderConfig {
33 ProviderConfig {
43 provider_type: "openai".to_string(),
44 api_key: None,
45 model: "MiniMax-M2.7".to_string(),
46 base_url: Some("https://llm-api.atomgit.com/v1".to_string()),
47 system_prompt: None,
48 user_agent: None,
49 context_window: 64_000,
50 max_tokens: None,
51 thinking_type: None,
52 thinking_keep: None,
53 reasoning_history: None,
54 thinking_enabled: None,
55 thinking_budget: None,
56 skip_tls_verify: false,
57 ephemeral: false,
58
59}
60}
61
62fn foreground_state_from_ui(state: &UiState) -> bg_runtime::RuntimeState {
63 if matches!(
64 state.phase,
65 crate::state::UiPhase::Streaming | crate::state::UiPhase::Approval
66 ) {
67 bg_runtime::RuntimeState::Running
68 } else {
69 bg_runtime::RuntimeState::Idle
70 }
71}
72
73fn render_welcome(renderer: &mut dyn Renderer, ctx: &LoopCtx) {
74 let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
75 renderer.render(UiLine::Welcome {
76 model: ctx.model_name.clone(),
77 working_dir: dir_display,
78 });
79}
80
81fn bind_telemetry_to_session(ctx: &LoopCtx, session: &Session) {
82 if let Ok(uuid) = uuid::Uuid::parse_str(session.id.as_str()) {
83 ctx.telemetry.set_session_id(uuid);
84 }
85}
86
87fn find_pending_approval(session: &Session) -> Option<(String, String)> {
92 use atomcode_core::conversation::message::{MessageContent, Role};
93 use crate::event_loop::format_tool_detail;
94
95 let mut answered_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
97 for m in &session.messages {
98 if let (Role::Tool, MessageContent::ToolResult(r)) = (&m.role, &m.content) {
99 answered_ids.insert(r.call_id.clone());
100 }
101 }
102
103 for m in session.messages.iter().rev() {
105 if let (
106 Role::Assistant,
107 MessageContent::AssistantWithToolCalls { tool_calls, .. },
108 ) = (&m.role, &m.content)
109 {
110 for tc in tool_calls.iter().rev() {
111 if !answered_ids.contains(&tc.id) {
112 let display = super::display_tool_name(&tc.name);
113 let detail = format_tool_detail(&tc.name, &tc.arguments);
114 return Some((display, detail));
115 }
116 }
117 }
118 }
119 None
120}
121
122fn short_task_name(task: &str) -> String {
123 let first_line = task.lines().next().unwrap_or(task).trim();
124 let mut out: String = first_line.chars().take(80).collect();
125 if out.is_empty() {
126 out = "background task".to_string();
127 }
128 out
129}
130
131fn spawn_runtime(
132 ctx: &mut LoopCtx,
133 session: Session,
134) -> (
135 bg_runtime::RuntimeId,
136 atomcode_core::agent::AgentClient,
137 Session,
138) {
139 let runtime_id = ctx.bg_manager.allocate_runtime_id();
140 let (client, event_rx) = ctx.runtime_factory.spawn_runtime(Conversation::new());
141 bg_runtime::spawn_event_forwarder(runtime_id, event_rx, ctx.runtime_event_tx.clone());
142 (runtime_id, client, session)
143}
144
145fn sync_bg_foreground(ctx: &mut LoopCtx) {
154 ctx.bg_manager.set_foreground_runtime(
155 ctx.foreground_runtime_id,
156 ctx.agent.clone(),
157 ctx.current_session.clone(),
158 );
159}
160
161pub const MAX_SESSION_NAME_LEN: usize = 100;
168
169pub fn validate_session_name(name: &str) -> Option<String> {
172 let trimmed = name.trim();
173 if trimmed.is_empty() {
174 return Some(t(Msg::SessionNameEmpty).into_owned());
175 }
176 if trimmed.chars().count() > MAX_SESSION_NAME_LEN {
177 return Some(t(Msg::SessionNameTooLong { max: MAX_SESSION_NAME_LEN }).into_owned());
178 }
179 if trimmed.chars().any(char::is_control) {
180 return Some(t(Msg::SessionNameControlChars).into_owned());
181 }
182 None
183}
184
185pub fn perform_session_rename(
187 session_manager: &SessionManager,
188 session_id: &SessionId,
189 new_name: &str,
190) -> Result<(String, String), String> {
191 if let Some(err) = validate_session_name(new_name) {
192 return Err(err);
193 }
194 let new_name = new_name.trim().to_string();
195 let session = session_manager
196 .load(session_id)
197 .map_err(|e| t(Msg::SessionLoadFailed { error: &e.to_string() }).into_owned())?;
198 let old_name = session.name.clone();
199 let renamed_session = atomcode_core::session::Session {
200 name: new_name.clone(),
201 updated_at: std::time::SystemTime::now()
202 .duration_since(std::time::UNIX_EPOCH)
203 .map(|d| d.as_secs())
204 .unwrap_or(session.updated_at),
205 user_renamed: true,
206 ..session
207 };
208 session_manager
209 .save(&renamed_session)
210 .map_err(|e| t(Msg::SessionSaveFailed { error: &e.to_string() }).into_owned())?;
211 Ok((old_name, new_name))
212}
213
214fn render_instruction_status_block(working_dir: &std::path::Path) -> String {
219 use atomcode_core::config::instructions::LayeredInstructions;
220 let instructions = LayeredInstructions::load(working_dir);
221 let mut out = t(Msg::StatusInstructionFilesHeader).into_owned();
222 for (level, path) in instructions.status_lines() {
223 match path {
224 Some(p) => out.push_str(&t(Msg::StatusInstructionPresent {
225 path: &p.display().to_string(),
226 label: level.label(),
227 })),
228 None => out.push_str(&t(Msg::StatusInstructionMissing { label: level.label() })),
229 }
230 }
231 out
232}
233
234pub(super) fn execute_slash_command(
235 cmd: &str,
236 arg: &str,
237 state: &mut UiState,
238 ctx: &mut LoopCtx,
239 renderer: &mut dyn Renderer,
240 active_modal: &mut Option<Box<dyn Modal>>,
241 fixissue_pending: &mut Option<atomcode_core::atomgit::IssueRef>,
242 fixissue_buffer: &mut String,
243) -> Result<()> {
244 let _ = (&fixissue_pending, &fixissue_buffer);
252
253 let cmd_lower = cmd.to_ascii_lowercase();
258 let cmd = cmd_lower.as_str();
259
260 {
263 use atomcode_telemetry::Event;
264 let cmd_name = cmd.trim_start_matches('/').to_string();
265 ctx.telemetry.track(Event::UseCommand { type_: cmd_name, success: Some(true), error_kind: None, error_data: None });
266 }
267
268 match cmd {
269 "quit" | "exit" => {
270 ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
271 }
272 "help" => {
273 if arg.trim() == "commands" {
274 let config_dir = Config::config_dir();
275 let cmds = ctx.custom_commands.list();
276 let mut out = t(Msg::HelpCustomCommandsHeader).into_owned();
277 for cmd in &cmds {
278 let source_label = if cmd.source.starts_with(&config_dir) {
279 t(Msg::HelpSourceGlobal)
280 } else {
281 t(Msg::HelpSourceProject)
282 };
283 out.push_str(&format!(
284 " /{} — {} ({})\n",
285 cmd.name, cmd.description, source_label
286 ));
287 }
288 if cmds.is_empty() {
289 out.push_str(&t(Msg::HelpCustomNone));
290 out.push_str(&t(Msg::HelpCustomCreateHint));
291 }
292 renderer.render(UiLine::CommandOutput(out));
293 } else {
294 renderer.render(UiLine::CommandOutput(ctx.commands.help_text()));
295 }
296 renderer.flush();
297 }
298 "keys" => {
299 renderer.render(UiLine::CommandOutput(
304 t(Msg::KeybindingsHelp).into_owned(),
305 ));
306 renderer.flush();
307 }
308 "plan" => {
309 state.agent_mode = AgentMode::Plan;
310 ctx.agent.cmd_tx.send(AgentCommand::SetPlanMode(true)).ok();
311 renderer.render(UiLine::CommandOutput(
312 t(Msg::CmdSwitchedPlanMode).into_owned(),
313 ));
314 renderer.flush();
315 }
316 "build" => {
317 state.agent_mode = AgentMode::Build;
318 ctx.agent.cmd_tx.send(AgentCommand::SetPlanMode(false)).ok();
319 renderer.render(UiLine::CommandOutput(
320 t(Msg::CmdSwitchedBuildMode).into_owned(),
321 ));
322 renderer.flush();
323 }
324 "config" => {
325 let config_path = Config::default_path().display().to_string();
328 let mut txt = t(Msg::ConfigProviderLabel {
329 provider: &ctx.config.default_provider,
330 path: &config_path,
331 }).into_owned();
332 txt.push_str(
336 " Example:\n\
337 \n\
338 ```toml\n\
339 default_provider = \"deepseek\"\n\
340 \n\
341 [providers.deepseek]\n\
342 type = \"openai\"\n\
343 api_key = \"sk-...\"\n\
344 model = \"deepseek-chat\"\n\
345 base_url = \"https://api.deepseek.com/v1\"\n\
346 context_window = 64000\n\
347 ```\n\
348 \n\
349 Full reference: docs/config.example.toml (every field, every provider flavour).\n\
350 Edit the file, then run /reload — no restart needed.\n",
351 );
352 renderer.render(UiLine::CommandOutput(txt));
353 renderer.flush();
354 }
355 "reload" => {
356 let path = Config::default_path();
362 match Config::load(&path) {
363 Ok(new_cfg) => {
364 let new_default = new_cfg.default_provider.clone();
365 let new_model = new_cfg
366 .providers
367 .get(&new_default)
368 .map(|p| p.model.clone())
369 .unwrap_or_else(|| new_default.clone());
370 ctx.config = new_cfg.clone();
371 ctx.runtime_factory.set_config(new_cfg.clone());
372 ctx.model_name = new_model.clone();
373 ctx.agent
374 .cmd_tx
375 .send(AgentCommand::ReloadConfig(new_cfg))
376 .ok();
377 renderer.render(UiLine::CommandOutput(
378 t(Msg::CmdReloadDone {
379 provider: &new_default, model: &new_model,
380 }).into_owned(),
381 ));
382 }
383 Err(e) => {
384 let msg = format!("{}", e);
385 renderer.render(UiLine::Error(
386 t(Msg::CmdReloadFailed { error: &msg }).into_owned(),
387 ));
388 }
389 }
390 renderer.flush();
391 }
392 "clear" => {
393 renderer.clear_screen();
398 let dir_display = ctx.working_dir.to_string_lossy().to_string();
399 renderer.render(UiLine::Welcome {
400 model: ctx.model_name.clone(),
401 working_dir: dir_display,
402 });
403 renderer.flush();
404 }
405 "session" => {
406 ctx.agent.cmd_tx.send(AgentCommand::ClearConversation).ok();
411 ctx.current_session_id = None;
412 state.total_tokens = 0;
413 state.prompt_tokens = 0;
414 state.completion_tokens = 0;
415 state.cached_tokens = 0;
416 state.last_context = None;
417 state.pending_context_render = None;
418 state.thinking_idx = 0;
419 state.on_turn_complete();
420 ctx.current_session =
424 atomcode_core::session::Session::default_session(ctx.working_dir.clone());
425 ctx.bg_manager
426 .set_foreground_session(ctx.current_session.clone());
427 if let Ok(uuid) = uuid::Uuid::parse_str(ctx.current_session.id.as_str()) {
429 ctx.telemetry.set_session_id(uuid);
430 }
431 renderer.reset();
436 let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
437 renderer.render(UiLine::Welcome {
438 model: ctx.model_name.clone(),
439 working_dir: dir_display,
440 });
441 renderer.render(UiLine::CommandOutput(
442 t(Msg::CmdNewSession).into_owned(),
443 ));
444 renderer.flush();
445 }
446 "model" => {
447 if ctx.config.providers.is_empty() {
448 renderer.render(UiLine::CommandOutput(
449 t(Msg::CmdNoProviders).into_owned(),
450 ));
451 renderer.flush();
452 } else {
453 *active_modal = Some(Box::new(ModelPicker::open(&ctx.config)));
454 }
455 }
456 "language" => {
457 if arg.is_empty() {
458 *active_modal = Some(Box::new(LanguagePicker::open()));
459 } else {
460 match arg.parse::<atomcode_core::locale::Locale>() {
461 Ok(locale) => {
462 crate::i18n::set_locale(locale);
463 ctx.config.language = Some(locale);
464 let config_path = atomcode_core::config::Config::default_path();
465 if let Err(e) = ctx.config.save(&config_path) {
466 eprintln!("[language] failed to save config: {e}");
468 }
469 let label = match locale {
473 atomcode_core::locale::Locale::En => "English",
474 atomcode_core::locale::Locale::ZhCn => "简体中文",
475 };
476 renderer.render(UiLine::CommandOutput(
477 t(Msg::LanguageSwitched {
478 label,
479 locale: &locale.to_string(),
480 })
481 .into_owned(),
482 ));
483 renderer.flush();
484 }
485 Err(_) => {
486 let msg = t(Msg::ErrUnsupportedLocale { input: arg });
487 renderer.render(UiLine::CommandOutput(format!(" {msg}\n")));
488 renderer.flush();
489 }
490 }
491 }
492 }
493 "resume" => match ctx.session_manager.list() {
494 Ok(all) => {
495 let sessions: Vec<_> = all.into_iter().filter(|s| s.message_count > 0).collect();
496 if sessions.is_empty() {
497 renderer.render(UiLine::CommandOutput(
498 t(Msg::CmdNoSessions).into_owned(),
499 ));
500 renderer.flush();
501 } else {
502 *active_modal = Some(Box::new(SessionPicker::open(sessions)));
503 }
504 }
505 Err(e) => {
506 renderer.render(UiLine::Error(
507 t(Msg::SessionListFailed { error: &e.to_string() }).into_owned(),
508 ));
509 renderer.flush();
510 }
511 },
512 "rename" => {
513 if let Some(err) = validate_session_name(arg) {
519 renderer.render(UiLine::Error(err));
520 renderer.flush();
521 } else {
522 let old_name = ctx.current_session.name.clone();
523 let new_name = arg.trim().to_string();
524 ctx.current_session.rename(new_name.clone());
525 match ctx.session_manager.save(&ctx.current_session) {
526 Ok(()) => {
527 renderer.render(UiLine::CommandOutput(
528 t(Msg::SessionRenamed { old: &old_name, new: &new_name })
529 .into_owned(),
530 ));
531 renderer.flush();
532 }
533 Err(e) => {
534 ctx.current_session.name = old_name;
537 renderer.render(UiLine::Error(
538 t(Msg::SessionSaveFailed { error: &e.to_string() })
539 .into_owned(),
540 ));
541 renderer.flush();
542 }
543 }
544 }
545 }
546 "provider" => {
547 *active_modal = Some(Box::new(ProviderWizard::MainMenu { selected: 0 }));
548 renderer.render(UiLine::CommandOutput(
549 t(Msg::ProviderWizardHeader).into_owned(),
550 ));
551 renderer.flush();
552 }
553 "status" => {
554 let mut txt = t(Msg::StatusBody {
555 model: &ctx.model_name,
556 dir: &ctx.working_dir.display().to_string(),
557 config: &Config::default_path().display().to_string(),
558 tokens: state.total_tokens,
559 }).into_owned();
560 txt.push_str(&render_codingplan_status_for_status_cmd());
561
562 txt.push('\n');
563 txt.push_str(&render_instruction_status_block(&ctx.working_dir));
564
565 renderer.render(UiLine::CommandOutput(txt));
566 renderer.flush();
567 }
568 "diff" => {
569 let out = std::process::Command::new("git")
570 .args(["diff", "--stat"])
571 .current_dir(&ctx.working_dir)
572 .output();
573 match out {
574 Ok(o) => {
575 let s = String::from_utf8_lossy(&o.stdout).to_string();
576 renderer.render(UiLine::CommandOutput(if s.is_empty() {
577 t(Msg::CmdNoChanges).into_owned()
578 } else {
579 s
580 }));
581 }
582 Err(e) => {
583 renderer.render(UiLine::Error(t(Msg::DiffFailed { error: &format!("{}", e) }).into_owned()));
584 }
585 }
586 renderer.flush();
587 }
588 "undo" => {
589 renderer.render(UiLine::CommandOutput(
590 t(Msg::CmdUndoNotSupported).into_owned(),
591 ));
592 renderer.flush();
593 }
594 "cost" => {
595 let total = state.prompt_tokens + state.completion_tokens;
596 let cache_rate = if state.prompt_tokens > 0 {
597 ((state.cached_tokens as f64 / state.prompt_tokens as f64 * 100.0) + 0.5) as usize
598 } else {
599 0
600 };
601 let cost = atomcode_core::pricing::calculate_cost(
602 &ctx.model_name,
603 state.prompt_tokens,
604 state.completion_tokens,
605 state.cached_tokens,
606 );
607 let cost_str = atomcode_core::pricing::format_cost(cost);
608 renderer.render(UiLine::CommandOutput(
609 t(Msg::CostReport {
610 prompt: state.prompt_tokens,
611 completion: state.completion_tokens,
612 cached: state.cached_tokens,
613 cache_rate,
614 total,
615 cost: &cost_str,
616 }).into_owned(),
617 ));
618 renderer.flush();
619 }
620 "context" => {
621 let show_prompt = arg.trim().eq_ignore_ascii_case("prompt");
638 state.pending_context_render = Some(show_prompt);
639 ctx.agent
640 .cmd_tx
641 .send(AgentCommand::RefreshContextStats)
642 .ok();
643 }
644 "compact" => {
645 let prompt = (!arg.trim().is_empty()).then(|| arg.trim().to_string());
646 ctx.agent.cmd_tx.send(AgentCommand::Compact { prompt }).ok();
651 }
652 "remember" => {
653 let text = arg.trim();
654 if text.is_empty() {
655 renderer.render(UiLine::Error(t(Msg::RememberUsage).into_owned()));
656
657 renderer.flush();
658 } else {
659 let (content, global) = if text.starts_with("--global ") {
660 (text[9..].trim().to_string(), true)
661 } else {
662 (text.to_string(), false)
663 };
664 if content.is_empty() {
665 renderer.render(UiLine::Error(t(Msg::RememberUsage).into_owned()));
666
667 renderer.flush();
668 } else {
669 ctx.agent
670 .cmd_tx
671 .send(AgentCommand::Remember { content, global })
672 .ok();
673 }
674 }
675 }
676 "forget" => {
677 let keyword = arg.trim();
678 if keyword.is_empty() {
679 renderer.render(UiLine::Error(t(Msg::ForgetUsage).into_owned()));
680 renderer.flush();
681 } else {
682 ctx.agent
683 .cmd_tx
684 .send(AgentCommand::Forget {
685 keyword: keyword.to_string(),
686 })
687 .ok();
688 }
689 }
690 "memory" => {
691 ctx.agent.cmd_tx.send(AgentCommand::ShowMemory).ok();
692 }
693 "login" => {
694 run_login_flow(renderer, ctx)?;
695 }
696 "codingplan" => {
697 run_codingplan_flow(renderer, ctx)?;
698 }
699 "logout" => {
700 match atomcode_core::auth::logout() {
707 Ok(()) => {
708 ctx.telemetry.set_account_id(None);
709 let _ = ctx
710 .agent
711 .cmd_tx
712 .send(AgentCommand::ReloadConfig(ctx.config.clone()));
713 renderer.render(UiLine::CommandOutput(
714 t(Msg::CmdLogoutDone).into_owned(),
715 ));
716 }
717 Err(e) => {
718 let msg = format!("{}", e);
719 renderer.render(UiLine::Error(
720 t(Msg::CmdLogoutFailed { error: &msg }).into_owned(),
721 ));
722 }
723 }
724 renderer.flush();
725 }
726 "whoami" => {
727 let txt = if let Some(auth) = atomcode_core::auth::get_stored_auth() {
728 let email = auth.user.email.as_deref().unwrap_or("—");
729 let name = auth.user.name.as_deref().unwrap_or(&auth.user.username);
730 format!(
731 " {} ({})\n {}\n auth: {}\n",
732 name,
733 auth.user.username,
734 email,
735 atomcode_core::auth::auth_file_path().display(),
736 )
737 } else {
738 t(Msg::CmdWhoamiNotSignedIn).into_owned()
739 };
740 renderer.render(UiLine::CommandOutput(txt));
741 renderer.flush();
742 }
743 "upgrade" => {
744 let arg_norm = arg.trim().to_ascii_lowercase();
749 if arg_norm == "rollback" {
750 match atomcode_core::self_update::run_rollback() {
754 Ok(sum) => {
755 let _ = ctx.upgrade_tx.send(
758 atomcode_core::self_update::UpgradeEvent::RolledBack {
759 exe: sum.exe,
760 backup: sum.backup,
761 },
762 );
763 }
764 Err(e) => {
765 let _ =
766 ctx.upgrade_tx
767 .send(atomcode_core::self_update::UpgradeEvent::Failed(format!(
768 "{:#}",
769 e
770 )));
771 }
772 }
773 } else {
774 let force = arg_norm == "--force" || arg_norm == "-f";
775 if !force && !arg_norm.is_empty() {
776 renderer.render(UiLine::Error(
777 t(Msg::UpgradeUnknownArg { arg }).into_owned(),
778 ));
779 renderer.flush();
780 return Ok(());
781 }
782 renderer.render(UiLine::CommandOutput(
783 t(Msg::CmdCheckingUpdate).into_owned(),
784 ));
785 renderer.flush();
786 let current = format!("v{}", env!("CARGO_PKG_VERSION"));
787 let tx = ctx.upgrade_tx.clone();
788 tokio::spawn(async move {
789 if let Err(e) =
793 atomcode_core::self_update::run_upgrade(current, force, tx.clone()).await
794 {
795 let _ = tx.send(atomcode_core::self_update::UpgradeEvent::Failed(format!(
796 "{:#}",
797 e
798 )));
799 }
800 });
801 }
802 }
803 "issue" => {
804 let _ = arg; let mut wiz = IssueWizard::open(
819 atomcode_core::atomgit::UPSTREAM_OWNER.to_string(),
820 atomcode_core::atomgit::UPSTREAM_REPO.to_string(),
821 );
822 wiz.emit_prompt(renderer);
823 *active_modal = Some(Box::new(wiz));
824 }
825 "cd" => {
826 if arg.is_empty() {
830 if ctx.recent_dirs.is_empty() {
831 let cwd = ctx.working_dir.display().to_string();
832 renderer.render(UiLine::CommandOutput(
833 t(Msg::CdWorkingDir { cwd: &cwd }).into_owned(),
834 ));
835 renderer.flush();
836 } else {
837 *active_modal = Some(Box::new(DirPicker::open(
838 ctx.recent_dirs.clone(),
839 ctx.working_dir.clone(),
840 )));
841 }
842 return Ok(());
843 }
844 let new_dir = resolve_cd(arg, &ctx.working_dir, ctx.previous_dir.as_deref());
845 match new_dir {
846 Ok(path) => {
847 apply_cd(ctx, path.clone());
848 let p = path.display().to_string();
849 renderer.render(UiLine::CommandOutput(
850 t(Msg::DirChanged { path: &p }).into_owned(),
851 ));
852 }
853 Err(e) => {
854 renderer.render(UiLine::Error(e));
855 }
856 }
857 renderer.flush();
858 }
859 "bg" => {
860 match bg_runtime::parse_bg_command(arg) {
861 bg_runtime::BgCommand::Help => {
862 renderer.render(UiLine::CommandOutput(bg_runtime::render_bg_help()));
863 }
864 bg_runtime::BgCommand::List => {
865 renderer.render(UiLine::CommandOutput(bg_runtime::render_bg_list(
866 ctx.bg_manager.backgrounds(),
867 )));
868 }
869 bg_runtime::BgCommand::BackgroundCurrent => {
870 sync_bg_foreground(ctx);
871 if !ctx.bg_manager.has_capacity() {
872 renderer.render(UiLine::Error(
873 t(Msg::BgSlotLimitReached { max: bg_runtime::MAX_BACKGROUND_SLOTS }).into_owned(),
874 ));
875 renderer.flush();
876 return Ok(());
877 }
878 let old_short_id = ctx.current_session.short_id().to_string();
879 let new_session = Session::default_session(ctx.working_dir.clone());
880 let new_short_id = new_session.short_id().to_string();
881 let (runtime_id, client, new_session) = spawn_runtime(ctx, new_session);
882 let old_state = foreground_state_from_ui(state);
883 let slot = match ctx.bg_manager.background_current(
884 client.clone(),
885 new_session.clone(),
886 runtime_id,
887 old_state,
888 ) {
889 Ok(slot) => slot,
890 Err(bg_runtime::BgError::SlotLimit { max }) => {
891 renderer.render(UiLine::Error(
892 t(Msg::BgSlotLimitReached { max }).into_owned(),
893 ));
894 renderer.flush();
895 return Ok(());
896 }
897 Err(bg_runtime::BgError::InvalidSlot { .. }) => unreachable!(),
898 };
899
900 ctx.agent = client;
901 ctx.foreground_runtime_id = runtime_id;
902 ctx.current_session = new_session;
903 bind_telemetry_to_session(ctx, &ctx.current_session);
904 state.on_turn_complete();
905 renderer.reset();
906 render_welcome(renderer, ctx);
907 renderer.render(UiLine::CommandOutput(
908 t(Msg::BgBackgroundCurrent {
909 new_id: &new_short_id,
910 slot,
911 old_id: &old_short_id,
912 state: &old_state.localised(),
913 }).into_owned(),
914 ));
915 }
916 bg_runtime::BgCommand::Resume(slot) => {
917 sync_bg_foreground(ctx);
918 let outcome = match ctx
919 .bg_manager
920 .resume_slot(slot, foreground_state_from_ui(state))
921 {
922 Ok(outcome) => outcome,
923 Err(bg_runtime::BgError::InvalidSlot { slot, len }) => {
924 renderer.render(UiLine::Error(
925 t(Msg::BgInvalidSlot { slot, available: len }).into_owned(),
926 ));
927 renderer.flush();
928 return Ok(());
929 }
930 Err(bg_runtime::BgError::SlotLimit { max }) => {
931 renderer.render(UiLine::Error(
932 t(Msg::BgSlotLimitReached { max }).into_owned(),
933 ));
934 renderer.flush();
935 return Ok(());
936 }
937 };
938 let Some(client) = outcome.resumed_client else {
939 renderer.render(UiLine::Error(
940 t(Msg::BgNoRuntimeClient).into_owned(),
941 ));
942 renderer.flush();
943 return Ok(());
944 };
945
946 ctx.agent = client;
947 ctx.foreground_runtime_id = outcome.resumed_runtime_id;
948 ctx.current_session = outcome.resumed_session;
949 bind_telemetry_to_session(ctx, &ctx.current_session);
950 state.on_turn_complete();
951 crate::modals::session_picker::replay_session(
952 renderer,
953 &ctx.current_session,
954 true,
955 );
956
957 let pending_approval = find_pending_approval(&ctx.current_session);
963 if let Some((tool_name, detail)) = pending_approval {
964 renderer.render(UiLine::ApprovalPrompt { tool: tool_name, detail });
965 state.on_approval_needed("");
966 }
967
968 let short_id = ctx.current_session.short_id().to_string();
969 let mut msg = t(Msg::BgResumed { slot, short_id: &short_id }).into_owned();
970 if let Some(previous_slot) = outcome.previous_foreground_slot {
971 msg.push_str(&t(Msg::BgPreviousForegroundMoved { slot: previous_slot }).into_owned());
972 }
973 renderer.render(UiLine::CommandOutput(msg));
974 }
975 bg_runtime::BgCommand::Drop(slot) => {
976 let dropped = match ctx.bg_manager.drop_slot(slot) {
977 Ok(dropped) => dropped,
978 Err(bg_runtime::BgError::InvalidSlot { slot, len }) => {
979 renderer.render(UiLine::Error(
980 t(Msg::BgInvalidSlot { slot, available: len }).into_owned(),
981 ));
982 renderer.flush();
983 return Ok(());
984 }
985 Err(bg_runtime::BgError::SlotLimit { .. }) => unreachable!(),
986 };
987 if matches!(dropped.state, bg_runtime::RuntimeState::Running) {
988 if let Some(client) = dropped.client.as_ref() {
989 client.cmd_tx.send(AgentCommand::Cancel).ok();
990 }
991 }
992 if !dropped.session.messages.is_empty() {
993 let _ = ctx.session_manager.save(&dropped.session);
994 }
995 let short_id = dropped.session.short_id().to_string();
996 renderer.render(UiLine::CommandOutput(
997 t(Msg::BgDropped { slot, short_id: &short_id }).into_owned(),
998 ));
999 }
1000 }
1001 renderer.flush();
1002 }
1003 "background" => {
1004 let task = arg.trim();
1007 if task.is_empty() {
1008 renderer.render(UiLine::CommandOutput(
1009 t(Msg::BackgroundUsage).into_owned(),
1010 ));
1011 renderer.flush();
1012 return Ok(());
1013 }
1014 if !ctx.bg_manager.has_capacity() {
1015 renderer.render(UiLine::Error(
1016 t(Msg::BgSlotLimitReached { max: bg_runtime::MAX_BACKGROUND_SLOTS }).into_owned(),
1017 ));
1018 renderer.flush();
1019 return Ok(());
1020 }
1021 let mut session = Session::default_session(ctx.working_dir.clone());
1022 session.name = short_task_name(task);
1023 let short_id = session.short_id().to_string();
1024 let (runtime_id, client, session) = spawn_runtime(ctx, session);
1025 let slot = match ctx.bg_manager.push_background_runtime(
1026 runtime_id,
1027 client.clone(),
1028 session,
1029 bg_runtime::RuntimeState::Running,
1030 ) {
1031 Ok(slot) => slot,
1032 Err(bg_runtime::BgError::SlotLimit { max }) => {
1033 renderer.render(UiLine::Error(
1034 t(Msg::BgSlotLimitReached { max }).into_owned(),
1035 ));
1036 renderer.flush();
1037 return Ok(());
1038 }
1039 Err(bg_runtime::BgError::InvalidSlot { .. }) => unreachable!(),
1040 };
1041 client
1042 .cmd_tx
1043 .send(AgentCommand::SendMessage { text: task.to_string(), images: Vec::new(), image_markers: Vec::new() })
1044 .ok();
1045 renderer.render(UiLine::CommandOutput(
1046 t(Msg::BgTaskStarted { slot, short_id: &short_id }).into_owned(),
1047 ));
1048 renderer.flush();
1049 }
1050 "init" => {
1051 let target = ctx.working_dir.join(".atomcode.md");
1056 let force = matches!(arg.trim(), "--force" | "force");
1057 if target.exists() && !force {
1058 let path_str = target.display().to_string();
1059 renderer.render(UiLine::CommandOutput(
1060 t(Msg::InitAlreadyExists { path: &path_str }).into_owned(),
1061 ));
1062 renderer.flush();
1063 return Ok(());
1064 }
1065 let content = atomcode_core::init::generate_project_instructions(&ctx.working_dir);
1066 match std::fs::write(&target, &content) {
1067 Ok(()) => {
1068 let path_str = target.display().to_string();
1069 renderer.render(UiLine::CommandOutput(
1070 t(Msg::InitWrote { path: &path_str, bytes: content.len() }).into_owned(),
1071 ));
1072 renderer.render(UiLine::CommandOutput(
1079 render_instruction_status_block(&ctx.working_dir),
1080 ));
1081 }
1082 Err(e) => {
1083 renderer.render(UiLine::Error(
1084 t(Msg::InitFailed { error: &format!("{}", e) }).into_owned(),
1085 ));
1086 }
1087 }
1088 renderer.flush();
1089 }
1090 "mcp" => {
1091 let sub = arg.trim();
1092 if let Some(rest) = sub.strip_prefix("login") {
1093 let server = rest.trim();
1094 if server.is_empty() {
1095 renderer.render(UiLine::CommandOutput(
1096 t(Msg::McpOAuthLoginUsage).into_owned(),
1097 ));
1098 renderer.flush();
1099 return Ok(());
1100 }
1101 let configs = match atomcode_core::mcp::load_mcp_config(&ctx.working_dir) {
1102 Ok(configs) => configs,
1103 Err(e) => {
1104 renderer.render(UiLine::Error(
1105 t(Msg::McpOAuthLoadConfigFailed { error: &format!("{:#}", e) }).into_owned(),
1106 ));
1107 renderer.flush();
1108 return Ok(());
1109 }
1110 };
1111 let Some(config) = configs.into_iter().find(|config| config.name == server) else {
1112 renderer.render(UiLine::Error(
1113 t(Msg::McpOAuthServerNotFound { server }).into_owned(),
1114 ));
1115 renderer.flush();
1116 return Ok(());
1117 };
1118 renderer.render(UiLine::CommandOutput(
1119 t(Msg::McpOAuthStarting { server }).into_owned(),
1120 ));
1121 renderer.flush();
1122 let is_github_server = matches!(
1123 &config.config,
1124 atomcode_core::mcp::McpTransportConfig::Http {
1125 auth: Some(atomcode_core::mcp::McpHttpAuthConfig::OAuth(auth)),
1126 ..
1127 } if auth.provider.as_deref() == Some("github")
1128 );
1129 let result = tokio::task::block_in_place(|| {
1130 atomcode_core::mcp::login_mcp_oauth(
1131 &config,
1132 atomcode_core::mcp::McpOAuthLoginOptions {
1133 client_id: if is_github_server {
1134 std::env::var("ATOMCODE_GITHUB_MCP_CLIENT_ID").ok()
1135 } else {
1136 None
1137 },
1138 client_secret_env: None,
1139 scopes: Vec::new(),
1140 },
1141 )
1142 });
1143 match result {
1144 Ok(token) => renderer.render(UiLine::CommandOutput(
1145 t(Msg::McpOAuthSaved { provider: &token.provider, server }).into_owned(),
1146 )),
1147 Err(e) => renderer.render(UiLine::Error(
1148 t(Msg::McpOAuthFailed { error: &format!("{:#}", e) }).into_owned(),
1149 )),
1150 }
1151 renderer.flush();
1152 return Ok(());
1153 }
1154
1155 if let Some(rest) = sub.strip_prefix("logout") {
1156 let server = rest.trim();
1157 if server.is_empty() {
1158 renderer.render(UiLine::CommandOutput(
1159 t(Msg::McpOAuthLogoutUsage).into_owned(),
1160 ));
1161 renderer.flush();
1162 return Ok(());
1163 }
1164 match atomcode_core::mcp::McpTokenStore::default().delete_token(server) {
1165 Ok(true) => renderer.render(UiLine::CommandOutput(
1166 t(Msg::McpOAuthTokenRemoved { server }).into_owned(),
1167 )),
1168 Ok(false) => renderer.render(UiLine::CommandOutput(
1169 t(Msg::McpOAuthNoToken { server }).into_owned(),
1170 )),
1171 Err(e) => renderer.render(UiLine::Error(
1172 t(Msg::McpOAuthLogoutFailed { error: &format!("{:#}", e) }).into_owned(),
1173 )),
1174 }
1175 renderer.flush();
1176 return Ok(());
1177 }
1178
1179 if sub.eq_ignore_ascii_case("reload") {
1180 let configs = match atomcode_core::mcp::load_mcp_config(&ctx.working_dir) {
1183 Ok(c) => c,
1184 Err(e) => {
1185 renderer.render(UiLine::Error(
1186 t(Msg::McpReloadFailed { error: &format!("{:#}", e) }).into_owned(),
1187 ));
1188 renderer.flush();
1189 return Ok(());
1190 }
1191 };
1192
1193 let mut header = t(Msg::McpReloading { count: configs.len() }).into_owned();
1194
1195 if !configs.is_empty() {
1196 header.push_str(&t(Msg::McpConnecting));
1197 for c in &configs {
1198 header.push_str(&t(Msg::McpConnectingServer { name: &c.name }));
1199 }
1200 } else {
1201 header.push_str(&t(Msg::McpNoServersConfigured));
1202 }
1203 renderer.render(UiLine::CommandOutput(header));
1204 renderer.flush();
1205
1206 let removed = tokio::task::block_in_place(|| {
1209 tokio::runtime::Handle::current().block_on(async {
1210 ctx.agent.tool_registry.unregister_prefix("mcp__").await
1211 })
1212 });
1213
1214 ctx.mcp_connect_rx = None;
1216 ctx.mcp_registry = None;
1217 ctx.mcp_reload = None;
1218
1219 if configs.is_empty() {
1221 renderer.render(UiLine::CommandOutput(
1222 t(Msg::McpClearedNoServers { removed }).into_owned(),
1223 ));
1224 renderer.flush();
1225 return Ok(());
1226 }
1227
1228 ctx.mcp_reload = Some(super::McpReloadProgress {
1230 total: configs.len(),
1231 done: 0,
1232 connected: 0,
1233 failed: 0,
1234 started_at: std::time::Instant::now(),
1235 });
1236
1237 use atomcode_core::mcp::McpConnectEvent;
1240 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<McpConnectEvent>();
1241 let registry = atomcode_core::mcp::McpRegistry::from_config_background_with_events(
1242 &ctx.working_dir,
1243 Some(tx),
1244 );
1245 ctx.mcp_registry = Some(std::sync::Arc::new(registry));
1246 ctx.mcp_connect_rx = Some(rx);
1247
1248 renderer.render(UiLine::CommandOutput(
1249 t(Msg::McpClearedReconnecting { removed }).into_owned(),
1250 ));
1251 renderer.flush();
1252 return Ok(());
1253 }
1254
1255 if let Some(rest) = sub.strip_prefix("tools") {
1258 let server = rest.trim();
1259 if server.is_empty() {
1260 renderer.render(UiLine::CommandOutput(
1261 t(Msg::McpToolsUsage).into_owned(),
1262 ));
1263 renderer.flush();
1264 return Ok(());
1265 }
1266 if let Some(registry) = &ctx.mcp_registry {
1267 let server = server.to_string();
1268 let server_for_msg = server.clone();
1269 let registry = registry.clone();
1270 let tx = registry.event_sender();
1271 tokio::spawn(async move {
1272 let list_timeout = registry.list_tools_timeout(&server).await;
1273 let tools = match tokio::time::timeout(
1274 list_timeout,
1275 registry.list_tools_for_server(&server),
1276 )
1277 .await
1278 {
1279 Ok(v) => v,
1280 Err(_) => {
1281 if let Some(tx) = &tx {
1282 let _ = tx.send(atomcode_core::mcp::McpConnectEvent::Warning {
1283 name: server.clone(),
1284 message: format!(
1285 "tools/list timed out after {}s (server connected but tools not listed yet)",
1286 list_timeout.as_secs()
1287 ),
1288 });
1289 }
1290 return;
1291 }
1292 };
1293 let mut msg = format!("tools:\n");
1294 if tools.is_empty() {
1295 msg.push_str(" (none — tools/list may have failed, timed out, or returned empty)\n");
1296 } else {
1297 for t in tools {
1298 msg.push_str(&format!(" - mcp__{}__{}\n", server, t.tool_name));
1299 }
1300 }
1301 if let Some(tx) = tx {
1302 let _ = tx.send(atomcode_core::mcp::McpConnectEvent::Warning {
1303 name: server,
1304 message: msg.trim_end().to_string(),
1305 });
1306 }
1307 });
1308 renderer.render(UiLine::CommandOutput(
1309 t(Msg::McpToolsListing { server: &server_for_msg }).into_owned(),
1310 ));
1311 } else {
1312 renderer.render(UiLine::CommandOutput(
1313 t(Msg::McpNoRegistry).into_owned(),
1314 ));
1315 }
1316 renderer.flush();
1317 return Ok(());
1318 }
1319
1320 if let Some(registry) = &ctx.mcp_registry {
1322 let statuses = tokio::task::block_in_place(|| {
1323 tokio::runtime::Handle::current().block_on(registry.server_statuses())
1324 });
1325 if statuses.is_empty() {
1326 renderer.render(UiLine::CommandOutput(
1327 t(Msg::McpNoServersConfigured).into_owned(),
1328 ));
1329 } else {
1330 let mut txt = t(Msg::McpServersHeader).into_owned();
1331 for (name, status) in statuses {
1332 txt.push_str(&format!(" {} {}\n", name, status));
1333 }
1334 renderer.render(UiLine::CommandOutput(txt));
1335 }
1336 } else {
1337 renderer.render(UiLine::CommandOutput(
1338 t(Msg::McpNoServersConfigured).into_owned(),
1339 ));
1340 }
1341 renderer.flush();
1342 }
1343 "welcome" => {
1344 let _ = arg;
1353 *active_modal = Some(Box::new(
1354 crate::modals::OnboardingWizard::new_with_confirm()
1355 .with_initial_language(ctx.config.language),
1356 ));
1357 }
1358 "worktree" => {
1359 handle_worktree(arg, ctx, renderer)?;
1360 }
1361 "think" => {
1362 let sub = arg.trim().to_ascii_lowercase();
1363 let provider_name = ctx.config.default_provider.clone();
1364 let provider = ctx.config.providers.get_mut(&provider_name);
1365 match provider {
1366 None => {
1367 renderer.render(UiLine::Error(
1368 t(Msg::CmdNoActiveProvider).into_owned(),
1369 ));
1370 renderer.flush();
1371 }
1372 Some(p) => {
1373 if sub.is_empty() {
1374 let enabled = p.thinking_enabled.unwrap_or(false);
1376 let budget = p.thinking_budget.unwrap_or(10_000);
1377 let status = if enabled { "enabled" } else { "disabled" };
1378 renderer.render(UiLine::CommandOutput(
1379 t(Msg::ThinkStatus { status, budget, provider: &provider_name }).into_owned(),
1380 ));
1381 renderer.flush();
1382 } else if sub == "on" {
1383 p.thinking_enabled = Some(true);
1384 let budget = p.thinking_budget.unwrap_or(10_000);
1385 save_and_reload(ctx, renderer);
1386 renderer.render(UiLine::CommandOutput(
1387 t(Msg::ThinkEnabled { budget }).into_owned(),
1388 ));
1389 renderer.flush();
1390 } else if sub == "off" {
1391 p.thinking_enabled = Some(false);
1392 save_and_reload(ctx, renderer);
1393 renderer.render(UiLine::CommandOutput(
1394 t(Msg::ThinkDisabled).into_owned(),
1395 ));
1396 renderer.flush();
1397 } else if let Some(rest) = sub.strip_prefix("budget") {
1398 let num_str = rest.trim();
1399 match num_str.parse::<u32>() {
1400 Ok(n) if n >= 1024 => {
1401 p.thinking_budget = Some(n);
1402 save_and_reload(ctx, renderer);
1403 renderer.render(UiLine::CommandOutput(
1404 t(Msg::ThinkBudgetSet { n }).into_owned(),
1405 ));
1406 renderer.flush();
1407 }
1408 Ok(n) => {
1409 renderer.render(UiLine::Error(
1410 t(Msg::ThinkBudgetTooSmall { n }).into_owned(),
1411 ));
1412 renderer.flush();
1413 }
1414 Err(_) => {
1415 renderer.render(UiLine::Error(
1416 t(Msg::ThinkBudgetUsage).into_owned(),
1417 ));
1418
1419 renderer.flush();
1420 }
1421 }
1422 } else {
1423 renderer.render(UiLine::CommandOutput(
1424 t(Msg::ThinkUsage).into_owned(),
1425 ));
1426 renderer.flush();
1427 }
1428 }
1429 }
1430 }
1431 "plugin" => {
1432 handle_plugin(arg, ctx, renderer);
1433 }
1434 "skills" => {
1435 let arg_trim = arg.trim();
1442 if arg_trim.is_empty() {
1443 let lines: Vec<String> = ctx
1451 .skill_registry
1452 .read()
1453 .ok()
1454 .map(|r| {
1455 let mut v: Vec<String> = r
1456 .user_invocable()
1457 .map(|s| format!(" /skills {:<48} {}", s.name, s.description))
1458 .collect();
1459 v.sort();
1460 v
1461 })
1462 .unwrap_or_default();
1463 if lines.is_empty() {
1464 renderer.render(UiLine::CommandOutput(
1465 t(Msg::SkillsNone).into_owned(),
1466 ));
1467 } else {
1468 renderer.render(UiLine::CommandOutput(format!(
1469 "{}{}\n",
1470 t(Msg::SkillsAvailable),
1471 lines.join("\n")
1472 )));
1473 }
1474 renderer.flush();
1475 } else {
1476 let mut parts = arg_trim.splitn(2, char::is_whitespace);
1477 let skill_name = parts.next().unwrap_or("");
1478 let skill_args = parts.next().unwrap_or("").trim_start();
1479 if let Some(rendered) = expand_skill(ctx, skill_name, skill_args) {
1486 ctx.agent
1487 .cmd_tx
1488 .send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
1489 .ok();
1490 state.on_submit();
1491 } else {
1492 renderer.render(UiLine::Error(
1493 t(Msg::SkillUnknown { name: skill_name }).into_owned(),
1494 ));
1495 renderer.flush();
1496 }
1497 }
1498 }
1499 "setup" => {
1500 let skill_already_installed = {
1505 let reg = ctx.skill_registry.read().ok();
1506 reg.as_ref().map_or(false, |r| r.get("setup").is_some())
1507 };
1508
1509 if skill_already_installed {
1510 if let Some(rendered) = expand_skill(ctx, "setup", arg) {
1512 renderer.render(UiLine::CommandOutput(
1513 t(Msg::CmdSetupRunningSkill).into_owned(),
1514 ));
1515 renderer.flush();
1516 ctx.agent
1517 .cmd_tx
1518 .send(AgentCommand::SendMessage {
1519 text: rendered,
1520 images: vec![],
1521 image_markers: vec![],
1522 })
1523 .ok();
1524 state.on_submit();
1525 } else {
1526 renderer.render(UiLine::Error(
1527 t(Msg::CmdSetupSkillMissing).into_owned(),
1528 ));
1529 renderer.flush();
1530 }
1531 } else {
1532 renderer.render(UiLine::CommandOutput(
1534 t(Msg::CmdSetupRunning).into_owned(),
1535 ));
1536 renderer.flush();
1537
1538 let project_root = ctx.working_dir.clone();
1539 let opts = atomcode_core::setup::RunOptions::new(project_root);
1540
1541 let result = tokio::task::block_in_place(|| {
1545 atomcode_core::setup::run(opts)
1546 });
1547
1548 match result {
1549 Ok(report) => {
1550 for line in report.render_cli().lines() {
1551 renderer.render(UiLine::CommandOutput(line.to_string()));
1552 }
1553
1554 let (skills_loaded, _) = super::reload_plugins(ctx);
1558 renderer.render(UiLine::CommandOutput(
1559 t(Msg::CmdSetupSkillsReloaded { count: skills_loaded }).into_owned(),
1560 ));
1561 renderer.flush();
1562
1563 if let Some(rendered) = expand_skill(ctx, "setup", arg) {
1568 renderer.render(UiLine::CommandOutput(
1569 t(Msg::CmdSetupRunningSkill).into_owned(),
1570 ));
1571 renderer.flush();
1572 ctx.agent
1573 .cmd_tx
1574 .send(AgentCommand::SendMessage {
1575 text: rendered,
1576 images: vec![],
1577 image_markers: vec![],
1578 })
1579 .ok();
1580 state.on_submit();
1581 } else {
1582 renderer.render(UiLine::Error(
1583 t(Msg::CmdSetupSkillMissing).into_owned(),
1584 ));
1585 renderer.flush();
1586 }
1587 }
1588 Err(e) => {
1589 renderer.render(UiLine::Error(
1590 t(Msg::CmdSetupError { error: &e.to_string() }).into_owned(),
1591 ));
1592 }
1593 }
1594 renderer.flush();
1595 }
1596 }
1597 other => {
1598 if let Some(rendered) = ctx.custom_commands.render(other, arg) {
1603 ctx.agent
1604 .cmd_tx
1605 .send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
1606 .ok();
1607 state.on_submit();
1608 } else if let Some(rendered) = expand_skill(ctx, other, arg) {
1609 ctx.agent
1610 .cmd_tx
1611 .send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
1612 .ok();
1613 state.on_submit();
1614 } else {
1615 let available_commands: Vec<&str> = vec![
1617 "help", "quit", "exit", "clear", "compact", "reload", "config",
1618 "plan", "build", "session", "model", "language", "resume",
1619 "rename", "provider", "status", "diff", "undo", "cost",
1620 "context", "remember", "forget", "memory", "login", "logout",
1621 "whoami", "upgrade", "issue", "cd", "bg", "codingplan",
1622 ];
1623 ctx.telemetry.track(atomcode_telemetry::Event::UseCommand {
1624 type_: other.to_string(),
1625 success: Some(false),
1626 error_kind: Some(atomcode_telemetry::UseCommandErrorKind::NotFound),
1627 error_data: Some(serde_json::json!({
1628 "command": other,
1629 "duration_ms": 0,
1630 "message": format!("Unknown command: {}", other),
1631 "reason": "用户输入了不存在的斜杠命令",
1632 "resolution": "使用 /help 查看所有可用命令",
1633 "available_commands": available_commands,
1634 }).to_string()),
1635 });
1636 renderer.render(UiLine::Error(
1637 t(Msg::CmdUnknownCommand { name: other }).into_owned(),
1638 ));
1639 renderer.flush();
1640 }
1641 }
1642 }
1643 Ok(())
1644}
1645
1646fn expand_skill(ctx: &LoopCtx, name: &str, arg: &str) -> Option<String> {
1650 let reg = ctx.skill_registry.read().ok()?;
1651 let skill = reg.get(name)?;
1652 if !skill.user_invocable {
1653 return None;
1654 }
1655 Some(skill.expand(arg, ctx.current_session.id.as_str()))
1656}
1657
1658fn handle_plugin(arg: &str, ctx: &mut super::LoopCtx, renderer: &mut dyn Renderer) {
1663 let rest = arg.trim();
1664 let mut parts = rest.splitn(3, char::is_whitespace);
1665 let sub = parts.next().unwrap_or("");
1666
1667 let ok = |renderer: &mut dyn Renderer, msg: String| {
1668 renderer.render(UiLine::CommandOutput(format!(" {}\n", msg)));
1669 renderer.flush();
1670 };
1671 let err = |renderer: &mut dyn Renderer, msg: String| {
1672 renderer.render(UiLine::Error(msg));
1673 renderer.flush();
1674 };
1675
1676 match sub {
1677 "marketplace" => {
1678 let action = parts.next().unwrap_or("");
1679 let arg = parts.next().unwrap_or("").trim();
1680 match action {
1681 "add" => {
1682 let url = arg.to_string();
1686 let tx = ctx.plugin_job_tx.clone();
1687 ok(renderer, t(Msg::PluginMarketplaceCloning { url: &url }).into_owned());
1688 tokio::task::spawn_blocking(move || {
1689 let ev = match atomcode_core::plugin::marketplace::add_marketplace(&url) {
1690 Ok(info) => atomcode_core::plugin::PluginJobEvent::MarketplaceAdded(info),
1691 Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
1692 op: "add marketplace".into(),
1693 msg: format!("{:#}", e),
1694 },
1695 };
1696 let _ = tx.send(ev);
1697 });
1698 }
1699 "remove" => match atomcode_core::plugin::marketplace::remove_marketplace(arg) {
1700 Ok(()) => {
1701 super::reload_plugins(ctx);
1702 ok(renderer, t(Msg::PluginMarketplaceRemoved { name: arg }).into_owned());
1703 }
1704 Err(e) => err(renderer, t(Msg::PluginMarketplaceRemoveFailed { error: &e.to_string() }).into_owned()),
1705 },
1706 "update" => {
1707 let name = arg.to_string();
1708 let tx = ctx.plugin_job_tx.clone();
1709 ok(renderer, t(Msg::PluginMarketplaceUpdating { name: &name }).into_owned());
1710 tokio::task::spawn_blocking(move || {
1711 let ev = match atomcode_core::plugin::marketplace::update_marketplace(&name) {
1712 Ok(info) => atomcode_core::plugin::PluginJobEvent::MarketplaceUpdated(info),
1713 Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
1714 op: "update marketplace".into(),
1715 msg: format!("{:#}", e),
1716 },
1717 };
1718 let _ = tx.send(ev);
1719 });
1720 }
1721 "list" => match atomcode_core::plugin::marketplace::list_marketplaces() {
1722 Ok(items) if items.is_empty() => {
1723 ok(renderer, t(Msg::PluginNoMarketplaces).into_owned());
1724 }
1725 Ok(items) => {
1726 let mut lines = vec![t(Msg::PluginMarketplacesHeader).into_owned()];
1727 for m in items {
1728 lines.push(format!(
1729 " {} {} {} ({} plugins)",
1730 m.name,
1731 m.source,
1732 &m.git_commit[..7.min(m.git_commit.len())],
1733 m.plugins.len()
1734 ));
1735 }
1736 renderer.render(UiLine::CommandOutput(format!(
1737 " {}\n",
1738 lines.join("\n ")
1739 )));
1740 renderer.flush();
1741 }
1742 Err(e) => err(renderer, t(Msg::PluginMarketplaceListFailed { error: &e.to_string() }).into_owned()),
1743 },
1744 _ => err(
1745 renderer,
1746 t(Msg::PluginMarketplaceUsage).into_owned(),
1747 ),
1748 }
1749 }
1750 "install" => match parse_plugin_at_marketplace(parts.next().unwrap_or("").trim()) {
1751 Some((plugin, mp)) => {
1752 let tx = ctx.plugin_job_tx.clone();
1757 ok(renderer, t(Msg::PluginInstalling { plugin: &plugin, marketplace: &mp }).into_owned());
1758 tokio::task::spawn_blocking(move || {
1759 let ev = match atomcode_core::plugin::installer::install(&plugin, &mp) {
1760 Ok(info) => atomcode_core::plugin::PluginJobEvent::PluginInstalled(info),
1761 Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
1762 op: "install".into(),
1763 msg: format!("{:#}", e),
1764 },
1765 };
1766 let _ = tx.send(ev);
1767 });
1768 }
1769 None => err(renderer, t(Msg::PluginInstallUsage).into_owned()),
1770 },
1771 "uninstall" => match parse_plugin_at_marketplace(parts.next().unwrap_or("").trim()) {
1772 Some((plugin, mp)) => match atomcode_core::plugin::installer::uninstall(&plugin, &mp) {
1773 Ok(()) => {
1774 super::reload_plugins(ctx);
1775 ok(renderer, t(Msg::PluginUninstalled { plugin: &plugin, marketplace: &mp }).into_owned());
1776 }
1777 Err(e) => err(renderer, t(Msg::PluginUninstallFailed { error: &e.to_string() }).into_owned()),
1778 },
1779 None => err(
1780 renderer,
1781 t(Msg::PluginUninstallUsage).into_owned(),
1782 ),
1783 },
1784 "list" => match atomcode_core::plugin::installer::list_installed() {
1785 Ok(items) if items.is_empty() => {
1786 ok(renderer, t(Msg::PluginNoInstalled).into_owned());
1787 }
1788 Ok(items) => {
1789 let mut lines = vec![t(Msg::PluginInstalledHeader).into_owned()];
1790 for p in items {
1791 lines.push(format!(" {}@{} {}", p.plugin, p.marketplace, p.plugin_dir));
1792 }
1793 renderer.render(UiLine::CommandOutput(format!(
1794 " {}\n",
1795 lines.join("\n ")
1796 )));
1797 renderer.flush();
1798 }
1799 Err(e) => err(renderer, t(Msg::PluginListFailed { error: &e.to_string() }).into_owned()),
1800 },
1801 _ => err(
1802 renderer,
1803 t(Msg::PluginUsage).into_owned(),
1804 ),
1805 }
1806}
1807
1808fn parse_plugin_at_marketplace(s: &str) -> Option<(String, String)> {
1809 let (plugin, mp) = s.split_once('@')?;
1810 if plugin.is_empty() || mp.is_empty() {
1811 return None;
1812 }
1813 Some((plugin.to_string(), mp.to_string()))
1814}
1815
1816fn handle_worktree(arg: &str, ctx: &mut LoopCtx, renderer: &mut dyn Renderer) -> Result<()> {
1818 use atomcode_core::git::worktree::WorktreeManager;
1819
1820 let parts: Vec<&str> = arg.split_whitespace().collect();
1821 let sub = parts.first().map(|s| s.to_ascii_lowercase());
1822
1823 match sub.as_deref() {
1824 Some("create") => {
1825 let branch = match parts.get(1) {
1826 Some(b) => *b,
1827 None => {
1828 renderer.render(UiLine::CommandOutput(
1829 t(Msg::WorktreeCreateUsage).into_owned(),
1830 ));
1831 renderer.flush();
1832 return Ok(());
1833 }
1834 };
1835 let base = parts
1836 .get(2)
1837 .map(|s| (*s).to_string())
1838 .or_else(|| detect_current_branch(&ctx.working_dir))
1839 .unwrap_or_else(|| "HEAD".to_string());
1840 let mgr = match WorktreeManager::from_dir(ctx.working_dir.clone()) {
1841 Ok(mgr) => mgr,
1842 Err(e) => {
1843 renderer.render(UiLine::Error(
1844 t(Msg::WorktreeCreateFailed { error: &format!("{:#}", e) }).into_owned(),
1845 ));
1846 renderer.flush();
1847 return Ok(());
1848 }
1849 };
1850 match mgr.create(branch, &base) {
1851 Ok(wt) => {
1852 ctx.worktree_original_dir = Some(ctx.working_dir.clone());
1854 apply_cd(ctx, wt.path.clone());
1855 let path_str = wt.path.display().to_string();
1856 renderer.render(UiLine::CommandOutput(
1857 t(Msg::WorktreeCreated { branch: &wt.branch, base: &wt.base_branch, path: &path_str }).into_owned(),
1858 ));
1859 }
1860 Err(e) => {
1861 renderer.render(UiLine::Error(
1862 t(Msg::WorktreeCreateFailed { error: &format!("{:#}", e) }).into_owned(),
1863 ));
1864 }
1865 }
1866 renderer.flush();
1867 }
1868 Some("list") => {
1869 let mgr = match WorktreeManager::from_dir(ctx.working_dir.clone()) {
1870 Ok(mgr) => mgr,
1871 Err(e) => {
1872 renderer.render(UiLine::Error(
1873 t(Msg::WorktreeListFailed { error: &format!("{:#}", e) }).into_owned(),
1874 ));
1875 renderer.flush();
1876 return Ok(());
1877 }
1878 };
1879 match mgr.list() {
1880 Ok(worktrees) => {
1881 if worktrees.is_empty() {
1882 renderer.render(UiLine::CommandOutput(
1883 t(Msg::WorktreeNoActive).into_owned(),
1884 ));
1885
1886 } else {
1887 let mut txt = t(Msg::WorktreeActiveHeader).into_owned();
1888 for (branch, path, has_changes) in &worktrees {
1889 let is_current = path == &ctx.working_dir;
1890 let marker = if is_current { "\u{25cf}" } else { "\u{25cb}" };
1891 let change_label = if *has_changes {
1892 t(Msg::WorktreeHasChanges)
1893 } else {
1894 t(Msg::WorktreeClean)
1895 };
1896 let current_hint = if is_current {
1897 t(Msg::WorktreeCurrent)
1898 } else {
1899 "".into()
1900 };
1901
1902 txt.push_str(&format!(
1903 " {} {:<16} {} {}{}\n",
1904 marker,
1905 branch,
1906 path.display(),
1907 change_label,
1908 current_hint,
1909 ));
1910 }
1911 renderer.render(UiLine::CommandOutput(txt));
1912 }
1913 }
1914 Err(e) => {
1915 renderer.render(UiLine::Error(
1916 t(Msg::WorktreeListFailed { error: &format!("{:#}", e) }).into_owned(),
1917 ));
1918 }
1919 }
1920 renderer.flush();
1921 }
1922 Some("done") => {
1923 if let Some(original) = ctx.worktree_original_dir.take() {
1924 let current_branch = detect_current_branch(&ctx.working_dir);
1925 apply_cd(ctx, original.clone());
1926 let path_str = original.display().to_string();
1927 renderer.render(UiLine::CommandOutput(
1928 t(Msg::WorktreeDoneBack { path: &path_str }).into_owned(),
1929 ));
1930 if let Some(branch) = current_branch {
1931 renderer.render(UiLine::CommandOutput(
1932 t(Msg::WorktreeDoneMergeHint { branch: &branch }).into_owned(),
1933 ));
1934 }
1935 } else {
1936 renderer.render(UiLine::CommandOutput(
1937 t(Msg::WorktreeNoSession).into_owned(),
1938 ));
1939 }
1940 renderer.flush();
1941 }
1942 Some("cleanup") => {
1943 let branch = match parts.get(1) {
1944 Some(b) => *b,
1945 None => {
1946 renderer.render(UiLine::CommandOutput(
1947 t(Msg::WorktreeCleanupUsage).into_owned(),
1948 ));
1949 renderer.flush();
1950 return Ok(());
1951 }
1952 };
1953 let force = parts
1954 .get(2)
1955 .map(|s| *s == "--force" || *s == "-f")
1956 .unwrap_or(false);
1957 let manager_dir = ctx
1958 .worktree_original_dir
1959 .as_ref()
1960 .cloned()
1961 .unwrap_or_else(|| ctx.working_dir.clone());
1962 let mgr = match WorktreeManager::from_dir(manager_dir) {
1963 Ok(mgr) => mgr,
1964 Err(e) => {
1965 renderer.render(UiLine::Error(
1966 t(Msg::WorktreeCleanupFailed { error: &format!("{:#}", e) }).into_owned(),
1967 ));
1968 renderer.flush();
1969 return Ok(());
1970 }
1971 };
1972 let cleanup_path = mgr
1973 .find_worktree_path(branch)
1974 .unwrap_or_else(|_| None)
1975 .unwrap_or_else(|| mgr.worktree_path(branch));
1976 let removing_current = paths_same(&cleanup_path, &ctx.working_dir);
1977 match mgr.remove(branch, force) {
1978 Ok(()) => {
1979 let switched_to = if removing_current {
1980 let target = ctx
1981 .worktree_original_dir
1982 .take()
1983 .unwrap_or_else(|| mgr.repo_root().to_path_buf());
1984 apply_cd(ctx, target.clone());
1985 Some(target)
1986 } else {
1987 None
1988 };
1989 renderer.render(UiLine::CommandOutput(
1990 t(Msg::WorktreeCleaned { branch }).into_owned(),
1991 ));
1992 if let Some(target) = switched_to {
1993 let path_str = target.display().to_string();
1994 renderer.render(UiLine::CommandOutput(
1995 t(Msg::WorktreeCleanedSwitched { path: &path_str }).into_owned(),
1996 ));
1997 }
1998 }
1999 Err(e) => {
2000 let err_msg = format!("{:#}", e);
2001 if !force
2002 && (err_msg.contains("untracked")
2003 || err_msg.contains("modified")
2004 || err_msg.contains("changes"))
2005 {
2006 renderer.render(UiLine::CommandOutput(
2007 t(Msg::WorktreeCleanupUncommitted { branch }).into_owned(),
2008 ));
2009 } else {
2010 renderer.render(UiLine::Error(
2011 t(Msg::WorktreeCleanupFailed { error: &err_msg }).into_owned(),
2012 ));
2013 }
2014 }
2015 }
2016 renderer.flush();
2017 }
2018 _ => {
2019 renderer.render(UiLine::CommandOutput(
2020 t(Msg::WorktreeUsage).into_owned(),
2021 ));
2022 renderer.flush();
2023 }
2024 }
2025 Ok(())
2026}
2027
2028fn detect_current_branch(dir: &std::path::Path) -> Option<String> {
2030 std::process::Command::new("git")
2031 .args(["rev-parse", "--abbrev-ref", "HEAD"])
2032 .current_dir(dir)
2033 .output()
2034 .ok()
2035 .and_then(|o| {
2036 if o.status.success() {
2037 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
2038 } else {
2039 None
2040 }
2041 })
2042}
2043
2044fn paths_same(a: &std::path::Path, b: &std::path::Path) -> bool {
2045 if a == b {
2046 return true;
2047 }
2048 match (a.canonicalize(), b.canonicalize()) {
2049 (Ok(a), Ok(b)) => a == b,
2050 _ => false,
2051 }
2052}
2053
2054pub(super) fn render_context_report(state: &UiState, ctx: &LoopCtx, show_prompt: bool) -> String {
2062 format_context_report(state.last_context.as_ref(), &ctx.model_name, show_prompt)
2063}
2064
2065fn render_codingplan_status_for_status_cmd() -> String {
2073 use atomcode_core::coding_plan::client::Client;
2074
2075 let client = match Client::from_stored_auth() {
2076 Ok(c) => c,
2077 Err(_) => {
2078 return t(Msg::StatusCpNotSignedIn).into_owned();
2079 }
2080 };
2081 let status = match client.status_v2() {
2082 Ok(s) => s,
2083 Err(e) => {
2084 return t(Msg::StatusCpFetchFailed { error: &format!("{:#}", e) }).into_owned();
2085 }
2086 };
2087 let plan = match &status.codingplan_free {
2088 Some(p) => p,
2089 None => {
2090 return t(Msg::StatusCpNoActive).into_owned();
2091 }
2092 };
2093
2094 let mut out = t(Msg::StatusCpLine {
2095 plan: &plan.plan_name,
2096 expires_at: &plan.expires_at,
2097 remaining_days: plan.remaining_days,
2098 total_days: plan.total_days,
2099 }).into_owned();
2100 if let Some(u) = &status.current_usage {
2101 out.push_str(&t(Msg::StatusCpUsage {
2102 usage: &u.display_desc(),
2103 reset_at: &u.reset_at_display,
2104 seconds: u.seconds_until_reset,
2105 }));
2106 }
2107 if status.window_quota_exhausted {
2108 if let Some(hint) = &status.window_quota_hint {
2109 out.push_str(&t(Msg::StatusCpWindowHint { hint }));
2110 } else {
2111 out.push_str(&t(Msg::StatusCpWindowExhausted));
2112 }
2113 }
2114 out
2115}
2116
2117fn format_context_report(
2120 snapshot: Option<&crate::state::ContextSnapshot>,
2121 model_name: &str,
2122 show_prompt: bool,
2123) -> String {
2124 let header = t(Msg::CtxUsageHeader);
2125 let Some(snap) = snapshot else {
2126 return format!(" {}\n \n {}\n", header, t(Msg::CtxUsageNoTurns));
2127 };
2128 if snap.ctx_window == 0 {
2129 return format!(" {}\n \n {}\n", header, t(Msg::CtxUsageWaiting));
2130 }
2131
2132 let window = snap.ctx_window;
2133 let sys = snap.system_tokens;
2137 let tools = snap.tool_defs_tokens;
2138 let cold = snap.cold_zone_tokens;
2139 let messages = snap.sent_tokens.saturating_sub(cold);
2143 let total_used = sys
2144 .saturating_add(tools)
2145 .saturating_add(cold)
2146 .saturating_add(messages);
2147 let free = window.saturating_sub(total_used);
2148
2149 const BAR_WIDTH: usize = 40;
2152 let cells = |tokens: usize| -> usize {
2153 if window == 0 {
2154 return 0;
2155 }
2156 (tokens as u128 * BAR_WIDTH as u128 / window as u128) as usize
2157 };
2158 let sys_cells = cells(sys);
2159 let tools_cells = cells(tools);
2160 let cold_cells = cells(cold);
2161 let msg_cells = cells(messages);
2162 let used_cells = sys_cells + tools_cells + cold_cells + msg_cells;
2164 let free_cells = BAR_WIDTH.saturating_sub(used_cells.min(BAR_WIDTH));
2165
2166 let mut bar = String::with_capacity(BAR_WIDTH * 3);
2167 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 {
2174 if window == 0 {
2175 return " —".to_string();
2176 }
2177 format!("{:>4.1}%", (t as f64 * 100.0) / window as f64)
2178 };
2179 let k = |t: usize| -> String {
2180 if t >= 1000 {
2181 format!("{:.1}K", t as f64 / 1000.0)
2182 } else {
2183 format!("{}", t)
2184 }
2185 };
2186
2187 let used_pct = pct(total_used);
2188
2189 let l_sys = t(Msg::CtxLabelSystemPrompt).into_owned();
2194 let l_tools = t(Msg::CtxLabelToolDefs).into_owned();
2195 let l_cold = t(Msg::CtxLabelColdZone).into_owned();
2196 let l_msgs = t(Msg::CtxLabelMessages).into_owned();
2197 let l_free = t(Msg::CtxLabelFree).into_owned();
2198 let max_label = [&l_sys, &l_tools, &l_cold, &l_msgs, &l_free]
2199 .iter()
2200 .map(|s| unicode_width::UnicodeWidthStr::width(s.as_str()))
2201 .max()
2202 .unwrap_or(0);
2203 let pad_label = |label: &str| -> String {
2204 let w = unicode_width::UnicodeWidthStr::width(label);
2205 format!("{}{}", label, " ".repeat(max_label.saturating_sub(w)))
2206 };
2207
2208 let ctx_name = if snap.ctx_name.is_empty() {
2209 "default"
2210 } else {
2211 snap.ctx_name.as_str()
2212 };
2213
2214 let mut out = format!(
2215 " {header}\n \
2216 \n \
2217 {bar}\n \
2218 {used}/{window} {tokens} ({used_pct})\n \
2219 \n \
2220 {provider}: {model} · {ctx_label}: {ctx_name}\n \
2221 \n \
2222 ▒ {l_sys} : {sys_s:>7} ({sys_p})\n \
2223 ▓ {l_tools} : {tools_s:>7} ({tools_p})\n \
2224 ░ {l_cold} : {cold_s:>7} ({cold_p})\n \
2225 █ {l_msgs} : {msgs_s:>7} ({msgs_p})\n \
2226 · {l_free} : {free_s:>7} ({free_p})\n \
2227 \n \
2228 {msg_count}\n",
2229 header = t(Msg::CtxUsageHeader),
2230 bar = bar,
2231 used = k(total_used),
2232 window = k(window),
2233 tokens = t(Msg::CtxTokensSuffix),
2234 used_pct = used_pct,
2235 provider = t(Msg::CtxProvider),
2236 ctx_label = t(Msg::CtxCtxName),
2237 model = model_name,
2238 ctx_name = ctx_name,
2239 l_sys = pad_label(&l_sys),
2240 l_tools = pad_label(&l_tools),
2241 l_cold = pad_label(&l_cold),
2242 l_msgs = pad_label(&l_msgs),
2243 l_free = pad_label(&l_free),
2244 sys_s = k(sys),
2245 sys_p = pct(sys),
2246 tools_s = k(tools),
2247 tools_p = pct(tools),
2248 cold_s = k(cold),
2249 cold_p = pct(cold),
2250 msgs_s = k(messages),
2251 msgs_p = pct(messages),
2252 free_s = k(free),
2253 free_p = pct(free),
2254 msg_count = t(Msg::CtxMessagesInWindow { n: snap.total_messages }),
2255 );
2256
2257 if show_prompt {
2264 out.push('\n');
2265 out.push_str(&format!(" {}\n", t(Msg::CtxSystemPromptHeader)));
2266 if snap.system_prompt.is_empty() {
2267 out.push_str(&format!(" {}\n", t(Msg::CtxSystemPromptEmpty)));
2268 } else {
2269 for line in snap.system_prompt.lines() {
2274 out.push_str(" ");
2275 out.push_str(line);
2276 out.push('\n');
2277 }
2278 }
2279 }
2280
2281 out
2282}
2283
2284#[allow(dead_code)]
2299pub(crate) fn launch_fixissue(
2300 url: &str,
2301 state: &mut UiState,
2302 ctx: &mut LoopCtx,
2303 renderer: &mut dyn Renderer,
2304 fixissue_pending: &mut Option<atomcode_core::atomgit::IssueRef>,
2305 fixissue_buffer: &mut String,
2306) {
2307 match atomcode_core::atomgit::fixissue::prepare(url, &ctx.working_dir) {
2308 Ok(atomcode_core::atomgit::fixissue::Prepared::Run {
2309 prompt,
2310 issue_title,
2311 issue_number,
2312 issue_ref,
2313 }) => {
2314 renderer.render(UiLine::CommandOutput(format!(
2315 " [fixissue] issue #{}: {}\n Handing off to agent... (will post summary + 'fixed' label on completion)\n",
2316 issue_number, issue_title,
2317 )));
2318 renderer.flush();
2319 *fixissue_pending = Some(issue_ref);
2320 fixissue_buffer.clear();
2321 ctx.agent
2322 .cmd_tx
2323 .send(AgentCommand::SendMessage { text: prompt, images: vec![], image_markers: vec![] })
2324 .ok();
2325 state.on_submit();
2326 }
2327 Ok(atomcode_core::atomgit::fixissue::Prepared::Skip { reason }) => {
2328 renderer.render(UiLine::CommandOutput(format!(" {}\n", reason)));
2329 renderer.flush();
2330 }
2331 Err(e) => {
2332 renderer.render(UiLine::CommandOutput(format!(
2333 " fixissue failed: {:#}\n",
2334 e
2335 )));
2336 renderer.flush();
2337 }
2338 }
2339}
2340
2341pub(crate) fn apply_cd(ctx: &mut LoopCtx, path: PathBuf) {
2346 ctx.agent
2347 .cmd_tx
2348 .send(AgentCommand::ChangeDir(path.to_string_lossy().to_string()))
2349 .ok();
2350 ctx.previous_dir = Some(std::mem::replace(&mut ctx.working_dir, path.clone()));
2351 ctx.runtime_factory.set_working_dir(path.clone());
2352 push_recent_dir(&mut ctx.recent_dirs, path);
2353 save_recent_dirs(&ctx.recent_dirs);
2354}
2355
2356pub(crate) fn push_recent_dir(dirs: &mut Vec<PathBuf>, new: PathBuf) {
2360 dirs.retain(|d| d != &new);
2361 dirs.insert(0, new);
2362 dirs.truncate(MAX_RECENT_DIRS);
2363}
2364
2365pub(crate) fn load_recent_dirs() -> Vec<PathBuf> {
2368 let path = atomcode_core::config::Config::config_dir().join("recent_dirs.txt");
2369 std::fs::read_to_string(&path)
2370 .ok()
2371 .map(|s| {
2372 s.lines()
2373 .filter(|l| !l.trim().is_empty())
2374 .map(PathBuf::from)
2375 .filter(|p| p.is_dir())
2376 .take(MAX_RECENT_DIRS)
2377 .collect()
2378 })
2379 .unwrap_or_default()
2380}
2381
2382pub(crate) fn save_recent_dirs(dirs: &[PathBuf]) {
2386 let path = atomcode_core::config::Config::config_dir().join("recent_dirs.txt");
2387 let content = dirs
2388 .iter()
2389 .map(|d| d.to_string_lossy().to_string())
2390 .collect::<Vec<_>>()
2391 .join("\n");
2392 let _ = std::fs::write(&path, content);
2393}
2394
2395fn resolve_cd(
2396 arg: &str,
2397 cwd: &std::path::Path,
2398 prev: Option<&std::path::Path>,
2399) -> std::result::Result<PathBuf, String> {
2400 let home = crate::platform::home_dir();
2401 let target = if arg.is_empty() {
2402 home.ok_or_else(|| "home directory not known".to_string())?
2403 } else if arg == "-" {
2404 prev.map(|p| p.to_path_buf())
2405 .ok_or_else(|| "No previous directory".to_string())?
2406 } else if let Some(rest) = arg.strip_prefix('~') {
2407 let home = home.ok_or_else(|| "home directory not known".to_string())?;
2408 let rest = rest.strip_prefix('/').unwrap_or(rest);
2409 if rest.is_empty() {
2410 home
2411 } else {
2412 home.join(rest)
2413 }
2414 } else {
2415 let p = PathBuf::from(arg);
2416 if p.is_absolute() {
2417 p
2418 } else {
2419 cwd.join(p)
2420 }
2421 };
2422 let canon = target
2423 .canonicalize()
2424 .map_err(|e| format!("{}: {}", target.display(), e))?;
2425 if !canon.is_dir() {
2426 return Err(t(Msg::DirNotADirectory { path: &canon.display().to_string() }).into_owned());
2427 }
2428 Ok(canon)
2429}
2430
2431fn compose_login_chrome(url: &str, unicode: bool) -> String {
2458 compose_login_chrome_inner(url, unicode, cfg!(target_env = "ohos"))
2459}
2460
2461fn compose_login_chrome_inner(url: &str, unicode: bool, omit_url: bool) -> String {
2471 let qr_block = pick_qr_style(unicode).and_then(|style| {
2472 let s = crate::render::qr::render_login_qr(url, style)?;
2473 let cols = crate::render::qr::block_cols(&s);
2474 let term_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
2475 if (cols as u16).saturating_add(4) <= term_cols {
2477 Some(
2478 s.lines()
2479 .map(|l| format!(" {}", l))
2480 .collect::<Vec<_>>()
2481 .join("\n"),
2482 )
2483 } else {
2484 None
2485 }
2486 });
2487
2488 let mut out = String::new();
2489 if let Some(block) = qr_block {
2490 out.push_str(&t(Msg::LoginQrHeader));
2491 out.push_str(&block);
2492 if !omit_url {
2493 out.push_str(&t(Msg::LoginUrlAfterQr));
2494 out.push_str(url);
2495 }
2496 } else if omit_url {
2497 out.push_str(&t(Msg::LoginNoQrNoUrl));
2501 } else {
2502 out.push_str(&t(Msg::LoginUrlOnly));
2503 out.push_str(url);
2504 }
2505 out.push_str(&t(Msg::LoginCancelHint));
2506 out
2507}
2508
2509fn pick_qr_style(unicode: bool) -> Option<crate::render::qr::QrStyle> {
2516 let env_flag = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()).is_some();
2517 let is_jediterm = std::env::var("TERMINAL_EMULATOR")
2518 .map(|v| v == "JetBrains-JediTerm")
2519 .unwrap_or(false);
2520 decide_qr_style(
2521 unicode,
2522 env_flag("ATOMCODE_QR_DENSE"),
2523 env_flag("ATOMCODE_QR_BRAILLE"),
2524 is_jediterm,
2525 )
2526}
2527
2528fn decide_qr_style(
2532 unicode: bool,
2533 force_dense: bool,
2534 force_braille: bool,
2535 is_jediterm: bool,
2536) -> Option<crate::render::qr::QrStyle> {
2537 use crate::render::qr::QrStyle;
2538 if !unicode {
2539 return None;
2540 }
2541 if force_dense {
2542 return Some(QrStyle::Dense1x2);
2543 }
2544 if force_braille {
2545 return Some(QrStyle::Braille);
2546 }
2547 if is_jediterm {
2548 return None;
2551 }
2552 Some(QrStyle::Dense1x2)
2553}
2554
2555#[cfg(test)]
2556mod qr_style_tests {
2557 use super::*;
2558 use crate::render::qr::QrStyle;
2559
2560 #[test]
2561 fn no_unicode_means_no_qr() {
2562 assert_eq!(decide_qr_style(false, false, false, false), None);
2563 assert_eq!(decide_qr_style(false, true, false, false), None);
2565 assert_eq!(decide_qr_style(false, false, true, false), None);
2566 }
2567
2568 #[test]
2569 fn jediterm_default_skips_qr() {
2570 assert_eq!(decide_qr_style(true, false, false, true), None);
2571 }
2572
2573 #[test]
2574 fn jediterm_with_braille_override_renders_braille() {
2575 assert_eq!(
2576 decide_qr_style(true, false, true, true),
2577 Some(QrStyle::Braille)
2578 );
2579 }
2580
2581 #[test]
2582 fn jediterm_with_dense_override_renders_dense() {
2583 assert_eq!(
2584 decide_qr_style(true, true, false, true),
2585 Some(QrStyle::Dense1x2)
2586 );
2587 }
2588
2589 #[test]
2590 fn dense_override_wins_over_braille_override() {
2591 assert_eq!(
2592 decide_qr_style(true, true, true, false),
2593 Some(QrStyle::Dense1x2)
2594 );
2595 }
2596
2597 #[test]
2598 fn braille_override_picks_braille_outside_jediterm() {
2599 assert_eq!(
2600 decide_qr_style(true, false, true, false),
2601 Some(QrStyle::Braille)
2602 );
2603 }
2604
2605 #[test]
2606 fn default_is_dense1x2() {
2607 assert_eq!(
2608 decide_qr_style(true, false, false, false),
2609 Some(QrStyle::Dense1x2)
2610 );
2611 }
2612}
2613
2614#[cfg(test)]
2615mod compose_login_chrome_tests {
2616 use super::*;
2617
2618 const URL: &str = "https://acs.atomgit.com/login?client_id=test";
2619
2620 #[test]
2622 fn omit_url_false_keeps_url_block_alongside_qr() {
2623 let _g = crate::i18n::test_lock();
2624 crate::i18n::set_locale(crate::i18n::Locale::En);
2625 let s = compose_login_chrome_inner(URL, true, false);
2626 assert!(s.contains("scan the QR code"), "QR header missing:\n{s}");
2627 assert!(
2628 s.contains("OR open the URL below"),
2629 "URL fallback header missing on non-OH build:\n{s}"
2630 );
2631 assert!(s.contains(URL), "URL itself missing on non-OH build:\n{s}");
2632 }
2633
2634 #[test]
2638 fn omit_url_true_drops_url_block_when_qr_present() {
2639 let _g = crate::i18n::test_lock();
2640 crate::i18n::set_locale(crate::i18n::Locale::En);
2641 let s = compose_login_chrome_inner(URL, true, true);
2642 assert!(s.contains("scan the QR code"), "QR header missing:\n{s}");
2643 assert!(
2644 !s.contains("OR open the URL below"),
2645 "URL fallback header must NOT appear when omit_url:\n{s}"
2646 );
2647 assert!(
2648 !s.contains(URL),
2649 "URL itself must NOT appear when omit_url:\n{s}"
2650 );
2651 }
2652
2653 #[test]
2658 fn omit_url_true_without_qr_explains_dead_end() {
2659 let _g = crate::i18n::test_lock();
2660 crate::i18n::set_locale(crate::i18n::Locale::En);
2661 let s = compose_login_chrome_inner(URL, false, true);
2662 assert!(
2663 !s.contains(URL),
2664 "URL must not appear when omit_url:\n{s}"
2665 );
2666 assert!(
2667 s.contains("Unicode-capable terminal"),
2668 "must guide the user to a unicode terminal:\n{s}"
2669 );
2670 }
2671
2672 #[test]
2675 fn omit_url_false_without_qr_shows_url_fallback() {
2676 let _g = crate::i18n::test_lock();
2677 crate::i18n::set_locale(crate::i18n::Locale::En);
2678 let s = compose_login_chrome_inner(URL, false, false);
2679 assert!(
2680 s.contains("Open this URL in any browser"),
2681 "URL fallback header missing on non-OH terminal-without-unicode:\n{s}"
2682 );
2683 assert!(s.contains(URL));
2684 }
2685}
2686
2687fn run_oauth_with_renderer(
2699 renderer: &mut dyn Renderer,
2700 ctx: &mut LoopCtx,
2701) -> Result<atomcode_core::auth::AuthInfo> {
2702 use crossterm::event::KeyCode;
2703 use std::time::{Duration, Instant};
2704 use tokio::sync::mpsc::error::TryRecvError;
2705
2706 let session = atomcode_core::auth::start_login()?;
2707
2708 renderer.render(UiLine::CommandOutput(compose_login_chrome(
2715 session.url(),
2716 ctx.caps.unicode_symbols,
2717 )));
2718 renderer.flush();
2719
2720 session.open_browser_best_effort();
2721
2722 loop {
2728 match session.poll_once()? {
2729 atomcode_core::auth::PollOutcome::Authorized => break,
2730 atomcode_core::auth::PollOutcome::Pending => {}
2731 }
2732
2733 let deadline = Instant::now() + Duration::from_secs(2);
2734 loop {
2735 if Instant::now() >= deadline {
2736 break;
2737 }
2738 match ctx.input_rx.try_recv() {
2739 Ok(crate::input::InputEvent::Key(k)) if k.code == KeyCode::Esc => {
2740 anyhow::bail!("login cancelled by user");
2741 }
2742 Ok(_) => {
2743 continue;
2748 }
2749 Err(TryRecvError::Empty) => {
2750 std::thread::sleep(Duration::from_millis(50));
2751 }
2752 Err(TryRecvError::Disconnected) => {
2753 anyhow::bail!("input channel closed");
2754 }
2755 }
2756 }
2757 }
2758
2759 session.finish(Some(&ctx.telemetry))
2760}
2761
2762pub(crate) fn run_login_flow(renderer: &mut dyn Renderer, ctx: &mut LoopCtx) -> Result<()> {
2766 let result = run_oauth_with_renderer(renderer, ctx)
2767 .and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|()| auth));
2768
2769 match result {
2770 Ok(auth) => {
2771 let name = auth
2777 .user
2778 .name
2779 .as_deref()
2780 .unwrap_or(&auth.user.username)
2781 .to_string();
2782 let had_provider = !ctx.config.providers.is_empty()
2783 && ctx
2784 .config
2785 .providers
2786 .contains_key(&ctx.config.default_provider);
2787 if !had_provider {
2788 let provider_name = "AtomGit".to_string();
2789 let provider = build_oauth_provider();
2790 ctx.model_name = provider.model.clone();
2791 ctx.config.providers.insert(provider_name.clone(), provider);
2792 ctx.config.default_provider = provider_name;
2793 save_and_reload(ctx, renderer);
2794 } else {
2795 if let Some(provider) = ctx.config.providers.get(&ctx.config.default_provider) {
2796 ctx.model_name = provider.model.clone();
2797 }
2798 let _ = ctx
2799 .agent
2800 .cmd_tx
2801 .send(AgentCommand::ReloadConfig(ctx.config.clone()));
2802 }
2803 renderer.render(UiLine::CommandOutput(
2804 t(Msg::LoginSignedInWithCpHint {
2805 name: &name,
2806 username: &auth.user.username,
2807 }).into_owned(),
2808 ));
2809 renderer.flush();
2810 }
2811 Err(e) => {
2812 renderer.render(UiLine::Error(
2813 t(Msg::CmdLoginFailed { error: &e.to_string() }).into_owned(),
2814 ));
2815 renderer.flush();
2816 }
2817 }
2818 Ok(())
2819}
2820
2821pub(crate) fn run_codingplan_flow(renderer: &mut dyn Renderer, ctx: &mut LoopCtx) -> Result<()> {
2831 if !atomcode_core::auth::is_logged_in() {
2833 if let Err(e) = run_oauth_with_renderer(renderer, ctx)
2834 .and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|_| auth))
2835 {
2836 renderer.render(UiLine::Error(
2840 t(Msg::CodingPlanSetupFailed { error: &e.to_string() }).into_owned(),
2841 ));
2842 renderer.flush();
2843 return Ok(());
2844 }
2845 }
2846
2847 let mut report = atomcode_core::coding_plan::run(&mut ctx.config, Some(&ctx.telemetry));
2860 if matches!(&report, Ok(r) if r.auth_expired) {
2861 renderer.render(UiLine::CommandOutput(
2862 t(Msg::CpReauthAfter401).into_owned(),
2863 ));
2864 renderer.flush();
2865 match run_oauth_with_renderer(renderer, ctx)
2866 .and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|_| auth))
2867 {
2868 Ok(_) => {
2869 report = atomcode_core::coding_plan::run(&mut ctx.config, Some(&ctx.telemetry));
2870 }
2871 Err(e) => {
2872 if let Ok(r) = &report {
2877 renderer.render(UiLine::CommandOutput(r.render()));
2878 }
2879 renderer.render(UiLine::Error(
2880 t(Msg::CodingPlanSetupFailed { error: &e.to_string() }).into_owned(),
2881 ));
2882 renderer.flush();
2883 return Ok(());
2884 }
2885 }
2886 }
2887
2888 match report {
2889 Ok(report) => {
2890 if report.should_persist_config() {
2891 save_and_reload(ctx, renderer);
2894 let _ = atomcode_core::coding_plan::write_last_sync_now();
2898 ctx.monitor_last_sync_seen = atomcode_core::coding_plan::read_last_sync();
2902 if let Some(p) = ctx.config.providers.get(&ctx.config.default_provider) {
2906 ctx.model_name = p.model.clone();
2907 }
2908 if let Ok(mut g) = ctx.monitor_warning.lock() {
2914 *g = None;
2915 }
2916 ctx.monitor_last_check_at = None;
2917 if let Ok(mut g) = ctx.usage_slot.lock() {
2920 *g = None;
2921 }
2922 ctx.usage_last_check_at = None;
2923 }
2924 renderer.render(UiLine::CommandOutput(report.render()));
2925 renderer.flush();
2926 }
2927 Err(e) => {
2928 renderer.render(UiLine::Error(
2929 t(Msg::CodingPlanSetupFailed { error: &format!("{:#}", e) }).into_owned(),
2930 ));
2931 renderer.flush();
2932 }
2933 }
2934 Ok(())
2935}
2936
2937#[cfg(test)]
2938mod tests {
2939 use super::*;
2940
2941 fn make_dirs() -> (tempfile::TempDir, PathBuf, PathBuf) {
2945 let tmp = tempfile::tempdir().expect("tempdir");
2946 let cwd = tmp.path().canonicalize().expect("canon cwd");
2947 let sub = cwd.join("sub");
2948 std::fs::create_dir(&sub).expect("mkdir sub");
2949 let sub = sub.canonicalize().expect("canon sub");
2950 (tmp, cwd, sub)
2951 }
2952
2953 #[test]
2954 fn relative_path_resolves_against_cwd() {
2955 let (_tmp, cwd, sub) = make_dirs();
2956 let got = resolve_cd("sub", &cwd, None).expect("relative resolves");
2957 assert_eq!(got, sub);
2958 }
2959
2960 #[test]
2961 fn absolute_path_ignores_cwd() {
2962 let (_tmp, _cwd, sub) = make_dirs();
2963 let alt_cwd = PathBuf::from("/"); let got = resolve_cd(sub.to_str().unwrap(), &alt_cwd, None).expect("absolute resolves");
2965 assert_eq!(got, sub);
2966 }
2967
2968 #[test]
2969 fn dash_uses_previous_dir() {
2970 let (_tmp, cwd, sub) = make_dirs();
2971 let got = resolve_cd("-", &sub, Some(&cwd)).expect("dash uses prev");
2972 assert_eq!(got, cwd);
2973 }
2974
2975 #[test]
2976 fn dash_without_previous_errors() {
2977 let (_tmp, cwd, _sub) = make_dirs();
2978 let err = resolve_cd("-", &cwd, None).expect_err("dash w/o prev");
2979 assert!(err.contains("No previous directory"), "got: {}", err);
2980 }
2981
2982 #[test]
2983 fn nonexistent_path_errors() {
2984 let (_tmp, cwd, _sub) = make_dirs();
2985 let err = resolve_cd("nope-does-not-exist", &cwd, None).expect_err("nonexistent errors");
2986 assert!(err.contains("nope-does-not-exist"), "got: {}", err);
2987 }
2988
2989 #[test]
2990 fn file_path_rejected_with_not_a_directory() {
2991 let (_tmp, cwd, _sub) = make_dirs();
2992 let file = cwd.join("a.txt");
2993 std::fs::write(&file, "hi").expect("write");
2994 let err = resolve_cd(file.to_str().unwrap(), &cwd, None).expect_err("file is not a dir");
2995 assert!(err.contains("Not a directory"), "got: {}", err);
2996 }
2997
2998 #[test]
2999 fn tilde_expands_to_home() {
3000 let Some(home) = crate::platform::home_dir() else {
3003 return;
3004 };
3005 let Ok(canon_home) = home.canonicalize() else {
3006 return;
3007 };
3008 let (_tmp, cwd, _sub) = make_dirs();
3009 let got = resolve_cd("~", &cwd, None).expect("~ resolves");
3010 assert_eq!(got, canon_home);
3011 }
3012
3013 #[test]
3014 fn paths_same_accepts_canonical_equivalents() {
3015 let (_tmp, cwd, sub) = make_dirs();
3016 let via_parent = sub.join("..").join("sub");
3017 assert!(paths_same(&sub, &via_parent));
3018 assert!(!paths_same(&cwd, &sub));
3019 }
3020
3021 #[test]
3022 fn context_report_without_snapshot_prompts_to_run_turn() {
3023 let _g = crate::i18n::test_lock();
3024 crate::i18n::set_locale(crate::i18n::Locale::En);
3025 let out = format_context_report(None, "claude-opus-4-7", false);
3026 assert!(out.contains("run at least one turn"));
3027 assert!(!out.contains("tokens ("));
3029 }
3030
3031 #[test]
3032 fn context_report_with_zero_window_flags_partial_stats() {
3033 let _g = crate::i18n::test_lock();
3034 crate::i18n::set_locale(crate::i18n::Locale::En);
3035 let snap = crate::state::ContextSnapshot {
3036 system_tokens: 100,
3037 sent_tokens: 200,
3038 tool_defs_tokens: 0,
3039 cold_zone_tokens: 0,
3040 total_messages: 5,
3041 ctx_window: 0,
3042 ctx_name: String::new(),
3043 system_prompt: String::new(),
3044 };
3045 let out = format_context_report(Some(&snap), "test-model", false);
3046 assert!(out.contains("waiting for first complete turn"));
3047 }
3048
3049 #[test]
3050 fn context_report_renders_full_breakdown() {
3051 let _g = crate::i18n::test_lock();
3052 crate::i18n::set_locale(crate::i18n::Locale::En);
3053 let snap = crate::state::ContextSnapshot {
3054 system_tokens: 8_000,
3055 sent_tokens: 30_000, tool_defs_tokens: 14_500,
3057 cold_zone_tokens: 2_000,
3058 total_messages: 42,
3059 ctx_window: 128_000,
3060 ctx_name: "default".into(),
3061 system_prompt: String::new(),
3062 };
3063 let out = format_context_report(Some(&snap), "claude-opus-4-7", false);
3064
3065 assert!(out.contains("Context Usage"));
3067 assert!(out.contains("▒") || out.contains("█"));
3069 assert!(out.contains("System prompt"));
3071 assert!(out.contains("Tool defs"));
3072 assert!(out.contains("Cold zone"));
3073 assert!(out.contains("Messages"));
3074 assert!(out.contains("Free"));
3075 assert!(out.contains("8.0K")); assert!(out.contains("14.5K")); assert!(out.contains("2.0K")); assert!(out.contains("128.0K")); assert!(out.contains("42"));
3082 assert!(out.contains("default"));
3084 assert!(out.contains("claude-opus-4-7"));
3085 }
3086
3087 #[test]
3088 fn context_report_messages_excludes_cold_zone() {
3089 let _g = crate::i18n::test_lock();
3090 crate::i18n::set_locale(crate::i18n::Locale::En);
3091 let snap = crate::state::ContextSnapshot {
3095 system_tokens: 1_000,
3096 sent_tokens: 10_000,
3097 tool_defs_tokens: 0,
3098 cold_zone_tokens: 3_000,
3099 total_messages: 10,
3100 ctx_window: 100_000,
3101 ctx_name: "default".into(),
3102 system_prompt: String::new(),
3103 };
3104 let out = format_context_report(Some(&snap), "m", false);
3105 let messages_line = out
3107 .lines()
3108 .find(|l| l.contains("Messages"))
3109 .expect("messages line must exist");
3110 assert!(
3111 messages_line.contains("7.0K"),
3112 "expected Messages=7.0K (sent-cold), got line: {}",
3113 messages_line
3114 );
3115 }
3116
3117 #[test]
3118 fn context_report_free_is_nonneg_under_rounding() {
3119 let _g = crate::i18n::test_lock();
3120 crate::i18n::set_locale(crate::i18n::Locale::En);
3121 let snap = crate::state::ContextSnapshot {
3124 system_tokens: 20_000,
3125 sent_tokens: 80_000,
3126 tool_defs_tokens: 20_000,
3127 cold_zone_tokens: 0,
3128 total_messages: 50,
3129 ctx_window: 120_000,
3130 ctx_name: "default".into(),
3131 system_prompt: String::new(),
3132 };
3133 let out = format_context_report(Some(&snap), "m", false);
3134 assert!(out.contains("Free"));
3137 let free_line = out
3139 .lines()
3140 .find(|l| l.contains("Free"))
3141 .expect("free line must exist");
3142 assert!(free_line.contains("0"), "free line: {}", free_line);
3143 }
3144
3145 #[test]
3146 fn context_report_without_show_prompt_omits_system_prompt_section() {
3147 let _g = crate::i18n::test_lock();
3148 crate::i18n::set_locale(crate::i18n::Locale::En);
3149 let snap = crate::state::ContextSnapshot {
3153 system_tokens: 1_000,
3154 sent_tokens: 5_000,
3155 tool_defs_tokens: 500,
3156 cold_zone_tokens: 0,
3157 total_messages: 8,
3158 ctx_window: 100_000,
3159 ctx_name: "default".into(),
3160 system_prompt: "You are AtomCode.\nSOME SENTINEL BYTES".into(),
3161 };
3162 let out = format_context_report(Some(&snap), "m", false);
3163 assert!(
3164 !out.contains("SYSTEM PROMPT"),
3165 "SYSTEM PROMPT header must not appear in default /context output"
3166 );
3167 assert!(
3168 !out.contains("SOME SENTINEL BYTES"),
3169 "raw prompt body must not leak into default /context output"
3170 );
3171 }
3172
3173 #[test]
3174 fn context_report_with_show_prompt_appends_cached_prompt() {
3175 let _g = crate::i18n::test_lock();
3176 crate::i18n::set_locale(crate::i18n::Locale::En);
3177 let snap = crate::state::ContextSnapshot {
3178 system_tokens: 1_000,
3179 sent_tokens: 5_000,
3180 tool_defs_tokens: 500,
3181 cold_zone_tokens: 0,
3182 total_messages: 8,
3183 ctx_window: 100_000,
3184 ctx_name: "default".into(),
3185 system_prompt: "You are AtomCode.\nRULE_LINE_ABC\nEND".into(),
3186 };
3187 let out = format_context_report(Some(&snap), "m", true);
3188 assert!(out.contains("=== SYSTEM PROMPT ==="));
3189 assert!(
3192 out.contains(" RULE_LINE_ABC"),
3193 "prompt lines should keep content after 2-space indent"
3194 );
3195 assert!(out.contains("Context Usage"));
3197 assert!(out.contains("System prompt"));
3198 }
3199
3200 #[test]
3201 fn context_report_show_prompt_with_empty_cached_prompt_shows_hint() {
3202 let snap = crate::state::ContextSnapshot {
3206 system_tokens: 100,
3207 sent_tokens: 200,
3208 tool_defs_tokens: 0,
3209 cold_zone_tokens: 0,
3210 total_messages: 3,
3211 ctx_window: 100_000,
3212 ctx_name: "default".into(),
3213 system_prompt: String::new(),
3214 };
3215 let out = format_context_report(Some(&snap), "m", true);
3216 assert!(out.contains("=== SYSTEM PROMPT ==="));
3217 assert!(
3218 out.contains("(empty"),
3219 "empty cached prompt must show an explanation, got: {}",
3220 out
3221 );
3222 }
3223}