v0.38.1 969e52a

view_plan_test.go RawHistoryFolder

1package tui
2
3import (
4	"encoding/json"
5	"strings"
6	"testing"
7
8	"github.com/bnema/zut/packages/provider"
9)
10
11func TestViewRendersUpdatePlanAsChecklist(t *testing.T) {
12	callID := "plan-1"
13	args := json.RawMessage(`{
14		"explanation":"Finished discovery",
15		"plan":[
16			{"step":"Map the implementation","status":"completed"},
17			{"step":"Add the built-in tool","status":"in_progress"},
18			{"step":"Verify the behavior","status":"pending"}
19		]
20	}`)
21	view := &View{Theme: Dark, Messages: []provider.Message{
22		{Role: provider.RoleAssistant, Content: []provider.Content{
23			provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: args},
24		}},
25		{Role: provider.RoleTool, Content: []provider.Content{
26			provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
27		}},
28	}}
29
30	plain := stripANSI(strings.Join(view.Build(60), "\n"))
31	for _, want := range []string{
32		"Updated Plan",
33		"Finished discovery",
34		"✓ Map the implementation",
35		"□ Add the built-in tool",
36		"□ Verify the behavior",
37	} {
38		if !strings.Contains(plain, want) {
39			t.Fatalf("rendered plan omitted %q:\n%s", want, plain)
40		}
41	}
42	if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") {
43		t.Fatalf("rendered plan leaked generic tool chrome:\n%s", plain)
44	}
45}
46
47func TestViewRendersLiveUpdatePlanAsChecklist(t *testing.T) {
48	args := json.RawMessage(`{"plan":[{"step":"Implement","status":"in_progress"}]}`)
49	view := &View{Theme: Dark}
50	plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{
51		ID:         "plan-1",
52		Name:       "update_plan",
53		RawJSONBuf: string(args),
54		Result:     "Plan updated",
55		Done:       true,
56	}, 50), "\n"))
57	if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") {
58		t.Fatalf("live plan rendering = %q", plain)
59	}
60	if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") {
61		t.Fatalf("live plan leaked generic tool chrome: %q", plain)
62	}
63}
64
65func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) {
66	callID := "plan-1"
67	args := json.RawMessage(`{"plan":[{"step":"Implement","status":"in_progress"}]}`)
68	messages := []provider.Message{
69		{Role: provider.RoleAssistant, Content: []provider.Content{
70			provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: args},
71		}},
72		{Role: provider.RoleTool, Content: []provider.Content{
73			provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
74		}},
75	}
76	seed := func() *View {
77		return &View{
78			Theme:            Dark,
79			Messages:         messages,
80			MessagesRevision: 7,
81			toolPathRevision: 7,
82			toolCallNames:    map[string]string{callID: "update_plan"},
83			toolCallArgs:     map[string]json.RawMessage{callID: args},
84			toolCallLabels:   map[string]string{callID: "update_plan"},
85			toolPaths:        map[string]string{},
86			toolStartLines:   map[string]int{},
87		}
88	}
89	assertChecklist := func(t *testing.T, view *View) {
90		t.Helper()
91		plain := stripANSI(strings.Join(view.Build(50), "\n"))
92		if !strings.Contains(plain, "Updated Plan") || strings.Contains(plain, "Plan updated") {
93			t.Fatalf("plan rendering = %q", plain)
94		}
95	}
96
97	original := seed()
98	assertChecklist(t, original.CloneForRender())
99
100	owner := &View{Theme: Dark, Messages: messages, MessagesRevision: 7}
101	owner.AdoptRenderCacheFrom(seed())
102	assertChecklist(t, owner)
103}
104
105func TestViewFallsBackForMalformedUpdatePlan(t *testing.T) {
106	cases := []string{
107		`{"plan":[{"status":"pending"}]}`,
108		`{"plan":[{"step":"work"}]}`,
109		`{"plan":[{"step":"work","status":"unknown"}]}`,
110		`{"plan":[{"step":"work","status":"pending","extra":true}]}`,
111		`{"plan":[],"extra":true}`,
112		`{"plan":[]} trailing`,
113	}
114	for _, raw := range cases {
115		t.Run(raw, func(t *testing.T) {
116			callID := "plan-1"
117			view := &View{Theme: Dark, Messages: []provider.Message{
118				{Role: provider.RoleAssistant, Content: []provider.Content{
119					provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: json.RawMessage(raw)},
120				}},
121				{Role: provider.RoleTool, Content: []provider.Content{
122					provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
123				}},
124			}}
125			plain := stripANSI(strings.Join(view.Build(50), "\n"))
126			if strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "Plan updated") {
127				t.Fatalf("malformed plan did not use generic fallback: %q", plain)
128			}
129		})
130	}
131}
132
133func TestViewRendersEmptyUpdatePlan(t *testing.T) {
134	callID := "plan-1"
135	view := &View{Theme: Dark, Messages: []provider.Message{
136		{Role: provider.RoleAssistant, Content: []provider.Content{
137			provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: json.RawMessage(`{"plan":[]}`)},
138		}},
139		{Role: provider.RoleTool, Content: []provider.Content{
140			provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
141		}},
142	}}
143
144	plain := stripANSI(strings.Join(view.Build(40), "\n"))
145	if !strings.Contains(plain, "(no steps provided)") {
146		t.Fatalf("empty plan rendering = %q", plain)
147	}
148}

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