use crate::spec_ai_core::agent::core::{AgentOutput, MemoryRecallStrategy};
use serde_json::to_string;
use std::cell::Cell;
use termimad::*;
thread_local! {
static FORCE_PLAIN_TEXT: Cell<bool> = const { Cell::new(false) };
}
pub fn set_plain_text_mode(enabled: bool) {
FORCE_PLAIN_TEXT.with(|f| f.set(enabled));
}
pub fn create_skin() -> MadSkin {
let mut skin = MadSkin::default();
let mut header_style = CompoundStyle::with_fg(termimad::crossterm::style::Color::Cyan);
header_style.add_attr(termimad::crossterm::style::Attribute::Bold);
skin.headers[0].compound_style = header_style;
skin.headers[1].compound_style =
CompoundStyle::with_fg(termimad::crossterm::style::Color::Cyan);
skin.bold.set_fg(termimad::crossterm::style::Color::White);
skin.italic.set_fg(termimad::crossterm::style::Color::Grey);
skin.inline_code
.set_fg(termimad::crossterm::style::Color::Yellow);
skin.code_block
.set_fg(termimad::crossterm::style::Color::White);
skin.paragraph.compound_style = CompoundStyle::default();
skin.bullet = StyledChar::from_fg_char(termimad::crossterm::style::Color::Green, '▸');
skin.paragraph.compound_style =
CompoundStyle::with_fg(termimad::crossterm::style::Color::White);
skin.quote_mark
.set_fg(termimad::crossterm::style::Color::DarkCyan);
skin.quote_mark.set_char('┃');
skin
}
pub fn is_terminal() -> bool {
if FORCE_PLAIN_TEXT.with(|f| f.get()) {
return false;
}
terminal_size::terminal_size().is_some()
}
pub fn render_markdown(text: &str) -> String {
if !is_terminal() {
return text.to_string();
}
let skin = create_skin();
let terminal_width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80);
skin.text(text, Some(terminal_width)).to_string()
}
pub fn render_agent_response(role: &str, content: &str) -> String {
if !is_terminal() {
return format!("{}: {}", role, content);
}
let skin = create_skin();
let terminal_width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80);
let formatted = format!("**{}:**\n\n{}", role, content);
skin.text(&formatted, Some(terminal_width)).to_string()
}
pub fn render_run_stats(output: &AgentOutput, show_reasoning: bool) -> Option<String> {
let mut sections = Vec::new();
if let Some(stats) = &output.recall_stats {
let mut section = String::from("## Memory Recall\n");
match stats.strategy {
MemoryRecallStrategy::Semantic {
requested,
returned,
} => {
section.push_str(&format!(
"- Strategy: semantic (requested top {}, returned {})\n",
requested, returned
));
}
MemoryRecallStrategy::RecentContext { limit } => {
section.push_str(&format!(
"- Strategy: recent context window (last {} messages)\n",
limit
));
}
}
if stats.matches.is_empty() {
section.push_str("- No recalled vector matches this turn.\n");
} else {
section.push_str("- Matches:\n");
for (idx, m) in stats.matches.iter().take(3).enumerate() {
section.push_str(&format!(
" {}. [{} | score {:.2}] {}\n",
idx + 1,
m.role.as_str(),
m.score,
m.preview
));
}
if stats.matches.len() > 3 {
section.push_str(&format!(
" ... {} additional matches omitted\n",
stats.matches.len() - 3
));
}
}
sections.push(section);
}
if !output.tool_invocations.is_empty() {
let mut section = String::from("## Tool Calls\n\n");
for (idx, inv) in output.tool_invocations.iter().enumerate() {
let status_symbol = if inv.success { "✓" } else { "✗" };
section.push_str(&format!(
"**{}. {} [{}]**\n\n",
idx + 1,
inv.name,
status_symbol
));
if let Ok(args_map) = serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(
inv.arguments.clone(),
) {
for (key, value) in args_map.iter() {
let formatted_value = match value {
serde_json::Value::String(s) => {
if s.len() > 80 {
format!("{}...", &s[..77])
} else {
s.clone()
}
}
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => to_string(value).unwrap_or_else(|_| "...".to_string()),
};
section.push_str(&format!(" - **{}**: `{}`\n", key, formatted_value));
}
}
if let Some(out) = &inv.output {
if !out.is_empty() {
section.push_str("\n **Result:**\n");
if let Ok(json_out) = serde_json::from_str::<serde_json::Value>(out) {
if let Some(obj) = json_out.as_object() {
if let Some(stdout) = obj.get("stdout").and_then(|v| v.as_str()) {
let lines: Vec<&str> = stdout.lines().collect();
if !lines.is_empty() {
section
.push_str(&format!(" - stdout: {} lines\n", lines.len()));
if lines.len() <= 5 {
for line in lines.iter().take(5) {
let trimmed =
if line.len() > 60 { &line[..60] } else { line };
section.push_str(&format!(" `{}`\n", trimmed));
}
}
}
}
if let Some(stderr) = obj.get("stderr").and_then(|v| v.as_str()) {
if !stderr.is_empty() {
section.push_str(&format!(" - stderr: {}\n", stderr));
}
}
if let Some(exit_code) = obj.get("exit_code") {
section.push_str(&format!(" - exit_code: {}\n", exit_code));
}
if let Some(duration_ms) = obj.get("duration_ms") {
section.push_str(&format!(" - duration: {}ms\n", duration_ms));
}
}
} else {
let trimmed = if out.len() > 200 {
format!("{}... ({} chars)", &out[..197], out.len())
} else {
out.clone()
};
section.push_str(&format!(" ```\n {}\n ```\n", trimmed));
}
}
}
if let Some(err) = &inv.error {
section.push_str(&format!("\n **Error:** {}\n", err));
}
section.push('\n');
}
sections.push(section);
}
if let Some(graph_debug) = &output.graph_debug {
let mut section = String::from("## Graph Debug\n");
section.push_str(&format!(
"- Enabled: {}\n- Memory: {}\n- Auto Build: {}\n- Steering: {}\n",
if graph_debug.enabled { "yes" } else { "no" },
if graph_debug.graph_memory_enabled {
"enabled"
} else {
"disabled"
},
if graph_debug.auto_graph_enabled {
"enabled"
} else {
"disabled"
},
if graph_debug.graph_steering_enabled {
"enabled"
} else {
"disabled"
}
));
if graph_debug.enabled {
section.push_str(&format!(
"- Node Count: {}\n- Edge Count: {}\n",
graph_debug.node_count, graph_debug.edge_count
));
if graph_debug.recent_nodes.is_empty() {
section.push_str("- Recent Nodes: none recorded yet\n");
} else {
section.push_str("- Recent Nodes:\n");
for node in &graph_debug.recent_nodes {
section.push_str(&format!(
" - #{} [{}] {}\n",
node.id, node.node_type, node.label
));
}
}
} else {
section.push_str("- Graph disabled; skipping node snapshot\n");
}
sections.push(section);
}
if show_reasoning {
if let Some(summary) = &output.reasoning_summary {
if !summary.is_empty() {
let mut section = String::from("## Reasoning\n\n");
section.push_str(&format!("💭 {}\n", summary));
sections.push(section);
}
} else if let Some(reasoning) = &output.reasoning {
if !reasoning.is_empty() {
let mut section = String::from("## Reasoning\n\n");
let preview = if reasoning.len() > 200 {
format!(
"💭 {}... ({} chars total)",
&reasoning[..197],
reasoning.len()
)
} else {
format!("💭 {}", reasoning)
};
section.push_str(&format!("{}\n", preview));
sections.push(section);
}
}
}
if let Some(next_action) = &output.next_action {
let mut section = String::from("## Graph Steering\n");
section.push_str(&format!("- Recommendation: {}\n", next_action));
sections.push(section);
}
if let Some(usage) = &output.token_usage {
sections.push(format!(
"## Tokens\n- Prompt: {}\n- Completion: {}\n- Total: {}\n",
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
));
}
if sections.is_empty() {
return None;
}
let markdown = format!("---\n\n# Run Stats\n\n{}", sections.join("\n"));
Some(render_markdown(&markdown))
}
pub fn render_help() -> String {
let help_text = r#"
# SpecAI Commands
## Agent Management
Manage your AI agent profiles and sessions:
- **`/agents`** or **`/list`** — List all available agent profiles
- **`/switch <name>`** — Switch to a different agent profile
- **`/new <name>`** — Create new conversation session
## Configuration
Control your SpecAI configuration:
- **`/config show`** — Display current configuration
- Shows model provider, temperature, and other settings
- **`/config reload`** — Reload configuration from file
- Useful after editing spec-ai.config.toml
## Memory & History
Access conversation memory:
- **`/memory show [N]`** — Show last N messages (default: 10)
- Displays color-coded conversation history
- **`/memory clear`** — Clear conversation history
## Session Management
Manage multiple conversation sessions:
- **`/session list`** — List all conversation sessions
- **`/session load <id>`** — Load a specific session
- **`/session delete <id>`** — Delete a session
## Knowledge Graph
AI reasoning with graph-based memory:
- **`/graph enable`** — Enable knowledge graph features
- Activates graph memory and automatic entity extraction
- **`/graph disable`** — Disable knowledge graph features
- **`/graph status`** — Show current graph configuration
- **`/graph show [N]`** — Display last N graph nodes (default: 10)
- **`/graph clear`** — Clear graph for current session
## Graph Synchronization
Distributed graph sync across instances:
- **`/sync`** or **`/sync list`** — List all graphs with sync enabled
Configure sync in `spec-ai.config.toml`:
```toml
[sync]
enabled = true
namespaces = [
{ session_id = "shared", graph_name = "knowledge" }
]
```
## Repository Bootstrap
Prime the knowledge graph with source facts before the first prompt:
- **`/init`** — Run the bootstrap-self pipeline against the repo (only valid as the first message)
- **`/refresh`** — Re-run the bootstrap-self pipeline with caching enabled (safe after `/init`)
## Audio Transcription
Mock audio input transcription for testing:
- **`/listen [scenario] [duration]`** — Start audio transcription simulation
- **Scenarios:** `simple_conversation`, `command_sequence`, `noisy_environment`, `emotional_context`, `multi_speaker`
- **Duration:** Time in seconds (default: 30)
- Example: `/listen simple_conversation 60`
- **`/speak [on|off|toggle]`** — Enable or disable macOS speech playback (`Ctrl+S` while a response is streaming also toggles)
## Spec Runs
Execute structured `.spec` files with clear goals:
- **`/spec run <file>`** — Load and execute a TOML spec (extension must be `.spec`)
- **`/spec <file>`** — Shorthand for `/spec run <file>`
- Specs must define a `goal` and at least one `tasks` or `deliverables` entry
## General Commands
- **`/help`** — Show this help message
- **`/quit`** or **`/exit`** — Exit the REPL
---
**Usage:** Type your message to chat with the current agent. Use `/` prefix for commands.
"#;
render_markdown(help_text)
}
pub fn render_agent_table(agents: Vec<(String, bool, Option<String>)>) -> String {
if !is_terminal() {
let mut output = String::from("Available agents:\n");
for (name, is_active, description) in agents {
let active_marker = if is_active { " (active)" } else { "" };
let desc = description.unwrap_or_default();
output.push_str(&format!(" - {}{}", name, active_marker));
if !desc.is_empty() {
output.push_str(&format!(" - {}", desc));
}
output.push('\n');
}
return output;
}
let skin = create_skin();
let terminal_width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80);
let mut table = String::from("# Available Agents\n\n");
table.push_str("| Agent | Status | Description |\n");
table.push_str("|-------|--------|-------------|\n");
for (name, is_active, description) in agents {
let status = if is_active { "**active**" } else { "" };
let desc = description.unwrap_or_default();
table.push_str(&format!("| {} | {} | {} |\n", name, status, desc));
}
skin.text(&table, Some(terminal_width)).to_string()
}
pub fn render_memory(messages: Vec<(String, String)>) -> String {
if !is_terminal() {
let mut output = String::new();
for (role, content) in messages {
output.push_str(&format!("{}: {}\n", role, content));
}
return output;
}
let skin = create_skin();
let terminal_width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80);
let mut formatted = String::from("# Conversation History\n\n");
for (role, content) in messages {
let role_formatted = match role.as_str() {
"user" => "**👤 User:**",
"assistant" => "**🤖 Assistant:**",
"system" => "**⚙️ System:**",
_ => &format!("**{}:**", role),
};
formatted.push_str(&format!("{}\n{}\n\n---\n\n", role_formatted, content));
}
skin.text(&formatted, Some(terminal_width)).to_string()
}
pub fn render_config(config_text: &str) -> String {
if !is_terminal() {
return config_text.to_string();
}
let skin = create_skin();
let terminal_width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80);
let formatted = format!("# Current Configuration\n\n```toml\n{}\n```", config_text);
skin.text(&formatted, Some(terminal_width)).to_string()
}
pub fn render_list(title: &str, items: Vec<String>) -> String {
if !is_terminal() {
let mut output = format!("{}:\n", title);
for item in items {
output.push_str(&format!(" - {}\n", item));
}
return output;
}
let skin = create_skin();
let terminal_width = terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80);
let mut formatted = format!("## {}\n\n", title);
for item in items {
formatted.push_str(&format!("- {}\n", item));
}
skin.text(&formatted, Some(terminal_width)).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_markdown_basic() {
let text = "**bold** and *italic*";
let result = render_markdown(text);
assert!(!result.is_empty());
}
#[test]
fn test_render_agent_table() {
let agents = vec![
(
"default".to_string(),
true,
Some("Default agent".to_string()),
),
("researcher".to_string(), false, None),
];
let result = render_agent_table(agents);
assert!(result.contains("default"));
assert!(result.contains("researcher"));
}
}