mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* Centralize TUI frame layout Add a reusable transcript frame snapshot with terminal regions for header, body, footer, composer, and status lines. Mouse composer hit testing, overlay positioning, and transcript viewport mapping now consume the same frame geometry instead of recalculating pinned-header and clipped-footer offsets separately. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui * Route TUI mouse hits through local regions Return overlay hit coordinates relative to the overlay block and derive transcript hit rows from the shared body rect. This keeps the current scroll behavior while making mouse handling consume component-local geometry from the frame layout. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui * Cover TUI frame geometry Add focused tests for the shared frame snapshot: pinned header/body/footer regions, tiny-terminal footer clipping, composer mouse hit regions, overlay centering, and transcript mouse row mapping through the body region. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui * Extract transcript viewport line window Introduce a line-based transcript viewport adapter that preserves Zero's existing bottom-anchored scroll offset. Render slicing, chat scroll metrics, scrollChat, and transcript selection viewport mapping now share the same clamping and visible-window calculation. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui * Build transcript body from layout items Introduce a compatibility transcript body item layer for title, empty state, separators, transcript rows, pending prompts, streaming interim content, and spec review prompts. The body still renders to the same line/selectable output, but layout now records item spans and local selectable offsets for the next visible-range step. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..." * Address TUI layout review findings Clamp overlay mouse hit regions to the visible transcript body and block transcript selection while the MCP add wizard is open. Strengthen layout and viewport edge-case tests, including full composer containment and degenerate dimensions. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..." * Make MCP wizard overlay test hit transcript text CodeRabbit caught that the MCP add wizard blocking test clicked at x=0, which could miss selectable transcript text and pass without proving the overlay guard. This updates the test helper to return both textStart and y, then clicks that known selectable point before asserting the wizard blocks transcript selection behind the modal. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: git diff --check Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Route transcript windows through body layout Thread the structured transcriptBodyLayout through alt-screen rendering, scroll metrics, and transcript mouse lookup instead of repeatedly joining and splitting the rendered body string. This preserves the current line-based viewport behavior while making the body layout the source of truth for visible windows and selectable line lookup. This is an incremental step toward visible-item rendering and item-level caching; it does not change the transcript visual output or input flow. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: git diff --check Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..." * Reuse row cache for selectable transcript rows Selectable user and assistant rows need selectable metadata for mouse interactions, but when no transcript selection is active their rendered strings are identical to the normal row render path. Route those rendered strings through renderRow so stable rows reuse the existing row cache while selection-active frames still render highlight-aware output. This keeps transcript behavior unchanged and adds a regression test that verifies selectable assistant rows produce stable metadata while hitting the render cache on the second render. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: git diff --check Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
190 lines
5.6 KiB
Go
190 lines
5.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
)
|
|
|
|
func TestStaticRenderCacheHitMiss(t *testing.T) {
|
|
cache := newStaticRenderCache(4, 100)
|
|
renders := 0
|
|
|
|
first := cache.render("row:1", true, func() string {
|
|
renders++
|
|
return "rendered"
|
|
})
|
|
second := cache.render("row:1", true, func() string {
|
|
renders++
|
|
return "changed"
|
|
})
|
|
|
|
if first != "rendered" || second != "rendered" {
|
|
t.Fatalf("cached values = %q, %q; want first render reused", first, second)
|
|
}
|
|
if renders != 1 {
|
|
t.Fatalf("render func called %d times, want 1", renders)
|
|
}
|
|
stats := cache.stats()
|
|
if stats.Hits != 1 || stats.Misses != 1 {
|
|
t.Fatalf("stats = %#v, want 1 hit and 1 miss", stats)
|
|
}
|
|
}
|
|
|
|
func TestStaticRenderCacheEvictsLeastRecentlyUsedEntry(t *testing.T) {
|
|
cache := newStaticRenderCache(2, 100)
|
|
|
|
cache.render("a", true, func() string { return "A" })
|
|
cache.render("b", true, func() string { return "B" })
|
|
cache.render("a", true, func() string { return "A2" })
|
|
cache.render("c", true, func() string { return "C" })
|
|
|
|
renders := 0
|
|
got := cache.render("b", true, func() string {
|
|
renders++
|
|
return "B2"
|
|
})
|
|
|
|
if got != "B2" || renders != 1 {
|
|
t.Fatalf("b render = %q with %d calls, want evicted entry to rerender", got, renders)
|
|
}
|
|
stats := cache.stats()
|
|
if stats.Evictions != 2 {
|
|
t.Fatalf("stats = %#v, want 2 evictions (c insertion, b reinsertion)", stats)
|
|
}
|
|
}
|
|
|
|
func TestStaticRenderCacheEvictsToRetainedCharacterLimit(t *testing.T) {
|
|
cache := newStaticRenderCache(10, 6)
|
|
|
|
cache.render("a", true, func() string { return "aaa" })
|
|
cache.render("b", true, func() string { return "bbb" })
|
|
cache.render("a", true, func() string { return "aaa" })
|
|
cache.render("c", true, func() string { return "cc" })
|
|
|
|
if got := cache.retainedCharacters(); got != 5 {
|
|
t.Fatalf("retained characters = %d, want 5", got)
|
|
}
|
|
|
|
renders := 0
|
|
got := cache.render("b", true, func() string {
|
|
renders++
|
|
return "bbb"
|
|
})
|
|
if got != "bbb" || renders != 1 {
|
|
t.Fatalf("b render = %q with %d calls, want retained-char eviction to rerender", got, renders)
|
|
}
|
|
if stats := cache.stats(); stats.Evictions != 2 {
|
|
t.Fatalf("stats = %#v, want 2 evictions (char-limit insertion, b reinsertion)", stats)
|
|
}
|
|
}
|
|
|
|
func TestStaticRenderCacheSkipsOversizedOutputs(t *testing.T) {
|
|
cache := newStaticRenderCache(4, 5)
|
|
renders := 0
|
|
|
|
for range 2 {
|
|
got := cache.render("huge", true, func() string {
|
|
renders++
|
|
return "123456"
|
|
})
|
|
if got != "123456" {
|
|
t.Fatalf("render = %q, want oversized value returned without caching", got)
|
|
}
|
|
}
|
|
|
|
stats := cache.stats()
|
|
if renders != 2 || stats.SkippedOversized != 2 || stats.Hits != 0 {
|
|
t.Fatalf("renders=%d stats=%#v, want rerendered oversized output with no hits", renders, stats)
|
|
}
|
|
if cache.retainedCharacters() != 0 {
|
|
t.Fatalf("retained characters = %d, want 0", cache.retainedCharacters())
|
|
}
|
|
}
|
|
|
|
func TestStaticRenderCacheSkipsUnstableRequests(t *testing.T) {
|
|
cache := newStaticRenderCache(4, 100)
|
|
renders := 0
|
|
|
|
for range 2 {
|
|
cache.render("spinner", false, func() string {
|
|
renders++
|
|
return "tick"
|
|
})
|
|
}
|
|
|
|
if renders != 2 {
|
|
t.Fatalf("render func called %d times, want 2 for unstable requests", renders)
|
|
}
|
|
if stats := cache.stats(); stats != (renderCacheStats{}) {
|
|
t.Fatalf("stats = %#v, want unstable requests to bypass cache accounting", stats)
|
|
}
|
|
}
|
|
|
|
func TestRenderRowCacheSkipsRunningToolRows(t *testing.T) {
|
|
cache := replaceDefaultRenderCache(t, newStaticRenderCache(8, 1000))
|
|
m := newModel(context.Background(), Options{})
|
|
m.pending = true
|
|
m.activeRunID = 7
|
|
row := transcriptRow{kind: rowToolCall, id: "call_1", runID: 7, tool: "grep", detail: "internal/tui"}
|
|
|
|
for range 2 {
|
|
_ = m.renderRow(row, 80, buildRowContext([]transcriptRow{row}))
|
|
}
|
|
|
|
if stats := cache.stats(); stats != (renderCacheStats{}) {
|
|
t.Fatalf("stats = %#v, want running tool rows to bypass cache", stats)
|
|
}
|
|
}
|
|
|
|
func TestRenderRowCacheSkipsPendingPermissionPromptRows(t *testing.T) {
|
|
cache := replaceDefaultRenderCache(t, newStaticRenderCache(8, 1000))
|
|
m := newModel(context.Background(), Options{})
|
|
m.pending = true
|
|
m.activeRunID = 9
|
|
row := transcriptRow{kind: rowPermission, id: "call_1", runID: 9, permission: &agent.PermissionEvent{
|
|
ToolCallID: "call_1",
|
|
ToolName: "bash",
|
|
Action: agent.PermissionActionPrompt,
|
|
}}
|
|
|
|
for range 2 {
|
|
_ = m.renderRow(row, 80, buildRowContext([]transcriptRow{row}))
|
|
}
|
|
|
|
if stats := cache.stats(); stats != (renderCacheStats{}) {
|
|
t.Fatalf("stats = %#v, want pending permission prompts to bypass cache", stats)
|
|
}
|
|
}
|
|
|
|
func TestSelectableAssistantRowsUseRenderCacheWhenSelectionInactive(t *testing.T) {
|
|
cache := replaceDefaultRenderCache(t, newStaticRenderCache(8, 1000))
|
|
m := newModel(context.Background(), Options{})
|
|
row := transcriptRow{kind: rowAssistant, text: "Done with **markdown**.", final: true, turnTools: 1}
|
|
|
|
first, firstSelectable := m.renderSelectableAssistantRow(0, row, 80, 0)
|
|
second, secondSelectable := m.renderSelectableAssistantRow(0, row, 80, 0)
|
|
|
|
if first == "" || first != second {
|
|
t.Fatalf("rendered rows = %q and %q, want stable cached output", first, second)
|
|
}
|
|
if len(firstSelectable) == 0 || len(secondSelectable) != len(firstSelectable) {
|
|
t.Fatalf("selectable metadata = %#v then %#v, want stable selectable lines", firstSelectable, secondSelectable)
|
|
}
|
|
stats := cache.stats()
|
|
if stats.Misses != 1 || stats.Hits != 1 {
|
|
t.Fatalf("cache stats = %#v, want one miss and one hit", stats)
|
|
}
|
|
}
|
|
|
|
func replaceDefaultRenderCache(t *testing.T, cache *staticRenderCache) *staticRenderCache {
|
|
t.Helper()
|
|
previous := defaultRenderCache
|
|
defaultRenderCache = cache
|
|
t.Cleanup(func() {
|
|
defaultRenderCache = previous
|
|
})
|
|
return cache
|
|
}
|