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