From 538ec09d7e1d84afc5d409f602c629181581c6e2 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Tue, 17 Mar 2026 22:41:25 -0400 Subject: [PATCH 1/6] fix: restore command entry and suspend handling in the TUI **What changed** - close the command palette once a full slash command is recognised and argument entry begins, so typed commands submit normally again - add parser and intent coverage for selected-item slash commands such as `/resume`, `/park`, and `/archive` - handle `Ctrl+Z` by restoring the terminal before suspend and re-entering the TUI cleanly on resume - update help text and docs so the documented TUI behaviour matches the shipped fixes **Why** - manually typed slash commands in `v0.0.3` could stay trapped in palette mode and block submission - suspend was being swallowed by the raw-mode event loop, which made `flo` behave unlike a normal terminal app - the parser, selected-item handlers, and docs had drifted apart, which increased the risk of more command UX regressions --- README.md | 3 +- SPEC.md | 3 +- crates/liminal-flow-core/src/model/capture.rs | 9 ++ crates/liminal-flow-core/src/rules.rs | 63 ++++++++ crates/liminal-flow-tui/src/app.rs | 106 ++++++------- crates/liminal-flow-tui/src/input.rs | 90 +++++++++++ crates/liminal-flow-tui/src/state.rs | 142 +++++++++++++++--- crates/liminal-flow-tui/src/ui/help.rs | 2 + 8 files changed, 336 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 5eb9bc0..e769873 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ The TUI starts in **Insert mode**: - Lifecycle slash commands can carry trailing note text, for example `/park need more data first` or `/done shipped first pass` - Unknown slash commands now error instead of silently being stored as notes - Type `/` on an empty line to open the **command palette** — navigate with arrow keys, select with Enter +- When you finish typing a full slash command and begin its argument text, the command palette closes so Enter submits the command normally - Type `?` on an empty line to see **shortcut hints** - **Up/Down** arrows navigate the thread list; the thread list auto-scrolls to keep selection visible; **Enter** on empty input expands/collapses branches - Mouse-wheel scrolling follows the hovered pane: `Threads`, `Status`, and `Help` each scroll independently @@ -157,7 +158,7 @@ The TUI starts in **Insert mode**: - Selected-item notes in the **Status** pane show compact timestamps and separators for readability - Type `/resume`, `/pause`, `/park`, `/done`, `/archive`, or `/note ` in Insert mode to act on the currently selected item without switching to Normal mode - Type plain text without a slash to add a note to the current active capture target -- Press `Esc` for **Normal mode** where `j`/`k` navigate, `Enter` expands or collapses the selected thread, `PageUp`/`PageDown` scroll the Status pane, `r` resumes a selected item to make it active again, `p` parks a selected branch, `d` marks the selected item done, `?` opens help, `a` shows about, and `q` quits +- Press `Esc` for **Normal mode** where `j`/`k` navigate, `Enter` expands or collapses the selected thread, `PageUp`/`PageDown` scroll the Status pane, `r` resumes a selected item to make it active again, `p` parks a selected branch, `d` marks the selected item done, `Ctrl+Z` suspends `flo`, `?` opens help, `a` shows about, and `q` quits - Press `Shift+A` in Normal mode to archive the selected item and remove it from the main working list - Done threads and branches stay visible as tombstones until they are archived, so you can still inspect and revive them with `r` - In the **Help** overlay, `j`/`k`/Up/Down and `PageUp`/`PageDown` scroll the help content on smaller terminals diff --git a/SPEC.md b/SPEC.md index 1894677..2b9c304 100644 --- a/SPEC.md +++ b/SPEC.md @@ -119,7 +119,7 @@ The TUI provides a three-pane interface: | Mode | Description | |---|---| | Insert | Text input active. Enter submits. Esc switches to Normal. | -| Normal | Keyboard navigation. `j`/`k`/Up/Down to move through threads and branches, `Enter` to expand or collapse the selected thread, `r` to resume the selected item and make it active, `p` to park a selected branch, `d` to mark the selected item done, `Shift+A` to archive the selected item, `i` to insert, `?` for help, `a` for about, `q` to quit. | +| Normal | Keyboard navigation. `j`/`k`/Up/Down to move through threads and branches, `Enter` to expand or collapse the selected thread, `r` to resume the selected item and make it active, `p` to park a selected branch, `d` to mark the selected item done, `Shift+A` to archive the selected item, `Ctrl+Z` to suspend `flo`, `i` to insert, `?` for help, `a` for about, `q` to quit. | | Help | Help overlay. Esc or `?` to dismiss. | | About | About overlay with app info. Esc, `q`, or Enter to dismiss. | @@ -151,6 +151,7 @@ The thread list supports navigating both threads and their branches: - Type `/` on an empty input line to open the **command palette** — a floating popup showing available slash commands. Navigate with Up/Down, select with Enter/Tab, dismiss with Esc. - Typing after `/` filters the command palette by both command name and description text. - Command-name matches outrank description-only matches, so `/par` prefers `/park` before `/back` even though `back` mentions `parent`. +- Once a full slash command is recognised and you move into argument entry, the command palette should close so Enter submits the command instead of re-selecting it. - Type `?` on an empty input line to show **shortcut hints** — a compact reference bar. Any key dismisses it. ### Slash Commands diff --git a/crates/liminal-flow-core/src/model/capture.rs b/crates/liminal-flow-core/src/model/capture.rs index abb3067..debee4f 100644 --- a/crates/liminal-flow-core/src/model/capture.rs +++ b/crates/liminal-flow-core/src/model/capture.rs @@ -55,8 +55,11 @@ pub enum Intent { ReturnToParent, AddNote, QueryCurrent, + Resume, Pause, + Park, Done, + Archive, Ambiguous, } @@ -68,8 +71,11 @@ impl Intent { Self::ReturnToParent => "return_to_parent", Self::AddNote => "add_note", Self::QueryCurrent => "query_current", + Self::Resume => "resume", Self::Pause => "pause", + Self::Park => "park", Self::Done => "done", + Self::Archive => "archive", Self::Ambiguous => "ambiguous", } } @@ -85,8 +91,11 @@ impl std::str::FromStr for Intent { "return_to_parent" => Ok(Self::ReturnToParent), "add_note" => Ok(Self::AddNote), "query_current" => Ok(Self::QueryCurrent), + "resume" => Ok(Self::Resume), "pause" => Ok(Self::Pause), + "park" => Ok(Self::Park), "done" => Ok(Self::Done), + "archive" => Ok(Self::Archive), "ambiguous" => Ok(Self::Ambiguous), _ => Err(format!("unknown intent: {s}")), } diff --git a/crates/liminal-flow-core/src/rules.rs b/crates/liminal-flow-core/src/rules.rs index a029f22..9837ccf 100644 --- a/crates/liminal-flow-core/src/rules.rs +++ b/crates/liminal-flow-core/src/rules.rs @@ -78,6 +78,14 @@ pub fn parse_slash_command(input: &str) -> Option<(Intent, String)> { return Some((Intent::QueryCurrent, String::new())); } + if trimmed == "/resume" { + return Some((Intent::Resume, String::new())); + } + + if let Some(rest) = trimmed.strip_prefix("/resume ") { + return Some((Intent::Resume, rest.trim().to_string())); + } + if trimmed == "/pause" { return Some((Intent::Pause, String::new())); } @@ -86,6 +94,14 @@ pub fn parse_slash_command(input: &str) -> Option<(Intent, String)> { return Some((Intent::Pause, rest.trim().to_string())); } + if trimmed == "/park" { + return Some((Intent::Park, String::new())); + } + + if let Some(rest) = trimmed.strip_prefix("/park ") { + return Some((Intent::Park, rest.trim().to_string())); + } + if trimmed == "/done" { return Some((Intent::Done, String::new())); } @@ -94,6 +110,14 @@ pub fn parse_slash_command(input: &str) -> Option<(Intent, String)> { return Some((Intent::Done, rest.trim().to_string())); } + if trimmed == "/archive" { + return Some((Intent::Archive, String::new())); + } + + if let Some(rest) = trimmed.strip_prefix("/archive ") { + return Some((Intent::Archive, rest.trim().to_string())); + } + // Heuristic: questions end with ? if trimmed.ends_with('?') { return Some((Intent::QueryCurrent, trimmed.to_string())); @@ -202,12 +226,30 @@ mod tests { assert_eq!(result, Some((Intent::Pause, String::new()))); } + #[test] + fn parse_resume_command() { + let result = parse_slash_command("/resume"); + assert_eq!(result, Some((Intent::Resume, String::new()))); + } + + #[test] + fn parse_park_command() { + let result = parse_slash_command("/park"); + assert_eq!(result, Some((Intent::Park, String::new()))); + } + #[test] fn parse_done_command() { let result = parse_slash_command("/done"); assert_eq!(result, Some((Intent::Done, String::new()))); } + #[test] + fn parse_archive_command() { + let result = parse_slash_command("/archive"); + assert_eq!(result, Some((Intent::Archive, String::new()))); + } + #[test] fn parse_back_command_with_note() { let result = parse_slash_command("/back need more data first"); @@ -223,12 +265,33 @@ mod tests { assert_eq!(result, Some((Intent::Pause, "blocked on review".into()))); } + #[test] + fn parse_resume_command_with_note() { + let result = parse_slash_command("/resume revisit this tomorrow"); + assert_eq!( + result, + Some((Intent::Resume, "revisit this tomorrow".into())) + ); + } + + #[test] + fn parse_park_command_with_note() { + let result = parse_slash_command("/park waiting on feedback"); + assert_eq!(result, Some((Intent::Park, "waiting on feedback".into()))); + } + #[test] fn parse_done_command_with_note() { let result = parse_slash_command("/done shipped first pass"); assert_eq!(result, Some((Intent::Done, "shipped first pass".into()))); } + #[test] + fn parse_archive_command_with_note() { + let result = parse_slash_command("/archive no longer needed"); + assert_eq!(result, Some((Intent::Archive, "no longer needed".into()))); + } + #[test] fn parse_question_heuristic() { let result = parse_slash_command("what am I working on?"); diff --git a/crates/liminal-flow-tui/src/app.rs b/crates/liminal-flow-tui/src/app.rs index fb30a62..b6bc109 100644 --- a/crates/liminal-flow-tui/src/app.rs +++ b/crates/liminal-flow-tui/src/app.rs @@ -7,6 +7,7 @@ use std::io; use std::time::Duration; use anyhow::Result; +use crossterm::cursor::Show; use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind, @@ -22,14 +23,33 @@ use tui_textarea::TextArea; use crate::input::{self, InputResult}; use crate::poll; -use crate::state::{command_palette_query, filtered_slash_commands}; -use crate::state::{Mode, SelectedItem, TuiState, SLASH_COMMANDS}; +use crate::state::{ + command_palette_query, filtered_slash_commands, should_keep_command_palette_open, +}; +use crate::state::{Mode, SelectedItem, TuiState}; use crate::ui::{ about, command_palette, help, hints_bar, input_pane, layout, reply_pane, thread_list, }; const TICK_RATE: Duration = Duration::from_millis(250); +fn enter_tui_terminal() -> Result<()> { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; + Ok(()) +} + +fn restore_tui_terminal() -> Result<()> { + disable_raw_mode()?; + execute!( + io::stdout(), + LeaveAlternateScreen, + DisableMouseCapture, + Show + )?; + Ok(()) +} + fn should_follow_active_after_submit(input: &str) -> bool { let trimmed = input.trim(); trimmed == "/resume" @@ -54,18 +74,7 @@ fn should_show_command_palette(query: &str) -> bool { if !trimmed_start.starts_with('/') { return false; } - - let command_token = trimmed_start - .split_whitespace() - .next() - .unwrap_or(trimmed_start); - let known_command = SLASH_COMMANDS.iter().any(|(cmd, _)| { - cmd.split_whitespace() - .next() - .is_some_and(|known| known == command_token) - }); - - !known_command + should_keep_command_palette_open(query) } fn refresh_command_palette_state(state: &mut TuiState, query: &str) { @@ -103,30 +112,6 @@ fn is_suspend_key(key: crossterm::event::KeyEvent) -> bool { key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('z') } -fn suspend_terminal(terminal: &mut Terminal>) -> Result<()> { - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - // Hand control back to the shell so Ctrl+Z behaves like a normal terminal app. - unsafe { - libc::raise(libc::SIGTSTP); - } - - enable_raw_mode()?; - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - )?; - terminal.clear()?; - Ok(()) -} - fn selected_command_target(state: &TuiState) -> Option { match &state.selected { SelectedItem::Thread(i) => state @@ -148,29 +133,21 @@ fn selected_command_target(state: &TuiState) -> Option { /// Run the TUI application. Takes ownership of the database connection. pub fn run(conn: Connection) -> Result<()> { // Set up terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); + enter_tui_terminal()?; + let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; // Install panic hook to restore terminal on crash let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + let _ = restore_tui_terminal(); original_hook(panic_info); })); let result = run_loop(&mut terminal, &conn); // Restore terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; + restore_tui_terminal()?; terminal.show_cursor()?; result @@ -344,8 +321,17 @@ fn run_loop( } if is_suspend_key(key) { - suspend_terminal(terminal)?; + restore_tui_terminal()?; + // SAFETY: `raise` sends SIGTSTP to the current process so the shell can + // suspend and later resume the TUI via `fg`. + unsafe { + libc::raise(libc::SIGTSTP); + } + enter_tui_terminal()?; + terminal.clear()?; + state.refresh_from_db(conn); sync_thread_viewport(terminal, &mut state)?; + state.poll_watermark = poll::current_watermark(conn); continue; } @@ -617,7 +603,7 @@ fn run_loop( KeyCode::Backspace => { textarea.input(Event::Key(key)); let query = textarea.lines().join("\n"); - if should_show_command_palette(&query) { + if should_keep_command_palette_open(&query) { clamp_palette_selection(&mut state, &query); } else { state.show_command_palette = false; @@ -626,7 +612,7 @@ fn run_loop( KeyCode::Char(_) => { textarea.input(Event::Key(key)); let query = textarea.lines().join("\n"); - if should_show_command_palette(&query) { + if should_keep_command_palette_open(&query) { clamp_palette_selection(&mut state, &query); } else { state.show_command_palette = false; @@ -773,25 +759,31 @@ mod tests { use super::*; #[test] - fn command_palette_stays_open_for_partial_commands() { + fn command_palette_stays_open_for_partial_and_argument_commands() { assert!(should_show_command_palette("/n")); assert!(should_show_command_palette("/par")); assert!(should_show_command_palette("/note-taking")); + // Commands that require an argument keep the palette open until a space is typed + assert!(should_show_command_palette("/now")); + assert!(should_show_command_palette("/branch")); + assert!(should_show_command_palette("/note")); } #[test] - fn command_palette_closes_once_command_token_is_complete() { - assert!(!should_show_command_palette("/now")); + fn command_palette_closes_once_command_is_complete_or_has_arguments() { assert!(!should_show_command_palette("/now improve suspend flow")); assert!(!should_show_command_palette("/done")); assert!(!should_show_command_palette("/done shipped")); assert!(!should_show_command_palette("/note ")); + assert!(!should_show_command_palette("/where")); + assert!(!should_show_command_palette("/resume")); } #[test] fn command_palette_reopens_for_partial_command_after_backspace() { assert!(should_show_command_palette("/no")); - assert!(!should_show_command_palette("/now")); + assert!(should_show_command_palette("/now")); + assert!(!should_show_command_palette("/done")); } #[test] diff --git a/crates/liminal-flow-tui/src/input.rs b/crates/liminal-flow-tui/src/input.rs index 922cf1e..f217f8c 100644 --- a/crates/liminal-flow-tui/src/input.rs +++ b/crates/liminal-flow-tui/src/input.rs @@ -305,6 +305,10 @@ fn execute_intent(conn: &Connection, intent: Intent, text: &str) -> Result { + anyhow::bail!("Use /resume on a selected thread or branch."); + } + Intent::Pause => { let Some(thread) = thread_repo::find_active(conn)? else { anyhow::bail!("No active thread to pause."); @@ -325,6 +329,10 @@ fn execute_intent(conn: &Connection, intent: Intent, text: &str) -> Result { + anyhow::bail!("Use /park on a selected branch."); + } + Intent::Ambiguous => { // Treat ambiguous input as a note execute_intent(conn, Intent::AddNote, text) @@ -357,6 +365,10 @@ fn execute_intent(conn: &Connection, intent: Intent, text: &str) -> Result { + anyhow::bail!("Use /archive on a selected thread or branch."); + } } } @@ -380,12 +392,30 @@ fn execute_intent_with_target( }; pause_command_target(conn, target, text) } + Intent::Resume => { + let Some(target) = target else { + return execute_intent(conn, intent, text); + }; + resume_command_target(conn, target, text) + } + Intent::Park => { + let Some(target) = target else { + return execute_intent(conn, intent, text); + }; + park_command_target(conn, target, text) + } Intent::Done => { let Some(target) = target else { return execute_intent(conn, intent, text); }; done_command_target(conn, target, text) } + Intent::Archive => { + let Some(target) = target else { + return execute_intent(conn, intent, text); + }; + archive_command_target(conn, target, text) + } Intent::ReturnToParent | Intent::SetCurrentThread | Intent::StartBranch @@ -682,6 +712,50 @@ fn pause_command_target(conn: &Connection, target: &CommandTarget, text: &str) - } } +fn resume_command_target(conn: &Connection, target: &CommandTarget, text: &str) -> Result { + let reply = match target { + CommandTarget::Thread(thread_id) => match resume_thread(conn, thread_id) { + InputResult::Reply(reply) => reply, + InputResult::Error(err) => anyhow::bail!(err), + InputResult::None => String::new(), + }, + CommandTarget::Branch { + thread_id, + branch_id, + } => match resume_branch(conn, thread_id, branch_id) { + InputResult::Reply(reply) => reply, + InputResult::Error(err) => anyhow::bail!(err), + InputResult::None => String::new(), + }, + }; + + if !text.trim().is_empty() { + attach_note_to_command_target(conn, target, text)?; + } + + Ok(reply) +} + +fn park_command_target(conn: &Connection, target: &CommandTarget, text: &str) -> Result { + let reply = match target { + CommandTarget::Thread(_) => anyhow::bail!("Select a branch to park."), + CommandTarget::Branch { + thread_id, + branch_id, + } => match park_branch(conn, thread_id, branch_id) { + InputResult::Reply(reply) => reply, + InputResult::Error(err) => anyhow::bail!(err), + InputResult::None => String::new(), + }, + }; + + if !text.trim().is_empty() { + attach_note_to_command_target(conn, target, text)?; + } + + Ok(reply) +} + fn done_command_target(conn: &Connection, target: &CommandTarget, text: &str) -> Result { match target { CommandTarget::Thread(thread_id) => { @@ -702,6 +776,22 @@ fn done_command_target(conn: &Connection, target: &CommandTarget, text: &str) -> } } +fn archive_command_target(conn: &Connection, target: &CommandTarget, text: &str) -> Result { + let reply = match target { + CommandTarget::Thread(thread_id) => archive_thread(conn, thread_id)?, + CommandTarget::Branch { + thread_id, + branch_id, + } => archive_branch(conn, thread_id, branch_id)?, + }; + + if !text.trim().is_empty() { + attach_note_to_command_target(conn, target, text)?; + } + + Ok(reply) +} + /// Resume a specific branch by ID — parks other active branches on the same thread first. /// Also ensures the parent thread is active. pub fn resume_branch(conn: &Connection, thread_id: &FlowId, branch_id: &FlowId) -> InputResult { diff --git a/crates/liminal-flow-tui/src/state.rs b/crates/liminal-flow-tui/src/state.rs index b515adf..b6b4a36 100644 --- a/crates/liminal-flow-tui/src/state.rs +++ b/crates/liminal-flow-tui/src/state.rs @@ -26,21 +26,70 @@ pub enum SelectedItem { Branch(usize, usize), // (thread_index, branch_index) } +pub struct SlashCommand { + pub syntax: &'static str, + pub description: &'static str, + pub requires_argument: bool, +} + +impl SlashCommand { + pub fn name(&self) -> &'static str { + self.syntax.split_whitespace().next().unwrap_or(self.syntax) + } +} + /// Slash commands available in the command palette. -pub const SLASH_COMMANDS: &[(&str, &str)] = &[ - ("/now ", "Set or replace the current thread"), - ("/branch ", "Start a branch beneath current thread"), - ( - "/back", - "Return from the active branch to the parent thread", - ), - ("/park", "Park the selected branch"), - ("/archive", "Archive the selected item"), - ("/note ", "Attach a note to the selected item"), - ("/where", "Show current thread and branches"), - ("/resume", "Resume the selected item"), - ("/pause", "Pause the selected thread"), - ("/done", "Mark the selected item done"), +pub const SLASH_COMMANDS: &[SlashCommand] = &[ + SlashCommand { + syntax: "/now ", + description: "Set or replace the current thread", + requires_argument: true, + }, + SlashCommand { + syntax: "/branch ", + description: "Start a branch beneath current thread", + requires_argument: true, + }, + SlashCommand { + syntax: "/back", + description: "Return from the active branch to the parent thread", + requires_argument: false, + }, + SlashCommand { + syntax: "/park", + description: "Park the selected branch", + requires_argument: false, + }, + SlashCommand { + syntax: "/archive", + description: "Archive the selected item", + requires_argument: false, + }, + SlashCommand { + syntax: "/note ", + description: "Attach a note to the selected item", + requires_argument: true, + }, + SlashCommand { + syntax: "/where", + description: "Show current thread and branches", + requires_argument: false, + }, + SlashCommand { + syntax: "/resume", + description: "Resume the selected item", + requires_argument: false, + }, + SlashCommand { + syntax: "/pause", + description: "Pause the selected thread", + requires_argument: false, + }, + SlashCommand { + syntax: "/done", + description: "Mark the selected item done", + requires_argument: false, + }, ]; /// Return the active slash-command token from palette input. @@ -48,6 +97,38 @@ pub fn command_palette_query(query: &str) -> &str { query.split_whitespace().next().unwrap_or("") } +fn slash_command_by_name(name: &str) -> Option<&'static SlashCommand> { + SLASH_COMMANDS.iter().find(|command| { + command + .name() + .trim_start_matches('/') + .eq_ignore_ascii_case(name) + }) +} + +pub fn should_keep_command_palette_open(query: &str) -> bool { + let normalized = query.trim_start(); + let Some(rest) = normalized.strip_prefix('/') else { + return false; + }; + + if rest.trim().is_empty() { + return true; + } + + let command_name = rest.split_whitespace().next().unwrap_or(""); + let has_trailing_text = rest[command_name.len()..] + .chars() + .next() + .is_some_and(char::is_whitespace); + + match slash_command_by_name(command_name) { + Some(_) if has_trailing_text => false, + Some(command) => command.requires_argument, + None => true, + } +} + /// Return slash commands filtered by the current palette query. pub fn filtered_slash_commands(query: &str) -> Vec<(usize, &'static str, &'static str)> { let normalized = command_palette_query(query).trim(); @@ -60,22 +141,17 @@ pub fn filtered_slash_commands(query: &str) -> Vec<(usize, &'static str, &'stati let mut matches = SLASH_COMMANDS .iter() .enumerate() - .filter(|(_, (cmd, desc))| { + .filter(|(_, command)| { if needle.is_empty() { return true; } - let cmd_name = cmd - .split_whitespace() - .next() - .unwrap_or(cmd) - .trim_start_matches('/') - .to_ascii_lowercase(); - let desc_text = desc.to_ascii_lowercase(); + let cmd_name = command.name().trim_start_matches('/').to_ascii_lowercase(); + let desc_text = command.description.to_ascii_lowercase(); cmd_name.contains(&needle) || desc_text.contains(&needle) }) - .map(|(index, (cmd, desc))| (index, *cmd, *desc)) + .map(|(index, command)| (index, command.syntax, command.description)) .collect::>(); matches.sort_by_key(|(_index, cmd, desc)| { @@ -673,4 +749,24 @@ mod tests { assert!(filtered.iter().any(|(_, cmd, _)| *cmd == "/now ")); assert!(filtered.iter().any(|(_, cmd, _)| *cmd == "/note ")); } + + #[test] + fn palette_stays_open_for_partial_and_argument_required_commands() { + assert!(should_keep_command_palette_open("/")); + assert!(should_keep_command_palette_open("/no")); + assert!(should_keep_command_palette_open("/now")); + assert!(should_keep_command_palette_open("/branch")); + assert!(should_keep_command_palette_open("/note")); + } + + #[test] + fn palette_closes_for_complete_commands_and_argument_entry() { + assert!(!should_keep_command_palette_open("/where")); + assert!(!should_keep_command_palette_open("/resume")); + assert!(!should_keep_command_palette_open("/archive")); + assert!(!should_keep_command_palette_open("/now test thread")); + assert!(!should_keep_command_palette_open( + "/pause blocked on review" + )); + } } diff --git a/crates/liminal-flow-tui/src/ui/help.rs b/crates/liminal-flow-tui/src/ui/help.rs index 22ea220..3fb0c04 100644 --- a/crates/liminal-flow-tui/src/ui/help.rs +++ b/crates/liminal-flow-tui/src/ui/help.rs @@ -34,6 +34,7 @@ const HELP_TEXT: &[(&str, &str)] = &[ ("Mouse wheel", "Scroll Threads or Status"), ("Enter (text)", "Submit input"), ("PageUp / PageDown", "Scroll the Status pane"), + ("Ctrl+Z", "Suspend flo and return to shell"), ("Esc", "Switch to Normal mode"), (S, "Normal Mode"), ("i", "Switch to Insert mode"), @@ -45,6 +46,7 @@ const HELP_TEXT: &[(&str, &str)] = &[ ("A", "Archive selected item"), ("PageUp / PageDown", "Scroll the Status pane"), ("Mouse wheel", "Scroll hovered pane"), + ("Ctrl+Z", "Suspend flo and return to shell"), ("?", "Toggle this help"), ("a", "About"), ("q", "Quit"), From 9313ce1fc66e8f50c562264e863dce38d541d472 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Tue, 17 Mar 2026 23:12:56 -0400 Subject: [PATCH 2/6] refactor: make slash-command parsing data-driven Replace the ~75-line if-chain in parse_slash_command with a static COMMAND_TABLE lookup. Add Copy to the Intent enum (all unit variants) to support use in the const table. Co-Authored-By: Claude Opus 4.6 --- crates/liminal-flow-core/src/model/capture.rs | 2 +- crates/liminal-flow-core/src/rules.rs | 101 ++++++------------ 2 files changed, 34 insertions(+), 69 deletions(-) diff --git a/crates/liminal-flow-core/src/model/capture.rs b/crates/liminal-flow-core/src/model/capture.rs index debee4f..8bbfcb2 100644 --- a/crates/liminal-flow-core/src/model/capture.rs +++ b/crates/liminal-flow-core/src/model/capture.rs @@ -47,7 +47,7 @@ impl std::str::FromStr for CaptureSource { } /// The inferred intent of a capture. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Intent { SetCurrentThread, diff --git a/crates/liminal-flow-core/src/rules.rs b/crates/liminal-flow-core/src/rules.rs index 9837ccf..5b5a393 100644 --- a/crates/liminal-flow-core/src/rules.rs +++ b/crates/liminal-flow-core/src/rules.rs @@ -39,85 +39,50 @@ pub fn normalise_title(raw: &str) -> String { trimmed.to_string() } +/// Slash command definitions for the parser. +/// +/// Each entry maps a command name to its intent and whether a non-empty argument +/// is required (e.g. `/now` needs text, `/done` does not). +const COMMAND_TABLE: &[(&str, Intent, bool)] = &[ + ("/now", Intent::SetCurrentThread, true), + ("/branch", Intent::StartBranch, true), + ("/back", Intent::ReturnToParent, false), + ("/note", Intent::AddNote, true), + ("/where", Intent::QueryCurrent, false), + ("/resume", Intent::Resume, false), + ("/pause", Intent::Pause, false), + ("/park", Intent::Park, false), + ("/done", Intent::Done, false), + ("/archive", Intent::Archive, false), +]; + /// Detect the intent of a slash command from TUI input. /// /// Returns `None` if the input doesn't match a known slash command. pub fn parse_slash_command(input: &str) -> Option<(Intent, String)> { let trimmed = input.trim(); - if let Some(rest) = trimmed.strip_prefix("/now ") { - let text = rest.trim().to_string(); - if !text.is_empty() { - return Some((Intent::SetCurrentThread, text)); - } - } - - if let Some(rest) = trimmed.strip_prefix("/branch ") { - let text = rest.trim().to_string(); - if !text.is_empty() { - return Some((Intent::StartBranch, text)); + for &(name, intent, requires_arg) in COMMAND_TABLE { + // Exact match: `/done` + if trimmed == name { + return if requires_arg { + None + } else { + Some((intent, String::new())) + }; } - } - - if trimmed == "/back" { - return Some((Intent::ReturnToParent, String::new())); - } - if let Some(rest) = trimmed.strip_prefix("/back ") { - return Some((Intent::ReturnToParent, rest.trim().to_string())); - } - - if let Some(rest) = trimmed.strip_prefix("/note ") { - let text = rest.trim().to_string(); - if !text.is_empty() { - return Some((Intent::AddNote, text)); + // Command with argument: `/done shipped` + let prefix = format!("{name} "); + if let Some(rest) = trimmed.strip_prefix(&prefix) { + let arg = rest.trim().to_string(); + if requires_arg && arg.is_empty() { + return None; + } + return Some((intent, arg)); } } - if trimmed == "/where" { - return Some((Intent::QueryCurrent, String::new())); - } - - if trimmed == "/resume" { - return Some((Intent::Resume, String::new())); - } - - if let Some(rest) = trimmed.strip_prefix("/resume ") { - return Some((Intent::Resume, rest.trim().to_string())); - } - - if trimmed == "/pause" { - return Some((Intent::Pause, String::new())); - } - - if let Some(rest) = trimmed.strip_prefix("/pause ") { - return Some((Intent::Pause, rest.trim().to_string())); - } - - if trimmed == "/park" { - return Some((Intent::Park, String::new())); - } - - if let Some(rest) = trimmed.strip_prefix("/park ") { - return Some((Intent::Park, rest.trim().to_string())); - } - - if trimmed == "/done" { - return Some((Intent::Done, String::new())); - } - - if let Some(rest) = trimmed.strip_prefix("/done ") { - return Some((Intent::Done, rest.trim().to_string())); - } - - if trimmed == "/archive" { - return Some((Intent::Archive, String::new())); - } - - if let Some(rest) = trimmed.strip_prefix("/archive ") { - return Some((Intent::Archive, rest.trim().to_string())); - } - // Heuristic: questions end with ? if trimmed.ends_with('?') { return Some((Intent::QueryCurrent, trimmed.to_string())); From 7173129e8867d32d6adeaaddc23ead97dc5e5a0f Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Tue, 17 Mar 2026 23:13:08 -0400 Subject: [PATCH 3/6] refactor: clean up TUI tech debt and fix palette dead-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tech debt: - Remove redundant should_show_command_palette wrapper; call should_keep_command_palette_open directly - Make command_palette_query private (only used within state.rs) - Unify duplicate Backspace/Char key arms in both palette-open and normal insert mode branches - Extract apply_input_result helper to replace repeated match blocks in Normal mode keybinding handlers (r, p, d, A) and Enter/submit - Add From> impl on InputResult to unify error handling Bug fix: - Close the command palette when trailing text follows an unrecognised command token (e.g. "/bran meow") — previously trapped the user in a "No matching commands" dead-end - Forward Delete, Left, Right, Home, End keys to the textarea while the palette is open instead of swallowing them Co-Authored-By: Claude Opus 4.6 --- crates/liminal-flow-tui/src/app.rs | 201 +++++++++------------------ crates/liminal-flow-tui/src/input.rs | 9 ++ crates/liminal-flow-tui/src/state.rs | 18 ++- 3 files changed, 91 insertions(+), 137 deletions(-) diff --git a/crates/liminal-flow-tui/src/app.rs b/crates/liminal-flow-tui/src/app.rs index b6bc109..129d776 100644 --- a/crates/liminal-flow-tui/src/app.rs +++ b/crates/liminal-flow-tui/src/app.rs @@ -23,9 +23,7 @@ use tui_textarea::TextArea; use crate::input::{self, InputResult}; use crate::poll; -use crate::state::{ - command_palette_query, filtered_slash_commands, should_keep_command_palette_open, -}; +use crate::state::{filtered_slash_commands, should_keep_command_palette_open}; use crate::state::{Mode, SelectedItem, TuiState}; use crate::ui::{ about, command_palette, help, hints_bar, input_pane, layout, reply_pane, thread_list, @@ -51,34 +49,37 @@ fn restore_tui_terminal() -> Result<()> { } fn should_follow_active_after_submit(input: &str) -> bool { + use liminal_flow_core::model::Intent; let trimmed = input.trim(); - trimmed == "/resume" - || trimmed.starts_with("/resume ") - || trimmed == "/park" - || trimmed.starts_with("/park ") - || trimmed == "/archive" - || trimmed.starts_with("/archive ") - || matches!( - input::parsed_intent(input), - Some( - liminal_flow_core::model::Intent::SetCurrentThread - | liminal_flow_core::model::Intent::StartBranch - | liminal_flow_core::model::Intent::ReturnToParent - ) + matches!( + input::parsed_intent(input), + Some( + Intent::Resume + | Intent::Park + | Intent::Archive + | Intent::SetCurrentThread + | Intent::StartBranch + | Intent::ReturnToParent ) - || (!trimmed.is_empty() && !trimmed.starts_with('/')) + ) || (!trimmed.is_empty() && !trimmed.starts_with('/')) } -fn should_show_command_palette(query: &str) -> bool { - let trimmed_start = command_palette_query(query); - if !trimmed_start.starts_with('/') { - return false; +/// Apply an `InputResult` to TUI state (set reply or error message). +fn apply_input_result(state: &mut TuiState, result: InputResult) { + match result { + InputResult::Reply(msg) => { + state.last_reply = Some(msg); + state.error_message = None; + } + InputResult::Error(msg) => { + state.error_message = Some(msg); + } + InputResult::None => {} } - should_keep_command_palette_open(query) } fn refresh_command_palette_state(state: &mut TuiState, query: &str) { - state.show_command_palette = should_show_command_palette(query); + state.show_command_palette = should_keep_command_palette_open(query); if state.show_command_palette { state.command_palette_index = 0; } @@ -384,11 +385,11 @@ fn run_loop( KeyCode::Char('r') => { // Resume/activate the selected thread or branch let result = match &state.selected { - crate::state::SelectedItem::Thread(i) => state + SelectedItem::Thread(i) => state .threads .get(*i) .map(|entry| input::resume_thread(conn, &entry.thread.id)), - crate::state::SelectedItem::Branch(i, j) => { + SelectedItem::Branch(i, j) => { state.threads.get(*i).and_then(|entry| { entry.branches.get(*j).map(|branch| { input::resume_branch( @@ -401,16 +402,7 @@ fn run_loop( } }; if let Some(result) = result { - match result { - InputResult::Reply(msg) => { - state.last_reply = Some(msg); - state.error_message = None; - } - InputResult::Error(msg) => { - state.error_message = Some(msg); - } - InputResult::None => {} - } + apply_input_result(&mut state, result); state.refresh_from_db(conn); state.select_active_item(); sync_thread_viewport(terminal, &mut state)?; @@ -418,9 +410,10 @@ fn run_loop( } } KeyCode::Char('p') => { + // Park the selected branch let result = match &state.selected { - crate::state::SelectedItem::Thread(_) => None, - crate::state::SelectedItem::Branch(i, j) => { + SelectedItem::Thread(_) => None, + SelectedItem::Branch(i, j) => { state.threads.get(*i).and_then(|entry| { entry.branches.get(*j).map(|branch| { input::park_branch( @@ -433,22 +426,13 @@ fn run_loop( } }; if let Some(result) = result { - match result { - InputResult::Reply(msg) => { - state.last_reply = Some(msg); - state.error_message = None; - if let crate::state::SelectedItem::Branch(i, _) = - state.selected - { - state.selected = - crate::state::SelectedItem::Thread(i); - } - } - InputResult::Error(msg) => { - state.error_message = Some(msg); + // Move selection to parent thread before refresh + if matches!(result, InputResult::Reply(_)) { + if let SelectedItem::Branch(i, _) = state.selected { + state.selected = SelectedItem::Thread(i); } - InputResult::None => {} } + apply_input_result(&mut state, result); state.refresh_from_db(conn); state.select_active_item(); sync_thread_viewport(terminal, &mut state)?; @@ -456,13 +440,12 @@ fn run_loop( } } KeyCode::Char('d') => { - let result = match &state.selected { - crate::state::SelectedItem::Thread(i) => { - state.threads.get(*i).map(|entry| { - input::mark_thread_done(conn, &entry.thread.id) - }) - } - crate::state::SelectedItem::Branch(i, j) => { + // Mark the selected item done + let result: Option = match &state.selected { + SelectedItem::Thread(i) => state.threads.get(*i).map(|entry| { + input::mark_thread_done(conn, &entry.thread.id).into() + }), + SelectedItem::Branch(i, j) => { state.threads.get(*i).and_then(|entry| { entry.branches.get(*j).map(|branch| { input::mark_branch_done( @@ -470,32 +453,25 @@ fn run_loop( &entry.thread.id, &branch.id, ) + .into() }) }) } }; if let Some(result) = result { - match result { - Ok(msg) => { - state.last_reply = Some(msg); - state.error_message = None; - } - Err(err) => { - state.error_message = Some(err.to_string()); - } - } + apply_input_result(&mut state, result); state.refresh_from_db(conn); sync_thread_viewport(terminal, &mut state)?; state.poll_watermark = poll::current_watermark(conn); } } KeyCode::Char('A') => { - let result = match &state.selected { - crate::state::SelectedItem::Thread(i) => state - .threads - .get(*i) - .map(|entry| input::archive_thread(conn, &entry.thread.id)), - crate::state::SelectedItem::Branch(i, j) => { + // Archive the selected item + let result: Option = match &state.selected { + SelectedItem::Thread(i) => state.threads.get(*i).map(|entry| { + input::archive_thread(conn, &entry.thread.id).into() + }), + SelectedItem::Branch(i, j) => { state.threads.get(*i).and_then(|entry| { entry.branches.get(*j).map(|branch| { input::archive_branch( @@ -503,20 +479,13 @@ fn run_loop( &entry.thread.id, &branch.id, ) + .into() }) }) } }; if let Some(result) = result { - match result { - Ok(msg) => { - state.last_reply = Some(msg); - state.error_message = None; - } - Err(err) => { - state.error_message = Some(err.to_string()); - } - } + apply_input_result(&mut state, result); state.refresh_from_db(conn); sync_thread_viewport(terminal, &mut state)?; state.poll_watermark = poll::current_watermark(conn); @@ -600,7 +569,9 @@ fn run_loop( state.show_command_palette = false; } } - KeyCode::Backspace => { + KeyCode::Backspace + | KeyCode::Delete + | KeyCode::Char(_) => { textarea.input(Event::Key(key)); let query = textarea.lines().join("\n"); if should_keep_command_palette_open(&query) { @@ -609,14 +580,8 @@ fn run_loop( state.show_command_palette = false; } } - KeyCode::Char(_) => { + KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => { textarea.input(Event::Key(key)); - let query = textarea.lines().join("\n"); - if should_keep_command_palette_open(&query) { - clamp_palette_selection(&mut state, &query); - } else { - state.show_command_palette = false; - } } _ => {} } @@ -688,20 +653,12 @@ fn run_loop( let follow_active = should_follow_active_after_submit(&text); let command_target = selected_command_target(&state); - match input::perform_command_on_target( + let result = input::perform_command_on_target( conn, &text, command_target.as_ref(), - ) { - InputResult::Reply(msg) => { - state.last_reply = Some(msg); - state.error_message = None; - } - InputResult::Error(msg) => { - state.error_message = Some(msg); - } - InputResult::None => {} - } + ); + apply_input_result(&mut state, result); // Refresh state from DB after mutation state.refresh_from_db(conn); @@ -711,23 +668,12 @@ fn run_loop( sync_thread_viewport(terminal, &mut state)?; state.poll_watermark = poll::current_watermark(conn); } - KeyCode::Char('/') if is_empty => { - // Show command palette - textarea.input(Event::Key(key)); - let query = textarea.lines().join("\n"); - refresh_command_palette_state(&mut state, &query); - } KeyCode::Char('?') if is_empty => { // Show shortcut hints state.show_hints = true; textarea.input(Event::Key(key)); } - KeyCode::Backspace => { - textarea.input(Event::Key(key)); - let query = textarea.lines().join("\n"); - refresh_command_palette_state(&mut state, &query); - } - KeyCode::Char(_) => { + KeyCode::Char(_) | KeyCode::Backspace => { textarea.input(Event::Key(key)); let query = textarea.lines().join("\n"); refresh_command_palette_state(&mut state, &query); @@ -759,31 +705,16 @@ mod tests { use super::*; #[test] - fn command_palette_stays_open_for_partial_and_argument_commands() { - assert!(should_show_command_palette("/n")); - assert!(should_show_command_palette("/par")); - assert!(should_show_command_palette("/note-taking")); - // Commands that require an argument keep the palette open until a space is typed - assert!(should_show_command_palette("/now")); - assert!(should_show_command_palette("/branch")); - assert!(should_show_command_palette("/note")); - } - - #[test] - fn command_palette_closes_once_command_is_complete_or_has_arguments() { - assert!(!should_show_command_palette("/now improve suspend flow")); - assert!(!should_show_command_palette("/done")); - assert!(!should_show_command_palette("/done shipped")); - assert!(!should_show_command_palette("/note ")); - assert!(!should_show_command_palette("/where")); - assert!(!should_show_command_palette("/resume")); + fn palette_open_cases_not_covered_in_state_tests() { + // Partial unknown commands keep palette open + assert!(should_keep_command_palette_open("/n")); + assert!(should_keep_command_palette_open("/note-taking")); } #[test] - fn command_palette_reopens_for_partial_command_after_backspace() { - assert!(should_show_command_palette("/no")); - assert!(should_show_command_palette("/now")); - assert!(!should_show_command_palette("/done")); + fn palette_close_cases_not_covered_in_state_tests() { + assert!(!should_keep_command_palette_open("/done shipped")); + assert!(!should_keep_command_palette_open("/note ")); } #[test] diff --git a/crates/liminal-flow-tui/src/input.rs b/crates/liminal-flow-tui/src/input.rs index f217f8c..15575e6 100644 --- a/crates/liminal-flow-tui/src/input.rs +++ b/crates/liminal-flow-tui/src/input.rs @@ -21,6 +21,15 @@ pub enum InputResult { None, } +impl From> for InputResult { + fn from(result: anyhow::Result) -> Self { + match result { + Ok(msg) => Self::Reply(msg), + Err(err) => Self::Error(err.to_string()), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandTarget { Thread(FlowId), diff --git a/crates/liminal-flow-tui/src/state.rs b/crates/liminal-flow-tui/src/state.rs index b6b4a36..ccb85c6 100644 --- a/crates/liminal-flow-tui/src/state.rs +++ b/crates/liminal-flow-tui/src/state.rs @@ -93,7 +93,7 @@ pub const SLASH_COMMANDS: &[SlashCommand] = &[ ]; /// Return the active slash-command token from palette input. -pub fn command_palette_query(query: &str) -> &str { +fn command_palette_query(query: &str) -> &str { query.split_whitespace().next().unwrap_or("") } @@ -122,8 +122,14 @@ pub fn should_keep_command_palette_open(query: &str) -> bool { .next() .is_some_and(char::is_whitespace); + if has_trailing_text { + // Once there's text after the command token, close the palette — the user + // is either typing an argument for a known command or has moved past the + // command-selection phase for an unknown prefix. + return false; + } + match slash_command_by_name(command_name) { - Some(_) if has_trailing_text => false, Some(command) => command.requires_argument, None => true, } @@ -769,4 +775,12 @@ mod tests { "/pause blocked on review" )); } + + #[test] + fn palette_closes_for_unknown_command_with_trailing_text() { + // Once the user types text after an unrecognised command token, + // the palette should close — they've moved past command selection. + assert!(!should_keep_command_palette_open("/bran meow")); + assert!(!should_keep_command_palette_open("/foo bar")); + } } From f24d9e2c5d204eb676c5d7350d690ecec8e9e580 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Tue, 17 Mar 2026 23:15:46 -0400 Subject: [PATCH 4/6] fix: reopen command palette when editing a partial slash command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette now reopens when cursor movement and editing change the command token back to an unrecognised prefix (e.g. /branch meow → backspace → /branc meow shows palette filtered to matching commands). - Keep palette open for unknown prefixes even with trailing text, to support the command-correction workflow (/now → /no → select /note) - Refresh palette state on all insert-mode keys (including cursor movement and Delete), not just Char/Backspace Co-Authored-By: Claude Opus 4.6 --- crates/liminal-flow-tui/src/app.rs | 10 +++++----- crates/liminal-flow-tui/src/state.rs | 21 +++++++++------------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/crates/liminal-flow-tui/src/app.rs b/crates/liminal-flow-tui/src/app.rs index 129d776..074a8e6 100644 --- a/crates/liminal-flow-tui/src/app.rs +++ b/crates/liminal-flow-tui/src/app.rs @@ -673,15 +673,15 @@ fn run_loop( state.show_hints = true; textarea.input(Event::Key(key)); } - KeyCode::Char(_) | KeyCode::Backspace => { + _ => { + // Forward to textarea, then refresh palette + // state — text-modifying keys (Char, Backspace, + // Delete) and cursor-movement keys (Left, Right) + // can both affect whether the palette should open. textarea.input(Event::Key(key)); let query = textarea.lines().join("\n"); refresh_command_palette_state(&mut state, &query); } - _ => { - // Forward to textarea - textarea.input(Event::Key(key)); - } } } } diff --git a/crates/liminal-flow-tui/src/state.rs b/crates/liminal-flow-tui/src/state.rs index ccb85c6..ea809c7 100644 --- a/crates/liminal-flow-tui/src/state.rs +++ b/crates/liminal-flow-tui/src/state.rs @@ -122,15 +122,11 @@ pub fn should_keep_command_palette_open(query: &str) -> bool { .next() .is_some_and(char::is_whitespace); - if has_trailing_text { - // Once there's text after the command token, close the palette — the user - // is either typing an argument for a known command or has moved past the - // command-selection phase for an unknown prefix. - return false; - } - match slash_command_by_name(command_name) { + Some(_) if has_trailing_text => false, Some(command) => command.requires_argument, + // Unknown prefix: keep palette open so the user can correct/select a + // command, even if there is trailing text (the argument they already typed). None => true, } } @@ -777,10 +773,11 @@ mod tests { } #[test] - fn palette_closes_for_unknown_command_with_trailing_text() { - // Once the user types text after an unrecognised command token, - // the palette should close — they've moved past command selection. - assert!(!should_keep_command_palette_open("/bran meow")); - assert!(!should_keep_command_palette_open("/foo bar")); + fn palette_stays_open_for_unknown_command_with_trailing_text() { + // Unknown prefix with trailing text: the user is likely editing the + // command name to pick a different one (e.g. /branch → /bran → /now), + // so keep the palette open for correction. + assert!(should_keep_command_palette_open("/bran meow")); + assert!(should_keep_command_palette_open("/foo bar")); } } From 1b7d6c7f4cff5560a2a79a9713b4eb429c11128a Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Tue, 17 Mar 2026 23:21:04 -0400 Subject: [PATCH 5/6] style: apply cargo fmt Co-Authored-By: Claude Opus 4.6 --- crates/liminal-flow-tui/src/app.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/liminal-flow-tui/src/app.rs b/crates/liminal-flow-tui/src/app.rs index 074a8e6..fc943dc 100644 --- a/crates/liminal-flow-tui/src/app.rs +++ b/crates/liminal-flow-tui/src/app.rs @@ -569,9 +569,7 @@ fn run_loop( state.show_command_palette = false; } } - KeyCode::Backspace - | KeyCode::Delete - | KeyCode::Char(_) => { + KeyCode::Backspace | KeyCode::Delete | KeyCode::Char(_) => { textarea.input(Event::Key(key)); let query = textarea.lines().join("\n"); if should_keep_command_palette_open(&query) { @@ -580,7 +578,10 @@ fn run_loop( state.show_command_palette = false; } } - KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => { + KeyCode::Left + | KeyCode::Right + | KeyCode::Home + | KeyCode::End => { textarea.input(Event::Key(key)); } _ => {} From 52c70b0720ea54741a85381cce82d0612ba8b74a Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Tue, 17 Mar 2026 23:36:43 -0400 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=94?= =?UTF-8?q?=20reject=20/where=20trailing=20text,=20fix=20palette=20case=20?= =?UTF-8?q?matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add accepts_trailing column to COMMAND_TABLE so /where only matches its exact form, preventing silent argument loss - Make slash_command_by_name use exact case matching to stay consistent with the core parser (which only recognises lowercase commands) Co-Authored-By: Claude Opus 4.6 --- crates/liminal-flow-core/src/rules.rs | 57 +++++++++++++++++---------- crates/liminal-flow-tui/src/state.rs | 20 +++++++--- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/crates/liminal-flow-core/src/rules.rs b/crates/liminal-flow-core/src/rules.rs index 5b5a393..106ef89 100644 --- a/crates/liminal-flow-core/src/rules.rs +++ b/crates/liminal-flow-core/src/rules.rs @@ -41,19 +41,24 @@ pub fn normalise_title(raw: &str) -> String { /// Slash command definitions for the parser. /// -/// Each entry maps a command name to its intent and whether a non-empty argument -/// is required (e.g. `/now` needs text, `/done` does not). -const COMMAND_TABLE: &[(&str, Intent, bool)] = &[ - ("/now", Intent::SetCurrentThread, true), - ("/branch", Intent::StartBranch, true), - ("/back", Intent::ReturnToParent, false), - ("/note", Intent::AddNote, true), - ("/where", Intent::QueryCurrent, false), - ("/resume", Intent::Resume, false), - ("/pause", Intent::Pause, false), - ("/park", Intent::Park, false), - ("/done", Intent::Done, false), - ("/archive", Intent::Archive, false), +/// Each entry maps a command name to its intent, whether a non-empty argument +/// is required, and whether trailing text is accepted at all. +/// +/// - `requires_arg = true`: a non-empty argument is mandatory (e.g. `/now`) +/// - `accepts_trailing = true`: optional trailing text is kept (e.g. `/done shipped`) +/// - `accepts_trailing = false`: exact match only (e.g. `/where`) +const COMMAND_TABLE: &[(&str, Intent, bool, bool)] = &[ + // command intent requires accepts_trailing + ("/now", Intent::SetCurrentThread, true, true), + ("/branch", Intent::StartBranch, true, true), + ("/back", Intent::ReturnToParent, false, true), + ("/note", Intent::AddNote, true, true), + ("/where", Intent::QueryCurrent, false, false), + ("/resume", Intent::Resume, false, true), + ("/pause", Intent::Pause, false, true), + ("/park", Intent::Park, false, true), + ("/done", Intent::Done, false, true), + ("/archive", Intent::Archive, false, true), ]; /// Detect the intent of a slash command from TUI input. @@ -62,7 +67,7 @@ const COMMAND_TABLE: &[(&str, Intent, bool)] = &[ pub fn parse_slash_command(input: &str) -> Option<(Intent, String)> { let trimmed = input.trim(); - for &(name, intent, requires_arg) in COMMAND_TABLE { + for &(name, intent, requires_arg, accepts_trailing) in COMMAND_TABLE { // Exact match: `/done` if trimmed == name { return if requires_arg { @@ -72,14 +77,16 @@ pub fn parse_slash_command(input: &str) -> Option<(Intent, String)> { }; } - // Command with argument: `/done shipped` - let prefix = format!("{name} "); - if let Some(rest) = trimmed.strip_prefix(&prefix) { - let arg = rest.trim().to_string(); - if requires_arg && arg.is_empty() { - return None; + // Command with argument or optional note: `/now improving AIDX`, `/done shipped` + if accepts_trailing { + let prefix = format!("{name} "); + if let Some(rest) = trimmed.strip_prefix(&prefix) { + let arg = rest.trim().to_string(); + if requires_arg && arg.is_empty() { + return None; + } + return Some((intent, arg)); } - return Some((intent, arg)); } } @@ -278,6 +285,14 @@ mod tests { ); } + #[test] + fn parse_where_rejects_trailing_text() { + // `/where` is exact-match only — trailing text should not be silently dropped + assert_eq!(parse_slash_command("/where anything"), None); + // Note: `/where status?` still matches the `?` question heuristic, + // which is correct — it becomes a QueryCurrent with the full text. + } + #[test] fn parse_plain_text_returns_none() { assert_eq!(parse_slash_command("reading article"), None); diff --git a/crates/liminal-flow-tui/src/state.rs b/crates/liminal-flow-tui/src/state.rs index ea809c7..8901274 100644 --- a/crates/liminal-flow-tui/src/state.rs +++ b/crates/liminal-flow-tui/src/state.rs @@ -98,12 +98,11 @@ fn command_palette_query(query: &str) -> &str { } fn slash_command_by_name(name: &str) -> Option<&'static SlashCommand> { - SLASH_COMMANDS.iter().find(|command| { - command - .name() - .trim_start_matches('/') - .eq_ignore_ascii_case(name) - }) + // Use exact (case-sensitive) matching to stay consistent with the core + // parser, which only recognises lowercase command names. + SLASH_COMMANDS + .iter() + .find(|command| command.name().trim_start_matches('/') == name) } pub fn should_keep_command_palette_open(query: &str) -> bool { @@ -780,4 +779,13 @@ mod tests { assert!(should_keep_command_palette_open("/bran meow")); assert!(should_keep_command_palette_open("/foo bar")); } + + #[test] + fn palette_stays_open_for_mixed_case_commands() { + // Mixed-case commands are not recognised by the parser, so the palette + // should stay open to let the user correct/complete them. + assert!(should_keep_command_palette_open("/WHERE")); + assert!(should_keep_command_palette_open("/Resume")); + assert!(should_keep_command_palette_open("/DONE")); + } }