diff --git a/internal/coding/tui/markdown.go b/internal/coding/tui/markdown.go index 8d5b6786..6de55d0f 100644 --- a/internal/coding/tui/markdown.go +++ b/internal/coding/tui/markdown.go @@ -39,14 +39,25 @@ type markdownEntry struct { } type markdownRenderer struct { - capacity int - entries map[markdownKey]*list.Element - recent *list.List - bytes int - maxBytes int - live map[string]markdownKey + capacity int + entries map[markdownKey]*list.Element + recent *list.List + bytes int + maxBytes int + live map[string]markdownKey + // frozen holds the assembled prefix of each live slot, so a streaming frame + // extends it instead of rendering the whole body again. thinking is the same + // state for the one live Thinking section, which is rendered as prose rather + // than Markdown. + frozen map[string]*liveFrozen + thinking liveThinking engine *glamour.TermRenderer engineKey markdownKey + // renderedBytes counts the source bytes handed to the engine since the last + // reset, and thinkingWrapped the reasoning bytes handed to the wrapper, so + // tests can assert that a streaming frame does not reprocess the whole body. + renderedBytes int + thinkingWrapped int } func newMarkdownRenderer(capacity int) *markdownRenderer { @@ -60,6 +71,7 @@ func newMarkdownRenderer(capacity int) *markdownRenderer { recent: list.New(), maxBytes: markdownCacheBytes, live: make(map[string]markdownKey), + frozen: make(map[string]*liveFrozen), } } @@ -74,15 +86,87 @@ func (r *markdownRenderer) render( // renderLive replaces the preceding version in one bounded slot. A new stream // prefix must not leave an entire obsolete ANSI document in the settled LRU. +// +// A growing body misses the content cache on every frame, so the render is +// assembled from a frozen prefix whenever a boundary allows it; the work then +// scales with the live tail instead of with the whole body. See +// [markdown_live.go] for the boundary contract. func (r *markdownRenderer) renderLive( slot, content string, width int, theme colorTheme, noColor bool, ) (string, error) { + if rendered, ok := r.renderLiveIncremental(slot, content, width, theme, noColor); ok { + // The assembled frame is never a reusable cache entry: it changes every + // frame. Dropping the previous version still keeps an obsolete document + // out of the settled LRU when a fallback render stored one. + r.evictLive(slot) + + return rendered, nil + } + return r.renderCached(slot, content, width, theme, noColor) } +// renderLiveIncremental renders a growing live body from its frozen prefix. ok +// is false when no boundary may be frozen or a render failed, so the caller +// renders the body whole. +func (r *markdownRenderer) renderLiveIncremental( + slot, content string, + width int, + theme colorTheme, + noColor bool, +) (string, bool) { + prefixEnd, block, ok := liveMarkdownBoundary(content) + if !ok || prefixEnd <= 0 || block == "" || prefixEnd > len(content) { + return "", false + } + + head, ok := r.markdownFrozenRows(slot, content, prefixEnd, block, width, theme, noColor) + if !ok { + return "", false + } + + blockRendered, err := r.render(block, width, theme, noColor) + if err != nil { + return "", false + } + + // The seam carries the last frozen block, so the tail is rendered in the + // context that decides the rows between them. + seam, err := r.renderUncached(block+"\n\n"+content[prefixEnd:], width, theme, noColor) + if err != nil { + return "", false + } + + tail, found := markdownRowsAfter(seam, markdownRowCount(blockRendered)) + if !found { + return "", false + } + + if tail == "" { + return head, true + } + if head == "" { + return tail, true + } + + return head + "\n" + tail, true +} + +// evictLive drops the live version stored for a slot, so a fallback render's +// obsolete document does not survive the next assembled frame. +func (r *markdownRenderer) evictLive(slot string) { + if slot == "" { + return + } + + if previous, exists := r.live[slot]; exists { + r.remove(r.entries[previous]) + } +} + func (r *markdownRenderer) renderCached( slot, content string, width int, @@ -117,6 +201,8 @@ func (r *markdownRenderer) renderCached( // The event loop owns this renderer. Reuse its configuration, but never share // the stateful engine between concurrently rendered Models. func (r *markdownRenderer) renderUncached(content string, width int, theme colorTheme, noColor bool) (string, error) { + r.renderedBytes += len(content) + key := markdownKey{width: max(1, width), theme: themeFingerprint(theme.fingerprint), noColor: noColor} if r.engine == nil || r.engineKey != key { engine, err := glamour.NewTermRenderer( diff --git a/internal/coding/tui/markdown_bench_test.go b/internal/coding/tui/markdown_bench_test.go index 9385a26c..fdcf30c8 100644 --- a/internal/coding/tui/markdown_bench_test.go +++ b/internal/coding/tui/markdown_bench_test.go @@ -21,13 +21,73 @@ func BenchmarkThinkingStreamRender(b *testing.B) { b.ReportAllocs() + renderer := newMarkdownRenderer(128) + for index := 0; b.Loop(); index++ { block := timelineBlock{ kind: blockThinking, id: draftThinkingID, body: fmt.Sprintf("%s%07d", base[:size-7], index), } - if renderThinkingBlock(block, 100, themeDark, false) == "" { + if renderThinkingBlock(block, renderer, 100, themeDark, false) == "" { + b.Fatal("empty rendering") + } + } + }) + } +} + +// benchmarkLiveMarkdownBody builds a streaming answer of the given size as +// ordinary paragraphs, the shape a real assistant message has. +func benchmarkLiveMarkdownBody(bytes int) string { + paragraph := "A streamed answer paragraph with ordinary words and a **bold** run.\n\n" + return strings.Repeat(paragraph, bytes/len(paragraph)+1)[:bytes] +} + +// BenchmarkLiveMarkdownFrame measures one frame's render of a growing live +// answer. The body is one paragraph longer each iteration, so a whole-document +// render would grow with it; the frozen prefix keeps the frame's cost tied to +// the tail. +func BenchmarkLiveMarkdownFrame(b *testing.B) { + for _, size := range []int{4 << 10, 32 << 10, 128 << 10} { + b.Run(fmt.Sprintf("bytes=%d", size), func(b *testing.B) { + base := benchmarkLiveMarkdownBody(size) + renderer := newMarkdownRenderer(128) + + b.ReportAllocs() + + for index := 0; b.Loop(); index++ { + body := base + fmt.Sprintf("frame %07d keeps streaming.\n\n", index) + if _, err := renderer.renderLive("draft", body, 100, themeDark, false); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// benchmarkThinkingBody builds a reasoning body of at least the given size as +// complete paragraphs, the shape a streamed thought has. +func benchmarkThinkingBody(bytes int) string { + line := "正在分析状态更新与界面渲染。Weighing the next step and its constraints.\n\n" + return strings.Repeat(line, bytes/len(line)+1) +} + +// BenchmarkLiveThinkingFrame measures one frame's render of a growing Thinking +// section. The body gains a paragraph each iteration and ends mid-line, the shape +// a streamed thought has, so rendering the whole thought per frame would grow +// with it; the frozen rows keep the frame's cost tied to the tail. +func BenchmarkLiveThinkingFrame(b *testing.B) { + for _, size := range []int{4 << 10, 32 << 10, 128 << 10} { + b.Run(fmt.Sprintf("bytes=%d", size), func(b *testing.B) { + base := benchmarkThinkingBody(size) + renderer := newMarkdownRenderer(128) + + b.ReportAllocs() + + for index := 0; b.Loop(); index++ { + body := base + fmt.Sprintf("frame %07d keeps reasoning onward", index) + if renderThinkingBlock(liveThinkingBlock(body), renderer, 100, themeDark, false) == "" { b.Fatal("empty rendering") } } diff --git a/internal/coding/tui/markdown_live.go b/internal/coding/tui/markdown_live.go new file mode 100644 index 00000000..8e2cccd8 --- /dev/null +++ b/internal/coding/tui/markdown_live.go @@ -0,0 +1,441 @@ +//nolint:wsl_v5 // The boundary scanner keeps its state updates next to the line they classify. +package tui + +import ( + "strings" + "unicode/utf8" +) + +// Streaming Markdown is re-rendered on every frame, and a growing body misses +// the content-hash cache every time. Rendering the whole document per frame is +// O(body) per frame and O(body^2) over one message, which is what turns a long +// answer into a sustained CPU spike. +// +// The renderer therefore freezes the rows of a stable prefix. A prefix may be +// frozen only where rendering it alone provably produces the rows the whole +// document would produce for it, so the frame's output is unchanged: +// +// - the boundary is a blank line outside a fenced code block, so every +// container that opened before it has closed; +// - the block immediately before the boundary is a plain top-level paragraph. +// Headings and fenced/indented code carry a trailing margin whose padding +// depends on what follows, and lists and block quotes can still be re-read +// as loose or continued when more text arrives, so none of them may end a +// frozen prefix; +// - the prefix carries no bracket, because goldmark resolves link references +// over the whole document, so a `[...]` on either side of the boundary can +// change the other side's rows. +// +// The seam is reconstructed by rendering the prefix's last block together with +// the tail and dropping that block's own rows, which reproduces the whole +// document's rows byte for byte (see TestLiveMarkdownMatchesWholeRender). + +// liveFrozen is the frozen prefix of one live slot. A streaming body freezes a +// new boundary as each paragraph completes, and rendering the whole prefix again +// every time would keep the frame cost growing with the body, so the frozen rows +// are extended by the newly frozen region instead. +type liveFrozen struct { + // source is the body prefix the rendered rows cover. It shares the caller's + // backing array, so keeping it costs no copy. + source string + // rendered is source's rendered rows. + rendered string + // block is the last frozen block of source, and blockRows how many rows it + // renders to. Both are the context the next extension renders against. + block string + blockRows int + // The geometry the rows were rendered at. Rows rendered for another width, + // theme or colour mode cannot be extended. + width int + theme themeFingerprint + noColor bool +} + +// markdownFrozenRows returns the rendered rows of content[:prefixEnd], extending +// the slot's frozen prefix by the newly frozen region rather than rendering the +// whole prefix again. +// +//nolint:gocyclo // The freeze key, the rewind check and the extension are one ordered slot update. +func (r *markdownRenderer) markdownFrozenRows( + slot, content string, + prefixEnd int, + block string, + width int, + theme colorTheme, + noColor bool, +) (string, bool) { + fingerprint := themeFingerprint(theme.fingerprint) + + frozen, exists := r.frozen[slot] + if !exists || frozen.width != width || frozen.theme != fingerprint || frozen.noColor != noColor { + frozen = &liveFrozen{width: width, theme: fingerprint, noColor: noColor} + r.frozen[slot] = frozen + } + + covered := len(frozen.source) + if covered > prefixEnd || !strings.HasPrefix(content, frozen.source) { + // The body was replaced or rewound: the frozen rows no longer describe it. + *frozen = liveFrozen{width: width, theme: fingerprint, noColor: noColor} + covered = 0 + } + + if covered < prefixEnd { + region := content[covered:prefixEnd] + + document := region + if frozen.block != "" { + document = frozen.block + "\n\n" + region + } + + extended, err := r.renderUncached(document, width, theme, noColor) + if err != nil { + return "", false + } + + tail, found := markdownRowsAfter(extended, frozen.blockRows) + if !found { + return "", false + } + + switch { + case tail == "": + case frozen.rendered == "": + frozen.rendered = tail + default: + frozen.rendered += "\n" + tail + } + + frozen.source = content[:prefixEnd] + } + + if frozen.block != block { + rendered, err := r.render(block, width, theme, noColor) + if err != nil { + return "", false + } + + frozen.block, frozen.blockRows = block, markdownRowCount(rendered) + } + + return frozen.rendered, true +} + +// markdownRowCount counts the rows of a rendered document. +func markdownRowCount(value string) int { + if value == "" { + return 0 + } + + return strings.Count(value, "\n") + 1 +} + +// markdownRowsAfter returns everything after the first count rows of a rendered +// document, and whether the document had that many rows. +func markdownRowsAfter(value string, count int) (string, bool) { + if count <= 0 { + return value, true + } + + offset := 0 + for range count { + at := strings.IndexByte(value[offset:], '\n') + if at < 0 { + return "", false + } + + offset += at + 1 + } + + return value[offset:], true +} + +// liveMarkdownBoundary reports where a frozen prefix may end and the text of the +// block immediately before it. ok is false when the body holds no boundary that +// may be frozen. +// +//nolint:gocyclo // One pass keeps the fence, bracket and pending-boundary state adjacent. +func liveMarkdownBoundary(content string) (prefixEnd int, block string, ok bool) { + fence := "" + start, end := -1, -1 + // A frozen prefix must take no part in link reference resolution. goldmark + // collects definitions from the whole document, so a bracket on either side of + // the boundary can change the other side's rows: a usage in the prefix that a + // definition in the tail resolves, or a definition in the prefix whose scope a + // usage in the tail would otherwise lose. A definition's label may span lines, + // so the test is the bracket itself rather than a per-line pattern. Autolinks, + // linkified bare URLs and inline links are resolved without definitions and + // stay freezable. + seenBracket := false + // A boundary is only settled by the first line after it: a definition + // marker there turns the block above into a term, which cannot be frozen. + pending, pendingEnd, pendingBlock := false, 0, "" + + for offset := 0; offset <= len(content); { + line, lineEnd, next := markdownLine(content, offset) + + if fence == "" && !markdownBlankLine(line) && pending { + pending = false + + if !markdownDefinitionMarker(line) { + prefixEnd, block, ok = pendingEnd, pendingBlock, true + } + } + + if fence != "" { + // A fence is one block: it swallows blank lines and any block that + // precedes it, so a boundary after it can never be frozen. + end = lineEnd + if markdownFenceCloses(line, fence) { + fence = "" + } + + offset = next + + continue + } + + if marker, opens := markdownFenceOpens(line); opens { + start, end, fence = offset, lineEnd, marker + offset = next + + continue + } + + if markdownBlankLine(line) { + if start >= 0 { + pendingBlock = content[start:end] + pending = !seenBracket && freezableMarkdownBlock(pendingBlock) + pendingEnd = next + start = -1 + } + + offset = next + + continue + } + + if start < 0 { + start = offset + } + + end = lineEnd + + if strings.ContainsRune(line, '[') { + seenBracket = true + } + + offset = next + } + + return prefixEnd, block, ok +} + +// markdownLine returns the line starting at offset, the offset just past its +// text, and the offset after its terminator. The final unterminated line ends +// past the content, so a blank final line still yields a boundary that consumes +// the whole body. +func markdownLine(content string, offset int) (line string, lineEnd, next int) { + found := strings.IndexByte(content[offset:], '\n') + if found < 0 { + return content[offset:], len(content), len(content) + 1 + } + + return content[offset : offset+found], offset + found, offset + found + 1 +} + +// freezableMarkdownBlock reports whether one blank-line-delimited run of lines +// is a plain top-level paragraph whose rendering cannot depend on what follows. +func freezableMarkdownBlock(text string) bool { + if text == "" || !utf8.ValidString(text) { + // A partial rune at a chunk boundary makes glamour emit an extra row for + // the block on its own, so such a block never ends a frozen prefix. + return false + } + + lines := strings.Split(text, "\n") + for index, line := range lines { + if markdownBlankLine(line) || strings.TrimLeft(line, " \t") != line { + // An indented line is a container continuation or indented code. + return false + } + + if markdownBlockStarts(line) { + return false + } + + if index == 1 && markdownTableDelimiter(line) { + return false + } + } + + return true +} + +// markdownBlockStarts reports whether a line at column zero opens a block whose +// type is not a plain paragraph. +func markdownBlockStarts(line string) bool { + if _, opens := markdownFenceOpens(line); opens { + return true + } + + if markdownDefinitionMarker(line) { + return true + } + + switch line[0] { + case '#': + rest := strings.TrimLeft(line, "#") + + return rest == "" || rest[0] == ' ' || rest[0] == '\t' + case '>', '|': + return true + case '<': + return markdownHTMLBlockStart(line) + case '-', '*', '+': + return markdownListMarker(line) || markdownThematicBreak(line) || markdownSetextUnderline(line) + case '=': + return markdownSetextUnderline(line) + case '_': + return markdownThematicBreak(line) + } + + return markdownOrderedListMarker(line) +} + +// markdownFenceOpens returns the fence marker a line opens, if any. An indented +// line is code, not a fence. +func markdownFenceOpens(line string) (string, bool) { + if markdownIndented(line) { + return "", false + } + + line = strings.TrimLeft(line, " \t") + if len(line) < 3 || (line[0] != '`' && line[0] != '~') { + return "", false + } + + run := 0 + for run < len(line) && line[run] == line[0] { + run++ + } + if run < 3 { + return "", false + } + + if line[0] == '`' && strings.Contains(line[run:], "`") { + // An info string on a backtick fence may not contain a backtick. + return "", false + } + + return strings.Repeat(string(line[0]), run), true +} + +// markdownBlankLine reports whether a line separates two blocks. Only spaces, +// tabs and a carriage return count: CommonMark keeps other whitespace (a form +// feed, for instance) as paragraph content, and glamour renders it that way. +func markdownBlankLine(line string) bool { + return strings.Trim(line, " \t\r") == "" +} + +// markdownIndented reports whether a line is indented far enough to be code. +func markdownIndented(line string) bool { + columns := 0 + for index := range len(line) { + switch line[index] { + case ' ': + columns++ + case '\t': + return true + default: + return columns >= 4 + } + } + + return false +} + +// markdownDefinitionMarker reports whether a line is a definition-list marker. +// A definition list makes the paragraph above it a term, so neither side of a +// boundary may end at one. +func markdownDefinitionMarker(line string) bool { + trimmed := strings.TrimLeft(line, " ") + if len(trimmed) < 2 || len(line)-len(trimmed) >= 4 { + return false + } + + return (trimmed[0] == ':' || trimmed[0] == '~') && (trimmed[1] == ' ' || trimmed[1] == '\t') +} + +// markdownFenceCloses reports whether a line closes the open fence. +func markdownFenceCloses(line, fence string) bool { + if markdownIndented(line) { + return false + } + + line = strings.TrimRight(strings.TrimLeft(line, " \t"), " \t") + + return len(line) >= len(fence) && strings.Trim(line, string(fence[0])) == "" +} + +// markdownThematicBreak reports whether a line is a horizontal rule. +func markdownThematicBreak(line string) bool { + line = strings.ReplaceAll(line, " ", "") + line = strings.ReplaceAll(line, "\t", "") + if len(line) < 3 { + return false + } + + for _, marker := range []byte{'-', '*', '_'} { + if strings.Trim(line, string(marker)) == "" { + return true + } + } + + return false +} + +// markdownSetextUnderline reports whether a line underlines the paragraph above. +func markdownSetextUnderline(line string) bool { + line = strings.TrimRight(line, " \t") + if line == "" { + return false + } + + return strings.Trim(line, "=") == "" || strings.Trim(line, "-") == "" +} + +// markdownListMarker reports whether a line opens a bullet list item. +func markdownListMarker(line string) bool { + if len(line) < 2 { + return false + } + + return (line[0] == '-' || line[0] == '*' || line[0] == '+') && (line[1] == ' ' || line[1] == '\t') +} + +// markdownOrderedListMarker reports whether a line opens an ordered list item. +func markdownOrderedListMarker(line string) bool { + digits := 0 + for digits < len(line) && line[digits] >= '0' && line[digits] <= '9' { + digits++ + } + if digits == 0 || digits > 9 || digits+1 >= len(line) { + return false + } + + return (line[digits] == '.' || line[digits] == ')') && (line[digits+1] == ' ' || line[digits+1] == '\t') +} + +// markdownHTMLBlockStart reports whether a line opens an HTML block. +func markdownHTMLBlockStart(line string) bool { + if len(line) < 2 { + return false + } + + rest := line[1] + if rest == '/' || rest == '!' || rest == '?' { + return true + } + + return (rest >= 'a' && rest <= 'z') || (rest >= 'A' && rest <= 'Z') +} diff --git a/internal/coding/tui/markdown_live_test.go b/internal/coding/tui/markdown_live_test.go new file mode 100644 index 00000000..3d33e5d7 --- /dev/null +++ b/internal/coding/tui/markdown_live_test.go @@ -0,0 +1,353 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// liveMarkdownCorpus holds the document shapes a streaming answer or reasoning +// section produces, plus the constructs whose rendering could depend on what +// follows a boundary. +func liveMarkdownCorpus() map[string]string { + return map[string]string{ + "paragraphs": "First paragraph of the answer.\n\nSecond paragraph of the answer.\n\nThird one.\n", + "single": "Only one paragraph, still streaming", + "atx": "# Heading\n\nBody paragraph under the heading.\n\nAnother paragraph.\n", + "setext": "Title\n=====\n\nBody.\n\nSub\n---\n\nMore.\n", + "tight list": "Intro paragraph.\n\n- item one\n- item two\n\nAfter the list.\n", + "loose list": "Intro.\n\n- item one\n\n- item two\n\nAfter.\n\nClosing paragraph.\n", + "nested": "Intro.\n\n- outer\n - inner\n\nAfter the nesting.\n\nTail.\n", + "ordered": "Steps:\n\n1. first\n2. second\n\nDone.\n\nMore prose.\n", + "fence": "Intro.\n\n```go\nfunc main() {}\n```\n\nAfter the code.\n\nTail.\n", + "fence info": "Intro.\n\n```go title=x\nx := 1\n```\n\nAfter.\n\nTail.\n", + "tilde": "Intro.\n\n~~~\nplain\n~~~\n\nAfter.\n\nTail.\n", + "indented": "Intro.\n\n indented code\n\nAfter.\n\nTail.\n", + "quote": "Intro.\n\n> quoted line\n\nAfter the quote.\n\nTail.\n", + "table": "Intro.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter the table.\n\nTail.\n", + "rule": "Intro.\n\n---\n\nAfter the rule.\n\nTail.\n", + "inline": "A **bold** start.\n\nA [link](https://example.com) and `code`.\n\nTail.\n", + "definition": "[target]: https://example.com\n\nSee [target] here.\n\nTail.\n", + "def later": "Intro.\n\n[target]: https://example.com\n\nSee [target].\n\nTail.\n", + // A usage can precede its definition, and a definition's label can span + // lines: goldmark resolves references over the whole document, so neither + // may end up on the opposite side of a frozen boundary from the other. + "forward ref": "Intro.\n\nUse [later] first.\n\n[later]: /url\n\nTail.\n", + "multi label": "A.\n\n[x\ny]: https://e.com \"t\"\n\nB.\n\nC uses [x y] here.\n", + "code bracket": "Intro.\n\n```go\narr[0] = 1\n```\n\nAfter.\n\nTail.\n", + "shortcut refs": "Intro paragraph.\n\nSee [one] and [two] and [three].\n\n[one]: /a\n\n[two]: /b\n\nTail.\n", + "html": "Intro.\n\n
block
\n\nAfter.\n\nTail.\n", + "cjk": "第一段说明文字,包含中文与标点。\n\n第二段继续说明,保持宽度测量。\n\n第三段结束。\n", + "emoji": "Intro with 🎯 emoji.\n\nSecond 🚀 paragraph.\n\nTail.\n", + "hard break": "Line one \nline two\n\nSecond paragraph.\n\nTail.\n", + "heading tail": "Intro.\n\n## Section\n\nBody.\n\n### Sub\n\nMore body.\n\nTail.\n", + "blank lines": "Intro.\n\n\n\nAfter extra blanks.\n\nTail.\n", + "form feed": "Intro.\n\n\f\nAfter a form feed line.\n\nTail.\n", + "vertical tab": "Intro.\n\n\v\nAfter a vertical tab.\n\nTail.\n", + "crlf": "Intro.\r\n\r\nSecond paragraph.\r\n\r\nTail.\r\n", + "list then prose": "Intro.\n\n- a\n- b\n\nProse paragraph after the list.\n\nTail paragraph.\n", + } +} + +// TestLiveMarkdownMatchesWholeRender streams every corpus document one byte at a +// time through the live renderer and asserts the output is byte-identical to a +// whole-document render at the same content. +func TestLiveMarkdownMatchesWholeRender(t *testing.T) { + t.Parallel() + + for name, document := range liveMarkdownCorpus() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + for _, width := range []int{24, 40, 118} { + // One renderer across the whole stream, so the frozen prefix and + // its cache are exercised the way a frame loop uses them. + streaming := newMarkdownRenderer(128) + + for size := 0; size <= len(document); size++ { + content := document[:size] + got, err := streaming.renderLive("draft", content, width, themeDark, false) + require.NoError(t, err) + + want, err := newMarkdownRenderer(1).render(content, width, themeDark, false) + require.NoError(t, err) + + if got != want { + t.Fatalf("width %d size %d:\n got %q\n want %q", width, size, got, want) + } + } + } + }) + } +} + +// TestLiveMarkdownMatchesWholeRenderWithColor repeats the streaming equivalence +// with styling on, because colour changes the padding glamour emits. +func TestLiveMarkdownMatchesWholeRenderWithColor(t *testing.T) { + t.Parallel() + + document := strings.Join([]string{ + "First paragraph with **bold** text.", + "Second paragraph with `code` and a [link](https://example.com).", + "第三段包含中文。", + "Fourth paragraph, still growing", + }, "\n\n") + + streaming := newMarkdownRenderer(128) + + for size := 0; size <= len(document); size++ { + content := document[:size] + got, err := streaming.renderLive("draft", content, 60, themeDark, true) + require.NoError(t, err) + + want, err := newMarkdownRenderer(1).render(content, 60, themeDark, true) + require.NoError(t, err) + + require.Equal(t, want, got, "size %d", size) + } +} + +// TestLiveMarkdownFreezesLongPrefixes asserts the boundary scanner actually +// finds a freeze point, so the equivalence test above is not passing by always +// falling back to a whole render. +func TestLiveMarkdownFreezesLongPrefixes(t *testing.T) { + t.Parallel() + + paragraphs := make([]string, 0, 40) + for index := range 40 { + paragraphs = append(paragraphs, fmt.Sprintf("Paragraph %d of a long answer that keeps streaming.", index)) + } + + document := strings.Join(paragraphs, "\n\n") + "\n\nstill growing" + + prefixEnd, block, ok := liveMarkdownBoundary(document) + require.True(t, ok, "a paragraph-only body must expose a freeze boundary") + assert.Greater(t, prefixEnd, len(document)/2, "the boundary must freeze most of the body") + assert.Equal(t, "Paragraph 39 of a long answer that keeps streaming.", block) + + renderer := newMarkdownRenderer(128) + _, err := renderer.renderLive("draft", document, 80, themeDark, false) + require.NoError(t, err) + + frozen, exists := renderer.frozen["draft"] + require.True(t, exists, "the live slot must hold its frozen prefix") + assert.Equal(t, document[:prefixEnd], frozen.source) + assert.NotEmpty(t, frozen.rendered) + assert.Equal(t, block, frozen.block) +} + +// TestLiveMarkdownFrameExtendsTheFrozenPrefix pins the bound: a frame renders the +// newly frozen region and the live tail, never the whole body again. Rendering +// the body per frame would make the total grow with the square of its length. +func TestLiveMarkdownFrameExtendsTheFrozenPrefix(t *testing.T) { + t.Parallel() + + const frames = 200 + + renderer := newMarkdownRenderer(128) + renderer.renderedBytes = 0 + + var body strings.Builder + for index := range frames { + fmt.Fprintf(&body, "Paragraph %d of a long answer that keeps streaming.\n\n", index) + _, err := renderer.renderLive("draft", body.String(), 80, themeDark, false) + require.NoError(t, err) + } + + // Each frame freezes one more paragraph, so a whole-prefix render would total + // roughly frames^2/2 paragraph lengths; extending renders a constant amount. + assert.LessOrEqual(t, renderer.renderedBytes, 20*body.Len(), + "the engine must not be handed the whole body on every frame") +} + +// TestLiveMarkdownBoundaryRejectsUnsafeBlocks pins the freeze rule: a block +// whose rendering can still change when more text arrives must never end a +// frozen prefix. The scanner may still freeze an earlier safe block, which is +// what keeps the render exact. +func TestLiveMarkdownBoundaryRejectsUnsafeBlocks(t *testing.T) { + t.Parallel() + + for name, testCase := range map[string]struct{ document, unsafe string }{ + "heading": {"Intro.\n\n## Section\n\nmore", "## Section"}, + "fenced code": {"Intro.\n\n```\ncode\n```\n\nmore", "```\ncode\n```"}, + "indented code": {"Intro.\n\n code\n\nmore", " code"}, + "open fence": {"Intro.\n\n```\ncode\n\nmore", "```\ncode"}, + "bullet list": {"Intro.\n\n- item\n\nmore", "- item"}, + "ordered list": {"Intro.\n\n1. item\n\nmore", "1. item"}, + "block quote": {"Intro.\n\n> quote\n\nmore", "> quote"}, + "table": {"Intro.\n\n| a | b |\n| - | - |\n\nmore", "| a | b |"}, + "thematic break": {"Intro.\n\n***\n\nmore", "***"}, + "setext underline": {"Intro.\n\nBody\n====\n\nmore", "Body\n===="}, + "html block": {"Intro.\n\n
\n\nmore", "
"}, + "definition": {"Intro.\n\n[t]: https://x\n\nSee [t].\n\nmore", "[t]: https://x"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + prefixEnd, block, ok := liveMarkdownBoundary(testCase.document) + if ok { + assert.True(t, freezableMarkdownBlock(block), "a frozen block must be freezable") + assert.NotEqual(t, testCase.unsafe, block, "an unsafe block must never end a frozen prefix") + assert.Less(t, prefixEnd, len(testCase.document)) + } + + // Whatever the scanner decides, the render must stay exact. + renderer := newMarkdownRenderer(128) + got, err := renderer.renderLive("draft", testCase.document, 40, themeDark, false) + require.NoError(t, err) + want, err := newMarkdownRenderer(1).render(testCase.document, 40, themeDark, false) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + } +} + +// TestLiveMarkdownFallsBackWhenNoBoundaryExists covers the single-paragraph +// case, where no boundary can be frozen. +func TestLiveMarkdownFallsBackWhenNoBoundaryExists(t *testing.T) { + t.Parallel() + + _, _, ok := liveMarkdownBoundary("one long paragraph with no blank line at all") + assert.False(t, ok) +} + +// liveMarkdownReference renders content whole, the way a settled record does. +func liveMarkdownReference(t *testing.T, content string, width int, noColor bool) string { + t.Helper() + + rendered, err := newMarkdownRenderer(1).render(content, width, themeDark, noColor) + require.NoError(t, err) + + return rendered +} + +// FuzzLiveMarkdownMatchesWholeRender drives the live renderer with arbitrary +// content and compares it with a whole-document render. +// +// Two comparisons are made. The colour-free render must match byte for byte. +// The coloured render is compared with its escape sequences removed, because +// the pinned fork resolves a fence's unrecognised language through chroma's +// lexer registry, whose analyser tie-break follows Go's map order; that picks a +// different colour index between two calls and is glamour's behaviour, not this +// renderer's (it reproduces on an unmodified checkout). +func FuzzLiveMarkdownMatchesWholeRender(f *testing.F) { + for _, seed := range liveMarkdownCorpus() { + f.Add(seed) + } + + f.Add("```\nunclosed\n\n# heading\n\ntext") + f.Add("a\n\n> q\n\n- l\n\n1. o\n\n| t |\n\n---\n\ntext") + + f.Fuzz(func(t *testing.T, content string) { + for _, width := range []int{20, 72} { + got, err := newMarkdownRenderer(128).renderLive("draft", content, width, themeDark, true) + require.NoError(t, err) + require.Equal(t, liveMarkdownReference(t, content, width, true), got, + "no-colour width %d content %q", width, content) + + colored, err := newMarkdownRenderer(128).renderLive("draft", content, width, themeDark, false) + require.NoError(t, err) + require.Equal(t, + ansi.Strip(liveMarkdownReference(t, content, width, false)), + ansi.Strip(colored), + "colour width %d content %q", width, content) + } + }) +} + +// TestLiveMarkdownFrozenPrefixResetsWhenTheBodyIsReplaced pins the invalidation: +// a slot's frozen rows describe one body, and a body that is replaced, rewound +// or re-rendered at another geometry must not reuse them. +func TestLiveMarkdownFrozenPrefixResetsWhenTheBodyIsReplaced(t *testing.T) { + t.Parallel() + + first := "Alpha paragraph one.\n\nAlpha paragraph two.\n\nAlpha paragraph three.\n\n" + replaced := "Beta paragraph one.\n\nBeta paragraph two.\n\nBeta paragraph three.\n\n" + rewound := "Alpha paragraph one.\n\nAlpha paragraph two.\n" + + for name, sequence := range map[string][]string{ + "replaced": {first, replaced, replaced + "Beta paragraph four.\n\n"}, + "rewound": {first, rewound, first}, + "new message": {first, "A fresh answer with no relation to the previous one.\n\nSecond line of it.\n\n"}, + "same content": {first, first, first + "More text after the first body.\n\n"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + renderer := newMarkdownRenderer(128) + for _, content := range sequence { + got, err := renderer.renderLive("draft", content, 60, themeDark, false) + require.NoError(t, err) + assert.Equal(t, liveMarkdownReference(t, content, 60, false), got, + "content %q", content) + } + }) + } +} + +// TestLiveMarkdownFrozenPrefixFollowsGeometryChanges pins that a width, theme or +// colour-mode change discards the frozen rows rather than extending them. +func TestLiveMarkdownFrozenPrefixFollowsGeometryChanges(t *testing.T) { + t.Parallel() + + renderer := newMarkdownRenderer(128) + body := "First paragraph of the answer.\n\nSecond paragraph of the answer.\n\n" + + for _, step := range []struct { + width int + theme colorTheme + noColor bool + }{ + {60, themeDark, false}, + {60, themeDark, true}, + {40, themeDark, false}, + {40, themeLight, false}, + {60, themeDark, false}, + } { + got, err := renderer.renderLive("draft", body, step.width, step.theme, step.noColor) + require.NoError(t, err) + + want, err := newMarkdownRenderer(1).render(body, step.width, step.theme, step.noColor) + require.NoError(t, err) + assert.Equal(t, want, got, "width %d noColor %v", step.width, step.noColor) + } +} + +// TestLiveMarkdownBoundaryStopsAtALinkReference pins the freeze rule for link +// references: goldmark resolves them over the whole document, so a bracket on +// either side of a boundary can change the other side's rows. A usage before its +// definition, a definition whose label spans lines, and a definition that a later +// usage relies on all have to stop the freeze. +func TestLiveMarkdownBoundaryStopsAtALinkReference(t *testing.T) { + t.Parallel() + + for name, testCase := range map[string]struct{ document, wantBlock string }{ + "forward reference": {"Intro.\n\nUse [later] first.\n\n[later]: /url\n\nTail.\n", "Intro."}, + "multiline label": {"A.\n\n[x\ny]: https://e.com \"t\"\n\nB.\n\nC uses [x y] here.\n", "A."}, + "definition first": {"Intro.\n\n[t]: https://x\n\nSee [t].\n\nmore", "Intro."}, + "usage after first": {"Intro paragraph.\n\nSee [one] here.\n\n[one]: /a\n\nTail.\n", "Intro paragraph."}, + "no definition": {"Intro.\n\nA [label] with no definition anywhere.\n\nTail.\n", "Intro."}, + "bracket in a fence": {"Intro.\n\n```go\narr[0] = 1\n```\n\nAfter.\n", "Intro."}, + "plain prose": {"First paragraph.\n\nSecond paragraph.\n\nstill growing", "Second paragraph."}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + prefixEnd, block, ok := liveMarkdownBoundary(testCase.document) + require.True(t, ok, "the document must expose a freeze boundary") + assert.Equal(t, testCase.wantBlock, block) + assert.LessOrEqual(t, prefixEnd, len(testCase.document)) + + // Whatever the scanner decides, the assembled render must equal the + // whole render, which is the property the freeze rule exists for. + renderer := newMarkdownRenderer(128) + got, err := renderer.renderLive("draft", testCase.document, 60, themeDark, false) + require.NoError(t, err) + assert.Equal(t, liveMarkdownReference(t, testCase.document, 60, false), got) + }) + } +} diff --git a/internal/coding/tui/markdown_test.go b/internal/coding/tui/markdown_test.go index 37350d4f..15e420b2 100644 --- a/internal/coding/tui/markdown_test.go +++ b/internal/coding/tui/markdown_test.go @@ -159,12 +159,15 @@ func TestMarkdownCacheBoundsRenderedBytes(t *testing.T) { require.NoError(t, err) assert.LessOrEqual(t, renderer.bytes, renderer.maxBytes) } - before := renderer.bytes source := strings.Repeat("large document\n\n", 100) got, err := renderer.renderLive("draft", source, 40, themeDark, false) require.NoError(t, err) assert.Contains(t, got, "large") - assert.Equal(t, before, renderer.bytes, "oversized outputs render without entering the cache") + // The live render itself is oversized, so it never becomes a cache entry. The + // frozen prefix it is assembled from may enter the settled cache, but the byte + // budget still bounds what the renderer holds. + assert.LessOrEqual(t, renderer.bytes, renderer.maxBytes, + "oversized outputs render without pushing the cache past its budget") assert.NotContains(t, renderer.live, "draft") } diff --git a/internal/coding/tui/model.go b/internal/coding/tui/model.go index 22eaddf7..7db6e1a9 100644 --- a/internal/coding/tui/model.go +++ b/internal/coding/tui/model.go @@ -2113,7 +2113,7 @@ func (m *Model) finishTranscriptFrame(forceBottom bool) { // Matches are row indexes, so a reflow, a prepended page or new output has to // re-resolve them; the reader's match is kept by identity. The store // fingerprints everything a scan reads, so an unchanged frame is skipped. - if m.search.active && m.search.scanned != m.transcript.revision { + if m.search.active && m.search.scanned != m.transcript.fingerprint() { m.refreshSearch() } } diff --git a/internal/coding/tui/search.go b/internal/coding/tui/search.go index 1bc53a1e..5835e3b9 100644 --- a/internal/coding/tui/search.go +++ b/internal/coding/tui/search.go @@ -122,7 +122,7 @@ func (m *Model) refreshSearch() { } m.search.matches = m.transcript.searchRows(m.searchQuery()) - m.search.scanned = m.transcript.revision + m.search.scanned = m.transcript.fingerprint() if len(m.search.matches) == 0 { m.search.index = -1 diff --git a/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/0d048d320e70c702 b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/0d048d320e70c702 new file mode 100644 index 00000000..c0221bf9 --- /dev/null +++ b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/0d048d320e70c702 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("```b \n1") diff --git a/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/1d1bad63e72e4cfe b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/1d1bad63e72e4cfe new file mode 100644 index 00000000..3ce2befb --- /dev/null +++ b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/1d1bad63e72e4cfe @@ -0,0 +1,2 @@ +go test fuzz v1 +string("00000000\xe4\n\n0") diff --git a/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/344a4b0ac07ecb03 b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/344a4b0ac07ecb03 new file mode 100644 index 00000000..45f677b5 --- /dev/null +++ b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/344a4b0ac07ecb03 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0\n\f\n0") diff --git a/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/76fa1e532bb83f0e b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/76fa1e532bb83f0e new file mode 100644 index 00000000..5b6aa14e --- /dev/null +++ b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/76fa1e532bb83f0e @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0\n```\n\n```\n\n0") diff --git a/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/f298eb7e080ddd37 b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/f298eb7e080ddd37 new file mode 100644 index 00000000..b5ba1952 --- /dev/null +++ b/internal/coding/tui/testdata/fuzz/FuzzLiveMarkdownMatchesWholeRender/f298eb7e080ddd37 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("00000\n\n: ") diff --git a/internal/coding/tui/thinking_live.go b/internal/coding/tui/thinking_live.go new file mode 100644 index 00000000..066eb5be --- /dev/null +++ b/internal/coding/tui/thinking_live.go @@ -0,0 +1,220 @@ +//nolint:wsl_v5 // The frozen rows and their alignment padding are written together. +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// A live Thinking block grows with every streamed delta, and re-wrapping, +// re-indenting and re-styling the whole thought on each frame is O(body) per +// frame and O(body^2) over one message. The renderer instead freezes the rows up +// to the last line break and renders only what follows it. +// +// Two properties make the split exact, and both are pinned by tests: +// +// - ansi.Wrap(body) == ansi.Wrap(body[:k]) + ansi.Wrap(body[k:]) when body[:k] +// ends on a line break, because the wrapper resets its line state there; +// - lipgloss pads every line of a rendered block to the widest line, so +// Style.Render(block) == join(Style.Render(line) + padding(line)). +// +// A thought without a line break has nowhere to freeze, so it still renders +// whole; the section is plain prose, so that only happens while the model has +// not finished its first line. +type liveThinking struct { + // source is the sanitized body prefix the frozen rows cover. It always ends + // on a line break, and it shares the caller's backing array. + source string + // rows are the frozen rows with the section's glyph or indent applied, + // styled their styled form, and widths each row's display width. + rows []string + styled []string + widths []int + // geometry: rows rendered at another wrap width, theme or colour mode cannot + // be extended. + limit int + theme themeFingerprint + noColor bool +} + +// thinkingRows wraps one body region and applies the section's leading glyph or +// indent. dropTrailing removes the empty row a trailing line break leaves, which +// the next frozen region continues. +func thinkingRows(region, lead, indent string, limit int, dropTrailing bool) []string { + rows := strings.Split(ansi.Wrap(region, limit, ""), "\n") + if dropTrailing && len(rows) > 0 && rows[len(rows)-1] == "" { + rows = rows[:len(rows)-1] + } + + for index := range rows { + if strings.TrimSpace(rows[index]) == "" { + // A blank separator row carries neither the glyph nor the indent. + rows[index] = "" + + continue + } + + if index == 0 { + rows[index] = lead + rows[index] + + continue + } + + rows[index] = indent + rows[index] + } + + return rows +} + +// renderLiveThinking renders one frame of the live Thinking block, extending the +// frozen rows rather than wrapping and styling the whole thought again. body is +// already sanitized. +func (r *markdownRenderer) renderLiveThinking( + body, glyph, indent string, + limit int, + theme colorTheme, + noColor bool, +) string { + fingerprint := themeFingerprint(theme.fingerprint) + + frozen := &r.thinking + if frozen.limit != limit || frozen.theme != fingerprint || frozen.noColor != noColor { + *frozen = liveThinking{limit: limit, theme: fingerprint, noColor: noColor} + } + + covered := len(frozen.source) + if covered > len(body) || !strings.HasPrefix(body, frozen.source) { + // The body was replaced or rewound: the frozen rows no longer describe it. + *frozen = liveThinking{limit: limit, theme: fingerprint, noColor: noColor} + covered = 0 + } + + // Only whole lines are frozen: a line break resets the wrapper's state, so + // everything before the last one wraps the same in or out of the block. + if checkpoint := strings.LastIndex(body, "\n") + 1; checkpoint > covered { + lead := indent + if covered == 0 { + lead = glyph + } + + region := body[covered:checkpoint] + r.thinkingWrapped += len(region) + frozen.freeze(thinkingRows(region, lead, indent, limit, true), theme, noColor) + frozen.source = body[:checkpoint] + covered = checkpoint + } + + // A thought with no line break yet has nowhere to freeze, so the whole body + // is the tail and it still leads with the glyph. + lead := indent + if covered == 0 { + lead = glyph + } + + tail := body[covered:] + r.thinkingWrapped += len(tail) + + return frozen.compose(thinkingRows(tail, lead, indent, limit, false), theme, noColor) +} + +// freeze appends one more region's rows to the frozen prefix. +func (f *liveThinking) freeze(rows []string, theme colorTheme, noColor bool) { + if len(rows) == 0 { + return + } + + if noColor { + f.rows = append(f.rows, rows...) + + return + } + + style := lipgloss.NewStyle().Foreground(paletteFor(theme).muted) + for _, row := range rows { + f.rows = append(f.rows, row) + f.styled = append(f.styled, style.Render(row)) + f.widths = append(f.widths, ansi.StringWidth(row)) + } +} + +// compose writes the frozen rows and the tail as one aligned block. The tail is +// never cached: it is the part still growing. +func (f *liveThinking) compose(tail []string, theme colorTheme, noColor bool) string { + if noColor { + rows := make([]string, 0, len(f.rows)+len(tail)) + rows = append(rows, f.rows...) + rows = append(rows, tail...) + + return strings.Join(rows, "\n") + } + + style := lipgloss.NewStyle().Foreground(paletteFor(theme).muted) + + styledTail := make([]string, len(tail)) + widthsTail := make([]int, len(tail)) + width := 0 + for _, rowWidth := range f.widths { + width = max(width, rowWidth) + } + + for index, row := range tail { + styledTail[index] = style.Render(row) + widthsTail[index] = ansi.StringWidth(row) + width = max(width, widthsTail[index]) + } + + // A block is aligned against its widest row, so one wide row in the tail + // widens the padding of the frozen rows too. The width is recomputed rather + // than remembered, because a tail row can be wider than every frozen row. + // The block is built into one exactly sized buffer, so a frame allocates the + // joined rows once. + frozenSize := thinkingBlockSize(f.styled, f.widths, width) + tailSize := thinkingBlockSize(styledTail, widthsTail, width) + + var builder strings.Builder + builder.Grow(frozenSize + tailSize + 1) + writeThinkingRows(&builder, f.styled, f.widths, width, false) + writeThinkingRows(&builder, styledTail, widthsTail, width, len(f.styled) > 0) + + return builder.String() +} + +// thinkingBlockSize reports the bytes writeThinkingRows writes for these rows, +// so compose can size its buffer exactly. +func thinkingBlockSize(styled []string, widths []int, width int) int { + size := 0 + for index, row := range styled { + if index > 0 { + size++ + } + + size += len(row) + if pad := width - widths[index]; pad > 0 { + size += pad + } + } + + return size +} + +// thinkingPadding serves alignment spaces without allocating one string per row. +const thinkingPadding = " " + +// writeThinkingRows writes styled rows with the padding that brings each to +// width, separating them with line breaks. separator prepends a break when these +// rows continue a block that already wrote some. +func writeThinkingRows(builder *strings.Builder, styled []string, widths []int, width int, separator bool) { + for index, row := range styled { + if index > 0 || separator { + builder.WriteByte('\n') + } + + builder.WriteString(row) + + for pad := width - widths[index]; pad > 0; pad -= len(thinkingPadding) { + builder.WriteString(thinkingPadding[:min(pad, len(thinkingPadding))]) + } + } +} diff --git a/internal/coding/tui/thinking_live_test.go b/internal/coding/tui/thinking_live_test.go new file mode 100644 index 00000000..b454a00a --- /dev/null +++ b/internal/coding/tui/thinking_live_test.go @@ -0,0 +1,206 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// liveThinkingBlock returns the live Thinking block a streaming reasoning delta +// projects into. +func liveThinkingBlock(body string) timelineBlock { + return timelineBlock{kind: blockThinking, id: draftThinkingID, body: body} +} + +// settledThinkingBlock returns the same section once it has stopped growing. +func settledThinkingBlock(body string) timelineBlock { + return timelineBlock{kind: blockThinking, id: "thinking:1:0", body: body} +} + +// liveThinkingCorpus holds the reasoning shapes a streamed thought produces. +func liveThinkingCorpus() map[string]string { + plain := strings.Repeat("Weighing the options for the next step, sentence after sentence. ", 10) + + return map[string]string{ + "single line": "One unbroken thought with no line break at all", + "paragraphs": "First reasoning paragraph.\n\nSecond reasoning paragraph.\n\nThird one.\n", + "trailing break": plain + "\n\n", + "crlf": "First thought.\r\n\r\nSecond thought.\r\n", + "blank runs": "First.\n\n\n\nSecond.\n\nTail.\n", + "indented": "First line.\n an indented continuation line\n\nNext.\n", + "tabs": "First\twith a tab inside.\n\nSecond.\n", + "cjk": strings.Repeat("正在分析状态更新与界面渲染。\n\n", 8), + "emoji": "Analysing 🎯 the target.\n\nChecking 🚀 the launch.\n\nDone.\n", + "long word": strings.Repeat("x", 200) + "\n\n" + strings.Repeat("y", 120) + "\n", + "many lines": strings.Repeat("A short reasoning line.\n", 40), + "trailing space": "First line with a trailing space \n\nSecond.\n", + "no break yet": plain, + } +} + +// TestLiveThinkingMatchesSettledSection streams every corpus body one byte at a +// time through the live path and asserts the output is byte-identical to the +// settled section rendered whole at the same content. +func TestLiveThinkingMatchesSettledSection(t *testing.T) { + t.Parallel() + + for name, body := range liveThinkingCorpus() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + for _, width := range []int{20, 60, 118} { + for _, noColor := range []bool{false, true} { + // One renderer across the stream, as the frame loop uses it. + renderer := newMarkdownRenderer(128) + + for size := 0; size <= len(body); size++ { + content := body[:size] + got := renderThinkingBlock(liveThinkingBlock(content), renderer, width, themeDark, noColor) + want := renderThinkingBlock(settledThinkingBlock(content), nil, width, themeDark, noColor) + + if got != want { + t.Fatalf("width %d noColor %v size %d:\n got %q\n want %q", + width, noColor, size, got, want) + } + } + } + } + }) + } +} + +// TestLiveThinkingMatchesSettledSectionAcrossFrames covers the frame loop rather +// than a byte-at-a-time stream: several deltas arrive, one frame renders. +func TestLiveThinkingMatchesSettledSectionAcrossFrames(t *testing.T) { + t.Parallel() + + body := strings.Join([]string{ + "First reasoning paragraph with ordinary words.", + "", + "Second reasoning paragraph, still growing", + }, "\n\n") + + renderer := newMarkdownRenderer(128) + + for frame := 1; frame <= len(body); frame++ { + content := body[:frame] + got := renderThinkingBlock(liveThinkingBlock(content), renderer, 60, themeDark, false) + want := renderThinkingBlock(settledThinkingBlock(content), nil, 60, themeDark, false) + require.Equal(t, want, got, "frame %d content %q", frame, content) + } +} + +// TestLiveThinkingFreezesWholeLines pins the two properties the split rests on, +// so the equivalence test above cannot pass by never freezing. +func TestLiveThinkingFreezesWholeLines(t *testing.T) { + t.Parallel() + + body := strings.Repeat("A reasoning paragraph that keeps streaming words.\n\n", 40) + + renderer := newMarkdownRenderer(128) + renderThinkingBlock(liveThinkingBlock(body), renderer, 80, themeDark, false) + + frozen := renderer.thinking + require.NotEmpty(t, frozen.source, "a body with line breaks must expose a frozen prefix") + assert.True(t, strings.HasSuffix(frozen.source, "\n"), "a frozen prefix ends on a line break") + assert.Greater(t, len(frozen.source), len(body)/2, "the frozen prefix must cover most of the body") + assert.NotEmpty(t, frozen.rows) + assert.Len(t, frozen.rows, len(frozen.styled)) + assert.Len(t, frozen.rows, len(frozen.widths)) +} + +// TestLiveThinkingFrameExtendsTheFrozenRows pins the bound: a frame wraps the +// newly streamed region and the live tail, never the whole thought again. +// Wrapping the body per frame would make the total grow with its square. +func TestLiveThinkingFrameExtendsTheFrozenRows(t *testing.T) { + t.Parallel() + + const frames = 200 + + renderer := newMarkdownRenderer(128) + + var body strings.Builder + + wrapped := 0 + + for index := range frames { + fmt.Fprintf(&body, "Reasoning paragraph %d that keeps streaming words.\n\n", index) + + renderer.thinkingWrapped = 0 + renderThinkingBlock(liveThinkingBlock(body.String()), renderer, 80, themeDark, false) + wrapped += renderer.thinkingWrapped + } + + assert.LessOrEqual(t, wrapped, 8*body.Len(), + "the wrapper must not be handed the whole thought on every frame") +} + +// TestLiveThinkingDropsFrozenRowsWhenTheBodyIsReplaced pins the invalidation: a +// replaced, rewound or re-geometried body must not reuse the frozen rows. +func TestLiveThinkingDropsFrozenRowsWhenTheBodyIsReplaced(t *testing.T) { + t.Parallel() + + first := "Alpha thought one.\n\nAlpha thought two.\n\n" + replaced := "Beta thought one.\n\nBeta thought two.\n\n" + rewound := "Alpha thought one.\n" + + for name, sequence := range map[string][]string{ + "replaced": {first, replaced, replaced + "Beta thought three.\n\n"}, + "rewound": {first, rewound, first}, + "geometry": {first, first}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + renderer := newMarkdownRenderer(128) + + for index, content := range sequence { + width := 60 + if name == "geometry" && index > 0 { + width = 40 + } + + got := renderThinkingBlock(liveThinkingBlock(content), renderer, width, themeDark, false) + want := renderThinkingBlock(settledThinkingBlock(content), nil, width, themeDark, false) + assert.Equal(t, want, got, "content %q width %d", content, width) + } + }) + } +} + +// TestLiveThinkingSettledSectionsAreNotCached pins that only the live section +// keeps frozen rows: a settled section is rendered once and never extended. +func TestLiveThinkingSettledSectionsAreNotCached(t *testing.T) { + t.Parallel() + + renderer := newMarkdownRenderer(128) + renderThinkingBlock(settledThinkingBlock("Settled thought one.\n\nSettled thought two.\n"), renderer, 60, themeDark, false) + + assert.Empty(t, renderer.thinking.source, "a settled section must not populate the live cache") +} + +// FuzzLiveThinkingMatchesSettledSection drives the live section with arbitrary +// reasoning text and compares it with the settled section's whole render. +func FuzzLiveThinkingMatchesSettledSection(f *testing.F) { + for _, seed := range liveThinkingCorpus() { + f.Add(seed) + } + + f.Add("a\n\nb") + f.Add("x") + f.Add("\n\n\n") + + f.Fuzz(func(t *testing.T, body string) { + for _, width := range []int{20, 72} { + for _, noColor := range []bool{false, true} { + got := renderThinkingBlock(liveThinkingBlock(body), newMarkdownRenderer(128), width, themeDark, noColor) + want := renderThinkingBlock(settledThinkingBlock(body), nil, width, themeDark, noColor) + + require.Equal(t, want, got, "width %d noColor %v body %q", width, noColor, body) + } + } + }) +} diff --git a/internal/coding/tui/thinking_test.go b/internal/coding/tui/thinking_test.go index ba03599c..5eff4f6f 100644 --- a/internal/coding/tui/thinking_test.go +++ b/internal/coding/tui/thinking_test.go @@ -82,7 +82,7 @@ func TestThinkingBlockRendersTheWholeBody(t *testing.T) { body := "step one\n\nstep two\n\nstep three\n\nstep four\n\n" + tail block := timelineBlock{kind: blockThinking, id: thinkingBlockID(2, 0), body: body} - got := renderThinkingBlock(block, 60, themeDark, true) + got := renderThinkingBlock(block, nil, 60, themeDark, true) rows := strings.Split(got, "\n") require.NotEmpty(t, rows) @@ -103,7 +103,7 @@ func TestThinkingBlockUsesTheThemeMutedColor(t *testing.T) { block := timelineBlock{kind: blockThinking, id: draftThinkingID, body: body} for _, theme := range []colorTheme{themeDark, themeLight} { - got := renderThinkingBlock(block, 60, theme, false) + got := renderThinkingBlock(block, nil, 60, theme, false) muted := lipgloss.NewStyle().Foreground(paletteFor(theme).muted) assert.Equal(t, muted.Render(thinkingGlyph+" "+body), got, @@ -115,7 +115,7 @@ func TestThinkingBlockWrapsToTheRenderWidth(t *testing.T) { t.Parallel() body := strings.TrimSpace(strings.Repeat("reasoning word ", 20)) - got := renderThinkingBlock(timelineBlock{kind: blockThinking, id: draftThinkingID, body: body}, 20, themeDark, true) + got := renderThinkingBlock(timelineBlock{kind: blockThinking, id: draftThinkingID, body: body}, nil, 20, themeDark, true) rows := strings.Split(got, "\n") require.Greater(t, len(rows), 2, "the long reasoning wraps onto several rows") @@ -128,10 +128,10 @@ func TestThinkingBlockDropsEmptyAndUnsafeText(t *testing.T) { t.Parallel() block := timelineBlock{kind: blockThinking, id: draftThinkingID, body: " \n "} - assert.Empty(t, renderThinkingBlock(block, 60, themeDark, true)) + assert.Empty(t, renderThinkingBlock(block, nil, 60, themeDark, true)) block.body = "before\x1b[31mafter" - got := renderThinkingBlock(block, 60, themeDark, false) + got := renderThinkingBlock(block, nil, 60, themeDark, false) assert.NotContains(t, got, "\x1b[31mafter", "model text cannot inject its own styling") assert.Contains(t, ansi.Strip(got), "beforeafter") } diff --git a/internal/coding/tui/timeline.go b/internal/coding/tui/timeline.go index ec97dc90..92a70b91 100644 --- a/internal/coding/tui/timeline.go +++ b/internal/coding/tui/timeline.go @@ -1383,7 +1383,7 @@ func renderTimelineBlockWithOptions( return renderWorkspaceChangeBlock(block, width, theme, noColor) } if block.kind == blockThinking { - return renderThinkingBlock(block, width, theme, noColor) + return renderThinkingBlock(block, markdown, width, theme, noColor) } return renderRegularTimelineBlock(block, markdown, width, theme, noColor) @@ -1393,14 +1393,18 @@ func renderTimelineBlockWithOptions( // prose led by the section glyph. The opaque Signature never reaches this // function; only ReasoningPart.Text does. // -// Reasoning is deliberately not run through the Markdown renderer. A live -// section is re-rendered on every streamed delta, and parsing it as Markdown -// allocates tens of thousands of times per render, so a long thought turns each -// frame into a CPU spike. Wrapping the same text costs a few hundred -// microseconds and a handful of allocations, and the reader sees it in one -// uniform color either way. +// Reasoning is deliberately not run through the Markdown renderer. A live section +// is re-rendered on every streamed delta, and parsing it as Markdown allocates +// tens of thousands of times per render, so a long thought turns each frame into +// a CPU spike. Wrapping the same text costs a few hundred microseconds and a +// handful of allocations, and the reader sees it in one uniform color either way. +// +// A live section still grows, so it is assembled from the rows frozen at its last +// line break instead of re-wrapping and re-styling the whole thought; see +// [thinking_live.go]. A settled section never changes and is rendered once. func renderThinkingBlock( block timelineBlock, + markdown *markdownRenderer, width int, theme colorTheme, noColor bool, @@ -1414,30 +1418,27 @@ func renderThinkingBlock( prefix := thinkingGlyph + " " prefixWidth := ansi.StringWidth(prefix) - var content string if width <= prefixWidth { // A frame this narrow has no room beside the glyph; keep the prose. - content = ansi.Wrap(body, width, "") - } else { - indent := strings.Repeat(" ", prefixWidth) - rows := strings.Split(ansi.Wrap(body, width-prefixWidth, ""), "\n") - for index := range rows { - if strings.TrimSpace(rows[index]) == "" { - // A blank separator row keeps no trailing indent. - rows[index] = "" - - continue - } - if index == 0 { - rows[index] = prefix + rows[index] + content := ansi.Wrap(body, width, "") - continue - } - rows[index] = indent + rows[index] - } - content = strings.Join(rows, "\n") + return styleThinkingRows(content, theme, noColor) + } + + indent := strings.Repeat(" ", prefixWidth) + limit := width - prefixWidth + if markdown != nil && blockIsLive(block) { + return markdown.renderLiveThinking(body, prefix, indent, limit, theme, noColor) } + rows := thinkingRows(body, prefix, indent, limit, false) + + return styleThinkingRows(strings.Join(rows, "\n"), theme, noColor) +} + +// styleThinkingRows applies the section's dim colour and the alignment lipgloss +// gives a rendered block. +func styleThinkingRows(content string, theme colorTheme, noColor bool) string { if noColor { return content } diff --git a/internal/coding/tui/tool_activity.go b/internal/coding/tui/tool_activity.go index a35920f1..3f8d49c3 100644 --- a/internal/coding/tui/tool_activity.go +++ b/internal/coding/tui/tool_activity.go @@ -13,6 +13,7 @@ import ( "sort" "strings" "unicode" + "unicode/utf8" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -736,6 +737,13 @@ func patchResultLines(value string, maximum int) []string { } func sanitizeToolText(value string) string { + if cleanToolText(value) { + // A body that carries no control sequence and no control character is + // already what the pass below would build, so a streaming frame does not + // copy the whole thought again. + return value + } + value = ansi.Strip(value) value = strings.ReplaceAll(value, "\r\n", "\n") value = strings.ReplaceAll(value, "\r", "\n") @@ -756,6 +764,32 @@ func sanitizeToolText(value string) string { return strings.TrimSpace(sanitized.String()) } +// cleanToolText reports whether sanitizeToolText returns value unchanged: no +// escape sequence, no carriage return, tab or other control character, no +// invalid UTF-8, and no surrounding space to trim. Plain prose is the common +// case, so the check is one allocation-free pass. +func cleanToolText(value string) bool { + if value == "" { + return true + } + + first, _ := utf8.DecodeRuneInString(value) + last, _ := utf8.DecodeLastRuneInString(value) + if unicode.IsSpace(first) || unicode.IsSpace(last) { + return false + } + + for _, char := range value { + // RuneError covers a byte the pass below would replace; a real U+FFFD in + // the input only costs the slow path. + if char != '\n' && (char == utf8.RuneError || unicode.IsControl(char)) { + return false + } + } + + return true +} + func oneLineToolText(value string) string { return strings.Join(strings.Fields(sanitizeToolText(value)), " ") } diff --git a/internal/coding/tui/tool_activity_test.go b/internal/coding/tui/tool_activity_test.go index cb3e8441..fa5fd48b 100644 --- a/internal/coding/tui/tool_activity_test.go +++ b/internal/coding/tui/tool_activity_test.go @@ -786,3 +786,48 @@ func codingToolResultFor(callID, name, body string) ai.ToolMessage { `{"schema":"pips.coding.tool_result/v1alpha1","ok":true,"tool":"`+name+`"}`+"\n\n"+body, ) } + +// TestSanitizeToolTextLeavesCleanProseUntouched pins the allocation-free path a +// streaming reasoning body takes: plain prose is returned as the caller's own +// string, so a frame does not copy the whole thought again. +// +//nolint:paralleltest // AllocsPerRun measures the whole process and refuses a parallel test. +func TestSanitizeToolTextLeavesCleanProseUntouched(t *testing.T) { + // AllocsPerRun measures the whole process, so this test cannot run in parallel. + // A streaming body ends mid-thought, so it carries no trailing whitespace. + body := strings.TrimRight(strings.Repeat("Reasoning prose with ordinary words and 中文.\n\n", 64), "\n") + + allocs := testing.AllocsPerRun(50, func() { + if got := sanitizeToolText(body); got != body { + t.Fatalf("clean prose was rewritten: %q", got) + } + }) + assert.Zero(t, allocs, "a clean body must not be copied") +} + +// TestSanitizeToolTextStillTransformsDirtyText pins the contract the clean-text +// check must not swallow: every input that needs work is still rewritten. +func TestSanitizeToolTextStillTransformsDirtyText(t *testing.T) { + t.Parallel() + + for name, testCase := range map[string]struct{ input, want string }{ + "carriage return": {"line one\r\nline two", "line one\nline two"}, + "bare return": {"line one\rline two", "line one\nline two"}, + "tab": {"a\tb", "a b"}, + "escape sequence": {"\x1b[31mred\x1b[0m", "red"}, + "bell": {"ring\x07", "ring"}, + "trim leading": {" padded", "padded"}, + "trim trailing": {"padded\n\n", "padded"}, + "null byte": {"a\x00b", "ab"}, + "c1 control": {"a\u0085b", "ab"}, + "invalid utf8": {"a\xffb", "ab"}, + "only spaces": {" ", ""}, + "empty": {"", ""}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, testCase.want, sanitizeToolText(testCase.input)) + }) + } +} diff --git a/internal/coding/tui/transcript.go b/internal/coding/tui/transcript.go index 9bc2542e..7e8578c5 100644 --- a/internal/coding/tui/transcript.go +++ b/internal/coding/tui/transcript.go @@ -99,6 +99,9 @@ type transcriptStore struct { renders int // evictions counts records whose rows were released since the last reset. evictions int + // liveRowsHashed counts the live record's rows read since the last reset, so + // tests can assert that a frame's revision does not read them. + liveRowsHashed int } // transcriptEntry is one projected entry offered to the store. A record that is @@ -325,9 +328,11 @@ func (s *transcriptStore) reindex() { s.indexDirty = false } -// updateRevision fingerprints the record set, its geometry and the live record's -// text. A search scan is skipped while the fingerprint is unchanged, so holding -// the find box open during a stream does not re-read every record each frame. +// updateRevision fingerprints the record set, its geometry and the live +// record's height, so a search scan can skip a frame in which nothing it reads +// changed. It deliberately does not read the live record's text: only a search +// looks at that, and hashing a growing thought on every frame is what made the +// frame cost scale with the stream. [fingerprint] adds the text when asked. func (s *transcriptStore) updateRevision() { hash := uint64(14695981039346656037) mix := func(value uint64) { @@ -349,23 +354,35 @@ func (s *transcriptStore) updateRevision() { } for index := range s.records { - record := &s.records[index] - mix(uint64(record.lines)) + mix(uint64(s.records[index].lines)) //nolint:gosec // lines is a row count; it is never negative. + } + + s.revision = hash +} +// fingerprint returns what a search scan compares against: the frame's revision +// with the live record's text folded in. A live record can change its text +// without changing its height, so the text is the only thing that tells a search +// it has to read the rows again. +func (s *transcriptStore) fingerprint() uint64 { + hash := s.revision + + for index := range s.records { + record := &s.records[index] if !record.live { continue } - // Only the live record can change its text without changing its - // identity, so it is the only content the fingerprint has to cover. + s.liveRowsHashed += len(record.rows) + for _, row := range record.rows { for offset := range len(row) { - mix(uint64(row[offset])) + hash = (hash ^ uint64(row[offset])) * 1099511628211 } } } - s.revision = hash + return hash } // loadRecord returns one record's rows, rendering them if they were released. diff --git a/internal/coding/tui/transcript_test.go b/internal/coding/tui/transcript_test.go index 1c884c0f..0d5b3609 100644 --- a/internal/coding/tui/transcript_test.go +++ b/internal/coding/tui/transcript_test.go @@ -329,3 +329,48 @@ func TestFullscreenStartupPutsTheBannerInTheTranscript(t *testing.T) { assert.Contains(t, inspection, "Status") assert.Empty(t, model.presentation.writes) } + +// buildRevisionProbe builds a store holding one settled record and one live +// record with the given body. +func buildRevisionProbe(store *transcriptStore, live string) { + store.build(40, themeDark, themeFingerprint(themeDark.fingerprint), false, newMarkdownRenderer(8), + []transcriptEntry{ + {id: "settled", block: timelineBlock{kind: blockAssistant, id: "settled", body: "settled paragraph"}}, + {id: "draft", live: true, block: timelineBlock{kind: blockDraft, id: "draft", body: live}}, + }) +} + +// TestStoreRevisionDoesNotReadTheLiveRows pins that a frame's revision is cheap: +// hashing the live record's growing text is left to the search scan that reads +// it, instead of running on every frame. +func TestStoreRevisionDoesNotReadTheLiveRows(t *testing.T) { + t.Parallel() + + var store transcriptStore + buildRevisionProbe(&store, strings.Repeat("streaming words ", 40)) + + store.liveRowsHashed = 0 + store.updateRevision() + + assert.Zero(t, store.liveRowsHashed, "a frame's revision must not read the live text") + + store.fingerprint() + assert.Positive(t, store.liveRowsHashed, "a search fingerprint must read the live text") +} + +// TestStoreFingerprintSeesLiveTextAtTheSameHeight pins that the lazy fingerprint +// still catches a live record whose text changed without changing its height, +// which is the only reason a search has to re-read its rows. +func TestStoreFingerprintSeesLiveTextAtTheSameHeight(t *testing.T) { + t.Parallel() + + var before, after transcriptStore + buildRevisionProbe(&before, "aaaa bbbb") + buildRevisionProbe(&after, "cccc dddd") + + require.Len(t, before.rowsIn(0, before.rowCount()), len(after.rowsIn(0, after.rowCount())), + "the probe bodies must render to the same row count so the height cannot differ") + require.Equal(t, before.revision, after.revision, "the height-only revision cannot tell them apart") + assert.NotEqual(t, before.fingerprint(), after.fingerprint(), + "a search must be told the live text changed") +}