mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
A ```diff fence rendered through the colourised built-in path on every frame, and a colourised row costs ~1.4 ms to lay out (chroma via regexp2) against ~1.3 µs for a plain one. While a fence streams every delta re-renders the whole growing body, so a multifile diff pegged a core and the display crawled. Changes: - Each "@@ " hunk header settles the rows before it, so render that prefix and leave only the in-progress hunk on the plain rail: the diff colours in as it streams instead of only at the closing fence. A closed fence settles the whole body. The formatter runs on each settled prefix too, so its output appears hunk by hunk. - Keep the already-formatted rows on screen while a settled body's run is pending: growing the body by one hunk changed its key and redrew the whole section on the plain rail until the new run landed, so the delta output flashed back to plain at every hunk. The renderer now draws the largest settled prefix whose result has landed and leaves only the newly settled remainder (and the in-progress hunk) on the rail. - Memoize the colourised rows per hunk (hunk, path, width, rail, theme). A completed hunk never changes, so it is highlighted once however many frames redraw it; caching the whole body instead would re-highlight every earlier hunk at each "@@ " and is quadratic in the hunk count (measured: 60 hunks, 79 KB took 40 s with a body memo, 2 s per-hunk). The theme is in the key so a runtime theme switch cannot serve the old palette's colours. - A fence still with fewer than two hunks keeps the plain rail, and a closed fence whose configured [cli].diff_formatter result is still pending also draws the rail, since that output supersedes the built-in rows anyway; a landed-but-failed run falls back to the built-in. - Hold the transcript viewport still when the content shrinks: a settled diff collapsing its raw preamble to one formatted line frees rows at the bottom, and following them back up made the screen jump. The position now holds, the freed rows stay blank, and later rows fill them, so scrolling resumes only when the output actually grows past what is shown. The hold has only a lower bound, so a shrink larger than the viewport would hold the position past the content and blank the whole transcript — a collapsing multifile fence, or /clear restarting over a shorter one — so the view re-anchors to its tail instead. A manual scroll or resize releases the hold. - Coalesce the stream frames the TUI hands the model: waitUpdate drains everything already queued into one updateMsg, so a burst of deltas costs one render instead of one per delta. Order is kept and a Gap still triggers a history reload. - Fix the file-header misdetection: a removed SQL "-- x" line renders "--- x", which trimDiffPreamble/diffSectionStart mistook for a "--- "/"+++ " file header, dropping the "@@" hunk header (line numbers reset to 0) or the rows themselves when a removed "--- x" sat before an added "+++ y". A pair is a file header only when a "@@ " hunk follows it, and countDiff no longer skips "--- "/"+++ " rows. - Keep a streaming multi-file diff's next file header on the plain rail: splitAtLastHunk settled everything before the last "@@ " header, which included the next file's "--- "/"+++ " pair (and its git preamble) once that file's first hunk header had arrived but no hunk of it had completed. isFileHeaderPair then saw no "@@ " after the pair, so the settled renderer read it as a removed/added row of the previous file's last hunk and counted it in that file's stat. A trailing dangling file header now moves from the settled part into the tail until its "@@ " arrives. Tests: the multi-file cases TestSplitAtLastHunk was missing and an end-to-end streamed test; a theme-switch test pinning the per-hunk memo's theme key; and a benchmark replaying a 4-file x 15-hunk stream, redrawn per streamed line. fix(cli): highlight a streaming diff fence's hunk once splitDiffSections appends a newline to every section, so splitHunks returns a section's last hunk with a trailing newline and every earlier hunk without. hunkRowsCached keyed the memo on the raw hunk, so one hunk carried two keys over a stream: it was highlighted once while it was its section's last hunk and again once a following hunk arrived. Every hunk but a file's last was therefore highlighted twice, at ~1.4 ms a row. Trim the trailing newline before keying. diffBody already trims, so the rendered rows are unchanged; the streaming benchmark over a 4-file, 30-hunk, 1816-line diff drops from 7.8 s to 5.6 s. TestStreamingDiffHunkMemoisedOnce pins one memo entry per hunk: it sees three entries for two hunks before this change, two after. Fold the writer-tool preview before highlighting it too. diffBody laid out every row and then dropped all but maxLines, so a write_file card's ~24-row preview colourised the whole diff: ~772 ms per repaint for a 199-line file, paid on every message while the call runs. Counting the fold from the source and laying out only the kept rows prices a 2000-line diff like a 24-line one. BenchmarkDiffBlockPreview prices both shapes through a diffPreviewFold seam, which TestDiffBodyFoldSeamSameRows proves draws the same rows: a 2000-line preview is 6.3 s and 86 MB with the whole body laid out, 71 ms and 1.0 MB folded, and flat from 24 lines up. docs(cli): correct diff-fence comment coherence Four comments described behaviour the code does not have: - diffRowCount's doc counted one row per non-blank line, but diffBody draws no row for a "@@ " header, so it over-counted by the hunk count. - renderCodeRail's doc claimed the in-progress hunk and the pending-formatter placeholder, both drawn by renderRailText, not here. - splitDiffSections' doc named only the fence path, omitting DiffText, and described the plain-rail fallback where DiffText drops a headerless section. - the file header claimed md.go still sits at the 800-line ceiling. Reword each to the constraint that holds now.
443 lines
12 KiB
Go
443 lines
12 KiB
Go
// Renders a unified diff as line-numbered, syntax-highlighted rows on
|
||
// Green/red background bars with a +/- gutter.
|
||
package termrender
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/alecthomas/chroma/v2"
|
||
"github.com/alecthomas/chroma/v2/formatters"
|
||
"github.com/alecthomas/chroma/v2/lexers"
|
||
"github.com/alecthomas/chroma/v2/styles"
|
||
|
||
"reasonix/internal/base/i18n"
|
||
"reasonix/internal/contract/event"
|
||
)
|
||
|
||
const tabWidth = 4
|
||
|
||
const (
|
||
bgDiffAdd = "\033[48;5;22m"
|
||
bgDiffDel = "\033[48;5;52m"
|
||
fgDiffAdd = "\033[1;38;5;46m"
|
||
fgDiffDel = "\033[1;38;5;203m"
|
||
)
|
||
|
||
var (
|
||
diffChromaFmt = formatters.Get("terminal256")
|
||
hunkRE = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
|
||
)
|
||
|
||
// Resolve on each render so runtime theme switches and theme-sweep preview
|
||
// frames cannot retain syntax colours from the previous light/dark mode.
|
||
func activeDiffChromaStyle() *chroma.Style {
|
||
mode := chroma.Dark
|
||
if activeTheme.Name == "light" {
|
||
mode = chroma.Light
|
||
}
|
||
return styles.GetForMode("github-dark", mode)
|
||
}
|
||
|
||
// DiffStat renders a change's "+A -B" tally, green/red, omitting a zero side.
|
||
func DiffStat(d event.FileDiff) string {
|
||
parts := make([]string, 0, 2)
|
||
if d.Added > 0 {
|
||
parts = append(parts, Green("+"+strconv.Itoa(d.Added)))
|
||
}
|
||
if d.Removed > 0 {
|
||
parts = append(parts, Red("-"+strconv.Itoa(d.Removed)))
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|
||
|
||
func diffPath(args string) string {
|
||
var p struct {
|
||
Path string `json:"path"`
|
||
}
|
||
_ = json.Unmarshal([]byte(args), &p)
|
||
return p.Path
|
||
}
|
||
|
||
// DiffBlock renders a writer call as a header line ("✎ name path +A -B") plus
|
||
// the highlighted, folded diff body. Returns nil when there's no textual diff.
|
||
// A configured [cli].diff_formatter (e.g. delta) takes the whole diff on stdin
|
||
// and its stdout is re-emitted under the header with non-SGR escapes stripped,
|
||
// mirroring the fenced-diff path — the built-in body (and its fold) is the fallback.
|
||
func DiffBlock(name, args string, d event.FileDiff, width, maxLines int) []string {
|
||
if d.Diff == "" {
|
||
return nil
|
||
}
|
||
path := diffPath(args)
|
||
header := " " + ToolDot(name) + " " + ToolHead(name, path, width)
|
||
if stat := DiffStat(d); stat != "" {
|
||
header += " " + stat
|
||
}
|
||
if rows, ok := formatToolDiff(dropFileHeaderPair(d.Diff), width); ok {
|
||
return append([]string{header}, rows...)
|
||
}
|
||
return append([]string{header}, diffBody(d, path, width, maxLines)...)
|
||
}
|
||
|
||
// dropFileHeaderPair removes a leading "--- "/"+++ " pair, the same pair the
|
||
// built-in body drops positionally. The external formatter would otherwise
|
||
// re-emit that raw header — including an absolute host path in the label — which
|
||
// the card header already names.
|
||
func dropFileHeaderPair(diff string) string {
|
||
lines := strings.SplitN(diff, "\n", 3)
|
||
if len(lines) == 3 && strings.HasPrefix(lines[0], "--- ") && strings.HasPrefix(lines[1], "+++ ") {
|
||
return lines[2]
|
||
}
|
||
return diff
|
||
}
|
||
|
||
// diffPreviewFold is on in production; the preview benchmark turns it off to
|
||
// price the whole-body highlight the fold replaced.
|
||
var diffPreviewFold = true
|
||
|
||
// diffBody renders the hunks with a line-number gutter, dropping the file and
|
||
// "@@" headers (a dim "⋮" marks each hunk jump) and folding past maxLines to a
|
||
// "+N more" footer. path selects the syntax lexer.
|
||
func diffBody(d event.FileDiff, path string, width, maxLines int) []string {
|
||
if d.Diff == "" {
|
||
return nil
|
||
}
|
||
// A diff that already carries colour (a formatter's output pasted back, a
|
||
// file whose own content is coloured) must not be re-parsed: the SGR-prefixed
|
||
// lines would be mis-read and the colours dropped, so show it verbatim.
|
||
if hasSGR(d.Diff) {
|
||
return verbatimDiffBody(d.Diff, width, maxLines)
|
||
}
|
||
src := strings.Split(strings.TrimRight(d.Diff, "\n"), "\n")
|
||
// Drop the "--- a/… / +++ b/…" header pair positionally — matching the prefix
|
||
// on every line would eat real content (a deleted SQL "-- x" renders "--- x",
|
||
// an added "++ y" renders "+++ y").
|
||
if len(src) >= 2 && strings.HasPrefix(src[0], "--- ") && strings.HasPrefix(src[1], "+++ ") {
|
||
src = src[2:]
|
||
}
|
||
gw := gutterWidth(src)
|
||
|
||
// The fold is known before any row is laid out: colourise only the rows kept
|
||
// and count the rest. A colourised row costs ~1.4 ms (chroma via regexp2), so
|
||
// a folded preview must not pay to highlight the tail it discards.
|
||
keep, folded := 0, 0
|
||
if diffPreviewFold {
|
||
keep, folded = foldPreview(diffRowCount(src), maxLines)
|
||
}
|
||
|
||
var rows []string
|
||
oldNo, newNo, hunks := 0, 0, 0
|
||
for _, ln := range src {
|
||
if ln == "" {
|
||
continue
|
||
}
|
||
if diffPreviewFold && len(rows) >= keep {
|
||
break
|
||
}
|
||
switch ln[0] {
|
||
case '@':
|
||
if m := hunkRE.FindStringSubmatch(ln); m != nil {
|
||
oldNo, newNo = atoi(m[1]), atoi(m[3])
|
||
}
|
||
if hunks > 0 {
|
||
rows = append(rows, " "+Dim("⋮"))
|
||
}
|
||
hunks++
|
||
case '+':
|
||
rows = append(rows, diffBar('+', ln[1:], path, width, bgSGR(activeTheme.DiffAddBG), fgSGR(activeTheme.Success), newNo, gw))
|
||
newNo++
|
||
case '-':
|
||
rows = append(rows, diffBar('-', ln[1:], path, width, bgSGR(activeTheme.DiffDelBG), fgSGR(activeTheme.Err), oldNo, gw))
|
||
oldNo++
|
||
case '\\':
|
||
rows = append(rows, " "+Dim(clampPlain(ln, width-2)))
|
||
default:
|
||
code := ln
|
||
if ln[0] == ' ' {
|
||
code = ln[1:]
|
||
}
|
||
rows = append(rows, diffContext(code, path, width, newNo, gw))
|
||
oldNo++
|
||
newNo++
|
||
}
|
||
}
|
||
|
||
if !diffPreviewFold {
|
||
rows, folded = foldRows(rows, maxLines)
|
||
}
|
||
if folded > 0 {
|
||
rows = append(rows, " "+Dim(fmt.Sprintf(i18n.M.DiffFoldedFmt, folded)))
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// foldRows drops a fully laid-out body's tail past maxLines, returning the kept
|
||
// rows and how many it hid. The seam's slow path: production counts the fold
|
||
// from the source and never lays the tail out.
|
||
func foldRows(rows []string, maxLines int) ([]string, int) {
|
||
if maxLines <= 0 || len(rows) <= maxLines {
|
||
return rows, 0
|
||
}
|
||
folded := len(rows) - (maxLines - 1)
|
||
return rows[:maxLines-1], folded
|
||
}
|
||
|
||
// foldPreview returns how many rows to draw and how many the fold hides for a
|
||
// body of total rows: the first maxLines-1 rows and the count of the rest, or
|
||
// every row and no fold when the body fits.
|
||
func foldPreview(total, maxLines int) (keep, folded int) {
|
||
if maxLines <= 0 || total <= maxLines {
|
||
return total, 0
|
||
}
|
||
keep = maxLines - 1
|
||
return keep, total - keep
|
||
}
|
||
|
||
// diffRowCount is how many rows diffBody draws for src: one per non-blank line
|
||
// that is not a "@@ " header, plus the "⋮" separator each hunk header after the
|
||
// first adds.
|
||
func diffRowCount(src []string) int {
|
||
total, hunks := 0, 0
|
||
for _, ln := range src {
|
||
switch {
|
||
case ln == "":
|
||
case ln[0] == '@':
|
||
if hunks > 0 {
|
||
total++
|
||
}
|
||
hunks++
|
||
default:
|
||
total++
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
// hasSGR reports whether s carries an ANSI CSI/SGR introducer. A diff already
|
||
// containing one was colourised externally and must not be re-parsed.
|
||
func hasSGR(s string) bool {
|
||
return strings.Contains(s, "\x1b[")
|
||
}
|
||
|
||
// verbatimDiffBody renders an externally colourised diff as-is: no gutter, no
|
||
// background bars, no header-drop, no syntax re-highlight — just the lines,
|
||
// width-clamped and sanitised so a hostile payload cannot drive the terminal.
|
||
func verbatimDiffBody(diff string, width, maxLines int) []string {
|
||
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
|
||
total := 0
|
||
for _, ln := range lines {
|
||
if ln != "" {
|
||
total++
|
||
}
|
||
}
|
||
keep, folded := 0, 0
|
||
if diffPreviewFold {
|
||
keep, folded = foldPreview(total, maxLines)
|
||
}
|
||
rows := make([]string, 0, keep)
|
||
for _, ln := range lines {
|
||
if ln == "" {
|
||
continue
|
||
}
|
||
if diffPreviewFold && len(rows) >= keep {
|
||
break
|
||
}
|
||
rows = append(rows, " "+clampPlain(sgrOnly(ln), max(width-2, 1)))
|
||
}
|
||
if !diffPreviewFold {
|
||
rows, folded = foldRows(rows, maxLines)
|
||
}
|
||
if folded > 0 {
|
||
rows = append(rows, " "+Dim(fmt.Sprintf(i18n.M.DiffFoldedFmt, folded)))
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// sgrOnly keeps SGR (colour/style) escape sequences and drops every other
|
||
// control sequence — OSC (clipboard pokes), cursor moves, non-SGR CSI — so an
|
||
// externally rendered diff can colour the terminal without hijacking it.
|
||
func sgrOnly(s string) string {
|
||
if !strings.ContainsRune(s, 0x1b) {
|
||
return s
|
||
}
|
||
var b strings.Builder
|
||
for i := 0; i < len(s); {
|
||
if s[i] != 0x1b {
|
||
b.WriteByte(s[i])
|
||
i++
|
||
continue
|
||
}
|
||
if i+1 >= len(s) {
|
||
break
|
||
}
|
||
switch s[i+1] {
|
||
case '[': // CSI … final byte in 0x40–0x7e
|
||
j := i + 2
|
||
for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) {
|
||
j++
|
||
}
|
||
if j >= len(s) {
|
||
return b.String() // unterminated: drop the tail
|
||
}
|
||
if s[j] == 'm' {
|
||
b.WriteString(s[i : j+1])
|
||
}
|
||
i = j + 1
|
||
case ']': // OSC … BEL or ST
|
||
j := i + 2
|
||
for j < len(s) {
|
||
if s[j] == 0x07 {
|
||
j++
|
||
break
|
||
}
|
||
if s[j] == 0x1b && j+1 < len(s) && s[j+1] == '\\' {
|
||
j += 2
|
||
break
|
||
}
|
||
j++
|
||
}
|
||
i = j
|
||
default: // other escape: drop ESC and its introducer byte
|
||
i += 2
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// diffBar draws one added/removed row on a full-width coloured background. The
|
||
// bg is re-applied after every chroma reset — \033[0m would otherwise end the
|
||
// bar mid-line — and padded to the bar width so it runs edge to edge.
|
||
func diffBar(sign byte, code, path string, width int, bg, signFg string, lineNo, gw int) string {
|
||
gutter := Dim(lpad(strconv.Itoa(lineNo), gw))
|
||
barW := max(width-2-gw-1, 4)
|
||
if !colorOn() {
|
||
return " " + gutter + " " + string(sign) + " " + clampPlain(code, barW-2)
|
||
}
|
||
hl := reapplyBG(highlightClamped(code, path, barW-2), bg)
|
||
pad := max(barW-2-VisibleWidth(hl), 0)
|
||
return " " + gutter + " " + bg + signFg + string(sign) + ansiReset + bg + " " + hl + strings.Repeat(" ", pad) + ansiReset
|
||
}
|
||
|
||
// diffContext draws an unchanged line: the gutter, no background, code aligned
|
||
// under the +/- rows' code column.
|
||
func diffContext(code, path string, width, lineNo, gw int) string {
|
||
gutter := Dim(lpad(strconv.Itoa(lineNo), gw))
|
||
return " " + gutter + " " + highlightClamped(code, path, width-5-gw)
|
||
}
|
||
|
||
func gutterWidth(lines []string) int {
|
||
max := 0
|
||
for _, ln := range lines {
|
||
m := hunkRE.FindStringSubmatch(ln)
|
||
if m == nil {
|
||
continue
|
||
}
|
||
for _, p := range [][2]int{{1, 2}, {3, 4}} {
|
||
end := atoi(m[p[0]])
|
||
if m[p[1]] != "" {
|
||
end += atoi(m[p[1]])
|
||
} else {
|
||
end++
|
||
}
|
||
if end > max {
|
||
max = end
|
||
}
|
||
}
|
||
}
|
||
if w := len(strconv.Itoa(max)); w > 2 {
|
||
return w
|
||
}
|
||
return 2
|
||
}
|
||
|
||
func lpad(s string, w int) string {
|
||
if len(s) >= w {
|
||
return s
|
||
}
|
||
return strings.Repeat(" ", w-len(s)) + s
|
||
}
|
||
|
||
func atoi(s string) int {
|
||
n, _ := strconv.Atoi(s)
|
||
return n
|
||
}
|
||
|
||
// highlightClamped highlights the whole line, then clamps it to w columns with
|
||
// every escape sequence kept. Clamping first would cut a token in two — a line
|
||
// ending inside an open string lexes the lone quote as an Error token, and the
|
||
// light theme's Error background then paints over the diff bar's own colour.
|
||
func highlightClamped(code, path string, w int) string {
|
||
if w < 1 {
|
||
w = 1
|
||
}
|
||
code = ExpandTabs(code)
|
||
if !colorOn() {
|
||
return Truncate(code, w, "")
|
||
}
|
||
return Truncate(highlightCode(path, code), w, "")
|
||
}
|
||
|
||
func clampPlain(s string, w int) string {
|
||
if w < 1 {
|
||
w = 1
|
||
}
|
||
return Truncate(ExpandTabs(s), w, "")
|
||
}
|
||
|
||
// ExpandTabs replaces tabs with spaces to the next tabWidth stop. A literal tab
|
||
// has zero StringWidth but the terminal advances it to a tab stop, so leaving
|
||
// tabs in a background-bar row overflows the bar — expand them so the measured
|
||
// width matches what's drawn.
|
||
func ExpandTabs(s string) string {
|
||
if !strings.ContainsRune(s, '\t') {
|
||
return s
|
||
}
|
||
var b strings.Builder
|
||
col := 0
|
||
for _, r := range s {
|
||
if r == '\t' {
|
||
n := tabWidth - col%tabWidth
|
||
for range n {
|
||
b.WriteByte(' ')
|
||
}
|
||
col += n
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
col++
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func reapplyBG(s, bg string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
return strings.ReplaceAll(s, ansiReset, ansiReset+bg)
|
||
}
|
||
|
||
// highlightCode returns code with chroma ANSI foreground colours for the lexer
|
||
// matched by path (plain fallback for unknown types). It emits no background, so
|
||
// it composes onto a diff bar; the caller re-applies the bar background.
|
||
func highlightCode(path, code string) string {
|
||
if code == "" {
|
||
return code
|
||
}
|
||
lexer := lexers.Match(path)
|
||
if lexer == nil {
|
||
lexer = lexers.Fallback
|
||
}
|
||
it, err := lexer.Tokenise(nil, code)
|
||
if err != nil {
|
||
return code
|
||
}
|
||
var b strings.Builder
|
||
if diffChromaFmt.Format(&b, activeDiffChromaStyle(), it) != nil {
|
||
return code
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|