mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a34741ec11 | ||
|
|
21636312b2 | ||
|
|
bdc7242938 | ||
|
|
7ec30011da | ||
|
|
0c1e0b93ac | ||
|
|
6be5bf5679 | ||
|
|
f70481ee3b | ||
|
|
ef8b13c5fa |
No files matched your search
@@ -16,7 +16,7 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sys v0.47.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
@@ -66,6 +66,8 @@ golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZC
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
|
||||
@@ -121,3 +121,59 @@ func TestToolResultRowExposesClickToggle(t *testing.T) {
|
||||
t.Fatalf("tool result visible body/footer must stay selectable, got %#v", selectable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlwaysExpandedToolResultHeaderIsNotToggleable(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
for _, tool := range []string{"edit_file", "apply_patch", "write_file"} {
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "diff",
|
||||
tool: tool,
|
||||
status: tools.StatusOK,
|
||||
detail: "--- a/example.go\n+++ b/example.go\n@@ -1 +1 @@\n-old\n+new",
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatalf("%s: expected a visible diff result row", tool)
|
||||
}
|
||||
if selectable[0].toggle {
|
||||
t.Fatalf("%s header must not advertise a collapse toggle: %#v", tool, selectable[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultOutputCannotCreateToggle(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "custom",
|
||||
tool: "custom_tool",
|
||||
status: tools.StatusOK,
|
||||
detail: "the file literally says: click to expand",
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatal("expected a visible tool result row")
|
||||
}
|
||||
if selectable[0].toggle {
|
||||
t.Fatalf("tool-output text must not create a header toggle: %#v", selectable[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapsedExploreToolResultHeaderIsToggleable(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "read",
|
||||
tool: "read_file",
|
||||
status: tools.StatusOK,
|
||||
detail: numberedLines(cardBodyMaxLines + 5),
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatal("expected a visible explore result row")
|
||||
}
|
||||
if !selectable[0].toggle {
|
||||
t.Fatalf("collapsed explore card header must remain toggleable: %#v", selectable[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
|
||||
// Visible unchanged lines kept at each edge of a collapsed diff section.
|
||||
const diffViewerContextLines = 3
|
||||
|
||||
// diffViewerLine is a source diff line or a synthetic collapsed-context row.
|
||||
// rawIndex keeps rendering anchored to the original diff so line-pair and
|
||||
// syntax-highlighting logic remain correct after context compaction.
|
||||
type diffViewerLine struct {
|
||||
text string
|
||||
rawIndex int
|
||||
hiddenContext int
|
||||
}
|
||||
|
||||
// compactDiffViewerContext returns the display sequence for a unified diff.
|
||||
// Long contiguous unchanged runs keep their leading and trailing context while
|
||||
// one synthetic line accounts for the elided middle. Structural and changed
|
||||
// rows always remain intact.
|
||||
func compactDiffViewerContext(raw []string) []diffViewerLine {
|
||||
lines := make([]diffViewerLine, 0, len(raw))
|
||||
hunk := diffHunkState{}
|
||||
for index := 0; index < len(raw); {
|
||||
if parsed, ok := parseDiffHunkHeader(raw[index]); ok {
|
||||
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
|
||||
hunk = parsed
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if !hunk.active() || !strings.HasPrefix(raw[index], " ") {
|
||||
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
|
||||
if hunk.active() && isDiffHunkBodyLine(raw[index]) {
|
||||
hunk.consume(raw[index])
|
||||
}
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
start := index
|
||||
for index < len(raw) && hunk.active() && strings.HasPrefix(raw[index], " ") {
|
||||
hunk.consume(raw[index])
|
||||
index++
|
||||
}
|
||||
count := index - start
|
||||
// Collapsing seven lines would replace one readable line with one marker,
|
||||
// leaving the display equally tall. Only collapse when it removes space.
|
||||
if count <= diffViewerContextLines*2+1 {
|
||||
for i := start; i < index; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for i := start; i < start+diffViewerContextLines; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
lines = append(lines, diffViewerLine{rawIndex: -1, hiddenContext: count - diffViewerContextLines*2})
|
||||
for i := index - diffViewerContextLines; i < index; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
func requireDiffViewerTrueColor(t *testing.T) {
|
||||
t.Helper()
|
||||
oldProfile := lipgloss.Writer.Profile
|
||||
lipgloss.Writer.Profile = colorprofile.TrueColor
|
||||
t.Cleanup(func() {
|
||||
lipgloss.Writer.Profile = oldProfile
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiffViewerUsesHunkHeaderForLineNumbers(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/internal/tui/view.go",
|
||||
"+++ b/internal/tui/view.go",
|
||||
"@@ -40,3 +40,4 @@ func renderView() {",
|
||||
" context",
|
||||
"-old line",
|
||||
"+new line",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 90, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if strings.Contains(got, "@@") || !strings.Contains(got, " 41 − old line") || !strings.Contains(got, " 41 + new line") {
|
||||
t.Fatalf("diff viewer did not apply the hunk range correctly:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerCollapsesLongUnchangedContext(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,12 +1,12 @@",
|
||||
}
|
||||
for i := 1; i <= 12; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
diff := strings.Join(lines, "\n")
|
||||
|
||||
body := diffCardBody(diff, 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "… 6 unchanged lines") {
|
||||
t.Fatalf("diff viewer did not collapse long unchanged context:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"context a", "context b", "context c", "context j", "context k", "context l"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diff viewer should preserve contextual edge %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsSevenUnchangedContextLines(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,7 +1,7 @@",
|
||||
}
|
||||
for i := 1; i <= diffViewerContextLines*2+1; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
|
||||
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if strings.Contains(got, "unchanged lines") {
|
||||
t.Fatalf("seven unchanged context lines should remain visible:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"context a", "context d", "context g"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diff viewer omitted %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsLineNumbersAfterCollapsedContext(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,13 +1,13 @@",
|
||||
}
|
||||
for i := 1; i <= 12; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
lines = append(lines, "-old value", "+new value")
|
||||
|
||||
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, " 13 − old value") || !strings.Contains(got, " 13 + new value") {
|
||||
t.Fatalf("line numbers drifted after collapsed context:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntheticContextHasNoSourceIndex(t *testing.T) {
|
||||
raw := make([]string, 0, diffViewerContextLines*2+2)
|
||||
for index := 0; index < diffViewerContextLines*2+2; index++ {
|
||||
raw = append(raw, " context")
|
||||
}
|
||||
for _, line := range compactDiffViewerContext(raw) {
|
||||
if line.hiddenContext > 0 && line.rawIndex != -1 {
|
||||
t.Fatalf("synthetic collapsed context rawIndex = %d, want -1", line.rawIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerNearRewriteDeletionSurvives(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/notes.log",
|
||||
"+++ b/notes.log",
|
||||
"@@ -1 +1 @@",
|
||||
"-alpha bravo charlie delta",
|
||||
"+zulu yankee xray whiskey",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "alpha bravo charlie delta") {
|
||||
t.Fatalf("near-rewrite deletion disappeared:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerRendersStyledDeletionOnce(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func value() string { return \"old\" }",
|
||||
"+func value() string { return \"new\" }",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if count := strings.Count(got, "func value() string { return \"old\" }"); count != 1 {
|
||||
t.Fatalf("styled deleted row count = %d, want 1:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerDoesNotFoldPreambleContext(t *testing.T) {
|
||||
raw := []string{
|
||||
"commit deadbeef",
|
||||
"Author: Example",
|
||||
"",
|
||||
}
|
||||
for index := 1; index <= 10; index++ {
|
||||
raw = append(raw, fmt.Sprintf(" commit message line %d", index))
|
||||
}
|
||||
raw = append(raw,
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1,1 +1,1 @@",
|
||||
"-old value",
|
||||
"+new value",
|
||||
)
|
||||
|
||||
compacted := compactDiffViewerContext(raw)
|
||||
for _, line := range compacted {
|
||||
if line.hiddenContext > 0 {
|
||||
t.Fatalf("preamble context was folded: %#v", compacted)
|
||||
}
|
||||
}
|
||||
if len(compacted) != len(raw) {
|
||||
t.Fatalf("compacted preamble length = %d, want %d", len(compacted), len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsHeaderLikeHunkContent(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"--- removed",
|
||||
"+++ added",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
for _, want := range []string{"-- removed", "++ added"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("diff viewer lost header-like hunk content %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
meta := diffCardMetadata(diff)
|
||||
if meta.adds != 1 || meta.dels != 1 {
|
||||
t.Fatalf("diff metadata counts = +%d -%d, want +1 -1", meta.adds, meta.dels)
|
||||
}
|
||||
if _, ok := highlightedDiffLines(strings.Split(diff, "\n"), meta)[3]; !ok {
|
||||
t.Fatal("deleted header-like hunk content was not syntax highlighted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSourcePathStripsOnlyOneDiffPrefix(t *testing.T) {
|
||||
if got, want := diffViewerSourcePath("--- a/b/c.go"), "b/c.go"; got != want {
|
||||
t.Fatalf("diffViewerSourcePath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlightsBothChangedSides(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func greet(name string) string { return \"old\" }",
|
||||
"+func greet(name string) string { return \"new\" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
joined := strings.Join(body.lines, "\n")
|
||||
// `func` uses Zero's accent and string literals use its green token style.
|
||||
if !strings.Contains(joined, "202;255;63") || !strings.Contains(joined, "93;209;164") {
|
||||
t.Fatalf("diff viewer did not retain Go syntax colors in changed rows:\n%s", joined)
|
||||
}
|
||||
// The exact changed string literal keeps the brighter word-diff background.
|
||||
if !strings.Contains(joined, "46;101;77") || !strings.Contains(joined, "80;45;48") {
|
||||
t.Fatalf("diff viewer lost word-level changed-span emphasis:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlightsUnchangedHunkContext(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" func greet() string {",
|
||||
"-\treturn \"old\"",
|
||||
"+\treturn \"new\"",
|
||||
" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
for _, line := range body.lines {
|
||||
if strings.Contains(plainRender(t, line), "func greet() string {") {
|
||||
if !strings.Contains(line, "202;255;63") {
|
||||
t.Fatalf("unchanged source context was not syntax-highlighted: %q", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("diff viewer omitted unchanged source context")
|
||||
}
|
||||
|
||||
func TestDiffViewerPreservesSyntaxStateAcrossHunkLines(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.py",
|
||||
"+++ b/example.py",
|
||||
"@@ -1,3 +1,4 @@",
|
||||
" def message():",
|
||||
"- return \"\"\"old\"\"\"",
|
||||
"+ return \"\"\"new",
|
||||
"+continued\"\"\"",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
for _, line := range body.lines {
|
||||
if strings.Contains(plainRender(t, line), "continued") {
|
||||
if !strings.Contains(line, "93;209;164") {
|
||||
t.Fatalf("multiline string lost syntax state across hunk rows: %q", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("diff viewer omitted multiline source row")
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsNonHunkDiffFallback(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"+func greet() string { return \"new\" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "func greet() string { return \"new\" }") {
|
||||
t.Fatalf("diff viewer lost content without a hunk header:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsRenameMetadataWithoutHunks(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"diff --git a/old-name.go b/new-name.go",
|
||||
"similarity index 100%",
|
||||
"rename from old-name.go",
|
||||
"rename to new-name.go",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "rename from old-name.go") || !strings.Contains(got, "rename to new-name.go") {
|
||||
t.Fatalf("rename-only diff lost its meaningful metadata:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsModeMetadataWithoutHunks(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"diff --git a/tool.sh b/tool.sh",
|
||||
"old mode 100644",
|
||||
"new mode 100755",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "old mode 100644") || !strings.Contains(got, "new mode 100755") {
|
||||
t.Fatalf("mode-only diff lost its meaningful metadata:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerFallsBackForOversizedSourceLine(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
longLine := strings.Repeat("x", diffViewerHighlightMaxLineBytes)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"+" + longLine,
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
joined := strings.Join(body.lines, "\n")
|
||||
if strings.Contains(joined, "202;255;63") {
|
||||
t.Fatalf("oversized diff line should use the plain diff fallback: %q", joined)
|
||||
}
|
||||
if !strings.Contains(plainRender(t, joined), strings.Repeat("x", 10)) {
|
||||
t.Fatalf("plain fallback lost oversized source line: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlighterKeepsSpans(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
styled, ok := highlightCodeForPathWithSpans(
|
||||
[]string{"func greet(name string) string { return \"new\" }"},
|
||||
"example.go",
|
||||
200,
|
||||
zeroTheme.addLine.GetBackground(),
|
||||
[]highlightSpan{{line: 0, start: 39, end: 44, background: zeroTheme.addLineWord.GetBackground()}},
|
||||
)
|
||||
if !ok || len(styled) != 1 {
|
||||
t.Fatalf("highlightCodeForPathWithSpans = %#v, %v", styled, ok)
|
||||
}
|
||||
if !strings.Contains(styled[0], "46;101;77") {
|
||||
t.Fatalf("highlighted span lost its word-diff background: %q", styled[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerHighlightsEachFileWithItsOwnPath(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
rawLines := []string{
|
||||
"diff --git a/example.go b/example.go",
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func old() {}",
|
||||
"+func new() {}",
|
||||
"diff --git a/example.py b/example.py",
|
||||
"--- a/example.py",
|
||||
"+++ b/example.py",
|
||||
"@@ -1 +1 @@",
|
||||
"-def old(): pass",
|
||||
"+def new(): pass",
|
||||
}
|
||||
|
||||
highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n")))
|
||||
if len(highlighted) != 4 {
|
||||
t.Fatalf("highlighted line count = %d, want 4: %#v", len(highlighted), highlighted)
|
||||
}
|
||||
for _, index := range []int{4, 5, 10, 11} {
|
||||
if !strings.Contains(highlighted[index], "202;255;63") {
|
||||
t.Fatalf("source row %d lost syntax highlighting: %q", index, highlighted[index])
|
||||
}
|
||||
}
|
||||
for _, header := range []int{2, 3, 8, 9} {
|
||||
if _, ok := highlighted[header]; ok {
|
||||
t.Fatalf("file header at row %d entered syntax highlighter: %q", header, highlighted[header])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerHighlightBudgetSpansHunks(t *testing.T) {
|
||||
hunkLines := diffViewerHighlightMaxLines/2 + 1
|
||||
rawLines := []string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
fmt.Sprintf("@@ -1,%d +1,%d @@", hunkLines, hunkLines),
|
||||
}
|
||||
for i := 0; i < hunkLines; i++ {
|
||||
rawLines = append(rawLines, " context")
|
||||
}
|
||||
rawLines = append(rawLines, fmt.Sprintf("@@ -6000,%d +6000,%d @@", hunkLines, hunkLines))
|
||||
for i := 0; i < hunkLines; i++ {
|
||||
rawLines = append(rawLines, " context")
|
||||
}
|
||||
if highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n"))); highlighted != nil {
|
||||
t.Fatalf("diff exceeding the aggregate highlight budget should use the plain fallback: %d highlighted rows", len(highlighted))
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,8 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
|
||||
}
|
||||
|
||||
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
|
||||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow, opts)
|
||||
}
|
||||
|
||||
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
|
||||
|
||||
+339
-70
@@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -1439,12 +1440,14 @@ func renderAskUserQuestionnaire(prompt pendingAskUserPrompt, input string, width
|
||||
const cardBodyMaxLines = 16
|
||||
|
||||
// cardBody is what a result-shape renderer hands back: body lines, an
|
||||
// optional footer embedded in the bottom border, and optional extra head
|
||||
// metadata (e.g. a read's line range).
|
||||
// optional footer embedded in the bottom border, optional extra head metadata
|
||||
// (e.g. a read's line range), and whether the rendered body exposes an
|
||||
// expand/collapse interaction.
|
||||
type cardBody struct {
|
||||
lines []string
|
||||
footer string
|
||||
headTag string
|
||||
lines []string
|
||||
footer string
|
||||
headTag string
|
||||
canToggle bool
|
||||
}
|
||||
|
||||
// renderRunningToolCard draws the head-only card for a tool call that has no
|
||||
@@ -1994,9 +1997,75 @@ func genericCardBody(detail string, opts cardRenderOptions) cardBody {
|
||||
return cardBody{lines: capCardLines(lines, opts.bodyCap)}
|
||||
}
|
||||
|
||||
// hunkHeaderPattern extracts the old/new start lines from a unified-diff hunk
|
||||
// header so the gutter can show real line numbers.
|
||||
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
|
||||
// hunkHeaderPattern extracts the old/new ranges from a unified-diff hunk
|
||||
// header so the gutter can show real line numbers and distinguish hunk content
|
||||
// that happens to resemble a file header.
|
||||
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
|
||||
|
||||
type diffHunkState struct {
|
||||
oldLine int
|
||||
newLine int
|
||||
oldRemaining int
|
||||
newRemaining int
|
||||
}
|
||||
|
||||
func parseDiffHunkHeader(line string) (diffHunkState, bool) {
|
||||
match := hunkHeaderPattern.FindStringSubmatch(line)
|
||||
if match == nil {
|
||||
return diffHunkState{}, false
|
||||
}
|
||||
oldLine, oldErr := strconv.Atoi(match[1])
|
||||
newLine, newErr := strconv.Atoi(match[3])
|
||||
if oldErr != nil || newErr != nil {
|
||||
return diffHunkState{}, false
|
||||
}
|
||||
oldRemaining, newRemaining := 1, 1
|
||||
if match[2] != "" {
|
||||
oldRemaining, oldErr = strconv.Atoi(match[2])
|
||||
}
|
||||
if match[4] != "" {
|
||||
newRemaining, newErr = strconv.Atoi(match[4])
|
||||
}
|
||||
if oldErr != nil || newErr != nil || oldRemaining < 0 || newRemaining < 0 {
|
||||
return diffHunkState{}, false
|
||||
}
|
||||
return diffHunkState{oldLine: oldLine, newLine: newLine, oldRemaining: oldRemaining, newRemaining: newRemaining}, true
|
||||
}
|
||||
|
||||
func (state diffHunkState) active() bool {
|
||||
return state.oldRemaining > 0 || state.newRemaining > 0
|
||||
}
|
||||
|
||||
func (state *diffHunkState) consume(line string) {
|
||||
if len(line) == 0 {
|
||||
// Some producers omit the leading space on a blank context line. Treat
|
||||
// that non-standard but harmless form exactly like normal context so the
|
||||
// parser stays aligned with the hunk ranges.
|
||||
state.oldRemaining--
|
||||
state.newRemaining--
|
||||
return
|
||||
}
|
||||
switch line[0] {
|
||||
case ' ':
|
||||
state.oldRemaining--
|
||||
state.newRemaining--
|
||||
case '-':
|
||||
state.oldRemaining--
|
||||
case '+':
|
||||
state.newRemaining--
|
||||
}
|
||||
}
|
||||
|
||||
func (state *diffHunkState) consumeContext(count int) {
|
||||
state.oldRemaining -= count
|
||||
state.newRemaining -= count
|
||||
state.oldLine += count
|
||||
state.newLine += count
|
||||
}
|
||||
|
||||
func isDiffFileHeader(line string) bool {
|
||||
return strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ")
|
||||
}
|
||||
|
||||
type diffMetadata struct {
|
||||
path string
|
||||
@@ -2007,18 +2076,31 @@ type diffMetadata struct {
|
||||
|
||||
func diffCardMetadata(detail string) diffMetadata {
|
||||
meta := diffMetadata{}
|
||||
hunk := diffHunkState{}
|
||||
for _, line := range strings.Split(detail, "\n") {
|
||||
if parsed, ok := parseDiffHunkHeader(line); ok {
|
||||
hunk = parsed
|
||||
continue
|
||||
}
|
||||
if hunk.active() && isDiffHunkBodyLine(line) {
|
||||
if len(line) > 0 {
|
||||
switch line[0] {
|
||||
case '+':
|
||||
meta.adds++
|
||||
case '-':
|
||||
meta.dels++
|
||||
}
|
||||
}
|
||||
hunk.consume(line)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
meta.path = strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(line, "+++ ")), "b/")
|
||||
meta.path = diffViewerSourcePath(line)
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
if strings.TrimSpace(strings.TrimPrefix(line, "--- ")) == "/dev/null" {
|
||||
meta.newFile = true
|
||||
}
|
||||
case strings.HasPrefix(line, "+"):
|
||||
meta.adds++
|
||||
case strings.HasPrefix(line, "-"):
|
||||
meta.dels++
|
||||
}
|
||||
}
|
||||
return meta
|
||||
@@ -2039,12 +2121,9 @@ func diffCountTag(adds int, dels int) string {
|
||||
zeroTheme.faint.Render(")")
|
||||
}
|
||||
|
||||
func (meta diffMetadata) addOnly() bool {
|
||||
return meta.adds > 0 && meta.dels == 0
|
||||
}
|
||||
|
||||
func diffCardBody(detail string, width int, opts cardRenderOptions) cardBody {
|
||||
rawLines := strings.Split(detail, "\n")
|
||||
displayLines := compactDiffViewerContext(rawLines)
|
||||
meta := diffCardMetadata(detail)
|
||||
innerWidth := width
|
||||
lines := []string{}
|
||||
@@ -2057,88 +2136,263 @@ func diffCardBody(detail string, width int, opts cardRenderOptions) cardBody {
|
||||
gutterWidth = 4
|
||||
}
|
||||
textBudget := maxInt(8, innerWidth-3-gutterWidth)
|
||||
highlightedAdds := highlightedAddedDiffLines(rawLines, meta, textBudget)
|
||||
highlightAddIndex := 0
|
||||
oldLine, newLine := 0, 0
|
||||
inHunk := false
|
||||
for i := 0; i < len(rawLines); i++ {
|
||||
line := rawLines[i]
|
||||
highlightedLines := highlightedDiffLines(rawLines, meta)
|
||||
hunk := diffHunkState{}
|
||||
for i := 0; i < len(displayLines); i++ {
|
||||
displayLine := displayLines[i]
|
||||
line := displayLine.text
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ "), strings.HasPrefix(line, "--- "):
|
||||
case displayLine.hiddenContext > 0:
|
||||
lines = append(lines, zeroTheme.diffMeta.Render(fmt.Sprintf("… %d unchanged lines", displayLine.hiddenContext)))
|
||||
hunk.consumeContext(displayLine.hiddenContext)
|
||||
case isDiffFileHeader(line) && !hunk.active():
|
||||
// Path and counts live in the tool head row.
|
||||
case strings.HasPrefix(line, "diff --git ") && !hunk.active():
|
||||
// A subsequent file section is metadata, not context from the preceding
|
||||
// hunk. Reset so its headers remain hidden until its next hunk starts.
|
||||
hunk = diffHunkState{}
|
||||
case strings.HasPrefix(line, "@@"):
|
||||
if match := hunkHeaderPattern.FindStringSubmatch(line); match != nil {
|
||||
oldLine, _ = strconv.Atoi(match[1])
|
||||
newLine, _ = strconv.Atoi(match[2])
|
||||
inHunk = true
|
||||
if parsed, ok := parseDiffHunkHeader(line); ok {
|
||||
hunk = parsed
|
||||
}
|
||||
// The raw hunk header is implementation metadata. Use it for line
|
||||
// numbers, but keep the visible diff focused on file content.
|
||||
continue
|
||||
case !inHunk, strings.HasPrefix(line, `\`):
|
||||
// Preamble ("diff --git", "index …", a stray "stdout:") and the
|
||||
// "\ No newline at end of file" marker are not content lines: no
|
||||
// gutter number, and the hunk counters must not advance.
|
||||
// The line ranges drive the gutters below but do not compete with source
|
||||
// content in a compact tool card.
|
||||
case !hunk.active():
|
||||
// The card head already carries the target path and change counts. Hide
|
||||
// Git transport metadata so the viewer opens on the first useful hunk.
|
||||
// Preserve unusual pre-hunk output as muted context rather than losing
|
||||
// diagnostics from nonstandard diff-producing commands.
|
||||
if !isDiffViewerPreamble(line) {
|
||||
lines = append(lines, zeroTheme.diffMeta.Render(truncateRunes(line, innerWidth)))
|
||||
}
|
||||
case strings.HasPrefix(line, `\`):
|
||||
// "\ No newline at end of file" has no source-line position.
|
||||
lines = append(lines, zeroTheme.diffMeta.Render(truncateRunes(line, innerWidth)))
|
||||
case strings.HasPrefix(line, "+"):
|
||||
text := truncateRunes(strings.TrimPrefix(line, "+"), textBudget)
|
||||
if len(highlightedAdds) == meta.adds && highlightAddIndex < len(highlightedAdds) {
|
||||
lines = append(lines, diffBodyStyledLine(newLine, "+", highlightedAdds[highlightAddIndex], true, textBudget, gutter))
|
||||
highlightAddIndex++
|
||||
if styled, ok := highlightedLines[displayLine.rawIndex]; ok {
|
||||
lines = append(lines, diffBodyStyledLine(hunk.newLine, "+", styled, true, textBudget, gutter))
|
||||
} else {
|
||||
lines = append(lines, diffBodyLine(newLine, "+", text, true, textBudget, gutter))
|
||||
text := truncateRunes(strings.TrimPrefix(line, "+"), textBudget)
|
||||
lines = append(lines, diffBodyLine(hunk.newLine, "+", text, true, textBudget, gutter))
|
||||
}
|
||||
newLine++
|
||||
hunk.newLine++
|
||||
hunk.consume(line)
|
||||
case strings.HasPrefix(line, "-"):
|
||||
// Isolated 1:1 replacement (one "-" immediately followed by one "+"):
|
||||
// highlight only the changed span on each side so a one-token edit reads
|
||||
// instantly. Block changes and near-rewrites fall back to whole-line tint.
|
||||
if isIsolatedReplacement(rawLines, i) {
|
||||
// instantly. When a lexer is available the syntax-highlighter carries
|
||||
// that same word span; unknown paths retain the established plain-text
|
||||
// word-diff fallback.
|
||||
if styled, ok := highlightedLines[displayLine.rawIndex]; ok {
|
||||
lines = append(lines, diffBodyStyledLine(hunk.oldLine, "−", styled, false, textBudget, gutter))
|
||||
hunk.oldLine++
|
||||
hunk.consume(line)
|
||||
continue
|
||||
} else if isIsolatedReplacement(rawLines, displayLine.rawIndex) {
|
||||
delText := truncateRunes(strings.TrimPrefix(line, "-"), textBudget)
|
||||
addText := truncateRunes(strings.TrimPrefix(rawLines[i+1], "+"), textBudget)
|
||||
if delRow, addRow, ok := renderWordDiffPair(oldLine, newLine, delText, addText, textBudget, gutter); ok {
|
||||
addText := truncateRunes(strings.TrimPrefix(rawLines[displayLine.rawIndex+1], "+"), textBudget)
|
||||
if delRow, addRow, ok := renderWordDiffPair(hunk.oldLine, hunk.newLine, delText, addText, textBudget, gutter); ok {
|
||||
lines = append(lines, delRow, addRow)
|
||||
oldLine++
|
||||
newLine++
|
||||
hunk.oldLine++
|
||||
hunk.newLine++
|
||||
hunk.consume(line)
|
||||
hunk.consume(rawLines[displayLine.rawIndex+1])
|
||||
i++ // consume the paired "+"
|
||||
continue
|
||||
}
|
||||
}
|
||||
text := truncateRunes(strings.TrimPrefix(line, "-"), textBudget)
|
||||
lines = append(lines, diffBodyLine(oldLine, "−", text, false, textBudget, gutter))
|
||||
oldLine++
|
||||
lines = append(lines, diffBodyLine(hunk.oldLine, "−", text, false, textBudget, gutter))
|
||||
hunk.oldLine++
|
||||
hunk.consume(line)
|
||||
default:
|
||||
text := truncateRunes(strings.TrimPrefix(line, " "), textBudget)
|
||||
row := " " + zeroTheme.muted.Render(text)
|
||||
if gutter {
|
||||
row = zeroTheme.faintest.Render(fmt.Sprintf("%4d", newLine)) + row
|
||||
if styled, ok := highlightedLines[displayLine.rawIndex]; ok {
|
||||
lines = append(lines, diffContextStyledLine(hunk.newLine, styled, textBudget, gutter))
|
||||
} else {
|
||||
text := truncateRunes(strings.TrimPrefix(line, " "), textBudget)
|
||||
row := " " + zeroTheme.muted.Render(text)
|
||||
if gutter {
|
||||
row = zeroTheme.faintest.Render(fmt.Sprintf("%4d", hunk.newLine)) + row
|
||||
}
|
||||
lines = append(lines, row)
|
||||
}
|
||||
lines = append(lines, row)
|
||||
oldLine++
|
||||
newLine++
|
||||
hunk.oldLine++
|
||||
hunk.newLine++
|
||||
hunk.consume(line)
|
||||
}
|
||||
}
|
||||
return cardBody{lines: capCardLines(lines, opts.bodyCap), headTag: diffHeadTag(meta)}
|
||||
}
|
||||
|
||||
func highlightedAddedDiffLines(rawLines []string, meta diffMetadata, textBudget int) []string {
|
||||
if !meta.addOnly() || meta.path == "" {
|
||||
return nil
|
||||
func isDiffViewerPreamble(line string) bool {
|
||||
for _, prefix := range []string{
|
||||
"diff --git ", "index ", "similarity index ", "dissimilarity index ",
|
||||
} {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
content := make([]string, 0, meta.adds)
|
||||
for _, line := range rawLines {
|
||||
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++ ") {
|
||||
content = append(content, strings.TrimPrefix(line, "+"))
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
diffViewerHighlightMaxLines = 10_000
|
||||
diffViewerHighlightMaxBytes = 512 * 1024
|
||||
diffViewerHighlightMaxLineBytes = 4 * 1024
|
||||
)
|
||||
|
||||
type diffHighlightBudget struct {
|
||||
lines int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (budget *diffHighlightBudget) reserve(lines int, bytes int) bool {
|
||||
if lines > diffViewerHighlightMaxLines || bytes > diffViewerHighlightMaxBytes ||
|
||||
budget.lines+lines > diffViewerHighlightMaxLines || budget.bytes+bytes > diffViewerHighlightMaxBytes {
|
||||
return false
|
||||
}
|
||||
budget.lines += lines
|
||||
budget.bytes += bytes
|
||||
return true
|
||||
}
|
||||
|
||||
// highlightedDiffLines lexes each unified-diff hunk as one source unit. This
|
||||
// preserves lexer state through multiline comments, strings, and declarations,
|
||||
// while still returning independently renderable rows. Large diffs fall back to
|
||||
// the regular viewer so expanding a tool card remains responsive.
|
||||
func highlightedDiffLines(rawLines []string, meta diffMetadata) map[int]string {
|
||||
highlighted := make(map[int]string)
|
||||
budget := diffHighlightBudget{}
|
||||
path := ""
|
||||
for index := 0; index < len(rawLines); {
|
||||
line := rawLines[index]
|
||||
switch {
|
||||
case isDiffFileHeader(line):
|
||||
if candidate := diffViewerSourcePath(line); candidate != "" {
|
||||
path = candidate
|
||||
}
|
||||
index++
|
||||
continue
|
||||
case !strings.HasPrefix(line, "@@"):
|
||||
index++
|
||||
continue
|
||||
}
|
||||
hunk, ok := parseDiffHunkHeader(line)
|
||||
if !ok {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
start := index + 1
|
||||
index = start
|
||||
for index < len(rawLines) && hunk.active() && isDiffHunkBodyLine(rawLines[index]) {
|
||||
hunk.consume(rawLines[index])
|
||||
index++
|
||||
}
|
||||
hunkPath := path
|
||||
if hunkPath == "" {
|
||||
hunkPath = meta.path
|
||||
}
|
||||
if hunkPath == "" || !highlightDiffHunk(rawLines[start:index], start, hunkPath, highlighted, &budget) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return highlighted
|
||||
}
|
||||
|
||||
func diffViewerSourcePath(line string) string {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(line, "--- "), "+++ "))
|
||||
if tab := strings.IndexByte(path, '\t'); tab >= 0 {
|
||||
path = path[:tab]
|
||||
}
|
||||
if path == "" || path == "/dev/null" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(path, "a/") {
|
||||
return strings.TrimPrefix(path, "a/")
|
||||
}
|
||||
return strings.TrimPrefix(path, "b/")
|
||||
}
|
||||
|
||||
func isDiffHunkBodyLine(line string) bool {
|
||||
return line == "" || line[0] == ' ' || line[0] == '+' || line[0] == '-' || line[0] == '\\'
|
||||
}
|
||||
|
||||
func highlightDiffHunk(rawLines []string, rawStart int, path string, highlighted map[int]string, budget *diffHighlightBudget) bool {
|
||||
content := make([]string, 0, len(rawLines))
|
||||
backgrounds := make([]color.Color, 0, len(rawLines))
|
||||
rawIndexes := make([]int, 0, len(rawLines))
|
||||
contentIndexes := make(map[int]int, len(rawLines))
|
||||
bytes := 0
|
||||
for offset, line := range rawLines {
|
||||
if len(line) == 0 || line[0] == '\\' {
|
||||
continue
|
||||
}
|
||||
prefix := line[0]
|
||||
if prefix != ' ' && prefix != '+' && prefix != '-' {
|
||||
continue
|
||||
}
|
||||
contentIndexes[rawStart+offset] = len(content)
|
||||
content = append(content, line[1:])
|
||||
rawIndexes = append(rawIndexes, rawStart+offset)
|
||||
bytes += len(line)
|
||||
if len(line) > diffViewerHighlightMaxLineBytes {
|
||||
return false
|
||||
}
|
||||
switch prefix {
|
||||
case '+':
|
||||
backgrounds = append(backgrounds, zeroTheme.addLine.GetBackground())
|
||||
case '-':
|
||||
backgrounds = append(backgrounds, zeroTheme.delLine.GetBackground())
|
||||
default:
|
||||
backgrounds = append(backgrounds, nil)
|
||||
}
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
highlighted, ok := highlightCodeForPath(content, meta.path, textBudget, zeroTheme.addLine.GetBackground())
|
||||
if !ok || len(highlighted) != len(content) {
|
||||
return nil
|
||||
if !budget.reserve(len(content), bytes) {
|
||||
return false
|
||||
}
|
||||
return highlighted
|
||||
|
||||
spans := diffHunkWordSpans(rawLines, rawStart, contentIndexes)
|
||||
styled, ok := highlightCodeForPathWithLineBackgrounds(content, path, 1<<20, backgrounds, spans)
|
||||
if !ok || len(styled) != len(content) {
|
||||
return false
|
||||
}
|
||||
for contentIndex, text := range styled {
|
||||
highlighted[rawIndexes[contentIndex]] = text
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func diffHunkWordSpans(rawLines []string, rawStart int, contentIndexes map[int]int) []highlightSpan {
|
||||
var spans []highlightSpan
|
||||
for offset, line := range rawLines {
|
||||
rawIndex := rawStart + offset
|
||||
if !isDiffDelContent(line) || offset+1 >= len(rawLines) || !isDiffAddContent(rawLines[offset+1]) {
|
||||
continue
|
||||
}
|
||||
if offset > 0 && isDiffDelContent(rawLines[offset-1]) {
|
||||
continue
|
||||
}
|
||||
if offset+2 < len(rawLines) && isDiffAddContent(rawLines[offset+2]) {
|
||||
continue
|
||||
}
|
||||
before := []rune(line[1:])
|
||||
after := []rune(rawLines[offset+1][1:])
|
||||
prefix, beforeEnd, afterEnd := changedSpan(before, after)
|
||||
changed := maxInt(beforeEnd-prefix, afterEnd-prefix)
|
||||
if changed == 0 || float64(changed)/float64(maxInt(len(before), len(after))) > 0.6 {
|
||||
continue
|
||||
}
|
||||
if lineIndex, ok := contentIndexes[rawIndex]; ok {
|
||||
spans = append(spans, highlightSpan{line: lineIndex, start: prefix, end: beforeEnd, background: zeroTheme.delLineWord.GetBackground()})
|
||||
}
|
||||
if lineIndex, ok := contentIndexes[rawIndex+1]; ok {
|
||||
spans = append(spans, highlightSpan{line: lineIndex, start: prefix, end: afterEnd, background: zeroTheme.addLineWord.GetBackground()})
|
||||
}
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// diffBodyLine paints one changed row: optional gutter number, sign column,
|
||||
@@ -2168,6 +2422,7 @@ func diffBodyStyledLine(number int, sign string, styledText string, added bool,
|
||||
if added {
|
||||
lineStyle, signStyle, numStyle = zeroTheme.addLine, zeroTheme.addSign, zeroTheme.addLineNum
|
||||
}
|
||||
styledText = fitStyledLine(styledText, textBudget)
|
||||
if pad := textBudget - lipgloss.Width(styledText); pad > 0 {
|
||||
styledText += lineStyle.Render(strings.Repeat(" ", pad))
|
||||
}
|
||||
@@ -2178,6 +2433,18 @@ func diffBodyStyledLine(number int, sign string, styledText string, added bool,
|
||||
return numCol + signStyle.Render(" "+sign+" ") + styledText
|
||||
}
|
||||
|
||||
func diffContextStyledLine(number int, styledText string, textBudget int, gutter bool) string {
|
||||
styledText = fitStyledLine(styledText, textBudget)
|
||||
if pad := textBudget - lipgloss.Width(styledText); pad > 0 {
|
||||
styledText += zeroTheme.muted.Render(strings.Repeat(" ", pad))
|
||||
}
|
||||
row := " " + styledText
|
||||
if gutter {
|
||||
row = zeroTheme.faintest.Render(fmt.Sprintf("%4d", number)) + row
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func isDiffAddContent(s string) bool {
|
||||
return strings.HasPrefix(s, "+") && !strings.HasPrefix(s, "+++ ")
|
||||
}
|
||||
@@ -2275,15 +2542,17 @@ func exploreCardBody(name string, hint string, arg string, detail string, width
|
||||
if strings.TrimSpace(detail) != "" {
|
||||
footer = zeroTheme.faint.Render("▸ details")
|
||||
}
|
||||
return cardBody{lines: []string{summary}, footer: footer}
|
||||
return cardBody{lines: []string{summary}, footer: footer, canToggle: footer != ""}
|
||||
}
|
||||
body := exploreDetailCardBody(name, detail, width, opts)
|
||||
lines := append([]string{summary}, body.lines...)
|
||||
footer := body.footer
|
||||
canToggle := body.canToggle
|
||||
if opts.expanded && opts.bodyCap > 0 && footer == "" {
|
||||
footer = zeroTheme.faint.Render("▾ collapse")
|
||||
canToggle = true
|
||||
}
|
||||
return cardBody{lines: lines, footer: footer}
|
||||
return cardBody{lines: lines, footer: footer, canToggle: canToggle}
|
||||
}
|
||||
|
||||
func exploreDetailCardBody(name string, detail string, width int, opts cardRenderOptions) cardBody {
|
||||
|
||||
@@ -975,6 +975,32 @@ func TestEditedDiffCardHidesHunkHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditedDiffCardHandlesBareBlankHunkContext(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
diff := strings.Join([]string{
|
||||
"--- a/time_test.py",
|
||||
"+++ b/time_test.py",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" from datetime import datetime",
|
||||
"",
|
||||
"-print(datetime.now())",
|
||||
"+print(f\"Current time: {datetime.now()}\")",
|
||||
}, "\n")
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "edit_file", status: tools.StatusOK, detail: diff}
|
||||
got := plainRender(t, m.renderRow(row, 96, buildRowContext(nil)))
|
||||
for _, want := range []string{"Edited", "time_test.py", "(+1 -1)", "from datetime import datetime", " 3 −", " 3 +"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("edited diff card = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
if strings.TrimSpace(line) == "2" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("edited diff card = %q, missing blank context line 2", got)
|
||||
}
|
||||
|
||||
func TestReadCardBodyShowsExploredSummary(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
// Mirrors the real read_file output shape: "<right-aligned N> | <text>".
|
||||
|
||||
@@ -101,6 +101,27 @@ func highlightCodeForPath(code []string, path string, measure int, bg color.Colo
|
||||
return highlightCodeWithLexer(cachedLexerForPath(path), code, measure, bg)
|
||||
}
|
||||
|
||||
// highlightSpan overlays a background on a rune range in one source line while
|
||||
// retaining the lexer-selected foreground. Diff rendering uses it to keep the
|
||||
// precise changed word visible inside a syntax-highlighted add/delete row.
|
||||
type highlightSpan struct {
|
||||
line int
|
||||
start, end int
|
||||
background color.Color
|
||||
}
|
||||
|
||||
func highlightCodeForPathWithSpans(code []string, path string, measure int, bg color.Color, spans []highlightSpan) ([]string, bool) {
|
||||
return highlightCodeWithLexerAndSpans(cachedLexerForPath(path), code, measure, bg, spans)
|
||||
}
|
||||
|
||||
// highlightCodeForPathWithLineBackgrounds applies syntax highlighting to a
|
||||
// single source unit while preserving a distinct background for every source
|
||||
// line. The diff viewer uses one lexer pass per hunk so multiline syntax keeps
|
||||
// its state across changed and unchanged lines.
|
||||
func highlightCodeForPathWithLineBackgrounds(code []string, path string, measure int, backgrounds []color.Color, spans []highlightSpan) ([]string, bool) {
|
||||
return highlightCodeWithLexerAndLineBackgrounds(cachedLexerForPath(path), code, measure, backgrounds, spans)
|
||||
}
|
||||
|
||||
func inferCodeLanguage(code []string) string {
|
||||
for _, line := range code {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
@@ -151,6 +172,18 @@ func inferCodeLanguage(code []string) string {
|
||||
}
|
||||
|
||||
func highlightCodeWithLexer(lexer chroma.Lexer, code []string, measure int, bg color.Color) ([]string, bool) {
|
||||
return highlightCodeWithLexerAndSpans(lexer, code, measure, bg, nil)
|
||||
}
|
||||
|
||||
func highlightCodeWithLexerAndSpans(lexer chroma.Lexer, code []string, measure int, bg color.Color, spans []highlightSpan) ([]string, bool) {
|
||||
backgrounds := make([]color.Color, len(code))
|
||||
for index := range backgrounds {
|
||||
backgrounds[index] = bg
|
||||
}
|
||||
return highlightCodeWithLexerAndLineBackgrounds(lexer, code, measure, backgrounds, spans)
|
||||
}
|
||||
|
||||
func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string, measure int, backgrounds []color.Color, spans []highlightSpan) ([]string, bool) {
|
||||
if measure < 4 {
|
||||
return nil, false
|
||||
}
|
||||
@@ -163,6 +196,10 @@ func highlightCodeWithLexer(lexer chroma.Lexer, code []string, measure int, bg c
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
spansByLine := make(map[int][]highlightSpan, len(spans))
|
||||
for _, span := range spans {
|
||||
spansByLine[span.line] = append(spansByLine[span.line], span)
|
||||
}
|
||||
var cur strings.Builder
|
||||
curWidth := 0
|
||||
flushLine := func() {
|
||||
@@ -170,18 +207,52 @@ func highlightCodeWithLexer(lexer chroma.Lexer, code []string, measure int, bg c
|
||||
cur.Reset()
|
||||
curWidth = 0
|
||||
}
|
||||
emit := func(style lipgloss.Style, s string) {
|
||||
if bg != nil {
|
||||
style = style.Background(bg)
|
||||
}
|
||||
emit := func(style lipgloss.Style, s string, line, column int) {
|
||||
var chunk strings.Builder
|
||||
var chunkStyle lipgloss.Style
|
||||
var chunkBackground color.Color
|
||||
var haveChunkStyle bool
|
||||
var styledBackground color.Color
|
||||
var styleWithBackground lipgloss.Style
|
||||
var haveStyledBackground bool
|
||||
flushChunk := func() {
|
||||
if chunk.Len() > 0 {
|
||||
cur.WriteString(style.Render(chunk.String()))
|
||||
cur.WriteString(chunkStyle.Render(chunk.String()))
|
||||
chunk.Reset()
|
||||
haveChunkStyle = false
|
||||
chunkBackground = nil
|
||||
}
|
||||
}
|
||||
for _, r := range s {
|
||||
for offset, r := range []rune(s) {
|
||||
var background color.Color
|
||||
if line < len(backgrounds) {
|
||||
background = backgrounds[line]
|
||||
}
|
||||
for _, span := range spansByLine[line] {
|
||||
if column+offset >= span.start && column+offset < span.end {
|
||||
background = span.background
|
||||
break
|
||||
}
|
||||
}
|
||||
if !haveStyledBackground || !sameHighlightColor(background, styledBackground) {
|
||||
styleWithBackground = style
|
||||
if background != nil {
|
||||
styleWithBackground = styleWithBackground.Background(background)
|
||||
}
|
||||
styledBackground = background
|
||||
haveStyledBackground = true
|
||||
}
|
||||
runeStyle := styleWithBackground
|
||||
if !haveChunkStyle {
|
||||
chunkStyle = runeStyle
|
||||
chunkBackground = background
|
||||
haveChunkStyle = true
|
||||
} else if !sameHighlightColor(background, chunkBackground) {
|
||||
flushChunk()
|
||||
chunkStyle = runeStyle
|
||||
chunkBackground = background
|
||||
haveChunkStyle = true
|
||||
}
|
||||
rw := lipgloss.Width(string(r))
|
||||
if curWidth+rw > measure {
|
||||
flushChunk()
|
||||
@@ -193,13 +264,17 @@ func highlightCodeWithLexer(lexer chroma.Lexer, code []string, measure int, bg c
|
||||
flushChunk()
|
||||
}
|
||||
|
||||
line, column := 0, 0
|
||||
for _, token := range iterator.Tokens() {
|
||||
style := tokenStyle(token.Type)
|
||||
for index, part := range strings.Split(token.Value, "\n") {
|
||||
if index > 0 {
|
||||
flushLine()
|
||||
line++
|
||||
column = 0
|
||||
}
|
||||
emit(style, part)
|
||||
emit(style, part, line, column)
|
||||
column += len([]rune(part))
|
||||
}
|
||||
}
|
||||
flushLine()
|
||||
@@ -209,3 +284,12 @@ func highlightCodeWithLexer(lexer chroma.Lexer, code []string, measure int, bg c
|
||||
}
|
||||
return lines, true
|
||||
}
|
||||
|
||||
func sameHighlightColor(a, b color.Color) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
ar, ag, ab, aa := a.RGBA()
|
||||
br, bg, bb, ba := b.RGBA()
|
||||
return ar == br && ag == bg && ab == bb && aa == ba
|
||||
}
|
||||
@@ -716,17 +716,19 @@ func (m model) transcriptRowBodyHeightCacheKeyOpts(row transcriptRow, width int,
|
||||
}
|
||||
|
||||
func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
|
||||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||||
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow, opts)
|
||||
}
|
||||
|
||||
// renderTranscriptDetailedRow routes through renderTranscriptRowFn with
|
||||
// renderRowDetailed (bodyCap: 0) so tool output appears uncapped.
|
||||
func (m model) renderTranscriptDetailedRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRowDetailed)
|
||||
opts := cardRenderOptions{bodyCap: 0, cwd: m.cwd}
|
||||
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRowDetailed, opts)
|
||||
}
|
||||
|
||||
// renderTranscriptRowFn dispatches row-kind rendering using the provided renderFn.
|
||||
func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn, toolOpts cardRenderOptions) (string, []transcriptSelectableLine) {
|
||||
switch row.kind {
|
||||
case rowUser:
|
||||
return m.renderSelectableUserRow(rowIndex, row, width, startBodyY)
|
||||
@@ -737,7 +739,7 @@ func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int,
|
||||
case rowSystem, rowError, rowToolCall, rowPermission, rowAskUser:
|
||||
return m.renderSelectableRenderedRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
case rowToolResult:
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, renderFn, toolOpts)
|
||||
case rowSpecialist:
|
||||
return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
default:
|
||||
@@ -750,19 +752,19 @@ func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int,
|
||||
}
|
||||
}
|
||||
|
||||
// renderSelectableToolResultRow renders the tool result card and marks its head
|
||||
// (first line) as a clickable collapse/expand toggle. Body/footer text remains
|
||||
// selectable so copying a visible transcript range includes command output.
|
||||
func (m model) renderSelectableToolResultRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
// renderSelectableToolResultRow renders the tool result card. Only cards that
|
||||
// can actually collapse expose a clickable header; always-visible diff cards
|
||||
// retain normal text selection and never imply an unavailable action.
|
||||
func (m model) renderSelectableToolResultRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn, opts cardRenderOptions) (string, []transcriptSelectableLine) {
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" {
|
||||
return "", nil
|
||||
}
|
||||
// The first rendered line is the clickable toggle header; carry its text so a
|
||||
// selection dragged through it copies the label too (the toggle flag still
|
||||
// expands/collapses on a direct click, resolved on press before selection).
|
||||
// Carry the first line's text so a selection dragged through the card copies
|
||||
// its label too. It receives a toggle only when this card exposes a collapse
|
||||
// affordance in the current transcript mode.
|
||||
allLines := viewLines(rendered)
|
||||
header := transcriptSelectableLine{bodyY: startBodyY, rowIndex: rowIndex, toggle: true}
|
||||
header := transcriptSelectableLine{bodyY: startBodyY, rowIndex: rowIndex, toggle: toolResultCanToggle(row, width, rc, opts)}
|
||||
if len(allLines) > 0 {
|
||||
if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY, allLines[0], false); ok {
|
||||
header.text = meta.text
|
||||
@@ -778,6 +780,38 @@ func (m model) renderSelectableToolResultRowFn(rowIndex int, row transcriptRow,
|
||||
return rendered, selectable
|
||||
}
|
||||
|
||||
func toolResultCanToggle(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) bool {
|
||||
name := toolRowName(row)
|
||||
if opts.bodyCap <= 0 || toolCardAlwaysExpands(name) {
|
||||
return false
|
||||
}
|
||||
failed := row.status == tools.StatusError
|
||||
if !failed && looksLikeRedundantConfirmation(row.detail) {
|
||||
return false
|
||||
}
|
||||
collapsedFooter := ""
|
||||
if failed || (!isExploreTool(name) && !isLocalControlTool(name)) {
|
||||
collapsedFooter = collapsedToolFooter(row.detail)
|
||||
}
|
||||
if collapsedFooter != "" && !row.expanded {
|
||||
return true
|
||||
}
|
||||
// Explore cards deliberately use their own compact "details" affordance
|
||||
// instead of the generic long-output footer. Ask the body renderer whether
|
||||
// it exposed that affordance so selection behavior stays coupled to the
|
||||
// rendered card rather than matching display text.
|
||||
if collapsedFooter == "" && !isExploreTool(name) {
|
||||
return false
|
||||
}
|
||||
bodyOpts := opts
|
||||
bodyOpts.expanded = row.expanded
|
||||
body := toolCardBody(name, rc.hints[rcKey(row.runID, row.id)], rc.args[rcKey(row.runID, row.id)], row.detail, width, bodyOpts, failed)
|
||||
if collapsedFooter != "" && row.expanded && body.footer == "" {
|
||||
return true
|
||||
}
|
||||
return body.canToggle
|
||||
}
|
||||
|
||||
func (m model) renderSelectableRenderedRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" {
|
||||
|
||||
Reference in new issue
Block a user