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