Skip to content

Commit 41b4514

Browse files
authored
Grow the Capture input only for multiline drafts (#48)
## Summary ### User-facing changes - **Adaptive capture height:** The TUI Capture input now stays compact for single-line text and grows as explicit new lines are added. - **Visible multiline drafts:** Multi-line notes get a few visible lines of space without permanently shrinking the thread and status panes. ### Documentation - **Behaviour notes:** Updated the README and spec to describe the compact-by-default, grow-on-newline Capture pane. ## Test plan - [x] `cargo fmt --check` - [x] `cargo build` - [x] `cargo clippy --workspace -- -D warnings` - [x] `cargo test`
2 parents a54ff63 + b534c4b commit 41b4514

4 files changed

Lines changed: 78 additions & 26 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ The TUI starts in **Insert mode**:
151151
- When you finish typing a full slash command and begin its argument text, the command palette closes so Enter submits the command normally
152152
- Type `?` on an empty line to see **shortcut hints**
153153
- **Up/Down** arrows navigate the thread list; the thread list auto-scrolls to keep selection visible; **Enter** on empty input expands/collapses branches
154+
- The **Capture** pane grows as you add new lines, so multi-line drafts stay visible without taking extra space up front
154155
- Press `Ctrl+J` in the **Capture** pane to insert a new line without submitting
155156
- Mouse-wheel scrolling follows the hovered pane: `Threads`, `Status`, and `Help` each scroll independently
156157
- Left-click in the thread list selects the clicked thread or branch

SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ The TUI provides a three-pane interface:
112112

113113
- **Left pane (30%):** Thread list with branches indented beneath
114114
- **Right pane (70%):** Detail view for the selected thread or branch, with scope context and recent notes
115-
- **Bottom (3 lines):** Chat-style input with tui-textarea
115+
- **Bottom (3-5 lines):** Chat-style input with tui-textarea that starts compact and grows as explicit new lines are added
116116

117117
### Modes
118118

crates/liminal-flow-tui/src/app.rs

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -162,23 +162,34 @@ fn run_loop(
162162
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
163163
conn: &Connection,
164164
) -> Result<()> {
165+
fn current_input_pane_height(textarea: &TextArea) -> u16 {
166+
layout::input_pane_height(textarea.lines().len())
167+
}
168+
165169
fn terminal_area(
166170
terminal: &Terminal<CrosstermBackend<io::Stdout>>,
167171
) -> Result<ratatui::layout::Rect> {
168172
let size = terminal.size()?;
169173
Ok(ratatui::layout::Rect::new(0, 0, size.width, size.height))
170174
}
171175

172-
fn thread_viewport_height(terminal: &Terminal<CrosstermBackend<io::Stdout>>) -> Result<usize> {
173-
let app_layout = layout::compute(terminal_area(terminal)?);
176+
fn thread_viewport_height(
177+
terminal: &Terminal<CrosstermBackend<io::Stdout>>,
178+
textarea: &TextArea,
179+
) -> Result<usize> {
180+
let app_layout = layout::compute(
181+
terminal_area(terminal)?,
182+
current_input_pane_height(textarea),
183+
);
174184
Ok(app_layout.thread_list.height.saturating_sub(2) as usize)
175185
}
176186

177187
fn sync_thread_viewport(
178188
terminal: &Terminal<CrosstermBackend<io::Stdout>>,
179189
state: &mut TuiState,
190+
textarea: &TextArea,
180191
) -> Result<()> {
181-
let viewport_height = thread_viewport_height(terminal)?;
192+
let viewport_height = thread_viewport_height(terminal, textarea)?;
182193
state.ensure_thread_selection_visible(viewport_height);
183194
state.clamp_thread_list_scroll(viewport_height);
184195
Ok(())
@@ -190,13 +201,13 @@ fn run_loop(
190201

191202
// Initial load
192203
state.refresh_from_db(conn);
193-
sync_thread_viewport(terminal, &mut state)?;
204+
sync_thread_viewport(terminal, &mut state, &textarea)?;
194205
state.poll_watermark = poll::current_watermark(conn);
195206

196207
loop {
197208
// Draw
198209
terminal.draw(|frame| {
199-
let app_layout = layout::compute(frame.area());
210+
let app_layout = layout::compute(frame.area(), current_input_pane_height(&textarea));
200211

201212
layout::render_header(frame, app_layout.header);
202213
thread_list::render(frame, app_layout.thread_list, &state);
@@ -234,7 +245,8 @@ fn run_loop(
234245
match event::read()? {
235246
Event::Mouse(mouse) => {
236247
let terminal_area = terminal_area(terminal)?;
237-
let app_layout = layout::compute(terminal_area);
248+
let app_layout =
249+
layout::compute(terminal_area, current_input_pane_height(&textarea));
238250

239251
if state.mode == Mode::Help {
240252
let popup_area = help::popup_area(terminal_area);
@@ -310,7 +322,7 @@ fn run_loop(
310322
if let Some(selected) = visible_rows.get(row_index) {
311323
state.selected = selected.clone();
312324
state.refresh_selected_details(conn);
313-
sync_thread_viewport(terminal, &mut state)?;
325+
sync_thread_viewport(terminal, &mut state, &textarea)?;
314326
}
315327
}
316328
}
@@ -335,7 +347,7 @@ fn run_loop(
335347
enter_tui_terminal()?;
336348
terminal.clear()?;
337349
state.refresh_from_db(conn);
338-
sync_thread_viewport(terminal, &mut state)?;
350+
sync_thread_viewport(terminal, &mut state, &textarea)?;
339351
state.poll_watermark = poll::current_watermark(conn);
340352
continue;
341353
}
@@ -384,7 +396,7 @@ fn run_loop(
384396
KeyCode::Enter => {
385397
state.toggle_expanded();
386398
state.refresh_selected_details(conn);
387-
sync_thread_viewport(terminal, &mut state)?;
399+
sync_thread_viewport(terminal, &mut state, &textarea)?;
388400
}
389401
KeyCode::Char('r') => {
390402
// Resume/activate the selected thread or branch
@@ -409,7 +421,7 @@ fn run_loop(
409421
apply_input_result(&mut state, result);
410422
state.refresh_from_db(conn);
411423
state.select_active_item();
412-
sync_thread_viewport(terminal, &mut state)?;
424+
sync_thread_viewport(terminal, &mut state, &textarea)?;
413425
state.poll_watermark = poll::current_watermark(conn);
414426
}
415427
}
@@ -439,7 +451,7 @@ fn run_loop(
439451
apply_input_result(&mut state, result);
440452
state.refresh_from_db(conn);
441453
state.select_active_item();
442-
sync_thread_viewport(terminal, &mut state)?;
454+
sync_thread_viewport(terminal, &mut state, &textarea)?;
443455
state.poll_watermark = poll::current_watermark(conn);
444456
}
445457
}
@@ -465,7 +477,7 @@ fn run_loop(
465477
if let Some(result) = result {
466478
apply_input_result(&mut state, result);
467479
state.refresh_from_db(conn);
468-
sync_thread_viewport(terminal, &mut state)?;
480+
sync_thread_viewport(terminal, &mut state, &textarea)?;
469481
state.poll_watermark = poll::current_watermark(conn);
470482
}
471483
}
@@ -491,19 +503,19 @@ fn run_loop(
491503
if let Some(result) = result {
492504
apply_input_result(&mut state, result);
493505
state.refresh_from_db(conn);
494-
sync_thread_viewport(terminal, &mut state)?;
506+
sync_thread_viewport(terminal, &mut state, &textarea)?;
495507
state.poll_watermark = poll::current_watermark(conn);
496508
}
497509
}
498510
KeyCode::Char('j') | KeyCode::Down => {
499511
state.select_next();
500512
state.refresh_selected_details(conn);
501-
sync_thread_viewport(terminal, &mut state)?;
513+
sync_thread_viewport(terminal, &mut state, &textarea)?;
502514
}
503515
KeyCode::Char('k') | KeyCode::Up => {
504516
state.select_prev();
505517
state.refresh_selected_details(conn);
506-
sync_thread_viewport(terminal, &mut state)?;
518+
sync_thread_viewport(terminal, &mut state, &textarea)?;
507519
}
508520
KeyCode::PageUp => {
509521
state.status_scroll = state.status_scroll.saturating_sub(5);
@@ -631,13 +643,13 @@ fn run_loop(
631643
// Arrow keys navigate the thread list
632644
state.select_prev();
633645
state.refresh_selected_details(conn);
634-
sync_thread_viewport(terminal, &mut state)?;
646+
sync_thread_viewport(terminal, &mut state, &textarea)?;
635647
}
636648
KeyCode::Down => {
637649
// Arrow keys navigate the thread list
638650
state.select_next();
639651
state.refresh_selected_details(conn);
640-
sync_thread_viewport(terminal, &mut state)?;
652+
sync_thread_viewport(terminal, &mut state, &textarea)?;
641653
}
642654
KeyCode::Enter => {
643655
// If input is empty, toggle thread expansion
@@ -646,7 +658,9 @@ fn run_loop(
646658
if is_empty {
647659
state.toggle_expanded();
648660
state.refresh_selected_details(conn);
649-
sync_thread_viewport(terminal, &mut state)?;
661+
sync_thread_viewport(
662+
terminal, &mut state, &textarea,
663+
)?;
650664
continue;
651665
}
652666

@@ -676,7 +690,7 @@ fn run_loop(
676690
if follow_active {
677691
state.select_active_item();
678692
}
679-
sync_thread_viewport(terminal, &mut state)?;
693+
sync_thread_viewport(terminal, &mut state, &textarea)?;
680694
state.poll_watermark = poll::current_watermark(conn);
681695
}
682696
KeyCode::Char('?') if is_empty => {
@@ -706,7 +720,7 @@ fn run_loop(
706720
// Check for external DB changes (from CLI in another terminal)
707721
if poll::has_changes(conn, &state.poll_watermark) {
708722
state.refresh_from_db(conn);
709-
sync_thread_viewport(terminal, &mut state)?;
723+
sync_thread_viewport(terminal, &mut state, &textarea)?;
710724
state.poll_watermark = poll::current_watermark(conn);
711725
}
712726
}

crates/liminal-flow-tui/src/ui/layout.rs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ use ratatui::Frame;
1010

1111
use crate::ui::theme;
1212

13+
const MIN_INPUT_PANE_HEIGHT: u16 = 3;
14+
const MAX_INPUT_PANE_HEIGHT: u16 = 5;
15+
1316
/// The three regions of the TUI layout.
1417
pub struct AppLayout {
1518
pub header: Rect,
@@ -30,14 +33,21 @@ pub struct AppLayout {
3033
/// │ > Input │
3134
/// └───────────────────────────────────────────────────────┘
3235
/// ```
33-
pub fn compute(area: Rect) -> AppLayout {
34-
// Vertical: header (1) + body (flex) + input (3)
36+
pub fn input_pane_height(line_count: usize) -> u16 {
37+
let content_height = u16::try_from(line_count)
38+
.unwrap_or(u16::MAX)
39+
.saturating_add(2);
40+
content_height.clamp(MIN_INPUT_PANE_HEIGHT, MAX_INPUT_PANE_HEIGHT)
41+
}
42+
43+
pub fn compute(area: Rect, input_pane_height: u16) -> AppLayout {
44+
// Vertical: header (1) + body (flex) + input (dynamic)
3545
let vertical = Layout::default()
3646
.direction(Direction::Vertical)
3747
.constraints([
38-
Constraint::Length(1), // header
39-
Constraint::Min(5), // body
40-
Constraint::Length(3), // input
48+
Constraint::Length(1), // header
49+
Constraint::Min(5), // body
50+
Constraint::Length(input_pane_height), // input
4151
])
4252
.split(area);
4353

@@ -106,3 +116,30 @@ pub fn contains_point(rect: Rect, column: u16, row: u16) -> bool {
106116
&& row >= rect.y
107117
&& row < rect.y.saturating_add(rect.height)
108118
}
119+
120+
#[cfg(test)]
121+
mod tests {
122+
use super::*;
123+
124+
#[test]
125+
fn input_pane_height_stays_compact_for_single_line_input() {
126+
assert_eq!(input_pane_height(1), MIN_INPUT_PANE_HEIGHT);
127+
}
128+
129+
#[test]
130+
fn input_pane_height_grows_for_multiline_input() {
131+
assert_eq!(input_pane_height(2), 4);
132+
assert_eq!(input_pane_height(3), MAX_INPUT_PANE_HEIGHT);
133+
}
134+
135+
#[test]
136+
fn input_pane_height_stops_growing_after_cap() {
137+
assert_eq!(input_pane_height(10), MAX_INPUT_PANE_HEIGHT);
138+
}
139+
140+
#[test]
141+
fn compute_uses_requested_input_pane_height() {
142+
let layout = compute(Rect::new(0, 0, 120, 40), 4);
143+
assert_eq!(layout.input_pane.height, 4);
144+
}
145+
}

0 commit comments

Comments
 (0)