Files
owlen/crates/app/ui/src/output.rs
vikingowl 4a07b97eab feat(ui): add autocomplete, command help, and streaming improvements
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>
2025-12-02 19:03:33 +01:00

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);
}
}