tui: make main-screen renderer viewport-safe
- commit
- 6c5c4f213a5087fee06ec562e793042989817bf5
- author
- patriceckhart · · 2026-05-05 15:33 UTC
- parent
- ea10cba
- signature
- unsigned
- refs
- tag: v0.1.56
2 files · +208 -77
M internal/agent/modes/interactive.go +0 -1
| @@ -1007,7 +1007,6 @@ func (i *Interactive) redraw() { | |||
| 1007 | 1007 | bottom = append(bottom, statusLines...) | |
| 1008 | 1008 | bottom = append(bottom, "") | |
| 1009 | 1009 | bottom = append(bottom, edLines...) | |
| 1010 | - | bottom = append(bottom, "") | |
| 1011 | 1010 | ||
| 1012 | 1011 | _, rows := i.cfg.Terminal.Size() | |
| 1013 | 1012 | chatRows := rows - len(bottom) | |
| @@ -1007,7 +1007,6 @@ func (i *Interactive) redraw() { | |||||
| 1007 | bottom = append(bottom, statusLines...) | 1007 | bottom = append(bottom, statusLines...) | ||
| 1008 | bottom = append(bottom, "") | 1008 | bottom = append(bottom, "") | ||
| 1009 | bottom = append(bottom, edLines...) | 1009 | bottom = append(bottom, edLines...) | ||
| 1010 | - | bottom = append(bottom, "") | |||
| 1011 | 1010 | ||||
| 1012 | _, rows := i.cfg.Terminal.Size() | 1011 | _, rows := i.cfg.Terminal.Size() | ||
| 1013 | chatRows := rows - len(bottom) | 1012 | chatRows := rows - len(bottom) | ||
M internal/tui/render.go +208 -76
| @@ -33,12 +33,18 @@ type Renderer struct { | |||
| 33 | 33 | // enabled via ZOT_INLINE_IMAGES; defaults to false. | |
| 34 | 34 | prevHadImage bool | |
| 35 | 35 | ||
| 36 | - | // Main-screen flow renderer state. logChat is the full chat buffer | |
| 37 | - | // already emitted into terminal scrollback. logBottom is the | |
| 38 | - | // editable/status block currently drawn after the chat. | |
| 39 | - | logChat []string | |
| 40 | - | logBottom []string | |
| 41 | - | logInit bool | |
| 36 | + | // Main-screen flow renderer state. logLines is the full logical | |
| 37 | + | // buffer (chat + live bottom band) from the previous DrawLog call. | |
| 38 | + | // logViewportTop/logHardwareRow track where that logical buffer sits | |
| 39 | + | // in the terminal's visible viewport so we can diff safely, and bail | |
| 40 | + | // out to clear+replay when the diff would touch rows that are no | |
| 41 | + | // longer addressable. | |
| 42 | + | logChat []string | |
| 43 | + | logBottom []string | |
| 44 | + | logLines []string | |
| 45 | + | logViewportTop int | |
| 46 | + | logHardwareRow int | |
| 47 | + | logInit bool | |
| 42 | 48 | } | |
| 43 | 49 | ||
| 44 | 50 | // NewRenderer returns a renderer that writes to out. | |
| @@ -59,6 +65,9 @@ func (r *Renderer) Resize(cols, rows int) { | |||
| 59 | 65 | r.prev = nil | |
| 60 | 66 | r.logChat = nil | |
| 61 | 67 | r.logBottom = nil | |
| 68 | + | r.logLines = nil | |
| 69 | + | r.logViewportTop = 0 | |
| 70 | + | r.logHardwareRow = 0 | |
| 62 | 71 | r.logInit = false | |
| 63 | 72 | if r.out != nil { | |
| 64 | 73 | _, _ = io.WriteString(r.out, SeqClearScreen) | |
| @@ -75,6 +84,9 @@ func (r *Renderer) Clear() { | |||
| 75 | 84 | r.prev = nil | |
| 76 | 85 | r.logChat = nil | |
| 77 | 86 | r.logBottom = nil | |
| 87 | + | r.logLines = nil | |
| 88 | + | r.logViewportTop = 0 | |
| 89 | + | r.logHardwareRow = 0 | |
| 78 | 90 | r.logInit = false | |
| 79 | 91 | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) | |
| 80 | 92 | } | |
| @@ -84,6 +96,7 @@ func (r *Renderer) Clear() { | |||
| 84 | 96 | // visible full-screen flash would be too distracting. | |
| 85 | 97 | func (r *Renderer) Invalidate() { | |
| 86 | 98 | r.prev = nil | |
| 99 | + | r.logLines = nil | |
| 87 | 100 | } | |
| 88 | 101 | ||
| 89 | 102 | // Draw updates the terminal so that the visible frame ends with the | |
| @@ -285,107 +298,226 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 285 | 298 | bottomFrame[i] = truncateToWidth(line, r.cols) | |
| 286 | 299 | } | |
| 287 | 300 | ||
| 301 | + | // Always reserve one real row below the editor/status band. This is | |
| 302 | + | // renderer-owned (not a best-effort trailing blank in the caller's | |
| 303 | + | // bottom block), so the logical-buffer diff keeps it visible and cursor | |
| 304 | + | // placement remains relative to the editor itself. | |
| 305 | + | const bottomMarginRows = 1 | |
| 306 | + | lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows) | |
| 307 | + | lines = append(lines, chatFrame...) | |
| 308 | + | lines = append(lines, bottomFrame...) | |
| 309 | + | for range bottomMarginRows { | |
| 310 | + | lines = append(lines, "") | |
| 311 | + | } | |
| 312 | + | if len(lines) == 0 { | |
| 313 | + | lines = []string{""} | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | cursorTargetRow := -1 | |
| 317 | + | if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) { | |
| 318 | + | cursorTargetRow = len(chatFrame) + cursorBottomRow | |
| 319 | + | } | |
| 320 | + | ||
| 288 | 321 | var w strings.Builder | |
| 289 | 322 | w.WriteString(SeqSynchronizedOn) | |
| 290 | 323 | w.WriteString(SeqHideCursor) | |
| 291 | 324 | ||
| 292 | - | if !r.logInit { | |
| 293 | - | // First paint: start at top-left and print the transcript followed | |
| 294 | - | // immediately by the live input/status block. No bottom padding, | |
| 295 | - | // no footer layout. | |
| 296 | - | w.WriteString(SeqClearScreen) | |
| 297 | - | w.WriteString(SeqClearScrollback) | |
| 298 | - | w.WriteString(MoveTo(1, 1)) | |
| 299 | - | for _, line := range chatFrame { | |
| 325 | + | writeFull := func(clear bool) { | |
| 326 | + | if clear { | |
| 327 | + | w.WriteString(SeqDeleteKittyImages) | |
| 328 | + | w.WriteString(SeqClearScreen) | |
| 329 | + | w.WriteString(SeqClearScrollback) | |
| 330 | + | w.WriteString(MoveTo(1, 1)) | |
| 331 | + | } | |
| 332 | + | for idx, line := range lines { | |
| 333 | + | if idx > 0 { | |
| 334 | + | w.WriteString("\r\n") | |
| 335 | + | } | |
| 300 | 336 | w.WriteString("\x1b[0m") | |
| 301 | 337 | w.WriteString(SeqClearLine) | |
| 302 | 338 | w.WriteString(line) | |
| 303 | - | w.WriteString("\r\n") | |
| 304 | 339 | } | |
| 305 | - | writeBlock(&w, bottomFrame) | |
| 306 | - | r.logInit = true | |
| 307 | - | } else { | |
| 308 | - | // Walk back up to the top of the previous bottom block. The cursor | |
| 309 | - | // was last positioned somewhere inside the bottom band by the | |
| 310 | - | // previous Draw (final ShowCursor below); we don't trust the | |
| 311 | - | // terminal's saved cursor across frames because terminal-driven | |
| 312 | - | // scrolling would invalidate it. Instead, rebuild the relative | |
| 313 | - | // position from r.cursorRow inside the previous bottomFrame. | |
| 314 | - | prevBottomRows := len(r.logBottom) | |
| 315 | - | prevCursorRow := r.cursorRow | |
| 316 | - | if prevCursorRow < 0 || prevCursorRow >= prevBottomRows { | |
| 317 | - | prevCursorRow = prevBottomRows - 1 | |
| 318 | - | if prevCursorRow < 0 { | |
| 319 | - | prevCursorRow = 0 | |
| 340 | + | r.logHardwareRow = len(lines) - 1 | |
| 341 | + | r.logViewportTop = len(lines) - r.rows | |
| 342 | + | if r.logViewportTop < 0 { | |
| 343 | + | r.logViewportTop = 0 | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | moveToLogicalRow := func(targetRow int) { | |
| 348 | + | if targetRow < 0 { | |
| 349 | + | targetRow = 0 | |
| 350 | + | } | |
| 351 | + | if targetRow >= len(lines) { | |
| 352 | + | targetRow = len(lines) - 1 | |
| 353 | + | } | |
| 354 | + | viewportBottom := r.logViewportTop + r.rows - 1 | |
| 355 | + | if targetRow > viewportBottom { | |
| 356 | + | currentScreenRow := r.logHardwareRow - r.logViewportTop | |
| 357 | + | if currentScreenRow < 0 { | |
| 358 | + | currentScreenRow = 0 | |
| 359 | + | } | |
| 360 | + | if currentScreenRow >= r.rows { | |
| 361 | + | currentScreenRow = r.rows - 1 | |
| 362 | + | } | |
| 363 | + | moveToBottom := r.rows - 1 - currentScreenRow | |
| 364 | + | if moveToBottom > 0 { | |
| 365 | + | w.WriteString("\x1b[" + itoa(moveToBottom) + "B") | |
| 320 | 366 | } | |
| 367 | + | scroll := targetRow - viewportBottom | |
| 368 | + | for s := 0; s < scroll; s++ { | |
| 369 | + | w.WriteString("\r\n") | |
| 370 | + | } | |
| 371 | + | r.logViewportTop += scroll | |
| 372 | + | r.logHardwareRow = targetRow | |
| 373 | + | return | |
| 321 | 374 | } | |
| 322 | - | up := prevCursorRow | |
| 323 | - | if prevBottomRows > 0 && up > 0 { | |
| 324 | - | w.WriteString("\x1b[" + itoa(up) + "A") | |
| 375 | + | currentScreenRow := r.logHardwareRow - r.logViewportTop | |
| 376 | + | targetScreenRow := targetRow - r.logViewportTop | |
| 377 | + | lineDiff := targetScreenRow - currentScreenRow | |
| 378 | + | if lineDiff > 0 { | |
| 379 | + | w.WriteString("\x1b[" + itoa(lineDiff) + "B") | |
| 380 | + | } else if lineDiff < 0 { | |
| 381 | + | w.WriteString("\x1b[" + itoa(-lineDiff) + "A") | |
| 382 | + | } | |
| 383 | + | r.logHardwareRow = targetRow | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | positionCursor := func() { | |
| 387 | + | if cursorTargetRow < 0 || cursorTargetRow >= len(lines) { | |
| 388 | + | return | |
| 325 | 389 | } | |
| 390 | + | moveToLogicalRow(cursorTargetRow) | |
| 326 | 391 | w.WriteString("\r") | |
| 392 | + | if cursorCol > 0 { | |
| 393 | + | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | |
| 394 | + | } | |
| 395 | + | w.WriteString(SeqShowCursor) | |
| 396 | + | } | |
| 327 | 397 | ||
| 328 | - | prefix := len(r.logChat) <= len(chatFrame) | |
| 329 | - | if prefix { | |
| 330 | - | for i := range r.logChat { | |
| 331 | - | if r.logChat[i] != chatFrame[i] { | |
| 332 | - | prefix = false | |
| 333 | - | break | |
| 398 | + | full := !r.logInit || len(r.logLines) == 0 | |
| 399 | + | if full { | |
| 400 | + | writeFull(true) | |
| 401 | + | r.logInit = true | |
| 402 | + | } else { | |
| 403 | + | firstChanged := -1 | |
| 404 | + | lastChanged := -1 | |
| 405 | + | maxLines := len(lines) | |
| 406 | + | if len(r.logLines) > maxLines { | |
| 407 | + | maxLines = len(r.logLines) | |
| 408 | + | } | |
| 409 | + | for idx := 0; idx < maxLines; idx++ { | |
| 410 | + | oldLine := "" | |
| 411 | + | if idx < len(r.logLines) { | |
| 412 | + | oldLine = r.logLines[idx] | |
| 413 | + | } | |
| 414 | + | newLine := "" | |
| 415 | + | if idx < len(lines) { | |
| 416 | + | newLine = lines[idx] | |
| 417 | + | } | |
| 418 | + | if oldLine != newLine { | |
| 419 | + | if firstChanged == -1 { | |
| 420 | + | firstChanged = idx | |
| 334 | 421 | } | |
| 422 | + | lastChanged = idx | |
| 335 | 423 | } | |
| 336 | 424 | } | |
| 337 | - | if prefix { | |
| 338 | - | // Erase old bottom band entirely, then append only genuinely new | |
| 339 | - | // chat rows above the new bottom band. New chat rows become real | |
| 340 | - | // terminal scrollback; inline image escapes are emitted once here. | |
| 341 | - | w.WriteString(SeqEraseToEnd) | |
| 342 | - | for _, line := range chatFrame[len(r.logChat):] { | |
| 343 | - | w.WriteString("\x1b[0m") | |
| 344 | - | w.WriteString(SeqClearLine) | |
| 345 | - | w.WriteString(line) | |
| 425 | + | ||
| 426 | + | if firstChanged == -1 { | |
| 427 | + | // No content changes; the final cursor positioning below may still | |
| 428 | + | // move the hardware cursor if the editor cursor changed. | |
| 429 | + | } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop { | |
| 430 | + | // Shrinks and changes above the visible viewport cannot be safely | |
| 431 | + | // patched in terminal scrollback. Replay the current logical buffer | |
| 432 | + | // instead of risking stale duplicated rows. | |
| 433 | + | writeFull(true) | |
| 434 | + | } else { | |
| 435 | + | prevViewportTop := r.logViewportTop | |
| 436 | + | viewportTop := prevViewportTop | |
| 437 | + | hardwareRow := r.logHardwareRow | |
| 438 | + | prevViewportBottom := prevViewportTop + r.rows - 1 | |
| 439 | + | appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0 | |
| 440 | + | moveTarget := firstChanged | |
| 441 | + | if appendStart { | |
| 442 | + | moveTarget = firstChanged - 1 | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | if moveTarget > prevViewportBottom { | |
| 446 | + | currentScreenRow := hardwareRow - prevViewportTop | |
| 447 | + | if currentScreenRow < 0 { | |
| 448 | + | currentScreenRow = 0 | |
| 449 | + | } | |
| 450 | + | if currentScreenRow >= r.rows { | |
| 451 | + | currentScreenRow = r.rows - 1 | |
| 452 | + | } | |
| 453 | + | moveToBottom := r.rows - 1 - currentScreenRow | |
| 454 | + | if moveToBottom > 0 { | |
| 455 | + | w.WriteString("\x1b[" + itoa(moveToBottom) + "B") | |
| 456 | + | } | |
| 457 | + | scroll := moveTarget - prevViewportBottom | |
| 458 | + | for s := 0; s < scroll; s++ { | |
| 459 | + | w.WriteString("\r\n") | |
| 460 | + | } | |
| 461 | + | prevViewportTop += scroll | |
| 462 | + | viewportTop += scroll | |
| 463 | + | hardwareRow = moveTarget | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | currentScreenRow := hardwareRow - prevViewportTop | |
| 467 | + | targetScreenRow := moveTarget - viewportTop | |
| 468 | + | lineDiff := targetScreenRow - currentScreenRow | |
| 469 | + | if lineDiff > 0 { | |
| 470 | + | w.WriteString("\x1b[" + itoa(lineDiff) + "B") | |
| 471 | + | } else if lineDiff < 0 { | |
| 472 | + | w.WriteString("\x1b[" + itoa(-lineDiff) + "A") | |
| 473 | + | } | |
| 474 | + | if appendStart { | |
| 346 | 475 | w.WriteString("\r\n") | |
| 476 | + | } else { | |
| 477 | + | w.WriteString("\r") | |
| 347 | 478 | } | |
| 348 | - | } else { | |
| 349 | - | // Transcript changed in place (streaming text grows within the | |
| 350 | - | // last assistant block, markdown finalises/reflows, ctrl+o | |
| 351 | - | // expand/collapse, /clear, session load). In main-screen flow | |
| 352 | - | // mode old chat lives in terminal scrollback; trying to move up | |
| 353 | - | // and rewrite it is unreliable, especially once images or native | |
| 354 | - | // scrolling are involved. Use the safe path: clear visible screen | |
| 355 | - | // + scrollback, then replay the current transcript once. | |
| 356 | - | w.WriteString(SeqDeleteKittyImages) | |
| 357 | - | w.WriteString(SeqClearScreen) | |
| 358 | - | w.WriteString(SeqClearScrollback) | |
| 359 | - | w.WriteString(MoveTo(1, 1)) | |
| 360 | - | for _, line := range chatFrame { | |
| 479 | + | ||
| 480 | + | renderEnd := lastChanged | |
| 481 | + | if renderEnd >= len(lines) { | |
| 482 | + | renderEnd = len(lines) - 1 | |
| 483 | + | } | |
| 484 | + | for idx := firstChanged; idx <= renderEnd; idx++ { | |
| 485 | + | if idx > firstChanged { | |
| 486 | + | w.WriteString("\r\n") | |
| 487 | + | } | |
| 361 | 488 | w.WriteString("\x1b[0m") | |
| 362 | 489 | w.WriteString(SeqClearLine) | |
| 363 | - | w.WriteString(line) | |
| 364 | - | w.WriteString("\r\n") | |
| 490 | + | w.WriteString(lines[idx]) | |
| 491 | + | } | |
| 492 | + | r.logHardwareRow = renderEnd | |
| 493 | + | r.logViewportTop = viewportTop | |
| 494 | + | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | |
| 495 | + | r.logViewportTop = minTop | |
| 496 | + | } | |
| 497 | + | if r.logViewportTop < 0 { | |
| 498 | + | r.logViewportTop = 0 | |
| 365 | 499 | } | |
| 366 | 500 | } | |
| 367 | - | writeBlock(&w, bottomFrame) | |
| 368 | 501 | } | |
| 369 | 502 | ||
| 370 | - | // writeBlock leaves the cursor on the last bottom row. Move to the | |
| 371 | - | // requested cursor position inside that block. | |
| 372 | - | if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) { | |
| 373 | - | up := (len(bottomFrame) - 1) - cursorBottomRow | |
| 374 | - | if up > 0 { | |
| 375 | - | w.WriteString("\x1b[" + itoa(up) + "A") | |
| 376 | - | } | |
| 503 | + | // Re-anchor the terminal viewport at the true end of the logical | |
| 504 | + | // buffer on every frame. During busy turns most redraws only mutate | |
| 505 | + | // spinner/live rows above the editor; without touching the final margin | |
| 506 | + | // row, the terminal can visually lose the bottom padding even though it | |
| 507 | + | // still exists in r.logLines. | |
| 508 | + | if len(lines) > 0 { | |
| 509 | + | moveToLogicalRow(len(lines) - 1) | |
| 377 | 510 | w.WriteString("\r") | |
| 378 | - | if cursorCol > 0 { | |
| 379 | - | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | |
| 380 | - | } | |
| 381 | - | w.WriteString(SeqShowCursor) | |
| 511 | + | w.WriteString("\x1b[0m") | |
| 512 | + | w.WriteString(SeqClearLine) | |
| 382 | 513 | } | |
| 383 | - | ||
| 514 | + | positionCursor() | |
| 384 | 515 | w.WriteString(SeqSynchronizedOff) | |
| 385 | 516 | _, _ = io.WriteString(r.out, w.String()) | |
| 386 | 517 | ||
| 387 | 518 | r.logChat = append(r.logChat[:0], chatFrame...) | |
| 388 | 519 | r.logBottom = append(r.logBottom[:0], bottomFrame...) | |
| 520 | + | r.logLines = append(r.logLines[:0], lines...) | |
| 389 | 521 | r.cursorRow = cursorBottomRow | |
| 390 | 522 | r.cursorCol = cursorCol | |
| 391 | 523 | } | |
| @@ -33,12 +33,18 @@ type Renderer struct { | |||||
| 33 | // enabled via ZOT_INLINE_IMAGES; defaults to false. | 33 | // enabled via ZOT_INLINE_IMAGES; defaults to false. | ||
| 34 | prevHadImage bool | 34 | prevHadImage bool | ||
| 35 | 35 | ||||
| 36 | - | // Main-screen flow renderer state. logChat is the full chat buffer | 36 | + | // Main-screen flow renderer state. logLines is the full logical |
| 37 | - | // already emitted into terminal scrollback. logBottom is the | 37 | + | // buffer (chat + live bottom band) from the previous DrawLog call. |
| 38 | - | // editable/status block currently drawn after the chat. | 38 | + | // logViewportTop/logHardwareRow track where that logical buffer sits |
| 39 | - | logChat []string | 39 | + | // in the terminal's visible viewport so we can diff safely, and bail |
| 40 | - | logBottom []string | 40 | + | // out to clear+replay when the diff would touch rows that are no |
| 41 | - | logInit bool | 41 | + | // longer addressable. |
| 42 | + | logChat []string | |||
| 43 | + | logBottom []string | |||
| 44 | + | logLines []string | |||
| 45 | + | logViewportTop int | |||
| 46 | + | logHardwareRow int | |||
| 47 | + | logInit bool | |||
| 42 | } | 48 | } | ||
| 43 | 49 | ||||
| 44 | // NewRenderer returns a renderer that writes to out. | 50 | // NewRenderer returns a renderer that writes to out. | ||
| @@ -59,6 +65,9 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 59 | r.prev = nil | 65 | r.prev = nil | ||
| 60 | r.logChat = nil | 66 | r.logChat = nil | ||
| 61 | r.logBottom = nil | 67 | r.logBottom = nil | ||
| 68 | + | r.logLines = nil | |||
| 69 | + | r.logViewportTop = 0 | |||
| 70 | + | r.logHardwareRow = 0 | |||
| 62 | r.logInit = false | 71 | r.logInit = false | ||
| 63 | if r.out != nil { | 72 | if r.out != nil { | ||
| 64 | _, _ = io.WriteString(r.out, SeqClearScreen) | 73 | _, _ = io.WriteString(r.out, SeqClearScreen) | ||
| @@ -75,6 +84,9 @@ func (r *Renderer) Clear() { | |||||
| 75 | r.prev = nil | 84 | r.prev = nil | ||
| 76 | r.logChat = nil | 85 | r.logChat = nil | ||
| 77 | r.logBottom = nil | 86 | r.logBottom = nil | ||
| 87 | + | r.logLines = nil | |||
| 88 | + | r.logViewportTop = 0 | |||
| 89 | + | r.logHardwareRow = 0 | |||
| 78 | r.logInit = false | 90 | r.logInit = false | ||
| 79 | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) | 91 | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) | ||
| 80 | } | 92 | } | ||
| @@ -84,6 +96,7 @@ func (r *Renderer) Clear() { | |||||
| 84 | // visible full-screen flash would be too distracting. | 96 | // visible full-screen flash would be too distracting. | ||
| 85 | func (r *Renderer) Invalidate() { | 97 | func (r *Renderer) Invalidate() { | ||
| 86 | r.prev = nil | 98 | r.prev = nil | ||
| 99 | + | r.logLines = nil | |||
| 87 | } | 100 | } | ||
| 88 | 101 | ||||
| 89 | // Draw updates the terminal so that the visible frame ends with the | 102 | // Draw updates the terminal so that the visible frame ends with the | ||
| @@ -285,107 +298,226 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 285 | bottomFrame[i] = truncateToWidth(line, r.cols) | 298 | bottomFrame[i] = truncateToWidth(line, r.cols) | ||
| 286 | } | 299 | } | ||
| 287 | 300 | ||||
| 301 | + | // Always reserve one real row below the editor/status band. This is | |||
| 302 | + | // renderer-owned (not a best-effort trailing blank in the caller's | |||
| 303 | + | // bottom block), so the logical-buffer diff keeps it visible and cursor | |||
| 304 | + | // placement remains relative to the editor itself. | |||
| 305 | + | const bottomMarginRows = 1 | |||
| 306 | + | lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows) | |||
| 307 | + | lines = append(lines, chatFrame...) | |||
| 308 | + | lines = append(lines, bottomFrame...) | |||
| 309 | + | for range bottomMarginRows { | |||
| 310 | + | lines = append(lines, "") | |||
| 311 | + | } | |||
| 312 | + | if len(lines) == 0 { | |||
| 313 | + | lines = []string{""} | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | cursorTargetRow := -1 | |||
| 317 | + | if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) { | |||
| 318 | + | cursorTargetRow = len(chatFrame) + cursorBottomRow | |||
| 319 | + | } | |||
| 320 | + | ||||
| 288 | var w strings.Builder | 321 | var w strings.Builder | ||
| 289 | w.WriteString(SeqSynchronizedOn) | 322 | w.WriteString(SeqSynchronizedOn) | ||
| 290 | w.WriteString(SeqHideCursor) | 323 | w.WriteString(SeqHideCursor) | ||
| 291 | 324 | ||||
| 292 | - | if !r.logInit { | 325 | + | writeFull := func(clear bool) { |
| 293 | - | // First paint: start at top-left and print the transcript followed | 326 | + | if clear { |
| 294 | - | // immediately by the live input/status block. No bottom padding, | 327 | + | w.WriteString(SeqDeleteKittyImages) |
| 295 | - | // no footer layout. | 328 | + | w.WriteString(SeqClearScreen) |
| 296 | - | w.WriteString(SeqClearScreen) | 329 | + | w.WriteString(SeqClearScrollback) |
| 297 | - | w.WriteString(SeqClearScrollback) | 330 | + | w.WriteString(MoveTo(1, 1)) |
| 298 | - | w.WriteString(MoveTo(1, 1)) | 331 | + | } |
| 299 | - | for _, line := range chatFrame { | 332 | + | for idx, line := range lines { |
| 333 | + | if idx > 0 { | |||
| 334 | + | w.WriteString("\r\n") | |||
| 335 | + | } | |||
| 300 | w.WriteString("\x1b[0m") | 336 | w.WriteString("\x1b[0m") | ||
| 301 | w.WriteString(SeqClearLine) | 337 | w.WriteString(SeqClearLine) | ||
| 302 | w.WriteString(line) | 338 | w.WriteString(line) | ||
| 303 | - | w.WriteString("\r\n") | |||
| 304 | } | 339 | } | ||
| 305 | - | writeBlock(&w, bottomFrame) | 340 | + | r.logHardwareRow = len(lines) - 1 |
| 306 | - | r.logInit = true | 341 | + | r.logViewportTop = len(lines) - r.rows |
| 307 | - | } else { | 342 | + | if r.logViewportTop < 0 { |
| 308 | - | // Walk back up to the top of the previous bottom block. The cursor | 343 | + | r.logViewportTop = 0 |
| 309 | - | // was last positioned somewhere inside the bottom band by the | 344 | + | } |
| 310 | - | // previous Draw (final ShowCursor below); we don't trust the | 345 | + | } |
| 311 | - | // terminal's saved cursor across frames because terminal-driven | 346 | + | |
| 312 | - | // scrolling would invalidate it. Instead, rebuild the relative | 347 | + | moveToLogicalRow := func(targetRow int) { |
| 313 | - | // position from r.cursorRow inside the previous bottomFrame. | 348 | + | if targetRow < 0 { |
| 314 | - | prevBottomRows := len(r.logBottom) | 349 | + | targetRow = 0 |
| 315 | - | prevCursorRow := r.cursorRow | 350 | + | } |
| 316 | - | if prevCursorRow < 0 || prevCursorRow >= prevBottomRows { | 351 | + | if targetRow >= len(lines) { |
| 317 | - | prevCursorRow = prevBottomRows - 1 | 352 | + | targetRow = len(lines) - 1 |
| 318 | - | if prevCursorRow < 0 { | 353 | + | } |
| 319 | - | prevCursorRow = 0 | 354 | + | viewportBottom := r.logViewportTop + r.rows - 1 |
| 355 | + | if targetRow > viewportBottom { | |||
| 356 | + | currentScreenRow := r.logHardwareRow - r.logViewportTop | |||
| 357 | + | if currentScreenRow < 0 { | |||
| 358 | + | currentScreenRow = 0 | |||
| 359 | + | } | |||
| 360 | + | if currentScreenRow >= r.rows { | |||
| 361 | + | currentScreenRow = r.rows - 1 | |||
| 362 | + | } | |||
| 363 | + | moveToBottom := r.rows - 1 - currentScreenRow | |||
| 364 | + | if moveToBottom > 0 { | |||
| 365 | + | w.WriteString("\x1b[" + itoa(moveToBottom) + "B") | |||
| 320 | } | 366 | } | ||
| 367 | + | scroll := targetRow - viewportBottom | |||
| 368 | + | for s := 0; s < scroll; s++ { | |||
| 369 | + | w.WriteString("\r\n") | |||
| 370 | + | } | |||
| 371 | + | r.logViewportTop += scroll | |||
| 372 | + | r.logHardwareRow = targetRow | |||
| 373 | + | return | |||
| 321 | } | 374 | } | ||
| 322 | - | up := prevCursorRow | 375 | + | currentScreenRow := r.logHardwareRow - r.logViewportTop |
| 323 | - | if prevBottomRows > 0 && up > 0 { | 376 | + | targetScreenRow := targetRow - r.logViewportTop |
| 324 | - | w.WriteString("\x1b[" + itoa(up) + "A") | 377 | + | lineDiff := targetScreenRow - currentScreenRow |
| 378 | + | if lineDiff > 0 { | |||
| 379 | + | w.WriteString("\x1b[" + itoa(lineDiff) + "B") | |||
| 380 | + | } else if lineDiff < 0 { | |||
| 381 | + | w.WriteString("\x1b[" + itoa(-lineDiff) + "A") | |||
| 382 | + | } | |||
| 383 | + | r.logHardwareRow = targetRow | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | positionCursor := func() { | |||
| 387 | + | if cursorTargetRow < 0 || cursorTargetRow >= len(lines) { | |||
| 388 | + | return | |||
| 325 | } | 389 | } | ||
| 390 | + | moveToLogicalRow(cursorTargetRow) | |||
| 326 | w.WriteString("\r") | 391 | w.WriteString("\r") | ||
| 392 | + | if cursorCol > 0 { | |||
| 393 | + | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | |||
| 394 | + | } | |||
| 395 | + | w.WriteString(SeqShowCursor) | |||
| 396 | + | } | |||
| 327 | 397 | ||||
| 328 | - | prefix := len(r.logChat) <= len(chatFrame) | 398 | + | full := !r.logInit || len(r.logLines) == 0 |
| 329 | - | if prefix { | 399 | + | if full { |
| 330 | - | for i := range r.logChat { | 400 | + | writeFull(true) |
| 331 | - | if r.logChat[i] != chatFrame[i] { | 401 | + | r.logInit = true |
| 332 | - | prefix = false | 402 | + | } else { |
| 333 | - | break | 403 | + | firstChanged := -1 |
| 404 | + | lastChanged := -1 | |||
| 405 | + | maxLines := len(lines) | |||
| 406 | + | if len(r.logLines) > maxLines { | |||
| 407 | + | maxLines = len(r.logLines) | |||
| 408 | + | } | |||
| 409 | + | for idx := 0; idx < maxLines; idx++ { | |||
| 410 | + | oldLine := "" | |||
| 411 | + | if idx < len(r.logLines) { | |||
| 412 | + | oldLine = r.logLines[idx] | |||
| 413 | + | } | |||
| 414 | + | newLine := "" | |||
| 415 | + | if idx < len(lines) { | |||
| 416 | + | newLine = lines[idx] | |||
| 417 | + | } | |||
| 418 | + | if oldLine != newLine { | |||
| 419 | + | if firstChanged == -1 { | |||
| 420 | + | firstChanged = idx | |||
| 334 | } | 421 | } | ||
| 422 | + | lastChanged = idx | |||
| 335 | } | 423 | } | ||
| 336 | } | 424 | } | ||
| 337 | - | if prefix { | 425 | + | |
| 338 | - | // Erase old bottom band entirely, then append only genuinely new | 426 | + | if firstChanged == -1 { |
| 339 | - | // chat rows above the new bottom band. New chat rows become real | 427 | + | // No content changes; the final cursor positioning below may still |
| 340 | - | // terminal scrollback; inline image escapes are emitted once here. | 428 | + | // move the hardware cursor if the editor cursor changed. |
| 341 | - | w.WriteString(SeqEraseToEnd) | 429 | + | } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop { |
| 342 | - | for _, line := range chatFrame[len(r.logChat):] { | 430 | + | // Shrinks and changes above the visible viewport cannot be safely |
| 343 | - | w.WriteString("\x1b[0m") | 431 | + | // patched in terminal scrollback. Replay the current logical buffer |
| 344 | - | w.WriteString(SeqClearLine) | 432 | + | // instead of risking stale duplicated rows. |
| 345 | - | w.WriteString(line) | 433 | + | writeFull(true) |
| 434 | + | } else { | |||
| 435 | + | prevViewportTop := r.logViewportTop | |||
| 436 | + | viewportTop := prevViewportTop | |||
| 437 | + | hardwareRow := r.logHardwareRow | |||
| 438 | + | prevViewportBottom := prevViewportTop + r.rows - 1 | |||
| 439 | + | appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0 | |||
| 440 | + | moveTarget := firstChanged | |||
| 441 | + | if appendStart { | |||
| 442 | + | moveTarget = firstChanged - 1 | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | if moveTarget > prevViewportBottom { | |||
| 446 | + | currentScreenRow := hardwareRow - prevViewportTop | |||
| 447 | + | if currentScreenRow < 0 { | |||
| 448 | + | currentScreenRow = 0 | |||
| 449 | + | } | |||
| 450 | + | if currentScreenRow >= r.rows { | |||
| 451 | + | currentScreenRow = r.rows - 1 | |||
| 452 | + | } | |||
| 453 | + | moveToBottom := r.rows - 1 - currentScreenRow | |||
| 454 | + | if moveToBottom > 0 { | |||
| 455 | + | w.WriteString("\x1b[" + itoa(moveToBottom) + "B") | |||
| 456 | + | } | |||
| 457 | + | scroll := moveTarget - prevViewportBottom | |||
| 458 | + | for s := 0; s < scroll; s++ { | |||
| 459 | + | w.WriteString("\r\n") | |||
| 460 | + | } | |||
| 461 | + | prevViewportTop += scroll | |||
| 462 | + | viewportTop += scroll | |||
| 463 | + | hardwareRow = moveTarget | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | currentScreenRow := hardwareRow - prevViewportTop | |||
| 467 | + | targetScreenRow := moveTarget - viewportTop | |||
| 468 | + | lineDiff := targetScreenRow - currentScreenRow | |||
| 469 | + | if lineDiff > 0 { | |||
| 470 | + | w.WriteString("\x1b[" + itoa(lineDiff) + "B") | |||
| 471 | + | } else if lineDiff < 0 { | |||
| 472 | + | w.WriteString("\x1b[" + itoa(-lineDiff) + "A") | |||
| 473 | + | } | |||
| 474 | + | if appendStart { | |||
| 346 | w.WriteString("\r\n") | 475 | w.WriteString("\r\n") | ||
| 476 | + | } else { | |||
| 477 | + | w.WriteString("\r") | |||
| 347 | } | 478 | } | ||
| 348 | - | } else { | 479 | + | |
| 349 | - | // Transcript changed in place (streaming text grows within the | 480 | + | renderEnd := lastChanged |
| 350 | - | // last assistant block, markdown finalises/reflows, ctrl+o | 481 | + | if renderEnd >= len(lines) { |
| 351 | - | // expand/collapse, /clear, session load). In main-screen flow | 482 | + | renderEnd = len(lines) - 1 |
| 352 | - | // mode old chat lives in terminal scrollback; trying to move up | 483 | + | } |
| 353 | - | // and rewrite it is unreliable, especially once images or native | 484 | + | for idx := firstChanged; idx <= renderEnd; idx++ { |
| 354 | - | // scrolling are involved. Use the safe path: clear visible screen | 485 | + | if idx > firstChanged { |
| 355 | - | // + scrollback, then replay the current transcript once. | 486 | + | w.WriteString("\r\n") |
| 356 | - | w.WriteString(SeqDeleteKittyImages) | 487 | + | } |
| 357 | - | w.WriteString(SeqClearScreen) | |||
| 358 | - | w.WriteString(SeqClearScrollback) | |||
| 359 | - | w.WriteString(MoveTo(1, 1)) | |||
| 360 | - | for _, line := range chatFrame { | |||
| 361 | w.WriteString("\x1b[0m") | 488 | w.WriteString("\x1b[0m") | ||
| 362 | w.WriteString(SeqClearLine) | 489 | w.WriteString(SeqClearLine) | ||
| 363 | - | w.WriteString(line) | 490 | + | w.WriteString(lines[idx]) |
| 364 | - | w.WriteString("\r\n") | 491 | + | } |
| 492 | + | r.logHardwareRow = renderEnd | |||
| 493 | + | r.logViewportTop = viewportTop | |||
| 494 | + | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | |||
| 495 | + | r.logViewportTop = minTop | |||
| 496 | + | } | |||
| 497 | + | if r.logViewportTop < 0 { | |||
| 498 | + | r.logViewportTop = 0 | |||
| 365 | } | 499 | } | ||
| 366 | } | 500 | } | ||
| 367 | - | writeBlock(&w, bottomFrame) | |||
| 368 | } | 501 | } | ||
| 369 | 502 | ||||
| 370 | - | // writeBlock leaves the cursor on the last bottom row. Move to the | 503 | + | // Re-anchor the terminal viewport at the true end of the logical |
| 371 | - | // requested cursor position inside that block. | 504 | + | // buffer on every frame. During busy turns most redraws only mutate |
| 372 | - | if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) { | 505 | + | // spinner/live rows above the editor; without touching the final margin |
| 373 | - | up := (len(bottomFrame) - 1) - cursorBottomRow | 506 | + | // row, the terminal can visually lose the bottom padding even though it |
| 374 | - | if up > 0 { | 507 | + | // still exists in r.logLines. |
| 375 | - | w.WriteString("\x1b[" + itoa(up) + "A") | 508 | + | if len(lines) > 0 { |
| 376 | - | } | 509 | + | moveToLogicalRow(len(lines) - 1) |
| 377 | w.WriteString("\r") | 510 | w.WriteString("\r") | ||
| 378 | - | if cursorCol > 0 { | 511 | + | w.WriteString("\x1b[0m") |
| 379 | - | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | 512 | + | w.WriteString(SeqClearLine) |
| 380 | - | } | |||
| 381 | - | w.WriteString(SeqShowCursor) | |||
| 382 | } | 513 | } | ||
| 383 | - | 514 | + | positionCursor() | |
| 384 | w.WriteString(SeqSynchronizedOff) | 515 | w.WriteString(SeqSynchronizedOff) | ||
| 385 | _, _ = io.WriteString(r.out, w.String()) | 516 | _, _ = io.WriteString(r.out, w.String()) | ||
| 386 | 517 | ||||
| 387 | r.logChat = append(r.logChat[:0], chatFrame...) | 518 | r.logChat = append(r.logChat[:0], chatFrame...) | ||
| 388 | r.logBottom = append(r.logBottom[:0], bottomFrame...) | 519 | r.logBottom = append(r.logBottom[:0], bottomFrame...) | ||
| 520 | + | r.logLines = append(r.logLines[:0], lines...) | |||
| 389 | r.cursorRow = cursorBottomRow | 521 | r.cursorRow = cursorBottomRow | ||
| 390 | r.cursorCol = cursorCol | 522 | r.cursorCol = cursorCol | ||
| 391 | } | 523 | } | ||