fix: squash blank lines in transcript render#10
Conversation
move_scroll was capping scroll at `events.len() * 3`, a heuristic assuming ~3 rendered lines per event. Works for typical Claude sessions with tool_use / tool_result / short assistant text events. Fails hard for sessions with a small number of events but large bodies: - e.g. a qwen sub-agent dispatch: 4 events total (1 user-prompt, 1 system, 1 thinking, 1 assistant), but the user-prompt body is 125 KB and renders to ~150 lines after markdown processing - max scroll = 4 * 3 = 12 - Down/PgDn/End all clamp at 12 → user sees the top ~12 + viewport rows and has no way to reach the rest of the user prompt, let alone the system / thinking / assistant sections below it Symptom from the user's perspective: "USER section doesn't finish, and nothing appears after it." Not a parser bug, not markdown collapsing, not auto-refresh — pure scroll clamp. Fix: draw_detail already computes the full `Vec<Line>` of rendered content. Stash its length onto App.detail_row_count (u16) and use that as the scroll upper bound. First frame is 0 (safe: scroll stays 0). Subsequent frames see the true line count. Validated on Mac + xserver (make deploy-xserver): build + --dry-run clean both hosts. Co-Authored-By: Claude <noreply@anthropic.com>
Long tool outputs and system wrappers flood the detail pane and bury
the actual conversation. Mirror the original web UI's behavior: fold
those by default, let the user expand either globally (all) or one
event at a time.
Scope of folding:
- tool_result / system / thinking bodies → candidates for folding
- user / assistant / tool_use header → always fully rendered (the
conversation itself)
- threshold: rendered-line count > 10 (short bodies stay inline —
folding them would be pure chrome with no screen savings)
UI:
- folded event replaces its body with a single placeholder row:
▌TOOL← 12:34 · 42 lines folded [+ x]
(styled in the event's tag color + dim grey detail suffix)
- `z` toggles global fold_mode (Smart ↔ Expanded). Smart = fold the
candidates per threshold; Expanded = nothing is folded.
- `x` toggles a per-event override in Smart mode: if the event under
the scroll cursor is currently folded it expands, and vice versa.
Overrides are keyed by (sid, event_idx) so flipping back to a
previously-viewed session preserves your choices.
Implementation:
- App gains FoldMode and fold_overrides: HashMap<sid, HashSet<usize>>
- render_events and event_row_offsets thread the fold context; a new
render_one_event helper lets both share the per-kind rendering logic
without duplication
- draw_detail caches the latest event_row_offsets on App so the `x`
handler can locate the focused event from self.scroll without
re-rendering
Validated: cargo build --release clean on Mac + xserver; --dry-run
smokes both sides. README.md / README.zh.md keys tables updated.
Co-Authored-By: Claude <noreply@anthropic.com>
User feedback: the detail pane felt "空旷" — excessive blank space between and inside events. Sources of blanks: - every event had a trailing blank separator appended by render_events - user prompts and assistant markdown often contain \n\n\n runs, each producing its own blank Line - pulldown-cmark paragraph breaks emit blank separators too Fix: single-pass renderer (render_detail) that squashes any run of blank lines down to one. The per-event trailing blank becomes a no-op when the event already ended on a blank, so the separator between events is exactly one blank instead of two-or-more. Same function now returns both the rendered Line buffer and the per-event row offsets, so draw_detail only renders once per frame instead of calling render_events + event_row_offsets in succession (the old path effectively rendered twice). The old private render_events / event_row_offsets wrappers are dropped — all call sites go through render_detail now. Validated: cargo build --release clean on Mac + xserver; --dry-run boots both sides. Visual tightening verified with a session that has heavy \n\n usage in prompts. Co-Authored-By: Claude <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR introduces a folding feature for the Sessions view in the TUI. It adds keybindings ( Changes
Possibly Related PRs
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/tui.rs (1)
1686-1717:⚠️ Potential issue | 🟡 MinorClear
detail_event_offsetswhen no transcript is currently rendered.The offsets cache is only refreshed on the
Some(events)branch. If the user switches to a session that's still debounced/loading, or transcript loading fails,transcript_forpoints at the new session whiledetail_event_offsetsstill belongs to the old one; the nextxpress then writes a bogus fold override for the wrong event index. Reset the offsets in thepreviewing…and fallback branches.Suggested fix
} else if self.pending_preview.is_some() { + self.detail_event_offsets.clear(); vec![Line::from(Span::styled( "previewing…", Style::default().fg(Color::DarkGray), ))] } else { + self.detail_event_offsets.clear(); vec![ Line::from("↑/↓ select · auto preview"), Line::from("Tab to scroll · q quit · r refresh"), ] };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/tui.rs` around lines 1686 - 1717, The code only updates self.detail_event_offsets inside the Some(events) branch from render_detail, leaving stale offsets when cur_t is None; update the two other branches (the pending_preview branch and the final fallback branch) to explicitly clear the cache by setting self.detail_event_offsets = Vec::new() (or Default::default()) before returning their rendered Vec<Line>, so handlers that consult detail_event_offsets (e.g., the x handler / pending_jump logic) won't see offsets from a different transcript.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/tui.rs`:
- Around line 2401-2411: render_one_event(ev, w) is being called before you
decide to fold, causing expensive rendering on every frame; change the flow to
first cheaply count/display-row estimate (e.g., add a helper like
count_display_rows_or_overflow(ev, w, limit)) that returns whether it would
exceed the fold threshold and how many rows up to a small cutoff, then use that
to compute foldable/should_fold (using existing is_foldable/FoldMode/overrides
logic) and only call render_one_event(ev, w) to build the full
Vec<Line<'static>> when the event will actually be expanded; keep
folded_placeholder(ev, buf.len()) for the folded case but compute buf.len() from
the cheap helper or avoid needing the full buffer when folded.
- Around line 1045-1059: toggle_fold_at_scroll can pick the previous event when
the viewport is on the blank separator row inserted by render_detail; change the
selection logic so it only toggles when scroll falls inside an event span. After
computing ev_idx from detail_event_offsets, verify that scroll is >=
detail_event_offsets[ev_idx] and < detail_event_offsets.get(ev_idx +
1).unwrap_or(INF) (or otherwise compute an end row/span per event); if the check
fails (i.e. scroll points at a separator or beyond the event end) simply return
without mutating fold_overrides. Keep references to toggle_fold_at_scroll,
detail_event_offsets, render_detail, fold_overrides and scroll when implementing
the guard.
---
Outside diff comments:
In `@src/tui.rs`:
- Around line 1686-1717: The code only updates self.detail_event_offsets inside
the Some(events) branch from render_detail, leaving stale offsets when cur_t is
None; update the two other branches (the pending_preview branch and the final
fallback branch) to explicitly clear the cache by setting
self.detail_event_offsets = Vec::new() (or Default::default()) before returning
their rendered Vec<Line>, so handlers that consult detail_event_offsets (e.g.,
the x handler / pending_jump logic) won't see offsets from a different
transcript.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 40e7b32a-e2e8-4b40-b7e3-f515ea0b24a1
📒 Files selected for processing (3)
README.mdREADME.zh.mdsrc/tui.rs
| fn toggle_fold_at_scroll(&mut self) { | ||
| let Some(sid) = self.transcript_for.clone() else { return }; | ||
| if self.detail_event_offsets.is_empty() { | ||
| return; | ||
| } | ||
| let scroll = self.scroll; | ||
| let ev_idx = self | ||
| .detail_event_offsets | ||
| .iter() | ||
| .rposition(|&o| o <= scroll) | ||
| .unwrap_or(0); | ||
| let set = self.fold_overrides.entry(sid).or_default(); | ||
| if !set.insert(ev_idx) { | ||
| set.remove(&ev_idx); | ||
| } |
There was a problem hiding this comment.
x can toggle the wrong event when the viewport starts on a separator line.
detail_event_offsets only tracks event start rows, but render_detail always inserts a blank separator after each event on Lines 2422-2427. When scroll lands on that blank row, rposition(|&o| o <= scroll) resolves to the previous event, so x mutates the wrong override instead of behaving like the documented no-op outside an event. Cache an end row/span per event, or explicitly skip separator rows before changing fold_overrides.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/tui.rs` around lines 1045 - 1059, toggle_fold_at_scroll can pick the
previous event when the viewport is on the blank separator row inserted by
render_detail; change the selection logic so it only toggles when scroll falls
inside an event span. After computing ev_idx from detail_event_offsets, verify
that scroll is >= detail_event_offsets[ev_idx] and <
detail_event_offsets.get(ev_idx + 1).unwrap_or(INF) (or otherwise compute an end
row/span per event); if the check fails (i.e. scroll points at a separator or
beyond the event end) simply return without mutating fold_overrides. Keep
references to toggle_fold_at_scroll, detail_event_offsets, render_detail,
fold_overrides and scroll when implementing the guard.
| let buf = render_one_event(ev, w); | ||
| let foldable = is_foldable(ev.kind, buf.len()); | ||
| let should_fold = match fold_mode { | ||
| FoldMode::Expanded => false, | ||
| FoldMode::Smart => foldable && !overrides.contains(&i), | ||
| }; | ||
| let event_lines: Vec<Line<'static>> = if should_fold { | ||
| vec![folded_placeholder(ev, buf.len())] | ||
| } else { | ||
| buf | ||
| }; |
There was a problem hiding this comment.
Folded events still incur the full render cost on every frame.
render_one_event(ev, w) materializes the entire event before you decide whether to replace it with a one-line placeholder. Since draw_detail calls render_detail every repaint, a huge folded tool_result / system / thinking block still pays the full wrapping/highlighting/allocation cost every frame, which will make scrolling and search preview noticeably choppy on large transcripts. Count rows with an early cutoff first, then only build Vec<Line> when the event is actually shown expanded.
Refactor sketch
- let buf = render_one_event(ev, w);
- let foldable = is_foldable(ev.kind, buf.len());
- let should_fold = match fold_mode {
- FoldMode::Expanded => false,
- FoldMode::Smart => foldable && !overrides.contains(&i),
- };
- let event_lines: Vec<Line<'static>> = if should_fold {
- vec![folded_placeholder(ev, buf.len())]
+ let can_fold = matches!(fold_mode, FoldMode::Smart)
+ && !overrides.contains(&i)
+ && matches!(
+ ev.kind,
+ TranscriptKind::ToolResult | TranscriptKind::System | TranscriptKind::Thinking
+ );
+ let row_count = if can_fold {
+ rendered_row_count_capped(ev, w, FOLD_THRESHOLD_ROWS + 1)
+ } else {
+ 0
+ };
+ let should_fold = can_fold && row_count > FOLD_THRESHOLD_ROWS;
+ let event_lines: Vec<Line<'static>> = if should_fold {
+ vec![folded_placeholder(ev, row_count)]
} else {
- buf
+ render_one_event(ev, w)
};This needs a helper outside this hunk, but it avoids allocating/rendering the full body for events that stay folded.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/tui.rs` around lines 2401 - 2411, render_one_event(ev, w) is being called
before you decide to fold, causing expensive rendering on every frame; change
the flow to first cheaply count/display-row estimate (e.g., add a helper like
count_display_rows_or_overflow(ev, w, limit)) that returns whether it would
exceed the fold threshold and how many rows up to a small cutoff, then use that
to compute foldable/should_fold (using existing is_foldable/FoldMode/overrides
logic) and only call render_one_event(ev, w) to build the full
Vec<Line<'static>> when the event will actually be expanded; keep
folded_placeholder(ev, buf.len()) for the folded case but compute buf.len() from
the cheap helper or avoid needing the full buffer when folded.
Summary
Detail pane felt "空旷" — excessive blank space between and inside events. Three sources of blanks compounded:
Fix
Single-pass renderer (`render_detail`) that squashes any run of blank lines down to one. The per-event trailing blank becomes a no-op when the event already ended on a blank, so the separator between events is exactly one blank instead of two-or-more.
Same function now returns both the rendered `Line` buffer and the per-event row offsets, so `draw_detail` only renders once per frame instead of the old double-render (`render_events` + `event_row_offsets` separately). The old private `render_events` / `event_row_offsets` wrappers are dropped — all call sites go through `render_detail` now.
Stacked on
This PR auto-rebases when those land.
Validated
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
zkeybinding to toggle between smart fold mode (collapses long tool_result, system, and thinking events) and expand all viewxkeybinding to toggle fold state of the event at current scroll cursor in smart modeDocumentation