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