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:
Claude 2026-06-11 19:12:36 -05:00
parent e68a80b418
commit faf2ab831b
4 changed files with 625 additions and 31 deletions

View File

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

View File

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

View File

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

View File

@ -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,
}
// ---------------------------------------------------------------------------