v0.15.0 6b178f8

interactive_render_test.go RawHistoryFolder

1package modes
2
3import (
4	"strings"
5	"sync"
6	"sync/atomic"
7	"testing"
8
9	"github.com/bnema/zut/packages/core"
10	"github.com/bnema/zut/packages/provider"
11	"github.com/bnema/zut/packages/tui"
12)
13
14func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
15	s := newLatestFrameScheduler()
16	started := make(chan struct{})
17	release := make(chan struct{})
18	type renderedFrame struct {
19		req     renderRequest
20		version int64
21	}
22	frames := make(chan renderedFrame, 4)
23	done := make(chan struct{})
24	var startedOnce sync.Once
25	var latestVersion atomic.Int64
26	go func() {
27		s.run(func(req renderRequest) {
28			frames <- renderedFrame{req: req, version: latestVersion.Load()}
29			startedOnce.Do(func() { close(started) })
30			<-release
31		})
32		close(done)
33	}()
34
35	latestVersion.Store(1)
36	if !s.request(false, false) {
37		t.Fatal("initial render request was rejected")
38	}
39	first := <-frames
40	<-started
41	if first.req.clear {
42		t.Fatal("ordinary request unexpectedly requested a clear")
43	}
44	for n := 0; n < 1000; n++ {
45		latestVersion.Store(int64(n + 2))
46		if !s.request(false, false) {
47			t.Fatal("request rejected before shutdown")
48		}
49	}
50	close(release)
51	second := <-frames
52	if second.req.clear {
53		t.Fatal("coalesced ordinary request unexpectedly requested a clear")
54	}
55	if second.version != 1001 {
56		t.Fatalf("renderer did not observe newest state: got version %d", second.version)
57	}
58	s.stop()
59	<-done
60
61	select {
62	case <-frames:
63		t.Fatal("scheduler retained more than one pending frame")
64	default:
65	}
66}
67
68func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) {
69	i := &Interactive{
70		dirty:     make(chan struct{}, 1),
71		toolCalls: make(map[string]*tui.ToolCallView),
72		toolGate:  make(map[string]int),
73	}
74
75	i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"})
76	alphaStart := i.toolCalls["alpha"].Revision
77	i.handleEventForPresentation(core.EvToolUseArgs{ID: "alpha", Delta: `{"path":"alpha.go"}`})
78	alphaArgs := i.toolCalls["alpha"].Revision
79	i.handleEventForPresentation(core.EvToolUseStart{ID: "beta", Name: "edit"})
80	betaStart := i.toolCalls["beta"].Revision
81	i.handleEventForPresentation(core.EvToolUseArgs{ID: "beta", Delta: `{"path":"beta.go"}`})
82	betaArgs := i.toolCalls["beta"].Revision
83
84	if alphaStart >= alphaArgs || alphaArgs >= betaStart || betaStart >= betaArgs {
85		t.Fatalf("tool revisions are not globally monotonic: alpha start=%d args=%d, beta start=%d args=%d",
86			alphaStart, alphaArgs, betaStart, betaArgs)
87	}
88}
89
90func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
91	scheduler := newLatestFrameScheduler()
92	i := &Interactive{
93		dirty:     make(chan struct{}, 1),
94		toolCalls: make(map[string]*tui.ToolCallView),
95		toolGate:  make(map[string]int),
96	}
97	i.renderScheduler.Store(scheduler)
98
99	i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})
100	i.handleEventForPresentation(core.EvToolUseArgs{ID: "call", Delta: `{"command":"pwd"}`})
101	i.handleEventForPresentation(core.EvToolUseEnd{ID: "call"})
102
103	if got := len(scheduler.wake); got != 0 {
104		t.Fatalf("tool events bypassed the main-loop redraw throttle: %d direct request", got)
105	}
106	select {
107	case <-i.dirty:
108	default:
109		t.Fatal("tool events did not wake the throttled redraw path")
110	}
111	select {
112	case <-i.dirty:
113		t.Fatal("tool event burst queued more than one throttled redraw")
114	default:
115	}
116}
117
118func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
119	i := &Interactive{
120		view: &tui.View{
121			Theme: tui.Dark,
122			Messages: []provider.Message{{
123				Role:    provider.RoleUser,
124				Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
125			}},
126			MessagesRevision: 1,
127		},
128	}
129
130	i.mu.Lock()
131	before := strings.Join(i.stableChatRowsLocked(80), "\n")
132	i.view.Theme = tui.Light
133	i.view.InvalidateRenderCache()
134	after := strings.Join(i.stableChatRowsLocked(80), "\n")
135	i.mu.Unlock()
136
137	if before == after {
138		t.Fatal("stable chat cache reused rows after a theme invalidation")
139	}
140}
141
142func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
143	const finalText = "final message revealed after paced flush"
144
145	agent := &core.Agent{}
146	agent.SetMessages([]provider.Message{
147		{
148			Role:    provider.RoleUser,
149			Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
150		},
151		{
152			Role:    provider.RoleAssistant,
153			Content: []provider.Content{provider.TextBlock{Text: finalText}},
154		},
155	})
156	i := &Interactive{
157		agent:              agent,
158		view:               &tui.View{Theme: tui.Dark},
159		renderOutsideLock:  true,
160		streamFlushPending: true,
161	}
162
163	revision := agent.Revision()
164	i.mu.Lock()
165	before := strings.Join(i.cachedChatLocked(80), "\n")
166	i.streamFlushPending = false
167	after := strings.Join(i.cachedChatLocked(80), "\n")
168	i.mu.Unlock()
169
170	if got := agent.Revision(); got != revision {
171		t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision)
172	}
173	if strings.Contains(before, finalText) {
174		t.Fatal("stable cache revealed the final message while stream flush was pending")
175	}
176	if !strings.Contains(after, finalText) {
177		t.Fatal("stable cache did not reveal the final message after stream flush")
178	}
179}
180
181func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
182	i := &Interactive{
183		dirty:     make(chan struct{}, 1),
184		toolCalls: make(map[string]*tui.ToolCallView),
185		toolGate:  make(map[string]int),
186	}
187	i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
188	select {
189	case <-i.dirty:
190	default:
191		t.Fatal("tool start did not invalidate the presentation")
192	}
193
194	const progressEvents = 4096
195	for n := 0; n < progressEvents; n++ {
196		i.handleEventForPresentation(core.EvToolProgress{
197			ID:   "storm",
198			Text: strings.Repeat("progress payload ", 256),
199		})
200	}
201	select {
202	case <-i.dirty:
203		t.Fatal("invisible progress created presentation invalidation")
204	default:
205	}
206
207	result := strings.Repeat("completed tool output\n", 256)
208	i.handleEventForPresentation(core.EvToolResult{
209		ID: "storm",
210		Result: core.ToolResult{Content: []provider.Content{
211			provider.TextBlock{Text: result},
212		}},
213	})
214	select {
215	case <-i.dirty:
216	default:
217		t.Fatal("completed tool result did not invalidate the presentation")
218	}
219	if got := i.toolCalls["storm"].Result; got != result {
220		t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
221	}
222	if got := i.toolCalls["storm"].Revision; got != 2 {
223		t.Fatalf("completed tool result revision: got %d, want 2", got)
224	}
225
226	view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
227	rows := view.BuildLive(80)
228	if len(rows) == 0 {
229		t.Fatal("completed tool result produced no live frame rows")
230	}
231}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close