1package tui
2
3import (
4 "io"
5 "strings"
6
7 "github.com/mattn/go-runewidth"
8)
9
10// runewidthRune reports the number of cells a rune occupies, pinned
11// here so the renderer does not depend on the editor's helper.
12func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
13
14// Renderer maintains a previous frame and writes only the lines that
15// changed on each Draw(). Callers pass a full target frame (slice of
16// styled lines, already wrapped to width).
17type Renderer struct {
18 out io.Writer
19 prev []string
20 rows int // terminal rows
21 cols int // terminal cols
22
23 // Cursor position after last draw (for placing input cursor).
24 cursorRow int
25 cursorCol int
26
27 // hideCursor when true prevents ShowCursor from being emitted.
28 hideCursor bool
29
30 // prevHadImage tracks whether the previous frame contained an
31 // inline-image escape so we can force a full clear+repaint whenever
32 // the image set changes. Only matters when inline images are
33 // enabled via ZOT_INLINE_IMAGES; defaults to false.
34 prevHadImage bool
35
36 // Main-screen flow renderer state. logChat is the full chat buffer
37 // already emitted into terminal scrollback. logBottom is the
38 // editable/status block currently drawn after the chat.
39 logChat []string
40 logBottom []string
41 logInit bool
42}
43
44// NewRenderer returns a renderer that writes to out.
45func NewRenderer(out io.Writer) *Renderer {
46 return &Renderer{out: out}
47}
48
49// Resize tells the renderer the current terminal size.
50//
51// On a real size change we also issue a clear-screen so the next Draw
52// starts from a blank slate. Without the clear, characters from the
53// old (wider) layout linger past the new right edge and rows from
54// before the new bottom hang around as garbage.
55func (r *Renderer) Resize(cols, rows int) {
56 if cols != r.cols || rows != r.rows {
57 r.cols = cols
58 r.rows = rows
59 r.prev = nil
60 r.logChat = nil
61 r.logBottom = nil
62 r.logInit = false
63 if r.out != nil {
64 _, _ = io.WriteString(r.out, SeqClearScreen)
65 }
66 }
67}
68
69// Clear forces a full repaint on the next Draw and clears the screen
70// plus scrollback. In main-screen flow mode this is required whenever
71// already-emitted transcript layout changes (for example ctrl+o
72// expand/collapse), because terminal scrollback cannot be edited
73// reliably once printed.
74func (r *Renderer) Clear() {
75 r.prev = nil
76 r.logChat = nil
77 r.logBottom = nil
78 r.logInit = false
79 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
80}
81
82// Invalidate forces a full repaint on the next Draw without clearing the
83// whole terminal first. Useful when the cached diff is unreliable but a
84// visible full-screen flash would be too distracting.
85func (r *Renderer) Invalidate() {
86 r.prev = nil
87}
88
89// Draw updates the terminal so that the visible frame ends with the
90// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
91// the lines slice indicating where to place the terminal cursor; use
92// -1 to hide it.
93// containsImageEscape reports whether the line carries an inline-image
94// escape we must repaint rather than diff against the previous frame.
95func containsImageEscape(s string) bool {
96 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
97}
98
99// truncateToWidth clips s so its on-screen width doesn't exceed cols
100// cells, preserving ANSI CSI escape sequences (which don't consume
101// cells). Lines carrying an inline-image escape are returned as-is
102// since we can't measure their painted size.
103//
104// Fast path: a byte-length <= cols is a conservative upper bound
105// guaranteeing the cell width is also <= cols, so we skip all the
106// rune-width math. That covers the vast majority of lines in a
107// transcript (narrow terminals wrap early; wide ones leave headroom).
108func truncateToWidth(s string, cols int) string {
109 if cols <= 0 || containsImageEscape(s) {
110 return s
111 }
112 if len(s) <= cols {
113 return s
114 }
115 var out strings.Builder
116 out.Grow(len(s))
117 seen := 0
118 runes := []rune(s)
119 for i := 0; i < len(runes); {
120 r := runes[i]
121 // CSI escape sequence (ESC [ ... final): zero-width.
122 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
123 out.WriteRune(r)
124 out.WriteRune(runes[i+1])
125 i += 2
126 for i < len(runes) {
127 c := runes[i]
128 out.WriteRune(c)
129 i++
130 if c >= 0x40 && c <= 0x7e {
131 break
132 }
133 }
134 continue
135 }
136 rw := runewidthRune(r)
137 if seen+rw > cols {
138 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
139 // so background colors and cleanup sequences survive.
140 for i < len(runes) {
141 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
142 out.WriteRune(runes[i])
143 out.WriteRune(runes[i+1])
144 i += 2
145 for i < len(runes) {
146 c := runes[i]
147 out.WriteRune(c)
148 i++
149 if c >= 0x40 && c <= 0x7e {
150 break
151 }
152 }
153 } else {
154 break
155 }
156 }
157 break
158 }
159 out.WriteRune(r)
160 seen += rw
161 i++
162 }
163 return out.String()
164}
165
166func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
167 if r.cols == 0 || r.rows == 0 {
168 return
169 }
170 // Bottom-align: only the last r.rows lines are visible.
171 visible := lines
172 if len(visible) > r.rows {
173 visible = visible[len(visible)-r.rows:]
174 cursorRow -= len(lines) - len(visible)
175 }
176 // Pad to r.rows with empty lines at the top. Every line is also
177 // hard-truncated to cols so the terminal never soft-wraps our output
178 // (which would push the status bar out of its row).
179 frame := make([]string, r.rows)
180 top := r.rows - len(visible)
181 for i := 0; i < top; i++ {
182 frame[i] = ""
183 }
184 for i, line := range visible {
185 frame[top+i] = truncateToWidth(line, r.cols)
186 }
187
188 var w strings.Builder
189 w.WriteString(SeqSynchronizedOn)
190 w.WriteString(SeqHideCursor)
191
192 // When inline images are in play we always full-repaint (clear
193 // screen first, then rewrite every row). Terminals manage image
194 // pixels in a layer we cannot diff against, so the per-line cache
195 // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
196 // the common code path below is the fast cached diff.
197 curHasImage := false
198 curHasKittyImage := false
199 for _, l := range frame {
200 if containsImageEscape(l) {
201 curHasImage = true
202 if strings.Contains(l, "\x1b_G") {
203 curHasKittyImage = true
204 }
205 }
206 }
207 forceAll := curHasImage || r.prevHadImage
208 if forceAll {
209 w.WriteString(SeqClearScreen)
210 if curHasKittyImage {
211 // Delete previously placed kitty images once per frame,
212 // before rewriting all rows. Doing this inside each image
213 // escape makes only the last image in the frame survive.
214 w.WriteString("\x1b_Ga=d\x1b\\")
215 }
216 }
217
218 // Detect selection highlights: if the current OR previous frame
219 // has selection-background rows, force full repaint. VS Code's
220 // terminal doesn't reliably clear background colors on row
221 // overwrites, leaving ghost highlights behind.
222 hasSelection := false
223 for _, l := range frame {
224 if strings.Contains(l, "\x1b[48;5;") {
225 hasSelection = true
226 break
227 }
228 }
229 if !hasSelection && r.prev != nil {
230 for _, l := range r.prev {
231 if strings.Contains(l, "\x1b[48;5;") {
232 hasSelection = true
233 break
234 }
235 }
236 }
237
238 full := r.prev == nil || len(r.prev) != r.rows
239 for i := 0; i < r.rows; i++ {
240 if full || forceAll || hasSelection || r.prev[i] != frame[i] {
241 w.WriteString(MoveTo(i+1, 1))
242 w.WriteString("\x1b[0m") // reset all attributes first
243 w.WriteString(SeqClearLine)
244 w.WriteString(frame[i])
245 }
246 }
247
248 if cursorRow >= 0 {
249 absRow := top + cursorRow + 1
250 absCol := cursorCol + 1
251 if absRow >= 1 && absRow <= r.rows {
252 w.WriteString(MoveTo(absRow, absCol))
253 w.WriteString(SeqShowCursor)
254 }
255 }
256 w.WriteString(SeqSynchronizedOff)
257
258 _, _ = io.WriteString(r.out, w.String())
259
260 r.prev = frame
261 r.prevHadImage = curHasImage
262 r.cursorRow = cursorRow
263 r.cursorCol = cursorCol
264}
265
266// DrawLog renders zot in the terminal's main screen as normal terminal
267// flow rather than a fixed full-screen frame. Chat lines are emitted once
268// into the host terminal scrollback; the current bottom block (dialogs,
269// slash popup, status, editor) is erased and redrawn in place at the end.
270//
271// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
272func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
273 if r.cols == 0 || r.rows == 0 {
274 return
275 }
276 if len(bottom) == 0 {
277 bottom = []string{""}
278 }
279 chatFrame := make([]string, len(chat))
280 for i, line := range chat {
281 chatFrame[i] = truncateToWidth(line, r.cols)
282 }
283 bottomFrame := make([]string, len(bottom))
284 for i, line := range bottom {
285 bottomFrame[i] = truncateToWidth(line, r.cols)
286 }
287
288 var w strings.Builder
289 w.WriteString(SeqSynchronizedOn)
290 w.WriteString(SeqHideCursor)
291
292 if !r.logInit {
293 // First paint: start at top-left and print the transcript followed
294 // immediately by the live input/status block. No bottom padding,
295 // no footer layout.
296 w.WriteString(SeqClearScreen)
297 w.WriteString(SeqClearScrollback)
298 w.WriteString(MoveTo(1, 1))
299 for _, line := range chatFrame {
300 w.WriteString("\x1b[0m")
301 w.WriteString(SeqClearLine)
302 w.WriteString(line)
303 w.WriteString("\r\n")
304 }
305 writeBlock(&w, bottomFrame)
306 r.logInit = true
307 } else {
308 // Walk back up to the top of the previous bottom block. The cursor
309 // was last positioned somewhere inside the bottom band by the
310 // previous Draw (final ShowCursor below); we don't trust the
311 // terminal's saved cursor across frames because terminal-driven
312 // scrolling would invalidate it. Instead, rebuild the relative
313 // position from r.cursorRow inside the previous bottomFrame.
314 prevBottomRows := len(r.logBottom)
315 prevCursorRow := r.cursorRow
316 if prevCursorRow < 0 || prevCursorRow >= prevBottomRows {
317 prevCursorRow = prevBottomRows - 1
318 if prevCursorRow < 0 {
319 prevCursorRow = 0
320 }
321 }
322 up := prevCursorRow
323 if prevBottomRows > 0 && up > 0 {
324 w.WriteString("\x1b[" + itoa(up) + "A")
325 }
326 w.WriteString("\r")
327
328 prefix := len(r.logChat) <= len(chatFrame)
329 if prefix {
330 for i := range r.logChat {
331 if r.logChat[i] != chatFrame[i] {
332 prefix = false
333 break
334 }
335 }
336 }
337 if prefix {
338 // Erase old bottom band entirely, then append only genuinely new
339 // chat rows above the new bottom band. New chat rows become real
340 // terminal scrollback; inline image escapes are emitted once here.
341 w.WriteString(SeqEraseToEnd)
342 for _, line := range chatFrame[len(r.logChat):] {
343 w.WriteString("\x1b[0m")
344 w.WriteString(SeqClearLine)
345 w.WriteString(line)
346 w.WriteString("\r\n")
347 }
348 } else {
349 // Transcript changed in place (streaming text grows within the
350 // last assistant block, markdown finalises/reflows, ctrl+o
351 // expand/collapse, /clear, session load). In main-screen flow
352 // mode old chat lives in terminal scrollback; trying to move up
353 // and rewrite it is unreliable, especially once images or native
354 // scrolling are involved. Use the safe path: clear visible screen
355 // + scrollback, then replay the current transcript once.
356 w.WriteString(SeqDeleteKittyImages)
357 w.WriteString(SeqClearScreen)
358 w.WriteString(SeqClearScrollback)
359 w.WriteString(MoveTo(1, 1))
360 for _, line := range chatFrame {
361 w.WriteString("\x1b[0m")
362 w.WriteString(SeqClearLine)
363 w.WriteString(line)
364 w.WriteString("\r\n")
365 }
366 }
367 writeBlock(&w, bottomFrame)
368 }
369
370 // writeBlock leaves the cursor on the last bottom row. Move to the
371 // requested cursor position inside that block.
372 if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
373 up := (len(bottomFrame) - 1) - cursorBottomRow
374 if up > 0 {
375 w.WriteString("\x1b[" + itoa(up) + "A")
376 }
377 w.WriteString("\r")
378 if cursorCol > 0 {
379 w.WriteString("\x1b[" + itoa(cursorCol) + "C")
380 }
381 w.WriteString(SeqShowCursor)
382 }
383
384 w.WriteString(SeqSynchronizedOff)
385 _, _ = io.WriteString(r.out, w.String())
386
387 r.logChat = append(r.logChat[:0], chatFrame...)
388 r.logBottom = append(r.logBottom[:0], bottomFrame...)
389 r.cursorRow = cursorBottomRow
390 r.cursorCol = cursorCol
391}
392
393func writeBlock(w *strings.Builder, lines []string) {
394 for i, line := range lines {
395 w.WriteString("\x1b[0m")
396 w.WriteString(SeqClearLine)
397 w.WriteString(line)
398 if i < len(lines)-1 {
399 w.WriteString("\r\n")
400 }
401 }
402}
403
404func tailTruncated(lines []string, maxRows, cols int) []string {
405 if maxRows <= 0 {
406 return nil
407 }
408 if len(lines) > maxRows {
409 lines = lines[len(lines)-maxRows:]
410 }
411 out := make([]string, len(lines))
412 for i, line := range lines {
413 out[i] = truncateToWidth(line, cols)
414 }
415 return out
416}