1use crate::agent::core::{AgentOutput, MemoryRecallStrategy};
4use serde_json::to_string;
5use std::cell::Cell;
6use termimad::*;
7
8thread_local! {
9 static FORCE_PLAIN_TEXT: Cell<bool> = const { Cell::new(false) };
11}
12
13pub fn set_plain_text_mode(enabled: bool) {
16 FORCE_PLAIN_TEXT.with(|f| f.set(enabled));
17}
18
19pub fn create_skin() -> MadSkin {
21 let mut skin = MadSkin::default();
22
23 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 skin.bold.set_fg(termimad::crossterm::style::Color::White);
32
33 skin.italic.set_fg(termimad::crossterm::style::Color::Grey);
35
36 skin.inline_code
38 .set_fg(termimad::crossterm::style::Color::Yellow);
39
40 skin.code_block
42 .set_fg(termimad::crossterm::style::Color::White);
43
44 skin.paragraph.compound_style = CompoundStyle::default();
46
47 skin.bullet = StyledChar::from_fg_char(termimad::crossterm::style::Color::Green, '▸');
49
50 skin.paragraph.compound_style =
52 CompoundStyle::with_fg(termimad::crossterm::style::Color::White);
53
54 skin.quote_mark
56 .set_fg(termimad::crossterm::style::Color::DarkCyan);
57 skin.quote_mark.set_char('┃');
58
59 skin
60}
61
62pub fn is_terminal() -> bool {
64 if FORCE_PLAIN_TEXT.with(|f| f.get()) {
66 return false;
67 }
68
69 terminal_size::terminal_size().is_some()
71}
72
73pub 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
88pub 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 let formatted = format!("**{}:**\n\n{}", role, content);
101 skin.text(&formatted, Some(terminal_width)).to_string()
102}
103
104pub 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 let status_symbol = if inv.success { "✓" } else { "✗" };
156
157 section.push_str(&format!(
159 "**{}. {} [{}]**\n\n",
160 idx + 1,
161 inv.name,
162 status_symbol
163 ));
164
165 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 if let Some(out) = &inv.output {
188 if !out.is_empty() {
189 section.push_str("\n **Result:**\n");
190
191 if let Ok(json_out) = serde_json::from_str::<serde_json::Value>(out) {
193 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 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 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 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 if show_reasoning {
292 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 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
340pub 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
417pub fn render_agent_table(agents: Vec<(String, bool, Option<String>)>) -> String {
419 if !is_terminal() {
420 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 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
453pub fn render_memory(messages: Vec<(String, String)>) -> String {
455 if !is_terminal() {
456 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
485pub 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
500pub 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 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}