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