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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ The TUI starts in **Insert mode**:
- 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
- Press `Ctrl+J` in the **Capture** pane to insert a new line without submitting
- Mouse-wheel scrolling follows the hovered pane: `Threads`, `Status`, and `Help` each scroll independently
- Left-click in the thread list selects the clicked thread or branch
- The **Status** pane follows the selected thread or branch for inspection
Expand Down
3 changes: 2 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ The TUI provides a three-pane interface:

| Mode | Description |
|---|---|
| Insert | Text input active. Enter submits. Esc switches to Normal. |
| Insert | Text input active. Enter submits, `Ctrl+J` inserts a new line, and 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, `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 All @@ -130,6 +130,7 @@ The thread list supports navigating both threads and their branches:
- **Up/Down** (Insert or Normal mode) moves between all visible items — threads and branches within expanded threads
- The thread list auto-scrolls to keep the selected row visible when the list exceeds the available height
- **Enter** (on empty input in Insert, or in Normal mode) toggles expand/collapse for the selected thread's branches
- **Ctrl+J** inserts a new line in the Capture pane without submitting the current input
- Mouse-wheel scrolling follows the hovered pane, so `Threads`, `Status`, and `Help` scroll independently
- Left-click in the thread list selects the clicked thread or branch
- The **Status** pane follows the selected item for inspection
Expand Down
161 changes: 97 additions & 64 deletions crates/liminal-flow-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ fn is_suspend_key(key: crossterm::event::KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('z')
}

fn is_insert_newline_key(key: crossterm::event::KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('j')
}

fn selected_command_target(state: &TuiState) -> Option<input::CommandTarget> {
match &state.selected {
SelectedItem::Thread(i) => state
Expand Down Expand Up @@ -612,76 +616,83 @@ fn run_loop(
}
} else {
// Normal Insert mode handling
match key.code {
KeyCode::Esc => {
state.mode = Mode::Normal;
state.show_command_palette = false;
state.show_hints = false;
}
KeyCode::Up => {
// Arrow keys navigate the thread list
state.select_prev();
state.refresh_selected_details(conn);
sync_thread_viewport(terminal, &mut state)?;
}
KeyCode::Down => {
// Arrow keys navigate the thread list
state.select_next();
state.refresh_selected_details(conn);
sync_thread_viewport(terminal, &mut state)?;
}
KeyCode::Enter => {
// If input is empty, toggle thread expansion
let is_empty =
textarea.lines().iter().all(|l| l.is_empty());
if is_empty {
state.toggle_expanded();
if is_insert_newline_key(key) {
textarea.insert_newline();
let query = textarea.lines().join("\n");
refresh_command_palette_state(&mut state, &query);
} else {
match key.code {
KeyCode::Esc => {
state.mode = Mode::Normal;
state.show_command_palette = false;
state.show_hints = false;
}
KeyCode::Up => {
// Arrow keys navigate the thread list
state.select_prev();
state.refresh_selected_details(conn);
sync_thread_viewport(terminal, &mut state)?;
continue;
}
KeyCode::Down => {
// Arrow keys navigate the thread list
state.select_next();
state.refresh_selected_details(conn);
sync_thread_viewport(terminal, &mut state)?;
}
KeyCode::Enter => {
// If input is empty, toggle thread expansion
let is_empty =
textarea.lines().iter().all(|l| l.is_empty());
if is_empty {
state.toggle_expanded();
state.refresh_selected_details(conn);
sync_thread_viewport(terminal, &mut state)?;
continue;
}

// Submit the input
let lines: Vec<String> = textarea.lines().to_vec();
let text = lines.join("\n");

// Clear the textarea
textarea = TextArea::default();
textarea.set_cursor_line_style(
ratatui::style::Style::default(),
);

// Submit the input
let lines: Vec<String> = textarea.lines().to_vec();
let text = lines.join("\n");

// Clear the textarea
textarea = TextArea::default();
textarea
.set_cursor_line_style(ratatui::style::Style::default());
// Process the input
let follow_active =
should_follow_active_after_submit(&text);
let command_target = selected_command_target(&state);
let result = input::perform_command_on_target(
conn,
&text,
command_target.as_ref(),
);
apply_input_result(&mut state, result);

// Process the input
let follow_active =
should_follow_active_after_submit(&text);
let command_target = selected_command_target(&state);
let result = input::perform_command_on_target(
conn,
&text,
command_target.as_ref(),
);
apply_input_result(&mut state, result);

// Refresh state from DB after mutation
state.refresh_from_db(conn);
if follow_active {
state.select_active_item();
// Refresh state from DB after mutation
state.refresh_from_db(conn);
if follow_active {
state.select_active_item();
}
sync_thread_viewport(terminal, &mut state)?;
state.poll_watermark = poll::current_watermark(conn);
}
KeyCode::Char('?') if is_empty => {
// Show shortcut hints
state.show_hints = true;
textarea.input(Event::Key(key));
}
_ => {
// 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);
}
sync_thread_viewport(terminal, &mut state)?;
state.poll_watermark = poll::current_watermark(conn);
}
KeyCode::Char('?') if is_empty => {
// Show shortcut hints
state.show_hints = true;
textarea.input(Event::Key(key));
}
_ => {
// 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);
}
}
}
Expand Down Expand Up @@ -755,4 +766,26 @@ mod tests {
state: crossterm::event::KeyEventState::NONE,
}));
}

#[test]
fn ctrl_j_is_treated_as_insert_newline() {
assert!(is_insert_newline_key(crossterm::event::KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::CONTROL,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}));
assert!(!is_insert_newline_key(crossterm::event::KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}));
assert!(!is_insert_newline_key(crossterm::event::KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::CONTROL,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}));
}
}
2 changes: 1 addition & 1 deletion crates/liminal-flow-tui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ pub fn filtered_slash_commands(query: &str) -> Vec<(usize, &'static str, &'stati
/// Keyboard shortcut hints shown when ? is typed on an empty line.
pub const SHORTCUT_HINTS: &[(&str, &str)] = &[
("/ for commands (Insert)", "Esc to Normal mode"),
("Enter submits/expands (Insert)", "i switches to Insert"),
("Enter submits/expands (Insert)", "Ctrl+J adds newline"),
("Up/Down move selection", "r resumes selected (Normal)"),
(
"p parks selected branch (Normal)",
Expand Down
1 change: 1 addition & 0 deletions crates/liminal-flow-tui/src/ui/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const HELP_TEXT: &[(&str, &str)] = &[
("? (empty line)", "Show shortcut hints"),
("Up / Down", "Navigate threads & branches"),
("Enter (empty)", "Expand/collapse branches"),
("Ctrl+J", "Insert newline"),
("Mouse wheel", "Scroll Threads or Status"),
("Enter (text)", "Submit input"),
("PageUp / PageDown", "Scroll the Status pane"),
Expand Down
Loading
Loading