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