detached 125f053

fix(modes): preserve queued image context

commit
125f053bce8610ff724e5d6e9914831872de4f6e
author
brice · · 2026-08-18 15:41 UTC
parent
4b33cf8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +103 -21
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 +6 -1
@@ -37,7 +37,12 @@ func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) b
3737 text := message.Text
3838 attachments := make([]clipboardImageAttachment, 0, len(message.Images))
3939 for index, image := range message.Images {
40- marker := fmt.Sprintf("[clipboard image #%d]", index+1)
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+ }
4146 if text != "" {
4247 text += " "
4348 }
@@ -37,7 +37,12 @@ func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) b
37 text := message.Text37 text := message.Text
38 attachments := make([]clipboardImageAttachment, 0, len(message.Images))38 attachments := make([]clipboardImageAttachment, 0, len(message.Images))
39 for index, image := range message.Images {39 for index, image := range message.Images {
40- marker := fmt.Sprintf("[clipboard image #%d]", index+1)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+ }
41 if text != "" {46 if text != "" {
42 text += " "47 text += " "
43 }48 }
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/interactive_host.go +1 -1
@@ -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;
@@ -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;
M packages/agent/modes/interactive_host_test.go +5 -1
@@ -29,7 +29,8 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {
2929 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"})
3030 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished})
3131
32- 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)
3334
3435 actions := i.applyCoordinator(orchestration.Event{
3536 Kind: orchestration.EventWorkerFinished,
@@ -45,4 +46,7 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {
4546 if got := actions[0].Text; got != "review the result" {
4647 t.Fatalf("completion action text = %q, want queued user input", got)
4748 }
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+ }
4852 }
@@ -29,7 +29,8 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {
29 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"})29 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"})
30 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished})30 i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished})
31 31
32- 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)
33 34
34 actions := i.applyCoordinator(orchestration.Event{35 actions := i.applyCoordinator(orchestration.Event{
35 Kind: orchestration.EventWorkerFinished,36 Kind: orchestration.EventWorkerFinished,
@@ -45,4 +46,7 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) {
45 if got := actions[0].Text; got != "review the result" {46 if got := actions[0].Text; got != "review the result" {
46 t.Fatalf("completion action text = %q, want queued user input", got)47 t.Fatalf("completion action text = %q, want queued user input", got)
47 }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+ }
48 }52 }
M packages/agent/modes/interactive_queue_test.go +17 -0
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/zut/packages/core"
@@ -9,6 +10,22 @@ import (
910 "github.com/bnema/zut/packages/tui"
1011 )
1112
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+
1229 func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) {
1330 agent := core.NewAgent(nil, "test-model", "", nil)
1431 i := NewInteractive(InteractiveConfig{Agent: agent})
@@ -2,6 +2,7 @@ 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,6 +10,22 @@ import (
9 "github.com/bnema/zut/packages/tui"10 "github.com/bnema/zut/packages/tui"
10 )11 )
11 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+
12 func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) {29 func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) {
13 agent := core.NewAgent(nil, "test-model", "", nil)30 agent := core.NewAgent(nil, "test-model", "", nil)
14 i := NewInteractive(InteractiveConfig{Agent: agent})31 i := NewInteractive(InteractiveConfig{Agent: agent})
M packages/agent/modes/interactive_rendering.go +7 -4
@@ -13,15 +13,18 @@ import (
1313 "github.com/bnema/zut/packages/tui"
1414 )
1515
16-func queuedMessageSummary(message core.QueuedMessage) string {
17- text := message.Text
16+func queuedMessageSummary(message core.QueuedMessage, maxWidth int) string {
1817 if len(message.Images) == 0 {
19- return text
18+ return truncateLine(message.Text, maxWidth)
2019 }
2120 imageLabel := "[image]"
2221 if len(message.Images) > 1 {
2322 imageLabel = fmt.Sprintf("[%d images]", len(message.Images))
2423 }
24+ if message.Text == "" || maxWidth <= len(imageLabel) {
25+ return truncateLine(imageLabel, maxWidth)
26+ }
27+ text := truncateLine(message.Text, maxWidth-len(imageLabel)-1)
2528 if text == "" {
2629 return imageLabel
2730 }
@@ -773,7 +776,7 @@ func (i *Interactive) redraw() {
773776 queue = append(queue, "")
774777 for _, q := range queued {
775778 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
776- text := truncateLine(queuedMessageSummary(q), mainCols-17)
779+ text := queuedMessageSummary(q, mainCols-17)
777780 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
778781 }
779782 // Hint row, rendered in the same muted tone as the model
@@ -13,15 +13,18 @@ import (
13 "github.com/bnema/zut/packages/tui"13 "github.com/bnema/zut/packages/tui"
14 )14 )
15 15
16-func queuedMessageSummary(message core.QueuedMessage) string {16+func queuedMessageSummary(message core.QueuedMessage, maxWidth int) string {
17- text := message.Text
18 if len(message.Images) == 0 {17 if len(message.Images) == 0 {
19- return text18+ return truncateLine(message.Text, maxWidth)
20 }19 }
21 imageLabel := "[image]"20 imageLabel := "[image]"
22 if len(message.Images) > 1 {21 if len(message.Images) > 1 {
23 imageLabel = fmt.Sprintf("[%d images]", len(message.Images))22 imageLabel = fmt.Sprintf("[%d images]", len(message.Images))
24 }23 }
24+ if message.Text == "" || maxWidth <= len(imageLabel) {
25+ return truncateLine(imageLabel, maxWidth)
26+ }
27+ text := truncateLine(message.Text, maxWidth-len(imageLabel)-1)
25 if text == "" {28 if text == "" {
26 return imageLabel29 return imageLabel
27 }30 }
@@ -773,7 +776,7 @@ func (i *Interactive) redraw() {
773 queue = append(queue, "")776 queue = append(queue, "")
774 for _, q := range queued {777 for _, q := range queued {
775 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")778 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
776- text := truncateLine(queuedMessageSummary(q), mainCols-17)779+ text := queuedMessageSummary(q, mainCols-17)
777 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))
778 }781 }
779 // 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 {

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