feat(tui): tool output keeps its colours - #5812
Conversation
strip_ansi_keep_sgr_into removes OSC (including OSC 8 hyperlink wrappers), cursor movement, DCS and lone control bytes exactly like strip_ansi_into, but lets ESC[…m colour/style sequences through so a renderer that understands them can paint tool output as emitted. Test: strip_keep_sgr_keeps_colour_and_drops_everything_else — Summary 1 test run: 1 passed. Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Shell-tool stdout that carries SGR colour (cargo, git, gh with colour forced on, anything run through a PTY) used to be flattened to one ink in the transcript. Each output row now also carries the styled segments its line asked for, parsed with ansi-to-tui (MIT, 8.0.1, ratatui-core 0.1 — already in the lockfile; default features off). Only the painted cell keeps the colour: the model, session store, pager, clipboard and exports still see the stripped text, and rows without an escape take the old path with no extra allocation. A tool's reset (ESC[0m / Color::Reset) is normalised to "nothing set" before it is patched over the cell's value style, so the cell's own dim/state/file:line ink shows through wherever the tool set nothing — ansi-to-tui's Style::reset() would otherwise wipe it. Wrapped rows are split along the same boundaries wrap_text chose, so colour survives a narrow card. OSC 8 wrappers are still stripped; links keep resolving from the visible text as before. Tests (crates/tui/src/tui/history/tool_output.rs ansi_colour_tests): plain line stays plain; cargo-style green bold verb; OSC 8 + SGR mix; wrap boundaries; painted span patches over the cell style; reset-only line stays plain. Summary 6 tests run: 6 passed scripts/dev-test.sh tui — Summary 11858 tests run: 11858 passed, 13 skipped cargo clippy (CI flags) clean; cargo fmt clean. Signed-off-by: CodeWhale Bot <bot@codewhale.net>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_b1ea3a93-7bf1-4f24-a600-cb68e03a123e) |
|
Claude finished @Hmbown's task in 3m 16s —— View job Review complete ✅
SummarySolid, well-scoped feature. The reset-normalization design ( One real bug found, plus a couple of minor notes. Bug: styled rows bypass the
|
There was a problem hiding this comment.
🟡 Changes recommended
Styled path/URL-like output currently gets wrapped (because row.styled takes precedence over row.intact), which can break path:line detection for “Open file at line” and contradict the existing “keep intact” behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR preserves ANSI SGR colouring in TUI tool-output transcripts by parsing colour/style codes into styled segments and rendering them with ratatui styles, while keeping the stored/exported text fully ANSI-stripped.
Changes:
- Add
styled: Option<Vec<StyledSegment>>toOutputRowand parse SGR sequences into styled segments (usingansi-to-tui) for colourized tool output. - Introduce
strip_ansi_keep_sgr_intoto remove non-SGR escapes (including OSC 8) while retaining SGR for styling. - Add a styled rendering path that patches tool-provided styles over the cell’s existing value style.
File summaries
| File | Description |
|---|---|
| crates/tui/src/tui/history/tool_output.rs | Builds OutputRow styled segments from SGR and renders them via a new styled line renderer. |
| crates/tui/src/tui/history.rs | Adds render_card_detail_line_styled to paint tool-provided styles while preserving cell ink where unset. |
| crates/tui/src/tui/osc8.rs | Adds strip_ansi_keep_sgr_into and refactors ANSI stripping to optionally retain SGR. |
| crates/tui/src/tui/output_rows_cache.rs | Updates cached OutputRow test helpers for the new styled field. |
| crates/tui/Cargo.toml | Adds ansi-to-tui dependency (default features off). |
| Cargo.lock | Locks ansi-to-tui and transitive dependencies. |
Review details
- Files reviewed: 5/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if let Some(segments) = &row.styled { | ||
| lines.extend(render_card_detail_line_styled( | ||
| label, | ||
| segments, | ||
| value_style, |
There was a problem hiding this comment.
Codewhale review
The PR adds ANSI colour preservation to TUI tool output by storing styled segments on OutputRow and rendering them with a new styled detail-line path. Text for non-display consumers remains stripped. The change is well tested and generally conservative, with two display-path concerns.
Findings
- [WARNING] Styled intact rows no longer use single-line rendering (
crates/tui/src/tui/history.rs:693)
When a row is intact (path/URL-like) and carries SGR, render_output_row now routes it through render_card_detail_line_styled, which wraps to width like non-intact rows. Previously intact rows were rendered as a single line via render_card_detail_line_single. Long coloured paths/URLs can now wrap across multiple lines, potentially changing link/path click regions and diverging from the plain intact path. - [INFO] Styled render path clones segments and re-wraps every frame (
crates/tui/src/tui/history.rs:2682)
render_card_detail_line_styled builds a fresh full String and calls split_segments(segments.to_vec(), ...) for every visible coloured row on each frame. For long coloured tool output this is avoidable; split_segments could borrow from the already-owned row.styled Vec, and full could reuse row.text. Not a correctness bug, but worth optimizing.
Suggestions
crates/tui/src/tui/history.rs:693— Add a styled single-line renderer for row.intact (or extend render_card_detail_line_single to accept styled segments) so coloured paths/URLs stay on one line and keep the existing intact/link behaviour.crates/tui/src/tui/history.rs:2682— Change split_segments to accept &[StyledSegment] and avoid creating full/cloning segments every frame; reuse the existing row.text or concatenate once.
Assessment
Solid feature with good test coverage and careful separation of display styling from stripped text. The main risk is the rendering path divergence for intact coloured rows; otherwise the implementation is clean and conservative.
Advisory review by Codewhale (codewhale review --pr 5812 --post, head 490608cdba309a3cbe6c91b89f7d3c25a73380f2). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.
| + usize::from(label.is_some()); | ||
| let content_width = usize::from(width).saturating_sub(prefix_width).max(1); | ||
|
|
||
| let full: String = segments.iter().map(|(text, _)| text.as_str()).collect(); |
There was a problem hiding this comment.
[INFO] Styled render path clones segments and re-wraps every frame
render_card_detail_line_styled builds a fresh full String and calls split_segments(segments.to_vec(), ...) for every visible coloured row on each frame. For long coloured tool output this is avoidable; split_segments could borrow from the already-owned row.styled Vec, and full could reuse row.text. Not a correctness bug, but worth optimizing.
|
|
||
| let full: String = segments.iter().map(|(text, _)| text.as_str()).collect(); | ||
| let parts = wrap_text(&full, content_width); | ||
| let split = tool_output::split_segments(segments.to_vec(), &parts); |
There was a problem hiding this comment.
Change split_segments to accept &[StyledSegment] and avoid creating full/cloning segments every frame; reuse the existing row.text or concatenate once.
Codewhale review on #5812: intact (path/URL-like) rows with SGR were routed through the wrapping styled renderer, so a long coloured path could wrap and move its click region while the plain one stayed on one line. Intact rows now take render_card_detail_line_single_styled — one line, same rail+label prefix — so hitboxes are identical with or without colour. Also from the review: split_segments borrows the segments and the wrapped renderer reuses row.text instead of rebuilding the string each frame. Test: coloured_intact_path_stays_on_one_line_with_the_plain_hitbox. ansi_colour: Summary 7 tests run: 7 passed history: Summary 199 tests run: 199 passed scripts/dev-test.sh tui: Summary 11859 tests run: 11859 passed, 13 skipped clippy (CI flags) exit 0; fmt clean. Signed-off-by: CodeWhale Bot <bot@codewhale.net>
|
Review addressed in |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_bc5c2133-312c-4a55-99f1-b298c5e003cd) |
|
Claude finished @Hmbown's task in 2m 49s —— View job Review complete ✅
SummaryGood news: the one real bug flagged by Cursor Bugbot, Copilot, and the earlier Codewhale review — styled The same commit also resolves the earlier "clones every frame" note: Re-reviewed the rest of the diff (
Test coverage for the new behavior (plain stays plain, cargo bold-green, OSC8+SGR mix, wrap-boundary split, reset-only, patched cell style, and now the intact+colour case) is solid. Not run: |
There was a problem hiding this comment.
Codewhale review
The PR preserves ANSI SGR colours in tool output by attaching optional styled segments to OutputRow and patching those styles over the cell's existing ink during rendering. The parsing and OSC8 handling have focused tests, and the fallback path keeps plain rows allocation-free.
Findings
- [WARNING] Styled single-line renderer relies on popping a dummy empty value span (
crates/tui/src/tui/history.rs)
render_card_detail_line_single_styled builds a plain line with an empty value, then calls line.spans.pop() under the assumption that the last span is the empty value span. This works with the current render_card_detail_line_single implementation, but if that function ever changes to omit empty value spans, the pop will remove the label/gap span and corrupt the prefix/hitbox. The styled path should construct its prefix directly or use a dedicated helper instead of depending on this implementation detail. - [INFO] Missing coverage for labelled wrapped styled rows and Unicode wrap boundaries (
crates/tui/src/tui/history/tool_output.rs)
The new styled wrapper is exercised for unlabelled row splitting and for a labelled intact row, but not for the combination of a label plus a wrapped coloured row. The split_segments helper is also only tested with ASCII text. A regression in the label/indent logic for wrapped lines, or in splitting styled segments when wrap_text breaks on Unicode-width boundaries, would not be caught by the current tests.
Suggestions
crates/tui/src/tui/history.rs— Construct the prefix spans directly in render_card_detail_line_single_styled rather than calling render_card_detail_line_single with an empty value and popping the last span. This removes the hidden coupling to the plain function's exact span layout.crates/tui/src/tui/history/tool_output.rs— Add a test that renders a labelled, wrapped coloured row and asserts the rail, label/gap indent, and styled segments on each line. Also add a wrapped-row test containing a multi-byte or wide character to verify split_segments stays aligned with wrap_text.
Assessment
Display-only change with a solid fallback and good targeted tests. No correctness issues were found in the diff, but the fragile pop-based prefix construction and the missing labelled wrapped-row coverage are worth addressing before merge.
Advisory review by Codewhale (codewhale review --pr 5812 --post, head 661c87702133b1943794c155d04c6bf9d83ce553). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.
|
Landing check (read the review threads, not the rollup):
|


No-Issue: internal 0.9.12 shell wave slice.
Rendering wave R4 (0.9.12 runlog): preserve ANSI colour in tool output instead of stripping it.
What changed for the user. When a shell tool's output carries colour —
cargobuild lines,git/ghwith colour forced on, anything run through a PTY — the transcript now paints it as the tool emitted it: the green boldCompiling, rederror, yellow warnings. Plain output looks exactly as before.Why. Every coloured line was flattened to one ink, so the reader lost the tool's own emphasis and had to re-read output the terminal would have shown at a glance.
How. Each
OutputRownow also carries styled segments parsed withansi-to-tui(MIT, 8.0.1; depends onratatui-core0.1 already in the lockfile; default features off). Only the painted cell keeps the colour — the model, session store, pager, clipboard and exports still see stripped text; rows without an escape take the old path with no extra allocation. A tool's reset (ESC[0m,Color::Reset) is normalised to "nothing set" before being patched over the cell's value style, so the cell's own dim/state/file:line ink (#5799) shows through wherever the tool set nothing. Wrapped rows are split along the same boundarieswrap_textchose. OSC 8 wrappers are still stripped; links keep resolving from the visible text (osc8::strip_ansi_keep_sgr_intois the new helper).Evidence.
ansi_colour_tests(plain stays plain; cargo green bold verb; OSC 8 + SGR mix; wrap boundaries; span patched over the cell style; reset-only stays plain):Summary 6 tests run: 6 passedstrip_keep_sgr_keeps_colour_and_drops_everything_else:Summary 1 test run: 1 passedscripts/dev-test.sh tui:Summary 11858 tests run: 11858 passed, 13 skippedcargo clippy --workspace --all-targetswith CI flags: clean.cargo fmt: clean.Not in this PR. Live PTY panes (R3) and word-level diff emphasis (R5) are separate slices.
Note
Low Risk
Display-only TUI rendering change with fallback to the plain path when parsing fails; no auth, persistence, or model-facing text changes.
Overview
Coloured shell tool output (
cargo,git, PTY runs, etc.) is now painted in the transcript with the tool’s SGR emphasis instead of a single flat ink. Plain lines behave as before with no extra allocation.How it works:
OutputRowoptionally carries parsedStyledSegments built withansi-to-tuiafterstrip_ansi_keep_sgr_intokeeps onlyESC [ … msequences while still stripping OSC 8, cursor controls, and other escapes. Display-only rendering patches those styles over the existing cell ink (diff/file:line/dim); resets are normalized so they don’t wipe cell styling. Wrapped rows split colours along the samewrap_textboundaries; intact paths stay one line for unchanged click hitboxes.Dependency:
ansi-to-tui8.0.1 (default features off) oncodewhale-tui. Session store, model, pager, clipboard, and exports still use fully stripped text.Reviewed by Cursor Bugbot for commit 661c877. Bugbot is set up for automated code reviews on this repo. Configure here.