1package modes
2
3import (
4 "fmt"
5 "strings"
6 "sync"
7 "time"
8
9 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/tui"
11)
12
13// confirmRequest is one pending confirmation. The agent goroutine
14// writes it onto the interactive's queue, the TUI renders the
15// dialog, and the user's answer goes back through `resp`.
16type confirmRequest struct {
17 toolName string
18 preview string
19 resp chan core.ConfirmDecision
20 returnToChild bool
21}
22
23// confirmDialog is the inline prompt shown before every tool call
24// when --no-yolo is on. It presents four options and routes the
25// answer back through the pending request's response channel.
26type confirmDialog struct {
27 mu sync.Mutex
28 pending []*confirmRequest
29 cursor int
30 focused bool
31 // activeSince timestamps when the current request started
32 // showing. Used only for debug/future UX (e.g. flashing the
33 // dialog border after N seconds of no answer).
34 activeSince time.Time
35}
36
37func newConfirmDialog() *confirmDialog { return &confirmDialog{} }
38
39// Enqueue adds a pending request. Multiple requests can queue up
40// if the agent produces tool calls in parallel (via extensions);
41// the TUI shows them one at a time, in order.
42func (d *confirmDialog) Enqueue(req *confirmRequest) {
43 d.mu.Lock()
44 defer d.mu.Unlock()
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 {
60 d.cursor = 0
61 d.focused = true
62 d.activeSince = time.Now()
63 }
64}
65
66// Active reports whether the dialog is consuming input (there's at
67// least one pending request).
68func (d *confirmDialog) Active() bool {
69 if d == nil {
70 return false
71 }
72 d.mu.Lock()
73 defer d.mu.Unlock()
74 return len(d.pending) > 0
75}
76
77// Focused reports whether confirmation currently owns keyboard input.
78func (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.
88func (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.
98func (d *confirmDialog) Blur() {
99 if d == nil {
100 return
101 }
102 d.mu.Lock()
103 d.focused = false
104 d.mu.Unlock()
105}
106
107// CancelAll refuses every pending request (used when the user
108// cancels the active turn or closes the session while the dialog
109// is open). Safe to call when idle.
110func (d *confirmDialog) CancelAll(reason string) {
111 d.mu.Lock()
112 pending := d.pending
113 d.pending = nil
114 d.focused = false
115 d.mu.Unlock()
116 for _, req := range pending {
117 select {
118 case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}:
119 default:
120 }
121 }
122}
123
124// CancelChildConfirmations refuses side-chat confirmations without affecting
125// queued main-thread calls. Closing /btw must unblock its agent even though
126// the confirmation UI is no longer visible.
127func (d *confirmDialog) CancelChildConfirmations(reason string) {
128 if d == nil {
129 return
130 }
131 d.mu.Lock()
132 pending := d.pending
133 remaining := make([]*confirmRequest, 0, len(pending))
134 cancelled := make([]*confirmRequest, 0, len(pending))
135 activeCancelled := false
136 for index, req := range pending {
137 if req.returnToChild {
138 cancelled = append(cancelled, req)
139 activeCancelled = activeCancelled || index == 0
140 continue
141 }
142 remaining = append(remaining, req)
143 }
144 d.pending = remaining
145 if activeCancelled {
146 d.cursor = 0
147 d.focused = len(remaining) > 0
148 if d.focused {
149 d.activeSince = time.Now()
150 }
151 }
152 d.mu.Unlock()
153
154 for _, req := range cancelled {
155 select {
156 case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}:
157 default:
158 }
159 }
160}
161
162// AllowAllPending approves every pending request and drains the
163// queue. Used by /yolo so any confirmation dialog already on screen
164// resolves immediately as "yes".
165func (d *confirmDialog) AllowAllPending() {
166 d.mu.Lock()
167 pending := d.pending
168 d.pending = nil
169 d.focused = false
170 d.mu.Unlock()
171 for _, req := range pending {
172 select {
173 case req.resp <- core.ConfirmDecision{Allow: true, RememberAll: true}:
174 default:
175 }
176 }
177}
178
179// confirmOptions lists the four responses in vertical order. Keyed
180// by index so HandleKey can send the right decision.
181var confirmOptions = []struct {
182 label string
183 decision core.ConfirmDecision
184}{
185 {
186 label: "yes (run this call)",
187 decision: core.ConfirmDecision{Allow: true},
188 },
189 {
190 label: "yes, always this tool (skip prompts for this tool for the rest of the session)",
191 decision: core.ConfirmDecision{Allow: true, RememberTool: true},
192 },
193 {
194 label: "yes, always (skip all prompts for the rest of the session)",
195 decision: core.ConfirmDecision{Allow: true, RememberAll: true},
196 },
197 {
198 label: "no (refuse and let the model try something else)",
199 decision: core.ConfirmDecision{Allow: false, Reason: "user declined"},
200 },
201}
202
203// advanceLocked removes the current request and chooses whether confirmation
204// or the child interaction behind it receives input next.
205func (d *confirmDialog) advanceLocked(req *confirmRequest) {
206 d.pending = d.pending[1:]
207 d.focused = len(d.pending) > 0
208 if req.returnToChild && (len(d.pending) == 0 || !d.pending[0].returnToChild) {
209 d.focused = false
210 }
211 if d.focused {
212 d.cursor = 0
213 d.activeSince = time.Now()
214 }
215}
216
217// HandleKey advances the selection or resolves the dialog. Returns
218// true when a decision was sent back to the agent (the dialog may
219// remain Active if there are more queued requests behind it).
220func (d *confirmDialog) HandleKey(k tui.Key) bool {
221 d.mu.Lock()
222 if len(d.pending) == 0 {
223 d.mu.Unlock()
224 return false
225 }
226 req := d.pending[0]
227
228 switch k.Kind {
229 case tui.KeyUp:
230 if d.cursor > 0 {
231 d.cursor--
232 }
233 d.mu.Unlock()
234 return false
235 case tui.KeyDown:
236 if d.cursor < len(confirmOptions)-1 {
237 d.cursor++
238 }
239 d.mu.Unlock()
240 return false
241 case tui.KeyEsc, tui.KeyCtrlC:
242 // Treat esc/ctrl+c as "no, refuse this tool call". The
243 // active turn can separately be cancelled by the outer esc
244 // handler, but for the dialog itself, we must always answer
245 // so the agent goroutine unblocks.
246 d.advanceLocked(req)
247 d.mu.Unlock()
248 req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"}
249 return true
250 case tui.KeyEnter:
251 selected := confirmOptions[d.cursor].decision
252 d.advanceLocked(req)
253 d.mu.Unlock()
254 req.resp <- selected
255 return true
256 }
257
258 // Numeric shortcuts 1..4
259 if k.Kind == tui.KeyRune && k.Rune >= '1' && k.Rune <= '4' {
260 idx := int(k.Rune - '1')
261 if idx >= 0 && idx < len(confirmOptions) {
262 selected := confirmOptions[idx].decision
263 d.advanceLocked(req)
264 d.mu.Unlock()
265 req.resp <- selected
266 return true
267 }
268 }
269
270 d.mu.Unlock()
271 return false
272}
273
274// Render draws the dialog for the head-of-queue request.
275func (d *confirmDialog) Render(th tui.Theme, width int) []string {
276 d.mu.Lock()
277 defer d.mu.Unlock()
278 if len(d.pending) == 0 {
279 return nil
280 }
281 req := d.pending[0]
282 cursor := d.cursor
283 queued := len(d.pending) - 1
284
285 var lines []string
286 title := "confirm tool call"
287 if queued > 0 {
288 title = fmt.Sprintf("confirm tool call (%d more queued)", queued)
289 }
290 lines = append(lines, frameHeader(th, title, width))
291
292 toolLine := " " + th.FGColor(th.Tool, req.toolName)
293 if req.preview != "" {
294 toolLine += " " + th.FGColor(th.Muted, req.preview)
295 }
296 lines = append(lines, toolLine)
297 lines = append(lines, "")
298 hint := "choose (\u2191/\u2193 or 1-4, enter to pick, esc to refuse, / for commands):"
299 if !d.focused {
300 hint = "confirmation paused (finish or esc the command to return):"
301 }
302 lines = append(lines, th.FGColor(th.Muted, hint))
303
304 for i, opt := range confirmOptions {
305 label := opt.label
306 prefix := fmt.Sprintf(" %d. ", i+1)
307 plain := prefix + label
308 // Truncate the tail if the line would exceed width; keeps
309 // the option numbers always visible.
310 if visibleLen(plain) > width-2 {
311 if width <= 5 {
312 plain = "..."[:max(0, width-2)]
313 } else {
314 plain = plain[:width-5] + "..."
315 }
316 }
317 if i == cursor {
318 lines = append(lines, th.PadHighlight(plain, width))
319 } else {
320 lines = append(lines, th.FGColor(th.Muted, plain))
321 }
322 }
323 lines = append(lines, frameRule(th, width))
324 return lines
325}
326
327// visibleLen is a cheap char-count; ANSI codes aren't in the raw
328// text at this point so len() is fine.
329func visibleLen(s string) int { return strings.Count(s, "") - 1 }