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 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-06-16 16:19:14 -05:00
parent 29d2df2f96
commit 785439344f
8 changed files with 271 additions and 47 deletions

View File

@ -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.

View File

@ -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<PaletteEntry>,
pub filtered: Vec<usize>,
pub selected: usize,
/// Indices of the curated "top / dangerous" commands used by Ctrl+Shift+D.
top_indices: Vec<usize>,
/// When set, filtering is restricted to this subset.
restricted: Option<Vec<usize>>,
}
#[derive(Debug, Clone)]
@ -108,6 +123,12 @@ impl CommandPalette {
});
}
let top_indices: Vec<usize> = 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<usize> = 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));

View File

@ -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(

View File

@ -7414,8 +7414,17 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
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;

View File

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

View File

@ -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<Action> {
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<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(
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
}
}

View File

@ -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 = &current_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<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 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()
@ -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);

View File

@ -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 => {