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 // prevHadImage tracks whether the previous frame contained an
29 // inline-image escape so we can force a full clear+repaint whenever
30 // the image set changes. prevHadKittyImage additionally ensures stale
31 // Kitty image pixels are explicitly deleted when their layer disappears.
32 prevHadImage bool
33 prevHadKittyImage bool
34 floatingActive bool
35 drawingFloating bool
36 floatingRect FloatingPaneRect
37
38 // Main-screen flow renderer state. logLines is the full logical
39 // buffer (chat + live bottom band) from the previous DrawLog call.
40 // logViewportTop/logHardwareRow track where that logical buffer sits
41 // in the terminal's visible viewport so we can diff safely, and bail
42 // out to clear+replay when the diff would touch rows that are no
43 // longer addressable.
44 logChat []string
45 logBottom []string
46 logLines []string
47 // Raw logical rows let DrawLog reject an unchanged frame before
48 // truncating and repainting every transcript row.
49 logRawChat []string
50 logRawBottom []string
51 logRawCols int
52 logRawRows int
53 logRawContentCols int
54 logRawBackground string
55 logRawHasThemeBG bool
56 logViewportTop int
57 logHardwareRow int
58 logInit bool
59
60 // The right bar is a viewport overlay on top of the main-screen flow
61 // renderer. Keeping its cache separate prevents persistent chrome from
62 // replacing the transcript's logical buffer and native scrollback.
63 rightBarActive bool
64 rightBarLines []string
65 rightBarMainCols int
66 rightBarDimmed bool
67
68 // keepScrollback is true when we must NOT emit \x1b[3J
69 // (erase-in-display 3, "clear scrollback").
70 //
71 // VS Code's integrated terminal (xterm.js) interprets \x1b[3J
72 // as "also snap the viewport to the top of the remaining
73 // buffer." Once the user has reopened a terminal with VS
74 // Code's persistent-sessions feature on, there is real
75 // replayed scrollback above the live cursor, and the snap is
76 // visible: the host scrollbar jumps to the top every time we
77 // do a full repaint (first paint, Ctrl+L via Renderer.Clear,
78 // any writeFull(true) shrink). On every other terminal we
79 // tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal)
80 // \x1b[3J just drops scrollback rows without moving the
81 // viewport, which is what we actually want.
82 //
83 // The trade-off when keepScrollback is true: stale zut frames
84 // remain in scrollback above the live view, so scrolling up
85 // in VS Code's terminal shows old (already-superseded) zut
86 // output. That is strictly less disruptive than the
87 // scrollbar yanking to top on every Ctrl+L, and it is a
88 // limitation specific to VS Code's terminal that we have no
89 // way to work around without breaking other terminals.
90 keepScrollback bool
91
92 // theme is optional renderer-level styling applied at the final
93 // row-writing boundary. In particular, Theme.Background is painted
94 // as a full-width row background without making every View renderer
95 // know about terminal padding and reset semantics.
96 theme Theme
97 backgroundStyle string
98}
99
100// NewRenderer returns a renderer that writes to out.
101//
102// Detects VS Code's integrated terminal via $TERM_PROGRAM and, when
103// detected, disables emission of \x1b[3J for the reasons documented
104// on Renderer.keepScrollback. The env var is set by VS Code itself
105// (and by Cursor, which forks VS Code's terminal — same xterm.js,
106// same bug), so no user configuration is required.
107func NewRenderer(out io.Writer) *Renderer {
108 return &Renderer{
109 out: out,
110 keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode",
111 }
112}
113
114// SetTheme updates renderer-level terminal styling. Changing the
115// background affects every row, so cached frame state is invalidated.
116func (r *Renderer) SetTheme(th Theme) {
117 r.theme = th
118 r.backgroundStyle = th.BackgroundStyle()
119 r.Invalidate()
120}
121
122// Resize tells the renderer the current terminal size.
123//
124// On a real size change we also issue a clear-screen so the next Draw
125// starts from a blank slate. Without the clear, characters from the
126// old (wider) layout linger past the new right edge and rows from
127// before the new bottom hang around as garbage.
128func (r *Renderer) ResetScrollRegion() {
129 if r.out != nil {
130 _, _ = io.WriteString(r.out, SeqResetScrollRegion)
131 }
132}
133
134func (r *Renderer) Resize(cols, rows int) {
135 if cols != r.cols || rows != r.rows {
136 r.cols = cols
137 r.rows = rows
138 r.prev = nil
139 r.logChat = nil
140 r.logBottom = nil
141 r.logLines = nil
142 r.logViewportTop = 0
143 r.logHardwareRow = 0
144 r.logInit = false
145 r.rightBarActive = false
146 r.rightBarLines = nil
147 r.rightBarMainCols = 0
148 if r.out != nil {
149 if r.keepScrollback {
150 // A resize is a discrete user action, like Ctrl+L: the
151 // old-width frame must be fully purged or the in-place
152 // viewport clear leaves the scrolled-away rows behind
153 // (the user otherwise has to press Ctrl+L to fix it).
154 // Emit \x1b[3J to drop the retained scrollback and
155 // repaint clean, accepting VS Code's viewport-snap the
156 // same way Clear does.
157 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
158 } else {
159 // Clear both screen and (where safe) scrollback so stale
160 // content from the old width doesn't bleed through. Move
161 // to (1,1) so the next DrawLog/writeFull starts from a
162 // clean slate. Use the no-home variant: the explicit
163 // MoveTo below sets the cursor without triggering VS
164 // Code's viewport-snap.
165 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
166 }
167 }
168 }
169}
170
171// Clear forces a full repaint on the next Draw and clears the screen
172// plus scrollback. In main-screen flow mode this is required whenever
173// already-emitted transcript layout changes (for example ctrl+o
174// expand/collapse), because terminal scrollback cannot be edited
175// reliably once printed.
176func (r *Renderer) Clear() {
177 r.prev = nil
178 r.logChat = nil
179 r.logBottom = nil
180 r.logLines = nil
181 r.logViewportTop = 0
182 r.logHardwareRow = 0
183 r.logInit = false
184 r.rightBarActive = false
185 r.rightBarLines = nil
186 r.rightBarMainCols = 0
187 if r.keepScrollback {
188 // On VS Code's xterm.js the transcript is taller than the
189 // viewport, so an in-place clear (home + erase-to-end) only
190 // wipes the visible rows: the part that scrolled above the
191 // viewport stays in the retained buffer and the next full
192 // repaint stacks a duplicate above the live frame.
193 //
194 // Clear() is an explicit user refresh (Ctrl+L), so here we do
195 // emit \x1b[3J to actually drop that scrollback, then home and
196 // repaint. This is the one place we accept VS Code's
197 // viewport-snap, because the user asked for a clean screen.
198 // (Implicit repaints, e.g. closing a dialog, deliberately
199 // avoid this path so they never snap the viewport.)
200 if r.out != nil {
201 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
202 }
203 return
204 }
205 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
206}
207
208// clearScrollbackSeq returns the scrollback-clear escape, or the
209// empty string when we are running under a terminal where emitting
210// it has user-visible side effects (see Renderer.keepScrollback).
211// Callers concatenate this into a larger control sequence; an empty
212// return value is a no-op there.
213func (r *Renderer) clearScrollbackSeq() string {
214 if r.keepScrollback {
215 return ""
216 }
217 return SeqClearScrollback
218}
219
220// KeepsScrollback reports whether this renderer suppresses the
221// scrollback-clear escape (true under VS Code's terminal). Callers
222// use it to pick a viewport-safe full repaint (Invalidate) over a
223// scrollback-clearing one (Clear) when redrawing overlays.
224func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback }
225
226// Invalidate forces a full repaint on the next Draw without clearing the
227// whole terminal first. Useful when the cached diff is unreliable but a
228// visible full-screen flash would be too distracting.
229func (r *Renderer) Invalidate() {
230 r.prev = nil
231 r.logLines = nil
232}
233
234// Draw updates the terminal so that the visible frame ends with the
235// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
236// the lines slice indicating where to place the terminal cursor; use
237// -1 to hide it.
238// containsImageEscape reports whether the line carries an inline-image
239// escape we must repaint rather than diff against the previous frame.
240func containsImageEscape(s string) bool {
241 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
242}
243
244func imageFootprintBlank(s string) bool {
245 s = strings.ReplaceAll(stripANSI(s), imageFootprintSentinel, "")
246 s = strings.Trim(strings.TrimSpace(s), "│")
247 return strings.TrimSpace(s) == ""
248}
249
250// visibleTailHasImage reports whether an inline image or its blank terminal
251// footprint intersects the visible tail. Images earlier in retained scrollback
252// do not prevent ordinary text reflows from using the stable in-place path.
253func visibleTailHasImage(lines []string, viewportTop int) bool {
254 if viewportTop < 0 {
255 viewportTop = 0
256 }
257 if viewportTop >= len(lines) {
258 return false
259 }
260 for _, line := range lines[viewportTop:] {
261 if containsImageEscape(line) {
262 return true
263 }
264 }
265 if viewportTop == 0 || !imageFootprintBlank(lines[viewportTop]) {
266 return false
267 }
268 for idx := viewportTop - 1; idx >= 0; idx-- {
269 if containsImageEscape(lines[idx]) {
270 return true
271 }
272 if !imageFootprintBlank(lines[idx]) {
273 return false
274 }
275 }
276 return false
277}
278
279// paintBackgroundRow applies the optional theme background to a single
280// already-truncated terminal row. It pads with spaces to cols so the
281// background reaches the right edge, and re-applies the background
282// after full SGR resets inside the row so local styling does not punch
283// transparent holes through the global tint.
284func paintBackgroundRow(line string, cols int, background string) string {
285 if background == "" || cols <= 0 || containsImageEscape(line) {
286 return line
287 }
288 line = strings.ReplaceAll(line, reset, reset+background)
289 if w := visibleWidth(line); w < cols {
290 line += strings.Repeat(" ", cols-w)
291 }
292 return background + line + reset
293}
294
295// truncateToWidth clips s so its on-screen width doesn't exceed cols
296// cells, preserving ANSI CSI escape sequences (which don't consume
297// cells). Lines carrying an inline-image escape are returned as-is
298// since we can't measure their painted size.
299//
300// Fast path: a byte-length <= cols is a conservative upper bound
301// guaranteeing the cell width is also <= cols, so we skip all the
302// rune-width math. That covers the vast majority of lines in a
303// transcript (narrow terminals wrap early; wide ones leave headroom).
304func truncateToWidth(s string, cols int) string {
305 if cols <= 0 || containsImageEscape(s) {
306 return s
307 }
308 if len(s) <= cols {
309 return s
310 }
311 var out strings.Builder
312 out.Grow(len(s))
313 seen := 0
314 osc8Active := false
315 runes := []rune(s)
316 for i := 0; i < len(runes); {
317 r := runes[i]
318 // CSI escape sequence (ESC [ ... final): zero-width.
319 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
320 out.WriteRune(r)
321 out.WriteRune(runes[i+1])
322 i += 2
323 for i < len(runes) {
324 c := runes[i]
325 out.WriteRune(c)
326 i++
327 if c >= 0x40 && c <= 0x7e {
328 break
329 }
330 }
331 continue
332 }
333 // OSC string escape (BEL or ST terminated): zero-width and atomic.
334 if r == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
335 start := i
336 i += 2
337 for i < len(runes) {
338 c := runes[i]
339 i++
340 if c == 0x07 {
341 break
342 }
343 if c == 0x1b && i < len(runes) && runes[i] == '\\' {
344 i++
345 break
346 }
347 }
348 osc8Closer := start+5 < i && runes[start+2] == '8' && runes[start+3] == ';' && runes[start+4] == ';' &&
349 ((runes[start+5] == 0x07 && i == start+6) ||
350 (start+6 < i && runes[start+5] == 0x1b && runes[start+6] == '\\' && i == start+7))
351 if seen < cols || osc8Closer {
352 for _, c := range runes[start:i] {
353 out.WriteRune(c)
354 }
355 if runes[start+2] == '8' {
356 osc8Active = !osc8Closer
357 }
358 }
359 continue
360 }
361 rw := runewidthRune(r)
362 if seen+rw > cols {
363 if osc8Active {
364 // The remaining text is omitted, so close a hyperlink
365 // whose opener was already emitted before the boundary.
366 out.WriteString("\x1b]8;;\x07")
367 }
368 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
369 // so background colors and cleanup sequences survive.
370 for i < len(runes) {
371 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
372 out.WriteRune(runes[i])
373 out.WriteRune(runes[i+1])
374 i += 2
375 for i < len(runes) {
376 c := runes[i]
377 out.WriteRune(c)
378 i++
379 if c >= 0x40 && c <= 0x7e {
380 break
381 }
382 }
383 } else if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
384 // Do not append a new OSC sequence after the visible
385 // boundary: an OSC 8 opener would leak hyperlink state
386 // into the next terminal row. A matching OSC 8 closer is
387 // retained so a link opened before the boundary is closed.
388 keep := i+5 < len(runes) && runes[i+2] == '8' && runes[i+3] == ';' && runes[i+4] == ';' &&
389 (runes[i+5] == 0x07 || (i+6 < len(runes) && runes[i+5] == 0x1b && runes[i+6] == '\\'))
390 start := i
391 i += 2
392 for i < len(runes) {
393 c := runes[i]
394 i++
395 if c == 0x07 {
396 break
397 }
398 if c == 0x1b && i < len(runes) && runes[i] == '\\' {
399 i++
400 break
401 }
402 }
403 if keep {
404 for _, c := range runes[start:i] {
405 out.WriteRune(c)
406 }
407 }
408 } else {
409 break
410 }
411 }
412 break
413 }
414 out.WriteRune(r)
415 seen += rw
416 i++
417 }
418 return out.String()
419}
420
421func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
422 if r.floatingActive && !r.drawingFloating {
423 r.Invalidate()
424 r.floatingActive = false
425 }
426 if r.cols == 0 || r.rows == 0 {
427 return
428 }
429 // Bottom-align: only the last r.rows lines are visible.
430 visible := lines
431 if len(visible) > r.rows {
432 visible = visible[len(visible)-r.rows:]
433 cursorRow -= len(lines) - len(visible)
434 }
435 // Pad to r.rows with empty lines at the top. Every line is also
436 // hard-truncated to cols so the terminal never soft-wraps our output
437 // (which would push the status bar out of its row).
438 frame := make([]string, r.rows)
439 top := r.rows - len(visible)
440 for i := 0; i < top; i++ {
441 frame[i] = ""
442 }
443 for i, line := range visible {
444 frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
445 }
446 if r.theme.Background != nil {
447 for i := 0; i < top; i++ {
448 frame[i] = paintBackgroundRow("", r.cols, r.backgroundStyle)
449 }
450 }
451
452 var w strings.Builder
453 w.WriteString(SeqSynchronizedOn)
454 w.WriteString(SeqHideCursor)
455
456 // When inline images are in play we always full-repaint (clear
457 // screen first, then rewrite every row). Terminals manage image
458 // pixels in a layer we cannot diff against, so the per-line cache
459 // is unreliable. Inline images are opt-in via ZUT_INLINE_IMAGES;
460 // the common code path below is the fast cached diff.
461 curHasImage := false
462 curHasKittyImage := false
463 for _, l := range frame {
464 if containsImageEscape(l) {
465 curHasImage = true
466 if strings.Contains(l, "\x1b_G") {
467 curHasKittyImage = true
468 }
469 }
470 }
471 forceAll := curHasImage || r.prevHadImage
472 if forceAll {
473 // No-home variant: the per-row MoveTo(i+1, 1) writes in the
474 // loop below position the cursor for every painted row, so
475 // the embedded \x1b[H would only serve to make VS Code snap
476 // its scrollbar to the top of the viewport on every image or
477 // selection-highlight frame.
478 w.WriteString(SeqClearScreenNoHome)
479 if curHasKittyImage || r.prevHadKittyImage {
480 // Delete previously placed kitty images once per frame, before
481 // rewriting all rows. This also removes a former foreground image
482 // when the replacement frame contains no Kitty image escape.
483 w.WriteString(SeqDeleteKittyImages)
484 }
485 }
486
487 // Detect selection highlights: if the current OR previous frame
488 // has selection-background rows, force full repaint. VS Code's
489 // terminal doesn't reliably clear background colors on row
490 // overwrites, leaving ghost highlights behind.
491 hasSelection := false
492 if r.theme.Background == nil {
493 selectionBG := r.theme.bgPrefix(r.theme.SelectionBG)
494 for _, l := range frame {
495 if selectionBG != "" && strings.Contains(l, selectionBG) {
496 hasSelection = true
497 break
498 }
499 }
500 if !hasSelection && r.prev != nil {
501 for _, l := range r.prev {
502 if selectionBG != "" && strings.Contains(l, selectionBG) {
503 hasSelection = true
504 break
505 }
506 }
507 }
508 }
509
510 full := r.prev == nil || len(r.prev) != r.rows
511 for i := 0; i < r.rows; i++ {
512 if full || forceAll || hasSelection || r.prev[i] != frame[i] {
513 w.WriteString(MoveTo(i+1, 1))
514 w.WriteString("\x1b[0m") // reset all attributes first
515 w.WriteString(SeqClearLine)
516 w.WriteString(frame[i])
517 }
518 }
519
520 if cursorRow >= 0 {
521 absRow := top + cursorRow + 1
522 absCol := cursorCol + 1
523 if absRow >= 1 && absRow <= r.rows {
524 w.WriteString(MoveTo(absRow, absCol))
525 w.WriteString(SeqShowCursor)
526 }
527 }
528 w.WriteString(SeqSynchronizedOff)
529
530 _, _ = io.WriteString(r.out, w.String())
531
532 r.prev = frame
533 r.prevHadImage = curHasImage
534 r.prevHadKittyImage = curHasKittyImage
535 r.cursorRow = cursorRow
536 r.cursorCol = cursorCol
537}
538
539// DrawFloating paints a complete, dimmed live background and then an opaque
540// floating pane at absolute coordinates. The background is deliberately
541// invalidated on every call: the pane is not represented in Draw's frame cache,
542// so repainting it first prevents stale foreground cells when either layer
543// changes or the terminal is resized.
544func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int) {
545 if r.cols == 0 || r.rows == 0 {
546 return
547 }
548 if !r.floatingActive || r.floatingRect != pane.Rect {
549 r.Invalidate()
550 }
551 r.drawingFloating = true
552 r.Draw(pane.Background, -1, 0)
553 r.drawingFloating = false
554
555 paneHasImage := false
556 paneHasKittyImage := false
557 for _, line := range pane.Lines {
558 if containsImageEscape(line) {
559 paneHasImage = true
560 if strings.Contains(line, "\x1b_G") {
561 paneHasKittyImage = true
562 }
563 }
564 }
565
566 var w strings.Builder
567 w.WriteString(SeqSynchronizedOn)
568 w.WriteString(SeqHideCursor)
569 for row, line := range pane.Lines {
570 y := pane.Rect.Y + row
571 if y < 0 || y >= r.rows {
572 continue
573 }
574 if pane.Rect.X >= r.cols {
575 continue
576 }
577 width := min(pane.Rect.Width, r.cols-pane.Rect.X)
578 w.WriteString(MoveTo(y+1, pane.Rect.X+1))
579 w.WriteString("\x1b[0m")
580 w.WriteString(paintBackgroundRow(truncateToWidth(line, width), width, r.backgroundStyle))
581 }
582 if cursorRow >= 0 && cursorRow < r.rows && cursorCol >= 0 && cursorCol < r.cols {
583 w.WriteString(MoveTo(cursorRow+1, cursorCol+1))
584 w.WriteString(SeqShowCursor)
585 }
586 w.WriteString(SeqSynchronizedOff)
587 _, _ = io.WriteString(r.out, w.String())
588 r.prevHadImage = r.prevHadImage || paneHasImage
589 r.prevHadKittyImage = r.prevHadKittyImage || paneHasKittyImage
590 // Keep the dimmed background cache while this geometry remains active.
591 r.floatingActive = true
592 r.floatingRect = pane.Rect
593}
594
595// DrawLog renders zut in the terminal's main screen as normal terminal
596// flow rather than a fixed full-screen frame. Chat lines are emitted once
597// into the host terminal scrollback; the current bottom block (dialogs,
598// slash popup, status, editor) is erased and redrawn in place at the end.
599//
600// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
601func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
602 if r.rightBarActive {
603 r.clearRightBarOverlay()
604 }
605 r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols)
606}
607
608// drawLog is DrawLog's owning flow implementation. contentCols may reserve
609// terminal columns for a separately composited viewport overlay.
610func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
611 if r.cols == 0 || r.rows == 0 {
612 return false
613 }
614 if contentCols <= 0 || contentCols > r.cols {
615 contentCols = r.cols
616 }
617 if len(bottom) == 0 {
618 bottom = []string{""}
619 }
620 if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) {
621 return false
622 }
623 chatFrame := make([]string, len(chat))
624 for i, line := range chat {
625 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
626 }
627 bottomFrame := make([]string, len(bottom))
628 for i, line := range bottom {
629 bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
630 }
631
632 // Always reserve one real row below the editor/status band. This is
633 // renderer-owned (not a best-effort trailing blank in the caller's
634 // bottom block), so the logical-buffer diff keeps it visible and cursor
635 // placement remains relative to the editor itself.
636 const bottomMarginRows = 1
637 lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
638 lines = append(lines, chatFrame...)
639 lines = append(lines, bottomFrame...)
640 for range bottomMarginRows {
641 lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
642 }
643 // In main-screen flow mode zut normally emits only its logical
644 // content rows and leaves the rest of the terminal viewport alone.
645 // When a theme background is configured, fill that otherwise-idle
646 // space with painted blank rows so the full window is tinted while
647 // keeping the scrollback-oriented renderer model unchanged for the
648 // default transparent case.
649 if r.theme.Background != nil {
650 for len(lines) < r.rows {
651 lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
652 }
653 }
654 if len(lines) == 0 {
655 lines = []string{""}
656 }
657
658 cursorTargetRow := -1
659 if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
660 cursorTargetRow = len(chatFrame) + cursorBottomRow
661 }
662
663 // Idle no-op fast path. When the buffer AND the cursor position
664 // haven't changed since the last DrawLog, emit nothing. The
665 // alternative — always writing SeqHideCursor + cursor-position +
666 // SeqShowCursor — resets the terminal's cursor blink timer on
667 // every tick. At our 120ms animation cadence that means the
668 // caret in an idle dialog editor (e.g. an open subagent transcript
669 // for an agent that's currently idle) appears as a solid block
670 // that never blinks, because we keep "showing" it before the
671 // terminal can blink it off. Bailing out here lets the OS run
672 // its blink cycle.
673 if r.logInit && r.logRawContentCols == contentCols &&
674 cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
675 return false
676 }
677
678 var w strings.Builder
679 w.WriteString(SeqSynchronizedOn)
680 w.WriteString(SeqHideCursor)
681
682 writeFull := func(clear, purgeScrollback bool) {
683 if clear {
684 w.WriteString(SeqDeleteKittyImages)
685 if r.keepScrollback {
686 // VS Code's xterm.js scrolls the visible content up into
687 // scrollback on \x1b[2J, which duplicates the frame (the
688 // old paint stays above the new one). Home to the
689 // viewport top and erase-to-end (\x1b[0J) instead: that
690 // clears the visible screen in place without pushing the
691 // previous frame into scrollback. We still cannot drop
692 // existing scrollback (\x1b[3J snaps the viewport there),
693 // but a full repaint no longer stacks a fresh copy below
694 // the old one.
695 w.WriteString(SeqCursorHome)
696 w.WriteString(SeqClearToEnd)
697 } else {
698 w.WriteString(SeqClearScreenNoHome)
699 // Purging scrollback invalidates native terminal selections.
700 // It is safe on the first paint because no zut output can be
701 // selected yet, but implicit recovery repaints happen while an
702 // agent is streaming and must preserve the user's selection.
703 if purgeScrollback {
704 w.WriteString(r.clearScrollbackSeq())
705 }
706 w.WriteString(MoveTo(1, 1))
707 }
708 }
709 for idx, line := range lines {
710 if idx > 0 {
711 w.WriteString("\r\n")
712 }
713 w.WriteString("\x1b[0m")
714 w.WriteString(SeqClearLine)
715 w.WriteString(line)
716 }
717 r.logHardwareRow = len(lines) - 1
718 r.logViewportTop = len(lines) - r.rows
719 if r.logViewportTop < 0 {
720 r.logViewportTop = 0
721 }
722 }
723
724 moveToLogicalRow := func(targetRow int) {
725 if targetRow < 0 {
726 targetRow = 0
727 }
728 if targetRow >= len(lines) {
729 targetRow = len(lines) - 1
730 }
731 viewportBottom := r.logViewportTop + r.rows - 1
732 if targetRow > viewportBottom {
733 currentScreenRow := r.logHardwareRow - r.logViewportTop
734 if currentScreenRow < 0 {
735 currentScreenRow = 0
736 }
737 if currentScreenRow >= r.rows {
738 currentScreenRow = r.rows - 1
739 }
740 moveToBottom := r.rows - 1 - currentScreenRow
741 if moveToBottom > 0 {
742 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
743 }
744 scroll := targetRow - viewportBottom
745 for s := 0; s < scroll; s++ {
746 w.WriteString("\r\n")
747 }
748 r.logViewportTop += scroll
749 r.logHardwareRow = targetRow
750 return
751 }
752 currentScreenRow := r.logHardwareRow - r.logViewportTop
753 targetScreenRow := targetRow - r.logViewportTop
754 lineDiff := targetScreenRow - currentScreenRow
755 if lineDiff > 0 {
756 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
757 } else if lineDiff < 0 {
758 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
759 }
760 r.logHardwareRow = targetRow
761 }
762
763 positionCursor := func() {
764 if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
765 return
766 }
767 moveToLogicalRow(cursorTargetRow)
768 w.WriteString("\r")
769 physicalCursorCol := cursorCol
770 if physicalCursorCol < 0 {
771 physicalCursorCol = 0
772 }
773 if physicalCursorCol >= contentCols {
774 physicalCursorCol = contentCols - 1
775 }
776 if physicalCursorCol > 0 {
777 w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C")
778 }
779 w.WriteString(SeqShowCursor)
780 }
781
782 // Selection-highlight workaround removed: it could mis-invalidate
783 // user-bubble padding rows whose colored bg made botHasHL trip,
784 // causing the next diff pass to leave those rows visually thinned
785 // because the cached entry was the \x00 sentinel rather than the
786 // real previous bg-colored row.
787
788 wasInitialized := r.logInit
789 full := !wasInitialized || len(r.logLines) == 0
790 if full {
791 // A cache invalidation may require a full viewport repaint after flow
792 // has started, but only the initial paint may discard old scrollback.
793 writeFull(true, !wasInitialized)
794 r.logInit = true
795 } else {
796 firstChanged := -1
797 lastChanged := -1
798 maxLines := len(lines)
799 if len(r.logLines) > maxLines {
800 maxLines = len(r.logLines)
801 }
802 for idx := 0; idx < maxLines; idx++ {
803 oldLine := ""
804 if idx < len(r.logLines) {
805 oldLine = r.logLines[idx]
806 }
807 newLine := ""
808 if idx < len(lines) {
809 newLine = lines[idx]
810 }
811 if oldLine != newLine {
812 if firstChanged == -1 {
813 firstChanged = idx
814 }
815 lastChanged = idx
816 }
817 }
818 // Buffer grew but the appended rows were empty (or otherwise
819 // equal to the implicit "" past the old end). The diff above
820 // won't flag those rows, yet the renderer still needs to
821 // advance its hardware cursor / viewport tracking past them so
822 // the next render starts from the correct position. Treat the
823 // extension as changed.
824 if len(lines) > len(r.logLines) {
825 if firstChanged == -1 {
826 firstChanged = len(r.logLines)
827 }
828 if lastChanged < len(lines)-1 {
829 lastChanged = len(lines) - 1
830 }
831 }
832
833 // Rows that have scrolled above the viewport are immutable terminal
834 // history. Replaying the full logical buffer to update one of them
835 // duplicates that history, and erasing it first destroys native text
836 // selections. Ignore inaccessible changes and patch only the first
837 // changed row that is still addressable.
838 if firstChanged >= 0 && firstChanged < r.logViewportTop {
839 delta := len(chatFrame) - len(r.logChat)
840 reflowAbove := false
841 if delta != 0 {
842 overlapEnd := min(len(chatFrame), len(r.logChat))
843 // When the viewport starts in the bottom band there are no chat
844 // rows to compare at the old logical coordinates, but changing
845 // chat height still shifts the entire visible band.
846 reflowAbove = r.logViewportTop >= overlapEnd
847 for idx := r.logViewportTop; !reflowAbove && idx < overlapEnd; idx++ {
848 if r.logChat[idx] != chatFrame[idx] {
849 reflowAbove = true
850 }
851 }
852 }
853 if reflowAbove {
854 // A text-only logical buffer can be rebased without resetting the
855 // terminal viewport. Advance physical rows when it grows, then
856 // repaint the visible tail in place for both growth and shrinkage.
857 // Cursor-home plus erase-to-end makes several terminals visibly
858 // jump on streaming-markdown reflows.
859 growth := len(lines) - len(r.logLines)
860 newViewportTop := max(0, len(lines)-r.rows)
861 hasVisibleImages := visibleTailHasImage(r.logLines, r.logViewportTop) ||
862 visibleTailHasImage(lines, newViewportTop)
863 if !hasVisibleImages && len(lines) >= r.rows {
864 currentScreenRow := r.logHardwareRow - r.logViewportTop
865 if currentScreenRow < 0 {
866 currentScreenRow = 0
867 }
868 if currentScreenRow >= r.rows {
869 currentScreenRow = r.rows - 1
870 }
871 if growth > 0 {
872 if down := r.rows - 1 - currentScreenRow; down > 0 {
873 w.WriteString("\x1b[" + itoa(down) + "B")
874 }
875 for range growth {
876 w.WriteString("\r\n")
877 }
878 currentScreenRow = r.rows - 1
879 }
880 if currentScreenRow > 0 {
881 w.WriteString("\x1b[" + itoa(currentScreenRow) + "A")
882 }
883 w.WriteString("\r")
884 for idx := newViewportTop; idx < len(lines); idx++ {
885 if idx > newViewportTop {
886 w.WriteString("\r\n")
887 }
888 w.WriteString("\x1b[0m")
889 w.WriteString(SeqClearLine)
890 w.WriteString(lines[idx])
891 }
892 r.logViewportTop = newViewportTop
893 r.logHardwareRow = len(lines) - 1
894 } else {
895 // Inline-image reflows and buffers shorter than the viewport need
896 // the conservative repaint path. Replaying the full transcript
897 // would duplicate retained scrollback.
898 w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd)
899 viewportTop := max(0, len(lines)-r.rows)
900 start := viewportTop
901 if start > 0 && imageFootprintBlank(lines[start]) {
902 for idx := start - 1; idx >= 0; idx-- {
903 if containsImageEscape(lines[idx]) {
904 start = idx
905 break
906 }
907 if !imageFootprintBlank(lines[idx]) {
908 break
909 }
910 }
911 }
912 for idx := start; idx < len(lines); idx++ {
913 if idx > start {
914 w.WriteString("\r\n")
915 }
916 w.WriteString("\x1b[0m")
917 w.WriteString(SeqClearLine)
918 w.WriteString(lines[idx])
919 }
920 r.logViewportTop = viewportTop
921 r.logHardwareRow = len(lines) - 1
922 }
923 firstChanged = -1
924 lastChanged = -1
925 } else {
926 firstChanged = -1
927 lastChanged = -1
928 for idx := r.logViewportTop; idx < maxLines; idx++ {
929 oldLine := ""
930 if idx < len(r.logLines) {
931 oldLine = r.logLines[idx]
932 }
933 newLine := ""
934 if idx < len(lines) {
935 newLine = lines[idx]
936 }
937 if oldLine != newLine {
938 if firstChanged == -1 {
939 firstChanged = idx
940 }
941 lastChanged = idx
942 }
943 }
944 // A newly appended blank row compares equal to the implicit empty
945 // row past the old slice, but it still has to advance the terminal.
946 if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {
947 firstChanged = len(r.logLines)
948 lastChanged = len(lines) - 1
949 }
950 }
951 }
952
953 if firstChanged == -1 {
954 // No addressable content changes; the final cursor positioning below
955 // may still move the hardware cursor if the editor cursor changed.
956 } else {
957 prevViewportTop := r.logViewportTop
958 viewportTop := prevViewportTop
959 hardwareRow := r.logHardwareRow
960 prevViewportBottom := prevViewportTop + r.rows - 1
961 appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
962 moveTarget := firstChanged
963 if appendStart {
964 moveTarget = firstChanged - 1
965 }
966
967 if moveTarget > prevViewportBottom {
968 currentScreenRow := hardwareRow - prevViewportTop
969 if currentScreenRow < 0 {
970 currentScreenRow = 0
971 }
972 if currentScreenRow >= r.rows {
973 currentScreenRow = r.rows - 1
974 }
975 moveToBottom := r.rows - 1 - currentScreenRow
976 if moveToBottom > 0 {
977 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
978 }
979 scroll := moveTarget - prevViewportBottom
980 for s := 0; s < scroll; s++ {
981 w.WriteString("\r\n")
982 }
983 prevViewportTop += scroll
984 viewportTop += scroll
985 hardwareRow = moveTarget
986 }
987
988 currentScreenRow := hardwareRow - prevViewportTop
989 targetScreenRow := moveTarget - viewportTop
990 lineDiff := targetScreenRow - currentScreenRow
991 if lineDiff > 0 {
992 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
993 } else if lineDiff < 0 {
994 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
995 }
996 if appendStart {
997 w.WriteString("\r\n")
998 } else {
999 w.WriteString("\r")
1000 }
1001
1002 renderEnd := lastChanged
1003 if renderEnd >= len(lines) {
1004 renderEnd = len(lines) - 1
1005 }
1006 for idx := firstChanged; idx <= renderEnd; idx++ {
1007 if idx > firstChanged {
1008 w.WriteString("\r\n")
1009 }
1010 w.WriteString("\x1b[0m")
1011 w.WriteString(SeqClearLine)
1012 w.WriteString(lines[idx])
1013 }
1014 finalRow := renderEnd
1015 if len(r.logLines) > len(lines) {
1016 extra := len(r.logLines) - len(lines)
1017 if extra > r.rows {
1018 writeFull(true, false)
1019 } else {
1020 for e := 0; e < extra; e++ {
1021 w.WriteString("\x1b[1B")
1022 w.WriteString("\r")
1023 w.WriteString("\x1b[0m")
1024 w.WriteString(SeqClearLine)
1025 finalRow++
1026 }
1027 if extra > 0 {
1028 w.WriteString("\x1b[" + itoa(extra) + "A")
1029 finalRow -= extra
1030 }
1031 }
1032 }
1033 r.logHardwareRow = finalRow
1034 r.logViewportTop = viewportTop
1035 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
1036 r.logViewportTop = minTop
1037 }
1038 if r.logViewportTop < 0 {
1039 r.logViewportTop = 0
1040 }
1041 }
1042 }
1043
1044 positionCursor()
1045 w.WriteString(SeqSynchronizedOff)
1046 _, _ = io.WriteString(r.out, w.String())
1047
1048 r.logChat = append(r.logChat[:0], chatFrame...)
1049 r.logBottom = append(r.logBottom[:0], bottomFrame...)
1050 r.logLines = append(r.logLines[:0], lines...)
1051 r.logRawChat = append(r.logRawChat[:0], chat...)
1052 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
1053 r.logRawCols = r.cols
1054 r.logRawRows = r.rows
1055 r.logRawContentCols = contentCols
1056 r.logRawBackground = r.backgroundStyle
1057 r.logRawHasThemeBG = r.theme.Background != nil
1058 r.cursorRow = cursorBottomRow
1059 r.cursorCol = cursorCol
1060 return true
1061}
1062
1063func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
1064 return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
1065 r.logRawContentUnchanged(chat, bottom, contentCols)
1066}
1067
1068func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool {
1069 return r.logInit && len(r.logLines) > 0 &&
1070 r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols &&
1071 r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) &&
1072 sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom)
1073}
1074
1075// sameLines reports whether two []string have the exact same
1076// length and per-row contents. Used by DrawLog's idle no-op fast
1077// path; cheap enough at our frame rates and far simpler than
1078// hashing every byte.
1079func sameLines(a, b []string) bool {
1080 if len(a) != len(b) {
1081 return false
1082 }
1083 for i := range a {
1084 if a[i] != b[i] {
1085 return false
1086 }
1087 }
1088 return true
1089}