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
- 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
Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 34 additions & 20 deletions crates/liminal-flow-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,23 +162,34 @@ fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
conn: &Connection,
) -> Result<()> {
fn current_input_pane_height(textarea: &TextArea) -> u16 {
layout::input_pane_height(textarea.lines().len())
}

fn terminal_area(
terminal: &Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<ratatui::layout::Rect> {
let size = terminal.size()?;
Ok(ratatui::layout::Rect::new(0, 0, size.width, size.height))
}

fn thread_viewport_height(terminal: &Terminal<CrosstermBackend<io::Stdout>>) -> Result<usize> {
let app_layout = layout::compute(terminal_area(terminal)?);
fn thread_viewport_height(
terminal: &Terminal<CrosstermBackend<io::Stdout>>,
textarea: &TextArea,
) -> Result<usize> {
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<CrosstermBackend<io::Stdout>>,
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(())
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)?;
}
}
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
}
}
Expand Down
47 changes: 42 additions & 5 deletions crates/liminal-flow-tui/src/ui/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand Down Expand Up @@ -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);
}
}
Loading