v0.11.1 ab30a57

btw_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"strings"
7	"sync"
8	"sync/atomic"
9	"testing"
10	"time"
11
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14	"github.com/bnema/zut/packages/tui"
15)
16
17type btwToolTestClient struct {
18	mu       sync.Mutex
19	requests []provider.Request
20	done     chan struct{}
21}
22
23func (c *btwToolTestClient) Name() string { return "btw-test" }
24
25func (c *btwToolTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
26	c.mu.Lock()
27	c.requests = append(c.requests, req)
28	call := len(c.requests)
29	c.mu.Unlock()
30
31	out := make(chan provider.Event, 4)
32	go func() {
33		defer close(out)
34		if call == 1 {
35			out <- provider.EventToolStart{ID: "tool-1", Name: "echo"}
36			out <- provider.EventToolArgs{ID: "tool-1", Delta: `{"text":"hello"}`}
37			out <- provider.EventToolEnd{ID: "tool-1"}
38			out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
39				Role: provider.RoleAssistant,
40				Content: []provider.Content{provider.ToolCallBlock{
41					ID: "tool-1", Name: "echo", Arguments: json.RawMessage(`{"text":"hello"}`),
42				}},
43			}}
44			return
45		}
46		out <- provider.EventTextDelta{Delta: "tool finished"}
47		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
48			Role: provider.RoleAssistant,
49			Content: []provider.Content{
50				provider.TextBlock{Text: "tool finished"},
51			},
52		}}
53		close(c.done)
54	}()
55	return out, nil
56}
57
58type btwEchoTool struct {
59	runs atomic.Int32
60}
61
62func (t *btwEchoTool) Name() string            { return "echo" }
63func (t *btwEchoTool) Description() string     { return "echo text" }
64func (t *btwEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
65func (t *btwEchoTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
66	t.runs.Add(1)
67	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "hello"}}}, nil
68}
69
70func TestBtwToolExpansionUsesSideChatView(t *testing.T) {
71	result := strings.Repeat("output line\n", tui.ToolCollapseLines+10)
72	d := &btwDialog{
73		active:   true,
74		toolView: &tui.View{Theme: tui.Dark},
75		turns: []btwTurn{{
76			User: "show output",
77			Tools: []tui.ToolCallView{{
78				ID: "tool-1", Name: "bash", Result: result, Done: true,
79			}},
80		}},
81		editor: tui.NewEditor(""),
82	}
83
84	if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
85		t.Fatalf("long /btw tool result was not initially collapsed:\n%s", rendered)
86	}
87	d.ToggleToolExpansion()
88	if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
89		t.Fatalf("long /btw tool result remained collapsed after expansion:\n%s", rendered)
90	}
91}
92
93func TestBtwCtrlOTogglesSideViewDuringConfirmation(t *testing.T) {
94	resp := make(chan core.ConfirmDecision, 1)
95	confirm := newConfirmDialog()
96	confirm.Enqueue(&confirmRequest{toolName: "bash", resp: resp})
97	btw := &btwDialog{active: true, toolView: &tui.View{}}
98	i := &Interactive{
99		view:          &tui.View{},
100		btwDialog:     btw,
101		confirmDialog: confirm,
102		dirty:         make(chan struct{}, 1),
103	}
104
105	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
106	if !btw.toolView.ExpandAll {
107		t.Fatal("ctrl+o did not expand /btw tool output during confirmation")
108	}
109	if i.view.ExpandAll {
110		t.Fatal("ctrl+o expanded the main view instead of the active /btw view")
111	}
112	if !confirm.Active() {
113		t.Fatal("ctrl+o closed the confirmation dialog")
114	}
115	select {
116	case decision := <-resp:
117		t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
118	default:
119	}
120}
121
122func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) {
123	client := &btwToolTestClient{done: make(chan struct{})}
124	tool := &btwEchoTool{}
125	main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
126	main.SetMessages([]provider.Message{{
127		Role:    provider.RoleUser,
128		Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},
129	}})
130	var policyCalls atomic.Int32
131	var policyOrigin string
132	main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
133		policyCalls.Add(1)
134		policyOrigin = call.Origin
135		return true, "", nil
136	}
137
138	d := newBtwDialog()
139	d.Open(tui.Theme{}, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
140
141	select {
142	case <-client.done:
143	case <-time.After(2 * time.Second):
144		t.Fatal("timed out waiting for /btw tool turn")
145	}
146	deadline := time.Now().Add(2 * time.Second)
147	for d.Loading() && time.Now().Before(deadline) {
148		time.Sleep(time.Millisecond)
149	}
150	if d.Loading() {
151		t.Fatal("/btw remained loading after the agent turn completed")
152	}
153
154	if got := tool.runs.Load(); got != 1 {
155		t.Fatalf("tool executions = %d, want 1", got)
156	}
157	if got := policyCalls.Load(); got != 1 {
158		t.Fatalf("main agent tool-policy calls = %d, want 1", got)
159	}
160	if policyOrigin != btwOrigin(1) {
161		t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1))
162	}
163
164	client.mu.Lock()
165	requests := append([]provider.Request(nil), client.requests...)
166	client.mu.Unlock()
167	if len(requests) != 2 {
168		t.Fatalf("provider requests = %d, want 2", len(requests))
169	}
170	if len(requests[0].Tools) != 1 || requests[0].Tools[0].Name != "echo" {
171		t.Fatalf("first /btw request tools = %#v, want echo", requests[0].Tools)
172	}
173	if got := len(main.Messages()); got != 1 {
174		t.Fatalf("main transcript messages = %d, want frozen length 1", got)
175	}
176
177	d.mu.Lock()
178	if len(d.turns) != 1 {
179		t.Fatalf("side-chat turns = %d, want 1", len(d.turns))
180	}
181	turn := d.turns[0]
182	if turn.Assistant != "tool finished" {
183		t.Fatalf("assistant = %q, want %q", turn.Assistant, "tool finished")
184	}
185	if len(turn.Tools) != 1 || !turn.Tools[0].Done || turn.Tools[0].Error {
186		t.Fatalf("rendered tool state = %#v, want one successful completed tool", turn.Tools)
187	}
188	d.mu.Unlock()
189
190	rows := d.Render(tui.Theme{}, 80)
191	rendered := strings.Join(rows, "\n")
192	if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") {
193		t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered)
194	}
195	toolRow := -1
196	for idx, row := range rows {
197		if strings.Contains(row, "┌─") {
198			toolRow = idx
199			break
200		}
201	}
202	if toolRow < 1 || rows[toolRow-1] != "" {
203		t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered)
204	}
205	if cursorRow, _ := d.CursorPos(80); cursorRow != len(rows)-3 {
206		t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
207	}
208}
209
210func TestBtwActivityRendersFactDrivenLabel(t *testing.T) {
211	d := &btwDialog{
212		active:   true,
213		loading:  true,
214		theme:    tui.Dark,
215		toolView: &tui.View{Theme: tui.Dark},
216		spin:     newSpinner(tui.Dark),
217		activity: newAgentActivity("anthropic", "claude-test"),
218	}
219	d.spin.Start()
220	d.activity.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
221
222	rendered := strings.Join(d.Render(tui.Dark, 80), "\n")
223	if !strings.Contains(rendered, "Sending request to anthropic") {
224		t.Fatalf("/btw activity = %q, want factual request label", rendered)
225	}
226	if strings.Contains(rendered, "reticulating splines") || strings.Contains(rendered, "thinking") {
227		t.Fatalf("/btw activity retained random spinner wording: %q", rendered)
228	}
229}
230
231func TestBtwDialogIgnoresCallbacksFromPreviousGeneration(t *testing.T) {
232	d := &btwDialog{
233		active:     true,
234		loading:    true,
235		generation: 2,
236		turns:      []btwTurn{{User: "new question"}},
237		activity:   newAgentActivity("provider", "model"),
238	}
239
240	d.handleAgentEvent(0, 1, core.EvTextDelta{Delta: "stale response"})
241	d.completeTurn(0, 1, "stale error")
242
243	d.mu.Lock()
244	defer d.mu.Unlock()
245	if d.turns[0].Assistant != "" || d.turns[0].Err != "" {
246		t.Fatalf("stale callback changed reopened turn: %#v", d.turns[0])
247	}
248	if !d.loading {
249		t.Fatal("stale completion cleared the reopened dialog loading state")
250	}
251}
252
253func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) {
254	client := &btwToolTestClient{done: make(chan struct{})}
255	tool := &btwEchoTool{}
256	main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
257	d := newBtwDialog()
258	i := &Interactive{
259		btwDialog:     d,
260		confirmDialog: newConfirmDialog(),
261		dirty:         make(chan struct{}, 1),
262	}
263	gate := core.NewConfirmGate(i)
264	mainResp := make(chan core.ConfirmDecision, 1)
265	i.confirmDialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp})
266	i.confirmDialog.Blur()
267	main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
268		return gate.CheckToolCall(core.ToolCallConfirmation{
269			ID:      call.ID,
270			Name:    call.Name,
271			Summary: "hello",
272			Origin:  call.Origin,
273		})
274	}
275
276	d.Open(tui.Dark, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
277	sideConfirmationFirst := func() bool {
278		i.confirmDialog.mu.Lock()
279		defer i.confirmDialog.mu.Unlock()
280		return len(i.confirmDialog.pending) > 0 && i.confirmDialog.pending[0].toolName == "echo"
281	}
282	deadline := time.Now().Add(2 * time.Second)
283	for !sideConfirmationFirst() && time.Now().Before(deadline) {
284		time.Sleep(time.Millisecond)
285	}
286	if !sideConfirmationFirst() {
287		t.Fatal("side-agent tool confirmation did not reach the front of the interactive confirmation queue")
288	}
289	if !i.confirmDialog.Focused() {
290		t.Fatal("side-agent confirmation did not focus the prioritized dialog")
291	}
292
293	d.mu.Lock()
294	if len(d.turns) != 1 || len(d.turns[0].Tools) != 1 {
295		d.mu.Unlock()
296		t.Fatalf("side-agent tool state = %#v, want one live tool", d.turns)
297	}
298	if got, want := d.turns[0].Tools[0].Args, "hello"; got != want {
299		d.mu.Unlock()
300		t.Fatalf("side-agent confirmation summary = %q, want %q", got, want)
301	}
302	if got, want := d.activity.label(), "Waiting for approval: echo"; got != want {
303		d.mu.Unlock()
304		t.Fatalf("side-agent activity = %q, want %q", got, want)
305	}
306	d.mu.Unlock()
307
308	i.confirmDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
309	select {
310	case <-client.done:
311	case <-time.After(2 * time.Second):
312		t.Fatal("side-agent did not continue after confirmation")
313	}
314	if got := tool.runs.Load(); got != 1 {
315		t.Fatalf("tool executions = %d, want 1 after confirmation", got)
316	}
317	if i.confirmDialog.Focused() {
318		t.Fatal("main confirmation regained focus instead of returning input to /btw")
319	}
320	i.confirmDialog.CancelAll("test complete")
321	select {
322	case <-mainResp:
323	default:
324		t.Fatal("main confirmation was not retained after resolving the side-chat call")
325	}
326}

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