Allow slash commands during tool confirmation
- commit
- 5b23b1dbfc2d763946e9000f006e184d963e78dd
- author
- patriceckhart · · 2026-07-24 06:07 UTC
- parent
- 2d8ec25
- signature
- unsigned
5 files · +387 -38
M README.md +1 -1
| @@ -197,7 +197,7 @@ zot --help | |||
| 197 | 197 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | |
| 198 | 198 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | |
| 199 | 199 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | |
| 200 | - | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | |
| 200 | + | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | |
| 201 | 201 | ||
| 202 | 202 | ## Tools | |
| 203 | 203 | ||
| @@ -197,7 +197,7 @@ zot --help | |||||
| 197 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | 197 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | ||
| 198 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | 198 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | ||
| 199 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | 199 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | ||
| 200 | - | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | 200 | + | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | |
| 201 | 201 | ||||
| 202 | ## Tools | 202 | ## Tools | ||
| 203 | 203 | ||||
M packages/agent/modes/btw_dialog.go +4 -3
| @@ -94,9 +94,9 @@ func (d *btwDialog) Loading() bool { | |||
| 94 | 94 | ||
| 95 | 95 | // SetToolPreview attaches the side-effect-free confirmation preview to the | |
| 96 | 96 | // matching side-chat tool box before execution begins. | |
| 97 | - | func (d *btwDialog) SetToolPreview(id, summary, content string) { | |
| 97 | + | func (d *btwDialog) SetToolPreview(id, summary, content string) bool { | |
| 98 | 98 | if d == nil || id == "" { | |
| 99 | - | return | |
| 99 | + | return false | |
| 100 | 100 | } | |
| 101 | 101 | d.mu.Lock() | |
| 102 | 102 | defer d.mu.Unlock() | |
| @@ -104,9 +104,10 @@ func (d *btwDialog) SetToolPreview(id, summary, content string) { | |||
| 104 | 104 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | |
| 105 | 105 | tool.Args = summary | |
| 106 | 106 | tool.Preview = content | |
| 107 | - | return | |
| 107 | + | return true | |
| 108 | 108 | } | |
| 109 | 109 | } | |
| 110 | + | return false | |
| 110 | 111 | } | |
| 111 | 112 | ||
| 112 | 113 | // ToggleToolExpansion expands or collapses long tool results in the side chat. | |
| @@ -94,9 +94,9 @@ func (d *btwDialog) Loading() bool { | |||||
| 94 | 94 | ||||
| 95 | // SetToolPreview attaches the side-effect-free confirmation preview to the | 95 | // SetToolPreview attaches the side-effect-free confirmation preview to the | ||
| 96 | // matching side-chat tool box before execution begins. | 96 | // matching side-chat tool box before execution begins. | ||
| 97 | - | func (d *btwDialog) SetToolPreview(id, summary, content string) { | 97 | + | func (d *btwDialog) SetToolPreview(id, summary, content string) bool { |
| 98 | if d == nil || id == "" { | 98 | if d == nil || id == "" { | ||
| 99 | - | return | 99 | + | return false |
| 100 | } | 100 | } | ||
| 101 | d.mu.Lock() | 101 | d.mu.Lock() | ||
| 102 | defer d.mu.Unlock() | 102 | defer d.mu.Unlock() | ||
| @@ -104,9 +104,10 @@ func (d *btwDialog) SetToolPreview(id, summary, content string) { | |||||
| 104 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | 104 | if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil { | ||
| 105 | tool.Args = summary | 105 | tool.Args = summary | ||
| 106 | tool.Preview = content | 106 | tool.Preview = content | ||
| 107 | - | return | 107 | + | return true |
| 108 | } | 108 | } | ||
| 109 | } | 109 | } | ||
| 110 | + | return false | |||
| 110 | } | 111 | } | ||
| 111 | 112 | ||||
| 112 | // ToggleToolExpansion expands or collapses long tool results in the side chat. | 113 | // ToggleToolExpansion expands or collapses long tool results in the side chat. | ||
M packages/agent/modes/confirm_dialog.go +75 -21
| @@ -14,9 +14,10 @@ import ( | |||
| 14 | 14 | // writes it onto the interactive's queue, the TUI renders the | |
| 15 | 15 | // dialog, and the user's answer goes back through `resp`. | |
| 16 | 16 | type confirmRequest struct { | |
| 17 | - | toolName string | |
| 18 | - | preview string | |
| 19 | - | resp chan core.ConfirmDecision | |
| 17 | + | toolName string | |
| 18 | + | preview string | |
| 19 | + | resp chan core.ConfirmDecision | |
| 20 | + | returnToChild bool | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // confirmDialog is the inline prompt shown before every tool call | |
| @@ -26,6 +27,7 @@ type confirmDialog struct { | |||
| 26 | 27 | mu sync.Mutex | |
| 27 | 28 | pending []*confirmRequest | |
| 28 | 29 | cursor int | |
| 30 | + | focused bool | |
| 29 | 31 | // activeSince timestamps when the current request started | |
| 30 | 32 | // showing. Used only for debug/future UX (e.g. flashing the | |
| 31 | 33 | // dialog border after N seconds of no answer). | |
| @@ -40,9 +42,23 @@ func newConfirmDialog() *confirmDialog { return &confirmDialog{} } | |||
| 40 | 42 | func (d *confirmDialog) Enqueue(req *confirmRequest) { | |
| 41 | 43 | d.mu.Lock() | |
| 42 | 44 | defer d.mu.Unlock() | |
| 43 | - | d.pending = append(d.pending, req) | |
| 44 | - | if len(d.pending) == 1 { | |
| 45 | + | wasEmpty := len(d.pending) == 0 | |
| 46 | + | if req.returnToChild && !wasEmpty { | |
| 47 | + | // A side-chat tool must be decidable without first resolving the | |
| 48 | + | // main-thread call the side chat was opened to investigate. | |
| 49 | + | idx := 0 | |
| 50 | + | for idx < len(d.pending) && d.pending[idx].returnToChild { | |
| 51 | + | idx++ | |
| 52 | + | } | |
| 53 | + | d.pending = append(d.pending, nil) | |
| 54 | + | copy(d.pending[idx+1:], d.pending[idx:]) | |
| 55 | + | d.pending[idx] = req | |
| 56 | + | } else { | |
| 57 | + | d.pending = append(d.pending, req) | |
| 58 | + | } | |
| 59 | + | if wasEmpty || req.returnToChild { | |
| 45 | 60 | d.cursor = 0 | |
| 61 | + | d.focused = true | |
| 46 | 62 | d.activeSince = time.Now() | |
| 47 | 63 | } | |
| 48 | 64 | } | |
| @@ -58,6 +74,36 @@ func (d *confirmDialog) Active() bool { | |||
| 58 | 74 | return len(d.pending) > 0 | |
| 59 | 75 | } | |
| 60 | 76 | ||
| 77 | + | // Focused reports whether confirmation currently owns keyboard input. | |
| 78 | + | func (d *confirmDialog) Focused() bool { | |
| 79 | + | if d == nil { | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | d.mu.Lock() | |
| 83 | + | defer d.mu.Unlock() | |
| 84 | + | return len(d.pending) > 0 && d.focused | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Focus returns keyboard input to the pending confirmation. | |
| 88 | + | func (d *confirmDialog) Focus() { | |
| 89 | + | if d == nil { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | d.mu.Lock() | |
| 93 | + | d.focused = len(d.pending) > 0 | |
| 94 | + | d.mu.Unlock() | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Blur leaves the confirmation pending while another interaction uses input. | |
| 98 | + | func (d *confirmDialog) Blur() { | |
| 99 | + | if d == nil { | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | d.mu.Lock() | |
| 103 | + | d.focused = false | |
| 104 | + | d.mu.Unlock() | |
| 105 | + | } | |
| 106 | + | ||
| 61 | 107 | // CancelAll refuses every pending request (used when the user | |
| 62 | 108 | // cancels the active turn or closes the session while the dialog | |
| 63 | 109 | // is open). Safe to call when idle. | |
| @@ -65,6 +111,7 @@ func (d *confirmDialog) CancelAll(reason string) { | |||
| 65 | 111 | d.mu.Lock() | |
| 66 | 112 | pending := d.pending | |
| 67 | 113 | d.pending = nil | |
| 114 | + | d.focused = false | |
| 68 | 115 | d.mu.Unlock() | |
| 69 | 116 | for _, req := range pending { | |
| 70 | 117 | select { | |
| @@ -81,6 +128,7 @@ func (d *confirmDialog) AllowAllPending() { | |||
| 81 | 128 | d.mu.Lock() | |
| 82 | 129 | pending := d.pending | |
| 83 | 130 | d.pending = nil | |
| 131 | + | d.focused = false | |
| 84 | 132 | d.mu.Unlock() | |
| 85 | 133 | for _, req := range pending { | |
| 86 | 134 | select { | |
| @@ -114,6 +162,20 @@ var confirmOptions = []struct { | |||
| 114 | 162 | }, | |
| 115 | 163 | } | |
| 116 | 164 | ||
| 165 | + | // advanceLocked removes the current request and chooses whether confirmation | |
| 166 | + | // or the child interaction behind it receives input next. | |
| 167 | + | func (d *confirmDialog) advanceLocked(req *confirmRequest) { | |
| 168 | + | d.pending = d.pending[1:] | |
| 169 | + | d.focused = len(d.pending) > 0 | |
| 170 | + | if req.returnToChild && (len(d.pending) == 0 || !d.pending[0].returnToChild) { | |
| 171 | + | d.focused = false | |
| 172 | + | } | |
| 173 | + | if d.focused { | |
| 174 | + | d.cursor = 0 | |
| 175 | + | d.activeSince = time.Now() | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 117 | 179 | // HandleKey advances the selection or resolves the dialog. Returns | |
| 118 | 180 | // true when a decision was sent back to the agent (the dialog may | |
| 119 | 181 | // remain Active if there are more queued requests behind it). | |
| @@ -143,21 +205,13 @@ func (d *confirmDialog) HandleKey(k tui.Key) bool { | |||
| 143 | 205 | // active turn can separately be cancelled by the outer esc | |
| 144 | 206 | // handler, but for the dialog itself, we must always answer | |
| 145 | 207 | // so the agent goroutine unblocks. | |
| 146 | - | d.pending = d.pending[1:] | |
| 147 | - | if len(d.pending) > 0 { | |
| 148 | - | d.cursor = 0 | |
| 149 | - | d.activeSince = time.Now() | |
| 150 | - | } | |
| 208 | + | d.advanceLocked(req) | |
| 151 | 209 | d.mu.Unlock() | |
| 152 | 210 | req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"} | |
| 153 | 211 | return true | |
| 154 | 212 | case tui.KeyEnter: | |
| 155 | 213 | selected := confirmOptions[d.cursor].decision | |
| 156 | - | d.pending = d.pending[1:] | |
| 157 | - | if len(d.pending) > 0 { | |
| 158 | - | d.cursor = 0 | |
| 159 | - | d.activeSince = time.Now() | |
| 160 | - | } | |
| 214 | + | d.advanceLocked(req) | |
| 161 | 215 | d.mu.Unlock() | |
| 162 | 216 | req.resp <- selected | |
| 163 | 217 | return true | |
| @@ -168,11 +222,7 @@ func (d *confirmDialog) HandleKey(k tui.Key) bool { | |||
| 168 | 222 | idx := int(k.Rune - '1') | |
| 169 | 223 | if idx >= 0 && idx < len(confirmOptions) { | |
| 170 | 224 | selected := confirmOptions[idx].decision | |
| 171 | - | d.pending = d.pending[1:] | |
| 172 | - | if len(d.pending) > 0 { | |
| 173 | - | d.cursor = 0 | |
| 174 | - | d.activeSince = time.Now() | |
| 175 | - | } | |
| 225 | + | d.advanceLocked(req) | |
| 176 | 226 | d.mu.Unlock() | |
| 177 | 227 | req.resp <- selected | |
| 178 | 228 | return true | |
| @@ -207,7 +257,11 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string { | |||
| 207 | 257 | } | |
| 208 | 258 | lines = append(lines, toolLine) | |
| 209 | 259 | lines = append(lines, "") | |
| 210 | - | lines = append(lines, th.FG256(th.Muted, "choose (\u2191/\u2193 or 1-4, enter to pick, esc to refuse):")) | |
| 260 | + | hint := "choose (\u2191/\u2193 or 1-4, enter to pick, esc to refuse, / for commands):" | |
| 261 | + | if !d.focused { | |
| 262 | + | hint = "confirmation paused (finish or esc the command to return):" | |
| 263 | + | } | |
| 264 | + | lines = append(lines, th.FG256(th.Muted, hint)) | |
| 211 | 265 | ||
| 212 | 266 | for i, opt := range confirmOptions { | |
| 213 | 267 | label := opt.label | |
| @@ -14,9 +14,10 @@ import ( | |||||
| 14 | // writes it onto the interactive's queue, the TUI renders the | 14 | // writes it onto the interactive's queue, the TUI renders the | ||
| 15 | // dialog, and the user's answer goes back through `resp`. | 15 | // dialog, and the user's answer goes back through `resp`. | ||
| 16 | type confirmRequest struct { | 16 | type confirmRequest struct { | ||
| 17 | - | toolName string | 17 | + | toolName string |
| 18 | - | preview string | 18 | + | preview string |
| 19 | - | resp chan core.ConfirmDecision | 19 | + | resp chan core.ConfirmDecision |
| 20 | + | returnToChild bool | |||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | // confirmDialog is the inline prompt shown before every tool call | 23 | // confirmDialog is the inline prompt shown before every tool call | ||
| @@ -26,6 +27,7 @@ type confirmDialog struct { | |||||
| 26 | mu sync.Mutex | 27 | mu sync.Mutex | ||
| 27 | pending []*confirmRequest | 28 | pending []*confirmRequest | ||
| 28 | cursor int | 29 | cursor int | ||
| 30 | + | focused bool | |||
| 29 | // activeSince timestamps when the current request started | 31 | // activeSince timestamps when the current request started | ||
| 30 | // showing. Used only for debug/future UX (e.g. flashing the | 32 | // showing. Used only for debug/future UX (e.g. flashing the | ||
| 31 | // dialog border after N seconds of no answer). | 33 | // dialog border after N seconds of no answer). | ||
| @@ -40,9 +42,23 @@ func newConfirmDialog() *confirmDialog { return &confirmDialog{} } | |||||
| 40 | func (d *confirmDialog) Enqueue(req *confirmRequest) { | 42 | func (d *confirmDialog) Enqueue(req *confirmRequest) { | ||
| 41 | d.mu.Lock() | 43 | d.mu.Lock() | ||
| 42 | defer d.mu.Unlock() | 44 | defer d.mu.Unlock() | ||
| 43 | - | d.pending = append(d.pending, req) | 45 | + | wasEmpty := len(d.pending) == 0 |
| 44 | - | if len(d.pending) == 1 { | 46 | + | if req.returnToChild && !wasEmpty { |
| 47 | + | // A side-chat tool must be decidable without first resolving the | |||
| 48 | + | // main-thread call the side chat was opened to investigate. | |||
| 49 | + | idx := 0 | |||
| 50 | + | for idx < len(d.pending) && d.pending[idx].returnToChild { | |||
| 51 | + | idx++ | |||
| 52 | + | } | |||
| 53 | + | d.pending = append(d.pending, nil) | |||
| 54 | + | copy(d.pending[idx+1:], d.pending[idx:]) | |||
| 55 | + | d.pending[idx] = req | |||
| 56 | + | } else { | |||
| 57 | + | d.pending = append(d.pending, req) | |||
| 58 | + | } | |||
| 59 | + | if wasEmpty || req.returnToChild { | |||
| 45 | d.cursor = 0 | 60 | d.cursor = 0 | ||
| 61 | + | d.focused = true | |||
| 46 | d.activeSince = time.Now() | 62 | d.activeSince = time.Now() | ||
| 47 | } | 63 | } | ||
| 48 | } | 64 | } | ||
| @@ -58,6 +74,36 @@ func (d *confirmDialog) Active() bool { | |||||
| 58 | return len(d.pending) > 0 | 74 | return len(d.pending) > 0 | ||
| 59 | } | 75 | } | ||
| 60 | 76 | ||||
| 77 | + | // Focused reports whether confirmation currently owns keyboard input. | |||
| 78 | + | func (d *confirmDialog) Focused() bool { | |||
| 79 | + | if d == nil { | |||
| 80 | + | return false | |||
| 81 | + | } | |||
| 82 | + | d.mu.Lock() | |||
| 83 | + | defer d.mu.Unlock() | |||
| 84 | + | return len(d.pending) > 0 && d.focused | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Focus returns keyboard input to the pending confirmation. | |||
| 88 | + | func (d *confirmDialog) Focus() { | |||
| 89 | + | if d == nil { | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 92 | + | d.mu.Lock() | |||
| 93 | + | d.focused = len(d.pending) > 0 | |||
| 94 | + | d.mu.Unlock() | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Blur leaves the confirmation pending while another interaction uses input. | |||
| 98 | + | func (d *confirmDialog) Blur() { | |||
| 99 | + | if d == nil { | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | d.mu.Lock() | |||
| 103 | + | d.focused = false | |||
| 104 | + | d.mu.Unlock() | |||
| 105 | + | } | |||
| 106 | + | ||||
| 61 | // CancelAll refuses every pending request (used when the user | 107 | // CancelAll refuses every pending request (used when the user | ||
| 62 | // cancels the active turn or closes the session while the dialog | 108 | // cancels the active turn or closes the session while the dialog | ||
| 63 | // is open). Safe to call when idle. | 109 | // is open). Safe to call when idle. | ||
| @@ -65,6 +111,7 @@ func (d *confirmDialog) CancelAll(reason string) { | |||||
| 65 | d.mu.Lock() | 111 | d.mu.Lock() | ||
| 66 | pending := d.pending | 112 | pending := d.pending | ||
| 67 | d.pending = nil | 113 | d.pending = nil | ||
| 114 | + | d.focused = false | |||
| 68 | d.mu.Unlock() | 115 | d.mu.Unlock() | ||
| 69 | for _, req := range pending { | 116 | for _, req := range pending { | ||
| 70 | select { | 117 | select { | ||
| @@ -81,6 +128,7 @@ func (d *confirmDialog) AllowAllPending() { | |||||
| 81 | d.mu.Lock() | 128 | d.mu.Lock() | ||
| 82 | pending := d.pending | 129 | pending := d.pending | ||
| 83 | d.pending = nil | 130 | d.pending = nil | ||
| 131 | + | d.focused = false | |||
| 84 | d.mu.Unlock() | 132 | d.mu.Unlock() | ||
| 85 | for _, req := range pending { | 133 | for _, req := range pending { | ||
| 86 | select { | 134 | select { | ||
| @@ -114,6 +162,20 @@ var confirmOptions = []struct { | |||||
| 114 | }, | 162 | }, | ||
| 115 | } | 163 | } | ||
| 116 | 164 | ||||
| 165 | + | // advanceLocked removes the current request and chooses whether confirmation | |||
| 166 | + | // or the child interaction behind it receives input next. | |||
| 167 | + | func (d *confirmDialog) advanceLocked(req *confirmRequest) { | |||
| 168 | + | d.pending = d.pending[1:] | |||
| 169 | + | d.focused = len(d.pending) > 0 | |||
| 170 | + | if req.returnToChild && (len(d.pending) == 0 || !d.pending[0].returnToChild) { | |||
| 171 | + | d.focused = false | |||
| 172 | + | } | |||
| 173 | + | if d.focused { | |||
| 174 | + | d.cursor = 0 | |||
| 175 | + | d.activeSince = time.Now() | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 117 | // HandleKey advances the selection or resolves the dialog. Returns | 179 | // HandleKey advances the selection or resolves the dialog. Returns | ||
| 118 | // true when a decision was sent back to the agent (the dialog may | 180 | // true when a decision was sent back to the agent (the dialog may | ||
| 119 | // remain Active if there are more queued requests behind it). | 181 | // remain Active if there are more queued requests behind it). | ||
| @@ -143,21 +205,13 @@ func (d *confirmDialog) HandleKey(k tui.Key) bool { | |||||
| 143 | // active turn can separately be cancelled by the outer esc | 205 | // active turn can separately be cancelled by the outer esc | ||
| 144 | // handler, but for the dialog itself, we must always answer | 206 | // handler, but for the dialog itself, we must always answer | ||
| 145 | // so the agent goroutine unblocks. | 207 | // so the agent goroutine unblocks. | ||
| 146 | - | d.pending = d.pending[1:] | 208 | + | d.advanceLocked(req) |
| 147 | - | if len(d.pending) > 0 { | |||
| 148 | - | d.cursor = 0 | |||
| 149 | - | d.activeSince = time.Now() | |||
| 150 | - | } | |||
| 151 | d.mu.Unlock() | 209 | d.mu.Unlock() | ||
| 152 | req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"} | 210 | req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"} | ||
| 153 | return true | 211 | return true | ||
| 154 | case tui.KeyEnter: | 212 | case tui.KeyEnter: | ||
| 155 | selected := confirmOptions[d.cursor].decision | 213 | selected := confirmOptions[d.cursor].decision | ||
| 156 | - | d.pending = d.pending[1:] | 214 | + | d.advanceLocked(req) |
| 157 | - | if len(d.pending) > 0 { | |||
| 158 | - | d.cursor = 0 | |||
| 159 | - | d.activeSince = time.Now() | |||
| 160 | - | } | |||
| 161 | d.mu.Unlock() | 215 | d.mu.Unlock() | ||
| 162 | req.resp <- selected | 216 | req.resp <- selected | ||
| 163 | return true | 217 | return true | ||
| @@ -168,11 +222,7 @@ func (d *confirmDialog) HandleKey(k tui.Key) bool { | |||||
| 168 | idx := int(k.Rune - '1') | 222 | idx := int(k.Rune - '1') | ||
| 169 | if idx >= 0 && idx < len(confirmOptions) { | 223 | if idx >= 0 && idx < len(confirmOptions) { | ||
| 170 | selected := confirmOptions[idx].decision | 224 | selected := confirmOptions[idx].decision | ||
| 171 | - | d.pending = d.pending[1:] | 225 | + | d.advanceLocked(req) |
| 172 | - | if len(d.pending) > 0 { | |||
| 173 | - | d.cursor = 0 | |||
| 174 | - | d.activeSince = time.Now() | |||
| 175 | - | } | |||
| 176 | d.mu.Unlock() | 226 | d.mu.Unlock() | ||
| 177 | req.resp <- selected | 227 | req.resp <- selected | ||
| 178 | return true | 228 | return true | ||
| @@ -207,7 +257,11 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string { | |||||
| 207 | } | 257 | } | ||
| 208 | lines = append(lines, toolLine) | 258 | lines = append(lines, toolLine) | ||
| 209 | lines = append(lines, "") | 259 | lines = append(lines, "") | ||
| 210 | - | lines = append(lines, th.FG256(th.Muted, "choose (\u2191/\u2193 or 1-4, enter to pick, esc to refuse):")) | 260 | + | hint := "choose (\u2191/\u2193 or 1-4, enter to pick, esc to refuse, / for commands):" |
| 261 | + | if !d.focused { | |||
| 262 | + | hint = "confirmation paused (finish or esc the command to return):" | |||
| 263 | + | } | |||
| 264 | + | lines = append(lines, th.FG256(th.Muted, hint)) | |||
| 211 | 265 | ||||
| 212 | for i, opt := range confirmOptions { | 266 | for i, opt := range confirmOptions { | ||
| 213 | label := opt.label | 267 | label := opt.label | ||
M packages/agent/modes/confirm_dialog_test.go +230 -0
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 12 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 11 | 13 | "github.com/patriceckhart/zot/packages/core" | |
| 12 | 14 | "github.com/patriceckhart/zot/packages/tui" | |
| 13 | 15 | ) | |
| @@ -77,6 +79,234 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||
| 77 | 79 | } | |
| 78 | 80 | } | |
| 79 | 81 | ||
| 82 | + | func newConfirmationInputInteractive() *Interactive { | |
| 83 | + | agent := core.NewAgent(nil, "test-model", "test system", nil) | |
| 84 | + | return NewInteractive(InteractiveConfig{ | |
| 85 | + | Theme: tui.Dark, | |
| 86 | + | Terminal: tui.NewProcTerm(), | |
| 87 | + | Agent: agent, | |
| 88 | + | Sandbox: tools.NewSandbox("."), | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func enqueueConfirmation(i *Interactive) <-chan core.ConfirmDecision { | |
| 93 | + | resp := make(chan core.ConfirmDecision, 1) | |
| 94 | + | i.confirmDialog.Enqueue(&confirmRequest{toolName: "bash", preview: "pwd", resp: resp}) | |
| 95 | + | return resp | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestConfirmDialogSlashOpensBtwBeforeDecision(t *testing.T) { | |
| 99 | + | i := newConfirmationInputInteractive() | |
| 100 | + | resp := enqueueConfirmation(i) | |
| 101 | + | ||
| 102 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'}) | |
| 103 | + | if got := i.ed.Value(); got != "/" { | |
| 104 | + | t.Fatalf("editor = %q, want slash command input", got) | |
| 105 | + | } | |
| 106 | + | if i.confirmDialog.Focused() { | |
| 107 | + | t.Fatal("confirmation retained focus after slash") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | i.ed.SetValue("/btw") | |
| 111 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 112 | + | if !i.btwDialog.Active() { | |
| 113 | + | t.Fatal("/btw did not open while confirmation was pending") | |
| 114 | + | } | |
| 115 | + | if i.confirmDialog.Focused() { | |
| 116 | + | t.Fatal("confirmation stole focus from /btw") | |
| 117 | + | } | |
| 118 | + | select { | |
| 119 | + | case decision := <-resp: | |
| 120 | + | t.Fatalf("/btw unexpectedly answered confirmation: %+v", decision) | |
| 121 | + | default: | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'x'}) | |
| 125 | + | i.btwDialog.mu.Lock() | |
| 126 | + | btwInput := i.btwDialog.editor.Value() | |
| 127 | + | i.btwDialog.mu.Unlock() | |
| 128 | + | if btwInput != "x" { | |
| 129 | + | t.Fatalf("/btw editor = %q, want %q", btwInput, "x") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| 133 | + | if i.btwDialog.Active() { | |
| 134 | + | t.Fatal("esc did not close /btw") | |
| 135 | + | } | |
| 136 | + | if !i.confirmDialog.Focused() { | |
| 137 | + | t.Fatal("closing /btw did not restore confirmation focus") | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestConfirmDialogPrioritizesSideChatToolAndReturnsToSideChat(t *testing.T) { | |
| 142 | + | dialog := newConfirmDialog() | |
| 143 | + | mainResp := make(chan core.ConfirmDecision, 1) | |
| 144 | + | sideResp := make(chan core.ConfirmDecision, 1) | |
| 145 | + | dialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp}) | |
| 146 | + | dialog.Blur() | |
| 147 | + | dialog.Enqueue(&confirmRequest{toolName: "side", resp: sideResp, returnToChild: true}) | |
| 148 | + | ||
| 149 | + | dialog.mu.Lock() | |
| 150 | + | first := dialog.pending[0].toolName | |
| 151 | + | dialog.mu.Unlock() | |
| 152 | + | if first != "side" { | |
| 153 | + | t.Fatalf("first confirmation = %q, want side-chat tool", first) | |
| 154 | + | } | |
| 155 | + | if !dialog.Focused() { | |
| 156 | + | t.Fatal("side-chat tool confirmation did not take focus") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | dialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 160 | + | select { | |
| 161 | + | case decision := <-sideResp: | |
| 162 | + | if !decision.Allow { | |
| 163 | + | t.Fatalf("side-chat decision = %+v", decision) | |
| 164 | + | } | |
| 165 | + | default: | |
| 166 | + | t.Fatal("side-chat confirmation was not resolved") | |
| 167 | + | } | |
| 168 | + | if !dialog.Active() { | |
| 169 | + | t.Fatal("main confirmation was lost") | |
| 170 | + | } | |
| 171 | + | if dialog.Focused() { | |
| 172 | + | t.Fatal("main confirmation stole focus after side-chat tool decision") | |
| 173 | + | } | |
| 174 | + | select { | |
| 175 | + | case decision := <-mainResp: | |
| 176 | + | t.Fatalf("main confirmation was unexpectedly resolved: %+v", decision) | |
| 177 | + | default: | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestConfirmDialogEscCancelsSlashInputOnly(t *testing.T) { | |
| 182 | + | i := newConfirmationInputInteractive() | |
| 183 | + | resp := enqueueConfirmation(i) | |
| 184 | + | ||
| 185 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'}) | |
| 186 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'h'}) | |
| 187 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| 188 | + | ||
| 189 | + | if !i.ed.IsEmpty() { | |
| 190 | + | t.Fatalf("editor was not cleared: %q", i.ed.Value()) | |
| 191 | + | } | |
| 192 | + | if !i.confirmDialog.Focused() { | |
| 193 | + | t.Fatal("esc did not return focus to confirmation") | |
| 194 | + | } | |
| 195 | + | select { | |
| 196 | + | case decision := <-resp: | |
| 197 | + | t.Fatalf("esc from slash input answered confirmation: %+v", decision) | |
| 198 | + | default: | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestConfirmDialogHelpEscDismissesHelpOnly(t *testing.T) { | |
| 203 | + | i := newConfirmationInputInteractive() | |
| 204 | + | resp := enqueueConfirmation(i) | |
| 205 | + | ||
| 206 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'}) | |
| 207 | + | i.ed.SetValue("/help") | |
| 208 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 209 | + | ||
| 210 | + | if len(i.helpBlock) == 0 { | |
| 211 | + | t.Fatal("/help did not open while confirmation was pending") | |
| 212 | + | } | |
| 213 | + | if i.confirmDialog.Focused() { | |
| 214 | + | t.Fatal("confirmation stole focus from /help") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| 218 | + | ||
| 219 | + | if len(i.helpBlock) != 0 { | |
| 220 | + | t.Fatal("esc did not dismiss /help") | |
| 221 | + | } | |
| 222 | + | if !i.confirmDialog.Focused() { | |
| 223 | + | t.Fatal("dismissing /help did not restore confirmation focus") | |
| 224 | + | } | |
| 225 | + | select { | |
| 226 | + | case decision := <-resp: | |
| 227 | + | t.Fatalf("esc from /help answered confirmation: %+v", decision) | |
| 228 | + | default: | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestConfirmDialogExtensionNoteEscDismissesNoteOnly(t *testing.T) { | |
| 233 | + | i := newConfirmationInputInteractive() | |
| 234 | + | resp := enqueueConfirmation(i) | |
| 235 | + | ||
| 236 | + | i.Display("example", "command output") | |
| 237 | + | if len(i.extNotes) == 0 { | |
| 238 | + | t.Fatal("extension note was not displayed") | |
| 239 | + | } | |
| 240 | + | if i.confirmDialog.Focused() { | |
| 241 | + | t.Fatal("confirmation retained focus over extension note") | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| 245 | + | ||
| 246 | + | if len(i.extNotes) != 0 { | |
| 247 | + | t.Fatal("esc did not dismiss extension note") | |
| 248 | + | } | |
| 249 | + | if !i.confirmDialog.Focused() { | |
| 250 | + | t.Fatal("dismissing extension note did not restore confirmation focus") | |
| 251 | + | } | |
| 252 | + | select { | |
| 253 | + | case decision := <-resp: | |
| 254 | + | t.Fatalf("esc from extension note answered confirmation: %+v", decision) | |
| 255 | + | default: | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func TestConfirmDialogExtensionPanelTakesAndReturnsFocus(t *testing.T) { | |
| 260 | + | i := newConfirmationInputInteractive() | |
| 261 | + | resp := enqueueConfirmation(i) | |
| 262 | + | ||
| 263 | + | i.OpenPanel("todo", extproto.PanelSpec{ID: "todos", Title: "Todos", Lines: []string{"one"}}) | |
| 264 | + | if !i.extPanel.Active() { | |
| 265 | + | t.Fatal("extension panel did not open") | |
| 266 | + | } | |
| 267 | + | if i.confirmDialog.Focused() { | |
| 268 | + | t.Fatal("confirmation retained focus over extension panel") | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| 272 | + | if i.extPanel.Active() { | |
| 273 | + | t.Fatal("esc did not close extension panel") | |
| 274 | + | } | |
| 275 | + | if !i.confirmDialog.Focused() { | |
| 276 | + | t.Fatal("closing extension panel did not restore confirmation focus") | |
| 277 | + | } | |
| 278 | + | select { | |
| 279 | + | case decision := <-resp: | |
| 280 | + | t.Fatalf("extension panel answered confirmation: %+v", decision) | |
| 281 | + | default: | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func TestCancelAndWaitDrainsPendingConfirmation(t *testing.T) { | |
| 286 | + | i := newConfirmationInputInteractive() | |
| 287 | + | resp := enqueueConfirmation(i) | |
| 288 | + | i.busy = true | |
| 289 | + | i.cancelTurn = func() { | |
| 290 | + | i.mu.Lock() | |
| 291 | + | i.busy = false | |
| 292 | + | i.mu.Unlock() | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | i.cancelAndWaitForIdle() | |
| 296 | + | ||
| 297 | + | if i.confirmDialog.Active() { | |
| 298 | + | t.Fatal("confirmation remained active after turn cancellation") | |
| 299 | + | } | |
| 300 | + | select { | |
| 301 | + | case decision := <-resp: | |
| 302 | + | if decision.Allow || decision.Reason != "turn cancelled" { | |
| 303 | + | t.Fatalf("cancellation decision = %+v", decision) | |
| 304 | + | } | |
| 305 | + | default: | |
| 306 | + | t.Fatal("pending confirmation was not resolved") | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 80 | 310 | func TestConfirmDialogAllowsToolExpansion(t *testing.T) { | |
| 81 | 311 | resp := make(chan core.ConfirmDecision, 1) | |
| 82 | 312 | dialog := newConfirmDialog() | |
| @@ -8,6 +8,8 @@ import ( | |||||
| 8 | "testing" | 8 | "testing" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 12 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |||
| 11 | "github.com/patriceckhart/zot/packages/core" | 13 | "github.com/patriceckhart/zot/packages/core" | ||
| 12 | "github.com/patriceckhart/zot/packages/tui" | 14 | "github.com/patriceckhart/zot/packages/tui" | ||
| 13 | ) | 15 | ) | ||
| @@ -77,6 +79,234 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||||
| 77 | } | 79 | } | ||
| 78 | } | 80 | } | ||
| 79 | 81 | ||||
| 82 | + | func newConfirmationInputInteractive() *Interactive { | |||
| 83 | + | agent := core.NewAgent(nil, "test-model", "test system", nil) | |||
| 84 | + | return NewInteractive(InteractiveConfig{ | |||
| 85 | + | Theme: tui.Dark, | |||
| 86 | + | Terminal: tui.NewProcTerm(), | |||
| 87 | + | Agent: agent, | |||
| 88 | + | Sandbox: tools.NewSandbox("."), | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func enqueueConfirmation(i *Interactive) <-chan core.ConfirmDecision { | |||
| 93 | + | resp := make(chan core.ConfirmDecision, 1) | |||
| 94 | + | i.confirmDialog.Enqueue(&confirmRequest{toolName: "bash", preview: "pwd", resp: resp}) | |||
| 95 | + | return resp | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestConfirmDialogSlashOpensBtwBeforeDecision(t *testing.T) { | |||
| 99 | + | i := newConfirmationInputInteractive() | |||
| 100 | + | resp := enqueueConfirmation(i) | |||
| 101 | + | ||||
| 102 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'}) | |||
| 103 | + | if got := i.ed.Value(); got != "/" { | |||
| 104 | + | t.Fatalf("editor = %q, want slash command input", got) | |||
| 105 | + | } | |||
| 106 | + | if i.confirmDialog.Focused() { | |||
| 107 | + | t.Fatal("confirmation retained focus after slash") | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | i.ed.SetValue("/btw") | |||
| 111 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |||
| 112 | + | if !i.btwDialog.Active() { | |||
| 113 | + | t.Fatal("/btw did not open while confirmation was pending") | |||
| 114 | + | } | |||
| 115 | + | if i.confirmDialog.Focused() { | |||
| 116 | + | t.Fatal("confirmation stole focus from /btw") | |||
| 117 | + | } | |||
| 118 | + | select { | |||
| 119 | + | case decision := <-resp: | |||
| 120 | + | t.Fatalf("/btw unexpectedly answered confirmation: %+v", decision) | |||
| 121 | + | default: | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'x'}) | |||
| 125 | + | i.btwDialog.mu.Lock() | |||
| 126 | + | btwInput := i.btwDialog.editor.Value() | |||
| 127 | + | i.btwDialog.mu.Unlock() | |||
| 128 | + | if btwInput != "x" { | |||
| 129 | + | t.Fatalf("/btw editor = %q, want %q", btwInput, "x") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |||
| 133 | + | if i.btwDialog.Active() { | |||
| 134 | + | t.Fatal("esc did not close /btw") | |||
| 135 | + | } | |||
| 136 | + | if !i.confirmDialog.Focused() { | |||
| 137 | + | t.Fatal("closing /btw did not restore confirmation focus") | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestConfirmDialogPrioritizesSideChatToolAndReturnsToSideChat(t *testing.T) { | |||
| 142 | + | dialog := newConfirmDialog() | |||
| 143 | + | mainResp := make(chan core.ConfirmDecision, 1) | |||
| 144 | + | sideResp := make(chan core.ConfirmDecision, 1) | |||
| 145 | + | dialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp}) | |||
| 146 | + | dialog.Blur() | |||
| 147 | + | dialog.Enqueue(&confirmRequest{toolName: "side", resp: sideResp, returnToChild: true}) | |||
| 148 | + | ||||
| 149 | + | dialog.mu.Lock() | |||
| 150 | + | first := dialog.pending[0].toolName | |||
| 151 | + | dialog.mu.Unlock() | |||
| 152 | + | if first != "side" { | |||
| 153 | + | t.Fatalf("first confirmation = %q, want side-chat tool", first) | |||
| 154 | + | } | |||
| 155 | + | if !dialog.Focused() { | |||
| 156 | + | t.Fatal("side-chat tool confirmation did not take focus") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | dialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 160 | + | select { | |||
| 161 | + | case decision := <-sideResp: | |||
| 162 | + | if !decision.Allow { | |||
| 163 | + | t.Fatalf("side-chat decision = %+v", decision) | |||
| 164 | + | } | |||
| 165 | + | default: | |||
| 166 | + | t.Fatal("side-chat confirmation was not resolved") | |||
| 167 | + | } | |||
| 168 | + | if !dialog.Active() { | |||
| 169 | + | t.Fatal("main confirmation was lost") | |||
| 170 | + | } | |||
| 171 | + | if dialog.Focused() { | |||
| 172 | + | t.Fatal("main confirmation stole focus after side-chat tool decision") | |||
| 173 | + | } | |||
| 174 | + | select { | |||
| 175 | + | case decision := <-mainResp: | |||
| 176 | + | t.Fatalf("main confirmation was unexpectedly resolved: %+v", decision) | |||
| 177 | + | default: | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestConfirmDialogEscCancelsSlashInputOnly(t *testing.T) { | |||
| 182 | + | i := newConfirmationInputInteractive() | |||
| 183 | + | resp := enqueueConfirmation(i) | |||
| 184 | + | ||||
| 185 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'}) | |||
| 186 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'h'}) | |||
| 187 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |||
| 188 | + | ||||
| 189 | + | if !i.ed.IsEmpty() { | |||
| 190 | + | t.Fatalf("editor was not cleared: %q", i.ed.Value()) | |||
| 191 | + | } | |||
| 192 | + | if !i.confirmDialog.Focused() { | |||
| 193 | + | t.Fatal("esc did not return focus to confirmation") | |||
| 194 | + | } | |||
| 195 | + | select { | |||
| 196 | + | case decision := <-resp: | |||
| 197 | + | t.Fatalf("esc from slash input answered confirmation: %+v", decision) | |||
| 198 | + | default: | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestConfirmDialogHelpEscDismissesHelpOnly(t *testing.T) { | |||
| 203 | + | i := newConfirmationInputInteractive() | |||
| 204 | + | resp := enqueueConfirmation(i) | |||
| 205 | + | ||||
| 206 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'}) | |||
| 207 | + | i.ed.SetValue("/help") | |||
| 208 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |||
| 209 | + | ||||
| 210 | + | if len(i.helpBlock) == 0 { | |||
| 211 | + | t.Fatal("/help did not open while confirmation was pending") | |||
| 212 | + | } | |||
| 213 | + | if i.confirmDialog.Focused() { | |||
| 214 | + | t.Fatal("confirmation stole focus from /help") | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |||
| 218 | + | ||||
| 219 | + | if len(i.helpBlock) != 0 { | |||
| 220 | + | t.Fatal("esc did not dismiss /help") | |||
| 221 | + | } | |||
| 222 | + | if !i.confirmDialog.Focused() { | |||
| 223 | + | t.Fatal("dismissing /help did not restore confirmation focus") | |||
| 224 | + | } | |||
| 225 | + | select { | |||
| 226 | + | case decision := <-resp: | |||
| 227 | + | t.Fatalf("esc from /help answered confirmation: %+v", decision) | |||
| 228 | + | default: | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestConfirmDialogExtensionNoteEscDismissesNoteOnly(t *testing.T) { | |||
| 233 | + | i := newConfirmationInputInteractive() | |||
| 234 | + | resp := enqueueConfirmation(i) | |||
| 235 | + | ||||
| 236 | + | i.Display("example", "command output") | |||
| 237 | + | if len(i.extNotes) == 0 { | |||
| 238 | + | t.Fatal("extension note was not displayed") | |||
| 239 | + | } | |||
| 240 | + | if i.confirmDialog.Focused() { | |||
| 241 | + | t.Fatal("confirmation retained focus over extension note") | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |||
| 245 | + | ||||
| 246 | + | if len(i.extNotes) != 0 { | |||
| 247 | + | t.Fatal("esc did not dismiss extension note") | |||
| 248 | + | } | |||
| 249 | + | if !i.confirmDialog.Focused() { | |||
| 250 | + | t.Fatal("dismissing extension note did not restore confirmation focus") | |||
| 251 | + | } | |||
| 252 | + | select { | |||
| 253 | + | case decision := <-resp: | |||
| 254 | + | t.Fatalf("esc from extension note answered confirmation: %+v", decision) | |||
| 255 | + | default: | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func TestConfirmDialogExtensionPanelTakesAndReturnsFocus(t *testing.T) { | |||
| 260 | + | i := newConfirmationInputInteractive() | |||
| 261 | + | resp := enqueueConfirmation(i) | |||
| 262 | + | ||||
| 263 | + | i.OpenPanel("todo", extproto.PanelSpec{ID: "todos", Title: "Todos", Lines: []string{"one"}}) | |||
| 264 | + | if !i.extPanel.Active() { | |||
| 265 | + | t.Fatal("extension panel did not open") | |||
| 266 | + | } | |||
| 267 | + | if i.confirmDialog.Focused() { | |||
| 268 | + | t.Fatal("confirmation retained focus over extension panel") | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |||
| 272 | + | if i.extPanel.Active() { | |||
| 273 | + | t.Fatal("esc did not close extension panel") | |||
| 274 | + | } | |||
| 275 | + | if !i.confirmDialog.Focused() { | |||
| 276 | + | t.Fatal("closing extension panel did not restore confirmation focus") | |||
| 277 | + | } | |||
| 278 | + | select { | |||
| 279 | + | case decision := <-resp: | |||
| 280 | + | t.Fatalf("extension panel answered confirmation: %+v", decision) | |||
| 281 | + | default: | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func TestCancelAndWaitDrainsPendingConfirmation(t *testing.T) { | |||
| 286 | + | i := newConfirmationInputInteractive() | |||
| 287 | + | resp := enqueueConfirmation(i) | |||
| 288 | + | i.busy = true | |||
| 289 | + | i.cancelTurn = func() { | |||
| 290 | + | i.mu.Lock() | |||
| 291 | + | i.busy = false | |||
| 292 | + | i.mu.Unlock() | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | i.cancelAndWaitForIdle() | |||
| 296 | + | ||||
| 297 | + | if i.confirmDialog.Active() { | |||
| 298 | + | t.Fatal("confirmation remained active after turn cancellation") | |||
| 299 | + | } | |||
| 300 | + | select { | |||
| 301 | + | case decision := <-resp: | |||
| 302 | + | if decision.Allow || decision.Reason != "turn cancelled" { | |||
| 303 | + | t.Fatalf("cancellation decision = %+v", decision) | |||
| 304 | + | } | |||
| 305 | + | default: | |||
| 306 | + | t.Fatal("pending confirmation was not resolved") | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 80 | func TestConfirmDialogAllowsToolExpansion(t *testing.T) { | 310 | func TestConfirmDialogAllowsToolExpansion(t *testing.T) { | ||
| 81 | resp := make(chan core.ConfirmDecision, 1) | 311 | resp := make(chan core.ConfirmDecision, 1) | ||
| 82 | dialog := newConfirmDialog() | 312 | dialog := newConfirmDialog() | ||
M packages/agent/modes/interactive.go +77 -13
| @@ -1145,6 +1145,8 @@ func (i *Interactive) redraw() { | |||
| 1145 | 1145 | dialog = i.swarmDialog.Render(i.cfg.Theme, cols) | |
| 1146 | 1146 | case i.jumpDialog.Active(): | |
| 1147 | 1147 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | |
| 1148 | + | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | |
| 1149 | + | dialog = i.extPanel.Render(i.cfg.Theme, cols) | |
| 1148 | 1150 | case i.confirmDialog.Active(): | |
| 1149 | 1151 | if i.btwDialog.Active() { | |
| 1150 | 1152 | // Keep the side-chat transcript and its live tool preview visible | |
| @@ -1170,8 +1172,6 @@ func (i *Interactive) redraw() { | |||
| 1170 | 1172 | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | |
| 1171 | 1173 | case i.sessionTreeDialog.Active(): | |
| 1172 | 1174 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols) | |
| 1173 | - | case i.extPanel.Active(): | |
| 1174 | - | dialog = i.extPanel.Render(i.cfg.Theme, cols) | |
| 1175 | 1175 | } | |
| 1176 | 1176 | if len(dialog) > 0 { | |
| 1177 | 1177 | dialog = padDialogFrame(dialog) | |
| @@ -1211,9 +1211,10 @@ func (i *Interactive) redraw() { | |||
| 1211 | 1211 | // in runSlash already handles the busy case per-command: safe | |
| 1212 | 1212 | // ones run immediately, destructive ones cancel the turn first. | |
| 1213 | 1213 | i.fileSuggest.SetCWD(i.cfg.CWD) | |
| 1214 | - | if len(dialog) == 0 && i.suggest.Active(currentInput) { | |
| 1214 | + | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive()) | |
| 1215 | + | if mainInputFocused && i.suggest.Active(currentInput) { | |
| 1215 | 1216 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | |
| 1216 | - | } else if len(dialog) == 0 && i.fileSuggest.Active(currentInput) { | |
| 1217 | + | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | |
| 1217 | 1218 | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols) | |
| 1218 | 1219 | } | |
| 1219 | 1220 | ||
| @@ -1866,7 +1867,46 @@ func (i *Interactive) toggleBtwToolExpansion() { | |||
| 1866 | 1867 | i.invalidate() | |
| 1867 | 1868 | } | |
| 1868 | 1869 | ||
| 1870 | + | // confirmChildActive reports whether an interaction opened from slash input | |
| 1871 | + | // currently owns the keyboard while a tool confirmation remains pending. | |
| 1872 | + | func (i *Interactive) confirmChildActive() bool { | |
| 1873 | + | return len(i.helpBlock) > 0 || | |
| 1874 | + | len(i.extNotes) > 0 || | |
| 1875 | + | i.dialog.Active() || | |
| 1876 | + | i.modelDialog.Active() || | |
| 1877 | + | i.llamaDialog.Active() || | |
| 1878 | + | i.rescueDialog.Active() || | |
| 1879 | + | i.sessionDialog.Active() || | |
| 1880 | + | i.swarmDialog.Active() || | |
| 1881 | + | i.jumpDialog.Active() || | |
| 1882 | + | i.btwDialog.Active() || | |
| 1883 | + | i.skillsDialog.Active() || | |
| 1884 | + | i.changelogDialog.Active() || | |
| 1885 | + | i.logoutDialog.Active() || | |
| 1886 | + | i.telegramDialog.Active() || | |
| 1887 | + | i.settingsDialog.Active() || | |
| 1888 | + | i.sessionOpsDialog.Active() || | |
| 1889 | + | i.sessionTreeDialog.Active() || | |
| 1890 | + | i.extPanel.Active() | |
| 1891 | + | } | |
| 1892 | + | ||
| 1893 | + | // restoreConfirmFocus returns input to confirmation after slash input is | |
| 1894 | + | // cleared or a child interaction closes. A non-empty editor keeps command | |
| 1895 | + | // input focused, and an active child continues to own its keys. | |
| 1896 | + | func (i *Interactive) restoreConfirmFocus() { | |
| 1897 | + | if !i.confirmDialog.Active() || i.confirmDialog.Focused() { | |
| 1898 | + | return | |
| 1899 | + | } | |
| 1900 | + | if !i.ed.IsEmpty() || i.confirmChildActive() { | |
| 1901 | + | return | |
| 1902 | + | } | |
| 1903 | + | i.confirmDialog.Focus() | |
| 1904 | + | i.invalidate() | |
| 1905 | + | } | |
| 1906 | + | ||
| 1869 | 1907 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 1908 | + | defer i.restoreConfirmFocus() | |
| 1909 | + | ||
| 1870 | 1910 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 1871 | 1911 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |
| 1872 | 1912 | // unexpectedly. The hint message also goes stale; clear it. | |
| @@ -1883,11 +1923,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1883 | 1923 | ||
| 1884 | 1924 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | |
| 1885 | 1925 | ||
| 1886 | - | // Confirm dialog has highest priority: the agent goroutine is | |
| 1887 | - | // blocked waiting for an answer, so we must not let keys leak | |
| 1888 | - | // anywhere else while it's up. Ctrl+O remains available so the | |
| 1889 | - | // user can expand or collapse a tool preview before deciding. | |
| 1890 | - | if i.confirmDialog.Active() { | |
| 1926 | + | // Confirmation owns input by default. Pressing / deliberately moves | |
| 1927 | + | // focus to the main editor without answering the pending request, so | |
| 1928 | + | // slash commands and the dialogs they open can run first. | |
| 1929 | + | if i.confirmDialog.Focused() { | |
| 1930 | + | if k.Kind == tui.KeyRune && k.Rune == '/' { | |
| 1931 | + | i.confirmDialog.Blur() | |
| 1932 | + | i.ed.Insert("/") | |
| 1933 | + | i.suggest.Reset() | |
| 1934 | + | i.invalidate() | |
| 1935 | + | return false | |
| 1936 | + | } | |
| 1891 | 1937 | if k.Kind == tui.KeyCtrlO { | |
| 1892 | 1938 | if i.btwDialog != nil && i.btwDialog.Active() { | |
| 1893 | 1939 | i.toggleBtwToolExpansion() | |
| @@ -1900,6 +1946,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1900 | 1946 | i.invalidate() | |
| 1901 | 1947 | return false | |
| 1902 | 1948 | } | |
| 1949 | + | if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc { | |
| 1950 | + | i.ed.Clear() | |
| 1951 | + | i.suggest.Reset() | |
| 1952 | + | i.fileSuggest.Reset() | |
| 1953 | + | i.confirmDialog.Focus() | |
| 1954 | + | i.invalidate() | |
| 1955 | + | return false | |
| 1956 | + | } | |
| 1903 | 1957 | if i.dialog.Active() { | |
| 1904 | 1958 | if k.Kind == tui.KeyCtrlC { | |
| 1905 | 1959 | i.dialog.Close() | |
| @@ -2727,6 +2781,10 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |||
| 2727 | 2781 | i.statusErr = "" | |
| 2728 | 2782 | i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line)) | |
| 2729 | 2783 | } | |
| 2784 | + | // Extension slash commands complete asynchronously. If their result is | |
| 2785 | + | // displayed while confirmation is waiting, keep Esc assigned to the note | |
| 2786 | + | // until it is dismissed rather than letting it answer the tool call. | |
| 2787 | + | i.confirmDialog.Blur() | |
| 2730 | 2788 | } | |
| 2731 | 2789 | ||
| 2732 | 2790 | // HostHooks implementation for the extension manager. The manager | |
| @@ -2911,6 +2969,7 @@ func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | |||
| 2911 | 2969 | i.mu.Lock() | |
| 2912 | 2970 | defer i.mu.Unlock() | |
| 2913 | 2971 | i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer) | |
| 2972 | + | i.confirmDialog.Blur() | |
| 2914 | 2973 | if i.cfg.Extensions != nil { | |
| 2915 | 2974 | cols, rows := i.cfg.Terminal.Size() | |
| 2916 | 2975 | _ = cols | |
| @@ -4496,6 +4555,9 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||
| 4496 | 4555 | if cancel != nil { | |
| 4497 | 4556 | cancel() | |
| 4498 | 4557 | } | |
| 4558 | + | // ConfirmToolCall waits on its response channel rather than the turn | |
| 4559 | + | // context, so cancellation must also resolve every pending prompt. | |
| 4560 | + | i.confirmDialog.CancelAll("turn cancelled") | |
| 4499 | 4561 | deadline := time.Now().Add(2 * time.Second) | |
| 4500 | 4562 | for time.Now().Before(deadline) { | |
| 4501 | 4563 | i.mu.Lock() | |
| @@ -5646,6 +5708,7 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis | |||
| 5646 | 5708 | // tool panel, then blocks until the user approves or refuses the call. | |
| 5647 | 5709 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |
| 5648 | 5710 | resp := make(chan core.ConfirmDecision, 1) | |
| 5711 | + | returnToChild := false | |
| 5649 | 5712 | if call.ID != "" { | |
| 5650 | 5713 | i.mu.Lock() | |
| 5651 | 5714 | if tc, ok := i.toolCalls[call.ID]; ok { | |
| @@ -5654,13 +5717,14 @@ func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.Confi | |||
| 5654 | 5717 | } | |
| 5655 | 5718 | i.mu.Unlock() | |
| 5656 | 5719 | if i.btwDialog != nil && i.btwDialog.Active() { | |
| 5657 | - | i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | |
| 5720 | + | returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | |
| 5658 | 5721 | } | |
| 5659 | 5722 | } | |
| 5660 | 5723 | i.confirmDialog.Enqueue(&confirmRequest{ | |
| 5661 | - | toolName: call.Name, | |
| 5662 | - | preview: call.Summary, | |
| 5663 | - | resp: resp, | |
| 5724 | + | toolName: call.Name, | |
| 5725 | + | preview: call.Summary, | |
| 5726 | + | resp: resp, | |
| 5727 | + | returnToChild: returnToChild, | |
| 5664 | 5728 | }) | |
| 5665 | 5729 | i.invalidate() | |
| 5666 | 5730 | return <-resp | |
| @@ -1145,6 +1145,8 @@ func (i *Interactive) redraw() { | |||||
| 1145 | dialog = i.swarmDialog.Render(i.cfg.Theme, cols) | 1145 | dialog = i.swarmDialog.Render(i.cfg.Theme, cols) | ||
| 1146 | case i.jumpDialog.Active(): | 1146 | case i.jumpDialog.Active(): | ||
| 1147 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | 1147 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | ||
| 1148 | + | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | |||
| 1149 | + | dialog = i.extPanel.Render(i.cfg.Theme, cols) | |||
| 1148 | case i.confirmDialog.Active(): | 1150 | case i.confirmDialog.Active(): | ||
| 1149 | if i.btwDialog.Active() { | 1151 | if i.btwDialog.Active() { | ||
| 1150 | // Keep the side-chat transcript and its live tool preview visible | 1152 | // Keep the side-chat transcript and its live tool preview visible | ||
| @@ -1170,8 +1172,6 @@ func (i *Interactive) redraw() { | |||||
| 1170 | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | 1172 | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | ||
| 1171 | case i.sessionTreeDialog.Active(): | 1173 | case i.sessionTreeDialog.Active(): | ||
| 1172 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols) | 1174 | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols) | ||
| 1173 | - | case i.extPanel.Active(): | |||
| 1174 | - | dialog = i.extPanel.Render(i.cfg.Theme, cols) | |||
| 1175 | } | 1175 | } | ||
| 1176 | if len(dialog) > 0 { | 1176 | if len(dialog) > 0 { | ||
| 1177 | dialog = padDialogFrame(dialog) | 1177 | dialog = padDialogFrame(dialog) | ||
| @@ -1211,9 +1211,10 @@ func (i *Interactive) redraw() { | |||||
| 1211 | // in runSlash already handles the busy case per-command: safe | 1211 | // in runSlash already handles the busy case per-command: safe | ||
| 1212 | // ones run immediately, destructive ones cancel the turn first. | 1212 | // ones run immediately, destructive ones cancel the turn first. | ||
| 1213 | i.fileSuggest.SetCWD(i.cfg.CWD) | 1213 | i.fileSuggest.SetCWD(i.cfg.CWD) | ||
| 1214 | - | if len(dialog) == 0 && i.suggest.Active(currentInput) { | 1214 | + | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive()) |
| 1215 | + | if mainInputFocused && i.suggest.Active(currentInput) { | |||
| 1215 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | 1216 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | ||
| 1216 | - | } else if len(dialog) == 0 && i.fileSuggest.Active(currentInput) { | 1217 | + | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { |
| 1217 | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols) | 1218 | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols) | ||
| 1218 | } | 1219 | } | ||
| 1219 | 1220 | ||||
| @@ -1866,7 +1867,46 @@ func (i *Interactive) toggleBtwToolExpansion() { | |||||
| 1866 | i.invalidate() | 1867 | i.invalidate() | ||
| 1867 | } | 1868 | } | ||
| 1868 | 1869 | ||||
| 1870 | + | // confirmChildActive reports whether an interaction opened from slash input | |||
| 1871 | + | // currently owns the keyboard while a tool confirmation remains pending. | |||
| 1872 | + | func (i *Interactive) confirmChildActive() bool { | |||
| 1873 | + | return len(i.helpBlock) > 0 || | |||
| 1874 | + | len(i.extNotes) > 0 || | |||
| 1875 | + | i.dialog.Active() || | |||
| 1876 | + | i.modelDialog.Active() || | |||
| 1877 | + | i.llamaDialog.Active() || | |||
| 1878 | + | i.rescueDialog.Active() || | |||
| 1879 | + | i.sessionDialog.Active() || | |||
| 1880 | + | i.swarmDialog.Active() || | |||
| 1881 | + | i.jumpDialog.Active() || | |||
| 1882 | + | i.btwDialog.Active() || | |||
| 1883 | + | i.skillsDialog.Active() || | |||
| 1884 | + | i.changelogDialog.Active() || | |||
| 1885 | + | i.logoutDialog.Active() || | |||
| 1886 | + | i.telegramDialog.Active() || | |||
| 1887 | + | i.settingsDialog.Active() || | |||
| 1888 | + | i.sessionOpsDialog.Active() || | |||
| 1889 | + | i.sessionTreeDialog.Active() || | |||
| 1890 | + | i.extPanel.Active() | |||
| 1891 | + | } | |||
| 1892 | + | ||||
| 1893 | + | // restoreConfirmFocus returns input to confirmation after slash input is | |||
| 1894 | + | // cleared or a child interaction closes. A non-empty editor keeps command | |||
| 1895 | + | // input focused, and an active child continues to own its keys. | |||
| 1896 | + | func (i *Interactive) restoreConfirmFocus() { | |||
| 1897 | + | if !i.confirmDialog.Active() || i.confirmDialog.Focused() { | |||
| 1898 | + | return | |||
| 1899 | + | } | |||
| 1900 | + | if !i.ed.IsEmpty() || i.confirmChildActive() { | |||
| 1901 | + | return | |||
| 1902 | + | } | |||
| 1903 | + | i.confirmDialog.Focus() | |||
| 1904 | + | i.invalidate() | |||
| 1905 | + | } | |||
| 1906 | + | ||||
| 1869 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | 1907 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | ||
| 1908 | + | defer i.restoreConfirmFocus() | |||
| 1909 | + | ||||
| 1870 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | 1910 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | ||
| 1871 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | 1911 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | ||
| 1872 | // unexpectedly. The hint message also goes stale; clear it. | 1912 | // unexpectedly. The hint message also goes stale; clear it. | ||
| @@ -1883,11 +1923,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1883 | 1923 | ||||
| 1884 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | 1924 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | ||
| 1885 | 1925 | ||||
| 1886 | - | // Confirm dialog has highest priority: the agent goroutine is | 1926 | + | // Confirmation owns input by default. Pressing / deliberately moves |
| 1887 | - | // blocked waiting for an answer, so we must not let keys leak | 1927 | + | // focus to the main editor without answering the pending request, so |
| 1888 | - | // anywhere else while it's up. Ctrl+O remains available so the | 1928 | + | // slash commands and the dialogs they open can run first. |
| 1889 | - | // user can expand or collapse a tool preview before deciding. | 1929 | + | if i.confirmDialog.Focused() { |
| 1890 | - | if i.confirmDialog.Active() { | 1930 | + | if k.Kind == tui.KeyRune && k.Rune == '/' { |
| 1931 | + | i.confirmDialog.Blur() | |||
| 1932 | + | i.ed.Insert("/") | |||
| 1933 | + | i.suggest.Reset() | |||
| 1934 | + | i.invalidate() | |||
| 1935 | + | return false | |||
| 1936 | + | } | |||
| 1891 | if k.Kind == tui.KeyCtrlO { | 1937 | if k.Kind == tui.KeyCtrlO { | ||
| 1892 | if i.btwDialog != nil && i.btwDialog.Active() { | 1938 | if i.btwDialog != nil && i.btwDialog.Active() { | ||
| 1893 | i.toggleBtwToolExpansion() | 1939 | i.toggleBtwToolExpansion() | ||
| @@ -1900,6 +1946,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1900 | i.invalidate() | 1946 | i.invalidate() | ||
| 1901 | return false | 1947 | return false | ||
| 1902 | } | 1948 | } | ||
| 1949 | + | if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc { | |||
| 1950 | + | i.ed.Clear() | |||
| 1951 | + | i.suggest.Reset() | |||
| 1952 | + | i.fileSuggest.Reset() | |||
| 1953 | + | i.confirmDialog.Focus() | |||
| 1954 | + | i.invalidate() | |||
| 1955 | + | return false | |||
| 1956 | + | } | |||
| 1903 | if i.dialog.Active() { | 1957 | if i.dialog.Active() { | ||
| 1904 | if k.Kind == tui.KeyCtrlC { | 1958 | if k.Kind == tui.KeyCtrlC { | ||
| 1905 | i.dialog.Close() | 1959 | i.dialog.Close() | ||
| @@ -2727,6 +2781,10 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |||||
| 2727 | i.statusErr = "" | 2781 | i.statusErr = "" | ||
| 2728 | i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line)) | 2782 | i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line)) | ||
| 2729 | } | 2783 | } | ||
| 2784 | + | // Extension slash commands complete asynchronously. If their result is | |||
| 2785 | + | // displayed while confirmation is waiting, keep Esc assigned to the note | |||
| 2786 | + | // until it is dismissed rather than letting it answer the tool call. | |||
| 2787 | + | i.confirmDialog.Blur() | |||
| 2730 | } | 2788 | } | ||
| 2731 | 2789 | ||||
| 2732 | // HostHooks implementation for the extension manager. The manager | 2790 | // HostHooks implementation for the extension manager. The manager | ||
| @@ -2911,6 +2969,7 @@ func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | |||||
| 2911 | i.mu.Lock() | 2969 | i.mu.Lock() | ||
| 2912 | defer i.mu.Unlock() | 2970 | defer i.mu.Unlock() | ||
| 2913 | i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer) | 2971 | i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer) | ||
| 2972 | + | i.confirmDialog.Blur() | |||
| 2914 | if i.cfg.Extensions != nil { | 2973 | if i.cfg.Extensions != nil { | ||
| 2915 | cols, rows := i.cfg.Terminal.Size() | 2974 | cols, rows := i.cfg.Terminal.Size() | ||
| 2916 | _ = cols | 2975 | _ = cols | ||
| @@ -4496,6 +4555,9 @@ func (i *Interactive) cancelAndWaitForIdle() { | |||||
| 4496 | if cancel != nil { | 4555 | if cancel != nil { | ||
| 4497 | cancel() | 4556 | cancel() | ||
| 4498 | } | 4557 | } | ||
| 4558 | + | // ConfirmToolCall waits on its response channel rather than the turn | |||
| 4559 | + | // context, so cancellation must also resolve every pending prompt. | |||
| 4560 | + | i.confirmDialog.CancelAll("turn cancelled") | |||
| 4499 | deadline := time.Now().Add(2 * time.Second) | 4561 | deadline := time.Now().Add(2 * time.Second) | ||
| 4500 | for time.Now().Before(deadline) { | 4562 | for time.Now().Before(deadline) { | ||
| 4501 | i.mu.Lock() | 4563 | i.mu.Lock() | ||
| @@ -5646,6 +5708,7 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis | |||||
| 5646 | // tool panel, then blocks until the user approves or refuses the call. | 5708 | // tool panel, then blocks until the user approves or refuses the call. | ||
| 5647 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | 5709 | func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | ||
| 5648 | resp := make(chan core.ConfirmDecision, 1) | 5710 | resp := make(chan core.ConfirmDecision, 1) | ||
| 5711 | + | returnToChild := false | |||
| 5649 | if call.ID != "" { | 5712 | if call.ID != "" { | ||
| 5650 | i.mu.Lock() | 5713 | i.mu.Lock() | ||
| 5651 | if tc, ok := i.toolCalls[call.ID]; ok { | 5714 | if tc, ok := i.toolCalls[call.ID]; ok { | ||
| @@ -5654,13 +5717,14 @@ func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.Confi | |||||
| 5654 | } | 5717 | } | ||
| 5655 | i.mu.Unlock() | 5718 | i.mu.Unlock() | ||
| 5656 | if i.btwDialog != nil && i.btwDialog.Active() { | 5719 | if i.btwDialog != nil && i.btwDialog.Active() { | ||
| 5657 | - | i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) | 5720 | + | returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content) |
| 5658 | } | 5721 | } | ||
| 5659 | } | 5722 | } | ||
| 5660 | i.confirmDialog.Enqueue(&confirmRequest{ | 5723 | i.confirmDialog.Enqueue(&confirmRequest{ | ||
| 5661 | - | toolName: call.Name, | 5724 | + | toolName: call.Name, |
| 5662 | - | preview: call.Summary, | 5725 | + | preview: call.Summary, |
| 5663 | - | resp: resp, | 5726 | + | resp: resp, |
| 5727 | + | returnToChild: returnToChild, | |||
| 5664 | }) | 5728 | }) | ||
| 5665 | i.invalidate() | 5729 | i.invalidate() | ||
| 5666 | return <-resp | 5730 | return <-resp | ||