detached 5b23b1d

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
197197 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
198198 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
199199 | `--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. |
201201
202202 ## Tools
203203
@@ -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 ## Tools202 ## Tools
203 203
M packages/agent/modes/btw_dialog.go +4 -3
@@ -94,9 +94,9 @@ func (d *btwDialog) Loading() bool {
9494
9595 // SetToolPreview attaches the side-effect-free confirmation preview to the
9696 // 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 {
9898 if d == nil || id == "" {
99- return
99+ return false
100100 }
101101 d.mu.Lock()
102102 defer d.mu.Unlock()
@@ -104,9 +104,10 @@ func (d *btwDialog) SetToolPreview(id, summary, content string) {
104104 if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {
105105 tool.Args = summary
106106 tool.Preview = content
107- return
107+ return true
108108 }
109109 }
110+ return false
110111 }
111112
112113 // 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 the95 // 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- return99+ 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 = summary105 tool.Args = summary
106 tool.Preview = content106 tool.Preview = content
107- return107+ 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 (
1414 // writes it onto the interactive's queue, the TUI renders the
1515 // dialog, and the user's answer goes back through `resp`.
1616 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
2021 }
2122
2223 // confirmDialog is the inline prompt shown before every tool call
@@ -26,6 +27,7 @@ type confirmDialog struct {
2627 mu sync.Mutex
2728 pending []*confirmRequest
2829 cursor int
30+ focused bool
2931 // activeSince timestamps when the current request started
3032 // showing. Used only for debug/future UX (e.g. flashing the
3133 // dialog border after N seconds of no answer).
@@ -40,9 +42,23 @@ func newConfirmDialog() *confirmDialog { return &confirmDialog{} }
4042 func (d *confirmDialog) Enqueue(req *confirmRequest) {
4143 d.mu.Lock()
4244 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 {
4560 d.cursor = 0
61+ d.focused = true
4662 d.activeSince = time.Now()
4763 }
4864 }
@@ -58,6 +74,36 @@ func (d *confirmDialog) Active() bool {
5874 return len(d.pending) > 0
5975 }
6076
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+
61107 // CancelAll refuses every pending request (used when the user
62108 // cancels the active turn or closes the session while the dialog
63109 // is open). Safe to call when idle.
@@ -65,6 +111,7 @@ func (d *confirmDialog) CancelAll(reason string) {
65111 d.mu.Lock()
66112 pending := d.pending
67113 d.pending = nil
114+ d.focused = false
68115 d.mu.Unlock()
69116 for _, req := range pending {
70117 select {
@@ -81,6 +128,7 @@ func (d *confirmDialog) AllowAllPending() {
81128 d.mu.Lock()
82129 pending := d.pending
83130 d.pending = nil
131+ d.focused = false
84132 d.mu.Unlock()
85133 for _, req := range pending {
86134 select {
@@ -114,6 +162,20 @@ var confirmOptions = []struct {
114162 },
115163 }
116164
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+
117179 // HandleKey advances the selection or resolves the dialog. Returns
118180 // true when a decision was sent back to the agent (the dialog may
119181 // remain Active if there are more queued requests behind it).
@@ -143,21 +205,13 @@ func (d *confirmDialog) HandleKey(k tui.Key) bool {
143205 // active turn can separately be cancelled by the outer esc
144206 // handler, but for the dialog itself, we must always answer
145207 // 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)
151209 d.mu.Unlock()
152210 req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"}
153211 return true
154212 case tui.KeyEnter:
155213 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)
161215 d.mu.Unlock()
162216 req.resp <- selected
163217 return true
@@ -168,11 +222,7 @@ func (d *confirmDialog) HandleKey(k tui.Key) bool {
168222 idx := int(k.Rune - '1')
169223 if idx >= 0 && idx < len(confirmOptions) {
170224 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)
176226 d.mu.Unlock()
177227 req.resp <- selected
178228 return true
@@ -207,7 +257,11 @@ func (d *confirmDialog) Render(th tui.Theme, width int) []string {
207257 }
208258 lines = append(lines, toolLine)
209259 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))
211265
212266 for i, opt := range confirmOptions {
213267 label := opt.label
@@ -14,9 +14,10 @@ import (
14 // writes it onto the interactive's queue, the TUI renders the14 // 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 string17+ toolName string
18- preview string18+ preview string
19- resp chan core.ConfirmDecision19+ resp chan core.ConfirmDecision
20+ returnToChild bool
20 }21 }
21 22
22 // confirmDialog is the inline prompt shown before every tool call23 // confirmDialog is the inline prompt shown before every tool call
@@ -26,6 +27,7 @@ type confirmDialog struct {
26 mu sync.Mutex27 mu sync.Mutex
27 pending []*confirmRequest28 pending []*confirmRequest
28 cursor int29 cursor int
30+ focused bool
29 // activeSince timestamps when the current request started31 // activeSince timestamps when the current request started
30 // showing. Used only for debug/future UX (e.g. flashing the32 // 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 = 060 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) > 074 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 user107 // CancelAll refuses every pending request (used when the user
62 // cancels the active turn or closes the session while the dialog108 // 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.pending112 pending := d.pending
67 d.pending = nil113 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.pending129 pending := d.pending
83 d.pending = nil130 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. Returns179 // HandleKey advances the selection or resolves the dialog. Returns
118 // true when a decision was sent back to the agent (the dialog may180 // 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 esc205 // active turn can separately be cancelled by the outer esc
144 // handler, but for the dialog itself, we must always answer206 // 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 true211 return true
154 case tui.KeyEnter:212 case tui.KeyEnter:
155 selected := confirmOptions[d.cursor].decision213 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 <- selected216 req.resp <- selected
163 return true217 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].decision224 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 <- selected227 req.resp <- selected
178 return true228 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.label267 label := opt.label
M packages/agent/modes/confirm_dialog_test.go +230 -0
@@ -8,6 +8,8 @@ import (
88 "testing"
99 "time"
1010
11+ "github.com/patriceckhart/zot/packages/agent/extproto"
12+ "github.com/patriceckhart/zot/packages/agent/tools"
1113 "github.com/patriceckhart/zot/packages/core"
1214 "github.com/patriceckhart/zot/packages/tui"
1315 )
@@ -77,6 +79,234 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
7779 }
7880 }
7981
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+
80310 func TestConfirmDialogAllowsToolExpansion(t *testing.T) {
81311 resp := make(chan core.ConfirmDecision, 1)
82312 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() {
11451145 dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
11461146 case i.jumpDialog.Active():
11471147 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)
11481150 case i.confirmDialog.Active():
11491151 if i.btwDialog.Active() {
11501152 // Keep the side-chat transcript and its live tool preview visible
@@ -1170,8 +1172,6 @@ func (i *Interactive) redraw() {
11701172 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
11711173 case i.sessionTreeDialog.Active():
11721174 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
1173- case i.extPanel.Active():
1174- dialog = i.extPanel.Render(i.cfg.Theme, cols)
11751175 }
11761176 if len(dialog) > 0 {
11771177 dialog = padDialogFrame(dialog)
@@ -1211,9 +1211,10 @@ func (i *Interactive) redraw() {
12111211 // in runSlash already handles the busy case per-command: safe
12121212 // ones run immediately, destructive ones cancel the turn first.
12131213 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) {
12151216 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) {
12171218 suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
12181219 }
12191220
@@ -1866,7 +1867,46 @@ func (i *Interactive) toggleBtwToolExpansion() {
18661867 i.invalidate()
18671868 }
18681869
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+
18691907 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1908+ defer i.restoreConfirmFocus()
1909+
18701910 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
18711911 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
18721912 // 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) {
18831923
18841924 // Dialogs consume keys while open (except ctrl+c, which always closes them).
18851925
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+ }
18911937 if k.Kind == tui.KeyCtrlO {
18921938 if i.btwDialog != nil && i.btwDialog.Active() {
18931939 i.toggleBtwToolExpansion()
@@ -1900,6 +1946,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
19001946 i.invalidate()
19011947 return false
19021948 }
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+ }
19031957 if i.dialog.Active() {
19041958 if k.Kind == tui.KeyCtrlC {
19051959 i.dialog.Close()
@@ -2727,6 +2781,10 @@ func (i *Interactive) appendExtensionNote(extName, msg, level string) {
27272781 i.statusErr = ""
27282782 i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line))
27292783 }
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()
27302788 }
27312789
27322790 // HostHooks implementation for the extension manager. The manager
@@ -2911,6 +2969,7 @@ func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
29112969 i.mu.Lock()
29122970 defer i.mu.Unlock()
29132971 i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
2972+ i.confirmDialog.Blur()
29142973 if i.cfg.Extensions != nil {
29152974 cols, rows := i.cfg.Terminal.Size()
29162975 _ = cols
@@ -4496,6 +4555,9 @@ func (i *Interactive) cancelAndWaitForIdle() {
44964555 if cancel != nil {
44974556 cancel()
44984557 }
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")
44994561 deadline := time.Now().Add(2 * time.Second)
45004562 for time.Now().Before(deadline) {
45014563 i.mu.Lock()
@@ -5646,6 +5708,7 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis
56465708 // tool panel, then blocks until the user approves or refuses the call.
56475709 func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
56485710 resp := make(chan core.ConfirmDecision, 1)
5711+ returnToChild := false
56495712 if call.ID != "" {
56505713 i.mu.Lock()
56515714 if tc, ok := i.toolCalls[call.ID]; ok {
@@ -5654,13 +5717,14 @@ func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.Confi
56545717 }
56555718 i.mu.Unlock()
56565719 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)
56585721 }
56595722 }
56605723 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,
56645728 })
56655729 i.invalidate()
56665730 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 visible1152 // 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: safe1211 // 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, so1910 // 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 quit1911 // 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 is1926+ // Confirmation owns input by default. Pressing / deliberately moves
1887- // blocked waiting for an answer, so we must not let keys leak1927+ // focus to the main editor without answering the pending request, so
1888- // anywhere else while it's up. Ctrl+O remains available so the1928+ // 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 false1947 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 manager2790 // 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 _ = cols2975 _ = 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 <-resp5730 return <-resp

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