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