v0.42.0 82578de

view_plan_test.go RawHistoryFolder

1package tui
2
3import (
4	"fmt"
5	"strings"
6	"testing"
7
8	"github.com/bnema/zut/packages/core"
9	"github.com/bnema/zut/packages/provider"
10)
11
12// planTestMessages builds the assistant tool_use + tool result pair a plan call
13// leaves in the transcript. Rendering now reads the checklist from the snapshot
14// map keyed by the call id, not from these arguments.
15func planTestMessages(callID, name, args string) []provider.Message {
16	return []provider.Message{
17		{Role: provider.RoleAssistant, Content: []provider.Content{
18			provider.ToolCallBlock{ID: callID, Name: name, Arguments: []byte(args)},
19		}},
20		{Role: provider.RoleTool, Content: []provider.Content{
21			provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
22		}},
23	}
24}
25
26func TestViewRendersPlanSnapshotAsChecklist(t *testing.T) {
27	callID := "plan-1"
28	explanation := "Finished discovery"
29	view := &View{
30		Theme:    Dark,
31		Messages: planTestMessages(callID, "plan", `{"action":"set"}`),
32		PlanUpdates: map[string]core.PlanUpdate{
33			callID: {
34				Explanation: &explanation,
35				Plan: []core.PlanStep{
36					{Step: "Map the implementation", Status: core.PlanCompleted},
37					{Step: "Add the built-in tool", Status: core.PlanInProgress},
38					{Step: "Verify the behavior", Status: core.PlanPending},
39				},
40			},
41		},
42	}
43
44	plain := stripANSI(strings.Join(view.Build(60), "\n"))
45	for _, want := range []string{
46		"Updated Plan",
47		"Finished discovery",
48		"✓ Map the implementation",
49		"□ Add the built-in tool",
50		"□ Verify the behavior",
51	} {
52		if !strings.Contains(plain, want) {
53			t.Fatalf("rendered plan omitted %q:\n%s", want, plain)
54		}
55	}
56	if strings.Contains(plain, "plan ") || strings.Contains(plain, "Plan updated") {
57		t.Fatalf("rendered plan leaked generic tool chrome:\n%s", plain)
58	}
59}
60
61func TestViewRendersLegacyUpdatePlanSnapshotFromMap(t *testing.T) {
62	callID := "legacy-1"
63	view := &View{
64		Theme:    Dark,
65		Messages: planTestMessages(callID, "update_plan", `{"plan":[{"step":"Implement","status":"in_progress"}]}`),
66		PlanUpdates: map[string]core.PlanUpdate{
67			callID: {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
68		},
69	}
70	plain := stripANSI(strings.Join(view.Build(60), "\n"))
71	if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") {
72		t.Fatalf("legacy plan rendering = %q", plain)
73	}
74}
75
76func TestViewRendersLivePlanFromSnapshot(t *testing.T) {
77	view := &View{
78		Theme: Dark,
79		PlanUpdates: map[string]core.PlanUpdate{
80			"plan-1": {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
81		},
82	}
83	plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{
84		ID:         "plan-1",
85		Name:       "plan",
86		RawJSONBuf: `{"action":"update","index":1,"status":"in_progress"}`,
87		Done:       true,
88	}, 50), "\n"))
89	if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") {
90		t.Fatalf("live plan rendering = %q", plain)
91	}
92	if strings.Contains(plain, "plan:") || strings.Contains(plain, "Plan updated") {
93		t.Fatalf("live plan leaked generic tool chrome: %q", plain)
94	}
95}
96
97// TestViewRendersCompactPlanLineWithoutSnapshot covers a plan call whose
98// arguments are still streaming, before execution materialized any checklist.
99func TestViewRendersCompactPlanLineWithoutSnapshot(t *testing.T) {
100	view := &View{Theme: Dark}
101	plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{
102		ID:         "plan-1",
103		Name:       "plan",
104		RawJSONBuf: `{"action":"update"`,
105	}, 50), "\n"))
106	if !strings.Contains(plain, "plan: update") {
107		t.Fatalf("compact plan line = %q", plain)
108	}
109	if strings.Contains(plain, "Updated Plan") {
110		t.Fatalf("compact plan line rendered a checklist: %q", plain)
111	}
112}
113
114func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) {
115	callID := "plan-1"
116	messages := planTestMessages(callID, "plan", `{"action":"set"}`)
117	seed := func() *View {
118		return &View{
119			Theme:            Dark,
120			Messages:         messages,
121			MessagesRevision: 7,
122			toolPathRevision: 7,
123			toolCallNames:    map[string]string{callID: "plan"},
124			PlanUpdates: map[string]core.PlanUpdate{
125				callID: {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
126			},
127		}
128	}
129	assertChecklist := func(t *testing.T, view *View) {
130		t.Helper()
131		plain := stripANSI(strings.Join(view.Build(50), "\n"))
132		if !strings.Contains(plain, "Updated Plan") || strings.Contains(plain, "Plan updated") {
133			t.Fatalf("plan rendering = %q", plain)
134		}
135	}
136
137	original := seed()
138	assertChecklist(t, original.CloneForRender())
139
140	owner := &View{Theme: Dark, Messages: messages, MessagesRevision: 7}
141	owner.AdoptRenderCacheFrom(seed())
142	assertChecklist(t, owner)
143}
144
145// TestViewCloneForRenderCopiesPlanSnapshots pins the deep copy: the render
146// goroutine reads the clone while the live view keeps mutating its own map.
147func TestViewCloneForRenderCopiesPlanSnapshots(t *testing.T) {
148	callID := "plan-1"
149	view := &View{
150		Theme: Dark,
151		PlanUpdates: map[string]core.PlanUpdate{
152			callID: {Plan: []core.PlanStep{{Step: "one", Status: core.PlanPending}}},
153		},
154	}
155	clone := view.CloneForRender()
156
157	entry := view.PlanUpdates[callID]
158	entry.Plan[0].Step = "changed"
159	view.PlanUpdates[callID] = entry
160	view.PlanUpdates["plan-2"] = core.PlanUpdate{}
161
162	if got := clone.PlanUpdates[callID]; len(got.Plan) != 1 || got.Plan[0].Step != "one" {
163		t.Fatalf("clone shares a plan slice with the source: %#v", got)
164	}
165	if _, ok := clone.PlanUpdates["plan-2"]; ok {
166		t.Fatalf("clone observed a map insertion made after it was cloned")
167	}
168}
169
170// TestViewPlanSnapshotsNotSharedWithRenderGoroutine is the -race guard for the
171// render clone: the goroutine renders from its own copy of the map while the
172// live view writes new snapshots.
173func TestViewPlanSnapshotsNotSharedWithRenderGoroutine(t *testing.T) {
174	callID := "plan-1"
175	view := &View{
176		Theme:       Dark,
177		Messages:    planTestMessages(callID, "plan", `{"action":"set"}`),
178		PlanUpdates: map[string]core.PlanUpdate{callID: {Plan: []core.PlanStep{{Step: "one", Status: core.PlanPending}}}},
179	}
180	clone := view.CloneForRender()
181	done := make(chan struct{})
182	go func() {
183		defer close(done)
184		for i := 0; i < 25; i++ {
185			_ = clone.Build(60)
186		}
187	}()
188	for i := 0; i < 500; i++ {
189		view.PlanUpdates[callID] = core.PlanUpdate{
190			Plan: []core.PlanStep{{Step: fmt.Sprintf("step-%d", i), Status: core.PlanInProgress}},
191		}
192	}
193	<-done
194}
195
196// TestViewPlanWithoutSnapshotFallsBackToToolChrome covers a plan call that
197// produced no checklist: a failed call, or a read such as show.
198func TestViewPlanWithoutSnapshotFallsBackToToolChrome(t *testing.T) {
199	callID := "plan-1"
200	view := &View{Theme: Dark, Messages: planTestMessages(callID, "plan", `{"action":"show"}`)}
201	plain := stripANSI(strings.Join(view.Build(60), "\n"))
202	if strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "Plan updated") {
203		t.Fatalf("plan without a snapshot did not use generic fallback: %q", plain)
204	}
205}
206
207func TestViewRendersEmptyPlanSnapshot(t *testing.T) {
208	callID := "plan-1"
209	view := &View{
210		Theme:       Dark,
211		Messages:    planTestMessages(callID, "plan", `{"action":"clear"}`),
212		PlanUpdates: map[string]core.PlanUpdate{callID: {}},
213	}
214	plain := stripANSI(strings.Join(view.Build(40), "\n"))
215	if !strings.Contains(plain, "(no steps provided)") {
216		t.Fatalf("empty plan rendering = %q", plain)
217	}
218}
219
220// TestViewCachedPlanRowInvalidatesOnRevision guards GO-009: PlanRevision is part
221// of the render-cache key so a late or changed snapshot invalidates a message
222// row that already rendered a checklist. Without it the cached path serves the
223// old steps forever.
224func TestViewCachedPlanRowInvalidatesOnRevision(t *testing.T) {
225	callID := "plan-1"
226	view := &View{
227		Theme:            Dark,
228		MessagesRevision: 1,
229		Messages:         planTestMessages(callID, "plan", `{"action":"update"}`),
230		PlanUpdates: map[string]core.PlanUpdate{
231			callID: {Plan: []core.PlanStep{{Step: "old step", Status: core.PlanInProgress}}},
232		},
233	}
234
235	first := stripANSI(strings.Join(view.Build(60), "\n"))
236	if !strings.Contains(first, "old step") {
237		t.Fatalf("first render = %q", first)
238	}
239
240	view.PlanUpdates = map[string]core.PlanUpdate{
241		callID: {Plan: []core.PlanStep{{Step: "new step", Status: core.PlanInProgress}}},
242	}
243	view.PlanRevision++
244
245	second := stripANSI(strings.Join(view.Build(60), "\n"))
246	if strings.Contains(second, "old step") {
247		t.Fatalf("second render reused the cached checklist: %q", second)
248	}
249	if !strings.Contains(second, "new step") {
250		t.Fatalf("second render omitted the new checklist: %q", second)
251	}
252}

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 477

↑↓ moveenter openesc close