Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 524a738eb0 docs(agents): record the normalize-before-match blocker
Three separate changes hit this in a fortnight, each written by someone
being careful about exactly the thing that got them.

A transform that removes bytes without leaving a gap is also a
reassembler. Redaction that matches by shape, run before a sanitizer that
strips control bytes, lets a credential split by a NUL or an ESC pass the
patterns as two fragments and be rejoined on the way out: the MCP failure
reason in #835, and the imported-transcript chokepoint in #878. The path
form of the same mistake is comparing where a handle landed against a
value produced by the same resolver the kernel just used, so a redirect
agrees with itself: the ACL guard in #808, where junctions were caught
only by an accident of Go's mode bits and directory symlinks were not
caught at all.

The unsplit value passing is what makes it survive review, so the note
says the test needs a split case.
2026-08-13 10:40:51 +05:30
11 changed files with 100 additions and 1030 deletions

No files matched your search

+8
View File
@@ -91,6 +91,14 @@ These classes drive multi-round reviews. Fix them before requesting review:
final-component-only no-follow is insufficient. On multi-step setup, roll back
only what this run created; never destroy pre-existing resources you did
not create; never report success when cleanup or unlock failed.
- **Normalize before matching:** Any transform that REMOVES bytes without
leaving a gap is also a reassembler, so it has to run before whatever matches
on the result. Redacting by shape and then stripping control bytes lets a
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
fragments and be rejoined on the way out; comparing a path against a form
produced by the same resolver the kernel just used makes a redirect agree with
itself. Strip, decode, or canonicalize first, then match. The unsplit value
passing is not evidence, so the test needs a split case.
- **Atomic shared state:** Write a complete temporary file, then atomically
replace the destination so concurrent readers never see a partial write.
Exclusive create or a write lock alone is not enough for readers. Serialize
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/Gitlawb/zero
go 1.26.6
go 1.26.5
require (
charm.land/bubbles/v2 v2.1.1
@@ -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.45.0
golang.org/x/image v0.44.0
golang.org/x/sys v0.47.0
mvdan.cc/sh/v3 v3.13.1
)
-2
View File
@@ -66,8 +66,6 @@ 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=
-56
View File
@@ -121,59 +121,3 @@ 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])
}
}
-64
View File
@@ -1,64 +0,0 @@
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
}
-402
View File
@@ -1,402 +0,0 @@
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))
}
}
+1 -2
View File
@@ -244,8 +244,7 @@ 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) {
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow, opts)
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
}
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
+70 -339
View File
@@ -2,7 +2,6 @@ package tui
import (
"fmt"
"image/color"
"net/url"
"os"
"path/filepath"
@@ -1440,14 +1439,12 @@ 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, optional extra head metadata
// (e.g. a read's line range), and whether the rendered body exposes an
// expand/collapse interaction.
// optional footer embedded in the bottom border, and optional extra head
// metadata (e.g. a read's line range).
type cardBody struct {
lines []string
footer string
headTag string
canToggle bool
lines []string
footer string
headTag string
}
// renderRunningToolCard draws the head-only card for a tool call that has no
@@ -1997,75 +1994,9 @@ func genericCardBody(detail string, opts cardRenderOptions) cardBody {
return cardBody{lines: capCardLines(lines, opts.bodyCap)}
}
// 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, "+++ ")
}
// 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+)? @@`)
type diffMetadata struct {
path string
@@ -2076,31 +2007,18 @@ 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 = diffViewerSourcePath(line)
meta.path = strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(line, "+++ ")), "b/")
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
@@ -2121,9 +2039,12 @@ 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{}
@@ -2136,263 +2057,88 @@ func diffCardBody(detail string, width int, opts cardRenderOptions) cardBody {
gutterWidth = 4
}
textBudget := maxInt(8, innerWidth-3-gutterWidth)
highlightedLines := highlightedDiffLines(rawLines, meta)
hunk := diffHunkState{}
for i := 0; i < len(displayLines); i++ {
displayLine := displayLines[i]
line := displayLine.text
highlightedAdds := highlightedAddedDiffLines(rawLines, meta, textBudget)
highlightAddIndex := 0
oldLine, newLine := 0, 0
inHunk := false
for i := 0; i < len(rawLines); i++ {
line := rawLines[i]
switch {
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():
case strings.HasPrefix(line, "+++ "), strings.HasPrefix(line, "--- "):
// 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 parsed, ok := parseDiffHunkHeader(line); ok {
hunk = parsed
if match := hunkHeaderPattern.FindStringSubmatch(line); match != nil {
oldLine, _ = strconv.Atoi(match[1])
newLine, _ = strconv.Atoi(match[2])
inHunk = true
}
// 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.
// 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.
lines = append(lines, zeroTheme.diffMeta.Render(truncateRunes(line, innerWidth)))
case strings.HasPrefix(line, "+"):
if styled, ok := highlightedLines[displayLine.rawIndex]; ok {
lines = append(lines, diffBodyStyledLine(hunk.newLine, "+", styled, true, textBudget, gutter))
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++
} else {
text := truncateRunes(strings.TrimPrefix(line, "+"), textBudget)
lines = append(lines, diffBodyLine(hunk.newLine, "+", text, true, textBudget, gutter))
lines = append(lines, diffBodyLine(newLine, "+", text, true, textBudget, gutter))
}
hunk.newLine++
hunk.consume(line)
newLine++
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. 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) {
// instantly. Block changes and near-rewrites fall back to whole-line tint.
if isIsolatedReplacement(rawLines, i) {
delText := truncateRunes(strings.TrimPrefix(line, "-"), textBudget)
addText := truncateRunes(strings.TrimPrefix(rawLines[displayLine.rawIndex+1], "+"), textBudget)
if delRow, addRow, ok := renderWordDiffPair(hunk.oldLine, hunk.newLine, delText, addText, textBudget, gutter); ok {
addText := truncateRunes(strings.TrimPrefix(rawLines[i+1], "+"), textBudget)
if delRow, addRow, ok := renderWordDiffPair(oldLine, newLine, delText, addText, textBudget, gutter); ok {
lines = append(lines, delRow, addRow)
hunk.oldLine++
hunk.newLine++
hunk.consume(line)
hunk.consume(rawLines[displayLine.rawIndex+1])
oldLine++
newLine++
i++ // consume the paired "+"
continue
}
}
text := truncateRunes(strings.TrimPrefix(line, "-"), textBudget)
lines = append(lines, diffBodyLine(hunk.oldLine, "−", text, false, textBudget, gutter))
hunk.oldLine++
hunk.consume(line)
lines = append(lines, diffBodyLine(oldLine, "−", text, false, textBudget, gutter))
oldLine++
default:
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)
text := truncateRunes(strings.TrimPrefix(line, " "), textBudget)
row := " " + zeroTheme.muted.Render(text)
if gutter {
row = zeroTheme.faintest.Render(fmt.Sprintf("%4d", newLine)) + row
}
hunk.oldLine++
hunk.newLine++
hunk.consume(line)
lines = append(lines, row)
oldLine++
newLine++
}
}
return cardBody{lines: capCardLines(lines, opts.bodyCap), headTag: diffHeadTag(meta)}
}
func isDiffViewerPreamble(line string) bool {
for _, prefix := range []string{
"diff --git ", "index ", "similarity index ", "dissimilarity index ",
} {
if strings.HasPrefix(line, prefix) {
return true
}
func highlightedAddedDiffLines(rawLines []string, meta diffMetadata, textBudget int) []string {
if !meta.addOnly() || meta.path == "" {
return nil
}
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)
content := make([]string, 0, meta.adds)
for _, line := range rawLines {
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++ ") {
content = append(content, strings.TrimPrefix(line, "+"))
}
}
if len(content) == 0 {
return true
return nil
}
if !budget.reserve(len(content), bytes) {
return false
highlighted, ok := highlightCodeForPath(content, meta.path, textBudget, zeroTheme.addLine.GetBackground())
if !ok || len(highlighted) != len(content) {
return nil
}
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
return highlighted
}
// diffBodyLine paints one changed row: optional gutter number, sign column,
@@ -2422,7 +2168,6 @@ 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))
}
@@ -2433,18 +2178,6 @@ 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, "+++ ")
}
@@ -2542,17 +2275,15 @@ 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, canToggle: footer != ""}
return cardBody{lines: []string{summary}, footer: 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, canToggle: canToggle}
return cardBody{lines: lines, footer: footer}
}
func exploreDetailCardBody(name string, detail string, width int, opts cardRenderOptions) cardBody {
-26
View File
@@ -975,32 +975,6 @@ 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>".
+7 -91
View File
@@ -101,27 +101,6 @@ 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)
@@ -172,18 +151,6 @@ 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
}
@@ -196,10 +163,6 @@ func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string,
}
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() {
@@ -207,52 +170,18 @@ func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string,
cur.Reset()
curWidth = 0
}
emit := func(style lipgloss.Style, s string, line, column int) {
emit := func(style lipgloss.Style, s string) {
if bg != nil {
style = style.Background(bg)
}
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(chunkStyle.Render(chunk.String()))
cur.WriteString(style.Render(chunk.String()))
chunk.Reset()
haveChunkStyle = false
chunkBackground = nil
}
}
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
}
for _, r := range s {
rw := lipgloss.Width(string(r))
if curWidth+rw > measure {
flushChunk()
@@ -264,17 +193,13 @@ func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string,
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, line, column)
column += len([]rune(part))
emit(style, part)
}
}
flushLine()
@@ -284,12 +209,3 @@ func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string,
}
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
}
+12 -46
View File
@@ -716,19 +716,17 @@ func (m model) transcriptRowBodyHeightCacheKeyOpts(row transcriptRow, width int,
}
func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow, opts)
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
}
// 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) {
opts := cardRenderOptions{bodyCap: 0, cwd: m.cwd}
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRowDetailed, opts)
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRowDetailed)
}
// 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, toolOpts cardRenderOptions) (string, []transcriptSelectableLine) {
func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
switch row.kind {
case rowUser:
return m.renderSelectableUserRow(rowIndex, row, width, startBodyY)
@@ -739,7 +737,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, toolOpts)
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
case rowSpecialist:
return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
default:
@@ -752,19 +750,19 @@ func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int,
}
}
// 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) {
// 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) {
rendered := renderFn(row, width, rc)
if rendered == "" {
return "", nil
}
// 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.
// 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).
allLines := viewLines(rendered)
header := transcriptSelectableLine{bodyY: startBodyY, rowIndex: rowIndex, toggle: toolResultCanToggle(row, width, rc, opts)}
header := transcriptSelectableLine{bodyY: startBodyY, rowIndex: rowIndex, toggle: true}
if len(allLines) > 0 {
if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY, allLines[0], false); ok {
header.text = meta.text
@@ -780,38 +778,6 @@ 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 == "" {