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