on main help sign in
perf(chat): stop O(n²) re-render of long thinking traces

While reasoning streams, renderThinking sent the entire accumulated trace through glamour on every ~33ms flush. The streamingMarkdown incremental cache only skips that when it finds a safe blank-line boundary, so a long unbroken "wall" of reasoning (no paragraph break) paid a full O(n) render every flush — O(n²) over the stream — which froze the UI. Bound the glamour input in renderThinking, since the collapsed and tail-window views only ever display the last N rendered lines: - boundThinkingInput caps the raw text fed to glamour, snapping the cut to a line boundary and re-opening a code fence left dangling by the cut so the tail renders as code, not prose. Full expansion is never bounded. - A boundary gate skips windowing when the tail since the last safe boundary already fits the budget: the incremental cache handles that case cheaply, and windowing (a sliding window is never a prefix extension) would reset it every flush. Without the gate, well-formatted thinking regressed ~14x. Also rewrites findSafeMarkdownBoundary from an O(n²) backward search to a single O(n) forward scan with closure-aware list tracking, so boundaries resume advancing after a closed list. Per-flush render cost (100-flush bench): wall, no blank lines: 60ms and growing -> 9ms flat well-formatted: 4.2ms -> 4.6ms (unchanged) Adds unit tests for boundThinkingInput/fenceLineCount and regression-guard benchmarks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commit0d766335295141be60dde3afcb3522ca7b412cc1 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 20:05 (3 months ago) parent23077d9
5 files changed+665−270
internal/ui/chat/assistant.go+111−4 internal/ui/chat/assistant_thinking_bench_test.go+69 internal/ui/chat/assistant_thinking_bound_test.go+105 internal/ui/chat/incremental_glamour_test.go+146−16 internal/ui/chat/streaming_markdown.go+234−250
internal/ui/chat/assistant.go+111−4
@@ -454,16 +454,33 @@ func (a *AssistantMessageItem) cachedError(width int) string {
454454// lines so the visual box matches what the user sees today.
455455func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
456456 renderer := common.QuietMarkdownRenderer(a.sty, width)
457− rendered := a.streamingThinking.Render(thinking, width, renderer)
457+
458+ // Cap the raw text handed to glamour while the block is windowed.
459+ // The collapsed and tail-window views only ever DISPLAY the last
460+ // N rendered lines, so rendering the entire (unbounded) reasoning
461+ // trace on every ~33ms streaming flush is wasted work that grows
462+ // with the trace — O(n) per flush, O(n²) over the stream — and is
463+ // the source of the "long thinking makes the UI lag" problem.
464+ // Full expansion is an explicit request to see everything and is
465+ // never bounded. droppedLines keeps the "N lines hidden" count
466+ // roughly truthful and never affects what is displayed.
467+ windowed, droppedLines := boundThinkingInput(thinking, a.thinkingViewMode, width)
468+ rendered := a.streamingThinking.Render(windowed, width, renderer)
458469 rendered = strings.TrimSpace(rendered)
459470
460471 lines := strings.Split(rendered, "\n")
461− totalLines := len(lines)
472+ renderedLines := len(lines)
473+ // totalLines counts everything the trace would render to, so the
474+ // affordance's hidden-line count stays honest even though only
475+ // the windowed tail was actually rendered.
476+ totalLines := renderedLines + droppedLines
462477
463478 switch a.thinkingViewMode {
464479 case thinkingCollapsed:
465480 if totalLines > maxCollapsedThinkingHeight {
466− lines = lines[totalLines-maxCollapsedThinkingHeight:]
481+ if renderedLines > maxCollapsedThinkingHeight {
482+ lines = lines[renderedLines-maxCollapsedThinkingHeight:]
483+ }
467484 hint := a.sty.Messages.ThinkingTruncationHint.Render(
468485 fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
469486 )
@@ -471,7 +488,9 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
471488 }
472489 case thinkingTailWindow:
473490 if totalLines > maxExpandedThinkingTailLines {
474− lines = lines[totalLines-maxExpandedThinkingTailLines:]
491+ if renderedLines > maxExpandedThinkingTailLines {
492+ lines = lines[renderedLines-maxExpandedThinkingTailLines:]
493+ }
475494 hint := a.sty.Messages.ThinkingTruncationHint.Render(
476495 fmt.Sprintf(assistantMessageTailWindowFormat, totalLines-maxExpandedThinkingTailLines),
477496 )
@@ -500,6 +519,94 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
500519 return result
501520}
502521
522+// boundThinkingInput caps the raw reasoning text fed to glamour so a
523+// streaming flush costs O(window) instead of O(entire trace). It
524+// returns the (possibly shortened) text to render plus the number of
525+// source lines dropped ahead of the window.
526+//
527+// A full render is preserved for [thinkingFullExpanded] (the user
528+// asked to see everything) and whenever the trace already fits the
529+// budget, so short blocks and the non-streaming steady state behave
530+// exactly as before.
531+//
532+// The window is snapped forward to a line boundary so a source line
533+// is never cut mid-way, and a code fence left open by the cut is
534+// re-opened at the window head so the tail keeps rendering as code
535+// (matching reality) instead of flipping to prose. Any other
536+// construct a raw cut might tear — a list, table, or blockquote at
537+// the very top of the window — only corrupts lines that sit above
538+// the displayed tail slice and are discarded before display.
539+func boundThinkingInput(thinking string, mode thinkingViewMode, width int) (windowed string, droppedLines int) {
540+ budget := thinkingWindowBytes(mode, width)
541+ if budget <= 0 || len(thinking) <= budget {
542+ return thinking, 0
543+ }
544+ // The streamingMarkdown incremental cache already keeps a flush
545+ // cheap when a safe boundary sits within `budget` of the end: it
546+ // re-renders only the small trailing segment. Windowing would
547+ // break that fast path (a sliding window is never a prefix
548+ // extension, forcing a cache reset every flush), so only window
549+ // when the tail since the last boundary is itself larger than the
550+ // budget — the "wall of text with no paragraph break" case the
551+ // cache cannot handle.
552+ if b := findSafeMarkdownBoundary(thinking); b >= 0 && len(thinking)-b <= budget {
553+ return thinking, 0
554+ }
555+ cut := len(thinking) - budget
556+ // Snap forward to the start of the next line so the window never
557+ // begins in the middle of a source line.
558+ if nl := strings.IndexByte(thinking[cut:], '\n'); nl >= 0 {
559+ cut += nl + 1
560+ }
561+ dropped := thinking[:cut]
562+ windowed = thinking[cut:]
563+ // Re-open a code fence left dangling by the cut so the window's
564+ // code renders as code rather than prose.
565+ if fenceLineCount(dropped)%2 != 0 {
566+ windowed = "```\n" + windowed
567+ }
568+ return windowed, strings.Count(dropped, "\n")
569+}
570+
571+// thinkingWindowBytes returns the maximum bytes of raw reasoning text
572+// to render for a view mode, or -1 for "no bound". The budget scales
573+// with the wrap width so the window still comfortably overfills the
574+// visible line cap after wrapping, with a floor for narrow widths.
575+func thinkingWindowBytes(mode thinkingViewMode, width int) int {
576+ if width < 1 {
577+ width = 1
578+ }
579+ switch mode {
580+ case thinkingCollapsed:
581+ return max(4096, maxCollapsedThinkingHeight*width*8)
582+ case thinkingTailWindow:
583+ return max(32768, maxExpandedThinkingTailLines*width*4)
584+ default:
585+ return -1
586+ }
587+}
588+
589+// fenceLineCount counts fenced-code-block delimiter lines in s. An
590+// odd count means a fence is still open at the end of s. Reuses the
591+// same [isFenceLine] rule as the streaming-markdown boundary scanner.
592+func fenceLineCount(s string) int {
593+ n := 0
594+ start := 0
595+ for i := 0; i < len(s); i++ {
596+ if s[i] != '\n' {
597+ continue
598+ }
599+ if isFenceLine(s[start:i]) {
600+ n++
601+ }
602+ start = i + 1
603+ }
604+ if start < len(s) && isFenceLine(s[start:]) {
605+ n++
606+ }
607+ return n
608+}
609+
503610// renderMarkdown renders content as markdown. F8 routes the call
504611// through streamingContent, which caches the glamour render of a
505612// "stable prefix" so each streaming flush only re-renders the
internal/ui/chat/assistant_thinking_bench_test.gonew file+69
@@ -0,0 +1,69 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+// benchThinkingStream simulates a reasoning stream: it grows `body`
13+// to full length in `steps` increments and drives the real
14+// AssistantMessageItem render path (SetMessage + RawRender) on every
15+// increment, exactly as the live TUI does on each ~33ms flush while
16+// the block sits collapsed. It guards against a regression of the
17+// O(n²) full-trace re-render that made long thinking lag the UI:
18+// with boundThinkingInput the per-flush cost is bounded, so the wall
19+// (no-blank-line) case stays close to the blank-line case instead of
20+// blowing up quadratically.
21+func benchThinkingStream(b *testing.B, body string, steps int) {
22+ sty := styles.CharmtonePantera()
23+ const width = 80
24+ b.ReportAllocs()
25+ b.ResetTimer()
26+ for n := 0; n < b.N; n++ {
27+ item := NewAssistantMessageItem(&sty, thinkingMsg("")).(*AssistantMessageItem)
28+ for i := 1; i <= steps; i++ {
29+ cut := len(body) * i / steps
30+ item.SetMessage(thinkingMsg(body[:cut]))
31+ _ = item.RawRender(width)
32+ }
33+ }
34+}
35+
36+func thinkingMsg(thinking string) *message.Message {
37+ return &message.Message{
38+ ID: "bench",
39+ Role: message.Assistant,
40+ Parts: []message.ContentPart{
41+ message.ReasoningContent{Thinking: thinking, StartedAt: testStartedAt},
42+ },
43+ }
44+}
45+
46+func benchParagraphs(n int) string {
47+ var sb strings.Builder
48+ for i := 0; i < n; i++ {
49+ fmt.Fprintf(&sb, "Reasoning paragraph %d with a few sentences of prose that a model might emit while working through a problem step by step.\n\n", i)
50+ }
51+ return sb.String()
52+}
53+
54+// benchWall is one giant paragraph with NO blank line — the case
55+// where no safe markdown boundary ever exists and, before
56+// boundThinkingInput, every flush re-rendered the whole growing
57+// trace.
58+func benchWall(n int) string {
59+ var sb strings.Builder
60+ for i := 0; i < n; i++ {
61+ fmt.Fprintf(&sb, "sentence %d that keeps going with no paragraph break at all. ", i)
62+ }
63+ return sb.String()
64+}
65+
66+func BenchmarkThinkingStream_BlankLines(b *testing.B) {
67+ benchThinkingStream(b, benchParagraphs(200), 100)
68+}
69+func BenchmarkThinkingStream_Wall(b *testing.B) { benchThinkingStream(b, benchWall(1500), 100) }
internal/ui/chat/assistant_thinking_bound_test.gonew file+105
@@ -0,0 +1,105 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// TestBoundThinkingInput_ShortNotWindowed: content within the budget
11+// is returned verbatim so short blocks and the non-streaming steady
12+// state are untouched.
13+func TestBoundThinkingInput_ShortNotWindowed(t *testing.T) {
14+ t.Parallel()
15+ in := "a short thought\n\nwith two paragraphs"
16+ out, dropped := boundThinkingInput(in, thinkingCollapsed, 80)
17+ require.Equal(t, in, out)
18+ require.Zero(t, dropped)
19+}
20+
21+// TestBoundThinkingInput_FullExpandedNeverWindowed: full expansion is
22+// an explicit request to see everything, so it is never bounded even
23+// for a huge trace.
24+func TestBoundThinkingInput_FullExpandedNeverWindowed(t *testing.T) {
25+ t.Parallel()
26+ in := strings.Repeat("wall of text with no breaks ", 100000)
27+ out, dropped := boundThinkingInput(in, thinkingFullExpanded, 80)
28+ require.Equal(t, in, out)
29+ require.Zero(t, dropped)
30+}
31+
32+// TestBoundThinkingInput_BoundariedNotWindowed: a large trace whose
33+// tail has a recent safe boundary is NOT windowed — the incremental
34+// cache renders only the small trailing segment, so windowing (which
35+// would reset that cache every flush) must be skipped.
36+func TestBoundThinkingInput_BoundariedNotWindowed(t *testing.T) {
37+ t.Parallel()
38+ var b strings.Builder
39+ for i := 0; i < 5000; i++ {
40+ b.WriteString("paragraph with a few words\n\n")
41+ }
42+ in := b.String()
43+ require.Greater(t, len(in), thinkingWindowBytes(thinkingCollapsed, 80),
44+ "fixture must exceed the budget so only the boundary gate can spare it")
45+ out, dropped := boundThinkingInput(in, thinkingCollapsed, 80)
46+ require.Equal(t, in, out, "boundaried content must not be windowed")
47+ require.Zero(t, dropped)
48+}
49+
50+// TestBoundThinkingInput_WallWindowed: a long unbroken wall (no
51+// paragraph break the incremental cache can use) IS windowed to a
52+// bounded tail.
53+func TestBoundThinkingInput_WallWindowed(t *testing.T) {
54+ t.Parallel()
55+ budget := thinkingWindowBytes(thinkingCollapsed, 80)
56+ in := strings.Repeat("word ", budget) // one giant line, no '\n'
57+ out, _ := boundThinkingInput(in, thinkingCollapsed, 80)
58+ require.Less(t, len(out), len(in), "a wall past budget must be shortened")
59+ require.LessOrEqual(t, len(out), budget+8, "window must respect the budget (+fence slack)")
60+ require.True(t, strings.HasSuffix(in, out), "window must be a tail of the source")
61+}
62+
63+// TestBoundThinkingInput_SnapsToLineBoundary: when a newline exists
64+// inside the trailing budget the window begins at the start of a
65+// line, never mid-line.
66+func TestBoundThinkingInput_SnapsToLineBoundary(t *testing.T) {
67+ t.Parallel()
68+ budget := thinkingWindowBytes(thinkingCollapsed, 80)
69+ // A long single line (forces windowing) followed by clean lines
70+ // within the budget window.
71+ head := strings.Repeat("x", budget)
72+ tail := "\nclean line one\nclean line two\n"
73+ out, dropped := boundThinkingInput(head+tail, thinkingCollapsed, 80)
74+ require.Greater(t, dropped, 0)
75+ require.False(t, strings.HasPrefix(out, "x"),
76+ "window must snap past the truncated head line")
77+ require.True(t, strings.HasPrefix(out, "clean line"))
78+}
79+
80+// TestBoundThinkingInput_ReopensDanglingFence: when the dropped
81+// prefix leaves a code fence open, the window is prefixed with a
82+// fence opener so the tail renders as code (matching reality) instead
83+// of flipping to prose.
84+func TestBoundThinkingInput_ReopensDanglingFence(t *testing.T) {
85+ t.Parallel()
86+ budget := thinkingWindowBytes(thinkingCollapsed, 80)
87+ // Open a fence, then emit a long body (no closing fence) that
88+ // pushes the opener out of the window.
89+ in := "```go\n" + strings.Repeat("code line here\n", budget/15+500)
90+ out, dropped := boundThinkingInput(in, thinkingCollapsed, 80)
91+ require.Greater(t, dropped, 0, "fixture must actually be windowed")
92+ require.True(t, strings.HasPrefix(out, "```\n"),
93+ "a dangling open fence must be re-opened at the window head")
94+}
95+
96+// TestFenceLineCount checks parity accounting used to detect a
97+// dangling open fence.
98+func TestFenceLineCount(t *testing.T) {
99+ t.Parallel()
100+ require.Equal(t, 0, fenceLineCount("no fences here\njust prose\n"))
101+ require.Equal(t, 2, fenceLineCount("```go\ncode\n```\n"))
102+ require.Equal(t, 1, fenceLineCount("```\nopen but never closed\n"))
103+ // A final line with no trailing newline still counts.
104+ require.Equal(t, 1, fenceLineCount("prose\n```"))
105+}
internal/ui/chat/incremental_glamour_test.go+146−16
@@ -1,6 +1,7 @@
11package chat
22
33import (
4+ "fmt"
45 "strings"
56 "testing"
67
@@ -223,12 +224,46 @@ func TestFindSafeMarkdownBoundary_TableDriven(t *testing.T) {
223224 {
224225 name: "closed list then paragraph",
225226 content: "- one\n- two\n\nPara.",
226− // blank line after the list. Last non-blank line
227− // of prefix is "- two" — a list item — so the
228− // candidate is REJECTED. (Conservative: we don't
229− // know the list is "closed" without looking at
230− // what follows.)
231− want: -1,
227+ // blank line after the list, then "Para." at
228+ // indent 0 (< listBaseIndent+2). The closure-
229+ // aware tracker sees the list close at "Para."
230+ // and accepts the boundary at its start — the
231+ // prefix contains a complete list.
232+ want: len("- one\n- two\n\n"),
233+ },
234+ {
235+ name: "closed ordered list then paragraph",
236+ content: "1. first\n2. second\n\nAfter list.",
237+ // Same closure rule for ordered lists. Boundary at
238+ // the start of "After list." — the list closed on
239+ // the dedented non-marker line.
240+ want: len("1. first\n2. second\n\n"),
241+ },
242+ {
243+ name: "list then multiple paragraphs",
244+ content: "- item\n\nPara one.\n\nPara two.",
245+ // The list closes at "Para one." and a boundary is
246+ // accepted there. A later boundary at "Para two."
247+ // should also be found (no list open anymore).
248+ // Latest boundary wins.
249+ want: len("- item\n\nPara one.\n\n"),
250+ },
251+ {
252+ name: "loose list continuation keeps list open",
253+ content: "- item one\n\n continuation\n\nPara after.",
254+ // " continuation" is indented 2 (>=
255+ // listBaseIndent+2) so the list stays open
256+ // through it. The list only closes at "Para
257+ // after." Boundary at the start of "Para after.".
258+ want: len("- item one\n\n continuation\n\n"),
259+ },
260+ {
261+ name: "nested list then closure",
262+ content: "- outer\n - inner\n\nBack to top level.",
263+ // Inner list opens at indent 2. "Back to top
264+ // level." at indent 0 closes everything.
265+ // Boundary at the start of "Back to top level.".
266+ want: len("- outer\n - inner\n\n"),
232267 },
233268 {
234269 name: "table at end",
@@ -638,9 +673,71 @@ func TestStreamingMarkdown_NoSafeBoundaryDoesNotCrash(t *testing.T) {
638673 }
639674}
640675
641−// -----------------------------------------------------------------------
642−// Integration assertions on the wired-in path.
643−// -----------------------------------------------------------------------
676+// TestStreamingMarkdown_LongThinkingWithLists is the regression
677+// test for the lag bug: a long thinking trace that contains a list
678+// early on, followed by many paragraphs. Before the closure-aware
679+// list tracker, the list marker forced findSafeMarkdownBoundary to
680+// return -1 for the ENTIRE trace, so every streaming delta did a
681+// full glamour re-render of the growing text — O(n²) overall and
682+// visible lag on long reasoning.
683+//
684+// With the closure-aware tracker the list closes at the first
685+// dedented paragraph, the stable-prefix cache advances past it,
686+// and subsequent deltas only re-render the trailing partial. This
687+// test asserts the cache actually advances (stablePrefix grows
688+// beyond the list) and the final output is visually equivalent to
689+// a fresh full render.
690+func TestStreamingMarkdown_LongThinkingWithLists(t *testing.T) {
691+ t.Parallel()
692+
693+ // Build a thinking trace: intro, a short list, then many
694+ // paragraphs that simulate a long reasoning section.
695+ var doc strings.Builder
696+ doc.WriteString("Let me analyze this step by step.\n\n")
697+ doc.WriteString("1. First consideration.\n\n")
698+ doc.WriteString("2. Second consideration.\n\n")
699+ doc.WriteString("3. Third consideration.\n\n")
700+ for i := range 200 {
701+ fmt.Fprintf(&doc, "Paragraph %d of the reasoning trace. It has enough text to be a real paragraph.\n\n", i)
702+ }
703+
704+ full := doc.String()
705+ listEnd := strings.Index(full, "3. Third consideration.\n\n") + len("3. Third consideration.\n\n")
706+ require.Greater(t, listEnd, 0, "test setup: list end offset")
707+
708+ const width = 80
709+ r := newTestRenderer(t, width)
710+ var sm streamingMarkdown
711+
712+ // Stream the document in ~50 progressive prefixes. After the
713+ // list closes, the cache should advance past listEnd.
714+ steps := 50
715+ prefixes := progressivePrefixes(full, steps)
716+ var lastOut string
717+ advanced := false
718+ for i, p := range prefixes {
719+ if p == "" {
720+ continue
721+ }
722+ lastOut = sm.Render(p, width, r)
723+ // Once the prefix extends well past the list, the
724+ // stable prefix should have advanced past listEnd.
725+ if len(p) > listEnd+200 && len(sm.stablePrefix) > listEnd {
726+ advanced = true
727+ }
728+ _ = i
729+ }
730+ require.True(t, advanced,
731+ "stable prefix must advance past the closed list — if it didn't, "+
732+ "every delta is doing a full re-render (the lag bug)")
733+
734+ // Final output must be visually equivalent to a fresh render.
735+ fresh := freshRender(t, full, width)
736+ require.Equal(t, normalizeRender(fresh), normalizeRender(lastOut),
737+ "final streaming output must match a fresh full render visually")
738+}
739+
740+
644741
645742// -----------------------------------------------------------------------
646743// T5 / T6 / T7: anywhere-in-prefix hazards (B1 / B2 / B3 from the
@@ -726,8 +823,11 @@ func runProgressiveBoundaryRespectTest(t *testing.T, doc string, hazardLineOffse
726823// trailing paragraph creates a candidate boundary between the list
727824// item and its continuation; the trailing non-blank line of that
728825// candidate prefix is the continuation paragraph (not a list
729−// marker), so the line-only check would accept it. The
730−// anywhere-in-prefix list-marker check rejects it.
826+// marker), so the line-only check would accept it. The closure-
827+// aware list tracker keeps the list open through the continuation
828+// (indent >= listBaseIndent+2) and only closes it at the dedented
829+// "Trailing paragraph" line, so boundaries INSIDE the list are
830+// rejected while the boundary at the closing line is accepted.
731831func TestStreamingMarkdown_LooseListContinuation(t *testing.T) {
732832 t.Parallel()
733833
@@ -744,12 +844,42 @@ func TestStreamingMarkdown_LooseListContinuation(t *testing.T) {
744844 }, "\n")
745845
746846 // The first list marker line begins after "Intro paragraph.\n\n".
747− // The cached stable prefix may include that boundary (BEFORE
748− // the list opens) but must never advance into the list.
749− hazardOffset := strings.Index(doc, "- item one")
750− require.Greater(t, hazardOffset, 0, "test setup")
847+ // The closure boundary is the start of "Trailing paragraph after
848+ // the list." — the first safe boundary after the list closes.
849+ listStart := strings.Index(doc, "- item one")
850+ require.Greater(t, listStart, 0, "test setup")
851+ closureStart := strings.Index(doc, "Trailing paragraph")
852+ require.Greater(t, closureStart, listStart, "test setup")
751853
752− runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
854+ const width = 80
855+ const steps = 25
856+ renderer := newTestRenderer(t, width)
857+ var sm streamingMarkdown
858+
859+ prefixes := progressivePrefixes(doc, steps)
860+ var lastOut string
861+ for i, p := range prefixes {
862+ if p == "" {
863+ continue
864+ }
865+ lastOut = sm.Render(p, width, renderer)
866+ require.NotEmptyf(t, lastOut, "step %d: empty render", i)
867+ // The stable prefix must NEVER be inside the list —
868+ // either it's at/before the intro boundary (before the
869+ // list opens) or at/after the closure boundary (after
870+ // the list closes). A value strictly between listStart
871+ // and closureStart would mean a boundary inside the
872+ // list was accepted, which would split the loose list.
873+ if len(sm.stablePrefix) > listStart && len(sm.stablePrefix) < closureStart {
874+ t.Fatalf("step %d: cached stable prefix landed inside the loose list "+
875+ "(len=%d, list starts at %d, closure at %d)\nsm.stablePrefix=%q",
876+ i, len(sm.stablePrefix), listStart, closureStart, sm.stablePrefix)
877+ }
878+ }
879+
880+ fresh := freshRender(t, doc, width)
881+ require.Equal(t, nonBlankLines(fresh), nonBlankLines(lastOut),
882+ "final streaming output must contain the same non-blank lines as a fresh full render")
753883}
754884
755885// TestStreamingMarkdown_HTMLBlock locks in the B2 fix. A raw HTML
internal/ui/chat/streaming_markdown.go+234−250
@@ -201,245 +201,271 @@ func trimGlamourMargins(s string) string {
201201// we have the slightest doubt we return -1 and let the caller fall
202202// back to a full render.
203203//
204−// Decision tree, in order of preference (latest boundary wins):
204+// The implementation is a single O(n) forward scan
205+// ([scanSafeBoundaries]) that tracks block state across the whole
206+// document: fenced-code toggle, list open/close depth, HTML-block
207+// opener, and link-reference-definition presence. State is updated
208+// per line, so every blank-line candidate is evaluated against
209+// already-computed state in O(1) — no re-scanning of the prefix.
205210//
206−// 1. Walk backward through every "blank line" position p such that
207−// content[:p] ends with "\n\n" (or "\n[ \t]*\n").
208−// 2. For each candidate, check that content[:p] has an even
209−// number of triple-backtick fence lines (no open fenced
210−// block). Any odd count means we'd be cutting inside a fence
211−// and mis-syntax-highlighting the trailing partial.
212−// 2b. Reject if any line in content[:p] (outside fenced blocks)
213−// is a list-marker line, an HTML-block opener, or a link
214−// reference definition. See [prefixHasOpenHazard] for the
215−// reasoning behind these "anywhere in prefix" rejects.
216−// 3. Reject if the last non-blank line of content[:p] is:
217−// - a list item marker line ("^\s*([-*+]|\d+\.)\s")
218−// - a table line (contains "|")
219−// - a block quote ("^\s*>")
220−// - a setext header underline ("^=+\s*$" or "^-+\s*$")
221−// - an indented code line (4+ leading spaces or a tab)
222−// 4. Reject if the line immediately AFTER the boundary (skipping
223−// leading blank lines) looks like a setext underline (a line
224−// of '=' or '-' only). Rendering the prefix as a paragraph
225−// would change once the underline arrived; that's exactly the
226−// "splitting changes the prefix render" hazard §4.4 calls out.
211+// Closure-aware list tracking (B1) replaces the prior "any list
212+// marker anywhere → reject" rule. A list is considered open from
213+// the first marker line until a blank line is followed by a
214+// non-continuation, non-marker line at the same or shallower
215+// indent. Once closed, prefixes ending after the close are
216+// accepted, so the incremental cache resumes working for the
217+// paragraphs that follow a list — the common case in LLM reasoning
218+// traces. On any ambiguity the list is kept open (conservative —
219+// falls back to -1).
220+//
221+// B2 (HTML block) and B3 (link reference definition) remain
222+// anywhere-in-prefix rejects: the typical assistant output
223+// contains neither, so the perf cost is zero in the common case.
227224//
228225// Returns the byte offset of the first character AFTER the blank
229226// line, i.e. the start of the trailing segment.
230227func findSafeMarkdownBoundary(content string) int {
231− if len(content) == 0 {
228+ boundaries := scanSafeBoundaries(content)
229+ if len(boundaries) == 0 {
232230 return -1
233231 }
234−
235− // Iterate every blank-line position from latest to earliest.
236− for p := blankLineBefore(content, len(content)); p > 0; p = blankLineBefore(content, p-1) {
237− if !isSafeBoundaryAt(content, p) {
238− continue
239− }
240− return p
241− }
242− return -1
232+ return boundaries[len(boundaries)-1]
243233}
244234
245−// blankLineBefore returns the byte offset of the first character
246−// AFTER the latest blank-line separator that ends strictly before
247−// `until`. A blank-line separator is a sequence "\n([ \t]*\n)+"
248−// — one newline, then one or more lines containing only spaces or
249−// tabs and terminated by another newline. The returned offset is
250−// the start of the first non-blank line that follows the
251−// separator (or the position immediately after the final newline,
252−// if no further content remains).
235+// scanSafeBoundaries walks content once, O(n), tracking block
236+// state, and returns the byte offsets of every safe stable-prefix
237+// boundary in increasing order. A boundary is the byte offset
238+// immediately after a blank-line separator at which content[:p]
239+// can be rendered independently of content[p:].
253240//
254−// Returns -1 when no blank-line separator exists before `until`.
255−func blankLineBefore(content string, until int) int {
256− if until <= 0 {
257− return -1
258− }
259− // Walk backward looking for a newline followed (after optional
260− // blank-line content) by another newline. We track the latest
261− // newline we've seen; if the next earlier newline has only
262− // blank chars between them, we have a blank-line separator
263− // and the boundary sits immediately after the latest newline.
264− end := until
265− for end > 0 {
266− nl := strings.LastIndexByte(content[:end], '\n')
267− if nl < 0 {
268− return -1
269− }
270− // Look for an earlier newline whose gap to nl is empty
271− // or whitespace only.
272− prev := strings.LastIndexByte(content[:nl], '\n')
273− for prev >= 0 {
274− gap := content[prev+1 : nl]
275− if isBlankOrSpaces(gap) {
276− return nl + 1
277− }
278− // Gap had non-whitespace; nl is not a blank-line
279− // separator. Move up: try with the earlier newline as
280− // the new "nl" candidate.
281− break
282− }
283− end = nl
284− }
285− return -1
286−}
287−
288−// isBlankOrSpaces reports whether s consists entirely of spaces
289−// and tabs (or is empty).
290−func isBlankOrSpaces(s string) bool {
291− for i := range len(s) {
292− if s[i] != ' ' && s[i] != '\t' {
293− return false
294− }
295− }
296− return true
297−}
298−
299−// isSafeBoundaryAt reports whether content[:p] is a safe stable
300−// prefix. p must be a blank-line boundary (start of a line, with a
301−// blank line immediately preceding).
241+// State carried across the scan:
302242//
303−// Beyond the last-line checks, three "anywhere in the prefix"
304−// hazards force a reject because they cannot be reliably reasoned
305−// about by inspecting the trailing line alone. For each of these
306−// the simplest, safest rule was chosen — see prefixHasOpenHazard.
307−func isSafeBoundaryAt(content string, p int) bool {
308− prefix := content[:p]
309−
310− // (2) Even number of triple-backtick fence lines.
311− if countFenceLines(prefix)%2 != 0 {
312− return false
313− }
314−
315− // (2b) Anywhere-in-prefix hazards: open list (B1), HTML block
316− // opener (B2), reference link definition (B3). Any of these
317− // anywhere in the prefix forces a fallback.
318− if prefixHasOpenHazard(prefix) {
319− return false
243+// - inFence: toggled by each fence line. While true, no boundary
244+// is safe (we're inside a fenced code block) and list/html/ref
245+// detection is skipped so code-block contents don't false-
246+// trigger the hazards.
247+// - listDepth: current open-list nesting. Incremented on a
248+// marker line, decremented on closure (see below). A boundary
249+// is only safe when listDepth == 0 (no list open).
250+// - sawHTMLBlock / sawLinkRef: anywhere-in-prefix flags for B2
251+// and B3. Once set they never clear — the prefix is
252+// permanently unsafe for splitting.
253+// - prevNonBlank: the last non-blank line seen before the
254+// current candidate, for the last-line check
255+// ([lineOpensConstruct]).
256+//
257+// Each blank-line candidate is evaluated in O(1) against the
258+// already-computed state, so the full scan is O(lines) regardless
259+// of how many candidates exist. This replaces the prior O(n²)
260+// backward search that re-scanned the prefix for every candidate.
261+//
262+// List closure detection (B1 fix): a list opens on any marker line
263+// when no list is open. A list closes when, after a blank line, a
264+// non-marker line appears at indent < listBaseIndent+2 (not a
265+// continuation paragraph). On any ambiguity the list is kept open
266+// (conservative — falls back to -1). This replaces the prior "any
267+// list marker anywhere → reject" rule, so boundaries after a
268+// closed list followed by paragraphs are accepted — the common
269+// case in LLM reasoning traces.
270+//
271+// Crucially, boundary evaluation happens BEFORE the state update
272+// for the current line: the boundary at the start of line L has
273+// prefix = content[:L], so the state must reflect everything
274+// BEFORE L, not including L's effects. For example, the boundary
275+// at the start of a fence-opening line is safe (the fence hasn't
276+// opened in the prefix yet); the boundary at the start of a list-
277+// marker line is safe (the list hasn't opened in the prefix yet).
278+func scanSafeBoundaries(content string) []int {
279+ if len(content) == 0 {
280+ return nil
320281 }
321282
322− // (3) Inspect the last non-blank line of the prefix.
323− lastLine := lastNonBlankLine(prefix)
324− if lastLine != "" && lineOpensConstruct(lastLine) {
325− return false
326− }
283+ var boundaries []int
284+ var prevNonBlank string
285+ inFence := false
286+ listDepth := 0
287+ sawHTMLBlock := false
288+ sawLinkRef := false
289+
290+ // listBaseIndent is the indent column of the innermost open
291+ // list's markers. Used for closure detection: a non-marker
292+ // line at indent < listBaseIndent+2 after a blank line closes
293+ // the list.
294+ listBaseIndent := 0
295+
296+ // blankPending tracks whether the line immediately before the
297+ // current line was blank. A boundary candidate exists at the
298+ // start of the first non-blank line after a blank line.
299+ blankPending := false
300+
301+ // processLine handles one line (without its newline
302+ // terminator) starting at byte offset off. It evaluates any
303+ // pending boundary candidate, then updates block state for
304+ // the line.
305+ //
306+ // Boundary evaluation happens BEFORE the state update for the
307+ // current line: the boundary at the start of line L has
308+ // prefix = content[:off], so the state must reflect everything
309+ // BEFORE L. However, when an open list CLOSES at line L (L is
310+ // a non-marker, dedented line after a blank line), the prefix
311+ // contains a COMPLETE list — the boundary is safe even though
312+ // listDepth is still > 0 in the pre-L state. We compute
313+ // listClosesHere before evaluation and pass it as an override.
314+ processLine := func(line string, off int) {
315+ blank := strings.TrimRight(line, " \t") == ""
316+
317+ if blank {
318+ blankPending = true
319+ return
320+ }
327321
328− // (4) If anything follows, make sure it doesn't look like a
329− // setext underline that would retroactively turn the last
330− // paragraph of the prefix into a header.
331− if rest := content[p:]; rest != "" {
332− first := firstNonBlankLine(rest)
333− if isSetextUnderlineCandidate(first) {
334− return false
322+ trimmed := strings.TrimLeft(line, " \t")
323+ indent := len(line) - len(trimmed)
324+ isMarker := isListItemMarker(trimmed)
325+
326+ // Compute whether this line closes the open list.
327+ // A list closes when a blank line is followed by a
328+ // non-marker line at indent < listBaseIndent+2 (not
329+ // a continuation paragraph).
330+ listClosesHere := false
331+ if listDepth > 0 && blankPending && !isMarker && indent < listBaseIndent+2 {
332+ listClosesHere = true
335333 }
336− }
337334
338− return true
339−}
335+ // Non-blank line. If a blank line preceded, this is a
336+ // boundary candidate at offset `off`. Evaluate BEFORE
337+ // updating state — the prefix is content[:off].
338+ if blankPending {
339+ if isSafeBoundaryState(inFence, listDepth, listClosesHere, sawHTMLBlock, sawLinkRef, prevNonBlank, trimmed) {
340+ boundaries = append(boundaries, off)
341+ }
342+ blankPending = false
343+ }
340344
341−// prefixHasOpenHazard reports whether prefix contains any of three
342−// constructs that cannot be safely cut at a blank-line boundary
343−// even when the immediately preceding line looks fine. Each check
344−// uses the SIMPLEST viable conservative rule per the F8 round-2
345−// review:
346−//
347−// B1 (loose lists). A loose list has a blank line between an item
348−// and a continuation paragraph that begins with indentation
349−// but no list marker. If a candidate boundary lands on that
350−// blank line, the prefix's trailing non-blank line is the
351−// continuation paragraph, NOT a list marker, so the last-line
352−// check would accept it even though the list is still open.
353−//
354−// Rule chosen: any list-marker line ANYWHERE in the prefix
355−// forces -1. This is overly conservative — it forfeits
356−// boundary advancement past a closed list — but it eliminates
357−// the entire bug class with zero parsing of CommonMark's
358−// loose-list closure semantics. We retain the most useful
359−// boundary in practice: the one BEFORE the list opens (no
360−// marker has appeared in the prefix yet).
361−//
362−// B2 (HTML blocks). CommonMark defines seven HTML-block opener
363−// patterns (script/pre/style/textarea, comments, processing
364−// instructions, CDATA, declarations, recognised tag names).
365−// If the prefix opens an HTML block that the suffix closes,
366−// splitting renders the prefix as raw HTML and the suffix as
367−// prose.
368−//
369−// Rule chosen: any HTML-block opener anywhere in the prefix
370−// forces -1. Same trade-off as B1 — the typical assistant
371−// output contains no raw HTML, so the perf cost is zero in
372−// the common case.
373−//
374−// B3 (reference link definitions). A line of the form
375−// "[label]: <url>" defines a link reference that the suffix
376−// may later use as "[text][label]". Splitting the document
377−// loses the definition because each half is rendered as an
378−// independent glamour document.
379−//
380−// Rule chosen: any reference link definition line anywhere in
381−// the prefix forces -1. Suffix-side reference detection is
382−// fragile (three syntaxes: [text][label], [label][], [label]),
383−// so the prefix-side check is the simpler safe choice.
384−//
385−// All three rules accept the perf hit of "no boundary after a
386−// list / HTML block / link def" in exchange for guaranteed
387−// soundness. If profiling shows this kills the F8 win on real
388−// streaming traces, the next iteration can promote each rule to
389−// its less-conservative variant (closure-aware list tracking,
390−// per-tag HTML close detection, suffix-aware ref tracking).
391−func prefixHasOpenHazard(prefix string) bool {
392− inFence := false
393− for line := range splitLines(prefix) {
394− // Track fenced state so list/html/ref patterns inside a
395− // fenced code block do not falsely trigger the hazards.
345+ // Now update state for this line.
396346 if isFenceLine(line) {
397347 inFence = !inFence
398− continue
348+ prevNonBlank = line
349+ return
399350 }
351+
400352 if inFence {
401− continue
353+ prevNonBlank = line
354+ return
402355 }
403− trimmed := strings.TrimLeft(line, " \t")
404− if trimmed == "" {
405− continue
356+
357+ // Update list depth (closure-aware B1).
358+ switch {
359+ case listDepth == 0 && isMarker:
360+ listDepth = 1
361+ listBaseIndent = indent
362+ case listDepth > 0 && isMarker:
363+ switch {
364+ case indent > listBaseIndent:
365+ // Nested list opens.
366+ listDepth++
367+ listBaseIndent = indent
368+ case indent < listBaseIndent:
369+ // Marker at shallower indent: close
370+ // current list. If an outer list
371+ // existed we'd need its base indent;
372+ // we don't track the stack, so be
373+ // conservative and close everything,
374+ // then open a new list at this indent.
375+ listDepth = 1
376+ listBaseIndent = indent
377+ }
378+ // Same indent: another item, depth unchanged.
379+ case listDepth > 0 && !isMarker && blankPending:
380+ // Blank line then non-marker line: check for
381+ // closure. A continuation paragraph is indented
382+ // 2+ relative to the marker.
383+ if indent < listBaseIndent+2 {
384+ // Dedent: list closed. Be conservative
385+ // and close all nesting levels.
386+ listDepth = 0
387+ }
388+ // Otherwise: continuation, list stays open.
406389 }
407− // B1: any list-item marker.
408− if isListItemMarker(trimmed) {
409− return true
390+ // listDepth > 0 && !isMarker && !blankPending:
391+ // A non-marker line directly after another line
392+ // (no blank line between). This is either a lazy
393+ // continuation or part of a tight list's item.
394+ // Keep the list open (conservative).
395+
396+ // B2: HTML block opener (anywhere in prefix).
397+ if !sawHTMLBlock && isHTMLBlockOpener(line) {
398+ sawHTMLBlock = true
410399 }
411− // B2: HTML block opener.
412− if isHTMLBlockOpener(line) {
413− return true
400+ // B3: link reference definition (anywhere in prefix).
401+ if !sawLinkRef && isLinkRefDefinition(line) {
402+ sawLinkRef = true
414403 }
415− // B3: link reference definition.
416− if isLinkRefDefinition(line) {
417− return true
404+
405+ prevNonBlank = line
406+ }
407+
408+ start := 0
409+ for i := 0; i < len(content); i++ {
410+ if content[i] != '\n' {
411+ continue
418412 }
413+ processLine(content[start:i], start)
414+ start = i + 1
419415 }
420− return false
416+ // Process the trailing segment (content after the last '\n').
417+ // A non-blank trailing line after a blank line is a valid
418+ // boundary candidate. A blank trailing line is irrelevant.
419+ if start < len(content) {
420+ processLine(content[start:], start)
421+ }
422+
423+ return boundaries
421424}
422425
423−// countFenceLines counts lines that begin a fenced code block in
424−// the CommonMark sense: a line whose first non-whitespace run is
425−// at least three consecutive backticks (or tildes). Each such
426−// line toggles the fenced state, so an even count means every
427−// opened fence has been closed.
426+// isSafeBoundaryState evaluates the safety of a boundary candidate
427+// given the block state computed by the forward scan (reflecting
428+// everything BEFORE the boundary line). All state is passed in
429+// explicitly so this helper stays pure and testable.
428430//
429−// We accept up to three leading spaces of indentation (CommonMark
430−// rule) and require the fence characters to be the FIRST
431−// non-whitespace content of the line. We deliberately do NOT
432−// attempt to parse info-strings or differentiate opener from
433−// closer beyond toggling — a closing fence is just any line
434−// whose first non-whitespace run is >=3 of the same fence char.
435−func countFenceLines(s string) int {
436− n := 0
437− for line := range splitLines(s) {
438− if isFenceLine(line) {
439− n++
440− }
431+// nextLineTrimmed is the first non-blank line of the suffix
432+// (already left-trimmed), used for the setext-underline check
433+// (rule 4). listClosesHere reports whether the next line closes
434+// the currently-open list; when true, the prefix contains a
435+// complete list and the boundary is safe even though listDepth
436+// is non-zero in the pre-line state.
437+func isSafeBoundaryState(inFence bool, listDepth int, listClosesHere, sawHTMLBlock, sawLinkRef bool, prevNonBlank, nextLineTrimmed string) bool {
438+ // (2) Not inside a fence.
439+ if inFence {
440+ return false
441+ }
442+ // (B1) No open list, unless the next line closes it.
443+ if listDepth != 0 && !listClosesHere {
444+ return false
441445 }
442− return n
446+ // (B2) No HTML block opener anywhere in the prefix.
447+ if sawHTMLBlock {
448+ return false
449+ }
450+ // (B3) No link reference definition anywhere in the prefix.
451+ if sawLinkRef {
452+ return false
453+ }
454+ // (3) The last non-blank line of the prefix must not keep a
455+ // construct open. When the list closes at the next line, the
456+ // prefix's last non-blank line is a list marker — that's
457+ // expected and safe because the list is complete, so skip
458+ // the check in that case.
459+ if !listClosesHere && prevNonBlank != "" && lineOpensConstruct(prevNonBlank) {
460+ return false
461+ }
462+ // (4) The first non-blank line of the suffix must not look
463+ // like a setext underline that would retroactively turn the
464+ // last paragraph of the prefix into a header.
465+ if isSetextUnderlineCandidate(nextLineTrimmed) {
466+ return false
467+ }
468+ return true
443469}
444470
445471// isFenceLine reports whether line opens or closes a fenced code
@@ -465,48 +491,6 @@ func isFenceLine(line string) bool {
465491 return run >= 3
466492}
467493
468−// lastNonBlankLine returns the last non-blank line of s, or ""
469−// when every line is blank.
470−func lastNonBlankLine(s string) string {
471− last := ""
472− for line := range splitLines(s) {
473− if strings.TrimSpace(line) != "" {
474− last = line
475− }
476− }
477− return last
478−}
479−
480−// firstNonBlankLine returns the first non-blank line of s, or ""
481−// when every line is blank.
482−func firstNonBlankLine(s string) string {
483− for line := range splitLines(s) {
484− if strings.TrimSpace(line) != "" {
485− return line
486− }
487− }
488− return ""
489−}
490−
491−// splitLines yields the lines of s without their terminators. The
492−// final segment is yielded even if not newline-terminated.
493−func splitLines(s string) func(yield func(string) bool) {
494− return func(yield func(string) bool) {
495− start := 0
496− for i := 0; i < len(s); i++ {
497− if s[i] == '\n' {
498− if !yield(s[start:i]) {
499− return
500− }
501− start = i + 1
502− }
503− }
504− if start <= len(s)-1 {
505− yield(s[start:])
506− }
507− }
508−}
509−
510494// lineOpensConstruct reports whether line keeps a markdown
511495// construct open across the boundary. We err conservatively —
512496// any case that smells like list/table/quote/setext/indented-code