feat(keymap): central keyboard routing system
Replace 53 inline KeyCode match arms with a KEYMAP table. Exact (context, code, mods) lookup; Action enum per screen. Fixes Ctrl+Left/Right word-jump (was dead code — shadowed). 5 audit tests including no_duplicate_bindings.
This commit is contained in:
parent
61bf34177e
commit
1b69648ba5
2 changed files with 638 additions and 310 deletions
312
src/ui/app/keymap.rs
Normal file
312
src/ui/app/keymap.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
//! Central keyboard map for the tuie app.
|
||||
//!
|
||||
//! Every binding lives in [`KEYMAP`] as a `(KeyContext, KeyCode, mods) ->
|
||||
//! Action` row. Dispatch computes the current [`KeyContext`] from app state,
|
||||
//! looks up the [`Action`] by *exact* `(code, mods)` match, and the action
|
||||
//! handler in `app/mod.rs` runs the semantics — which may still branch on app
|
||||
//! state (Presence `Esc` interrupts or leaves depending on posture; Chat `Esc`
|
||||
//! depends on `busy`/overlay).
|
||||
//!
|
||||
//! Why a table instead of inline `match` arms:
|
||||
//! * Lookup is exact on `(code, mods)`, so a plain key and its modified
|
||||
//! sibling are *distinct rows*. The old `KeyCode::Left` arm shadowing a
|
||||
//! later `KeyCode::Left if CONTROL` arm — silently killing Ctrl+Left word
|
||||
//! jump — is now structurally impossible.
|
||||
//! * [`tests::no_duplicate_bindings`] fails the build if any context binds the
|
||||
//! same `(code, mods)` twice. Accidental overlap can't ship.
|
||||
//!
|
||||
//! Scope: covers Welcome, Presence, Chat (+ its modal sub-contexts), and the
|
||||
//! agent manager. Setup/Splash/Cron/Settings keep their own handlers for now
|
||||
//! (form-style text editing); folding them in is a follow-up.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
/// The modal context a key is interpreted in. Finer-grained than `Screen`
|
||||
/// because Chat has several modal overlays that capture keys differently.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyContext {
|
||||
/// Home screen menu.
|
||||
Welcome,
|
||||
/// Fullscreen "be with her" voice mode.
|
||||
Presence,
|
||||
/// Agent manager card grid.
|
||||
AgentsManager,
|
||||
/// Plain back-to-welcome screens (Therapy, AgentTime).
|
||||
GenericBack,
|
||||
/// Chat is selected but no `ChatState` is connected yet.
|
||||
ChatDisconnected,
|
||||
/// Normal chat text input.
|
||||
Chat,
|
||||
/// `/btw` fork pane is showing.
|
||||
ChatBtw,
|
||||
/// Slash-command completion overlay (falls through to `Chat` on miss).
|
||||
ChatSlashComplete,
|
||||
/// Conversation picker overlay (fully modal — no fall-through).
|
||||
ChatConvPicker,
|
||||
/// Esc interrupt-or-leave overlay shown while a turn is running.
|
||||
ChatEscOverlay,
|
||||
}
|
||||
|
||||
/// A semantic intent resolved from a keypress. The handler owns the behavior;
|
||||
/// several actions stay state-dependent on purpose (see module docs).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
// Global-ish
|
||||
Quit,
|
||||
Back,
|
||||
|
||||
// Welcome
|
||||
MenuUp,
|
||||
MenuDown,
|
||||
MenuSelect,
|
||||
CycleAgent,
|
||||
OpenPresence,
|
||||
OpenManager,
|
||||
|
||||
// Presence
|
||||
PresenceEsc,
|
||||
PresenceRecordToggle,
|
||||
PresenceExit,
|
||||
PresenceReplay,
|
||||
PresenceRegen,
|
||||
PresenceSave,
|
||||
|
||||
// Agent manager
|
||||
ManagerLeft,
|
||||
ManagerRight,
|
||||
ManagerUp,
|
||||
ManagerDown,
|
||||
ManagerSelect,
|
||||
ManagerPin,
|
||||
|
||||
// Chat — input editing
|
||||
Submit,
|
||||
InsertNewline,
|
||||
Backspace,
|
||||
CharLeft,
|
||||
CharRight,
|
||||
WordLeft,
|
||||
WordRight,
|
||||
LineHome,
|
||||
LineEnd,
|
||||
|
||||
// Chat — global
|
||||
ToggleCockpit,
|
||||
ToggleToolCards,
|
||||
PasteImage,
|
||||
ChatEsc,
|
||||
|
||||
// Chat — overlays
|
||||
OverlayUp,
|
||||
OverlayDown,
|
||||
OverlayAccept,
|
||||
OverlayCancel,
|
||||
PickerNewConversation,
|
||||
|
||||
// Chat — /btw pane
|
||||
BtwDismiss,
|
||||
BtwJump,
|
||||
|
||||
// Chat — esc overlay
|
||||
EscOverlayResume,
|
||||
EscOverlayInterject,
|
||||
EscOverlayLeave,
|
||||
}
|
||||
|
||||
/// One row of the keymap. `mods` is stored in canonical form (see [`canon`]).
|
||||
pub struct Binding {
|
||||
pub ctx: KeyContext,
|
||||
pub code: KeyCode,
|
||||
pub mods: KeyModifiers,
|
||||
pub action: Action,
|
||||
}
|
||||
|
||||
const NONE: KeyModifiers = KeyModifiers::NONE;
|
||||
const CTRL: KeyModifiers = KeyModifiers::CONTROL;
|
||||
const SHIFT: KeyModifiers = KeyModifiers::SHIFT;
|
||||
|
||||
use Action::*;
|
||||
use KeyContext::*;
|
||||
|
||||
/// The single source of truth for every keyboard binding in the covered
|
||||
/// contexts. Grouped by context; a multi-key action (e.g. `q`/`Esc`) is just
|
||||
/// several rows. Order does not matter — lookup is exact, not first-match.
|
||||
pub const KEYMAP: &[Binding] = &[
|
||||
// ── Welcome ──────────────────────────────────────────
|
||||
b(Welcome, KeyCode::Char('q'), NONE, Quit),
|
||||
b(Welcome, KeyCode::Esc, NONE, Quit),
|
||||
b(Welcome, KeyCode::Up, NONE, MenuUp),
|
||||
b(Welcome, KeyCode::Down, NONE, MenuDown),
|
||||
b(Welcome, KeyCode::Enter, NONE, MenuSelect),
|
||||
b(Welcome, KeyCode::Char('a'), NONE, CycleAgent),
|
||||
b(Welcome, KeyCode::Char('p'), NONE, OpenPresence),
|
||||
b(Welcome, KeyCode::Char('i'), NONE, OpenManager),
|
||||
|
||||
// ── Presence ─────────────────────────────────────────
|
||||
b(Presence, KeyCode::Esc, NONE, PresenceEsc),
|
||||
b(Presence, KeyCode::Char(' '), NONE, PresenceRecordToggle),
|
||||
b(Presence, KeyCode::Char('q'), NONE, PresenceExit),
|
||||
b(Presence, KeyCode::Char('r'), NONE, PresenceReplay),
|
||||
b(Presence, KeyCode::Char('g'), NONE, PresenceRegen),
|
||||
b(Presence, KeyCode::Char('s'), NONE, PresenceSave),
|
||||
|
||||
// ── Agent manager ────────────────────────────────────
|
||||
b(AgentsManager, KeyCode::Char('q'), NONE, Back),
|
||||
b(AgentsManager, KeyCode::Esc, NONE, Back),
|
||||
b(AgentsManager, KeyCode::Char('i'), NONE, Back),
|
||||
b(AgentsManager, KeyCode::Left, NONE, ManagerLeft),
|
||||
b(AgentsManager, KeyCode::Right, NONE, ManagerRight),
|
||||
b(AgentsManager, KeyCode::Up, NONE, ManagerUp),
|
||||
b(AgentsManager, KeyCode::Down, NONE, ManagerDown),
|
||||
b(AgentsManager, KeyCode::Enter, NONE, ManagerSelect),
|
||||
b(AgentsManager, KeyCode::Char('f'), NONE, ManagerPin),
|
||||
|
||||
// ── Generic back-to-welcome screens ──────────────────
|
||||
b(GenericBack, KeyCode::Char('q'), NONE, Back),
|
||||
b(GenericBack, KeyCode::Esc, NONE, Back),
|
||||
b(GenericBack, KeyCode::Char('m'), NONE, Back),
|
||||
|
||||
// ── Chat, not yet connected ──────────────────────────
|
||||
b(ChatDisconnected, KeyCode::Esc, NONE, Back),
|
||||
b(ChatDisconnected, KeyCode::Char('q'), NONE, Back),
|
||||
|
||||
// ── Chat /btw fork pane ──────────────────────────────
|
||||
b(ChatBtw, KeyCode::Esc, NONE, BtwDismiss),
|
||||
b(ChatBtw, KeyCode::Char('q'), NONE, BtwDismiss),
|
||||
b(ChatBtw, KeyCode::Char('j'), NONE, BtwJump),
|
||||
|
||||
// ── Chat slash-completion overlay (misses fall through to Chat) ──
|
||||
b(ChatSlashComplete, KeyCode::Up, NONE, OverlayUp),
|
||||
b(ChatSlashComplete, KeyCode::Down, NONE, OverlayDown),
|
||||
b(ChatSlashComplete, KeyCode::Tab, NONE, OverlayAccept),
|
||||
b(ChatSlashComplete, KeyCode::Enter, NONE, OverlayAccept),
|
||||
b(ChatSlashComplete, KeyCode::Esc, NONE, OverlayCancel),
|
||||
|
||||
// ── Chat conversation picker (fully modal) ───────────
|
||||
b(ChatConvPicker, KeyCode::Up, NONE, OverlayUp),
|
||||
b(ChatConvPicker, KeyCode::Char('k'), NONE, OverlayUp),
|
||||
b(ChatConvPicker, KeyCode::Down, NONE, OverlayDown),
|
||||
b(ChatConvPicker, KeyCode::Char('j'), NONE, OverlayDown),
|
||||
b(ChatConvPicker, KeyCode::Enter, NONE, OverlayAccept),
|
||||
b(ChatConvPicker, KeyCode::Char('n'), NONE, PickerNewConversation),
|
||||
b(ChatConvPicker, KeyCode::Esc, NONE, OverlayCancel),
|
||||
|
||||
// ── Chat esc overlay (interrupt-or-leave, shown while busy) ──
|
||||
b(ChatEscOverlay, KeyCode::Esc, NONE, EscOverlayResume),
|
||||
b(ChatEscOverlay, KeyCode::Char('c'), NONE, EscOverlayResume),
|
||||
b(ChatEscOverlay, KeyCode::Char('i'), NONE, EscOverlayInterject),
|
||||
b(ChatEscOverlay, KeyCode::Char('m'), NONE, EscOverlayLeave),
|
||||
|
||||
// ── Chat, normal text input ──────────────────────────
|
||||
// Unmatched keys here fall back to inserting the character (handled by the
|
||||
// dispatcher), so plain printable chars deliberately have no rows.
|
||||
b(Chat, KeyCode::Esc, NONE, ChatEsc),
|
||||
b(Chat, KeyCode::Enter, SHIFT, InsertNewline),
|
||||
b(Chat, KeyCode::Char('j'), CTRL, InsertNewline),
|
||||
b(Chat, KeyCode::Enter, NONE, Submit),
|
||||
b(Chat, KeyCode::Backspace, NONE, Backspace),
|
||||
b(Chat, KeyCode::Left, NONE, CharLeft),
|
||||
b(Chat, KeyCode::Right, NONE, CharRight),
|
||||
b(Chat, KeyCode::Left, CTRL, WordLeft),
|
||||
b(Chat, KeyCode::Right, CTRL, WordRight),
|
||||
b(Chat, KeyCode::Home, NONE, LineHome),
|
||||
b(Chat, KeyCode::End, NONE, LineEnd),
|
||||
b(Chat, KeyCode::Tab, NONE, ToggleCockpit),
|
||||
b(Chat, KeyCode::Char('c'), CTRL, Quit),
|
||||
b(Chat, KeyCode::Char('t'), CTRL, ToggleToolCards),
|
||||
// Ctrl+Shift+V — `Char('V')` is the shifted glyph; canon keeps CTRL only.
|
||||
b(Chat, KeyCode::Char('V'), CTRL, PasteImage),
|
||||
];
|
||||
|
||||
/// Const-fn row constructor so [`KEYMAP`] stays a `const`.
|
||||
const fn b(ctx: KeyContext, code: KeyCode, mods: KeyModifiers, action: Action) -> Binding {
|
||||
Binding { ctx, code, mods, action }
|
||||
}
|
||||
|
||||
/// Reduce raw modifiers to the bits the keymap compares on.
|
||||
///
|
||||
/// For `Char` codes the SHIFT bit is dropped — case is already encoded in the
|
||||
/// glyph (`v` vs `V`), so a shifted letter must not look different from a typed
|
||||
/// capital. For every other code SHIFT is meaningful (e.g. Shift+Enter) and
|
||||
/// kept. Modifiers we never bind on (SUPER, HYPER, META) are discarded.
|
||||
pub fn canon(code: KeyCode, mods: KeyModifiers) -> KeyModifiers {
|
||||
let mut m = mods & (CTRL | KeyModifiers::ALT | SHIFT);
|
||||
if matches!(code, KeyCode::Char(_)) {
|
||||
m.remove(SHIFT);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Resolve a keypress to an [`Action`] in the given context, or `None` if the
|
||||
/// context doesn't bind it. A `None` is the caller's cue to apply the context's
|
||||
/// fallback (insert the char in Chat, exit-if-idle in Presence, etc.).
|
||||
pub fn resolve(ctx: KeyContext, code: KeyCode, mods: KeyModifiers) -> Option<Action> {
|
||||
let want = canon(code, mods);
|
||||
KEYMAP
|
||||
.iter()
|
||||
.find(|bnd| bnd.ctx == ctx && bnd.code == code && bnd.mods == want)
|
||||
.map(|bnd| bnd.action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// No context may bind the same `(code, canon mods)` twice — that's the
|
||||
/// accidental-overlap class of bug, caught at build time.
|
||||
#[test]
|
||||
fn no_duplicate_bindings() {
|
||||
let mut seen: Vec<(KeyContext, KeyCode, KeyModifiers)> = Vec::new();
|
||||
for bnd in KEYMAP {
|
||||
let key = (bnd.ctx, bnd.code, canon(bnd.code, bnd.mods));
|
||||
assert!(
|
||||
!seen.contains(&key),
|
||||
"duplicate binding: {:?} {:?} {:?}",
|
||||
bnd.ctx,
|
||||
bnd.code,
|
||||
bnd.mods
|
||||
);
|
||||
seen.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Table rows must already be canonical — otherwise `resolve` (which
|
||||
/// canonicalizes the *incoming* key) could never match them.
|
||||
#[test]
|
||||
fn rows_are_canonical() {
|
||||
for bnd in KEYMAP {
|
||||
assert_eq!(
|
||||
bnd.mods,
|
||||
canon(bnd.code, bnd.mods),
|
||||
"non-canonical mods on {:?} {:?}",
|
||||
bnd.ctx,
|
||||
bnd.code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression that motivated the table: a plain arrow and its Ctrl
|
||||
/// sibling must resolve to different actions.
|
||||
#[test]
|
||||
fn ctrl_arrows_are_distinct() {
|
||||
assert_eq!(resolve(Chat, KeyCode::Left, NONE), Some(CharLeft));
|
||||
assert_eq!(resolve(Chat, KeyCode::Left, CTRL), Some(WordLeft));
|
||||
assert_eq!(resolve(Chat, KeyCode::Right, NONE), Some(CharRight));
|
||||
assert_eq!(resolve(Chat, KeyCode::Right, CTRL), Some(WordRight));
|
||||
}
|
||||
|
||||
/// A shifted capital `V` inserts; only Ctrl+(Shift+)V pastes.
|
||||
#[test]
|
||||
fn shift_v_inserts_ctrl_v_pastes() {
|
||||
assert_eq!(resolve(Chat, KeyCode::Char('V'), SHIFT), None);
|
||||
assert_eq!(resolve(Chat, KeyCode::Char('V'), CTRL), Some(PasteImage));
|
||||
assert_eq!(resolve(Chat, KeyCode::Char('V'), CTRL | SHIFT), Some(PasteImage));
|
||||
}
|
||||
|
||||
/// Shift+Enter and plain Enter are different actions.
|
||||
#[test]
|
||||
fn shift_enter_is_newline() {
|
||||
assert_eq!(resolve(Chat, KeyCode::Enter, NONE), Some(Submit));
|
||||
assert_eq!(resolve(Chat, KeyCode::Enter, SHIFT), Some(InsertNewline));
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ mod images;
|
|||
mod dashboard;
|
||||
mod agents;
|
||||
mod settings_handler;
|
||||
mod keymap;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
|
|
@ -30,7 +31,7 @@ use ratatui::{
|
|||
Frame,
|
||||
};
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
|
|
@ -55,7 +56,6 @@ use figlet_rs::FIGlet;
|
|||
|
||||
pub struct App {
|
||||
current_screen: Screen,
|
||||
splash_start: Instant,
|
||||
menu_selected: usize,
|
||||
/// Setup wizard state (None = wizard not active).
|
||||
setup_state: Option<SetupState>,
|
||||
|
|
@ -234,7 +234,6 @@ impl App {
|
|||
info!("Creating Souveraine App");
|
||||
let mut app = Self {
|
||||
current_screen: Screen::Splash,
|
||||
splash_start: Instant::now(),
|
||||
menu_selected: 0,
|
||||
setup_state: None,
|
||||
welcome_hint: None,
|
||||
|
|
@ -499,11 +498,6 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
if self.current_screen == Screen::Splash {
|
||||
if self.splash_start.elapsed() > Duration::from_secs(8) {
|
||||
self.transition_from_splash().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disable_raw_mode()?;
|
||||
|
|
@ -519,9 +513,15 @@ impl App {
|
|||
}
|
||||
|
||||
async fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||
use keymap::{Action, KeyContext};
|
||||
|
||||
// Screens with their own bespoke handlers (form text editing, async
|
||||
// transitions) keep their routing; everything else flows through the
|
||||
// central keymap (see `keymap.rs`).
|
||||
match self.current_screen {
|
||||
Screen::Splash => {
|
||||
self.transition_from_splash().await;
|
||||
return;
|
||||
}
|
||||
Screen::Setup => {
|
||||
// Capture the setup_complete and step_was_welcome flags before
|
||||
|
|
@ -542,19 +542,61 @@ impl App {
|
|||
if self.setup_state.as_ref().map(|s| s.complete).unwrap_or(false) {
|
||||
self.finish_setup().await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
Screen::Welcome => {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
|
||||
KeyCode::Up => if self.menu_selected > 0 { self.menu_selected -= 1; }
|
||||
KeyCode::Down => if self.menu_selected < 4 { self.menu_selected += 1; }
|
||||
KeyCode::Enter => self.select_menu_item().await,
|
||||
KeyCode::Char('a') => {
|
||||
Screen::Chat => {
|
||||
self.handle_chat_key(key).await;
|
||||
return;
|
||||
}
|
||||
Screen::Cron => {
|
||||
self.handle_schedules_key(key);
|
||||
return;
|
||||
}
|
||||
Screen::Settings => {
|
||||
self.handle_settings_key(key).await;
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Keymap-driven screens.
|
||||
let ctx = match self.current_screen {
|
||||
Screen::Welcome => KeyContext::Welcome,
|
||||
Screen::Presence => KeyContext::Presence,
|
||||
Screen::AgentsManager => KeyContext::AgentsManager,
|
||||
_ => KeyContext::GenericBack,
|
||||
};
|
||||
let action = keymap::resolve(ctx, key.code, key.modifiers);
|
||||
match (ctx, action) {
|
||||
(KeyContext::Welcome, Some(a)) => self.welcome_action(a).await,
|
||||
(KeyContext::Presence, Some(a)) => self.presence_action(a).await,
|
||||
(KeyContext::Presence, None) => {
|
||||
// Unbound key while Idle and silent: treat as "go back."
|
||||
if self.presence.posture == Posture::Idle && self.voice_capture.is_none() {
|
||||
self.exit_presence();
|
||||
}
|
||||
}
|
||||
(KeyContext::AgentsManager, Some(a)) => self.manager_action(a).await,
|
||||
(KeyContext::GenericBack, Some(Action::Back)) => {
|
||||
self.current_screen = Screen::Welcome;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn welcome_action(&mut self, action: keymap::Action) {
|
||||
use keymap::Action;
|
||||
match action {
|
||||
Action::Quit => self.should_quit = true,
|
||||
Action::MenuUp => if self.menu_selected > 0 { self.menu_selected -= 1; },
|
||||
Action::MenuDown => if self.menu_selected < 4 { self.menu_selected += 1; },
|
||||
Action::MenuSelect => self.select_menu_item().await,
|
||||
Action::CycleAgent => {
|
||||
// WIP: Create agent alias - this will be expanded with a full agent creation flow
|
||||
// For now, cycle through available agents or create a default
|
||||
self.cycle_agent_selection();
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
Action::OpenPresence => {
|
||||
// Presence mode — sit with her, voice loop if enabled.
|
||||
// Chat must be initialized so voice submissions route through
|
||||
// the agent via the same path as typed messages.
|
||||
|
|
@ -571,16 +613,16 @@ impl App {
|
|||
self.current_screen = Screen::Presence;
|
||||
self.dispatch(TuiEvent::ScreenChanged(Screen::Presence));
|
||||
}
|
||||
KeyCode::Char('i') => {
|
||||
self.open_agent_manager().await;
|
||||
}
|
||||
Action::OpenManager => self.open_agent_manager().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Screen::Presence => {
|
||||
match key.code {
|
||||
|
||||
async fn presence_action(&mut self, action: keymap::Action) {
|
||||
use keymap::Action;
|
||||
match action {
|
||||
// Esc: interrupt Speaking → Idle; or exit Presence when Idle.
|
||||
KeyCode::Esc => {
|
||||
Action::PresenceEsc => {
|
||||
if self.presence.posture == Posture::Speaking {
|
||||
if let Some(player) = &self.voice_player {
|
||||
player.stop();
|
||||
|
|
@ -605,7 +647,7 @@ impl App {
|
|||
// Space: tap-to-record. First press opens mic, second press
|
||||
// closes and sends. Esc cancels. No release events needed —
|
||||
// terminals drop them.
|
||||
KeyCode::Char(' ') => {
|
||||
Action::PresenceRecordToggle => {
|
||||
if self.presence.posture == Posture::Listening {
|
||||
// Already recording — second press: send.
|
||||
self.handle_presence_space_release().await;
|
||||
|
|
@ -620,10 +662,9 @@ impl App {
|
|||
self.start_listening();
|
||||
}
|
||||
}
|
||||
// Any other key exits Presence (meditative mode).
|
||||
KeyCode::Char('q') => self.exit_presence(),
|
||||
Action::PresenceExit => self.exit_presence(),
|
||||
// Vocal Recall keys: r = replay, g = regen, s = save
|
||||
KeyCode::Char('r') => {
|
||||
Action::PresenceReplay => {
|
||||
if let Some(bytes) = self.voice_last_tts_bytes.clone() {
|
||||
if let Some(player) = &self.voice_player {
|
||||
player.stop();
|
||||
|
|
@ -632,13 +673,13 @@ impl App {
|
|||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
Action::PresenceRegen => {
|
||||
if let Some(text) = self.voice_last_tts_text.clone() {
|
||||
self.voice_last_synthesized = None; // force re-synth
|
||||
self.tts_last_text = Some(text); // stash for the pipeline
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
Action::PresenceSave => {
|
||||
if let (Some(bytes), Some(text)) = (self.voice_last_tts_bytes.as_ref(), self.voice_last_tts_text.as_ref()) {
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||
let filename = format!("voice-recording-{}.mp3", timestamp);
|
||||
|
|
@ -650,47 +691,37 @@ impl App {
|
|||
let _ = text;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// If Idle and no voice activity, treat as "go back."
|
||||
if self.presence.posture == Posture::Idle
|
||||
&& self.voice_capture.is_none()
|
||||
{
|
||||
self.exit_presence();
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Screen::Chat => self.handle_chat_key(key).await,
|
||||
Screen::Cron => self.handle_schedules_key(key),
|
||||
Screen::Settings => self.handle_settings_key(key).await,
|
||||
Screen::AgentsManager => {
|
||||
|
||||
async fn manager_action(&mut self, action: keymap::Action) {
|
||||
use keymap::Action;
|
||||
let n = self.agent_cards.len();
|
||||
let cols = self.manager_cols.max(1);
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('i') => {
|
||||
self.current_screen = Screen::Welcome;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
match action {
|
||||
Action::Back => self.current_screen = Screen::Welcome,
|
||||
Action::ManagerLeft => {
|
||||
if self.manager_selected > 0 {
|
||||
self.manager_selected -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
Action::ManagerRight => {
|
||||
if n > 0 && self.manager_selected + 1 < n {
|
||||
self.manager_selected += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
Action::ManagerUp => {
|
||||
if self.manager_selected >= cols {
|
||||
self.manager_selected -= cols;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
Action::ManagerDown => {
|
||||
if n > 0 && self.manager_selected + cols < n {
|
||||
self.manager_selected += cols;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
Action::ManagerSelect => {
|
||||
if let Some(card) = self.agent_cards.get(self.manager_selected) {
|
||||
let name = card.name.clone();
|
||||
self.select_agent(&name);
|
||||
|
|
@ -699,7 +730,7 @@ impl App {
|
|||
}
|
||||
}
|
||||
// f — pin/star selected card as favorite primary without leaving the manager
|
||||
KeyCode::Char('f') => {
|
||||
Action::ManagerPin => {
|
||||
if let Some(card) = self.agent_cards.get(self.manager_selected) {
|
||||
let name = card.name.clone();
|
||||
self.select_agent(&name);
|
||||
|
|
@ -709,38 +740,53 @@ impl App {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => {
|
||||
self.current_screen = Screen::Welcome;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_chat_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||
use crate::ui::chat::Overlay;
|
||||
use keymap::{Action, KeyContext};
|
||||
|
||||
let Some(chat) = self.chat.as_mut() else {
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
|
||||
// Chat selected but no session yet — only "go back" is bound.
|
||||
if self.chat.is_none() {
|
||||
if let Some(Action::Back) =
|
||||
keymap::resolve(KeyContext::ChatDisconnected, key.code, key.modifiers)
|
||||
{
|
||||
self.current_screen = Screen::Welcome;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the active modal sub-context. Precedence matches the old
|
||||
// nested guards: /btw pane > overlay > esc-overlay > normal input.
|
||||
let ctx = {
|
||||
let chat = self.chat.as_ref().unwrap();
|
||||
if chat.btw_active() {
|
||||
KeyContext::ChatBtw
|
||||
} else {
|
||||
match &chat.overlay {
|
||||
Overlay::SlashComplete { .. } => KeyContext::ChatSlashComplete,
|
||||
Overlay::ConversationPicker { .. } => KeyContext::ChatConvPicker,
|
||||
Overlay::None => {
|
||||
if chat.show_esc_overlay && chat.busy {
|
||||
KeyContext::ChatEscOverlay
|
||||
} else {
|
||||
KeyContext::Chat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// BtwPane key routing — when a /btw fork pane is showing, Esc
|
||||
// dismisses it and 'j' jumps to the forked conversation.
|
||||
if chat.btw_active() {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
chat.btw_dismiss();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('j') => {
|
||||
let action = keymap::resolve(ctx, key.code, key.modifiers);
|
||||
|
||||
match ctx {
|
||||
// ── /btw fork pane ───────────────────────────────
|
||||
KeyContext::ChatBtw => {
|
||||
let chat = self.chat.as_mut().unwrap();
|
||||
match action {
|
||||
Some(Action::BtwDismiss) => chat.btw_dismiss(),
|
||||
Some(Action::BtwJump) => {
|
||||
if let Some(forked_id) = chat.btw_jump() {
|
||||
// Switch to the forked conversation
|
||||
// Switch to the forked conversation.
|
||||
let backend = chat.backend.clone();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
chat.switch_rx = Some(rx);
|
||||
|
|
@ -756,125 +802,112 @@ impl App {
|
|||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay key routing — when an overlay is active, it captures
|
||||
// navigation keys. Other keys fall through to normal handling.
|
||||
if chat.overlay_active() {
|
||||
match &chat.overlay {
|
||||
Overlay::SlashComplete { selected, matches } => {
|
||||
// ── Slash-command completion (misses type a char) ─
|
||||
KeyContext::ChatSlashComplete => match action {
|
||||
Some(Action::OverlayUp) => {
|
||||
let chat = self.chat.as_mut().unwrap();
|
||||
if let Overlay::SlashComplete { selected, matches } = &chat.overlay {
|
||||
let count = matches.len();
|
||||
match key.code {
|
||||
KeyCode::Up => {
|
||||
let sel = if *selected == 0 { count.saturating_sub(1) } else { selected - 1 };
|
||||
if let Overlay::SlashComplete { selected: ref mut s, .. } = chat.overlay { *s = sel; }
|
||||
return;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
}
|
||||
Some(Action::OverlayDown) => {
|
||||
let chat = self.chat.as_mut().unwrap();
|
||||
if let Overlay::SlashComplete { selected, matches } = &chat.overlay {
|
||||
let count = matches.len();
|
||||
let sel = if *selected + 1 >= count { 0 } else { selected + 1 };
|
||||
if let Overlay::SlashComplete { selected: ref mut s, .. } = chat.overlay { *s = sel; }
|
||||
return;
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Enter => {
|
||||
chat.accept_completion();
|
||||
return;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
chat.overlay = Overlay::None;
|
||||
return;
|
||||
}
|
||||
_ => {} // fall through to normal handling
|
||||
}
|
||||
}
|
||||
Overlay::ConversationPicker { selected, conversations } => {
|
||||
Some(Action::OverlayAccept) => self.chat.as_mut().unwrap().accept_completion(),
|
||||
Some(Action::OverlayCancel) => self.chat.as_mut().unwrap().overlay = Overlay::None,
|
||||
// Anything else falls through to normal input handling.
|
||||
_ => self.chat_input_action(key).await,
|
||||
},
|
||||
// ── Conversation picker (fully modal) ────────────
|
||||
KeyContext::ChatConvPicker => {
|
||||
let chat = self.chat.as_mut().unwrap();
|
||||
match action {
|
||||
Some(Action::OverlayUp) => {
|
||||
if let Overlay::ConversationPicker { selected, conversations } = &chat.overlay {
|
||||
let count = conversations.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let sel = if *selected == 0 { count.saturating_sub(1) } else { selected - 1 };
|
||||
if let Overlay::ConversationPicker { selected: ref mut s, .. } = chat.overlay { *s = sel; }
|
||||
return;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
}
|
||||
Some(Action::OverlayDown) => {
|
||||
if let Overlay::ConversationPicker { selected, conversations } = &chat.overlay {
|
||||
let count = conversations.len();
|
||||
let sel = if *selected + 1 >= count { 0 } else { selected + 1 };
|
||||
if let Overlay::ConversationPicker { selected: ref mut s, .. } = chat.overlay { *s = sel; }
|
||||
return;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
chat.accept_conversation_pick();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
Some(Action::OverlayAccept) => chat.accept_conversation_pick(),
|
||||
Some(Action::PickerNewConversation) => {
|
||||
// Keep the fresh conversation connect() already made.
|
||||
chat.overlay = Overlay::None;
|
||||
chat.system_message("New conversation.".to_string());
|
||||
return;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
chat.overlay = Overlay::None;
|
||||
return;
|
||||
}
|
||||
_ => return, // picker is fully modal
|
||||
Some(Action::OverlayCancel) => chat.overlay = Overlay::None,
|
||||
_ => {} // modal — swallow everything else
|
||||
}
|
||||
}
|
||||
Overlay::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Esc overlay (interrupt-or-leave) ──────────────
|
||||
if chat.show_esc_overlay && chat.busy {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('c') => {
|
||||
// ── Esc interrupt-or-leave overlay ───────────────
|
||||
KeyContext::ChatEscOverlay => match action {
|
||||
Some(Action::EscOverlayResume) => {
|
||||
// Hide overlay, stay in chat, turn keeps running.
|
||||
chat.show_esc_overlay = false;
|
||||
return;
|
||||
self.chat.as_mut().unwrap().show_esc_overlay = false;
|
||||
}
|
||||
KeyCode::Char('i') => {
|
||||
Some(Action::EscOverlayInterject) => {
|
||||
let chat = self.chat.as_mut().unwrap();
|
||||
chat.raise_hand();
|
||||
chat.show_esc_overlay = false;
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
chat.show_esc_overlay = false;
|
||||
Some(Action::EscOverlayLeave) => {
|
||||
self.chat.as_mut().unwrap().show_esc_overlay = false;
|
||||
self.current_screen = Screen::Welcome;
|
||||
return;
|
||||
}
|
||||
_ => return, // block all other keys while overlay is up
|
||||
_ => {} // block all other keys while overlay is up
|
||||
},
|
||||
// ── Normal text input ────────────────────────────
|
||||
KeyContext::Chat => self.chat_input_action(key).await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal chat key handling.
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
/// Normal chat-input handling. Unbound keys fall through to inserting the
|
||||
/// typed character — that's why printable chars have no rows in the keymap.
|
||||
async fn chat_input_action(&mut self, key: crossterm::event::KeyEvent) {
|
||||
use keymap::{Action, KeyContext};
|
||||
|
||||
let action = keymap::resolve(KeyContext::Chat, key.code, key.modifiers);
|
||||
let Some(chat) = self.chat.as_mut() else { return };
|
||||
match action {
|
||||
Some(Action::ChatEsc) => {
|
||||
if chat.busy {
|
||||
chat.show_esc_overlay = !chat.show_esc_overlay;
|
||||
} else {
|
||||
self.current_screen = Screen::Welcome;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||
Some(Action::InsertNewline) => {
|
||||
if chat.input.len() < 8_192 {
|
||||
chat.input.insert(chat.input_cursor, '\n');
|
||||
chat.input_cursor += '\n'.len_utf8();
|
||||
chat.update_completion();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if chat.input.len() < 8_192 {
|
||||
chat.input.insert(chat.input_cursor, '\n');
|
||||
chat.input_cursor += '\n'.len_utf8();
|
||||
chat.update_completion();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
Some(Action::Submit) => {
|
||||
// Submit always — when busy, this becomes an interjection
|
||||
// (queued and prepended to the agent's next LLM round).
|
||||
chat.submit();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
Some(Action::Backspace) => {
|
||||
if chat.input_cursor > 0 {
|
||||
let prev = chat.input[..chat.input_cursor]
|
||||
.char_indices()
|
||||
|
|
@ -886,8 +919,7 @@ impl App {
|
|||
chat.update_completion();
|
||||
}
|
||||
}
|
||||
// Arrow-key navigation within the input field.
|
||||
KeyCode::Left => {
|
||||
Some(Action::CharLeft) => {
|
||||
if chat.input_cursor > 0 {
|
||||
chat.input_cursor = chat.input[..chat.input_cursor]
|
||||
.char_indices()
|
||||
|
|
@ -896,21 +928,14 @@ impl App {
|
|||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
Some(Action::CharRight) => {
|
||||
if chat.input_cursor < chat.input.len() {
|
||||
if let Some(c) = chat.input[chat.input_cursor..].chars().next() {
|
||||
chat.input_cursor += c.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Home => {
|
||||
chat.input_cursor = 0;
|
||||
}
|
||||
KeyCode::End => {
|
||||
chat.input_cursor = chat.input.len();
|
||||
}
|
||||
// Word-boundary navigation: Ctrl+Left / Ctrl+Right.
|
||||
KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(Action::WordLeft) => {
|
||||
// Step back to start of current or previous word.
|
||||
let before = &chat.input[..chat.input_cursor];
|
||||
let prev_word = before
|
||||
|
|
@ -923,7 +948,7 @@ impl App {
|
|||
.unwrap_or(0);
|
||||
chat.input_cursor = prev_word;
|
||||
}
|
||||
KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(Action::WordRight) => {
|
||||
// Step forward to start of next word.
|
||||
let from = &chat.input[chat.input_cursor..];
|
||||
let first_non_space = from.find(|c: char| !c.is_whitespace());
|
||||
|
|
@ -939,31 +964,22 @@ impl App {
|
|||
};
|
||||
chat.input_cursor = next_word;
|
||||
}
|
||||
// Arrow / page keys no longer scroll the message history — that
|
||||
// is mouse-wheel only now. The keys fall through as no-ops so
|
||||
// they're free for input-cursor movement later.
|
||||
KeyCode::Tab => {
|
||||
chat.toggle_cockpit();
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
// `Ctrl+t` toggles whether tool cards render collapsed or expanded.
|
||||
// Not plain `t` — that would block starting sentences with "t".
|
||||
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
chat.tool_cards_expanded = !chat.tool_cards_expanded;
|
||||
}
|
||||
// `Ctrl+Shift+V` pastes an image from the system clipboard.
|
||||
KeyCode::Char('V') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
chat.paste_clipboard_image();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
Some(Action::LineHome) => chat.input_cursor = 0,
|
||||
Some(Action::LineEnd) => chat.input_cursor = chat.input.len(),
|
||||
Some(Action::ToggleCockpit) => chat.toggle_cockpit(),
|
||||
Some(Action::Quit) => self.should_quit = true,
|
||||
Some(Action::ToggleToolCards) => chat.tool_cards_expanded = !chat.tool_cards_expanded,
|
||||
Some(Action::PasteImage) => chat.paste_clipboard_image(),
|
||||
// Unbound key → type it.
|
||||
None => {
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
if chat.input.len() < 8_192 {
|
||||
chat.input.insert(chat.input_cursor, c);
|
||||
chat.input_cursor += c.len_utf8();
|
||||
chat.update_completion();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue