fix(tui): preserve scrollback with right bar
- commit
- f976a5c9b256b6c9905b0c7c0d5a7d8ae1c5b27a
- author
- brice · · 2026-08-08 05:24 UTC
- parent
- ab30a57
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +390 -83
M packages/agent/modes/interactive.go +0 -5
| @@ -2224,11 +2224,6 @@ func (i *Interactive) redraw() { | |||
| 2224 | 2224 | i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0 | |
| 2225 | 2225 | ||
| 2226 | 2226 | chatRows := rows - len(bottom) | |
| 2227 | - | if rightBarActive { | |
| 2228 | - | // DrawRightBar keeps the renderer-owned bottom margin inside the | |
| 2229 | - | // fixed frame, so reserve that row before selecting visible chat. | |
| 2230 | - | chatRows-- | |
| 2231 | - | } | |
| 2232 | 2227 | if chatRows < 1 { | |
| 2233 | 2228 | chatRows = 1 | |
| 2234 | 2229 | } | |
| @@ -2224,11 +2224,6 @@ func (i *Interactive) redraw() { | |||||
| 2224 | i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0 | 2224 | i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0 | ||
| 2225 | 2225 | ||||
| 2226 | chatRows := rows - len(bottom) | 2226 | chatRows := rows - len(bottom) | ||
| 2227 | - | if rightBarActive { | |||
| 2228 | - | // DrawRightBar keeps the renderer-owned bottom margin inside the | |||
| 2229 | - | // fixed frame, so reserve that row before selecting visible chat. | |||
| 2230 | - | chatRows-- | |||
| 2231 | - | } | |||
| 2232 | if chatRows < 1 { | 2227 | if chatRows < 1 { | ||
| 2233 | chatRows = 1 | 2228 | chatRows = 1 | ||
| 2234 | } | 2229 | } | ||
M packages/tui/editor.go +15 -0
| @@ -912,6 +912,21 @@ func stripANSI(s string) string { | |||
| 912 | 912 | } | |
| 913 | 913 | continue | |
| 914 | 914 | } | |
| 915 | + | if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' { | |
| 916 | + | i += 2 | |
| 917 | + | for i < len(runes) { | |
| 918 | + | if runes[i] == 0x07 { | |
| 919 | + | i++ | |
| 920 | + | break | |
| 921 | + | } | |
| 922 | + | if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '\\' { | |
| 923 | + | i += 2 | |
| 924 | + | break | |
| 925 | + | } | |
| 926 | + | i++ | |
| 927 | + | } | |
| 928 | + | continue | |
| 929 | + | } | |
| 915 | 930 | out = append(out, runes[i]) | |
| 916 | 931 | i++ | |
| 917 | 932 | } | |
| @@ -912,6 +912,21 @@ func stripANSI(s string) string { | |||||
| 912 | } | 912 | } | ||
| 913 | continue | 913 | continue | ||
| 914 | } | 914 | } | ||
| 915 | + | if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' { | |||
| 916 | + | i += 2 | |||
| 917 | + | for i < len(runes) { | |||
| 918 | + | if runes[i] == 0x07 { | |||
| 919 | + | i++ | |||
| 920 | + | break | |||
| 921 | + | } | |||
| 922 | + | if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '\\' { | |||
| 923 | + | i += 2 | |||
| 924 | + | break | |||
| 925 | + | } | |||
| 926 | + | i++ | |||
| 927 | + | } | |||
| 928 | + | continue | |||
| 929 | + | } | |||
| 915 | out = append(out, runes[i]) | 930 | out = append(out, runes[i]) | ||
| 916 | i++ | 931 | i++ | ||
| 917 | } | 932 | } | ||
M packages/tui/render.go +131 -30
| @@ -42,16 +42,24 @@ type Renderer struct { | |||
| 42 | 42 | logLines []string | |
| 43 | 43 | // Raw logical rows let DrawLog reject an unchanged frame before | |
| 44 | 44 | // truncating and repainting every transcript row. | |
| 45 | - | logRawChat []string | |
| 46 | - | logRawBottom []string | |
| 47 | - | logRawCols int | |
| 48 | - | logRawRows int | |
| 49 | - | logRawBackground string | |
| 50 | - | logRawHasThemeBG bool | |
| 51 | - | logViewportTop int | |
| 52 | - | logHardwareRow int | |
| 53 | - | logInit bool | |
| 54 | - | logNeedsFullRepaint bool | |
| 45 | + | logRawChat []string | |
| 46 | + | logRawBottom []string | |
| 47 | + | logRawCols int | |
| 48 | + | logRawRows int | |
| 49 | + | logRawContentCols int | |
| 50 | + | logRawBackground string | |
| 51 | + | logRawHasThemeBG bool | |
| 52 | + | logViewportTop int | |
| 53 | + | logHardwareRow int | |
| 54 | + | logInit bool | |
| 55 | + | ||
| 56 | + | // The right bar is a viewport overlay on top of the main-screen flow | |
| 57 | + | // renderer. Keeping its cache separate prevents persistent chrome from | |
| 58 | + | // replacing the transcript's logical buffer and native scrollback. | |
| 59 | + | rightBarActive bool | |
| 60 | + | rightBarLines []string | |
| 61 | + | rightBarMainCols int | |
| 62 | + | rightBarDimmed bool | |
| 55 | 63 | ||
| 56 | 64 | // keepScrollback is true when we must NOT emit \x1b[3J | |
| 57 | 65 | // (erase-in-display 3, "clear scrollback"). | |
| @@ -130,6 +138,9 @@ func (r *Renderer) Resize(cols, rows int) { | |||
| 130 | 138 | r.logViewportTop = 0 | |
| 131 | 139 | r.logHardwareRow = 0 | |
| 132 | 140 | r.logInit = false | |
| 141 | + | r.rightBarActive = false | |
| 142 | + | r.rightBarLines = nil | |
| 143 | + | r.rightBarMainCols = 0 | |
| 133 | 144 | if r.out != nil { | |
| 134 | 145 | if r.keepScrollback { | |
| 135 | 146 | // A resize is a discrete user action, like Ctrl+L: the | |
| @@ -166,6 +177,9 @@ func (r *Renderer) Clear() { | |||
| 166 | 177 | r.logViewportTop = 0 | |
| 167 | 178 | r.logHardwareRow = 0 | |
| 168 | 179 | r.logInit = false | |
| 180 | + | r.rightBarActive = false | |
| 181 | + | r.rightBarLines = nil | |
| 182 | + | r.rightBarMainCols = 0 | |
| 169 | 183 | if r.keepScrollback { | |
| 170 | 184 | // On VS Code's xterm.js the transcript is taller than the | |
| 171 | 185 | // viewport, so an in-place clear (home + erase-to-end) only | |
| @@ -264,6 +278,7 @@ func truncateToWidth(s string, cols int) string { | |||
| 264 | 278 | var out strings.Builder | |
| 265 | 279 | out.Grow(len(s)) | |
| 266 | 280 | seen := 0 | |
| 281 | + | osc8Active := false | |
| 267 | 282 | runes := []rune(s) | |
| 268 | 283 | for i := 0; i < len(runes); { | |
| 269 | 284 | r := runes[i] | |
| @@ -282,8 +297,41 @@ func truncateToWidth(s string, cols int) string { | |||
| 282 | 297 | } | |
| 283 | 298 | continue | |
| 284 | 299 | } | |
| 300 | + | // OSC string escape (BEL or ST terminated): zero-width and atomic. | |
| 301 | + | if r == 0x1b && i+1 < len(runes) && runes[i+1] == ']' { | |
| 302 | + | start := i | |
| 303 | + | i += 2 | |
| 304 | + | for i < len(runes) { | |
| 305 | + | c := runes[i] | |
| 306 | + | i++ | |
| 307 | + | if c == 0x07 { | |
| 308 | + | break | |
| 309 | + | } | |
| 310 | + | if c == 0x1b && i < len(runes) && runes[i] == '\\' { | |
| 311 | + | i++ | |
| 312 | + | break | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | osc8Closer := start+5 < i && runes[start+2] == '8' && runes[start+3] == ';' && runes[start+4] == ';' && | |
| 316 | + | ((runes[start+5] == 0x07 && i == start+6) || | |
| 317 | + | (start+6 < i && runes[start+5] == 0x1b && runes[start+6] == '\\' && i == start+7)) | |
| 318 | + | if seen < cols || osc8Closer { | |
| 319 | + | for _, c := range runes[start:i] { | |
| 320 | + | out.WriteRune(c) | |
| 321 | + | } | |
| 322 | + | if runes[start+2] == '8' { | |
| 323 | + | osc8Active = !osc8Closer | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | continue | |
| 327 | + | } | |
| 285 | 328 | rw := runewidthRune(r) | |
| 286 | 329 | if seen+rw > cols { | |
| 330 | + | if osc8Active { | |
| 331 | + | // The remaining text is omitted, so close a hyperlink | |
| 332 | + | // whose opener was already emitted before the boundary. | |
| 333 | + | out.WriteString("\x1b]8;;\x07") | |
| 334 | + | } | |
| 287 | 335 | // Flush any trailing ANSI escapes (resets, erase-to-EOL) | |
| 288 | 336 | // so background colors and cleanup sequences survive. | |
| 289 | 337 | for i < len(runes) { | |
| @@ -299,6 +347,31 @@ func truncateToWidth(s string, cols int) string { | |||
| 299 | 347 | break | |
| 300 | 348 | } | |
| 301 | 349 | } | |
| 350 | + | } else if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' { | |
| 351 | + | // Do not append a new OSC sequence after the visible | |
| 352 | + | // boundary: an OSC 8 opener would leak hyperlink state | |
| 353 | + | // into the next terminal row. A matching OSC 8 closer is | |
| 354 | + | // retained so a link opened before the boundary is closed. | |
| 355 | + | keep := i+5 < len(runes) && runes[i+2] == '8' && runes[i+3] == ';' && runes[i+4] == ';' && | |
| 356 | + | (runes[i+5] == 0x07 || (i+6 < len(runes) && runes[i+5] == 0x1b && runes[i+6] == '\\')) | |
| 357 | + | start := i | |
| 358 | + | i += 2 | |
| 359 | + | for i < len(runes) { | |
| 360 | + | c := runes[i] | |
| 361 | + | i++ | |
| 362 | + | if c == 0x07 { | |
| 363 | + | break | |
| 364 | + | } | |
| 365 | + | if c == 0x1b && i < len(runes) && runes[i] == '\\' { | |
| 366 | + | i++ | |
| 367 | + | break | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | if keep { | |
| 371 | + | for _, c := range runes[start:i] { | |
| 372 | + | out.WriteRune(c) | |
| 373 | + | } | |
| 374 | + | } | |
| 302 | 375 | } else { | |
| 303 | 376 | break | |
| 304 | 377 | } | |
| @@ -432,26 +505,34 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 432 | 505 | // | |
| 433 | 506 | // cursorBottomRow/cursorCol are offsets into bottom, not the full frame. | |
| 434 | 507 | func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) { | |
| 508 | + | if r.rightBarActive { | |
| 509 | + | r.clearRightBarOverlay() | |
| 510 | + | } | |
| 511 | + | r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols) | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | // drawLog is DrawLog's owning flow implementation. contentCols may reserve | |
| 515 | + | // terminal columns for a separately composited viewport overlay. | |
| 516 | + | func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool { | |
| 435 | 517 | if r.cols == 0 || r.rows == 0 { | |
| 436 | - | return | |
| 518 | + | return false | |
| 519 | + | } | |
| 520 | + | if contentCols <= 0 || contentCols > r.cols { | |
| 521 | + | contentCols = r.cols | |
| 437 | 522 | } | |
| 438 | 523 | if len(bottom) == 0 { | |
| 439 | 524 | bottom = []string{""} | |
| 440 | 525 | } | |
| 441 | - | if r.logInit && len(r.logLines) > 0 && | |
| 442 | - | cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && | |
| 443 | - | r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle && | |
| 444 | - | r.logRawHasThemeBG == (r.theme.Background != nil) && | |
| 445 | - | sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) { | |
| 446 | - | return | |
| 526 | + | if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) { | |
| 527 | + | return false | |
| 447 | 528 | } | |
| 448 | 529 | chatFrame := make([]string, len(chat)) | |
| 449 | 530 | for i, line := range chat { | |
| 450 | - | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | |
| 531 | + | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle) | |
| 451 | 532 | } | |
| 452 | 533 | bottomFrame := make([]string, len(bottom)) | |
| 453 | 534 | for i, line := range bottom { | |
| 454 | - | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | |
| 535 | + | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle) | |
| 455 | 536 | } | |
| 456 | 537 | ||
| 457 | 538 | // Always reserve one real row below the editor/status band. This is | |
| @@ -463,7 +544,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 463 | 544 | lines = append(lines, chatFrame...) | |
| 464 | 545 | lines = append(lines, bottomFrame...) | |
| 465 | 546 | for range bottomMarginRows { | |
| 466 | - | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) | |
| 547 | + | lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle)) | |
| 467 | 548 | } | |
| 468 | 549 | // In main-screen flow mode zut normally emits only its logical | |
| 469 | 550 | // content rows and leaves the rest of the terminal viewport alone. | |
| @@ -473,7 +554,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 473 | 554 | // default transparent case. | |
| 474 | 555 | if r.theme.Background != nil { | |
| 475 | 556 | for len(lines) < r.rows { | |
| 476 | - | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) | |
| 557 | + | lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle)) | |
| 477 | 558 | } | |
| 478 | 559 | } | |
| 479 | 560 | if len(lines) == 0 { | |
| @@ -495,8 +576,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 495 | 576 | // that never blinks, because we keep "showing" it before the | |
| 496 | 577 | // terminal can blink it off. Bailing out here lets the OS run | |
| 497 | 578 | // its blink cycle. | |
| 498 | - | if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { | |
| 499 | - | return | |
| 579 | + | if r.logInit && r.logRawContentCols == contentCols && | |
| 580 | + | cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { | |
| 581 | + | return false | |
| 500 | 582 | } | |
| 501 | 583 | ||
| 502 | 584 | var w strings.Builder | |
| @@ -590,8 +672,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 590 | 672 | } | |
| 591 | 673 | moveToLogicalRow(cursorTargetRow) | |
| 592 | 674 | w.WriteString("\r") | |
| 593 | - | if cursorCol > 0 { | |
| 594 | - | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | |
| 675 | + | physicalCursorCol := cursorCol | |
| 676 | + | if physicalCursorCol < 0 { | |
| 677 | + | physicalCursorCol = 0 | |
| 678 | + | } | |
| 679 | + | if physicalCursorCol >= contentCols { | |
| 680 | + | physicalCursorCol = contentCols - 1 | |
| 681 | + | } | |
| 682 | + | if physicalCursorCol > 0 { | |
| 683 | + | w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C") | |
| 595 | 684 | } | |
| 596 | 685 | w.WriteString(SeqShowCursor) | |
| 597 | 686 | } | |
| @@ -605,12 +694,10 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 605 | 694 | wasInitialized := r.logInit | |
| 606 | 695 | full := !wasInitialized || len(r.logLines) == 0 | |
| 607 | 696 | if full { | |
| 608 | - | // Returning from the fixed right-bar frame needs a full viewport | |
| 609 | - | // repaint, but it must not discard terminal scrollback merely because | |
| 610 | - | // the renderer's flow cache is intentionally empty. | |
| 611 | - | writeFull(true, !wasInitialized && !r.logNeedsFullRepaint) | |
| 697 | + | // A cache invalidation may require a full viewport repaint after flow | |
| 698 | + | // has started, but only the initial paint may discard old scrollback. | |
| 699 | + | writeFull(true, !wasInitialized) | |
| 612 | 700 | r.logInit = true | |
| 613 | - | r.logNeedsFullRepaint = false | |
| 614 | 701 | } else { | |
| 615 | 702 | firstChanged := -1 | |
| 616 | 703 | lastChanged := -1 | |
| @@ -827,10 +914,24 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 827 | 914 | r.logRawBottom = append(r.logRawBottom[:0], bottom...) | |
| 828 | 915 | r.logRawCols = r.cols | |
| 829 | 916 | r.logRawRows = r.rows | |
| 917 | + | r.logRawContentCols = contentCols | |
| 830 | 918 | r.logRawBackground = r.backgroundStyle | |
| 831 | 919 | r.logRawHasThemeBG = r.theme.Background != nil | |
| 832 | 920 | r.cursorRow = cursorBottomRow | |
| 833 | 921 | r.cursorCol = cursorCol | |
| 922 | + | return true | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool { | |
| 926 | + | return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && | |
| 927 | + | r.logRawContentUnchanged(chat, bottom, contentCols) | |
| 928 | + | } | |
| 929 | + | ||
| 930 | + | func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool { | |
| 931 | + | return r.logInit && len(r.logLines) > 0 && | |
| 932 | + | r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols && | |
| 933 | + | r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) && | |
| 934 | + | sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) | |
| 834 | 935 | } | |
| 835 | 936 | ||
| 836 | 937 | // sameLines reports whether two []string have the exact same | |
| @@ -42,16 +42,24 @@ type Renderer struct { | |||||
| 42 | logLines []string | 42 | logLines []string | ||
| 43 | // Raw logical rows let DrawLog reject an unchanged frame before | 43 | // Raw logical rows let DrawLog reject an unchanged frame before | ||
| 44 | // truncating and repainting every transcript row. | 44 | // truncating and repainting every transcript row. | ||
| 45 | - | logRawChat []string | 45 | + | logRawChat []string |
| 46 | - | logRawBottom []string | 46 | + | logRawBottom []string |
| 47 | - | logRawCols int | 47 | + | logRawCols int |
| 48 | - | logRawRows int | 48 | + | logRawRows int |
| 49 | - | logRawBackground string | 49 | + | logRawContentCols int |
| 50 | - | logRawHasThemeBG bool | 50 | + | logRawBackground string |
| 51 | - | logViewportTop int | 51 | + | logRawHasThemeBG bool |
| 52 | - | logHardwareRow int | 52 | + | logViewportTop int |
| 53 | - | logInit bool | 53 | + | logHardwareRow int |
| 54 | - | logNeedsFullRepaint bool | 54 | + | logInit bool |
| 55 | + | ||||
| 56 | + | // The right bar is a viewport overlay on top of the main-screen flow | |||
| 57 | + | // renderer. Keeping its cache separate prevents persistent chrome from | |||
| 58 | + | // replacing the transcript's logical buffer and native scrollback. | |||
| 59 | + | rightBarActive bool | |||
| 60 | + | rightBarLines []string | |||
| 61 | + | rightBarMainCols int | |||
| 62 | + | rightBarDimmed bool | |||
| 55 | 63 | ||||
| 56 | // keepScrollback is true when we must NOT emit \x1b[3J | 64 | // keepScrollback is true when we must NOT emit \x1b[3J | ||
| 57 | // (erase-in-display 3, "clear scrollback"). | 65 | // (erase-in-display 3, "clear scrollback"). | ||
| @@ -130,6 +138,9 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 130 | r.logViewportTop = 0 | 138 | r.logViewportTop = 0 | ||
| 131 | r.logHardwareRow = 0 | 139 | r.logHardwareRow = 0 | ||
| 132 | r.logInit = false | 140 | r.logInit = false | ||
| 141 | + | r.rightBarActive = false | |||
| 142 | + | r.rightBarLines = nil | |||
| 143 | + | r.rightBarMainCols = 0 | |||
| 133 | if r.out != nil { | 144 | if r.out != nil { | ||
| 134 | if r.keepScrollback { | 145 | if r.keepScrollback { | ||
| 135 | // A resize is a discrete user action, like Ctrl+L: the | 146 | // A resize is a discrete user action, like Ctrl+L: the | ||
| @@ -166,6 +177,9 @@ func (r *Renderer) Clear() { | |||||
| 166 | r.logViewportTop = 0 | 177 | r.logViewportTop = 0 | ||
| 167 | r.logHardwareRow = 0 | 178 | r.logHardwareRow = 0 | ||
| 168 | r.logInit = false | 179 | r.logInit = false | ||
| 180 | + | r.rightBarActive = false | |||
| 181 | + | r.rightBarLines = nil | |||
| 182 | + | r.rightBarMainCols = 0 | |||
| 169 | if r.keepScrollback { | 183 | if r.keepScrollback { | ||
| 170 | // On VS Code's xterm.js the transcript is taller than the | 184 | // On VS Code's xterm.js the transcript is taller than the | ||
| 171 | // viewport, so an in-place clear (home + erase-to-end) only | 185 | // viewport, so an in-place clear (home + erase-to-end) only | ||
| @@ -264,6 +278,7 @@ func truncateToWidth(s string, cols int) string { | |||||
| 264 | var out strings.Builder | 278 | var out strings.Builder | ||
| 265 | out.Grow(len(s)) | 279 | out.Grow(len(s)) | ||
| 266 | seen := 0 | 280 | seen := 0 | ||
| 281 | + | osc8Active := false | |||
| 267 | runes := []rune(s) | 282 | runes := []rune(s) | ||
| 268 | for i := 0; i < len(runes); { | 283 | for i := 0; i < len(runes); { | ||
| 269 | r := runes[i] | 284 | r := runes[i] | ||
| @@ -282,8 +297,41 @@ func truncateToWidth(s string, cols int) string { | |||||
| 282 | } | 297 | } | ||
| 283 | continue | 298 | continue | ||
| 284 | } | 299 | } | ||
| 300 | + | // OSC string escape (BEL or ST terminated): zero-width and atomic. | |||
| 301 | + | if r == 0x1b && i+1 < len(runes) && runes[i+1] == ']' { | |||
| 302 | + | start := i | |||
| 303 | + | i += 2 | |||
| 304 | + | for i < len(runes) { | |||
| 305 | + | c := runes[i] | |||
| 306 | + | i++ | |||
| 307 | + | if c == 0x07 { | |||
| 308 | + | break | |||
| 309 | + | } | |||
| 310 | + | if c == 0x1b && i < len(runes) && runes[i] == '\\' { | |||
| 311 | + | i++ | |||
| 312 | + | break | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | osc8Closer := start+5 < i && runes[start+2] == '8' && runes[start+3] == ';' && runes[start+4] == ';' && | |||
| 316 | + | ((runes[start+5] == 0x07 && i == start+6) || | |||
| 317 | + | (start+6 < i && runes[start+5] == 0x1b && runes[start+6] == '\\' && i == start+7)) | |||
| 318 | + | if seen < cols || osc8Closer { | |||
| 319 | + | for _, c := range runes[start:i] { | |||
| 320 | + | out.WriteRune(c) | |||
| 321 | + | } | |||
| 322 | + | if runes[start+2] == '8' { | |||
| 323 | + | osc8Active = !osc8Closer | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | continue | |||
| 327 | + | } | |||
| 285 | rw := runewidthRune(r) | 328 | rw := runewidthRune(r) | ||
| 286 | if seen+rw > cols { | 329 | if seen+rw > cols { | ||
| 330 | + | if osc8Active { | |||
| 331 | + | // The remaining text is omitted, so close a hyperlink | |||
| 332 | + | // whose opener was already emitted before the boundary. | |||
| 333 | + | out.WriteString("\x1b]8;;\x07") | |||
| 334 | + | } | |||
| 287 | // Flush any trailing ANSI escapes (resets, erase-to-EOL) | 335 | // Flush any trailing ANSI escapes (resets, erase-to-EOL) | ||
| 288 | // so background colors and cleanup sequences survive. | 336 | // so background colors and cleanup sequences survive. | ||
| 289 | for i < len(runes) { | 337 | for i < len(runes) { | ||
| @@ -299,6 +347,31 @@ func truncateToWidth(s string, cols int) string { | |||||
| 299 | break | 347 | break | ||
| 300 | } | 348 | } | ||
| 301 | } | 349 | } | ||
| 350 | + | } else if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' { | |||
| 351 | + | // Do not append a new OSC sequence after the visible | |||
| 352 | + | // boundary: an OSC 8 opener would leak hyperlink state | |||
| 353 | + | // into the next terminal row. A matching OSC 8 closer is | |||
| 354 | + | // retained so a link opened before the boundary is closed. | |||
| 355 | + | keep := i+5 < len(runes) && runes[i+2] == '8' && runes[i+3] == ';' && runes[i+4] == ';' && | |||
| 356 | + | (runes[i+5] == 0x07 || (i+6 < len(runes) && runes[i+5] == 0x1b && runes[i+6] == '\\')) | |||
| 357 | + | start := i | |||
| 358 | + | i += 2 | |||
| 359 | + | for i < len(runes) { | |||
| 360 | + | c := runes[i] | |||
| 361 | + | i++ | |||
| 362 | + | if c == 0x07 { | |||
| 363 | + | break | |||
| 364 | + | } | |||
| 365 | + | if c == 0x1b && i < len(runes) && runes[i] == '\\' { | |||
| 366 | + | i++ | |||
| 367 | + | break | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | if keep { | |||
| 371 | + | for _, c := range runes[start:i] { | |||
| 372 | + | out.WriteRune(c) | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 302 | } else { | 375 | } else { | ||
| 303 | break | 376 | break | ||
| 304 | } | 377 | } | ||
| @@ -432,26 +505,34 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 432 | // | 505 | // | ||
| 433 | // cursorBottomRow/cursorCol are offsets into bottom, not the full frame. | 506 | // cursorBottomRow/cursorCol are offsets into bottom, not the full frame. | ||
| 434 | func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) { | 507 | func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) { | ||
| 508 | + | if r.rightBarActive { | |||
| 509 | + | r.clearRightBarOverlay() | |||
| 510 | + | } | |||
| 511 | + | r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols) | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | // drawLog is DrawLog's owning flow implementation. contentCols may reserve | |||
| 515 | + | // terminal columns for a separately composited viewport overlay. | |||
| 516 | + | func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool { | |||
| 435 | if r.cols == 0 || r.rows == 0 { | 517 | if r.cols == 0 || r.rows == 0 { | ||
| 436 | - | return | 518 | + | return false |
| 519 | + | } | |||
| 520 | + | if contentCols <= 0 || contentCols > r.cols { | |||
| 521 | + | contentCols = r.cols | |||
| 437 | } | 522 | } | ||
| 438 | if len(bottom) == 0 { | 523 | if len(bottom) == 0 { | ||
| 439 | bottom = []string{""} | 524 | bottom = []string{""} | ||
| 440 | } | 525 | } | ||
| 441 | - | if r.logInit && len(r.logLines) > 0 && | 526 | + | if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) { |
| 442 | - | cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && | 527 | + | return false |
| 443 | - | r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle && | |||
| 444 | - | r.logRawHasThemeBG == (r.theme.Background != nil) && | |||
| 445 | - | sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) { | |||
| 446 | - | return | |||
| 447 | } | 528 | } | ||
| 448 | chatFrame := make([]string, len(chat)) | 529 | chatFrame := make([]string, len(chat)) | ||
| 449 | for i, line := range chat { | 530 | for i, line := range chat { | ||
| 450 | - | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | 531 | + | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle) |
| 451 | } | 532 | } | ||
| 452 | bottomFrame := make([]string, len(bottom)) | 533 | bottomFrame := make([]string, len(bottom)) | ||
| 453 | for i, line := range bottom { | 534 | for i, line := range bottom { | ||
| 454 | - | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | 535 | + | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle) |
| 455 | } | 536 | } | ||
| 456 | 537 | ||||
| 457 | // Always reserve one real row below the editor/status band. This is | 538 | // Always reserve one real row below the editor/status band. This is | ||
| @@ -463,7 +544,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 463 | lines = append(lines, chatFrame...) | 544 | lines = append(lines, chatFrame...) | ||
| 464 | lines = append(lines, bottomFrame...) | 545 | lines = append(lines, bottomFrame...) | ||
| 465 | for range bottomMarginRows { | 546 | for range bottomMarginRows { | ||
| 466 | - | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) | 547 | + | lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle)) |
| 467 | } | 548 | } | ||
| 468 | // In main-screen flow mode zut normally emits only its logical | 549 | // In main-screen flow mode zut normally emits only its logical | ||
| 469 | // content rows and leaves the rest of the terminal viewport alone. | 550 | // content rows and leaves the rest of the terminal viewport alone. | ||
| @@ -473,7 +554,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 473 | // default transparent case. | 554 | // default transparent case. | ||
| 474 | if r.theme.Background != nil { | 555 | if r.theme.Background != nil { | ||
| 475 | for len(lines) < r.rows { | 556 | for len(lines) < r.rows { | ||
| 476 | - | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) | 557 | + | lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle)) |
| 477 | } | 558 | } | ||
| 478 | } | 559 | } | ||
| 479 | if len(lines) == 0 { | 560 | if len(lines) == 0 { | ||
| @@ -495,8 +576,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 495 | // that never blinks, because we keep "showing" it before the | 576 | // that never blinks, because we keep "showing" it before the | ||
| 496 | // terminal can blink it off. Bailing out here lets the OS run | 577 | // terminal can blink it off. Bailing out here lets the OS run | ||
| 497 | // its blink cycle. | 578 | // its blink cycle. | ||
| 498 | - | if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { | 579 | + | if r.logInit && r.logRawContentCols == contentCols && |
| 499 | - | return | 580 | + | cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { |
| 581 | + | return false | |||
| 500 | } | 582 | } | ||
| 501 | 583 | ||||
| 502 | var w strings.Builder | 584 | var w strings.Builder | ||
| @@ -590,8 +672,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 590 | } | 672 | } | ||
| 591 | moveToLogicalRow(cursorTargetRow) | 673 | moveToLogicalRow(cursorTargetRow) | ||
| 592 | w.WriteString("\r") | 674 | w.WriteString("\r") | ||
| 593 | - | if cursorCol > 0 { | 675 | + | physicalCursorCol := cursorCol |
| 594 | - | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | 676 | + | if physicalCursorCol < 0 { |
| 677 | + | physicalCursorCol = 0 | |||
| 678 | + | } | |||
| 679 | + | if physicalCursorCol >= contentCols { | |||
| 680 | + | physicalCursorCol = contentCols - 1 | |||
| 681 | + | } | |||
| 682 | + | if physicalCursorCol > 0 { | |||
| 683 | + | w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C") | |||
| 595 | } | 684 | } | ||
| 596 | w.WriteString(SeqShowCursor) | 685 | w.WriteString(SeqShowCursor) | ||
| 597 | } | 686 | } | ||
| @@ -605,12 +694,10 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 605 | wasInitialized := r.logInit | 694 | wasInitialized := r.logInit | ||
| 606 | full := !wasInitialized || len(r.logLines) == 0 | 695 | full := !wasInitialized || len(r.logLines) == 0 | ||
| 607 | if full { | 696 | if full { | ||
| 608 | - | // Returning from the fixed right-bar frame needs a full viewport | 697 | + | // A cache invalidation may require a full viewport repaint after flow |
| 609 | - | // repaint, but it must not discard terminal scrollback merely because | 698 | + | // has started, but only the initial paint may discard old scrollback. |
| 610 | - | // the renderer's flow cache is intentionally empty. | 699 | + | writeFull(true, !wasInitialized) |
| 611 | - | writeFull(true, !wasInitialized && !r.logNeedsFullRepaint) | |||
| 612 | r.logInit = true | 700 | r.logInit = true | ||
| 613 | - | r.logNeedsFullRepaint = false | |||
| 614 | } else { | 701 | } else { | ||
| 615 | firstChanged := -1 | 702 | firstChanged := -1 | ||
| 616 | lastChanged := -1 | 703 | lastChanged := -1 | ||
| @@ -827,10 +914,24 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 827 | r.logRawBottom = append(r.logRawBottom[:0], bottom...) | 914 | r.logRawBottom = append(r.logRawBottom[:0], bottom...) | ||
| 828 | r.logRawCols = r.cols | 915 | r.logRawCols = r.cols | ||
| 829 | r.logRawRows = r.rows | 916 | r.logRawRows = r.rows | ||
| 917 | + | r.logRawContentCols = contentCols | |||
| 830 | r.logRawBackground = r.backgroundStyle | 918 | r.logRawBackground = r.backgroundStyle | ||
| 831 | r.logRawHasThemeBG = r.theme.Background != nil | 919 | r.logRawHasThemeBG = r.theme.Background != nil | ||
| 832 | r.cursorRow = cursorBottomRow | 920 | r.cursorRow = cursorBottomRow | ||
| 833 | r.cursorCol = cursorCol | 921 | r.cursorCol = cursorCol | ||
| 922 | + | return true | |||
| 923 | + | } | |||
| 924 | + | ||||
| 925 | + | func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool { | |||
| 926 | + | return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && | |||
| 927 | + | r.logRawContentUnchanged(chat, bottom, contentCols) | |||
| 928 | + | } | |||
| 929 | + | ||||
| 930 | + | func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool { | |||
| 931 | + | return r.logInit && len(r.logLines) > 0 && | |||
| 932 | + | r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols && | |||
| 933 | + | r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) && | |||
| 934 | + | sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) | |||
| 834 | } | 935 | } | ||
| 835 | 936 | ||||
| 836 | // sameLines reports whether two []string have the exact same | 937 | // sameLines reports whether two []string have the exact same | ||
M packages/tui/rightbar.go +92 -42
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "io" | |
| 4 | 5 | "sort" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ) | |
| @@ -372,12 +373,9 @@ func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, | |||
| 372 | 373 | return main + separator + rightBar | |
| 373 | 374 | } | |
| 374 | 375 | ||
| 375 | - | // DrawRightBar renders an already-laid-out visible chat slice and sticky | |
| 376 | - | // bottom block in a fixed frame with a persistent right bar. The normal | |
| 377 | - | // DrawLog path deliberately uses terminal scrollback; a persistent side rail | |
| 378 | - | // needs a fixed viewport so updates can repaint its columns without leaving | |
| 379 | - | // stale rows behind. Interactive mode still owns chat scrolling and passes | |
| 380 | - | // the selected visible slice here. | |
| 376 | + | // DrawRightBar renders the complete transcript through DrawLog, then | |
| 377 | + | // composites the persistent rail over the visible viewport. The flow cache | |
| 378 | + | // remains authoritative so appended chat still enters native scrollback. | |
| 381 | 379 | func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | |
| 382 | 380 | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false) | |
| 383 | 381 | } | |
| @@ -394,50 +392,102 @@ func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||
| 394 | 392 | } | |
| 395 | 393 | mainWidth, rightBarWidth, ok := RightBarColumns(r.cols) | |
| 396 | 394 | if !ok { | |
| 395 | + | r.DrawLog(chat, bottom, cursorBottomRow, cursorCol) | |
| 397 | 396 | return | |
| 398 | 397 | } | |
| 398 | + | ||
| 399 | 399 | if len(bottom) == 0 { | |
| 400 | 400 | bottom = []string{""} | |
| 401 | 401 | } | |
| 402 | - | if len(rightBar) < r.rows { | |
| 403 | - | padded := make([]string, r.rows) | |
| 404 | - | copy(padded, rightBar) | |
| 405 | - | rightBar = padded | |
| 406 | - | } else if len(rightBar) > r.rows { | |
| 407 | - | rightBar = rightBar[:r.rows] | |
| 408 | - | } | |
| 409 | 402 | ||
| 410 | - | main := make([]string, 0, len(chat)+len(bottom)+1) | |
| 411 | - | main = append(main, chat...) | |
| 412 | - | main = append(main, bottom...) | |
| 413 | - | main = append(main, "") // mirror DrawLog's renderer-owned bottom margin | |
| 414 | - | cursorRow := len(chat) + cursorBottomRow | |
| 415 | - | if len(main) > r.rows { | |
| 416 | - | trim := len(main) - r.rows | |
| 417 | - | main = main[trim:] | |
| 418 | - | cursorRow -= trim | |
| 419 | - | } | |
| 420 | - | if len(main) < r.rows { | |
| 421 | - | padding := make([]string, r.rows-len(main)) | |
| 422 | - | main = append(padding, main...) | |
| 423 | - | cursorRow += len(padding) | |
| 403 | + | // Keep DrawLog's final blank margin unobstructed, matching the normal flow | |
| 404 | + | // renderer and the previous right-bar layout. | |
| 405 | + | frameRows := max(0, r.rows-1) | |
| 406 | + | barLines := make([]string, frameRows) | |
| 407 | + | copy(barLines, rightBar) | |
| 408 | + | ||
| 409 | + | barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth || | |
| 410 | + | r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines) | |
| 411 | + | contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth) | |
| 412 | + | flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol | |
| 413 | + | if flowUnchanged && !barChanged { | |
| 414 | + | return | |
| 415 | + | } | |
| 416 | + | if !contentUnchanged && r.rightBarActive { | |
| 417 | + | // Remove viewport chrome before DrawLog can naturally scroll a row into | |
| 418 | + | // history. Otherwise every appended transcript row captures a stale | |
| 419 | + | // copy of the rail in native terminal scrollback. | |
| 420 | + | r.clearRightBarOverlay() | |
| 424 | 421 | } | |
| 422 | + | r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth) | |
| 423 | + | if contentUnchanged && !barChanged { | |
| 424 | + | // Cursor-only updates do not disturb the composited rail. | |
| 425 | + | return | |
| 426 | + | } | |
| 427 | + | r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator) | |
| 428 | + | r.rightBarActive = true | |
| 429 | + | r.rightBarLines = append(r.rightBarLines[:0], barLines...) | |
| 430 | + | r.rightBarMainCols = mainWidth | |
| 431 | + | r.rightBarDimmed = dimSeparator | |
| 432 | + | } | |
| 425 | 433 | ||
| 426 | - | frame := make([]string, r.rows) | |
| 427 | - | for row := range frame { | |
| 428 | - | frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator) | |
| 434 | + | // clearRightBarOverlay removes viewport chrome before DrawLog expands back to | |
| 435 | + | // the terminal width. Restoring the tracked flow cursor keeps DrawLog's | |
| 436 | + | // hardware-row model valid across the intermediate absolute writes. | |
| 437 | + | func (r *Renderer) clearRightBarOverlay() { | |
| 438 | + | if !r.rightBarActive { | |
| 439 | + | return | |
| 429 | 440 | } | |
| 441 | + | remaining := r.cols - r.rightBarMainCols | |
| 442 | + | if remaining > 0 { | |
| 443 | + | blank := strings.Repeat(" ", remaining) | |
| 444 | + | var w strings.Builder | |
| 445 | + | w.WriteString(SeqSynchronizedOn) | |
| 446 | + | w.WriteString(SeqHideCursor) | |
| 447 | + | for row := 0; row < max(0, r.rows-1); row++ { | |
| 448 | + | w.WriteString(MoveTo(row+1, r.rightBarMainCols+1)) | |
| 449 | + | w.WriteString("\x1b[0m") | |
| 450 | + | w.WriteString(blank) | |
| 451 | + | } | |
| 452 | + | r.appendTrackedLogCursor(&w, r.rightBarMainCols) | |
| 453 | + | w.WriteString(SeqSynchronizedOff) | |
| 454 | + | _, _ = io.WriteString(r.out, w.String()) | |
| 455 | + | } | |
| 456 | + | r.rightBarActive = false | |
| 457 | + | r.rightBarLines = nil | |
| 458 | + | r.rightBarMainCols = 0 | |
| 459 | + | r.rightBarDimmed = false | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) { | |
| 463 | + | var w strings.Builder | |
| 464 | + | w.WriteString(SeqSynchronizedOn) | |
| 465 | + | w.WriteString(SeqHideCursor) | |
| 466 | + | for row, line := range rightBar { | |
| 467 | + | w.WriteString(MoveTo(row+1, mainWidth+1)) | |
| 468 | + | w.WriteString("\x1b[0m") | |
| 469 | + | w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator)) | |
| 470 | + | } | |
| 471 | + | r.appendTrackedLogCursor(&w, mainWidth) | |
| 472 | + | w.WriteString(SeqSynchronizedOff) | |
| 473 | + | _, _ = io.WriteString(r.out, w.String()) | |
| 474 | + | } | |
| 430 | 475 | ||
| 431 | - | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | |
| 432 | - | // later fallback to DrawLog starts with a full repaint instead of trying | |
| 433 | - | // to diff against logical rows from before the rail was enabled. Mark the | |
| 434 | - | // transition separately so that repaint does not clear scrollback. | |
| 435 | - | r.logChat = nil | |
| 436 | - | r.logBottom = nil | |
| 437 | - | r.logLines = nil | |
| 438 | - | r.logViewportTop = 0 | |
| 439 | - | r.logHardwareRow = 0 | |
| 440 | - | r.logInit = false | |
| 441 | - | r.logNeedsFullRepaint = true | |
| 442 | - | r.Draw(frame, cursorRow, cursorCol) | |
| 476 | + | func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) { | |
| 477 | + | if !r.logInit || r.cursorRow < 0 { | |
| 478 | + | return | |
| 479 | + | } | |
| 480 | + | row := len(r.logChat) + r.cursorRow - r.logViewportTop | |
| 481 | + | if row < 0 || row >= r.rows { | |
| 482 | + | return | |
| 483 | + | } | |
| 484 | + | cursorCol := r.cursorCol | |
| 485 | + | if cursorCol < 0 { | |
| 486 | + | cursorCol = 0 | |
| 487 | + | } | |
| 488 | + | if cursorCol >= contentCols { | |
| 489 | + | cursorCol = contentCols - 1 | |
| 490 | + | } | |
| 491 | + | w.WriteString(MoveTo(row+1, cursorCol+1)) | |
| 492 | + | w.WriteString(SeqShowCursor) | |
| 443 | 493 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package tui | 1 | package tui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "io" | |||
| 4 | "sort" | 5 | "sort" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | ) | 7 | ) | ||
| @@ -372,12 +373,9 @@ func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, | |||||
| 372 | return main + separator + rightBar | 373 | return main + separator + rightBar | ||
| 373 | } | 374 | } | ||
| 374 | 375 | ||||
| 375 | - | // DrawRightBar renders an already-laid-out visible chat slice and sticky | 376 | + | // DrawRightBar renders the complete transcript through DrawLog, then |
| 376 | - | // bottom block in a fixed frame with a persistent right bar. The normal | 377 | + | // composites the persistent rail over the visible viewport. The flow cache |
| 377 | - | // DrawLog path deliberately uses terminal scrollback; a persistent side rail | 378 | + | // remains authoritative so appended chat still enters native scrollback. |
| 378 | - | // needs a fixed viewport so updates can repaint its columns without leaving | |||
| 379 | - | // stale rows behind. Interactive mode still owns chat scrolling and passes | |||
| 380 | - | // the selected visible slice here. | |||
| 381 | func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | 379 | func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | ||
| 382 | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false) | 380 | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false) | ||
| 383 | } | 381 | } | ||
| @@ -394,50 +392,102 @@ func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||||
| 394 | } | 392 | } | ||
| 395 | mainWidth, rightBarWidth, ok := RightBarColumns(r.cols) | 393 | mainWidth, rightBarWidth, ok := RightBarColumns(r.cols) | ||
| 396 | if !ok { | 394 | if !ok { | ||
| 395 | + | r.DrawLog(chat, bottom, cursorBottomRow, cursorCol) | |||
| 397 | return | 396 | return | ||
| 398 | } | 397 | } | ||
| 398 | + | ||||
| 399 | if len(bottom) == 0 { | 399 | if len(bottom) == 0 { | ||
| 400 | bottom = []string{""} | 400 | bottom = []string{""} | ||
| 401 | } | 401 | } | ||
| 402 | - | if len(rightBar) < r.rows { | |||
| 403 | - | padded := make([]string, r.rows) | |||
| 404 | - | copy(padded, rightBar) | |||
| 405 | - | rightBar = padded | |||
| 406 | - | } else if len(rightBar) > r.rows { | |||
| 407 | - | rightBar = rightBar[:r.rows] | |||
| 408 | - | } | |||
| 409 | 402 | ||||
| 410 | - | main := make([]string, 0, len(chat)+len(bottom)+1) | 403 | + | // Keep DrawLog's final blank margin unobstructed, matching the normal flow |
| 411 | - | main = append(main, chat...) | 404 | + | // renderer and the previous right-bar layout. |
| 412 | - | main = append(main, bottom...) | 405 | + | frameRows := max(0, r.rows-1) |
| 413 | - | main = append(main, "") // mirror DrawLog's renderer-owned bottom margin | 406 | + | barLines := make([]string, frameRows) |
| 414 | - | cursorRow := len(chat) + cursorBottomRow | 407 | + | copy(barLines, rightBar) |
| 415 | - | if len(main) > r.rows { | 408 | + | |
| 416 | - | trim := len(main) - r.rows | 409 | + | barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth || |
| 417 | - | main = main[trim:] | 410 | + | r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines) |
| 418 | - | cursorRow -= trim | 411 | + | contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth) |
| 419 | - | } | 412 | + | flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol |
| 420 | - | if len(main) < r.rows { | 413 | + | if flowUnchanged && !barChanged { |
| 421 | - | padding := make([]string, r.rows-len(main)) | 414 | + | return |
| 422 | - | main = append(padding, main...) | 415 | + | } |
| 423 | - | cursorRow += len(padding) | 416 | + | if !contentUnchanged && r.rightBarActive { |
| 417 | + | // Remove viewport chrome before DrawLog can naturally scroll a row into | |||
| 418 | + | // history. Otherwise every appended transcript row captures a stale | |||
| 419 | + | // copy of the rail in native terminal scrollback. | |||
| 420 | + | r.clearRightBarOverlay() | |||
| 424 | } | 421 | } | ||
| 422 | + | r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth) | |||
| 423 | + | if contentUnchanged && !barChanged { | |||
| 424 | + | // Cursor-only updates do not disturb the composited rail. | |||
| 425 | + | return | |||
| 426 | + | } | |||
| 427 | + | r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator) | |||
| 428 | + | r.rightBarActive = true | |||
| 429 | + | r.rightBarLines = append(r.rightBarLines[:0], barLines...) | |||
| 430 | + | r.rightBarMainCols = mainWidth | |||
| 431 | + | r.rightBarDimmed = dimSeparator | |||
| 432 | + | } | |||
| 425 | 433 | ||||
| 426 | - | frame := make([]string, r.rows) | 434 | + | // clearRightBarOverlay removes viewport chrome before DrawLog expands back to |
| 427 | - | for row := range frame { | 435 | + | // the terminal width. Restoring the tracked flow cursor keeps DrawLog's |
| 428 | - | frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator) | 436 | + | // hardware-row model valid across the intermediate absolute writes. |
| 437 | + | func (r *Renderer) clearRightBarOverlay() { | |||
| 438 | + | if !r.rightBarActive { | |||
| 439 | + | return | |||
| 429 | } | 440 | } | ||
| 441 | + | remaining := r.cols - r.rightBarMainCols | |||
| 442 | + | if remaining > 0 { | |||
| 443 | + | blank := strings.Repeat(" ", remaining) | |||
| 444 | + | var w strings.Builder | |||
| 445 | + | w.WriteString(SeqSynchronizedOn) | |||
| 446 | + | w.WriteString(SeqHideCursor) | |||
| 447 | + | for row := 0; row < max(0, r.rows-1); row++ { | |||
| 448 | + | w.WriteString(MoveTo(row+1, r.rightBarMainCols+1)) | |||
| 449 | + | w.WriteString("\x1b[0m") | |||
| 450 | + | w.WriteString(blank) | |||
| 451 | + | } | |||
| 452 | + | r.appendTrackedLogCursor(&w, r.rightBarMainCols) | |||
| 453 | + | w.WriteString(SeqSynchronizedOff) | |||
| 454 | + | _, _ = io.WriteString(r.out, w.String()) | |||
| 455 | + | } | |||
| 456 | + | r.rightBarActive = false | |||
| 457 | + | r.rightBarLines = nil | |||
| 458 | + | r.rightBarMainCols = 0 | |||
| 459 | + | r.rightBarDimmed = false | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) { | |||
| 463 | + | var w strings.Builder | |||
| 464 | + | w.WriteString(SeqSynchronizedOn) | |||
| 465 | + | w.WriteString(SeqHideCursor) | |||
| 466 | + | for row, line := range rightBar { | |||
| 467 | + | w.WriteString(MoveTo(row+1, mainWidth+1)) | |||
| 468 | + | w.WriteString("\x1b[0m") | |||
| 469 | + | w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator)) | |||
| 470 | + | } | |||
| 471 | + | r.appendTrackedLogCursor(&w, mainWidth) | |||
| 472 | + | w.WriteString(SeqSynchronizedOff) | |||
| 473 | + | _, _ = io.WriteString(r.out, w.String()) | |||
| 474 | + | } | |||
| 430 | 475 | ||||
| 431 | - | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | 476 | + | func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) { |
| 432 | - | // later fallback to DrawLog starts with a full repaint instead of trying | 477 | + | if !r.logInit || r.cursorRow < 0 { |
| 433 | - | // to diff against logical rows from before the rail was enabled. Mark the | 478 | + | return |
| 434 | - | // transition separately so that repaint does not clear scrollback. | 479 | + | } |
| 435 | - | r.logChat = nil | 480 | + | row := len(r.logChat) + r.cursorRow - r.logViewportTop |
| 436 | - | r.logBottom = nil | 481 | + | if row < 0 || row >= r.rows { |
| 437 | - | r.logLines = nil | 482 | + | return |
| 438 | - | r.logViewportTop = 0 | 483 | + | } |
| 439 | - | r.logHardwareRow = 0 | 484 | + | cursorCol := r.cursorCol |
| 440 | - | r.logInit = false | 485 | + | if cursorCol < 0 { |
| 441 | - | r.logNeedsFullRepaint = true | 486 | + | cursorCol = 0 |
| 442 | - | r.Draw(frame, cursorRow, cursorCol) | 487 | + | } |
| 488 | + | if cursorCol >= contentCols { | |||
| 489 | + | cursorCol = contentCols - 1 | |||
| 490 | + | } | |||
| 491 | + | w.WriteString(MoveTo(row+1, cursorCol+1)) | |||
| 492 | + | w.WriteString(SeqShowCursor) | |||
| 443 | } | 493 | } | ||
M packages/tui/rightbar_test.go +152 -6
| @@ -209,6 +209,141 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |||
| 209 | 209 | } | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | + | func TestRightBarTruncationPreservesOSCSequences(t *testing.T) { | |
| 213 | + | for _, tc := range []struct { | |
| 214 | + | opener string | |
| 215 | + | closer string | |
| 216 | + | }{ | |
| 217 | + | {"\x1b]8;;https://example.test\x07", "\x1b]8;;\x07"}, | |
| 218 | + | {"\x1b]8;;https://example.test\x1b\\", "\x1b]8;;\x1b\\"}, | |
| 219 | + | } { | |
| 220 | + | got := JoinRightBar(Dark, tc.opener+"label-more", "", 5, 1) | |
| 221 | + | if !strings.HasPrefix(got, tc.opener+"label") { | |
| 222 | + | t.Fatalf("truncated right-bar row split OSC escape %q: %q", tc.opener, got) | |
| 223 | + | } | |
| 224 | + | got = truncateToWidth("12345"+tc.opener+"tail", 5) | |
| 225 | + | if got != "12345" { | |
| 226 | + | t.Fatalf("truncated right-bar row leaked trailing OSC escape %q: %q", tc.opener, got) | |
| 227 | + | } | |
| 228 | + | got = truncateToWidth(tc.opener+"12345x"+tc.closer, 5) | |
| 229 | + | if !strings.HasSuffix(got, "\x1b]8;;\x07") && !strings.HasSuffix(got, tc.closer) { | |
| 230 | + | t.Fatalf("truncated right-bar row left OSC 8 hyperlink open: %q", got) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestRendererDrawRightBarKeepsFlowScrollback(t *testing.T) { | |
| 236 | + | t.Setenv("TERM_PROGRAM", "") | |
| 237 | + | ||
| 238 | + | var out bytes.Buffer | |
| 239 | + | r := NewRenderer(&out) | |
| 240 | + | r.SetTheme(Dark) | |
| 241 | + | r.Resize(80, 5) | |
| 242 | + | chat := []string{"one", "two", "three", "four", "five", "six"} | |
| 243 | + | r.DrawRightBar( | |
| 244 | + | chat, | |
| 245 | + | []string{"input"}, | |
| 246 | + | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |
| 247 | + | 0, | |
| 248 | + | 0, | |
| 249 | + | ) | |
| 250 | + | if !r.logInit { | |
| 251 | + | t.Fatal("right-bar draw abandoned the flow renderer") | |
| 252 | + | } | |
| 253 | + | if got := len(r.logChat); got != len(chat) { | |
| 254 | + | t.Fatalf("cached chat rows = %d, want complete transcript length %d", got, len(chat)) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | out.Reset() | |
| 258 | + | chat = append(chat, "seven") | |
| 259 | + | r.DrawRightBar( | |
| 260 | + | chat, | |
| 261 | + | []string{"input"}, | |
| 262 | + | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |
| 263 | + | 0, | |
| 264 | + | 0, | |
| 265 | + | ) | |
| 266 | + | got := out.String() | |
| 267 | + | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |
| 268 | + | t.Fatalf("right-bar append cleared retained scrollback: %q", got) | |
| 269 | + | } | |
| 270 | + | if !strings.Contains(stripANSI(got), "seven") { | |
| 271 | + | t.Fatalf("right-bar append omitted new transcript row: %q", got) | |
| 272 | + | } | |
| 273 | + | mainWidth, _, _ := RightBarColumns(80) | |
| 274 | + | clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth) | |
| 275 | + | if clearAt, appendAt := strings.Index(got, clearedRail), strings.Index(got, "seven"); clearAt < 0 || clearAt > appendAt { | |
| 276 | + | t.Fatalf("right bar was not removed before transcript append entered scrollback: %q", got) | |
| 277 | + | } | |
| 278 | + | if r.logViewportTop == 0 { | |
| 279 | + | t.Fatal("test did not exercise transcript rows above the viewport") | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestRendererDrawRightBarIdleNoOpEmitsNothing(t *testing.T) { | |
| 284 | + | t.Setenv("TERM_PROGRAM", "") | |
| 285 | + | var out bytes.Buffer | |
| 286 | + | r := NewRenderer(&out) | |
| 287 | + | r.SetTheme(Dark) | |
| 288 | + | r.Resize(80, 5) | |
| 289 | + | chat := []string{"chat"} | |
| 290 | + | bottom := []string{"input"} | |
| 291 | + | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |
| 292 | + | r.DrawRightBar(chat, bottom, rail, 0, 0) | |
| 293 | + | out.Reset() | |
| 294 | + | ||
| 295 | + | r.DrawRightBar(chat, bottom, rail, 0, 0) | |
| 296 | + | if out.Len() != 0 { | |
| 297 | + | t.Fatalf("idle right-bar redraw emitted %d bytes: %q", out.Len(), out.String()) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func TestRendererDrawRightBarCursorMoveDoesNotRepaintRail(t *testing.T) { | |
| 302 | + | t.Setenv("TERM_PROGRAM", "") | |
| 303 | + | var out bytes.Buffer | |
| 304 | + | r := NewRenderer(&out) | |
| 305 | + | r.SetTheme(Dark) | |
| 306 | + | r.Resize(80, 5) | |
| 307 | + | chat := []string{"chat"} | |
| 308 | + | bottom := []string{"input"} | |
| 309 | + | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |
| 310 | + | r.DrawRightBar(chat, bottom, rail, 0, 0) | |
| 311 | + | out.Reset() | |
| 312 | + | ||
| 313 | + | r.DrawRightBar(chat, bottom, rail, 0, 1) | |
| 314 | + | if out.Len() == 0 { | |
| 315 | + | t.Fatal("cursor-only right-bar update emitted nothing") | |
| 316 | + | } | |
| 317 | + | if !strings.Contains(out.String(), "\r\x1b[1C") { | |
| 318 | + | t.Fatalf("cursor-only update did not move the cursor to column 1: %q", out.String()) | |
| 319 | + | } | |
| 320 | + | if strings.Contains(stripANSI(out.String()), "task") { | |
| 321 | + | t.Fatalf("cursor-only update repainted the entire rail: %q", out.String()) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func TestRendererDrawRightBarClampsCursorToMainContent(t *testing.T) { | |
| 326 | + | t.Setenv("TERM_PROGRAM", "") | |
| 327 | + | var out bytes.Buffer | |
| 328 | + | r := NewRenderer(&out) | |
| 329 | + | r.SetTheme(Dark) | |
| 330 | + | r.Resize(80, 5) | |
| 331 | + | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |
| 332 | + | r.DrawRightBar([]string{"chat"}, []string{"input"}, rail, 0, 100) | |
| 333 | + | ||
| 334 | + | mainWidth, _, ok := RightBarColumns(80) | |
| 335 | + | if !ok { | |
| 336 | + | t.Fatal("right bar should fit") | |
| 337 | + | } | |
| 338 | + | got := out.String() | |
| 339 | + | if strings.Contains(got, MoveTo(2, 101)) { | |
| 340 | + | t.Fatalf("cursor escaped the main content area: %q", got) | |
| 341 | + | } | |
| 342 | + | if !strings.Contains(got, MoveTo(2, mainWidth)) { | |
| 343 | + | t.Fatalf("cursor was not clamped to the main content edge: %q", got) | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 212 | 347 | func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | |
| 213 | 348 | t.Setenv("TERM_PROGRAM", "") | |
| 214 | 349 | ||
| @@ -226,10 +361,21 @@ func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | |||
| 226 | 361 | ||
| 227 | 362 | out.Reset() | |
| 228 | 363 | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | |
| 229 | - | if !strings.Contains(stripANSI(out.String()), "input") { | |
| 230 | - | t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String()) | |
| 364 | + | if r.rightBarActive { | |
| 365 | + | t.Fatal("right-bar fallback left the viewport overlay active") | |
| 366 | + | } | |
| 367 | + | if r.logRawContentCols != 80 { | |
| 368 | + | t.Fatalf("flow content width = %d, want full terminal width 80", r.logRawContentCols) | |
| 231 | 369 | } | |
| 232 | - | if strings.Contains(out.String(), SeqClearScrollback) { | |
| 370 | + | mainWidth, _, ok := RightBarColumns(80) | |
| 371 | + | if !ok { | |
| 372 | + | t.Fatal("right bar should fit before falling back to DrawLog") | |
| 373 | + | } | |
| 374 | + | clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth) | |
| 375 | + | if !strings.Contains(out.String(), clearedRail) { | |
| 376 | + | t.Fatalf("right-bar fallback did not clear the old rail: %q", out.String()) | |
| 377 | + | } | |
| 378 | + | if strings.Contains(out.String(), SeqClearScreenNoHome) || strings.Contains(out.String(), SeqClearScrollback) { | |
| 233 | 379 | t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String()) | |
| 234 | 380 | } | |
| 235 | 381 | } | |
| @@ -261,14 +407,14 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | |||
| 261 | 407 | if !strings.Contains(stripANSI(out.String()), "Updated") { | |
| 262 | 408 | t.Fatalf("right-bar update did not repaint changed content: %q", out.String()) | |
| 263 | 409 | } | |
| 264 | - | if r.logInit { | |
| 265 | - | t.Fatal("right-bar mode left flow renderer initialized") | |
| 410 | + | if !r.logInit { | |
| 411 | + | t.Fatal("right-bar update abandoned the flow renderer") | |
| 266 | 412 | } | |
| 267 | 413 | ||
| 268 | 414 | out.Reset() | |
| 269 | 415 | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | |
| 270 | 416 | if !r.logInit { | |
| 271 | - | t.Fatal("flow renderer was not reinitialized after right-bar clearing") | |
| 417 | + | t.Fatal("flow renderer was not retained after right-bar clearing") | |
| 272 | 418 | } | |
| 273 | 419 | ||
| 274 | 420 | out.Reset() | |
| @@ -209,6 +209,141 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |||||
| 209 | } | 209 | } | ||
| 210 | } | 210 | } | ||
| 211 | 211 | ||||
| 212 | + | func TestRightBarTruncationPreservesOSCSequences(t *testing.T) { | |||
| 213 | + | for _, tc := range []struct { | |||
| 214 | + | opener string | |||
| 215 | + | closer string | |||
| 216 | + | }{ | |||
| 217 | + | {"\x1b]8;;https://example.test\x07", "\x1b]8;;\x07"}, | |||
| 218 | + | {"\x1b]8;;https://example.test\x1b\\", "\x1b]8;;\x1b\\"}, | |||
| 219 | + | } { | |||
| 220 | + | got := JoinRightBar(Dark, tc.opener+"label-more", "", 5, 1) | |||
| 221 | + | if !strings.HasPrefix(got, tc.opener+"label") { | |||
| 222 | + | t.Fatalf("truncated right-bar row split OSC escape %q: %q", tc.opener, got) | |||
| 223 | + | } | |||
| 224 | + | got = truncateToWidth("12345"+tc.opener+"tail", 5) | |||
| 225 | + | if got != "12345" { | |||
| 226 | + | t.Fatalf("truncated right-bar row leaked trailing OSC escape %q: %q", tc.opener, got) | |||
| 227 | + | } | |||
| 228 | + | got = truncateToWidth(tc.opener+"12345x"+tc.closer, 5) | |||
| 229 | + | if !strings.HasSuffix(got, "\x1b]8;;\x07") && !strings.HasSuffix(got, tc.closer) { | |||
| 230 | + | t.Fatalf("truncated right-bar row left OSC 8 hyperlink open: %q", got) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestRendererDrawRightBarKeepsFlowScrollback(t *testing.T) { | |||
| 236 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 237 | + | ||||
| 238 | + | var out bytes.Buffer | |||
| 239 | + | r := NewRenderer(&out) | |||
| 240 | + | r.SetTheme(Dark) | |||
| 241 | + | r.Resize(80, 5) | |||
| 242 | + | chat := []string{"one", "two", "three", "four", "five", "six"} | |||
| 243 | + | r.DrawRightBar( | |||
| 244 | + | chat, | |||
| 245 | + | []string{"input"}, | |||
| 246 | + | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |||
| 247 | + | 0, | |||
| 248 | + | 0, | |||
| 249 | + | ) | |||
| 250 | + | if !r.logInit { | |||
| 251 | + | t.Fatal("right-bar draw abandoned the flow renderer") | |||
| 252 | + | } | |||
| 253 | + | if got := len(r.logChat); got != len(chat) { | |||
| 254 | + | t.Fatalf("cached chat rows = %d, want complete transcript length %d", got, len(chat)) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | out.Reset() | |||
| 258 | + | chat = append(chat, "seven") | |||
| 259 | + | r.DrawRightBar( | |||
| 260 | + | chat, | |||
| 261 | + | []string{"input"}, | |||
| 262 | + | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |||
| 263 | + | 0, | |||
| 264 | + | 0, | |||
| 265 | + | ) | |||
| 266 | + | got := out.String() | |||
| 267 | + | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |||
| 268 | + | t.Fatalf("right-bar append cleared retained scrollback: %q", got) | |||
| 269 | + | } | |||
| 270 | + | if !strings.Contains(stripANSI(got), "seven") { | |||
| 271 | + | t.Fatalf("right-bar append omitted new transcript row: %q", got) | |||
| 272 | + | } | |||
| 273 | + | mainWidth, _, _ := RightBarColumns(80) | |||
| 274 | + | clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth) | |||
| 275 | + | if clearAt, appendAt := strings.Index(got, clearedRail), strings.Index(got, "seven"); clearAt < 0 || clearAt > appendAt { | |||
| 276 | + | t.Fatalf("right bar was not removed before transcript append entered scrollback: %q", got) | |||
| 277 | + | } | |||
| 278 | + | if r.logViewportTop == 0 { | |||
| 279 | + | t.Fatal("test did not exercise transcript rows above the viewport") | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestRendererDrawRightBarIdleNoOpEmitsNothing(t *testing.T) { | |||
| 284 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 285 | + | var out bytes.Buffer | |||
| 286 | + | r := NewRenderer(&out) | |||
| 287 | + | r.SetTheme(Dark) | |||
| 288 | + | r.Resize(80, 5) | |||
| 289 | + | chat := []string{"chat"} | |||
| 290 | + | bottom := []string{"input"} | |||
| 291 | + | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |||
| 292 | + | r.DrawRightBar(chat, bottom, rail, 0, 0) | |||
| 293 | + | out.Reset() | |||
| 294 | + | ||||
| 295 | + | r.DrawRightBar(chat, bottom, rail, 0, 0) | |||
| 296 | + | if out.Len() != 0 { | |||
| 297 | + | t.Fatalf("idle right-bar redraw emitted %d bytes: %q", out.Len(), out.String()) | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func TestRendererDrawRightBarCursorMoveDoesNotRepaintRail(t *testing.T) { | |||
| 302 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 303 | + | var out bytes.Buffer | |||
| 304 | + | r := NewRenderer(&out) | |||
| 305 | + | r.SetTheme(Dark) | |||
| 306 | + | r.Resize(80, 5) | |||
| 307 | + | chat := []string{"chat"} | |||
| 308 | + | bottom := []string{"input"} | |||
| 309 | + | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |||
| 310 | + | r.DrawRightBar(chat, bottom, rail, 0, 0) | |||
| 311 | + | out.Reset() | |||
| 312 | + | ||||
| 313 | + | r.DrawRightBar(chat, bottom, rail, 0, 1) | |||
| 314 | + | if out.Len() == 0 { | |||
| 315 | + | t.Fatal("cursor-only right-bar update emitted nothing") | |||
| 316 | + | } | |||
| 317 | + | if !strings.Contains(out.String(), "\r\x1b[1C") { | |||
| 318 | + | t.Fatalf("cursor-only update did not move the cursor to column 1: %q", out.String()) | |||
| 319 | + | } | |||
| 320 | + | if strings.Contains(stripANSI(out.String()), "task") { | |||
| 321 | + | t.Fatalf("cursor-only update repainted the entire rail: %q", out.String()) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func TestRendererDrawRightBarClampsCursorToMainContent(t *testing.T) { | |||
| 326 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 327 | + | var out bytes.Buffer | |||
| 328 | + | r := NewRenderer(&out) | |||
| 329 | + | r.SetTheme(Dark) | |||
| 330 | + | r.Resize(80, 5) | |||
| 331 | + | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |||
| 332 | + | r.DrawRightBar([]string{"chat"}, []string{"input"}, rail, 0, 100) | |||
| 333 | + | ||||
| 334 | + | mainWidth, _, ok := RightBarColumns(80) | |||
| 335 | + | if !ok { | |||
| 336 | + | t.Fatal("right bar should fit") | |||
| 337 | + | } | |||
| 338 | + | got := out.String() | |||
| 339 | + | if strings.Contains(got, MoveTo(2, 101)) { | |||
| 340 | + | t.Fatalf("cursor escaped the main content area: %q", got) | |||
| 341 | + | } | |||
| 342 | + | if !strings.Contains(got, MoveTo(2, mainWidth)) { | |||
| 343 | + | t.Fatalf("cursor was not clamped to the main content edge: %q", got) | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | ||||
| 212 | func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | 347 | func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | ||
| 213 | t.Setenv("TERM_PROGRAM", "") | 348 | t.Setenv("TERM_PROGRAM", "") | ||
| 214 | 349 | ||||
| @@ -226,10 +361,21 @@ func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | |||||
| 226 | 361 | ||||
| 227 | out.Reset() | 362 | out.Reset() | ||
| 228 | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | 363 | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | ||
| 229 | - | if !strings.Contains(stripANSI(out.String()), "input") { | 364 | + | if r.rightBarActive { |
| 230 | - | t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String()) | 365 | + | t.Fatal("right-bar fallback left the viewport overlay active") |
| 366 | + | } | |||
| 367 | + | if r.logRawContentCols != 80 { | |||
| 368 | + | t.Fatalf("flow content width = %d, want full terminal width 80", r.logRawContentCols) | |||
| 231 | } | 369 | } | ||
| 232 | - | if strings.Contains(out.String(), SeqClearScrollback) { | 370 | + | mainWidth, _, ok := RightBarColumns(80) |
| 371 | + | if !ok { | |||
| 372 | + | t.Fatal("right bar should fit before falling back to DrawLog") | |||
| 373 | + | } | |||
| 374 | + | clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth) | |||
| 375 | + | if !strings.Contains(out.String(), clearedRail) { | |||
| 376 | + | t.Fatalf("right-bar fallback did not clear the old rail: %q", out.String()) | |||
| 377 | + | } | |||
| 378 | + | if strings.Contains(out.String(), SeqClearScreenNoHome) || strings.Contains(out.String(), SeqClearScrollback) { | |||
| 233 | t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String()) | 379 | t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String()) | ||
| 234 | } | 380 | } | ||
| 235 | } | 381 | } | ||
| @@ -261,14 +407,14 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | |||||
| 261 | if !strings.Contains(stripANSI(out.String()), "Updated") { | 407 | if !strings.Contains(stripANSI(out.String()), "Updated") { | ||
| 262 | t.Fatalf("right-bar update did not repaint changed content: %q", out.String()) | 408 | t.Fatalf("right-bar update did not repaint changed content: %q", out.String()) | ||
| 263 | } | 409 | } | ||
| 264 | - | if r.logInit { | 410 | + | if !r.logInit { |
| 265 | - | t.Fatal("right-bar mode left flow renderer initialized") | 411 | + | t.Fatal("right-bar update abandoned the flow renderer") |
| 266 | } | 412 | } | ||
| 267 | 413 | ||||
| 268 | out.Reset() | 414 | out.Reset() | ||
| 269 | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | 415 | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | ||
| 270 | if !r.logInit { | 416 | if !r.logInit { | ||
| 271 | - | t.Fatal("flow renderer was not reinitialized after right-bar clearing") | 417 | + | t.Fatal("flow renderer was not retained after right-bar clearing") |
| 272 | } | 418 | } | ||
| 273 | 419 | ||||
| 274 | out.Reset() | 420 | out.Reset() | ||