fix(tui): keep the live viewport aligned while streaming (#208)
DrawLog rescans for addressable rows when the first changed row has already scrolled above the viewport. That rescan reset lastChanged and re-applied the appended-row extension only when it found no change of its own. A frame that grew by an implicit trailing row while an older row changed elsewhere therefore never emitted that row. The trailing row is what advances the terminal, so logViewportTop ended up one row short and left stale content pinned at the top of the screen, which is visible during fast streaming with frequent tool events. Apply the growth extension after the rescan too. Recovery repaints also replayed rows that were already retained in scrollback. writeFull now starts at the earlier of the new viewport top and the previous one, so those rows are no longer duplicated while every row visible in the previous frame is still written. Repaints whose skipped prefix contains an inline-image escape keep the previous full replay, because SeqDeleteKittyImages would otherwise drop an image that still sits in scrollback.
- commit
- e6deef9af8f6196e5912120362a705252d86ae2d
- author
- bnema · · 2026-09-14 16:17 UTC
- committer
- GitHub · · 2026-09-14 16:17 UTC
- parent
- 58133ea
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
2 files · +200 -4
M packages/tui/render.go +45 -4
| @@ -683,7 +683,39 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 683 | 683 | w.WriteString(MoveTo(1, 1)) | |
| 684 | 684 | } | |
| 685 | 685 | } | |
| 686 | - | for idx, line := range lines { | |
| 686 | + | // The initial paint emits the whole logical buffer so the terminal's | |
| 687 | + | // retained scrollback receives the full transcript. Recovery repaints | |
| 688 | + | // (cache invalidation, structural shrink) happen while an agent is | |
| 689 | + | // streaming, after earlier rows have already scrolled above the | |
| 690 | + | // viewport. Replaying that prefix pushes retained history back into | |
| 691 | + | // scrollback and duplicates it, so start from the new viewport top. | |
| 692 | + | // Never skip a row that was visible (or never emitted) in the previous | |
| 693 | + | // frame: if the tail starts below the old viewport top, emit from the | |
| 694 | + | // old top instead so no visible content is lost. | |
| 695 | + | // | |
| 696 | + | // The clear above deletes Kitty images, so an inline image can only be | |
| 697 | + | // kept by re-emitting its escape. When the skipped prefix carries an | |
| 698 | + | // image escape, fall back to the full replay rather than delete a | |
| 699 | + | // retained offscreen image without replacement. Image frames keep the | |
| 700 | + | // older duplicate-history tradeoff in exchange for a safe repaint. | |
| 701 | + | emit := lines | |
| 702 | + | if clear && !purgeScrollback && len(lines) > r.rows { | |
| 703 | + | start := len(lines) - r.rows | |
| 704 | + | if start > r.logViewportTop { | |
| 705 | + | start = r.logViewportTop | |
| 706 | + | } | |
| 707 | + | skippedHasImage := false | |
| 708 | + | for _, line := range lines[:start] { | |
| 709 | + | if containsImageEscape(line) { | |
| 710 | + | skippedHasImage = true | |
| 711 | + | break | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | if !skippedHasImage { | |
| 715 | + | emit = lines[start:] | |
| 716 | + | } | |
| 717 | + | } | |
| 718 | + | for idx, line := range emit { | |
| 687 | 719 | if idx > 0 { | |
| 688 | 720 | w.WriteString("\r\n") | |
| 689 | 721 | } | |
| @@ -936,9 +968,18 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 936 | 968 | } | |
| 937 | 969 | // A newly appended blank row compares equal to the implicit empty | |
| 938 | 970 | // row past the old slice, but it still has to advance the terminal. | |
| 939 | - | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |
| 940 | - | firstChanged = len(r.logLines) | |
| 941 | - | lastChanged = len(lines) - 1 | |
| 971 | + | // Mirror the growth rule applied before this rescan: extend | |
| 972 | + | // lastChanged whenever the logical buffer grew, regardless of | |
| 973 | + | // whether the rescan already found a visible change. Otherwise an | |
| 974 | + | // appended blank bottom-margin row is never emitted and the | |
| 975 | + | // viewport fails to scroll, shifting the whole frame up by a row. | |
| 976 | + | if len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |
| 977 | + | if firstChanged == -1 { | |
| 978 | + | firstChanged = len(r.logLines) | |
| 979 | + | } | |
| 980 | + | if lastChanged < len(lines)-1 { | |
| 981 | + | lastChanged = len(lines) - 1 | |
| 982 | + | } | |
| 942 | 983 | } | |
| 943 | 984 | } | |
| 944 | 985 | } | |
| @@ -683,7 +683,39 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 683 | w.WriteString(MoveTo(1, 1)) | 683 | w.WriteString(MoveTo(1, 1)) | ||
| 684 | } | 684 | } | ||
| 685 | } | 685 | } | ||
| 686 | - | for idx, line := range lines { | 686 | + | // The initial paint emits the whole logical buffer so the terminal's |
| 687 | + | // retained scrollback receives the full transcript. Recovery repaints | |||
| 688 | + | // (cache invalidation, structural shrink) happen while an agent is | |||
| 689 | + | // streaming, after earlier rows have already scrolled above the | |||
| 690 | + | // viewport. Replaying that prefix pushes retained history back into | |||
| 691 | + | // scrollback and duplicates it, so start from the new viewport top. | |||
| 692 | + | // Never skip a row that was visible (or never emitted) in the previous | |||
| 693 | + | // frame: if the tail starts below the old viewport top, emit from the | |||
| 694 | + | // old top instead so no visible content is lost. | |||
| 695 | + | // | |||
| 696 | + | // The clear above deletes Kitty images, so an inline image can only be | |||
| 697 | + | // kept by re-emitting its escape. When the skipped prefix carries an | |||
| 698 | + | // image escape, fall back to the full replay rather than delete a | |||
| 699 | + | // retained offscreen image without replacement. Image frames keep the | |||
| 700 | + | // older duplicate-history tradeoff in exchange for a safe repaint. | |||
| 701 | + | emit := lines | |||
| 702 | + | if clear && !purgeScrollback && len(lines) > r.rows { | |||
| 703 | + | start := len(lines) - r.rows | |||
| 704 | + | if start > r.logViewportTop { | |||
| 705 | + | start = r.logViewportTop | |||
| 706 | + | } | |||
| 707 | + | skippedHasImage := false | |||
| 708 | + | for _, line := range lines[:start] { | |||
| 709 | + | if containsImageEscape(line) { | |||
| 710 | + | skippedHasImage = true | |||
| 711 | + | break | |||
| 712 | + | } | |||
| 713 | + | } | |||
| 714 | + | if !skippedHasImage { | |||
| 715 | + | emit = lines[start:] | |||
| 716 | + | } | |||
| 717 | + | } | |||
| 718 | + | for idx, line := range emit { | |||
| 687 | if idx > 0 { | 719 | if idx > 0 { | ||
| 688 | w.WriteString("\r\n") | 720 | w.WriteString("\r\n") | ||
| 689 | } | 721 | } | ||
| @@ -936,9 +968,18 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 936 | } | 968 | } | ||
| 937 | // A newly appended blank row compares equal to the implicit empty | 969 | // A newly appended blank row compares equal to the implicit empty | ||
| 938 | // row past the old slice, but it still has to advance the terminal. | 970 | // row past the old slice, but it still has to advance the terminal. | ||
| 939 | - | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | 971 | + | // Mirror the growth rule applied before this rescan: extend |
| 940 | - | firstChanged = len(r.logLines) | 972 | + | // lastChanged whenever the logical buffer grew, regardless of |
| 941 | - | lastChanged = len(lines) - 1 | 973 | + | // whether the rescan already found a visible change. Otherwise an |
| 974 | + | // appended blank bottom-margin row is never emitted and the | |||
| 975 | + | // viewport fails to scroll, shifting the whole frame up by a row. | |||
| 976 | + | if len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |||
| 977 | + | if firstChanged == -1 { | |||
| 978 | + | firstChanged = len(r.logLines) | |||
| 979 | + | } | |||
| 980 | + | if lastChanged < len(lines)-1 { | |||
| 981 | + | lastChanged = len(lines) - 1 | |||
| 982 | + | } | |||
| 942 | } | 983 | } | ||
| 943 | } | 984 | } | ||
| 944 | } | 985 | } | ||
M packages/tui/render_test.go +155 -0
| @@ -275,6 +275,161 @@ func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) { | |||
| 275 | 275 | } | |
| 276 | 276 | } | |
| 277 | 277 | ||
| 278 | + | // TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport is a | |
| 279 | + | // regression for the streaming-render corruption where an offscreen | |
| 280 | + | // (already scrolled above the viewport) chat row changes while a new | |
| 281 | + | // chat row is appended. The renderer must not replay the inaccessible | |
| 282 | + | // row, but it must still emit the appended row and let the trailing | |
| 283 | + | // bottom-margin blank scroll the viewport so the new tail is visible. | |
| 284 | + | // | |
| 285 | + | // Previously the rescan triggered by the inaccessible change reset | |
| 286 | + | // firstChanged/lastChanged, and the appended-blank extension was only | |
| 287 | + | // re-applied when the rescan found no visible change at all. Because the | |
| 288 | + | // rescan did find the visible shift, lastChanged stayed short, the | |
| 289 | + | // implicit blank bottom margin was never emitted, and the viewport failed | |
| 290 | + | // to advance, leaving the frame shifted by one row. | |
| 291 | + | func TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport(t *testing.T) { | |
| 292 | + | t.Setenv("TERM_PROGRAM", "") | |
| 293 | + | var buf bytes.Buffer | |
| 294 | + | r := NewRenderer(&buf) | |
| 295 | + | r.Resize(50, 7) | |
| 296 | + | ||
| 297 | + | bottom := []string{"STATUS", "editor"} | |
| 298 | + | chat := []string{"row-00", "row-01", "row-02", "row-03", "row-04", "row-05"} | |
| 299 | + | r.DrawLog(chat, bottom, 1, 2) | |
| 300 | + | if want := 2; r.logViewportTop != want { | |
| 301 | + | t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // Repeated growth exercises the fix more than once: each round mutates | |
| 305 | + | // a row that has already scrolled above the viewport and appends one | |
| 306 | + | // new chat row, which grows the logical buffer by exactly one row. | |
| 307 | + | appended := []string{"new-00", "new-01"} | |
| 308 | + | for round := 0; round < len(appended); round++ { | |
| 309 | + | buf.Reset() | |
| 310 | + | mutated := append([]string(nil), chat...) | |
| 311 | + | mutated[round] = mutated[round] + "-mutated" | |
| 312 | + | chat = append(mutated, appended[round]) | |
| 313 | + | r.DrawLog(chat, bottom, 1, 2) | |
| 314 | + | got := buf.String() | |
| 315 | + | ||
| 316 | + | if strings.Contains(got, "-mutated") { | |
| 317 | + | t.Fatalf("round %d replayed an inaccessible mutated row: %q", round, got) | |
| 318 | + | } | |
| 319 | + | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |
| 320 | + | t.Fatalf("round %d cleared retained scrollback: %q", round, got) | |
| 321 | + | } | |
| 322 | + | if !strings.Contains(got, appended[round]) { | |
| 323 | + | t.Fatalf("round %d did not emit appended row %q: %q", round, appended[round], got) | |
| 324 | + | } | |
| 325 | + | if want := len(chat) + len(bottom) + 1 - r.rows; r.logViewportTop != want { | |
| 326 | + | t.Fatalf("round %d viewport top = %d, want %d (blank tail did not scroll)", round, r.logViewportTop, want) | |
| 327 | + | } | |
| 328 | + | if want := len(chat) + len(bottom) - 1; r.logHardwareRow != want { | |
| 329 | + | t.Fatalf("round %d hardware row = %d, want cursor row %d", round, r.logHardwareRow, want) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback is a | |
| 335 | + | // regression for a bottom-band shrink while the transcript is taller than | |
| 336 | + | // the viewport. The recovery path must repaint the cleared visible screen | |
| 337 | + | // with only the new visible tail; replaying the full logical buffer pushes | |
| 338 | + | // transcript rows that are already retained in scrollback back into it, | |
| 339 | + | // duplicating history. | |
| 340 | + | func TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback(t *testing.T) { | |
| 341 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 342 | + | var buf bytes.Buffer | |
| 343 | + | r := NewRenderer(&buf) | |
| 344 | + | r.Resize(50, 4) | |
| 345 | + | ||
| 346 | + | chat := []string{"T1", "T2", "T3", "T4", "T5"} | |
| 347 | + | r.DrawLog(chat, []string{"b1", "b2", "b3", "b4", "b5"}, -1, 0) | |
| 348 | + | buf.Reset() | |
| 349 | + | ||
| 350 | + | r.DrawLog(chat, []string{"d1", "d2"}, -1, 0) | |
| 351 | + | got := buf.String() | |
| 352 | + | ||
| 353 | + | if strings.Contains(got, SeqClearScrollback) { | |
| 354 | + | t.Fatalf("bottom shrink purged retained scrollback: %q", got) | |
| 355 | + | } | |
| 356 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |
| 357 | + | t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got) | |
| 358 | + | } | |
| 359 | + | for _, dup := range []string{"T1", "T2", "T3", "T4"} { | |
| 360 | + | if strings.Contains(got, dup) { | |
| 361 | + | t.Fatalf("bottom shrink replayed retained transcript row %q: %q", dup, got) | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | for _, want := range []string{"T5", "d1", "d2"} { | |
| 365 | + | if !strings.Contains(got, want) { | |
| 366 | + | t.Fatalf("bottom shrink repaint missing visible row %q: %q", want, got) | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | if want := 4; r.logViewportTop != want { | |
| 370 | + | t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want) | |
| 371 | + | } | |
| 372 | + | if want := 7; r.logHardwareRow != want { | |
| 373 | + | t.Fatalf("hardware row = %d, want %d", r.logHardwareRow, want) | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // TestDrawLogInvalidationGrowthKeepsHistory guards the scrollback-safe | |
| 378 | + | // recovery trim. When the previous frame fit the viewport, nothing has | |
| 379 | + | // scrolled into retained scrollback yet, so a following recovery repaint | |
| 380 | + | // with a taller buffer must still emit the leading rows (letting them | |
| 381 | + | // scroll into history) instead of dropping them. | |
| 382 | + | func TestDrawLogInvalidationGrowthKeepsHistory(t *testing.T) { | |
| 383 | + | t.Setenv("TERM_PROGRAM", "") | |
| 384 | + | var buf bytes.Buffer | |
| 385 | + | r := NewRenderer(&buf) | |
| 386 | + | r.Resize(50, 4) | |
| 387 | + | ||
| 388 | + | r.DrawLog([]string{"T1", "T2"}, []string{"editor"}, -1, 0) | |
| 389 | + | if want := 0; r.logViewportTop != want { | |
| 390 | + | t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want) | |
| 391 | + | } | |
| 392 | + | buf.Reset() | |
| 393 | + | ||
| 394 | + | r.Invalidate() | |
| 395 | + | r.DrawLog([]string{"T1", "T2", "T3", "T4", "T5", "T6"}, []string{"editor"}, -1, 0) | |
| 396 | + | got := buf.String() | |
| 397 | + | for _, want := range []string{"T1", "T2", "T3", "T4", "T5", "T6"} { | |
| 398 | + | if !strings.Contains(got, want) { | |
| 399 | + | t.Fatalf("recovery growth dropped history row %q: %q", want, got) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | // TestDrawLogBottomShrinkKeepsRetainedOffscreenImage is a regression for the | |
| 405 | + | // scrollback-safe recovery trim deleting a retained offscreen image. The | |
| 406 | + | // clear step deletes Kitty images, so the repaint must not skip an image | |
| 407 | + | // escape in the prefix without re-emitting it; image frames fall back to the | |
| 408 | + | // full replay instead. | |
| 409 | + | func TestDrawLogBottomShrinkKeepsRetainedOffscreenImage(t *testing.T) { | |
| 410 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 411 | + | var buf bytes.Buffer | |
| 412 | + | r := NewRenderer(&buf) | |
| 413 | + | r.Resize(50, 4) | |
| 414 | + | ||
| 415 | + | image := "\x1b_Ga=T;payload\x1b\\" | |
| 416 | + | r.DrawLog([]string{image, "a", "b", "c"}, []string{"d", "e"}, -1, 0) | |
| 417 | + | buf.Reset() | |
| 418 | + | ||
| 419 | + | r.DrawLog([]string{image, "a", "b", "c"}, []string{"d"}, -1, 0) | |
| 420 | + | got := buf.String() | |
| 421 | + | ||
| 422 | + | if !strings.Contains(got, SeqDeleteKittyImages) { | |
| 423 | + | t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got) | |
| 424 | + | } | |
| 425 | + | if !strings.Contains(got, image) { | |
| 426 | + | t.Fatalf("bottom shrink deleted a retained offscreen image without re-emitting it: %q", got) | |
| 427 | + | } | |
| 428 | + | if !strings.Contains(got, "d") { | |
| 429 | + | t.Fatalf("bottom shrink repaint missing bottom row: %q", got) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 278 | 433 | // TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for | |
| 279 | 434 | // cache invalidations, which can happen during an active turn independently | |
| 280 | 435 | // of an inaccessible changed row. | |
| @@ -275,6 +275,161 @@ func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) { | |||||
| 275 | } | 275 | } | ||
| 276 | } | 276 | } | ||
| 277 | 277 | ||||
| 278 | + | // TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport is a | |||
| 279 | + | // regression for the streaming-render corruption where an offscreen | |||
| 280 | + | // (already scrolled above the viewport) chat row changes while a new | |||
| 281 | + | // chat row is appended. The renderer must not replay the inaccessible | |||
| 282 | + | // row, but it must still emit the appended row and let the trailing | |||
| 283 | + | // bottom-margin blank scroll the viewport so the new tail is visible. | |||
| 284 | + | // | |||
| 285 | + | // Previously the rescan triggered by the inaccessible change reset | |||
| 286 | + | // firstChanged/lastChanged, and the appended-blank extension was only | |||
| 287 | + | // re-applied when the rescan found no visible change at all. Because the | |||
| 288 | + | // rescan did find the visible shift, lastChanged stayed short, the | |||
| 289 | + | // implicit blank bottom margin was never emitted, and the viewport failed | |||
| 290 | + | // to advance, leaving the frame shifted by one row. | |||
| 291 | + | func TestDrawLogInaccessibleMutationAppendedBlankAdvancesViewport(t *testing.T) { | |||
| 292 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 293 | + | var buf bytes.Buffer | |||
| 294 | + | r := NewRenderer(&buf) | |||
| 295 | + | r.Resize(50, 7) | |||
| 296 | + | ||||
| 297 | + | bottom := []string{"STATUS", "editor"} | |||
| 298 | + | chat := []string{"row-00", "row-01", "row-02", "row-03", "row-04", "row-05"} | |||
| 299 | + | r.DrawLog(chat, bottom, 1, 2) | |||
| 300 | + | if want := 2; r.logViewportTop != want { | |||
| 301 | + | t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // Repeated growth exercises the fix more than once: each round mutates | |||
| 305 | + | // a row that has already scrolled above the viewport and appends one | |||
| 306 | + | // new chat row, which grows the logical buffer by exactly one row. | |||
| 307 | + | appended := []string{"new-00", "new-01"} | |||
| 308 | + | for round := 0; round < len(appended); round++ { | |||
| 309 | + | buf.Reset() | |||
| 310 | + | mutated := append([]string(nil), chat...) | |||
| 311 | + | mutated[round] = mutated[round] + "-mutated" | |||
| 312 | + | chat = append(mutated, appended[round]) | |||
| 313 | + | r.DrawLog(chat, bottom, 1, 2) | |||
| 314 | + | got := buf.String() | |||
| 315 | + | ||||
| 316 | + | if strings.Contains(got, "-mutated") { | |||
| 317 | + | t.Fatalf("round %d replayed an inaccessible mutated row: %q", round, got) | |||
| 318 | + | } | |||
| 319 | + | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |||
| 320 | + | t.Fatalf("round %d cleared retained scrollback: %q", round, got) | |||
| 321 | + | } | |||
| 322 | + | if !strings.Contains(got, appended[round]) { | |||
| 323 | + | t.Fatalf("round %d did not emit appended row %q: %q", round, appended[round], got) | |||
| 324 | + | } | |||
| 325 | + | if want := len(chat) + len(bottom) + 1 - r.rows; r.logViewportTop != want { | |||
| 326 | + | t.Fatalf("round %d viewport top = %d, want %d (blank tail did not scroll)", round, r.logViewportTop, want) | |||
| 327 | + | } | |||
| 328 | + | if want := len(chat) + len(bottom) - 1; r.logHardwareRow != want { | |||
| 329 | + | t.Fatalf("round %d hardware row = %d, want cursor row %d", round, r.logHardwareRow, want) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback is a | |||
| 335 | + | // regression for a bottom-band shrink while the transcript is taller than | |||
| 336 | + | // the viewport. The recovery path must repaint the cleared visible screen | |||
| 337 | + | // with only the new visible tail; replaying the full logical buffer pushes | |||
| 338 | + | // transcript rows that are already retained in scrollback back into it, | |||
| 339 | + | // duplicating history. | |||
| 340 | + | func TestDrawLogBottomShrinkRepaintsViewportWithoutDuplicatingScrollback(t *testing.T) { | |||
| 341 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 342 | + | var buf bytes.Buffer | |||
| 343 | + | r := NewRenderer(&buf) | |||
| 344 | + | r.Resize(50, 4) | |||
| 345 | + | ||||
| 346 | + | chat := []string{"T1", "T2", "T3", "T4", "T5"} | |||
| 347 | + | r.DrawLog(chat, []string{"b1", "b2", "b3", "b4", "b5"}, -1, 0) | |||
| 348 | + | buf.Reset() | |||
| 349 | + | ||||
| 350 | + | r.DrawLog(chat, []string{"d1", "d2"}, -1, 0) | |||
| 351 | + | got := buf.String() | |||
| 352 | + | ||||
| 353 | + | if strings.Contains(got, SeqClearScrollback) { | |||
| 354 | + | t.Fatalf("bottom shrink purged retained scrollback: %q", got) | |||
| 355 | + | } | |||
| 356 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |||
| 357 | + | t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got) | |||
| 358 | + | } | |||
| 359 | + | for _, dup := range []string{"T1", "T2", "T3", "T4"} { | |||
| 360 | + | if strings.Contains(got, dup) { | |||
| 361 | + | t.Fatalf("bottom shrink replayed retained transcript row %q: %q", dup, got) | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | for _, want := range []string{"T5", "d1", "d2"} { | |||
| 365 | + | if !strings.Contains(got, want) { | |||
| 366 | + | t.Fatalf("bottom shrink repaint missing visible row %q: %q", want, got) | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | if want := 4; r.logViewportTop != want { | |||
| 370 | + | t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want) | |||
| 371 | + | } | |||
| 372 | + | if want := 7; r.logHardwareRow != want { | |||
| 373 | + | t.Fatalf("hardware row = %d, want %d", r.logHardwareRow, want) | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | // TestDrawLogInvalidationGrowthKeepsHistory guards the scrollback-safe | |||
| 378 | + | // recovery trim. When the previous frame fit the viewport, nothing has | |||
| 379 | + | // scrolled into retained scrollback yet, so a following recovery repaint | |||
| 380 | + | // with a taller buffer must still emit the leading rows (letting them | |||
| 381 | + | // scroll into history) instead of dropping them. | |||
| 382 | + | func TestDrawLogInvalidationGrowthKeepsHistory(t *testing.T) { | |||
| 383 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 384 | + | var buf bytes.Buffer | |||
| 385 | + | r := NewRenderer(&buf) | |||
| 386 | + | r.Resize(50, 4) | |||
| 387 | + | ||||
| 388 | + | r.DrawLog([]string{"T1", "T2"}, []string{"editor"}, -1, 0) | |||
| 389 | + | if want := 0; r.logViewportTop != want { | |||
| 390 | + | t.Fatalf("setup viewport top = %d, want %d", r.logViewportTop, want) | |||
| 391 | + | } | |||
| 392 | + | buf.Reset() | |||
| 393 | + | ||||
| 394 | + | r.Invalidate() | |||
| 395 | + | r.DrawLog([]string{"T1", "T2", "T3", "T4", "T5", "T6"}, []string{"editor"}, -1, 0) | |||
| 396 | + | got := buf.String() | |||
| 397 | + | for _, want := range []string{"T1", "T2", "T3", "T4", "T5", "T6"} { | |||
| 398 | + | if !strings.Contains(got, want) { | |||
| 399 | + | t.Fatalf("recovery growth dropped history row %q: %q", want, got) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | // TestDrawLogBottomShrinkKeepsRetainedOffscreenImage is a regression for the | |||
| 405 | + | // scrollback-safe recovery trim deleting a retained offscreen image. The | |||
| 406 | + | // clear step deletes Kitty images, so the repaint must not skip an image | |||
| 407 | + | // escape in the prefix without re-emitting it; image frames fall back to the | |||
| 408 | + | // full replay instead. | |||
| 409 | + | func TestDrawLogBottomShrinkKeepsRetainedOffscreenImage(t *testing.T) { | |||
| 410 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 411 | + | var buf bytes.Buffer | |||
| 412 | + | r := NewRenderer(&buf) | |||
| 413 | + | r.Resize(50, 4) | |||
| 414 | + | ||||
| 415 | + | image := "\x1b_Ga=T;payload\x1b\\" | |||
| 416 | + | r.DrawLog([]string{image, "a", "b", "c"}, []string{"d", "e"}, -1, 0) | |||
| 417 | + | buf.Reset() | |||
| 418 | + | ||||
| 419 | + | r.DrawLog([]string{image, "a", "b", "c"}, []string{"d"}, -1, 0) | |||
| 420 | + | got := buf.String() | |||
| 421 | + | ||||
| 422 | + | if !strings.Contains(got, SeqDeleteKittyImages) { | |||
| 423 | + | t.Fatalf("bottom shrink did not repaint the visible viewport: %q", got) | |||
| 424 | + | } | |||
| 425 | + | if !strings.Contains(got, image) { | |||
| 426 | + | t.Fatalf("bottom shrink deleted a retained offscreen image without re-emitting it: %q", got) | |||
| 427 | + | } | |||
| 428 | + | if !strings.Contains(got, "d") { | |||
| 429 | + | t.Fatalf("bottom shrink repaint missing bottom row: %q", got) | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 278 | // TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for | 433 | // TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for | ||
| 279 | // cache invalidations, which can happen during an active turn independently | 434 | // cache invalidations, which can happen during an active turn independently | ||
| 280 | // of an inaccessible changed row. | 435 | // of an inaccessible changed row. | ||