Merge pull request #129 from bnema/fix/restore-held-message-on-escape
fix(interactive): restore queued draft on escape
3 files · +71 -1
M README.md +1 -1
| @@ -887,7 +887,7 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run | |||
| 887 | 887 | ||
| 888 | 888 | ## 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 | 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 | ||
| @@ -887,7 +887,7 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run | |||||
| 887 | 887 | ||||
| 888 | ## Queued messages | 888 | ## 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) { | |||
| 3352 | 3352 | i.mu.Lock() | |
| 3353 | 3353 | busyCancel := i.busy && i.cancelTurn != nil | |
| 3354 | 3354 | cancelTurn := i.cancelTurn | |
| 3355 | + | var restoreQueued string | |
| 3355 | 3356 | var handoff json.RawMessage | |
| 3356 | 3357 | var persistHandoff bool | |
| 3357 | 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 | 3369 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 3359 | 3370 | } | |
| 3360 | 3371 | i.mu.Unlock() | |
| 3361 | 3372 | if busyCancel { | |
| 3373 | + | if restoreQueued != "" { | |
| 3374 | + | i.ed.SetValue(restoreQueued) | |
| 3375 | + | i.inputHistoryIndex = -1 | |
| 3376 | + | } | |
| 3362 | 3377 | i.updateActiveGoal(core.GoalPaused, "interrupted by user") | |
| 3363 | 3378 | cancelTurn() | |
| 3364 | 3379 | // 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 != nil | 3353 | busyCancel := i.busy && i.cancelTurn != nil | ||
| 3354 | cancelTurn := i.cancelTurn | 3354 | cancelTurn := i.cancelTurn | ||
| 3355 | + | var restoreQueued string | |||
| 3355 | var handoff json.RawMessage | 3356 | var handoff json.RawMessage | ||
| 3356 | var persistHandoff bool | 3357 | 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 agent | 3379 | // 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 | + | } | |||