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