Skip to content

Commit d191840

Browse files
committed
fix(diff): correct inline highlight offsets for tab and whitespace changes
Diff on raw code so whitespace-only changes (e.g. tab to spaces) are detected, then map raw offsets to rendered offsets using expandtabs column semantics with a defensive fallback if lengths mismatch. - Add _build_offset_map using expandtabs column tracking with bounded monotonic fallback when rendered length diverges - Reuse highlighted Text objects for skipped (low-similarity) pairs to avoid redundant highlighting - Preserve trailing whitespace in highlighted output (strip only the newline Pygments appends, not meaningful trailing spaces) - Use public Text.spans instead of private Text._spans in tests - Add tests for tab-indented diffs, tab-to-space changes, mixed tab+space edits, precise per-character offsets, and trailing whitespace preservation
1 parent a8f09bc commit d191840

3 files changed

Lines changed: 232 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Only write entries that are worth mentioning to users.
3232
- Shell: Add `/undo` and `/fork` commands for session forking — `/undo` lets you pick a previous turn and fork a new session with the selected message pre-filled for re-editing; `/fork` duplicates the entire session history into a new session; the original session is always preserved
3333
- CLI: Add `-r` as a short alias for `--session` and print a resume hint (`kimi -r <session-id>`) whenever a session exits — covers normal exit, Ctrl-C, `/undo`, `/fork`, and `/sessions` switch so users can always find their way back
3434
- Core: Fix `custom_headers` not being passed to non-Kimi providers — OpenAI, Anthropic, Google GenAI, and Vertex AI providers now correctly forward custom headers configured in `providers.*.custom_headers`
35+
- Shell: Fix inline diff highlights misaligned on lines containing tabs — raw-code diff offsets are now mapped to rendered positions via expandtabs column tracking so highlight spans land correctly after tab expansion
3536

3637
## 1.29.0 (2026-04-01)
3738

src/kimi_cli/utils/rich/diff_render.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,48 @@ def _make_highlighter(path: str) -> KimiSyntax:
139139

140140
def _highlight(highlighter: KimiSyntax, code: str) -> Text:
141141
t = highlighter.highlight(code)
142-
t.rstrip()
142+
# Pygments appends a trailing newline (ensurenl=True); strip only that,
143+
# not trailing whitespace which may be meaningful in diffs.
144+
if t.plain.endswith("\n"):
145+
t.right_crop(1)
143146
return t
144147

145148

149+
def _build_offset_map(raw: str, rendered: str, tab_size: int) -> list[int]:
150+
"""Build a mapping from raw-string indices to rendered-string indices.
151+
152+
The highlighter expands tabs via ``str.expandtabs(tab_size)`` before
153+
tokenising. We replicate the same column-aware expansion that the
154+
Python builtin defines (the only parameter is *tab_size*; the behaviour
155+
is fully specified in the language docs and has no external
156+
configurability).
157+
158+
Returns a list of length ``len(raw) + 1`` where ``result[i]`` is the
159+
rendered offset corresponding to raw position *i*.
160+
"""
161+
if raw == rendered:
162+
return list(range(len(raw) + 1))
163+
offsets: list[int] = []
164+
col = 0
165+
for ch in raw:
166+
offsets.append(col)
167+
if ch == "\t":
168+
col += tab_size - (col % tab_size)
169+
else:
170+
col += 1
171+
offsets.append(col)
172+
if col != len(rendered):
173+
# The highlighter transformed the text in a way we didn't expect.
174+
# Return a bounded, monotonic best-effort map so inline stylizing
175+
# can proceed without crashing or producing out-of-range offsets.
176+
rendered_len = len(rendered)
177+
raw_len = len(raw)
178+
if raw_len == 0:
179+
return [rendered_len]
180+
return [(i * rendered_len) // raw_len for i in range(raw_len)] + [rendered_len]
181+
return offsets
182+
183+
146184
def _apply_inline_diff(
147185
highlighter: KimiSyntax,
148186
del_lines: list[DiffLine],
@@ -153,20 +191,27 @@ def _apply_inline_diff(
153191
Modifies DiffLine.content in place for paired lines.
154192
"""
155193
colors = get_diff_colors()
194+
tab_size = highlighter.tab_size
156195
paired = min(len(del_lines), len(add_lines))
157196
for j in range(paired):
158197
old_code = del_lines[j].code
159198
new_code = add_lines[j].code
199+
old_text = _highlight(highlighter, old_code)
200+
new_text = _highlight(highlighter, new_code)
201+
# Store highlighted content even when skipping inline pairing,
202+
# so _highlight_hunk's second pass doesn't re-highlight these lines.
203+
del_lines[j].content = old_text
204+
add_lines[j].content = new_text
160205
sm = SequenceMatcher(None, old_code, new_code)
161206
if sm.ratio() < _INLINE_DIFF_MIN_RATIO:
162207
continue
163-
old_text = _highlight(highlighter, old_code)
164-
new_text = _highlight(highlighter, new_code)
208+
old_map = _build_offset_map(old_code, old_text.plain, tab_size)
209+
new_map = _build_offset_map(new_code, new_text.plain, tab_size)
165210
for op, i1, i2, j1, j2 in sm.get_opcodes():
166211
if op in ("delete", "replace"):
167-
old_text.stylize(colors.del_hl, i1, i2)
212+
old_text.stylize(colors.del_hl, old_map[i1], old_map[i2])
168213
if op in ("insert", "replace"):
169-
new_text.stylize(colors.add_hl, j1, j2)
214+
new_text.stylize(colors.add_hl, new_map[j1], new_map[j2])
170215
del_lines[j].content = old_text
171216
del_lines[j].is_inline_paired = True
172217
add_lines[j].content = new_text

tests/utils/test_diff_render.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,187 @@ def test_unequal_block_sizes_partial_pairing(self) -> None:
163163
# 3rd delete not paired
164164
assert not deletes[2].is_inline_paired
165165

166+
def test_inline_diff_with_tabs(self) -> None:
167+
"""Inline highlight offsets must account for tab-to-space expansion."""
168+
old = "\told_value = 1"
169+
new = "\tnew_value = 2"
170+
hunks = _build_diff_lines(old, new, 1, 1)
171+
hl = _make_highlighter("test.py")
172+
_highlight_hunk(hl, hunks[0])
173+
deletes = [dl for dl in hunks[0] if dl.kind == DiffLineKind.DELETE]
174+
adds = [dl for dl in hunks[0] if dl.kind == DiffLineKind.ADD]
175+
assert deletes[0].is_inline_paired
176+
assert adds[0].is_inline_paired
177+
assert deletes[0].content is not None
178+
assert adds[0].content is not None
179+
del_plain = deletes[0].content.plain
180+
add_plain = adds[0].content.plain
181+
assert "old_value" in del_plain
182+
assert "new_value" in add_plain
183+
# Verify the highlight spans cover the actual changed words,
184+
# not characters shifted by unexpanded-tab offsets.
185+
from kimi_cli.utils.rich.diff_render import get_diff_colors
186+
187+
colors = get_diff_colors()
188+
assert deletes[0].content is not None
189+
assert adds[0].content is not None
190+
del_hl_spans = [
191+
(s.start, s.end) for s in deletes[0].content.spans if s.style == colors.del_hl
192+
]
193+
add_hl_spans = [(s.start, s.end) for s in adds[0].content.spans if s.style == colors.add_hl]
194+
# The highlighted region in the old line must cover "old" (from old_value)
195+
del_highlighted = "".join(del_plain[s:e] for s, e in del_hl_spans)
196+
add_highlighted = "".join(add_plain[s:e] for s, e in add_hl_spans)
197+
assert "old" in del_highlighted, f"expected 'old' in highlighted text: {del_highlighted!r}"
198+
assert "new" in add_highlighted, f"expected 'new' in highlighted text: {add_highlighted!r}"
199+
200+
def test_inline_diff_tab_to_spaces(self) -> None:
201+
"""A change from tab to spaces should be highlighted as an inline diff."""
202+
old = "\tvalue = 1"
203+
new = " value = 1"
204+
hunks = _build_diff_lines(old, new, 1, 1)
205+
hl = _make_highlighter("test.py")
206+
_highlight_hunk(hl, hunks[0])
207+
deletes = [dl for dl in hunks[0] if dl.kind == DiffLineKind.DELETE]
208+
adds = [dl for dl in hunks[0] if dl.kind == DiffLineKind.ADD]
209+
assert deletes[0].is_inline_paired
210+
assert adds[0].is_inline_paired
211+
# The inline highlight should cover the indentation region where
212+
# tab vs spaces differ.
213+
from kimi_cli.utils.rich.diff_render import get_diff_colors
214+
215+
colors = get_diff_colors()
216+
assert deletes[0].content is not None
217+
assert adds[0].content is not None
218+
del_hl_spans = [
219+
(s.start, s.end) for s in deletes[0].content.spans if s.style == colors.del_hl
220+
]
221+
add_hl_spans = [(s.start, s.end) for s in adds[0].content.spans if s.style == colors.add_hl]
222+
# There must be highlight spans — the whitespace change should not be invisible
223+
assert del_hl_spans, "tab indentation should be highlighted in deleted line"
224+
assert add_hl_spans, "space indentation should be highlighted in added line"
225+
226+
def test_inline_diff_mixed_tab_and_space(self) -> None:
227+
"""Inline highlight must handle a tab adjacent to a literal space."""
228+
old = "a\t b"
229+
new = "a b"
230+
hunks = _build_diff_lines(old, new, 1, 1)
231+
hl = _make_highlighter("test.txt")
232+
_highlight_hunk(hl, hunks[0])
233+
deletes = [dl for dl in hunks[0] if dl.kind == DiffLineKind.DELETE]
234+
adds = [dl for dl in hunks[0] if dl.kind == DiffLineKind.ADD]
235+
assert deletes[0].is_inline_paired
236+
assert adds[0].is_inline_paired
237+
from kimi_cli.utils.rich.diff_render import get_diff_colors
238+
239+
colors = get_diff_colors()
240+
assert deletes[0].content is not None
241+
assert adds[0].content is not None
242+
del_hl_spans = [
243+
(s.start, s.end) for s in deletes[0].content.spans if s.style == colors.del_hl
244+
]
245+
add_hl_spans = [(s.start, s.end) for s in adds[0].content.spans if s.style == colors.add_hl]
246+
# The tab+space region in the old line must be highlighted with a
247+
# non-zero width — not collapsed to an empty range.
248+
assert del_hl_spans, "tab+space region should be highlighted in deleted line"
249+
assert all(s < e for s, e in del_hl_spans), "highlight spans must have non-zero width"
250+
assert add_hl_spans, "space region should be highlighted in added line"
251+
assert all(s < e for s, e in add_hl_spans), "highlight spans must have non-zero width"
252+
253+
def test_inline_diff_tab_to_spaces_precise(self) -> None:
254+
"""Highlight spans must cover exactly the indentation region."""
255+
# "a\tb" with tab_size=4: rendered as "a b" (tab expands to 3 spaces)
256+
# "a b": literal 3 spaces
257+
# The only difference is the tab vs 3 spaces — both render as "a b".
258+
# The highlight should cover the whitespace region (rendered[1:4]).
259+
old = "a\tb"
260+
new = "a b"
261+
hunks = _build_diff_lines(old, new, 1, 1)
262+
hl = _make_highlighter("test.txt")
263+
_highlight_hunk(hl, hunks[0])
264+
deletes = [dl for dl in hunks[0] if dl.kind == DiffLineKind.DELETE]
265+
adds = [dl for dl in hunks[0] if dl.kind == DiffLineKind.ADD]
266+
assert deletes[0].is_inline_paired
267+
assert adds[0].is_inline_paired
268+
from kimi_cli.utils.rich.diff_render import get_diff_colors
269+
270+
colors = get_diff_colors()
271+
assert deletes[0].content is not None
272+
assert adds[0].content is not None
273+
del_hl_spans = [
274+
(s.start, s.end) for s in deletes[0].content.spans if s.style == colors.del_hl
275+
]
276+
add_hl_spans = [(s.start, s.end) for s in adds[0].content.spans if s.style == colors.add_hl]
277+
del_plain = deletes[0].content.plain # "a b"
278+
add_plain = adds[0].content.plain # "a b"
279+
del_highlighted = "".join(del_plain[s:e] for s, e in del_hl_spans)
280+
add_highlighted = "".join(add_plain[s:e] for s, e in add_hl_spans)
281+
# The highlighted region must be exactly the whitespace, not spill into "a" or "b".
282+
assert "a" not in del_highlighted, f"'a' should not be highlighted: {del_hl_spans}"
283+
assert "b" not in del_highlighted, f"'b' should not be highlighted: {del_hl_spans}"
284+
assert "a" not in add_highlighted, f"'a' should not be highlighted: {add_hl_spans}"
285+
assert "b" not in add_highlighted, f"'b' should not be highlighted: {add_hl_spans}"
286+
assert del_hl_spans, "whitespace region should be highlighted in deleted line"
287+
assert add_hl_spans, "whitespace region should be highlighted in added line"
288+
289+
def test_inline_diff_multiple_tabs_precise(self) -> None:
290+
"""Changing only the first tab must not highlight the second tab."""
291+
# old: "a\t\ta" with tab_size=4 renders as "a a"
292+
# a=col0, tab1 expands to 3sp (cols 1-3), tab2 expands to 4sp (cols 4-7), a=col8
293+
# new: "a \ta" renders as "a a"
294+
# a=col0, space=col1, tab expands to 2sp (cols 2-3), a=col4
295+
# Only the first tab (old rendered[1:4]) should be highlighted,
296+
# NOT the second tab (old rendered[4:8]).
297+
old = "a\t\ta"
298+
new = "a \ta"
299+
hunks = _build_diff_lines(old, new, 1, 1)
300+
hl = _make_highlighter("test.txt")
301+
_highlight_hunk(hl, hunks[0])
302+
deletes = [dl for dl in hunks[0] if dl.kind == DiffLineKind.DELETE]
303+
assert deletes[0].is_inline_paired
304+
assert deletes[0].content is not None
305+
from kimi_cli.utils.rich.diff_render import get_diff_colors
306+
307+
colors = get_diff_colors()
308+
del_hl_spans = [
309+
(s.start, s.end) for s in deletes[0].content.spans if s.style == colors.del_hl
310+
]
311+
# The highlight in the old line must cover only the first tab's
312+
# rendered region [1:4], not spill into the second tab [4:8].
313+
assert del_hl_spans, "first tab region should be highlighted"
314+
for s, e in del_hl_spans:
315+
assert e <= 4, (
316+
f"highlight span ({s},{e}) extends into second tab region "
317+
f"(rendered[4:8]), should stop at 4"
318+
)
319+
320+
def test_inline_diff_trailing_whitespace(self) -> None:
321+
"""Trailing whitespace must be preserved and highlighted in diffs."""
322+
old = "hello "
323+
new = "hello"
324+
hunks = _build_diff_lines(old, new, 1, 1)
325+
hl = _make_highlighter("test.txt")
326+
_highlight_hunk(hl, hunks[0]) # should not raise
327+
deletes = [dl for dl in hunks[0] if dl.kind == DiffLineKind.DELETE]
328+
adds = [dl for dl in hunks[0] if dl.kind == DiffLineKind.ADD]
329+
assert deletes[0].is_inline_paired
330+
assert adds[0].is_inline_paired
331+
# Trailing spaces must be preserved in the rendered content,
332+
# not stripped away — they are a meaningful part of the diff.
333+
assert deletes[0].content is not None
334+
assert deletes[0].content.plain == "hello "
335+
from kimi_cli.utils.rich.diff_render import get_diff_colors
336+
337+
colors = get_diff_colors()
338+
del_hl_spans = [
339+
(s.start, s.end) for s in deletes[0].content.spans if s.style == colors.del_hl
340+
]
341+
# The trailing spaces should be highlighted as the deleted region.
342+
del_highlighted = "".join(deletes[0].content.plain[s:e] for s, e in del_hl_spans)
343+
assert " " in del_highlighted, (
344+
f"trailing spaces should be highlighted, got: {del_highlighted!r}"
345+
)
346+
166347

167348
# ---------------------------------------------------------------------------
168349
# collect_diff_hunks

0 commit comments

Comments
 (0)