diff --git a/README.md b/README.md index 910e3a6..6a33f4d 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ The TUI starts in **Insert mode**: - **Up/Down** arrows navigate the thread list; the thread list auto-scrolls to keep selection visible; **Enter** on empty input expands/collapses branches - The **Capture** pane grows as you add new lines, so multi-line drafts stay visible without taking extra space up front - Press `Ctrl+J` in the **Capture** pane to insert a new line without submitting +- Press `Ctrl+Up` and `Ctrl+Down` in the **Capture** pane to browse plain-text note history from the current session and return to the in-flight draft at the bottom of the stack - 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 diff --git a/SPEC.md b/SPEC.md index 484c496..0a2f62a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -118,7 +118,7 @@ The TUI provides a three-pane interface: | Mode | Description | |---|---| -| Insert | Text input active. Enter submits, `Ctrl+J` inserts a new line, and Esc switches to Normal. | +| Insert | Text input active. Enter submits, `Ctrl+J` inserts a new line, `Ctrl+Up` and `Ctrl+Down` browse session note history, 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. | @@ -131,6 +131,7 @@ The thread list supports navigating both threads and their branches: - 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 +- **Ctrl+Up** and **Ctrl+Down** browse plain-text note history from the current TUI session and can restore the current in-flight draft - 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 diff --git a/crates/liminal-flow-tui/src/app.rs b/crates/liminal-flow-tui/src/app.rs index 62cb5c6..0029dac 100644 --- a/crates/liminal-flow-tui/src/app.rs +++ b/crates/liminal-flow-tui/src/app.rs @@ -19,7 +19,7 @@ use crossterm::terminal::{ use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use rusqlite::Connection; -use tui_textarea::TextArea; +use tui_textarea::{CursorMove, TextArea}; use crate::input::{self, InputResult}; use crate::poll; @@ -117,6 +117,43 @@ fn is_insert_newline_key(key: crossterm::event::KeyEvent) -> bool { key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('j') } +fn is_capture_history_previous_key(key: crossterm::event::KeyEvent) -> bool { + key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Up +} + +fn is_capture_history_next_key(key: crossterm::event::KeyEvent) -> bool { + key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Down +} + +fn is_note_history_entry(input: &str) -> bool { + let trimmed = input.trim(); + !trimmed.is_empty() && !trimmed.starts_with('/') +} + +fn textarea_value(textarea: &TextArea) -> String { + textarea.lines().join("\n") +} + +fn reset_textarea(textarea: &mut TextArea) { + *textarea = TextArea::default(); + textarea.set_cursor_line_style(ratatui::style::Style::default()); +} + +fn replace_textarea_value(textarea: &mut TextArea, value: &str) { + reset_textarea(textarea); + if !value.is_empty() { + textarea.insert_str(value); + } +} + +fn textarea_cursor_is_on_first_line(textarea: &TextArea) -> bool { + textarea.cursor().0 == 0 +} + +fn textarea_cursor_is_on_last_line(textarea: &TextArea) -> bool { + textarea.cursor().0 + 1 >= textarea.lines().len() +} + fn selected_command_target(state: &TuiState) -> Option { match &state.selected { SelectedItem::Thread(i) => state @@ -546,9 +583,7 @@ fn run_loop( KeyCode::Esc => { state.show_command_palette = false; // Clear the `/` from the textarea - textarea = TextArea::default(); - textarea - .set_cursor_line_style(ratatui::style::Style::default()); + reset_textarea(&mut textarea); } KeyCode::Up => { let query = textarea.lines().join("\n"); @@ -577,11 +612,7 @@ fn run_loop( { let completed = complete_command_palette_selection(&query, cmd); - textarea = TextArea::default(); - textarea.set_cursor_line_style( - ratatui::style::Style::default(), - ); - textarea.insert_str(completed); + replace_textarea_value(&mut textarea, &completed); state.show_command_palette = false; } } @@ -607,34 +638,50 @@ fn run_loop( match key.code { KeyCode::Esc => { state.show_hints = false; - textarea = TextArea::default(); - textarea - .set_cursor_line_style(ratatui::style::Style::default()); + reset_textarea(&mut textarea); } KeyCode::Backspace => { state.show_hints = false; - textarea = TextArea::default(); - textarea - .set_cursor_line_style(ratatui::style::Style::default()); + reset_textarea(&mut textarea); } _ => { state.show_hints = false; // Clear the `?` and forward the new key - textarea = TextArea::default(); - textarea - .set_cursor_line_style(ratatui::style::Style::default()); + reset_textarea(&mut textarea); textarea.input(Event::Key(key)); } } } else { // Normal Insert mode handling - if is_insert_newline_key(key) { + if is_capture_history_previous_key(key) { + if textarea_cursor_is_on_first_line(&textarea) { + if let Some(previous) = state + .previous_capture_history(&textarea_value(&textarea)) + { + replace_textarea_value(&mut textarea, &previous); + refresh_command_palette_state(&mut state, &previous); + } + } else { + textarea.move_cursor(CursorMove::Up); + } + } else if is_capture_history_next_key(key) { + if textarea_cursor_is_on_last_line(&textarea) { + if let Some(next) = state.next_capture_history() { + replace_textarea_value(&mut textarea, &next); + refresh_command_palette_state(&mut state, &next); + } + } else { + textarea.move_cursor(CursorMove::Down); + } + } else if is_insert_newline_key(key) { + state.clear_capture_history_navigation(); textarea.insert_newline(); - let query = textarea.lines().join("\n"); + let query = textarea_value(&textarea); refresh_command_palette_state(&mut state, &query); } else { match key.code { KeyCode::Esc => { + state.clear_capture_history_navigation(); state.mode = Mode::Normal; state.show_command_palette = false; state.show_hints = false; @@ -667,12 +714,11 @@ fn run_loop( // Submit the input let lines: Vec = textarea.lines().to_vec(); let text = lines.join("\n"); + let should_record_note = is_note_history_entry(&text); // Clear the textarea - textarea = TextArea::default(); - textarea.set_cursor_line_style( - ratatui::style::Style::default(), - ); + reset_textarea(&mut textarea); + state.clear_capture_history_navigation(); // Process the input let follow_active = @@ -683,7 +729,13 @@ fn run_loop( &text, command_target.as_ref(), ); + let record_note = + matches!(result, InputResult::Reply(_)) + && should_record_note; apply_input_result(&mut state, result); + if record_note { + state.push_capture_history(&text); + } // Refresh state from DB after mutation state.refresh_from_db(conn); @@ -694,17 +746,27 @@ fn run_loop( state.poll_watermark = poll::current_watermark(conn); } KeyCode::Char('?') if is_empty => { + state.clear_capture_history_navigation(); // Show shortcut hints state.show_hints = true; textarea.input(Event::Key(key)); } _ => { + if matches!( + key.code, + KeyCode::Backspace + | KeyCode::Delete + | KeyCode::Char(_) + | KeyCode::Tab + ) { + state.clear_capture_history_navigation(); + } // 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"); + let query = textarea_value(&textarea); refresh_command_palette_state(&mut state, &query); } } @@ -802,4 +864,55 @@ mod tests { state: crossterm::event::KeyEventState::NONE, })); } + + #[test] + fn ctrl_up_is_treated_as_capture_history_previous() { + assert!(is_capture_history_previous_key( + crossterm::event::KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::CONTROL, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + } + )); + assert!(!is_capture_history_previous_key( + crossterm::event::KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + } + )); + } + + #[test] + fn ctrl_down_is_treated_as_capture_history_next() { + assert!(is_capture_history_next_key(crossterm::event::KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::CONTROL, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + })); + assert!(!is_capture_history_next_key(crossterm::event::KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + })); + } + + #[test] + fn textarea_first_and_last_line_detection_tracks_cursor_position() { + let mut textarea = TextArea::from(["first", "second", "third"]); + assert!(textarea_cursor_is_on_first_line(&textarea)); + assert!(!textarea_cursor_is_on_last_line(&textarea)); + + textarea.move_cursor(CursorMove::Down); + assert!(!textarea_cursor_is_on_first_line(&textarea)); + assert!(!textarea_cursor_is_on_last_line(&textarea)); + + textarea.move_cursor(CursorMove::Down); + assert!(!textarea_cursor_is_on_first_line(&textarea)); + assert!(textarea_cursor_is_on_last_line(&textarea)); + } } diff --git a/crates/liminal-flow-tui/src/state.rs b/crates/liminal-flow-tui/src/state.rs index 37b9b8f..ca45872 100644 --- a/crates/liminal-flow-tui/src/state.rs +++ b/crates/liminal-flow-tui/src/state.rs @@ -10,6 +10,8 @@ use liminal_flow_core::model::{Branch, Capture, FlowId, Thread, ThreadStatus}; use liminal_flow_store::repo::{branch_repo, capture_repo, scope_repo, thread_repo}; use rusqlite::Connection; +const MAX_CAPTURE_HISTORY: usize = 100; + /// Interaction mode for the TUI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { @@ -184,7 +186,7 @@ pub fn filtered_slash_commands(query: &str) -> Vec<(usize, &'static str, &'stati pub const SHORTCUT_HINTS: &[(&str, &str)] = &[ ("/ for commands (Insert)", "Esc to Normal mode"), ("Enter submits/expands (Insert)", "Ctrl+J adds newline"), - ("Up/Down move selection", "r resumes selected (Normal)"), + ("Ctrl+Up/Down browse notes", "Up/Down move selection"), ( "p parks selected branch (Normal)", "d marks selected done (Normal)", @@ -236,6 +238,12 @@ pub struct TuiState { pub expanded: HashSet, /// Recent notes (captures) for the selected thread or branch, shown in the status pane. pub selected_notes: Vec, + /// Session-only plain-text note history for the Capture input. + pub capture_history: Vec, + /// Current index within `capture_history` while browsing with Ctrl+Up/Down. + pub capture_history_index: Option, + /// Draft text saved before history browsing so Ctrl+Down can restore it. + pub capture_history_draft: Option, } impl Default for TuiState { @@ -263,6 +271,60 @@ impl TuiState { help_scroll: 0, expanded: HashSet::new(), selected_notes: Vec::new(), + capture_history: Vec::new(), + capture_history_index: None, + capture_history_draft: None, + } + } + + pub fn clear_capture_history_navigation(&mut self) { + self.capture_history_index = None; + self.capture_history_draft = None; + } + + pub fn push_capture_history(&mut self, note: &str) { + let trimmed = note.trim(); + if trimmed.is_empty() || trimmed.starts_with('/') { + return; + } + + self.clear_capture_history_navigation(); + + if self.capture_history.len() == MAX_CAPTURE_HISTORY { + self.capture_history.remove(0); + } + + self.capture_history.push(note.to_string()); + } + + pub fn previous_capture_history(&mut self, draft: &str) -> Option { + if self.capture_history.is_empty() { + return None; + } + + let next_index = match self.capture_history_index { + Some(0) => 0, + Some(index) => index.saturating_sub(1), + None => { + self.capture_history_draft = Some(draft.to_string()); + self.capture_history.len() - 1 + } + }; + + self.capture_history_index = Some(next_index); + self.capture_history.get(next_index).cloned() + } + + pub fn next_capture_history(&mut self) -> Option { + let index = self.capture_history_index?; + + if index + 1 < self.capture_history.len() { + let next_index = index + 1; + self.capture_history_index = Some(next_index); + self.capture_history.get(next_index).cloned() + } else { + self.capture_history_index = None; + Some(self.capture_history_draft.take().unwrap_or_default()) } } @@ -714,6 +776,75 @@ mod tests { assert_eq!(state.selected_notes[0].text, "note on branch two"); } + #[test] + fn capture_history_ignores_blank_and_slash_inputs() { + let mut state = TuiState::new(); + state.push_capture_history(""); + state.push_capture_history(" "); + state.push_capture_history("/done"); + assert!(state.capture_history.is_empty()); + } + + #[test] + fn capture_history_moves_back_through_session_notes() { + let mut state = TuiState::new(); + state.push_capture_history("first note"); + state.push_capture_history("second note"); + + assert_eq!( + state.previous_capture_history("draft in flight"), + Some("second note".to_string()) + ); + assert_eq!( + state.previous_capture_history("ignored"), + Some("first note".to_string()) + ); + assert_eq!( + state.previous_capture_history("ignored"), + Some("first note".to_string()) + ); + } + + #[test] + fn capture_history_restores_the_saved_draft_at_the_bottom() { + let mut state = TuiState::new(); + state.push_capture_history("first note"); + state.push_capture_history("second note"); + + assert_eq!( + state.previous_capture_history("draft in flight"), + Some("second note".to_string()) + ); + assert_eq!( + state.next_capture_history(), + Some("draft in flight".to_string()) + ); + assert_eq!(state.capture_history_index, None); + assert_eq!(state.capture_history_draft, None); + } + + #[test] + fn capture_history_moves_forward_after_multiple_steps_back() { + let mut state = TuiState::new(); + state.push_capture_history("first note"); + state.push_capture_history("second note"); + state.push_capture_history("third note"); + + assert_eq!( + state.previous_capture_history("draft in flight"), + Some("third note".to_string()) + ); + assert_eq!( + state.previous_capture_history("ignored"), + Some("second note".to_string()) + ); + assert_eq!(state.next_capture_history(), Some("third note".to_string())); + assert_eq!( + state.next_capture_history(), + Some("draft in flight".to_string()) + ); + } + #[test] fn slash_commands_filter_by_query() { let filtered = filtered_slash_commands("/res"); diff --git a/crates/liminal-flow-tui/src/ui/command_palette.rs b/crates/liminal-flow-tui/src/ui/command_palette.rs index 12cef29..5769318 100644 --- a/crates/liminal-flow-tui/src/ui/command_palette.rs +++ b/crates/liminal-flow-tui/src/ui/command_palette.rs @@ -14,6 +14,65 @@ use ratatui::Frame; use crate::state::{filtered_slash_commands, TuiState}; use crate::ui::theme; +fn truncate_for_width(text: &str, width: usize) -> String { + let chars: Vec = text.chars().collect(); + if chars.len() <= width { + return text.to_string(); + } + + if width <= 1 { + return "…".repeat(width); + } + + let visible: String = chars.into_iter().take(width - 1).collect(); + format!("{visible}…") +} + +fn command_palette_row( + cmd: &str, + desc: &str, + is_selected: bool, + popup_width: u16, +) -> Line<'static> { + let inner_width = usize::from(popup_width.saturating_sub(2)); + let marker = if is_selected { " > " } else { " " }; + let marker_width = marker.chars().count(); + let gap_width = 1; + let min_cmd_width = 12; + let max_cmd_width = 20; + let available_after_marker = inner_width.saturating_sub(marker_width); + let cmd_width = available_after_marker + .saturating_sub(gap_width) + .min(max_cmd_width) + .max(min_cmd_width) + .min(available_after_marker); + let desc_width = available_after_marker + .saturating_sub(cmd_width) + .saturating_sub(gap_width); + + let cmd_text = truncate_for_width(cmd, cmd_width); + let cmd_padded = format!("{cmd_text: " } else { " " }; - - ListItem::new(Line::from(vec![ - Span::styled(marker, cmd_style), - Span::styled(format!("{cmd:<20}"), cmd_style), - Span::styled(*desc, desc_style), - ])) + ListItem::new(command_palette_row( + cmd, + desc, + is_selected, + popup_area.width, + )) }) .collect() }; @@ -78,3 +126,30 @@ pub fn render(frame: &mut Frame, input_area: Rect, state: &TuiState, query: &str let list = List::new(items).block(block); frame.render_widget(list, popup_area); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_palette_row_stays_within_inner_width() { + let line = command_palette_row( + "/branch ", + "Start a branch beneath current thread", + true, + 40, + ); + let rendered_width: usize = line + .spans + .iter() + .map(|span| span.content.chars().count()) + .sum(); + assert!(rendered_width <= 38); + } + + #[test] + fn truncate_for_width_adds_ellipsis_when_needed() { + assert_eq!(truncate_for_width("branch", 4), "bra…"); + assert_eq!(truncate_for_width("ok", 4), "ok"); + } +} diff --git a/crates/liminal-flow-tui/src/ui/help.rs b/crates/liminal-flow-tui/src/ui/help.rs index 025e548..fc17bc8 100644 --- a/crates/liminal-flow-tui/src/ui/help.rs +++ b/crates/liminal-flow-tui/src/ui/help.rs @@ -30,6 +30,7 @@ const HELP_TEXT: &[(&str, &str)] = &[ ("/ (empty line)", "Open command palette"), ("? (empty line)", "Show shortcut hints"), ("Up / Down", "Navigate threads & branches"), + ("Ctrl+Up / Ctrl+Down", "Browse session note history"), ("Enter (empty)", "Expand/collapse branches"), ("Ctrl+J", "Insert newline"), ("Mouse wheel", "Scroll Threads or Status"), diff --git a/crates/liminal-flow-tui/src/ui/reply_pane.rs b/crates/liminal-flow-tui/src/ui/reply_pane.rs index e15caf7..78f9da0 100644 --- a/crates/liminal-flow-tui/src/ui/reply_pane.rs +++ b/crates/liminal-flow-tui/src/ui/reply_pane.rs @@ -12,6 +12,18 @@ use ratatui::Frame; use crate::state::TuiState; use crate::ui::theme; +fn note_lines(note_text: &str) -> Vec> { + note_text + .split('\n') + .map(|line| { + Line::from(vec![ + Span::styled(" ".to_string(), theme::text()), + Span::styled(line.to_string(), theme::text()), + ]) + }) + .collect() +} + /// Render the reply/status pane into the given area. pub fn render(frame: &mut Frame, area: Rect, state: &TuiState) { let mut lines: Vec = Vec::new(); @@ -68,10 +80,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &TuiState) { theme::muted(), ), ])); - lines.push(Line::from(vec![ - Span::styled(" ", theme::text()), - Span::styled(note.text.clone(), theme::text()), - ])); + lines.extend(note_lines(¬e.text)); } } @@ -121,3 +130,17 @@ pub fn render(frame: &mut Frame, area: Rect, state: &TuiState) { .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn note_lines_preserve_embedded_newlines() { + let lines = note_lines("first line\nsecond line\n"); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].spans[1].content.as_ref(), "first line"); + assert_eq!(lines[1].spans[1].content.as_ref(), "second line"); + assert_eq!(lines[2].spans[1].content.as_ref(), ""); + } +}