diff --git a/rust/crates/rusty-claude-cli/Cargo.toml b/rust/crates/rusty-claude-cli/Cargo.toml index d0441760..3a5248d0 100644 --- a/rust/crates/rusty-claude-cli/Cargo.toml +++ b/rust/crates/rusty-claude-cli/Cargo.toml @@ -14,7 +14,9 @@ api = { path = "../api" } commands = { path = "../commands" } crossterm = "0.28" pulldown-cmark = "0.13" +ratatui = "0.29" rustyline = "15" +tui-textarea = "0.7" runtime = { path = "../runtime" } plugins = { path = "../plugins" } serde = { version = "1", features = ["derive"] } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 6de88cee..0b3b31ac 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -16,6 +16,7 @@ )] mod init; mod input; +mod tui; mod render; mod setup_wizard; @@ -7072,6 +7073,254 @@ fn run_repl( base_commit: Option, reasoning_effort: Option, allow_broad_cwd: bool, +) -> Result<(), Box> { + if io::stdout().is_terminal() { + return run_repl_tui(model, allowed_tools, permission_mode, base_commit, reasoning_effort, allow_broad_cwd); + } + run_repl_classic(model, allowed_tools, permission_mode, base_commit, reasoning_effort, allow_broad_cwd) +} + +#[allow(clippy::too_many_lines)] +fn run_repl_tui( + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, +) -> Result<(), Box> { + enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; + run_stale_base_preflight(base_commit.as_deref()); + let resolved_model = resolve_repl_model(model.clone()); + let reasoning_effort_clone = reasoning_effort.clone(); + + let mut cli = LiveCli::new(resolved_model.clone(), true, allowed_tools.clone(), permission_mode)?; + let cwd = std::env::current_dir().unwrap_or_default(); + let lsp_auto = runtime::ConfigLoader::default_for(&cwd) + .load() + .map(|c| c.lsp_auto_start()) + .unwrap_or(true); + cli.lsp_auto_start = lsp_auto; + cli.set_reasoning_effort(reasoning_effort_clone); + + // Build shared dashboard state + let dashboard_state = tui::SharedDashboardState::new(std::sync::RwLock::new({ + let mut ds = tui::DashboardState::new(); + ds.model = cli.model.clone(); + ds.permission_mode = permission_mode.as_str().to_string(); + ds.session_id = Some(cli.session.id.clone()); + if let Ok(config) = runtime::ConfigLoader::default_for(&cwd).load() { + if let Some(base_url) = config.provider().base_url() { + ds.provider_url = base_url.to_string(); + } + } + ds + })); + + // Discover and register LSP servers (before TUI takes over the terminal) + let lsp_servers = runtime::lsp_discovery::discover_available_servers(); + if !lsp_servers.is_empty() { + for server in &lsp_servers { + tools::global_lsp_registry().register_with_descriptor( + &server.language, + runtime::lsp_client::LspServerStatus::Starting, + None, + vec![], + server.clone(), + ); + } + if cli.lsp_auto_start { + let registry = tools::global_lsp_registry(); + for server in &lsp_servers { + let _ = registry.start_server(&server.language); + } + } + } + update_dashboard_lsp(&dashboard_state); + + // Initialize TUI + let mut app = match tui::TuiApp::init(dashboard_state.clone()) { + Ok(app) => app, + Err(e) => { + eprintln!("TUI init failed, falling back to classic mode: {e}"); + return run_repl_classic(model, allowed_tools, permission_mode, base_commit, reasoning_effort, allow_broad_cwd); + } + }; + // TUI banner — styled ASCII art with info lines + { + let status = status_context(None).ok(); + let git_branch = status + .as_ref() + .and_then(|ctx| ctx.git_branch.as_deref()) + .unwrap_or("unknown"); + let workspace = status.as_ref().map_or_else( + || "unknown".to_string(), + |ctx| ctx.git_summary.headline(), + ); + let banner_lines = vec![ + tui::BannerLine { text: " ██████╗██╗ █████╗ ██╗ ██╗".to_string(), color: ratatui::style::Color::Red }, + tui::BannerLine { text: "██╔════╝██║ ██╔══██╗██║ ██║".to_string(), color: ratatui::style::Color::Red }, + tui::BannerLine { text: "██║ ██║ ███████║██║ █╗ ██║".to_string(), color: ratatui::style::Color::Red }, + tui::BannerLine { text: "██║ ██║ ██╔══██║██║███╗██║".to_string(), color: ratatui::style::Color::Red }, + tui::BannerLine { text: "╚██████╗███████╗██║ ██║╚███╔███╔╝".to_string(), color: ratatui::style::Color::Red }, + tui::BannerLine { text: " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ Code".to_string(), color: ratatui::style::Color::Red }, + tui::BannerLine { text: String::new(), color: ratatui::style::Color::White }, + tui::BannerLine { text: format!(" Model {}", cli.model), color: ratatui::style::Color::White }, + tui::BannerLine { text: format!(" Mode {}", permission_mode.as_str()), color: ratatui::style::Color::White }, + tui::BannerLine { text: format!(" Branch {}", git_branch), color: ratatui::style::Color::Green }, + tui::BannerLine { text: format!(" Workspace {}", workspace), color: ratatui::style::Color::White }, + tui::BannerLine { text: format!(" Session {}", cli.session.id), color: ratatui::style::Color::Gray }, + tui::BannerLine { text: String::new(), color: ratatui::style::Color::White }, + ]; + app.push_banner(banner_lines); + } + + // Main TUI event loop + loop { + app.set_slash_completions(cli.repl_completion_candidates().unwrap_or_default()); + match app.read_line()? { + tui::TuiReadOutcome::Pending => continue, + tui::TuiReadOutcome::Submit(input) => { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if matches!(trimmed.as_str(), "/exit" | "/quit") { + let _ = app.restore_terminal(); + cli.shutdown_lsp_servers(); + cli.persist_session()?; + break; + } + match SlashCommand::parse(&trimmed) { + Ok(Some(command)) => { + app.suspend()?; + let cmd_result = cli.handle_repl_command(command); + if let Ok(true) = cmd_result { + cli.persist_session()?; + } + if let Err(e) = app.resume() { + eprintln!("TUI resume failed: {e}"); + let _ = app.restore_terminal(); + return Err(e); + } + continue; + } + Ok(None) => {} + Err(error) => { + app.push_system_message(&error.to_string()); + continue; + } + } + let cwd_check = std::env::current_dir().unwrap_or_default(); + let prompt = if let Some(p) = try_resolve_bare_skill_prompt(&cwd_check, &trimmed) { + p + } else { + trimmed.clone() + }; + app.push_user_input(&input); + cli.record_prompt_history(&trimmed); + update_dashboard(&dashboard_state, &cli); + app.set_status("Thinking..."); + + // Suspend TUI, run turn with normal stdout, then resume + app.suspend()?; + let result = cli.run_turn(&prompt); + if let Err(e) = app.resume() { + eprintln!("TUI resume failed: {e}, restoring terminal..."); + let _ = app.restore_terminal(); + return Err(e); + } + + match result { + Ok(()) => { + app.set_status("Done"); + if let Ok(mut ds) = dashboard_state.write() { + ds.status_message.clear(); + } + } + Err(e) => { + app.push_system_message(&format!("Error: {e}")); + app.set_status(""); + } + } + update_dashboard(&dashboard_state, &cli); + } + tui::TuiReadOutcome::ProviderSwap => { + app.suspend()?; + setup_wizard::run_setup_wizard()?; + let cwd = std::env::current_dir().unwrap_or_default(); + let config = runtime::ConfigLoader::default_for(&cwd).load().ok(); + if let Some(new_model) = config.as_ref().and_then(|c| c.provider().model().map(str::to_string)) { + cli.set_model(Some(new_model))?; + } + if let Err(e) = app.resume() { + eprintln!("TUI resume failed: {e}"); + let _ = app.restore_terminal(); + return Err(e); + } + update_dashboard(&dashboard_state, &cli); + } + tui::TuiReadOutcome::TeamToggle => { + let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default(); + if current == "1" { + std::env::set_var("CLAWD_AGENT_TEAMS", "0"); + app.push_system_message("[team] Agent teams disabled"); + } else { + std::env::set_var("CLAWD_AGENT_TEAMS", "1"); + app.push_system_message("[team] Agent teams enabled"); + } + } + tui::TuiReadOutcome::Cancel => { + // Ctrl+C clears input (handled in TUI), just continue + } + tui::TuiReadOutcome::Exit => { + let _ = app.restore_terminal(); + cli.shutdown_lsp_servers(); + cli.persist_session()?; + break; + } + } + } + + Ok(()) +} + +fn update_dashboard(state: &tui::SharedDashboardState, cli: &LiveCli) { + if let Ok(mut ds) = state.write() { + ds.model = cli.model.clone(); + ds.turn_count = cli.runtime.usage().turns(); + let usage = cli.runtime.usage().cumulative_usage(); + ds.input_tokens = usage.input_tokens; + ds.output_tokens = usage.output_tokens; + ds.cache_read_tokens = usage.cache_read_input_tokens; + ds.cache_creation_tokens = usage.cache_creation_input_tokens; + ds.cost_usd = usage.estimate_cost_usd().total_cost_usd(); + ds.session_id = Some(cli.session.id.clone()); + } + update_dashboard_lsp(state); +} + +fn update_dashboard_lsp(state: &tui::SharedDashboardState) { + if let Ok(mut ds) = state.write() { + ds.lsp_servers = tools::global_lsp_registry() + .list_servers() + .into_iter() + .map(|s| tui::LspInfo { + language: s.language, + status: s.status.to_string(), + }) + .collect(); + } +} + +#[allow(clippy::too_many_lines)] +fn run_repl_classic( + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, ) -> Result<(), Box> { enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; run_stale_base_preflight(base_commit.as_deref()); diff --git a/rust/crates/rusty-claude-cli/src/tui.rs b/rust/crates/rusty-claude-cli/src/tui.rs new file mode 100644 index 00000000..3241c13b --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui.rs @@ -0,0 +1,753 @@ +use std::io; +use std::sync::{Arc, RwLock}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Wrap}; +use ratatui::Frame; +use tui_textarea::TextArea; + +// --------------------------------------------------------------------------- +// Shared dashboard state +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct DashboardState { + pub model: String, + pub provider: String, + pub provider_url: String, + pub session_id: Option, + pub turn_count: u32, + pub input_tokens: u32, + pub output_tokens: u32, + pub cache_read_tokens: u32, + pub cache_creation_tokens: u32, + pub cost_usd: f64, + pub context_percent: f64, + pub context_window: u32, + pub compaction_count: usize, + pub lsp_servers: Vec, + pub team: Option, + pub working_dir: String, + pub git_branch: Option, + pub permission_mode: String, + pub status_message: String, +} + +#[derive(Debug, Clone)] +pub struct LspInfo { + pub language: String, + pub status: String, +} + +#[derive(Debug, Clone)] +pub struct TeamInfo { + pub team_id: String, + pub team_name: String, + pub total_agents: usize, + pub completed_agents: usize, + pub failed_agents: usize, + pub running_agents: usize, + pub agents: Vec, +} + +#[derive(Debug, Clone)] +pub struct AgentInfo { + pub agent_id: String, + pub name: String, + pub subagent_type: Option, + pub status: String, +} + +impl Default for DashboardState { + fn default() -> Self { + Self::new() + } +} + +impl DashboardState { + pub fn new() -> Self { + let cwd = std::env::current_dir().unwrap_or_default(); + let git_branch = std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&cwd) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }); + + Self { + model: String::new(), + provider: String::new(), + provider_url: String::new(), + session_id: None, + turn_count: 0, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + cost_usd: 0.0, + context_percent: 0.0, + context_window: 0, + compaction_count: 0, + lsp_servers: Vec::new(), + team: None, + working_dir: cwd.display().to_string(), + git_branch, + permission_mode: String::new(), + status_message: String::new(), + } + } +} + +pub type SharedDashboardState = Arc>; + +// --------------------------------------------------------------------------- +// Banner line for startup banner +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct BannerLine { + pub text: String, + pub color: Color, +} + +// --------------------------------------------------------------------------- +// Conversation line +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct ConversationLine { + pub text: String, + pub color: Color, + pub bold: bool, +} + +// --------------------------------------------------------------------------- +// TUI App +// --------------------------------------------------------------------------- + +pub struct TuiApp { + dashboard: SharedDashboardState, + conversation: Vec, + conversation_scroll: u16, + input: TextArea<'static>, + should_exit: bool, + terminal: ratatui::Terminal>, + slash_completions: Vec, + completion_index: usize, + showing_completions: bool, + spinner_frame: usize, + needs_redraw: bool, +} + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +impl TuiApp { + pub fn init(state: SharedDashboardState) -> Result> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = ratatui::backend::CrosstermBackend::new(io::stdout()); + let terminal = ratatui::Terminal::new(backend)?; + + let mut input = TextArea::new(vec![String::new()]); + input.set_block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" > "), + ); + input.set_style(Style::default().fg(Color::White)); + input.set_cursor_style(Style::default().fg(Color::Cyan)); + + Ok(Self { + dashboard: state, + conversation: Vec::new(), + conversation_scroll: 0, + input, + should_exit: false, + terminal, + slash_completions: Vec::new(), + completion_index: 0, + showing_completions: false, + spinner_frame: 0, + needs_redraw: true, + }) + } + + /// Suspend TUI: restore normal terminal so stdout works. + pub fn suspend(&mut self) -> Result<(), Box> { + let _ = self.terminal.show_cursor(); + let _ = disable_raw_mode(); + let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + let _ = crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::All) + ); + let _ = crossterm::execute!(io::stdout(), crossterm::cursor::MoveTo(0, 0)); + Ok(()) + } + + /// Resume TUI after suspend. + pub fn resume(&mut self) -> Result<(), Box> { + enable_raw_mode()?; + let mut out = io::stdout(); + crossterm::execute!(out, EnterAlternateScreen)?; + let _ = self.terminal.hide_cursor(); + self.needs_redraw = true; + self.draw_screen()?; + Ok(()) + } + + /// Fully restore terminal on exit. + pub fn restore_terminal(&mut self) -> Result<(), Box> { + let _ = self.terminal.show_cursor(); + let _ = disable_raw_mode(); + let _ = crossterm::execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + Ok(()) + } + + pub fn push_banner(&mut self, lines: Vec) { + for bl in lines { + self.conversation.push(ConversationLine { + text: bl.text, + color: bl.color, + bold: true, + }); + } + self.auto_scroll(); + } + + pub fn push_user_input(&mut self, text: &str) { + self.conversation.push(ConversationLine { + text: text.to_string(), + color: Color::Cyan, + bold: true, + }); + self.auto_scroll(); + } + + pub fn push_system_message(&mut self, text: &str) { + self.conversation.push(ConversationLine { + text: text.to_string(), + color: Color::Yellow, + bold: false, + }); + self.auto_scroll(); + } + + pub fn push_output(&mut self, text: &str, is_error: bool) { + if text.is_empty() { + return; + } + for raw_line in text.lines() { + self.conversation.push(ConversationLine { + text: raw_line.to_string(), + color: if is_error { Color::Red } else { Color::White }, + bold: false, + }); + } + self.auto_scroll(); + } + + pub fn set_slash_completions(&mut self, completions: Vec) { + self.slash_completions = completions; + } + + pub fn set_status(&mut self, msg: &str) { + if let Ok(mut s) = self.dashboard.write() { + s.status_message = msg.to_string(); + } + self.needs_redraw = true; + } + + fn auto_scroll(&mut self) { + let line_count = self.conversation.len() as u16; + self.conversation_scroll = line_count.saturating_sub(20); + self.needs_redraw = true; + } + + // ----------------------------------------------------------------------- + // Main event loop + // ----------------------------------------------------------------------- + + pub fn read_line(&mut self) -> io::Result { + // Fast 16ms poll + if event::poll(std::time::Duration::from_millis(16))? { + if let Event::Key(key) = event::read()? { + self.needs_redraw = true; + return self.handle_key(key); + } + } + + // Redraw on dirty or every ~80ms for spinner + self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len(); + if self.needs_redraw || self.spinner_frame % 5 == 0 { + self.draw_screen()?; + self.needs_redraw = false; + } + + Ok(TuiReadOutcome::Pending) + } + + fn draw_screen(&mut self) -> io::Result<()> { + let dashboard = self.dashboard.clone(); + let conversation = self.conversation.clone(); + let conversation_scroll = self.conversation_scroll; + let slash_completions = self.slash_completions.clone(); + let completion_index = self.completion_index; + let showing_completions = self.showing_completions; + let spinner_frame = self.spinner_frame; + let input_lines: Vec = self.input.lines().iter().cloned().collect(); + let input_text = input_lines.join("\n"); + + self.terminal.draw(|f| { + draw_frame( + f, + &dashboard, + &conversation, + conversation_scroll, + &input_text, + &input_lines, + &slash_completions, + completion_index, + showing_completions, + spinner_frame, + ); + })?; + Ok(()) + } + + fn handle_key(&mut self, key: KeyEvent) -> io::Result { + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('d') => { + self.should_exit = true; + return Ok(TuiReadOutcome::Exit); + } + KeyCode::Char('c') => { + self.input.select_all(); + self.input.cut(); + return Ok(TuiReadOutcome::Cancel); + } + KeyCode::Char('p') => { + self.input.select_all(); + self.input.cut(); + return Ok(TuiReadOutcome::ProviderSwap); + } + KeyCode::Char('t') => { + self.input.select_all(); + self.input.cut(); + return Ok(TuiReadOutcome::TeamToggle); + } + _ => {} + } + } + + match key.code { + KeyCode::Enter => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.input.insert_newline(); + return Ok(TuiReadOutcome::Pending); + } + let lines = self.input.lines(); + let text = lines.join("\n"); + self.input.select_all(); + self.input.cut(); + if text.trim().is_empty() { + return Ok(TuiReadOutcome::Pending); + } + Ok(TuiReadOutcome::Submit(text)) + } + KeyCode::Tab => { + self.handle_tab(); + Ok(TuiReadOutcome::Pending) + } + KeyCode::Esc => { + self.showing_completions = false; + Ok(TuiReadOutcome::Cancel) + } + KeyCode::PageUp => { + self.conversation_scroll = self.conversation_scroll.saturating_sub(5); + Ok(TuiReadOutcome::Pending) + } + KeyCode::PageDown => { + self.conversation_scroll = self.conversation_scroll.saturating_add(5); + Ok(TuiReadOutcome::Pending) + } + _ => { + self.showing_completions = false; + self.input.input(key); + Ok(TuiReadOutcome::Pending) + } + } + } + + fn handle_tab(&mut self) { + if !self.showing_completions { + let current_text: String = self.input.lines().join(""); + if current_text.starts_with('/') { + let prefix = current_text.as_str(); + let matches: Vec<&String> = self + .slash_completions + .iter() + .filter(|c| c.starts_with(prefix)) + .collect(); + if matches.len() == 1 { + self.input.select_all(); + self.input.cut(); + for ch in matches[0].chars() { + self.input.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); + } + } +} + +// --------------------------------------------------------------------------- +// Read outcome +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TuiReadOutcome { + Pending, + Submit(String), + Cancel, + Exit, + ProviderSwap, + TeamToggle, +} + +// --------------------------------------------------------------------------- +// Standalone draw function +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn draw_frame( + f: &mut Frame, + dashboard: &SharedDashboardState, + conversation: &[ConversationLine], + conversation_scroll: u16, + input_text: &str, + input_lines: &[String], + slash_completions: &[String], + completion_index: usize, + showing_completions: bool, + spinner_frame: usize, +) { + let size = f.area(); + let main_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(40), Constraint::Length(36)]) + .split(size); + + draw_left_pane( + f, + main_layout[0], + conversation, + conversation_scroll, + input_text, + input_lines, + slash_completions, + completion_index, + showing_completions, + ); + draw_right_pane(f, main_layout[1], dashboard, spinner_frame); +} + +#[allow(clippy::too_many_arguments)] +fn draw_left_pane( + f: &mut Frame, + area: Rect, + conversation: &[ConversationLine], + conversation_scroll: u16, + input_text: &str, + input_lines: &[String], + slash_completions: &[String], + completion_index: usize, + showing_completions: bool, +) { + let left_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(7)]) + .split(area); + + let conv_lines: Vec = conversation + .iter() + .map(|line| { + let mut style = Style::default().fg(line.color); + if line.bold { + style = style.add_modifier(Modifier::BOLD); + } + Line::from(Span::styled(&line.text, style)) + }) + .collect(); + + let conversation_widget = Paragraph::new(conv_lines) + .block( + Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled( + " Conversation ", + Style::default().fg(Color::DarkGray), + )), + ) + .wrap(Wrap { trim: true }) + .scroll((conversation_scroll, 0)); + + f.render_widget(conversation_widget, left_layout[0]); + + // Input area with cursor indicator + let input_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(Span::styled( + " > ", + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + )); + let display_text = format!("{input_text}\u{2588}"); + let input_para = Paragraph::new(display_text).block(input_block); + f.render_widget(input_para, left_layout[1]); + + // Completion popup + if showing_completions { + let current_text: String = input_lines.join(""); + let matches: Vec<&String> = slash_completions + .iter() + .filter(|c| c.starts_with(current_text.as_str())) + .collect(); + if !matches.is_empty() { + let items: Vec = matches + .iter() + .enumerate() + .map(|(i, m)| { + let style = if i == completion_index % matches.len() { + Style::default().bg(Color::DarkGray).fg(Color::White) + } else { + Style::default().fg(Color::Gray) + }; + 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(Color::DarkGray)), + ); + let popup_area = Rect { + x: left_layout[1].x, + y: left_layout[1].y.saturating_sub(matches.len().min(8) as u16 + 2), + width: left_layout[1].width.min(40), + height: (matches.len() as u16 + 2).min(10), + }; + f.render_widget(list, popup_area); + } + } +} + +fn draw_right_pane( + f: &mut Frame, + area: Rect, + dashboard: &SharedDashboardState, + spinner_frame: usize, +) { + let state = dashboard.read().unwrap_or_else(|e| e.into_inner()); + let mut lines: Vec = Vec::new(); + + // Connection + lines.push(section("Connection")); + lines.push(kv("Model", &state.model, Color::White)); + lines.push(kv("Provider", &state.provider, Color::Gray)); + lines.push(kv("URL", &state.provider_url, Color::DarkGray)); + lines.push(kv("Mode", &state.permission_mode, Color::Yellow)); + if let Some(ref branch) = state.git_branch { + lines.push(kv("Branch", branch, Color::Green)); + } + lines.push(Line::from("")); + + // Tokens + lines.push(section("Tokens")); + lines.push(kv("Turns", &state.turn_count.to_string(), Color::White)); + lines.push(kv("Input", &state.input_tokens.to_string(), Color::White)); + lines.push(kv("Output", &state.output_tokens.to_string(), Color::White)); + lines.push(kv("Cache R", &state.cache_read_tokens.to_string(), Color::Gray)); + lines.push(kv("Cache W", &state.cache_creation_tokens.to_string(), Color::Gray)); + lines.push(kv("Cost", &format!("${:.4}", state.cost_usd), Color::Yellow)); + lines.push(Line::from("")); + + // Context + let pct = state.context_percent; + let gauge_color = if pct > 80.0 { + Color::Red + } else if pct > 50.0 { + Color::Yellow + } else { + Color::Green + }; + lines.push(section("Context")); + lines.push(kv( + "Used", + &format!("{:.1}% of {}", pct, state.context_window), + Color::White, + )); + lines.push(Line::from("")); // gauge placeholder + lines.push(kv( + "Compactions", + &state.compaction_count.to_string(), + Color::Gray, + )); + lines.push(Line::from("")); + + // LSP + if !state.lsp_servers.is_empty() { + lines.push(section("LSP")); + for lsp in &state.lsp_servers { + let status_color = match lsp.status.as_str() { + "connected" => Color::Green, + "starting" => Color::Yellow, + _ => Color::Red, + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", lsp.language), Style::default().fg(Color::White)), + Span::styled(lsp.status.clone(), Style::default().fg(status_color)), + ])); + } + lines.push(Line::from("")); + } + + // Team + if let Some(ref team) = state.team { + lines.push(section("Team")); + lines.push(kv("Name", &team.team_name, Color::White)); + lines.push(Line::from(vec![ + Span::styled(" Progress ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{}/{} done", team.completed_agents, team.total_agents), + Style::default().fg(Color::Green), + ), + Span::styled( + format!(", {} fail", team.failed_agents), + Style::default().fg(Color::Red), + ), + Span::styled( + format!(", {} run", team.running_agents), + Style::default().fg(Color::Cyan), + ), + ])); + for agent in &team.agents { + let st_color = match agent.status.as_str() { + "completed" => Color::Green, + "failed" => Color::Red, + _ => Color::Cyan, + }; + lines.push(Line::from(vec![ + Span::styled(" ● ", Style::default().fg(st_color)), + Span::styled(&agent.name, Style::default().fg(Color::White)), + Span::styled( + format!(" ({})", agent.subagent_type.as_deref().unwrap_or("?")), + Style::default().fg(Color::Gray), + ), + ])); + } + lines.push(Line::from("")); + } + + // Session + lines.push(section("Session")); + lines.push(kv( + "ID", + state.session_id.as_deref().unwrap_or("-"), + Color::Gray, + )); + + // Spinner + if !state.status_message.is_empty() { + let frame = SPINNER_FRAMES[spinner_frame]; + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("{frame} {}", state.status_message), + Style::default().fg(Color::Blue), + ))); + } + + // Keys + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "─ Keys ─", + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from(Span::styled( + " Enter Submit Shift+Enter Newline", + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from(Span::styled( + " ^P Swap ^T Team ^C Cancel ^D Exit", + Style::default().fg(Color::DarkGray), + ))); + + let dashboard_widget = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::LEFT) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled( + " Dashboard ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + ) + .wrap(Wrap { trim: true }); + + f.render_widget(dashboard_widget, area); + + // Context gauge overlay + let gauge_y = area.y + 16; + let gauge_area = Rect { + x: area.x + 2, + y: gauge_y, + width: area.width.saturating_sub(4), + height: 1, + }; + let gauge = Gauge::default() + .gauge_style(Style::default().fg(gauge_color).bg(Color::DarkGray)) + .ratio(if pct > 0.0 { (pct / 100.0).min(1.0) } else { 0.0 }); + f.render_widget(gauge, gauge_area); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn section<'a>(label: &str) -> Line<'a> { + Line::from(Span::styled( + format!("─ {label} ─"), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + )) +} + +fn kv<'a>(key: &str, val: &str, val_color: Color) -> Line<'a> { + Line::from(vec![ + Span::styled(format!(" {key} "), Style::default().fg(Color::DarkGray)), + Span::styled(val.to_string(), Style::default().fg(val_color)), + ]) +}