Skip to main content

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