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
\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")
+}