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