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