From 39dc76a8c6ae24dda4ba12a94058c8336720b728 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Tue, 26 May 2026 20:50:38 +0000 Subject: [PATCH] feat(tui): word-level highlighting in edit_file diff view Inside a modified line, highlight only the specific tokens that changed instead of painting the whole line. Paired delete/insert lines are run through a word-aware LCS diff; differing segments are rendered with a stronger, more saturated background tint derived from the active theme's DiffAddBg / DiffRemoveBg colors. New helpers: - pkg/tui/styles: DiffAddEmphBg / DiffRemoveEmphBg colors and matching styles, computed in rebuildStyles via a deriveEmphasisBg HSL boost so every theme gets sensible emphasis colors automatically. - pkg/tui/components/tool/editfile/wordiff.go: tokenizer + word-level diff that respects identifier, whitespace, and punctuation boundaries. --- pkg/tui/components/tool/editfile/render.go | 178 ++++++++++++++++-- .../components/tool/editfile/render_test.go | 128 +++++++++++++ pkg/tui/components/tool/editfile/wordiff.go | 163 ++++++++++++++++ .../components/tool/editfile/wordiff_test.go | 128 +++++++++++++ pkg/tui/styles/colorutil.go | 35 ++++ pkg/tui/styles/styles.go | 24 ++- pkg/tui/styles/theme.go | 4 + 7 files changed, 645 insertions(+), 15 deletions(-) create mode 100644 pkg/tui/components/tool/editfile/render_test.go create mode 100644 pkg/tui/components/tool/editfile/wordiff.go create mode 100644 pkg/tui/components/tool/editfile/wordiff_test.go diff --git a/pkg/tui/components/tool/editfile/render.go b/pkg/tui/components/tool/editfile/render.go index de1424275..30938c4e2 100644 --- a/pkg/tui/components/tool/editfile/render.go +++ b/pkg/tui/components/tool/editfile/render.go @@ -71,6 +71,10 @@ func InvalidateCaches() { type chromaToken struct { Text string Style lipgloss.Style + // Emphasized marks tokens whose underlying text was identified as part + // of a word-level diff. Renderers paint these with a stronger background + // so users can locate the precise edit within a long line. + Emphasized bool } type linePair struct { @@ -310,18 +314,111 @@ func chromaToLipgloss(tokenType chroma.TokenType, style *chroma.Style) lipgloss. return lipStyle } +// segRange records the byte extent of a single word-diff segment within the +// full line, plus whether that extent represents a change. +type segRange struct { + start, end int + changed bool +} + +// applyWordEmphasis re-tags chroma tokens so that any portion of the token +// text that falls inside a "changed" word-diff segment is split off into its +// own emphasized token. Token boundaries from chroma and from the word-diff +// rarely line up, so each chroma token is sliced against the segment cursor +// to produce correctly emphasized sub-tokens. +func applyWordEmphasis(tokens []chromaToken, segs []wordSegment) []chromaToken { + if len(segs) == 0 { + return tokens + } + + ranges := make([]segRange, 0, len(segs)) + pos := 0 + for _, s := range segs { + ranges = append(ranges, segRange{start: pos, end: pos + len(s.Text), changed: s.Changed}) + pos += len(s.Text) + } + + out := make([]chromaToken, 0, len(tokens)) + bytePos := 0 + for _, tok := range tokens { + text := tok.Text + tokStart := bytePos + tokEnd := bytePos + len(text) + + cursor := tokStart + for cursor < tokEnd { + r := findSegRange(ranges, cursor) + if r == nil { + out = append(out, chromaToken{ + Text: text[cursor-tokStart:], + Style: tok.Style, + }) + break + } + end := min(tokEnd, r.end) + sub := text[cursor-tokStart : end-tokStart] + if sub != "" { + out = append(out, chromaToken{ + Text: sub, + Style: tok.Style, + Emphasized: r.changed, + }) + } + cursor = end + } + bytePos = tokEnd + } + + return out +} + +func findSegRange(ranges []segRange, pos int) *segRange { + for i := range ranges { + if pos >= ranges[i].start && pos < ranges[i].end { + return &ranges[i] + } + } + return nil +} + +// emphasisStyleFor returns the per-side emphasis style (with a stronger +// background tint) for the row's diff kind. Returns the unchanged style for +// kinds that should never carry word-level emphasis. +func emphasisStyleFor(kind udiff.OpKind) lipgloss.Style { + switch kind { + case udiff.Delete: + return styles.DiffRemoveEmphStyle + case udiff.Insert: + return styles.DiffAddEmphStyle + default: + return styles.DiffUnchangedStyle + } +} + func renderDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, width int) string { var output strings.Builder contentWidth := width - lineNumWidth for _, hunk := range diff { + // Build word-diff lookups for paired delete/insert lines so we can + // emphasize the precise tokens that changed. + wordDiffs := buildLineWordDiffs(hunk.Lines) + oldLineNum := hunk.FromLine newLineNum := hunk.ToLine - for _, line := range hunk.Lines { + for li, line := range hunk.Lines { lineNum := getDisplayLineNumber(&line, &oldLineNum, &newLineNum) content := prepareContent(line.Content) tokens := syntaxHighlight(content, filePath) + if wd, ok := wordDiffs[li]; ok { + switch line.Kind { + case udiff.Delete: + tokens = applyWordEmphasis(tokens, wd.old) + case udiff.Insert: + tokens = applyWordEmphasis(tokens, wd.new) + } + } lineStyle := getLineStyle(line.Kind) wrappedTokens := wrapTokens(tokens, contentWidth) @@ -334,7 +431,7 @@ func renderDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, width in // Use continuation indicator for wrapped lines lineNumStr = styles.LineNumberStyle.Render(" → ") } - rendered := renderTokensWithStyle(tokenLine, lineStyle) + rendered := renderTokensWithStyle(tokenLine, lineStyle, line.Kind) padded := padToWidth(rendered, contentWidth, lineStyle) output.WriteString(lineNumStr + padded + "\n") } @@ -358,8 +455,22 @@ func renderSplitDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, wid for _, hunk := range diff { for _, pair := range pairDiffLines(hunk.Lines, hunk.FromLine, hunk.ToLine) { - leftLines := renderSplitSide(pair.old, pair.oldLineNum, filePath, contentWidth) - rightLines := renderSplitSide(pair.new, pair.newLineNum, filePath, contentWidth) + // Word-diff is only meaningful when both halves are present + // and represent a delete/insert pair. Inputs go through + // prepareContent so the segment byte offsets line up with the + // chroma tokens produced for rendering (which also receive + // tab-expanded content). + var oldSegs, newSegs []wordSegment + if pair.old != nil && pair.new != nil && + pair.old.Kind == udiff.Delete && pair.new.Kind == udiff.Insert { + oldSegs, newSegs = diffWords( + prepareContent(pair.old.Content), + prepareContent(pair.new.Content), + ) + } + + leftLines := renderSplitSide(pair.old, pair.oldLineNum, filePath, contentWidth, oldSegs) + rightLines := renderSplitSide(pair.new, pair.newLineNum, filePath, contentWidth, newSegs) // Ensure both sides have the same number of lines for alignment maxLines := max(len(rightLines), len(leftLines)) @@ -383,6 +494,32 @@ func renderSplitDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, wid return strings.TrimSuffix(output.String(), "\n") } +// lineWordDiff holds the per-side segment arrays computed for one +// delete/insert pair. It is keyed by the *delete* line index — the matching +// insert sits directly after it. +type lineWordDiff struct { + old []wordSegment + new []wordSegment +} + +func buildLineWordDiffs(lines []udiff.Line) map[int]lineWordDiff { + out := map[int]lineWordDiff{} + for i := range len(lines) - 1 { + if lines[i].Kind != udiff.Delete || lines[i+1].Kind != udiff.Insert { + continue + } + // Use prepareContent so segment offsets align with the chroma + // tokens (which are produced from the same tab-expanded text). + oldText := prepareContent(lines[i].Content) + newText := prepareContent(lines[i+1].Content) + oldSegs, newSegs := diffWords(oldText, newText) + wd := lineWordDiff{old: oldSegs, new: newSegs} + out[i] = wd + out[i+1] = wd + } + return out +} + func getDisplayLineNumber(line *udiff.Line, oldLineNum, newLineNum *int) int { switch line.Kind { case udiff.Delete: @@ -456,7 +593,11 @@ func wrapTokens(tokens []chromaToken, maxWidth int) [][]chromaToken { fitWidth = runewidth.RuneWidth(r) } - currentLine = append(currentLine, chromaToken{Text: text[:fitLen], Style: token.Style}) + currentLine = append(currentLine, chromaToken{ + Text: text[:fitLen], + Style: token.Style, + Emphasized: token.Emphasized, + }) currentWidth += fitWidth text = text[fitLen:] } @@ -473,14 +614,20 @@ func wrapTokens(tokens []chromaToken, maxWidth int) [][]chromaToken { return lines } -// renderSplitSide renders a split side with text wrapping support -func renderSplitSide(line *udiff.Line, lineNum int, filePath string, width int) []string { +// renderSplitSide renders a split side with text wrapping support. +// When wordSegs is non-nil and the line is part of a delete/insert pair, the +// chroma tokens are re-tagged so the changed substrings render with a +// stronger background tint. +func renderSplitSide(line *udiff.Line, lineNum int, filePath string, width int, wordSegs []wordSegment) []string { if line == nil { return []string{renderEmptySplitSide(width)} } content := prepareContent(line.Content) tokens := syntaxHighlight(content, filePath) + if len(wordSegs) > 0 { + tokens = applyWordEmphasis(tokens, wordSegs) + } lineStyle := getLineStyle(line.Kind) wrappedTokens := wrapTokens(tokens, width) @@ -494,7 +641,7 @@ func renderSplitSide(line *udiff.Line, lineNum int, filePath string, width int) // Use continuation indicator for wrapped lines lineNumStr = " → " } - rendered := renderTokensWithStyle(tokenLine, lineStyle) + rendered := renderTokensWithStyle(tokenLine, lineStyle, line.Kind) padded := padToWidth(rendered, width, lineStyle) result = append(result, styles.LineNumberStyle.Render(lineNumStr)+padded) } @@ -509,12 +656,21 @@ func renderEmptySplitSide(width int) string { return styles.LineNumberStyle.Render(lineNumStr) + emptySpace } -func renderTokensWithStyle(tokens []chromaToken, lineStyle lipgloss.Style) string { +func renderTokensWithStyle(tokens []chromaToken, lineStyle lipgloss.Style, kind udiff.OpKind) string { var output strings.Builder + emph := emphasisStyleFor(kind) for _, token := range tokens { - styledToken := token.Style.Background(lineStyle.GetBackground()) - output.WriteString(styledToken.Render(token.Text)) + if token.Emphasized && (kind == udiff.Delete || kind == udiff.Insert) { + // Keep the chroma foreground so syntax colors carry through into + // the emphasized block, but override the background with the + // stronger emphasis tint and add bold for extra weight. + style := token.Style.Background(emph.GetBackground()).Bold(true) + output.WriteString(style.Render(token.Text)) + continue + } + style := token.Style.Background(lineStyle.GetBackground()) + output.WriteString(style.Render(token.Text)) } return output.String() diff --git a/pkg/tui/components/tool/editfile/render_test.go b/pkg/tui/components/tool/editfile/render_test.go new file mode 100644 index 000000000..c2a54f009 --- /dev/null +++ b/pkg/tui/components/tool/editfile/render_test.go @@ -0,0 +1,128 @@ +package editfile + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tui/types" +) + +// TestRenderEditFile_EndToEnd writes a temporary source file, builds an +// edit_file tool call against it, and renders both unified and split views. +// The test focuses on structural elements rather than exact escape sequences, +// which depend on the active theme. +func TestRenderEditFile_EndToEnd(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + + updated := `package main + +import "fmt" + +func main() { + x := 10 + y := 20 + fmt.Println(x + y) +} +` + // Simulate the post-execution state: file already contains the new content. + require.NoError(t, os.WriteFile(path, []byte(updated), 0o644)) + + args := map[string]any{ + "path": path, + "edits": []map[string]string{ + { + "oldText": "\tx := 1\n\ty := 2", + "newText": "\tx := 10\n\ty := 20", + }, + }, + } + encoded, err := json.Marshal(args) + require.NoError(t, err) + + toolCall := tools.ToolCall{ + ID: "test-render-1", + Function: tools.FunctionCall{ + Name: "edit_file", + Arguments: string(encoded), + }, + } + + // Reset cache so the test is hermetic across runs. + InvalidateCaches() + t.Cleanup(InvalidateCaches) + + unified := renderEditFile(toolCall, 120, false, types.ToolStatusCompleted) + split := renderEditFile(toolCall, 120, true, types.ToolStatusCompleted) + + for _, out := range []string{unified, split} { + assert.NotEmpty(t, out) + // Source content should appear in the diff regardless of theme escapes. + assert.True(t, strings.Contains(out, "10") || strings.Contains(out, "20")) + } + + added, removed := countDiffLines(toolCall, types.ToolStatusCompleted) + assert.Equal(t, 2, added) + assert.Equal(t, 2, removed) +} + +func TestRenderEditFile_TabIndentedLineDoesNotPanic(t *testing.T) { + // Regression: tab-indented modified lines used to feed raw (1-byte-tab) + // text into diffWords while chroma tokens were built from the + // tab-expanded variant, producing out-of-bounds slice indices in + // applyWordEmphasis. The fix routes both through prepareContent. + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + + updated := "package main\n\nfunc main() {\n\tx := 10\n}\n" + require.NoError(t, os.WriteFile(path, []byte(updated), 0o644)) + + args := map[string]any{ + "path": path, + "edits": []map[string]string{ + {"oldText": "\tx := 1", "newText": "\tx := 10"}, + }, + } + encoded, _ := json.Marshal(args) + toolCall := tools.ToolCall{ + ID: "test-tab-1", + Function: tools.FunctionCall{Name: "edit_file", Arguments: string(encoded)}, + } + + InvalidateCaches() + t.Cleanup(InvalidateCaches) + + assert.NotPanics(t, func() { + _ = renderEditFile(toolCall, 120, false, types.ToolStatusCompleted) + _ = renderEditFile(toolCall, 120, true, types.ToolStatusCompleted) + }) +} + +func TestRenderEditFile_MissingFileReturnsEmptyDiff(t *testing.T) { + args := map[string]any{ + "path": "/nonexistent/path/that/does/not/exist.go", + "edits": []map[string]string{ + {"oldText": "a", "newText": "b"}, + }, + } + encoded, _ := json.Marshal(args) + toolCall := tools.ToolCall{ + ID: "test-missing-1", + Function: tools.FunctionCall{Name: "edit_file", Arguments: string(encoded)}, + } + + InvalidateCaches() + t.Cleanup(InvalidateCaches) + + // Should not panic on a missing source file. + assert.NotPanics(t, func() { + _ = renderEditFile(toolCall, 100, false, types.ToolStatusCompleted) + }) +} diff --git a/pkg/tui/components/tool/editfile/wordiff.go b/pkg/tui/components/tool/editfile/wordiff.go new file mode 100644 index 000000000..4cb218bed --- /dev/null +++ b/pkg/tui/components/tool/editfile/wordiff.go @@ -0,0 +1,163 @@ +package editfile + +import ( + "strings" + "unicode" + + "github.com/aymanbagabas/go-udiff/lcs" +) + +// wordSegment is a contiguous substring of a line, paired with a flag that +// indicates whether the segment differs from its counterpart on the paired +// line. Word-level diffing emits a sequence of segments for both the old and +// new content of a delete+insert pair so that unchanged prefixes/suffixes can +// be dimmed and the actual edit can be emphasized. +type wordSegment struct { + Text string + Changed bool +} + +// tokenizeForWordDiff splits a line into word-ish tokens preserving every byte. +// Tokens are runs of identifier characters, runs of whitespace, and individual +// punctuation/symbol runes. This granularity matches what users intuitively +// recognize as a "word change" in a code diff (`foo` -> `bar`, `42` -> `43`), +// while still picking up small edits like added punctuation. +func tokenizeForWordDiff(line string) []string { + if line == "" { + return nil + } + + var tokens []string + var current strings.Builder + currentKind := -1 + + flush := func() { + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + } + + for _, r := range line { + k := runeKind(r) + // Identifier and whitespace runs are coalesced; everything else is + // emitted as a single-rune token so a lone added bracket or comma + // shows up as a precise highlight. + if k != currentKind || (k != kindIdent && k != kindSpace) { + flush() + currentKind = k + } + current.WriteRune(r) + } + flush() + + return tokens +} + +const ( + kindIdent = iota + kindSpace + kindOther +) + +func runeKind(r rune) int { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_': + return kindIdent + case unicode.IsSpace(r): + return kindSpace + default: + return kindOther + } +} + +// diffWords compares two lines token-by-token and returns segments for each +// side. Segments are concatenated in order so callers can re-emit the full +// original line while restyling only the portions that changed. +// +// The implementation uses the same LCS routine that powers udiff so changes +// fall on natural token boundaries instead of the rune boundaries that +// udiff.Strings would produce on a single line. +func diffWords(oldLine, newLine string) (oldSegs, newSegs []wordSegment) { + if oldLine == newLine { + seg := []wordSegment{{Text: oldLine, Changed: false}} + return seg, seg + } + + oldTokens := tokenizeForWordDiff(oldLine) + newTokens := tokenizeForWordDiff(newLine) + + if len(oldTokens) == 0 || len(newTokens) == 0 { + // Nothing on one side to compare against — fall back to whole-line + // highlight so users still see something changed. + oldSegs = []wordSegment{{Text: oldLine, Changed: oldLine != ""}} + newSegs = []wordSegment{{Text: newLine, Changed: newLine != ""}} + return oldSegs, newSegs + } + + diffs := lcs.DiffLines(oldTokens, newTokens) + + // Walk diffs in order to interleave equal regions with changed regions. + // Old- and new-side equal gaps are handled independently so the + // segment streams reconstruct their respective source strings exactly, + // even if the LCS implementation produces an asymmetric gap. + oldPos, newPos := 0, 0 + for _, d := range diffs { + if d.Start > oldPos { + eq := strings.Join(oldTokens[oldPos:d.Start], "") + if eq != "" { + oldSegs = append(oldSegs, wordSegment{Text: eq, Changed: false}) + } + } + if d.ReplStart > newPos { + eq := strings.Join(newTokens[newPos:d.ReplStart], "") + if eq != "" { + newSegs = append(newSegs, wordSegment{Text: eq, Changed: false}) + } + } + + oldChange := strings.Join(oldTokens[d.Start:d.End], "") + newChange := strings.Join(newTokens[d.ReplStart:d.ReplEnd], "") + if oldChange != "" { + oldSegs = append(oldSegs, wordSegment{Text: oldChange, Changed: true}) + } + if newChange != "" { + newSegs = append(newSegs, wordSegment{Text: newChange, Changed: true}) + } + + oldPos = d.End + newPos = d.ReplEnd + } + + if oldPos < len(oldTokens) { + tail := strings.Join(oldTokens[oldPos:], "") + if tail != "" { + oldSegs = append(oldSegs, wordSegment{Text: tail, Changed: false}) + } + } + if newPos < len(newTokens) { + tail := strings.Join(newTokens[newPos:], "") + if tail != "" { + newSegs = append(newSegs, wordSegment{Text: tail, Changed: false}) + } + } + + // Guard against the degenerate "no changes detected" case (e.g. identical + // inputs that still differ in normalization). Treat the whole line as + // changed so the user is not misled. + if !anyChanged(oldSegs) && !anyChanged(newSegs) && oldLine != newLine { + oldSegs = []wordSegment{{Text: oldLine, Changed: oldLine != ""}} + newSegs = []wordSegment{{Text: newLine, Changed: newLine != ""}} + } + + return oldSegs, newSegs +} + +func anyChanged(segs []wordSegment) bool { + for _, s := range segs { + if s.Changed { + return true + } + } + return false +} diff --git a/pkg/tui/components/tool/editfile/wordiff_test.go b/pkg/tui/components/tool/editfile/wordiff_test.go new file mode 100644 index 000000000..fb0fb33f8 --- /dev/null +++ b/pkg/tui/components/tool/editfile/wordiff_test.go @@ -0,0 +1,128 @@ +package editfile + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTokenizeForWordDiff(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {"empty", "", nil}, + {"single word", "foo", []string{"foo"}}, + {"two words", "foo bar", []string{"foo", " ", "bar"}}, + { + name: "function call", + in: "fmt.Printf(\"hello\")", + want: []string{"fmt", ".", "Printf", "(", "\"", "hello", "\"", ")"}, + }, + { + name: "preserves whitespace runs", + in: " foo bar", + want: []string{" ", "foo", " ", "bar"}, + }, + { + name: "punctuation runs split into individual tokens", + in: "x++", + want: []string{"x", "+", "+"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tokenizeForWordDiff(tc.in) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDiffWords_IdenticalLines(t *testing.T) { + oldSegs, newSegs := diffWords("foo bar", "foo bar") + assert.Equal(t, []wordSegment{{Text: "foo bar", Changed: false}}, oldSegs) + assert.Equal(t, []wordSegment{{Text: "foo bar", Changed: false}}, newSegs) +} + +func TestDiffWords_SingleWordChange(t *testing.T) { + oldSegs, newSegs := diffWords("foo bar baz", "foo qux baz") + + assert.Equal(t, "foo bar baz", concat(oldSegs)) + assert.Equal(t, "foo qux baz", concat(newSegs)) + + assert.True(t, hasChange(oldSegs, "bar")) + assert.True(t, hasChange(newSegs, "qux")) + assert.True(t, hasUnchanged(oldSegs, "foo ")) + assert.True(t, hasUnchanged(newSegs, " baz")) +} + +func TestDiffWords_OneSideEmpty(t *testing.T) { + oldSegs, newSegs := diffWords("", "added line") + assert.Empty(t, concat(oldSegs)) + assert.Equal(t, "added line", concat(newSegs)) + assert.True(t, anyChanged(newSegs)) +} + +func TestDiffWords_PunctuationOnlyChange(t *testing.T) { + oldSegs, newSegs := diffWords("return err", "return fmt.Errorf(\"%w\", err)") + + assert.Equal(t, "return err", concat(oldSegs)) + assert.Equal(t, "return fmt.Errorf(\"%w\", err)", concat(newSegs)) + + // The literal "return" identifier and " err" run should both be reported + // as unchanged on the new side; only the inserted fmt.Errorf wrapper is + // flagged as a change. + assert.True(t, hasUnchanged(newSegs, "return")) + assert.True(t, hasUnchanged(newSegs, " err")) + assert.True(t, anyChanged(newSegs)) +} + +// TestDiffWords_SegmentsReconstructInputs guards against asymmetric LCS gaps: +// the concatenation of the returned segments must equal each side's input, +// otherwise byte offsets fed to applyWordEmphasis would be wrong. +func TestDiffWords_SegmentsReconstructInputs(t *testing.T) { + cases := []struct { + old, new string + }{ + {"foo bar baz", "foo qux baz"}, + {"", "added"}, + {"removed", ""}, + {"a b c d e", "a x y c z e"}, + {"\tx := 1", "\tx := 10"}, + {"func foo() error", "func foo(ctx context.Context) error"}, + } + for _, tc := range cases { + oldSegs, newSegs := diffWords(tc.old, tc.new) + assert.Equal(t, tc.old, concat(oldSegs), "old=%q", tc.old) + assert.Equal(t, tc.new, concat(newSegs), "new=%q", tc.new) + } +} + +func concat(segs []wordSegment) string { + var b strings.Builder + for _, seg := range segs { + b.WriteString(seg.Text) + } + return b.String() +} + +func hasChange(segs []wordSegment, text string) bool { + for _, s := range segs { + if s.Changed && s.Text == text { + return true + } + } + return false +} + +func hasUnchanged(segs []wordSegment, text string) bool { + for _, s := range segs { + if !s.Changed && s.Text == text { + return true + } + } + return false +} diff --git a/pkg/tui/styles/colorutil.go b/pkg/tui/styles/colorutil.go index fe0951b89..cab0969d3 100644 --- a/pkg/tui/styles/colorutil.go +++ b/pkg/tui/styles/colorutil.go @@ -420,3 +420,38 @@ func labF(t float64) float64 { } return 7.787*t + 16.0/116.0 } + +// deriveEmphasisBg returns a more saturated, slightly lighter (for dark +// themes) or darker (for light themes) variant of the given hex color. +// It is used to highlight the precise tokens that changed inside a diff +// line, on top of the row's existing add/remove background tint. +// +// The transformation preserves the original hue so the result stays +// recognizably "add" (greenish) or "remove" (reddish) for every theme that +// follows that convention. If parsing fails the input is returned as-is. +func deriveEmphasisBg(hex string) color.Color { + r, g, b, ok := parseHexRGB(hex) + if !ok { + return lipgloss.Color(hex) + } + + h, s, l := rgbToHSL(r, g, b) + + // Push saturation toward a strong, vivid value. Diff backgrounds are + // typically very desaturated (~0.1-0.25); we want emphasis to read as + // a distinct color block. + s = max(s, 0.55) + + // Shift lightness away from the line background so the emphasis block + // is visible. For dark backgrounds (l < 0.5) we lift; for light ones we + // drop. The magnitude is intentionally modest so changed tokens remain + // readable against the surrounding row color. + if l < 0.5 { + l = min(l+0.18, 0.50) + } else { + l = max(l-0.18, 0.65) + } + + nr, ng, nb := hslToRGB(h, s, l) + return lipgloss.Color(RGBToHex(nr, ng, nb)) +} diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index b9f5aba80..706216389 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -54,10 +54,12 @@ var ( // Diff colors - DiffAddBg color.Color - DiffRemoveBg color.Color - DiffAddFg color.Color - DiffRemoveFg color.Color + DiffAddBg color.Color + DiffRemoveBg color.Color + DiffAddFg color.Color + DiffRemoveFg color.Color + DiffAddEmphBg color.Color + DiffRemoveEmphBg color.Color // UI element colors @@ -295,6 +297,20 @@ var ( Foreground(DiffRemoveFg) DiffUnchangedStyle = BaseStyle.Background(BackgroundAlt) + + // DiffAddEmphStyle and DiffRemoveEmphStyle highlight the specific tokens + // that changed within a modified line. They share the foreground of the + // surrounding diff line but use a stronger background so the changed + // words are unmistakable. + DiffAddEmphStyle = BaseStyle. + Background(DiffAddEmphBg). + Foreground(DiffAddFg). + Bold(true) + + DiffRemoveEmphStyle = BaseStyle. + Background(DiffRemoveEmphBg). + Foreground(DiffRemoveFg). + Bold(true) ) // Syntax highlighting UI element styles diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index 30a1cd55c..25fbf3492 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -916,6 +916,8 @@ func ApplyTheme(theme *Theme) { DiffRemoveBg = lipgloss.Color(c.DiffRemoveBg) DiffAddFg = lipgloss.Color(c.Success) DiffRemoveFg = lipgloss.Color(c.Error) + DiffAddEmphBg = deriveEmphasisBg(c.DiffAddBg) + DiffRemoveEmphBg = deriveEmphasisBg(c.DiffRemoveBg) // UI element colors LineNumber = lipgloss.Color(c.LineNumber) Separator = lipgloss.Color(c.Separator) @@ -1100,6 +1102,8 @@ func rebuildStyles() { DiffAddStyle = BaseStyle.Background(DiffAddBg).Foreground(DiffAddFg) DiffRemoveStyle = BaseStyle.Background(DiffRemoveBg).Foreground(DiffRemoveFg) DiffUnchangedStyle = BaseStyle.Background(BackgroundAlt) + DiffAddEmphStyle = BaseStyle.Background(DiffAddEmphBg).Foreground(DiffAddFg).Bold(true) + DiffRemoveEmphStyle = BaseStyle.Background(DiffRemoveEmphBg).Foreground(DiffRemoveFg).Bold(true) // Syntax highlighting styles LineNumberStyle = BaseStyle.Foreground(LineNumber).Background(BackgroundAlt)