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