Files
galaxy/specs/APP-4004/TECH.md
T

5.9 KiB
Raw Blame History

Trim Trailing Blank Lines from CLI Agent Block Output

1. Problem

CLI agents (Codex, Claude CLI, etc.) running in Warp blocks produce trailing blank lines. Output grid height is based on max_cursor_point (furthest row cursor visited), but agents frequently emit newlines/clear screen, leaving empty rows below content. Wastes space, especially in shared sessions.

2. Approach

Trim trailing blank rows from active CLI agent blocks by capping BlockGrid::len_displayed() at GridHandler::content_len(). A trim_trailing_blank_rows flag on BlockGrid gates the cap. Block toggles the flag via set_is_cli_agent_active(). Gated by FeatureFlag::TrimTrailingBlankLines (dogfood-only).

Since output_grid_displayed_height() already delegates to len_displayed(), all downstream call sites (selection, viewport, seek, rendering) automatically see trimmed values with no per-site changes.

3. Implementation

3.1 Feature flag

FeatureFlag::TrimTrailingBlankLines added to crates/warp_features/src/lib.rs, included in DOGFOOD_FLAGS.

3.2 Backward scan in GridHandler

Field: bottommost_nonempty_row: Option<usize>

  • None = not yet computed or grid is all blank
  • Some(row) = bottommost non-empty row index

bottommost_nonempty_row_backward() scans from max_cursor_point downward, checking Cell::is_empty() per cell. Returns on first non-empty cell found, so cost is O(trailing_blank_rows × cols).

Updated in on_finish_byte_processing() (per PTY-read batch). Initialized to None in new() / new_for_split().

3.3 GridHandler::content_len()

pub fn content_len(&self) -> usize {
    match self.bottommost_nonempty_row {
        Some(row) => row + 1,
        None => self.grid.max_cursor_point.row.0 + self.history_size() + 1,
    }
}

Returns trimmed row count when cached value exists. Falls back to full max_cursor_point-based length when not yet computed or grid is all blank (avoids trimming to 0).

3.4 is_cli_agent_active flag on Block

Boolean field, default false. Setter propagates to output grid (see 3.6).

Set from view layer in TerminalView's CLIAgentSessionsModelEvent handler:

  • Started (matching self.view_id) → set_is_cli_agent_active(true) on active block
  • Ended (matching self.view_id) → set_is_cli_agent_active(false) on active block

Both acquire model.lock()block_list_mut()active_block_mut().

3.5 trim_trailing_blank_rows flag on BlockGrid

Boolean field, default false. Setter: set_trim_trailing_blank_rows(bool). Cleared in BlockGrid::finish().

When true, len_displayed() caps its return at content_len():

pub fn len_displayed(&self) -> usize {
    let base = if let Some(len_displayed) = self.grid_handler().len_displayed() {
        len_displayed
    } else {
        self.len()
    };
    if self.trim_trailing_blank_rows {
        base.min(self.grid_handler().content_len())
    } else {
        base
    }
}

3.6 Wiring from Block

Block::set_is_cli_agent_active() propagates to the output grid, combining the active state with the feature flag:

pub fn set_is_cli_agent_active(&mut self, active: bool) {
    self.is_cli_agent_active = active;
    self.output_grid.set_trim_trailing_blank_rows(
        active && FeatureFlag::TrimTrailingBlankLines.is_enabled(),
    );
}

Block::finish() calls self.output_grid.finish() which resets trim_trailing_blank_rows = false, so trimming is implicitly cleared when the block completes.

4. Modified Files

  • crates/warp_features/src/lib.rs — flag enum + DOGFOOD_FLAGS
  • app/src/terminal/model/grid/grid_handler.rsbottommost_nonempty_row field, backward scan, content_len()
  • app/src/terminal/model/grid/ansi_handler.rs — cache update in on_finish_byte_processing()
  • app/src/terminal/model/blockgrid.rstrim_trailing_blank_rows flag, updated len_displayed()
  • app/src/terminal/model/block.rsis_cli_agent_active field, set_is_cli_agent_active() wiring
  • app/src/terminal/view.rs — wiring CLIAgentSessionsModelEvent::Started/Ended to set flag on active block
  • app/src/terminal/model/grid/grid_handler_test.rs — 7 unit tests

5. Tests

7 unit tests in grid_handler_test.rs.

Tests that move the cursor below content use goto() (CSI CUP \e[row;colH) rather than linefeed(). This matches real-world behavior: capturing raw PTY output from Codex CLI (script -q /tmp/capture.bin codex ... + xxd) showed it positions the cursor via CUP sequences (e.g. \e[73;2H to clear row 73), not bare LFs. This matters because goto goes through update_cursorupdate_max_cursor (updates max before the move), while linefeed uses move_cursor_forward (skips update_max_cursor), producing different max_cursor_point values.

  • Interspersed blanks preserved (interior blank rows not trimmed)
  • Trailing blanks trimmed (cursor moved below via goto)
  • Cursor below content → trimmed to content (cursor moved via goto)
  • New output restores height after trimming
  • Single trailing newline → no extra blank row
  • All-blank grid → content_len() falls back to max_cursor_point-based length
  • No trailing blanks → content_len() equals full length

6. Risks and Mitigations

  • Performance: backward scan O(trailing_blanks × cols). Updated once per PTY-read batch.
  • Intentional blank lines: only trailing blanks trimmed; interior blanks preserved.
  • Scope: only active CLI agent blocks. Alt-screen, finished blocks, and non-agent blocks unaffected.
  • Flag lifecycle: trim_trailing_blank_rows cleared in BlockGrid::finish(). Feature flag for safe rollback.
  • Single source of truth: trimming in len_displayed() means all call sites automatically correct.

7. Follow-Ups

  • Remove feature flag after stabilization
  • Incremental tracking of last-written row to avoid per-batch scan
  • Update server::Block::embed_pixel_height to use trimmed height
  • Consider broadening to all non-alt-screen blocks