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::{
1119 AudioConfig, DatabaseConfig, LoggingConfig, ModelConfig, PluginConfig, UiConfig,
1120 };
1121 use serde_json::json;
1122 use std::collections::HashMap;
1123 use std::path::PathBuf;
1124 use tempfile::tempdir;
1125
1126 #[test]
1127 fn test_parse_commands() {
1128 assert_eq!(parse_command("/help"), Command::Help);
1129 assert_eq!(parse_command("/quit"), Command::Quit);
1130 assert_eq!(parse_command("/config reload"), Command::ConfigReload);
1131 assert_eq!(parse_command("/config show"), Command::ConfigShow);
1132 assert_eq!(parse_command("/agents"), Command::ListAgents);
1133 assert_eq!(parse_command("/list"), Command::ListAgents);
1134 assert_eq!(parse_command("/init"), Command::Init(None));
1135 assert_eq!(
1136 parse_command("/init --plugins=rust-cargo"),
1137 Command::Init(Some(vec!["rust-cargo".to_string()]))
1138 );
1139 assert_eq!(
1140 parse_command("/init --plugins=rust-cargo,python"),
1141 Command::Init(Some(vec!["rust-cargo".to_string(), "python".to_string()]))
1142 );
1143 assert_eq!(
1144 parse_command("/switch coder"),
1145 Command::SwitchAgent("coder".into())
1146 );
1147 assert_eq!(
1148 parse_command("/memory show 5"),
1149 Command::MemoryShow(Some(5))
1150 );
1151 assert_eq!(parse_command("/session list"), Command::SessionList);
1152 assert_eq!(parse_command("/session new"), Command::SessionNew(None));
1153 assert_eq!(
1154 parse_command("/session new s2"),
1155 Command::SessionNew(Some("s2".into()))
1156 );
1157 assert_eq!(
1158 parse_command("/session switch abc"),
1159 Command::SessionSwitch("abc".into())
1160 );
1161 assert_eq!(
1162 parse_command("/spec run plan.spec"),
1163 Command::RunSpec(PathBuf::from("plan.spec"))
1164 );
1165 assert_eq!(
1166 parse_command("/spec nested/path/my.spec"),
1167 Command::RunSpec(PathBuf::from("nested/path/my.spec"))
1168 );
1169 assert_eq!(parse_command("hello"), Command::Message("hello".into()));
1170 assert_eq!(parse_command(" "), Command::Empty);
1171 }
1172
1173 #[test]
1174 fn reasoning_messages_default() {
1175 let output = AgentOutput {
1176 response: String::new(),
1177 response_message_id: None,
1178 token_usage: None,
1179 tool_invocations: Vec::new(),
1180 finish_reason: None,
1181 recall_stats: None,
1182 run_id: "run-default".to_string(),
1183 next_action: None,
1184 reasoning: None,
1185 reasoning_summary: None,
1186 graph_debug: None,
1187 };
1188 let lines = CliState::format_reasoning_messages(&output);
1189 assert_eq!(
1190 lines,
1191 vec![
1192 "Recall: not used".to_string(),
1193 "Tool: idle".to_string(),
1194 "Finish: pending".to_string()
1195 ]
1196 );
1197 }
1198
1199 #[test]
1200 fn reasoning_messages_with_details() {
1201 let stats = MemoryRecallStats {
1202 strategy: MemoryRecallStrategy::Semantic {
1203 requested: 5,
1204 returned: 2,
1205 },
1206 matches: Vec::new(),
1207 };
1208 let invocation = ToolInvocation {
1209 name: "search".to_string(),
1210 arguments: json!({}),
1211 success: true,
1212 output: Some("ok".to_string()),
1213 error: None,
1214 };
1215 let output = AgentOutput {
1216 response: String::new(),
1217 response_message_id: None,
1218 token_usage: None,
1219 tool_invocations: vec![invocation],
1220 finish_reason: Some("stop".to_string()),
1221 recall_stats: Some(stats),
1222 run_id: "run-details".to_string(),
1223 next_action: None,
1224 reasoning: None,
1225 reasoning_summary: None,
1226 graph_debug: None,
1227 };
1228 let lines = CliState::format_reasoning_messages(&output);
1229 assert!(lines[0].starts_with("Recall: semantic"));
1230 assert!(lines[1].contains("search"));
1231 assert_eq!(lines[2], "Finish: stop");
1232 }
1233
1234 #[test]
1235 fn reasoning_messages_tokens() {
1236 let usage = TokenUsage {
1237 prompt_tokens: 4,
1238 completion_tokens: 6,
1239 total_tokens: 10,
1240 };
1241 let output = AgentOutput {
1242 response: String::new(),
1243 response_message_id: None,
1244 token_usage: Some(usage),
1245 tool_invocations: Vec::new(),
1246 finish_reason: None,
1247 recall_stats: None,
1248 run_id: "run-tokens".to_string(),
1249 next_action: None,
1250 reasoning: None,
1251 reasoning_summary: None,
1252 graph_debug: None,
1253 };
1254 let lines = CliState::format_reasoning_messages(&output);
1255 assert_eq!(lines[2], "Tokens: P 4 C 6 T 10");
1256 }
1257
1258 async fn test_cli_smoke() {
1260 formatting::set_plain_text_mode(true);
1262
1263 let dir = tempdir().unwrap();
1264 let db_path = dir.path().join("cli.duckdb");
1265
1266 let mut agents = HashMap::new();
1268 agents.insert("test".to_string(), AgentProfile::default());
1269
1270 let config = AppConfig {
1271 database: DatabaseConfig { path: db_path },
1272 model: ModelConfig {
1273 provider: "mock".into(),
1274 model_name: None,
1275 embeddings_model: None,
1276 api_key_source: None,
1277 temperature: 0.7,
1278 },
1279 ui: UiConfig {
1280 prompt: "> ".into(),
1281 theme: "default".into(),
1282 },
1283 logging: LoggingConfig {
1284 level: "info".into(),
1285 },
1286 audio: AudioConfig::default(),
1287 mesh: crate::config::MeshConfig::default(),
1288 plugins: PluginConfig::default(),
1289 agents,
1290 default_agent: Some("test".into()),
1291 };
1292
1293 let mut cli = CliState::new_with_config(config).unwrap();
1294
1295 let out1 = cli.handle_line("hello").await.unwrap().unwrap();
1297 assert!(!out1.is_empty()); let out2 = cli.handle_line("/memory show 10").await.unwrap().unwrap();
1301 assert!(out2.contains("user:"));
1302 assert!(out2.contains("assistant:"));
1303
1304 let out3 = cli.handle_line("/session new s2").await.unwrap().unwrap();
1306 assert!(out3.contains("s2"));
1307
1308 let _ = cli.handle_line("hi").await.unwrap().unwrap();
1310
1311 let out4 = cli.handle_line("/session list").await.unwrap().unwrap();
1313 assert!(out4.contains("s2"));
1314 }
1315
1316 #[cfg_attr(
1317 target_os = "macos",
1318 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1319 )]
1320 #[tokio::test]
1321 async fn test_list_agents_command() {
1322 formatting::set_plain_text_mode(true);
1324
1325 let dir = tempdir().unwrap();
1326 let db_path = dir.path().join("cli_agents.duckdb");
1327
1328 let mut agents = HashMap::new();
1330 agents.insert("coder".to_string(), AgentProfile::default());
1331 agents.insert("researcher".to_string(), AgentProfile::default());
1332
1333 let config = AppConfig {
1334 database: DatabaseConfig { path: db_path },
1335 model: ModelConfig {
1336 provider: "mock".into(),
1337 model_name: None,
1338 embeddings_model: None,
1339 api_key_source: None,
1340 temperature: 0.7,
1341 },
1342 ui: UiConfig {
1343 prompt: "> ".into(),
1344 theme: "default".into(),
1345 },
1346 logging: LoggingConfig {
1347 level: "info".into(),
1348 },
1349 audio: AudioConfig::default(),
1350 mesh: crate::config::MeshConfig::default(),
1351 plugins: PluginConfig::default(),
1352 agents,
1353 default_agent: Some("coder".into()),
1354 };
1355
1356 let mut cli = CliState::new_with_config(config).unwrap();
1357
1358 let out = cli.handle_line("/agents").await.unwrap().unwrap();
1360 assert!(out.contains("Available agents:"));
1361 assert!(out.contains("coder"));
1362 assert!(out.contains("researcher"));
1363 assert!(out.contains("(active)")); let out2 = cli.handle_line("/list").await.unwrap().unwrap();
1367 assert!(out2.contains("Available agents:"));
1368 }
1369
1370 #[cfg_attr(
1371 target_os = "macos",
1372 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1373 )]
1374 #[tokio::test]
1375 async fn test_config_show_command() {
1376 let dir = tempdir().unwrap();
1377 let db_path = dir.path().join("cli_config.duckdb");
1378
1379 let mut agents = HashMap::new();
1380 agents.insert("test".to_string(), AgentProfile::default());
1381
1382 let config = AppConfig {
1383 database: DatabaseConfig {
1384 path: db_path.clone(),
1385 },
1386 model: ModelConfig {
1387 provider: "mock".into(),
1388 model_name: Some("test-model".into()),
1389 embeddings_model: None,
1390 api_key_source: None,
1391 temperature: 0.8,
1392 },
1393 ui: UiConfig {
1394 prompt: "> ".into(),
1395 theme: "dark".into(),
1396 },
1397 logging: LoggingConfig {
1398 level: "debug".into(),
1399 },
1400 audio: AudioConfig::default(),
1401 mesh: crate::config::MeshConfig::default(),
1402 plugins: PluginConfig::default(),
1403 agents,
1404 default_agent: Some("test".into()),
1405 };
1406
1407 let mut cli = CliState::new_with_config(config).unwrap();
1408
1409 let out = cli.handle_line("/config show").await.unwrap().unwrap();
1411 assert!(out.contains("Configuration loaded:"));
1412 assert!(out.contains("Model Provider: mock"));
1413 assert!(out.contains("Model Name: test-model"));
1414 assert!(out.contains("Temperature: 0.8"));
1415 assert!(out.contains("Logging Level: debug"));
1416 assert!(out.contains("UI Theme: dark"));
1417 }
1418
1419 #[cfg_attr(
1420 target_os = "macos",
1421 ignore = "SystemConfiguration unavailable in sandboxed macOS runners"
1422 )]
1423 #[tokio::test]
1424 async fn test_help_command() {
1425 let dir = tempdir().unwrap();
1426 let db_path = dir.path().join("cli_help.duckdb");
1427
1428 let mut agents = HashMap::new();
1429 agents.insert("test".to_string(), AgentProfile::default());
1430
1431 let config = AppConfig {
1432 database: DatabaseConfig { path: db_path },
1433 model: ModelConfig {
1434 provider: "mock".into(),
1435 model_name: None,
1436 embeddings_model: None,
1437 api_key_source: None,
1438 temperature: 0.7,
1439 },
1440 ui: UiConfig {
1441 prompt: "> ".into(),
1442 theme: "default".into(),
1443 },
1444 logging: LoggingConfig {
1445 level: "info".into(),
1446 },
1447 audio: AudioConfig::default(),
1448 mesh: crate::config::MeshConfig::default(),
1449 plugins: PluginConfig::default(),
1450 agents,
1451 default_agent: Some("test".into()),
1452 };
1453
1454 let mut cli = CliState::new_with_config(config).unwrap();
1455
1456 let out = cli.handle_line("/help").await.unwrap().unwrap();
1458 assert!(out.contains("Commands") || out.contains("SpecAI"));
1459 assert!(out.contains("/config show") || out.contains("config"));
1460 assert!(out.contains("/agents") || out.contains("agents"));
1461 assert!(out.contains("/list") || out.contains("list"));
1462 }
1463}