v0.27.0 01c3d31

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.streamFlushPending && len(i.streamPending) == 0
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.streamPending = []rune("final answer")
170	i.streamFlushPending = true
171	i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "agent_done"}
172	ctx, cancel := context.WithCancel(context.Background())
173	defer cancel()
174	go i.runStreamPacer(ctx)
175
176	deadline := time.Now().Add(time.Second)
177	for time.Now().Before(deadline) {
178		i.mu.Lock()
179		ready := !i.streamFlushPending && len(i.streamPending) == 0 && i.pendingAlert != nil
180		i.mu.Unlock()
181		if ready {
182			i.redraw()
183			break
184		}
185		time.Sleep(time.Millisecond)
186	}
187	if got := term.String(); got != "\a" {
188		t.Fatalf("paced alert output = %q, want BEL after final redraw", got)
189	}
190	i.mu.Lock()
191	on, pending, alert := i.streamOn, len(i.streamPending), i.pendingAlert
192	i.mu.Unlock()
193	if on || pending != 0 || alert != nil {
194		t.Fatalf("stream state after alert: on=%v pending=%d alert=%v", on, pending, alert)
195	}
196}
197
198func TestMainAlertReasonPolicy(t *testing.T) {
199	cancelled, cancel := context.WithCancel(context.Background())
200	cancel()
201
202	tests := []struct {
203		name       string
204		ctx        context.Context
205		err        error
206		turnErr    error
207		stop       provider.StopReason
208		awaiting   bool
209		queued     bool
210		rescue     bool
211		recovering bool
212		auto       bool
213		want       string
214	}{
215		{name: "normal", ctx: context.Background(), stop: provider.StopEnd, want: "agent_done"},
216		{name: "length", ctx: context.Background(), stop: provider.StopLength, want: "response_truncated"},
217		{name: "returned error", ctx: context.Background(), err: errors.New("boom"), stop: provider.StopError, want: "agent_error"},
218		{name: "guard error", ctx: context.Background(), stop: provider.StopError, want: "agent_error"},
219		{name: "rescue", ctx: context.Background(), err: errors.New("temporary"), stop: provider.StopError, rescue: true, want: "rescue_required"},
220		{name: "cancelled", ctx: cancelled, stop: provider.StopAborted},
221		{name: "aborted", ctx: context.Background(), stop: provider.StopAborted},
222		{name: "queued", ctx: context.Background(), stop: provider.StopEnd, queued: true},
223		{name: "recovering", ctx: context.Background(), stop: provider.StopError, recovering: true},
224		{name: "auto compacting", ctx: context.Background(), stop: provider.StopEnd, auto: true},
225		{name: "startup pre", ctx: context.Background(), stop: provider.StopEnd, awaiting: true},
226	}
227	for _, tt := range tests {
228		t.Run(tt.name, func(t *testing.T) {
229			got := mainAlertReason(tt.ctx, tt.err, tt.turnErr, tt.stop, tt.awaiting, tt.queued, tt.rescue, tt.recovering, tt.auto)
230			if got != tt.want {
231				t.Fatalf("mainAlertReason() = %q, want %q", got, tt.want)
232			}
233		})
234	}
235}
236
237func TestTerminalAlertsSettingDefaultsEnabledAndToggles(t *testing.T) {
238	i := NewInteractive(InteractiveConfig{Terminal: &alertTestTerminal{}})
239	i.openSettingsDialog()
240	var item *settingsItem
241	for idx := range i.settingsDialog.items {
242		if i.settingsDialog.items[idx].key == "terminal_alerts_enabled" {
243			item = &i.settingsDialog.items[idx]
244			break
245		}
246	}
247	if item == nil || !item.value {
248		t.Fatal("terminal alerts setting should be present and enabled by default")
249	}
250
251	i.applySettingToggle("terminal_alerts_enabled", false)
252	if i.cfg.TerminalAlertsEnabled == nil || *i.cfg.TerminalAlertsEnabled {
253		t.Fatal("terminal alerts setting did not disable")
254	}
255	i.applySettingToggle("terminal_alerts_enabled", true)
256	if i.cfg.TerminalAlertsEnabled == nil || !*i.cfg.TerminalAlertsEnabled {
257		t.Fatal("terminal alerts setting did not re-enable")
258	}
259}

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