Lines
87.17 %
Functions
65.38 %
Branches
100 %
//! Key-event routing.
//!
//! Rather than drive crossterm `KeyEvent` types through unit tests
//! (which would pull the terminal into test scope) we translate key
//! events into a small internal vocabulary and dispatch that. The
//! vocabulary is expressive enough to drive every interactive
//! operation in the TUI.
use crate::app::{App, Tab};
use crate::modal::{ConfigSetField, ConfigSetModal, Modal};
use crate::palette;
use crate::widgets::{EditMode, Editor, VimAction, VimMode};
use cli_core::{CommandNode, command_tree};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
Quit,
NextTab,
PreviousTab,
SelectTab(Tab),
OpenCommandLine,
CloseTopmost,
SubmitCommandLine,
InsertChar(char),
DeleteBackward,
MoveLeft,
MoveRight,
MoveHome,
MoveEnd,
KillToEnd,
KillWordBackward,
Vim(VimAction),
ToggleEditMode,
OpenHelp,
ConsoleFocus,
ConsoleBlur,
ConsoleSubmit,
ConsoleInterrupt,
ConsoleHistoryPrev,
ConsoleHistoryNext,
}
/// Apply an intent to the app. The caller (the real event loop) is
/// responsible for translating crossterm key events into intents.
pub fn apply(app: &mut App, intent: Intent) {
if handle_modal(app, intent) {
return;
if app.command_line_active {
handle_command_line(app, intent);
if app.console_input_active {
handle_console(app, intent);
handle_tab(app, intent);
/// Route input while the console is focused. `Enter` assembles the input
/// line into the pending form; a complete (balanced) form is submitted
/// to the eval, an incomplete one keeps buffering. Editing intents
/// mutate the console input editor; history keys walk prior submissions.
fn handle_console(app: &mut App, intent: Intent) {
match intent {
Intent::ConsoleBlur => app.console_input_active = false,
Intent::ConsoleSubmit => {
if let Some(form) = app.console.take_complete_form() {
app.submit_console_form(form);
Intent::ConsoleInterrupt => app.interrupt_console(),
Intent::ConsoleHistoryPrev => app.console.history_prev(),
Intent::ConsoleHistoryNext => app.console.history_next(),
Intent::InsertChar(c) => app.console.input.insert_char(c),
Intent::DeleteBackward => app.console.input.delete_backward(),
Intent::MoveLeft => app.console.input.move_left(),
Intent::MoveRight => app.console.input.move_right(),
Intent::MoveHome => app.console.input.move_home(),
Intent::MoveEnd => app.console.input.move_end(),
Intent::KillToEnd => app.console.input.kill_to_end(),
Intent::KillWordBackward => app.console.input.kill_word_backward(),
Intent::Vim(action) => app.console.input.vim_action(action),
_ => {}
fn handle_modal(app: &mut App, intent: Intent) -> bool {
if app.modals.is_empty() {
return false;
if matches!(intent, Intent::CloseTopmost) {
app.modals.pop();
return true;
if let Some(top) = app.modals.top_mut() {
apply_modal_intent(top, intent);
true
fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
let Modal::ConfigSet(form) = modal else {
};
if matches!(intent, Intent::NextTab | Intent::PreviousTab) {
form.focus = match form.focus {
ConfigSetField::Name => ConfigSetField::Value,
ConfigSetField::Value => ConfigSetField::Name,
let editor = match form.focus {
ConfigSetField::Name => &mut form.name,
ConfigSetField::Value => &mut form.value,
Intent::InsertChar(c) => editor.insert_char(c),
Intent::DeleteBackward => editor.delete_backward(),
Intent::MoveLeft => editor.move_left(),
Intent::MoveRight => editor.move_right(),
Intent::MoveHome => editor.move_home(),
Intent::MoveEnd => editor.move_end(),
Intent::KillToEnd => editor.kill_to_end(),
Intent::KillWordBackward => editor.kill_word_backward(),
Intent::Vim(action) => editor.vim_action(action),
fn handle_command_line(app: &mut App, intent: Intent) {
let editor = &mut app.command_line;
Intent::CloseTopmost => {
if app.edit_mode == EditMode::Vim && editor.vim_mode() == VimMode::Insert {
editor.enter_normal_mode();
} else {
app.command_line_active = false;
Intent::SubmitCommandLine => {
let buffer = editor.buffer().to_string();
app.close_command_line();
submit_palette(app, &buffer);
/// Parse a command-palette input and act on the resolved command.
/// Known leaves either open a matching modal form or set the status
/// with a friendly resolution trace. Unknown paths surface a clear
/// error in the status line.
fn submit_palette(app: &mut App, input: &str) {
let query = palette::parse(input);
if query.path.is_empty() {
app.set_status("");
let tree = command_tree();
match palette::resolve(&tree, &query) {
Some(node) => apply_resolved_command(app, node, &query.args),
None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
fn apply_resolved_command(app: &mut App, node: &CommandNode, args: &[(String, String)]) {
// Map recognised leaves onto TUI actions. Unknown leaves simply
// surface as status-line text for now — the runnable layer is
// wired in Phase 3 follow-up work as per-tab commands land.
match node.name.as_str() {
"set" => open_config_set_modal(app, args),
"version" => app.set_status("nomisync automation CLI carries `version` for now"),
_ => app.set_status(format!("resolved: {}", node.name)),
fn open_config_set_modal(app: &mut App, args: &[(String, String)]) {
let mut name = Editor::new(app.edit_mode);
let mut value = Editor::new(app.edit_mode);
for (k, v) in args {
match k.as_str() {
"name" => name = Editor::with_buffer(app.edit_mode, v.clone()),
"value" => value = Editor::with_buffer(app.edit_mode, v.clone()),
app.modals.push(Modal::ConfigSet(ConfigSetModal {
name,
value,
focus: ConfigSetField::Name,
}));
fn handle_tab(app: &mut App, intent: Intent) {
Intent::Quit => app.request_quit(),
Intent::NextTab => app.next_tab(),
Intent::PreviousTab => app.previous_tab(),
Intent::SelectTab(t) => app.switch_tab(t),
Intent::OpenCommandLine => app.open_command_line(),
Intent::ConsoleFocus => app.console_input_active = true,
Intent::OpenHelp => app.modals.push(Modal::Help),
Intent::ToggleEditMode => {
let next = match app.edit_mode {
EditMode::Emacs => EditMode::Vim,
EditMode::Vim => EditMode::Emacs,
app.set_edit_mode(next);
#[cfg(test)]
mod tests {
use super::*;
use sqlx::types::Uuid;
fn make_app() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
#[test]
fn quit_intent_sets_quit_flag() {
let mut app = make_app();
apply(&mut app, Intent::Quit);
assert!(app.should_quit);
fn next_tab_intent_advances_tab() {
app.active_tab = Tab::Accounts;
apply(&mut app, Intent::NextTab);
assert_eq!(app.active_tab, Tab::Transactions);
fn select_tab_intent_jumps_directly() {
apply(&mut app, Intent::SelectTab(Tab::Reports));
assert_eq!(app.active_tab, Tab::Reports);
fn open_command_line_activates_and_accepts_input() {
apply(&mut app, Intent::OpenCommandLine);
assert!(app.command_line_active);
apply(&mut app, Intent::InsertChar('v'));
apply(&mut app, Intent::InsertChar('x'));
assert_eq!(app.command_line.buffer(), "vx");
fn command_line_escape_exits_when_already_in_normal_mode() {
app.set_edit_mode(EditMode::Vim);
app.command_line.enter_normal_mode();
apply(&mut app, Intent::CloseTopmost);
assert!(!app.command_line_active);
fn command_line_escape_first_drops_vim_to_normal() {
assert_eq!(app.command_line.vim_mode(), VimMode::Insert);
assert!(
app.command_line_active,
"first Esc should keep cmdline open"
);
assert_eq!(app.command_line.vim_mode(), VimMode::Normal);
fn submit_command_line_records_buffer_and_closes() {
for c in "version".chars() {
apply(&mut app, Intent::InsertChar(c));
apply(&mut app, Intent::SubmitCommandLine);
app.status.contains("version"),
"status should report the resolved command, got {}",
app.status
fn submit_command_line_with_unknown_path_surfaces_error() {
for c in "bogus-cmd".chars() {
assert!(app.status.contains("unknown"));
fn submit_config_set_opens_form_modal() {
for c in "config set name=locale value=en".chars() {
assert!(!app.modals.is_empty());
match app.modals.top() {
Some(Modal::ConfigSet(form)) => {
assert_eq!(form.name.buffer(), "locale");
assert_eq!(form.value.buffer(), "en");
other => panic!("expected ConfigSet modal, got {other:?}"),
fn open_help_pushes_a_modal() {
apply(&mut app, Intent::OpenHelp);
assert!(matches!(app.modals.top(), Some(Modal::Help)));
fn close_topmost_pops_modal_before_touching_tabs() {
!app.should_quit,
"quit should be swallowed by the modal layer"
assert!(app.modals.is_empty());
fn toggle_edit_mode_flips_emacs_vim() {
assert_eq!(app.edit_mode, EditMode::Emacs);
apply(&mut app, Intent::ToggleEditMode);
assert_eq!(app.edit_mode, EditMode::Vim);
fn console_focus_sets_flag_and_blur_clears_it() {
app.active_tab = Tab::Console;
apply(&mut app, Intent::ConsoleFocus);
assert!(app.console_input_active);
apply(&mut app, Intent::ConsoleBlur);
assert!(!app.console_input_active);
fn console_editing_intents_mutate_input_editor() {
app.console_input_active = true;
apply(&mut app, Intent::InsertChar('('));
apply(&mut app, Intent::InsertChar('a'));
assert_eq!(app.console.input.buffer(), "(a");
apply(&mut app, Intent::DeleteBackward);
assert_eq!(app.console.input.buffer(), "(");
fn console_submit_incomplete_form_keeps_buffering() {
for c in "(list".chars() {
apply(&mut app, Intent::ConsoleSubmit);
assert_eq!(app.console.pending, "(list");
assert!(app.console.input.buffer().is_empty());
#[tokio::test]
async fn console_submit_routes_complete_form_to_echo_eval() {
use crate::tabs::nms_eval::ConsoleEval;
app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
for c in "(+ 1 2)".chars() {
tokio::task::yield_now().await;
app.drain_console();
assert!(app.console.scrollback.iter().any(|l| l == "> (+ 1 2)"));
app.console
.scrollback
.iter()
.any(|l| l.contains("(:id 0 :form (+ 1 2))"))
fn console_history_keys_navigate_prior_submissions() {
for form in ["(a)", "(b)"] {
for c in form.chars() {
apply(&mut app, Intent::ConsoleHistoryPrev);
assert_eq!(app.console.input.buffer(), "(b)");
assert_eq!(app.console.input.buffer(), "(a)");
apply(&mut app, Intent::ConsoleHistoryNext);
fn console_interrupt_without_eval_does_not_panic() {
apply(&mut app, Intent::ConsoleInterrupt);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn console_interrupt_intent_reaches_attached_eval() {
use rpc::{ScriptCtx, ScriptLimits};
use std::time::Duration;
use tokio::time::sleep;
// Unbounded fuel so the runaway loop only ever stops on the
// explicit interrupt — driving the cancel through the real event
// layer (apply -> App::interrupt_console -> eval.interrupt).
let ctx = ScriptCtx::new(Uuid::nil()).with_limits(ScriptLimits {
fuel: u64::MAX,
..ScriptLimits::default()
});
let eval =
ConsoleEval::spawn_with_ctx(&tokio::runtime::Handle::current(), ctx).expect("spawn");
app.attach_console(eval);
for c in "(do ((i 0 (+ i 1))) ((>= i 2000000000) i))".chars() {
sleep(Duration::from_millis(40)).await;
let mut interrupted = false;
for _ in 0..200 {
if app
.console
.any(|l| l.contains(":code interrupted"))
{
interrupted = true;
break;
sleep(Duration::from_millis(20)).await;
assert!(interrupted, "interrupt did not reach the eval");