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`
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue