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}