1pub mod formatting;
4
5use anyhow::{Context, Result};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader};
9use tokio::sync::mpsc;
10
11use crate::agent::core::MemoryRecallStrategy;
12use crate::agent::{
13 create_transcription_provider, create_transcription_provider_simple, TranscriptionProvider,
14};
15use crate::agent::{AgentBuilder, AgentCore, AgentOutput};
16use crate::bootstrap_self::BootstrapSelf;
17use crate::config::{AgentProfile, AgentRegistry, AppConfig};
18use crate::persistence::Persistence;
19use crate::policy::PolicyEngine;
20use crate::spec::AgentSpec;
21use terminal_size::terminal_size;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Command {
25 Help,
26 Quit,
27 ConfigReload,
28 ConfigShow,
29 PolicyReload,
30 SwitchAgent(String),
31 ListAgents,
32 MemoryShow(Option<usize>),
33 SessionNew(Option<String>),
34 SessionList,
35 SessionSwitch(String),
36 GraphEnable,
38 GraphDisable,
39 GraphStatus,
40 GraphShow(Option<usize>),
41 GraphClear,
42 ListenStart(Option<u64>), ListenStop,
45 ListenStatus,
46 Listen(Option<String>, Option<u64>), PasteStart,
48 RunSpec(PathBuf),
49 Init(Option<Vec<String>>), Refresh(Option<Vec<String>>), Message(String),
52 Empty,
53}
54
55pub fn parse_command(input: &str) -> Command {
56 let line = input.trim();
57 if line.is_empty() {
58 return Command::Empty;
59 }
60
61 if let Some(rest) = line.strip_prefix('/') {
62 let mut parts = rest.split_whitespace();
63 let cmd = parts.next().unwrap_or("").to_lowercase();
64 match cmd.as_str() {
65 "help" | "h" | "?" => Command::Help,
66 "quit" | "q" | "exit" => Command::Quit,
67 "config" => match parts.next() {
68 Some("reload") => Command::ConfigReload,
69 Some("show") => Command::ConfigShow,
70 _ => Command::Help,
71 },
72 "policy" => match parts.next() {
73 Some("reload") => Command::PolicyReload,
74 _ => Command::Help,
75 },
76 "agents" | "list" => Command::ListAgents,
77 "switch" => {
78 let name = parts.next().unwrap_or("").to_string();
79 if name.is_empty() {
80 Command::Help
81 } else {
82 Command::SwitchAgent(name)
83 }
84 }
85 "memory" => match parts.next() {
86 Some("show") => {
87 let n = parts.next().and_then(|s| s.parse::<usize>().ok());
88 Command::MemoryShow(n)
89 }
90 _ => Command::Help,
91 },
92 "session" => match parts.next() {
93 Some("new") => {
94 let id = parts.next().map(|s| s.to_string());
95 Command::SessionNew(id)
96 }
97 Some("list") => Command::SessionList,
98 Some("switch") => {
99 let id = parts.next().unwrap_or("").to_string();
100 if id.is_empty() {
101 Command::Help
102 } else {
103 Command::SessionSwitch(id)
104 }
105 }
106 _ => Command::Help,
107 },
108 "graph" => match parts.next() {
109 Some("enable") => Command::GraphEnable,
110 Some("disable") => Command::GraphDisable,
111 Some("status") => Command::GraphStatus,
112 Some("show") => {
113 let n = parts.next().and_then(|s| s.parse::<usize>().ok());
114 Command::GraphShow(n)
115 }
116 Some("clear") => Command::GraphClear,
117 _ => Command::Help,
118 },
119 "listen" => {
120 match parts.next() {
121 Some("stop") => Command::ListenStop,
122 Some("status") => Command::ListenStatus,
123 Some("start") => {
124 let duration = parts.next().and_then(|s| s.parse::<u64>().ok());
125 Command::ListenStart(duration)
126 }
127 Some(duration_str) => {
128 let duration = duration_str.parse::<u64>().ok();
130 Command::ListenStart(duration)
131 }
132 None => Command::ListenStart(None),
133 }
134 }
135 "paste" => Command::PasteStart,
136 "init" => {
137 let plugins = if let Some(arg) = parts.next() {
138 if arg.starts_with("--plugins=") {
139 Some(
140 arg.strip_prefix("--plugins=")
141 .unwrap_or("")
142 .split(',')
143 .map(|p| p.trim().to_string())
144 .collect(),
145 )
146 } else {
147 None
148 }
149 } else {
150 None
151 };
152 Command::Init(plugins)
153 }
154 "refresh" => {
155 let plugins = if let Some(arg) = parts.next() {
156 if arg.starts_with("--plugins=") {
157 Some(
158 arg.strip_prefix("--plugins=")
159 .unwrap_or("")
160 .split(',')
161 .map(|p| p.trim().to_string())
162 .collect(),
163 )
164 } else {
165 None
166 }
167 } else {
168 None
169 };
170 Command::Refresh(plugins)
171 }
172 "spec" => {
173 let args: Vec<&str> = parts.collect();
174 if args.is_empty() {
175 Command::Help
176 } else {
177 let (path_parts, _explicit_run) = if args[0].eq_ignore_ascii_case("run") {
178 (args[1..].to_vec(), true)
179 } else {
180 (args, false)
181 };
182 if path_parts.is_empty() {
183 Command::Help
184 } else {
185 let path = path_parts.join(" ");
186 Command::RunSpec(PathBuf::from(path))
187 }
188 }
189 }
190 _ => Command::Help,
191 }
192 } else {
193 Command::Message(line.to_string())
194 }
195}
196
197struct TranscriptionTask {
199 handle: std::thread::JoinHandle<()>,
200 stop_tx: mpsc::UnboundedSender<()>,
201 started_at: std::time::SystemTime,
202 duration_secs: Option<u64>,
203 chunks_rx: mpsc::UnboundedReceiver<String>,
204}
205
206pub struct CliState {
207 pub config: AppConfig,
208 pub persistence: Persistence,
209 pub registry: AgentRegistry,
210 pub agent: AgentCore,
211 pub transcription_provider: Arc<dyn TranscriptionProvider>,
212 pub reasoning_messages: Vec<String>,
213 pub status_message: String,
214 paste_mode: bool,
215 paste_buffer: String,
216 init_allowed: bool,
217 transcription_task: Option<TranscriptionTask>,
218}
219
220impl CliState {
221 pub fn initialize() -> Result<Self> {
223 let config = AppConfig::load()?;
224 Self::new_with_config(config)
225 }
226
227 pub fn initialize_with_path(path: Option<PathBuf>) -> Result<Self> {
229 let config = if let Some(config_path) = path {
230 AppConfig::load_from_file(&config_path)?
231 } else {
232 AppConfig::load()?
233 };
234 Self::new_with_config(config)
235 }
236
237 pub fn new_with_config(config: AppConfig) -> Result<Self> {
239 let persistence =
240 Persistence::new(&config.database.path).context("initializing persistence")?;
241
242 let initial_agents = config.agents.clone();
244 let registry = AgentRegistry::new(initial_agents.clone(), persistence.clone());
245 registry.init()?;
246
247 if registry.active_name().is_none() {
249 if let Some(default_name) = &config.default_agent {
250 if registry.get(default_name).is_some() {
251 registry.set_active(default_name)?;
252 }
253 }
254 }
255 if registry.active_name().is_none() {
256 if initial_agents.is_empty() {
258 let default_profile = AgentProfile::default();
259 registry.upsert("default".to_string(), default_profile)?;
260 registry.set_active("default")?;
261 } else {
262 if let Some(first) = registry.list().first().cloned() {
264 registry.set_active(&first)?;
265 }
266 }
267 }
268
269 let agent = AgentBuilder::new_with_registry(®istry, &config, None)?;
271
272 let transcription_provider = {
274 use crate::agent::transcription_factory::TranscriptionProviderConfig;
275 let provider_config = TranscriptionProviderConfig {
276 provider: config.audio.provider.clone(),
277 api_key_source: config.audio.api_key_source.clone(),
278 endpoint: config.audio.endpoint.clone(),
279 on_device: config.audio.on_device,
280 settings: serde_json::Value::Null,
281 };
282 create_transcription_provider(&provider_config)
283 .or_else(|_| create_transcription_provider_simple("mock"))
284 .context("Failed to create transcription provider")?
285 };
286
287 let mut state = Self {
288 config,
289 persistence,
290 registry,
291 agent,
292 transcription_provider,
293 reasoning_messages: vec!["Reasoning: idle".to_string()],
294 status_message: "Status: initializing".to_string(),
295 paste_mode: false,
296 paste_buffer: String::new(),
297 init_allowed: true,
298 transcription_task: None,
299 };
300
301 state.refresh_init_gate()?;
302
303 Ok(state)
304 }
305
306 async fn save_transcription_chunks(&self, chunks: &[String]) -> usize {
308 let session_id = self.agent.session_id();
309 let mut chunk_count = 0;
310 for (idx, text) in chunks.iter().enumerate() {
311 let timestamp = chrono::Utc::now();
312
313 match self
315 .persistence
316 .insert_transcription(session_id, idx as i64, text, timestamp)
317 {
318 Ok(transcription_id) => {
319 chunk_count += 1;
320
321 if let Some(embedding_id) = self.agent.generate_embedding(text).await {
323 if let Err(e) = self
324 .persistence
325 .update_transcription_embedding(transcription_id, embedding_id)
326 {
327 eprintln!(
328 "[Transcription] Failed to link embedding for chunk {}: {}",
329 idx, e
330 );
331 }
332 }
333 }
334 Err(e) => {
335 eprintln!("[Transcription] Failed to save chunk {}: {}", idx, e);
336 }
337 }
338 }
339 chunk_count
340 }
341
342 pub async fn handle_line(&mut self, line: &str) -> Result<Option<String>> {
344 match parse_command(line) {
345 Command::Empty => Ok(None),
346 Command::Help => Ok(Some(formatting::render_help())),
347 Command::Quit => Ok(Some("__QUIT__".to_string())),
348 Command::ConfigShow => {
349 let summary = self.config.summary();
350 Ok(Some(formatting::render_config(&summary)))
351 }
352 Command::ListAgents => {
353 let agents = self.registry.list();
354 let active = self.registry.active_name();
355 if agents.is_empty() {
356 Ok(Some("No agents configured.".to_string()))
357 } else {
358 let agent_data: Vec<(String, bool, Option<String>)> = agents
359 .into_iter()
360 .map(|name| {
361 let is_active = Some(&name) == active.as_ref();
362 let description =
363 self.registry.get(&name).and_then(|p| p.style.clone());
364 (name, is_active, description)
365 })
366 .collect();
367 Ok(Some(formatting::render_agent_table(agent_data)))
368 }
369 }
370 Command::ConfigReload => {
371 let current_session = self.agent.session_id().to_string();
372 self.config = AppConfig::load()?;
373 self.persistence = Persistence::new(&self.config.database.path)?;
375 self.registry =
377 AgentRegistry::new(self.config.agents.clone(), self.persistence.clone());
378 self.registry.init()?;
379 if let Some(default_name) = &self.config.default_agent {
380 let _ = self.registry.set_active(default_name);
381 }
382 self.agent = AgentBuilder::new_with_registry(
384 &self.registry,
385 &self.config,
386 Some(current_session),
387 )?;
388 self.refresh_init_gate()?;
389 Ok(Some("Configuration reloaded.".to_string()))
390 }
391 Command::PolicyReload => {
392 let policy_engine = PolicyEngine::load_from_persistence(&self.persistence)
394 .context("Failed to load policies from persistence")?;
395 let rule_count = policy_engine.rule_count();
396
397 self.agent
399 .set_policy_engine(std::sync::Arc::new(policy_engine));
400
401 Ok(Some(format!(
402 "Policies reloaded. {} rule(s) active.",
403 rule_count
404 )))
405 }
406 Command::SwitchAgent(name) => {
407 self.registry.set_active(&name)?;
408 let session = self.agent.session_id().to_string();
409 self.agent =
410 AgentBuilder::new_with_registry(&self.registry, &self.config, Some(session))?;
411 Ok(Some(format!("Switched active agent to '{}'.", name)))
412 }
413 Command::MemoryShow(n) => {
414 let limit = n.unwrap_or(10) as i64;
415 let sid = self.agent.session_id().to_string();
416 let msgs = self.persistence.list_messages(&sid, limit)?;
417 if msgs.is_empty() {
418 Ok(Some("No messages in this session.".to_string()))
419 } else {
420 let messages: Vec<(String, String)> = msgs
421 .into_iter()
422 .map(|m| (m.role.as_str().to_string(), m.content))
423 .collect();
424 Ok(Some(formatting::render_memory(messages)))
425 }
426 }
427 Command::SessionNew(id_opt) => {
428 let new_id = id_opt.unwrap_or_else(|| {
429 format!("session-{}", chrono::Utc::now().timestamp_millis())
430 });
431 self.agent = AgentBuilder::new_with_registry(
432 &self.registry,
433 &self.config,
434 Some(new_id.clone()),
435 )?;
436 self.init_allowed = true;
437 Ok(Some(format!("Started new session '{}'.", new_id)))
438 }
439 Command::SessionList => {
440 let sessions = self.persistence.list_sessions()?;
441 if sessions.is_empty() {
442 return Ok(Some("No sessions yet.".to_string()));
443 }
444 Ok(Some(formatting::render_list(
445 "Sessions (most recent first)",
446 sessions,
447 )))
448 }
449 Command::SessionSwitch(id) => {
450 self.agent = AgentBuilder::new_with_registry(
451 &self.registry,
452 &self.config,
453 Some(id.clone()),
454 )?;
455 self.refresh_init_gate()?;
456 Ok(Some(format!("Switched to session '{}'.", id)))
457 }
458 Command::GraphEnable => {
460 Ok(Some(
463 "To enable knowledge graph features, update your spec-ai.config.toml:\n\n\
464 [agents.your_agent_name]\n\
465 enable_graph = true\n\
466 graph_memory = true\n\
467 auto_graph = true\n\
468 graph_steering = true\n\
469 graph_depth = 3\n\
470 graph_weight = 0.5\n\
471 graph_threshold = 0.7\n\n\
472 Then run: /config reload"
473 .to_string(),
474 ))
475 }
476 Command::GraphDisable => {
477 Ok(Some(
479 "To disable knowledge graph features, update your spec-ai.config.toml:\n\n\
480 [agents.your_agent_name]\n\
481 enable_graph = false\n\n\
482 Then run: /config reload"
483 .to_string(),
484 ))
485 }
486 Command::GraphStatus => {
487 let profile = self.agent.profile();
488 let status = format!(
489 "Knowledge Graph Configuration:\n \
490 Enabled: {}\n \
491 Graph Memory: {}\n \
492 Auto Build: {}\n \
493 Graph Steering: {}\n \
494 Traversal Depth: {}\n \
495 Graph Weight: {:.2}\n \
496 Tool Threshold: {:.2}",
497 profile.enable_graph,
498 profile.graph_memory,
499 profile.auto_graph,
500 profile.graph_steering,
501 profile.graph_depth,
502 profile.graph_weight,
503 profile.graph_threshold,
504 );
505 Ok(Some(status))
506 }
507 Command::GraphShow(limit) => {
508 let limit_val = limit.unwrap_or(10) as i64;
509 let session_id = self.agent.session_id();
510 let nodes = self
511 .persistence
512 .list_graph_nodes(session_id, None, Some(limit_val))?;
513
514 if nodes.is_empty() {
515 Ok(Some("No graph nodes in current session.".to_string()))
516 } else {
517 let mut output = format!(
518 "Graph Nodes (showing {} of {}):\n",
519 nodes.len(),
520 nodes.len()
521 );
522 for node in &nodes {
523 output.push_str(&format!(
524 " [{:?}] {} - {}\n",
525 node.node_type,
526 node.label,
527 node.properties["name"].as_str().unwrap_or("unnamed")
528 ));
529 }
530
531 let edges = self.persistence.list_graph_edges(session_id, None, None)?;
533 output.push_str(&format!("\nTotal edges: {}", edges.len()));
534
535 Ok(Some(output))
536 }
537 }
538 Command::GraphClear => {
539 let session_id = self.agent.session_id();
540
541 let nodes = self.persistence.list_graph_nodes(session_id, None, None)?;
543 let count = nodes.len();
544
545 for node in nodes {
546 self.persistence.delete_graph_node(node.id)?;
547 }
548
549 Ok(Some(format!(
550 "Cleared {} graph nodes for session '{}'",
551 count, session_id
552 )))
553 }
554 Command::ListenStart(duration) => {
555 use crate::agent::{TranscriptionConfig, TranscriptionEvent};
556 use futures::StreamExt;
557
558 if self.transcription_task.is_some() {
560 return Ok(Some(
561 "Transcription is already running. Use /listen stop to stop it first."
562 .to_string(),
563 ));
564 }
565
566 let config = TranscriptionConfig {
568 duration_secs: duration.or(Some(self.config.audio.default_duration_secs)),
569 chunk_duration_secs: self.config.audio.chunk_duration_secs,
570 model: self
571 .config
572 .audio
573 .model
574 .clone()
575 .unwrap_or_else(|| "whisper-1".to_string()),
576 out_file: self.config.audio.out_file.clone(),
577 language: self.config.audio.language.clone(),
578 endpoint: self.config.audio.endpoint.clone(),
579 };
580
581 let (stop_tx, mut stop_rx) = mpsc::unbounded_channel::<()>();
583 let (chunks_tx, chunks_rx) = mpsc::unbounded_channel::<String>();
584
585 let provider = Arc::clone(&self.transcription_provider);
587 let provider_name = provider.metadata().name.clone();
588 let provider_name_display = provider_name.clone(); let started_at = std::time::SystemTime::now();
590
591 let handle = std::thread::spawn(move || {
593 let rt = tokio::runtime::Builder::new_current_thread()
595 .enable_all()
596 .build()
597 .expect("Failed to create runtime");
598
599 let local = tokio::task::LocalSet::new();
600
601 local.block_on(&rt, async move {
602 let stream_result = provider.start_transcription(&config).await;
604
605 match stream_result {
606 Ok(mut stream) => {
607 println!("\n[Transcription] Started using {}", provider_name);
608
609 loop {
610 tokio::select! {
611 _ = stop_rx.recv() => {
613 println!("\n[Transcription] Stopped by user");
614 break;
615 }
616 event = stream.next() => {
618 match event {
619 Some(Ok(TranscriptionEvent::Started { .. })) => {
620 }
622 Some(Ok(TranscriptionEvent::Transcription { chunk_id, text, .. })) => {
623 println!("[Transcription] Chunk {}: {}", chunk_id, text);
624 let _ = chunks_tx.send(text);
625 }
626 Some(Ok(TranscriptionEvent::Error { chunk_id, message })) => {
627 eprintln!("[Transcription] Error in chunk {}: {}", chunk_id, message);
628 }
629 Some(Ok(TranscriptionEvent::Completed { total_chunks, .. })) => {
630 println!("[Transcription] Completed. Processed {} chunks.", total_chunks);
631 break;
632 }
633 Some(Err(e)) => {
634 eprintln!("[Transcription] Error: {}", e);
635 break;
636 }
637 None => {
638 break;
639 }
640 }
641 }
642 }
643 }
644 }
645 Err(e) => {
646 eprintln!("[Transcription] Failed to start: {}", e);
647 }
648 }
649 })
650 });
651
652 self.transcription_task = Some(TranscriptionTask {
654 handle,
655 stop_tx,
656 started_at,
657 duration_secs: duration.or(Some(self.config.audio.default_duration_secs)),
658 chunks_rx,
659 });
660
661 Ok(Some(format!(
662 "Started background transcription using {} (duration: {} seconds)\nUse /listen stop to stop, /listen status to check status.",
663 provider_name_display,
664 duration.or(Some(self.config.audio.default_duration_secs)).unwrap_or(30)
665 )))
666 }
667 Command::ListenStop => {
668 if let Some(mut task) = self.transcription_task.take() {
669 let _ = task.stop_tx.send(());
671
672 let mut chunks = Vec::new();
674 while let Ok(text) = task.chunks_rx.try_recv() {
675 chunks.push(text);
676 }
677
678 let chunk_count = self.save_transcription_chunks(&chunks).await;
680
681 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
682
683 Ok(Some(format!(
684 "Stopped transcription (ran for {} seconds, saved {} chunks to database)",
685 elapsed, chunk_count
686 )))
687 } else {
688 Ok(Some("No transcription is currently running.".to_string()))
689 }
690 }
691 Command::ListenStatus => {
692 if let Some(task) = self.transcription_task.take() {
694 if task.handle.is_finished() {
695 let mut chunks = Vec::new();
697 let mut chunks_rx = task.chunks_rx;
698 while let Ok(text) = chunks_rx.try_recv() {
699 chunks.push(text);
700 }
701
702 let chunk_count = self.save_transcription_chunks(&chunks).await;
704
705 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
706
707 return Ok(Some(format!(
708 "Transcription completed (ran for {} seconds, saved {} chunks to database)",
709 elapsed, chunk_count
710 )));
711 } else {
712 self.transcription_task = Some(task);
714 }
715 }
716
717 if let Some(ref task) = self.transcription_task {
718 let elapsed = task.started_at.elapsed().map(|d| d.as_secs()).unwrap_or(0);
719
720 let duration_info = if let Some(dur) = task.duration_secs {
721 format!("/{} seconds", dur)
722 } else {
723 String::from(" (continuous)")
724 };
725
726 Ok(Some(format!(
727 "Transcription status: running\nElapsed: {}{}\nUse /listen stop to stop and save chunks.",
728 elapsed,
729 duration_info
730 )))
731 } else {
732 Ok(Some("No transcription is currently running.\nUse /listen start [duration] to start.".to_string()))
733 }
734 }
735 Command::Listen(_scenario, duration) => {
736 Ok(Some(format!(
738 "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...",
739 duration.unwrap_or(self.config.audio.default_duration_secs)
740 )))
741 }
742 Command::PasteStart => {
743 Ok(Some(
745 "Entering paste mode. Paste your block and finish with /end on its own line."
746 .to_string(),
747 ))
748 }
749 Command::RunSpec(path) => {
750 let output = self.run_spec_command(&path).await?;
751 Ok(Some(output))
752 }
753 Command::Init(plugins) => {
754 if !self.init_allowed {
755 return Ok(Some(
756 "The /init command must be the first action in a session. Start a new session to run it again."
757 .to_string(),
758 ));
759 }
760 let bootstrapper =
761 BootstrapSelf::from_environment(&self.persistence, self.agent.session_id())?;
762 let outcome = bootstrapper.run_with_plugins(plugins.clone())?;
763 self.init_allowed = false;
764 Ok(Some(format!(
765 "Knowledge graph bootstrap complete for '{}': {} nodes and {} edges captured ({} components, {} documents).",
766 outcome.repository_name,
767 outcome.nodes_created,
768 outcome.edges_created,
769 outcome.component_count,
770 outcome.document_count
771 )))
772 }
773 Command::Refresh(plugins) => {
774 let bootstrapper =
775 BootstrapSelf::from_environment(&self.persistence, self.agent.session_id())?;
776 let outcome = bootstrapper.refresh_with_plugins(plugins.clone())?;
777 self.init_allowed = false;
778 Ok(Some(format!(
779 "Knowledge graph refresh complete for '{}': {} nodes and {} edges captured ({} components, {} documents).",
780 outcome.repository_name,
781 outcome.nodes_created,
782 outcome.edges_created,
783 outcome.component_count,
784 outcome.document_count
785 )))
786 }
787 Command::Message(text) => {
788 self.init_allowed = false;
789 let output = self.agent.run_step(&text).await?;
790 self.update_reasoning_messages(&output);
791 let mut formatted =
792 formatting::render_agent_response("assistant", &output.response);
793 let show_reasoning = self.agent.profile().show_reasoning;
794 if let Some(stats) = formatting::render_run_stats(&output, show_reasoning) {
795 formatted.push('\n');
796 formatted.push_str(&stats);
797 }
798 Ok(Some(formatted))
799 }
800 }
801 }
802
803 pub async fn run_repl(&mut self) -> Result<()> {
805 let stdin = io::stdin();
806 let mut reader = BufReader::new(stdin);
807 let mut line = String::new();
808 let mut stdout = tokio::io::stdout();
809
810 stdout.write_all(self.config.summary().as_bytes()).await?;
812 stdout.write_all(b"\nType /help for commands.\n").await?;
813 stdout.flush().await?;
814
815 self.set_status_idle();
816 loop {
817 self.render_reasoning_prompt(&mut stdout).await?;
818 line.clear();
819 let n = reader.read_line(&mut line).await?;
820 if n == 0 {
821 break;
822 } let trimmed = line.trim_end_matches(&['\n', '\r'][..]);
825
826 if self.paste_mode {
830 if trimmed == "/end" {
831 self.paste_mode = false;
833 let full_input = std::mem::take(&mut self.paste_buffer);
834 let command_preview = Command::Message(full_input.clone());
835 self.update_status_for_command(&command_preview);
836 if !matches!(command_preview, Command::Empty) {
837 self.render_status_line(&mut stdout).await?;
838 }
839 if let Some(out) = self.handle_line(&full_input).await? {
840 if out == "__QUIT__" {
841 break;
842 }
843 stdout.write_all(out.as_bytes()).await?;
844 if !out.ends_with('\n') {
845 stdout.write_all(b"\n").await?;
846 }
847 stdout.flush().await?;
848 }
849 self.set_status_idle();
850 } else if !trimmed.is_empty() {
851 if !self.paste_buffer.is_empty() {
852 self.paste_buffer.push('\n');
853 }
854 self.paste_buffer.push_str(trimmed);
855 }
856 continue;
857 }
858
859 let command_preview = parse_command(&line);
861 if matches!(command_preview, Command::PasteStart) {
862 self.paste_mode = true;
864 self.paste_buffer.clear();
865 self.status_message =
866 "Status: paste mode (end with /end on its own line)".to_string();
867 self.render_status_line(&mut stdout).await?;
868 continue;
869 }
870
871 self.update_status_for_command(&command_preview);
872 if !matches!(command_preview, Command::Empty) {
873 self.render_status_line(&mut stdout).await?;
874 }
875 if let Some(out) = self.handle_line(&line).await? {
876 if out == "__QUIT__" {
877 break;
878 }
879 stdout.write_all(out.as_bytes()).await?;
880 if !out.ends_with('\n') {
881 stdout.write_all(b"\n").await?;
882 }
883 stdout.flush().await?;
884 }
885 self.set_status_idle();
886 }
887
888 let _ = self.persistence.checkpoint();
890
891 Ok(())
892 }
893
894 async fn run_spec_command(&mut self, path: &Path) -> Result<String> {
895 let spec = AgentSpec::from_file(path)?;
896 let mut intro = format!("Executing spec `{}`", spec.display_name());
897 if let Some(source) = spec.source_path() {
898 intro.push_str(&format!(" ({})", source.display()));
899 }
900 intro.push('\n');
901
902 let preview = spec.preview();
903 if !preview.is_empty() {
904 intro.push('\n');
905 intro.push_str(&preview);
906 intro.push_str("\n\n");
907 }
908
909 let output = self.agent.run_spec(&spec).await?;
910 self.update_reasoning_messages(&output);
911 intro.push_str(&formatting::render_agent_response(
912 "assistant",
913 &output.response,
914 ));
915 let show_reasoning = self.agent.profile().show_reasoning;
916 if let Some(stats) = formatting::render_run_stats(&output, show_reasoning) {
917 intro.push('\n');
918 intro.push_str(&stats);
919 }
920
921 Ok(intro)
922 }
923
924 fn update_reasoning_messages(&mut self, output: &AgentOutput) {
925 self.reasoning_messages = Self::format_reasoning_messages(output);
926 }
927
928 fn format_reasoning_messages(output: &AgentOutput) -> Vec<String> {
929 let mut lines = Vec::with_capacity(3);
930
931 if let Some(stats) = &output.recall_stats {
932 match &stats.strategy {
933 MemoryRecallStrategy::Semantic {
934 requested,
935 returned,
936 } => lines.push(format!(
937 "Recall: semantic (requested {}, returned {})",
938 requested, returned
939 )),
940 MemoryRecallStrategy::RecentContext { limit } => {
941 lines.push(format!("Recall: recent context (last {} messages)", limit))
942 }
943 }
944 } else {
945 lines.push("Recall: not used".to_string());
946 }
947
948 if let Some(invocation) = output.tool_invocations.last() {
949 let status = if invocation.success { "ok" } else { "err" };
950 lines.push(format!("Tool: {} ({})", invocation.name, status));
951 } else {
952 lines.push("Tool: idle".to_string());
953 }
954
955 if let Some(reason) = &output.finish_reason {
956 lines.push(format!("Finish: {}", reason));
957 } else if let Some(usage) = &output.token_usage {
958 lines.push(format!(
959 "Tokens: P {} C {} T {}",
960 usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
961 ));
962 } else {
963 lines.push("Finish: pending".to_string());
964 }
965
966 lines
967 }
968
969 fn set_status_idle(&mut self) {
970 self.status_message = "Status: awaiting input".to_string();
971 }
972
973 fn update_status_for_command(&mut self, command: &Command) {
974 self.status_message = Self::status_message_for_command(command);
975 }
976
977 fn status_message_for_command(command: &Command) -> String {
978 match command {
979 Command::Empty => "Status: awaiting input".to_string(),
980 Command::Help => "Status: showing help".to_string(),
981 Command::Quit => "Status: exiting".to_string(),
982 Command::ConfigReload => "Status: reloading configuration".to_string(),
983 Command::ConfigShow => "Status: displaying configuration".to_string(),
984 Command::PolicyReload => "Status: reloading policies".to_string(),
985 Command::SwitchAgent(name) => {
986 format!("Status: switching to agent '{}'", name)
987 }
988 Command::ListAgents => "Status: listing agents".to_string(),
989 Command::MemoryShow(Some(limit)) => {
990 format!("Status: showing last {} messages", limit)
991 }
992 Command::MemoryShow(None) => "Status: showing recent messages".to_string(),
993 Command::SessionNew(Some(id)) => {
994 format!("Status: starting session '{}'", id)
995 }
996 Command::SessionNew(None) => "Status: starting new session".to_string(),
997 Command::SessionList => "Status: listing sessions".to_string(),
998 Command::SessionSwitch(id) => {
999 format!("Status: switching to session '{}'", id)
1000 }
1001 Command::GraphEnable => "Status: showing graph enable instructions".to_string(),
1002 Command::GraphDisable => "Status: showing graph disable instructions".to_string(),
1003 Command::GraphStatus => "Status: showing graph status".to_string(),
1004 Command::GraphShow(Some(limit)) => {
1005 format!("Status: inspecting graph (limit {})", limit)
1006 }
1007 Command::GraphShow(None) => "Status: inspecting graph".to_string(),
1008 Command::GraphClear => "Status: clearing session graph".to_string(),
1009 Command::Init(_) => "Status: bootstrapping repository graph".to_string(),
1010 Command::ListenStart(duration) => {
1011 let mut status = "Status: starting background transcription".to_string();
1012 if let Some(d) = duration {
1013 status.push_str(&format!(" for {} seconds", d));
1014 }
1015 status
1016 }
1017 Command::ListenStop => "Status: stopping transcription".to_string(),
1018 Command::ListenStatus => "Status: checking transcription status".to_string(),
1019 Command::Listen(scenario, duration) => {
1020 let mut status = "Status: starting audio transcription".to_string();
1021 if let Some(s) = scenario {
1022 status.push_str(&format!(" (scenario: {})", s));
1023 }
1024 if let Some(d) = duration {
1025 status.push_str(&format!(" for {} seconds", d));
1026 }
1027 status
1028 }
1029 Command::RunSpec(path) => {
1030 format!("Status: executing spec '{}'", path.display())
1031 }
1032 Command::PasteStart => {
1033 "Status: entering paste mode (end with /end on its own line)".to_string()
1034 }
1035 Command::Message(_) => "Status: running agent step".to_string(),
1036 Command::Refresh(_) => "Status: refreshing internal knowledge graph".to_string(),
1037 }
1038 }
1039
1040 fn pad_line_to_width(line: &str, width: usize) -> String {
1041 if width == 0 {
1042 return String::new();
1043 }
1044 let truncated: String = line.chars().take(width).collect();
1045 let truncated_len = truncated.chars().count();
1046 if truncated_len >= width {
1047 return truncated;
1048 }
1049 let mut padded = truncated;
1050 padded.push_str(&" ".repeat(width - truncated_len));
1051 padded
1052 }
1053
1054 fn reasoning_display_lines(&self, width: usize) -> Vec<String> {
1055 (0..3)
1056 .map(|idx| {
1057 let content = self
1058 .reasoning_messages
1059 .get(idx)
1060 .map(String::as_str)
1061 .unwrap_or("");
1062 Self::pad_line_to_width(content, width)
1063 })
1064 .collect()
1065 }
1066
1067 fn status_display_line(&self, width: usize) -> String {
1068 Self::pad_line_to_width(&self.status_message, width)
1069 }
1070
1071 fn input_display_width(&self) -> usize {
1072 let terminal_width = terminal_size().map(|(w, _)| w.0 as usize).unwrap_or(80);
1073 let prompt_len = self.config.ui.prompt.chars().count();
1074 if terminal_width <= prompt_len {
1075 1
1076 } else {
1077 terminal_width - prompt_len
1078 }
1079 }
1080
1081 async fn render_reasoning_prompt(&self, stdout: &mut io::Stdout) -> Result<()> {
1082 let width = self.input_display_width();
1083 for line in self.reasoning_display_lines(width) {
1084 stdout.write_all(line.as_bytes()).await?;
1085 stdout.write_all(b"\n").await?;
1086 }
1087 stdout.write_all(b"\n").await?;
1088 let status_line = self.status_display_line(width);
1089 stdout.write_all(status_line.as_bytes()).await?;
1090 stdout.write_all(b"\n").await?;
1091 stdout.write_all(self.config.ui.prompt.as_bytes()).await?;
1092 stdout.flush().await?;
1093 Ok(())
1094 }
1095
1096 async fn render_status_line(&self, stdout: &mut io::Stdout) -> Result<()> {
1097 let width = self.input_display_width();
1098 let status_line = self.status_display_line(width);
1099 stdout.write_all(status_line.as_bytes()).await?;
1100 stdout.write_all(b"\n").await?;
1101 stdout.flush().await?;
1102 Ok(())
1103 }
1104
1105 fn refresh_init_gate(&mut self) -> Result<()> {
1106 let messages = self.persistence.list_messages(self.agent.session_id(), 1)?;
1107 self.init_allowed = messages.is_empty();
1108 Ok(())
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use crate::agent::core::{MemoryRecallStats, MemoryRecallStrategy, ToolInvocation};
1116 use crate::agent::model::TokenUsage;
1117 use crate::agent::AgentOutput;
1118 use crate::config::{AudioConfig, DatabaseConfig, LoggingConfig, ModelConfig, UiConfig};
1119 use serde_json::json;
1120 use std::collections::HashMap;
1121 use std::path::PathBuf;
1122 use tempfile::tempdir;
1123
1124 #[test]
1125 fn test_parse_commands() {
1126 assert_eq!(parse_command("/help"), Command::Help);
1127 assert_eq!(parse_command("/quit"), Command::Quit);
1128 assert_eq!(parse_command("/config reload"), Command::ConfigReload);
1129 assert_eq!(parse_command("/config show"), Command::ConfigShow);
1130 assert_eq!(parse_command("/agents"), Command::ListAgents);
1131 assert_eq!(parse_command("/list"), Command::ListAgents);
1132 assert_eq!(parse_command("/init"), Command::Init(None));
1133 assert_eq!(
1134 parse_command("/init --plugins=rust-cargo"),
1135 Command::Init(Some(vec!["rust-cargo".to_string()]))
1136 );
1137 assert_eq!(
1138 parse_command("/init --plugins=rust-cargo,python"),
1139 Command::Init(Some(vec!["rust-cargo".to_string(), "python".to_string()]))
1140 );
1141 assert_eq!(
1142 parse_command("/switch coder"),
1143 Command::SwitchAgent("coder".into())
1144 );
1145 assert_eq!(
1146 parse_command("/memory show 5"),
1147 Command::MemoryShow(Some(5))
1148 );
1149 assert_eq!(parse_command("/session list"), Command::SessionList);
1150 assert_eq!(parse_command("/session new"), Command::SessionNew(None));
1151 assert_eq!(
1152 parse_command("/session new s2"),
1153 Command::SessionNew(Some("s2".into()))
1154 );
1155 assert_eq!(
1156 parse_command("/session switch abc"),
1157 Command::SessionSwitch("abc".into())
1158 );
1159 assert_eq!(
1160 parse_command("/spec run plan.spec"),
1161 Command::RunSpec(PathBuf::from("plan.spec"))
1162 );
1163 assert_eq!(
1164 parse_command("/spec nested/path/my.spec"),
1165 Command::RunSpec(PathBuf::from("nested/path/my.spec"))
1166 );
1167 assert_eq!(parse_command("hello"), Command::Message("hello".into()));
1168 assert_eq!(parse_command(" "), Command::Empty);
1169 }
1170
1171 #[test]
1172 fn reasoning_messages_default() {
1173 let output = AgentOutput {
1174 response: String::new(),
1175 response_message_id: None,
1176 token_usage: None,
1177 tool_invocations: Vec::new(),
1178 finish_reason: None,
1179 recall_stats: None,
1180 run_id: "run-default".to_string(),
1181 next_action: None,
1182 reasoning: None,
1183 reasoning_summary: None,
1184 graph_debug: None,
1185 };
1186 let lines = CliState::format_reasoning_messages(&output);
1187 assert_eq!(
1188 lines,
1189 vec![
1190 "Recall: not used".to_string(),
1191 "Tool: idle".to_string(),
1192 "Finish: pending".to_string()
1193 ]
1194 );
1195 }
1196
1197 #[test]
1198 fn reasoning_messages_with_details() {
1199 let stats = MemoryRecallStats {
1200 strategy: MemoryRecallStrategy::Semantic {
1201 requested: 5,
1202 returned: 2,
1203 },
1204 matches: Vec::new(),
1205 };
1206 let invocation = ToolInvocation {
1207 name: "search".to_string(),
1208 arguments: json!({}),
1209 success: true,
1210 output: Some("ok".to_string()),
1211 error: None,
1212 };
1213 let output = AgentOutput {
1214 response: String::new(),
1215 response_message_id: None,
1216 token_usage: None,
1217 tool_invocations: vec![invocation],
1218 finish_reason: Some("stop".to_string()),
1219 recall_stats: Some(stats),
1220 run_id: "run-details".to_string(),
1221 next_action: None,
1222 reasoning: None,
1223 reasoning_summary: None,
1224 graph_debug: None,
1225 };
1226 let lines = CliState::format_reasoning_messages(&output);
1227 assert!(lines[0].starts_with("Recall: semantic"));
1228 assert!(lines[1].contains("search"));
1229 assert_eq!(lines[2], "Finish: stop");
1230 }
1231
1232 #[test]
1233 fn reasoning_messages_tokens() {
1234 let usage = TokenUsage {
1235 prompt_tokens: 4,
1236 completion_tokens: 6,
1237 total_tokens: 10,
1238 };
1239 let output = AgentOutput {
1240 response: String::new(),
1241 response_message_id: None,
1242 token_usage: Some(usage),
1243 tool_invocations: Vec::new(),
1244 finish_reason: None,
1245 recall_stats: None,
1246 run_id: "run-tokens".to_string(),
1247 next_action: None,
1248 reasoning: None,
1249 reasoning_summary: None,
1250 graph_debug: None,
1251 };
1252 let lines = CliState::format_reasoning_messages(&output);
1253 assert_eq!(lines[2], "Tokens: P 4 C 6 T 10");
1254 }
1255
1256 async fn test_cli_smoke() {
1258 formatting::set_plain_text_mode(true);
1260
1261 let dir = tempdir().unwrap();
1262 let db_path = dir.path().join("cli.duckdb");
1263
1264 let mut agents = HashMap::new();
1266 agents.insert("test".to_string(), AgentProfile::default());
1267
1268 let config = AppConfig {
1269 database: DatabaseConfig { path: db_path },
1270 model: ModelConfig {
1271 provider: "mock".into(),
1272 model_name: None,
1273 embeddings_model: None,
1274 api_key_source: None,
1275 temperature: 0.7,
1276 },
1277 ui: UiConfig {
1278 prompt: "> ".into(),
1279 theme: "default".into(),
1280 },
1281 logging: LoggingConfig {
1282 level: "info".into(),
1283 },
1284 audio: AudioConfig::default(),
1285 mesh: crate::config::MeshConfig::default(),
1286 agents,
1287 default_agent: Some("test".into()),
1288 };
1289
1290 let mut cli = CliState::new_with_config(config).unwrap();
1291
1292 let out1 = cli.handle_line("hello").await.unwrap().unwrap();
1294 assert!(!out1.is_empty()); let out2 = cli.handle_line("/memory show 10").await.unwrap().unwrap();
1298 assert!(out2.contains("user:"));
1299 assert!(out2.contains("assistant:"));
1300
1301 let out3 = cli.handle_line("/session new s2").await.unwrap().unwrap();
1303 assert!(out3.contains("s2"));
1304
1305 let _ = cli.handle_line("hi").await.unwrap().unwrap();
1307
1308 let out4 = cli.handle_line("/session list").await.unwrap().unwrap();
1310 assert!(out4.contains("s2"));
1311 }
1312
1313 #[cfg_attr(
1314 target_os = "macos",
1315 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1316 )]
1317 #[tokio::test]
1318 async fn test_list_agents_command() {
1319 formatting::set_plain_text_mode(true);
1321
1322 let dir = tempdir().unwrap();
1323 let db_path = dir.path().join("cli_agents.duckdb");
1324
1325 let mut agents = HashMap::new();
1327 agents.insert("coder".to_string(), AgentProfile::default());
1328 agents.insert("researcher".to_string(), AgentProfile::default());
1329
1330 let config = AppConfig {
1331 database: DatabaseConfig { path: db_path },
1332 model: ModelConfig {
1333 provider: "mock".into(),
1334 model_name: None,
1335 embeddings_model: None,
1336 api_key_source: None,
1337 temperature: 0.7,
1338 },
1339 ui: UiConfig {
1340 prompt: "> ".into(),
1341 theme: "default".into(),
1342 },
1343 logging: LoggingConfig {
1344 level: "info".into(),
1345 },
1346 audio: AudioConfig::default(),
1347 mesh: crate::config::MeshConfig::default(),
1348 agents,
1349 default_agent: Some("coder".into()),
1350 };
1351
1352 let mut cli = CliState::new_with_config(config).unwrap();
1353
1354 let out = cli.handle_line("/agents").await.unwrap().unwrap();
1356 assert!(out.contains("Available agents:"));
1357 assert!(out.contains("coder"));
1358 assert!(out.contains("researcher"));
1359 assert!(out.contains("(active)")); let out2 = cli.handle_line("/list").await.unwrap().unwrap();
1363 assert!(out2.contains("Available agents:"));
1364 }
1365
1366 #[cfg_attr(
1367 target_os = "macos",
1368 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1369 )]
1370 #[tokio::test]
1371 async fn test_config_show_command() {
1372 let dir = tempdir().unwrap();
1373 let db_path = dir.path().join("cli_config.duckdb");
1374
1375 let mut agents = HashMap::new();
1376 agents.insert("test".to_string(), AgentProfile::default());
1377
1378 let config = AppConfig {
1379 database: DatabaseConfig {
1380 path: db_path.clone(),
1381 },
1382 model: ModelConfig {
1383 provider: "mock".into(),
1384 model_name: Some("test-model".into()),
1385 embeddings_model: None,
1386 api_key_source: None,
1387 temperature: 0.8,
1388 },
1389 ui: UiConfig {
1390 prompt: "> ".into(),
1391 theme: "dark".into(),
1392 },
1393 logging: LoggingConfig {
1394 level: "debug".into(),
1395 },
1396 audio: AudioConfig::default(),
1397 mesh: crate::config::MeshConfig::default(),
1398 agents,
1399 default_agent: Some("test".into()),
1400 };
1401
1402 let mut cli = CliState::new_with_config(config).unwrap();
1403
1404 let out = cli.handle_line("/config show").await.unwrap().unwrap();
1406 assert!(out.contains("Configuration loaded:"));
1407 assert!(out.contains("Model Provider: mock"));
1408 assert!(out.contains("Model Name: test-model"));
1409 assert!(out.contains("Temperature: 0.8"));
1410 assert!(out.contains("Logging Level: debug"));
1411 assert!(out.contains("UI Theme: dark"));
1412 }
1413
1414 #[cfg_attr(
1415 target_os = "macos",
1416 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1417 )]
1418 #[tokio::test]
1419 async fn test_help_command() {
1420 let dir = tempdir().unwrap();
1421 let db_path = dir.path().join("cli_help.duckdb");
1422
1423 let mut agents = HashMap::new();
1424 agents.insert("test".to_string(), AgentProfile::default());
1425
1426 let config = AppConfig {
1427 database: DatabaseConfig { path: db_path },
1428 model: ModelConfig {
1429 provider: "mock".into(),
1430 model_name: None,
1431 embeddings_model: None,
1432 api_key_source: None,
1433 temperature: 0.7,
1434 },
1435 ui: UiConfig {
1436 prompt: "> ".into(),
1437 theme: "default".into(),
1438 },
1439 logging: LoggingConfig {
1440 level: "info".into(),
1441 },
1442 audio: AudioConfig::default(),
1443 mesh: crate::config::MeshConfig::default(),
1444 agents,
1445 default_agent: Some("test".into()),
1446 };
1447
1448 let mut cli = CliState::new_with_config(config).unwrap();
1449
1450 let out = cli.handle_line("/help").await.unwrap().unwrap();
1452 assert!(out.contains("Commands") || out.contains("SpecAI"));
1453 assert!(out.contains("/config show") || out.contains("config"));
1454 assert!(out.contains("/agents") || out.contains("agents"));
1455 assert!(out.contains("/list") || out.contains("list"));
1456 }
1457}