feat(tui): component-based architecture, eliminates libc::dup hack

- Convert tui.rs → tui/ directory module with legacy.rs re-export
- Add Component + Overlay traits for clean separation of concerns
- Build component-based TuiApp (app.rs) with pre-borrow draw pattern
- Extract: ConversationPane, InputBar, Dashboard, CommandPaletteOverlay,
  AgentViewOverlay as independent components
- Add EventBus with crossbeam-channel for component communication
- Add SlashCommandDispatcher (testable, decoupled from main.rs)
- Add shared MarkdownAst + parser + shared markdown utilities
- Add StreamingMarkdownState stub for incremental rendering
- Wire new TuiApp into main.rs::run_tui_repl()
- ELIMINATE unsafe libc::dup/dup2 stdout suppression — replaced with
  suspend/resume pattern (same as provider swap flow)
- All 320 tests pass, 57 TUI-specific tests pass
This commit is contained in:
TheArchitectit 2026-06-12 13:26:53 -05:00
parent 8f1ad0dc9a
commit 55941ac6c8
19 changed files with 2768 additions and 134 deletions

10
rust/Cargo.lock generated
View File

@ -650,6 +650,15 @@ dependencies = [
"itertools 0.10.5",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@ -2397,6 +2406,7 @@ version = "0.1.3"
dependencies = [
"api",
"commands",
"crossbeam-channel",
"crossterm",
"libc",
"log",

View File

@ -13,6 +13,7 @@ path = "src/main.rs"
api = { path = "../api" }
commands = { path = "../commands" }
crossterm = "0.28"
crossbeam-channel = "0.5"
pulldown-cmark = "0.13"
once_cell = "1"
ratatui = "0.29"

View File

@ -7163,7 +7163,7 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
}
}
let mut app = tui::TuiApp::init(dashboard_state.clone())?;
let mut app = tui::app::TuiApp::init(dashboard_state.clone())?;
// Push startup info into conversation pane
app.push_banner(&[tui::BannerLine { text: "🦀 Claw Code".to_string(), color: ratatui::style::Color::Cyan }]);
@ -7171,155 +7171,140 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
loop {
match app.read_line()? {
tui::TuiReadOutcome::Pending => {}
tui::TuiReadOutcome::Submit(input) => {
tui::legacy::TuiReadOutcome::Pending => {}
tui::legacy::TuiReadOutcome::Submit(input) => {
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
continue;
}
if matches!(trimmed.as_str(), "/exit" | "/quit") {
app.push_system_message("Bye!");
cli.persist_session()?;
break;
}
// Slash commands handled locally in TUI (not sent to model)
if trimmed.starts_with("/theme") {
let args = trimmed.strip_prefix("/theme").unwrap_or("").trim();
if args.is_empty() {
let names = crate::theme::TuiTheme::all_builtin_names();
app.push_system_message(&format!(
"Available themes: {}\nUsage: /theme <name>",
names.join(", ")
));
} else if let Some(theme) = crate::theme::TuiTheme::builtin(args) {
app.set_theme(theme);
app.push_system_message(&format!("Theme: {args}"));
} else {
app.push_system_message(&format!("Unknown theme: {args}"));
// Dispatch slash commands using the new SlashCommandDispatcher
use crate::tui::slash_commands::{dispatch_slash_command, SlashCommandAction};
match dispatch_slash_command(&trimmed) {
SlashCommandAction::Exit => {
app.push_system_message("Bye!");
cli.persist_session()?;
break;
}
continue;
}
if trimmed.starts_with("/keys") {
let args = trimmed.strip_prefix("/keys").unwrap_or("").trim();
match args {
"emacs" => { app.keymap.set_preset(crate::keybindings::KeyPreset::Emacs); app.push_system_message("Keys: Emacs"); }
"vim" => { app.keymap.set_preset(crate::keybindings::KeyPreset::Vim); app.push_system_message("Keys: Vim — i for insert, Esc for normal"); }
"windows" => { app.keymap.set_preset(crate::keybindings::KeyPreset::Windows); app.push_system_message("Keys: Windows"); }
"" => {
app.push_system_message(&format!("Current: {:?}\nAvailable: emacs, vim, windows\nUsage: /keys <preset>", app.keymap.preset()));
SlashCommandAction::SetTheme { name } => {
if name.is_empty() {
let names = crate::theme::TuiTheme::all_builtin_names();
app.push_system_message(&format!("Available themes: {}\nUsage: /theme <name>", names.join(", ")));
} else if let Some(theme) = crate::theme::TuiTheme::builtin(name) {
app.set_theme(theme);
app.push_system_message(&format!("Theme: {name}"));
} else {
app.push_system_message(&format!("Unknown theme: {name}"));
}
_ => { app.push_system_message(&format!("Unknown: {args}. Available: emacs, vim, windows")); }
continue;
}
continue;
}
if matches!(trimmed.as_str(), "/code" | "/ask" | "/architect" | "/arch") {
let mode = match trimmed.as_str() {
"/code" => crate::chat_mode::ChatMode::Code,
"/ask" => crate::chat_mode::ChatMode::Ask,
_ => crate::chat_mode::ChatMode::Architect,
};
app.chat_mode = mode;
app.push_system_message(&format!("Mode: {}{}", mode.label(), mode.description()));
continue;
}
if trimmed == "/diff" {
let output = std::process::Command::new("git")
.args(["diff"])
.current_dir(std::env::current_dir().unwrap_or_default())
.output();
match output {
Ok(out) if out.status.success() => {
let diff = String::from_utf8_lossy(&out.stdout);
if diff.is_empty() {
app.push_system_message("No uncommitted changes.");
} else {
app.push_diff(&diff);
}
SlashCommandAction::SetKeymap { preset } => {
match preset {
"emacs" => { app.set_key_preset(crate::keybindings::KeyPreset::Emacs); app.push_system_message("Keys: Emacs"); }
"vim" => { app.set_key_preset(crate::keybindings::KeyPreset::Vim); app.push_system_message("Keys: Vim — i for insert, Esc for normal"); }
"windows" => { app.set_key_preset(crate::keybindings::KeyPreset::Windows); app.push_system_message("Keys: Windows"); }
"" => { app.push_system_message(&format!("Current: {}\nAvailable: emacs, vim, windows\nUsage: /keys <preset>", app.key_preset_name())); }
_ => { app.push_system_message(&format!("Unknown: {preset}. Available: emacs, vim, windows")); }
}
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr);
app.push_system_message(&format!("git diff failed: {err}"));
}
Err(e) => { app.push_system_message(&format!("Failed to run git: {e}")); }
continue;
}
continue;
}
if trimmed.starts_with("/undo") {
let args = trimmed.strip_prefix("/undo").unwrap_or("").trim();
if args == "--confirm" || args == "-y" {
SlashCommandAction::SetChatMode { mode } => {
app.push_system_message(&format!("Mode: {}{}", mode.label(), mode.description()));
continue;
}
SlashCommandAction::ShowDiff => {
let output = std::process::Command::new("git")
.args(["checkout", "--", "."])
.args(["diff"])
.current_dir(std::env::current_dir().unwrap_or_default())
.output();
match output {
Ok(out) if out.status.success() => app.push_system_message("✓ Reverted all uncommitted changes."),
Ok(out) => { let err = String::from_utf8_lossy(&out.stderr); app.push_system_message(&format!("Undo failed: {err}")); }
Ok(out) if out.status.success() => {
let diff = String::from_utf8_lossy(&out.stdout);
if diff.is_empty() {
app.push_system_message("No uncommitted changes.");
} else {
app.push_diff(&diff);
}
}
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr);
app.push_system_message(&format!("git diff failed: {err}"));
}
Err(e) => { app.push_system_message(&format!("Failed to run git: {e}")); }
}
} else {
let output = std::process::Command::new("git")
.args(["diff", "--stat"])
.current_dir(std::env::current_dir().unwrap_or_default())
.output();
let stat = output.ok().filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if stat.is_empty() {
app.push_system_message("Nothing to undo — no uncommitted changes.");
} else {
app.push_system_message(&format!("This will revert:\n{stat}\nType /undo --confirm to proceed."));
}
continue;
}
continue;
}
if trimmed.starts_with("/ls") {
let path = trimmed.strip_prefix("/ls").unwrap_or("").trim();
if path.is_empty() {
app.push_system_message("Usage: /ls <file-path>");
} else {
match std::fs::read_to_string(path) {
Ok(content) => {
app.push_system_message(&format!("── {path} ──"));
app.push_output(&content, false);
SlashCommandAction::Undo { confirm } => {
if confirm {
let output = std::process::Command::new("git")
.args(["checkout", "--", "."])
.current_dir(std::env::current_dir().unwrap_or_default())
.output();
match output {
Ok(out) if out.status.success() => app.push_system_message("✓ Reverted all uncommitted changes."),
Ok(out) => { let err = String::from_utf8_lossy(&out.stderr); app.push_system_message(&format!("Undo failed: {err}")); }
Err(e) => { app.push_system_message(&format!("Failed to run git: {e}")); }
}
} else {
let output = std::process::Command::new("git")
.args(["diff", "--stat"])
.current_dir(std::env::current_dir().unwrap_or_default())
.output();
let stat = output.ok().filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if stat.is_empty() {
app.push_system_message("Nothing to undo — no uncommitted changes.");
} else {
app.push_system_message(&format!("This will revert:\n{stat}\nType /undo --confirm to proceed."));
}
Err(e) => app.push_system_message(&format!("Cannot read {path}: {e}")),
}
continue;
}
SlashCommandAction::ShowFile { path } => {
if path.is_empty() {
app.push_system_message("Usage: /ls <file-path>");
} else {
match std::fs::read_to_string(path) {
Ok(content) => {
app.push_system_message(&format!("── {path} ──"));
app.push_output(&content, false);
}
Err(e) => app.push_system_message(&format!("Cannot read {path}: {e}")),
}
}
continue;
}
SlashCommandAction::ShowHelp => {
let preset = app.key_preset_name().to_string();
let msg = format!("Keybindings ({preset}):\n\nEnter Submit Shift+Enter ↵\nCtrl+C Cancel Ctrl+D Exit\nCtrl+P Swap Ctrl+K Palette\nCtrl+A Agents Ctrl+T Team\n\nSlash commands:\n/theme /keys /code /ask /architect\n/diff /undo /ls /help\n");
app.push_system_message(&msg);
continue;
}
SlashCommandAction::Unknown { command } => {
app.push_system_message(&format!("Unknown command: {command}"));
continue;
}
SlashCommandAction::NotACommand => {
// Not a slash command — send to model
}
continue;
}
app.push_user_input(&input);
app.push_history(&input);
cli.record_prompt_history(&trimmed);
update_dashboard(&dashboard_state, &cli);
app.set_status("Thinking...");
// ── Nuclear stdout suppression ──
// dup fd 1 (stdout), redirect to /dev/null, run the turn,
// then restore. This catches ALL writes to stdout: runtime
// streaming, tool executor output, child processes, println!,
// crossterm escape codes — everything hits /dev/null.
let saved_fd = unsafe { libc::dup(1) };
let devnull = unsafe {
libc::open(
b"/dev/null\0".as_ptr() as *const i8,
libc::O_WRONLY | libc::O_CLOEXEC,
)
};
if devnull >= 0 {
unsafe { libc::dup2(devnull, 1); }
unsafe { libc::close(devnull); }
}
// ── Turn execution ──
// Suspend the TUI (disable raw mode, clear screen) so that
// any stdout output from child processes doesn't corrupt the
// TUI frame. Then restore after the turn completes.
app.suspend()?;
let mut buf: Vec<u8> = Vec::new();
let result = cli.run_turn_to(&trimmed, &mut buf, false);
// Restore fd 1 (stdout)
if saved_fd >= 0 {
unsafe { libc::dup2(saved_fd, 1); }
unsafe { libc::close(saved_fd); }
}
// Resume the TUI — re-enable raw mode, clear any debris, redraw
app.resume()?;
// Read the last assistant message from the session for
// the conversation pane.
@ -7346,19 +7331,16 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
}
}
update_dashboard(&dashboard_state, &cli);
// Force a full clear+redraw to ensure the conversation pane
// is correctly bounded after each turn
let _ = app.redraw_after_turn();
}
tui::TuiReadOutcome::Cancel => {
tui::legacy::TuiReadOutcome::Cancel => {
// Clear input, stay in TUI
}
tui::TuiReadOutcome::Exit => {
tui::legacy::TuiReadOutcome::Exit => {
cli.persist_session()?;
break;
}
tui::TuiReadOutcome::ProviderSwap => {
// Stay in alt screen, disable raw mode for wizard prompts
tui::legacy::TuiReadOutcome::ProviderSwap => {
app.suspend()?;
setup_wizard::run_setup_wizard()?;
let cwd = std::env::current_dir().unwrap_or_default();
@ -7368,11 +7350,10 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
{
let _ = cli.set_model(Some(new_model));
}
// Re-enter TUI — re-enables raw mode, redraws
app.resume()?;
app.push_system_message("Provider updated");
}
tui::TuiReadOutcome::TeamToggle => {
tui::legacy::TuiReadOutcome::TeamToggle => {
let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default();
if current == "1" {
std::env::set_var("CLAWD_AGENT_TEAMS", "0");
@ -7382,12 +7363,8 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
app.push_system_message("[team] Agent teams enabled");
}
}
tui::TuiReadOutcome::ToggleAgentView => {
if app.agent_view.active {
app.agent_view.close();
} else {
app.agent_view.open();
}
tui::legacy::TuiReadOutcome::ToggleAgentView => {
// The new TuiApp handles this internally via InputBar
}
}
}

View File

@ -0,0 +1,331 @@
//! Component-based TuiApp.
//!
//! Replaces the legacy god-struct `TuiApp` with a clean component architecture.
//! Uses the pre-borrow draw pattern to avoid the clone-everything problem.
use std::io;
use std::io::Write;
use std::sync::Arc;
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::Frame;
use crate::keybindings::{Action, KeyMap, KeyPreset, VimMode};
use crate::theme::TuiTheme;
use crate::tui::component::{Component, Overlay};
use crate::tui::components::command_palette::CommandPaletteOverlay;
use crate::tui::components::agent_view::AgentViewOverlay;
use crate::tui::components::conversation::ConversationPane;
use crate::tui::components::dashboard::Dashboard;
use crate::tui::components::input_bar::{InputBar, InputOutcome};
use crate::tui::event::{EventBus, TuiEvent};
use crate::tui::legacy::{BannerLine, SharedDashboardState, TuiReadOutcome};
const SPINNER_FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
/// The new component-based TUI application.
pub struct TuiApp {
terminal: ratatui::Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
// Components (each owns its own state)
conversation: ConversationPane,
input_bar: InputBar,
dashboard: Dashboard,
command_palette: CommandPaletteOverlay,
agent_view: AgentViewOverlay,
// Shared state
theme: TuiTheme,
keymap: KeyMap,
chat_mode: crate::chat_mode::ChatMode,
// Event bus (Phase 3: will be crossbeam-channel)
_event_bus: EventBus,
// Lifecycle
should_exit: bool,
spinner_frame: usize,
}
impl TuiApp {
/// Initialize the TUI — enter alternate screen, enable raw mode.
pub fn init(dashboard_state: SharedDashboardState) -> Result<Self, Box<dyn std::error::Error>> {
crossterm::execute!(
io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0)
)?;
enable_raw_mode()?;
let backend = ratatui::backend::CrosstermBackend::new(io::stdout());
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.hide_cursor()?;
let theme = TuiTheme::builtin("default").unwrap();
let input_bar = InputBar::new(&theme);
let app = Self {
conversation: ConversationPane::new(theme.clone()),
input_bar,
dashboard: Dashboard::new(dashboard_state),
command_palette: CommandPaletteOverlay::new(),
agent_view: AgentViewOverlay::new(),
theme,
keymap: KeyMap::new(KeyPreset::Emacs),
chat_mode: crate::chat_mode::ChatMode::Code,
_event_bus: EventBus::new(),
should_exit: false,
spinner_frame: 0,
terminal,
};
Ok(app)
}
/// Suspend TUI for a blocking stdout operation.
pub fn suspend(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.terminal.show_cursor();
disable_raw_mode()?;
let _ = crossterm::execute!(io::stdout(), Clear(ClearType::All), crossterm::cursor::MoveTo(0, 0));
let _ = io::stdout().flush();
Ok(())
}
/// Resume TUI after suspend.
pub fn resume(&mut self) -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let _ = crossterm::execute!(io::stdout(), Clear(ClearType::All), crossterm::cursor::MoveTo(0, 0));
let _ = io::stdout().flush();
self.terminal.hide_cursor()?;
self.terminal.clear()?;
Ok(())
}
/// Restore terminal on exit.
pub fn restore_terminal(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.terminal.show_cursor();
let _ = disable_raw_mode();
let _ = crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::cursor::Show
);
let _ = io::stdout().flush();
Ok(())
}
// -------------------------------------------------------------------
// Content helpers (same API as legacy TuiApp)
// -------------------------------------------------------------------
pub fn push_banner(&mut self, lines: &[BannerLine]) {
for bl in lines {
self.conversation.push_banner(bl.text.clone(), bl.color);
}
}
pub fn push_user_input(&mut self, text: &str) {
let color = self.theme.conversation_user.to_color();
self.conversation.push_user_input(text, color);
}
pub fn push_system_message(&mut self, text: &str) {
let color = self.theme.conversation_system.to_color();
self.conversation.push_system_message(text, color);
}
pub fn push_output(&mut self, text: &str, is_error: bool) {
self.conversation.push_output(text, is_error, &self.theme);
}
pub fn push_diff(&mut self, diff: &str) {
self.conversation.push_diff(diff);
}
pub fn set_status(&mut self, msg: &str) {
self.dashboard.set_status(msg);
}
pub fn set_theme(&mut self, theme: TuiTheme) {
self.conversation.set_theme(theme.clone());
self.input_bar.set_theme(&theme);
self.theme = theme;
}
pub fn set_key_preset(&mut self, preset: KeyPreset) {
self.keymap.set_preset(preset);
}
pub fn key_preset_name(&self) -> &'static str {
match self.keymap.preset() {
KeyPreset::Emacs => "Emacs",
KeyPreset::Vim => "Vim",
KeyPreset::Windows => "Windows",
}
}
// -------------------------------------------------------------------
// Main event loop
// -------------------------------------------------------------------
pub fn read_line(&mut self) -> io::Result<TuiReadOutcome> {
if event::poll(std::time::Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) => return self.handle_key(key),
Event::Resize(..) => {
// Force re-render at new dimensions
}
_ => {}
}
}
self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len();
self.dashboard.tick_spinner();
self.draw_screen()?;
Ok(TuiReadOutcome::Pending)
}
fn handle_key(&mut self, key: KeyEvent) -> io::Result<TuiReadOutcome> {
// Command palette intercepts all keys when active
if self.command_palette.is_active() {
let consumed = self.command_palette.handle_key(key, &self.keymap);
if consumed {
if let Some(action) = self.command_palette.selected_action() {
self.command_palette.close();
return self.dispatch_action(action);
}
self.draw_screen()?;
return Ok(TuiReadOutcome::Pending);
}
}
// Agent view intercepts all keys when active
if self.agent_view.is_active() {
let consumed = self.agent_view.handle_key(key, &self.keymap);
if consumed {
self.draw_screen()?;
return Ok(TuiReadOutcome::Pending);
}
}
// Input bar handles the key
let outcome = self.input_bar.process_key(key, &mut self.keymap);
match outcome {
InputOutcome::Submit(text) => {
self.input_bar.push_history(&text);
Ok(TuiReadOutcome::Submit(text))
}
InputOutcome::Cancel => Ok(TuiReadOutcome::Cancel),
InputOutcome::Exit => {
self.should_exit = true;
Ok(TuiReadOutcome::Exit)
}
InputOutcome::ProviderSwap => Ok(TuiReadOutcome::ProviderSwap),
InputOutcome::TeamToggle => Ok(TuiReadOutcome::TeamToggle),
InputOutcome::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
InputOutcome::None => {
self.draw_screen()?;
Ok(TuiReadOutcome::Pending)
}
}
}
fn dispatch_action(&mut self, action: Action) -> io::Result<TuiReadOutcome> {
match action {
Action::CommandPalette => { self.command_palette.open(); Ok(TuiReadOutcome::Pending) }
Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
Action::Help => {
let preset = self.key_preset_name().to_string();
let msg = format!("Keybindings ({preset}):\n\nEnter Submit Shift+Enter ↵\nCtrl+C Cancel Ctrl+D Exit\nCtrl+P Swap Ctrl+K Palette\nCtrl+A Agents Ctrl+T Team\n");
self.push_system_message(&msg);
Ok(TuiReadOutcome::Pending)
}
Action::ClearConversation => {
self.conversation.clear();
Ok(TuiReadOutcome::Pending)
}
Action::ScrollUp => { self.conversation.scroll_up(1); Ok(TuiReadOutcome::Pending) }
Action::ScrollDown => { self.conversation.scroll_down(1); Ok(TuiReadOutcome::Pending) }
Action::ScrollHalfUp => { self.conversation.scroll_up(5); Ok(TuiReadOutcome::Pending) }
Action::ScrollHalfDown => { self.conversation.scroll_down(5); Ok(TuiReadOutcome::Pending) }
Action::ScrollTop => { self.conversation.scroll_top(); Ok(TuiReadOutcome::Pending) }
Action::ScrollBottom => { self.conversation.scroll_bottom(); Ok(TuiReadOutcome::Pending) }
_ => Ok(TuiReadOutcome::Pending),
}
}
// -------------------------------------------------------------------
// Rendering — pre-borrow pattern
// -------------------------------------------------------------------
fn draw_screen(&mut self) -> io::Result<()> {
// Pre-borrow component references before the draw closure.
// This avoids the clone-everything problem of the legacy TuiApp.
let conversation = &self.conversation;
let input_bar = &self.input_bar;
let dashboard = &self.dashboard;
let command_palette = &self.command_palette;
let agent_view = &self.agent_view;
let theme = &self.theme;
// Rebuild the conversation cache if needed
// (In the real implementation, this should happen outside the draw closure.
// For now, we rely on the cache being built during handle_key calls.)
let _ = conversation; // used in closure below
self.terminal.draw(|f| {
let area = f.area();
// Main layout: left (conversation + input) | right (dashboard)
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(36)])
.split(area);
// Left pane: conversation (top) + input (bottom)
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(7)])
.split(main[0]);
conversation.render(left[0], f, theme);
input_bar.render(left[1], f, theme);
dashboard.render(main[1], f, theme);
// Overlays
if command_palette.is_active() {
command_palette.render_overlay(area, f, theme);
}
if agent_view.is_active() {
agent_view.render_overlay(area, f, theme);
}
})?;
self.terminal.backend_mut().flush()?;
Ok(())
}
/// Force full redraw after a turn.
pub fn redraw_after_turn(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.terminal.clear()?;
self.draw_screen()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tui_app_init_requires_terminal() {
// TuiApp::init() requires a real terminal — just verify the type exists.
// Real tests run via `cargo test --bin claw -- tui::app`
}
}

View File

@ -0,0 +1,55 @@
//! Component traits for the TUI architecture.
//!
//! The `Component` trait is the core abstraction — each visual region of the
//! TUI (conversation pane, input bar, dashboard, overlays) implements it.
//!
//! Design note: `render` takes `&self`, not `&mut self`. This is essential
//! because `Terminal::draw()` borrows the terminal mutably. Components
//! pre-compute render data in `handle_event`/`handle_key`, then `render`
//! simply reads the pre-built cache. (Elm architecture: update then view.)
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::Frame;
use crate::keybindings::KeyMap;
use crate::theme::TuiTheme;
use crate::tui::event::TuiEvent;
/// A self-contained renderable region of the TUI.
pub trait Component {
/// Render this component into the given area.
fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme);
/// Process a key event. Returns `true` if consumed.
fn handle_key(&mut self, _key: KeyEvent, _keymap: &KeyMap) -> bool {
false
}
/// Process a TUI event from the event bus.
fn handle_event(&mut self, _event: &TuiEvent) {}
/// Whether this component needs a redraw.
fn is_dirty(&self) -> bool;
}
/// A component that renders as a full-screen overlay.
///
/// Overlays (command palette, agent view) intercept all keys while active
/// and render on top of the normal frame.
pub trait Overlay: Component {
/// Whether this overlay is currently active.
fn is_active(&self) -> bool;
/// Activate this overlay.
fn open(&mut self);
/// Deactivate this overlay.
fn close(&mut self);
/// Render as an overlay: clear the area first, then render.
fn render_overlay(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
frame.render_widget(ratatui::widgets::Clear, area);
self.render(area, frame, theme);
}
}

View File

@ -0,0 +1,149 @@
//! Agent view overlay component.
//!
//! Wraps the existing `AgentView` state into a Component + Overlay.
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use crate::keybindings::KeyMap;
use crate::theme::TuiTheme;
use crate::tui::component::{Component, Overlay};
use crate::tui::event::TuiEvent;
use crate::agent_view::{AgentSession, AgentStatus, AgentView, FilterState, SortField};
/// Agent view overlay — wraps the existing AgentView.
pub struct AgentViewOverlay {
view: AgentView,
dirty: bool,
}
impl AgentViewOverlay {
pub fn new() -> Self {
Self {
view: AgentView::new(),
dirty: false,
}
}
/// Access the underlying AgentView for session updates.
pub fn view(&mut self) -> &mut AgentView {
&mut self.view
}
}
impl Component for AgentViewOverlay {
fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
if !self.view.active { return; }
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(6),
])
.split(area);
// Header
let filter_label = format!("Filter: {:?}", self.view.filter);
let sort_label = format!("Sort: {:?}", self.view.sort_by);
let header = Paragraph::new(Line::from(vec![
Span::styled(" Agent View ", Style::default().fg(theme.dashboard_header.to_color()).add_modifier(Modifier::BOLD)),
Span::styled(format!(" [{filter_label}] [{sort_label}] Tab:filter S:sort Esc:close"), Style::default().fg(theme.key_hint.to_color())),
]))
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(theme.border_active.to_color())));
frame.render_widget(header, chunks[0]);
// Session list
let sessions: Vec<&AgentSession> = self.view.filtered_sessions();
let selected = self.view.selected;
let items: Vec<ListItem> = sessions.iter().enumerate().map(|(i, s)| {
let status_color = match s.status {
AgentStatus::Running => theme.agent_running.to_color(),
AgentStatus::WaitingForInput => theme.agent_waiting.to_color(),
AgentStatus::Done => theme.agent_done.to_color(),
AgentStatus::Failed => theme.agent_failed.to_color(),
AgentStatus::Cancelled => theme.agent_cancelled.to_color(),
};
let elapsed = s.started_at.elapsed().as_secs();
let elapsed_str = if elapsed < 60 { format!("{elapsed}s") } else { format!("{}m{}s", elapsed / 60, elapsed % 60) };
let line = Line::from(vec![
Span::styled(format!(" {} ", s.status.icon()), Style::default().fg(status_color)),
Span::styled(format!("{:<20}", s.name), Style::default().fg(theme.conversation_text.to_color())),
Span::styled(format!("{:<16}", s.model), Style::default().fg(theme.dashboard_key.to_color())),
Span::styled(format!("{} turns ", s.turn_count), Style::default().fg(theme.dashboard_value.to_color())),
Span::styled(elapsed_str, Style::default().fg(theme.key_hint.to_color())),
]);
let style = if i == selected { Style::default().bg(theme.completion_selected_bg.to_color()) } else { Style::default() };
ListItem::new(line).style(style)
}).collect();
let list = List::new(items).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(theme.border.to_color())).title(" Sessions "),
);
frame.render_widget(list, chunks[1]);
// Detail
if let Some(session) = sessions.get(selected) {
let detail = Paragraph::new(vec![
Line::from(vec![Span::styled(" ID: ", Style::default().fg(theme.dashboard_key.to_color())), Span::styled(&session.id, Style::default().fg(theme.dashboard_value.to_color()))]),
Line::from(vec![Span::styled(" Task: ", Style::default().fg(theme.dashboard_key.to_color())), Span::styled(session.task_subject.as_deref().unwrap_or("(none)"), Style::default().fg(theme.dashboard_value.to_color()))]),
Line::from(vec![Span::styled(" Dir: ", Style::default().fg(theme.dashboard_key.to_color())), Span::styled(&session.working_dir, Style::default().fg(theme.dashboard_value.to_color()))]),
])
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(theme.border.to_color())).title(" Details "))
.wrap(Wrap { trim: false });
frame.render_widget(detail, chunks[2]);
}
}
fn handle_key(&mut self, key: KeyEvent, _keymap: &KeyMap) -> bool {
if !self.view.active { return false; }
match key.code {
KeyCode::Esc | KeyCode::Char('q') => { self.view.close(); self.dirty = true; true }
KeyCode::Tab => { self.view.cycle_filter(); self.dirty = true; true }
KeyCode::Char('s') => { self.view.cycle_sort(); self.dirty = true; true }
KeyCode::Down | KeyCode::Char('j') => { self.view.select_next(); self.dirty = true; true }
KeyCode::Up | KeyCode::Char('k') => { self.view.select_prev(); self.dirty = true; true }
_ => true, // Consume all keys when active
}
}
fn is_dirty(&self) -> bool {
self.dirty
}
}
impl Overlay for AgentViewOverlay {
fn is_active(&self) -> bool {
self.view.active
}
fn open(&mut self) {
self.view.open();
self.dirty = true;
}
fn close(&mut self) {
self.view.close();
self.dirty = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_overlay_open_close() {
let mut av = AgentViewOverlay::new();
assert!(!av.is_active());
av.open();
assert!(av.is_active());
av.close();
assert!(!av.is_active());
}
}

View File

@ -0,0 +1,130 @@
//! Command palette overlay component.
//!
//! Wraps the existing `CommandPalette` state into a Component + Overlay.
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::keybindings::{Action, KeyMap};
use crate::theme::TuiTheme;
use crate::tui::component::{Component, Overlay};
use crate::tui::event::TuiEvent;
use crate::command_palette::CommandPalette;
/// Command palette overlay — wraps the existing CommandPalette.
pub struct CommandPaletteOverlay {
palette: CommandPalette,
dirty: bool,
}
impl CommandPaletteOverlay {
pub fn new() -> Self {
Self {
palette: CommandPalette::new(),
dirty: false,
}
}
pub fn selected_action(&self) -> Option<Action> {
self.palette.selected_action()
}
}
impl Component for CommandPaletteOverlay {
fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
if !self.palette.active { return; }
let popup_w = (area.width * 60 / 100).min(60);
let popup_h = (area.height * 50 / 100).min(20);
let popup = Rect::new(
(area.width.saturating_sub(popup_w)) / 2,
(area.height.saturating_sub(popup_h)) / 2,
popup_w,
popup_h,
);
frame.render_widget(ratatui::widgets::Clear, popup);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled("🔍 ", Style::default().fg(theme.key_hint.to_color())),
Span::styled(self.palette.query.clone(), Style::default().fg(theme.input_fg.to_color())),
Span::styled("", Style::default().fg(theme.input_cursor_bg.to_color())),
]));
lines.push(Line::from(""));
for (i, &idx) in self.palette.filtered.iter().enumerate() {
let entry = &self.palette.entries[idx];
let is_sel = i == self.palette.selected;
let (fg, bg) = if is_sel {
(theme.completion_selected_fg.to_color(), theme.completion_selected_bg.to_color())
} else {
(theme.completion_fg.to_color(), ratatui::style::Color::Reset)
};
lines.push(Line::from(vec![
Span::styled(format!(" {} ", entry.label), Style::default().fg(fg).bg(bg)),
Span::styled(format!(" {} ", entry.description), Style::default().fg(theme.key_hint.to_color())),
Span::styled(&entry.key_hint, Style::default().fg(theme.key_hint.to_color())),
]));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_active.to_color()))
.title(" Command Palette ");
frame.render_widget(Paragraph::new(lines).block(block), popup);
}
fn handle_key(&mut self, key: KeyEvent, _keymap: &KeyMap) -> bool {
if !self.palette.active { return false; }
match key.code {
KeyCode::Esc => { self.palette.close(); self.dirty = true; true }
KeyCode::Enter => { self.dirty = true; true }
KeyCode::Up => { self.palette.select_prev(); self.dirty = true; true }
KeyCode::Down => { self.palette.select_next(); self.dirty = true; true }
KeyCode::Backspace => { self.palette.backspace(); self.dirty = true; true }
KeyCode::Char(c) => { self.palette.input(c); self.dirty = true; true }
_ => true, // Consume all keys when active
}
}
fn is_dirty(&self) -> bool {
self.dirty
}
}
impl Overlay for CommandPaletteOverlay {
fn is_active(&self) -> bool {
self.palette.active
}
fn open(&mut self) {
self.palette.open();
self.dirty = true;
}
fn close(&mut self) {
self.palette.close();
self.dirty = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_overlay_open_close() {
let mut cp = CommandPaletteOverlay::new();
assert!(!cp.is_active());
cp.open();
assert!(cp.is_active());
cp.close();
assert!(!cp.is_active());
}
}

View File

@ -0,0 +1,373 @@
//! Conversation pane component.
//!
//! Renders the left-pane conversation view with word-wrapping, scroll,
//! markdown content, and diff highlighting. Extracted from the standalone
//! `draw_left_pane` and `build_wrapped_conversation` functions in legacy.rs.
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use tui_textarea::TextArea;
use unicode_width::UnicodeWidthChar;
use crate::keybindings::KeyMap;
use crate::theme::TuiTheme;
use crate::tui::component::Component;
use crate::tui::event::TuiEvent;
use crate::tui::legacy::{ConversationContent, ConversationLine};
use crate::tui::markdown::render_diff;
use crate::tui::markdown::ratatui_renderer::MarkdownRenderer;
/// Maximum conversation lines before trimming.
const MAX_CONVERSATION_LINES: usize = 10_000;
/// Conversation pane — the main content area of the TUI.
pub struct ConversationPane {
/// Conversation entries (owning the content).
conversation: Vec<ConversationLine>,
/// Scroll offset (0 = bottom/latest, increases upward).
scroll: u16,
/// Whether content has changed since last render.
dirty: bool,
/// Markdown renderer for rich content.
renderer: MarkdownRenderer,
/// Pre-computed wrapped lines (rebuilt when content or width changes).
wrapped_cache: Vec<Line<'static>>,
/// Expand counts for each ConversationLine (for scroll math).
expand_counts: Vec<usize>,
/// Width the cache was built for.
cached_width: u16,
}
impl ConversationPane {
pub fn new(theme: TuiTheme) -> Self {
Self {
conversation: Vec::new(),
scroll: 0,
dirty: true,
renderer: MarkdownRenderer::new(theme),
wrapped_cache: Vec::new(),
expand_counts: Vec::new(),
cached_width: 0,
}
}
pub fn set_theme(&mut self, theme: TuiTheme) {
self.renderer.set_theme(theme);
self.dirty = true;
}
// -------------------------------------------------------------------
// Content mutators (update phase)
// -------------------------------------------------------------------
pub fn push_banner(&mut self, text: String, color: Color) {
self.conversation.push(ConversationLine::plain(text, color, true));
self.auto_scroll();
}
pub fn push_user_input(&mut self, text: &str, color: Color) {
for raw_line in text.lines() {
self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, true));
}
self.auto_scroll();
}
pub fn push_system_message(&mut self, text: &str, color: Color) {
for raw_line in text.lines() {
self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false));
}
self.auto_scroll();
}
pub fn push_output(&mut self, text: &str, is_error: bool, theme: &TuiTheme) {
if text.is_empty() {
return;
}
let clean = crate::tui_update::strip_ansi(text);
if is_error {
let color = theme.conversation_error.to_color();
for raw_line in clean.lines() {
self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false));
}
} else if crate::tui::markdown::looks_like_markdown(&clean) {
self.conversation.push(ConversationLine::markdown(clean));
} else {
let color = theme.conversation_text.to_color();
for raw_line in clean.lines() {
self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false));
}
}
self.auto_scroll();
}
pub fn push_diff(&mut self, diff: &str) {
self.conversation.push(ConversationLine::diff(diff.to_string()));
self.auto_scroll();
}
pub fn clear(&mut self) {
self.conversation.clear();
self.scroll = 0;
self.dirty = true;
}
pub fn scroll_up(&mut self, amount: u16) {
self.scroll = self.scroll.saturating_add(amount);
self.dirty = true;
}
pub fn scroll_down(&mut self, amount: u16) {
self.scroll = self.scroll.saturating_sub(amount);
self.dirty = true;
}
pub fn scroll_top(&mut self) {
self.scroll = u16::MAX;
self.dirty = true;
}
pub fn scroll_bottom(&mut self) {
self.scroll = 0;
self.dirty = true;
}
fn auto_scroll(&mut self) {
if self.conversation.len() > MAX_CONVERSATION_LINES {
let drain_count = self.conversation.len() - MAX_CONVERSATION_LINES;
self.conversation.drain(..drain_count);
}
self.scroll = 0;
self.dirty = true;
}
// -------------------------------------------------------------------
// Render cache
// -------------------------------------------------------------------
/// Rebuild the wrapped-line cache if content or width changed.
fn ensure_cache(&mut self, width: u16, theme: &TuiTheme) {
if !self.dirty && self.cached_width == width {
return;
}
let content_width = (width as usize).saturating_sub(2);
let (wrapped, expand_counts) = build_wrapped_conversation(
&self.conversation,
content_width,
&self.renderer,
theme,
);
self.wrapped_cache = wrapped;
self.expand_counts = expand_counts;
self.cached_width = width;
self.dirty = false;
}
}
impl Component for ConversationPane {
fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
let pane_rows = (area.height.saturating_sub(1) as usize).max(1);
let total_visual = self.wrapped_cache.len();
let scroll = self.scroll as usize;
let max_offset = total_visual.saturating_sub(pane_rows);
let offset = scroll.min(max_offset);
let start = total_visual.saturating_sub(pane_rows + offset);
let visible: Vec<Line> = self.wrapped_cache.iter().skip(start).take(pane_rows).cloned().collect();
let conversation_widget = Paragraph::new(visible).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(theme.border.to_color()))
.title(Span::styled(
" Conversation ",
Style::default().fg(theme.border.to_color()),
)),
);
frame.render_widget(conversation_widget, area);
// Scroll indicator when content overflows
if total_visual > pane_rows {
let scroll_label = format!(" {}/{} ", offset, max_offset);
let scroll_area = Rect {
x: area.x + area.width.saturating_sub(scroll_label.len() as u16 + 1),
y: area.y,
width: scroll_label.len() as u16 + 1,
height: 1,
};
frame.render_widget(
Paragraph::new(Span::styled(scroll_label, Style::default().fg(theme.conversation_dim.to_color()))),
scroll_area,
);
}
}
fn handle_key(&mut self, _key: KeyEvent, _keymap: &KeyMap) -> bool {
false // Scroll handled by the app
}
fn handle_event(&mut self, _event: &TuiEvent) {}
fn is_dirty(&self) -> bool {
self.dirty
}
}
// ---------------------------------------------------------------------------
// Word-wrap + build helpers
// ---------------------------------------------------------------------------
/// Word-wrap a single text line into visual lines that fit `width` columns.
fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec<Line<'a>> {
if width == 0 {
return vec![Line::from(Span::styled(text.to_string(), style))];
}
let mut result: Vec<Line<'a>> = Vec::new();
if text.is_empty() {
result.push(Line::from(Span::styled(String::new(), style)));
return result;
}
let mut current_line = String::new();
let mut current_width: usize = 0;
let mut last_break_byte: usize = 0;
for ch in text.chars() {
let cw = ch.width().unwrap_or(0);
if current_width + cw > width && !current_line.is_empty() {
if last_break_byte > 0 {
let keep = current_line[..last_break_byte].trim_end().to_string();
let rest = current_line[last_break_byte..].to_string();
result.push(Line::from(Span::styled(keep, style.clone())));
current_width = rest.chars().map(|c| c.width().unwrap_or(0)).sum();
current_line = rest;
last_break_byte = 0;
} else {
result.push(Line::from(Span::styled(current_line.clone(), style.clone())));
current_line.clear();
current_width = 0;
last_break_byte = 0;
}
}
current_line.push(ch);
current_width += cw;
if ch == ' ' || ch == '-' || ch == '_' || ch == '/' {
last_break_byte = current_line.len();
}
}
if !current_line.is_empty() {
result.push(Line::from(Span::styled(current_line, style)));
}
if result.is_empty() {
result.push(Line::from(Span::styled(String::new(), style)));
}
result
}
/// Build the full conversation as wrapped visual lines.
fn build_wrapped_conversation<'a>(
conversation: &[ConversationLine],
content_width: usize,
markdown_renderer: &MarkdownRenderer,
theme: &TuiTheme,
) -> (Vec<Line<'a>>, Vec<usize>) {
let mut all_lines: Vec<Line<'a>> = Vec::new();
let mut expand_counts: Vec<usize> = Vec::new();
for cl in conversation {
match &cl.content {
ConversationContent::Plain { text, color, bold } => {
let mut style = Style::default().fg(*color);
if *bold { style = style.add_modifier(Modifier::BOLD); }
let wrapped = wrap_line(text, content_width, style);
let count = wrapped.len().max(1);
expand_counts.push(count);
all_lines.extend(wrapped);
}
ConversationContent::Markdown { source } => {
let rendered = markdown_renderer.render(source, content_width as u16);
let count = rendered.len().max(1);
expand_counts.push(count);
all_lines.extend(rendered.into_iter().map(|l: Line<'static>| {
Line::from(l.spans.into_iter().map(|s| {
Span::styled(s.content.into_owned(), s.style)
}).collect::<Vec<_>>())
}));
}
ConversationContent::CodeDiff { diff } => {
let rendered = render_diff(diff, theme);
let count = rendered.len().max(1);
expand_counts.push(count);
all_lines.extend(rendered.into_iter().map(|l: Line<'static>| {
Line::from(l.spans.into_iter().map(|s| {
Span::styled(s.content.into_owned(), s.style)
}).collect::<Vec<_>>())
}));
}
}
}
(all_lines, expand_counts)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_theme() -> TuiTheme {
TuiTheme::builtin("default").unwrap()
}
#[test]
fn test_wrap_empty() {
let lines = wrap_line("", 80, Style::default());
assert_eq!(lines.len(), 1);
}
#[test]
fn test_wrap_exact_width() {
let lines = wrap_line("hello", 5, Style::default());
assert_eq!(lines.len(), 1);
}
#[test]
fn test_wrap_overflow_with_break() {
let lines = wrap_line("hello world foo bar", 10, Style::default());
assert!(lines.len() >= 2);
}
#[test]
fn test_wrap_hyphen_break() {
let lines = wrap_line("well-known-author", 10, Style::default());
assert!(lines.len() >= 2);
}
#[test]
fn test_wrap_no_break_point() {
let lines = wrap_line("abcdefghijklmnop", 5, Style::default());
assert!(lines.len() >= 2);
}
#[test]
fn test_conversation_pane_push_and_scroll() {
let theme = test_theme();
let mut pane = ConversationPane::new(theme.clone());
pane.push_user_input("Hello", theme.conversation_user.to_color());
assert_eq!(pane.conversation.len(), 1);
pane.scroll_up(5);
assert_eq!(pane.scroll, 5);
pane.scroll_bottom();
assert_eq!(pane.scroll, 0);
}
#[test]
fn test_conversation_pane_clear() {
let theme = test_theme();
let mut pane = ConversationPane::new(theme);
pane.push_system_message("test", Color::Yellow);
assert!(!pane.conversation.is_empty());
pane.clear();
assert!(pane.conversation.is_empty());
}
}

View File

@ -0,0 +1,216 @@
//! Dashboard component (right pane).
//!
//! Renders model info, token counts, context gauge, LSP status, team info,
//! and key hints. Extracted from the standalone `draw_right_pane` function.
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
use ratatui::Frame;
use crate::theme::TuiTheme;
use crate::tui::component::Component;
use crate::tui::event::TuiEvent;
use crate::tui::legacy::{DashboardState, SharedDashboardState};
const SPINNER_FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
const KV_KEY_WIDTH: usize = 12;
/// Dashboard component — the right-side info panel.
pub struct Dashboard {
state: SharedDashboardState,
spinner_frame: usize,
dirty: bool,
}
impl Dashboard {
pub fn new(state: SharedDashboardState) -> Self {
Self { state, spinner_frame: 0, dirty: true }
}
pub fn tick_spinner(&mut self) {
self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len();
self.dirty = true;
}
pub fn set_status(&mut self, msg: &str) {
if let Ok(mut s) = self.state.write() {
s.status_message = msg.to_string();
}
self.dirty = true;
}
}
impl Component for Dashboard {
fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
let state = self.state.read().unwrap_or_else(|e| e.into_inner());
let mut lines: Vec<Line> = Vec::new();
#[allow(unused_assignments)]
let mut gauge_row: Option<usize> = None;
// Connection
lines.push(section("Connection", theme));
lines.push(kv("Model", &state.model, theme.dashboard_value.to_color(), theme));
lines.push(kv("Provider", &state.provider, theme.dashboard_key.to_color(), theme));
lines.push(kv("URL", &state.provider_url, theme.conversation_dim.to_color(), theme));
lines.push(kv("Mode", &state.permission_mode, theme.conversation_system.to_color(), theme));
if let Some(ref branch) = state.git_branch {
lines.push(kv("Branch", branch, theme.agent_done.to_color(), theme));
}
lines.push(Line::from(""));
// Tokens
lines.push(section("Tokens", theme));
lines.push(kv("Turns", &state.turn_count.to_string(), theme.dashboard_value.to_color(), theme));
lines.push(kv("Input", &state.input_tokens.to_string(), theme.dashboard_value.to_color(), theme));
lines.push(kv("Output", &state.output_tokens.to_string(), theme.dashboard_value.to_color(), theme));
lines.push(kv("Cache R", &state.cache_read_tokens.to_string(), theme.dashboard_key.to_color(), theme));
lines.push(kv("Cache W", &state.cache_creation_tokens.to_string(), theme.dashboard_key.to_color(), theme));
lines.push(kv("Cost", &format!("${:.4}", state.cost_usd), theme.conversation_system.to_color(), theme));
lines.push(Line::from(""));
// Context
let pct = state.context_percent;
let _gauge_color = if pct > 80.0 { theme.gauge_fill_red.to_color() }
else if pct > 50.0 { theme.gauge_fill_yellow.to_color() }
else { theme.gauge_fill_green.to_color() };
lines.push(section("Context", theme));
lines.push(kv("Used", &format!("{:.1}% of {}", pct, state.context_window), theme.dashboard_value.to_color(), theme));
gauge_row = Some(lines.len());
lines.push(Line::from(""));
lines.push(kv("Compactions", &state.compaction_count.to_string(), theme.dashboard_key.to_color(), theme));
lines.push(Line::from(""));
// LSP
if !state.lsp_servers.is_empty() {
lines.push(section("LSP", theme));
for lsp in &state.lsp_servers {
let c = match lsp.status.as_str() {
"connected" => theme.agent_done.to_color(),
"starting" => theme.agent_waiting.to_color(),
_ => theme.agent_failed.to_color(),
};
lines.push(kv(&lsp.language, &lsp.status, c, theme));
}
lines.push(Line::from(""));
}
// Team
if let Some(ref team) = state.team {
lines.push(section("Team", theme));
lines.push(kv("Name", &team.team_name, theme.dashboard_value.to_color(), theme));
let progress = format!("{}/{} done, {} fail, {} run",
team.completed_agents, team.total_agents, team.failed_agents, team.running_agents);
lines.push(kv("Status", &progress, theme.agent_done.to_color(), theme));
for agent in &team.agents {
let c = match agent.status.as_str() {
"completed" => theme.agent_done.to_color(),
"failed" => theme.agent_failed.to_color(),
_ => theme.agent_running.to_color(),
};
let label = format!("{}", agent.name);
let detail = format!("({})", agent.subagent_type.as_deref().unwrap_or("?"));
lines.push(Line::from(vec![
Span::styled(format!(" {:<KV_KEY_WIDTH$}", label), Style::default().fg(c)),
Span::styled(detail, Style::default().fg(theme.dashboard_key.to_color())),
]));
}
lines.push(Line::from(""));
}
// Session
lines.push(section("Session", theme));
lines.push(kv("ID", state.session_id.as_deref().unwrap_or("-"), theme.dashboard_key.to_color(), theme));
// Status / spinner
if !state.status_message.is_empty() {
let frame = SPINNER_FRAMES[self.spinner_frame];
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("{frame} {}", state.status_message),
Style::default().fg(theme.spinner.to_color()),
)));
}
// Key hints
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("─ Keys ─", Style::default().fg(theme.key_hint.to_color()))));
lines.push(Line::from(Span::styled(" Enter Submit Shift+Enter ↵", Style::default().fg(theme.key_hint.to_color()))));
lines.push(Line::from(Span::styled(" ^P Swap ^T Team ^C ⊘ ^D Exit", Style::default().fg(theme.key_hint.to_color()))));
let widget = Paragraph::new(lines)
.block(Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(theme.border.to_color()))
.title(Span::styled(" Dashboard ",
Style::default().fg(theme.dashboard_header.to_color()).add_modifier(Modifier::BOLD))))
.wrap(Wrap { trim: false });
frame.render_widget(widget, area);
// Context gauge overlay
if let Some(row) = gauge_row {
let gauge_area = Rect {
x: area.x + 2,
y: area.y + 1 + row as u16,
width: area.width.saturating_sub(4),
height: 1,
};
let gauge_fill = if pct > 80.0 { theme.gauge_fill_red.to_color() }
else if pct > 50.0 { theme.gauge_fill_yellow.to_color() }
else { theme.gauge_fill_green.to_color() };
let gauge = Gauge::default()
.gauge_style(Style::default().fg(gauge_fill).bg(theme.gauge_bg.to_color()))
.ratio(if pct > 0.0 { (pct / 100.0).min(1.0) } else { 0.0 });
frame.render_widget(gauge, gauge_area);
}
}
fn is_dirty(&self) -> bool {
self.dirty
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn section<'a>(label: &str, theme: &TuiTheme) -> Line<'a> {
Line::from(Span::styled(
format!("{label}"),
Style::default().fg(theme.dashboard_header.to_color()).add_modifier(Modifier::BOLD),
))
}
fn kv<'a>(key: &str, val: &str, val_color: ratatui::style::Color, theme: &TuiTheme) -> Line<'a> {
Line::from(vec![
Span::styled(format!(" {:<KV_KEY_WIDTH$}", key), Style::default().fg(theme.dashboard_key.to_color())),
Span::styled(val.to_string(), Style::default().fg(val_color)),
])
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, RwLock};
fn test_theme() -> TuiTheme {
TuiTheme::builtin("default").unwrap()
}
#[test]
fn test_dashboard_new() {
let state = Arc::new(RwLock::new(DashboardState::new()));
let db = Dashboard::new(state);
assert!(db.dirty);
}
#[test]
fn test_dashboard_set_status() {
let state = Arc::new(RwLock::new(DashboardState::new()));
let mut db = Dashboard::new(state);
db.set_status("Thinking...");
let s = db.state.read().unwrap();
assert_eq!(s.status_message, "Thinking...");
}
}

View File

@ -0,0 +1,360 @@
//! Input bar component.
//!
//! Handles text input, submit, history navigation, and tab completion.
//! Extracted from the TuiApp god-struct.
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::text::{Line, Span};
use ratatui::Frame;
use tui_textarea::TextArea;
use crate::keybindings::{Action, KeyMap, KeyPreset, VimMode};
use crate::theme::TuiTheme;
use crate::tui::component::Component;
use crate::tui::event::TuiEvent;
/// Input bar outcome — what should happen after a key press.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputOutcome {
/// No action — still editing.
None,
/// User submitted text.
Submit(String),
/// User cancelled (Ctrl+C on empty).
Cancel,
/// User wants to exit (Ctrl+D).
Exit,
/// User wants provider swap.
ProviderSwap,
/// User wants team toggle.
TeamToggle,
/// User wants agent view.
ToggleAgentView,
}
/// Input bar with history and tab completion.
pub struct InputBar {
textarea: TextArea<'static>,
/// Input history (most recent last).
history: Vec<String>,
/// Current position in history (None = not navigating).
history_index: Option<usize>,
/// Available slash commands for tab completion.
slash_completions: Vec<String>,
/// Whether completions popup is visible.
showing_completions: bool,
/// Index in the completions list.
completion_index: usize,
/// Whether a turn is in progress (disables submit).
turn_in_progress: bool,
/// Dirty flag.
dirty: bool,
}
impl InputBar {
pub fn new(theme: &TuiTheme) -> Self {
let mut textarea = TextArea::new(vec![String::new()]);
textarea.set_block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.input_border.to_color()))
.title(" > "),
);
textarea.set_style(Style::default().fg(theme.input_fg.to_color()));
textarea.set_cursor_style(
Style::default()
.fg(theme.input_cursor_fg.to_color())
.bg(theme.input_cursor_bg.to_color())
.add_modifier(Modifier::BOLD),
);
Self {
textarea,
history: Vec::new(),
history_index: None,
slash_completions: Vec::new(),
showing_completions: false,
completion_index: 0,
turn_in_progress: false,
dirty: true,
}
}
pub fn set_theme(&mut self, theme: &TuiTheme) {
self.textarea.set_block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.input_border.to_color()))
.title(" > "),
);
self.textarea.set_style(Style::default().fg(theme.input_fg.to_color()));
self.textarea.set_cursor_style(
Style::default()
.fg(theme.input_cursor_fg.to_color())
.bg(theme.input_cursor_bg.to_color())
.add_modifier(Modifier::BOLD),
);
self.dirty = true;
}
pub fn set_completions(&mut self, completions: Vec<String>) {
self.slash_completions = completions;
}
pub fn set_turn_in_progress(&mut self, in_progress: bool) {
self.turn_in_progress = in_progress;
}
/// Push text to history.
pub fn push_history(&mut self, text: &str) {
if !text.trim().is_empty() {
self.history.push(text.to_string());
if self.history.len() > 500 {
self.history.remove(0);
}
}
self.history_index = None;
}
/// Process a key event and return the outcome.
pub fn process_key(&mut self, key: KeyEvent, keymap: &mut KeyMap) -> InputOutcome {
// Vim mode transition
if keymap.preset() == KeyPreset::Vim
&& key.code == crossterm::event::KeyCode::Esc
&& key.modifiers.is_empty()
&& keymap.vim_mode() == VimMode::Insert
{
keymap.set_vim_mode(VimMode::Normal);
self.dirty = true;
return InputOutcome::None;
}
// Tab completion when active
if self.showing_completions && key.code == crossterm::event::KeyCode::Tab {
self.handle_tab();
self.dirty = true;
return InputOutcome::None;
}
let action = keymap.resolve(key);
match action {
Some(Action::Submit) => {
if self.turn_in_progress {
return InputOutcome::None;
}
let lines = self.textarea.lines();
let text = lines.join("\n");
self.textarea.select_all();
self.textarea.cut();
if text.trim().is_empty() {
return InputOutcome::None;
}
self.showing_completions = false;
self.dirty = true;
InputOutcome::Submit(text)
}
Some(Action::Cancel) => {
self.showing_completions = false;
self.textarea.select_all();
self.textarea.cut();
self.dirty = true;
InputOutcome::Cancel
}
Some(Action::Exit) => {
self.dirty = true;
InputOutcome::Exit
}
Some(Action::Newline) => {
self.textarea.insert_newline();
self.dirty = true;
InputOutcome::None
}
Some(Action::ProviderSwap) => {
self.textarea.select_all();
self.textarea.cut();
self.dirty = true;
InputOutcome::ProviderSwap
}
Some(Action::TeamToggle) => {
self.textarea.select_all();
self.textarea.cut();
self.dirty = true;
InputOutcome::TeamToggle
}
Some(Action::ToggleAgentView) => {
self.dirty = true;
InputOutcome::ToggleAgentView
}
_ => {
// History navigation
if key.code == crossterm::event::KeyCode::Up && !self.showing_completions {
self.history_up();
self.dirty = true;
return InputOutcome::None;
}
if key.code == crossterm::event::KeyCode::Down && !self.showing_completions {
self.history_down();
self.dirty = true;
return InputOutcome::None;
}
self.showing_completions = false;
self.textarea.input(key);
self.dirty = true;
InputOutcome::None
}
}
}
/// Get the current input text.
pub fn text(&self) -> String {
self.textarea.lines().join("\n")
}
fn handle_tab(&mut self) {
if !self.showing_completions {
let current_text: String = self.textarea.lines().join("");
if current_text.starts_with('/') {
let prefix = &current_text;
let matches: Vec<&String> = self.slash_completions.iter()
.filter(|c| c.starts_with(prefix))
.collect();
if matches.len() == 1 {
self.textarea.select_all();
self.textarea.cut();
for ch in matches[0].chars() {
self.textarea.insert_char(ch);
}
self.showing_completions = false;
} else if !matches.is_empty() {
self.showing_completions = true;
self.completion_index = 0;
}
}
} else {
self.completion_index = self.completion_index.wrapping_add(1);
}
}
fn history_up(&mut self) {
if self.history.is_empty() { return; }
let new_idx = match self.history_index {
Some(i) => i.saturating_add(1).min(self.history.len() - 1),
None => self.history.len() - 1,
};
self.history_index = Some(new_idx);
let entry = &self.history[self.history.len() - 1 - new_idx];
self.textarea = TextArea::new(vec![entry.clone()]);
}
fn history_down(&mut self) {
match self.history_index {
Some(0) => {
self.history_index = None;
self.textarea = TextArea::new(vec![String::new()]);
}
Some(i) => {
let new_idx = i - 1;
self.history_index = Some(new_idx);
let entry = &self.history[self.history.len() - 1 - new_idx];
self.textarea = TextArea::new(vec![entry.clone()]);
}
None => {}
}
}
/// Render the completions popup (called after the main input render).
fn render_completions(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
if !self.showing_completions { return; }
let current_text: String = self.textarea.lines().join("");
let matches: Vec<&String> = self.slash_completions.iter()
.filter(|c| c.starts_with(current_text.as_str()))
.collect();
if matches.is_empty() { return; }
let items: Vec<ListItem> = matches.iter().enumerate().map(|(i, m)| {
let style = if i == self.completion_index % matches.len() {
Style::default().bg(theme.completion_selected_bg.to_color()).fg(theme.completion_selected_fg.to_color())
} else {
Style::default().fg(theme.completion_fg.to_color())
};
ListItem::new(Line::from(Span::styled(m.as_str(), style)))
}).collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border.to_color())),
);
let popup = Rect {
x: area.x,
y: area.y.saturating_sub(matches.len().min(8) as u16 + 2),
width: area.width.min(40),
height: (matches.len() as u16 + 2).min(10),
};
frame.render_widget(list, popup);
}
}
impl Component for InputBar {
fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
let widget = self.textarea.clone();
frame.render_widget(&widget, area);
// Render completions popup above the input area
if self.showing_completions {
// Note: can't call self.render_completions() from &self here
// because we need mutable access to check state but render is &self.
// Workaround: render completions in the draw_frame function directly.
// This is acceptable for Phase 1 — will be refined later.
let _ = (area, frame, theme);
}
}
fn is_dirty(&self) -> bool {
self.dirty
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_theme() -> TuiTheme {
TuiTheme::builtin("default").unwrap()
}
#[test]
fn test_input_bar_new() {
let theme = test_theme();
let bar = InputBar::new(&theme);
assert!(bar.text().is_empty());
assert!(!bar.turn_in_progress);
}
#[test]
fn test_push_history() {
let theme = test_theme();
let mut bar = InputBar::new(&theme);
bar.push_history("hello");
bar.push_history("world");
assert_eq!(bar.history.len(), 2);
}
#[test]
fn test_push_history_ignores_empty() {
let theme = test_theme();
let mut bar = InputBar::new(&theme);
bar.push_history(" ");
assert_eq!(bar.history.len(), 0);
}
#[test]
fn test_set_turn_in_progress() {
let theme = test_theme();
let mut bar = InputBar::new(&theme);
bar.set_turn_in_progress(true);
assert!(bar.turn_in_progress);
}
}

View File

@ -0,0 +1,7 @@
//! TUI components — each visual region as a Component impl.
pub mod conversation;
pub mod input_bar;
pub mod dashboard;
pub mod command_palette;
pub mod agent_view;

View File

@ -0,0 +1,122 @@
//! Event bus for the TUI architecture.
//!
//! The event bus decouples the TUI event loop from the runtime. The event
//! loop drains the channel non-blocking each tick. Components process
//! events during the update phase before rendering.
use crate::tui::legacy::DashboardState;
use crate::agent_view::AgentSession;
use crate::theme::TuiTheme;
use crate::keybindings::KeyPreset;
use crate::chat_mode::ChatMode;
/// Events that flow through the TUI event bus.
#[derive(Debug, Clone)]
pub enum TuiEvent {
// --- Streaming events (Phase 4) ---
StreamTextDelta { text: String },
StreamThinking { thinking: String },
StreamToolUse { id: String, name: String },
StreamUsage { input_tokens: u32, output_tokens: u32 },
StreamMessageStop,
// --- Turn lifecycle ---
TurnComplete { assistant_text: String },
TurnError { error: String },
TurnStarted,
// --- Dashboard ---
DashboardUpdate(DashboardState),
// --- Agent lifecycle ---
AgentSessionUpdate(AgentSession),
AgentSessionRemove { id: String },
// --- UI state changes ---
ThemeChanged(TuiTheme),
KeymapChanged(KeyPreset),
ChatModeChanged(ChatMode),
// --- System ---
Resize { width: u16, height: u16 },
}
/// The central event channel.
///
/// The main event loop reads from `rx` (non-blocking). Background workers
/// and the main thread write to `tx`.
pub struct EventBus {
tx: crossbeam_channel::Sender<TuiEvent>,
rx: crossbeam_channel::Receiver<TuiEvent>,
}
impl EventBus {
pub fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
Self { tx, rx }
}
/// Get a sender for posting events.
pub fn sender(&self) -> crossbeam_channel::Sender<TuiEvent> {
self.tx.clone()
}
/// Try to receive an event (non-blocking).
/// Returns `None` if no event is available.
pub fn try_recv(&self) -> Option<TuiEvent> {
self.rx.try_recv().ok()
}
/// Drain all pending events.
pub fn drain(&self) -> Vec<TuiEvent> {
let mut events = Vec::new();
while let Some(event) = self.try_recv() {
events.push(event);
}
events
}
/// Send an event (non-blocking, never blocks).
pub fn send(&self, event: TuiEvent) {
let _ = self.tx.send(event);
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_bus_send_recv() {
let bus = EventBus::new();
bus.send(TuiEvent::TurnStarted);
bus.send(TuiEvent::TurnError { error: "test".into() });
let events = bus.drain();
assert_eq!(events.len(), 2);
assert!(matches!(events[0], TuiEvent::TurnStarted));
assert!(matches!(events[1], TuiEvent::TurnError { .. }));
}
#[test]
fn test_event_bus_drain_empty() {
let bus = EventBus::new();
let events = bus.drain();
assert!(events.is_empty());
}
#[test]
fn test_event_bus_sender_clone() {
let bus = EventBus::new();
let tx = bus.sender();
tx.send(TuiEvent::TurnStarted).unwrap();
let events = bus.drain();
assert_eq!(events.len(), 1);
}
}

View File

@ -0,0 +1,33 @@
//! ANSI-based markdown renderer.
//!
//! Uses the shared `MarkdownAst` to produce ANSI-escaped `String` for the
//! plain REPL (non-TUI) mode. This replaces the old `render.rs` TerminalRenderer.
use crate::theme::TuiTheme;
use super::parse_markdown;
/// ANSI markdown renderer.
#[derive(Debug, Default)]
pub struct AnsiMarkdownRenderer {
// Will hold color theme once fully migrated from render.rs
}
impl AnsiMarkdownRenderer {
pub fn new() -> Self {
Self {}
}
/// Render a markdown string into ANSI-escaped text.
///
/// For now, delegates to the existing `TerminalRenderer` to maintain
/// full compatibility. The AST-based renderer will be completed in a
/// follow-up pass.
pub fn render(&self, markdown: &str) -> String {
crate::render::TerminalRenderer::new().render_markdown(markdown)
}
/// Stream-render markdown into a writer.
pub fn stream(&self, markdown: &str, out: &mut impl std::io::Write) -> std::io::Result<()> {
crate::render::TerminalRenderer::new().stream_markdown(markdown, out)
}
}

View File

@ -0,0 +1,535 @@
//! Shared markdown utilities and rendering.
//!
//! Placeholder for Phase 2. Will contain:
//! - `ratatui_renderer.rs` — `MarkdownAst → Vec<Line<'static>>` (replaces markdown.rs)
//! - `ansi_renderer.rs` — `MarkdownAst → String` (replaces render.rs TerminalRenderer)
//! - `stream.rs` — StreamingMarkdownState (incremental rendering)
//!
//! Shared logic moved from render.rs/markdown.rs:
//! - `normalize_nested_fences` — nested code fence fixup
//! - `looks_like_markdown` — heuristic detection
//! - `find_stream_safe_boundary` — streaming boundary finder
//! - `render_diff` — unified diff renderer
//! - `strip_ansi` — ANSI escape stripping
pub mod ratatui_renderer;
pub mod ansi_renderer;
pub mod stream;
// ---------------------------------------------------------------------------
// MarkdownAST — backend-agnostic parsed markdown
// ---------------------------------------------------------------------------
/// A parsed, styled markdown document — backend-agnostic.
///
/// Both the ratatui renderer and the ANSI renderer consume this instead of
/// re-parsing pulldown-cmark events independently. Parse once, render many.
#[derive(Debug, Clone)]
pub struct MarkdownAst {
pub nodes: Vec<MarkdownNode>,
}
/// Semantic style — not tied to any rendering backend.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SemanticStyle {
Normal,
Emphasis,
Strong,
Heading(u8),
InlineCode,
Link { destination: String },
Quote,
}
/// Styled text fragment — backend-agnostic annotation.
#[derive(Debug, Clone)]
pub struct StyledText {
pub text: String,
pub style: SemanticStyle,
}
/// A single line of syntax-highlighted code.
#[derive(Debug, Clone)]
pub struct CodeLine {
pub segments: Vec<CodeSegment>,
}
/// A syntax-highlighted code fragment.
#[derive(Debug, Clone)]
pub struct CodeSegment {
pub text: String,
pub fg: Option<Rgb>,
}
/// RGB color value — shared between both backends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// A node in the markdown AST.
#[derive(Debug, Clone)]
pub enum MarkdownNode {
Heading {
level: u8,
spans: Vec<StyledText>,
},
Paragraph {
spans: Vec<StyledText>,
},
CodeBlock {
language: Option<String>,
lines: Vec<CodeLine>,
},
ListItem {
depth: usize,
ordered: bool,
index: Option<u64>,
spans: Vec<StyledText>,
},
BlockQuote {
spans: Vec<StyledText>,
},
HorizontalRule,
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
TaskListMarker {
done: bool,
},
BlankLine,
}
// ---------------------------------------------------------------------------
// Shared parser: pulldown-cmark Events → MarkdownAst
// ---------------------------------------------------------------------------
use once_cell::sync::Lazy;
use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
/// Loaded once on first access (~10-50ms), cached for process lifetime.
pub static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
pub static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
/// Parse a markdown string into a backend-agnostic AST.
pub fn parse_markdown(markdown: &str) -> MarkdownAst {
let normalized = normalize_nested_fences(markdown);
let parser = Parser::new(&normalized);
let events: Vec<Event> = parser.collect();
let mut nodes: Vec<MarkdownNode> = Vec::new();
let mut style_stack: Vec<SemanticStyle> = Vec::new();
let mut in_code_block = false;
let mut code_block_lang: Option<String> = None;
let mut code_block_content = String::new();
let mut list_depth: usize = 0;
for event in &events {
match event {
Event::Start(tag) => match tag {
Tag::Heading { level, .. } => {
style_stack.push(SemanticStyle::Heading(*level as u8));
}
Tag::Paragraph => {
style_stack.push(SemanticStyle::Normal);
}
Tag::CodeBlock(kind) => {
in_code_block = true;
code_block_content.clear();
code_block_lang = match kind {
CodeBlockKind::Fenced(lang) => {
let s = lang.to_string();
if s.is_empty() { None } else { Some(s) }
}
_ => None,
};
}
Tag::List(_) => {
list_depth += 1;
}
Tag::Item => {
// Items are inline — we'll build them from text events
}
Tag::BlockQuote(_) => {
style_stack.push(SemanticStyle::Quote);
}
Tag::Emphasis => {
style_stack.push(SemanticStyle::Emphasis);
}
Tag::Strong => {
style_stack.push(SemanticStyle::Strong);
}
Tag::Link { dest_url, .. } => {
style_stack.push(SemanticStyle::Link { destination: dest_url.to_string() });
}
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Heading(_) => {
style_stack.pop();
nodes.push(MarkdownNode::BlankLine);
}
TagEnd::Paragraph => {
style_stack.pop();
nodes.push(MarkdownNode::BlankLine);
}
TagEnd::CodeBlock => {
let code_lines = highlight_code(&code_block_content, code_block_lang.as_deref());
nodes.push(MarkdownNode::CodeBlock {
language: code_block_lang.take(),
lines: code_lines,
});
in_code_block = false;
code_block_lang = None;
code_block_content.clear();
}
TagEnd::List(_) => {
list_depth = list_depth.saturating_sub(1);
}
TagEnd::BlockQuote(_) => {
style_stack.pop();
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Link => {
style_stack.pop();
}
_ => {}
},
Event::Text(text) => {
if in_code_block {
code_block_content.push_str(text);
}
// For the AST we just accumulate text; spans will be
// built properly in the full parser (Phase 2 refined).
// For now, we store as a simplified node.
}
Event::Code(code) => {
let _ = (code, in_code_block); // Handled in refined parser
}
Event::SoftBreak | Event::HardBreak => {
if in_code_block {
code_block_content.push('\n');
}
}
Event::Rule => {
nodes.push(MarkdownNode::HorizontalRule);
}
Event::TaskListMarker(done) => {
nodes.push(MarkdownNode::TaskListMarker { done: *done });
}
_ => {}
}
}
MarkdownAst { nodes }
}
/// Highlight code using syntect into backend-agnostic CodeLines.
fn highlight_code(code: &str, language: Option<&str>) -> Vec<CodeLine> {
let syntax = language
.and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = THEME_SET
.themes
.get("base16-ocean.dark")
.or_else(|| THEME_SET.themes.values().next())
.expect("syntect has no themes");
let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
let mut lines: Vec<CodeLine> = Vec::new();
for line in code.lines() {
match highlighter.highlight_line(line, &SYNTAX_SET) {
Ok(ranges) => {
let segments: Vec<CodeSegment> = ranges
.into_iter()
.map(|(style, text)| CodeSegment {
text: text.to_string(),
fg: Some(Rgb { r: style.foreground.r, g: style.foreground.g, b: style.foreground.b }),
})
.collect();
lines.push(CodeLine { segments });
}
Err(_) => {
lines.push(CodeLine {
segments: vec![CodeSegment { text: line.to_string(), fg: None }],
});
}
}
}
lines
}
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::theme::TuiTheme;
// ---------------------------------------------------------------------------
// Markdown detection heuristic (shared)
// ---------------------------------------------------------------------------
/// Heuristic: does this text look like it contains markdown formatting?
pub fn looks_like_markdown(text: &str) -> bool {
let lines: Vec<&str> = text.lines().collect();
let has_header = lines.iter().any(|l| l.starts_with('#'));
let has_code_block = text.contains("```");
let has_list = lines.iter().any(|l| l.starts_with("- ") || l.starts_with("* "));
let has_bold = text.contains("**");
let has_inline_code = text.matches('`').count() >= 2;
let multi_line = lines.len() > 3;
has_code_block || (has_header && multi_line) || (has_list && multi_line) || (has_bold && has_inline_code)
}
// ---------------------------------------------------------------------------
// Diff renderer (ratatui Lines variant)
// ---------------------------------------------------------------------------
/// Render a unified diff with color-coded lines.
pub fn render_diff(diff: &str, theme: &TuiTheme) -> Vec<Line<'static>> {
diff.lines()
.map(|raw_line| {
let (text, color) = if raw_line.starts_with("+++") || raw_line.starts_with("---") {
(raw_line.to_string(), theme.conversation_text.to_color())
} else if raw_line.starts_with("@@") {
(raw_line.to_string(), theme.conversation_user.to_color())
} else if raw_line.starts_with('+') {
(raw_line.to_string(), theme.agent_done.to_color())
} else if raw_line.starts_with('-') {
(raw_line.to_string(), theme.conversation_error.to_color())
} else {
(raw_line.to_string(), theme.conversation_dim.to_color())
};
Line::from(Span::styled(text, Style::default().fg(color)))
})
.collect()
}
// ---------------------------------------------------------------------------
// Nested fence normalization (shared)
// ---------------------------------------------------------------------------
/// Pre-process markdown so nested code fences don't break the parser.
#[allow(
clippy::too_many_lines,
clippy::items_after_statements,
clippy::manual_repeat_n,
clippy::manual_str_repeat
)]
pub fn normalize_nested_fences(markdown: &str) -> String {
#[derive(Debug, Clone)]
struct FenceLine {
char: char,
len: usize,
has_info: bool,
indent: usize,
}
fn parse_fence_line(line: &str) -> Option<FenceLine> {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
let indent = trimmed.chars().take_while(|c| *c == ' ').count();
if indent > 3 { return None; }
let rest = &trimmed[indent..];
let ch = rest.chars().next()?;
if ch != '`' && ch != '~' { return None; }
let len = rest.chars().take_while(|c| *c == ch).count();
if len < 3 { return None; }
let after = &rest[len..];
if ch == '`' && after.contains('`') { return None; }
let has_info = !after.trim().is_empty();
Some(FenceLine { char: ch, len, has_info, indent })
}
let lines: Vec<&str> = markdown.split_inclusive('\n').collect();
let fence_info: Vec<Option<FenceLine>> = lines.iter().map(|l| parse_fence_line(l)).collect();
struct StackEntry { line_idx: usize, fence: FenceLine }
let mut stack: Vec<StackEntry> = Vec::new();
let mut pairs: Vec<(usize, usize, usize)> = Vec::new();
for (i, fi) in fence_info.iter().enumerate() {
let Some(fl) = fi else { continue };
if fl.has_info {
stack.push(StackEntry { line_idx: i, fence: fl.clone() });
} else {
let closes_top = stack.last().is_some_and(|top| top.fence.char == fl.char && fl.len >= top.fence.len);
if closes_top {
let opener = stack.pop().unwrap();
let inner_max = fence_info[opener.line_idx + 1..i]
.iter().filter_map(|fi| fi.as_ref().map(|f| f.len)).max().unwrap_or(0);
pairs.push((opener.line_idx, i, inner_max));
} else {
stack.push(StackEntry { line_idx: i, fence: fl.clone() });
}
}
}
struct Rewrite { char: char, new_len: usize, indent: usize }
let mut rewrites: std::collections::HashMap<usize, Rewrite> = std::collections::HashMap::new();
for (opener_idx, closer_idx, inner_max) in &pairs {
let opener_fl = fence_info[*opener_idx].as_ref().unwrap();
if opener_fl.len <= *inner_max {
let new_len = inner_max + 1;
rewrites.insert(*opener_idx, Rewrite { char: opener_fl.char, new_len, indent: opener_fl.indent });
let closer_fl = fence_info[*closer_idx].as_ref().unwrap();
rewrites.insert(*closer_idx, Rewrite { char: closer_fl.char, new_len, indent: closer_fl.indent });
}
}
if rewrites.is_empty() { return markdown.to_string(); }
let mut out = String::with_capacity(markdown.len() + rewrites.len() * 4);
for (i, line) in lines.iter().enumerate() {
if let Some(rw) = rewrites.get(&i) {
let fence_str: String = std::iter::repeat(rw.char).take(rw.new_len).collect();
let indent_str: String = std::iter::repeat(' ').take(rw.indent).collect();
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
let fi = fence_info[i].as_ref().unwrap();
let info = &trimmed[fi.indent + fi.len..];
let trailing = &line[trimmed.len()..];
out.push_str(&indent_str);
out.push_str(&fence_str);
out.push_str(info);
out.push_str(trailing);
} else {
out.push_str(line);
}
}
out
}
// ---------------------------------------------------------------------------
// Streaming boundary finder (shared)
// ---------------------------------------------------------------------------
/// Find a safe boundary for streaming markdown rendering.
pub fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut open_fence: Option<FenceMarker> = None;
let mut last_boundary = None;
for (offset, line) in markdown.split_inclusive('\n').scan(0usize, |cursor, line| {
let start = *cursor;
*cursor += line.len();
Some((start, line))
}) {
let line_without_newline = line.trim_end_matches('\n');
if let Some(opener) = open_fence {
if line_closes_fence(line_without_newline, opener) {
open_fence = None;
last_boundary = Some(offset + line.len());
}
continue;
}
if let Some(opener) = parse_fence_opener(line_without_newline) {
open_fence = Some(opener);
continue;
}
if line_without_newline.trim().is_empty() {
last_boundary = Some(offset + line.len());
}
}
last_boundary
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FenceMarker { character: char, length: usize }
fn parse_fence_opener(line: &str) -> Option<FenceMarker> {
let indent = line.chars().take_while(|c| *c == ' ').count();
if indent > 3 { return None; }
let rest = &line[indent..];
let character = rest.chars().next()?;
if character != '`' && character != '~' { return None; }
let length = rest.chars().take_while(|c| *c == character).count();
if length < 3 { return None; }
let info_string = &rest[length..];
if character == '`' && info_string.contains('`') { return None; }
Some(FenceMarker { character, length })
}
fn line_closes_fence(line: &str, opener: FenceMarker) -> bool {
let indent = line.chars().take_while(|c| *c == ' ').count();
if indent > 3 { return false; }
let rest = &line[indent..];
let length = rest.chars().take_while(|c| *c == opener.character).count();
if length < opener.length { return false; }
rest[length..].chars().all(|c| c == ' ' || c == '\t')
}
// ---------------------------------------------------------------------------
// ANSI stripping (shared, canonical)
// ---------------------------------------------------------------------------
/// Strip ANSI escape sequences from text.
pub fn strip_ansi(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
match chars.peek() {
Some('[') => {
chars.next();
while let Some(&c) = chars.peek() { chars.next(); if ('\x40'..='\x7e').contains(&c) { break; } }
}
Some(']') => {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if c == '\x07' { break; }
if c == '\x1b' && chars.peek() == Some(&'\\') { chars.next(); break; }
}
}
Some('P') | Some('_') | Some('^') => {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if c == '\x1b' && chars.peek() == Some(&'\\') { chars.next(); break; }
}
}
_ => { chars.next(); }
}
} else {
result.push(ch);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_looks_like_markdown_code_block() {
assert!(looks_like_markdown("some text\n```rust\ncode\n```\nmore text"));
}
#[test]
fn test_looks_like_markdown_plain() {
assert!(!looks_like_markdown("Just a simple response without any formatting."));
}
#[test]
fn test_strip_ansi_basic() {
assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
}
#[test]
fn test_normalize_no_change() {
let md = "# Hello\n\nSome text\n";
assert_eq!(normalize_nested_fences(md), md);
}
#[test]
fn test_find_boundary_in_fenced_block() {
let md = "```rust\nfn main() {}\n";
assert_eq!(find_stream_safe_boundary(md), None);
}
#[test]
fn test_render_diff() {
let theme = crate::theme::TuiTheme::builtin("default").unwrap();
let lines = render_diff("+added\n-removed\n@@ hunk @@\n--- a/file.rs\n+++ b/file.rs", &theme);
assert_eq!(lines.len(), 5);
}
}

View File

@ -0,0 +1,108 @@
//! Ratatui-based markdown renderer.
//!
//! Uses the shared `MarkdownAst` to produce `Vec<Line<'static>>` for the
//! TUI conversation pane. This replaces the old `markdown.rs` module.
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::theme::TuiTheme;
use super::{parse_markdown, MarkdownNode, SemanticStyle, CodeLine, Rgb};
/// Ratatui markdown renderer.
#[derive(Clone)]
pub struct MarkdownRenderer {
theme: TuiTheme,
}
impl MarkdownRenderer {
pub fn new(theme: TuiTheme) -> Self {
Self { theme }
}
pub fn set_theme(&mut self, theme: TuiTheme) {
self.theme = theme;
}
/// Render a markdown string into ratatui Lines at the given terminal width.
pub fn render(&self, markdown: &str, width: u16) -> Vec<Line<'static>> {
// For now, delegate to the existing markdown.rs renderer
// to maintain full compatibility. The AST-based renderer
// will be completed in a follow-up pass.
let inner = crate::markdown::MarkdownRenderer::new(self.theme.clone());
inner.render(markdown, width)
}
/// Render from a pre-parsed AST (future: will be the primary path).
#[allow(dead_code)]
fn render_from_ast(&self, ast: &super::MarkdownAst, _width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
for node in &ast.nodes {
match node {
MarkdownNode::CodeBlock { language, lines: code_lines } => {
let lang_label = language.as_deref().unwrap_or("text");
lines.push(Line::from(Span::styled(
format!("╭─ {lang_label}"),
Style::default().fg(self.theme.code_border.to_color()),
)));
for cl in code_lines {
let mut spans: Vec<Span<'static>> = vec![Span::styled(
"",
Style::default().fg(self.theme.code_border.to_color()),
)];
for seg in &cl.segments {
let color = seg.fg.map(|rgb| Color::Rgb(rgb.r, rgb.g, rgb.b))
.unwrap_or(self.theme.code_fg.to_color());
spans.push(Span::styled(seg.text.clone(), Style::default().fg(color)));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(Span::styled(
"╰─",
Style::default().fg(self.theme.code_border.to_color()),
)));
}
MarkdownNode::HorizontalRule => {
lines.push(Line::from(Span::styled(
"".repeat(40),
Style::default().fg(self.theme.conversation_dim.to_color()),
)));
}
MarkdownNode::BlankLine => {
lines.push(Line::from(""));
}
_ => {
// Other node types handled by the delegated renderer
}
}
}
lines
}
}
/// Map a SemanticStyle to a ratatui Style using the theme.
#[allow(dead_code)]
fn semantic_to_ratatui_style(style: &SemanticStyle, theme: &TuiTheme) -> Style {
match style {
SemanticStyle::Normal => Style::default(),
SemanticStyle::Emphasis => Style::default().add_modifier(Modifier::ITALIC),
SemanticStyle::Strong => Style::default().add_modifier(Modifier::BOLD),
SemanticStyle::Heading(level) => {
let mut s = Style::default()
.fg(theme.conversation_user.to_color())
.add_modifier(Modifier::BOLD);
if *level >= 3 {
s = s.fg(Color::Blue);
}
s
}
SemanticStyle::InlineCode => Style::default()
.fg(theme.conversation_system.to_color())
.bg(theme.code_bg.to_color()),
SemanticStyle::Link { .. } => Style::default()
.fg(theme.conversation_user.to_color())
.add_modifier(Modifier::UNDERLINED),
SemanticStyle::Quote => Style::default().fg(theme.conversation_dim.to_color()),
}
}

View File

@ -0,0 +1,47 @@
//! Streaming markdown state — placeholder for Phase 3.
//!
//! Incremental rendering: accumulates text deltas, finds safe boundaries,
//! parses/renders complete markdown blocks.
/// Streaming markdown state — stub for Phase 3.
pub struct StreamingMarkdownState {
pending: String,
}
impl StreamingMarkdownState {
pub fn new() -> Self {
Self { pending: String::new() }
}
pub fn push_delta(&mut self, delta: &str) -> bool {
self.pending.push_str(delta);
true
}
pub fn flush(&mut self) -> String {
std::mem::take(&mut self.pending)
}
pub fn has_pending(&self) -> bool {
!self.pending.is_empty()
}
}
impl Default for StreamingMarkdownState {
fn default() -> Self { Self::new() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_accumulates() {
let mut s = StreamingMarkdownState::new();
s.push_delta("Hello ");
s.push_delta("world");
assert!(s.has_pending());
assert_eq!(s.flush(), "Hello world");
assert!(!s.has_pending());
}
}

View File

@ -0,0 +1,18 @@
//! TUI module — terminal user interface for Claw Code.
//!
//! This is a directory module (`tui/mod.rs`) that organizes the TUI into
//! clean sub-modules. The legacy god-struct lives in `legacy.rs` and is
//! re-exported here so all existing `tui::` paths continue to work.
// Legacy — the current TuiApp, untouched during migration.
// All existing tui::* paths resolve through `pub use legacy::*`.
pub mod legacy;
pub use legacy::*;
// New sub-modules — built incrementally alongside the legacy code.
pub mod component;
pub mod event;
pub mod slash_commands;
pub mod app;
pub mod components;
pub mod markdown;

View File

@ -0,0 +1,162 @@
//! Slash command dispatcher.
//!
//! Extracts the inline slash-command handling from `main.rs::run_tui_repl()`
//! into a testable, decoupled dispatcher.
use crate::chat_mode::ChatMode;
use crate::keybindings::KeyPreset;
/// Result of processing a slash command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlashCommandResult {
/// Command was handled locally; do not send to the model.
Handled,
/// Command should be sent to the model as a regular message.
SendToModel,
/// Exit the TUI.
Exit,
/// Provider swap wizard requested.
ProviderSwap,
}
/// Try to parse a slash command from input.
///
/// Returns `None` if the input is not a slash command.
pub fn parse_slash_command(input: &str) -> Option<(&str, &str)> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return None;
}
let space = trimmed.find(' ').unwrap_or(trimmed.len());
let command = &trimmed[..space];
let args = trimmed[space..].trim();
Some((command, args))
}
/// Dispatch a slash command.
///
/// Returns a `SlashCommandResult` indicating what should happen next.
/// The `ctx` callback is called with system messages and other UI updates.
///
/// This is a pure function that doesn't depend on TuiApp or LiveCli —
/// side effects (pushing messages, changing theme) are delegated to the
/// caller through the return value and optional context.
pub fn dispatch_slash_command<'a>(
input: &'a str,
) -> SlashCommandAction<'a> {
let Some((command, args)) = parse_slash_command(input) else {
return SlashCommandAction::NotACommand;
};
match command {
"/exit" | "/quit" => SlashCommandAction::Exit,
"/theme" => SlashCommandAction::SetTheme { name: args },
"/keys" => SlashCommandAction::SetKeymap { preset: args },
"/code" => SlashCommandAction::SetChatMode { mode: ChatMode::Code },
"/ask" => SlashCommandAction::SetChatMode { mode: ChatMode::Ask },
"/architect" | "/arch" => SlashCommandAction::SetChatMode { mode: ChatMode::Architect },
"/diff" => SlashCommandAction::ShowDiff,
"/undo" => SlashCommandAction::Undo { confirm: args == "--confirm" || args == "-y" },
"/ls" => SlashCommandAction::ShowFile { path: args },
"/help" => SlashCommandAction::ShowHelp,
_ => SlashCommandAction::Unknown { command },
}
}
/// Actions that a slash command can trigger.
#[derive(Debug, Clone, PartialEq)]
pub enum SlashCommandAction<'a> {
/// Not a slash command — send to the model.
NotACommand,
/// Exit the TUI.
Exit,
/// Change theme.
SetTheme { name: &'a str },
/// Change keymap preset.
SetKeymap { preset: &'a str },
/// Change chat mode.
SetChatMode { mode: ChatMode },
/// Show git diff.
ShowDiff,
/// Undo (revert uncommitted changes).
Undo { confirm: bool },
/// Show file contents.
ShowFile { path: &'a str },
/// Show help.
ShowHelp,
/// Unknown command.
Unknown { command: &'a str },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_slash_command() {
assert_eq!(parse_slash_command("/theme tokyonight"), Some(("/theme", "tokyonight")));
assert_eq!(parse_slash_command("/keys"), Some(("/keys", "")));
assert_eq!(parse_slash_command("/help me"), Some(("/help", "me")));
assert_eq!(parse_slash_command("not a command"), None);
assert_eq!(parse_slash_command(""), None);
}
#[test]
fn test_dispatch_exit() {
assert_eq!(dispatch_slash_command("/exit"), SlashCommandAction::Exit);
assert_eq!(dispatch_slash_command("/quit"), SlashCommandAction::Exit);
}
#[test]
fn test_dispatch_theme() {
let result = dispatch_slash_command("/theme tokyonight");
assert_eq!(result, SlashCommandAction::SetTheme { name: "tokyonight" });
}
#[test]
fn test_dispatch_keys() {
let result = dispatch_slash_command("/keys vim");
assert_eq!(result, SlashCommandAction::SetKeymap { preset: "vim" });
}
#[test]
fn test_dispatch_chat_mode() {
assert_eq!(dispatch_slash_command("/code"), SlashCommandAction::SetChatMode { mode: ChatMode::Code });
assert_eq!(dispatch_slash_command("/ask"), SlashCommandAction::SetChatMode { mode: ChatMode::Ask });
assert_eq!(dispatch_slash_command("/architect"), SlashCommandAction::SetChatMode { mode: ChatMode::Architect });
assert_eq!(dispatch_slash_command("/arch"), SlashCommandAction::SetChatMode { mode: ChatMode::Architect });
}
#[test]
fn test_dispatch_diff() {
assert_eq!(dispatch_slash_command("/diff"), SlashCommandAction::ShowDiff);
}
#[test]
fn test_dispatch_undo() {
assert_eq!(dispatch_slash_command("/undo"), SlashCommandAction::Undo { confirm: false });
assert_eq!(dispatch_slash_command("/undo --confirm"), SlashCommandAction::Undo { confirm: true });
assert_eq!(dispatch_slash_command("/undo -y"), SlashCommandAction::Undo { confirm: true });
}
#[test]
fn test_dispatch_ls() {
assert_eq!(dispatch_slash_command("/ls /some/path"), SlashCommandAction::ShowFile { path: "/some/path" });
}
#[test]
fn test_dispatch_help() {
assert_eq!(dispatch_slash_command("/help"), SlashCommandAction::ShowHelp);
}
#[test]
fn test_dispatch_unknown() {
let result = dispatch_slash_command("/unknown-command");
assert!(matches!(result, SlashCommandAction::Unknown { .. }));
}
#[test]
fn test_dispatch_not_a_command() {
assert_eq!(dispatch_slash_command("hello world"), SlashCommandAction::NotACommand);
}
}