From 785439344ff804f70c931fcf4a2dbab026424c9c Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 16 Jun 2026 16:19:14 -0500 Subject: [PATCH] fix(tui): selectable command palette, top-commands shortcut, capture slash output - Fix palette navigation: Up/Down now move selection; only Enter runs the selected command. Previously any palette key immediately dispatched. - Add Action::TopCommandsPalette bound to Ctrl+Shift+D. It opens a filtered palette with high-stakes slash commands: /permissions, /setup, /model, /env, /plugins, /mcp, /team. - Capture stdout/stderr while running slash commands inside the TUI so /status, /cost, /memory, etc. are visible in the conversation pane. /setup also shows a confirmation message with the new provider/model. - Empty Ctrl+C opens the full command palette; with text it still clears. - Document TUI keybindings and palette behavior in USAGE.md. Co-Authored-By: Claude Fable 5 --- USAGE.md | 20 +++++ .../rusty-claude-cli/src/command_palette.rs | 51 ++++++++++-- .../rusty-claude-cli/src/keybindings.rs | 12 +++ rust/crates/rusty-claude-cli/src/main.rs | 45 ++++++++++- rust/crates/rusty-claude-cli/src/tui/app.rs | 26 ++++-- .../src/tui/components/command_palette.rs | 70 +++++++++++++--- .../src/tui/components/input_bar.rs | 79 ++++++++++++++----- .../crates/rusty-claude-cli/src/tui/legacy.rs | 15 +++- 8 files changed, 271 insertions(+), 47 deletions(-) diff --git a/USAGE.md b/USAGE.md index 873dde91..8a8f8357 100644 --- a/USAGE.md +++ b/USAGE.md @@ -673,3 +673,23 @@ Current Rust crates: - `rusty-claude-cli` - `telemetry` - `tools` + +## TUI mode (`claw /tui`) + +The terminal UI is an alternate-screen dashboard for interactive sessions. + +| Key | Action | +|-----|--------| +| `Enter` | Submit the current input | +| `Shift+Enter` | Insert a newline | +| `Ctrl+C` | Clear input; opens the command palette when input is empty | +| `Ctrl+D` | Exit the TUI | +| `Ctrl+K` | Open the full command palette ( searchable, includes every `/` command) | +| `Ctrl+Shift+D` | Open the **top / dangerous commands** palette (`/permissions`, `/setup`, `/model`, `/env`, `/plugins`, `/mcp`, `/team`) | +| `Ctrl+P` | Swap provider (runs `/setup`) | +| `Ctrl+A` | Toggle agent view | +| `Ctrl+T` | Toggle team pane | +| `Ctrl+L` | Clear conversation | +| `↑` / `↓` | Navigate palette or input history | + +In the palette, type to fuzzy-filter, use `↑`/`↓` to move, and `Enter` to run the selected slash command. Slash command output (for example `/status`, `/cost`, `/memory`) is captured and shown inside the TUI conversation pane instead of disappearing when the alternate screen returns. diff --git a/rust/crates/rusty-claude-cli/src/command_palette.rs b/rust/crates/rusty-claude-cli/src/command_palette.rs index a4bb7579..aa968bed 100644 --- a/rust/crates/rusty-claude-cli/src/command_palette.rs +++ b/rust/crates/rusty-claude-cli/src/command_palette.rs @@ -3,12 +3,27 @@ use crate::keybindings::Action; use commands::slash_command_specs; +/// Curated "top / high-stakes" slash commands shown by the Ctrl+Shift+D palette. +const TOP_COMMANDS: &[&str] = &[ + "/permissions", + "/setup", + "/model", + "/env", + "/plugins", + "/mcp", + "/team", +]; + pub struct CommandPalette { pub active: bool, pub query: String, pub entries: Vec, pub filtered: Vec, pub selected: usize, + /// Indices of the curated "top / dangerous" commands used by Ctrl+Shift+D. + top_indices: Vec, + /// When set, filtering is restricted to this subset. + restricted: Option>, } #[derive(Debug, Clone)] @@ -108,6 +123,12 @@ impl CommandPalette { }); } + let top_indices: Vec = entries + .iter() + .enumerate() + .filter(|(_, e)| TOP_COMMANDS.contains(&e.label.as_str())) + .map(|(i, _)| i) + .collect(); let filtered = (0..entries.len()).collect(); Self { active: false, @@ -115,16 +136,28 @@ impl CommandPalette { entries, filtered, selected: 0, + top_indices, + restricted: None, } } pub fn open(&mut self) { self.active = true; self.query.clear(); + self.restricted = None; self.filtered = (0..self.entries.len()).collect(); self.selected = 0; } + /// Open the palette restricted to the curated top/dangerous commands. + pub fn open_top_commands(&mut self) { + self.active = true; + self.query.clear(); + self.restricted = Some(self.top_indices.clone()); + self.filtered = self.top_indices.clone(); + self.selected = 0; + } + pub fn close(&mut self) { self.active = false; self.query.clear(); @@ -163,20 +196,24 @@ impl CommandPalette { } fn update_filter(&mut self) { + let pool: Vec = self + .restricted + .as_ref() + .cloned() + .unwrap_or_else(|| (0..self.entries.len()).collect()); + if self.query.is_empty() { - self.filtered = (0..self.entries.len()).collect(); + self.filtered = pool; } else { let q = self.query.to_lowercase(); - self.filtered = self - .entries - .iter() - .enumerate() - .filter(|(_, e)| { + self.filtered = pool + .into_iter() + .filter(|&i| { + let e = &self.entries[i]; e.label.to_lowercase().contains(&q) || e.description.to_lowercase().contains(&q) || e.category.to_lowercase().contains(&q) }) - .map(|(i, _)| i) .collect(); } self.selected = self.selected.min(self.filtered.len().saturating_sub(1)); diff --git a/rust/crates/rusty-claude-cli/src/keybindings.rs b/rust/crates/rusty-claude-cli/src/keybindings.rs index 7d1d4758..12e8e864 100644 --- a/rust/crates/rusty-claude-cli/src/keybindings.rs +++ b/rust/crates/rusty-claude-cli/src/keybindings.rs @@ -27,6 +27,8 @@ pub enum Action { CycleChatMode, /// Run a slash command by index into `commands::slash_command_specs()`. RunSlashCommand(usize), + /// Open a filtered palette with high-stakes / top slash commands. + TopCommandsPalette, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -103,6 +105,11 @@ impl KeyMap { KeyCode::Char('l'), Action::ClearConversation, ); + self.bind( + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + KeyCode::Char('D'), + Action::TopCommandsPalette, + ); self.bind(KeyModifiers::CONTROL, KeyCode::Char('h'), Action::Help); self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp); self.bind( @@ -140,6 +147,11 @@ impl KeyMap { KeyCode::Char('l'), Action::ClearConversation, ); + self.bind( + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + KeyCode::Char('D'), + Action::TopCommandsPalette, + ); self.bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help); self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp); self.bind( diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a7bb967c..ee160ca2 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -7414,8 +7414,17 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box> { // Commands that need interactive terminal access // (permissions prompts, setup wizard) leave alternate // screen and let the user interact directly. + let is_setup = matches!(command, SlashCommand::Setup); app.leave_for_turn()?; - let should_persist = cli.handle_repl_command(command)?; + + // Capture stdout/stderr so command output survives the + // re-enter into the TUI instead of being wiped by the + // alternate-screen swap. + let (result, stdout, stderr) = crate::tui::capture::capture_output(|| { + cli.handle_repl_command(command) + }); + let should_persist = result?; + app.reenter_after_turn()?; // `true` means the runtime/session was mutated @@ -7424,6 +7433,40 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box> { if should_persist { cli.persist_session()?; } + + // Push captured command output into the conversation pane. + if !stdout.is_empty() { + app.push_system_message(&crate::tui_update::strip_ansi(&stdout)); + } + if !stderr.is_empty() { + app.push_system_message(&format!( + "Error output:\n{}", + crate::tui_update::strip_ansi(&stderr) + )); + } + + // /setup doesn't print a tidy summary; give the user one. + if is_setup { + let cwd = std::env::current_dir().unwrap_or_default(); + let config = runtime::ConfigLoader::default_for(&cwd).load().ok(); + let model = config + .as_ref() + .and_then(|c| c.provider().model()) + .unwrap_or(&cli.model); + let provider = config + .as_ref() + .and_then(|c| c.provider().kind()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + std::env::var("CLAWD_PROVIDER") + .unwrap_or_else(|_| "custom".to_string()) + }); + app.push_system_message(&format!( + "Setup complete. Provider: {} Model: {}", + provider, model + )); + } + update_dashboard(&dashboard_state, &cli); let _ = app.redraw_after_turn(); continue; diff --git a/rust/crates/rusty-claude-cli/src/tui/app.rs b/rust/crates/rusty-claude-cli/src/tui/app.rs index 73a3e3ed..d5120295 100644 --- a/rust/crates/rusty-claude-cli/src/tui/app.rs +++ b/rust/crates/rusty-claude-cli/src/tui/app.rs @@ -344,9 +344,11 @@ impl TuiApp { 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); + if key.code == crossterm::event::KeyCode::Enter { + 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); @@ -370,7 +372,17 @@ impl TuiApp { self.set_turn_in_progress(true); Ok(TuiReadOutcome::Submit(text)) } - InputOutcome::Cancel => Ok(TuiReadOutcome::Cancel), + InputOutcome::Cancel => { + // Empty input + Ctrl/Cancel opens the command palette so the + // user has a quick keyboard path to every slash command. + if self.input_bar.is_empty() { + self.command_palette.open(); + self.draw_screen()?; + Ok(TuiReadOutcome::Pending) + } else { + Ok(TuiReadOutcome::Cancel) + } + } InputOutcome::Exit => { self.should_exit = true; Ok(TuiReadOutcome::Exit) @@ -391,10 +403,14 @@ impl TuiApp { self.command_palette.open(); Ok(TuiReadOutcome::Pending) } + Action::TopCommandsPalette => { + self.command_palette.open_top(); + 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 (includes /slash commands)\nCtrl+A Agents Ctrl+T Team\n"); + let msg = format!("Keybindings ({preset}):\n\nEnter Submit Shift+Enter ↵\nCtrl+C clear input (opens commands when empty) Ctrl+D Exit\nCtrl+P Swap Ctrl+K Palette (includes /slash commands)\nCtrl+Shift+D Top/dangerous commands Ctrl+A Agents Ctrl+T Team\n"); self.push_system_message(&msg); Ok(TuiReadOutcome::Pending) } diff --git a/rust/crates/rusty-claude-cli/src/tui/components/command_palette.rs b/rust/crates/rusty-claude-cli/src/tui/components/command_palette.rs index 1f15142e..680791b3 100644 --- a/rust/crates/rusty-claude-cli/src/tui/components/command_palette.rs +++ b/rust/crates/rusty-claude-cli/src/tui/components/command_palette.rs @@ -9,11 +9,11 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; +use crate::command_palette::CommandPalette; 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 { @@ -32,11 +32,18 @@ impl CommandPaletteOverlay { pub fn selected_action(&self) -> Option { self.palette.selected_action() } + + pub fn open_top(&mut self) { + self.palette.open_top_commands(); + self.dirty = true; + } } impl Component for CommandPaletteOverlay { fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) { - if !self.palette.active { return; } + if !self.palette.active { + return; + } let popup_w = (area.width * 60 / 100).min(60); let popup_h = (area.height * 50 / 100).min(20); @@ -52,7 +59,10 @@ impl Component for CommandPaletteOverlay { let mut lines: Vec = 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( + 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("")); @@ -61,14 +71,23 @@ impl Component for CommandPaletteOverlay { 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()) + ( + 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())), + 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()), + ), ])); } @@ -81,14 +100,39 @@ impl Component for CommandPaletteOverlay { } fn handle_key(&mut self, key: KeyEvent, _keymap: &KeyMap) -> bool { - if !self.palette.active { return false; } + 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 } + 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 } } diff --git a/rust/crates/rusty-claude-cli/src/tui/components/input_bar.rs b/rust/crates/rusty-claude-cli/src/tui/components/input_bar.rs index 46c38c0f..2b4f9b36 100644 --- a/rust/crates/rusty-claude-cli/src/tui/components/input_bar.rs +++ b/rust/crates/rusty-claude-cli/src/tui/components/input_bar.rs @@ -6,8 +6,8 @@ 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::widgets::{Block, Borders, List, ListItem}; use ratatui::Frame; use tui_textarea::TextArea; @@ -89,7 +89,8 @@ impl InputBar { .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_style(Style::default().fg(theme.input_fg.to_color())); self.textarea.set_cursor_style( Style::default() .fg(theme.input_cursor_fg.to_color()) @@ -213,12 +214,19 @@ impl InputBar { self.textarea.lines().join("\n") } + /// Whether the input box is empty. + pub fn is_empty(&self) -> bool { + self.textarea.lines().join("").trim().is_empty() + } + fn handle_tab(&mut self) { if !self.showing_completions { let current_text: String = self.textarea.lines().join(""); if current_text.starts_with('/') { let prefix = ¤t_text; - let matches: Vec<&String> = self.slash_completions.iter() + let matches: Vec<&String> = self + .slash_completions + .iter() .filter(|c| c.starts_with(prefix)) .collect(); if matches.len() == 1 { @@ -239,7 +247,9 @@ impl InputBar { } fn history_up(&mut self) { - if self.history.is_empty() { return; } + 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, @@ -267,21 +277,33 @@ impl InputBar { /// 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; } + if !self.showing_completions { + return; + } let current_text: String = self.textarea.lines().join(""); - let matches: Vec<&String> = self.slash_completions.iter() + let matches: Vec<&String> = self + .slash_completions + .iter() .filter(|c| c.starts_with(current_text.as_str())) .collect(); - if matches.is_empty() { return; } + if matches.is_empty() { + return; + } - let items: Vec = 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 items: Vec = 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() @@ -379,9 +401,16 @@ mod tests { // Try to submit — should be blocked let mut keymap = KeyMap::new(KeyPreset::Emacs); - let enter_key = KeyEvent::new(crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE); + let enter_key = KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ); let outcome = bar.process_key(enter_key, &mut keymap); - assert_eq!(outcome, InputOutcome::None, "Submit should return None while turn is in progress"); + assert_eq!( + outcome, + InputOutcome::None, + "Submit should return None while turn is in progress" + ); } /// Regression: Turn state is properly cleared after the turn completes. @@ -405,10 +434,17 @@ mod tests { // Should be able to submit again now let mut keymap = KeyMap::new(KeyPreset::Emacs); - let enter_key = KeyEvent::new(crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE); + let enter_key = KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ); let outcome = bar.process_key(enter_key, &mut keymap); // Either Submit or None (empty after textarea was cleared by previous submit) - assert_ne!(outcome, InputOutcome::None, "Should be able to submit after turn completes"); + assert_ne!( + outcome, + InputOutcome::None, + "Should be able to submit after turn completes" + ); } /// Regression: Completions popup is a no-op when not showing. @@ -452,7 +488,10 @@ mod tests { // Cancel let mut keymap = KeyMap::new(KeyPreset::Emacs); - let ctrl_c = KeyEvent::new(crossterm::event::KeyCode::Char('c'), crossterm::event::KeyModifiers::CONTROL); + let ctrl_c = KeyEvent::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::CONTROL, + ); let outcome = bar.process_key(ctrl_c, &mut keymap); assert_eq!(outcome, InputOutcome::Cancel); assert!(bar.dirty); diff --git a/rust/crates/rusty-claude-cli/src/tui/legacy.rs b/rust/crates/rusty-claude-cli/src/tui/legacy.rs index 126e8c03..8daccc4c 100644 --- a/rust/crates/rusty-claude-cli/src/tui/legacy.rs +++ b/rust/crates/rusty-claude-cli/src/tui/legacy.rs @@ -652,6 +652,13 @@ impl TuiApp { Ok(TuiReadOutcome::Submit(text)) } Some(Action::Cancel) => { + // Empty input + Cancel opens the command palette as a quick + // way to run slash commands like /setup or /permissions. + if self.input.lines().join("").trim().is_empty() { + self.command_palette.open(); + self.needs_redraw = true; + return Ok(TuiReadOutcome::Pending); + } self.showing_completions = false; self.input.select_all(); self.input.cut(); @@ -754,10 +761,11 @@ impl TuiApp { let mut msg = format!("Keybindings ({preset:?}):\n\n"); msg += "Enter Submit\n"; msg += "Shift+Enter Newline\n"; - msg += "Ctrl+C Cancel\n"; + msg += "Ctrl+C Cancel (open commands when empty)\n"; msg += "Ctrl+D Exit TUI\n"; msg += "Ctrl+P Swap provider\n"; msg += "Ctrl+K Command palette\n"; + msg += "Ctrl+Shift+D Top/dangerous commands\n"; msg += "Ctrl+A Agent view\n"; msg += "Ctrl+T Team toggle\n"; msg += "Ctrl+L Clear conversation\n"; @@ -823,6 +831,11 @@ impl TuiApp { self.needs_redraw = true; Ok(TuiReadOutcome::Pending) } + Action::TopCommandsPalette => { + self.command_palette.open_top_commands(); + self.needs_redraw = true; + Ok(TuiReadOutcome::Pending) + } Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView), Action::ToggleSidebar => Ok(TuiReadOutcome::Pending), Action::ClearConversation => {