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// visibleTailHasImage reports whether an inline image or its blank terminal
247// footprint intersects the visible tail. Images earlier in retained scrollback
248// do not prevent ordinary text reflows from using the stable in-place path.
249func visibleTailHasImage(lines []string, viewportTop int) bool {
250 if viewportTop < 0 {
251 viewportTop = 0
252 }
253 if viewportTop >= len(lines) {
254 return false
255 }
256 for _, line := range lines[viewportTop:] {
257 if containsImageEscape(line) {
258 return true
259 }
260 }
261 if viewportTop == 0 || !imageFootprintBlank(lines[viewportTop]) {
262 return false
263 }
264 for idx := viewportTop - 1; idx >= 0; idx-- {
265 if containsImageEscape(lines[idx]) {
266 return true
267 }
268 if !imageFootprintBlank(lines[idx]) {
269 return false
270 }
271 }
272 return false
273}
274
275// paintBackgroundRow applies the optional theme background to a single
276// already-truncated terminal row. It pads with spaces to cols so the
277// background reaches the right edge, and re-applies the background
278// after full SGR resets inside the row so local styling does not punch
279// transparent holes through the global tint.
280func paintBackgroundRow(line string, cols int, background string) string {
281 if background == "" || cols <= 0 || containsImageEscape(line) {
282 return line
283 }
284 line = strings.ReplaceAll(line, reset, reset+background)
285 if w := visibleWidth(line); w < cols {
286 line += strings.Repeat(" ", cols-w)
287 }
288 return background + line + reset
289}
290
291// truncateToWidth clips s so its on-screen width doesn't exceed cols
292// cells, preserving ANSI CSI escape sequences (which don't consume
293// cells). Lines carrying an inline-image escape are returned as-is
294// since we can't measure their painted size.
295//
296// Fast path: a byte-length <= cols is a conservative upper bound
297// guaranteeing the cell width is also <= cols, so we skip all the
298// rune-width math. That covers the vast majority of lines in a
299// transcript (narrow terminals wrap early; wide ones leave headroom).
300func truncateToWidth(s string, cols int) string {
301 if cols <= 0 || containsImageEscape(s) {
302 return s
303 }
304 if len(s) <= cols {
305 return s
306 }
307 var out strings.Builder
308 out.Grow(len(s))
309 seen := 0
310 osc8Active := false
311 runes := []rune(s)
312 for i := 0; i < len(runes); {
313 r := runes[i]
314 // CSI escape sequence (ESC [ ... final): zero-width.
315 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
316 out.WriteRune(r)
317 out.WriteRune(runes[i+1])
318 i += 2
319 for i < len(runes) {
320 c := runes[i]
321 out.WriteRune(c)
322 i++
323 if c >= 0x40 && c <= 0x7e {
324 break
325 }
326 }
327 continue
328 }
329 // OSC string escape (BEL or ST terminated): zero-width and atomic.
330 if r == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
331 start := i
332 i += 2
333 for i < len(runes) {
334 c := runes[i]
335 i++
336 if c == 0x07 {
337 break
338 }
339 if c == 0x1b && i < len(runes) && runes[i] == '\\' {
340 i++
341 break
342 }
343 }
344 osc8Closer := start+5 < i && runes[start+2] == '8' && runes[start+3] == ';' && runes[start+4] == ';' &&
345 ((runes[start+5] == 0x07 && i == start+6) ||
346 (start+6 < i && runes[start+5] == 0x1b && runes[start+6] == '\\' && i == start+7))
347 if seen < cols || osc8Closer {
348 for _, c := range runes[start:i] {
349 out.WriteRune(c)
350 }
351 if runes[start+2] == '8' {
352 osc8Active = !osc8Closer
353 }
354 }
355 continue
356 }
357 rw := runewidthRune(r)
358 if seen+rw > cols {
359 if osc8Active {
360 // The remaining text is omitted, so close a hyperlink
361 // whose opener was already emitted before the boundary.
362 out.WriteString("\x1b]8;;\x07")
363 }
364 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
365 // so background colors and cleanup sequences survive.
366 for i < len(runes) {
367 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
368 out.WriteRune(runes[i])
369 out.WriteRune(runes[i+1])
370 i += 2
371 for i < len(runes) {
372 c := runes[i]
373 out.WriteRune(c)
374 i++
375 if c >= 0x40 && c <= 0x7e {
376 break
377 }
378 }
379 } else if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
380 // Do not append a new OSC sequence after the visible
381 // boundary: an OSC 8 opener would leak hyperlink state
382 // into the next terminal row. A matching OSC 8 closer is
383 // retained so a link opened before the boundary is closed.
384 keep := i+5 < len(runes) && runes[i+2] == '8' && runes[i+3] == ';' && runes[i+4] == ';' &&
385 (runes[i+5] == 0x07 || (i+6 < len(runes) && runes[i+5] == 0x1b && runes[i+6] == '\\'))
386 start := i
387 i += 2
388 for i < len(runes) {
389 c := runes[i]
390 i++
391 if c == 0x07 {
392 break
393 }
394 if c == 0x1b && i < len(runes) && runes[i] == '\\' {
395 i++
396 break
397 }
398 }
399 if keep {
400 for _, c := range runes[start:i] {
401 out.WriteRune(c)
402 }
403 }
404 } else {
405 break
406 }
407 }
408 break
409 }
410 out.WriteRune(r)
411 seen += rw
412 i++
413 }
414 return out.String()
415}
416
417func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
418 if r.cols == 0 || r.rows == 0 {
419 return
420 }
421 // Bottom-align: only the last r.rows lines are visible.
422 visible := lines
423 if len(visible) > r.rows {
424 visible = visible[len(visible)-r.rows:]
425 cursorRow -= len(lines) - len(visible)
426 }
427 // Pad to r.rows with empty lines at the top. Every line is also
428 // hard-truncated to cols so the terminal never soft-wraps our output
429 // (which would push the status bar out of its row).
430 frame := make([]string, r.rows)
431 top := r.rows - len(visible)
432 for i := 0; i < top; i++ {
433 frame[i] = ""
434 }
435 for i, line := range visible {
436 frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
437 }
438 if r.theme.Background != nil {
439 for i := 0; i < top; i++ {
440 frame[i] = paintBackgroundRow("", r.cols, r.backgroundStyle)
441 }
442 }
443
444 var w strings.Builder
445 w.WriteString(SeqSynchronizedOn)
446 w.WriteString(SeqHideCursor)
447
448 // When inline images are in play we always full-repaint (clear
449 // screen first, then rewrite every row). Terminals manage image
450 // pixels in a layer we cannot diff against, so the per-line cache
451 // is unreliable. Inline images are opt-in via ZUT_INLINE_IMAGES;
452 // the common code path below is the fast cached diff.
453 curHasImage := false
454 curHasKittyImage := false
455 for _, l := range frame {
456 if containsImageEscape(l) {
457 curHasImage = true
458 if strings.Contains(l, "\x1b_G") {
459 curHasKittyImage = true
460 }
461 }
462 }
463 forceAll := curHasImage || r.prevHadImage
464 if forceAll {
465 // No-home variant: the per-row MoveTo(i+1, 1) writes in the
466 // loop below position the cursor for every painted row, so
467 // the embedded \x1b[H would only serve to make VS Code snap
468 // its scrollbar to the top of the viewport on every image or
469 // selection-highlight frame.
470 w.WriteString(SeqClearScreenNoHome)
471 if curHasKittyImage {
472 // Delete previously placed kitty images once per frame,
473 // before rewriting all rows. Doing this inside each image
474 // escape makes only the last image in the frame survive.
475 w.WriteString("\x1b_Ga=d\x1b\\")
476 }
477 }
478
479 // Detect selection highlights: if the current OR previous frame
480 // has selection-background rows, force full repaint. VS Code's
481 // terminal doesn't reliably clear background colors on row
482 // overwrites, leaving ghost highlights behind.
483 hasSelection := false
484 if r.theme.Background == nil {
485 selectionBG := r.theme.bgPrefix(r.theme.SelectionBG)
486 for _, l := range frame {
487 if selectionBG != "" && strings.Contains(l, selectionBG) {
488 hasSelection = true
489 break
490 }
491 }
492 if !hasSelection && r.prev != nil {
493 for _, l := range r.prev {
494 if selectionBG != "" && strings.Contains(l, selectionBG) {
495 hasSelection = true
496 break
497 }
498 }
499 }
500 }
501
502 full := r.prev == nil || len(r.prev) != r.rows
503 for i := 0; i < r.rows; i++ {
504 if full || forceAll || hasSelection || r.prev[i] != frame[i] {
505 w.WriteString(MoveTo(i+1, 1))
506 w.WriteString("\x1b[0m") // reset all attributes first
507 w.WriteString(SeqClearLine)
508 w.WriteString(frame[i])
509 }
510 }
511
512 if cursorRow >= 0 {
513 absRow := top + cursorRow + 1
514 absCol := cursorCol + 1
515 if absRow >= 1 && absRow <= r.rows {
516 w.WriteString(MoveTo(absRow, absCol))
517 w.WriteString(SeqShowCursor)
518 }
519 }
520 w.WriteString(SeqSynchronizedOff)
521
522 _, _ = io.WriteString(r.out, w.String())
523
524 r.prev = frame
525 r.prevHadImage = curHasImage
526 r.cursorRow = cursorRow
527 r.cursorCol = cursorCol
528}
529
530// DrawLog renders zut in the terminal's main screen as normal terminal
531// flow rather than a fixed full-screen frame. Chat lines are emitted once
532// into the host terminal scrollback; the current bottom block (dialogs,
533// slash popup, status, editor) is erased and redrawn in place at the end.
534//
535// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
536func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
537 if r.rightBarActive {
538 r.clearRightBarOverlay()
539 }
540 r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols)
541}
542
543// drawLog is DrawLog's owning flow implementation. contentCols may reserve
544// terminal columns for a separately composited viewport overlay.
545func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
546 if r.cols == 0 || r.rows == 0 {
547 return false
548 }
549 if contentCols <= 0 || contentCols > r.cols {
550 contentCols = r.cols
551 }
552 if len(bottom) == 0 {
553 bottom = []string{""}
554 }
555 if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) {
556 return false
557 }
558 chatFrame := make([]string, len(chat))
559 for i, line := range chat {
560 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
561 }
562 bottomFrame := make([]string, len(bottom))
563 for i, line := range bottom {
564 bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
565 }
566
567 // Always reserve one real row below the editor/status band. This is
568 // renderer-owned (not a best-effort trailing blank in the caller's
569 // bottom block), so the logical-buffer diff keeps it visible and cursor
570 // placement remains relative to the editor itself.
571 const bottomMarginRows = 1
572 lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
573 lines = append(lines, chatFrame...)
574 lines = append(lines, bottomFrame...)
575 for range bottomMarginRows {
576 lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
577 }
578 // In main-screen flow mode zut normally emits only its logical
579 // content rows and leaves the rest of the terminal viewport alone.
580 // When a theme background is configured, fill that otherwise-idle
581 // space with painted blank rows so the full window is tinted while
582 // keeping the scrollback-oriented renderer model unchanged for the
583 // default transparent case.
584 if r.theme.Background != nil {
585 for len(lines) < r.rows {
586 lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
587 }
588 }
589 if len(lines) == 0 {
590 lines = []string{""}
591 }
592
593 cursorTargetRow := -1
594 if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
595 cursorTargetRow = len(chatFrame) + cursorBottomRow
596 }
597
598 // Idle no-op fast path. When the buffer AND the cursor position
599 // haven't changed since the last DrawLog, emit nothing. The
600 // alternative — always writing SeqHideCursor + cursor-position +
601 // SeqShowCursor — resets the terminal's cursor blink timer on
602 // every tick. At our 120ms animation cadence that means the
603 // caret in an idle dialog editor (e.g. an open subagent transcript
604 // for an agent that's currently idle) appears as a solid block
605 // that never blinks, because we keep "showing" it before the
606 // terminal can blink it off. Bailing out here lets the OS run
607 // its blink cycle.
608 if r.logInit && r.logRawContentCols == contentCols &&
609 cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
610 return false
611 }
612
613 var w strings.Builder
614 w.WriteString(SeqSynchronizedOn)
615 w.WriteString(SeqHideCursor)
616
617 writeFull := func(clear, purgeScrollback bool) {
618 if clear {
619 w.WriteString(SeqDeleteKittyImages)
620 if r.keepScrollback {
621 // VS Code's xterm.js scrolls the visible content up into
622 // scrollback on \x1b[2J, which duplicates the frame (the
623 // old paint stays above the new one). Home to the
624 // viewport top and erase-to-end (\x1b[0J) instead: that
625 // clears the visible screen in place without pushing the
626 // previous frame into scrollback. We still cannot drop
627 // existing scrollback (\x1b[3J snaps the viewport there),
628 // but a full repaint no longer stacks a fresh copy below
629 // the old one.
630 w.WriteString(SeqCursorHome)
631 w.WriteString(SeqClearToEnd)
632 } else {
633 w.WriteString(SeqClearScreenNoHome)
634 // Purging scrollback invalidates native terminal selections.
635 // It is safe on the first paint because no zut output can be
636 // selected yet, but implicit recovery repaints happen while an
637 // agent is streaming and must preserve the user's selection.
638 if purgeScrollback {
639 w.WriteString(r.clearScrollbackSeq())
640 }
641 w.WriteString(MoveTo(1, 1))
642 }
643 }
644 for idx, line := range lines {
645 if idx > 0 {
646 w.WriteString("\r\n")
647 }
648 w.WriteString("\x1b[0m")
649 w.WriteString(SeqClearLine)
650 w.WriteString(line)
651 }
652 r.logHardwareRow = len(lines) - 1
653 r.logViewportTop = len(lines) - r.rows
654 if r.logViewportTop < 0 {
655 r.logViewportTop = 0
656 }
657 }
658
659 moveToLogicalRow := func(targetRow int) {
660 if targetRow < 0 {
661 targetRow = 0
662 }
663 if targetRow >= len(lines) {
664 targetRow = len(lines) - 1
665 }
666 viewportBottom := r.logViewportTop + r.rows - 1
667 if targetRow > viewportBottom {
668 currentScreenRow := r.logHardwareRow - r.logViewportTop
669 if currentScreenRow < 0 {
670 currentScreenRow = 0
671 }
672 if currentScreenRow >= r.rows {
673 currentScreenRow = r.rows - 1
674 }
675 moveToBottom := r.rows - 1 - currentScreenRow
676 if moveToBottom > 0 {
677 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
678 }
679 scroll := targetRow - viewportBottom
680 for s := 0; s < scroll; s++ {
681 w.WriteString("\r\n")
682 }
683 r.logViewportTop += scroll
684 r.logHardwareRow = targetRow
685 return
686 }
687 currentScreenRow := r.logHardwareRow - r.logViewportTop
688 targetScreenRow := targetRow - r.logViewportTop
689 lineDiff := targetScreenRow - currentScreenRow
690 if lineDiff > 0 {
691 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
692 } else if lineDiff < 0 {
693 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
694 }
695 r.logHardwareRow = targetRow
696 }
697
698 positionCursor := func() {
699 if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
700 return
701 }
702 moveToLogicalRow(cursorTargetRow)
703 w.WriteString("\r")
704 physicalCursorCol := cursorCol
705 if physicalCursorCol < 0 {
706 physicalCursorCol = 0
707 }
708 if physicalCursorCol >= contentCols {
709 physicalCursorCol = contentCols - 1
710 }
711 if physicalCursorCol > 0 {
712 w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C")
713 }
714 w.WriteString(SeqShowCursor)
715 }
716
717 // Selection-highlight workaround removed: it could mis-invalidate
718 // user-bubble padding rows whose colored bg made botHasHL trip,
719 // causing the next diff pass to leave those rows visually thinned
720 // because the cached entry was the \x00 sentinel rather than the
721 // real previous bg-colored row.
722
723 wasInitialized := r.logInit
724 full := !wasInitialized || len(r.logLines) == 0
725 if full {
726 // A cache invalidation may require a full viewport repaint after flow
727 // has started, but only the initial paint may discard old scrollback.
728 writeFull(true, !wasInitialized)
729 r.logInit = true
730 } else {
731 firstChanged := -1
732 lastChanged := -1
733 maxLines := len(lines)
734 if len(r.logLines) > maxLines {
735 maxLines = len(r.logLines)
736 }
737 for idx := 0; idx < maxLines; idx++ {
738 oldLine := ""
739 if idx < len(r.logLines) {
740 oldLine = r.logLines[idx]
741 }
742 newLine := ""
743 if idx < len(lines) {
744 newLine = lines[idx]
745 }
746 if oldLine != newLine {
747 if firstChanged == -1 {
748 firstChanged = idx
749 }
750 lastChanged = idx
751 }
752 }
753 // Buffer grew but the appended rows were empty (or otherwise
754 // equal to the implicit "" past the old end). The diff above
755 // won't flag those rows, yet the renderer still needs to
756 // advance its hardware cursor / viewport tracking past them so
757 // the next render starts from the correct position. Treat the
758 // extension as changed.
759 if len(lines) > len(r.logLines) {
760 if firstChanged == -1 {
761 firstChanged = len(r.logLines)
762 }
763 if lastChanged < len(lines)-1 {
764 lastChanged = len(lines) - 1
765 }
766 }
767
768 // Rows that have scrolled above the viewport are immutable terminal
769 // history. Replaying the full logical buffer to update one of them
770 // duplicates that history, and erasing it first destroys native text
771 // selections. Ignore inaccessible changes and patch only the first
772 // changed row that is still addressable.
773 if firstChanged >= 0 && firstChanged < r.logViewportTop {
774 delta := len(chatFrame) - len(r.logChat)
775 reflowAbove := false
776 if delta != 0 {
777 overlapEnd := min(len(chatFrame), len(r.logChat))
778 // When the viewport starts in the bottom band there are no chat
779 // rows to compare at the old logical coordinates, but changing
780 // chat height still shifts the entire visible band.
781 reflowAbove = r.logViewportTop >= overlapEnd
782 for idx := r.logViewportTop; !reflowAbove && idx < overlapEnd; idx++ {
783 if r.logChat[idx] != chatFrame[idx] {
784 reflowAbove = true
785 }
786 }
787 }
788 if reflowAbove {
789 // A text-only logical buffer can be rebased without resetting the
790 // terminal viewport. Advance physical rows when it grows, then
791 // repaint the visible tail in place for both growth and shrinkage.
792 // Cursor-home plus erase-to-end makes several terminals visibly
793 // jump on streaming-markdown reflows.
794 growth := len(lines) - len(r.logLines)
795 newViewportTop := max(0, len(lines)-r.rows)
796 hasVisibleImages := visibleTailHasImage(r.logLines, r.logViewportTop) ||
797 visibleTailHasImage(lines, newViewportTop)
798 if !hasVisibleImages && len(lines) >= r.rows {
799 currentScreenRow := r.logHardwareRow - r.logViewportTop
800 if currentScreenRow < 0 {
801 currentScreenRow = 0
802 }
803 if currentScreenRow >= r.rows {
804 currentScreenRow = r.rows - 1
805 }
806 if growth > 0 {
807 if down := r.rows - 1 - currentScreenRow; down > 0 {
808 w.WriteString("\x1b[" + itoa(down) + "B")
809 }
810 for range growth {
811 w.WriteString("\r\n")
812 }
813 currentScreenRow = r.rows - 1
814 }
815 if currentScreenRow > 0 {
816 w.WriteString("\x1b[" + itoa(currentScreenRow) + "A")
817 }
818 w.WriteString("\r")
819 for idx := newViewportTop; idx < len(lines); idx++ {
820 if idx > newViewportTop {
821 w.WriteString("\r\n")
822 }
823 w.WriteString("\x1b[0m")
824 w.WriteString(SeqClearLine)
825 w.WriteString(lines[idx])
826 }
827 r.logViewportTop = newViewportTop
828 r.logHardwareRow = len(lines) - 1
829 } else {
830 // Inline-image reflows and buffers shorter than the viewport need
831 // the conservative repaint path. Replaying the full transcript
832 // would duplicate retained scrollback.
833 w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd)
834 viewportTop := max(0, len(lines)-r.rows)
835 start := viewportTop
836 if start > 0 && imageFootprintBlank(lines[start]) {
837 for idx := start - 1; idx >= 0; idx-- {
838 if containsImageEscape(lines[idx]) {
839 start = idx
840 break
841 }
842 if !imageFootprintBlank(lines[idx]) {
843 break
844 }
845 }
846 }
847 for idx := start; idx < len(lines); idx++ {
848 if idx > start {
849 w.WriteString("\r\n")
850 }
851 w.WriteString("\x1b[0m")
852 w.WriteString(SeqClearLine)
853 w.WriteString(lines[idx])
854 }
855 r.logViewportTop = viewportTop
856 r.logHardwareRow = len(lines) - 1
857 }
858 firstChanged = -1
859 lastChanged = -1
860 } else {
861 firstChanged = -1
862 lastChanged = -1
863 for idx := r.logViewportTop; idx < maxLines; idx++ {
864 oldLine := ""
865 if idx < len(r.logLines) {
866 oldLine = r.logLines[idx]
867 }
868 newLine := ""
869 if idx < len(lines) {
870 newLine = lines[idx]
871 }
872 if oldLine != newLine {
873 if firstChanged == -1 {
874 firstChanged = idx
875 }
876 lastChanged = idx
877 }
878 }
879 // A newly appended blank row compares equal to the implicit empty
880 // row past the old slice, but it still has to advance the terminal.
881 if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {
882 firstChanged = len(r.logLines)
883 lastChanged = len(lines) - 1
884 }
885 }
886 }
887
888 if firstChanged == -1 {
889 // No addressable content changes; the final cursor positioning below
890 // may still move the hardware cursor if the editor cursor changed.
891 } else {
892 prevViewportTop := r.logViewportTop
893 viewportTop := prevViewportTop
894 hardwareRow := r.logHardwareRow
895 prevViewportBottom := prevViewportTop + r.rows - 1
896 appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
897 moveTarget := firstChanged
898 if appendStart {
899 moveTarget = firstChanged - 1
900 }
901
902 if moveTarget > prevViewportBottom {
903 currentScreenRow := hardwareRow - prevViewportTop
904 if currentScreenRow < 0 {
905 currentScreenRow = 0
906 }
907 if currentScreenRow >= r.rows {
908 currentScreenRow = r.rows - 1
909 }
910 moveToBottom := r.rows - 1 - currentScreenRow
911 if moveToBottom > 0 {
912 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
913 }
914 scroll := moveTarget - prevViewportBottom
915 for s := 0; s < scroll; s++ {
916 w.WriteString("\r\n")
917 }
918 prevViewportTop += scroll
919 viewportTop += scroll
920 hardwareRow = moveTarget
921 }
922
923 currentScreenRow := hardwareRow - prevViewportTop
924 targetScreenRow := moveTarget - viewportTop
925 lineDiff := targetScreenRow - currentScreenRow
926 if lineDiff > 0 {
927 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
928 } else if lineDiff < 0 {
929 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
930 }
931 if appendStart {
932 w.WriteString("\r\n")
933 } else {
934 w.WriteString("\r")
935 }
936
937 renderEnd := lastChanged
938 if renderEnd >= len(lines) {
939 renderEnd = len(lines) - 1
940 }
941 for idx := firstChanged; idx <= renderEnd; idx++ {
942 if idx > firstChanged {
943 w.WriteString("\r\n")
944 }
945 w.WriteString("\x1b[0m")
946 w.WriteString(SeqClearLine)
947 w.WriteString(lines[idx])
948 }
949 finalRow := renderEnd
950 if len(r.logLines) > len(lines) {
951 extra := len(r.logLines) - len(lines)
952 if extra > r.rows {
953 writeFull(true, false)
954 } else {
955 for e := 0; e < extra; e++ {
956 w.WriteString("\x1b[1B")
957 w.WriteString("\r")
958 w.WriteString("\x1b[0m")
959 w.WriteString(SeqClearLine)
960 finalRow++
961 }
962 if extra > 0 {
963 w.WriteString("\x1b[" + itoa(extra) + "A")
964 finalRow -= extra
965 }
966 }
967 }
968 r.logHardwareRow = finalRow
969 r.logViewportTop = viewportTop
970 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
971 r.logViewportTop = minTop
972 }
973 if r.logViewportTop < 0 {
974 r.logViewportTop = 0
975 }
976 }
977 }
978
979 positionCursor()
980 w.WriteString(SeqSynchronizedOff)
981 _, _ = io.WriteString(r.out, w.String())
982
983 r.logChat = append(r.logChat[:0], chatFrame...)
984 r.logBottom = append(r.logBottom[:0], bottomFrame...)
985 r.logLines = append(r.logLines[:0], lines...)
986 r.logRawChat = append(r.logRawChat[:0], chat...)
987 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
988 r.logRawCols = r.cols
989 r.logRawRows = r.rows
990 r.logRawContentCols = contentCols
991 r.logRawBackground = r.backgroundStyle
992 r.logRawHasThemeBG = r.theme.Background != nil
993 r.cursorRow = cursorBottomRow
994 r.cursorCol = cursorCol
995 return true
996}
997
998func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
999 return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
1000 r.logRawContentUnchanged(chat, bottom, contentCols)
1001}
1002
1003func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool {
1004 return r.logInit && len(r.logLines) > 0 &&
1005 r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols &&
1006 r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) &&
1007 sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom)
1008}
1009
1010// sameLines reports whether two []string have the exact same
1011// length and per-row contents. Used by DrawLog's idle no-op fast
1012// path; cheap enough at our frame rates and far simpler than
1013// hashing every byte.
1014func sameLines(a, b []string) bool {
1015 if len(a) != len(b) {
1016 return false
1017 }
1018 for i := range a {
1019 if a[i] != b[i] {
1020 return false
1021 }
1022 }
1023 return true
1024}