detached 1ddba1e

fix(modes): recall queued user input without host reports (#235)

* fix(modes): recall queued user input without host reports

Skip host evidence when restoring queued drafts with Alt+Up or Escape. Show Alt+Shift+Up under Vev, which reserves Alt+Up for pane navigation.

* test(modes): synchronize scheduled repetition guard follow-up

Hold the eighth provider request until SubmitFollowUp registers its scheduled prompt, preventing the active turn from finishing before test setup completes.
commit
1ddba1e6d2a3b24332b4f54af87c749de680c823
author
bnema · · 2026-09-30 04:41 UTC
committer
GitHub · · 2026-09-30 04:41 UTC
parent
76a207d
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.47.0
10 files · +281 -29
M README.md +2 -2
@@ -989,9 +989,9 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run
989989
990990 ## Queued messages
991991
992-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.
992+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 user message and its image markers to the input for editing, skipping sub-agent reports and other host events. `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.
993993
994-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`.
994+To recover your most recently queued message back into the editor (to tweak it before it runs), press `Alt+↑` (`Option+↑` on macOS). In Vev, use `Alt+Shift+↑` because `Alt+↑` is reserved for pane navigation. In VS Code's integrated terminal on macOS, use `Option+Shift+↑`. zut's hint line adapts based on `$TERM_PROGRAM` and appears only when a user message is available to recover.
995995
996996 Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/info`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
997997
@@ -989,9 +989,9 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run
989 989
990 ## Queued messages990 ## Queued messages
991 991
992-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.992+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 user message and its image markers to the input for editing, skipping sub-agent reports and other host events. `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.
993 993
994-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`.994+To recover your most recently queued message back into the editor (to tweak it before it runs), press `Alt+↑` (`Option+↑` on macOS). In Vev, use `Alt+Shift+↑` because `Alt+↑` is reserved for pane navigation. In VS Code's integrated terminal on macOS, use `Option+Shift+↑`. zut's hint line adapts based on `$TERM_PROGRAM` and appears only when a user message is available to recover.
995 995
996 Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/info`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.996 Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/info`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
997 997
M packages/agent/modes/interactive_host.go +21 -0
@@ -332,6 +332,27 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput
332332 i.startQueuedTurn(i.runCtx, message)
333333 }
334334
335+// popQueuedUserMessageLocked leaves host reports queued for the agent.
336+// The caller must hold i.mu, as with the queue's enqueue and teardown paths.
337+func (i *Interactive) popQueuedUserMessageLocked() (core.QueuedMessage, bool) {
338+ if i.agent != nil {
339+ if message, ok := i.agent.PopQueuedUserMessage(); ok {
340+ return message, true
341+ }
342+ }
343+ for index := len(i.queued) - 1; index >= 0; index-- {
344+ if i.queued[index].HostEvent {
345+ continue
346+ }
347+ message := i.queued[index]
348+ copy(i.queued[index:], i.queued[index+1:])
349+ i.queued[len(i.queued)-1] = core.QueuedMessage{}
350+ i.queued = i.queued[:len(i.queued)-1]
351+ return message, true
352+ }
353+ return core.QueuedMessage{}, false
354+}
355+
335356 func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage {
336357 var pending []core.QueuedMessage
337358 if i.agent != nil {
@@ -332,6 +332,27 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput
332 i.startQueuedTurn(i.runCtx, message)332 i.startQueuedTurn(i.runCtx, message)
333 }333 }
334 334
335+// popQueuedUserMessageLocked leaves host reports queued for the agent.
336+// The caller must hold i.mu, as with the queue's enqueue and teardown paths.
337+func (i *Interactive) popQueuedUserMessageLocked() (core.QueuedMessage, bool) {
338+ if i.agent != nil {
339+ if message, ok := i.agent.PopQueuedUserMessage(); ok {
340+ return message, true
341+ }
342+ }
343+ for index := len(i.queued) - 1; index >= 0; index-- {
344+ if i.queued[index].HostEvent {
345+ continue
346+ }
347+ message := i.queued[index]
348+ copy(i.queued[index:], i.queued[index+1:])
349+ i.queued[len(i.queued)-1] = core.QueuedMessage{}
350+ i.queued = i.queued[:len(i.queued)-1]
351+ return message, true
352+ }
353+ return core.QueuedMessage{}, false
354+}
355+
335 func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage {356 func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage {
336 var pending []core.QueuedMessage357 var pending []core.QueuedMessage
337 if i.agent != nil {358 if i.agent != nil {
M packages/agent/modes/interactive_input_routing.go +3 -22
@@ -614,15 +614,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
614614 if busyCancel {
615615 // Keep the most recently queued follow-up as an editable draft
616616 // instead of losing it with the cancelled turn's stale queue.
617- if i.agent != nil {
618- restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage()
619- }
620- if !hasRestoreQueued && len(i.queued) > 0 {
621- n := len(i.queued) - 1
622- restoreQueued = i.queued[n]
623- i.queued = i.queued[:n]
624- hasRestoreQueued = true
625- }
617+ restoreQueued, hasRestoreQueued = i.popQueuedUserMessageLocked()
626618 handoff, persistHandoff = i.resetCompactContinuationLocked()
627619 }
628620 i.mu.Unlock()
@@ -670,25 +662,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
670662 return false
671663 case tui.KeyUp:
672664 // Alt/Option+Up: pop the most recently queued ("sliding in")
673- // message back into the editor so the user can edit and
665+ // user message (never host evidence) so the user can edit and
674666 // resend it. Repeated presses keep peeling messages off the
675667 // tail of the queue; each press *replaces* the editor
676668 // contents (we don't append/push). When the queue is empty
677669 // the keypress falls through to the normal scroll behavior.
678670 if k.Alt {
679671 i.mu.Lock()
680- var message core.QueuedMessage
681- var ok bool
682- if i.agent != nil {
683- message, ok = i.agent.PopQueuedMessage()
684- }
685- if !ok {
686- if n := len(i.queued); n > 0 {
687- message = i.queued[n-1]
688- i.queued = i.queued[:n-1]
689- ok = true
690- }
691- }
672+ message, ok := i.popQueuedUserMessageLocked()
692673 i.mu.Unlock()
693674 if ok && i.restoreQueuedMessageToEditor(message) {
694675 i.invalidate()
@@ -614,15 +614,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
614 if busyCancel {614 if busyCancel {
615 // Keep the most recently queued follow-up as an editable draft615 // Keep the most recently queued follow-up as an editable draft
616 // instead of losing it with the cancelled turn's stale queue.616 // instead of losing it with the cancelled turn's stale queue.
617- if i.agent != nil {617+ restoreQueued, hasRestoreQueued = i.popQueuedUserMessageLocked()
618- restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage()
619- }
620- if !hasRestoreQueued && len(i.queued) > 0 {
621- n := len(i.queued) - 1
622- restoreQueued = i.queued[n]
623- i.queued = i.queued[:n]
624- hasRestoreQueued = true
625- }
626 handoff, persistHandoff = i.resetCompactContinuationLocked()618 handoff, persistHandoff = i.resetCompactContinuationLocked()
627 }619 }
628 i.mu.Unlock()620 i.mu.Unlock()
@@ -670,25 +662,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
670 return false662 return false
671 case tui.KeyUp:663 case tui.KeyUp:
672 // Alt/Option+Up: pop the most recently queued ("sliding in")664 // Alt/Option+Up: pop the most recently queued ("sliding in")
673- // message back into the editor so the user can edit and665+ // user message (never host evidence) so the user can edit and
674 // resend it. Repeated presses keep peeling messages off the666 // resend it. Repeated presses keep peeling messages off the
675 // tail of the queue; each press *replaces* the editor667 // tail of the queue; each press *replaces* the editor
676 // contents (we don't append/push). When the queue is empty668 // contents (we don't append/push). When the queue is empty
677 // the keypress falls through to the normal scroll behavior.669 // the keypress falls through to the normal scroll behavior.
678 if k.Alt {670 if k.Alt {
679 i.mu.Lock()671 i.mu.Lock()
680- var message core.QueuedMessage672+ message, ok := i.popQueuedUserMessageLocked()
681- var ok bool
682- if i.agent != nil {
683- message, ok = i.agent.PopQueuedMessage()
684- }
685- if !ok {
686- if n := len(i.queued); n > 0 {
687- message = i.queued[n-1]
688- i.queued = i.queued[:n-1]
689- ok = true
690- }
691- }
692 i.mu.Unlock()673 i.mu.Unlock()
693 if ok && i.restoreQueuedMessageToEditor(message) {674 if ok && i.restoreQueuedMessageToEditor(message) {
694 i.invalidate()675 i.invalidate()
M packages/agent/modes/interactive_queue_test.go +141 -0
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "io"
56 "strings"
67 "testing"
78
@@ -68,6 +69,146 @@ func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) {
6869 }
6970 }
7071
72+func TestSlideBackSkipsHostEvents(t *testing.T) {
73+ for _, hostQueue := range []bool{false, true} {
74+ name := "agent queue"
75+ if hostQueue {
76+ name = "host queue"
77+ }
78+ t.Run(name, func(t *testing.T) {
79+ agent := core.NewAgent(nil, "test-model", "", nil)
80+ i := NewInteractive(InteractiveConfig{Agent: agent})
81+ i.busy = true
82+ messages := []core.QueuedMessage{
83+ {Text: "first report", HostEvent: true},
84+ {Text: "older draft"},
85+ {Text: "recover this draft"},
86+ {Text: "latest report", HostEvent: true},
87+ }
88+ if hostQueue {
89+ i.queued = messages
90+ } else {
91+ for _, message := range messages {
92+ agent.QueuePrompt(message)
93+ }
94+ }
95+ for _, want := range []string{"recover this draft", "older draft"} {
96+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
97+ if got := i.ed.Value(); got != want {
98+ t.Fatalf("editor = %q, want %q", got, want)
99+ }
100+ }
101+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
102+ if got := i.ed.Value(); got != "older draft" {
103+ t.Fatalf("host-only queue changed editor to %q", got)
104+ }
105+ pending := i.queued
106+ if !hostQueue {
107+ pending = agent.PendingQueuedMessages()
108+ }
109+ if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent {
110+ t.Fatalf("remaining reports = %#v, want both reports in original order", pending)
111+ }
112+ })
113+ }
114+}
115+
116+func TestSlideBackParsesModifiedArrow(t *testing.T) {
117+ for name, sequence := range map[string]string{
118+ "alt up": "\x1b[1;3A",
119+ "alt shift up": "\x1b[1;4A",
120+ } {
121+ t.Run(name, func(t *testing.T) {
122+ agent := core.NewAgent(nil, "test-model", "", nil)
123+ i := NewInteractive(InteractiveConfig{Agent: agent})
124+ i.busy = true
125+ i.ed.SetValue("recover this draft")
126+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
127+ agent.QueuePrompt(core.QueuedMessage{Text: "worker report", HostEvent: true})
128+ reader := tui.NewReader(func() (byte, error) {
129+ if sequence == "" {
130+ return 0, io.EOF
131+ }
132+ b := sequence[0]
133+ sequence = sequence[1:]
134+ return b, nil
135+ })
136+ key, err := reader.Read()
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ i.handleKey(context.Background(), key)
141+ if got := i.ed.Value(); got != "recover this draft" {
142+ t.Fatalf("editor = %q, want user draft", got)
143+ }
144+ pending := agent.PendingQueuedMessages()
145+ if len(pending) != 1 || !pending[0].HostEvent {
146+ t.Fatalf("pending = %#v, want worker report", pending)
147+ }
148+ })
149+ }
150+}
151+
152+func TestEscapeSkipsHostEvents(t *testing.T) {
153+ for _, hasUserMessage := range []bool{false, true} {
154+ for _, hostQueue := range []bool{false, true} {
155+ agent := core.NewAgent(nil, "test-model", "", nil)
156+ i := NewInteractive(InteractiveConfig{Agent: agent})
157+ i.busy = true
158+ i.cancelTurn = func() {}
159+ i.ed.SetValue("existing draft")
160+ messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}}
161+ want := "existing draft"
162+ if hasUserMessage {
163+ messages = append([]core.QueuedMessage{{Text: "recover this draft"}}, messages...)
164+ want = "recover this draft"
165+ }
166+ if hostQueue {
167+ i.queued = messages
168+ } else {
169+ for _, message := range messages {
170+ agent.QueuePrompt(message)
171+ }
172+ }
173+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
174+ if got := i.ed.Value(); got != want {
175+ t.Fatalf("user=%v hostQueue=%v: editor = %q, want %q", hasUserMessage, hostQueue, got, want)
176+ }
177+ }
178+ }
179+}
180+
181+func TestSlidingQueueHintOnlyForUserMessages(t *testing.T) {
182+ t.Setenv("TERM_PROGRAM", "vev")
183+ for _, hostQueue := range []bool{false, true} {
184+ for _, hasUserMessage := range []bool{false, true} {
185+ agent := core.NewAgent(nil, "test-model", "", nil)
186+ term := &alertTestTerminal{}
187+ i := NewInteractive(InteractiveConfig{Agent: agent, Terminal: term, Theme: tui.Dark})
188+ i.rend.Resize(80, 24)
189+ messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}}
190+ if hasUserMessage {
191+ messages = append(messages, core.QueuedMessage{Text: "user draft"})
192+ }
193+ if hostQueue {
194+ i.queued = messages
195+ } else {
196+ for _, message := range messages {
197+ agent.QueuePrompt(message)
198+ }
199+ }
200+ i.redraw()
201+ output := stripANSIBytes(term.String())
202+ if !strings.Contains(output, "worker report") {
203+ t.Fatal("host report missing from sliding queue")
204+ }
205+ if got := strings.Contains(output, "Press Alt+Shift+↑"); got != hasUserMessage {
206+ t.Fatalf("user=%v hostQueue=%v: recall hint visible=%v", hasUserMessage, hostQueue, got)
207+ }
208+ }
209+ }
210+}
211+
71212 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
72213 agent := core.NewAgent(nil, "test-model", "", nil)
73214 agent.QueueMessage("older follow-up", nil)
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "io"
5 "strings"6 "strings"
6 "testing"7 "testing"
7 8
@@ -68,6 +69,146 @@ func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) {
68 }69 }
69 }70 }
70 71
72+func TestSlideBackSkipsHostEvents(t *testing.T) {
73+ for _, hostQueue := range []bool{false, true} {
74+ name := "agent queue"
75+ if hostQueue {
76+ name = "host queue"
77+ }
78+ t.Run(name, func(t *testing.T) {
79+ agent := core.NewAgent(nil, "test-model", "", nil)
80+ i := NewInteractive(InteractiveConfig{Agent: agent})
81+ i.busy = true
82+ messages := []core.QueuedMessage{
83+ {Text: "first report", HostEvent: true},
84+ {Text: "older draft"},
85+ {Text: "recover this draft"},
86+ {Text: "latest report", HostEvent: true},
87+ }
88+ if hostQueue {
89+ i.queued = messages
90+ } else {
91+ for _, message := range messages {
92+ agent.QueuePrompt(message)
93+ }
94+ }
95+ for _, want := range []string{"recover this draft", "older draft"} {
96+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
97+ if got := i.ed.Value(); got != want {
98+ t.Fatalf("editor = %q, want %q", got, want)
99+ }
100+ }
101+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true})
102+ if got := i.ed.Value(); got != "older draft" {
103+ t.Fatalf("host-only queue changed editor to %q", got)
104+ }
105+ pending := i.queued
106+ if !hostQueue {
107+ pending = agent.PendingQueuedMessages()
108+ }
109+ if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent {
110+ t.Fatalf("remaining reports = %#v, want both reports in original order", pending)
111+ }
112+ })
113+ }
114+}
115+
116+func TestSlideBackParsesModifiedArrow(t *testing.T) {
117+ for name, sequence := range map[string]string{
118+ "alt up": "\x1b[1;3A",
119+ "alt shift up": "\x1b[1;4A",
120+ } {
121+ t.Run(name, func(t *testing.T) {
122+ agent := core.NewAgent(nil, "test-model", "", nil)
123+ i := NewInteractive(InteractiveConfig{Agent: agent})
124+ i.busy = true
125+ i.ed.SetValue("recover this draft")
126+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
127+ agent.QueuePrompt(core.QueuedMessage{Text: "worker report", HostEvent: true})
128+ reader := tui.NewReader(func() (byte, error) {
129+ if sequence == "" {
130+ return 0, io.EOF
131+ }
132+ b := sequence[0]
133+ sequence = sequence[1:]
134+ return b, nil
135+ })
136+ key, err := reader.Read()
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ i.handleKey(context.Background(), key)
141+ if got := i.ed.Value(); got != "recover this draft" {
142+ t.Fatalf("editor = %q, want user draft", got)
143+ }
144+ pending := agent.PendingQueuedMessages()
145+ if len(pending) != 1 || !pending[0].HostEvent {
146+ t.Fatalf("pending = %#v, want worker report", pending)
147+ }
148+ })
149+ }
150+}
151+
152+func TestEscapeSkipsHostEvents(t *testing.T) {
153+ for _, hasUserMessage := range []bool{false, true} {
154+ for _, hostQueue := range []bool{false, true} {
155+ agent := core.NewAgent(nil, "test-model", "", nil)
156+ i := NewInteractive(InteractiveConfig{Agent: agent})
157+ i.busy = true
158+ i.cancelTurn = func() {}
159+ i.ed.SetValue("existing draft")
160+ messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}}
161+ want := "existing draft"
162+ if hasUserMessage {
163+ messages = append([]core.QueuedMessage{{Text: "recover this draft"}}, messages...)
164+ want = "recover this draft"
165+ }
166+ if hostQueue {
167+ i.queued = messages
168+ } else {
169+ for _, message := range messages {
170+ agent.QueuePrompt(message)
171+ }
172+ }
173+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
174+ if got := i.ed.Value(); got != want {
175+ t.Fatalf("user=%v hostQueue=%v: editor = %q, want %q", hasUserMessage, hostQueue, got, want)
176+ }
177+ }
178+ }
179+}
180+
181+func TestSlidingQueueHintOnlyForUserMessages(t *testing.T) {
182+ t.Setenv("TERM_PROGRAM", "vev")
183+ for _, hostQueue := range []bool{false, true} {
184+ for _, hasUserMessage := range []bool{false, true} {
185+ agent := core.NewAgent(nil, "test-model", "", nil)
186+ term := &alertTestTerminal{}
187+ i := NewInteractive(InteractiveConfig{Agent: agent, Terminal: term, Theme: tui.Dark})
188+ i.rend.Resize(80, 24)
189+ messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}}
190+ if hasUserMessage {
191+ messages = append(messages, core.QueuedMessage{Text: "user draft"})
192+ }
193+ if hostQueue {
194+ i.queued = messages
195+ } else {
196+ for _, message := range messages {
197+ agent.QueuePrompt(message)
198+ }
199+ }
200+ i.redraw()
201+ output := stripANSIBytes(term.String())
202+ if !strings.Contains(output, "worker report") {
203+ t.Fatal("host report missing from sliding queue")
204+ }
205+ if got := strings.Contains(output, "Press Alt+Shift+↑"); got != hasUserMessage {
206+ t.Fatalf("user=%v hostQueue=%v: recall hint visible=%v", hasUserMessage, hostQueue, got)
207+ }
208+ }
209+ }
210+}
211+
71 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {212 func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
72 agent := core.NewAgent(nil, "test-model", "", nil)213 agent := core.NewAgent(nil, "test-model", "", nil)
73 agent.QueueMessage("older follow-up", nil)214 agent.QueueMessage("older follow-up", nil)
M packages/agent/modes/interactive_rendering.go +6 -2
@@ -717,7 +717,9 @@ func (i *Interactive) redraw() {
717717 }
718718 if len(queued) > 0 {
719719 queue = append(queue, "")
720+ hasUserMessage := false
720721 for _, q := range queued {
722+ hasUserMessage = hasUserMessage || !q.HostEvent
721723 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
722724 text := queuedMessageSummary(q, mainCols-17)
723725 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
@@ -726,8 +728,10 @@ func (i *Interactive) redraw() {
726728 // info on the status bar so it reads as ambient metadata
727729 // rather than a chip. Tells the user how to recover the
728730 // most recent queued message back into the editor.
729- hint := " Press " + slideBackChordHint() + " to slide back into input"
730- queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
731+ if hasUserMessage {
732+ hint := " Press " + slideBackChordHint() + " to slide your message back into input"
733+ queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
734+ }
731735 }
732736
733737 extensionLines := i.extensionChromeLinesLocked(mainCols)
@@ -717,7 +717,9 @@ func (i *Interactive) redraw() {
717 }717 }
718 if len(queued) > 0 {718 if len(queued) > 0 {
719 queue = append(queue, "")719 queue = append(queue, "")
720+ hasUserMessage := false
720 for _, q := range queued {721 for _, q := range queued {
722+ hasUserMessage = hasUserMessage || !q.HostEvent
721 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")723 label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
722 text := queuedMessageSummary(q, mainCols-17)724 text := queuedMessageSummary(q, mainCols-17)
723 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))725 queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
@@ -726,8 +728,10 @@ func (i *Interactive) redraw() {
726 // info on the status bar so it reads as ambient metadata728 // info on the status bar so it reads as ambient metadata
727 // rather than a chip. Tells the user how to recover the729 // rather than a chip. Tells the user how to recover the
728 // most recent queued message back into the editor.730 // most recent queued message back into the editor.
729- hint := " Press " + slideBackChordHint() + " to slide back into input"731+ if hasUserMessage {
730- queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))732+ hint := " Press " + slideBackChordHint() + " to slide your message back into input"
733+ queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
734+ }
731 }735 }
732 736
733 extensionLines := i.extensionChromeLinesLocked(mainCols)737 extensionLines := i.extensionChromeLinesLocked(mainCols)
M packages/agent/modes/keychord_hint.go +6 -0
@@ -26,12 +26,18 @@ import (
2626 // alt=true, so the binding fires; we just need to tell the
2727 // user the right chord.
2828 //
29+// - Vev reserves Alt+Up for pane navigation. Alt+Shift+Up is
30+// forwarded to the child process as \x1b[1;4A instead.
31+//
2932 // Detection is by $TERM_PROGRAM; VS Code sets this to "vscode" on
3033 // every platform it ships an integrated terminal on. The decoder
3134 // accepts both chords on any terminal, so getting the env-var sniff
3235 // wrong only ever shows a slightly-off hint string — never breaks
3336 // the binding itself.
3437 func slideBackChordHint() string {
38+ if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vev") {
39+ return "Alt+Shift+↑"
40+ }
3541 if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") {
3642 return "Option+Shift+↑"
3743 }
@@ -26,12 +26,18 @@ import (
26 // alt=true, so the binding fires; we just need to tell the26 // alt=true, so the binding fires; we just need to tell the
27 // user the right chord.27 // user the right chord.
28 //28 //
29+// - Vev reserves Alt+Up for pane navigation. Alt+Shift+Up is
30+// forwarded to the child process as \x1b[1;4A instead.
31+//
29 // Detection is by $TERM_PROGRAM; VS Code sets this to "vscode" on32 // Detection is by $TERM_PROGRAM; VS Code sets this to "vscode" on
30 // every platform it ships an integrated terminal on. The decoder33 // every platform it ships an integrated terminal on. The decoder
31 // accepts both chords on any terminal, so getting the env-var sniff34 // accepts both chords on any terminal, so getting the env-var sniff
32 // wrong only ever shows a slightly-off hint string — never breaks35 // wrong only ever shows a slightly-off hint string — never breaks
33 // the binding itself.36 // the binding itself.
34 func slideBackChordHint() string {37 func slideBackChordHint() string {
38+ if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vev") {
39+ return "Alt+Shift+↑"
40+ }
35 if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") {41 if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") {
36 return "Option+Shift+↑"42 return "Option+Shift+↑"
37 }43 }
M packages/agent/modes/keychord_hint_test.go +11 -0
@@ -27,6 +27,17 @@ func TestSlideBackChordHintVSCodeCaseInsensitive(t *testing.T) {
2727 }
2828 }
2929
30+func TestSlideBackChordHintVev(t *testing.T) {
31+ for _, value := range []string{"vev", "VEV"} {
32+ t.Run(value, func(t *testing.T) {
33+ t.Setenv("TERM_PROGRAM", value)
34+ if got, want := slideBackChordHint(), "Alt+Shift+↑"; got != want {
35+ t.Errorf("Vev hint = %q; want %q", got, want)
36+ }
37+ })
38+ }
39+}
40+
3041 // TestSlideBackChordHintDefault pins the default for every
3142 // non-VS-Code terminal: the snappier Option+↑ chord, which Ghostty,
3243 // iTerm2 (with Meta=Option), Terminal.app (with Use Option as Meta),
@@ -27,6 +27,17 @@ func TestSlideBackChordHintVSCodeCaseInsensitive(t *testing.T) {
27 }27 }
28 }28 }
29 29
30+func TestSlideBackChordHintVev(t *testing.T) {
31+ for _, value := range []string{"vev", "VEV"} {
32+ t.Run(value, func(t *testing.T) {
33+ t.Setenv("TERM_PROGRAM", value)
34+ if got, want := slideBackChordHint(), "Alt+Shift+↑"; got != want {
35+ t.Errorf("Vev hint = %q; want %q", got, want)
36+ }
37+ })
38+ }
39+}
40+
30 // TestSlideBackChordHintDefault pins the default for every41 // TestSlideBackChordHintDefault pins the default for every
31 // non-VS-Code terminal: the snappier Option+↑ chord, which Ghostty,42 // non-VS-Code terminal: the snappier Option+↑ chord, which Ghostty,
32 // iTerm2 (with Meta=Option), Terminal.app (with Use Option as Meta),43 // iTerm2 (with Meta=Option), Terminal.app (with Use Option as Meta),
M packages/agent/modes/repetition_guard_test.go +39 -1
@@ -168,16 +168,53 @@ func TestInteractiveRepetitionGuardCanResumeStalledGoal(t *testing.T) {
168168 }
169169
170170 func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) {
171- client := &repetitiveInteractiveClient{}
171+ client := &repetitiveInteractiveClient{
172+ entered: make(chan struct{}),
173+ resume: make(chan struct{}),
174+ }
175+ defer func() {
176+ select {
177+ case <-client.resume:
178+ default:
179+ close(client.resume)
180+ }
181+ }()
172182 agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
173183 interactive := NewInteractive(InteractiveConfig{Agent: agent})
174184 ctx, cancel := context.WithCancel(context.Background())
175185 defer cancel()
176186
177187 interactive.startTurn(ctx, "start")
188+ select {
189+ case <-client.entered:
190+ case <-time.After(3 * time.Second):
191+ t.Fatal("eighth provider request did not start")
192+ }
178193 followUp := make(chan error, 1)
179194 go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }()
180195
196+ // SubmitFollowUp blocks until teardown resolves it. Hold the eighth
197+ // provider call until the scheduled prompt is actually registered, not
198+ // merely until its goroutine has been launched.
199+ deadline := time.NewTimer(3 * time.Second)
200+ defer deadline.Stop()
201+ poll := time.NewTicker(time.Millisecond)
202+ defer poll.Stop()
203+ for {
204+ interactive.mu.Lock()
205+ queued := len(interactive.scheduled) == 1
206+ interactive.mu.Unlock()
207+ if queued {
208+ break
209+ }
210+ select {
211+ case <-deadline.C:
212+ t.Fatal("scheduled follow-up did not enter the queue")
213+ case <-poll.C:
214+ }
215+ }
216+ close(client.resume)
217+
181218 select {
182219 case err := <-followUp:
183220 if !errors.Is(err, core.ErrRepetitiveLoop) {
@@ -186,6 +223,7 @@ func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) {
186223 case <-time.After(3 * time.Second):
187224 t.Fatal("scheduled follow-up remained blocked after repetitive loop stop")
188225 }
226+ waitInteractiveIdle(t, interactive)
189227 if got := client.calls.Load(); got != 8 {
190228 t.Fatalf("provider calls = %d, want 8", got)
191229 }
@@ -168,16 +168,53 @@ func TestInteractiveRepetitionGuardCanResumeStalledGoal(t *testing.T) {
168 }168 }
169 169
170 func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) {170 func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) {
171- client := &repetitiveInteractiveClient{}171+ client := &repetitiveInteractiveClient{
172+ entered: make(chan struct{}),
173+ resume: make(chan struct{}),
174+ }
175+ defer func() {
176+ select {
177+ case <-client.resume:
178+ default:
179+ close(client.resume)
180+ }
181+ }()
172 agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})182 agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
173 interactive := NewInteractive(InteractiveConfig{Agent: agent})183 interactive := NewInteractive(InteractiveConfig{Agent: agent})
174 ctx, cancel := context.WithCancel(context.Background())184 ctx, cancel := context.WithCancel(context.Background())
175 defer cancel()185 defer cancel()
176 186
177 interactive.startTurn(ctx, "start")187 interactive.startTurn(ctx, "start")
188+ select {
189+ case <-client.entered:
190+ case <-time.After(3 * time.Second):
191+ t.Fatal("eighth provider request did not start")
192+ }
178 followUp := make(chan error, 1)193 followUp := make(chan error, 1)
179 go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }()194 go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }()
180 195
196+ // SubmitFollowUp blocks until teardown resolves it. Hold the eighth
197+ // provider call until the scheduled prompt is actually registered, not
198+ // merely until its goroutine has been launched.
199+ deadline := time.NewTimer(3 * time.Second)
200+ defer deadline.Stop()
201+ poll := time.NewTicker(time.Millisecond)
202+ defer poll.Stop()
203+ for {
204+ interactive.mu.Lock()
205+ queued := len(interactive.scheduled) == 1
206+ interactive.mu.Unlock()
207+ if queued {
208+ break
209+ }
210+ select {
211+ case <-deadline.C:
212+ t.Fatal("scheduled follow-up did not enter the queue")
213+ case <-poll.C:
214+ }
215+ }
216+ close(client.resume)
217+
181 select {218 select {
182 case err := <-followUp:219 case err := <-followUp:
183 if !errors.Is(err, core.ErrRepetitiveLoop) {220 if !errors.Is(err, core.ErrRepetitiveLoop) {
@@ -186,6 +223,7 @@ func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) {
186 case <-time.After(3 * time.Second):223 case <-time.After(3 * time.Second):
187 t.Fatal("scheduled follow-up remained blocked after repetitive loop stop")224 t.Fatal("scheduled follow-up remained blocked after repetitive loop stop")
188 }225 }
226+ waitInteractiveIdle(t, interactive)
189 if got := client.calls.Load(); got != 8 {227 if got := client.calls.Load(); got != 8 {
190 t.Fatalf("provider calls = %d, want 8", got)228 t.Fatalf("provider calls = %d, want 8", got)
191 }229 }
M packages/core/agent.go +20 -2
@@ -344,8 +344,8 @@ func (a *Agent) QueuedMessageCount() int {
344344 return len(a.queued)
345345 }
346346
347-// PopQueuedMessage removes and returns the most recently queued
348-// message. Hosts use this for the slide-back keybinding.
347+// PopQueuedMessage removes and returns the most recently queued message,
348+// including host events.
349349 func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) {
350350 a.mu.Lock()
351351 defer a.mu.Unlock()
@@ -358,6 +358,24 @@ func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) {
358358 return message, true
359359 }
360360
361+// PopQueuedUserMessage removes the most recently queued ordinary input,
362+// leaving host evidence in place and preserving the remaining queue order.
363+func (a *Agent) PopQueuedUserMessage() (QueuedMessage, bool) {
364+ a.mu.Lock()
365+ defer a.mu.Unlock()
366+ for index := len(a.queued) - 1; index >= 0; index-- {
367+ if a.queued[index].message.HostEvent {
368+ continue
369+ }
370+ message := cloneQueuedMessage(a.queued[index].message)
371+ copy(a.queued[index:], a.queued[index+1:])
372+ a.queued[len(a.queued)-1] = queuedMessage{}
373+ a.queued = a.queued[:len(a.queued)-1]
374+ return message, true
375+ }
376+ return QueuedMessage{}, false
377+}
378+
361379 // DrainQueuedMessages discards and returns every queued message.
362380 // Hosts use this on explicit cancel/clear so stale follow-ups do
363381 // not run after the user aborted the turn.
@@ -344,8 +344,8 @@ func (a *Agent) QueuedMessageCount() int {
344 return len(a.queued)344 return len(a.queued)
345 }345 }
346 346
347-// PopQueuedMessage removes and returns the most recently queued347+// PopQueuedMessage removes and returns the most recently queued message,
348-// message. Hosts use this for the slide-back keybinding.348+// including host events.
349 func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) {349 func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) {
350 a.mu.Lock()350 a.mu.Lock()
351 defer a.mu.Unlock()351 defer a.mu.Unlock()
@@ -358,6 +358,24 @@ func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) {
358 return message, true358 return message, true
359 }359 }
360 360
361+// PopQueuedUserMessage removes the most recently queued ordinary input,
362+// leaving host evidence in place and preserving the remaining queue order.
363+func (a *Agent) PopQueuedUserMessage() (QueuedMessage, bool) {
364+ a.mu.Lock()
365+ defer a.mu.Unlock()
366+ for index := len(a.queued) - 1; index >= 0; index-- {
367+ if a.queued[index].message.HostEvent {
368+ continue
369+ }
370+ message := cloneQueuedMessage(a.queued[index].message)
371+ copy(a.queued[index:], a.queued[index+1:])
372+ a.queued[len(a.queued)-1] = queuedMessage{}
373+ a.queued = a.queued[:len(a.queued)-1]
374+ return message, true
375+ }
376+ return QueuedMessage{}, false
377+}
378+
361 // DrainQueuedMessages discards and returns every queued message.379 // DrainQueuedMessages discards and returns every queued message.
362 // Hosts use this on explicit cancel/clear so stale follow-ups do380 // Hosts use this on explicit cancel/clear so stale follow-ups do
363 // not run after the user aborted the turn.381 // not run after the user aborted the turn.
M packages/core/queue_test.go +32 -0
@@ -203,6 +203,38 @@ func TestQueuePromptPreservesHostProvenance(t *testing.T) {
203203 }
204204 }
205205
206+func TestPopQueuedUserMessagePreservesHostEvents(t *testing.T) {
207+ a := NewAgent(nil, "fake", "", Registry{})
208+ a.QueuePrompt(QueuedMessage{Text: "first report", HostEvent: true})
209+ a.QueueMessage("older draft", nil)
210+ a.QueueMessage("newest draft", []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}})
211+ a.QueuePrompt(QueuedMessage{Text: "latest report", HostEvent: true})
212+ before := append([]queuedMessage(nil), a.queued...)
213+
214+ message, ok := a.PopQueuedUserMessage()
215+ if !ok || message.Text != "newest draft" || len(message.Images) != 1 || string(message.Images[0].Data) != "png-1" {
216+ t.Fatalf("popped = %#v, %v, want newest draft with image", message, ok)
217+ }
218+ for index, original := range []int{0, 1, 3} {
219+ if !a.queued[index].accepted.Equal(before[original].accepted) {
220+ t.Fatal("recall changed a remaining message's accepted time")
221+ }
222+ }
223+ if message, ok := a.PopQueuedUserMessage(); !ok || message.Text != "older draft" {
224+ t.Fatalf("second pop = %#v, %v, want older draft", message, ok)
225+ }
226+ if message, ok := a.PopQueuedUserMessage(); ok {
227+ t.Fatalf("host-only pop = %#v, want no user message", message)
228+ }
229+ pending := a.DrainQueuedMessages()
230+ if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent {
231+ t.Fatalf("remaining reports = %#v, want original reports in order", pending)
232+ }
233+ if _, ok := a.PopQueuedUserMessage(); ok {
234+ t.Fatal("empty pop succeeded")
235+ }
236+}
237+
206238 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {
207239 a := NewAgent(nil, "fake", "", Registry{})
208240 if a.QueueMessage(" ", nil) {
@@ -203,6 +203,38 @@ func TestQueuePromptPreservesHostProvenance(t *testing.T) {
203 }203 }
204 }204 }
205 205
206+func TestPopQueuedUserMessagePreservesHostEvents(t *testing.T) {
207+ a := NewAgent(nil, "fake", "", Registry{})
208+ a.QueuePrompt(QueuedMessage{Text: "first report", HostEvent: true})
209+ a.QueueMessage("older draft", nil)
210+ a.QueueMessage("newest draft", []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}})
211+ a.QueuePrompt(QueuedMessage{Text: "latest report", HostEvent: true})
212+ before := append([]queuedMessage(nil), a.queued...)
213+
214+ message, ok := a.PopQueuedUserMessage()
215+ if !ok || message.Text != "newest draft" || len(message.Images) != 1 || string(message.Images[0].Data) != "png-1" {
216+ t.Fatalf("popped = %#v, %v, want newest draft with image", message, ok)
217+ }
218+ for index, original := range []int{0, 1, 3} {
219+ if !a.queued[index].accepted.Equal(before[original].accepted) {
220+ t.Fatal("recall changed a remaining message's accepted time")
221+ }
222+ }
223+ if message, ok := a.PopQueuedUserMessage(); !ok || message.Text != "older draft" {
224+ t.Fatalf("second pop = %#v, %v, want older draft", message, ok)
225+ }
226+ if message, ok := a.PopQueuedUserMessage(); ok {
227+ t.Fatalf("host-only pop = %#v, want no user message", message)
228+ }
229+ pending := a.DrainQueuedMessages()
230+ if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent {
231+ t.Fatalf("remaining reports = %#v, want original reports in order", pending)
232+ }
233+ if _, ok := a.PopQueuedUserMessage(); ok {
234+ t.Fatal("empty pop succeeded")
235+ }
236+}
237+
206 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {238 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {
207 a := NewAgent(nil, "fake", "", Registry{})239 a := NewAgent(nil, "fake", "", Registry{})
208 if a.QueueMessage(" ", nil) {240 if a.QueueMessage(" ", 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