spec_ai_core/cli/
mod.rs

1//! CLI module for Epic 4 — minimal REPL and command parser
2
3pub 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    // Graph commands
41    GraphEnable,
42    GraphDisable,
43    GraphStatus,
44    GraphShow(Option<usize>),
45    GraphClear,
46    // Sync commands
47    SyncList,
48    // Audio commands
49    ListenStart(Option<u64>), // duration in seconds
50    ListenStop,
51    ListenStatus,
52    Listen(Option<String>, Option<u64>), // Deprecated: kept for backward compatibility
53    PasteStart,
54    RunSpec(PathBuf),
55    SpeechToggle(Option<bool>),
56    Init(Option<Vec<String>>),    // optional plugins list
57    Refresh(Option<Vec<String>>), // rerun bootstrap with caching
58    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                        // If it's a number, treat it as duration for backward compatibility
140                        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
214/// Transcription task handle for background listening
215struct 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    /// Initialize from loaded config (AppConfig::load)
240    pub fn initialize() -> Result<Self> {
241        let config = AppConfig::load()?;
242        Self::new_with_config(config)
243    }
244
245    /// Initialize from a specific config file path
246    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    /// Create a CLI state from a provided config
256    pub fn new_with_config(config: AppConfig) -> Result<Self> {
257        let persistence =
258            Persistence::new(&config.database.path).context("initializing persistence")?;
259
260        // Build registry and ensure an active agent exists
261        let initial_agents = config.agents.clone();
262        let registry = AgentRegistry::new(initial_agents.clone(), persistence.clone());
263        registry.init()?;
264
265        // Ensure we have an active agent
266        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 still none, create or pick a default profile
275            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                // Pick first agent by name
281                if let Some(first) = registry.list().first().cloned() {
282                    registry.set_active(&first)?;
283                }
284            }
285        }
286
287        // Create the AgentCore from registry + config
288        let agent = AgentBuilder::new_with_registry(&registry, &config, None)?;
289
290        // Create transcription provider from config
291        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        // Apply sync configuration from config file
326        state.apply_sync_config()?;
327
328        Ok(state)
329    }
330
331    /// Apply sync configuration from config file
332    fn apply_sync_config(&self) -> Result<()> {
333        if !self.config.sync.enabled {
334            return Ok(());
335        }
336
337        // Enable sync for each configured namespace
338        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    /// Save transcription chunks to database with embeddings
354    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            // Insert transcription
361            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                    // Generate and link embedding
369                    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    /// Handle a single line of input. Returns an optional output string.
390    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                // rebuild persistence (path may have changed)
421                self.persistence = Persistence::new(&self.config.database.path)?;
422                // rebuild registry with new agents
423                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                // Recreate agent preserving session
430                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                // Load policies from persistence
443                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                // Update the agent's policy engine
448                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            // Graph commands
515            Command::GraphEnable => {
516                // For now, just show instructions for enabling graph features
517                // Since modifying the agent at runtime requires complex rebuilding
518                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                // For now, just show instructions for disabling graph features
534                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                    // Also show edge count
588                    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                // Get all nodes and delete them (edges will cascade)
598                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            // Sync commands
611            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                // Check if already running
629                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                // Build transcription config from app config
637                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                // Create stop channel and chunks channel
652                let (stop_tx, mut stop_rx) = mpsc::unbounded_channel::<()>();
653                let (chunks_tx, chunks_rx) = mpsc::unbounded_channel::<String>();
654
655                // Clone provider for background task
656                let provider = Arc::clone(&self.transcription_provider);
657                let provider_name = provider.metadata().name.clone();
658                let provider_name_display = provider_name.clone(); // Clone for response message
659                let started_at = std::time::SystemTime::now();
660
661                // Spawn background thread with LocalSet for spawn_local support
662                let handle = std::thread::spawn(move || {
663                    // Create a current_thread runtime with LocalSet support
664                    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                        // Start transcription
673                        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                                        // Check for stop signal
682                                        _ = stop_rx.recv() => {
683                                            println!("\n[Transcription] Stopped by user");
684                                            break;
685                                        }
686                                        // Process transcription events
687                                        event = stream.next() => {
688                                            match event {
689                                                Some(Ok(TranscriptionEvent::Started { .. })) => {
690                                                    // Already logged above
691                                                }
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                // Store task info
723                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                    // Send stop signal
740                    let _ = task.stop_tx.send(());
741
742                    // Collect any remaining chunks
743                    let mut chunks = Vec::new();
744                    while let Ok(text) = task.chunks_rx.try_recv() {
745                        chunks.push(text);
746                    }
747
748                    // Save to database
749                    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                // Check if task is finished and save chunks if so
763                if let Some(task) = self.transcription_task.take() {
764                    if task.handle.is_finished() {
765                        // Collect chunks
766                        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                        // Save to database
773                        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                        // Put it back since it's still running
783                        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                // Redirect to new command
807                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                // Paste mode is handled at the REPL loop level; this arm is mainly for tests
814                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    /// Run interactive REPL on stdin/stdout
902    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        // Print welcome and summary
909        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            } // EOF
921
922            let trimmed = line.trim_end_matches(&['\n', '\r'][..]);
923
924            // If we're currently in paste mode, accumulate lines until the user
925            // types /end on its own line, then send the entire block as one
926            // message.
927            if self.paste_mode {
928                if trimmed == "/end" {
929                    // Leave paste mode and send the buffered block
930                    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            // Normal mode: single-line commands and messages
958            let command_preview = parse_command(&line);
959            if matches!(command_preview, Command::PasteStart) {
960                // Enter paste mode; UI hint
961                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 this is a normal message to the agent, allow interruption with ESC
975            if matches!(command_preview, Command::Message(_)) {
976                let speech_flag = self.speech_enabled.clone();
977                let mut pending_speech_toggle: Option<bool> = None;
978                // Prepare the future for handling the line
979                let mut fut = Box::pin(self.handle_line(&line));
980
981                // Enable raw mode to capture ESC without requiring Enter
982                let _ = enable_raw_mode();
983                let mut events = EventStream::new();
984                let mut interrupted = false;
985
986                // Wait for either the agent response or an ESC key press
987                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                                        // User requested interruption; drop the future by breaking
998                                        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(_)) => { /* ignore other events */ }
1021                                Some(Err(_)) => { /* ignore event errors */ }
1022                                None => { /* stream ended */ }
1023                            }
1024                        }
1025                    }
1026                };
1027
1028                // Ensure the in-flight future is dropped before mutating self to release the &mut borrow
1029                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                    // Set interrupted status and hand control back to the user
1038                    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        // Checkpoint database before exiting to ensure all WAL data is written
1070        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    /// Optionally speak the assistant response aloud (macOS only)
1301    #[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    /// No-op placeholder for non-macOS platforms
1326    #[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        // Padding shorter string
1348        let padded = CliState::pad_line_to_width("abc", 5);
1349        assert_eq!(padded, "abc  ");
1350
1351        // Exact width should be unchanged
1352        let exact = CliState::pad_line_to_width("hello", 5);
1353        assert_eq!(exact, "hello");
1354
1355        // Truncation should occur cleanly by character boundaries
1356        let truncated = CliState::pad_line_to_width("helloworld", 4);
1357        assert_eq!(truncated, "hell");
1358
1359        // Zero width should return empty string
1360        let zero = CliState::pad_line_to_width("anything", 0);
1361        assert_eq!(zero, "");
1362
1363        // Unicode characters: ensure we truncate/pad by chars, not bytes
1364        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    // #[tokio::test]
1509    async fn test_cli_smoke() {
1510        // Force plain text mode for consistent test output
1511        formatting::set_plain_text_mode(true);
1512
1513        let dir = tempdir().unwrap();
1514        let db_path = dir.path().join("cli.duckdb");
1515
1516        // Minimal config with one agent
1517        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        // Send a user message
1547        let out1 = cli.handle_line("hello").await.unwrap().unwrap();
1548        assert!(!out1.is_empty()); // mock response
1549
1550        // Memory show should show the last two messages
1551        let out2 = cli.handle_line("/memory show 10").await.unwrap().unwrap();
1552        assert!(out2.contains("user:"));
1553        assert!(out2.contains("assistant:"));
1554
1555        // Start a new session and ensure it switches
1556        let out3 = cli.handle_line("/session new s2").await.unwrap().unwrap();
1557        assert!(out3.contains("s2"));
1558
1559        // Send another message in new session
1560        let _ = cli.handle_line("hi").await.unwrap().unwrap();
1561
1562        // List sessions should include s2
1563        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        // Force plain text mode for consistent test output
1574        formatting::set_plain_text_mode(true);
1575
1576        let dir = tempdir().unwrap();
1577        let db_path = dir.path().join("cli_agents.duckdb");
1578
1579        // Config with multiple agents
1580        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        // Test /agents command
1611        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)")); // coder should be marked active
1616
1617        // Test /list alias
1618        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        // Test /config show command
1663        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        // Test /help command - output now includes markdown formatting
1711        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}