1package modes
2
3import (
4 "fmt"
5 "strings"
6 "sync"
7 "time"
8
9 "github.com/patriceckhart/zot/packages/core"
10 "github.com/patriceckhart/zot/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// AllowAllPending approves every pending request and drains the
125// queue. Used by /yolo so any confirmation dialog already on screen
126// resolves immediately as "yes".
127func (d *confirmDialog) AllowAllPending() {
128 d.mu.Lock()
129 pending := d.pending
130 d.pending = nil
131 d.focused = false
132 d.mu.Unlock()
133 for _, req := range pending {
134 select {
135 case req.resp <- core.ConfirmDecision{Allow: true, RememberAll: true}:
136 default:
137 }
138 }
139}
140
141// confirmOptions lists the four responses in vertical order. Keyed
142// by index so HandleKey can send the right decision.
143var confirmOptions = []struct {
144 label string
145 decision core.ConfirmDecision
146}{
147 {
148 label: "yes (run this call)",
149 decision: core.ConfirmDecision{Allow: true},
150 },
151 {
152 label: "yes, always this tool (skip prompts for this tool for the rest of the session)",
153 decision: core.ConfirmDecision{Allow: true, RememberTool: true},
154 },
155 {
156 label: "yes, always (skip all prompts for the rest of the session)",
157 decision: core.ConfirmDecision{Allow: true, RememberAll: true},
158 },
159 {
160 label: "no (refuse and let the model try something else)",
161 decision: core.ConfirmDecision{Allow: false, Reason: "user declined"},
162 },
163}
164
165// advanceLocked removes the current request and chooses whether confirmation
166// or the child interaction behind it receives input next.
167func (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
179// HandleKey advances the selection or resolves the dialog. Returns
180// true when a decision was sent back to the agent (the dialog may
181// remain Active if there are more queued requests behind it).
182func (d *confirmDialog) HandleKey(k tui.Key) bool {
183 d.mu.Lock()
184 if len(d.pending) == 0 {
185 d.mu.Unlock()
186 return false
187 }
188 req := d.pending[0]
189
190 switch k.Kind {
191 case tui.KeyUp:
192 if d.cursor > 0 {
193 d.cursor--
194 }
195 d.mu.Unlock()
196 return false
197 case tui.KeyDown:
198 if d.cursor < len(confirmOptions)-1 {
199 d.cursor++
200 }
201 d.mu.Unlock()
202 return false
203 case tui.KeyEsc, tui.KeyCtrlC:
204 // Treat esc/ctrl+c as "no, refuse this tool call". The
205 // active turn can separately be cancelled by the outer esc
206 // handler, but for the dialog itself, we must always answer
207 // so the agent goroutine unblocks.
208 d.advanceLocked(req)
209 d.mu.Unlock()
210 req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"}
211 return true
212 case tui.KeyEnter:
213 selected := confirmOptions[d.cursor].decision
214 d.advanceLocked(req)
215 d.mu.Unlock()
216 req.resp <- selected
217 return true
218 }
219
220 // Numeric shortcuts 1..4
221 if k.Kind == tui.KeyRune && k.Rune >= '1' && k.Rune <= '4' {
222 idx := int(k.Rune - '1')
223 if idx >= 0 && idx < len(confirmOptions) {
224 selected := confirmOptions[idx].decision
225 d.advanceLocked(req)
226 d.mu.Unlock()
227 req.resp <- selected
228 return true
229 }
230 }
231
232 d.mu.Unlock()
233 return false
234}
235
236// Render draws the dialog for the head-of-queue request.
237func (d *confirmDialog) Render(th tui.Theme, width int) []string {
238 d.mu.Lock()
239 defer d.mu.Unlock()
240 if len(d.pending) == 0 {
241 return nil
242 }
243 req := d.pending[0]
244 cursor := d.cursor
245 queued := len(d.pending) - 1
246
247 var lines []string
248 title := "confirm tool call"
249 if queued > 0 {
250 title = fmt.Sprintf("confirm tool call (%d more queued)", queued)
251 }
252 lines = append(lines, frameHeader(th, title, width))
253
254 toolLine := " " + th.FG256(th.Tool, req.toolName)
255 if req.preview != "" {
256 toolLine += " " + th.FG256(th.Muted, req.preview)
257 }
258 lines = append(lines, toolLine)
259 lines = append(lines, "")
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))
265
266 for i, opt := range confirmOptions {
267 label := opt.label
268 prefix := fmt.Sprintf(" %d. ", i+1)
269 plain := prefix + label
270 // Truncate the tail if the line would exceed width; keeps
271 // the option numbers always visible.
272 if visibleLen(plain) > width-2 {
273 if width <= 5 {
274 plain = "..."[:max(0, width-2)]
275 } else {
276 plain = plain[:width-5] + "..."
277 }
278 }
279 if i == cursor {
280 lines = append(lines, th.PadHighlight(plain, width))
281 } else {
282 lines = append(lines, th.FG256(th.Muted, plain))
283 }
284 }
285 lines = append(lines, frameRule(th, width))
286 return lines
287}
288
289// visibleLen is a cheap char-count; ANSI codes aren't in the raw
290// text at this point so len() is fine.
291func visibleLen(s string) int { return strings.Count(s, "") - 1 }