1package tui
2
3import (
4 "io"
5 "os"
6 "strings"
7
8 "github.com/mattn/go-runewidth"
9)
10
11// runewidthRune reports the number of cells a rune occupies, pinned
12// here so the renderer does not depend on the editor's helper.
13func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
14
15// Renderer maintains a previous frame and writes only the lines that
16// changed on each Draw(). Callers pass a full target frame (slice of
17// styled lines, already wrapped to width).
18type Renderer struct {
19 out io.Writer
20 prev []string
21 rows int // terminal rows
22 cols int // terminal cols
23
24 // Cursor position after last draw (for placing input cursor).
25 cursorRow int
26 cursorCol int
27
28 // hideCursor when true prevents ShowCursor from being emitted.
29 hideCursor bool
30
31 // prevHadImage tracks whether the previous frame contained an
32 // inline-image escape so we can force a full clear+repaint whenever
33 // the image set changes. Only matters when inline images are
34 // enabled via ZOT_INLINE_IMAGES; defaults to false.
35 prevHadImage bool
36
37 // Main-screen flow renderer state. logLines is the full logical
38 // buffer (chat + live bottom band) from the previous DrawLog call.
39 // logViewportTop/logHardwareRow track where that logical buffer sits
40 // in the terminal's visible viewport so we can diff safely, and bail
41 // out to clear+replay when the diff would touch rows that are no
42 // longer addressable.
43 logChat []string
44 logBottom []string
45 logLines []string
46 logViewportTop int
47 logHardwareRow int
48 logInit bool
49
50 // keepScrollback is true when we must NOT emit \x1b[3J
51 // (erase-in-display 3, "clear scrollback").
52 //
53 // VS Code's integrated terminal (xterm.js) interprets \x1b[3J
54 // as "also snap the viewport to the top of the remaining
55 // buffer." Once the user has reopened a terminal with VS
56 // Code's persistent-sessions feature on, there is real
57 // replayed scrollback above the live cursor, and the snap is
58 // visible: the host scrollbar jumps to the top every time we
59 // do a full repaint (first paint, Ctrl+L via Renderer.Clear,
60 // any writeFull(true) shrink). On every other terminal we
61 // tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal)
62 // \x1b[3J just drops scrollback rows without moving the
63 // viewport, which is what we actually want.
64 //
65 // The trade-off when keepScrollback is true: stale zot frames
66 // remain in scrollback above the live view, so scrolling up
67 // in VS Code's terminal shows old (already-superseded) zot
68 // output. That is strictly less disruptive than the
69 // scrollbar yanking to top on every Ctrl+L, and it is a
70 // limitation specific to VS Code's terminal that we have no
71 // way to work around without breaking other terminals.
72 keepScrollback bool
73}
74
75// NewRenderer returns a renderer that writes to out.
76//
77// Detects VS Code's integrated terminal via $TERM_PROGRAM and, when
78// detected, disables emission of \x1b[3J for the reasons documented
79// on Renderer.keepScrollback. The env var is set by VS Code itself
80// (and by Cursor, which forks VS Code's terminal — same xterm.js,
81// same bug), so no user configuration is required.
82func NewRenderer(out io.Writer) *Renderer {
83 return &Renderer{
84 out: out,
85 keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode",
86 }
87}
88
89// Resize tells the renderer the current terminal size.
90//
91// On a real size change we also issue a clear-screen so the next Draw
92// starts from a blank slate. Without the clear, characters from the
93// old (wider) layout linger past the new right edge and rows from
94// before the new bottom hang around as garbage.
95func (r *Renderer) ResetScrollRegion() {
96 if r.out != nil {
97 _, _ = io.WriteString(r.out, SeqResetScrollRegion)
98 }
99}
100
101func (r *Renderer) Resize(cols, rows int) {
102 if cols != r.cols || rows != r.rows {
103 r.cols = cols
104 r.rows = rows
105 r.prev = nil
106 r.logChat = nil
107 r.logBottom = nil
108 r.logLines = nil
109 r.logViewportTop = 0
110 r.logHardwareRow = 0
111 r.logInit = false
112 if r.out != nil {
113 // Clear both screen and (where safe) scrollback so stale
114 // content from the old width doesn't bleed through. Move
115 // to (1,1) so the next DrawLog/writeFull starts from a
116 // clean slate. Use the no-home variant: the explicit
117 // MoveTo below sets the cursor without triggering VS
118 // Code's viewport-snap. See Renderer.keepScrollback for
119 // why we skip \x1b[3J on VS Code's terminal.
120 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
121 }
122 }
123}
124
125// Clear forces a full repaint on the next Draw and clears the screen
126// plus scrollback. In main-screen flow mode this is required whenever
127// already-emitted transcript layout changes (for example ctrl+o
128// expand/collapse), because terminal scrollback cannot be edited
129// reliably once printed.
130func (r *Renderer) Clear() {
131 r.prev = nil
132 r.logChat = nil
133 r.logBottom = nil
134 r.logLines = nil
135 r.logViewportTop = 0
136 r.logHardwareRow = 0
137 r.logInit = false
138 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
139}
140
141// clearScrollbackSeq returns the scrollback-clear escape, or the
142// empty string when we are running under a terminal where emitting
143// it has user-visible side effects (see Renderer.keepScrollback).
144// Callers concatenate this into a larger control sequence; an empty
145// return value is a no-op there.
146func (r *Renderer) clearScrollbackSeq() string {
147 if r.keepScrollback {
148 return ""
149 }
150 return SeqClearScrollback
151}
152
153// Invalidate forces a full repaint on the next Draw without clearing the
154// whole terminal first. Useful when the cached diff is unreliable but a
155// visible full-screen flash would be too distracting.
156func (r *Renderer) Invalidate() {
157 r.prev = nil
158 r.logLines = nil
159}
160
161// Draw updates the terminal so that the visible frame ends with the
162// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
163// the lines slice indicating where to place the terminal cursor; use
164// -1 to hide it.
165// containsImageEscape reports whether the line carries an inline-image
166// escape we must repaint rather than diff against the previous frame.
167func containsImageEscape(s string) bool {
168 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
169}
170
171// truncateToWidth clips s so its on-screen width doesn't exceed cols
172// cells, preserving ANSI CSI escape sequences (which don't consume
173// cells). Lines carrying an inline-image escape are returned as-is
174// since we can't measure their painted size.
175//
176// Fast path: a byte-length <= cols is a conservative upper bound
177// guaranteeing the cell width is also <= cols, so we skip all the
178// rune-width math. That covers the vast majority of lines in a
179// transcript (narrow terminals wrap early; wide ones leave headroom).
180func truncateToWidth(s string, cols int) string {
181 if cols <= 0 || containsImageEscape(s) {
182 return s
183 }
184 if len(s) <= cols {
185 return s
186 }
187 var out strings.Builder
188 out.Grow(len(s))
189 seen := 0
190 runes := []rune(s)
191 for i := 0; i < len(runes); {
192 r := runes[i]
193 // CSI escape sequence (ESC [ ... final): zero-width.
194 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
195 out.WriteRune(r)
196 out.WriteRune(runes[i+1])
197 i += 2
198 for i < len(runes) {
199 c := runes[i]
200 out.WriteRune(c)
201 i++
202 if c >= 0x40 && c <= 0x7e {
203 break
204 }
205 }
206 continue
207 }
208 rw := runewidthRune(r)
209 if seen+rw > cols {
210 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
211 // so background colors and cleanup sequences survive.
212 for i < len(runes) {
213 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
214 out.WriteRune(runes[i])
215 out.WriteRune(runes[i+1])
216 i += 2
217 for i < len(runes) {
218 c := runes[i]
219 out.WriteRune(c)
220 i++
221 if c >= 0x40 && c <= 0x7e {
222 break
223 }
224 }
225 } else {
226 break
227 }
228 }
229 break
230 }
231 out.WriteRune(r)
232 seen += rw
233 i++
234 }
235 return out.String()
236}
237
238func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
239 if r.cols == 0 || r.rows == 0 {
240 return
241 }
242 // Bottom-align: only the last r.rows lines are visible.
243 visible := lines
244 if len(visible) > r.rows {
245 visible = visible[len(visible)-r.rows:]
246 cursorRow -= len(lines) - len(visible)
247 }
248 // Pad to r.rows with empty lines at the top. Every line is also
249 // hard-truncated to cols so the terminal never soft-wraps our output
250 // (which would push the status bar out of its row).
251 frame := make([]string, r.rows)
252 top := r.rows - len(visible)
253 for i := 0; i < top; i++ {
254 frame[i] = ""
255 }
256 for i, line := range visible {
257 frame[top+i] = truncateToWidth(line, r.cols)
258 }
259
260 var w strings.Builder
261 w.WriteString(SeqSynchronizedOn)
262 w.WriteString(SeqHideCursor)
263
264 // When inline images are in play we always full-repaint (clear
265 // screen first, then rewrite every row). Terminals manage image
266 // pixels in a layer we cannot diff against, so the per-line cache
267 // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
268 // the common code path below is the fast cached diff.
269 curHasImage := false
270 curHasKittyImage := false
271 for _, l := range frame {
272 if containsImageEscape(l) {
273 curHasImage = true
274 if strings.Contains(l, "\x1b_G") {
275 curHasKittyImage = true
276 }
277 }
278 }
279 forceAll := curHasImage || r.prevHadImage
280 if forceAll {
281 // No-home variant: the per-row MoveTo(i+1, 1) writes in the
282 // loop below position the cursor for every painted row, so
283 // the embedded \x1b[H would only serve to make VS Code snap
284 // its scrollbar to the top of the viewport on every image or
285 // selection-highlight frame.
286 w.WriteString(SeqClearScreenNoHome)
287 if curHasKittyImage {
288 // Delete previously placed kitty images once per frame,
289 // before rewriting all rows. Doing this inside each image
290 // escape makes only the last image in the frame survive.
291 w.WriteString("\x1b_Ga=d\x1b\\")
292 }
293 }
294
295 // Detect selection highlights: if the current OR previous frame
296 // has selection-background rows, force full repaint. VS Code's
297 // terminal doesn't reliably clear background colors on row
298 // overwrites, leaving ghost highlights behind.
299 hasSelection := false
300 for _, l := range frame {
301 if strings.Contains(l, "\x1b[48;5;") {
302 hasSelection = true
303 break
304 }
305 }
306 if !hasSelection && r.prev != nil {
307 for _, l := range r.prev {
308 if strings.Contains(l, "\x1b[48;5;") {
309 hasSelection = true
310 break
311 }
312 }
313 }
314
315 full := r.prev == nil || len(r.prev) != r.rows
316 for i := 0; i < r.rows; i++ {
317 if full || forceAll || hasSelection || r.prev[i] != frame[i] {
318 w.WriteString(MoveTo(i+1, 1))
319 w.WriteString("\x1b[0m") // reset all attributes first
320 w.WriteString(SeqClearLine)
321 w.WriteString(frame[i])
322 }
323 }
324
325 if cursorRow >= 0 {
326 absRow := top + cursorRow + 1
327 absCol := cursorCol + 1
328 if absRow >= 1 && absRow <= r.rows {
329 w.WriteString(MoveTo(absRow, absCol))
330 w.WriteString(SeqShowCursor)
331 }
332 }
333 w.WriteString(SeqSynchronizedOff)
334
335 _, _ = io.WriteString(r.out, w.String())
336
337 r.prev = frame
338 r.prevHadImage = curHasImage
339 r.cursorRow = cursorRow
340 r.cursorCol = cursorCol
341}
342
343// DrawLog renders zot in the terminal's main screen as normal terminal
344// flow rather than a fixed full-screen frame. Chat lines are emitted once
345// into the host terminal scrollback; the current bottom block (dialogs,
346// slash popup, status, editor) is erased and redrawn in place at the end.
347//
348// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
349func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
350 if r.cols == 0 || r.rows == 0 {
351 return
352 }
353 if len(bottom) == 0 {
354 bottom = []string{""}
355 }
356 chatFrame := make([]string, len(chat))
357 for i, line := range chat {
358 chatFrame[i] = truncateToWidth(line, r.cols)
359 }
360 bottomFrame := make([]string, len(bottom))
361 for i, line := range bottom {
362 bottomFrame[i] = truncateToWidth(line, r.cols)
363 }
364
365 // Always reserve one real row below the editor/status band. This is
366 // renderer-owned (not a best-effort trailing blank in the caller's
367 // bottom block), so the logical-buffer diff keeps it visible and cursor
368 // placement remains relative to the editor itself.
369 const bottomMarginRows = 1
370 lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
371 lines = append(lines, chatFrame...)
372 lines = append(lines, bottomFrame...)
373 for range bottomMarginRows {
374 lines = append(lines, "")
375 }
376 if len(lines) == 0 {
377 lines = []string{""}
378 }
379
380 cursorTargetRow := -1
381 if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
382 cursorTargetRow = len(chatFrame) + cursorBottomRow
383 }
384
385 // Idle no-op fast path. When the buffer AND the cursor position
386 // haven't changed since the last DrawLog, emit nothing. The
387 // alternative — always writing SeqHideCursor + cursor-position +
388 // SeqShowCursor — resets the terminal's cursor blink timer on
389 // every tick. At our 120ms animation cadence that means the
390 // caret in an idle dialog editor (e.g. an open swarm transcript
391 // for an agent that's currently idle) appears as a solid block
392 // that never blinks, because we keep "showing" it before the
393 // terminal can blink it off. Bailing out here lets the OS run
394 // its blink cycle.
395 if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
396 return
397 }
398
399 var w strings.Builder
400 w.WriteString(SeqSynchronizedOn)
401 w.WriteString(SeqHideCursor)
402
403 writeFull := func(clear bool) {
404 if clear {
405 w.WriteString(SeqDeleteKittyImages)
406 w.WriteString(SeqClearScreenNoHome)
407 w.WriteString(r.clearScrollbackSeq())
408 w.WriteString(MoveTo(1, 1))
409 }
410 for idx, line := range lines {
411 if idx > 0 {
412 w.WriteString("\r\n")
413 }
414 w.WriteString("\x1b[0m")
415 w.WriteString(SeqClearLine)
416 w.WriteString(line)
417 }
418 r.logHardwareRow = len(lines) - 1
419 r.logViewportTop = len(lines) - r.rows
420 if r.logViewportTop < 0 {
421 r.logViewportTop = 0
422 }
423 }
424
425 moveToLogicalRow := func(targetRow int) {
426 if targetRow < 0 {
427 targetRow = 0
428 }
429 if targetRow >= len(lines) {
430 targetRow = len(lines) - 1
431 }
432 viewportBottom := r.logViewportTop + r.rows - 1
433 if targetRow > viewportBottom {
434 currentScreenRow := r.logHardwareRow - r.logViewportTop
435 if currentScreenRow < 0 {
436 currentScreenRow = 0
437 }
438 if currentScreenRow >= r.rows {
439 currentScreenRow = r.rows - 1
440 }
441 moveToBottom := r.rows - 1 - currentScreenRow
442 if moveToBottom > 0 {
443 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
444 }
445 scroll := targetRow - viewportBottom
446 for s := 0; s < scroll; s++ {
447 w.WriteString("\r\n")
448 }
449 r.logViewportTop += scroll
450 r.logHardwareRow = targetRow
451 return
452 }
453 currentScreenRow := r.logHardwareRow - r.logViewportTop
454 targetScreenRow := targetRow - r.logViewportTop
455 lineDiff := targetScreenRow - currentScreenRow
456 if lineDiff > 0 {
457 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
458 } else if lineDiff < 0 {
459 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
460 }
461 r.logHardwareRow = targetRow
462 }
463
464 positionCursor := func() {
465 if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
466 return
467 }
468 moveToLogicalRow(cursorTargetRow)
469 w.WriteString("\r")
470 if cursorCol > 0 {
471 w.WriteString("\x1b[" + itoa(cursorCol) + "C")
472 }
473 w.WriteString(SeqShowCursor)
474 }
475
476 // Selection-highlight workaround removed: it could mis-invalidate
477 // user-bubble padding rows whose colored bg made botHasHL trip,
478 // causing the next diff pass to leave those rows visually thinned
479 // because the cached entry was the \x00 sentinel rather than the
480 // real previous bg-colored row.
481
482 full := !r.logInit || len(r.logLines) == 0
483 if full {
484 writeFull(true)
485 r.logInit = true
486 } else {
487 firstChanged := -1
488 lastChanged := -1
489 maxLines := len(lines)
490 if len(r.logLines) > maxLines {
491 maxLines = len(r.logLines)
492 }
493 for idx := 0; idx < maxLines; idx++ {
494 oldLine := ""
495 if idx < len(r.logLines) {
496 oldLine = r.logLines[idx]
497 }
498 newLine := ""
499 if idx < len(lines) {
500 newLine = lines[idx]
501 }
502 if oldLine != newLine {
503 if firstChanged == -1 {
504 firstChanged = idx
505 }
506 lastChanged = idx
507 }
508 }
509 // Buffer grew but the appended rows were empty (or otherwise
510 // equal to the implicit "" past the old end). The diff above
511 // won't flag those rows, yet the renderer still needs to
512 // advance its hardware cursor / viewport tracking past them so
513 // the next render starts from the correct position. Treat the
514 // extension as changed.
515 if len(lines) > len(r.logLines) {
516 if firstChanged == -1 {
517 firstChanged = len(r.logLines)
518 }
519 if lastChanged < len(lines)-1 {
520 lastChanged = len(lines) - 1
521 }
522 }
523
524 if firstChanged == -1 {
525 // No content changes; the final cursor positioning below may still
526 // move the hardware cursor if the editor cursor changed.
527 } else if firstChanged < r.logViewportTop {
528 // Changes above the visible viewport cannot be patched safely.
529 writeFull(true)
530 } else {
531 prevViewportTop := r.logViewportTop
532 viewportTop := prevViewportTop
533 hardwareRow := r.logHardwareRow
534 prevViewportBottom := prevViewportTop + r.rows - 1
535 appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
536 moveTarget := firstChanged
537 if appendStart {
538 moveTarget = firstChanged - 1
539 }
540
541 if moveTarget > prevViewportBottom {
542 currentScreenRow := hardwareRow - prevViewportTop
543 if currentScreenRow < 0 {
544 currentScreenRow = 0
545 }
546 if currentScreenRow >= r.rows {
547 currentScreenRow = r.rows - 1
548 }
549 moveToBottom := r.rows - 1 - currentScreenRow
550 if moveToBottom > 0 {
551 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
552 }
553 scroll := moveTarget - prevViewportBottom
554 for s := 0; s < scroll; s++ {
555 w.WriteString("\r\n")
556 }
557 prevViewportTop += scroll
558 viewportTop += scroll
559 hardwareRow = moveTarget
560 }
561
562 currentScreenRow := hardwareRow - prevViewportTop
563 targetScreenRow := moveTarget - viewportTop
564 lineDiff := targetScreenRow - currentScreenRow
565 if lineDiff > 0 {
566 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
567 } else if lineDiff < 0 {
568 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
569 }
570 if appendStart {
571 w.WriteString("\r\n")
572 } else {
573 w.WriteString("\r")
574 }
575
576 renderEnd := lastChanged
577 if renderEnd >= len(lines) {
578 renderEnd = len(lines) - 1
579 }
580 for idx := firstChanged; idx <= renderEnd; idx++ {
581 if idx > firstChanged {
582 w.WriteString("\r\n")
583 }
584 w.WriteString("\x1b[0m")
585 w.WriteString(SeqClearLine)
586 w.WriteString(lines[idx])
587 }
588 finalRow := renderEnd
589 if len(r.logLines) > len(lines) {
590 extra := len(r.logLines) - len(lines)
591 if extra > r.rows {
592 writeFull(true)
593 } else {
594 for e := 0; e < extra; e++ {
595 w.WriteString("\x1b[1B")
596 w.WriteString("\r")
597 w.WriteString("\x1b[0m")
598 w.WriteString(SeqClearLine)
599 finalRow++
600 }
601 if extra > 0 {
602 w.WriteString("\x1b[" + itoa(extra) + "A")
603 finalRow -= extra
604 }
605 }
606 }
607 r.logHardwareRow = finalRow
608 r.logViewportTop = viewportTop
609 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
610 r.logViewportTop = minTop
611 }
612 if r.logViewportTop < 0 {
613 r.logViewportTop = 0
614 }
615 }
616 }
617
618 positionCursor()
619 w.WriteString(SeqSynchronizedOff)
620 _, _ = io.WriteString(r.out, w.String())
621
622 r.logChat = append(r.logChat[:0], chatFrame...)
623 r.logBottom = append(r.logBottom[:0], bottomFrame...)
624 r.logLines = append(r.logLines[:0], lines...)
625 r.cursorRow = cursorBottomRow
626 r.cursorCol = cursorCol
627}
628
629// sameLines reports whether two []string have the exact same
630// length and per-row contents. Used by DrawLog's idle no-op fast
631// path; cheap enough at our frame rates and far simpler than
632// hashing every byte.
633func sameLines(a, b []string) bool {
634 if len(a) != len(b) {
635 return false
636 }
637 for i := range a {
638 if a[i] != b[i] {
639 return false
640 }
641 }
642 return true
643}
644
645func writeBlock(w *strings.Builder, lines []string) {
646 for i, line := range lines {
647 w.WriteString("\x1b[0m")
648 w.WriteString(SeqClearLine)
649 w.WriteString(line)
650 if i < len(lines)-1 {
651 w.WriteString("\r\n")
652 }
653 }
654}
655
656func tailTruncated(lines []string, maxRows, cols int) []string {
657 if maxRows <= 0 {
658 return nil
659 }
660 if len(lines) > maxRows {
661 lines = lines[len(lines)-maxRows:]
662 }
663 out := make([]string, len(lines))
664 for i, line := range lines {
665 out[i] = truncateToWidth(line, cols)
666 }
667 return out
668}