Symptom
Text from the chat transcript bleeds across the sidebar boundary AND across its own column, leaving stale character fragments overlaid on freshly-rendered content. Gets dramatically worse while scrolling.
Concrete artifacts visible in attached screenshots:
The corruption follows a clear pattern: a single string (an ISO timestamp from `gh issue list` output, or a known token like `Tasks`/`ls`) gets written by one widget and partially overwritten by another, with the trailing fragment of the loser persisting visibly.
Root cause (smoking gun)
This is a known bug class with an existing — but insufficient — guard.
`crates/tui/src/tui/widgets/mod.rs:212`:
```rust
fn render(&self, _area: Rect, buf: &mut Buffer) {
// Repaint the full chat area with the deepseek-ink background each
// frame. Ratatui's Paragraph only writes cells that contain text,
// so cells the current frame's paragraph doesn't touch would
// otherwise hold the previous frame's contents (the :24Z
// timestamp-tail bleed-through reported in v0.8.5 testing). Using
// Clear reset cells to terminal default …
Block::default()
.style(Style::default().bg(palette::DEEPSEEK_INK))
.render(self.content_area, buf);
let paragraph =
Paragraph::new(self.lines.clone()).style(Style::default().bg(palette::DEEPSEEK_INK));
paragraph.render(self.content_area, buf);
```
The comment literally documents this bug from v0.8.5 testing (`:24Z` tail bleed) — the user is now seeing the same pattern with current-second `:51Z` fragments. The current fix uses `self.content_area` but ignores the `_area` parameter handed in at `ui.rs:4211` (`chat_widget.render(chat_area, buf)`). If those two ever drift — which they do — the Block fill misses cells.
Three concrete drift scenarios (all currently possible):
-
Layout split changes mid-session. The new file-tree pane (uncommitted, `crates/tui/src/tui/file_tree.rs`) introduces a third horizontal split at `ui.rs:4169`. When the user toggles Ctrl+E, `chat_area.width` changes but `self.content_area` was captured at `ChatWidget::new()` time using the previous width. The cached transcript lines are re-wrapped (good — `transcript_cache.ensure_split` keys on width) but the Block fill paints the old width.
-
Sidebar partial-render bailout. `crates/tui/src/tui/sidebar.rs:466` early-returns when `area.width < 4 || area.height < 3`:
```rust
if area.width < 4 || area.height < 3 {
return;
}
```
The 4-section vertical split at `sidebar.rs:34` uses `Constraint::Percentage(25)` × 3 + `Constraint::Min(6)`. On narrow heights, individual sections drop below 3 rows and bail out — leaving previous-frame cells intact. No sibling widget repaints them.
-
Sidebar Paragraph wrap overflow. `render_sidebar_section` at `sidebar.rs:478` uses `Paragraph::new(lines).wrap(Wrap { trim: false })`. When sidebar text wraps to more rows than the section has, the surplus rows write outside the Block — into adjacent sections OR into the chat area. This is exactly what we see: sidebar timestamp fragments appearing inside the chat column.
The "worse while scrolling" symptom is consistent with #2 + #3 — every scroll event triggers a re-layout, partial-bailouts on tight rows fire repeatedly, and stale cells accumulate.
Reproduction
- `deepseek` in this repo (or any moderately-busy session).
- Run a command that emits long lines containing repetitive timestamps, e.g. `gh issue list --limit 30` from inside an exec tool.
- Open the sidebar (auto-visible at terminal width ≥ `SIDEBAR_VISIBLE_MIN_WIDTH = 100` per `ui.rs:129`).
- Scroll up and down a few times with PgUp/PgDn or mouse wheel.
- Sidebar headers and content rows accumulate fragments of the timestamp string.
(Both screenshots in this issue were captured this way.)
Fix sketch
Three layered changes; ship 1 + 2 minimum:
-
Use the passed `area` parameter, not `self.content_area` in `ChatWidget::render` (`widgets/mod.rs:212`). And/or assert `debug_assert_eq!(self.content_area, _area)` so future drift surfaces in tests.
-
Always repaint the Block in `render_sidebar_section` even on the early-bailout path. Replace the bare `return` at `sidebar.rs:467` with a `Clear` or styled `Block` fill of the area before returning, so collapsed sections don't preserve stale cells.
-
Stop wrapping inside sidebar sections, or constrain rows to the section height. `render_sidebar_section` should pre-truncate lines to the available rows × width and use `Wrap { trim: true }` (or no wrap), so nothing the Paragraph emits can land outside its Block. Add a snapshot test that runs `gh issue list`-style content through ChatWidget at the precise width that triggers `SIDEBAR_VISIBLE_MIN_WIDTH` rounding.
-
Defensive backstop: at `ui.rs:4209` (just before `chat_widget.render`), explicitly fill the entire `chunks[1]` with the ink background before any sub-widgets render. This catches any future widget that forgets to pad to area.width — a one-line guard rail.
Acceptance
References
- `crates/tui/src/tui/widgets/mod.rs:212` — existing `:24Z` bleed guard (insufficient).
- `crates/tui/src/tui/widgets/mod.rs:64` — `ChatWidget::new` captures `content_area` at construction; can drift from render-time area.
- `crates/tui/src/tui/sidebar.rs:34` — sidebar layout split; `:466` partial-render bailout; `:478` Wrap overflow site.
- `crates/tui/src/tui/ui.rs:4169` — chat/sidebar/file-tree horizontal split (file-tree path is new on `feat/v0.8.6`).
- `crates/tui/src/tui/ui.rs:4211` — passes `chat_area` to `ChatWidget::render` but the widget reads `self.content_area` instead.
- Related: this is the same bug class as the v0.8.5 `:24Z` testing issue called out in the source comment.
Symptom
Text from the chat transcript bleeds across the sidebar boundary AND across its own column, leaving stale character fragments overlaid on freshly-rendered content. Gets dramatically worse while scrolling.
Concrete artifacts visible in attached screenshots:
The corruption follows a clear pattern: a single string (an ISO timestamp from `gh issue list` output, or a known token like `Tasks`/`ls`) gets written by one widget and partially overwritten by another, with the trailing fragment of the loser persisting visibly.
Root cause (smoking gun)
This is a known bug class with an existing — but insufficient — guard.
`crates/tui/src/tui/widgets/mod.rs:212`:
```rust
fn render(&self, _area: Rect, buf: &mut Buffer) {
// Repaint the full chat area with the deepseek-ink background each
// frame. Ratatui's
Paragraphonly writes cells that contain text,// so cells the current frame's paragraph doesn't touch would
// otherwise hold the previous frame's contents (the
:24Z// timestamp-tail bleed-through reported in v0.8.5 testing). Using
//
Clearreset cells to terminal default …Block::default()
.style(Style::default().bg(palette::DEEPSEEK_INK))
.render(self.content_area, buf);
```
The comment literally documents this bug from v0.8.5 testing (`:24Z` tail bleed) — the user is now seeing the same pattern with current-second `:51Z` fragments. The current fix uses `self.content_area` but ignores the `_area` parameter handed in at `ui.rs:4211` (`chat_widget.render(chat_area, buf)`). If those two ever drift — which they do — the Block fill misses cells.
Three concrete drift scenarios (all currently possible):
Layout split changes mid-session. The new file-tree pane (uncommitted, `crates/tui/src/tui/file_tree.rs`) introduces a third horizontal split at `ui.rs:4169`. When the user toggles Ctrl+E, `chat_area.width` changes but `self.content_area` was captured at `ChatWidget::new()` time using the previous width. The cached transcript lines are re-wrapped (good — `transcript_cache.ensure_split` keys on width) but the Block fill paints the old width.
Sidebar partial-render bailout. `crates/tui/src/tui/sidebar.rs:466` early-returns when `area.width < 4 || area.height < 3`:
```rust
if area.width < 4 || area.height < 3 {
return;
}
```
The 4-section vertical split at `sidebar.rs:34` uses `Constraint::Percentage(25)` × 3 + `Constraint::Min(6)`. On narrow heights, individual sections drop below 3 rows and bail out — leaving previous-frame cells intact. No sibling widget repaints them.
Sidebar Paragraph wrap overflow. `render_sidebar_section` at `sidebar.rs:478` uses `Paragraph::new(lines).wrap(Wrap { trim: false })`. When sidebar text wraps to more rows than the section has, the surplus rows write outside the Block — into adjacent sections OR into the chat area. This is exactly what we see: sidebar timestamp fragments appearing inside the chat column.
The "worse while scrolling" symptom is consistent with #2 + #3 — every scroll event triggers a re-layout, partial-bailouts on tight rows fire repeatedly, and stale cells accumulate.
Reproduction
(Both screenshots in this issue were captured this way.)
Fix sketch
Three layered changes; ship 1 + 2 minimum:
Use the passed `area` parameter, not `self.content_area` in `ChatWidget::render` (`widgets/mod.rs:212`). And/or assert `debug_assert_eq!(self.content_area, _area)` so future drift surfaces in tests.
Always repaint the Block in `render_sidebar_section` even on the early-bailout path. Replace the bare `return` at `sidebar.rs:467` with a `Clear` or styled `Block` fill of the area before returning, so collapsed sections don't preserve stale cells.
Stop wrapping inside sidebar sections, or constrain rows to the section height. `render_sidebar_section` should pre-truncate lines to the available rows × width and use `Wrap { trim: true }` (or no wrap), so nothing the Paragraph emits can land outside its Block. Add a snapshot test that runs `gh issue list`-style content through ChatWidget at the precise width that triggers `SIDEBAR_VISIBLE_MIN_WIDTH` rounding.
Defensive backstop: at `ui.rs:4209` (just before `chat_widget.render`), explicitly fill the entire `chunks[1]` with the ink background before any sub-widgets render. This catches any future widget that forgets to pad to area.width — a one-line guard rail.
Acceptance
References