detached 835069e

fix(interactive): restore queued draft on escape

commit
835069e4e671e8584d40262f5f129378fe41b4e4
author
brice · · 2026-08-14 03:48 UTC
parent
8ae9a73
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +71 -1
M README.md +1 -1
@@ -887,7 +887,7 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run
887887
888888 ## Queued messages
889889
890-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 and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zut.
890+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.
891891
892892 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`.
893893
@@ -887,7 +887,7 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run
887 887
888 ## Queued messages888 ## Queued messages
889 889
890-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 and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zut.890+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.
891 891
892 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`.892 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`.
893 893
M packages/agent/modes/interactive.go +15 -0
@@ -3352,13 +3352,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
33523352 i.mu.Lock()
33533353 busyCancel := i.busy && i.cancelTurn != nil
33543354 cancelTurn := i.cancelTurn
3355+ var restoreQueued string
33553356 var handoff json.RawMessage
33563357 var persistHandoff bool
33573358 if busyCancel {
3359+ // Keep the most recently queued follow-up as an editable draft
3360+ // instead of losing it with the cancelled turn's stale queue.
3361+ if i.agent != nil {
3362+ restoreQueued, _ = i.agent.PopQueuedMessage()
3363+ }
3364+ if restoreQueued == "" && len(i.queued) > 0 {
3365+ n := len(i.queued) - 1
3366+ restoreQueued = i.queued[n]
3367+ i.queued = i.queued[:n]
3368+ }
33583369 handoff, persistHandoff = i.resetCompactContinuationLocked()
33593370 }
33603371 i.mu.Unlock()
33613372 if busyCancel {
3373+ if restoreQueued != "" {
3374+ i.ed.SetValue(restoreQueued)
3375+ i.inputHistoryIndex = -1
3376+ }
33623377 i.updateActiveGoal(core.GoalPaused, "interrupted by user")
33633378 cancelTurn()
33643379 // If a confirm dialog is pending, refuse it so the agent
@@ -3352,13 +3352,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3352 i.mu.Lock()3352 i.mu.Lock()
3353 busyCancel := i.busy && i.cancelTurn != nil3353 busyCancel := i.busy && i.cancelTurn != nil
3354 cancelTurn := i.cancelTurn3354 cancelTurn := i.cancelTurn
3355+ var restoreQueued string
3355 var handoff json.RawMessage3356 var handoff json.RawMessage
3356 var persistHandoff bool3357 var persistHandoff bool
3357 if busyCancel {3358 if busyCancel {
3359+ // Keep the most recently queued follow-up as an editable draft
3360+ // instead of losing it with the cancelled turn's stale queue.
3361+ if i.agent != nil {
3362+ restoreQueued, _ = i.agent.PopQueuedMessage()
3363+ }
3364+ if restoreQueued == "" && len(i.queued) > 0 {
3365+ n := len(i.queued) - 1
3366+ restoreQueued = i.queued[n]
3367+ i.queued = i.queued[:n]
3368+ }
3358 handoff, persistHandoff = i.resetCompactContinuationLocked()3369 handoff, persistHandoff = i.resetCompactContinuationLocked()
3359 }3370 }
3360 i.mu.Unlock()3371 i.mu.Unlock()
3361 if busyCancel {3372 if busyCancel {
3373+ if restoreQueued != "" {
3374+ i.ed.SetValue(restoreQueued)
3375+ i.inputHistoryIndex = -1
3376+ }
3362 i.updateActiveGoal(core.GoalPaused, "interrupted by user")3377 i.updateActiveGoal(core.GoalPaused, "interrupted by user")
3363 cancelTurn()3378 cancelTurn()
3364 // If a confirm dialog is pending, refuse it so the agent3379 // If a confirm dialog is pending, refuse it so the agent
A packages/agent/modes/interactive_queue_test.go +55 -0
@@ -0,0 +1,55 @@
1+package modes
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/tui"
9+)
10+
11+func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
12+ agent := core.NewAgent(nil, "test-model", "", nil)
13+ agent.QueueMessage("older follow-up")
14+ agent.QueueMessage("recover this draft")
15+
16+ i := NewInteractive(InteractiveConfig{Agent: agent})
17+ i.ed.SetValue("existing draft")
18+ cancelled := 0
19+ i.mu.Lock()
20+ i.busy = true
21+ i.cancelTurn = func() { cancelled++ }
22+ i.mu.Unlock()
23+
24+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
25+ t.Fatal("Escape exited")
26+ }
27+ if cancelled != 1 {
28+ t.Fatalf("cancelled = %d, want 1", cancelled)
29+ }
30+ if got, want := i.ed.Value(), "recover this draft"; got != want {
31+ t.Errorf("editor = %q, want %q", got, want)
32+ }
33+ if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0] != "older follow-up" {
34+ t.Errorf("remaining queued messages = %v, want [older follow-up]", got)
35+ }
36+}
37+
38+func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) {
39+ i := NewInteractive(InteractiveConfig{})
40+ cancelled := 0
41+ i.mu.Lock()
42+ i.busy = true
43+ i.cancelTurn = func() { cancelled++ }
44+ i.queued = []string{"recover this draft"}
45+ i.mu.Unlock()
46+
47+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
48+
49+ if cancelled != 1 {
50+ t.Fatalf("cancelled = %d, want 1", cancelled)
51+ }
52+ if got, want := i.ed.Value(), "recover this draft"; got != want {
53+ t.Errorf("editor = %q, want %q", got, want)
54+ }
55+}
@@ -0,0 +1,55 @@
1+package modes
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/tui"
9+)
10+
11+func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) {
12+ agent := core.NewAgent(nil, "test-model", "", nil)
13+ agent.QueueMessage("older follow-up")
14+ agent.QueueMessage("recover this draft")
15+
16+ i := NewInteractive(InteractiveConfig{Agent: agent})
17+ i.ed.SetValue("existing draft")
18+ cancelled := 0
19+ i.mu.Lock()
20+ i.busy = true
21+ i.cancelTurn = func() { cancelled++ }
22+ i.mu.Unlock()
23+
24+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
25+ t.Fatal("Escape exited")
26+ }
27+ if cancelled != 1 {
28+ t.Fatalf("cancelled = %d, want 1", cancelled)
29+ }
30+ if got, want := i.ed.Value(), "recover this draft"; got != want {
31+ t.Errorf("editor = %q, want %q", got, want)
32+ }
33+ if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0] != "older follow-up" {
34+ t.Errorf("remaining queued messages = %v, want [older follow-up]", got)
35+ }
36+}
37+
38+func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) {
39+ i := NewInteractive(InteractiveConfig{})
40+ cancelled := 0
41+ i.mu.Lock()
42+ i.busy = true
43+ i.cancelTurn = func() { cancelled++ }
44+ i.queued = []string{"recover this draft"}
45+ i.mu.Unlock()
46+
47+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
48+
49+ if cancelled != 1 {
50+ t.Fatalf("cancelled = %d, want 1", cancelled)
51+ }
52+ if got, want := i.ed.Value(), "recover this draft"; got != want {
53+ t.Errorf("editor = %q, want %q", got, want)
54+ }
55+}

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