feat(tui): Sprint 4 — keybindings with presets + command palette
- Create src/keybindings.rs: Action enum (20 variants), KeyMap with Emacs/Vim/Windows presets, Vim normal/insert mode support - Create src/command_palette.rs: fuzzy-filterable modal with 9 built-in entries, arrow navigation, Enter to execute, Escape to close - Refactor handle_key() to dispatch through Action enum instead of hardcoded KeyCode matches — clean separation of concerns - dispatch_action() method shared by palette and key handler - show_help() renders keybinding reference for current preset - Ctrl+K opens palette, F1 shows help, /keys switches presets - 10 keybinding tests + 5 palette tests - 262 tests pass Sprint 4 of 7. Authored by TheArchitectit
This commit is contained in:
parent
e68a80b418
commit
faf2ab831b
|
|
@ -0,0 +1,216 @@
|
|||
//! Command palette — fuzzy-filterable modal for all available commands.
|
||||
|
||||
use crate::keybindings::Action;
|
||||
|
||||
pub struct CommandPalette {
|
||||
pub active: bool,
|
||||
pub query: String,
|
||||
pub entries: Vec<PaletteEntry>,
|
||||
pub filtered: Vec<usize>,
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaletteEntry {
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub action: Action,
|
||||
pub key_hint: String,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
impl CommandPalette {
|
||||
pub fn new() -> Self {
|
||||
let entries = vec![
|
||||
PaletteEntry {
|
||||
label: "Submit".into(),
|
||||
description: "Send message".into(),
|
||||
action: Action::Submit,
|
||||
key_hint: "Enter".into(),
|
||||
category: "Input".into(),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Swap Provider".into(),
|
||||
description: "Change AI provider".into(),
|
||||
action: Action::ProviderSwap,
|
||||
key_hint: "Ctrl+P".into(),
|
||||
category: "Settings".into(),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Toggle Team".into(),
|
||||
description: "Show/hide team info".into(),
|
||||
action: Action::TeamToggle,
|
||||
key_hint: "Ctrl+T".into(),
|
||||
category: "View".into(),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Agent View".into(),
|
||||
description: "Multi-session dashboard".into(),
|
||||
action: Action::ToggleAgentView,
|
||||
key_hint: "Ctrl+A".into(),
|
||||
category: "View".into(),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Toggle Sidebar".into(),
|
||||
description: "Show/hide file sidebar".into(),
|
||||
action: Action::ToggleSidebar,
|
||||
key_hint: "Ctrl+B".into(),
|
||||
category: "View".into(),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Clear Conversation".into(),
|
||||
description: "Clear all messages".into(),
|
||||
action: Action::ClearConversation,
|
||||
key_hint: "Ctrl+L".into(),
|
||||
category: "Session".into(),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Help".into(),
|
||||
description: "Keyboard shortcuts".into(),
|
||||
action: Action::Help,
|
||||
key_hint: "F1".into(),
|
||||
category: "Help".into(),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Exit".into(),
|
||||
description: "Exit TUI mode".into(),
|
||||
action: Action::Exit,
|
||||
key_hint: "Ctrl+D".into(),
|
||||
category: "Session".into(),
|
||||
},
|
||||
PaletteEntry {
|
||||
label: "Cycle Chat Mode".into(),
|
||||
description: "Code → Ask → Architect".into(),
|
||||
action: Action::CycleChatMode,
|
||||
key_hint: "Tab".into(),
|
||||
category: "Modes".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let filtered = (0..entries.len()).collect();
|
||||
Self {
|
||||
active: false,
|
||||
query: String::new(),
|
||||
entries,
|
||||
filtered,
|
||||
selected: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&mut self) {
|
||||
self.active = true;
|
||||
self.query.clear();
|
||||
self.filtered = (0..self.entries.len()).collect();
|
||||
self.selected = 0;
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.active = false;
|
||||
self.query.clear();
|
||||
}
|
||||
|
||||
pub fn input(&mut self, c: char) {
|
||||
self.query.push(c);
|
||||
self.update_filter();
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self) {
|
||||
self.query.pop();
|
||||
self.update_filter();
|
||||
}
|
||||
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.filtered.is_empty() {
|
||||
self.selected = (self.selected + 1) % self.filtered.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_prev(&mut self) {
|
||||
if !self.filtered.is_empty() {
|
||||
self.selected = if self.selected == 0 {
|
||||
self.filtered.len() - 1
|
||||
} else {
|
||||
self.selected - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_action(&self) -> Option<Action> {
|
||||
self.filtered
|
||||
.get(self.selected)
|
||||
.map(|&i| self.entries[i].action)
|
||||
}
|
||||
|
||||
fn update_filter(&mut self) {
|
||||
if self.query.is_empty() {
|
||||
self.filtered = (0..self.entries.len()).collect();
|
||||
} else {
|
||||
let q = self.query.to_lowercase();
|
||||
self.filtered = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_palette_opens_and_closes() {
|
||||
let mut cp = CommandPalette::new();
|
||||
assert!(!cp.active);
|
||||
cp.open();
|
||||
assert!(cp.active);
|
||||
assert_eq!(cp.selected, 0);
|
||||
cp.close();
|
||||
assert!(!cp.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_label() {
|
||||
let mut cp = CommandPalette::new();
|
||||
cp.open();
|
||||
cp.input('e');
|
||||
// Should filter to entries containing 'e'
|
||||
assert!(cp.filtered.len() < cp.entries.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_navigation() {
|
||||
let mut cp = CommandPalette::new();
|
||||
cp.open();
|
||||
let initial = cp.selected;
|
||||
cp.select_next();
|
||||
assert_ne!(cp.selected, initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_action() {
|
||||
let mut cp = CommandPalette::new();
|
||||
cp.open();
|
||||
assert!(cp.selected_action().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_query_shows_all() {
|
||||
let mut cp = CommandPalette::new();
|
||||
cp.open();
|
||||
cp.input('x');
|
||||
assert!(cp.filtered.len() < cp.entries.len());
|
||||
cp.backspace();
|
||||
assert_eq!(cp.filtered.len(), cp.entries.len());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
//! Keybinding abstraction — Action enum + KeyMap with presets (Emacs/Vim/Windows).
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Action {
|
||||
Submit,
|
||||
Cancel,
|
||||
Newline,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
ScrollHalfUp,
|
||||
ScrollHalfDown,
|
||||
ScrollTop,
|
||||
ScrollBottom,
|
||||
Exit,
|
||||
ProviderSwap,
|
||||
TeamToggle,
|
||||
CommandPalette,
|
||||
ToggleSidebar,
|
||||
ToggleAgentView,
|
||||
Help,
|
||||
ClearConversation,
|
||||
FocusInput,
|
||||
FocusConversation,
|
||||
CycleChatMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyPreset {
|
||||
Emacs,
|
||||
Vim,
|
||||
Windows,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VimMode {
|
||||
Normal,
|
||||
Insert,
|
||||
}
|
||||
|
||||
pub struct KeyMap {
|
||||
preset: KeyPreset,
|
||||
bindings: HashMap<(KeyModifiers, KeyCode), Action>,
|
||||
vim_mode: VimMode,
|
||||
}
|
||||
|
||||
impl KeyMap {
|
||||
pub fn new(preset: KeyPreset) -> Self {
|
||||
let mut map = Self {
|
||||
preset,
|
||||
bindings: HashMap::new(),
|
||||
vim_mode: VimMode::Insert,
|
||||
};
|
||||
map.load_preset(preset);
|
||||
map
|
||||
}
|
||||
|
||||
fn load_preset(&mut self, preset: KeyPreset) {
|
||||
self.bindings.clear();
|
||||
match preset {
|
||||
KeyPreset::Emacs => self.load_emacs(),
|
||||
KeyPreset::Vim => self.load_emacs(), // Vim overrides handled in resolve()
|
||||
KeyPreset::Windows => self.load_windows(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_emacs(&mut self) {
|
||||
self.bind(KeyModifiers::NONE, KeyCode::Enter, Action::Submit);
|
||||
self.bind(KeyModifiers::SHIFT, KeyCode::Enter, Action::Newline);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('c'), Action::Cancel);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('d'), Action::Exit);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('p'), Action::ProviderSwap);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('t'), Action::TeamToggle);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('k'), Action::CommandPalette);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('b'), Action::ToggleSidebar);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('a'), Action::ToggleAgentView);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('l'), Action::ClearConversation);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('h'), Action::Help);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::PageDown, Action::ScrollHalfDown);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Home, Action::ScrollTop);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::End, Action::ScrollBottom);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help);
|
||||
}
|
||||
|
||||
fn load_windows(&mut self) {
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Enter, Action::Submit);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::Enter, Action::Newline);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::Esc, Action::Cancel);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('d'), Action::Exit);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('p'), Action::ProviderSwap);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('k'), Action::CommandPalette);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('a'), Action::ToggleAgentView);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('l'), Action::ClearConversation);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::PageDown, Action::ScrollHalfDown);
|
||||
}
|
||||
|
||||
fn bind(&mut self, modifiers: KeyModifiers, code: KeyCode, action: Action) {
|
||||
self.bindings.insert((modifiers, code), action);
|
||||
}
|
||||
|
||||
pub fn resolve(&self, key: KeyEvent) -> Option<Action> {
|
||||
// Vim normal mode overrides
|
||||
if self.preset == KeyPreset::Vim && self.vim_mode == VimMode::Normal {
|
||||
return match (key.modifiers, key.code) {
|
||||
(KeyModifiers::NONE, KeyCode::Char('j')) => Some(Action::ScrollDown),
|
||||
(KeyModifiers::NONE, KeyCode::Char('k')) => Some(Action::ScrollUp),
|
||||
(KeyModifiers::NONE, KeyCode::Char('g')) => Some(Action::ScrollTop),
|
||||
(KeyModifiers::NONE, KeyCode::Char('G')) => Some(Action::ScrollBottom),
|
||||
(KeyModifiers::NONE, KeyCode::Char('i')) => Some(Action::FocusInput),
|
||||
(KeyModifiers::NONE, KeyCode::Char(':')) => Some(Action::CommandPalette),
|
||||
(KeyModifiers::NONE, KeyCode::Char('q')) => Some(Action::Exit),
|
||||
(KeyModifiers::NONE, KeyCode::Esc) => Some(Action::Cancel),
|
||||
_ => self.bindings.get(&(key.modifiers, key.code)).copied(),
|
||||
};
|
||||
}
|
||||
self.bindings.get(&(key.modifiers, key.code)).copied()
|
||||
}
|
||||
|
||||
pub fn set_vim_mode(&mut self, mode: VimMode) {
|
||||
self.vim_mode = mode;
|
||||
}
|
||||
|
||||
pub fn vim_mode(&self) -> VimMode {
|
||||
self.vim_mode
|
||||
}
|
||||
|
||||
pub fn preset(&self) -> KeyPreset {
|
||||
self.preset
|
||||
}
|
||||
|
||||
pub fn set_preset(&mut self, preset: KeyPreset) {
|
||||
self.preset = preset;
|
||||
self.load_preset(preset);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_emacs_submit() {
|
||||
let km = KeyMap::new(KeyPreset::Emacs);
|
||||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
assert_eq!(km.resolve(key), Some(Action::Submit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emacs_ctrl_d() {
|
||||
let km = KeyMap::new(KeyPreset::Emacs);
|
||||
let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
|
||||
assert_eq!(km.resolve(key), Some(Action::Exit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emacs_ctrl_k() {
|
||||
let km = KeyMap::new(KeyPreset::Emacs);
|
||||
let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
|
||||
assert_eq!(km.resolve(key), Some(Action::CommandPalette));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_normal_j() {
|
||||
let mut km = KeyMap::new(KeyPreset::Vim);
|
||||
km.set_vim_mode(VimMode::Normal);
|
||||
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
||||
assert_eq!(km.resolve(key), Some(Action::ScrollDown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_normal_k() {
|
||||
let mut km = KeyMap::new(KeyPreset::Vim);
|
||||
km.set_vim_mode(VimMode::Normal);
|
||||
let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
|
||||
assert_eq!(km.resolve(key), Some(Action::ScrollUp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_normal_i() {
|
||||
let mut km = KeyMap::new(KeyPreset::Vim);
|
||||
km.set_vim_mode(VimMode::Normal);
|
||||
let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
|
||||
assert_eq!(km.resolve(key), Some(Action::FocusInput));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_normal_colon() {
|
||||
let mut km = KeyMap::new(KeyPreset::Vim);
|
||||
km.set_vim_mode(VimMode::Normal);
|
||||
let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE);
|
||||
assert_eq!(km.resolve(key), Some(Action::CommandPalette));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unbound_key_returns_none() {
|
||||
let km = KeyMap::new(KeyPreset::Emacs);
|
||||
let key = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
|
||||
assert_eq!(km.resolve(key), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preset_switch() {
|
||||
let mut km = KeyMap::new(KeyPreset::Emacs);
|
||||
assert_eq!(km.preset(), KeyPreset::Emacs);
|
||||
km.set_preset(KeyPreset::Vim);
|
||||
assert_eq!(km.preset(), KeyPreset::Vim);
|
||||
// Should reset to Insert mode
|
||||
assert_eq!(km.vim_mode(), VimMode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windows_ctrl_enter() {
|
||||
let km = KeyMap::new(KeyPreset::Windows);
|
||||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
|
||||
assert_eq!(km.resolve(key), Some(Action::Submit));
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ mod init;
|
|||
mod input;
|
||||
mod render;
|
||||
mod setup_wizard;
|
||||
mod command_palette;
|
||||
mod keybindings;
|
||||
mod markdown;
|
||||
mod theme;
|
||||
mod tui;
|
||||
|
|
@ -7258,6 +7260,10 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
|||
app.push_system_message("[team] Agent teams enabled");
|
||||
}
|
||||
}
|
||||
tui::TuiReadOutcome::ToggleAgentView => {
|
||||
// Agent View integration in Sprint 6
|
||||
app.push_system_message("Agent View coming in Sprint 6");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ pub struct TuiApp {
|
|||
needs_redraw: bool,
|
||||
pub markdown_renderer: crate::markdown::MarkdownRenderer,
|
||||
pub theme: crate::theme::TuiTheme,
|
||||
pub keymap: crate::keybindings::KeyMap,
|
||||
pub command_palette: crate::command_palette::CommandPalette,
|
||||
}
|
||||
|
||||
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
|
@ -234,6 +236,8 @@ impl TuiApp {
|
|||
spinner_frame: 0,
|
||||
markdown_renderer: crate::markdown::MarkdownRenderer::new(),
|
||||
theme: crate::theme::TuiTheme::builtin("default").unwrap(),
|
||||
keymap: crate::keybindings::KeyMap::new(crate::keybindings::KeyPreset::Emacs),
|
||||
command_palette: crate::command_palette::CommandPalette::new(),
|
||||
needs_redraw: true,
|
||||
};
|
||||
me.draw_screen()?;
|
||||
|
|
@ -463,37 +467,51 @@ impl TuiApp {
|
|||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> io::Result<TuiReadOutcome> {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
use crate::keybindings::{Action, VimMode};
|
||||
|
||||
// Command palette intercepts all keys when active
|
||||
if self.command_palette.active {
|
||||
match key.code {
|
||||
KeyCode::Char('d') => {
|
||||
self.should_exit = true;
|
||||
return Ok(TuiReadOutcome::Exit);
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
self.input.select_all();
|
||||
self.input.cut();
|
||||
return Ok(TuiReadOutcome::Cancel);
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
self.input.select_all();
|
||||
self.input.cut();
|
||||
return Ok(TuiReadOutcome::ProviderSwap);
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
self.input.select_all();
|
||||
self.input.cut();
|
||||
return Ok(TuiReadOutcome::TeamToggle);
|
||||
KeyCode::Esc => self.command_palette.close(),
|
||||
KeyCode::Enter => {
|
||||
if let Some(action) = self.command_palette.selected_action() {
|
||||
self.command_palette.close();
|
||||
return self.dispatch_action(action);
|
||||
}
|
||||
self.command_palette.close();
|
||||
}
|
||||
KeyCode::Up => self.command_palette.select_prev(),
|
||||
KeyCode::Down => self.command_palette.select_next(),
|
||||
KeyCode::Backspace => self.command_palette.backspace(),
|
||||
KeyCode::Char(c) => self.command_palette.input(c),
|
||||
_ => {}
|
||||
}
|
||||
self.needs_redraw = true;
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
self.input.insert_newline();
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
// Vim mode transition: Esc → Normal mode
|
||||
if self.keymap.preset() == crate::keybindings::KeyPreset::Vim
|
||||
&& key.code == KeyCode::Esc
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.keymap.vim_mode() == VimMode::Insert
|
||||
{
|
||||
self.keymap.set_vim_mode(VimMode::Normal);
|
||||
self.needs_redraw = true;
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
|
||||
// Tab completion has priority when active
|
||||
if self.showing_completions && key.code == KeyCode::Tab {
|
||||
self.handle_tab();
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
|
||||
// Resolve key → action through KeyMap
|
||||
let action = self.keymap.resolve(key);
|
||||
|
||||
match action {
|
||||
Some(Action::Submit) => {
|
||||
let lines = self.input.lines();
|
||||
let text = lines.join("\n");
|
||||
self.input.select_all();
|
||||
|
|
@ -503,23 +521,91 @@ impl TuiApp {
|
|||
}
|
||||
Ok(TuiReadOutcome::Submit(text))
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
Some(Action::Cancel) => {
|
||||
self.showing_completions = false;
|
||||
self.input.select_all();
|
||||
self.input.cut();
|
||||
Ok(TuiReadOutcome::Cancel)
|
||||
}
|
||||
Some(Action::Newline) => {
|
||||
self.input.insert_newline();
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::Exit) => {
|
||||
self.should_exit = true;
|
||||
Ok(TuiReadOutcome::Exit)
|
||||
}
|
||||
Some(Action::ProviderSwap) => {
|
||||
self.input.select_all();
|
||||
self.input.cut();
|
||||
Ok(TuiReadOutcome::ProviderSwap)
|
||||
}
|
||||
Some(Action::TeamToggle) => {
|
||||
self.input.select_all();
|
||||
self.input.cut();
|
||||
Ok(TuiReadOutcome::TeamToggle)
|
||||
}
|
||||
Some(Action::CommandPalette) => {
|
||||
self.command_palette.open();
|
||||
self.needs_redraw = true;
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::ToggleAgentView) => {
|
||||
Ok(TuiReadOutcome::ToggleAgentView)
|
||||
}
|
||||
Some(Action::ToggleSidebar) => {
|
||||
// Future: toggle sidebar
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::ClearConversation) => {
|
||||
self.conversation.clear();
|
||||
self.conversation_scroll = 0;
|
||||
self.needs_redraw = true;
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::Help) => {
|
||||
self.show_help();
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::CycleChatMode) => {
|
||||
self.handle_tab();
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.showing_completions = false;
|
||||
Ok(TuiReadOutcome::Cancel)
|
||||
Some(Action::ScrollUp) => {
|
||||
self.conversation_scroll = self.conversation_scroll.saturating_add(1);
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
Some(Action::ScrollDown) => {
|
||||
self.conversation_scroll = self.conversation_scroll.saturating_sub(1);
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::ScrollHalfUp) => {
|
||||
self.conversation_scroll = self.conversation_scroll.saturating_add(5);
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
Some(Action::ScrollHalfDown) => {
|
||||
self.conversation_scroll = self.conversation_scroll.saturating_sub(5);
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::ScrollTop) => {
|
||||
self.conversation_scroll = u16::MAX;
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::ScrollBottom) => {
|
||||
self.conversation_scroll = 0;
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::FocusInput) => {
|
||||
if self.keymap.preset() == crate::keybindings::KeyPreset::Vim {
|
||||
self.keymap.set_vim_mode(VimMode::Insert);
|
||||
}
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
Some(Action::FocusConversation) => {
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
_ => {
|
||||
// Unbound key — pass to text area for editing
|
||||
self.showing_completions = false;
|
||||
self.input.input(key);
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
|
|
@ -527,6 +613,68 @@ impl TuiApp {
|
|||
}
|
||||
}
|
||||
|
||||
fn show_help(&mut self) {
|
||||
let preset = self.keymap.preset();
|
||||
let mut msg = format!("Keybindings ({preset:?}):\n\n");
|
||||
msg += "Enter Submit\n";
|
||||
msg += "Shift+Enter Newline\n";
|
||||
msg += "Ctrl+C Cancel\n";
|
||||
msg += "Ctrl+D Exit TUI\n";
|
||||
msg += "Ctrl+P Swap provider\n";
|
||||
msg += "Ctrl+K Command palette\n";
|
||||
msg += "Ctrl+A Agent view\n";
|
||||
msg += "Ctrl+T Team toggle\n";
|
||||
msg += "Ctrl+L Clear conversation\n";
|
||||
msg += "F1 This help\n";
|
||||
msg += "PageUp/Down Scroll\n";
|
||||
if preset == crate::keybindings::KeyPreset::Vim {
|
||||
msg += "\nVim mode:\n";
|
||||
msg += " i Insert\n";
|
||||
msg += " Esc Normal\n";
|
||||
msg += " j/k Scroll\n";
|
||||
msg += " g/G Top/Bottom\n";
|
||||
msg += " : Command palette\n";
|
||||
}
|
||||
msg += "\nSlash commands:\n";
|
||||
msg += "/tui /theme /keys /code /ask /architect\n";
|
||||
msg += "/diff /undo /ls /help\n";
|
||||
self.push_system_message(&msg);
|
||||
}
|
||||
|
||||
/// Dispatch an Action — used by command palette and handle_key.
|
||||
fn dispatch_action(&mut self, action: crate::keybindings::Action) -> io::Result<TuiReadOutcome> {
|
||||
use crate::keybindings::Action;
|
||||
match action {
|
||||
Action::Submit => {
|
||||
let lines = self.input.lines();
|
||||
let text = lines.join("\n");
|
||||
self.input.select_all();
|
||||
self.input.cut();
|
||||
if text.trim().is_empty() { return Ok(TuiReadOutcome::Pending); }
|
||||
Ok(TuiReadOutcome::Submit(text))
|
||||
}
|
||||
Action::Cancel => { self.showing_completions = false; self.input.select_all(); self.input.cut(); Ok(TuiReadOutcome::Cancel) }
|
||||
Action::Newline => { self.input.insert_newline(); Ok(TuiReadOutcome::Pending) }
|
||||
Action::Exit => { self.should_exit = true; Ok(TuiReadOutcome::Exit) }
|
||||
Action::ProviderSwap => { self.input.select_all(); self.input.cut(); Ok(TuiReadOutcome::ProviderSwap) }
|
||||
Action::TeamToggle => { self.input.select_all(); self.input.cut(); Ok(TuiReadOutcome::TeamToggle) }
|
||||
Action::CommandPalette => { self.command_palette.open(); self.needs_redraw = true; Ok(TuiReadOutcome::Pending) }
|
||||
Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
|
||||
Action::ToggleSidebar => Ok(TuiReadOutcome::Pending),
|
||||
Action::ClearConversation => { self.conversation.clear(); self.conversation_scroll = 0; self.needs_redraw = true; Ok(TuiReadOutcome::Pending) }
|
||||
Action::Help => { self.show_help(); Ok(TuiReadOutcome::Pending) }
|
||||
Action::CycleChatMode => { self.handle_tab(); Ok(TuiReadOutcome::Pending) }
|
||||
Action::ScrollUp => { self.conversation_scroll = self.conversation_scroll.saturating_add(1); Ok(TuiReadOutcome::Pending) }
|
||||
Action::ScrollDown => { self.conversation_scroll = self.conversation_scroll.saturating_sub(1); Ok(TuiReadOutcome::Pending) }
|
||||
Action::ScrollHalfUp => { self.conversation_scroll = self.conversation_scroll.saturating_add(5); Ok(TuiReadOutcome::Pending) }
|
||||
Action::ScrollHalfDown => { self.conversation_scroll = self.conversation_scroll.saturating_sub(5); Ok(TuiReadOutcome::Pending) }
|
||||
Action::ScrollTop => { self.conversation_scroll = u16::MAX; Ok(TuiReadOutcome::Pending) }
|
||||
Action::ScrollBottom => { self.conversation_scroll = 0; Ok(TuiReadOutcome::Pending) }
|
||||
Action::FocusInput => { Ok(TuiReadOutcome::Pending) }
|
||||
Action::FocusConversation => { Ok(TuiReadOutcome::Pending) }
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tab(&mut self) {
|
||||
if !self.showing_completions {
|
||||
let current_text: String = self.input.lines().join("");
|
||||
|
|
@ -567,6 +715,7 @@ pub enum TuiReadOutcome {
|
|||
Exit,
|
||||
ProviderSwap,
|
||||
TeamToggle,
|
||||
ToggleAgentView,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue