Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-06-04 14:13:52 -04:00
commit 1b69648ba5
2 changed files with 638 additions and 310 deletions

312
src/ui/app/keymap.rs Normal file
View 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));
}
}

View file

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