TUI Enhancements: - Add autocomplete dropdown with fuzzy filtering for slash commands - Fix autocomplete: Tab confirms selection, Enter submits message - Add command help overlay with scroll support (j/k, arrows, Page Up/Down) - Brighten Tokyo Night theme colors for better readability - Add todo panel component for task display - Add rich command output formatting (tables, trees, lists) Streaming Fixes: - Refactor to non-blocking background streaming with channel events - Add StreamStart/StreamEnd/StreamError events - Fix LlmChunk to append instead of creating new messages - Display user message immediately before LLM call New Components: - completions.rs: Command completion engine with fuzzy matching - autocomplete.rs: Inline autocomplete dropdown - command_help.rs: Modal help overlay with scrolling - todo_panel.rs: Todo list display panel - output.rs: Rich formatted output (tables, trees, code blocks) - commands.rs: Built-in command implementations Planning Mode Groundwork: - Add EnterPlanMode/ExitPlanMode tools scaffolding - Add Skill tool for plugin skill invocation - Extend permissions with planning mode support - Add compact.rs stub for context compaction 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
389 lines
11 KiB
Rust
389 lines
11 KiB
Rust
//! Rich command output formatting
|
|
//!
|
|
//! Provides formatted output for commands like /help, /mcp, /hooks
|
|
//! with tables, trees, and syntax highlighting.
|
|
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
use crate::completions::CommandInfo;
|
|
use crate::theme::Theme;
|
|
|
|
/// A tree node for hierarchical display
|
|
#[derive(Debug, Clone)]
|
|
pub struct TreeNode {
|
|
pub label: String,
|
|
pub children: Vec<TreeNode>,
|
|
}
|
|
|
|
impl TreeNode {
|
|
pub fn new(label: impl Into<String>) -> Self {
|
|
Self {
|
|
label: label.into(),
|
|
children: vec![],
|
|
}
|
|
}
|
|
|
|
pub fn with_children(mut self, children: Vec<TreeNode>) -> Self {
|
|
self.children = children;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// A list item with optional icon/marker
|
|
#[derive(Debug, Clone)]
|
|
pub struct ListItem {
|
|
pub text: String,
|
|
pub marker: Option<String>,
|
|
pub style: Option<Style>,
|
|
}
|
|
|
|
/// Different output formats
|
|
#[derive(Debug, Clone)]
|
|
pub enum OutputFormat {
|
|
/// Formatted table with headers and rows
|
|
Table {
|
|
headers: Vec<String>,
|
|
rows: Vec<Vec<String>>,
|
|
},
|
|
/// Hierarchical tree view
|
|
Tree {
|
|
root: TreeNode,
|
|
},
|
|
/// Syntax-highlighted code block
|
|
Code {
|
|
language: String,
|
|
content: String,
|
|
},
|
|
/// Side-by-side diff view
|
|
Diff {
|
|
old: String,
|
|
new: String,
|
|
},
|
|
/// Simple list with markers
|
|
List {
|
|
items: Vec<ListItem>,
|
|
},
|
|
/// Plain text
|
|
Text {
|
|
content: String,
|
|
},
|
|
}
|
|
|
|
/// Rich command output renderer
|
|
pub struct CommandOutput {
|
|
pub format: OutputFormat,
|
|
}
|
|
|
|
impl CommandOutput {
|
|
pub fn new(format: OutputFormat) -> Self {
|
|
Self { format }
|
|
}
|
|
|
|
/// Create a help table output
|
|
pub fn help_table(commands: &[CommandInfo]) -> Self {
|
|
let headers = vec![
|
|
"Command".to_string(),
|
|
"Description".to_string(),
|
|
"Source".to_string(),
|
|
];
|
|
|
|
let rows: Vec<Vec<String>> = commands
|
|
.iter()
|
|
.map(|c| vec![
|
|
format!("/{}", c.name),
|
|
c.description.clone(),
|
|
c.source.clone(),
|
|
])
|
|
.collect();
|
|
|
|
Self {
|
|
format: OutputFormat::Table { headers, rows },
|
|
}
|
|
}
|
|
|
|
/// Create an MCP servers tree view
|
|
pub fn mcp_tree(servers: &[(String, Vec<String>)]) -> Self {
|
|
let children: Vec<TreeNode> = servers
|
|
.iter()
|
|
.map(|(name, tools)| {
|
|
TreeNode {
|
|
label: name.clone(),
|
|
children: tools.iter().map(|t| TreeNode::new(t)).collect(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Self {
|
|
format: OutputFormat::Tree {
|
|
root: TreeNode {
|
|
label: "MCP Servers".to_string(),
|
|
children,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Create a hooks list output
|
|
pub fn hooks_list(hooks: &[(String, String, bool)]) -> Self {
|
|
let items: Vec<ListItem> = hooks
|
|
.iter()
|
|
.map(|(event, path, enabled)| {
|
|
let marker = if *enabled { "✓" } else { "✗" };
|
|
let style = if *enabled {
|
|
Some(Style::default().fg(Color::Green))
|
|
} else {
|
|
Some(Style::default().fg(Color::Red))
|
|
};
|
|
ListItem {
|
|
text: format!("{}: {}", event, path),
|
|
marker: Some(marker.to_string()),
|
|
style,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Self {
|
|
format: OutputFormat::List { items },
|
|
}
|
|
}
|
|
|
|
/// Render to TUI Lines
|
|
pub fn render(&self, theme: &Theme) -> Vec<Line<'static>> {
|
|
match &self.format {
|
|
OutputFormat::Table { headers, rows } => {
|
|
self.render_table(headers, rows, theme)
|
|
}
|
|
OutputFormat::Tree { root } => {
|
|
self.render_tree(root, 0, theme)
|
|
}
|
|
OutputFormat::List { items } => {
|
|
self.render_list(items, theme)
|
|
}
|
|
OutputFormat::Code { content, .. } => {
|
|
content.lines()
|
|
.map(|line| Line::from(Span::styled(line.to_string(), theme.tool_call)))
|
|
.collect()
|
|
}
|
|
OutputFormat::Diff { old, new } => {
|
|
self.render_diff(old, new, theme)
|
|
}
|
|
OutputFormat::Text { content } => {
|
|
content.lines()
|
|
.map(|line| Line::from(line.to_string()))
|
|
.collect()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_table(&self, headers: &[String], rows: &[Vec<String>], theme: &Theme) -> Vec<Line<'static>> {
|
|
let mut lines = Vec::new();
|
|
|
|
// Calculate column widths
|
|
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
|
|
for row in rows {
|
|
for (i, cell) in row.iter().enumerate() {
|
|
if i < widths.len() {
|
|
widths[i] = widths[i].max(cell.len());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Header line
|
|
let header_spans: Vec<Span> = headers
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(i, h)| {
|
|
let padded = format!("{:width$}", h, width = widths.get(i).copied().unwrap_or(h.len()));
|
|
vec![
|
|
Span::styled(padded, Style::default().add_modifier(Modifier::BOLD)),
|
|
Span::raw(" "),
|
|
]
|
|
})
|
|
.collect();
|
|
lines.push(Line::from(header_spans));
|
|
|
|
// Separator
|
|
let sep: String = widths.iter().map(|w| "─".repeat(*w)).collect::<Vec<_>>().join("──");
|
|
lines.push(Line::from(Span::styled(sep, theme.status_dim)));
|
|
|
|
// Rows
|
|
for row in rows {
|
|
let row_spans: Vec<Span> = row
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(i, cell)| {
|
|
let padded = format!("{:width$}", cell, width = widths.get(i).copied().unwrap_or(cell.len()));
|
|
let style = if i == 0 {
|
|
theme.status_accent // Command names in accent color
|
|
} else {
|
|
theme.status_bar
|
|
};
|
|
vec![
|
|
Span::styled(padded, style),
|
|
Span::raw(" "),
|
|
]
|
|
})
|
|
.collect();
|
|
lines.push(Line::from(row_spans));
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
fn render_tree(&self, node: &TreeNode, depth: usize, theme: &Theme) -> Vec<Line<'static>> {
|
|
let mut lines = Vec::new();
|
|
|
|
// Render current node
|
|
let prefix = if depth == 0 {
|
|
"".to_string()
|
|
} else {
|
|
format!("{}├─ ", "│ ".repeat(depth - 1))
|
|
};
|
|
|
|
let style = if depth == 0 {
|
|
Style::default().add_modifier(Modifier::BOLD)
|
|
} else if node.children.is_empty() {
|
|
theme.status_bar
|
|
} else {
|
|
theme.status_accent
|
|
};
|
|
|
|
lines.push(Line::from(vec![
|
|
Span::styled(prefix, theme.status_dim),
|
|
Span::styled(node.label.clone(), style),
|
|
]));
|
|
|
|
// Render children
|
|
for child in &node.children {
|
|
lines.extend(self.render_tree(child, depth + 1, theme));
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
fn render_list(&self, items: &[ListItem], theme: &Theme) -> Vec<Line<'static>> {
|
|
items
|
|
.iter()
|
|
.map(|item| {
|
|
let marker_span = if let Some(marker) = &item.marker {
|
|
Span::styled(
|
|
format!("{} ", marker),
|
|
item.style.unwrap_or(theme.status_bar),
|
|
)
|
|
} else {
|
|
Span::raw("• ")
|
|
};
|
|
|
|
Line::from(vec![
|
|
marker_span,
|
|
Span::styled(
|
|
item.text.clone(),
|
|
item.style.unwrap_or(theme.status_bar),
|
|
),
|
|
])
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn render_diff(&self, old: &str, new: &str, _theme: &Theme) -> Vec<Line<'static>> {
|
|
let mut lines = Vec::new();
|
|
|
|
// Simple line-by-line diff
|
|
let old_lines: Vec<&str> = old.lines().collect();
|
|
let new_lines: Vec<&str> = new.lines().collect();
|
|
|
|
let max_len = old_lines.len().max(new_lines.len());
|
|
|
|
for i in 0..max_len {
|
|
let old_line = old_lines.get(i).copied().unwrap_or("");
|
|
let new_line = new_lines.get(i).copied().unwrap_or("");
|
|
|
|
if old_line != new_line {
|
|
if !old_line.is_empty() {
|
|
lines.push(Line::from(Span::styled(
|
|
format!("- {}", old_line),
|
|
Style::default().fg(Color::Red),
|
|
)));
|
|
}
|
|
if !new_line.is_empty() {
|
|
lines.push(Line::from(Span::styled(
|
|
format!("+ {}", new_line),
|
|
Style::default().fg(Color::Green),
|
|
)));
|
|
}
|
|
} else {
|
|
lines.push(Line::from(format!(" {}", old_line)));
|
|
}
|
|
}
|
|
|
|
lines
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_help_table() {
|
|
let commands = vec![
|
|
CommandInfo::new("help", "Show help", "builtin"),
|
|
CommandInfo::new("clear", "Clear screen", "builtin"),
|
|
];
|
|
let output = CommandOutput::help_table(&commands);
|
|
|
|
match output.format {
|
|
OutputFormat::Table { headers, rows } => {
|
|
assert_eq!(headers.len(), 3);
|
|
assert_eq!(rows.len(), 2);
|
|
}
|
|
_ => panic!("Expected Table format"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_tree() {
|
|
let servers = vec![
|
|
("filesystem".to_string(), vec!["read".to_string(), "write".to_string()]),
|
|
("database".to_string(), vec!["query".to_string()]),
|
|
];
|
|
let output = CommandOutput::mcp_tree(&servers);
|
|
|
|
match output.format {
|
|
OutputFormat::Tree { root } => {
|
|
assert_eq!(root.label, "MCP Servers");
|
|
assert_eq!(root.children.len(), 2);
|
|
}
|
|
_ => panic!("Expected Tree format"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_hooks_list() {
|
|
let hooks = vec![
|
|
("PreToolUse".to_string(), "./hooks/pre".to_string(), true),
|
|
("PostToolUse".to_string(), "./hooks/post".to_string(), false),
|
|
];
|
|
let output = CommandOutput::hooks_list(&hooks);
|
|
|
|
match output.format {
|
|
OutputFormat::List { items } => {
|
|
assert_eq!(items.len(), 2);
|
|
}
|
|
_ => panic!("Expected List format"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_tree_node() {
|
|
let node = TreeNode::new("root")
|
|
.with_children(vec![
|
|
TreeNode::new("child1"),
|
|
TreeNode::new("child2"),
|
|
]);
|
|
assert_eq!(node.label, "root");
|
|
assert_eq!(node.children.len(), 2);
|
|
}
|
|
}
|