detached 4b33cf8

feat(modes): queue image prompts while busy

commit
4b33cf8ce7a442b99b2dcde2b1117a7956d7c66c
author
brice · · 2026-08-18 15:14 UTC
parent
6fc319c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +241 -87
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/modes/clipboard_images.go +22 -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,26 @@ 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+ marker := fmt.Sprintf("[clipboard image #%d]", index+1)
41+ if text != "" {
42+ text += " "
43+ }
44+ text += marker
45+ attachments = append(attachments, clipboardImageAttachment{Marker: marker, Image: image})
46+ }
47+ i.clipboardImages = attachments
48+ i.ed.SetValue(text)
49+ i.inputHistoryIndex = -1
50+ return true
51+}
52+
3153 func removeClipboardMarker(text, marker string) string {
3254 for {
3355 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,26 @@ 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+ marker := fmt.Sprintf("[clipboard image #%d]", index+1)
41+ if text != "" {
42+ text += " "
43+ }
44+ text += marker
45+ attachments = append(attachments, clipboardImageAttachment{Marker: marker, Image: image})
46+ }
47+ i.clipboardImages = attachments
48+ i.ed.SetValue(text)
49+ i.inputHistoryIndex = -1
50+ return true
51+}
52+
31 func removeClipboardMarker(text, marker string) string {53 func removeClipboardMarker(text, marker string) string {
32 for {54 for {
33 idx := strings.Index(text, marker)55 idx := strings.Index(text, marker)
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 +3 -4
@@ -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 {
@@ -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 +15 -0
@@ -6,8 +6,23 @@ 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})
@@ -6,8 +6,23 @@ 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})
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 +47 -4
@@ -5,13 +5,56 @@ import (
55 "testing"
66
77 "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/provider"
89 "github.com/bnema/zut/packages/tui"
910 )
1011
12+func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) {
13+ agent := core.NewAgent(nil, "test-model", "", nil)
14+ i := NewInteractive(InteractiveConfig{Agent: agent})
15+ i.mu.Lock()
16+ i.busy = true
17+ i.mu.Unlock()
18+ i.ed.SetValue("inspect [clipboard image #1]")
19+ i.clipboardImages = []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
20+
21+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
22+
23+ queued := agent.PendingQueuedMessages()
24+ if len(queued) != 1 || queued[0].Text != "inspect" {
25+ t.Fatalf("queued messages = %#v, want one inspect prompt", queued)
26+ }
27+ if len(queued[0].Images) != 1 || string(queued[0].Images[0].Data) != "png-1" {
28+ t.Fatalf("queued images = %#v, want png-1", queued[0].Images)
29+ }
30+ if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 {
31+ t.Fatal("submitted image prompt remained in the editor")
32+ }
33+}
34+
35+func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) {
36+ agent := core.NewAgent(nil, "test-model", "", nil)
37+ image := testClipboardImage("unused", "png-1").Image
38+ agent.QueueMessage("inspect", []provider.ImageBlock{image})
39+ i := NewInteractive(InteractiveConfig{Agent: agent})
40+ i.mu.Lock()
41+ i.busy = true
42+ i.mu.Unlock()
43+
44+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
45+
46+ if got := i.ed.Value(); got != "inspect [clipboard image #1]" {
47+ t.Fatalf("editor = %q, want restored image marker", got)
48+ }
49+ if len(i.clipboardImages) != 1 || string(i.clipboardImages[0].Image.Data) != "png-1" {
50+ t.Fatalf("clipboard images = %#v, want png-1", i.clipboardImages)
51+ }
52+}
53+
1154 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
1255 agent := core.NewAgent(nil, "test-model", "", nil)
13- agent.QueueMessage("older follow-up")
14- agent.QueueMessage("recover this draft")
56+ agent.QueueMessage("older follow-up", nil)
57+ agent.QueueMessage("recover this draft", nil)
1558
1659 i := NewInteractive(InteractiveConfig{Agent: agent})
1760 i.ed.SetValue("existing draft")
@@ -30,7 +73,7 @@ func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
3073 if got, want := i.ed.Value(), "recover this draft"; got != want {
3174 t.Errorf("editor = %q, want %q", got, want)
3275 }
33- if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0] != "older follow-up" {
76+ if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0].Text != "older follow-up" {
3477 t.Errorf("remaining queued messages = %v, want [older follow-up]", got)
3578 }
3679 }
@@ -41,7 +84,7 @@ func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) {
4184 i.mu.Lock()
4285 i.busy = true
4386 i.cancelTurn = func() { cancelled++ }
44- i.queued = []string{"recover this draft"}
87+ i.queued = []core.QueuedMessage{{Text: "recover this draft"}}
4588 i.mu.Unlock()
4689
4790 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
@@ -5,13 +5,56 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/zut/packages/core"7 "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/provider"
8 "github.com/bnema/zut/packages/tui"9 "github.com/bnema/zut/packages/tui"
9 )10 )
10 11
12+func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) {
13+ agent := core.NewAgent(nil, "test-model", "", nil)
14+ i := NewInteractive(InteractiveConfig{Agent: agent})
15+ i.mu.Lock()
16+ i.busy = true
17+ i.mu.Unlock()
18+ i.ed.SetValue("inspect [clipboard image #1]")
19+ i.clipboardImages = []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")}
20+
21+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
22+
23+ queued := agent.PendingQueuedMessages()
24+ if len(queued) != 1 || queued[0].Text != "inspect" {
25+ t.Fatalf("queued messages = %#v, want one inspect prompt", queued)
26+ }
27+ if len(queued[0].Images) != 1 || string(queued[0].Images[0].Data) != "png-1" {
28+ t.Fatalf("queued images = %#v, want png-1", queued[0].Images)
29+ }
30+ if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 {
31+ t.Fatal("submitted image prompt remained in the editor")
32+ }
33+}
34+
35+func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) {
36+ agent := core.NewAgent(nil, "test-model", "", nil)
37+ image := testClipboardImage("unused", "png-1").Image
38+ agent.QueueMessage("inspect", []provider.ImageBlock{image})
39+ i := NewInteractive(InteractiveConfig{Agent: agent})
40+ i.mu.Lock()
41+ i.busy = true
42+ i.mu.Unlock()
43+
44+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
45+
46+ if got := i.ed.Value(); got != "inspect [clipboard image #1]" {
47+ t.Fatalf("editor = %q, want restored image marker", got)
48+ }
49+ if len(i.clipboardImages) != 1 || string(i.clipboardImages[0].Image.Data) != "png-1" {
50+ t.Fatalf("clipboard images = %#v, want png-1", i.clipboardImages)
51+ }
52+}
53+
11 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {54 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
12 agent := core.NewAgent(nil, "test-model", "", nil)55 agent := core.NewAgent(nil, "test-model", "", nil)
13- agent.QueueMessage("older follow-up")56+ agent.QueueMessage("older follow-up", nil)
14- agent.QueueMessage("recover this draft")57+ agent.QueueMessage("recover this draft", nil)
15 58
16 i := NewInteractive(InteractiveConfig{Agent: agent})59 i := NewInteractive(InteractiveConfig{Agent: agent})
17 i.ed.SetValue("existing draft")60 i.ed.SetValue("existing draft")
@@ -30,7 +73,7 @@ func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
30 if got, want := i.ed.Value(), "recover this draft"; got != want {73 if got, want := i.ed.Value(), "recover this draft"; got != want {
31 t.Errorf("editor = %q, want %q", got, want)74 t.Errorf("editor = %q, want %q", got, want)
32 }75 }
33- if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0] != "older follow-up" {76+ 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)77 t.Errorf("remaining queued messages = %v, want [older follow-up]", got)
35 }78 }
36 }79 }
@@ -41,7 +84,7 @@ func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) {
41 i.mu.Lock()84 i.mu.Lock()
42 i.busy = true85 i.busy = true
43 i.cancelTurn = func() { cancelled++ }86 i.cancelTurn = func() { cancelled++ }
44- i.queued = []string{"recover this draft"}87+ i.queued = []core.QueuedMessage{{Text: "recover this draft"}}
45 i.mu.Unlock()88 i.mu.Unlock()
46 89
47 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})90 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
M packages/agent/modes/interactive_rendering.go +18 -2
@@ -8,10 +8,26 @@ 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) string {
17+ text := message.Text
18+ if len(message.Images) == 0 {
19+ return text
20+ }
21+ imageLabel := "[image]"
22+ if len(message.Images) > 1 {
23+ imageLabel = fmt.Sprintf("[%d images]", len(message.Images))
24+ }
25+ if text == "" {
26+ return imageLabel
27+ }
28+ return text + " " + imageLabel
29+}
30+
1531 func (i *Interactive) cachedChatLocked(cols int) []string {
1632 // A busy frame contains mutable streaming/tool state. Keep the stable
1733 // transcript cache, but never return a full-frame cache entry that would
@@ -749,7 +765,7 @@ func (i *Interactive) redraw() {
749765 // in flight. Shown directly above the status bar so they're close
750766 // to the editor but don't push the chat around.
751767 var queue []string
752- queued := append([]string(nil), i.queued...)
768+ queued := append([]core.QueuedMessage(nil), i.queued...)
753769 if i.agent != nil {
754770 queued = append(queued, i.agent.PendingQueuedMessages()...)
755771 }
@@ -757,7 +773,7 @@ func (i *Interactive) redraw() {
757773 queue = append(queue, "")
758774 for _, q := range queued {
759775 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
760- text := truncateLine(q, mainCols-17)
776+ text := truncateLine(queuedMessageSummary(q), mainCols-17)
761777 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
762778 }
763779 // Hint row, rendered in the same muted tone as the model
@@ -8,10 +8,26 @@ 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) string {
17+ text := message.Text
18+ if len(message.Images) == 0 {
19+ return text
20+ }
21+ imageLabel := "[image]"
22+ if len(message.Images) > 1 {
23+ imageLabel = fmt.Sprintf("[%d images]", len(message.Images))
24+ }
25+ if text == "" {
26+ return imageLabel
27+ }
28+ return text + " " + imageLabel
29+}
30+
15 func (i *Interactive) cachedChatLocked(cols int) []string {31 func (i *Interactive) cachedChatLocked(cols int) []string {
16 // A busy frame contains mutable streaming/tool state. Keep the stable32 // A busy frame contains mutable streaming/tool state. Keep the stable
17 // transcript cache, but never return a full-frame cache entry that would33 // transcript cache, but never return a full-frame cache entry that would
@@ -749,7 +765,7 @@ func (i *Interactive) redraw() {
749 // in flight. Shown directly above the status bar so they're close765 // in flight. Shown directly above the status bar so they're close
750 // to the editor but don't push the chat around.766 // to the editor but don't push the chat around.
751 var queue []string767 var queue []string
752- queued := append([]string(nil), i.queued...)768+ queued := append([]core.QueuedMessage(nil), i.queued...)
753 if i.agent != nil {769 if i.agent != nil {
754 queued = append(queued, i.agent.PendingQueuedMessages()...)770 queued = append(queued, i.agent.PendingQueuedMessages()...)
755 }771 }
@@ -757,7 +773,7 @@ func (i *Interactive) redraw() {
757 queue = append(queue, "")773 queue = append(queue, "")
758 for _, q := range queued {774 for _, q := range queued {
759 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")775 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
760- text := truncateLine(q, mainCols-17)776+ text := truncateLine(queuedMessageSummary(q), mainCols-17)
761 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))777 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
762 }778 }
763 // Hint row, rendered in the same muted tone as the model779 // Hint row, rendered in the same muted tone as the model
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