on main help sign in
perf(chat): only render the chat lines that fit on screen

Previously the chat list pulled the entire rendered output of every on-screen item into a buffer and then trimmed it down to the viewport height at the end. For very tall items, like a long reasoning block, that meant building a buffer with thousands of lines just to throw most of them away every frame. The list now stops collecting lines as soon as the viewport is full, so per-frame work is bounded by the visible window. Output is unchanged. Co-Authored-By: Charm Crush <crush@charm.land>

commit77b6c38b0fb150d97dbce466f737d1217e5a7604 authorChristian Rocha <christian@rocha.is> dateTue 12 May 2026, 14:41 (5 months ago) parent6b101f3
2 files changed+404−17
internal/ui/list/list.go+35−17 internal/ui/list/list_test.go+369
internal/ui/list/list.go+35−17
@@ -457,18 +457,30 @@ func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
457457}
458458
459459// Render renders the list and returns the visible lines.
460+//
461+// F7: per-item slicing is bounded by the remaining viewport budget so
462+// per-frame work is O(viewport) rather than O(total item heights).
463+// We never append beyond l.height lines to the output buffer; the
464+// final trim is therefore unnecessary. Reverse mode applies the same
465+// final reversal as before, which is byte-identical because the
466+// pre-F7 trim happened at the tail of the joined buffer (the same
467+// lines we now drop implicitly per item).
460468func (l *List) Render() string {
461469 if len(l.items) == 0 {
462470 return ""
463471 }
464472
465− var lines []string
473+ budget := max(l.height, 0)
474+ lines := make([]string, 0, budget)
466475 currentIdx := l.offsetIdx
467476 currentOffset := l.offsetLine
468477
469− linesNeeded := l.height
478+ for currentIdx < len(l.items) {
479+ remaining := budget - len(lines)
480+ if remaining <= 0 {
481+ break
482+ }
470483
471− for linesNeeded > 0 && currentIdx < len(l.items) {
472484 entry := l.renderItemEntry(currentIdx)
473485 if entry == nil {
474486 break
@@ -477,37 +489,43 @@ func (l *List) Render() string {
477489 itemHeight := len(itemLines)
478490
479491 if currentOffset >= 0 && currentOffset < itemHeight {
480− // Add visible content lines
481− lines = append(lines, itemLines[currentOffset:]...)
492+ // Append only the visible slice that fits in the
493+ // remaining viewport budget. Anything past the
494+ // budget would be discarded by the pre-F7 tail
495+ // trim, so skipping the append here is
496+ // byte-identical and bounded.
497+ visible := itemLines[currentOffset:]
498+ if len(visible) > remaining {
499+ visible = visible[:remaining]
500+ }
501+ lines = append(lines, visible...)
482502
483− // Add gap if this is not the absolute last visual element (conceptually gaps are between items)
484− // But in the loop we can just add it and trim later
503+ // Gap rows after the item, capped to the
504+ // remaining budget so a 30k-line item with a
505+ // trailing gap can't push past the viewport.
485506 if l.gap > 0 {
486− for i := 0; i < l.gap; i++ {
507+ gapBudget := min(budget-len(lines), l.gap)
508+ for range gapBudget {
487509 lines = append(lines, "")
488510 }
489511 }
490512 } else {
491− // offsetLine starts in the gap
513+ // offsetLine starts inside the gap.
492514 gapOffset := currentOffset - itemHeight
493515 gapRemaining := l.gap - gapOffset
494516 if gapRemaining > 0 {
495− for range gapRemaining {
517+ gapBudget := min(budget-len(lines), gapRemaining)
518+ for range gapBudget {
496519 lines = append(lines, "")
497520 }
498521 }
499522 }
500523
501− linesNeeded = l.height - len(lines)
502524 currentIdx++
503− currentOffset = 0 // Reset offset for subsequent items
525+ currentOffset = 0 // Reset offset for subsequent items.
504526 }
505527
506− l.height = max(l.height, 0)
507−
508− if len(lines) > l.height {
509− lines = lines[:l.height]
510− }
528+ l.height = budget
511529
512530 if l.reverse {
513531 // Reverse the lines so the list renders bottom-to-top.
internal/ui/list/list_test.go+369
@@ -399,3 +399,372 @@ func TestVersioned_BumpMonotonic(t *testing.T) {
399399 v.Bump()
400400 require.Equal(t, uint64(3), v.Version())
401401}
402+
403+// multiLineItem is a test helper whose Render returns a fixed
404+// multi-line body. Each line is uniquely identifiable (id:N) so a
405+// test can reconstruct the expected visible window by index. F7's
406+// byte-identity matrix is built around these.
407+type multiLineItem struct {
408+ *Versioned
409+ id string
410+ height int
411+}
412+
413+func newMultiLineItem(id string, height int) *multiLineItem {
414+ return &multiLineItem{
415+ Versioned: NewVersioned(),
416+ id: id,
417+ height: height,
418+ }
419+}
420+
421+func (m *multiLineItem) Render(_ int) string {
422+ if m.height <= 0 {
423+ return ""
424+ }
425+ parts := make([]string, m.height)
426+ for i := range m.height {
427+ parts[i] = m.id + ":" + strconv.Itoa(i)
428+ }
429+ return strings.Join(parts, "\n")
430+}
431+
432+func (m *multiLineItem) Finished() bool { return true }
433+
434+// expectedRender computes what list.Render *should* produce from
435+// first principles given the item heights, viewport, offsetIdx,
436+// offsetLine, gap, and reverse settings. It mirrors the pre-F7
437+// "build everything, trim to height, reverse" semantics so we can
438+// assert byte-identity against the new bounded path.
439+func expectedRender(items []*multiLineItem, height, offsetIdx, offsetLine, gap int, reverse bool) string {
440+ if len(items) == 0 {
441+ return ""
442+ }
443+ budget := max(height, 0)
444+ var lines []string
445+ currentOffset := offsetLine
446+ for idx := offsetIdx; idx < len(items) && len(lines) < budget; idx++ {
447+ body := items[idx].Render(0)
448+ body = strings.TrimRight(body, "\n")
449+ itemLines := strings.Split(body, "\n")
450+ itemHeight := len(itemLines)
451+
452+ if currentOffset >= 0 && currentOffset < itemHeight {
453+ lines = append(lines, itemLines[currentOffset:]...)
454+ for range gap {
455+ lines = append(lines, "")
456+ }
457+ } else {
458+ gapOffset := currentOffset - itemHeight
459+ gapRemaining := gap - gapOffset
460+ for range max(gapRemaining, 0) {
461+ lines = append(lines, "")
462+ }
463+ }
464+ currentOffset = 0
465+ }
466+ if len(lines) > budget {
467+ lines = lines[:budget]
468+ }
469+ if reverse {
470+ for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
471+ lines[i], lines[j] = lines[j], lines[i]
472+ }
473+ }
474+ return strings.Join(lines, "\n")
475+}
476+
477+// TestList_F7_ByteIdentityMatrix is T1 from the F7 plan: a sweep
478+// over (item heights × viewport heights × offsets × gaps × reverse)
479+// that asserts list.Render produces output byte-identical to a
480+// pre-F7-equivalent reference (build full buffer, trim at end).
481+func TestList_F7_ByteIdentityMatrix(t *testing.T) {
482+ t.Parallel()
483+
484+ itemHeights := [][]int{
485+ {1},
486+ {5},
487+ {1, 1, 1},
488+ {3, 7, 2},
489+ {20, 5, 30},
490+ {50, 1, 50, 1},
491+ }
492+ viewportHeights := []int{1, 3, 5, 10, 25, 100}
493+ offsetIdxs := []int{0, 1, 2}
494+ offsetLines := []int{0, 1, 4}
495+ gaps := []int{0, 1, 3}
496+ reverses := []bool{false, true}
497+
498+ for _, heights := range itemHeights {
499+ for _, vh := range viewportHeights {
500+ for _, oIdx := range offsetIdxs {
501+ if oIdx >= len(heights) {
502+ continue
503+ }
504+ for _, oLine := range offsetLines {
505+ maxOffset := heights[oIdx]
506+ if oLine >= maxOffset {
507+ continue
508+ }
509+ for _, gap := range gaps {
510+ for _, reverse := range reverses {
511+ items := make([]*multiLineItem, len(heights))
512+ asItems := make([]Item, len(heights))
513+ for i, h := range heights {
514+ items[i] = newMultiLineItem("i"+strconv.Itoa(i), h)
515+ asItems[i] = items[i]
516+ }
517+ l := NewList(asItems...)
518+ l.SetSize(40, vh)
519+ l.SetGap(gap)
520+ l.SetReverse(reverse)
521+ l.offsetIdx = oIdx
522+ l.offsetLine = oLine
523+
524+ got := l.Render()
525+ want := expectedRender(items, vh, oIdx, oLine, gap, reverse)
526+ require.Equalf(t, want, got,
527+ "mismatch heights=%v vh=%d oIdx=%d oLine=%d gap=%d reverse=%v",
528+ heights, vh, oIdx, oLine, gap, reverse)
529+ }
530+ }
531+ }
532+ }
533+ }
534+ }
535+}
536+
537+// TestList_F7_GiantItemBoundedRender is T2: a single 10,000-line
538+// item with a 50-line viewport. Render must return exactly 50
539+// lines — no off-by-one, no trim issue. This is the F7 win in
540+// test form: per-frame work is bounded by viewport, not item
541+// height.
542+func TestList_F7_GiantItemBoundedRender(t *testing.T) {
543+ t.Parallel()
544+
545+ const itemHeight = 10000
546+ const viewport = 50
547+
548+ giant := newMultiLineItem("giant", itemHeight)
549+ l := NewList(giant)
550+ l.SetSize(40, viewport)
551+
552+ out := l.Render()
553+ got := strings.Count(out, "\n") + 1
554+ require.Equal(t, viewport, got, "render output must be exactly viewport lines for an oversized item")
555+
556+ // And the lines are the prefix of the item starting at
557+ // offset 0.
558+ lines := strings.Split(out, "\n")
559+ for i, line := range lines {
560+ require.Equal(t, "giant:"+strconv.Itoa(i), line, "line %d does not match expected slice", i)
561+ }
562+}
563+
564+// TestList_F7_GiantItemWithOffsetBoundedRender complements T2 with
565+// a non-zero offsetLine so we exercise both the "skip prefix" and
566+// "bound suffix" sides of the slice.
567+func TestList_F7_GiantItemWithOffsetBoundedRender(t *testing.T) {
568+ t.Parallel()
569+
570+ const itemHeight = 10000
571+ const viewport = 50
572+ const offset = 1234
573+
574+ giant := newMultiLineItem("giant", itemHeight)
575+ l := NewList(giant)
576+ l.SetSize(40, viewport)
577+ l.offsetLine = offset
578+
579+ out := l.Render()
580+ lines := strings.Split(out, "\n")
581+ require.Len(t, lines, viewport, "render output must be exactly viewport lines for an oversized item")
582+ for i, line := range lines {
583+ require.Equal(t, "giant:"+strconv.Itoa(offset+i), line, "line %d does not match expected slice", i)
584+ }
585+}
586+
587+// TestList_F7_GapOverflow is T3: viewport height 5, two items each
588+// 10 lines, gap of 3. Render returns exactly 5 lines and never
589+// includes gap rows beyond the viewport.
590+func TestList_F7_GapOverflow(t *testing.T) {
591+ t.Parallel()
592+
593+ a := newMultiLineItem("a", 10)
594+ b := newMultiLineItem("b", 10)
595+ l := NewList(a, b)
596+ l.SetSize(40, 5)
597+ l.SetGap(3)
598+
599+ out := l.Render()
600+ lines := strings.Split(out, "\n")
601+ require.Len(t, lines, 5, "viewport must clamp output to height even with gap rows pending")
602+
603+ // Gap rows after item a would only appear if the viewport
604+ // extended past the first 10 lines, which it doesn't here.
605+ for i, line := range lines {
606+ require.Equal(t, "a:"+strconv.Itoa(i), line, "line %d", i)
607+ }
608+}
609+
610+// TestList_F7_GapOverflow_BoundaryStraddle exercises a viewport
611+// that lands inside the gap region between two items: 12 lines
612+// viewport, item a height 10, item b height 10, gap 3 — first 10
613+// lines from a, then 2 of the 3 gap rows, no b lines yet.
614+func TestList_F7_GapOverflow_BoundaryStraddle(t *testing.T) {
615+ t.Parallel()
616+
617+ a := newMultiLineItem("a", 10)
618+ b := newMultiLineItem("b", 10)
619+ l := NewList(a, b)
620+ l.SetSize(40, 12)
621+ l.SetGap(3)
622+
623+ out := l.Render()
624+ lines := strings.Split(out, "\n")
625+ require.Len(t, lines, 12)
626+
627+ for i := range 10 {
628+ require.Equal(t, "a:"+strconv.Itoa(i), lines[i])
629+ }
630+ require.Equal(t, "", lines[10])
631+ require.Equal(t, "", lines[11])
632+}
633+
634+// TestList_F7_ReverseGiantItem is T4: same bounded-slicing
635+// invariant in reverse mode. Reverse mode keeps the same final
636+// trim semantics; bounded slicing must produce the same window
637+// (just reversed).
638+func TestList_F7_ReverseGiantItem(t *testing.T) {
639+ t.Parallel()
640+
641+ const itemHeight = 10000
642+ const viewport = 50
643+
644+ giant := newMultiLineItem("giant", itemHeight)
645+ l := NewList(giant)
646+ l.SetSize(40, viewport)
647+ l.SetReverse(true)
648+
649+ out := l.Render()
650+ lines := strings.Split(out, "\n")
651+ require.Len(t, lines, viewport)
652+
653+ // Expected: the same first-50 slice as the non-reverse path
654+ // but reversed.
655+ for i, line := range lines {
656+ expectedIdx := viewport - 1 - i
657+ require.Equal(t, "giant:"+strconv.Itoa(expectedIdx), line, "reverse line %d", i)
658+ }
659+}
660+
661+// TestList_F7_OffsetLineAtItemBoundary is T5: offsetLine ==
662+// itemHeight lands exactly past the last visible line of the item
663+// at offsetIdx. The renderer must not address line N (which does
664+// not exist); the visible window starts at the gap rows (when gap
665+// > 0) or at the next item (when gap == 0).
666+func TestList_F7_OffsetLineAtItemBoundary(t *testing.T) {
667+ t.Parallel()
668+
669+ tests := []struct {
670+ name string
671+ gap int
672+ viewport int
673+ want []string
674+ }{
675+ {
676+ name: "gap zero jumps straight to next item",
677+ gap: 0,
678+ viewport: 5,
679+ want: []string{
680+ "b:0", "b:1", "b:2", "b:3", "b:4",
681+ },
682+ },
683+ {
684+ name: "gap two emits gap rows then next item",
685+ gap: 2,
686+ viewport: 5,
687+ want: []string{
688+ "", "", "b:0", "b:1", "b:2",
689+ },
690+ },
691+ }
692+
693+ const itemHeight = 4
694+ for _, tc := range tests {
695+ t.Run(tc.name, func(t *testing.T) {
696+ t.Parallel()
697+
698+ a := newMultiLineItem("a", itemHeight)
699+ b := newMultiLineItem("b", 10)
700+ l := NewList(a, b)
701+ l.SetSize(40, tc.viewport)
702+ l.SetGap(tc.gap)
703+ l.offsetIdx = 0
704+ l.offsetLine = itemHeight // exactly at the boundary
705+
706+ out := l.Render()
707+ require.Equal(t, strings.Join(tc.want, "\n"), out)
708+ })
709+ }
710+}
711+
712+// TestList_F7_OffsetLineInsideGap is T6: offsetLine is one row
713+// past the end of the item at offsetIdx, landing inside the gap
714+// region. The visible window starts at the second gap row and
715+// then continues into the next item.
716+func TestList_F7_OffsetLineInsideGap(t *testing.T) {
717+ t.Parallel()
718+
719+ const itemHeight = 4
720+ const gap = 3
721+ const viewport = 5
722+
723+ a := newMultiLineItem("a", itemHeight)
724+ b := newMultiLineItem("b", 10)
725+ l := NewList(a, b)
726+ l.SetSize(40, viewport)
727+ l.SetGap(gap)
728+ l.offsetIdx = 0
729+ l.offsetLine = itemHeight + 1 // one row into the gap
730+
731+ out := l.Render()
732+ want := strings.Join([]string{
733+ "", // second gap row
734+ "", // third gap row
735+ "b:0", // next item starts
736+ "b:1",
737+ "b:2",
738+ }, "\n")
739+ require.Equal(t, want, out)
740+}
741+
742+// TestList_F7_ViewportZeroOrNegative is T7: a non-positive
743+// viewport height must produce an empty string with no panic and
744+// must normalize l.height to zero (the budget := max(l.height, 0)
745+// side effect).
746+func TestList_F7_ViewportZeroOrNegative(t *testing.T) {
747+ t.Parallel()
748+
749+ tests := []struct {
750+ name string
751+ height int
752+ }{
753+ {name: "height zero", height: 0},
754+ {name: "height negative", height: -1},
755+ }
756+
757+ for _, tc := range tests {
758+ t.Run(tc.name, func(t *testing.T) {
759+ t.Parallel()
760+
761+ a := newMultiLineItem("a", 5)
762+ l := NewList(a)
763+ l.SetSize(40, tc.height)
764+
765+ out := l.Render()
766+ require.Equal(t, "", out, "render must be empty for non-positive viewport")
767+ require.Equal(t, 0, l.height, "render must normalize height to zero")
768+ })
769+ }
770+}