spec_ai_core/cli/
formatting.rs

1//! Terminal formatting utilities using termimad for rich markdown rendering
2
3use crate::agent::core::{AgentOutput, MemoryRecallStrategy};
4use serde_json::to_string;
5use std::cell::Cell;
6use termimad::*;
7
8thread_local! {
9    /// Override for terminal detection in tests
10    static FORCE_PLAIN_TEXT: Cell<bool> = const { Cell::new(false) };
11}
12
13/// Force plain text output (for testing)
14/// Available for both unit and integration tests
15pub fn set_plain_text_mode(enabled: bool) {
16    FORCE_PLAIN_TEXT.with(|f| f.set(enabled));
17}
18
19/// Initialize a custom MadSkin with spec-ai color scheme
20pub fn create_skin() -> MadSkin {
21    let mut skin = MadSkin::default();
22
23    // Headers - cyan with bold
24    let mut header_style = CompoundStyle::with_fg(termimad::crossterm::style::Color::Cyan);
25    header_style.add_attr(termimad::crossterm::style::Attribute::Bold);
26    skin.headers[0].compound_style = header_style;
27    skin.headers[1].compound_style =
28        CompoundStyle::with_fg(termimad::crossterm::style::Color::Cyan);
29
30    // Bold text - bright white
31    skin.bold.set_fg(termimad::crossterm::style::Color::White);
32
33    // Italic - dim white
34    skin.italic.set_fg(termimad::crossterm::style::Color::Grey);
35
36    // Inline code - yellow background
37    skin.inline_code
38        .set_fg(termimad::crossterm::style::Color::Yellow);
39
40    // Code blocks - with border
41    skin.code_block
42        .set_fg(termimad::crossterm::style::Color::White);
43
44    // Links - blue
45    skin.paragraph.compound_style = CompoundStyle::default();
46
47    // Lists - improved bullet points with better colors and symbol
48    skin.bullet = StyledChar::from_fg_char(termimad::crossterm::style::Color::Green, '▸');
49
50    // List item styling - make list items stand out
51    skin.paragraph.compound_style =
52        CompoundStyle::with_fg(termimad::crossterm::style::Color::White);
53
54    // Quote styling for better visual hierarchy
55    skin.quote_mark
56        .set_fg(termimad::crossterm::style::Color::DarkCyan);
57    skin.quote_mark.set_char('┃');
58
59    skin
60}
61
62/// Check if we're in a TTY (terminal) or if output is piped/redirected
63pub fn is_terminal() -> bool {
64    // Check for test override first
65    if FORCE_PLAIN_TEXT.with(|f| f.get()) {
66        return false;
67    }
68
69    // Use terminal_size as a proxy for TTY detection
70    terminal_size::terminal_size().is_some()
71}
72
73/// Render markdown text with the spec-ai skin
74/// Falls back to plain text if not in a terminal
75pub fn render_markdown(text: &str) -> String {
76    if !is_terminal() {
77        return text.to_string();
78    }
79
80    let skin = create_skin();
81    let terminal_width = terminal_size::terminal_size()
82        .map(|(w, _)| w.0 as usize)
83        .unwrap_or(80);
84
85    skin.text(text, Some(terminal_width)).to_string()
86}
87
88/// Render agent response with markdown formatting
89pub fn render_agent_response(role: &str, content: &str) -> String {
90    if !is_terminal() {
91        return format!("{}: {}", role, content);
92    }
93
94    let skin = create_skin();
95    let terminal_width = terminal_size::terminal_size()
96        .map(|(w, _)| w.0 as usize)
97        .unwrap_or(80);
98
99    // Format with role header
100    let formatted = format!("**{}:**\n\n{}", role, content);
101    skin.text(&formatted, Some(terminal_width)).to_string()
102}
103
104/// Render run metadata (memory recall, tools, token usage)
105pub fn render_run_stats(output: &AgentOutput, show_reasoning: bool) -> Option<String> {
106    let mut sections = Vec::new();
107
108    if let Some(stats) = &output.recall_stats {
109        let mut section = String::from("## Memory Recall\n");
110        match stats.strategy {
111            MemoryRecallStrategy::Semantic {
112                requested,
113                returned,
114            } => {
115                section.push_str(&format!(
116                    "- Strategy: semantic (requested top {}, returned {})\n",
117                    requested, returned
118                ));
119            }
120            MemoryRecallStrategy::RecentContext { limit } => {
121                section.push_str(&format!(
122                    "- Strategy: recent context window (last {} messages)\n",
123                    limit
124                ));
125            }
126        }
127
128        if stats.matches.is_empty() {
129            section.push_str("- No recalled vector matches this turn.\n");
130        } else {
131            section.push_str("- Matches:\n");
132            for (idx, m) in stats.matches.iter().take(3).enumerate() {
133                section.push_str(&format!(
134                    "  {}. [{} | score {:.2}] {}\n",
135                    idx + 1,
136                    m.role.as_str(),
137                    m.score,
138                    m.preview
139                ));
140            }
141            if stats.matches.len() > 3 {
142                section.push_str(&format!(
143                    "  ... {} additional matches omitted\n",
144                    stats.matches.len() - 3
145                ));
146            }
147        }
148        sections.push(section);
149    }
150
151    if !output.tool_invocations.is_empty() {
152        let mut section = String::from("## Tool Calls\n\n");
153        for (idx, inv) in output.tool_invocations.iter().enumerate() {
154            // Status symbol
155            let status_symbol = if inv.success { "✓" } else { "✗" };
156
157            // Tool header
158            section.push_str(&format!(
159                "**{}. {} [{}]**\n\n",
160                idx + 1,
161                inv.name,
162                status_symbol
163            ));
164
165            // Parse and format arguments nicely
166            if let Ok(args_map) = serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(
167                inv.arguments.clone(),
168            ) {
169                for (key, value) in args_map.iter() {
170                    let formatted_value = match value {
171                        serde_json::Value::String(s) => {
172                            if s.len() > 80 {
173                                format!("{}...", &s[..77])
174                            } else {
175                                s.clone()
176                            }
177                        }
178                        serde_json::Value::Number(n) => n.to_string(),
179                        serde_json::Value::Bool(b) => b.to_string(),
180                        _ => to_string(value).unwrap_or_else(|_| "...".to_string()),
181                    };
182                    section.push_str(&format!("  - **{}**: `{}`\n", key, formatted_value));
183                }
184            }
185
186            // Output section
187            if let Some(out) = &inv.output {
188                if !out.is_empty() {
189                    section.push_str("\n  **Result:**\n");
190
191                    // Try to parse as JSON for better formatting
192                    if let Ok(json_out) = serde_json::from_str::<serde_json::Value>(out) {
193                        // Extract key fields for common tool responses
194                        if let Some(obj) = json_out.as_object() {
195                            if let Some(stdout) = obj.get("stdout").and_then(|v| v.as_str()) {
196                                let lines: Vec<&str> = stdout.lines().collect();
197                                if !lines.is_empty() {
198                                    section
199                                        .push_str(&format!("  - stdout: {} lines\n", lines.len()));
200                                    // Show first few lines if not too many
201                                    if lines.len() <= 5 {
202                                        for line in lines.iter().take(5) {
203                                            let trimmed =
204                                                if line.len() > 60 { &line[..60] } else { line };
205                                            section.push_str(&format!("    `{}`\n", trimmed));
206                                        }
207                                    }
208                                }
209                            }
210                            if let Some(stderr) = obj.get("stderr").and_then(|v| v.as_str()) {
211                                if !stderr.is_empty() {
212                                    section.push_str(&format!("  - stderr: {}\n", stderr));
213                                }
214                            }
215                            if let Some(exit_code) = obj.get("exit_code") {
216                                section.push_str(&format!("  - exit_code: {}\n", exit_code));
217                            }
218                            if let Some(duration_ms) = obj.get("duration_ms") {
219                                section.push_str(&format!("  - duration: {}ms\n", duration_ms));
220                            }
221                        }
222                    } else {
223                        // Plain text output
224                        let trimmed = if out.len() > 200 {
225                            format!("{}... ({} chars)", &out[..197], out.len())
226                        } else {
227                            out.clone()
228                        };
229                        section.push_str(&format!("  ```\n  {}\n  ```\n", trimmed));
230                    }
231                }
232            }
233
234            // Error section
235            if let Some(err) = &inv.error {
236                section.push_str(&format!("\n  **Error:** {}\n", err));
237            }
238
239            section.push('\n');
240        }
241        sections.push(section);
242    }
243
244    if let Some(graph_debug) = &output.graph_debug {
245        let mut section = String::from("## Graph Debug\n");
246        section.push_str(&format!(
247            "- Enabled: {}\n- Memory: {}\n- Auto Build: {}\n- Steering: {}\n",
248            if graph_debug.enabled { "yes" } else { "no" },
249            if graph_debug.graph_memory_enabled {
250                "enabled"
251            } else {
252                "disabled"
253            },
254            if graph_debug.auto_graph_enabled {
255                "enabled"
256            } else {
257                "disabled"
258            },
259            if graph_debug.graph_steering_enabled {
260                "enabled"
261            } else {
262                "disabled"
263            }
264        ));
265
266        if graph_debug.enabled {
267            section.push_str(&format!(
268                "- Node Count: {}\n- Edge Count: {}\n",
269                graph_debug.node_count, graph_debug.edge_count
270            ));
271
272            if graph_debug.recent_nodes.is_empty() {
273                section.push_str("- Recent Nodes: none recorded yet\n");
274            } else {
275                section.push_str("- Recent Nodes:\n");
276                for node in &graph_debug.recent_nodes {
277                    section.push_str(&format!(
278                        "  - #{} [{}] {}\n",
279                        node.id, node.node_type, node.label
280                    ));
281                }
282            }
283        } else {
284            section.push_str("- Graph disabled; skipping node snapshot\n");
285        }
286
287        sections.push(section);
288    }
289
290    // Display reasoning summary if enabled and available
291    if show_reasoning {
292        // Display reasoning summary if available (more user-friendly)
293        // Fall back to full reasoning if no summary was generated
294        if let Some(summary) = &output.reasoning_summary {
295            if !summary.is_empty() {
296                let mut section = String::from("## Reasoning\n\n");
297                section.push_str(&format!("💭 {}\n", summary));
298                sections.push(section);
299            }
300        } else if let Some(reasoning) = &output.reasoning {
301            if !reasoning.is_empty() {
302                let mut section = String::from("## Reasoning\n\n");
303                // Truncate long reasoning for display
304                let preview = if reasoning.len() > 200 {
305                    format!(
306                        "💭 {}... ({} chars total)",
307                        &reasoning[..197],
308                        reasoning.len()
309                    )
310                } else {
311                    format!("💭 {}", reasoning)
312                };
313                section.push_str(&format!("{}\n", preview));
314                sections.push(section);
315            }
316        }
317    }
318
319    if let Some(next_action) = &output.next_action {
320        let mut section = String::from("## Graph Steering\n");
321        section.push_str(&format!("- Recommendation: {}\n", next_action));
322        sections.push(section);
323    }
324
325    if let Some(usage) = &output.token_usage {
326        sections.push(format!(
327            "## Tokens\n- Prompt: {}\n- Completion: {}\n- Total: {}\n",
328            usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
329        ));
330    }
331
332    if sections.is_empty() {
333        return None;
334    }
335
336    let markdown = format!("---\n\n# Run Stats\n\n{}", sections.join("\n"));
337    Some(render_markdown(&markdown))
338}
339
340/// Render help text with rich markdown formatting
341pub fn render_help() -> String {
342    let help_text = r#"
343# SpecAI Commands
344
345## Agent Management
346Manage your AI agent profiles and sessions:
347
348- **`/agents`** or **`/list`** — List all available agent profiles
349- **`/switch <name>`** — Switch to a different agent profile
350- **`/new <name>`** — Create new conversation session
351
352## Configuration
353Control your SpecAI configuration:
354
355- **`/config show`** — Display current configuration
356  - Shows model provider, temperature, and other settings
357- **`/config reload`** — Reload configuration from file
358  - Useful after editing spec-ai.config.toml
359
360## Memory & History
361Access conversation memory:
362
363- **`/memory show [N]`** — Show last N messages (default: 10)
364  - Displays color-coded conversation history
365- **`/memory clear`** — Clear conversation history
366
367## Session Management
368Manage multiple conversation sessions:
369
370- **`/session list`** — List all conversation sessions
371- **`/session load <id>`** — Load a specific session
372- **`/session delete <id>`** — Delete a session
373
374## Knowledge Graph
375AI reasoning with graph-based memory:
376
377- **`/graph enable`** — Enable knowledge graph features
378  - Activates graph memory and automatic entity extraction
379- **`/graph disable`** — Disable knowledge graph features
380- **`/graph status`** — Show current graph configuration
381- **`/graph show [N]`** — Display last N graph nodes (default: 10)
382- **`/graph clear`** — Clear graph for current session
383
384## Repository Bootstrap
385Prime the knowledge graph with source facts before the first prompt:
386
387- **`/init`** — Run the bootstrap-self pipeline against the repo (only valid as the first message)
388- **`/refresh`** — Re-run the bootstrap-self pipeline with caching enabled (safe after `/init`)
389
390## Audio Transcription
391Mock audio input transcription for testing:
392
393- **`/listen [scenario] [duration]`** — Start audio transcription simulation
394  - **Scenarios:** `simple_conversation`, `command_sequence`, `noisy_environment`, `emotional_context`, `multi_speaker`
395  - **Duration:** Time in seconds (default: 30)
396  - Example: `/listen simple_conversation 60`
397
398## Spec Runs
399Execute structured `.spec` files with clear goals:
400
401- **`/spec run <file>`** — Load and execute a TOML spec (extension must be `.spec`)
402- **`/spec <file>`** — Shorthand for `/spec run <file>`
403  - Specs must define a `goal` and at least one `tasks` or `deliverables` entry
404
405## General Commands
406- **`/help`** — Show this help message
407- **`/quit`** or **`/exit`** — Exit the REPL
408
409---
410
411**Usage:** Type your message to chat with the current agent. Use `/` prefix for commands.
412"#;
413
414    render_markdown(help_text)
415}
416
417/// Create a formatted table for agent list
418pub fn render_agent_table(agents: Vec<(String, bool, Option<String>)>) -> String {
419    if !is_terminal() {
420        // Plain text fallback
421        let mut output = String::from("Available agents:\n");
422        for (name, is_active, description) in agents {
423            let active_marker = if is_active { " (active)" } else { "" };
424            let desc = description.unwrap_or_default();
425            output.push_str(&format!("  - {}{}", name, active_marker));
426            if !desc.is_empty() {
427                output.push_str(&format!(" - {}", desc));
428            }
429            output.push('\n');
430        }
431        return output;
432    }
433
434    let skin = create_skin();
435    let terminal_width = terminal_size::terminal_size()
436        .map(|(w, _)| w.0 as usize)
437        .unwrap_or(80);
438
439    // Build markdown table
440    let mut table = String::from("# Available Agents\n\n");
441    table.push_str("| Agent | Status | Description |\n");
442    table.push_str("|-------|--------|-------------|\n");
443
444    for (name, is_active, description) in agents {
445        let status = if is_active { "**active**" } else { "" };
446        let desc = description.unwrap_or_default();
447        table.push_str(&format!("| {} | {} | {} |\n", name, status, desc));
448    }
449
450    skin.text(&table, Some(terminal_width)).to_string()
451}
452
453/// Format memory/history display with role-based color coding
454pub fn render_memory(messages: Vec<(String, String)>) -> String {
455    if !is_terminal() {
456        // Plain text fallback
457        let mut output = String::new();
458        for (role, content) in messages {
459            output.push_str(&format!("{}: {}\n", role, content));
460        }
461        return output;
462    }
463
464    let skin = create_skin();
465    let terminal_width = terminal_size::terminal_size()
466        .map(|(w, _)| w.0 as usize)
467        .unwrap_or(80);
468
469    let mut formatted = String::from("# Conversation History\n\n");
470
471    for (role, content) in messages {
472        let role_formatted = match role.as_str() {
473            "user" => "**👤 User:**",
474            "assistant" => "**🤖 Assistant:**",
475            "system" => "**⚙️  System:**",
476            _ => &format!("**{}:**", role),
477        };
478
479        formatted.push_str(&format!("{}\n{}\n\n---\n\n", role_formatted, content));
480    }
481
482    skin.text(&formatted, Some(terminal_width)).to_string()
483}
484
485/// Format configuration display with sections
486pub fn render_config(config_text: &str) -> String {
487    if !is_terminal() {
488        return config_text.to_string();
489    }
490
491    let skin = create_skin();
492    let terminal_width = terminal_size::terminal_size()
493        .map(|(w, _)| w.0 as usize)
494        .unwrap_or(80);
495
496    let formatted = format!("# Current Configuration\n\n```toml\n{}\n```", config_text);
497    skin.text(&formatted, Some(terminal_width)).to_string()
498}
499
500/// Render a formatted list with custom bullet styling
501pub fn render_list(title: &str, items: Vec<String>) -> String {
502    if !is_terminal() {
503        let mut output = format!("{}:\n", title);
504        for item in items {
505            output.push_str(&format!("  - {}\n", item));
506        }
507        return output;
508    }
509
510    let skin = create_skin();
511    let terminal_width = terminal_size::terminal_size()
512        .map(|(w, _)| w.0 as usize)
513        .unwrap_or(80);
514
515    let mut formatted = format!("## {}\n\n", title);
516    for item in items {
517        formatted.push_str(&format!("- {}\n", item));
518    }
519
520    skin.text(&formatted, Some(terminal_width)).to_string()
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn test_render_markdown_basic() {
529        let text = "**bold** and *italic*";
530        let result = render_markdown(text);
531        // Just ensure it doesn't panic
532        assert!(!result.is_empty());
533    }
534
535    #[test]
536    fn test_render_agent_table() {
537        let agents = vec![
538            (
539                "default".to_string(),
540                true,
541                Some("Default agent".to_string()),
542            ),
543            ("researcher".to_string(), false, None),
544        ];
545        let result = render_agent_table(agents);
546        assert!(result.contains("default"));
547        assert!(result.contains("researcher"));
548    }
549}