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