Files

126 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()`
```rust
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()`:
```rust
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:
```rust
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.rs``bottommost_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.rs``trim_trailing_blank_rows` flag, updated `len_displayed()`
- `app/src/terminal/model/block.rs``is_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_cursor``update_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