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