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 dashboard;
mod agents; mod agents;
mod settings_handler; mod settings_handler;
mod keymap;
use std::collections::HashMap; use std::collections::HashMap;
use std::io; use std::io;
@ -30,7 +31,7 @@ use ratatui::{
Frame, Frame,
}; };
use crossterm::{ use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers}, event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
@ -55,7 +56,6 @@ use figlet_rs::FIGlet;
pub struct App { pub struct App {
current_screen: Screen, current_screen: Screen,
splash_start: Instant,
menu_selected: usize, menu_selected: usize,
/// Setup wizard state (None = wizard not active). /// Setup wizard state (None = wizard not active).
setup_state: Option<SetupState>, setup_state: Option<SetupState>,
@ -234,7 +234,6 @@ impl App {
info!("Creating Souveraine App"); info!("Creating Souveraine App");
let mut app = Self { let mut app = Self {
current_screen: Screen::Splash, current_screen: Screen::Splash,
splash_start: Instant::now(),
menu_selected: 0, menu_selected: 0,
setup_state: None, setup_state: None,
welcome_hint: 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()?; disable_raw_mode()?;
@ -519,9 +513,15 @@ impl App {
} }
async fn handle_key(&mut self, key: crossterm::event::KeyEvent) { 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 { match self.current_screen {
Screen::Splash => { Screen::Splash => {
self.transition_from_splash().await; self.transition_from_splash().await;
return;
} }
Screen::Setup => { Screen::Setup => {
// Capture the setup_complete and step_was_welcome flags before // Capture the setup_complete and step_was_welcome flags before
@ -542,339 +542,372 @@ impl App {
if self.setup_state.as_ref().map(|s| s.complete).unwrap_or(false) { if self.setup_state.as_ref().map(|s| s.complete).unwrap_or(false) {
self.finish_setup().await; self.finish_setup().await;
} }
return;
} }
Screen::Welcome => { Screen::Chat => {
match key.code { self.handle_chat_key(key).await;
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, return;
KeyCode::Up => if self.menu_selected > 0 { self.menu_selected -= 1; } }
KeyCode::Down => if self.menu_selected < 4 { self.menu_selected += 1; } Screen::Cron => {
KeyCode::Enter => self.select_menu_item().await, self.handle_schedules_key(key);
KeyCode::Char('a') => { return;
// WIP: Create agent alias - this will be expanded with a full agent creation flow }
// For now, cycle through available agents or create a default Screen::Settings => {
self.cycle_agent_selection(); self.handle_settings_key(key).await;
} return;
KeyCode::Char('p') => { }
// 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.
if self.chat.is_none() { // Keymap-driven screens.
match ChatState::connect(self.config.clone(), &self.agent_pref).await { let ctx = match self.current_screen {
Ok(c) => self.chat = Some(c), Screen::Welcome => KeyContext::Welcome,
Err(e) => { Screen::Presence => KeyContext::Presence,
tracing::warn!(error = %e, "failed to init chat for Presence"); Screen::AgentsManager => KeyContext::AgentsManager,
} _ => KeyContext::GenericBack,
} };
} let action = keymap::resolve(ctx, key.code, key.modifiers);
self.preload_agent_expressions().await; match (ctx, action) {
self.init_voice_session().await; (KeyContext::Welcome, Some(a)) => self.welcome_action(a).await,
self.current_screen = Screen::Presence; (KeyContext::Presence, Some(a)) => self.presence_action(a).await,
self.dispatch(TuiEvent::ScreenChanged(Screen::Presence)); (KeyContext::Presence, None) => {
} // Unbound key while Idle and silent: treat as "go back."
KeyCode::Char('i') => { if self.presence.posture == Posture::Idle && self.voice_capture.is_none() {
self.open_agent_manager().await; self.exit_presence();
}
_ => {}
} }
} }
Screen::Presence => { (KeyContext::AgentsManager, Some(a)) => self.manager_action(a).await,
match key.code { (KeyContext::GenericBack, Some(Action::Back)) => {
// Esc: interrupt Speaking → Idle; or exit Presence when Idle. self.current_screen = Screen::Welcome;
KeyCode::Esc => { }
if self.presence.posture == Posture::Speaking { _ => {}
if let Some(player) = &self.voice_player { }
player.stop(); }
}
self.presence.posture = Posture::Idle; async fn welcome_action(&mut self, action: keymap::Action) {
self.presence.sync_atmosphere_pub(); use keymap::Action;
} else if matches!( match action {
self.presence.posture, Action::Quit => self.should_quit = true,
Posture::Listening | Posture::Thinking | Posture::Processing Action::MenuUp => if self.menu_selected > 0 { self.menu_selected -= 1; },
) { Action::MenuDown => if self.menu_selected < 4 { self.menu_selected += 1; },
// Interrupt in-flight voice turn — drop capture, cancel pipeline. Action::MenuSelect => self.select_menu_item().await,
self.voice_capture = None; Action::CycleAgent => {
self.voice_stt_rx = None; // WIP: Create agent alias - this will be expanded with a full agent creation flow
self.voice_tts_rx = None; // For now, cycle through available agents or create a default
self.presence.posture = Posture::Idle; self.cycle_agent_selection();
self.presence.sync_atmosphere_pub(); }
} else { Action::OpenPresence => {
// Idle — exit Presence. // Presence mode — sit with her, voice loop if enabled.
self.exit_presence(); // Chat must be initialized so voice submissions route through
} // the agent via the same path as typed messages.
} if self.chat.is_none() {
// Space: tap-to-record. First press opens mic, second press match ChatState::connect(self.config.clone(), &self.agent_pref).await {
// closes and sends. Esc cancels. No release events needed — Ok(c) => self.chat = Some(c),
// terminals drop them. Err(e) => {
KeyCode::Char(' ') => { tracing::warn!(error = %e, "failed to init chat for Presence");
if self.presence.posture == Posture::Listening {
// Already recording — second press: send.
self.handle_presence_space_release().await;
} else if self.presence.posture == Posture::Speaking {
// Space during Speaking: stop, start a new listen.
if let Some(player) = &self.voice_player {
player.stop();
}
self.start_listening();
} else if self.voice_client.is_some() {
// First press: open mic.
self.start_listening();
}
}
// Any other key exits Presence (meditative mode).
KeyCode::Char('q') => self.exit_presence(),
// Vocal Recall keys: r = replay, g = regen, s = save
KeyCode::Char('r') => {
if let Some(bytes) = self.voice_last_tts_bytes.clone() {
if let Some(player) = &self.voice_player {
player.stop();
let _ = player.play_mp3(bytes);
self.presence.set_posture(Posture::Speaking);
}
}
}
KeyCode::Char('g') => {
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') => {
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);
if let Err(e) = std::fs::write(&filename, bytes) {
tracing::warn!(error = %e, "failed to save voice recording");
} else {
tracing::info!(file = %filename, "voice recording saved");
}
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();
} }
} }
} }
self.preload_agent_expressions().await;
self.init_voice_session().await;
self.current_screen = Screen::Presence;
self.dispatch(TuiEvent::ScreenChanged(Screen::Presence));
} }
Screen::Chat => self.handle_chat_key(key).await, Action::OpenManager => self.open_agent_manager().await,
Screen::Cron => self.handle_schedules_key(key), _ => {}
Screen::Settings => self.handle_settings_key(key).await, }
Screen::AgentsManager => { }
let n = self.agent_cards.len();
let cols = self.manager_cols.max(1); async fn presence_action(&mut self, action: keymap::Action) {
match key.code { use keymap::Action;
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('i') => { match action {
self.current_screen = Screen::Welcome; // Esc: interrupt Speaking → Idle; or exit Presence when Idle.
Action::PresenceEsc => {
if self.presence.posture == Posture::Speaking {
if let Some(player) = &self.voice_player {
player.stop();
} }
KeyCode::Left => { self.presence.posture = Posture::Idle;
if self.manager_selected > 0 { self.presence.sync_atmosphere_pub();
self.manager_selected -= 1; } else if matches!(
} self.presence.posture,
} Posture::Listening | Posture::Thinking | Posture::Processing
KeyCode::Right => { ) {
if n > 0 && self.manager_selected + 1 < n { // Interrupt in-flight voice turn — drop capture, cancel pipeline.
self.manager_selected += 1; self.voice_capture = None;
} self.voice_stt_rx = None;
} self.voice_tts_rx = None;
KeyCode::Up => { self.presence.posture = Posture::Idle;
if self.manager_selected >= cols { self.presence.sync_atmosphere_pub();
self.manager_selected -= cols; } else {
} // Idle — exit Presence.
} self.exit_presence();
KeyCode::Down => {
if n > 0 && self.manager_selected + cols < n {
self.manager_selected += cols;
}
}
KeyCode::Enter => {
if let Some(card) = self.agent_cards.get(self.manager_selected) {
let name = card.name.clone();
self.select_agent(&name);
self.refresh_dashboard().await;
self.current_screen = Screen::Welcome;
}
}
// f — pin/star selected card as favorite primary without leaving the manager
KeyCode::Char('f') => {
if let Some(card) = self.agent_cards.get(self.manager_selected) {
let name = card.name.clone();
self.select_agent(&name);
self.refresh_dashboard().await;
}
}
_ => {}
} }
} }
_ => { // Space: tap-to-record. First press opens mic, second press
match key.code { // closes and sends. Esc cancels. No release events needed —
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('m') => { // terminals drop them.
self.current_screen = Screen::Welcome; Action::PresenceRecordToggle => {
if self.presence.posture == Posture::Listening {
// Already recording — second press: send.
self.handle_presence_space_release().await;
} else if self.presence.posture == Posture::Speaking {
// Space during Speaking: stop, start a new listen.
if let Some(player) = &self.voice_player {
player.stop();
} }
_ => {} self.start_listening();
} else if self.voice_client.is_some() {
// First press: open mic.
self.start_listening();
} }
} }
Action::PresenceExit => self.exit_presence(),
// Vocal Recall keys: r = replay, g = regen, s = save
Action::PresenceReplay => {
if let Some(bytes) = self.voice_last_tts_bytes.clone() {
if let Some(player) = &self.voice_player {
player.stop();
let _ = player.play_mp3(bytes);
self.presence.set_posture(Posture::Speaking);
}
}
}
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
}
}
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);
if let Err(e) = std::fs::write(&filename, bytes) {
tracing::warn!(error = %e, "failed to save voice recording");
} else {
tracing::info!(file = %filename, "voice recording saved");
}
let _ = text;
}
}
_ => {}
}
}
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 action {
Action::Back => self.current_screen = Screen::Welcome,
Action::ManagerLeft => {
if self.manager_selected > 0 {
self.manager_selected -= 1;
}
}
Action::ManagerRight => {
if n > 0 && self.manager_selected + 1 < n {
self.manager_selected += 1;
}
}
Action::ManagerUp => {
if self.manager_selected >= cols {
self.manager_selected -= cols;
}
}
Action::ManagerDown => {
if n > 0 && self.manager_selected + cols < n {
self.manager_selected += cols;
}
}
Action::ManagerSelect => {
if let Some(card) = self.agent_cards.get(self.manager_selected) {
let name = card.name.clone();
self.select_agent(&name);
self.refresh_dashboard().await;
self.current_screen = Screen::Welcome;
}
}
// f — pin/star selected card as favorite primary without leaving the manager
Action::ManagerPin => {
if let Some(card) = self.agent_cards.get(self.manager_selected) {
let name = card.name.clone();
self.select_agent(&name);
self.refresh_dashboard().await;
}
}
_ => {}
} }
} }
async fn handle_chat_key(&mut self, key: crossterm::event::KeyEvent) { async fn handle_chat_key(&mut self, key: crossterm::event::KeyEvent) {
use crate::ui::chat::Overlay; use crate::ui::chat::Overlay;
use keymap::{Action, KeyContext};
let Some(chat) = self.chat.as_mut() else { // Chat selected but no session yet — only "go back" is bound.
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { if self.chat.is_none() {
if let Some(Action::Back) =
keymap::resolve(KeyContext::ChatDisconnected, key.code, key.modifiers)
{
self.current_screen = Screen::Welcome; self.current_screen = Screen::Welcome;
} }
return; 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 let action = keymap::resolve(ctx, key.code, key.modifiers);
// 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') => {
if let Some(forked_id) = chat.btw_jump() {
// Switch to the forked conversation
let backend = chat.backend.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
chat.switch_rx = Some(rx);
let _agent_id = chat.agent_id.clone();
tokio::spawn(async move {
match backend.load_conversation(&forked_id).await {
Ok(messages) => {
let _ = tx.send(Ok((forked_id, messages)));
}
Err(e) => {
let _ = tx.send(Err(e));
}
}
});
}
return;
}
_ => {}
}
}
// Overlay key routing — when an overlay is active, it captures match ctx {
// navigation keys. Other keys fall through to normal handling. // ── /btw fork pane ───────────────────────────────
if chat.overlay_active() { KeyContext::ChatBtw => {
match &chat.overlay { let chat = self.chat.as_mut().unwrap();
Overlay::SlashComplete { selected, matches } => { match action {
let count = matches.len(); Some(Action::BtwDismiss) => chat.btw_dismiss(),
match key.code { Some(Action::BtwJump) => {
KeyCode::Up => { if let Some(forked_id) = chat.btw_jump() {
let sel = if *selected == 0 { count.saturating_sub(1) } else { selected - 1 }; // Switch to the forked conversation.
if let Overlay::SlashComplete { selected: ref mut s, .. } = chat.overlay { *s = sel; } let backend = chat.backend.clone();
return; let (tx, rx) = tokio::sync::oneshot::channel();
chat.switch_rx = Some(rx);
let _agent_id = chat.agent_id.clone();
tokio::spawn(async move {
match backend.load_conversation(&forked_id).await {
Ok(messages) => {
let _ = tx.send(Ok((forked_id, messages)));
}
Err(e) => {
let _ = tx.send(Err(e));
}
}
});
} }
KeyCode::Down => { }
let sel = if *selected + 1 >= count { 0 } else { selected + 1 }; _ => {}
if let Overlay::SlashComplete { selected: ref mut s, .. } = chat.overlay { *s = sel; } }
return; }
} // ── Slash-command completion (misses type a char) ─
KeyCode::Tab | KeyCode::Enter => { KeyContext::ChatSlashComplete => match action {
chat.accept_completion(); Some(Action::OverlayUp) => {
return; let chat = self.chat.as_mut().unwrap();
} if let Overlay::SlashComplete { selected, matches } = &chat.overlay {
KeyCode::Esc => { let count = matches.len();
chat.overlay = Overlay::None; let sel = if *selected == 0 { count.saturating_sub(1) } else { selected - 1 };
return; if let Overlay::SlashComplete { selected: ref mut s, .. } = chat.overlay { *s = sel; }
}
_ => {} // fall through to normal handling
} }
} }
Overlay::ConversationPicker { selected, conversations } => { Some(Action::OverlayDown) => {
let count = conversations.len(); let chat = self.chat.as_mut().unwrap();
match key.code { if let Overlay::SlashComplete { selected, matches } = &chat.overlay {
KeyCode::Up | KeyCode::Char('k') => { 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; }
}
}
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();
let sel = if *selected == 0 { count.saturating_sub(1) } else { selected - 1 }; let sel = if *selected == 0 { count.saturating_sub(1) } else { selected - 1 };
if let Overlay::ConversationPicker { selected: ref mut s, .. } = chat.overlay { *s = sel; } 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 }; let sel = if *selected + 1 >= count { 0 } else { selected + 1 };
if let Overlay::ConversationPicker { selected: ref mut s, .. } = chat.overlay { *s = sel; } if let Overlay::ConversationPicker { selected: ref mut s, .. } = chat.overlay { *s = sel; }
return;
} }
KeyCode::Enter => {
chat.accept_conversation_pick();
return;
}
KeyCode::Char('n') => {
// 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::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());
}
Some(Action::OverlayCancel) => chat.overlay = Overlay::None,
_ => {} // modal — swallow everything else
} }
Overlay::None => {}
} }
} // ── Esc interrupt-or-leave overlay ───────────────
KeyContext::ChatEscOverlay => match action {
// ── Esc overlay (interrupt-or-leave) ────────────── Some(Action::EscOverlayResume) => {
if chat.show_esc_overlay && chat.busy {
match key.code {
KeyCode::Esc | KeyCode::Char('c') => {
// Hide overlay, stay in chat, turn keeps running. // Hide overlay, stay in chat, turn keeps running.
chat.show_esc_overlay = false; self.chat.as_mut().unwrap().show_esc_overlay = false;
return;
} }
KeyCode::Char('i') => { Some(Action::EscOverlayInterject) => {
let chat = self.chat.as_mut().unwrap();
chat.raise_hand(); chat.raise_hand();
chat.show_esc_overlay = false; chat.show_esc_overlay = false;
return;
} }
KeyCode::Char('m') => { Some(Action::EscOverlayLeave) => {
chat.show_esc_overlay = false; self.chat.as_mut().unwrap().show_esc_overlay = false;
self.current_screen = Screen::Welcome; 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. /// Normal chat-input handling. Unbound keys fall through to inserting the
match key.code { /// typed character — that's why printable chars have no rows in the keymap.
KeyCode::Esc => { 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 { if chat.busy {
chat.show_esc_overlay = !chat.show_esc_overlay; chat.show_esc_overlay = !chat.show_esc_overlay;
} else { } else {
self.current_screen = Screen::Welcome; self.current_screen = Screen::Welcome;
} }
} }
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => { Some(Action::InsertNewline) => {
if chat.input.len() < 8_192 { if chat.input.len() < 8_192 {
chat.input.insert(chat.input_cursor, '\n'); chat.input.insert(chat.input_cursor, '\n');
chat.input_cursor += '\n'.len_utf8(); chat.input_cursor += '\n'.len_utf8();
chat.update_completion(); chat.update_completion();
} }
} }
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(Action::Submit) => {
if chat.input.len() < 8_192 {
chat.input.insert(chat.input_cursor, '\n');
chat.input_cursor += '\n'.len_utf8();
chat.update_completion();
}
}
KeyCode::Enter => {
// Submit always — when busy, this becomes an interjection // Submit always — when busy, this becomes an interjection
// (queued and prepended to the agent's next LLM round). // (queued and prepended to the agent's next LLM round).
chat.submit(); chat.submit();
} }
KeyCode::Backspace => { Some(Action::Backspace) => {
if chat.input_cursor > 0 { if chat.input_cursor > 0 {
let prev = chat.input[..chat.input_cursor] let prev = chat.input[..chat.input_cursor]
.char_indices() .char_indices()
@ -886,8 +919,7 @@ impl App {
chat.update_completion(); chat.update_completion();
} }
} }
// Arrow-key navigation within the input field. Some(Action::CharLeft) => {
KeyCode::Left => {
if chat.input_cursor > 0 { if chat.input_cursor > 0 {
chat.input_cursor = chat.input[..chat.input_cursor] chat.input_cursor = chat.input[..chat.input_cursor]
.char_indices() .char_indices()
@ -896,21 +928,14 @@ impl App {
.unwrap_or(0); .unwrap_or(0);
} }
} }
KeyCode::Right => { Some(Action::CharRight) => {
if chat.input_cursor < chat.input.len() { if chat.input_cursor < chat.input.len() {
if let Some(c) = chat.input[chat.input_cursor..].chars().next() { if let Some(c) = chat.input[chat.input_cursor..].chars().next() {
chat.input_cursor += c.len_utf8(); chat.input_cursor += c.len_utf8();
} }
} }
} }
KeyCode::Home => { Some(Action::WordLeft) => {
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) => {
// Step back to start of current or previous word. // Step back to start of current or previous word.
let before = &chat.input[..chat.input_cursor]; let before = &chat.input[..chat.input_cursor];
let prev_word = before let prev_word = before
@ -923,7 +948,7 @@ impl App {
.unwrap_or(0); .unwrap_or(0);
chat.input_cursor = prev_word; chat.input_cursor = prev_word;
} }
KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(Action::WordRight) => {
// Step forward to start of next word. // Step forward to start of next word.
let from = &chat.input[chat.input_cursor..]; let from = &chat.input[chat.input_cursor..];
let first_non_space = from.find(|c: char| !c.is_whitespace()); let first_non_space = from.find(|c: char| !c.is_whitespace());
@ -939,29 +964,20 @@ impl App {
}; };
chat.input_cursor = next_word; chat.input_cursor = next_word;
} }
// Arrow / page keys no longer scroll the message history — that Some(Action::LineHome) => chat.input_cursor = 0,
// is mouse-wheel only now. The keys fall through as no-ops so Some(Action::LineEnd) => chat.input_cursor = chat.input.len(),
// they're free for input-cursor movement later. Some(Action::ToggleCockpit) => chat.toggle_cockpit(),
KeyCode::Tab => { Some(Action::Quit) => self.should_quit = true,
chat.toggle_cockpit(); Some(Action::ToggleToolCards) => chat.tool_cards_expanded = !chat.tool_cards_expanded,
} Some(Action::PasteImage) => chat.paste_clipboard_image(),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Unbound key → type it.
self.should_quit = true; None => {
} if let KeyCode::Char(c) = key.code {
// `Ctrl+t` toggles whether tool cards render collapsed or expanded. if chat.input.len() < 8_192 {
// Not plain `t` — that would block starting sentences with "t". chat.input.insert(chat.input_cursor, c);
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { chat.input_cursor += c.len_utf8();
chat.tool_cards_expanded = !chat.tool_cards_expanded; chat.update_completion();
} }
// `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) => {
if chat.input.len() < 8_192 {
chat.input.insert(chat.input_cursor, c);
chat.input_cursor += c.len_utf8();
chat.update_completion();
} }
} }
_ => {} _ => {}