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
4 changes: 3 additions & 1 deletion src/commands/list/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,6 @@ Measures pure skeleton latency. Target: <60ms.
- `collect/` — orchestrates collection, manages pre/post-skeleton phases, task definitions and execution (see `collect/mod.rs` module docstring for phase details)
- `render.rs` — row formatting, skeleton rows, cell rendering
- `layout.rs` — column width calculation
- `progressive_table.rs` — terminal rendering with in-place updates
- `progressive_table.rs` — terminal rendering with in-place updates; reserves
blank rows below the table so the shell prompt printed after exit renders
into pre-scrolled rows instead of jerking the settled table up
57 changes: 46 additions & 11 deletions src/commands/list/progressive_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
//! - Uses our own escape-aware width calculations (StyledLine, truncate_visible)
//! - Supports OSC-8 hyperlinks correctly
//! - Has predictable cursor behavior based on our rendering logic
//! - Reserves blank rows below the table ([`PROMPT_RESERVE_LINES`]) so the
//! shell prompt printed after exit doesn't scroll the settled table

use crossterm::{
ExecutableCommand,
Expand All @@ -17,6 +19,32 @@ use std::io::{IsTerminal, Write, stdout};

use crate::display::truncate_visible;

/// Blank rows kept below the table so the shell prompt printed after exit
/// renders into pre-scrolled rows instead of scrolling the settled table.
///
/// The table sits on screen for the whole progressive phase, so a scroll at
/// exit — the terminal making room for the shell's multi-line prompt — reads
/// as a jarring jump at an unpredictable moment. Emitting the blank rows with
/// the skeleton moves that scroll to command start, where output is expected
/// to scroll. The prompt's height is unknowable, but the failure modes are
/// asymmetric: extra reserved rows are consumed invisibly by whatever prints
/// next, while a prompt taller than `PROMPT_RESERVE_LINES + 1` rows scrolls
/// by just the difference. Two rows absorb prompts up to three rows tall
/// (fish default 1, starship default 2, tide/powerlevel10k typically 3).
const PROMPT_RESERVE_LINES: u16 = 2;

/// Emit the prompt-reserve rows and move the cursor back up over them,
/// returning it to the resting position (the line after the last content
/// line). Callers emit this after the last content write, so the reserve
/// always sits between the table and the shell prompt.
fn write_prompt_reserve(stdout: &mut std::io::Stdout) -> std::io::Result<()> {
for _ in 0..PROMPT_RESERVE_LINES {
writeln!(stdout)?;
}
stdout.execute(MoveUp(PROMPT_RESERVE_LINES))?;
Ok(())
}

/// Progressive table that updates rows in-place using crossterm cursor control.
///
/// The table structure is:
Expand All @@ -25,6 +53,11 @@ use crate::display::truncate_visible;
/// - Spacer (blank line)
/// - Footer (loading status / summary)
///
/// Below the footer sit [`PROMPT_RESERVE_LINES`] pre-scrolled blank rows.
/// The cursor rests on the line after the footer (the first reserved row)
/// between flushes, so all redraw math is relative to that fixed resting
/// position and the reserve stays invisible to it.
///
/// Data mutation (`update_row`, `update_footer`) is separate from rendering (`flush`).
/// Call `flush()` after updates to write changes to the terminal.
pub struct ProgressiveTable {
Expand Down Expand Up @@ -79,11 +112,11 @@ impl ProgressiveTable {
) -> Self {
let total_row_count = skeletons.len();

// Limit visible rows to fit in terminal: header + rows + spacer + footer = rows + 3
// Reserve one extra line for the cursor position after printing.
// Limit visible rows to fit in terminal: header + rows + spacer + footer
// = rows + 3, plus the prompt-reserve rows and one line for the cursor.
// Only limit when we have height info — None means non-TTY or unknown.
let visible_row_count = terminal_height
.map(|h| total_row_count.min(h.saturating_sub(4)))
.map(|h| total_row_count.min(h.saturating_sub(4 + PROMPT_RESERVE_LINES as usize)))
.unwrap_or(total_row_count);

// Build initial lines: header + visible rows + spacer + footer
Expand Down Expand Up @@ -121,12 +154,13 @@ impl ProgressiveTable {
Ok(())
}

/// Print all lines to stdout.
/// Print all lines to stdout, followed by the prompt-reserve rows.
fn print_all(&self) -> std::io::Result<()> {
let mut stdout = stdout();
for line in &self.lines {
writeln!(stdout, "{}", line)?;
}
write_prompt_reserve(&mut stdout)?;
stdout.flush()
}

Expand Down Expand Up @@ -272,6 +306,7 @@ impl ProgressiveTable {
"{}",
truncate_visible(&final_footer, self.max_width)
)?;
write_prompt_reserve(&mut stdout)?;
stdout.flush()
} else {
// Normal: update rows in-place + footer
Expand Down Expand Up @@ -454,14 +489,14 @@ mod tests {

#[test]
fn overflow_limits_visible_rows() {
// 10 rows, terminal height 8 → visible = 8 - 4 = 4
// 10 rows, terminal height 10 → visible = 10 - 4 - PROMPT_RESERVE_LINES = 4
let skeletons: Vec<String> = (0..10).map(|i| format!("row{i}")).collect();
let table = ProgressiveTable::new_with_height(
"header".into(),
skeletons,
"loading".into(),
80,
Some(8),
Some(10),
);

assert_eq!(table.row_count, 4);
Expand Down Expand Up @@ -495,14 +530,14 @@ mod tests {

#[test]
fn overflow_boundary_exact_fit() {
// 5 rows need height 5+4=9, terminal height 9 → fits exactly, no overflow
// 5 rows need height 5+4+PROMPT_RESERVE_LINES=11 → fits exactly, no overflow
let skeletons: Vec<String> = (0..5).map(|i| format!("row{i}")).collect();
let table = ProgressiveTable::new_with_height(
"header".into(),
skeletons,
"loading".into(),
80,
Some(9),
Some(11),
);

assert_eq!(table.row_count, 5);
Expand All @@ -511,14 +546,14 @@ mod tests {

#[test]
fn overflow_boundary_one_short() {
// 5 rows need height 5+4=9, terminal height 8 → overflow, visible = 4
// 5 rows need height 5+4+PROMPT_RESERVE_LINES=11, height 10 → overflow, visible = 4
let skeletons: Vec<String> = (0..5).map(|i| format!("row{i}")).collect();
let table = ProgressiveTable::new_with_height(
"header".into(),
skeletons,
"loading".into(),
80,
Some(8),
Some(10),
);

assert_eq!(table.row_count, 4);
Expand All @@ -534,7 +569,7 @@ mod tests {
skeletons,
"loading".into(),
80,
Some(8),
Some(10),
);

// Can update visible rows (0..4)
Expand Down
7 changes: 7 additions & 0 deletions tests/common/progressive_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ pub struct ProgressiveOutput {
pub exit_code: i32,
/// Total execution time
pub total_duration: Duration,
/// Cursor position (row, col) after all output drained — where the shell
/// prompt would print
pub final_cursor: (u16, u16),
}

impl ProgressiveOutput {
Expand Down Expand Up @@ -521,11 +524,13 @@ pub fn capture_progressive_output(
.unwrap_or_else(|_| panic!("Failed to wait for 'wt {}' to exit", subcommand));
let exit_code = exit_status.exit_code() as i32;
let total_duration = start_time.elapsed();
let final_cursor = parser.screen().cursor_position();

ProgressiveOutput {
stages: snapshots,
exit_code,
total_duration,
final_cursor,
}
}

Expand Down Expand Up @@ -616,6 +621,7 @@ mod tests {
stages,
exit_code: 0,
total_duration: Duration::from_millis(150),
final_cursor: (0, 0),
};

// Test samples
Expand Down Expand Up @@ -656,6 +662,7 @@ mod tests {
stages,
exit_code: 0,
total_duration: Duration::from_millis(150),
final_cursor: (0, 0),
};

// Should verify successfully
Expand Down
26 changes: 24 additions & 2 deletions tests/integration_tests/list_progressive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ fn test_list_progressive_api(mut repo: TestRepo) {
#[rstest]
fn test_list_progressive_overflow(mut repo: TestRepo) {
// Create enough worktrees to overflow a 10-row terminal.
// With height=10: visible_rows = 10 - 4 (header + spacer + footer + cursor) = 6
// 10 worktrees + main = 11 rows, well above the 6-row limit.
// With height=10: visible_rows = 10 - 4 (header + spacer + footer + cursor)
// - 2 (prompt reserve) = 4. 10 worktrees + main = 11 rows, well above that.
for i in 1..=10 {
repo.add_worktree(&format!("overflow-{:02}", i));
}
Expand Down Expand Up @@ -132,6 +132,17 @@ fn test_list_progressive_overflow(mut repo: TestRepo) {
!final_text.contains('·'),
"No placeholder dots should remain after finalize.\nFinal output:\n{final_text}"
);

// The prompt reserve leaves the cursor two rows above the bottom of the
// scrolled screen, with the footer directly above it — the shell prompt
// printed after exit renders into the reserved rows instead of scrolling
// the settled table.
assert_eq!(
output.final_cursor,
(7, 0),
"Cursor should rest 2 reserved rows above the bottom of the 10-row \
terminal.\nFinal output:\n{final_text}"
);
}

/// Tests progressive rendering with no worktrees (fast path).
Expand All @@ -156,4 +167,15 @@ fn test_list_progressive_fast_command(repo: TestRepo) {
output.final_output().contains("Branch"),
"Should have table header"
);

// The prompt-reserve rows are emitted below the footer with the cursor
// moved back over them: it rests directly under the last content row, so
// a reserve/MoveUp mismatch would show up as a displaced cursor.
let content_rows = output.final_output().trim_end().lines().count() as u16;
assert_eq!(
output.final_cursor,
(content_rows, 0),
"Cursor should rest directly under the footer.\nFinal output:\n{}",
output.final_output()
);
}
Loading