Compare commits

...
10 changed files with 1030 additions and 92 deletions

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/Gitlawb/zero
go 1.26.5
go 1.26.6
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.44.0
golang.org/x/image v0.45.0
golang.org/x/sys v0.47.0
mvdan.cc/sh/v3 v3.13.1
)
+2
View File
@@ -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=
+56
View File
@@ -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])
}
}
+64
View File
@@ -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
}
+402
View File
@@ -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))
}
}
+2 -1
View File
@@ -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
View File
@@ -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 {
+26
View File
@@ -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>".
+91 -7
View File
@@ -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
}
+46 -12
View File
@@ -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 == "" {