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::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader};
13use tokio::sync::mpsc;
14
15use crate::agent::core::MemoryRecallStrategy;
16use crate::agent::{
17 create_transcription_provider, create_transcription_provider_simple, TranscriptionProvider,
18};
19use crate::agent::{AgentBuilder, AgentCore, AgentOutput};
20use crate::bootstrap_self::BootstrapSelf;
21use crate::config::{AgentProfile, AgentRegistry, AppConfig};
22use crate::persistence::Persistence;
23use crate::policy::PolicyEngine;
24use crate::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::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) = self
340 .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::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.or(Some(self.config.audio.default_duration_secs)).unwrap_or(30)
735 )))
736 }
737 Command::ListenStop => {
738 if let Some(mut task) = self.transcription_task.take() {
739 let _ = task.stop_tx.send(());
741
742 let mut chunks = Vec::new();
744 while let Ok(text) = task.chunks_rx.try_recv() {
745 chunks.push(text);
746 }
747
748 let chunk_count = self.save_transcription_chunks(&chunks).await;
750
751 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
752
753 Ok(Some(format!(
754 "Stopped transcription (ran for {} seconds, saved {} chunks to database)",
755 elapsed, chunk_count
756 )))
757 } else {
758 Ok(Some("No transcription is currently running.".to_string()))
759 }
760 }
761 Command::ListenStatus => {
762 if let Some(task) = self.transcription_task.take() {
764 if task.handle.is_finished() {
765 let mut chunks = Vec::new();
767 let mut chunks_rx = task.chunks_rx;
768 while let Ok(text) = chunks_rx.try_recv() {
769 chunks.push(text);
770 }
771
772 let chunk_count = self.save_transcription_chunks(&chunks).await;
774
775 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
776
777 return Ok(Some(format!(
778 "Transcription completed (ran for {} seconds, saved {} chunks to database)",
779 elapsed, chunk_count
780 )));
781 } else {
782 self.transcription_task = Some(task);
784 }
785 }
786
787 if let Some(ref task) = self.transcription_task {
788 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
789
790 let duration_info = if let Some(dur) = task.duration_secs {
791 format!("/{} seconds", dur)
792 } else {
793 String::from(" (continuous)")
794 };
795
796 Ok(Some(format!(
797 "Transcription status: running\nElapsed: {}{}\nUse /listen stop to stop and save chunks.",
798 elapsed,
799 duration_info
800 )))
801 } else {
802 Ok(Some("No transcription is currently running.\nUse /listen start [duration] to start.".to_string()))
803 }
804 }
805 Command::Listen(_scenario, duration) => {
806 Ok(Some(format!(
808 "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...",
809 duration.unwrap_or(self.config.audio.default_duration_secs)
810 )))
811 }
812 Command::PasteStart => {
813 Ok(Some(
815 "Entering paste mode. Paste your block and finish with /end on its own line."
816 .to_string(),
817 ))
818 }
819 Command::RunSpec(path) => {
820 let output = self.run_spec_command(&path).await?;
821 Ok(Some(output))
822 }
823 Command::SpeechToggle(mode) => {
824 #[cfg(target_os = "macos")]
825 {
826 let new_state = match mode {
827 Some(explicit) => {
828 self.speech_enabled.store(explicit, Ordering::Relaxed);
829 explicit
830 }
831 None => !self.speech_enabled.fetch_xor(true, Ordering::Relaxed),
832 };
833 self.config.audio.speak_responses = new_state;
834 self.agent.set_speak_responses(new_state);
835 let status = if new_state { "enabled" } else { "disabled" };
836 return Ok(Some(format!("Speech playback {}", status)));
837 }
838
839 #[cfg(not(target_os = "macos"))]
840 {
841 return Ok(Some(
842 "Speech playback requires macOS and is not available on this platform."
843 .to_string(),
844 ));
845 }
846 }
847 Command::Init(plugins) => {
848 if !self.init_allowed {
849 return Ok(Some(
850 "The /init command must be the first action in a session. Start a new session to run it again."
851 .to_string(),
852 ));
853 }
854 let bootstrapper =
855 BootstrapSelf::from_environment(&self.persistence, self.agent.session_id())?;
856 let outcome = bootstrapper.run_with_plugins(plugins.clone())?;
857 self.init_allowed = false;
858 Ok(Some(format!(
859 "Knowledge graph bootstrap complete for '{}': {} nodes and {} edges captured ({} components, {} documents).",
860 outcome.repository_name,
861 outcome.nodes_created,
862 outcome.edges_created,
863 outcome.component_count,
864 outcome.document_count
865 )))
866 }
867 Command::Refresh(plugins) => {
868 let bootstrapper =
869 BootstrapSelf::from_environment(&self.persistence, self.agent.session_id())?;
870 let outcome = bootstrapper.refresh_with_plugins(plugins.clone())?;
871 self.init_allowed = false;
872 Ok(Some(format!(
873 "Knowledge graph refresh complete for '{}': {} nodes and {} edges captured ({} components, {} documents).",
874 outcome.repository_name,
875 outcome.nodes_created,
876 outcome.edges_created,
877 outcome.component_count,
878 outcome.document_count
879 )))
880 }
881 Command::Message(text) => {
882 self.init_allowed = false;
883 let speak_enabled = self.speech_enabled.load(Ordering::Relaxed);
884 self.config.audio.speak_responses = speak_enabled;
885 self.agent.set_speak_responses(speak_enabled);
886 let output = self.agent.run_step(&text).await?;
887 self.update_reasoning_messages(&output);
888 self.maybe_speak_response(&output.response);
889 let mut formatted =
890 formatting::render_agent_response("assistant", &output.response);
891 let show_reasoning = self.agent.profile().show_reasoning;
892 if let Some(stats) = formatting::render_run_stats(&output, show_reasoning) {
893 formatted.push('\n');
894 formatted.push_str(&stats);
895 }
896 Ok(Some(formatted))
897 }
898 }
899 }
900
901 pub async fn run_repl(&mut self) -> Result<()> {
903 let stdin = io::stdin();
904 let mut reader = BufReader::new(stdin);
905 let mut line = String::new();
906 let mut stdout = tokio::io::stdout();
907
908 stdout.write_all(self.config.summary().as_bytes()).await?;
910 stdout.write_all(b"\nType /help for commands.\n").await?;
911 stdout.flush().await?;
912
913 self.set_status_idle();
914 loop {
915 self.render_reasoning_prompt(&mut stdout).await?;
916 line.clear();
917 let n = reader.read_line(&mut line).await?;
918 if n == 0 {
919 break;
920 } let trimmed = line.trim_end_matches(&['\n', '\r'][..]);
923
924 if self.paste_mode {
928 if trimmed == "/end" {
929 self.paste_mode = false;
931 let full_input = std::mem::take(&mut self.paste_buffer);
932 let command_preview = Command::Message(full_input.clone());
933 self.update_status_for_command(&command_preview);
934 if !matches!(command_preview, Command::Empty) {
935 self.render_status_line(&mut stdout).await?;
936 }
937 if let Some(out) = self.handle_line(&full_input).await? {
938 if out == "__QUIT__" {
939 break;
940 }
941 stdout.write_all(out.as_bytes()).await?;
942 if !out.ends_with('\n') {
943 stdout.write_all(b"\n").await?;
944 }
945 stdout.flush().await?;
946 }
947 self.set_status_idle();
948 } else if !trimmed.is_empty() {
949 if !self.paste_buffer.is_empty() {
950 self.paste_buffer.push('\n');
951 }
952 self.paste_buffer.push_str(trimmed);
953 }
954 continue;
955 }
956
957 let command_preview = parse_command(&line);
959 if matches!(command_preview, Command::PasteStart) {
960 self.paste_mode = true;
962 self.paste_buffer.clear();
963 self.status_message =
964 "Status: paste mode (end with /end on its own line)".to_string();
965 self.render_status_line(&mut stdout).await?;
966 continue;
967 }
968
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
974 if matches!(command_preview, Command::Message(_)) {
976 let speech_flag = self.speech_enabled.clone();
977 let mut pending_speech_toggle: Option<bool> = None;
978 let mut fut = Box::pin(self.handle_line(&line));
980
981 let _ = enable_raw_mode();
983 let mut events = EventStream::new();
984 let mut interrupted = false;
985
986 let out = loop {
988 tokio::select! {
989 res = &mut fut => {
990 let _ = disable_raw_mode();
991 break Some(res?);
992 }
993 maybe_event = events.next() => {
994 match maybe_event {
995 Some(Ok(Event::Key(key))) => {
996 if key.code == KeyCode::Esc {
997 interrupted = true;
999 let _ = disable_raw_mode();
1000 break None;
1001 } else if key.code == KeyCode::Char('s')
1002 && key.modifiers.contains(KeyModifiers::CONTROL)
1003 {
1004 #[cfg(target_os = "macos")]
1005 {
1006 let now =
1007 !speech_flag.fetch_xor(true, Ordering::Relaxed);
1008 pending_speech_toggle = Some(now);
1009 println!(
1010 "\n[Speech] Playback {}",
1011 if now { "enabled" } else { "disabled" }
1012 );
1013 }
1014 #[cfg(not(target_os = "macos"))]
1015 {
1016 println!("\n[Speech] Playback unavailable on this platform");
1017 }
1018 }
1019 }
1020 Some(Ok(_)) => { }
1021 Some(Err(_)) => { }
1022 None => { }
1023 }
1024 }
1025 }
1026 };
1027
1028 drop(fut);
1030 if let Some(state) = pending_speech_toggle {
1031 self.config.audio.speak_responses = state;
1032 self.agent.set_speak_responses(state);
1033 }
1034 let _ = disable_raw_mode();
1035
1036 if interrupted {
1037 self.status_message = "Status: interrupted".to_string();
1039 self.render_status_line(&mut stdout).await?;
1040 self.set_status_idle();
1041 } else if let Some(out_opt) = out {
1042 if let Some(out) = out_opt {
1043 if out == "__QUIT__" {
1044 break;
1045 }
1046 stdout.write_all(out.as_bytes()).await?;
1047 if !out.ends_with('\n') {
1048 stdout.write_all(b"\n").await?;
1049 }
1050 stdout.flush().await?;
1051 }
1052 self.set_status_idle();
1053 }
1054 } else {
1055 if let Some(out) = self.handle_line(&line).await? {
1056 if out == "__QUIT__" {
1057 break;
1058 }
1059 stdout.write_all(out.as_bytes()).await?;
1060 if !out.ends_with('\n') {
1061 stdout.write_all(b"\n").await?;
1062 }
1063 stdout.flush().await?;
1064 }
1065 self.set_status_idle();
1066 }
1067 }
1068
1069 let _ = self.persistence.checkpoint();
1071
1072 Ok(())
1073 }
1074
1075 async fn run_spec_command(&mut self, path: &Path) -> Result<String> {
1076 let spec = AgentSpec::from_file(path)?;
1077 let mut intro = format!("Executing spec `{}`", spec.display_name());
1078 if let Some(source) = spec.source_path() {
1079 intro.push_str(&format!(" ({})", source.display()));
1080 }
1081 intro.push('\n');
1082
1083 let preview = spec.preview();
1084 if !preview.is_empty() {
1085 intro.push('\n');
1086 intro.push_str(&preview);
1087 intro.push_str("\n\n");
1088 }
1089
1090 let speak_enabled = self.speech_enabled.load(Ordering::Relaxed);
1091 self.config.audio.speak_responses = speak_enabled;
1092 self.agent.set_speak_responses(speak_enabled);
1093 let output = self.agent.run_spec(&spec).await?;
1094 self.update_reasoning_messages(&output);
1095 self.maybe_speak_response(&output.response);
1096 intro.push_str(&formatting::render_agent_response(
1097 "assistant",
1098 &output.response,
1099 ));
1100 let show_reasoning = self.agent.profile().show_reasoning;
1101 if let Some(stats) = formatting::render_run_stats(&output, show_reasoning) {
1102 intro.push('\n');
1103 intro.push_str(&stats);
1104 }
1105
1106 Ok(intro)
1107 }
1108
1109 fn update_reasoning_messages(&mut self, output: &AgentOutput) {
1110 self.reasoning_messages = Self::format_reasoning_messages(output);
1111 }
1112
1113 fn format_reasoning_messages(output: &AgentOutput) -> Vec<String> {
1114 let mut lines = Vec::with_capacity(3);
1115
1116 if let Some(stats) = &output.recall_stats {
1117 match &stats.strategy {
1118 MemoryRecallStrategy::Semantic {
1119 requested,
1120 returned,
1121 } => lines.push(format!(
1122 "Recall: semantic (requested {}, returned {})",
1123 requested, returned
1124 )),
1125 MemoryRecallStrategy::RecentContext { limit } => {
1126 lines.push(format!("Recall: recent context (last {} messages)", limit))
1127 }
1128 }
1129 } else {
1130 lines.push("Recall: not used".to_string());
1131 }
1132
1133 if let Some(invocation) = output.tool_invocations.last() {
1134 let status = if invocation.success { "ok" } else { "err" };
1135 lines.push(format!("Tool: {} ({})", invocation.name, status));
1136 } else {
1137 lines.push("Tool: idle".to_string());
1138 }
1139
1140 if let Some(reason) = &output.finish_reason {
1141 lines.push(format!("Finish: {}", reason));
1142 } else if let Some(usage) = &output.token_usage {
1143 lines.push(format!(
1144 "Tokens: P {} C {} T {}",
1145 usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
1146 ));
1147 } else {
1148 lines.push("Finish: pending".to_string());
1149 }
1150
1151 lines
1152 }
1153
1154 fn set_status_idle(&mut self) {
1155 self.status_message = "Status: awaiting input".to_string();
1156 }
1157
1158 fn update_status_for_command(&mut self, command: &Command) {
1159 self.status_message = Self::status_message_for_command(command);
1160 }
1161
1162 fn status_message_for_command(command: &Command) -> String {
1163 match command {
1164 Command::Empty => "Status: awaiting input".to_string(),
1165 Command::Help => "Status: showing help".to_string(),
1166 Command::Quit => "Status: exiting".to_string(),
1167 Command::ConfigReload => "Status: reloading configuration".to_string(),
1168 Command::ConfigShow => "Status: displaying configuration".to_string(),
1169 Command::PolicyReload => "Status: reloading policies".to_string(),
1170 Command::SwitchAgent(name) => {
1171 format!("Status: switching to agent '{}'", name)
1172 }
1173 Command::ListAgents => "Status: listing agents".to_string(),
1174 Command::MemoryShow(Some(limit)) => {
1175 format!("Status: showing last {} messages", limit)
1176 }
1177 Command::MemoryShow(None) => "Status: showing recent messages".to_string(),
1178 Command::SessionNew(Some(id)) => {
1179 format!("Status: starting session '{}'", id)
1180 }
1181 Command::SessionNew(None) => "Status: starting new session".to_string(),
1182 Command::SessionList => "Status: listing sessions".to_string(),
1183 Command::SessionSwitch(id) => {
1184 format!("Status: switching to session '{}'", id)
1185 }
1186 Command::GraphEnable => "Status: showing graph enable instructions".to_string(),
1187 Command::GraphDisable => "Status: showing graph disable instructions".to_string(),
1188 Command::GraphStatus => "Status: showing graph status".to_string(),
1189 Command::GraphShow(Some(limit)) => {
1190 format!("Status: inspecting graph (limit {})", limit)
1191 }
1192 Command::GraphShow(None) => "Status: inspecting graph".to_string(),
1193 Command::GraphClear => "Status: clearing session graph".to_string(),
1194 Command::SyncList => "Status: listing sync-enabled graphs".to_string(),
1195 Command::Init(_) => "Status: bootstrapping repository graph".to_string(),
1196 Command::ListenStart(duration) => {
1197 let mut status = "Status: starting background transcription".to_string();
1198 if let Some(d) = duration {
1199 status.push_str(&format!(" for {} seconds", d));
1200 }
1201 status
1202 }
1203 Command::ListenStop => "Status: stopping transcription".to_string(),
1204 Command::ListenStatus => "Status: checking transcription status".to_string(),
1205 Command::Listen(scenario, duration) => {
1206 let mut status = "Status: starting audio transcription".to_string();
1207 if let Some(s) = scenario {
1208 status.push_str(&format!(" (scenario: {})", s));
1209 }
1210 if let Some(d) = duration {
1211 status.push_str(&format!(" for {} seconds", d));
1212 }
1213 status
1214 }
1215 Command::RunSpec(path) => {
1216 format!("Status: executing spec '{}'", path.display())
1217 }
1218 Command::PasteStart => {
1219 "Status: entering paste mode (end with /end on its own line)".to_string()
1220 }
1221 Command::SpeechToggle(Some(true)) => "Status: enabling speech playback".to_string(),
1222 Command::SpeechToggle(Some(false)) => "Status: disabling speech playback".to_string(),
1223 Command::SpeechToggle(None) => "Status: toggling speech playback".to_string(),
1224 Command::Message(_) => "Status: running agent step".to_string(),
1225 Command::Refresh(_) => "Status: refreshing internal knowledge graph".to_string(),
1226 }
1227 }
1228
1229 fn pad_line_to_width(line: &str, width: usize) -> String {
1230 if width == 0 {
1231 return String::new();
1232 }
1233 let truncated: String = line.chars().take(width).collect();
1234 let truncated_len = truncated.chars().count();
1235 if truncated_len >= width {
1236 return truncated;
1237 }
1238 let mut padded = truncated;
1239 padded.push_str(&" ".repeat(width - truncated_len));
1240 padded
1241 }
1242
1243 fn reasoning_display_lines(&self, width: usize) -> Vec<String> {
1244 (0..3)
1245 .map(|idx| {
1246 let content = self
1247 .reasoning_messages
1248 .get(idx)
1249 .map(String::as_str)
1250 .unwrap_or("");
1251 Self::pad_line_to_width(content, width)
1252 })
1253 .collect()
1254 }
1255
1256 fn status_display_line(&self, width: usize) -> String {
1257 Self::pad_line_to_width(&self.status_message, width)
1258 }
1259
1260 fn input_display_width(&self) -> usize {
1261 let terminal_width = terminal_size().map(|(w, _)| w.0 as usize).unwrap_or(80);
1262 let prompt_len = self.config.ui.prompt.chars().count();
1263 if terminal_width <= prompt_len {
1264 1
1265 } else {
1266 terminal_width - prompt_len
1267 }
1268 }
1269
1270 async fn render_reasoning_prompt(&self, stdout: &mut io::Stdout) -> Result<()> {
1271 let width = self.input_display_width();
1272 for line in self.reasoning_display_lines(width) {
1273 stdout.write_all(line.as_bytes()).await?;
1274 stdout.write_all(b"\n").await?;
1275 }
1276 stdout.write_all(b"\n").await?;
1277 let status_line = self.status_display_line(width);
1278 stdout.write_all(status_line.as_bytes()).await?;
1279 stdout.write_all(b"\n").await?;
1280 stdout.write_all(self.config.ui.prompt.as_bytes()).await?;
1281 stdout.flush().await?;
1282 Ok(())
1283 }
1284
1285 async fn render_status_line(&self, stdout: &mut io::Stdout) -> Result<()> {
1286 let width = self.input_display_width();
1287 let status_line = self.status_display_line(width);
1288 stdout.write_all(status_line.as_bytes()).await?;
1289 stdout.write_all(b"\n").await?;
1290 stdout.flush().await?;
1291 Ok(())
1292 }
1293
1294 fn refresh_init_gate(&mut self) -> Result<()> {
1295 let messages = self.persistence.list_messages(self.agent.session_id(), 1)?;
1296 self.init_allowed = messages.is_empty();
1297 Ok(())
1298 }
1299
1300 #[cfg(target_os = "macos")]
1302 pub fn maybe_speak_response(&self, text: &str) {
1303 if !self.speech_enabled.load(Ordering::Relaxed) {
1304 return;
1305 }
1306
1307 let spoken = text.trim();
1308 if spoken.is_empty() {
1309 return;
1310 }
1311
1312 let mut command = tokio::process::Command::new("say");
1313 command.arg(spoken);
1314
1315 match command.spawn() {
1316 Ok(mut child) => {
1317 tokio::spawn(async move {
1318 let _ = child.wait().await;
1319 });
1320 }
1321 Err(err) => eprintln!("[Speech] Failed to invoke `say`: {}", err),
1322 }
1323 }
1324
1325 #[cfg(not(target_os = "macos"))]
1327 pub fn maybe_speak_response(&self, _text: &str) {}
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333 use crate::agent::core::{MemoryRecallStats, MemoryRecallStrategy, ToolInvocation};
1334 use crate::agent::model::TokenUsage;
1335 use crate::agent::AgentOutput;
1336 use crate::config::{
1337 AudioConfig, DatabaseConfig, LoggingConfig, ModelConfig, PluginConfig, SyncConfig,
1338 UiConfig,
1339 };
1340 use serde_json::json;
1341 use std::collections::HashMap;
1342 use std::path::PathBuf;
1343 use tempfile::tempdir;
1344
1345 #[test]
1346 fn pad_line_to_width_padding_and_truncation() {
1347 let padded = CliState::pad_line_to_width("abc", 5);
1349 assert_eq!(padded, "abc ");
1350
1351 let exact = CliState::pad_line_to_width("hello", 5);
1353 assert_eq!(exact, "hello");
1354
1355 let truncated = CliState::pad_line_to_width("helloworld", 4);
1357 assert_eq!(truncated, "hell");
1358
1359 let zero = CliState::pad_line_to_width("anything", 0);
1361 assert_eq!(zero, "");
1362
1363 let unicode_trunc = CliState::pad_line_to_width("🔥fire", 2);
1365 assert_eq!(unicode_trunc, "🔥f");
1366
1367 let unicode_pad = CliState::pad_line_to_width("🔥", 3);
1368 assert_eq!(unicode_pad, "🔥 ");
1369 }
1370
1371 #[test]
1372 fn test_parse_commands() {
1373 assert_eq!(parse_command("/help"), Command::Help);
1374 assert_eq!(parse_command("/quit"), Command::Quit);
1375 assert_eq!(parse_command("/config reload"), Command::ConfigReload);
1376 assert_eq!(parse_command("/config show"), Command::ConfigShow);
1377 assert_eq!(parse_command("/agents"), Command::ListAgents);
1378 assert_eq!(parse_command("/list"), Command::ListAgents);
1379 assert_eq!(parse_command("/init"), Command::Init(None));
1380 assert_eq!(
1381 parse_command("/init --plugins=rust-cargo"),
1382 Command::Init(Some(vec!["rust-cargo".to_string()]))
1383 );
1384 assert_eq!(
1385 parse_command("/init --plugins=rust-cargo,python"),
1386 Command::Init(Some(vec!["rust-cargo".to_string(), "python".to_string()]))
1387 );
1388 assert_eq!(
1389 parse_command("/switch coder"),
1390 Command::SwitchAgent("coder".into())
1391 );
1392 assert_eq!(
1393 parse_command("/memory show 5"),
1394 Command::MemoryShow(Some(5))
1395 );
1396 assert_eq!(parse_command("/session list"), Command::SessionList);
1397 assert_eq!(parse_command("/session new"), Command::SessionNew(None));
1398 assert_eq!(
1399 parse_command("/session new s2"),
1400 Command::SessionNew(Some("s2".into()))
1401 );
1402 assert_eq!(
1403 parse_command("/session switch abc"),
1404 Command::SessionSwitch("abc".into())
1405 );
1406 assert_eq!(
1407 parse_command("/spec run plan.spec"),
1408 Command::RunSpec(PathBuf::from("plan.spec"))
1409 );
1410 assert_eq!(
1411 parse_command("/spec nested/path/my.spec"),
1412 Command::RunSpec(PathBuf::from("nested/path/my.spec"))
1413 );
1414 assert_eq!(parse_command("/speak"), Command::SpeechToggle(None));
1415 assert_eq!(
1416 parse_command("/speak on"),
1417 Command::SpeechToggle(Some(true))
1418 );
1419 assert_eq!(parse_command("hello"), Command::Message("hello".into()));
1420 assert_eq!(parse_command(" "), Command::Empty);
1421 }
1422
1423 #[test]
1424 fn reasoning_messages_default() {
1425 let output = AgentOutput {
1426 response: String::new(),
1427 response_message_id: None,
1428 token_usage: None,
1429 tool_invocations: Vec::new(),
1430 finish_reason: None,
1431 recall_stats: None,
1432 run_id: "run-default".to_string(),
1433 next_action: None,
1434 reasoning: None,
1435 reasoning_summary: None,
1436 graph_debug: None,
1437 };
1438 let lines = CliState::format_reasoning_messages(&output);
1439 assert_eq!(
1440 lines,
1441 vec![
1442 "Recall: not used".to_string(),
1443 "Tool: idle".to_string(),
1444 "Finish: pending".to_string()
1445 ]
1446 );
1447 }
1448
1449 #[test]
1450 fn reasoning_messages_with_details() {
1451 let stats = MemoryRecallStats {
1452 strategy: MemoryRecallStrategy::Semantic {
1453 requested: 5,
1454 returned: 2,
1455 },
1456 matches: Vec::new(),
1457 };
1458 let invocation = ToolInvocation {
1459 name: "search".to_string(),
1460 arguments: json!({}),
1461 success: true,
1462 output: Some("ok".to_string()),
1463 error: None,
1464 };
1465 let output = AgentOutput {
1466 response: String::new(),
1467 response_message_id: None,
1468 token_usage: None,
1469 tool_invocations: vec![invocation],
1470 finish_reason: Some("stop".to_string()),
1471 recall_stats: Some(stats),
1472 run_id: "run-details".to_string(),
1473 next_action: None,
1474 reasoning: None,
1475 reasoning_summary: None,
1476 graph_debug: None,
1477 };
1478 let lines = CliState::format_reasoning_messages(&output);
1479 assert!(lines[0].starts_with("Recall: semantic"));
1480 assert!(lines[1].contains("search"));
1481 assert_eq!(lines[2], "Finish: stop");
1482 }
1483
1484 #[test]
1485 fn reasoning_messages_tokens() {
1486 let usage = TokenUsage {
1487 prompt_tokens: 4,
1488 completion_tokens: 6,
1489 total_tokens: 10,
1490 };
1491 let output = AgentOutput {
1492 response: String::new(),
1493 response_message_id: None,
1494 token_usage: Some(usage),
1495 tool_invocations: Vec::new(),
1496 finish_reason: None,
1497 recall_stats: None,
1498 run_id: "run-tokens".to_string(),
1499 next_action: None,
1500 reasoning: None,
1501 reasoning_summary: None,
1502 graph_debug: None,
1503 };
1504 let lines = CliState::format_reasoning_messages(&output);
1505 assert_eq!(lines[2], "Tokens: P 4 C 6 T 10");
1506 }
1507
1508 async fn test_cli_smoke() {
1510 formatting::set_plain_text_mode(true);
1512
1513 let dir = tempdir().unwrap();
1514 let db_path = dir.path().join("cli.duckdb");
1515
1516 let mut agents = HashMap::new();
1518 agents.insert("test".to_string(), AgentProfile::default());
1519
1520 let config = AppConfig {
1521 database: DatabaseConfig { path: db_path },
1522 model: ModelConfig {
1523 provider: "mock".into(),
1524 model_name: None,
1525 embeddings_model: None,
1526 api_key_source: None,
1527 temperature: 0.7,
1528 },
1529 ui: UiConfig {
1530 prompt: "> ".into(),
1531 theme: "default".into(),
1532 },
1533 logging: LoggingConfig {
1534 level: "info".into(),
1535 },
1536 audio: AudioConfig::default(),
1537 mesh: crate::config::MeshConfig::default(),
1538 plugins: PluginConfig::default(),
1539 sync: SyncConfig::default(),
1540 agents,
1541 default_agent: Some("test".into()),
1542 };
1543
1544 let mut cli = CliState::new_with_config(config).unwrap();
1545
1546 let out1 = cli.handle_line("hello").await.unwrap().unwrap();
1548 assert!(!out1.is_empty()); let out2 = cli.handle_line("/memory show 10").await.unwrap().unwrap();
1552 assert!(out2.contains("user:"));
1553 assert!(out2.contains("assistant:"));
1554
1555 let out3 = cli.handle_line("/session new s2").await.unwrap().unwrap();
1557 assert!(out3.contains("s2"));
1558
1559 let _ = cli.handle_line("hi").await.unwrap().unwrap();
1561
1562 let out4 = cli.handle_line("/session list").await.unwrap().unwrap();
1564 assert!(out4.contains("s2"));
1565 }
1566
1567 #[cfg_attr(
1568 target_os = "macos",
1569 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1570 )]
1571 #[tokio::test]
1572 async fn test_list_agents_command() {
1573 formatting::set_plain_text_mode(true);
1575
1576 let dir = tempdir().unwrap();
1577 let db_path = dir.path().join("cli_agents.duckdb");
1578
1579 let mut agents = HashMap::new();
1581 agents.insert("coder".to_string(), AgentProfile::default());
1582 agents.insert("researcher".to_string(), AgentProfile::default());
1583
1584 let config = AppConfig {
1585 database: DatabaseConfig { path: db_path },
1586 model: ModelConfig {
1587 provider: "mock".into(),
1588 model_name: None,
1589 embeddings_model: None,
1590 api_key_source: None,
1591 temperature: 0.7,
1592 },
1593 ui: UiConfig {
1594 prompt: "> ".into(),
1595 theme: "default".into(),
1596 },
1597 logging: LoggingConfig {
1598 level: "info".into(),
1599 },
1600 audio: AudioConfig::default(),
1601 mesh: crate::config::MeshConfig::default(),
1602 plugins: PluginConfig::default(),
1603 sync: SyncConfig::default(),
1604 agents,
1605 default_agent: Some("coder".into()),
1606 };
1607
1608 let mut cli = CliState::new_with_config(config).unwrap();
1609
1610 let out = cli.handle_line("/agents").await.unwrap().unwrap();
1612 assert!(out.contains("Available agents:"));
1613 assert!(out.contains("coder"));
1614 assert!(out.contains("researcher"));
1615 assert!(out.contains("(active)")); let out2 = cli.handle_line("/list").await.unwrap().unwrap();
1619 assert!(out2.contains("Available agents:"));
1620 }
1621
1622 #[cfg_attr(
1623 target_os = "macos",
1624 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1625 )]
1626 #[tokio::test]
1627 async fn test_config_show_command() {
1628 let dir = tempdir().unwrap();
1629 let db_path = dir.path().join("cli_config.duckdb");
1630
1631 let mut agents = HashMap::new();
1632 agents.insert("test".to_string(), AgentProfile::default());
1633
1634 let config = AppConfig {
1635 database: DatabaseConfig {
1636 path: db_path.clone(),
1637 },
1638 model: ModelConfig {
1639 provider: "mock".into(),
1640 model_name: Some("test-model".into()),
1641 embeddings_model: None,
1642 api_key_source: None,
1643 temperature: 0.8,
1644 },
1645 ui: UiConfig {
1646 prompt: "> ".into(),
1647 theme: "dark".into(),
1648 },
1649 logging: LoggingConfig {
1650 level: "debug".into(),
1651 },
1652 audio: AudioConfig::default(),
1653 mesh: crate::config::MeshConfig::default(),
1654 plugins: PluginConfig::default(),
1655 sync: SyncConfig::default(),
1656 agents,
1657 default_agent: Some("test".into()),
1658 };
1659
1660 let mut cli = CliState::new_with_config(config).unwrap();
1661
1662 let out = cli.handle_line("/config show").await.unwrap().unwrap();
1664 assert!(out.contains("Configuration loaded:"));
1665 assert!(out.contains("Model Provider: mock"));
1666 assert!(out.contains("Model Name: test-model"));
1667 assert!(out.contains("Temperature: 0.8"));
1668 assert!(out.contains("Logging Level: debug"));
1669 assert!(out.contains("UI Theme: dark"));
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_help_command() {
1678 let dir = tempdir().unwrap();
1679 let db_path = dir.path().join("cli_help.duckdb");
1680
1681 let mut agents = HashMap::new();
1682 agents.insert("test".to_string(), AgentProfile::default());
1683
1684 let config = AppConfig {
1685 database: DatabaseConfig { path: db_path },
1686 model: ModelConfig {
1687 provider: "mock".into(),
1688 model_name: None,
1689 embeddings_model: None,
1690 api_key_source: None,
1691 temperature: 0.7,
1692 },
1693 ui: UiConfig {
1694 prompt: "> ".into(),
1695 theme: "default".into(),
1696 },
1697 logging: LoggingConfig {
1698 level: "info".into(),
1699 },
1700 audio: AudioConfig::default(),
1701 mesh: crate::config::MeshConfig::default(),
1702 plugins: PluginConfig::default(),
1703 sync: SyncConfig::default(),
1704 agents,
1705 default_agent: Some("test".into()),
1706 };
1707
1708 let mut cli = CliState::new_with_config(config).unwrap();
1709
1710 let out = cli.handle_line("/help").await.unwrap().unwrap();
1712 assert!(out.contains("Commands") || out.contains("SpecAI"));
1713 assert!(out.contains("/config show") || out.contains("config"));
1714 assert!(out.contains("/agents") || out.contains("agents"));
1715 assert!(out.contains("/list") || out.contains("list"));
1716 }
1717}