diff --git a/README.md b/README.md index 7dac8a2..910e3a6 100644 --- a/README.md +++ b/README.md @@ -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 +- 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 - 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 diff --git a/SPEC.md b/SPEC.md index 20b8949..484c496 100644 --- a/SPEC.md +++ b/SPEC.md @@ -112,7 +112,7 @@ The TUI provides a three-pane interface: - **Left pane (30%):** Thread list with branches indented beneath - **Right pane (70%):** Detail view for the selected thread or branch, with scope context and recent notes -- **Bottom (3 lines):** Chat-style input with tui-textarea +- **Bottom (3-5 lines):** Chat-style input with tui-textarea that starts compact and grows as explicit new lines are added ### Modes diff --git a/crates/liminal-flow-tui/src/app.rs b/crates/liminal-flow-tui/src/app.rs index 97554d9..62cb5c6 100644 --- a/crates/liminal-flow-tui/src/app.rs +++ b/crates/liminal-flow-tui/src/app.rs @@ -162,6 +162,10 @@ fn run_loop( terminal: &mut Terminal>, conn: &Connection, ) -> Result<()> { + fn current_input_pane_height(textarea: &TextArea) -> u16 { + layout::input_pane_height(textarea.lines().len()) + } + fn terminal_area( terminal: &Terminal>, ) -> Result { @@ -169,16 +173,23 @@ fn run_loop( Ok(ratatui::layout::Rect::new(0, 0, size.width, size.height)) } - fn thread_viewport_height(terminal: &Terminal>) -> Result { - let app_layout = layout::compute(terminal_area(terminal)?); + fn thread_viewport_height( + terminal: &Terminal>, + textarea: &TextArea, + ) -> Result { + let app_layout = layout::compute( + terminal_area(terminal)?, + current_input_pane_height(textarea), + ); Ok(app_layout.thread_list.height.saturating_sub(2) as usize) } fn sync_thread_viewport( terminal: &Terminal>, state: &mut TuiState, + textarea: &TextArea, ) -> Result<()> { - let viewport_height = thread_viewport_height(terminal)?; + let viewport_height = thread_viewport_height(terminal, textarea)?; state.ensure_thread_selection_visible(viewport_height); state.clamp_thread_list_scroll(viewport_height); Ok(()) @@ -190,13 +201,13 @@ fn run_loop( // Initial load state.refresh_from_db(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; state.poll_watermark = poll::current_watermark(conn); loop { // Draw terminal.draw(|frame| { - let app_layout = layout::compute(frame.area()); + let app_layout = layout::compute(frame.area(), current_input_pane_height(&textarea)); layout::render_header(frame, app_layout.header); thread_list::render(frame, app_layout.thread_list, &state); @@ -234,7 +245,8 @@ fn run_loop( match event::read()? { Event::Mouse(mouse) => { let terminal_area = terminal_area(terminal)?; - let app_layout = layout::compute(terminal_area); + let app_layout = + layout::compute(terminal_area, current_input_pane_height(&textarea)); if state.mode == Mode::Help { let popup_area = help::popup_area(terminal_area); @@ -310,7 +322,7 @@ fn run_loop( if let Some(selected) = visible_rows.get(row_index) { state.selected = selected.clone(); state.refresh_selected_details(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; } } } @@ -335,7 +347,7 @@ fn run_loop( enter_tui_terminal()?; terminal.clear()?; state.refresh_from_db(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; state.poll_watermark = poll::current_watermark(conn); continue; } @@ -384,7 +396,7 @@ fn run_loop( KeyCode::Enter => { state.toggle_expanded(); state.refresh_selected_details(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; } KeyCode::Char('r') => { // Resume/activate the selected thread or branch @@ -409,7 +421,7 @@ fn run_loop( apply_input_result(&mut state, result); state.refresh_from_db(conn); state.select_active_item(); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; state.poll_watermark = poll::current_watermark(conn); } } @@ -439,7 +451,7 @@ fn run_loop( apply_input_result(&mut state, result); state.refresh_from_db(conn); state.select_active_item(); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; state.poll_watermark = poll::current_watermark(conn); } } @@ -465,7 +477,7 @@ fn run_loop( if let Some(result) = result { apply_input_result(&mut state, result); state.refresh_from_db(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; state.poll_watermark = poll::current_watermark(conn); } } @@ -491,19 +503,19 @@ fn run_loop( if let Some(result) = result { apply_input_result(&mut state, result); state.refresh_from_db(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; state.poll_watermark = poll::current_watermark(conn); } } KeyCode::Char('j') | KeyCode::Down => { state.select_next(); state.refresh_selected_details(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; } KeyCode::Char('k') | KeyCode::Up => { state.select_prev(); state.refresh_selected_details(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; } KeyCode::PageUp => { state.status_scroll = state.status_scroll.saturating_sub(5); @@ -631,13 +643,13 @@ fn run_loop( // Arrow keys navigate the thread list state.select_prev(); state.refresh_selected_details(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; } KeyCode::Down => { // Arrow keys navigate the thread list state.select_next(); state.refresh_selected_details(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; } KeyCode::Enter => { // If input is empty, toggle thread expansion @@ -646,7 +658,9 @@ fn run_loop( if is_empty { state.toggle_expanded(); state.refresh_selected_details(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport( + terminal, &mut state, &textarea, + )?; continue; } @@ -676,7 +690,7 @@ fn run_loop( if follow_active { state.select_active_item(); } - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; state.poll_watermark = poll::current_watermark(conn); } KeyCode::Char('?') if is_empty => { @@ -706,7 +720,7 @@ fn run_loop( // Check for external DB changes (from CLI in another terminal) if poll::has_changes(conn, &state.poll_watermark) { state.refresh_from_db(conn); - sync_thread_viewport(terminal, &mut state)?; + sync_thread_viewport(terminal, &mut state, &textarea)?; state.poll_watermark = poll::current_watermark(conn); } } diff --git a/crates/liminal-flow-tui/src/ui/layout.rs b/crates/liminal-flow-tui/src/ui/layout.rs index 0ae889e..886adba 100644 --- a/crates/liminal-flow-tui/src/ui/layout.rs +++ b/crates/liminal-flow-tui/src/ui/layout.rs @@ -10,6 +10,9 @@ use ratatui::Frame; use crate::ui::theme; +const MIN_INPUT_PANE_HEIGHT: u16 = 3; +const MAX_INPUT_PANE_HEIGHT: u16 = 5; + /// The three regions of the TUI layout. pub struct AppLayout { pub header: Rect, @@ -30,14 +33,21 @@ pub struct AppLayout { /// │ > Input │ /// └───────────────────────────────────────────────────────┘ /// ``` -pub fn compute(area: Rect) -> AppLayout { - // Vertical: header (1) + body (flex) + input (3) +pub fn input_pane_height(line_count: usize) -> u16 { + let content_height = u16::try_from(line_count) + .unwrap_or(u16::MAX) + .saturating_add(2); + content_height.clamp(MIN_INPUT_PANE_HEIGHT, MAX_INPUT_PANE_HEIGHT) +} + +pub fn compute(area: Rect, input_pane_height: u16) -> AppLayout { + // Vertical: header (1) + body (flex) + input (dynamic) let vertical = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // header - Constraint::Min(5), // body - Constraint::Length(3), // input + Constraint::Length(1), // header + Constraint::Min(5), // body + Constraint::Length(input_pane_height), // input ]) .split(area); @@ -106,3 +116,30 @@ pub fn contains_point(rect: Rect, column: u16, row: u16) -> bool { && row >= rect.y && row < rect.y.saturating_add(rect.height) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_pane_height_stays_compact_for_single_line_input() { + assert_eq!(input_pane_height(1), MIN_INPUT_PANE_HEIGHT); + } + + #[test] + fn input_pane_height_grows_for_multiline_input() { + assert_eq!(input_pane_height(2), 4); + assert_eq!(input_pane_height(3), MAX_INPUT_PANE_HEIGHT); + } + + #[test] + fn input_pane_height_stops_growing_after_cap() { + assert_eq!(input_pane_height(10), MAX_INPUT_PANE_HEIGHT); + } + + #[test] + fn compute_uses_requested_input_pane_height() { + let layout = compute(Rect::new(0, 0, 120, 40), 4); + assert_eq!(layout.input_pane.height, 4); + } +}