detached f4d798e

Merge pull request #144 from bnema/feat/queue-image-prompts

feat(modes): queue image prompts while busy
commit
f4d798e8e5f2ff782032316735759272ae508cf3
author
bnema · · 2026-08-18 15:43 UTC
committer
GitHub · · 2026-08-18 15:43 UTC
merge of
6fc319c + 125f053 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +339 -103
M README.md +1 -1
@@ -902,7 +902,7 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run
902902
903903 ## Queued messages
904904
905-You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn, returns the most recently queued message to the input for editing, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zut.
905+You can keep typing while the agent is working. Pressing `enter` during a turn queues the message, including pasted screenshots, instead of interrupting. It appears above the status bar as `sliding in: <text> [image]` and is delivered at the next safe model-call boundary. Queue as many messages as you want; they run in order. `alt+up` returns the most recently queued message and its image markers to the input for editing. `esc` cancels the active turn, restores the most recent queued message in the same way, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, and a second `ctrl+c` within two seconds exits zut.
906906
907907 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zut's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
908908
@@ -902,7 +902,7 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run
902 902
903 ## Queued messages903 ## Queued messages
904 904
905-You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn, returns the most recently queued message to the input for editing, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zut.905+You can keep typing while the agent is working. Pressing `enter` during a turn queues the message, including pasted screenshots, instead of interrupting. It appears above the status bar as `sliding in: <text> [image]` and is delivered at the next safe model-call boundary. Queue as many messages as you want; they run in order. `alt+up` returns the most recently queued message and its image markers to the input for editing. `esc` cancels the active turn, restores the most recent queued message in the same way, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, and a second `ctrl+c` within two seconds exits zut.
906 906
907 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zut's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.907 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zut's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
908 908
M packages/agent/internal/orchestration/coordinator.go +32 -10
@@ -4,7 +4,10 @@
44 // events and execute the returned actions.
55 package orchestration
66
7-import "github.com/bnema/zut/packages/agent/subagents"
7+import (
8+ "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/provider"
10+)
811
912 // Coordinator collects one sealed worker wave per manager turn. A terminal
1013 // worker cannot wake the manager until the owning turn is sealed. Queued user
@@ -17,7 +20,12 @@ type Coordinator struct {
1720 goalActive bool
1821 workers map[string]*worker
1922 workerOrder []string
20- queuedUser []string
23+ queuedUser []queuedUserInput
24+}
25+
26+type queuedUserInput struct {
27+ text string
28+ images []provider.ImageBlock
2129 }
2230
2331 type worker struct {
@@ -43,6 +51,7 @@ type Event struct {
4351 WorkerID string
4452 Completion subagents.Completion
4553 Text string
54+ Images []provider.ImageBlock
4655 GoalActive bool
4756 }
4857
@@ -66,6 +75,7 @@ type Action struct {
6675 Kind ActionKind
6776 Reason WakeReason
6877 Text string
78+ Images []provider.ImageBlock
6979 Completions []subagents.Completion
7080 }
7181
@@ -124,10 +134,10 @@ func (c *Coordinator) Apply(event Event) Result {
124134 return c.wakeIfIdle()
125135 }
126136 case EventUserInput:
127- if event.Text == "" {
137+ if event.Text == "" && len(event.Images) == 0 {
128138 return Result{}
129139 }
130- c.queuedUser = append(c.queuedUser, event.Text)
140+ c.queuedUser = append(c.queuedUser, queuedUserInput{text: event.Text, images: cloneImages(event.Images)})
131141 return c.wakeIfIdle()
132142 case EventGoalChanged:
133143 c.goalActive = event.GoalActive
@@ -145,22 +155,34 @@ func (c *Coordinator) wakeIfIdle() Result {
145155 }
146156 completions := c.completedWorkers()
147157 if len(c.queuedUser) != 0 {
148- text := c.queuedUser[0]
158+ input := c.queuedUser[0]
149159 c.queuedUser = c.queuedUser[1:]
150- return c.startManager(WakeUser, text, completions)
160+ return c.startManager(WakeUser, input.text, input.images, completions)
151161 }
152162 if len(completions) != 0 {
153- return c.startManager(WakeWorkers, "", completions)
163+ return c.startManager(WakeWorkers, "", nil, completions)
154164 }
155165 if c.goalActive {
156- return c.startManager(WakeGoal, "", nil)
166+ return c.startManager(WakeGoal, "", nil, nil)
157167 }
158168 return Result{}
159169 }
160170
161-func (c *Coordinator) startManager(reason WakeReason, text string, completions []subagents.Completion) Result {
171+func (c *Coordinator) startManager(reason WakeReason, text string, images []provider.ImageBlock, completions []subagents.Completion) Result {
162172 c.managerActive = true
163- return Result{Actions: []Action{{Kind: ActionRunManager, Reason: reason, Text: text, Completions: completions}}}
173+ return Result{Actions: []Action{{Kind: ActionRunManager, Reason: reason, Text: text, Images: images, Completions: completions}}}
174+}
175+
176+func cloneImages(images []provider.ImageBlock) []provider.ImageBlock {
177+ if len(images) == 0 {
178+ return nil
179+ }
180+ cloned := make([]provider.ImageBlock, len(images))
181+ for index, image := range images {
182+ cloned[index] = image
183+ cloned[index].Data = append([]byte(nil), image.Data...)
184+ }
185+ return cloned
164186 }
165187
166188 func (c *Coordinator) hasPendingWorkers() bool {
@@ -4,7 +4,10 @@
4 // events and execute the returned actions.4 // events and execute the returned actions.
5 package orchestration5 package orchestration
6 6
7-import "github.com/bnema/zut/packages/agent/subagents"7+import (
8+ "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/provider"
10+)
8 11
9 // Coordinator collects one sealed worker wave per manager turn. A terminal12 // Coordinator collects one sealed worker wave per manager turn. A terminal
10 // worker cannot wake the manager until the owning turn is sealed. Queued user13 // worker cannot wake the manager until the owning turn is sealed. Queued user
@@ -17,7 +20,12 @@ type Coordinator struct {
17 goalActive bool20 goalActive bool
18 workers map[string]*worker21 workers map[string]*worker
19 workerOrder []string22 workerOrder []string
20- queuedUser []string23+ queuedUser []queuedUserInput
24+}
25+
26+type queuedUserInput struct {
27+ text string
28+ images []provider.ImageBlock
21 }29 }
22 30
23 type worker struct {31 type worker struct {
@@ -43,6 +51,7 @@ type Event struct {
43 WorkerID string51 WorkerID string
44 Completion subagents.Completion52 Completion subagents.Completion
45 Text string53 Text string
54+ Images []provider.ImageBlock
46 GoalActive bool55 GoalActive bool
47 }56 }
48 57
@@ -66,6 +75,7 @@ type Action struct {
66 Kind ActionKind75 Kind ActionKind
67 Reason WakeReason76 Reason WakeReason
68 Text string77 Text string
78+ Images []provider.ImageBlock
69 Completions []subagents.Completion79 Completions []subagents.Completion
70 }80 }
71 81
@@ -124,10 +134,10 @@ func (c *Coordinator) Apply(event Event) Result {
124 return c.wakeIfIdle()134 return c.wakeIfIdle()
125 }135 }
126 case EventUserInput:136 case EventUserInput:
127- if event.Text == "" {137+ if event.Text == "" && len(event.Images) == 0 {
128 return Result{}138 return Result{}
129 }139 }
130- c.queuedUser = append(c.queuedUser, event.Text)140+ c.queuedUser = append(c.queuedUser, queuedUserInput{text: event.Text, images: cloneImages(event.Images)})
131 return c.wakeIfIdle()141 return c.wakeIfIdle()
132 case EventGoalChanged:142 case EventGoalChanged:
133 c.goalActive = event.GoalActive143 c.goalActive = event.GoalActive
@@ -145,22 +155,34 @@ func (c *Coordinator) wakeIfIdle() Result {
145 }155 }
146 completions := c.completedWorkers()156 completions := c.completedWorkers()
147 if len(c.queuedUser) != 0 {157 if len(c.queuedUser) != 0 {
148- text := c.queuedUser[0]158+ input := c.queuedUser[0]
149 c.queuedUser = c.queuedUser[1:]159 c.queuedUser = c.queuedUser[1:]
150- return c.startManager(WakeUser, text, completions)160+ return c.startManager(WakeUser, input.text, input.images, completions)
151 }161 }
152 if len(completions) != 0 {162 if len(completions) != 0 {
153- return c.startManager(WakeWorkers, "", completions)163+ return c.startManager(WakeWorkers, "", nil, completions)
154 }164 }
155 if c.goalActive {165 if c.goalActive {
156- return c.startManager(WakeGoal, "", nil)166+ return c.startManager(WakeGoal, "", nil, nil)
157 }167 }
158 return Result{}168 return Result{}
159 }169 }
160 170
161-func (c *Coordinator) startManager(reason WakeReason, text string, completions []subagents.Completion) Result {171+func (c *Coordinator) startManager(reason WakeReason, text string, images []provider.ImageBlock, completions []subagents.Completion) Result {
162 c.managerActive = true172 c.managerActive = true
163- return Result{Actions: []Action{{Kind: ActionRunManager, Reason: reason, Text: text, Completions: completions}}}173+ return Result{Actions: []Action{{Kind: ActionRunManager, Reason: reason, Text: text, Images: images, Completions: completions}}}
174+}
175+
176+func cloneImages(images []provider.ImageBlock) []provider.ImageBlock {
177+ if len(images) == 0 {
178+ return nil
179+ }
180+ cloned := make([]provider.ImageBlock, len(images))
181+ for index, image := range images {
182+ cloned[index] = image
183+ cloned[index].Data = append([]byte(nil), image.Data...)
184+ }
185+ return cloned
164 }186 }
165 187
166 func (c *Coordinator) hasPendingWorkers() bool {188 func (c *Coordinator) hasPendingWorkers() bool {
M packages/agent/internal/orchestration/coordinator_test.go +12 -2
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/zut/packages/agent/subagents"
7+ "github.com/bnema/zut/packages/provider"
78 )
89
910 func TestCoordinatorSealsWorkerWaveAndWakesOnce(t *testing.T) {
@@ -24,9 +25,10 @@ func TestCoordinatorGivesQueuedUserInputPriority(t *testing.T) {
2425 c.Apply(Event{Kind: EventManagerStarted})
2526 c.Apply(Event{Kind: EventWorkerRegistered, WorkerID: "worker"})
2627 c.Apply(Event{Kind: EventManagerFinished})
27- c.Apply(Event{Kind: EventUserInput, Text: "stop and explain"})
28+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")}
29+ c.Apply(Event{Kind: EventUserInput, Text: "stop and explain", Images: []provider.ImageBlock{image}})
2830 assertActions(t, c.Apply(Event{Kind: EventWorkerFinished, WorkerID: "worker", Completion: subagents.Completion{AgentID: "worker", Status: "failed"}}), []Action{{
29- Kind: ActionRunManager, Reason: WakeUser, Text: "stop and explain",
31+ Kind: ActionRunManager, Reason: WakeUser, Text: "stop and explain", Images: []provider.ImageBlock{image},
3032 Completions: []subagents.Completion{{AgentID: "worker", Status: "failed"}},
3133 }})
3234 }
@@ -55,6 +57,14 @@ func assertActions(t *testing.T, got Result, want []Action) {
5557 if got.Actions[i].Kind != want[i].Kind || got.Actions[i].Reason != want[i].Reason || got.Actions[i].Text != want[i].Text {
5658 t.Fatalf("action %d = %#v, want %#v", i, got.Actions[i], want[i])
5759 }
60+ if len(got.Actions[i].Images) != len(want[i].Images) {
61+ t.Fatalf("action %d images = %#v, want %#v", i, got.Actions[i].Images, want[i].Images)
62+ }
63+ for j := range want[i].Images {
64+ if got.Actions[i].Images[j].MimeType != want[i].Images[j].MimeType || string(got.Actions[i].Images[j].Data) != string(want[i].Images[j].Data) {
65+ t.Fatalf("action %d image %d = %#v, want %#v", i, j, got.Actions[i].Images[j], want[i].Images[j])
66+ }
67+ }
5868 if len(got.Actions[i].Completions) != len(want[i].Completions) {
5969 t.Fatalf("action %d completions = %#v, want %#v", i, got.Actions[i].Completions, want[i].Completions)
6070 }
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/zut/packages/agent/subagents"6 "github.com/bnema/zut/packages/agent/subagents"
7+ "github.com/bnema/zut/packages/provider"
7 )8 )
8 9
9 func TestCoordinatorSealsWorkerWaveAndWakesOnce(t *testing.T) {10 func TestCoordinatorSealsWorkerWaveAndWakesOnce(t *testing.T) {
@@ -24,9 +25,10 @@ func TestCoordinatorGivesQueuedUserInputPriority(t *testing.T) {
24 c.Apply(Event{Kind: EventManagerStarted})25 c.Apply(Event{Kind: EventManagerStarted})
25 c.Apply(Event{Kind: EventWorkerRegistered, WorkerID: "worker"})26 c.Apply(Event{Kind: EventWorkerRegistered, WorkerID: "worker"})
26 c.Apply(Event{Kind: EventManagerFinished})27 c.Apply(Event{Kind: EventManagerFinished})
27- c.Apply(Event{Kind: EventUserInput, Text: "stop and explain"})28+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")}
29+ c.Apply(Event{Kind: EventUserInput, Text: "stop and explain", Images: []provider.ImageBlock{image}})
28 assertActions(t, c.Apply(Event{Kind: EventWorkerFinished, WorkerID: "worker", Completion: subagents.Completion{AgentID: "worker", Status: "failed"}}), []Action{{30 assertActions(t, c.Apply(Event{Kind: EventWorkerFinished, WorkerID: "worker", Completion: subagents.Completion{AgentID: "worker", Status: "failed"}}), []Action{{
29- Kind: ActionRunManager, Reason: WakeUser, Text: "stop and explain",31+ Kind: ActionRunManager, Reason: WakeUser, Text: "stop and explain", Images: []provider.ImageBlock{image},
30 Completions: []subagents.Completion{{AgentID: "worker", Status: "failed"}},32 Completions: []subagents.Completion{{AgentID: "worker", Status: "failed"}},
31 }})33 }})
32 }34 }
@@ -55,6 +57,14 @@ func assertActions(t *testing.T, got Result, want []Action) {
55 if got.Actions[i].Kind != want[i].Kind || got.Actions[i].Reason != want[i].Reason || got.Actions[i].Text != want[i].Text {57 if got.Actions[i].Kind != want[i].Kind || got.Actions[i].Reason != want[i].Reason || got.Actions[i].Text != want[i].Text {
56 t.Fatalf("action %d = %#v, want %#v", i, got.Actions[i], want[i])58 t.Fatalf("action %d = %#v, want %#v", i, got.Actions[i], want[i])
57 }59 }
60+ if len(got.Actions[i].Images) != len(want[i].Images) {
61+ t.Fatalf("action %d images = %#v, want %#v", i, got.Actions[i].Images, want[i].Images)
62+ }
63+ for j := range want[i].Images {
64+ if got.Actions[i].Images[j].MimeType != want[i].Images[j].MimeType || string(got.Actions[i].Images[j].Data) != string(want[i].Images[j].Data) {
65+ t.Fatalf("action %d image %d = %#v, want %#v", i, j, got.Actions[i].Images[j], want[i].Images[j])
66+ }
67+ }
58 if len(got.Actions[i].Completions) != len(want[i].Completions) {68 if len(got.Actions[i].Completions) != len(want[i].Completions) {
59 t.Fatalf("action %d completions = %#v, want %#v", i, got.Actions[i].Completions, want[i].Completions)69 t.Fatalf("action %d completions = %#v, want %#v", i, got.Actions[i].Completions, want[i].Completions)
60 }70 }
M packages/agent/modes/clipboard_images.go +27 -0
@@ -1,8 +1,10 @@
11 package modes
22
33 import (
4+ "fmt"
45 "strings"
56
7+ "github.com/bnema/zut/packages/core"
68 "github.com/bnema/zut/packages/provider"
79 )
810
@@ -28,6 +30,31 @@ func preparePromptWithClipboardImages(text string, pending []clipboardImageAttac
2830 return strings.TrimSpace(out), images
2931 }
3032
33+func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) bool {
34+ if message.Text == "" && len(message.Images) == 0 {
35+ return false
36+ }
37+ text := message.Text
38+ attachments := make([]clipboardImageAttachment, 0, len(message.Images))
39+ for index, image := range message.Images {
40+ markerIndex := index + 1
41+ marker := fmt.Sprintf("[clipboard image #%d]", markerIndex)
42+ for strings.Contains(text, marker) {
43+ markerIndex++
44+ marker = fmt.Sprintf("[clipboard image #%d]", markerIndex)
45+ }
46+ if text != "" {
47+ text += " "
48+ }
49+ text += marker
50+ attachments = append(attachments, clipboardImageAttachment{Marker: marker, Image: image})
51+ }
52+ i.clipboardImages = attachments
53+ i.ed.SetValue(text)
54+ i.inputHistoryIndex = -1
55+ return true
56+}
57+
3158 func removeClipboardMarker(text, marker string) string {
3259 for {
3360 idx := strings.Index(text, marker)
@@ -1,8 +1,10 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "fmt"
4 "strings"5 "strings"
5 6
7+ "github.com/bnema/zut/packages/core"
6 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
7 )9 )
8 10
@@ -28,6 +30,31 @@ func preparePromptWithClipboardImages(text string, pending []clipboardImageAttac
28 return strings.TrimSpace(out), images30 return strings.TrimSpace(out), images
29 }31 }
30 32
33+func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) bool {
34+ if message.Text == "" && len(message.Images) == 0 {
35+ return false
36+ }
37+ text := message.Text
38+ attachments := make([]clipboardImageAttachment, 0, len(message.Images))
39+ for index, image := range message.Images {
40+ markerIndex := index + 1
41+ marker := fmt.Sprintf("[clipboard image #%d]", markerIndex)
42+ for strings.Contains(text, marker) {
43+ markerIndex++
44+ marker = fmt.Sprintf("[clipboard image #%d]", markerIndex)
45+ }
46+ if text != "" {
47+ text += " "
48+ }
49+ text += marker
50+ attachments = append(attachments, clipboardImageAttachment{Marker: marker, Image: image})
51+ }
52+ i.clipboardImages = attachments
53+ i.ed.SetValue(text)
54+ i.inputHistoryIndex = -1
55+ return true
56+}
57+
31 func removeClipboardMarker(text, marker string) string {58 func removeClipboardMarker(text, marker string) string {
32 for {59 for {
33 idx := strings.Index(text, marker)60 idx := strings.Index(text, marker)
M packages/agent/modes/clipboard_images_test.go +21 -0
@@ -4,6 +4,7 @@ import (
44 "strings"
55 "testing"
66
7+ "github.com/bnema/zut/packages/core"
78 "github.com/bnema/zut/packages/provider"
89 )
910
@@ -14,6 +15,26 @@ func testClipboardImage(marker string, data string) clipboardImageAttachment {
1415 }
1516 }
1617
18+func TestRestoredQueuedImageDoesNotConsumeLiteralClipboardMarker(t *testing.T) {
19+ i := NewInteractive(InteractiveConfig{})
20+ message := core.QueuedMessage{
21+ Text: "keep literal [clipboard image #1]",
22+ Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}},
23+ }
24+
25+ if !i.restoreQueuedMessageToEditor(message) {
26+ t.Fatal("queued message was not restored")
27+ }
28+ text, images := preparePromptWithClipboardImages(i.ed.Value(), i.clipboardImages)
29+
30+ if text != message.Text {
31+ t.Fatalf("resubmitted text = %q, want %q", text, message.Text)
32+ }
33+ if len(images) != 1 || string(images[0].Data) != "png-1" {
34+ t.Fatalf("resubmitted images = %#v, want png-1", images)
35+ }
36+}
37+
1738 func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) {
1839 pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
1940
@@ -4,6 +4,7 @@ import (
4 "strings"4 "strings"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/zut/packages/core"
7 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
8 )9 )
9 10
@@ -14,6 +15,26 @@ func testClipboardImage(marker string, data string) clipboardImageAttachment {
14 }15 }
15 }16 }
16 17
18+func TestRestoredQueuedImageDoesNotConsumeLiteralClipboardMarker(t *testing.T) {
19+ i := NewInteractive(InteractiveConfig{})
20+ message := core.QueuedMessage{
21+ Text: "keep literal [clipboard image #1]",
22+ Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}},
23+ }
24+
25+ if !i.restoreQueuedMessageToEditor(message) {
26+ t.Fatal("queued message was not restored")
27+ }
28+ text, images := preparePromptWithClipboardImages(i.ed.Value(), i.clipboardImages)
29+
30+ if text != message.Text {
31+ t.Fatalf("resubmitted text = %q, want %q", text, message.Text)
32+ }
33+ if len(images) != 1 || string(images[0].Data) != "png-1" {
34+ t.Fatalf("resubmitted images = %#v, want png-1", images)
35+ }
36+}
37+
17 func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) {38 func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) {
18 pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}39 pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
19 40
M packages/agent/modes/compact_queue_test.go +2 -2
@@ -543,7 +543,7 @@ func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) {
543543 })
544544 interactive.runCtx = context.Background()
545545 interactive.mu.Lock()
546- interactive.queued = []string{"newer user request"}
546+ interactive.queued = []core.QueuedMessage{{Text: "newer user request"}}
547547 interactive.mu.Unlock()
548548
549549 done := make(chan struct{})
@@ -631,7 +631,7 @@ func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
631631 })
632632 interactive.runCtx = context.Background()
633633 interactive.mu.Lock()
634- interactive.queued = []string{"newer user request"}
634+ interactive.queued = []core.QueuedMessage{{Text: "newer user request"}}
635635 interactive.mu.Unlock()
636636
637637 interactive.startRestoredCompactHandoff(context.Background())
@@ -543,7 +543,7 @@ func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) {
543 })543 })
544 interactive.runCtx = context.Background()544 interactive.runCtx = context.Background()
545 interactive.mu.Lock()545 interactive.mu.Lock()
546- interactive.queued = []string{"newer user request"}546+ interactive.queued = []core.QueuedMessage{{Text: "newer user request"}}
547 interactive.mu.Unlock()547 interactive.mu.Unlock()
548 548
549 done := make(chan struct{})549 done := make(chan struct{})
@@ -631,7 +631,7 @@ func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) {
631 })631 })
632 interactive.runCtx = context.Background()632 interactive.runCtx = context.Background()
633 interactive.mu.Lock()633 interactive.mu.Lock()
634- interactive.queued = []string{"newer user request"}634+ interactive.queued = []core.QueuedMessage{{Text: "newer user request"}}
635 interactive.mu.Unlock()635 interactive.mu.Unlock()
636 636
637 interactive.startRestoredCompactHandoff(context.Background())637 interactive.startRestoredCompactHandoff(context.Background())
M packages/agent/modes/goal.go +2 -2
@@ -100,7 +100,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
100100 i.queued = i.queued[1:]
101101 i.mu.Unlock()
102102 i.abandonGoalRun(run)
103- i.startTurn(parent, next)
103+ i.startTurnWithImages(parent, next.Text, next.Images)
104104 return
105105 }
106106 if ag.QueuedMessageCount() > 0 {
@@ -134,7 +134,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
134134 i.queued = i.queued[1:]
135135 i.mu.Unlock()
136136 i.abandonGoalRun(run)
137- i.startTurn(parent, next)
137+ i.startTurnWithImages(parent, next.Text, next.Images)
138138 return
139139 }
140140 i.mu.Unlock()
@@ -100,7 +100,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
100 i.queued = i.queued[1:]100 i.queued = i.queued[1:]
101 i.mu.Unlock()101 i.mu.Unlock()
102 i.abandonGoalRun(run)102 i.abandonGoalRun(run)
103- i.startTurn(parent, next)103+ i.startTurnWithImages(parent, next.Text, next.Images)
104 return104 return
105 }105 }
106 if ag.QueuedMessageCount() > 0 {106 if ag.QueuedMessageCount() > 0 {
@@ -134,7 +134,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov
134 i.queued = i.queued[1:]134 i.queued = i.queued[1:]
135 i.mu.Unlock()135 i.mu.Unlock()
136 i.abandonGoalRun(run)136 i.abandonGoalRun(run)
137- i.startTurn(parent, next)137+ i.startTurnWithImages(parent, next.Text, next.Images)
138 return138 return
139 }139 }
140 i.mu.Unlock()140 i.mu.Unlock()
M packages/agent/modes/interactive.go +6 -9
@@ -733,7 +733,7 @@ type Interactive struct {
733733 // Messages typed while a turn is in flight. Each is delivered as
734734 // its own follow-up turn once the current one finishes. Rendered
735735 // above the status bar as "sliding in: ..." chips.
736- queued []string
736+ queued []core.QueuedMessage
737737
738738 // scheduled holds prompt-only follow-ups injected by the in-process
739739 // scheduler. Unlike agent.QueueMessage, these always wait until the
@@ -1533,13 +1533,10 @@ const ctrlCExitWindow = 2 * time.Second
15331533 // agent cancel the active turn first via the same path the editor
15341534 // uses for typed slash commands.
15351535
1536-// SubmitOrQueue runs text immediately if the agent is idle, or
1537-// appends it to the pending queue if a turn is already in flight.
1538-// Used by the telegram bridge (and by the editor submit path) so
1539-// both input sources share the same "queue behind an active turn"
1540-// semantics. Images are ignored for now — only the text prompt is
1541-// forwarded — because the queued-prompt path is text-only; a
1542-// follow-up can expand the queue entry to carry images.
1536+// SubmitOrQueue runs a text-and-image prompt immediately if the agent is idle,
1537+// or appends it to the pending queue if a turn is already in flight. Used by
1538+// the Telegram bridge and editor submit path so both input sources share the
1539+// same queue semantics.
15431540
15441541 // CancelTurn aborts the active turn if one is running. Used by the
15451542 // telegram bridge when the paired user sends /stop.
@@ -1667,7 +1664,7 @@ const modalBackdropDimPercent = 50
16671664 // model call immediately instead of just opening an empty dialog.
16681665
16691666 // submitOrQueuePrompt submits a slash command's expanded prompt immediately,
1670-// or queues it behind the active turn using the normal text-only queue.
1667+// or queues it behind the active turn.
16711668
16721669 // openSkillsDialog opens the skill inspector. The picker reflects
16731670 // whatever SkillSnapshot returns at call time, so edits to a
@@ -733,7 +733,7 @@ type Interactive struct {
733 // Messages typed while a turn is in flight. Each is delivered as733 // Messages typed while a turn is in flight. Each is delivered as
734 // its own follow-up turn once the current one finishes. Rendered734 // its own follow-up turn once the current one finishes. Rendered
735 // above the status bar as "sliding in: ..." chips.735 // above the status bar as "sliding in: ..." chips.
736- queued []string736+ queued []core.QueuedMessage
737 737
738 // scheduled holds prompt-only follow-ups injected by the in-process738 // scheduled holds prompt-only follow-ups injected by the in-process
739 // scheduler. Unlike agent.QueueMessage, these always wait until the739 // scheduler. Unlike agent.QueueMessage, these always wait until the
@@ -1533,13 +1533,10 @@ const ctrlCExitWindow = 2 * time.Second
1533 // agent cancel the active turn first via the same path the editor1533 // agent cancel the active turn first via the same path the editor
1534 // uses for typed slash commands.1534 // uses for typed slash commands.
1535 1535
1536-// SubmitOrQueue runs text immediately if the agent is idle, or1536+// SubmitOrQueue runs a text-and-image prompt immediately if the agent is idle,
1537-// appends it to the pending queue if a turn is already in flight.1537+// or appends it to the pending queue if a turn is already in flight. Used by
1538-// Used by the telegram bridge (and by the editor submit path) so1538+// the Telegram bridge and editor submit path so both input sources share the
1539-// both input sources share the same "queue behind an active turn"1539+// same queue semantics.
1540-// semantics. Images are ignored for now — only the text prompt is
1541-// forwarded — because the queued-prompt path is text-only; a
1542-// follow-up can expand the queue entry to carry images.
1543 1540
1544 // CancelTurn aborts the active turn if one is running. Used by the1541 // CancelTurn aborts the active turn if one is running. Used by the
1545 // telegram bridge when the paired user sends /stop.1542 // telegram bridge when the paired user sends /stop.
@@ -1667,7 +1664,7 @@ const modalBackdropDimPercent = 50
1667 // model call immediately instead of just opening an empty dialog.1664 // model call immediately instead of just opening an empty dialog.
1668 1665
1669 // submitOrQueuePrompt submits a slash command's expanded prompt immediately,1666 // submitOrQueuePrompt submits a slash command's expanded prompt immediately,
1670-// or queues it behind the active turn using the normal text-only queue.1667+// or queues it behind the active turn.
1671 1668
1672 // openSkillsDialog opens the skill inspector. The picker reflects1669 // openSkillsDialog opens the skill inspector. The picker reflects
1673 // whatever SkillSnapshot returns at call time, so edits to a1670 // whatever SkillSnapshot returns at call time, so edits to a
M packages/agent/modes/interactive_commands.go +4 -4
@@ -347,12 +347,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
347347 if ag != nil && !compacting {
348348 i.mu.Lock()
349349 handoff, persistHandoff = i.resetCompactContinuationLocked()
350- ag.QueueMessage(studyPrompt)
350+ ag.QueueMessage(studyPrompt, nil)
351351 i.mu.Unlock()
352352 } else {
353353 i.mu.Lock()
354354 handoff, persistHandoff = i.resetCompactContinuationLocked()
355- i.queued = append(i.queued, studyPrompt)
355+ i.queued = append(i.queued, core.QueuedMessage{Text: studyPrompt})
356356 i.mu.Unlock()
357357 }
358358 if persistHandoff {
@@ -880,10 +880,10 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
880880 var persistHandoff bool
881881 if i.compacting {
882882 handoff, persistHandoff = i.resetCompactContinuationLocked()
883- i.queued = append(i.queued, prompt)
883+ i.queued = append(i.queued, core.QueuedMessage{Text: prompt})
884884 } else {
885885 handoff, persistHandoff = i.resetCompactContinuationLocked()
886- i.agent.QueueMessage(prompt)
886+ i.agent.QueueMessage(prompt, nil)
887887 }
888888 i.mu.Unlock()
889889 if persistHandoff {
@@ -347,12 +347,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
347 if ag != nil && !compacting {347 if ag != nil && !compacting {
348 i.mu.Lock()348 i.mu.Lock()
349 handoff, persistHandoff = i.resetCompactContinuationLocked()349 handoff, persistHandoff = i.resetCompactContinuationLocked()
350- ag.QueueMessage(studyPrompt)350+ ag.QueueMessage(studyPrompt, nil)
351 i.mu.Unlock()351 i.mu.Unlock()
352 } else {352 } else {
353 i.mu.Lock()353 i.mu.Lock()
354 handoff, persistHandoff = i.resetCompactContinuationLocked()354 handoff, persistHandoff = i.resetCompactContinuationLocked()
355- i.queued = append(i.queued, studyPrompt)355+ i.queued = append(i.queued, core.QueuedMessage{Text: studyPrompt})
356 i.mu.Unlock()356 i.mu.Unlock()
357 }357 }
358 if persistHandoff {358 if persistHandoff {
@@ -880,10 +880,10 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
880 var persistHandoff bool880 var persistHandoff bool
881 if i.compacting {881 if i.compacting {
882 handoff, persistHandoff = i.resetCompactContinuationLocked()882 handoff, persistHandoff = i.resetCompactContinuationLocked()
883- i.queued = append(i.queued, prompt)883+ i.queued = append(i.queued, core.QueuedMessage{Text: prompt})
884 } else {884 } else {
885 handoff, persistHandoff = i.resetCompactContinuationLocked()885 handoff, persistHandoff = i.resetCompactContinuationLocked()
886- i.agent.QueueMessage(prompt)886+ i.agent.QueueMessage(prompt, nil)
887 }887 }
888 i.mu.Unlock()888 i.mu.Unlock()
889 if persistHandoff {889 if persistHandoff {
M packages/agent/modes/interactive_compaction.go +12 -6
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "fmt"
77
8+ "github.com/bnema/zut/packages/core"
89 "github.com/bnema/zut/packages/provider"
910 "github.com/bnema/zut/packages/tui"
1011 )
@@ -109,7 +110,8 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
109110 return
110111 }
111112
112- var next string
113+ var next core.QueuedMessage
114+ var hasNext bool
113115 continueQueued := false
114116 var resume compactHandoffResume
115117 var handoff json.RawMessage
@@ -117,6 +119,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
117119 switch {
118120 case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
119121 next, i.queued = i.queued[0], i.queued[1:]
122+ hasNext = true
120123 handoff, persistHandoff = i.resetCompactContinuationLocked()
121124 case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
122125 continueQueued = true
@@ -129,7 +132,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
129132 }
130133 // Reserve the turn slot before persisting or dispatching so concurrent input
131134 // can only queue behind the restored handoff, never start a competing turn.
132- starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
135+ starting := hasNext || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
133136 i.busy = starting
134137 i.mu.Unlock()
135138 if persistHandoff {
@@ -140,8 +143,8 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
140143 return
141144 }
142145 switch {
143- case next != "":
144- i.startTurn(parent, next)
146+ case hasNext:
147+ i.startTurnWithImages(parent, next.Text, next.Images)
145148 case continueQueued:
146149 i.startTurnRequest(parent, "", nil, true, false)
147150 case resume == compactHandoffContinueExisting:
@@ -173,7 +176,7 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
173176 if persistHandoff {
174177 i.persistCompactHandoff(handoff)
175178 }
176- i.startTurn(parent, next)
179+ i.startTurnWithImages(parent, next.Text, next.Images)
177180 return
178181 }
179182 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
@@ -338,7 +341,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
338341 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
339342 continueAutomatically = true
340343 case len(i.queued) > 0:
341- next, i.queued = i.queued[0], i.queued[1:]
344+ queued := i.queued[0]
345+ i.queued = i.queued[1:]
346+ next = queued.Text
347+ nextImages = queued.Images
342348 hasNext = true
343349 handoff, persistHandoff = i.resetCompactContinuationLocked()
344350 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 7
8+ "github.com/bnema/zut/packages/core"
8 "github.com/bnema/zut/packages/provider"9 "github.com/bnema/zut/packages/provider"
9 "github.com/bnema/zut/packages/tui"10 "github.com/bnema/zut/packages/tui"
10 )11 )
@@ -109,7 +110,8 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
109 return110 return
110 }111 }
111 112
112- var next string113+ var next core.QueuedMessage
114+ var hasNext bool
113 continueQueued := false115 continueQueued := false
114 var resume compactHandoffResume116 var resume compactHandoffResume
115 var handoff json.RawMessage117 var handoff json.RawMessage
@@ -117,6 +119,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
117 switch {119 switch {
118 case state.reason != compactContinuationForcedLength && len(i.queued) > 0:120 case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
119 next, i.queued = i.queued[0], i.queued[1:]121 next, i.queued = i.queued[0], i.queued[1:]
122+ hasNext = true
120 handoff, persistHandoff = i.resetCompactContinuationLocked()123 handoff, persistHandoff = i.resetCompactContinuationLocked()
121 case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:124 case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
122 continueQueued = true125 continueQueued = true
@@ -129,7 +132,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
129 }132 }
130 // Reserve the turn slot before persisting or dispatching so concurrent input133 // Reserve the turn slot before persisting or dispatching so concurrent input
131 // can only queue behind the restored handoff, never start a competing turn.134 // can only queue behind the restored handoff, never start a competing turn.
132- starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt135+ starting := hasNext || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
133 i.busy = starting136 i.busy = starting
134 i.mu.Unlock()137 i.mu.Unlock()
135 if persistHandoff {138 if persistHandoff {
@@ -140,8 +143,8 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
140 return143 return
141 }144 }
142 switch {145 switch {
143- case next != "":146+ case hasNext:
144- i.startTurn(parent, next)147+ i.startTurnWithImages(parent, next.Text, next.Images)
145 case continueQueued:148 case continueQueued:
146 i.startTurnRequest(parent, "", nil, true, false)149 i.startTurnRequest(parent, "", nil, true, false)
147 case resume == compactHandoffContinueExisting:150 case resume == compactHandoffContinueExisting:
@@ -173,7 +176,7 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
173 if persistHandoff {176 if persistHandoff {
174 i.persistCompactHandoff(handoff)177 i.persistCompactHandoff(handoff)
175 }178 }
176- i.startTurn(parent, next)179+ i.startTurnWithImages(parent, next.Text, next.Images)
177 return180 return
178 }181 }
179 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {182 if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
@@ -338,7 +341,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
338 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})341 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
339 continueAutomatically = true342 continueAutomatically = true
340 case len(i.queued) > 0:343 case len(i.queued) > 0:
341- next, i.queued = i.queued[0], i.queued[1:]344+ queued := i.queued[0]
345+ i.queued = i.queued[1:]
346+ next = queued.Text
347+ nextImages = queued.Images
342 hasNext = true348 hasNext = true
343 handoff, persistHandoff = i.resetCompactContinuationLocked()349 handoff, persistHandoff = i.resetCompactContinuationLocked()
344 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:350 case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
M packages/agent/modes/interactive_host.go +4 -5
@@ -256,7 +256,7 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u
256256 // the manager is dormant behind a sealed worker wave, the coordinator makes
257257 // this the next manager turn and carries any completed worker summary with it.
258258 if userInput && i.coordinatorAcceptsUserInput() {
259- actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text})
259+ actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text, Images: images})
260260 i.executeCoordinatorActions(actions)
261261 // The coordinator has accepted the input for its sealed worker wave.
262262 // It may intentionally defer an action until pending workers finish;
@@ -266,8 +266,7 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u
266266 i.maybeStartSessionTitle(i.runCtx, text)
267267 i.mu.Lock()
268268 if i.busy {
269- // Queue text only; images are dropped for queued prompts. Keep the
270- // interactive mutex held through Agent.QueueMessage so turn teardown
269+ // Keep the interactive mutex held through Agent.QueueMessage so turn teardown
271270 // cannot inspect the agent queue between the busy check and enqueue.
272271 // Compaction uses the host queue because it has no active agent loop
273272 // to drain Agent.QueueMessage entries.
@@ -275,10 +274,10 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u
275274 var persistHandoff bool
276275 if i.agent != nil && !i.compacting {
277276 handoff, persistHandoff = i.resetCompactContinuationLocked()
278- i.agent.QueueMessage(text)
277+ i.agent.QueueMessage(text, images)
279278 } else {
280279 handoff, persistHandoff = i.resetCompactContinuationLocked()
281- i.queued = append(i.queued, text)
280+ i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images})
282281 }
283282 i.mu.Unlock()
284283 if persistHandoff {
@@ -256,7 +256,7 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u
256 // the manager is dormant behind a sealed worker wave, the coordinator makes256 // the manager is dormant behind a sealed worker wave, the coordinator makes
257 // this the next manager turn and carries any completed worker summary with it.257 // this the next manager turn and carries any completed worker summary with it.
258 if userInput && i.coordinatorAcceptsUserInput() {258 if userInput && i.coordinatorAcceptsUserInput() {
259- actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text})259+ actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text, Images: images})
260 i.executeCoordinatorActions(actions)260 i.executeCoordinatorActions(actions)
261 // The coordinator has accepted the input for its sealed worker wave.261 // The coordinator has accepted the input for its sealed worker wave.
262 // It may intentionally defer an action until pending workers finish;262 // It may intentionally defer an action until pending workers finish;
@@ -266,8 +266,7 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u
266 i.maybeStartSessionTitle(i.runCtx, text)266 i.maybeStartSessionTitle(i.runCtx, text)
267 i.mu.Lock()267 i.mu.Lock()
268 if i.busy {268 if i.busy {
269- // Queue text only; images are dropped for queued prompts. Keep the269+ // Keep the interactive mutex held through Agent.QueueMessage so turn teardown
270- // interactive mutex held through Agent.QueueMessage so turn teardown
271 // cannot inspect the agent queue between the busy check and enqueue.270 // cannot inspect the agent queue between the busy check and enqueue.
272 // Compaction uses the host queue because it has no active agent loop271 // Compaction uses the host queue because it has no active agent loop
273 // to drain Agent.QueueMessage entries.272 // to drain Agent.QueueMessage entries.
@@ -275,10 +274,10 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u
275 var persistHandoff bool274 var persistHandoff bool
276 if i.agent != nil && !i.compacting {275 if i.agent != nil && !i.compacting {
277 handoff, persistHandoff = i.resetCompactContinuationLocked()276 handoff, persistHandoff = i.resetCompactContinuationLocked()
278- i.agent.QueueMessage(text)277+ i.agent.QueueMessage(text, images)
279 } else {278 } else {
280 handoff, persistHandoff = i.resetCompactContinuationLocked()279 handoff, persistHandoff = i.resetCompactContinuationLocked()
281- i.queued = append(i.queued, text)280+ i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images})
282 }281 }
283 i.mu.Unlock()282 i.mu.Unlock()
284 if persistHandoff {283 if persistHandoff {
M packages/agent/modes/interactive_host_test.go +20 -1
@@ -6,15 +6,31 @@ import (
66 "github.com/bnema/zut/packages/agent/internal/orchestration"
77 "github.com/bnema/zut/packages/agent/subagents"
88 "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
910 )
1011
12+func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) {
13+ i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true}
14+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")}
15+
16+ i.submitOrQueue("inspect", []provider.ImageBlock{image}, false)
17+
18+ if len(i.queued) != 1 || i.queued[0].Text != "inspect" {
19+ t.Fatalf("queued messages = %#v, want one inspect prompt", i.queued)
20+ }
21+ if len(i.queued[0].Images) != 1 || string(i.queued[0].Images[0].Data) != "png-1" {
22+ t.Fatalf("queued images = %#v, want png-1", i.queued[0].Images)
23+ }
24+}
25+
1126 func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {
1227 i := &Interactive{agent: &core.Agent{}}
1328 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted})
1429 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"})
1530 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished})
1631
17- i.submitOrQueue("review the result", nil, true)
32+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")}
33+ i.submitOrQueue("review the result", []provider.ImageBlock{image}, true)
1834
1935 actions := i.applyCoordinator(orchestration.Event{
2036 Kind: orchestration.EventWorkerFinished,
@@ -30,4 +46,7 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {
3046 if got := actions[0].Text; got != "review the result" {
3147 t.Fatalf("completion action text = %q, want queued user input", got)
3248 }
49+ if len(actions[0].Images) != 1 || string(actions[0].Images[0].Data) != "png-1" {
50+ t.Fatalf("completion action images = %#v, want queued image", actions[0].Images)
51+ }
3352 }
@@ -6,15 +6,31 @@ import (
6 "github.com/bnema/zut/packages/agent/internal/orchestration"6 "github.com/bnema/zut/packages/agent/internal/orchestration"
7 "github.com/bnema/zut/packages/agent/subagents"7 "github.com/bnema/zut/packages/agent/subagents"
8 "github.com/bnema/zut/packages/core"8 "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
9 )10 )
10 11
12+func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) {
13+ i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true}
14+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")}
15+
16+ i.submitOrQueue("inspect", []provider.ImageBlock{image}, false)
17+
18+ if len(i.queued) != 1 || i.queued[0].Text != "inspect" {
19+ t.Fatalf("queued messages = %#v, want one inspect prompt", i.queued)
20+ }
21+ if len(i.queued[0].Images) != 1 || string(i.queued[0].Images[0].Data) != "png-1" {
22+ t.Fatalf("queued images = %#v, want png-1", i.queued[0].Images)
23+ }
24+}
25+
11 func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {26 func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {
12 i := &Interactive{agent: &core.Agent{}}27 i := &Interactive{agent: &core.Agent{}}
13 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted})28 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted})
14 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"})29 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"})
15 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished})30 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished})
16 31
17- i.submitOrQueue("review the result", nil, true)32+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")}
33+ i.submitOrQueue("review the result", []provider.ImageBlock{image}, true)
18 34
19 actions := i.applyCoordinator(orchestration.Event{35 actions := i.applyCoordinator(orchestration.Event{
20 Kind: orchestration.EventWorkerFinished,36 Kind: orchestration.EventWorkerFinished,
@@ -30,4 +46,7 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {
30 if got := actions[0].Text; got != "review the result" {46 if got := actions[0].Text; got != "review the result" {
31 t.Fatalf("completion action text = %q, want queued user input", got)47 t.Fatalf("completion action text = %q, want queued user input", got)
32 }48 }
49+ if len(actions[0].Images) != 1 || string(actions[0].Images[0].Data) != "png-1" {
50+ t.Fatalf("completion action images = %#v, want queued image", actions[0].Images)
51+ }
33 }52 }
M packages/agent/modes/interactive_input_routing.go +16 -22
@@ -578,27 +578,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
578578 i.mu.Lock()
579579 busyCancel := i.busy && i.cancelTurn != nil
580580 cancelTurn := i.cancelTurn
581- var restoreQueued string
581+ var restoreQueued core.QueuedMessage
582+ var hasRestoreQueued bool
582583 var handoff json.RawMessage
583584 var persistHandoff bool
584585 if busyCancel {
585586 // Keep the most recently queued follow-up as an editable draft
586587 // instead of losing it with the cancelled turn's stale queue.
587588 if i.agent != nil {
588- restoreQueued, _ = i.agent.PopQueuedMessage()
589+ restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage()
589590 }
590- if restoreQueued == "" && len(i.queued) > 0 {
591+ if !hasRestoreQueued && len(i.queued) > 0 {
591592 n := len(i.queued) - 1
592593 restoreQueued = i.queued[n]
593594 i.queued = i.queued[:n]
595+ hasRestoreQueued = true
594596 }
595597 handoff, persistHandoff = i.resetCompactContinuationLocked()
596598 }
597599 i.mu.Unlock()
598600 if busyCancel {
599- if restoreQueued != "" {
600- i.ed.SetValue(restoreQueued)
601- i.inputHistoryIndex = -1
601+ if hasRestoreQueued {
602+ i.restoreQueuedMessageToEditor(restoreQueued)
602603 }
603604 i.updateActiveGoal(core.GoalPaused, "interrupted by user")
604605 cancelTurn()
@@ -647,20 +648,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
647648 // the keypress falls through to the normal scroll behavior.
648649 if k.Alt {
649650 i.mu.Lock()
650- var text string
651+ var message core.QueuedMessage
652+ var ok bool
651653 if i.agent != nil {
652- text, _ = i.agent.PopQueuedMessage()
654+ message, ok = i.agent.PopQueuedMessage()
653655 }
654- if text == "" {
656+ if !ok {
655657 if n := len(i.queued); n > 0 {
656- text = i.queued[n-1]
658+ message = i.queued[n-1]
657659 i.queued = i.queued[:n-1]
660+ ok = true
658661 }
659662 }
660663 i.mu.Unlock()
661- if text != "" {
662- i.ed.SetValue(text)
663- i.inputHistoryIndex = -1
664+ if ok && i.restoreQueuedMessageToEditor(message) {
664665 i.invalidate()
665666 return false
666667 }
@@ -914,25 +915,18 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
914915 ag := i.agent
915916 i.mu.Unlock()
916917 if busy {
917- if len(images) > 0 {
918- i.mu.Lock()
919- i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
920- i.mu.Unlock()
921- i.invalidate()
922- return false
923- }
924918 clearSubmittedInput()
925919 var handoff json.RawMessage
926920 var persistHandoff bool
927921 if ag != nil && !compacting {
928922 i.mu.Lock()
929923 handoff, persistHandoff = i.resetCompactContinuationLocked()
930- ag.QueueMessage(text)
924+ ag.QueueMessage(text, images)
931925 i.mu.Unlock()
932926 } else {
933927 i.mu.Lock()
934928 handoff, persistHandoff = i.resetCompactContinuationLocked()
935- i.queued = append(i.queued, text)
929+ i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images})
936930 i.mu.Unlock()
937931 }
938932 if persistHandoff {
@@ -578,27 +578,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
578 i.mu.Lock()578 i.mu.Lock()
579 busyCancel := i.busy && i.cancelTurn != nil579 busyCancel := i.busy && i.cancelTurn != nil
580 cancelTurn := i.cancelTurn580 cancelTurn := i.cancelTurn
581- var restoreQueued string581+ var restoreQueued core.QueuedMessage
582+ var hasRestoreQueued bool
582 var handoff json.RawMessage583 var handoff json.RawMessage
583 var persistHandoff bool584 var persistHandoff bool
584 if busyCancel {585 if busyCancel {
585 // Keep the most recently queued follow-up as an editable draft586 // Keep the most recently queued follow-up as an editable draft
586 // instead of losing it with the cancelled turn's stale queue.587 // instead of losing it with the cancelled turn's stale queue.
587 if i.agent != nil {588 if i.agent != nil {
588- restoreQueued, _ = i.agent.PopQueuedMessage()589+ restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage()
589 }590 }
590- if restoreQueued == "" && len(i.queued) > 0 {591+ if !hasRestoreQueued && len(i.queued) > 0 {
591 n := len(i.queued) - 1592 n := len(i.queued) - 1
592 restoreQueued = i.queued[n]593 restoreQueued = i.queued[n]
593 i.queued = i.queued[:n]594 i.queued = i.queued[:n]
595+ hasRestoreQueued = true
594 }596 }
595 handoff, persistHandoff = i.resetCompactContinuationLocked()597 handoff, persistHandoff = i.resetCompactContinuationLocked()
596 }598 }
597 i.mu.Unlock()599 i.mu.Unlock()
598 if busyCancel {600 if busyCancel {
599- if restoreQueued != "" {601+ if hasRestoreQueued {
600- i.ed.SetValue(restoreQueued)602+ i.restoreQueuedMessageToEditor(restoreQueued)
601- i.inputHistoryIndex = -1
602 }603 }
603 i.updateActiveGoal(core.GoalPaused, "interrupted by user")604 i.updateActiveGoal(core.GoalPaused, "interrupted by user")
604 cancelTurn()605 cancelTurn()
@@ -647,20 +648,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
647 // the keypress falls through to the normal scroll behavior.648 // the keypress falls through to the normal scroll behavior.
648 if k.Alt {649 if k.Alt {
649 i.mu.Lock()650 i.mu.Lock()
650- var text string651+ var message core.QueuedMessage
652+ var ok bool
651 if i.agent != nil {653 if i.agent != nil {
652- text, _ = i.agent.PopQueuedMessage()654+ message, ok = i.agent.PopQueuedMessage()
653 }655 }
654- if text == "" {656+ if !ok {
655 if n := len(i.queued); n > 0 {657 if n := len(i.queued); n > 0 {
656- text = i.queued[n-1]658+ message = i.queued[n-1]
657 i.queued = i.queued[:n-1]659 i.queued = i.queued[:n-1]
660+ ok = true
658 }661 }
659 }662 }
660 i.mu.Unlock()663 i.mu.Unlock()
661- if text != "" {664+ if ok && i.restoreQueuedMessageToEditor(message) {
662- i.ed.SetValue(text)
663- i.inputHistoryIndex = -1
664 i.invalidate()665 i.invalidate()
665 return false666 return false
666 }667 }
@@ -914,25 +915,18 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
914 ag := i.agent915 ag := i.agent
915 i.mu.Unlock()916 i.mu.Unlock()
916 if busy {917 if busy {
917- if len(images) > 0 {
918- i.mu.Lock()
919- i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
920- i.mu.Unlock()
921- i.invalidate()
922- return false
923- }
924 clearSubmittedInput()918 clearSubmittedInput()
925 var handoff json.RawMessage919 var handoff json.RawMessage
926 var persistHandoff bool920 var persistHandoff bool
927 if ag != nil && !compacting {921 if ag != nil && !compacting {
928 i.mu.Lock()922 i.mu.Lock()
929 handoff, persistHandoff = i.resetCompactContinuationLocked()923 handoff, persistHandoff = i.resetCompactContinuationLocked()
930- ag.QueueMessage(text)924+ ag.QueueMessage(text, images)
931 i.mu.Unlock()925 i.mu.Unlock()
932 } else {926 } else {
933 i.mu.Lock()927 i.mu.Lock()
934 handoff, persistHandoff = i.resetCompactContinuationLocked()928 handoff, persistHandoff = i.resetCompactContinuationLocked()
935- i.queued = append(i.queued, text)929+ i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images})
936 i.mu.Unlock()930 i.mu.Unlock()
937 }931 }
938 if persistHandoff {932 if persistHandoff {
M packages/agent/modes/interactive_queue_test.go +64 -4
@@ -2,16 +2,76 @@ package modes
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
810 "github.com/bnema/zut/packages/tui"
911 )
1012
13+func TestQueuedMessageSummaryKeepsImageIndicatorWhenTextIsTruncated(t *testing.T) {
14+ message := core.QueuedMessage{
15+ Text: strings.Repeat("x", 100),
16+ Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}},
17+ }
18+
19+ summary := queuedMessageSummary(message, 24)
20+
21+ if !strings.HasSuffix(summary, " [image]") {
22+ t.Fatalf("summary = %q, want visible image indicator", summary)
23+ }
24+ if len([]rune(summary)) > 24 {
25+ t.Fatalf("summary width = %d, want at most 24", len([]rune(summary)))
26+ }
27+}
28+
29+func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) {
30+ agent := core.NewAgent(nil, "test-model", "", nil)
31+ i := NewInteractive(InteractiveConfig{Agent: agent})
32+ i.mu.Lock()
33+ i.busy = true
34+ i.mu.Unlock()
35+ i.ed.SetValue("inspect [clipboard image #1]")
36+ i.clipboardImages = []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
37+
38+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
39+
40+ queued := agent.PendingQueuedMessages()
41+ if len(queued) != 1 || queued[0].Text != "inspect" {
42+ t.Fatalf("queued messages = %#v, want one inspect prompt", queued)
43+ }
44+ if len(queued[0].Images) != 1 || string(queued[0].Images[0].Data) != "png-1" {
45+ t.Fatalf("queued images = %#v, want png-1", queued[0].Images)
46+ }
47+ if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 {
48+ t.Fatal("submitted image prompt remained in the editor")
49+ }
50+}
51+
52+func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) {
53+ agent := core.NewAgent(nil, "test-model", "", nil)
54+ image := testClipboardImage("unused", "png-1").Image
55+ agent.QueueMessage("inspect", []provider.ImageBlock{image})
56+ i := NewInteractive(InteractiveConfig{Agent: agent})
57+ i.mu.Lock()
58+ i.busy = true
59+ i.mu.Unlock()
60+
61+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
62+
63+ if got := i.ed.Value(); got != "inspect [clipboard image #1]" {
64+ t.Fatalf("editor = %q, want restored image marker", got)
65+ }
66+ if len(i.clipboardImages) != 1 || string(i.clipboardImages[0].Image.Data) != "png-1" {
67+ t.Fatalf("clipboard images = %#v, want png-1", i.clipboardImages)
68+ }
69+}
70+
1171 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
1272 agent := core.NewAgent(nil, "test-model", "", nil)
13- agent.QueueMessage("older follow-up")
14- agent.QueueMessage("recover this draft")
73+ agent.QueueMessage("older follow-up", nil)
74+ agent.QueueMessage("recover this draft", nil)
1575
1676 i := NewInteractive(InteractiveConfig{Agent: agent})
1777 i.ed.SetValue("existing draft")
@@ -30,7 +90,7 @@ func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
3090 if got, want := i.ed.Value(), "recover this draft"; got != want {
3191 t.Errorf("editor = %q, want %q", got, want)
3292 }
33- if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0] != "older follow-up" {
93+ if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0].Text != "older follow-up" {
3494 t.Errorf("remaining queued messages = %v, want [older follow-up]", got)
3595 }
3696 }
@@ -41,7 +101,7 @@ func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) {
41101 i.mu.Lock()
42102 i.busy = true
43103 i.cancelTurn = func() { cancelled++ }
44- i.queued = []string{"recover this draft"}
104+ i.queued = []core.QueuedMessage{{Text: "recover this draft"}}
45105 i.mu.Unlock()
46106
47107 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
@@ -2,16 +2,76 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/zut/packages/core"8 "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
8 "github.com/bnema/zut/packages/tui"10 "github.com/bnema/zut/packages/tui"
9 )11 )
10 12
13+func TestQueuedMessageSummaryKeepsImageIndicatorWhenTextIsTruncated(t *testing.T) {
14+ message := core.QueuedMessage{
15+ Text: strings.Repeat("x", 100),
16+ Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}},
17+ }
18+
19+ summary := queuedMessageSummary(message, 24)
20+
21+ if !strings.HasSuffix(summary, " [image]") {
22+ t.Fatalf("summary = %q, want visible image indicator", summary)
23+ }
24+ if len([]rune(summary)) > 24 {
25+ t.Fatalf("summary width = %d, want at most 24", len([]rune(summary)))
26+ }
27+}
28+
29+func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) {
30+ agent := core.NewAgent(nil, "test-model", "", nil)
31+ i := NewInteractive(InteractiveConfig{Agent: agent})
32+ i.mu.Lock()
33+ i.busy = true
34+ i.mu.Unlock()
35+ i.ed.SetValue("inspect [clipboard image #1]")
36+ i.clipboardImages = []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
37+
38+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
39+
40+ queued := agent.PendingQueuedMessages()
41+ if len(queued) != 1 || queued[0].Text != "inspect" {
42+ t.Fatalf("queued messages = %#v, want one inspect prompt", queued)
43+ }
44+ if len(queued[0].Images) != 1 || string(queued[0].Images[0].Data) != "png-1" {
45+ t.Fatalf("queued images = %#v, want png-1", queued[0].Images)
46+ }
47+ if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 {
48+ t.Fatal("submitted image prompt remained in the editor")
49+ }
50+}
51+
52+func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) {
53+ agent := core.NewAgent(nil, "test-model", "", nil)
54+ image := testClipboardImage("unused", "png-1").Image
55+ agent.QueueMessage("inspect", []provider.ImageBlock{image})
56+ i := NewInteractive(InteractiveConfig{Agent: agent})
57+ i.mu.Lock()
58+ i.busy = true
59+ i.mu.Unlock()
60+
61+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
62+
63+ if got := i.ed.Value(); got != "inspect [clipboard image #1]" {
64+ t.Fatalf("editor = %q, want restored image marker", got)
65+ }
66+ if len(i.clipboardImages) != 1 || string(i.clipboardImages[0].Image.Data) != "png-1" {
67+ t.Fatalf("clipboard images = %#v, want png-1", i.clipboardImages)
68+ }
69+}
70+
11 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {71 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
12 agent := core.NewAgent(nil, "test-model", "", nil)72 agent := core.NewAgent(nil, "test-model", "", nil)
13- agent.QueueMessage("older follow-up")73+ agent.QueueMessage("older follow-up", nil)
14- agent.QueueMessage("recover this draft")74+ agent.QueueMessage("recover this draft", nil)
15 75
16 i := NewInteractive(InteractiveConfig{Agent: agent})76 i := NewInteractive(InteractiveConfig{Agent: agent})
17 i.ed.SetValue("existing draft")77 i.ed.SetValue("existing draft")
@@ -30,7 +90,7 @@ func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
30 if got, want := i.ed.Value(), "recover this draft"; got != want {90 if got, want := i.ed.Value(), "recover this draft"; got != want {
31 t.Errorf("editor = %q, want %q", got, want)91 t.Errorf("editor = %q, want %q", got, want)
32 }92 }
33- if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0] != "older follow-up" {93+ if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0].Text != "older follow-up" {
34 t.Errorf("remaining queued messages = %v, want [older follow-up]", got)94 t.Errorf("remaining queued messages = %v, want [older follow-up]", got)
35 }95 }
36 }96 }
@@ -41,7 +101,7 @@ func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) {
41 i.mu.Lock()101 i.mu.Lock()
42 i.busy = true102 i.busy = true
43 i.cancelTurn = func() { cancelled++ }103 i.cancelTurn = func() { cancelled++ }
44- i.queued = []string{"recover this draft"}104+ i.queued = []core.QueuedMessage{{Text: "recover this draft"}}
45 i.mu.Unlock()105 i.mu.Unlock()
46 106
47 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})107 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
M packages/agent/modes/interactive_rendering.go +21 -2
@@ -8,10 +8,29 @@ import (
88
99 "github.com/bnema/zut/packages/agent/extproto"
1010 "github.com/bnema/zut/packages/agent/skills"
11+ "github.com/bnema/zut/packages/core"
1112 "github.com/bnema/zut/packages/provider"
1213 "github.com/bnema/zut/packages/tui"
1314 )
1415
16+func queuedMessageSummary(message core.QueuedMessage, maxWidth int) string {
17+ if len(message.Images) == 0 {
18+ return truncateLine(message.Text, maxWidth)
19+ }
20+ imageLabel := "[image]"
21+ if len(message.Images) > 1 {
22+ imageLabel = fmt.Sprintf("[%d images]", len(message.Images))
23+ }
24+ if message.Text == "" || maxWidth <= len(imageLabel) {
25+ return truncateLine(imageLabel, maxWidth)
26+ }
27+ text := truncateLine(message.Text, maxWidth-len(imageLabel)-1)
28+ if text == "" {
29+ return imageLabel
30+ }
31+ return text + " " + imageLabel
32+}
33+
1534 func (i *Interactive) cachedChatLocked(cols int) []string {
1635 // A busy frame contains mutable streaming/tool state. Keep the stable
1736 // transcript cache, but never return a full-frame cache entry that would
@@ -749,7 +768,7 @@ func (i *Interactive) redraw() {
749768 // in flight. Shown directly above the status bar so they're close
750769 // to the editor but don't push the chat around.
751770 var queue []string
752- queued := append([]string(nil), i.queued...)
771+ queued := append([]core.QueuedMessage(nil), i.queued...)
753772 if i.agent != nil {
754773 queued = append(queued, i.agent.PendingQueuedMessages()...)
755774 }
@@ -757,7 +776,7 @@ func (i *Interactive) redraw() {
757776 queue = append(queue, "")
758777 for _, q := range queued {
759778 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
760- text := truncateLine(q, mainCols-17)
779+ text := queuedMessageSummary(q, mainCols-17)
761780 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
762781 }
763782 // Hint row, rendered in the same muted tone as the model
@@ -8,10 +8,29 @@ import (
8 8
9 "github.com/bnema/zut/packages/agent/extproto"9 "github.com/bnema/zut/packages/agent/extproto"
10 "github.com/bnema/zut/packages/agent/skills"10 "github.com/bnema/zut/packages/agent/skills"
11+ "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"12 "github.com/bnema/zut/packages/provider"
12 "github.com/bnema/zut/packages/tui"13 "github.com/bnema/zut/packages/tui"
13 )14 )
14 15
16+func queuedMessageSummary(message core.QueuedMessage, maxWidth int) string {
17+ if len(message.Images) == 0 {
18+ return truncateLine(message.Text, maxWidth)
19+ }
20+ imageLabel := "[image]"
21+ if len(message.Images) > 1 {
22+ imageLabel = fmt.Sprintf("[%d images]", len(message.Images))
23+ }
24+ if message.Text == "" || maxWidth <= len(imageLabel) {
25+ return truncateLine(imageLabel, maxWidth)
26+ }
27+ text := truncateLine(message.Text, maxWidth-len(imageLabel)-1)
28+ if text == "" {
29+ return imageLabel
30+ }
31+ return text + " " + imageLabel
32+}
33+
15 func (i *Interactive) cachedChatLocked(cols int) []string {34 func (i *Interactive) cachedChatLocked(cols int) []string {
16 // A busy frame contains mutable streaming/tool state. Keep the stable35 // A busy frame contains mutable streaming/tool state. Keep the stable
17 // transcript cache, but never return a full-frame cache entry that would36 // transcript cache, but never return a full-frame cache entry that would
@@ -749,7 +768,7 @@ func (i *Interactive) redraw() {
749 // in flight. Shown directly above the status bar so they're close768 // in flight. Shown directly above the status bar so they're close
750 // to the editor but don't push the chat around.769 // to the editor but don't push the chat around.
751 var queue []string770 var queue []string
752- queued := append([]string(nil), i.queued...)771+ queued := append([]core.QueuedMessage(nil), i.queued...)
753 if i.agent != nil {772 if i.agent != nil {
754 queued = append(queued, i.agent.PendingQueuedMessages()...)773 queued = append(queued, i.agent.PendingQueuedMessages()...)
755 }774 }
@@ -757,7 +776,7 @@ func (i *Interactive) redraw() {
757 queue = append(queue, "")776 queue = append(queue, "")
758 for _, q := range queued {777 for _, q := range queued {
759 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")778 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
760- text := truncateLine(q, mainCols-17)779+ text := queuedMessageSummary(q, mainCols-17)
761 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))780 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
762 }781 }
763 // Hint row, rendered in the same muted tone as the model782 // Hint row, rendered in the same muted tone as the model
M packages/agent/modes/interactive_resident_subagents.go +2 -2
@@ -190,8 +190,8 @@ func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action)
190190 prompt = update
191191 }
192192 }
193- if prompt != "" {
194- i.submitOrQueue(prompt, nil, false)
193+ if prompt != "" || len(action.Images) != 0 {
194+ i.submitOrQueue(prompt, action.Images, false)
195195 } else if action.Reason == orchestration.WakeGoal {
196196 parent := i.runCtx
197197 if parent == nil {
@@ -190,8 +190,8 @@ func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action)
190 prompt = update190 prompt = update
191 }191 }
192 }192 }
193- if prompt != "" {193+ if prompt != "" || len(action.Images) != 0 {
194- i.submitOrQueue(prompt, nil, false)194+ i.submitOrQueue(prompt, action.Images, false)
195 } else if action.Reason == orchestration.WakeGoal {195 } else if action.Reason == orchestration.WakeGoal {
196 parent := i.runCtx196 parent := i.runCtx
197 if parent == nil {197 if parent == nil {
M packages/agent/modes/interactive_turn.go +2 -2
@@ -331,7 +331,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
331331 // deciding whether this continuation may spend another rescue attempt.
332332 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
333333 // Pop the next queued message, if any, and relaunch.
334- var next string
334+ var next core.QueuedMessage
335335 var hasNext bool
336336 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
337337 next, i.queued = i.queued[0], i.queued[1:]
@@ -409,7 +409,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
409409 }
410410 switch {
411411 case hasNext:
412- i.startTurn(parent, next)
412+ i.startTurnWithImages(parent, next.Text, next.Images)
413413 case hasScheduled:
414414 // The scheduler holds the session transition read lock until this
415415 // acknowledgement, so accepting here binds the prompt to the agent
@@ -331,7 +331,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
331 // deciding whether this continuation may spend another rescue attempt.331 // deciding whether this continuation may spend another rescue attempt.
332 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue332 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
333 // Pop the next queued message, if any, and relaunch.333 // Pop the next queued message, if any, and relaunch.
334- var next string334+ var next core.QueuedMessage
335 var hasNext bool335 var hasNext bool
336 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {336 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
337 next, i.queued = i.queued[0], i.queued[1:]337 next, i.queued = i.queued[0], i.queued[1:]
@@ -409,7 +409,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
409 }409 }
410 switch {410 switch {
411 case hasNext:411 case hasNext:
412- i.startTurn(parent, next)412+ i.startTurnWithImages(parent, next.Text, next.Images)
413 case hasScheduled:413 case hasScheduled:
414 // The scheduler holds the session transition read lock until this414 // The scheduler holds the session transition read lock until this
415 // acknowledgement, so accepting here binds the prompt to the agent415 // acknowledgement, so accepting here binds the prompt to the agent
M packages/core/agent.go +46 -20
@@ -24,8 +24,14 @@ const (
2424 DefaultStreamMaxRetries = 5
2525 )
2626
27+// QueuedMessage is a user prompt waiting for the next safe model-call boundary.
28+type QueuedMessage struct {
29+ Text string
30+ Images []provider.ImageBlock
31+}
32+
2733 type queuedMessage struct {
28- text string
34+ message QueuedMessage
2935 accepted time.Time
3036 }
3137
@@ -274,18 +280,18 @@ func (a *Agent) FastModeEnabled() bool {
274280 return a.FastMode
275281 }
276282
277-// QueueMessage queues text to be injected as a user message at the
278-// next safe boundary of the active agent loop. It is non-blocking in
279-// the sense that it never waits for model/tool work; it only takes
280-// the transcript mutex briefly. Empty/whitespace-only messages are
281-// ignored.
282-func (a *Agent) QueueMessage(text string) bool {
283+// QueueMessage queues a text and image prompt to be injected as a user message
284+// at the next safe boundary of the active agent loop. It is non-blocking in the
285+// sense that it never waits for model/tool work; it only takes the transcript
286+// mutex briefly. Prompts without text or images are ignored.
287+func (a *Agent) QueueMessage(text string, images []provider.ImageBlock) bool {
283288 text = strings.TrimSpace(text)
284- if text == "" {
289+ if text == "" && len(images) == 0 {
285290 return false
286291 }
292+ message := cloneQueuedMessage(QueuedMessage{Text: text, Images: images})
287293 a.mu.Lock()
288- a.queued = append(a.queued, queuedMessage{text: text, accepted: time.Now()})
294+ a.queued = append(a.queued, queuedMessage{message: message, accepted: time.Now()})
289295 a.mu.Unlock()
290296 return true
291297 }
@@ -293,12 +299,12 @@ func (a *Agent) QueueMessage(text string) bool {
293299 // PendingQueuedMessages returns a snapshot of user messages waiting
294300 // to be injected. Used by hosts to render the visible "sliding in"
295301 // chips without consuming them.
296-func (a *Agent) PendingQueuedMessages() []string {
302+func (a *Agent) PendingQueuedMessages() []QueuedMessage {
297303 a.mu.Lock()
298304 defer a.mu.Unlock()
299- out := make([]string, len(a.queued))
305+ out := make([]QueuedMessage, len(a.queued))
300306 for i, queued := range a.queued {
301- out[i] = queued.text
307+ out[i] = cloneQueuedMessage(queued.message)
302308 }
303309 return out
304310 }
@@ -313,30 +319,43 @@ func (a *Agent) QueuedMessageCount() int {
313319
314320 // PopQueuedMessage removes and returns the most recently queued
315321 // message. Hosts use this for the slide-back keybinding.
316-func (a *Agent) PopQueuedMessage() (string, bool) {
322+func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) {
317323 a.mu.Lock()
318324 defer a.mu.Unlock()
319325 n := len(a.queued)
320326 if n == 0 {
321- return "", false
327+ return QueuedMessage{}, false
322328 }
323- text := a.queued[n-1].text
329+ message := cloneQueuedMessage(a.queued[n-1].message)
324330 a.queued = a.queued[:n-1]
325- return text, true
331+ return message, true
326332 }
327333
328334 // DrainQueuedMessages discards and returns every queued message.
329335 // Hosts use this on explicit cancel/clear so stale follow-ups do
330336 // not run after the user aborted the turn.
331-func (a *Agent) DrainQueuedMessages() []string {
337+func (a *Agent) DrainQueuedMessages() []QueuedMessage {
332338 queued := a.drainQueuedMessages()
333- out := make([]string, len(queued))
339+ out := make([]QueuedMessage, len(queued))
334340 for i, message := range queued {
335- out[i] = message.text
341+ out[i] = cloneQueuedMessage(message.message)
336342 }
337343 return out
338344 }
339345
346+func cloneQueuedMessage(message QueuedMessage) QueuedMessage {
347+ clone := QueuedMessage{Text: message.Text}
348+ if len(message.Images) == 0 {
349+ return clone
350+ }
351+ clone.Images = make([]provider.ImageBlock, len(message.Images))
352+ for i, image := range message.Images {
353+ clone.Images[i] = image
354+ clone.Images[i].Data = append([]byte(nil), image.Data...)
355+ }
356+ return clone
357+}
358+
340359 func (a *Agent) drainQueuedMessages() []queuedMessage {
341360 a.mu.Lock()
342361 defer a.mu.Unlock()
@@ -351,9 +370,16 @@ func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEven
351370 if accepted.IsZero() {
352371 accepted = time.Now()
353372 }
373+ content := make([]provider.Content, 0, 1+len(queued.message.Images))
374+ if queued.message.Text != "" {
375+ content = append(content, provider.TextBlock{Text: queued.message.Text})
376+ }
377+ for _, image := range queued.message.Images {
378+ content = append(content, image)
379+ }
354380 msg := provider.Message{
355381 Role: provider.RoleUser,
356- Content: []provider.Content{provider.TextBlock{Text: queued.text}},
382+ Content: content,
357383 Time: accepted,
358384 }
359385 a.mu.Lock()
@@ -24,8 +24,14 @@ const (
24 DefaultStreamMaxRetries = 524 DefaultStreamMaxRetries = 5
25 )25 )
26 26
27+// QueuedMessage is a user prompt waiting for the next safe model-call boundary.
28+type QueuedMessage struct {
29+ Text string
30+ Images []provider.ImageBlock
31+}
32+
27 type queuedMessage struct {33 type queuedMessage struct {
28- text string34+ message QueuedMessage
29 accepted time.Time35 accepted time.Time
30 }36 }
31 37
@@ -274,18 +280,18 @@ func (a *Agent) FastModeEnabled() bool {
274 return a.FastMode280 return a.FastMode
275 }281 }
276 282
277-// QueueMessage queues text to be injected as a user message at the283+// QueueMessage queues a text and image prompt to be injected as a user message
278-// next safe boundary of the active agent loop. It is non-blocking in284+// at the next safe boundary of the active agent loop. It is non-blocking in the
279-// the sense that it never waits for model/tool work; it only takes285+// sense that it never waits for model/tool work; it only takes the transcript
280-// the transcript mutex briefly. Empty/whitespace-only messages are286+// mutex briefly. Prompts without text or images are ignored.
281-// ignored.287+func (a *Agent) QueueMessage(text string, images []provider.ImageBlock) bool {
282-func (a *Agent) QueueMessage(text string) bool {
283 text = strings.TrimSpace(text)288 text = strings.TrimSpace(text)
284- if text == "" {289+ if text == "" && len(images) == 0 {
285 return false290 return false
286 }291 }
292+ message := cloneQueuedMessage(QueuedMessage{Text: text, Images: images})
287 a.mu.Lock()293 a.mu.Lock()
288- a.queued = append(a.queued, queuedMessage{text: text, accepted: time.Now()})294+ a.queued = append(a.queued, queuedMessage{message: message, accepted: time.Now()})
289 a.mu.Unlock()295 a.mu.Unlock()
290 return true296 return true
291 }297 }
@@ -293,12 +299,12 @@ func (a *Agent) QueueMessage(text string) bool {
293 // PendingQueuedMessages returns a snapshot of user messages waiting299 // PendingQueuedMessages returns a snapshot of user messages waiting
294 // to be injected. Used by hosts to render the visible "sliding in"300 // to be injected. Used by hosts to render the visible "sliding in"
295 // chips without consuming them.301 // chips without consuming them.
296-func (a *Agent) PendingQueuedMessages() []string {302+func (a *Agent) PendingQueuedMessages() []QueuedMessage {
297 a.mu.Lock()303 a.mu.Lock()
298 defer a.mu.Unlock()304 defer a.mu.Unlock()
299- out := make([]string, len(a.queued))305+ out := make([]QueuedMessage, len(a.queued))
300 for i, queued := range a.queued {306 for i, queued := range a.queued {
301- out[i] = queued.text307+ out[i] = cloneQueuedMessage(queued.message)
302 }308 }
303 return out309 return out
304 }310 }
@@ -313,30 +319,43 @@ func (a *Agent) QueuedMessageCount() int {
313 319
314 // PopQueuedMessage removes and returns the most recently queued320 // PopQueuedMessage removes and returns the most recently queued
315 // message. Hosts use this for the slide-back keybinding.321 // message. Hosts use this for the slide-back keybinding.
316-func (a *Agent) PopQueuedMessage() (string, bool) {322+func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) {
317 a.mu.Lock()323 a.mu.Lock()
318 defer a.mu.Unlock()324 defer a.mu.Unlock()
319 n := len(a.queued)325 n := len(a.queued)
320 if n == 0 {326 if n == 0 {
321- return "", false327+ return QueuedMessage{}, false
322 }328 }
323- text := a.queued[n-1].text329+ message := cloneQueuedMessage(a.queued[n-1].message)
324 a.queued = a.queued[:n-1]330 a.queued = a.queued[:n-1]
325- return text, true331+ return message, true
326 }332 }
327 333
328 // DrainQueuedMessages discards and returns every queued message.334 // DrainQueuedMessages discards and returns every queued message.
329 // Hosts use this on explicit cancel/clear so stale follow-ups do335 // Hosts use this on explicit cancel/clear so stale follow-ups do
330 // not run after the user aborted the turn.336 // not run after the user aborted the turn.
331-func (a *Agent) DrainQueuedMessages() []string {337+func (a *Agent) DrainQueuedMessages() []QueuedMessage {
332 queued := a.drainQueuedMessages()338 queued := a.drainQueuedMessages()
333- out := make([]string, len(queued))339+ out := make([]QueuedMessage, len(queued))
334 for i, message := range queued {340 for i, message := range queued {
335- out[i] = message.text341+ out[i] = cloneQueuedMessage(message.message)
336 }342 }
337 return out343 return out
338 }344 }
339 345
346+func cloneQueuedMessage(message QueuedMessage) QueuedMessage {
347+ clone := QueuedMessage{Text: message.Text}
348+ if len(message.Images) == 0 {
349+ return clone
350+ }
351+ clone.Images = make([]provider.ImageBlock, len(message.Images))
352+ for i, image := range message.Images {
353+ clone.Images[i] = image
354+ clone.Images[i].Data = append([]byte(nil), image.Data...)
355+ }
356+ return clone
357+}
358+
340 func (a *Agent) drainQueuedMessages() []queuedMessage {359 func (a *Agent) drainQueuedMessages() []queuedMessage {
341 a.mu.Lock()360 a.mu.Lock()
342 defer a.mu.Unlock()361 defer a.mu.Unlock()
@@ -351,9 +370,16 @@ func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEven
351 if accepted.IsZero() {370 if accepted.IsZero() {
352 accepted = time.Now()371 accepted = time.Now()
353 }372 }
373+ content := make([]provider.Content, 0, 1+len(queued.message.Images))
374+ if queued.message.Text != "" {
375+ content = append(content, provider.TextBlock{Text: queued.message.Text})
376+ }
377+ for _, image := range queued.message.Images {
378+ content = append(content, image)
379+ }
354 msg := provider.Message{380 msg := provider.Message{
355 Role: provider.RoleUser,381 Role: provider.RoleUser,
356- Content: []provider.Content{provider.TextBlock{Text: queued.text}},382+ Content: content,
357 Time: accepted,383 Time: accepted,
358 }384 }
359 a.mu.Lock()385 a.mu.Lock()
M packages/core/queue_test.go +45 -9
@@ -100,7 +100,7 @@ func TestQueuedMessageInjectedAfterToolBatchBeforeNextModelCall(t *testing.T) {
100100 }()
101101
102102 <-tool.started
103- if !a.QueueMessage("also do Y") {
103+ if !a.QueueMessage("also do Y", nil) {
104104 t.Fatal("QueueMessage returned false")
105105 }
106106 close(tool.release)
@@ -131,10 +131,46 @@ func queueTestContains(xs []string, want string) bool {
131131 return false
132132 }
133133
134+func TestQueuedImageMessageInjectedAtNextSafeBoundary(t *testing.T) {
135+ client := &queueFakeClient{}
136+ tool := &blockingTool{started: make(chan struct{}), release: make(chan struct{})}
137+ a := NewAgent(client, "fake-model", "system", Registry{"echo": tool})
138+
139+ var queued provider.Message
140+ done := make(chan error, 1)
141+ go func() {
142+ done <- a.Prompt(context.Background(), "inspect later", nil, func(ev AgentEvent) {
143+ if user, ok := ev.(EvUserMessage); ok && len(user.Message.Content) > 0 {
144+ if _, isImage := user.Message.Content[0].(provider.ImageBlock); isImage {
145+ queued = user.Message
146+ }
147+ }
148+ })
149+ }()
150+
151+ <-tool.started
152+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")}
153+ if !a.QueueMessage("", []provider.ImageBlock{image}) {
154+ t.Fatal("QueueMessage returned false for image-only prompt")
155+ }
156+ close(tool.release)
157+
158+ if err := <-done; err != nil {
159+ t.Fatalf("Prompt returned %v", err)
160+ }
161+ if len(queued.Content) != 1 {
162+ t.Fatalf("queued content = %#v, want one image", queued.Content)
163+ }
164+ got, ok := queued.Content[0].(provider.ImageBlock)
165+ if !ok || got.MimeType != image.MimeType || string(got.Data) != string(image.Data) {
166+ t.Fatalf("queued image = %#v, want %#v", queued.Content[0], image)
167+ }
168+}
169+
134170 func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) {
135171 a := NewAgent(nil, "fake", "", Registry{})
136172 before := time.Now()
137- if !a.QueueMessage("queued") {
173+ if !a.QueueMessage("queued", nil) {
138174 t.Fatal("QueueMessage returned false")
139175 }
140176 after := time.Now()
@@ -150,18 +186,18 @@ func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) {
150186
151187 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {
152188 a := NewAgent(nil, "fake", "", Registry{})
153- if a.QueueMessage(" ") {
189+ if a.QueueMessage(" ", nil) {
154190 t.Fatal("blank queue message accepted")
155191 }
156- a.QueueMessage("one")
157- a.QueueMessage("two")
158- if got := a.PendingQueuedMessages(); len(got) != 2 || got[0] != "one" || got[1] != "two" {
192+ a.QueueMessage("one", nil)
193+ a.QueueMessage("two", nil)
194+ if got := a.PendingQueuedMessages(); len(got) != 2 || got[0].Text != "one" || got[1].Text != "two" {
159195 t.Fatalf("PendingQueuedMessages = %v; want [one two]", got)
160196 }
161- if text, ok := a.PopQueuedMessage(); !ok || text != "two" {
162- t.Fatalf("PopQueuedMessage = %q,%v; want two,true", text, ok)
197+ if message, ok := a.PopQueuedMessage(); !ok || message.Text != "two" {
198+ t.Fatalf("PopQueuedMessage = %#v,%v; want two,true", message, ok)
163199 }
164- if got := a.DrainQueuedMessages(); len(got) != 1 || got[0] != "one" {
200+ if got := a.DrainQueuedMessages(); len(got) != 1 || got[0].Text != "one" {
165201 t.Fatalf("DrainQueuedMessages = %v; want [one]", got)
166202 }
167203 if got := a.QueuedMessageCount(); got != 0 {
@@ -100,7 +100,7 @@ func TestQueuedMessageInjectedAfterToolBatchBeforeNextModelCall(t *testing.T) {
100 }()100 }()
101 101
102 <-tool.started102 <-tool.started
103- if !a.QueueMessage("also do Y") {103+ if !a.QueueMessage("also do Y", nil) {
104 t.Fatal("QueueMessage returned false")104 t.Fatal("QueueMessage returned false")
105 }105 }
106 close(tool.release)106 close(tool.release)
@@ -131,10 +131,46 @@ func queueTestContains(xs []string, want string) bool {
131 return false131 return false
132 }132 }
133 133
134+func TestQueuedImageMessageInjectedAtNextSafeBoundary(t *testing.T) {
135+ client := &queueFakeClient{}
136+ tool := &blockingTool{started: make(chan struct{}), release: make(chan struct{})}
137+ a := NewAgent(client, "fake-model", "system", Registry{"echo": tool})
138+
139+ var queued provider.Message
140+ done := make(chan error, 1)
141+ go func() {
142+ done <- a.Prompt(context.Background(), "inspect later", nil, func(ev AgentEvent) {
143+ if user, ok := ev.(EvUserMessage); ok && len(user.Message.Content) > 0 {
144+ if _, isImage := user.Message.Content[0].(provider.ImageBlock); isImage {
145+ queued = user.Message
146+ }
147+ }
148+ })
149+ }()
150+
151+ <-tool.started
152+ image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")}
153+ if !a.QueueMessage("", []provider.ImageBlock{image}) {
154+ t.Fatal("QueueMessage returned false for image-only prompt")
155+ }
156+ close(tool.release)
157+
158+ if err := <-done; err != nil {
159+ t.Fatalf("Prompt returned %v", err)
160+ }
161+ if len(queued.Content) != 1 {
162+ t.Fatalf("queued content = %#v, want one image", queued.Content)
163+ }
164+ got, ok := queued.Content[0].(provider.ImageBlock)
165+ if !ok || got.MimeType != image.MimeType || string(got.Data) != string(image.Data) {
166+ t.Fatalf("queued image = %#v, want %#v", queued.Content[0], image)
167+ }
168+}
169+
134 func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) {170 func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) {
135 a := NewAgent(nil, "fake", "", Registry{})171 a := NewAgent(nil, "fake", "", Registry{})
136 before := time.Now()172 before := time.Now()
137- if !a.QueueMessage("queued") {173+ if !a.QueueMessage("queued", nil) {
138 t.Fatal("QueueMessage returned false")174 t.Fatal("QueueMessage returned false")
139 }175 }
140 after := time.Now()176 after := time.Now()
@@ -150,18 +186,18 @@ func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) {
150 186
151 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {187 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {
152 a := NewAgent(nil, "fake", "", Registry{})188 a := NewAgent(nil, "fake", "", Registry{})
153- if a.QueueMessage(" ") {189+ if a.QueueMessage(" ", nil) {
154 t.Fatal("blank queue message accepted")190 t.Fatal("blank queue message accepted")
155 }191 }
156- a.QueueMessage("one")192+ a.QueueMessage("one", nil)
157- a.QueueMessage("two")193+ a.QueueMessage("two", nil)
158- if got := a.PendingQueuedMessages(); len(got) != 2 || got[0] != "one" || got[1] != "two" {194+ if got := a.PendingQueuedMessages(); len(got) != 2 || got[0].Text != "one" || got[1].Text != "two" {
159 t.Fatalf("PendingQueuedMessages = %v; want [one two]", got)195 t.Fatalf("PendingQueuedMessages = %v; want [one two]", got)
160 }196 }
161- if text, ok := a.PopQueuedMessage(); !ok || text != "two" {197+ if message, ok := a.PopQueuedMessage(); !ok || message.Text != "two" {
162- t.Fatalf("PopQueuedMessage = %q,%v; want two,true", text, ok)198+ t.Fatalf("PopQueuedMessage = %#v,%v; want two,true", message, ok)
163 }199 }
164- if got := a.DrainQueuedMessages(); len(got) != 1 || got[0] != "one" {200+ if got := a.DrainQueuedMessages(); len(got) != 1 || got[0].Text != "one" {
165 t.Fatalf("DrainQueuedMessages = %v; want [one]", got)201 t.Fatalf("DrainQueuedMessages = %v; want [one]", got)
166 }202 }
167 if got := a.QueuedMessageCount(); got != 0 {203 if got := a.QueuedMessageCount(); got != 0 {

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