package app import ( "image/color" "strconv" "strings" "charm.land/lipgloss/v2" ) type diffKind int const ( diffBlank diffKind = iota // no line on this side diffContext diffDel diffAdd ) // diffRow is one aligned row of a split (side-by-side) diff: the old line on the // left, the new line on the right. Either side may be blank (pure add/remove). type diffRow struct { oldNum, newNum int // 0 = blank cell oldText, newText string oldKind, newKind diffKind } // parseUnifiedDiff turns a unified diff into aligned old|new rows. File headers // (---, +++, diff, index) are dropped; @@ markers seed the line numbers. // Consecutive removals and additions are zipped so an edit lines up; a pure // create yields blank left cells (and vice-versa for a delete). func parseUnifiedDiff(diff string) []diffRow { var rows []diffRow oldN, newN := 1, 1 var dels, adds []string flush := func() { n := len(dels) if len(adds) > n { n = len(adds) } for i := 0; i < n; i++ { r := diffRow{} if i < len(dels) { r.oldNum, r.oldText, r.oldKind = oldN, dels[i], diffDel oldN++ } if i < len(adds) { r.newNum, r.newText, r.newKind = newN, adds[i], diffAdd newN++ } rows = append(rows, r) } dels, adds = dels[:0], adds[:0] } for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") { switch { // File headers carry a trailing space ("+++ b/x"); requiring it means a real // changed line whose content starts with "++"/"--" isn't mistaken for a header // and silently dropped from the rendered diff (and its row count). case strings.HasPrefix(ln, "+++ "), strings.HasPrefix(ln, "--- "), strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "): continue case strings.HasPrefix(ln, "@@"): flush() oldN, newN = hunkStarts(ln) case strings.HasPrefix(ln, "-"): dels = append(dels, ln[1:]) case strings.HasPrefix(ln, "+"): adds = append(adds, ln[1:]) default: // context (leading space) or stray blank line flush() text := strings.TrimPrefix(ln, " ") rows = append(rows, diffRow{ oldNum: oldN, newNum: newN, oldText: text, newText: text, oldKind: diffContext, newKind: diffContext, }) oldN++ newN++ } } flush() return rows } // isUnifiedDiff reports whether s looks like a unified diff (file/hunk headers) // rather than arbitrary preview text such as a shell argv JSON. Non-diff previews // must not go through the two-column renderer (it would show identical columns). func isUnifiedDiff(s string) bool { return strings.Contains(s, "@@ -") || strings.HasPrefix(s, "--- ") || strings.Contains(s, "\n--- ") } // previewPlainLines splits non-diff preview text into display lines. func previewPlainLines(s string) []string { return strings.Split(strings.TrimRight(s, "\n"), "\n") } // previewDisplayLines is the single source of truth for how a non-diff preview is laid out: // a scannable summary for structured (JSON) artifacts, raw lines otherwise. Both the renderer // and previewRowCount go through it so band sizing and scroll bounds match what's drawn. func previewDisplayLines(s string) []string { if lines, ok := artifactDisplayLines(s); ok { return lines } return previewPlainLines(s) } // previewRowCount is the number of content rows a preview occupies — diff rows // for a real diff, plain lines otherwise. Drives band sizing and scroll bounds. func previewRowCount(s string) int { if isUnifiedDiff(s) { return len(parseUnifiedDiff(s)) } return len(previewDisplayLines(s)) } // renderPreviewLines renders non-diff preview text as dim plain lines (single // column), up to maxRows starting at off, each truncated to width. func (m Model) renderPreviewLines(content string, width, maxRows, off int) []string { lines := previewDisplayLines(content) if off < 0 { off = 0 } if off > len(lines) { off = len(lines) } end := off + maxRows if end > len(lines) { end = len(lines) } out := make([]string, 0, end-off) for _, ln := range lines[off:end] { out = append(out, m.colorizePreviewLine(ln, width)) } return out } // colorizePreviewLine highlights a summarized-artifact line: flush-left section headers (Goal, // Plan · N) in accent, the "→ produces" target picked out, everything else dim — so the structure // reads at a glance without a full JSON syntax highlighter. Plain (raw) previews stay uniformly dim. func (m Model) colorizePreviewLine(ln string, w int) string { t := m.theme ln = truncate(ln, w) if ln == "" { return "" } // Section header: no leading indent (e.g. "Goal", "Plan · 4 stages"). if ln[0] != ' ' { return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Bold(true).Render(ln) } // Stage head: pick out the "→ produces" target after truncation so widths stay bounded. if i := strings.Index(ln, " → "); i >= 0 { head := t.span(ln[:i], t.P.FgStrong) arrow := t.span(" → ", t.P.Faint) prod := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render(ln[i+len(" → "):]) return head + arrow + prod } return t.span(ln, t.P.Dim) } // hunkStarts reads the starting old/new line numbers from an @@ -a,b +c,d @@ marker. func hunkStarts(h string) (int, int) { old, newer := 1, 1 for _, f := range strings.Fields(h) { switch { case strings.HasPrefix(f, "-"): old = atoiComma(f[1:]) case strings.HasPrefix(f, "+"): newer = atoiComma(f[1:]) } } return old, newer } func atoiComma(s string) int { if i := strings.IndexByte(s, ','); i >= 0 { s = s[:i] } n, _ := strconv.Atoi(s) return n } // diffTarget returns the path the diff writes to (the +++ side), stripped of the // b/ prefix. Empty if the diff carries no header. func diffTarget(diff string) string { for _, ln := range strings.Split(diff, "\n") { if strings.HasPrefix(ln, "+++ ") { p := strings.Fields(strings.TrimPrefix(ln, "+++ "))[0] p = strings.TrimPrefix(p, "b/") if p != "/dev/null" { return p } } } return "" } const diffGutter = 4 // line-number column width // renderSplitDiff renders aligned rows as a two-column old|new view exactly // `width` cells wide, returning up to maxRows lines starting at offset off. func (m Model) renderSplitDiff(rows []diffRow, width, maxRows, off int) []string { colW := (width - 1) / 2 if colW < diffGutter+4 { colW = diffGutter + 4 } if off < 0 { off = 0 } if off > len(rows) { off = len(rows) } end := off + maxRows if end > len(rows) { end = len(rows) } sep := lipgloss.NewStyle().Foreground(m.theme.P.Border).Background(m.theme.P.Bg).Render("│") out := make([]string, 0, end-off) for _, r := range rows[off:end] { out = append(out, m.diffCell(r.oldNum, r.oldText, r.oldKind, colW)+sep+ m.diffCell(r.newNum, r.newText, r.newKind, colW)) } return out } // diffCell renders one side of a diff row (gutter number, +/- sign, text) padded // to width w with the kind's background tint. func (m Model) diffCell(num int, text string, kind diffKind, w int) string { t := m.theme if kind == diffBlank { return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w)) } var bg, textFg, signFg color.Color sign := " " switch kind { case diffAdd: bg, textFg, signFg, sign = t.P.DiffAdd, t.P.FgStrong, t.P.OK, "+" case diffDel: bg, textFg, signFg, sign = t.P.DiffDel, t.P.FgStrong, t.P.Bad, "-" default: // context bg, textFg, signFg = t.P.Bg, t.P.Dim, t.P.Faint } numStr := "" if num > 0 { numStr = itoa(num) } numCell := lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(padLeft(numStr, diffGutter) + " ") signCell := lipgloss.NewStyle().Foreground(signFg).Background(bg).Bold(true).Render(sign + " ") textW := w - diffGutter - 3 if textW < 1 { textW = 1 } body := lipgloss.NewStyle().Foreground(textFg).Background(bg).Render(truncate(text, textW)) return padTo(numCell+signCell+body, w, bg) } // padLeft right-aligns s in a field of width w (space-padded on the left). func padLeft(s string, w int) string { if len(s) >= w { return s } return strings.Repeat(" ", w-len(s)) + s }