v0.50.1 a637010

interactive_queue_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"io"
6	"strings"
7	"testing"
8
9	"github.com/bnema/zut/packages/core"
10	"github.com/bnema/zut/packages/provider"
11	"github.com/bnema/zut/packages/tui"
12)
13
14func TestQueuedMessageSummaryKeepsImageIndicatorWhenTextIsTruncated(t *testing.T) {
15	message := core.QueuedMessage{
16		Text:   strings.Repeat("x", 100),
17		Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}},
18	}
19
20	summary := queuedMessageSummary(message, 24)
21
22	if !strings.HasSuffix(summary, " [image]") {
23		t.Fatalf("summary = %q, want visible image indicator", summary)
24	}
25	if len([]rune(summary)) > 24 {
26		t.Fatalf("summary width = %d, want at most 24", len([]rune(summary)))
27	}
28}
29
30func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) {
31	agent := core.NewAgent(nil, "test-model", "", nil)
32	i := NewInteractive(InteractiveConfig{Agent: agent})
33	i.mu.Lock()
34	i.busy = true
35	i.mu.Unlock()
36	i.ed.SetValue("inspect [clipboard image #1]")
37	i.clipboardImages = []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
38
39	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
40
41	queued := agent.PendingQueuedMessages()
42	if len(queued) != 1 || queued[0].Text != "inspect" {
43		t.Fatalf("queued messages = %#v, want one inspect prompt", queued)
44	}
45	if len(queued[0].Images) != 1 || string(queued[0].Images[0].Data) != "png-1" {
46		t.Fatalf("queued images = %#v, want png-1", queued[0].Images)
47	}
48	if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 {
49		t.Fatal("submitted image prompt remained in the editor")
50	}
51}
52
53func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) {
54	agent := core.NewAgent(nil, "test-model", "", nil)
55	image := testClipboardImage("unused", "png-1").Image
56	agent.QueueMessage("inspect", []provider.ImageBlock{image})
57	i := NewInteractive(InteractiveConfig{Agent: agent})
58	i.mu.Lock()
59	i.busy = true
60	i.mu.Unlock()
61
62	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
63
64	if got := i.ed.Value(); got != "inspect [clipboard image #1]" {
65		t.Fatalf("editor = %q, want restored image marker", got)
66	}
67	if len(i.clipboardImages) != 1 || string(i.clipboardImages[0].Image.Data) != "png-1" {
68		t.Fatalf("clipboard images = %#v, want png-1", i.clipboardImages)
69	}
70}
71
72func TestSlideBackSkipsHostEvents(t *testing.T) {
73	for _, hostQueue := range []bool{false, true} {
74		name := "agent queue"
75		if hostQueue {
76			name = "host queue"
77		}
78		t.Run(name, func(t *testing.T) {
79			agent := core.NewAgent(nil, "test-model", "", nil)
80			i := NewInteractive(InteractiveConfig{Agent: agent})
81			i.busy = true
82			messages := []core.QueuedMessage{
83				{Text: "first report", HostEvent: true},
84				{Text: "older draft"},
85				{Text: "recover this draft"},
86				{Text: "latest report", HostEvent: true},
87			}
88			if hostQueue {
89				i.queued = messages
90			} else {
91				for _, message := range messages {
92					agent.QueuePrompt(message)
93				}
94			}
95			for _, want := range []string{"recover this draft", "older draft"} {
96				i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
97				if got := i.ed.Value(); got != want {
98					t.Fatalf("editor = %q, want %q", got, want)
99				}
100			}
101			i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
102			if got := i.ed.Value(); got != "older draft" {
103				t.Fatalf("host-only queue changed editor to %q", got)
104			}
105			pending := i.queued
106			if !hostQueue {
107				pending = agent.PendingQueuedMessages()
108			}
109			if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent {
110				t.Fatalf("remaining reports = %#v, want both reports in original order", pending)
111			}
112		})
113	}
114}
115
116func TestSlideBackParsesModifiedArrow(t *testing.T) {
117	for name, sequence := range map[string]string{
118		"alt up":       "\x1b[1;3A",
119		"alt shift up": "\x1b[1;4A",
120	} {
121		t.Run(name, func(t *testing.T) {
122			agent := core.NewAgent(nil, "test-model", "", nil)
123			i := NewInteractive(InteractiveConfig{Agent: agent})
124			i.busy = true
125			i.ed.SetValue("recover this draft")
126			i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
127			agent.QueuePrompt(core.QueuedMessage{Text: "worker report", HostEvent: true})
128			reader := tui.NewReader(func() (byte, error) {
129				if sequence == "" {
130					return 0, io.EOF
131				}
132				b := sequence[0]
133				sequence = sequence[1:]
134				return b, nil
135			})
136			key, err := reader.Read()
137			if err != nil {
138				t.Fatal(err)
139			}
140			i.handleKey(context.Background(), key)
141			if got := i.ed.Value(); got != "recover this draft" {
142				t.Fatalf("editor = %q, want user draft", got)
143			}
144			pending := agent.PendingQueuedMessages()
145			if len(pending) != 1 || !pending[0].HostEvent {
146				t.Fatalf("pending = %#v, want worker report", pending)
147			}
148		})
149	}
150}
151
152func TestEscapeSkipsHostEvents(t *testing.T) {
153	for _, hasUserMessage := range []bool{false, true} {
154		for _, hostQueue := range []bool{false, true} {
155			agent := core.NewAgent(nil, "test-model", "", nil)
156			i := NewInteractive(InteractiveConfig{Agent: agent})
157			i.busy = true
158			i.cancelTurn = func() {}
159			i.ed.SetValue("existing draft")
160			messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}}
161			want := "existing draft"
162			if hasUserMessage {
163				messages = append([]core.QueuedMessage{{Text: "recover this draft"}}, messages...)
164				want = "recover this draft"
165			}
166			if hostQueue {
167				i.queued = messages
168			} else {
169				for _, message := range messages {
170					agent.QueuePrompt(message)
171				}
172			}
173			i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
174			if got := i.ed.Value(); got != want {
175				t.Fatalf("user=%v hostQueue=%v: editor = %q, want %q", hasUserMessage, hostQueue, got, want)
176			}
177		}
178	}
179}
180
181func TestSlidingQueueHintOnlyForUserMessages(t *testing.T) {
182	t.Setenv("TERM_PROGRAM", "vev")
183	for _, hostQueue := range []bool{false, true} {
184		for _, hasUserMessage := range []bool{false, true} {
185			agent := core.NewAgent(nil, "test-model", "", nil)
186			term := &alertTestTerminal{}
187			i := NewInteractive(InteractiveConfig{Agent: agent, Terminal: term, Theme: tui.Dark})
188			i.rend.Resize(80, 24)
189			messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}}
190			if hasUserMessage {
191				messages = append(messages, core.QueuedMessage{Text: "user draft"})
192			}
193			if hostQueue {
194				i.queued = messages
195			} else {
196				for _, message := range messages {
197					agent.QueuePrompt(message)
198				}
199			}
200			i.redraw()
201			output := stripANSIBytes(term.String())
202			if !strings.Contains(output, "worker report") {
203				t.Fatal("host report missing from sliding queue")
204			}
205			if got := strings.Contains(output, "Press Alt+Shift+↑"); got != hasUserMessage {
206				t.Fatalf("user=%v hostQueue=%v: recall hint visible=%v", hasUserMessage, hostQueue, got)
207			}
208		}
209	}
210}
211
212func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
213	agent := core.NewAgent(nil, "test-model", "", nil)
214	agent.QueueMessage("older follow-up", nil)
215	agent.QueueMessage("recover this draft", nil)
216
217	i := NewInteractive(InteractiveConfig{Agent: agent})
218	i.ed.SetValue("existing draft")
219	cancelled := 0
220	i.mu.Lock()
221	i.busy = true
222	i.cancelTurn = func() { cancelled++ }
223	i.mu.Unlock()
224
225	if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
226		t.Fatal("Escape exited")
227	}
228	if cancelled != 1 {
229		t.Fatalf("cancelled = %d, want 1", cancelled)
230	}
231	if got, want := i.ed.Value(), "recover this draft"; got != want {
232		t.Errorf("editor = %q, want %q", got, want)
233	}
234	if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0].Text != "older follow-up" {
235		t.Errorf("remaining queued messages = %v, want [older follow-up]", got)
236	}
237}
238
239func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) {
240	i := NewInteractive(InteractiveConfig{})
241	cancelled := 0
242	i.mu.Lock()
243	i.busy = true
244	i.cancelTurn = func() { cancelled++ }
245	i.queued = []core.QueuedMessage{{Text: "recover this draft"}}
246	i.mu.Unlock()
247
248	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
249
250	if cancelled != 1 {
251		t.Fatalf("cancelled = %d, want 1", cancelled)
252	}
253	if got, want := i.ed.Value(), "recover this draft"; got != want {
254		t.Errorf("editor = %q, want %q", got, want)
255	}
256}

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