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. logLines is the full logical
37 // buffer (chat + live bottom band) from the previous DrawLog call.
38 // logViewportTop/logHardwareRow track where that logical buffer sits
39 // in the terminal's visible viewport so we can diff safely, and bail
40 // out to clear+replay when the diff would touch rows that are no
41 // longer addressable.
42 logChat []string
43 logBottom []string
44 logLines []string
45 logViewportTop int
46 logHardwareRow int
47 logInit bool
48}
49
50// NewRenderer returns a renderer that writes to out.
51func NewRenderer(out io.Writer) *Renderer {
52 return &Renderer{out: out}
53}
54
55// Resize tells the renderer the current terminal size.
56//
57// On a real size change we also issue a clear-screen so the next Draw
58// starts from a blank slate. Without the clear, characters from the
59// old (wider) layout linger past the new right edge and rows from
60// before the new bottom hang around as garbage.
61func (r *Renderer) Resize(cols, rows int) {
62 if cols != r.cols || rows != r.rows {
63 r.cols = cols
64 r.rows = rows
65 r.prev = nil
66 r.logChat = nil
67 r.logBottom = nil
68 r.logLines = nil
69 r.logViewportTop = 0
70 r.logHardwareRow = 0
71 r.logInit = false
72 if r.out != nil {
73 _, _ = io.WriteString(r.out, SeqClearScreen)
74 }
75 }
76}
77
78// Clear forces a full repaint on the next Draw and clears the screen
79// plus scrollback. In main-screen flow mode this is required whenever
80// already-emitted transcript layout changes (for example ctrl+o
81// expand/collapse), because terminal scrollback cannot be edited
82// reliably once printed.
83func (r *Renderer) Clear() {
84 r.prev = nil
85 r.logChat = nil
86 r.logBottom = nil
87 r.logLines = nil
88 r.logViewportTop = 0
89 r.logHardwareRow = 0
90 r.logInit = false
91 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
92}
93
94// Invalidate forces a full repaint on the next Draw without clearing the
95// whole terminal first. Useful when the cached diff is unreliable but a
96// visible full-screen flash would be too distracting.
97func (r *Renderer) Invalidate() {
98 r.prev = nil
99 r.logLines = nil
100}
101
102// Draw updates the terminal so that the visible frame ends with the
103// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
104// the lines slice indicating where to place the terminal cursor; use
105// -1 to hide it.
106// containsImageEscape reports whether the line carries an inline-image
107// escape we must repaint rather than diff against the previous frame.
108func containsImageEscape(s string) bool {
109 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
110}
111
112// truncateToWidth clips s so its on-screen width doesn't exceed cols
113// cells, preserving ANSI CSI escape sequences (which don't consume
114// cells). Lines carrying an inline-image escape are returned as-is
115// since we can't measure their painted size.
116//
117// Fast path: a byte-length <= cols is a conservative upper bound
118// guaranteeing the cell width is also <= cols, so we skip all the
119// rune-width math. That covers the vast majority of lines in a
120// transcript (narrow terminals wrap early; wide ones leave headroom).
121func truncateToWidth(s string, cols int) string {
122 if cols <= 0 || containsImageEscape(s) {
123 return s
124 }
125 if len(s) <= cols {
126 return s
127 }
128 var out strings.Builder
129 out.Grow(len(s))
130 seen := 0
131 runes := []rune(s)
132 for i := 0; i < len(runes); {
133 r := runes[i]
134 // CSI escape sequence (ESC [ ... final): zero-width.
135 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
136 out.WriteRune(r)
137 out.WriteRune(runes[i+1])
138 i += 2
139 for i < len(runes) {
140 c := runes[i]
141 out.WriteRune(c)
142 i++
143 if c >= 0x40 && c <= 0x7e {
144 break
145 }
146 }
147 continue
148 }
149 rw := runewidthRune(r)
150 if seen+rw > cols {
151 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
152 // so background colors and cleanup sequences survive.
153 for i < len(runes) {
154 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
155 out.WriteRune(runes[i])
156 out.WriteRune(runes[i+1])
157 i += 2
158 for i < len(runes) {
159 c := runes[i]
160 out.WriteRune(c)
161 i++
162 if c >= 0x40 && c <= 0x7e {
163 break
164 }
165 }
166 } else {
167 break
168 }
169 }
170 break
171 }
172 out.WriteRune(r)
173 seen += rw
174 i++
175 }
176 return out.String()
177}
178
179func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
180 if r.cols == 0 || r.rows == 0 {
181 return
182 }
183 // Bottom-align: only the last r.rows lines are visible.
184 visible := lines
185 if len(visible) > r.rows {
186 visible = visible[len(visible)-r.rows:]
187 cursorRow -= len(lines) - len(visible)
188 }
189 // Pad to r.rows with empty lines at the top. Every line is also
190 // hard-truncated to cols so the terminal never soft-wraps our output
191 // (which would push the status bar out of its row).
192 frame := make([]string, r.rows)
193 top := r.rows - len(visible)
194 for i := 0; i < top; i++ {
195 frame[i] = ""
196 }
197 for i, line := range visible {
198 frame[top+i] = truncateToWidth(line, r.cols)
199 }
200
201 var w strings.Builder
202 w.WriteString(SeqSynchronizedOn)
203 w.WriteString(SeqHideCursor)
204
205 // When inline images are in play we always full-repaint (clear
206 // screen first, then rewrite every row). Terminals manage image
207 // pixels in a layer we cannot diff against, so the per-line cache
208 // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
209 // the common code path below is the fast cached diff.
210 curHasImage := false
211 curHasKittyImage := false
212 for _, l := range frame {
213 if containsImageEscape(l) {
214 curHasImage = true
215 if strings.Contains(l, "\x1b_G") {
216 curHasKittyImage = true
217 }
218 }
219 }
220 forceAll := curHasImage || r.prevHadImage
221 if forceAll {
222 w.WriteString(SeqClearScreen)
223 if curHasKittyImage {
224 // Delete previously placed kitty images once per frame,
225 // before rewriting all rows. Doing this inside each image
226 // escape makes only the last image in the frame survive.
227 w.WriteString("\x1b_Ga=d\x1b\\")
228 }
229 }
230
231 // Detect selection highlights: if the current OR previous frame
232 // has selection-background rows, force full repaint. VS Code's
233 // terminal doesn't reliably clear background colors on row
234 // overwrites, leaving ghost highlights behind.
235 hasSelection := false
236 for _, l := range frame {
237 if strings.Contains(l, "\x1b[48;5;") {
238 hasSelection = true
239 break
240 }
241 }
242 if !hasSelection && r.prev != nil {
243 for _, l := range r.prev {
244 if strings.Contains(l, "\x1b[48;5;") {
245 hasSelection = true
246 break
247 }
248 }
249 }
250
251 full := r.prev == nil || len(r.prev) != r.rows
252 for i := 0; i < r.rows; i++ {
253 if full || forceAll || hasSelection || r.prev[i] != frame[i] {
254 w.WriteString(MoveTo(i+1, 1))
255 w.WriteString("\x1b[0m") // reset all attributes first
256 w.WriteString(SeqClearLine)
257 w.WriteString(frame[i])
258 }
259 }
260
261 if cursorRow >= 0 {
262 absRow := top + cursorRow + 1
263 absCol := cursorCol + 1
264 if absRow >= 1 && absRow <= r.rows {
265 w.WriteString(MoveTo(absRow, absCol))
266 w.WriteString(SeqShowCursor)
267 }
268 }
269 w.WriteString(SeqSynchronizedOff)
270
271 _, _ = io.WriteString(r.out, w.String())
272
273 r.prev = frame
274 r.prevHadImage = curHasImage
275 r.cursorRow = cursorRow
276 r.cursorCol = cursorCol
277}
278
279// DrawLog renders zot in the terminal's main screen as normal terminal
280// flow rather than a fixed full-screen frame. Chat lines are emitted once
281// into the host terminal scrollback; the current bottom block (dialogs,
282// slash popup, status, editor) is erased and redrawn in place at the end.
283//
284// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
285func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
286 if r.cols == 0 || r.rows == 0 {
287 return
288 }
289 if len(bottom) == 0 {
290 bottom = []string{""}
291 }
292 chatFrame := make([]string, len(chat))
293 for i, line := range chat {
294 chatFrame[i] = truncateToWidth(line, r.cols)
295 }
296 bottomFrame := make([]string, len(bottom))
297 for i, line := range bottom {
298 bottomFrame[i] = truncateToWidth(line, r.cols)
299 }
300
301 // Always reserve one real row below the editor/status band. This is
302 // renderer-owned (not a best-effort trailing blank in the caller's
303 // bottom block), so the logical-buffer diff keeps it visible and cursor
304 // placement remains relative to the editor itself.
305 const bottomMarginRows = 1
306 lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
307 lines = append(lines, chatFrame...)
308 lines = append(lines, bottomFrame...)
309 for range bottomMarginRows {
310 lines = append(lines, "")
311 }
312 if len(lines) == 0 {
313 lines = []string{""}
314 }
315
316 cursorTargetRow := -1
317 if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
318 cursorTargetRow = len(chatFrame) + cursorBottomRow
319 }
320
321 var w strings.Builder
322 w.WriteString(SeqSynchronizedOn)
323 w.WriteString(SeqHideCursor)
324
325 writeFull := func(clear bool) {
326 if clear {
327 w.WriteString(SeqDeleteKittyImages)
328 w.WriteString(SeqClearScreen)
329 w.WriteString(SeqClearScrollback)
330 w.WriteString(MoveTo(1, 1))
331 }
332 for idx, line := range lines {
333 if idx > 0 {
334 w.WriteString("\r\n")
335 }
336 w.WriteString("\x1b[0m")
337 w.WriteString(SeqClearLine)
338 w.WriteString(line)
339 }
340 r.logHardwareRow = len(lines) - 1
341 r.logViewportTop = len(lines) - r.rows
342 if r.logViewportTop < 0 {
343 r.logViewportTop = 0
344 }
345 }
346
347 moveToLogicalRow := func(targetRow int) {
348 if targetRow < 0 {
349 targetRow = 0
350 }
351 if targetRow >= len(lines) {
352 targetRow = len(lines) - 1
353 }
354 viewportBottom := r.logViewportTop + r.rows - 1
355 if targetRow > viewportBottom {
356 currentScreenRow := r.logHardwareRow - r.logViewportTop
357 if currentScreenRow < 0 {
358 currentScreenRow = 0
359 }
360 if currentScreenRow >= r.rows {
361 currentScreenRow = r.rows - 1
362 }
363 moveToBottom := r.rows - 1 - currentScreenRow
364 if moveToBottom > 0 {
365 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
366 }
367 scroll := targetRow - viewportBottom
368 for s := 0; s < scroll; s++ {
369 w.WriteString("\r\n")
370 }
371 r.logViewportTop += scroll
372 r.logHardwareRow = targetRow
373 return
374 }
375 currentScreenRow := r.logHardwareRow - r.logViewportTop
376 targetScreenRow := targetRow - r.logViewportTop
377 lineDiff := targetScreenRow - currentScreenRow
378 if lineDiff > 0 {
379 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
380 } else if lineDiff < 0 {
381 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
382 }
383 r.logHardwareRow = targetRow
384 }
385
386 positionCursor := func() {
387 if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
388 return
389 }
390 moveToLogicalRow(cursorTargetRow)
391 w.WriteString("\r")
392 if cursorCol > 0 {
393 w.WriteString("\x1b[" + itoa(cursorCol) + "C")
394 }
395 w.WriteString(SeqShowCursor)
396 }
397
398 full := !r.logInit || len(r.logLines) == 0
399 if full {
400 writeFull(true)
401 r.logInit = true
402 } else {
403 firstChanged := -1
404 lastChanged := -1
405 maxLines := len(lines)
406 if len(r.logLines) > maxLines {
407 maxLines = len(r.logLines)
408 }
409 for idx := 0; idx < maxLines; idx++ {
410 oldLine := ""
411 if idx < len(r.logLines) {
412 oldLine = r.logLines[idx]
413 }
414 newLine := ""
415 if idx < len(lines) {
416 newLine = lines[idx]
417 }
418 if oldLine != newLine {
419 if firstChanged == -1 {
420 firstChanged = idx
421 }
422 lastChanged = idx
423 }
424 }
425
426 if firstChanged == -1 {
427 // No content changes; the final cursor positioning below may still
428 // move the hardware cursor if the editor cursor changed.
429 } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop {
430 // Shrinks and changes above the visible viewport cannot be safely
431 // patched in terminal scrollback. Replay the current logical buffer
432 // instead of risking stale duplicated rows.
433 writeFull(true)
434 } else {
435 prevViewportTop := r.logViewportTop
436 viewportTop := prevViewportTop
437 hardwareRow := r.logHardwareRow
438 prevViewportBottom := prevViewportTop + r.rows - 1
439 appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
440 moveTarget := firstChanged
441 if appendStart {
442 moveTarget = firstChanged - 1
443 }
444
445 if moveTarget > prevViewportBottom {
446 currentScreenRow := hardwareRow - prevViewportTop
447 if currentScreenRow < 0 {
448 currentScreenRow = 0
449 }
450 if currentScreenRow >= r.rows {
451 currentScreenRow = r.rows - 1
452 }
453 moveToBottom := r.rows - 1 - currentScreenRow
454 if moveToBottom > 0 {
455 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
456 }
457 scroll := moveTarget - prevViewportBottom
458 for s := 0; s < scroll; s++ {
459 w.WriteString("\r\n")
460 }
461 prevViewportTop += scroll
462 viewportTop += scroll
463 hardwareRow = moveTarget
464 }
465
466 currentScreenRow := hardwareRow - prevViewportTop
467 targetScreenRow := moveTarget - viewportTop
468 lineDiff := targetScreenRow - currentScreenRow
469 if lineDiff > 0 {
470 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
471 } else if lineDiff < 0 {
472 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
473 }
474 if appendStart {
475 w.WriteString("\r\n")
476 } else {
477 w.WriteString("\r")
478 }
479
480 renderEnd := lastChanged
481 if renderEnd >= len(lines) {
482 renderEnd = len(lines) - 1
483 }
484 for idx := firstChanged; idx <= renderEnd; idx++ {
485 if idx > firstChanged {
486 w.WriteString("\r\n")
487 }
488 w.WriteString("\x1b[0m")
489 w.WriteString(SeqClearLine)
490 w.WriteString(lines[idx])
491 }
492 r.logHardwareRow = renderEnd
493 r.logViewportTop = viewportTop
494 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
495 r.logViewportTop = minTop
496 }
497 if r.logViewportTop < 0 {
498 r.logViewportTop = 0
499 }
500 }
501 }
502
503 // Re-anchor the terminal viewport at the true end of the logical
504 // buffer on every frame. During busy turns most redraws only mutate
505 // spinner/live rows above the editor; without touching the final margin
506 // row, the terminal can visually lose the bottom padding even though it
507 // still exists in r.logLines.
508 if len(lines) > 0 {
509 moveToLogicalRow(len(lines) - 1)
510 w.WriteString("\r")
511 w.WriteString("\x1b[0m")
512 w.WriteString(SeqClearLine)
513 }
514 positionCursor()
515 w.WriteString(SeqSynchronizedOff)
516 _, _ = io.WriteString(r.out, w.String())
517
518 r.logChat = append(r.logChat[:0], chatFrame...)
519 r.logBottom = append(r.logBottom[:0], bottomFrame...)
520 r.logLines = append(r.logLines[:0], lines...)
521 r.cursorRow = cursorBottomRow
522 r.cursorCol = cursorCol
523}
524
525func writeBlock(w *strings.Builder, lines []string) {
526 for i, line := range lines {
527 w.WriteString("\x1b[0m")
528 w.WriteString(SeqClearLine)
529 w.WriteString(line)
530 if i < len(lines)-1 {
531 w.WriteString("\r\n")
532 }
533 }
534}
535
536func tailTruncated(lines []string, maxRows, cols int) []string {
537 if maxRows <= 0 {
538 return nil
539 }
540 if len(lines) > maxRows {
541 lines = lines[len(lines)-maxRows:]
542 }
543 out := make([]string, len(lines))
544 for i, line := range lines {
545 out[i] = truncateToWidth(line, cols)
546 }
547 return out
548}