1pub mod formatting;
4
5use anyhow::{Context, Result};
6use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers};
7use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
8use futures::StreamExt;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader};
13use tokio::sync::mpsc;
14
15use crate::spec_ai_core::agent::core::MemoryRecallStrategy;
16use crate::spec_ai_core::agent::model::{ModelStreamItem, TokenUsage};
17use crate::spec_ai_core::agent::{AgentBuilder, AgentCore, AgentOutput};
18use crate::spec_ai_core::agent::{
19 TranscriptionProvider, create_transcription_provider, create_transcription_provider_simple,
20};
21use crate::spec_ai_core::bootstrap_self::BootstrapSelf;
22use crate::spec_ai_core::config::{AgentProfile, AgentRegistry, AppConfig};
23use crate::spec_ai_core::persistence::Persistence;
24use crate::spec_ai_core::policy::PolicyEngine;
25use crate::spec_ai_core::spec::AgentSpec;
26use terminal_size::terminal_size;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Command {
30 Help,
31 Quit,
32 ConfigReload,
33 ConfigShow,
34 PolicyReload,
35 SwitchAgent(String),
36 ListAgents,
37 MemoryShow(Option<usize>),
38 SessionNew(Option<String>),
39 SessionList,
40 SessionSwitch(String),
41 GraphEnable,
43 GraphDisable,
44 GraphStatus,
45 GraphShow(Option<usize>),
46 GraphClear,
47 SyncList,
49 ListenStart(Option<u64>), ListenStop,
52 ListenStatus,
53 Listen(Option<String>, Option<u64>), PasteStart,
55 RunSpec(PathBuf),
56 SpeechToggle(Option<bool>),
57 Init(Option<Vec<String>>), Refresh(Option<Vec<String>>), Message(String),
60 Empty,
61}
62
63pub fn parse_command(input: &str) -> Command {
64 let line = input.trim();
65 if line.is_empty() {
66 return Command::Empty;
67 }
68
69 if let Some(rest) = line.strip_prefix('/') {
70 let mut parts = rest.split_whitespace();
71 let cmd = parts.next().unwrap_or("").to_lowercase();
72 match cmd.as_str() {
73 "help" | "h" | "?" => Command::Help,
74 "quit" | "q" | "exit" => Command::Quit,
75 "config" => match parts.next() {
76 Some("reload") => Command::ConfigReload,
77 Some("show") => Command::ConfigShow,
78 _ => Command::Help,
79 },
80 "policy" => match parts.next() {
81 Some("reload") => Command::PolicyReload,
82 _ => Command::Help,
83 },
84 "agents" | "list" => Command::ListAgents,
85 "switch" => {
86 let name = parts.next().unwrap_or("").to_string();
87 if name.is_empty() {
88 Command::Help
89 } else {
90 Command::SwitchAgent(name)
91 }
92 }
93 "memory" => match parts.next() {
94 Some("show") => {
95 let n = parts.next().and_then(|s| s.parse::<usize>().ok());
96 Command::MemoryShow(n)
97 }
98 _ => Command::Help,
99 },
100 "session" => match parts.next() {
101 Some("new") => {
102 let id = parts.next().map(|s| s.to_string());
103 Command::SessionNew(id)
104 }
105 Some("list") => Command::SessionList,
106 Some("switch") => {
107 let id = parts.next().unwrap_or("").to_string();
108 if id.is_empty() {
109 Command::Help
110 } else {
111 Command::SessionSwitch(id)
112 }
113 }
114 _ => Command::Help,
115 },
116 "graph" => match parts.next() {
117 Some("enable") => Command::GraphEnable,
118 Some("disable") => Command::GraphDisable,
119 Some("status") => Command::GraphStatus,
120 Some("show") => {
121 let n = parts.next().and_then(|s| s.parse::<usize>().ok());
122 Command::GraphShow(n)
123 }
124 Some("clear") => Command::GraphClear,
125 _ => Command::Help,
126 },
127 "sync" => match parts.next() {
128 Some("list") | None => Command::SyncList,
129 _ => Command::Help,
130 },
131 "listen" => {
132 match parts.next() {
133 Some("stop") => Command::ListenStop,
134 Some("status") => Command::ListenStatus,
135 Some("start") => {
136 let duration = parts.next().and_then(|s| s.parse::<u64>().ok());
137 Command::ListenStart(duration)
138 }
139 Some(duration_str) => {
140 let duration = duration_str.parse::<u64>().ok();
142 Command::ListenStart(duration)
143 }
144 None => Command::ListenStart(None),
145 }
146 }
147 "paste" => Command::PasteStart,
148 "init" => {
149 let plugins = if let Some(arg) = parts.next() {
150 if arg.starts_with("--plugins=") {
151 Some(
152 arg.strip_prefix("--plugins=")
153 .unwrap_or("")
154 .split(',')
155 .map(|p| p.trim().to_string())
156 .collect(),
157 )
158 } else {
159 None
160 }
161 } else {
162 None
163 };
164 Command::Init(plugins)
165 }
166 "refresh" => {
167 let plugins = if let Some(arg) = parts.next() {
168 if arg.starts_with("--plugins=") {
169 Some(
170 arg.strip_prefix("--plugins=")
171 .unwrap_or("")
172 .split(',')
173 .map(|p| p.trim().to_string())
174 .collect(),
175 )
176 } else {
177 None
178 }
179 } else {
180 None
181 };
182 Command::Refresh(plugins)
183 }
184 "spec" => {
185 let args: Vec<&str> = parts.collect();
186 if args.is_empty() {
187 Command::Help
188 } else {
189 let (path_parts, _explicit_run) = if args[0].eq_ignore_ascii_case("run") {
190 (args[1..].to_vec(), true)
191 } else {
192 (args, false)
193 };
194 if path_parts.is_empty() {
195 Command::Help
196 } else {
197 let path = path_parts.join(" ");
198 Command::RunSpec(PathBuf::from(path))
199 }
200 }
201 }
202 "speak" | "voice" => match parts.next() {
203 Some("on") => Command::SpeechToggle(Some(true)),
204 Some("off") => Command::SpeechToggle(Some(false)),
205 Some("toggle") | None => Command::SpeechToggle(None),
206 _ => Command::Help,
207 },
208 _ => Command::Help,
209 }
210 } else {
211 Command::Message(line.to_string())
212 }
213}
214
215struct TranscriptionTask {
217 handle: std::thread::JoinHandle<()>,
218 stop_tx: mpsc::UnboundedSender<()>,
219 started_at: std::time::SystemTime,
220 duration_secs: Option<u64>,
221 chunks_rx: mpsc::UnboundedReceiver<String>,
222}
223
224pub struct CliState {
225 pub config: AppConfig,
226 pub persistence: Persistence,
227 pub registry: AgentRegistry,
228 pub agent: AgentCore,
229 pub transcription_provider: Arc<dyn TranscriptionProvider>,
230 pub reasoning_messages: Vec<String>,
231 pub last_token_usage: Option<crate::spec_ai_core::agent::model::TokenUsage>,
232 pub status_message: String,
233 speech_enabled: Arc<AtomicBool>,
234 paste_mode: bool,
235 paste_buffer: String,
236 init_allowed: bool,
237 transcription_task: Option<TranscriptionTask>,
238}
239
240impl CliState {
241 pub fn initialize() -> Result<Self> {
243 let config = AppConfig::load()?;
244 Self::new_with_config(config)
245 }
246
247 pub fn initialize_with_path(path: Option<PathBuf>) -> Result<Self> {
249 let config = if let Some(config_path) = path {
250 AppConfig::load_from_file(&config_path)?
251 } else {
252 AppConfig::load()?
253 };
254 Self::new_with_config(config)
255 }
256
257 pub fn new_with_config(config: AppConfig) -> Result<Self> {
259 let persistence =
260 Persistence::new(&config.database.path).context("initializing persistence")?;
261
262 let initial_agents = config.agents.clone();
264 let registry = AgentRegistry::new(initial_agents.clone(), persistence.clone());
265 registry.init()?;
266
267 if registry.active_name().is_none() {
269 if let Some(default_name) = &config.default_agent {
270 if registry.get(default_name).is_some() {
271 registry.set_active(default_name)?;
272 }
273 }
274 }
275 if registry.active_name().is_none() {
276 if initial_agents.is_empty() {
278 let default_profile = AgentProfile::default();
279 registry.upsert("default".to_string(), default_profile)?;
280 registry.set_active("default")?;
281 } else {
282 if let Some(first) = registry.list().first().cloned() {
284 registry.set_active(&first)?;
285 }
286 }
287 }
288
289 let agent = AgentBuilder::new_with_registry(®istry, &config, None)?;
291
292 let transcription_provider = {
294 use crate::spec_ai_core::agent::transcription_factory::TranscriptionProviderConfig;
295 let provider_config = TranscriptionProviderConfig {
296 provider: config.audio.provider.clone(),
297 api_key_source: config.audio.api_key_source.clone(),
298 endpoint: config.audio.endpoint.clone(),
299 on_device: config.audio.on_device,
300 settings: serde_json::Value::Null,
301 };
302 create_transcription_provider(&provider_config)
303 .or_else(|_| create_transcription_provider_simple("mock"))
304 .context("Failed to create transcription provider")?
305 };
306
307 let speech_on = cfg!(target_os = "macos") && config.audio.speak_responses;
308
309 let mut state = Self {
310 config,
311 persistence,
312 registry,
313 agent,
314 transcription_provider,
315 reasoning_messages: vec!["Reasoning: idle".to_string()],
316 last_token_usage: None,
317 status_message: "Status: initializing".to_string(),
318 speech_enabled: Arc::new(AtomicBool::new(speech_on)),
319 paste_mode: false,
320 paste_buffer: String::new(),
321 init_allowed: true,
322 transcription_task: None,
323 };
324
325 state.agent.set_speak_responses(speech_on);
326 state.refresh_init_gate()?;
327
328 state.apply_sync_config()?;
330
331 Ok(state)
332 }
333
334 fn apply_sync_config(&self) -> Result<()> {
336 if !self.config.sync.enabled {
337 return Ok(());
338 }
339
340 for ns in &self.config.sync.namespaces {
342 if let Err(e) =
343 self.persistence
344 .graph_set_sync_enabled(&ns.session_id, &ns.graph_name, true)
345 {
346 eprintln!(
347 "Warning: Failed to enable sync for {}/{}: {}",
348 ns.session_id, ns.graph_name, e
349 );
350 }
351 }
352
353 Ok(())
354 }
355
356 async fn save_transcription_chunks(&self, chunks: &[String]) -> usize {
358 let session_id = self.agent.session_id();
359 let mut chunk_count = 0;
360 for (idx, text) in chunks.iter().enumerate() {
361 let timestamp = chrono::Utc::now();
362
363 match self
365 .persistence
366 .insert_transcription(session_id, idx as i64, text, timestamp)
367 {
368 Ok(transcription_id) => {
369 chunk_count += 1;
370
371 if let Some(embedding_id) = self.agent.generate_embedding(text).await {
373 if let Err(e) = self
374 .persistence
375 .update_transcription_embedding(transcription_id, embedding_id)
376 {
377 eprintln!(
378 "[Transcription] Failed to link embedding for chunk {}: {}",
379 idx, e
380 );
381 }
382 }
383 }
384 Err(e) => {
385 eprintln!("[Transcription] Failed to save chunk {}: {}", idx, e);
386 }
387 }
388 }
389 chunk_count
390 }
391
392 pub async fn handle_line(&mut self, line: &str) -> Result<Option<String>> {
394 match parse_command(line) {
395 Command::Empty => Ok(None),
396 Command::Help => Ok(Some(formatting::render_help())),
397 Command::Quit => Ok(Some("__QUIT__".to_string())),
398 Command::ConfigShow => {
399 let summary = self.config.summary();
400 Ok(Some(formatting::render_config(&summary)))
401 }
402 Command::ListAgents => {
403 let agents = self.registry.list();
404 let active = self.registry.active_name();
405 if agents.is_empty() {
406 Ok(Some("No agents configured.".to_string()))
407 } else {
408 let agent_data: Vec<(String, bool, Option<String>)> = agents
409 .into_iter()
410 .map(|name| {
411 let is_active = Some(&name) == active.as_ref();
412 let description =
413 self.registry.get(&name).and_then(|p| p.style.clone());
414 (name, is_active, description)
415 })
416 .collect();
417 Ok(Some(formatting::render_agent_table(agent_data)))
418 }
419 }
420 Command::ConfigReload => {
421 let current_session = self.agent.session_id().to_string();
422 self.config = AppConfig::load()?;
423 self.persistence = Persistence::new(&self.config.database.path)?;
425 self.registry =
427 AgentRegistry::new(self.config.agents.clone(), self.persistence.clone());
428 self.registry.init()?;
429 if let Some(default_name) = &self.config.default_agent {
430 let _ = self.registry.set_active(default_name);
431 }
432 self.agent = AgentBuilder::new_with_registry(
434 &self.registry,
435 &self.config,
436 Some(current_session),
437 )?;
438 let speech_on = cfg!(target_os = "macos") && self.config.audio.speak_responses;
439 self.speech_enabled.store(speech_on, Ordering::Relaxed);
440 self.agent.set_speak_responses(speech_on);
441 self.refresh_init_gate()?;
442 Ok(Some("Configuration reloaded.".to_string()))
443 }
444 Command::PolicyReload => {
445 let policy_engine = PolicyEngine::load_from_persistence(&self.persistence)
447 .context("Failed to load policies from persistence")?;
448 let rule_count = policy_engine.rule_count();
449
450 self.agent
452 .set_policy_engine(std::sync::Arc::new(policy_engine));
453
454 Ok(Some(format!(
455 "Policies reloaded. {} rule(s) active.",
456 rule_count
457 )))
458 }
459 Command::SwitchAgent(name) => {
460 self.registry.set_active(&name)?;
461 let session = self.agent.session_id().to_string();
462 self.agent =
463 AgentBuilder::new_with_registry(&self.registry, &self.config, Some(session))?;
464 let speak_enabled = self.speech_enabled.load(Ordering::Relaxed);
465 self.agent.set_speak_responses(speak_enabled);
466 Ok(Some(format!("Switched active agent to '{}'.", name)))
467 }
468 Command::MemoryShow(n) => {
469 let limit = n.unwrap_or(10) as i64;
470 let sid = self.agent.session_id().to_string();
471 let msgs = self.persistence.list_messages(&sid, limit)?;
472 if msgs.is_empty() {
473 Ok(Some("No messages in this session.".to_string()))
474 } else {
475 let messages: Vec<(String, String)> = msgs
476 .into_iter()
477 .map(|m| (m.role.as_str().to_string(), m.content))
478 .collect();
479 Ok(Some(formatting::render_memory(messages)))
480 }
481 }
482 Command::SessionNew(id_opt) => {
483 let new_id = id_opt.unwrap_or_else(|| {
484 format!("session-{}", chrono::Utc::now().timestamp_millis())
485 });
486 self.agent = AgentBuilder::new_with_registry(
487 &self.registry,
488 &self.config,
489 Some(new_id.clone()),
490 )?;
491 let speak_enabled = self.speech_enabled.load(Ordering::Relaxed);
492 self.agent.set_speak_responses(speak_enabled);
493 self.init_allowed = true;
494 Ok(Some(format!("Started new session '{}'.", new_id)))
495 }
496 Command::SessionList => {
497 let sessions = self.persistence.list_sessions()?;
498 if sessions.is_empty() {
499 return Ok(Some("No sessions yet.".to_string()));
500 }
501 Ok(Some(formatting::render_list(
502 "Sessions (most recent first)",
503 sessions,
504 )))
505 }
506 Command::SessionSwitch(id) => {
507 self.agent = AgentBuilder::new_with_registry(
508 &self.registry,
509 &self.config,
510 Some(id.clone()),
511 )?;
512 let speak_enabled = self.speech_enabled.load(Ordering::Relaxed);
513 self.agent.set_speak_responses(speak_enabled);
514 self.refresh_init_gate()?;
515 Ok(Some(format!("Switched to session '{}'.", id)))
516 }
517 Command::GraphEnable => {
519 Ok(Some(
522 "To enable knowledge graph features, update your spec-ai.config.toml:\n\n\
523 [agents.your_agent_name]\n\
524 enable_graph = true\n\
525 graph_memory = true\n\
526 auto_graph = true\n\
527 graph_steering = true\n\
528 graph_depth = 3\n\
529 graph_weight = 0.5\n\
530 graph_threshold = 0.7\n\n\
531 Then run: /config reload"
532 .to_string(),
533 ))
534 }
535 Command::GraphDisable => {
536 Ok(Some(
538 "To disable knowledge graph features, update your spec-ai.config.toml:\n\n\
539 [agents.your_agent_name]\n\
540 enable_graph = false\n\n\
541 Then run: /config reload"
542 .to_string(),
543 ))
544 }
545 Command::GraphStatus => {
546 let profile = self.agent.profile();
547 let status = format!(
548 "Knowledge Graph Configuration:\n \
549 Enabled: {}\n \
550 Graph Memory: {}\n \
551 Auto Build: {}\n \
552 Graph Steering: {}\n \
553 Traversal Depth: {}\n \
554 Graph Weight: {:.2}\n \
555 Tool Threshold: {:.2}",
556 profile.enable_graph,
557 profile.graph_memory,
558 profile.auto_graph,
559 profile.graph_steering,
560 profile.graph_depth,
561 profile.graph_weight,
562 profile.graph_threshold,
563 );
564 Ok(Some(status))
565 }
566 Command::GraphShow(limit) => {
567 let limit_val = limit.unwrap_or(10) as i64;
568 let session_id = self.agent.session_id();
569 let nodes = self
570 .persistence
571 .list_graph_nodes(session_id, None, Some(limit_val))?;
572
573 if nodes.is_empty() {
574 Ok(Some("No graph nodes in current session.".to_string()))
575 } else {
576 let mut output = format!(
577 "Graph Nodes (showing {} of {}):\n",
578 nodes.len(),
579 nodes.len()
580 );
581 for node in &nodes {
582 output.push_str(&format!(
583 " [{:?}] {} - {}\n",
584 node.node_type,
585 node.label,
586 node.properties["name"].as_str().unwrap_or("unnamed")
587 ));
588 }
589
590 let edges = self.persistence.list_graph_edges(session_id, None, None)?;
592 output.push_str(&format!("\nTotal edges: {}", edges.len()));
593
594 Ok(Some(output))
595 }
596 }
597 Command::GraphClear => {
598 let session_id = self.agent.session_id();
599
600 let nodes = self.persistence.list_graph_nodes(session_id, None, None)?;
602 let count = nodes.len();
603
604 for node in nodes {
605 self.persistence.delete_graph_node(node.id)?;
606 }
607
608 Ok(Some(format!(
609 "Cleared {} graph nodes for session '{}'",
610 count, session_id
611 )))
612 }
613 Command::SyncList => {
615 let sync_enabled = self.persistence.graph_list_sync_enabled()?;
616
617 if sync_enabled.is_empty() {
618 Ok(Some("No graphs currently have sync enabled.".to_string()))
619 } else {
620 let mut output = String::from("Sync-enabled graphs:\n");
621 for (session_id, graph_name) in &sync_enabled {
622 output.push_str(&format!(" - {}/{}\n", session_id, graph_name));
623 }
624 Ok(Some(output))
625 }
626 }
627 Command::ListenStart(duration) => {
628 use crate::spec_ai_core::agent::{TranscriptionConfig, TranscriptionEvent};
629 use futures::StreamExt;
630
631 if self.transcription_task.is_some() {
633 return Ok(Some(
634 "Transcription is already running. Use /listen stop to stop it first."
635 .to_string(),
636 ));
637 }
638
639 let config = TranscriptionConfig {
641 duration_secs: duration.or(Some(self.config.audio.default_duration_secs)),
642 chunk_duration_secs: self.config.audio.chunk_duration_secs,
643 model: self
644 .config
645 .audio
646 .model
647 .clone()
648 .unwrap_or_else(|| "whisper-1".to_string()),
649 out_file: self.config.audio.out_file.clone(),
650 language: self.config.audio.language.clone(),
651 endpoint: self.config.audio.endpoint.clone(),
652 };
653
654 let (stop_tx, mut stop_rx) = mpsc::unbounded_channel::<()>();
656 let (chunks_tx, chunks_rx) = mpsc::unbounded_channel::<String>();
657
658 let provider = Arc::clone(&self.transcription_provider);
660 let provider_name = provider.metadata().name.clone();
661 let provider_name_display = provider_name.clone(); let started_at = std::time::SystemTime::now();
663
664 let handle = std::thread::spawn(move || {
666 let rt = tokio::runtime::Builder::new_current_thread()
668 .enable_all()
669 .build()
670 .expect("Failed to create runtime");
671
672 let local = tokio::task::LocalSet::new();
673
674 local.block_on(&rt, async move {
675 let stream_result = provider.start_transcription(&config).await;
677
678 match stream_result {
679 Ok(mut stream) => {
680 println!("\n[Transcription] Started using {}", provider_name);
681
682 loop {
683 tokio::select! {
684 _ = stop_rx.recv() => {
686 println!("\n[Transcription] Stopped by user");
687 break;
688 }
689 event = stream.next() => {
691 match event {
692 Some(Ok(TranscriptionEvent::Started { .. })) => {
693 }
695 Some(Ok(TranscriptionEvent::Transcription { chunk_id, text, .. })) => {
696 println!("[Transcription] Chunk {}: {}", chunk_id, text);
697 let _ = chunks_tx.send(text);
698 }
699 Some(Ok(TranscriptionEvent::Error { chunk_id, message })) => {
700 eprintln!("[Transcription] Error in chunk {}: {}", chunk_id, message);
701 }
702 Some(Ok(TranscriptionEvent::Completed { total_chunks, .. })) => {
703 println!("[Transcription] Completed. Processed {} chunks.", total_chunks);
704 break;
705 }
706 Some(Err(e)) => {
707 eprintln!("[Transcription] Error: {}", e);
708 break;
709 }
710 None => {
711 break;
712 }
713 }
714 }
715 }
716 }
717 }
718 Err(e) => {
719 eprintln!("[Transcription] Failed to start: {}", e);
720 }
721 }
722 })
723 });
724
725 self.transcription_task = Some(TranscriptionTask {
727 handle,
728 stop_tx,
729 started_at,
730 duration_secs: duration.or(Some(self.config.audio.default_duration_secs)),
731 chunks_rx,
732 });
733
734 Ok(Some(format!(
735 "Started background transcription using {} (duration: {} seconds)\nUse /listen stop to stop, /listen status to check status.",
736 provider_name_display,
737 duration
738 .or(Some(self.config.audio.default_duration_secs))
739 .unwrap_or(30)
740 )))
741 }
742 Command::ListenStop => {
743 if let Some(mut task) = self.transcription_task.take() {
744 let _ = task.stop_tx.send(());
746
747 let mut chunks = Vec::new();
749 while let Ok(text) = task.chunks_rx.try_recv() {
750 chunks.push(text);
751 }
752
753 let chunk_count = self.save_transcription_chunks(&chunks).await;
755
756 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
757
758 Ok(Some(format!(
759 "Stopped transcription (ran for {} seconds, saved {} chunks to database)",
760 elapsed, chunk_count
761 )))
762 } else {
763 Ok(Some("No transcription is currently running.".to_string()))
764 }
765 }
766 Command::ListenStatus => {
767 if let Some(task) = self.transcription_task.take() {
769 if task.handle.is_finished() {
770 let mut chunks = Vec::new();
772 let mut chunks_rx = task.chunks_rx;
773 while let Ok(text) = chunks_rx.try_recv() {
774 chunks.push(text);
775 }
776
777 let chunk_count = self.save_transcription_chunks(&chunks).await;
779
780 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
781
782 return Ok(Some(format!(
783 "Transcription completed (ran for {} seconds, saved {} chunks to database)",
784 elapsed, chunk_count
785 )));
786 } else {
787 self.transcription_task = Some(task);
789 }
790 }
791
792 if let Some(ref task) = self.transcription_task {
793 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
794
795 let duration_info = if let Some(dur) = task.duration_secs {
796 format!("/{} seconds", dur)
797 } else {
798 String::from(" (continuous)")
799 };
800
801 Ok(Some(format!(
802 "Transcription status: running\nElapsed: {}{}\nUse /listen stop to stop and save chunks.",
803 elapsed, duration_info
804 )))
805 } else {
806 Ok(Some("No transcription is currently running.\nUse /listen start [duration] to start.".to_string()))
807 }
808 }
809 Command::Listen(_scenario, duration) => {
810 Ok(Some(format!(
812 "The /listen command has been updated. Use:\n /listen start [duration] - Start background transcription\n /listen stop - Stop transcription\n /listen status - Check status\n\nStarting transcription with {} seconds...",
813 duration.unwrap_or(self.config.audio.default_duration_secs)
814 )))
815 }
816 Command::PasteStart => {
817 Ok(Some(
819 "Entering paste mode. Paste your block and finish with /end on its own line."
820 .to_string(),
821 ))
822 }
823 Command::RunSpec(path) => {
824 let output = self.run_spec_command(&path).await?;
825 Ok(output)
826 }
827 Command::SpeechToggle(_mode) => {
828 #[cfg(target_os = "macos")]
829 {
830 let new_state = match _mode {
831 Some(explicit) => {
832 self.speech_enabled.store(explicit, Ordering::Relaxed);
833 explicit
834 }
835 None => !self.speech_enabled.fetch_xor(true, Ordering::Relaxed),
836 };
837 self.config.audio.speak_responses = new_state;
838 self.agent.set_speak_responses(new_state);
839 let status = if new_state { "enabled" } else { "disabled" };
840 Ok(Some(format!("Speech playback {}", status)))
841 }
842
843 #[cfg(not(target_os = "macos"))]
844 {
845 Ok(Some(
846 "Speech playback requires macOS and is not available on this platform."
847 .to_string(),
848 ))
849 }
850 }
851 Command::Init(plugins) => {
852 if !self.init_allowed {
853 return Ok(Some(
854 "The /init command must be the first action in a session. Start a new session to run it again."
855 .to_string(),
856 ));
857 }
858 let bootstrapper =
859 BootstrapSelf::from_environment(&self.persistence, self.agent.session_id())?;
860 let outcome = bootstrapper.run_with_plugins(plugins.clone())?;
861 self.init_allowed = false;
862 Ok(Some(format!(
863 "Knowledge graph bootstrap complete for '{}': {} nodes and {} edges captured ({} components, {} documents).",
864 outcome.repository_name,
865 outcome.nodes_created,
866 outcome.edges_created,
867 outcome.component_count,
868 outcome.document_count
869 )))
870 }
871 Command::Refresh(plugins) => {
872 let bootstrapper =
873 BootstrapSelf::from_environment(&self.persistence, self.agent.session_id())?;
874 let outcome = bootstrapper.refresh_with_plugins(plugins.clone())?;
875 self.init_allowed = false;
876 Ok(Some(format!(
877 "Knowledge graph refresh complete for '{}': {} nodes and {} edges captured ({} components, {} documents).",
878 outcome.repository_name,
879 outcome.nodes_created,
880 outcome.edges_created,
881 outcome.component_count,
882 outcome.document_count
883 )))
884 }
885 Command::Message(text) => {
886 self.init_allowed = false;
887 let speak_enabled = self.speech_enabled.load(Ordering::Relaxed);
888 self.config.audio.speak_responses = speak_enabled;
889 self.agent.set_speak_responses(speak_enabled);
890 let output = self.agent.run_step(&text).await?;
891 self.update_reasoning_from_output(&output);
892 self.maybe_speak_response(&output.response);
893 let mut formatted =
894 formatting::render_agent_response("assistant", &output.response);
895 let show_reasoning = self.agent.profile().show_reasoning;
896 if let Some(stats) = formatting::render_run_stats(&output, show_reasoning) {
897 formatted.push('\n');
898 formatted.push_str(&stats);
899 }
900 Ok(Some(formatted))
901 }
902 }
903 }
904
905 pub async fn run_repl(&mut self) -> Result<()> {
907 let stdin = io::stdin();
908 let mut reader = BufReader::new(stdin);
909 let mut line = String::new();
910 let mut stdout = tokio::io::stdout();
911
912 stdout.write_all(self.config.summary().as_bytes()).await?;
914 stdout.write_all(b"\nType /help for commands.\n").await?;
915 stdout.flush().await?;
916
917 self.set_status_idle();
918 loop {
919 self.render_reasoning_prompt(&mut stdout).await?;
920 line.clear();
921 let n = reader.read_line(&mut line).await?;
922 if n == 0 {
923 break;
924 } let trimmed = line.trim_end_matches(&['\n', '\r'][..]);
927
928 if self.paste_mode {
932 if trimmed == "/end" {
933 self.paste_mode = false;
935 let full_input = std::mem::take(&mut self.paste_buffer);
936 let command_preview = Command::Message(full_input.clone());
937 self.update_status_for_command(&command_preview);
938 if !matches!(command_preview, Command::Empty) {
939 self.render_status_line(&mut stdout).await?;
940 }
941 if let Some(out) = self.handle_line(&full_input).await? {
942 if out == "__QUIT__" {
943 break;
944 }
945 stdout.write_all(out.as_bytes()).await?;
946 if !out.ends_with('\n') {
947 stdout.write_all(b"\n").await?;
948 }
949 stdout.flush().await?;
950 }
951 self.set_status_idle();
952 } else if !trimmed.is_empty() {
953 if !self.paste_buffer.is_empty() {
954 self.paste_buffer.push('\n');
955 }
956 self.paste_buffer.push_str(trimmed);
957 }
958 continue;
959 }
960
961 let command_preview = parse_command(&line);
963 if matches!(command_preview, Command::PasteStart) {
964 self.paste_mode = true;
966 self.paste_buffer.clear();
967 self.status_message =
968 "Status: paste mode (end with /end on its own line)".to_string();
969 self.render_status_line(&mut stdout).await?;
970 continue;
971 }
972
973 self.update_status_for_command(&command_preview);
974 if !matches!(command_preview, Command::Empty) {
975 self.render_status_line(&mut stdout).await?;
976 }
977
978 if matches!(command_preview, Command::Message(_)) {
980 #[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
981 let speech_flag = self.speech_enabled.clone();
982 #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
983 let mut pending_speech_toggle: Option<bool> = None;
984 let mut fut = Box::pin(self.handle_line(&line));
986
987 let _ = enable_raw_mode();
989 let mut events = EventStream::new();
990 let mut interrupted = false;
991
992 let out = loop {
994 tokio::select! {
995 res = &mut fut => {
996 let _ = disable_raw_mode();
997 break Some(res?);
998 }
999 maybe_event = events.next() => {
1000 match maybe_event {
1001 Some(Ok(Event::Key(key))) => {
1002 if key.code == KeyCode::Esc {
1003 interrupted = true;
1005 let _ = disable_raw_mode();
1006 break None;
1007 } else if key.code == KeyCode::Char('s')
1008 && key.modifiers.contains(KeyModifiers::CONTROL)
1009 {
1010 #[cfg(target_os = "macos")]
1011 {
1012 let now =
1013 !speech_flag.fetch_xor(true, Ordering::Relaxed);
1014 pending_speech_toggle = Some(now);
1015 println!(
1016 "\n[Speech] Playback {}",
1017 if now { "enabled" } else { "disabled" }
1018 );
1019 }
1020 #[cfg(not(target_os = "macos"))]
1021 {
1022 println!("\n[Speech] Playback unavailable on this platform");
1023 }
1024 }
1025 }
1026 Some(Ok(_)) => { }
1027 Some(Err(_)) => { }
1028 None => { }
1029 }
1030 }
1031 }
1032 };
1033
1034 drop(fut);
1036 if let Some(state) = pending_speech_toggle {
1037 self.config.audio.speak_responses = state;
1038 self.agent.set_speak_responses(state);
1039 }
1040 let _ = disable_raw_mode();
1041
1042 if interrupted {
1043 self.status_message = "Status: interrupted".to_string();
1045 self.render_status_line(&mut stdout).await?;
1046 self.set_status_idle();
1047 } else if let Some(out_opt) = out {
1048 if let Some(out) = out_opt {
1049 if out == "__QUIT__" {
1050 break;
1051 }
1052 stdout.write_all(out.as_bytes()).await?;
1053 if !out.ends_with('\n') {
1054 stdout.write_all(b"\n").await?;
1055 }
1056 stdout.flush().await?;
1057 }
1058 self.set_status_idle();
1059 }
1060 } else {
1061 if let Some(out) = self.handle_line(&line).await? {
1062 if out == "__QUIT__" {
1063 break;
1064 }
1065 stdout.write_all(out.as_bytes()).await?;
1066 if !out.ends_with('\n') {
1067 stdout.write_all(b"\n").await?;
1068 }
1069 stdout.flush().await?;
1070 }
1071 self.set_status_idle();
1072 }
1073 }
1074
1075 let _ = self.persistence.checkpoint();
1077
1078 Ok(())
1079 }
1080
1081 async fn run_spec_command(&mut self, path: &Path) -> Result<Option<String>> {
1082 let spec = AgentSpec::from_file(path)?;
1083 let mut intro = format!("Executing spec `{}`", spec.display_name());
1084 if let Some(source) = spec.source_path() {
1085 intro.push_str(&format!(" ({})", source.display()));
1086 }
1087 intro.push('\n');
1088
1089 let preview = spec.preview();
1090 if !preview.is_empty() {
1091 intro.push('\n');
1092 intro.push_str(&preview);
1093 intro.push_str("\n\n");
1094 }
1095
1096 let speak_enabled = self.speech_enabled.load(Ordering::Relaxed);
1097 self.config.audio.speak_responses = speak_enabled;
1098 self.agent.set_speak_responses(speak_enabled);
1099 if self.agent.supports_streaming() {
1100 let mut stdout = io::stdout();
1101 stdout.write_all(intro.as_bytes()).await?;
1102 let header = if formatting::is_terminal() {
1103 "assistant:\n\n"
1104 } else {
1105 "assistant: "
1106 };
1107 stdout.write_all(header.as_bytes()).await?;
1108 stdout.flush().await?;
1109
1110 let response = self.stream_spec_response(&spec).await?;
1111 self.maybe_speak_response(&response);
1112 if !response.ends_with('\n') {
1113 stdout.write_all(b"\n").await?;
1114 }
1115 stdout.flush().await?;
1116 self.reasoning_messages.clear();
1117 Ok(None)
1118 } else {
1119 let output = self.agent.run_spec(&spec).await?;
1120 self.update_reasoning_from_output(&output);
1121 self.maybe_speak_response(&output.response);
1122 intro.push_str(&formatting::render_agent_response(
1123 "assistant",
1124 &output.response,
1125 ));
1126 let show_reasoning = self.agent.profile().show_reasoning;
1127 if let Some(stats) = formatting::render_run_stats(&output, show_reasoning) {
1128 intro.push('\n');
1129 intro.push_str(&stats);
1130 }
1131
1132 Ok(Some(intro))
1133 }
1134 }
1135
1136 async fn stream_spec_response(&mut self, spec: &AgentSpec) -> Result<String> {
1137 let prompt = spec.to_prompt();
1138 let mut stream = self.agent.run_step_streaming(&prompt).await?;
1139 let mut response = String::new();
1140 let mut stdout = io::stdout();
1141 let mut token_usage = None;
1142 let mut finish_reason = None;
1143
1144 while let Some(item) = stream.next().await {
1145 match item? {
1146 ModelStreamItem::Content(chunk) => {
1147 response.push_str(&chunk);
1148 stdout.write_all(chunk.as_bytes()).await?;
1149 stdout.flush().await?;
1150 }
1151 ModelStreamItem::Usage(usage) => {
1152 token_usage = Some(usage);
1153 }
1154 ModelStreamItem::FinishReason(reason) => {
1155 finish_reason = Some(reason);
1156 }
1157 }
1158 }
1159
1160 let output = self
1161 .agent
1162 .finalize_streaming_step(&response, token_usage, finish_reason)
1163 .await?;
1164 self.update_reasoning_from_output(&output);
1165
1166 Ok(response)
1167 }
1168
1169 pub fn update_reasoning_from_output(&mut self, output: &AgentOutput) {
1170 self.last_token_usage = output.token_usage.clone();
1171 self.update_reasoning_messages(output);
1172 }
1173
1174 fn update_reasoning_messages(&mut self, output: &AgentOutput) {
1175 self.reasoning_messages = Self::format_reasoning_messages(output);
1176 }
1177
1178 fn format_reasoning_messages(output: &AgentOutput) -> Vec<String> {
1179 let mut lines = Vec::with_capacity(3);
1180
1181 if let Some(stats) = &output.recall_stats {
1182 match &stats.strategy {
1183 MemoryRecallStrategy::Semantic {
1184 requested,
1185 returned,
1186 } => lines.push(format!(
1187 "Recall: semantic (requested {}, returned {})",
1188 requested, returned
1189 )),
1190 MemoryRecallStrategy::RecentContext { limit } => {
1191 lines.push(format!("Recall: recent context (last {} messages)", limit))
1192 }
1193 }
1194 } else {
1195 lines.push("Recall: not used".to_string());
1196 }
1197
1198 if let Some(invocation) = output.tool_invocations.last() {
1199 let status = if invocation.success { "ok" } else { "err" };
1200 lines.push(format!("Tool: {} ({})", invocation.name, status));
1201 } else {
1202 lines.push("Tool: idle".to_string());
1203 }
1204
1205 let mut third_line = String::new();
1206 if let Some(usage) = &output.token_usage {
1207 third_line.push_str(&format!(
1208 "Tokens: P {} C {} T {}",
1209 usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
1210 ));
1211 }
1212
1213 if let Some(reason) = &output.finish_reason {
1214 if !third_line.is_empty() {
1215 third_line.push_str(" | ");
1216 }
1217 third_line.push_str(&format!("Finish: {}", reason));
1218 }
1219
1220 if third_line.is_empty() {
1221 third_line.push_str("Finish: pending");
1222 }
1223 lines.push(third_line);
1224
1225 lines
1226 }
1227
1228 fn set_status_idle(&mut self) {
1229 self.status_message = "Status: awaiting input".to_string();
1230 }
1231
1232 fn update_status_for_command(&mut self, command: &Command) {
1233 self.status_message = Self::status_message_for_command(command);
1234 }
1235
1236 fn status_message_for_command(command: &Command) -> String {
1237 match command {
1238 Command::Empty => "Status: awaiting input".to_string(),
1239 Command::Help => "Status: showing help".to_string(),
1240 Command::Quit => "Status: exiting".to_string(),
1241 Command::ConfigReload => "Status: reloading configuration".to_string(),
1242 Command::ConfigShow => "Status: displaying configuration".to_string(),
1243 Command::PolicyReload => "Status: reloading policies".to_string(),
1244 Command::SwitchAgent(name) => {
1245 format!("Status: switching to agent '{}'", name)
1246 }
1247 Command::ListAgents => "Status: listing agents".to_string(),
1248 Command::MemoryShow(Some(limit)) => {
1249 format!("Status: showing last {} messages", limit)
1250 }
1251 Command::MemoryShow(None) => "Status: showing recent messages".to_string(),
1252 Command::SessionNew(Some(id)) => {
1253 format!("Status: starting session '{}'", id)
1254 }
1255 Command::SessionNew(None) => "Status: starting new session".to_string(),
1256 Command::SessionList => "Status: listing sessions".to_string(),
1257 Command::SessionSwitch(id) => {
1258 format!("Status: switching to session '{}'", id)
1259 }
1260 Command::GraphEnable => "Status: showing graph enable instructions".to_string(),
1261 Command::GraphDisable => "Status: showing graph disable instructions".to_string(),
1262 Command::GraphStatus => "Status: showing graph status".to_string(),
1263 Command::GraphShow(Some(limit)) => {
1264 format!("Status: inspecting graph (limit {})", limit)
1265 }
1266 Command::GraphShow(None) => "Status: inspecting graph".to_string(),
1267 Command::GraphClear => "Status: clearing session graph".to_string(),
1268 Command::SyncList => "Status: listing sync-enabled graphs".to_string(),
1269 Command::Init(_) => "Status: bootstrapping repository graph".to_string(),
1270 Command::ListenStart(duration) => {
1271 let mut status = "Status: starting background transcription".to_string();
1272 if let Some(d) = duration {
1273 status.push_str(&format!(" for {} seconds", d));
1274 }
1275 status
1276 }
1277 Command::ListenStop => "Status: stopping transcription".to_string(),
1278 Command::ListenStatus => "Status: checking transcription status".to_string(),
1279 Command::Listen(scenario, duration) => {
1280 let mut status = "Status: starting audio transcription".to_string();
1281 if let Some(s) = scenario {
1282 status.push_str(&format!(" (scenario: {})", s));
1283 }
1284 if let Some(d) = duration {
1285 status.push_str(&format!(" for {} seconds", d));
1286 }
1287 status
1288 }
1289 Command::RunSpec(path) => {
1290 format!("Status: executing spec '{}'", path.display())
1291 }
1292 Command::PasteStart => {
1293 "Status: entering paste mode (end with /end on its own line)".to_string()
1294 }
1295 Command::SpeechToggle(Some(true)) => "Status: enabling speech playback".to_string(),
1296 Command::SpeechToggle(Some(false)) => "Status: disabling speech playback".to_string(),
1297 Command::SpeechToggle(None) => "Status: toggling speech playback".to_string(),
1298 Command::Message(_) => "Status: running agent step".to_string(),
1299 Command::Refresh(_) => "Status: refreshing internal knowledge graph".to_string(),
1300 }
1301 }
1302
1303 fn pad_line_to_width(line: &str, width: usize) -> String {
1304 if width == 0 {
1305 return String::new();
1306 }
1307 let truncated: String = line.chars().take(width).collect();
1308 let truncated_len = truncated.chars().count();
1309 if truncated_len >= width {
1310 return truncated;
1311 }
1312 let mut padded = truncated;
1313 padded.push_str(&" ".repeat(width - truncated_len));
1314 padded
1315 }
1316
1317 fn reasoning_display_lines(&self, width: usize) -> Vec<String> {
1318 (0..3)
1319 .map(|idx| {
1320 let content = self
1321 .reasoning_messages
1322 .get(idx)
1323 .map(String::as_str)
1324 .unwrap_or("");
1325 Self::pad_line_to_width(content, width)
1326 })
1327 .collect()
1328 }
1329
1330 fn status_display_line(&self, width: usize) -> String {
1331 Self::pad_line_to_width(&self.status_message, width)
1332 }
1333
1334 fn input_display_width(&self) -> usize {
1335 let terminal_width = terminal_size().map(|(w, _)| w.0 as usize).unwrap_or(80);
1336 let prompt_len = self.config.ui.prompt.chars().count();
1337 if terminal_width <= prompt_len {
1338 1
1339 } else {
1340 terminal_width - prompt_len
1341 }
1342 }
1343
1344 async fn render_reasoning_prompt(&self, stdout: &mut io::Stdout) -> Result<()> {
1345 let width = self.input_display_width();
1346 for line in self.reasoning_display_lines(width) {
1347 stdout.write_all(line.as_bytes()).await?;
1348 stdout.write_all(b"\n").await?;
1349 }
1350 stdout.write_all(b"\n").await?;
1351 let status_line = self.status_display_line(width);
1352 stdout.write_all(status_line.as_bytes()).await?;
1353 stdout.write_all(b"\n").await?;
1354 stdout.write_all(self.config.ui.prompt.as_bytes()).await?;
1355 stdout.flush().await?;
1356 Ok(())
1357 }
1358
1359 async fn render_status_line(&self, stdout: &mut io::Stdout) -> Result<()> {
1360 let width = self.input_display_width();
1361 let status_line = self.status_display_line(width);
1362 stdout.write_all(status_line.as_bytes()).await?;
1363 stdout.write_all(b"\n").await?;
1364 stdout.flush().await?;
1365 Ok(())
1366 }
1367
1368 fn refresh_init_gate(&mut self) -> Result<()> {
1369 let messages = self.persistence.list_messages(self.agent.session_id(), 1)?;
1370 self.init_allowed = messages.is_empty();
1371 Ok(())
1372 }
1373
1374 #[cfg(target_os = "macos")]
1376 pub fn maybe_speak_response(&self, text: &str) {
1377 if !self.speech_enabled.load(Ordering::Relaxed) {
1378 return;
1379 }
1380
1381 let spoken = text.trim();
1382 if spoken.is_empty() {
1383 return;
1384 }
1385
1386 let mut command = tokio::process::Command::new("say");
1387 command.arg(spoken);
1388
1389 match command.spawn() {
1390 Ok(mut child) => {
1391 tokio::spawn(async move {
1392 let _ = child.wait().await;
1393 });
1394 }
1395 Err(err) => eprintln!("[Speech] Failed to invoke `say`: {}", err),
1396 }
1397 }
1398
1399 #[cfg(not(target_os = "macos"))]
1401 pub fn maybe_speak_response(&self, _text: &str) {}
1402}
1403
1404#[cfg(test)]
1405mod tests {
1406 use super::*;
1407 use crate::spec_ai_core::agent::AgentOutput;
1408 use crate::spec_ai_core::agent::core::{MemoryRecallStats, MemoryRecallStrategy, ToolInvocation};
1409 use crate::spec_ai_core::agent::model::TokenUsage;
1410 use crate::spec_ai_core::agent::safety::SafetyStats;
1411 use crate::spec_ai_core::config::{
1412 AudioConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig, PluginConfig,
1413 SafetyConfig, SyncConfig, UiConfig,
1414 };
1415 use serde_json::json;
1416 use std::collections::HashMap;
1417 use std::path::PathBuf;
1418 use tempfile::tempdir;
1419
1420 #[test]
1421 fn pad_line_to_width_padding_and_truncation() {
1422 let padded = CliState::pad_line_to_width("abc", 5);
1424 assert_eq!(padded, "abc ");
1425
1426 let exact = CliState::pad_line_to_width("hello", 5);
1428 assert_eq!(exact, "hello");
1429
1430 let truncated = CliState::pad_line_to_width("helloworld", 4);
1432 assert_eq!(truncated, "hell");
1433
1434 let zero = CliState::pad_line_to_width("anything", 0);
1436 assert_eq!(zero, "");
1437
1438 let unicode_trunc = CliState::pad_line_to_width("🔥fire", 2);
1440 assert_eq!(unicode_trunc, "🔥f");
1441
1442 let unicode_pad = CliState::pad_line_to_width("🔥", 3);
1443 assert_eq!(unicode_pad, "🔥 ");
1444 }
1445
1446 #[test]
1447 fn test_parse_commands() {
1448 assert_eq!(parse_command("/help"), Command::Help);
1449 assert_eq!(parse_command("/quit"), Command::Quit);
1450 assert_eq!(parse_command("/config reload"), Command::ConfigReload);
1451 assert_eq!(parse_command("/config show"), Command::ConfigShow);
1452 assert_eq!(parse_command("/agents"), Command::ListAgents);
1453 assert_eq!(parse_command("/list"), Command::ListAgents);
1454 assert_eq!(parse_command("/init"), Command::Init(None));
1455 assert_eq!(
1456 parse_command("/init --plugins=rust-cargo"),
1457 Command::Init(Some(vec!["rust-cargo".to_string()]))
1458 );
1459 assert_eq!(
1460 parse_command("/init --plugins=rust-cargo,python"),
1461 Command::Init(Some(vec!["rust-cargo".to_string(), "python".to_string()]))
1462 );
1463 assert_eq!(
1464 parse_command("/switch coder"),
1465 Command::SwitchAgent("coder".into())
1466 );
1467 assert_eq!(
1468 parse_command("/memory show 5"),
1469 Command::MemoryShow(Some(5))
1470 );
1471 assert_eq!(parse_command("/session list"), Command::SessionList);
1472 assert_eq!(parse_command("/session new"), Command::SessionNew(None));
1473 assert_eq!(
1474 parse_command("/session new s2"),
1475 Command::SessionNew(Some("s2".into()))
1476 );
1477 assert_eq!(
1478 parse_command("/session switch abc"),
1479 Command::SessionSwitch("abc".into())
1480 );
1481 assert_eq!(
1482 parse_command("/spec run plan.spec"),
1483 Command::RunSpec(PathBuf::from("plan.spec"))
1484 );
1485 assert_eq!(
1486 parse_command("/spec nested/path/my.spec"),
1487 Command::RunSpec(PathBuf::from("nested/path/my.spec"))
1488 );
1489 assert_eq!(parse_command("/speak"), Command::SpeechToggle(None));
1490 assert_eq!(
1491 parse_command("/speak on"),
1492 Command::SpeechToggle(Some(true))
1493 );
1494 assert_eq!(parse_command("hello"), Command::Message("hello".into()));
1495 assert_eq!(parse_command(" "), Command::Empty);
1496 }
1497
1498 #[test]
1499 fn reasoning_messages_default() {
1500 let output = AgentOutput {
1501 response: String::new(),
1502 response_message_id: None,
1503 token_usage: None,
1504 tool_invocations: Vec::new(),
1505 finish_reason: None,
1506 recall_stats: None,
1507 run_id: "run-default".to_string(),
1508 next_action: None,
1509 reasoning: None,
1510 reasoning_summary: None,
1511 graph_debug: None,
1512 safety: SafetyStats::default(),
1513 };
1514 let lines = CliState::format_reasoning_messages(&output);
1515 assert_eq!(
1516 lines,
1517 vec![
1518 "Recall: not used".to_string(),
1519 "Tool: idle".to_string(),
1520 "Finish: pending".to_string()
1521 ]
1522 );
1523 }
1524
1525 #[test]
1526 fn reasoning_messages_with_details() {
1527 let stats = MemoryRecallStats {
1528 strategy: MemoryRecallStrategy::Semantic {
1529 requested: 5,
1530 returned: 2,
1531 },
1532 matches: Vec::new(),
1533 };
1534 let invocation = ToolInvocation {
1535 name: "search".to_string(),
1536 arguments: json!({}),
1537 success: true,
1538 output: Some("ok".to_string()),
1539 error: None,
1540 };
1541 let output = AgentOutput {
1542 response: String::new(),
1543 response_message_id: None,
1544 token_usage: None,
1545 tool_invocations: vec![invocation],
1546 finish_reason: Some("stop".to_string()),
1547 recall_stats: Some(stats),
1548 run_id: "run-details".to_string(),
1549 next_action: None,
1550 reasoning: None,
1551 reasoning_summary: None,
1552 graph_debug: None,
1553 safety: SafetyStats::default(),
1554 };
1555 let lines = CliState::format_reasoning_messages(&output);
1556 assert!(lines[0].starts_with("Recall: semantic"));
1557 assert!(lines[1].contains("search"));
1558 assert_eq!(lines[2], "Finish: stop");
1559 }
1560
1561 #[test]
1562 fn reasoning_messages_tokens() {
1563 let usage = TokenUsage {
1564 prompt_tokens: 4,
1565 completion_tokens: 6,
1566 total_tokens: 10,
1567 };
1568 let output = AgentOutput {
1569 response: String::new(),
1570 response_message_id: None,
1571 token_usage: Some(usage),
1572 tool_invocations: Vec::new(),
1573 finish_reason: None,
1574 recall_stats: None,
1575 run_id: "run-tokens".to_string(),
1576 next_action: None,
1577 reasoning: None,
1578 reasoning_summary: None,
1579 graph_debug: None,
1580 safety: SafetyStats::default(),
1581 };
1582 let lines = CliState::format_reasoning_messages(&output);
1583 assert_eq!(lines[2], "Tokens: P 4 C 6 T 10");
1584 }
1585
1586 #[test]
1587 fn reasoning_messages_combined() {
1588 let usage = TokenUsage {
1589 prompt_tokens: 10,
1590 completion_tokens: 20,
1591 total_tokens: 30,
1592 };
1593 let output = AgentOutput {
1594 response: String::new(),
1595 response_message_id: None,
1596 token_usage: Some(usage),
1597 tool_invocations: Vec::new(),
1598 finish_reason: Some("stop".to_string()),
1599 recall_stats: None,
1600 run_id: "run-combined".to_string(),
1601 next_action: None,
1602 reasoning: None,
1603 reasoning_summary: None,
1604 graph_debug: None,
1605 safety: SafetyStats::default(),
1606 };
1607 let lines = CliState::format_reasoning_messages(&output);
1608 assert_eq!(lines[2], "Tokens: P 10 C 20 T 30 | Finish: stop");
1609 }
1610
1611 #[allow(dead_code)]
1613 async fn test_cli_smoke() {
1614 formatting::set_plain_text_mode(true);
1616
1617 let dir = tempdir().unwrap();
1618 let db_path = dir.path().join("cli.duckdb");
1619
1620 let mut agents = HashMap::new();
1622 agents.insert("test".to_string(), AgentProfile::default());
1623
1624 let config = AppConfig {
1625 database: DatabaseConfig { path: db_path },
1626 model: ModelConfig {
1627 provider: "mock".into(),
1628 model_name: None,
1629 code_model: None,
1630 embeddings_model: None,
1631 api_key_source: None,
1632 temperature: 0.7,
1633 },
1634 ui: UiConfig {
1635 prompt: "> ".into(),
1636 theme: "default".into(),
1637 },
1638 logging: LoggingConfig {
1639 level: "info".into(),
1640 },
1641 audio: AudioConfig::default(),
1642 mesh: crate::spec_ai_core::config::MeshConfig::default(),
1643 plugins: PluginConfig::default(),
1644 sync: SyncConfig::default(),
1645 auth: AuthConfig::default(),
1646 safety: SafetyConfig::default(),
1647 approval: Default::default(),
1648 agents,
1649 default_agent: Some("test".into()),
1650 };
1651
1652 let mut cli = CliState::new_with_config(config).unwrap();
1653
1654 let out1 = cli.handle_line("hello").await.unwrap().unwrap();
1656 assert!(!out1.is_empty()); let out2 = cli.handle_line("/memory show 10").await.unwrap().unwrap();
1660 assert!(out2.contains("user:"));
1661 assert!(out2.contains("assistant:"));
1662
1663 let out3 = cli.handle_line("/session new s2").await.unwrap().unwrap();
1665 assert!(out3.contains("s2"));
1666
1667 let _ = cli.handle_line("hi").await.unwrap().unwrap();
1669
1670 let out4 = cli.handle_line("/session list").await.unwrap().unwrap();
1672 assert!(out4.contains("s2"));
1673 }
1674
1675 #[cfg_attr(
1676 target_os = "macos",
1677 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1678 )]
1679 #[tokio::test]
1680 async fn test_list_agents_command() {
1681 formatting::set_plain_text_mode(true);
1683
1684 let dir = tempdir().unwrap();
1685 let db_path = dir.path().join("cli_agents.duckdb");
1686
1687 let mut agents = HashMap::new();
1689 agents.insert("coder".to_string(), AgentProfile::default());
1690 agents.insert("researcher".to_string(), AgentProfile::default());
1691
1692 let config = AppConfig {
1693 database: DatabaseConfig { path: db_path },
1694 model: ModelConfig {
1695 provider: "mock".into(),
1696 model_name: None,
1697 code_model: None,
1698 embeddings_model: None,
1699 api_key_source: None,
1700 temperature: 0.7,
1701 },
1702 ui: UiConfig {
1703 prompt: "> ".into(),
1704 theme: "default".into(),
1705 },
1706 logging: LoggingConfig {
1707 level: "info".into(),
1708 },
1709 audio: AudioConfig::default(),
1710 mesh: crate::spec_ai_core::config::MeshConfig::default(),
1711 plugins: PluginConfig::default(),
1712 sync: SyncConfig::default(),
1713 auth: AuthConfig::default(),
1714 safety: SafetyConfig::default(),
1715 approval: Default::default(),
1716 agents,
1717 default_agent: Some("coder".into()),
1718 };
1719
1720 let mut cli = CliState::new_with_config(config).unwrap();
1721
1722 let out = cli.handle_line("/agents").await.unwrap().unwrap();
1724 assert!(out.contains("Available agents:"));
1725 assert!(out.contains("coder"));
1726 assert!(out.contains("researcher"));
1727 assert!(out.contains("(active)")); let out2 = cli.handle_line("/list").await.unwrap().unwrap();
1731 assert!(out2.contains("Available agents:"));
1732 }
1733
1734 #[cfg_attr(
1735 target_os = "macos",
1736 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1737 )]
1738 #[tokio::test]
1739 async fn test_config_show_command() {
1740 let dir = tempdir().unwrap();
1741 let db_path = dir.path().join("cli_config.duckdb");
1742
1743 let mut agents = HashMap::new();
1744 agents.insert("test".to_string(), AgentProfile::default());
1745
1746 let config = AppConfig {
1747 database: DatabaseConfig {
1748 path: db_path.clone(),
1749 },
1750 model: ModelConfig {
1751 provider: "mock".into(),
1752 model_name: Some("test-model".into()),
1753 code_model: None,
1754 embeddings_model: None,
1755 api_key_source: None,
1756 temperature: 0.8,
1757 },
1758 ui: UiConfig {
1759 prompt: "> ".into(),
1760 theme: "dark".into(),
1761 },
1762 logging: LoggingConfig {
1763 level: "debug".into(),
1764 },
1765 audio: AudioConfig::default(),
1766 mesh: crate::spec_ai_core::config::MeshConfig::default(),
1767 plugins: PluginConfig::default(),
1768 sync: SyncConfig::default(),
1769 auth: AuthConfig::default(),
1770 safety: SafetyConfig::default(),
1771 approval: Default::default(),
1772 agents,
1773 default_agent: Some("test".into()),
1774 };
1775
1776 let mut cli = CliState::new_with_config(config).unwrap();
1777
1778 let out = cli.handle_line("/config show").await.unwrap().unwrap();
1780 assert!(out.contains("Configuration loaded:"));
1781 assert!(out.contains("Model Provider: mock"));
1782 assert!(out.contains("Model Name: test-model"));
1783 assert!(out.contains("Temperature: 0.8"));
1784 assert!(out.contains("Logging Level: debug"));
1785 assert!(out.contains("UI Theme: dark"));
1786 }
1787
1788 #[cfg_attr(
1789 target_os = "macos",
1790 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1791 )]
1792 #[tokio::test]
1793 async fn test_help_command() {
1794 let dir = tempdir().unwrap();
1795 let db_path = dir.path().join("cli_help.duckdb");
1796
1797 let mut agents = HashMap::new();
1798 agents.insert("test".to_string(), AgentProfile::default());
1799
1800 let config = AppConfig {
1801 database: DatabaseConfig { path: db_path },
1802 model: ModelConfig {
1803 provider: "mock".into(),
1804 model_name: None,
1805 code_model: None,
1806 embeddings_model: None,
1807 api_key_source: None,
1808 temperature: 0.7,
1809 },
1810 ui: UiConfig {
1811 prompt: "> ".into(),
1812 theme: "default".into(),
1813 },
1814 logging: LoggingConfig {
1815 level: "info".into(),
1816 },
1817 audio: AudioConfig::default(),
1818 mesh: crate::spec_ai_core::config::MeshConfig::default(),
1819 plugins: PluginConfig::default(),
1820 sync: SyncConfig::default(),
1821 auth: AuthConfig::default(),
1822 safety: SafetyConfig::default(),
1823 approval: Default::default(),
1824 agents,
1825 default_agent: Some("test".into()),
1826 };
1827
1828 let mut cli = CliState::new_with_config(config).unwrap();
1829
1830 let out = cli.handle_line("/help").await.unwrap().unwrap();
1832 assert!(out.contains("Commands") || out.contains("SpecAI"));
1833 assert!(out.contains("/config show") || out.contains("config"));
1834 assert!(out.contains("/agents") || out.contains("agents"));
1835 assert!(out.contains("/list") || out.contains("list"));
1836 }
1837}