v0.44.0 efd47b3

alert_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"errors"
6	"io"
7	"sync"
8	"testing"
9	"time"
10
11	"github.com/bnema/zut/packages/agent/extproto"
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14	"github.com/bnema/zut/packages/tui"
15)
16
17type alertTestTerminal struct {
18	mu   sync.Mutex
19	data []byte
20}
21
22func (t *alertTestTerminal) Write(p []byte) (int, error) {
23	t.mu.Lock()
24	defer t.mu.Unlock()
25	t.data = append(t.data, p...)
26	return len(p), nil
27}
28func (t *alertTestTerminal) WriteString(s string) (int, error) {
29	t.mu.Lock()
30	defer t.mu.Unlock()
31	t.data = append(t.data, s...)
32	return len(s), nil
33}
34func (t *alertTestTerminal) String() string {
35	t.mu.Lock()
36	defer t.mu.Unlock()
37	return string(t.data)
38}
39
40func (*alertTestTerminal) Size() (int, int) { return 80, 24 }
41func (*alertTestTerminal) OnResize(func())  {}
42func (*alertTestTerminal) EnterRaw() (func() error, error) {
43	return func() error { return nil }, nil
44}
45func (*alertTestTerminal) ReadByte() (byte, error) { return 0, io.EOF }
46func (*alertTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
47	return 0, false, nil
48}
49func (*alertTestTerminal) SetNonblock(bool) error { return nil }
50
51func TestInteractiveAlertUsesSharedTerminalPolicy(t *testing.T) {
52	term := &alertTestTerminal{}
53	i := &Interactive{cfg: InteractiveConfig{Terminal: term}}
54
55	i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
56	if got := term.String(); got != "\a" {
57		t.Fatalf("alert output = %q, want standalone BEL", got)
58	}
59}
60
61func TestInteractiveAlertRespectsDisabledPolicy(t *testing.T) {
62	term := &alertTestTerminal{}
63	disabled := false
64	i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &disabled}}
65
66	i.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
67	if got := term.String(); got != "" {
68		t.Fatalf("disabled alert output = %q, want empty", got)
69	}
70}
71
72type alertBlockingClient struct{}
73
74func (alertBlockingClient) Name() string { return "alert-blocking" }
75func (alertBlockingClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
76	out := make(chan provider.Event, 1)
77	go func() {
78		defer close(out)
79		<-ctx.Done()
80		out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
81	}()
82	return out, nil
83}
84
85func TestCancelledMainTurnDoesNotBell(t *testing.T) {
86	term := &alertTestTerminal{}
87	ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
88	i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
89	i.startTurn(context.Background(), "question")
90
91	deadline := time.Now().Add(time.Second)
92	started := false
93	for time.Now().Before(deadline) {
94		i.mu.Lock()
95		busy := i.busy
96		i.mu.Unlock()
97		if busy {
98			started = true
99			break
100		}
101		time.Sleep(time.Millisecond)
102	}
103	if !started {
104		t.Fatal("turn did not enter busy state")
105	}
106	i.CancelTurn()
107
108	deadline = time.Now().Add(time.Second)
109	stopped := false
110	for time.Now().Before(deadline) {
111		i.mu.Lock()
112		busy := i.busy
113		i.mu.Unlock()
114		if !busy {
115			stopped = true
116			break
117		}
118		time.Sleep(time.Millisecond)
119	}
120	if !stopped {
121		t.Fatal("turn did not finish after cancellation")
122	}
123	if got := term.String(); got != "" {
124		t.Fatalf("cancelled main turn alert output = %q, want empty", got)
125	}
126}
127
128func TestMainTurnEmitsBellAfterVisibleResponse(t *testing.T) {
129	term := &alertTestTerminal{}
130	client := &streamTestClient{events: []provider.Event{
131		provider.EventTextDelta{Delta: "answer"},
132		provider.EventDone{
133			Stop: provider.StopEnd,
134			Message: provider.Message{
135				Role:    provider.RoleAssistant,
136				Content: []provider.Content{provider.TextBlock{Text: "answer"}},
137			},
138		},
139	}}
140	ag := core.NewAgent(client, "test-model", "", nil)
141	i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term})
142	i.rend = tui.NewRenderer(io.Discard)
143	ctx, cancel := context.WithCancel(context.Background())
144	defer cancel()
145	i.runCtx = ctx
146	go i.runStreamPacer(ctx)
147	i.startTurn(ctx, "question")
148
149	deadline := time.Now().Add(time.Second)
150	for time.Now().Before(deadline) {
151		i.mu.Lock()
152		ready := i.pendingAlert != nil && !i.stream.Active()
153		i.mu.Unlock()
154		if ready {
155			i.redraw()
156			break
157		}
158		time.Sleep(time.Millisecond)
159	}
160	if got := term.String(); got != "\a" {
161		t.Fatalf("main turn alert output = %q, want BEL after response redraw", got)
162	}
163}
164
165func TestMainAlertWaitsForPacedText(t *testing.T) {
166	term := &alertTestTerminal{}
167	i := NewInteractive(InteractiveConfig{Terminal: term})
168	i.rend = tui.NewRenderer(io.Discard)
169	i.stream.Start(-1, 0)
170	i.stream.Push("final answer")
171	i.stream.Finish()
172	i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
173	ctx, cancel := context.WithCancel(context.Background())
174	defer cancel()
175	go i.runStreamPacer(ctx)
176
177	deadline := time.Now().Add(time.Second)
178	for time.Now().Before(deadline) {
179		i.mu.Lock()
180		ready := !i.stream.Active() && i.pendingAlert != nil
181		i.mu.Unlock()
182		if ready {
183			i.redraw()
184			break
185		}
186		time.Sleep(time.Millisecond)
187	}
188	if got := term.String(); got != "\a" {
189		t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
190	}
191	i.mu.Lock()
192	on, alert := i.stream.Active(), i.pendingAlert
193	i.mu.Unlock()
194	if on || alert != nil {
195		t.Fatalf("stream state after alert: on=%v alert=%v", on, alert)
196	}
197}
198
199func TestMainAlertReasonPolicy(t *testing.T) {
200	cancelled, cancel := context.WithCancel(context.Background())
201	cancel()
202
203	tests := []struct {
204		name       string
205		ctx        context.Context
206		err        error
207		turnErr    error
208		stop       provider.StopReason
209		awaiting   bool
210		queued     bool
211		rescue     bool
212		recovering bool
213		auto       bool
214		want       string
215	}{
216		{name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"},
217		{name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"},
218		{name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"},
219		{name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"},
220		{name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"},
221		{name: "cancelled", ctx: cancelled, stop: provider.StopAborted},
222		{name: "aborted", ctx: context.Background(), stop: provider.StopAborted},
223		{name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true},
224		{name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true},
225		{name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true},
226		{name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true},
227	}
228	for _, tt := range tests {
229		t.Run(tt.name, func(t *testing.T) {
230			got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto)
231			if got != tt.want {
232				t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want)
233			}
234		})
235	}
236}
237
238func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) {
239	i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}})
240	i.openSettingsDialog()
241	var item *settingsItem
242	for idx := range i.settingsDialog.items {
243		if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" {
244			item = &i.settingsDialog.items[idx]
245			break
246		}
247	}
248	if item == nil || !item.value {
249		t.Fatal("terminal alerts setting should be present and enabled by default")
250	}
251
252	i.applySettingToggle("terminal_alerts_enabled", false)
253	if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled {
254		t.Fatal("terminal alerts setting did not disable")
255	}
256	i.applySettingToggle("terminal_alerts_enabled", true)
257	if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled {
258		t.Fatal("terminal alerts setting did not re-enable")
259	}
260}

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