Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <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
Expand Down
3 changes: 2 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion crates/liminal-flow-core/src/model/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,19 @@ 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,
StartBranch,
ReturnToParent,
AddNote,
QueryCurrent,
Resume,
Pause,
Park,
Done,
Archive,
Ambiguous,
}

Expand All @@ -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",
}
}
Expand All @@ -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}")),
}
Expand Down
131 changes: 87 additions & 44 deletions crates/liminal-flow-core/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,61 +39,57 @@ 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, 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.
///
/// 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, accepts_trailing) 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 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));
}
}
}

if trimmed == "/where" {
return Some((Intent::QueryCurrent, String::new()));
}

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 == "/done" {
return Some((Intent::Done, String::new()));
}

if let Some(rest) = trimmed.strip_prefix("/done ") {
return Some((Intent::Done, rest.trim().to_string()));
}

// Heuristic: questions end with ?
if trimmed.ends_with('?') {
return Some((Intent::QueryCurrent, trimmed.to_string()));
Expand Down Expand Up @@ -202,12 +198,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");
Expand All @@ -223,12 +237,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?");
Expand All @@ -250,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);
Expand Down
Loading
Loading