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 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue