1use crate::app::{App, Tab};
10use crate::modal::{ConfigSetField, ConfigSetModal, Modal};
11use crate::palette;
12use crate::widgets::{EditMode, Editor, VimAction, VimMode};
13use cli_core::{CommandNode, command_tree};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Intent {
17 Quit,
18 NextTab,
19 PreviousTab,
20 SelectTab(Tab),
21 OpenCommandLine,
22 CloseTopmost,
23 SubmitCommandLine,
24 InsertChar(char),
25 DeleteBackward,
26 MoveLeft,
27 MoveRight,
28 MoveHome,
29 MoveEnd,
30 KillToEnd,
31 KillWordBackward,
32 Vim(VimAction),
33 ToggleEditMode,
34 OpenHelp,
35 ConsoleFocus,
36 ConsoleBlur,
37 ConsoleSubmit,
38 ConsoleInterrupt,
39 ConsoleHistoryPrev,
40 ConsoleHistoryNext,
41}
42
43pub fn apply(app: &mut App, intent: Intent) {
46 if handle_modal(app, intent) {
47 return;
48 }
49 if app.command_line_active {
50 handle_command_line(app, intent);
51 return;
52 }
53 if app.console_input_active {
54 handle_console(app, intent);
55 return;
56 }
57 handle_tab(app, intent);
58}
59
60fn handle_console(app: &mut App, intent: Intent) {
65 match intent {
66 Intent::ConsoleBlur => app.console_input_active = false,
67 Intent::ConsoleSubmit => {
68 if let Some(form) = app.console.take_complete_form() {
69 app.submit_console_form(form);
70 }
71 }
72 Intent::ConsoleInterrupt => app.interrupt_console(),
73 Intent::ConsoleHistoryPrev => app.console.history_prev(),
74 Intent::ConsoleHistoryNext => app.console.history_next(),
75 Intent::InsertChar(c) => app.console.input.insert_char(c),
76 Intent::DeleteBackward => app.console.input.delete_backward(),
77 Intent::MoveLeft => app.console.input.move_left(),
78 Intent::MoveRight => app.console.input.move_right(),
79 Intent::MoveHome => app.console.input.move_home(),
80 Intent::MoveEnd => app.console.input.move_end(),
81 Intent::KillToEnd => app.console.input.kill_to_end(),
82 Intent::KillWordBackward => app.console.input.kill_word_backward(),
83 Intent::Vim(action) => app.console.input.vim_action(action),
84 _ => {}
85 }
86}
87
88fn handle_modal(app: &mut App, intent: Intent) -> bool {
89 if app.modals.is_empty() {
90 return false;
91 }
92 if matches!(intent, Intent::CloseTopmost) {
93 app.modals.pop();
94 return true;
95 }
96 if let Some(top) = app.modals.top_mut() {
97 apply_modal_intent(top, intent);
98 }
99 true
100}
101
102fn apply_modal_intent(modal: &mut Modal, intent: Intent) {
103 let Modal::ConfigSet(form) = modal else {
104 return;
105 };
106 if matches!(intent, Intent::NextTab | Intent::PreviousTab) {
107 form.focus = match form.focus {
108 ConfigSetField::Name => ConfigSetField::Value,
109 ConfigSetField::Value => ConfigSetField::Name,
110 };
111 return;
112 }
113 let editor = match form.focus {
114 ConfigSetField::Name => &mut form.name,
115 ConfigSetField::Value => &mut form.value,
116 };
117 match intent {
118 Intent::InsertChar(c) => editor.insert_char(c),
119 Intent::DeleteBackward => editor.delete_backward(),
120 Intent::MoveLeft => editor.move_left(),
121 Intent::MoveRight => editor.move_right(),
122 Intent::MoveHome => editor.move_home(),
123 Intent::MoveEnd => editor.move_end(),
124 Intent::KillToEnd => editor.kill_to_end(),
125 Intent::KillWordBackward => editor.kill_word_backward(),
126 Intent::Vim(action) => editor.vim_action(action),
127 _ => {}
128 }
129}
130
131fn handle_command_line(app: &mut App, intent: Intent) {
132 let editor = &mut app.command_line;
133 match intent {
134 Intent::CloseTopmost => {
135 if app.edit_mode == EditMode::Vim && editor.vim_mode() == VimMode::Insert {
136 editor.enter_normal_mode();
137 } else {
138 app.command_line_active = false;
139 }
140 }
141 Intent::SubmitCommandLine => {
142 let buffer = editor.buffer().to_string();
143 app.close_command_line();
144 submit_palette(app, &buffer);
145 }
146 Intent::InsertChar(c) => editor.insert_char(c),
147 Intent::DeleteBackward => editor.delete_backward(),
148 Intent::MoveLeft => editor.move_left(),
149 Intent::MoveRight => editor.move_right(),
150 Intent::MoveHome => editor.move_home(),
151 Intent::MoveEnd => editor.move_end(),
152 Intent::KillToEnd => editor.kill_to_end(),
153 Intent::KillWordBackward => editor.kill_word_backward(),
154 Intent::Vim(action) => editor.vim_action(action),
155 _ => {}
156 }
157}
158
159fn submit_palette(app: &mut App, input: &str) {
164 let query = palette::parse(input);
165 if query.path.is_empty() {
166 app.set_status("");
167 return;
168 }
169 let tree = command_tree();
170 match palette::resolve(&tree, &query) {
171 Some(node) => apply_resolved_command(app, node, &query.args),
172 None => app.set_status(format!("unknown command: {}", query.path.join(" "))),
173 }
174}
175
176fn apply_resolved_command(app: &mut App, node: &CommandNode, args: &[(String, String)]) {
177 match node.name.as_str() {
181 "set" => open_config_set_modal(app, args),
182 "version" => app.set_status("nomisync automation CLI carries `version` for now"),
183 _ => app.set_status(format!("resolved: {}", node.name)),
184 }
185}
186
187fn open_config_set_modal(app: &mut App, args: &[(String, String)]) {
188 let mut name = Editor::new(app.edit_mode);
189 let mut value = Editor::new(app.edit_mode);
190 for (k, v) in args {
191 match k.as_str() {
192 "name" => name = Editor::with_buffer(app.edit_mode, v.clone()),
193 "value" => value = Editor::with_buffer(app.edit_mode, v.clone()),
194 _ => {}
195 }
196 }
197 app.modals.push(Modal::ConfigSet(ConfigSetModal {
198 name,
199 value,
200 focus: ConfigSetField::Name,
201 }));
202}
203
204fn handle_tab(app: &mut App, intent: Intent) {
205 match intent {
206 Intent::Quit => app.request_quit(),
207 Intent::NextTab => app.next_tab(),
208 Intent::PreviousTab => app.previous_tab(),
209 Intent::SelectTab(t) => app.switch_tab(t),
210 Intent::OpenCommandLine => app.open_command_line(),
211 Intent::ConsoleFocus => app.console_input_active = true,
212 Intent::OpenHelp => app.modals.push(Modal::Help),
213 Intent::ToggleEditMode => {
214 let next = match app.edit_mode {
215 EditMode::Emacs => EditMode::Vim,
216 EditMode::Vim => EditMode::Emacs,
217 };
218 app.set_edit_mode(next);
219 }
220 _ => {}
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use sqlx::types::Uuid;
228
229 fn make_app() -> App {
230 App::new(Uuid::new_v4(), EditMode::Emacs)
231 }
232
233 #[test]
234 fn quit_intent_sets_quit_flag() {
235 let mut app = make_app();
236 apply(&mut app, Intent::Quit);
237 assert!(app.should_quit);
238 }
239
240 #[test]
241 fn next_tab_intent_advances_tab() {
242 let mut app = make_app();
243 app.active_tab = Tab::Accounts;
244 apply(&mut app, Intent::NextTab);
245 assert_eq!(app.active_tab, Tab::Transactions);
246 }
247
248 #[test]
249 fn select_tab_intent_jumps_directly() {
250 let mut app = make_app();
251 apply(&mut app, Intent::SelectTab(Tab::Reports));
252 assert_eq!(app.active_tab, Tab::Reports);
253 }
254
255 #[test]
256 fn open_command_line_activates_and_accepts_input() {
257 let mut app = make_app();
258 apply(&mut app, Intent::OpenCommandLine);
259 assert!(app.command_line_active);
260 apply(&mut app, Intent::InsertChar('v'));
261 apply(&mut app, Intent::InsertChar('x'));
262 assert_eq!(app.command_line.buffer(), "vx");
263 }
264
265 #[test]
266 fn command_line_escape_exits_when_already_in_normal_mode() {
267 let mut app = make_app();
268 app.set_edit_mode(EditMode::Vim);
269 apply(&mut app, Intent::OpenCommandLine);
270 app.command_line.enter_normal_mode();
271 apply(&mut app, Intent::CloseTopmost);
272 assert!(!app.command_line_active);
273 }
274
275 #[test]
276 fn command_line_escape_first_drops_vim_to_normal() {
277 let mut app = make_app();
278 app.set_edit_mode(EditMode::Vim);
279 apply(&mut app, Intent::OpenCommandLine);
280 apply(&mut app, Intent::InsertChar('x'));
281 assert_eq!(app.command_line.vim_mode(), VimMode::Insert);
282 apply(&mut app, Intent::CloseTopmost);
283 assert!(
284 app.command_line_active,
285 "first Esc should keep cmdline open"
286 );
287 assert_eq!(app.command_line.vim_mode(), VimMode::Normal);
288 }
289
290 #[test]
291 fn submit_command_line_records_buffer_and_closes() {
292 let mut app = make_app();
293 apply(&mut app, Intent::OpenCommandLine);
294 for c in "version".chars() {
295 apply(&mut app, Intent::InsertChar(c));
296 }
297 apply(&mut app, Intent::SubmitCommandLine);
298 assert!(!app.command_line_active);
299 assert!(
300 app.status.contains("version"),
301 "status should report the resolved command, got {}",
302 app.status
303 );
304 }
305
306 #[test]
307 fn submit_command_line_with_unknown_path_surfaces_error() {
308 let mut app = make_app();
309 apply(&mut app, Intent::OpenCommandLine);
310 for c in "bogus-cmd".chars() {
311 apply(&mut app, Intent::InsertChar(c));
312 }
313 apply(&mut app, Intent::SubmitCommandLine);
314 assert!(app.status.contains("unknown"));
315 }
316
317 #[test]
318 fn submit_config_set_opens_form_modal() {
319 let mut app = make_app();
320 apply(&mut app, Intent::OpenCommandLine);
321 for c in "config set name=locale value=en".chars() {
322 apply(&mut app, Intent::InsertChar(c));
323 }
324 apply(&mut app, Intent::SubmitCommandLine);
325 assert!(!app.modals.is_empty());
326 match app.modals.top() {
327 Some(Modal::ConfigSet(form)) => {
328 assert_eq!(form.name.buffer(), "locale");
329 assert_eq!(form.value.buffer(), "en");
330 }
331 other => panic!("expected ConfigSet modal, got {other:?}"),
332 }
333 }
334
335 #[test]
336 fn open_help_pushes_a_modal() {
337 let mut app = make_app();
338 apply(&mut app, Intent::OpenHelp);
339 assert!(!app.modals.is_empty());
340 assert!(matches!(app.modals.top(), Some(Modal::Help)));
341 }
342
343 #[test]
344 fn close_topmost_pops_modal_before_touching_tabs() {
345 let mut app = make_app();
346 apply(&mut app, Intent::OpenHelp);
347 apply(&mut app, Intent::Quit);
348 assert!(
349 !app.should_quit,
350 "quit should be swallowed by the modal layer"
351 );
352 apply(&mut app, Intent::CloseTopmost);
353 assert!(app.modals.is_empty());
354 apply(&mut app, Intent::Quit);
355 assert!(app.should_quit);
356 }
357
358 #[test]
359 fn toggle_edit_mode_flips_emacs_vim() {
360 let mut app = make_app();
361 assert_eq!(app.edit_mode, EditMode::Emacs);
362 apply(&mut app, Intent::ToggleEditMode);
363 assert_eq!(app.edit_mode, EditMode::Vim);
364 apply(&mut app, Intent::ToggleEditMode);
365 assert_eq!(app.edit_mode, EditMode::Emacs);
366 }
367
368 #[test]
369 fn console_focus_sets_flag_and_blur_clears_it() {
370 let mut app = make_app();
371 app.active_tab = Tab::Console;
372 apply(&mut app, Intent::ConsoleFocus);
373 assert!(app.console_input_active);
374 apply(&mut app, Intent::ConsoleBlur);
375 assert!(!app.console_input_active);
376 }
377
378 #[test]
379 fn console_editing_intents_mutate_input_editor() {
380 let mut app = make_app();
381 app.console_input_active = true;
382 apply(&mut app, Intent::InsertChar('('));
383 apply(&mut app, Intent::InsertChar('a'));
384 assert_eq!(app.console.input.buffer(), "(a");
385 apply(&mut app, Intent::DeleteBackward);
386 assert_eq!(app.console.input.buffer(), "(");
387 }
388
389 #[test]
390 fn console_submit_incomplete_form_keeps_buffering() {
391 let mut app = make_app();
392 app.console_input_active = true;
393 for c in "(list".chars() {
394 apply(&mut app, Intent::InsertChar(c));
395 }
396 apply(&mut app, Intent::ConsoleSubmit);
397 assert_eq!(app.console.pending, "(list");
398 assert!(app.console.input.buffer().is_empty());
399 }
400
401 #[tokio::test]
402 async fn console_submit_routes_complete_form_to_echo_eval() {
403 use crate::tabs::nms_eval::ConsoleEval;
404 let mut app = make_app();
405 app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
406 app.console_input_active = true;
407 for c in "(+ 1 2)".chars() {
408 apply(&mut app, Intent::InsertChar(c));
409 }
410 apply(&mut app, Intent::ConsoleSubmit);
411 tokio::task::yield_now().await;
412 app.drain_console();
413 assert!(app.console.scrollback.iter().any(|l| l == "> (+ 1 2)"));
414 assert!(
415 app.console
416 .scrollback
417 .iter()
418 .any(|l| l.contains("(:id 0 :form (+ 1 2))"))
419 );
420 }
421
422 #[test]
423 fn console_history_keys_navigate_prior_submissions() {
424 let mut app = make_app();
425 app.console_input_active = true;
426 for form in ["(a)", "(b)"] {
427 for c in form.chars() {
428 apply(&mut app, Intent::InsertChar(c));
429 }
430 apply(&mut app, Intent::ConsoleSubmit);
431 }
432 apply(&mut app, Intent::ConsoleHistoryPrev);
433 assert_eq!(app.console.input.buffer(), "(b)");
434 apply(&mut app, Intent::ConsoleHistoryPrev);
435 assert_eq!(app.console.input.buffer(), "(a)");
436 apply(&mut app, Intent::ConsoleHistoryNext);
437 assert_eq!(app.console.input.buffer(), "(b)");
438 }
439
440 #[test]
441 fn console_interrupt_without_eval_does_not_panic() {
442 let mut app = make_app();
443 app.console_input_active = true;
444 apply(&mut app, Intent::ConsoleInterrupt);
445 }
446
447 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
448 async fn console_interrupt_intent_reaches_attached_eval() {
449 use crate::tabs::nms_eval::ConsoleEval;
450 use rpc::{ScriptCtx, ScriptLimits};
451 use std::time::Duration;
452 use tokio::time::sleep;
453
454 let ctx = ScriptCtx::new(Uuid::nil()).with_limits(ScriptLimits {
458 fuel: u64::MAX,
459 ..ScriptLimits::default()
460 });
461 let eval =
462 ConsoleEval::spawn_with_ctx(&tokio::runtime::Handle::current(), ctx).expect("spawn");
463 let mut app = make_app();
464 app.attach_console(eval);
465 app.console_input_active = true;
466 for c in "(do ((i 0 (+ i 1))) ((>= i 2000000000) i))".chars() {
467 apply(&mut app, Intent::InsertChar(c));
468 }
469 apply(&mut app, Intent::ConsoleSubmit);
470 sleep(Duration::from_millis(40)).await;
471 apply(&mut app, Intent::ConsoleInterrupt);
472
473 let mut interrupted = false;
474 for _ in 0..200 {
475 app.drain_console();
476 if app
477 .console
478 .scrollback
479 .iter()
480 .any(|l| l.contains(":code interrupted"))
481 {
482 interrupted = true;
483 break;
484 }
485 sleep(Duration::from_millis(20)).await;
486 }
487 assert!(interrupted, "interrupt did not reach the eval");
488 }
489}