From 12b5ec88a43ed9e1c3c3b230e79c93fb99dc17e1 Mon Sep 17 00:00:00 2001 From: Yi Yang Date: Wed, 1 Apr 2026 23:02:53 +0800 Subject: [PATCH] fix(diff): align inline highlight offsets with tab-expanded text --- CHANGELOG.md | 2 ++ src/kimi_cli/utils/rich/diff_render.py | 10 ++++---- tests/utils/test_diff_render.py | 32 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df47f2076..5d8a7b190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Fix inline diff highlights misaligned on lines containing tabs — the sequence matcher now compares tab-expanded text so highlight offsets match the rendered output + ## 1.29.0 (2026-04-01) - Core: Support hierarchical `AGENTS.md` loading — the CLI now discovers and merges `AGENTS.md` files from the git project root down to the working directory, including `.kimi/AGENTS.md` at each level; deeper files take priority under a 32 KiB budget cap, ensuring the most specific instructions are never truncated diff --git a/src/kimi_cli/utils/rich/diff_render.py b/src/kimi_cli/utils/rich/diff_render.py index 4ff71ee9c..e924fbf95 100644 --- a/src/kimi_cli/utils/rich/diff_render.py +++ b/src/kimi_cli/utils/rich/diff_render.py @@ -155,13 +155,13 @@ def _apply_inline_diff( colors = get_diff_colors() paired = min(len(del_lines), len(add_lines)) for j in range(paired): - old_code = del_lines[j].code - new_code = add_lines[j].code - sm = SequenceMatcher(None, old_code, new_code) + old_text = _highlight(highlighter, del_lines[j].code) + new_text = _highlight(highlighter, add_lines[j].code) + # Compare the highlighted plain text so offsets match the Text objects + # (highlighting may expand tabs, so raw .code offsets would be wrong). + sm = SequenceMatcher(None, old_text.plain, new_text.plain) if sm.ratio() < _INLINE_DIFF_MIN_RATIO: continue - old_text = _highlight(highlighter, old_code) - new_text = _highlight(highlighter, new_code) for op, i1, i2, j1, j2 in sm.get_opcodes(): if op in ("delete", "replace"): old_text.stylize(colors.del_hl, i1, i2) diff --git a/tests/utils/test_diff_render.py b/tests/utils/test_diff_render.py index 6640e7ee3..7aaa5027d 100644 --- a/tests/utils/test_diff_render.py +++ b/tests/utils/test_diff_render.py @@ -163,6 +163,38 @@ def test_unequal_block_sizes_partial_pairing(self) -> None: # 3rd delete not paired assert not deletes[2].is_inline_paired + def test_inline_diff_with_tabs(self) -> None: + """Inline highlight offsets must account for tab-to-space expansion.""" + old = "\told_value = 1" + new = "\tnew_value = 2" + hunks = _build_diff_lines(old, new, 1, 1) + hl = _make_highlighter("test.py") + _highlight_hunk(hl, hunks[0]) + deletes = [dl for dl in hunks[0] if dl.kind == DiffLineKind.DELETE] + adds = [dl for dl in hunks[0] if dl.kind == DiffLineKind.ADD] + assert deletes[0].is_inline_paired + assert adds[0].is_inline_paired + del_plain = deletes[0].content.plain + add_plain = adds[0].content.plain + assert "old_value" in del_plain + assert "new_value" in add_plain + # Verify the highlight spans cover the actual changed words, + # not characters shifted by unexpanded-tab offsets. + from kimi_cli.utils.rich.diff_render import get_diff_colors + + colors = get_diff_colors() + del_hl_spans = [ + (s.start, s.end) for s in deletes[0].content._spans if s.style == colors.del_hl + ] + add_hl_spans = [ + (s.start, s.end) for s in adds[0].content._spans if s.style == colors.add_hl + ] + # The highlighted region in the old line must cover "old" (from old_value) + del_highlighted = "".join(del_plain[s:e] for s, e in del_hl_spans) + add_highlighted = "".join(add_plain[s:e] for s, e in add_hl_spans) + assert "old" in del_highlighted, f"expected 'old' in highlighted text: {del_highlighted!r}" + assert "new" in add_highlighted, f"expected 'new' in highlighted text: {add_highlighted!r}" + # --------------------------------------------------------------------------- # collect_diff_hunks