Skip to content

feat(tui): diff cards emphasise the changed words within a line - #5813

Merged
Hmbown merged 6 commits into
mainfrom
feat/diff-intraline-20260901
Sep 2, 2026
Merged

feat(tui): diff cards emphasise the changed words within a line#5813
Hmbown merged 6 commits into
mainfrom
feat/diff-intraline-20260901

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 2, 2026

Copy link
Copy Markdown
Owner

No-Issue: internal 0.9.12 shell wave slice.

Rendering wave R5 (founder's list, rung 5 of the ladder: both crates already installed).

What changed for the user. In a diff card, when a line was replaced by another line, the words that actually changed are now emphasised (bold + reversed) on the red/green line, so a review card reads at a glance instead of forcing a word-by-word compare. Only 1:1 replaced runs get this; unequal runs and wholesale rewrites (less than half the words shared) render exactly as before.

Why. diff_render.rs painted whole lines −rose/+green and never used similar's inline ops even though the crate was already the diff engine; review cards were hard to scan.

How. render_diff_body_bounded buffers a - run and the + run that follows it; equal-length runs are paired and each pair goes through TextDiff::from_unicode_words (unicode word boundaries keep punctuation out of the emphasis). Emphasis flags are carried across the wrapper's breaks; line text is byte-identical to before. similar gains its unicode feature — unicode-segmentation was already in the tree, no new crate. syntect is not used here: colouring hunk lines by extension was not a small, obviously-safe addition to this renderer.

Evidence.

  • scripts/dev-test.sh tui diff_rendertest result: ok. 9 passed; 0 failed; 0 ignored (3 new tests: replaced-pair emphasis, unequal/rewrite fall-through, emphasis across wrapping).
  • scripts/dev-test.sh tui11854 tests run: 11854 passed, 13 skipped; no golden changed.
  • cargo clippy -p codewhale-tui --all-targets -- -D warnings with CI's allow flags: clean.

Refs: 0.9.12 runlog, rendering wave R5.


Note

Low Risk
TUI-only diff presentation with bounded allocation and fallbacks to unchanged line rendering; no security or persistence impact.

Overview
Diff cards now highlight only the words that changed when a deleted line is replaced by an added line of the same length, instead of painting the whole row red/green.

The renderer buffers equal-length -/+ runs, pairs lines, and uses similar’s unicode word diff (TextDiff::from_unicode_words) to build emphasis segments. Changed tokens get bold + reversed styling; line text stays the same. Emphasis is skipped for unequal runs, runs longer than 64 lines, pairs with low shared-word ratio (treats them as rewrites), and lines that differ only in syntax. \ No newline at end of file markers are peeled off so pairing still works. Wrapping maps emphasis per character with a safe fallback to plain lines if mapping fails.

similar is enabled with the unicode feature in codewhale-tui (lockfile pulls in unicode-segmentation). New unit tests cover pairing, unicode words, wrapping, and newline markers.

Reviewed by Cursor Bugbot for commit 812e2f5. Bugbot is set up for automated code reviews on this repo. Configure here.

A deleted run followed by an added run of the same length is a set of
replaced lines. Each pair is now diffed at unicode word boundaries
(similar's `from_unicode_words`, so punctuation stays out of the
emphasis) and the words that differ are painted bold + reversed on the
existing −rose / +green line; unchanged words keep the plain line
style. Any other shape — unequal runs, or a pair sharing less than half
its words — renders exactly as before, so a rewrite does not light up
the whole row. Emphasis flags are carried across the wrapper's breaks,
and the row text is unchanged.

`similar` gains its `unicode` feature; unicode-segmentation was already
in the tree (ratatui), so no new crate. syntect is left out: colouring
hunk lines by file extension is not a small, obviously-safe addition to
this renderer today.

Evidence: `scripts/dev-test.sh tui diff_render` —
test result: ok. 9 passed; 0 failed; 0 ignored.
`scripts/dev-test.sh tui` — 11854 tests run: 11854 passed, 13 skipped
(no golden changed). clippy -D warnings with CI's allow flags: clean.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Copilot AI lite review requested due to automatic review settings September 2, 2026 01:16
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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_35bf9ae7-0c9f-4f9a-b309-c1e02772dae9)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a non-blocking comment: Cursor Bugbot is present but skipped (usage limit / incomplete analysis), so this is not auto-approved. Human review is needed; assigned M-Maciej and SparkofSpike.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 4m 54s —— View job


Review: PR #5813 — diff cards emphasise the changed words within a line

  • Gather context (diff, files, CLAUDE.md rules)
  • Understand the request (code review)
  • Review crates/tui/src/tui/diff_render.rs changes
  • Review Cargo.toml / Cargo.lock changes
  • Check correctness, provider/model safety, reuse, tests, security
  • Post final review

Note on evidence: this sandbox doesn't have Bash/cargo execution permission for this job (cargo test, git fetch, even python3 heredocs were all blocked pending approval that never arrived), so this is a careful static/manual trace review, not a re-run of the author's test suite. The finding below is worked through by hand against the actual algorithm and is fairly mechanical, but a quick cargo test on the suggested repro would confirm it directly.

Correctness — 1 real bug found (inline comment on diff_render.rs:454-485)

emphasised_spans re-syncs a cursor against a precomputed (char, bool) source slice as it walks each wrap_text-wrapped chunk. That works when the only divergence between chunk and source is dropped/collapsed whitespace (which the cursor correctly skips past). But wrap_text (L557+) resets current = lead.clone() at every wrap boundary — it repeats a line's leading indentation on every continuation chunk (L591/L604), while source only contains that indentation once. When the indent is ≥ 2 columns (i.e. essentially all real, indented code), the extra repeated indentation on a continuation chunk has no counterpart left in source, so the cursor overruns past real characters trying to "find" a match, lands at source.len(), and gets stuck there — emphasis silently disappears for the rest of that logical line once wrapping kicks in.

Concretely: the PR's own commit-message example ( let total = price * quantity; let total = price * count;) wrapped at a narrow width loses the emphasis on quantity/count entirely once the indent forces a wrap. Since diff cards render at panel width (not a fixed 80 cols — see file_mutation.rs:167-189) and indented code is the norm, this isn't a contrived edge case, it'll show up in ordinary use.

The gap wasn't caught because the one wrapping test (emphasis_survives_wrapping_without_changing_text) deliberately uses unindented text ("alpha beta gamma..."), so lead is empty and this path is never exercised.

Full trace and a suggested fix direction are in the inline comment. This is a rendering-correctness bug only — no panic, and the line text itself stays byte-identical (their invariant holds), only the styling/emphasis is wrong.

Provider/model/route safety

Not applicable — this PR is confined to diff-card rendering and touches no provider, model, or routing code.

Reuse / simplification

Good ponytail-rung-5 compliance: similar already provided from_unicode_words/ratio(), and unicode-segmentation was already a transitive dependency (via ratatui) — no new crate added, just an existing feature flag (crates/tui/Cargo.toml). is_added/is_deleted extraction and the run-buffering via Peekable::next_if are clean and match unified-diff's actual shape (contiguous - run followed by contiguous + run). The unequal-length and low-ratio (INTRALINE_MIN_RATIO) fallbacks correctly degrade to the old whole-line rendering.

The one design concern is the root cause of the bug above: reconciling emphasis by heuristically re-matching plain wrapped text against a separately-computed segment list is inherently fragile given wrap_text's lossy whitespace handling. Carrying (char, bool) through wrapping directly (wrap the segments, not the plain string) would remove this entire class of desync risk rather than patching the symptom.

Tests

3 new tests cover the happy path (single-pair emphasis), the fallback paths (unequal run counts, low-ratio rewrites), and unindented wrapping — but none cover indented + wrapped, which is exactly the case that's broken. Recommend adding one.

Security

No concerns — this operates only on already-materialized local diff text for display; no new input parsing surface, no secrets/exec paths touched.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new run-buffering logic can allocate proportional to large hunks (even with small max_rows), undermining the function’s stated bounded-allocation behavior and risking memory spikes in the TUI.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR enhances the TUI diff-card renderer to emphasize intraline edits for 1:1 replaced lines, using similar’s word-level diffing to highlight only the changed word runs while preserving the existing per-line red/green styling and wrapping behavior.

Changes:

  • Pair consecutive -/+ runs of equal length in render_diff_body_bounded and compute per-line intraline emphasis via TextDiff::from_unicode_words.
  • Extend line rendering to optionally emit multiple styled spans per wrapped chunk so emphasis survives wrapping without changing displayed text.
  • Enable similar’s unicode feature in codewhale-tui (lockfile updated accordingly).
File summaries
File Description
crates/tui/src/tui/diff_render.rs Implements run pairing + intraline emphasis, adds helpers for added/deleted detection/styles, and adds tests for emphasis behavior and wrapping.
crates/tui/Cargo.toml Enables similar’s unicode feature needed for unicode word boundary diffing.
Cargo.lock Updates resolved dependencies due to the new similar feature flag.
Review details
  • Files reviewed: 2/3 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.

Comment thread crates/tui/src/tui/diff_render.rs Outdated
Comment on lines +106 to +114
if is_deleted(raw) {
// A deleted run followed by an added run of the same length is a
// set of replaced lines: emphasise the words that changed within
// each pair. Any other shape renders line by line as before.
let mut removed = vec![raw.trim_start_matches('-')];
while let Some(next) = lines.next_if(|next| is_deleted(next)) {
removed.push(next.trim_start_matches('-'));
}
let mut added = Vec::new();
Comment thread crates/tui/src/tui/diff_render.rs Outdated
Comment on lines +454 to +485
fn emphasised_spans(
chunk: &str,
style: Style,
source: &[(char, bool)],
cursor: &mut usize,
) -> Vec<Span<'static>> {
let emphasis = style.add_modifier(Modifier::BOLD | Modifier::REVERSED);
let mut spans = Vec::new();
let mut run = String::new();
let mut run_flag = false;
for ch in chunk.chars() {
while *cursor < source.len()
&& !(source[*cursor].0 == ch
|| (source[*cursor].0.is_whitespace() && ch.is_whitespace()))
{
*cursor += 1;
}
let flag = source.get(*cursor).map(|(_, flag)| *flag).unwrap_or(false);
*cursor = cursor.saturating_add(1).min(source.len());
if flag != run_flag && !run.is_empty() {
let painted = if run_flag { emphasis } else { style };
spans.push(Span::styled(std::mem::take(&mut run), painted));
}
run_flag = flag;
run.push(ch);
}
if !run.is_empty() {
let painted = if run_flag { emphasis } else { style };
spans.push(Span::styled(run, painted));
}
spans
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: emphasis desyncs (and silently disappears) on wrapped, indented lines.

wrap_text (L557+) resets current = lead.clone() at every wrap point (L591, L604), so it repeats the line's leading indentation on every continuation chunk. source (built at L500-508 from the pre-computed segments) contains that indentation only once, at the very start.

emphasised_spans's cursor (L465-472) only tolerates missing whitespace in source (it skips forward past extra source chars), not extra whitespace in chunk that has no counterpart left in source. When a continuation chunk's repeated lead is wider than the single separator space that wrapping dropped at the break (i.e. any indent ≥ 2 columns — the common case for real code), the extra lead characters skip the cursor past the real next character(s) in source without ever matching them. Once that happens cursor lands at source.len(), and every remaining char for every subsequent chunk of that logical line gets flag = false — emphasis silently vanishes for the rest of the line.

Concretely, using the PR's own example line but at a width that forces a wrap:

-    let total = price * quantity;
+    let total = price * count;

at e.g. width = 20, the first wrapped chunk consumes the 4-space indent + let total = cleanly, but the second chunk re-adds (4 spaces) while only one source char (the dropped word-separator space) is available to match against — the cursor overruns into the following real characters and gets stuck at the end. The changed word (quantity/count) ends up unemphasised, or the diff panics-free but visibly wrong.

None of the three new tests exercise this: emphasis_survives_wrapping_without_changing_text deliberately uses text with no leading whitespace ("alpha beta gamma..."), so lead is empty and the repeated-indent path is never hit. Since diff cards render at the panel width (file_mutation.rs:167-189), not a fixed 80 columns, and most real replaced lines are indented Rust/etc. code, this will trigger routinely in normal use, not just in a contrived edge case.

Suggest either: (a) make wrap_text (or a variant used for emphasised lines) not repeat lead in a way that's invisible to the cursor — e.g. also advance the cursor implicitly past repeated-lead chars without requiring a source match, or (b) restructure so wrapping operates on the already-flagged segments directly (carry (char, bool) through wrap_text) rather than re-deriving alignment by heuristically re-matching plain wrapped text against the source afterward. (b) removes this whole class of desync risk.

Fix this →

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

Adds intraline word emphasis to TUI diff rendering for 1:1 replaced line runs. The core change is well structured for the common case, but the word-similarity fallback, the chunk-to-source emphasis mapping, and a few edge-case tests need attention before merge.

Findings

  • [WARNING] Intraline fallback ratio includes non-word tokens (crates/tui/src/tui/diff_render.rs:419)
    TextDiff::from_unicode_words(...).ratio() scores every token, including whitespace and punctuation. Lines with similar syntax but differing identifiers can stay above INTRALINE_MIN_RATIO and receive word emphasis where a wholesale rewrite was intended to be skipped. Consider filtering to word tokens only for the threshold or using a separate word-only diff ratio.
  • [WARNING] Emphasis alignment is heuristic and has no recovery path (crates/tui/src/tui/diff_render.rs:455)
    emphasised_spans maps each wrapped chunk back to the original source by greedy forward matching, assuming wrap_text only drops or collapses whitespace. If that invariant changes, or a Unicode/rewrap edge case introduces a mismatch, the cursor silently drifts and subsequent words get the wrong emphasis (or none) with no panic or fallback. Add a debug assertion that non-whitespace characters are consumed in order and a fallback to whole-line styling on mismatch; add direct tests for wrapped changed-word placement.
  • [WARNING] Test coverage misses important replacement-shape cases (crates/tui/src/tui/diff_render.rs)
    The three new tests cover a single 1:1 pair, unequal/rewrite fallback, and a broad wrapping check. They do not cover multiple replacement pairs in one run, mixed Some/None pairs (one pair above threshold, one below), Unicode word-boundary/punctuation behavior, or exact emphasis locations after wrapping—the wrapping test concatenates all emphasised text and cannot detect emphasis on the wrong side of the line.
  • [INFO] No-newline markers can prevent pairing (crates/tui/src/tui/diff_render.rs)
    In unified diffs a \ No newline at end of file line can appear between a deleted and added line. next_if(is_added) stops at that marker, so an otherwise 1:1 replaced pair is not emphasised for files without trailing newlines. The rendered text remains correct, but the new feature silently does not engage. Consider skipping these marker lines when forming runs.

Assessment

Low-risk presentation-only change with a solid common-case implementation and good basics tests. Address the threshold semantics for wholesale rewrites, make the emphasis-alignment path more defensive, and add edge-case tests before shipping.


Advisory review by Codewhale (codewhale review --pr 5813 --post, head 0a26418e12f0a9b3bd6ef25c80b34248f48cba0a). 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.

Comment thread crates/tui/src/tui/diff_render.rs Outdated
fn intraline_segments(old: &str, new: &str) -> Option<(Vec<Segment>, Vec<Segment>)> {
let diff = TextDiff::from_unicode_words(old, new);
if diff.ratio() < INTRALINE_MIN_RATIO {
return None;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Intraline fallback ratio includes non-word tokens

TextDiff::from_unicode_words(...).ratio() scores every token, including whitespace and punctuation. Lines with similar syntax but differing identifiers can stay above INTRALINE_MIN_RATIO and receive word emphasis where a wholesale rewrite was intended to be skipped. Consider filtering to word tokens only for the threshold or using a separate word-only diff ratio.

Comment thread crates/tui/src/tui/diff_render.rs Outdated
/// across the wrap. `wrap_text` only drops or collapses whitespace, so every
/// chunk character is matched forward against the source characters.
fn emphasised_spans(
chunk: &str,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Emphasis alignment is heuristic and has no recovery path

emphasised_spans maps each wrapped chunk back to the original source by greedy forward matching, assuming wrap_text only drops or collapses whitespace. If that invariant changes, or a Unicode/rewrap edge case introduces a mismatch, the cursor silently drifts and subsequent words get the wrong emphasis (or none) with no panic or fallback. Add a debug assertion that non-whitespace characters are consumed in order and a fallback to whole-line styling on mismatch; add direct tests for wrapped changed-word placement.

… only

Review findings on #5813:

- Emphasis desynced on wrapped, indented lines: `wrap_text` re-inserts
  the indent lead on every continuation chunk and the greedy forward
  match drifted past it. Chunks now map each non-whitespace character
  back to the source by position (the invariant the wrapper keeps);
  whitespace joins an emphasised run only when both neighbours are in
  it. A `debug_assert!` pins the order, and any mismatch falls back to
  whole-line styling — never a wrong-word emphasis.
- The similarity threshold counted whitespace and punctuation tokens,
  so lines agreeing only on syntax passed. The ratio now counts word
  tokens only (tokens bearing an alphanumeric character).
- `\ No newline at end of file` markers are lifted out of a `-`/`+` run
  and painted after it, so files without a trailing newline still pair.
- The buffered run is capped at `INTRALINE_MAX_RUN` (64) replaced pairs
  and the `render_diff_body_bounded` doc states the bound honestly: the
  retained preview, one wrapped source line, and the current run's
  borrowed slices plus word segments for at most 64 pairs.
- Tests: multiple pairs in one run; a rewrite next to an edit in one
  run; syntax-only sharing stays plain; unicode word boundaries; the
  no-newline marker; an indented line whose change lands on the third
  wrapped chunk, asserting the exact text before the emphasis; and the
  wrapping test now asserts emphasis position per chunk.

Evidence: `scripts/dev-test.sh tui diff_render` —
test result: ok. 15 passed; 0 failed; 0 ignored.
`scripts/dev-test.sh tui` — 11860 tests run: 11859 passed, 1 failed
(chatgpt_oauth::tests::callback_is_accepted_on_either_loopback_family,
untouched by this diff; passes alone: 1 passed; 0 failed — loopback
port contention under six concurrent builds). clippy -D warnings with
CI's allow flags: clean.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@Hmbown

Hmbown commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Review findings addressed in the follow-up commit:

  1. Emphasis on wrapped, indented lines — chunks now map each non-whitespace character back to the source by position (the invariant wrap_text keeps: it only drops, collapses, or re-inserts whitespace). Whitespace joins an emphasised run only when both neighbours are in it. debug_assert! pins the order; any mismatch falls back to whole-line styling. New test emphasis_lands_on_the_right_chunk_of_a_wrapped_indented_line pins the change on the third wrapped chunk with the exact text before the emphasis (" gamma, ").
  2. ThresholdINTRALINE_MIN_RATIO is now computed over word tokens only (tokens bearing an alphanumeric char). Test lines_sharing_only_syntax_are_not_emphasised (let alpha = beta; vs let gamma = delta;) stays plain.
  3. Tests — multiple pairs in one run; a rewrite next to an edit in one run; unicode word boundaries (laitthé); the wrapping test now asserts emphasis per chunk plus the text before it.
  4. \ No newline at end of file — lifted out of the run and painted after it; test no_newline_marker_does_not_break_pairing.
  5. Allocation claim — both: the run is capped at INTRALINE_MAX_RUN = 64 replaced pairs (beyond that, plain line-by-line), and the render_diff_body_bounded doc now states the bound as the retained preview + one wrapped source line + the current run's borrowed slices and word segments for at most 64 pairs.

Evidence: scripts/dev-test.sh tui diff_rendertest result: ok. 15 passed; 0 failed. scripts/dev-test.sh tui11860 tests run: 11859 passed, 1 failed — the one failure is chatgpt_oauth::tests::callback_is_accepted_on_either_loopback_family, untouched by this PR and passing alone (1 passed; 0 failed); loopback port contention under six concurrent builds. clippy -D warnings with CI's allow flags: clean.

@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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_2acd7a5d-cfed-4eaa-a1b2-3e3e1d28c93a)

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 2m 59s —— View job


Review: PR #5813 — diff cards emphasise the changed words within a line

  • Gather context (diff, files, CLAUDE.md rules, prior review threads)
  • Check whether commit a708452 addressed the previously-reported wrapping/indentation bug
  • Review current state of crates/tui/src/tui/diff_render.rs
  • Check correctness, provider/model safety, reuse, tests, security
  • Post final review

Note on evidence: this sandbox has no Bash approval for cargo check/cargo test in this job, so the below is a careful static trace against the current diff (HEAD~2..HEAD, matching the PR's +439/-32), not a re-run of the author's suite. The prior claude review on this PR ran into the same limitation.

Correctness — the previously reported wrapping bug is fixed

I filed the original finding on this PR: emphasised_spans's cursor drifted past real characters when wrap_text re-inserted a line's lead indent on every continuation chunk, silently killing emphasis after the first wrap on any indented line. Commit a708452 (emphasised_chunks, diff_render.rs:495-551) replaces that with a cleaner approach — it strips all whitespace out of source up front and maps only non-whitespace characters back to their flag by position, deriving whitespace emphasis from prev_flag plus a peek at the next source flag. I traced this against wrap_text (:614-676): it never reorders or drops non-whitespace characters, only whitespace, so the non-whitespace sequence really is invariant across wrapping — the fix addresses the actual root cause rather than patching the symptom. The new emphasis_lands_on_the_right_chunk_of_a_wrapped_indented_line test (:846-871) is exactly the repro from my original comment (8-space indent, change on the third wrapped chunk) and asserts the emphasis lands on the right chunk with byte-identical row text. Confirmed correct by trace.

One minor/non-blocking note: debug_assert_eq! at :521-524 fires before the graceful if expected != ch { return None; } a few lines below it. If the invariant it defends were ever violated, a debug build (tests, cargo run without --release) would panic instead of falling back to whole-line styling like a release build would — the doc comment above the function ("Returns None if the invariant ever fails... never a wrong-word emphasis") is technically only true in release. Given the invariant is structurally guaranteed by wrap_text as traced above, this looks like intentional defense-in-depth rather than a live bug, but worth being aware of if a future wrap_text change ever breaks the invariant — it'll show up as a test panic rather than a quiet fallback.

Other prior findings — also addressed

  • Ratio counted whitespace/punctuation (codewhale-agent): intraline_segments (:443-479) now tallies shared_words/total_words only for tokens containing an alphanumeric char, using the standard 2*matches/total ratio. The new lines_sharing_only_syntax_are_not_emphasised test covers this directly.
  • \ No newline at end of file breaking pairing (codewhale-agent): markers are now lifted out of the run and replayed after it (:117-135, 181-183), with a dedicated test.
  • Unbounded allocation on large hunks (copilot): INTRALINE_MAX_RUN = 64 (:18) caps how many pairs get word-segmented; the doc comment on render_diff_body_bounded (:63-68) now honestly states the bound includes the current run's borrowed line slices plus segments for at most 64 pairs. Note the removed/added buffering itself (Vec<&str>, borrowed) is still proportional to the full run's line count before the cap is checked — a pathological single hunk with e.g. 1M consecutive -/+ lines would still build two ~1M-entry pointer vectors before falling back to plain rendering. That's a few MB of pointers, not the unbounded string-cloning the original comment flagged, so this is a reasonable, low-risk tradeoff rather than a full fix — not blocking.
  • Mixed Some/None pairs untested (codewhale-agent): now covered by a_rewrite_inside_a_replaced_run_stays_plain_while_its_neighbour_is_emphasised.

Provider/model/route safety

Not applicable — no provider, model, or routing code touched.

Reuse / simplification

No new issues. is_added/is_deleted/is_no_newline_marker extraction stays clean; push_segment dedup logic is shared between old/new sides. Cargo.lock diff confirms the PR's claim: similar gains an edge to unicode-segmentation, which was already resolved in the lockfile (via bstr) — no new crate.

Tests

15 tests now cover: single-pair emphasis, multiple pairs in one run, a rewrite mixed with an edit in the same run, syntax-only similarity (ratio fix), unequal runs, Unicode word boundaries, the no-newline marker, and — the one that mattered most — an indented line whose change lands on the third wrapped chunk. This closes every gap the earlier review rounds flagged.

Security

No concerns — presentation-only, no new input-parsing or exec surface.

Assessment

The fix commit (a708452) directly and correctly resolves the wrapping/indentation correctness bug from the earlier review round, plus the ratio, no-newline-marker, and unbounded-allocation findings from the other reviewers, each with a targeted regression test. I found no new correctness issues in this round. Nothing here blocks merge from my read; the two notes above (debug_assert vs. release fallback, residual pointer-vector buffering before the run cap) are informational, not action items.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a non-blocking comment: Cursor Bugbot is present but skipped (usage limit / incomplete analysis), so this is not auto-approved. Human review is needed; assigned M-Maciej and SparkofSpike.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

The PR adds intraline word emphasis for 1:1 replaced diff lines using similar's unicode word diff. The rendering logic is well covered by new tests and the fallback paths are generally safe, but the advertised allocation bound is not enforced during run collection, and no-newline marker handling has ordering and asymmetrical fallthrough issues.

Findings

  • [WARNING] Run collection is unbounded despite INTRALINE_MAX_RUN (crates/tui/src/tui/diff_render.rs:129)
    The new buffering path consumes every consecutive deleted line into removed and every consecutive added line into added before the removed.len() <= INTRALINE_MAX_RUN check is evaluated. A large deletion-only or replacement hunk therefore allocates a Vec<&str> proportional to the whole run, contradicting the bounded-allocation comment. The cap only disables emphasis after the vectors are already allocated.
  • [WARNING] No-newline markers outside a deletion run still render as context rows (crates/tui/src/tui/diff_render.rs:96)
    is_no_newline_marker is only consumed inside a - run. A pure insertion hunk such as +new line / \ No newline at end of file falls through to the context branch, which renders the marker with line-number gutters and increments old_line/new_line for it. That can display incorrect line numbers and is inconsistent with the header-style marker rendering used in the deletion path.
  • [INFO] No-newline markers are moved after the whole replaced run (crates/tui/src/tui/diff_render.rs:181)
    In the deletion-run path, markers are collected and rendered after all removed and added lines. For a replacement pair such as -old line, marker, +new line, marker, the rendered order becomes old, new, marker, marker. The marker no longer sits next to the line missing the trailing newline, so a single marker can appear to belong to the added line when it actually belongs to the deleted line. Tests assert marker count and text, not placement.
  • [INFO] Missing regression tests for INTRALINE_MAX_RUN and pure-insertion markers (crates/tui/src/tui/diff_render.rs)
    The new suite covers replaced-pair emphasis, rewrite fallback, unicode boundaries, and wrapping, but does not cover: (a) a replacement run longer than INTRALINE_MAX_RUN, where pairing must be skipped and allocation bounded; or (b) a no-newline marker following a pure insertion. Both paths currently lack regression protection.

Suggestions

  • crates/tui/src/tui/diff_render.rs:96 — Handle no-newline markers globally before the added/deleted branches so markers outside a deletion run do not fall through to the context branch and corrupt line numbering.

            if is_no_newline_marker(raw) {
                collector.extend(render_header_line(raw, width));
                continue;
            }
    
            if is_added(raw) {
    

Assessment

The feature is low-risk and well tested for its core happy paths, but the run-collection memory bound must be enforced before merge, and no-newline marker handling should be made consistent so line numbering and marker placement remain faithful.


Advisory review by Codewhale (codewhale review --pr 5813 --post, head a708452d6c25ada3631e5207145d8feb41bedafd). 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.

Comment thread crates/tui/src/tui/diff_render.rs Outdated
} else if added.is_empty()
&& let Some(next) = lines.next_if(|next| is_deleted(next))
{
removed.push(next.trim_start_matches('-'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Run collection is unbounded despite INTRALINE_MAX_RUN

The new buffering path consumes every consecutive deleted line into removed and every consecutive added line into added before the removed.len() <= INTRALINE_MAX_RUN check is evaluated. A large deletion-only or replacement hunk therefore allocates a Vec<&str> proportional to the whole run, contradicting the bounded-allocation comment. The cap only disables emphasis after the vectors are already allocated.

}

if raw.starts_with('+') && !raw.starts_with("+++") {
if is_added(raw) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] No-newline markers outside a deletion run still render as context rows

is_no_newline_marker is only consumed inside a - run. A pure insertion hunk such as +new line / \ No newline at end of file falls through to the context branch, which renders the marker with line-number gutters and increments old_line/new_line for it. That can display incorrect line numbers and is inconsistent with the header-style marker rendering used in the deletion path.

Comment thread crates/tui/src/tui/diff_render.rs Outdated
*line = line.saturating_add(1);
}
}
for marker in markers {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[INFO] No-newline markers are moved after the whole replaced run

In the deletion-run path, markers are collected and rendered after all removed and added lines. For a replacement pair such as -old line, marker, +new line, marker, the rendered order becomes old, new, marker, marker. The marker no longer sits next to the line missing the trailing newline, so a single marker can appear to belong to the added line when it actually belongs to the deleted line. Tests assert marker count and text, not placement.

}

if raw.starts_with('+') && !raw.starts_with("+++") {
if is_added(raw) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle no-newline markers globally before the added/deleted branches so markers outside a deletion run do not fall through to the context branch and corrupt line numbering.

Suggested change
if is_added(raw) {
if is_no_newline_marker(raw) {
collector.extend(render_header_line(raw, width));
continue;
}
if is_added(raw) {

@Hmbown
Hmbown enabled auto-merge September 2, 2026 03:11
Keep the unicode feature on 'similar' (intraline word segmentation) and
take main's new ansi-to-tui dependency.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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_2c3e0546-4adf-4928-b197-581e968ccad3)

devin-ai-integration Bot and others added 2 commits September 2, 2026 03:16
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Hunter Bown <hmbown@gmail.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 3 potential issues.

Devin Review

Comment thread crates/tui/src/tui/diff_render.rs
Comment thread crates/tui/src/tui/diff_render.rs Outdated
Comment on lines +462 to +470
ChangeTag::Delete => {
changed = true;
total_words += usize::from(is_word);
push_segment(&mut old_segments, text, true);
}
ChangeTag::Insert => {
changed = true;
total_words += usize::from(is_word);
push_segment(&mut new_segments, text, true);

@devin-ai-integration devin-ai-integration Bot Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Punctuation receives changed-word emphasis

When similar lines differ only in punctuation, push_segment emphasizes those punctuation tokens. Diff cards highlight symbols instead of changed words.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

let mut total_words = 0usize;
for change in diff.iter_all_changes() {
let text = change.value();
let is_word = text.chars().any(char::is_alphanumeric);

@devin-ai-integration devin-ai-integration Bot Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rewrite threshold miscounts adjacent words

When many adjacent words change, intraline_segments counts the entire changed run once. Wholesale rewrites can reach the 0.5 threshold and receive misleading emphasis.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

.map(move |ch| (ch, *flag))
})
.collect();
let emphasis = style.add_modifier(Modifier::BOLD | Modifier::REVERSED);

@devin-ai-integration devin-ai-integration Bot Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Terminal appearance remains unverified

Span assertions cannot establish that reversed red and green text stays legible across supported terminals. Capture the changed cards at the prescribed terminal sizes.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Hunter Bown <hmbown@gmail.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

&mut new_line,
'-',
);
continue 'line;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Oversized replacements gain false emphasis

When a deletion run exceeds 64 lines and its remaining tail matches the addition count, continue 'line restarts pairing mid-run. Unequal replacements receive misleading changed-word emphasis.

Prompt for agents
In crates/tui/src/tui/diff_render.rs, render_diff_body_bounded restarts the outer parser after overflowing the 64-line removed buffer. The unconsumed tail can then be interpreted as a fresh, equal-length replacement and receive intraline emphasis even though the complete replacement run exceeded the limit or had unequal lengths. Preserve the bounded buffering, but once either side exceeds INTRALINE_MAX_RUN, render the entire remainder of that contiguous deleted/added/marker run plainly without allowing pairing to restart inside it. Add a regression case such as 100 deleted lines followed by 35 added lines and assert that no row is emphasized.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@Hmbown
Hmbown merged commit e68dfce into main Sep 2, 2026
31 of 34 checks passed
@Hmbown
Hmbown deleted the feat/diff-intraline-20260901 branch September 2, 2026 04:01

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codewhale review

The PR adds intraline word emphasis for equal-length replaced runs in the TUI diff renderer. The line pairing and wrapping logic is well structured and the new tests cover the main paths, but there is a correctness bug where punctuation/whitespace changes are still emphasised despite the stated word-only intent, and a secondary user-visible change where no-newline markers are now rendered as header rows.

Findings

  • [WARNING] Punctuation and whitespace changes are emphasised as word changes (crates/tui/src/tui/diff_render.rs:532)
    In intraline_segments, Delete/Insert tokens are always pushed with true even when they are not words (is_word is false). A line pair whose words are mostly identical but where only punctuation or whitespace changes will pass the ratio check and highlight the punctuation/whitespace, contradicting the comment and PR claim that punctuation stays out of the emphasis. For example, let x = 1; vs let x = 1, would highlight ; and ,. A test for punctuation-only or whitespace-only changes is missing.
  • [WARNING] No-newline markers are now rendered as diff header rows (crates/tui/src/tui/diff_render.rs:109)
    Previously, \ No newline at end of file lines fell through the old loop and were not rendered. The new branch renders these markers with render_header_line, adding visible header-styled rows for every diff containing such markers and consuming rows from the bounded preview budget. This is a user-visible output change outside the stated intraline-emphasis scope. If this visibility is intentional it should be reflected in the PR description; otherwise, markers should be peeled for pairing but not rendered as header rows.

Suggestions

  • crates/tui/src/tui/diff_render.rs:532 — Use is_word rather than true so only word tokens are emphasised, keeping punctuation and whitespace changes plain.

                    push_segment(&mut old_segments, text, is_word);
    
  • crates/tui/src/tui/diff_render.rs:537 — Use is_word rather than true for inserted tokens so only word tokens are emphasised.

                    push_segment(&mut new_segments, text, is_word);
    

Assessment

The implementation is generally solid and well-tested, but the punctuation/whitespace emphasis bug should be fixed before merge because it produces incorrect highlighting in common edits. The no-newline marker rendering change should either be made intentional and documented, or adjusted to preserve the previous output.


Advisory review by Codewhale (codewhale review --pr 5813 --post, head b2ccdde16fd96569ea15bf6abfa9334204da17f0). 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.

changed = true;
total_words += usize::from(is_word);
push_segment(&mut old_segments, text, true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Punctuation and whitespace changes are emphasised as word changes

In intraline_segments, Delete/Insert tokens are always pushed with true even when they are not words (is_word is false). A line pair whose words are mostly identical but where only punctuation or whitespace changes will pass the ratio check and highlight the punctuation/whitespace, contradicting the comment and PR claim that punctuation stays out of the emphasis. For example, let x = 1; vs let x = 1, would highlight ; and ,. A test for punctuation-only or whitespace-only changes is missing.

Style::default()
.fg(palette::DIFF_ADDED)
.bg(palette::DIFF_ADDED_BG),
added_style(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] No-newline markers are now rendered as diff header rows

Previously, \ No newline at end of file lines fell through the old loop and were not rendered. The new branch renders these markers with render_header_line, adding visible header-styled rows for every diff containing such markers and consuming rows from the bounded preview budget. This is a user-visible output change outside the stated intraline-emphasis scope. If this visibility is intentional it should be reflected in the PR description; otherwise, markers should be peeled for pairing but not rendered as header rows.

changed = true;
total_words += usize::from(is_word);
push_segment(&mut old_segments, text, true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use is_word rather than true so only word tokens are emphasised, keeping punctuation and whitespace changes plain.

Suggested change
}
push_segment(&mut old_segments, text, is_word);

changed = true;
total_words += usize::from(is_word);
push_segment(&mut new_segments, text, true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use is_word rather than true for inserted tokens so only word tokens are emphasised.

Suggested change
}
push_segment(&mut new_segments, text, is_word);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants