1package tui
2
3import "strings"
4
5// assistantIndent is the left indent of assistant prose rows.
6const assistantIndent = " "
7
8// liveTextCache keeps the rendered rows of the finished blocks of a
9// streaming assistant reply. Streaming text only grows, so each frame
10// renders just the unfinished tail instead of the whole reply.
11type liveTextCache struct {
12 width int
13 theme uint64
14 // src is the source prefix already rendered; it always ends at a
15 // renderMarkdownRaw block boundary.
16 src string
17 rows []string
18}
19
20// appendAssistantRows wraps rendered markdown rows to inner and indents them.
21func appendAssistantRows(dst []string, rendered string, inner int) []string {
22 for _, l := range strings.Split(rendered, "\n") {
23 for _, w := range wrapANSILine(l, inner) {
24 dst = append(dst, assistantIndent+w)
25 }
26 }
27 return dst
28}
29
30// renderAssistantText renders assistant prose as a finished transcript
31// message. Live text must render identically, otherwise rows already pushed
32// into terminal scrollback would no longer match the final message.
33func renderAssistantText(text string, th Theme, width int) []string {
34 inner := assistantBodyWidth(width - len(assistantIndent))
35 return appendAssistantRows(nil, RenderMarkdown(strings.TrimLeft(text, "\n"), th, inner), inner)
36}
37
38// liveTextRows renders the streaming reply, reusing cached rows for its
39// finished blocks. The result is identical to renderAssistantText(text).
40func (v *View) liveTextRows(text string, width int) []string {
41 text = strings.TrimLeft(text, "\n")
42 inner := assistantBodyWidth(width - len(assistantIndent))
43 c := &v.liveText
44 themeKey := toolThemeKey(v.Theme)
45 if c.width != width || c.theme != themeKey || !strings.HasPrefix(text, c.src) {
46 *c = liveTextCache{width: width, theme: themeKey}
47 }
48
49 // Render only the uncached tail. The renderer reports where finished
50 // blocks end, so the cache never has to re-parse markdown on its own.
51 lines := strings.Split(text[len(c.src):], "\n")
52 cutLine, cutOut := 0, 0
53 raw := renderMarkdownRaw(lines, v.Theme, inner, func(line, outLen int) {
54 cutLine, cutOut = line, outLen
55 })
56 if cutLine > 0 {
57 c.rows = appendAssistantRows(c.rows, strings.TrimSuffix(raw[:cutOut], "\n"), inner)
58 n := 0
59 for _, l := range lines[:cutLine] {
60 n += len(l) + 1
61 }
62 c.src = text[:len(c.src)+n]
63 raw = raw[cutOut:]
64 }
65
66 // RenderMarkdown trims trailing newlines from the whole reply. When the
67 // tail has content, that trim only affects the tail; otherwise it also
68 // drops the cached block's trailing empty rows.
69 tail := strings.TrimRight(raw, "\n")
70 if tail == "" {
71 rows := c.rows
72 for len(rows) > 1 && rows[len(rows)-1] == assistantIndent {
73 rows = rows[:len(rows)-1]
74 }
75 if len(rows) == 0 {
76 return []string{assistantIndent}
77 }
78 return append([]string(nil), rows...)
79 }
80 return appendAssistantRows(append(make([]string, 0, len(c.rows)+8), c.rows...), tail, inner)
81}