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:
parent
29d2df2f96
commit
785439344f
20
USAGE.md
20
USAGE.md
|
|
@ -673,3 +673,23 @@ Current Rust crates:
|
||||||
- `rusty-claude-cli`
|
- `rusty-claude-cli`
|
||||||
- `telemetry`
|
- `telemetry`
|
||||||
- `tools`
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,27 @@
|
||||||
use crate::keybindings::Action;
|
use crate::keybindings::Action;
|
||||||
use commands::slash_command_specs;
|
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 struct CommandPalette {
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
pub query: String,
|
pub query: String,
|
||||||
pub entries: Vec<PaletteEntry>,
|
pub entries: Vec<PaletteEntry>,
|
||||||
pub filtered: Vec<usize>,
|
pub filtered: Vec<usize>,
|
||||||
pub selected: 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)]
|
#[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();
|
let filtered = (0..entries.len()).collect();
|
||||||
Self {
|
Self {
|
||||||
active: false,
|
active: false,
|
||||||
|
|
@ -115,16 +136,28 @@ impl CommandPalette {
|
||||||
entries,
|
entries,
|
||||||
filtered,
|
filtered,
|
||||||
selected: 0,
|
selected: 0,
|
||||||
|
top_indices,
|
||||||
|
restricted: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open(&mut self) {
|
pub fn open(&mut self) {
|
||||||
self.active = true;
|
self.active = true;
|
||||||
self.query.clear();
|
self.query.clear();
|
||||||
|
self.restricted = None;
|
||||||
self.filtered = (0..self.entries.len()).collect();
|
self.filtered = (0..self.entries.len()).collect();
|
||||||
self.selected = 0;
|
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) {
|
pub fn close(&mut self) {
|
||||||
self.active = false;
|
self.active = false;
|
||||||
self.query.clear();
|
self.query.clear();
|
||||||
|
|
@ -163,20 +196,24 @@ impl CommandPalette {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_filter(&mut self) {
|
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() {
|
if self.query.is_empty() {
|
||||||
self.filtered = (0..self.entries.len()).collect();
|
self.filtered = pool;
|
||||||
} else {
|
} else {
|
||||||
let q = self.query.to_lowercase();
|
let q = self.query.to_lowercase();
|
||||||
self.filtered = self
|
self.filtered = pool
|
||||||
.entries
|
.into_iter()
|
||||||
.iter()
|
.filter(|&i| {
|
||||||
.enumerate()
|
let e = &self.entries[i];
|
||||||
.filter(|(_, e)| {
|
|
||||||
e.label.to_lowercase().contains(&q)
|
e.label.to_lowercase().contains(&q)
|
||||||
|| e.description.to_lowercase().contains(&q)
|
|| e.description.to_lowercase().contains(&q)
|
||||||
|| e.category.to_lowercase().contains(&q)
|
|| e.category.to_lowercase().contains(&q)
|
||||||
})
|
})
|
||||||
.map(|(i, _)| i)
|
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
self.selected = self.selected.min(self.filtered.len().saturating_sub(1));
|
self.selected = self.selected.min(self.filtered.len().saturating_sub(1));
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ pub enum Action {
|
||||||
CycleChatMode,
|
CycleChatMode,
|
||||||
/// Run a slash command by index into `commands::slash_command_specs()`.
|
/// Run a slash command by index into `commands::slash_command_specs()`.
|
||||||
RunSlashCommand(usize),
|
RunSlashCommand(usize),
|
||||||
|
/// Open a filtered palette with high-stakes / top slash commands.
|
||||||
|
TopCommandsPalette,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -103,6 +105,11 @@ impl KeyMap {
|
||||||
KeyCode::Char('l'),
|
KeyCode::Char('l'),
|
||||||
Action::ClearConversation,
|
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::CONTROL, KeyCode::Char('h'), Action::Help);
|
||||||
self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp);
|
self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp);
|
||||||
self.bind(
|
self.bind(
|
||||||
|
|
@ -140,6 +147,11 @@ impl KeyMap {
|
||||||
KeyCode::Char('l'),
|
KeyCode::Char('l'),
|
||||||
Action::ClearConversation,
|
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::F(1), Action::Help);
|
||||||
self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp);
|
self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp);
|
||||||
self.bind(
|
self.bind(
|
||||||
|
|
|
||||||
|
|
@ -7414,8 +7414,17 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Commands that need interactive terminal access
|
// Commands that need interactive terminal access
|
||||||
// (permissions prompts, setup wizard) leave alternate
|
// (permissions prompts, setup wizard) leave alternate
|
||||||
// screen and let the user interact directly.
|
// screen and let the user interact directly.
|
||||||
|
let is_setup = matches!(command, SlashCommand::Setup);
|
||||||
app.leave_for_turn()?;
|
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()?;
|
app.reenter_after_turn()?;
|
||||||
|
|
||||||
// `true` means the runtime/session was mutated
|
// `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 {
|
if should_persist {
|
||||||
cli.persist_session()?;
|
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);
|
update_dashboard(&dashboard_state, &cli);
|
||||||
let _ = app.redraw_after_turn();
|
let _ = app.redraw_after_turn();
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -344,9 +344,11 @@ impl TuiApp {
|
||||||
if self.command_palette.is_active() {
|
if self.command_palette.is_active() {
|
||||||
let consumed = self.command_palette.handle_key(key, &self.keymap);
|
let consumed = self.command_palette.handle_key(key, &self.keymap);
|
||||||
if consumed {
|
if consumed {
|
||||||
if let Some(action) = self.command_palette.selected_action() {
|
if key.code == crossterm::event::KeyCode::Enter {
|
||||||
self.command_palette.close();
|
if let Some(action) = self.command_palette.selected_action() {
|
||||||
return self.dispatch_action(action);
|
self.command_palette.close();
|
||||||
|
return self.dispatch_action(action);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.draw_screen()?;
|
self.draw_screen()?;
|
||||||
return Ok(TuiReadOutcome::Pending);
|
return Ok(TuiReadOutcome::Pending);
|
||||||
|
|
@ -370,7 +372,17 @@ impl TuiApp {
|
||||||
self.set_turn_in_progress(true);
|
self.set_turn_in_progress(true);
|
||||||
Ok(TuiReadOutcome::Submit(text))
|
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 => {
|
InputOutcome::Exit => {
|
||||||
self.should_exit = true;
|
self.should_exit = true;
|
||||||
Ok(TuiReadOutcome::Exit)
|
Ok(TuiReadOutcome::Exit)
|
||||||
|
|
@ -391,10 +403,14 @@ impl TuiApp {
|
||||||
self.command_palette.open();
|
self.command_palette.open();
|
||||||
Ok(TuiReadOutcome::Pending)
|
Ok(TuiReadOutcome::Pending)
|
||||||
}
|
}
|
||||||
|
Action::TopCommandsPalette => {
|
||||||
|
self.command_palette.open_top();
|
||||||
|
Ok(TuiReadOutcome::Pending)
|
||||||
|
}
|
||||||
Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
|
Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
|
||||||
Action::Help => {
|
Action::Help => {
|
||||||
let preset = self.key_preset_name().to_string();
|
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);
|
self.push_system_message(&msg);
|
||||||
Ok(TuiReadOutcome::Pending)
|
Ok(TuiReadOutcome::Pending)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::command_palette::CommandPalette;
|
||||||
use crate::keybindings::{Action, KeyMap};
|
use crate::keybindings::{Action, KeyMap};
|
||||||
use crate::theme::TuiTheme;
|
use crate::theme::TuiTheme;
|
||||||
use crate::tui::component::{Component, Overlay};
|
use crate::tui::component::{Component, Overlay};
|
||||||
use crate::tui::event::TuiEvent;
|
use crate::tui::event::TuiEvent;
|
||||||
use crate::command_palette::CommandPalette;
|
|
||||||
|
|
||||||
/// Command palette overlay — wraps the existing CommandPalette.
|
/// Command palette overlay — wraps the existing CommandPalette.
|
||||||
pub struct CommandPaletteOverlay {
|
pub struct CommandPaletteOverlay {
|
||||||
|
|
@ -32,11 +32,18 @@ impl CommandPaletteOverlay {
|
||||||
pub fn selected_action(&self) -> Option<Action> {
|
pub fn selected_action(&self) -> Option<Action> {
|
||||||
self.palette.selected_action()
|
self.palette.selected_action()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn open_top(&mut self) {
|
||||||
|
self.palette.open_top_commands();
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for CommandPaletteOverlay {
|
impl Component for CommandPaletteOverlay {
|
||||||
fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
|
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_w = (area.width * 60 / 100).min(60);
|
||||||
let popup_h = (area.height * 50 / 100).min(20);
|
let popup_h = (area.height * 50 / 100).min(20);
|
||||||
|
|
@ -52,7 +59,10 @@ impl Component for CommandPaletteOverlay {
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled("🔍 ", Style::default().fg(theme.key_hint.to_color())),
|
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())),
|
Span::styled("█", Style::default().fg(theme.input_cursor_bg.to_color())),
|
||||||
]));
|
]));
|
||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
|
|
@ -61,14 +71,23 @@ impl Component for CommandPaletteOverlay {
|
||||||
let entry = &self.palette.entries[idx];
|
let entry = &self.palette.entries[idx];
|
||||||
let is_sel = i == self.palette.selected;
|
let is_sel = i == self.palette.selected;
|
||||||
let (fg, bg) = if is_sel {
|
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 {
|
} else {
|
||||||
(theme.completion_fg.to_color(), ratatui::style::Color::Reset)
|
(theme.completion_fg.to_color(), ratatui::style::Color::Reset)
|
||||||
};
|
};
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(format!(" {} ", entry.label), Style::default().fg(fg).bg(bg)),
|
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(
|
||||||
Span::styled(&entry.key_hint, Style::default().fg(theme.key_hint.to_color())),
|
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 {
|
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 {
|
match key.code {
|
||||||
KeyCode::Esc => { self.palette.close(); self.dirty = true; true }
|
KeyCode::Esc => {
|
||||||
KeyCode::Enter => { self.dirty = true; true }
|
self.palette.close();
|
||||||
KeyCode::Up => { self.palette.select_prev(); self.dirty = true; true }
|
self.dirty = true;
|
||||||
KeyCode::Down => { self.palette.select_next(); self.dirty = true; true }
|
true
|
||||||
KeyCode::Backspace => { self.palette.backspace(); self.dirty = true; true }
|
}
|
||||||
KeyCode::Char(c) => { self.palette.input(c); 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
|
_ => true, // Consume all keys when active
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem};
|
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use tui_textarea::TextArea;
|
use tui_textarea::TextArea;
|
||||||
|
|
||||||
|
|
@ -89,7 +89,8 @@ impl InputBar {
|
||||||
.border_style(Style::default().fg(theme.input_border.to_color()))
|
.border_style(Style::default().fg(theme.input_border.to_color()))
|
||||||
.title(" > "),
|
.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(
|
self.textarea.set_cursor_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.input_cursor_fg.to_color())
|
.fg(theme.input_cursor_fg.to_color())
|
||||||
|
|
@ -213,12 +214,19 @@ impl InputBar {
|
||||||
self.textarea.lines().join("\n")
|
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) {
|
fn handle_tab(&mut self) {
|
||||||
if !self.showing_completions {
|
if !self.showing_completions {
|
||||||
let current_text: String = self.textarea.lines().join("");
|
let current_text: String = self.textarea.lines().join("");
|
||||||
if current_text.starts_with('/') {
|
if current_text.starts_with('/') {
|
||||||
let prefix = ¤t_text;
|
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))
|
.filter(|c| c.starts_with(prefix))
|
||||||
.collect();
|
.collect();
|
||||||
if matches.len() == 1 {
|
if matches.len() == 1 {
|
||||||
|
|
@ -239,7 +247,9 @@ impl InputBar {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn history_up(&mut self) {
|
fn history_up(&mut self) {
|
||||||
if self.history.is_empty() { return; }
|
if self.history.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let new_idx = match self.history_index {
|
let new_idx = match self.history_index {
|
||||||
Some(i) => i.saturating_add(1).min(self.history.len() - 1),
|
Some(i) => i.saturating_add(1).min(self.history.len() - 1),
|
||||||
None => 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).
|
/// Render the completions popup (called after the main input render).
|
||||||
fn render_completions(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
|
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 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()))
|
.filter(|c| c.starts_with(current_text.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
if matches.is_empty() { return; }
|
if matches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let items: Vec<ListItem> = matches.iter().enumerate().map(|(i, m)| {
|
let items: Vec<ListItem> = matches
|
||||||
let style = if i == self.completion_index % matches.len() {
|
.iter()
|
||||||
Style::default().bg(theme.completion_selected_bg.to_color()).fg(theme.completion_selected_fg.to_color())
|
.enumerate()
|
||||||
} else {
|
.map(|(i, m)| {
|
||||||
Style::default().fg(theme.completion_fg.to_color())
|
let style = if i == self.completion_index % matches.len() {
|
||||||
};
|
Style::default()
|
||||||
ListItem::new(Line::from(Span::styled(m.as_str(), style)))
|
.bg(theme.completion_selected_bg.to_color())
|
||||||
}).collect();
|
.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(
|
let list = List::new(items).block(
|
||||||
Block::default()
|
Block::default()
|
||||||
|
|
@ -379,9 +401,16 @@ mod tests {
|
||||||
|
|
||||||
// Try to submit — should be blocked
|
// Try to submit — should be blocked
|
||||||
let mut keymap = KeyMap::new(KeyPreset::Emacs);
|
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);
|
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.
|
/// Regression: Turn state is properly cleared after the turn completes.
|
||||||
|
|
@ -405,10 +434,17 @@ mod tests {
|
||||||
|
|
||||||
// Should be able to submit again now
|
// Should be able to submit again now
|
||||||
let mut keymap = KeyMap::new(KeyPreset::Emacs);
|
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);
|
let outcome = bar.process_key(enter_key, &mut keymap);
|
||||||
// Either Submit or None (empty after textarea was cleared by previous submit)
|
// 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.
|
/// Regression: Completions popup is a no-op when not showing.
|
||||||
|
|
@ -452,7 +488,10 @@ mod tests {
|
||||||
|
|
||||||
// Cancel
|
// Cancel
|
||||||
let mut keymap = KeyMap::new(KeyPreset::Emacs);
|
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);
|
let outcome = bar.process_key(ctrl_c, &mut keymap);
|
||||||
assert_eq!(outcome, InputOutcome::Cancel);
|
assert_eq!(outcome, InputOutcome::Cancel);
|
||||||
assert!(bar.dirty);
|
assert!(bar.dirty);
|
||||||
|
|
|
||||||
|
|
@ -652,6 +652,13 @@ impl TuiApp {
|
||||||
Ok(TuiReadOutcome::Submit(text))
|
Ok(TuiReadOutcome::Submit(text))
|
||||||
}
|
}
|
||||||
Some(Action::Cancel) => {
|
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.showing_completions = false;
|
||||||
self.input.select_all();
|
self.input.select_all();
|
||||||
self.input.cut();
|
self.input.cut();
|
||||||
|
|
@ -754,10 +761,11 @@ impl TuiApp {
|
||||||
let mut msg = format!("Keybindings ({preset:?}):\n\n");
|
let mut msg = format!("Keybindings ({preset:?}):\n\n");
|
||||||
msg += "Enter Submit\n";
|
msg += "Enter Submit\n";
|
||||||
msg += "Shift+Enter Newline\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+D Exit TUI\n";
|
||||||
msg += "Ctrl+P Swap provider\n";
|
msg += "Ctrl+P Swap provider\n";
|
||||||
msg += "Ctrl+K Command palette\n";
|
msg += "Ctrl+K Command palette\n";
|
||||||
|
msg += "Ctrl+Shift+D Top/dangerous commands\n";
|
||||||
msg += "Ctrl+A Agent view\n";
|
msg += "Ctrl+A Agent view\n";
|
||||||
msg += "Ctrl+T Team toggle\n";
|
msg += "Ctrl+T Team toggle\n";
|
||||||
msg += "Ctrl+L Clear conversation\n";
|
msg += "Ctrl+L Clear conversation\n";
|
||||||
|
|
@ -823,6 +831,11 @@ impl TuiApp {
|
||||||
self.needs_redraw = true;
|
self.needs_redraw = true;
|
||||||
Ok(TuiReadOutcome::Pending)
|
Ok(TuiReadOutcome::Pending)
|
||||||
}
|
}
|
||||||
|
Action::TopCommandsPalette => {
|
||||||
|
self.command_palette.open_top_commands();
|
||||||
|
self.needs_redraw = true;
|
||||||
|
Ok(TuiReadOutcome::Pending)
|
||||||
|
}
|
||||||
Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
|
Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
|
||||||
Action::ToggleSidebar => Ok(TuiReadOutcome::Pending),
|
Action::ToggleSidebar => Ok(TuiReadOutcome::Pending),
|
||||||
Action::ClearConversation => {
|
Action::ClearConversation => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue