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}
21
22// confirmDialog is the inline prompt shown before every tool call
23// when --no-yolo is on. It presents four options and routes the
24// answer back through the pending request's response channel.
25type confirmDialog struct {
26 mu sync.Mutex
27 pending []*confirmRequest
28 cursor int
29 // activeSince timestamps when the current request started
30 // showing. Used only for debug/future UX (e.g. flashing the
31 // dialog border after N seconds of no answer).
32 activeSince time.Time
33}
34
35func newConfirmDialog() *confirmDialog { return &confirmDialog{} }
36
37// Enqueue adds a pending request. Multiple requests can queue up
38// if the agent produces tool calls in parallel (via extensions);
39// the TUI shows them one at a time, in order.
40func (d *confirmDialog) Enqueue(req *confirmRequest) {
41 d.mu.Lock()
42 defer d.mu.Unlock()
43 d.pending = append(d.pending, req)
44 if len(d.pending) == 1 {
45 d.cursor = 0
46 d.activeSince = time.Now()
47 }
48}
49
50// Active reports whether the dialog is consuming input (there's at
51// least one pending request).
52func (d *confirmDialog) Active() bool {
53 if d == nil {
54 return false
55 }
56 d.mu.Lock()
57 defer d.mu.Unlock()
58 return len(d.pending) > 0
59}
60
61// CancelAll refuses every pending request (used when the user
62// cancels the active turn or closes the session while the dialog
63// is open). Safe to call when idle.
64func (d *confirmDialog) CancelAll(reason string) {
65 d.mu.Lock()
66 pending := d.pending
67 d.pending = nil
68 d.mu.Unlock()
69 for _, req := range pending {
70 select {
71 case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}:
72 default:
73 }
74 }
75}
76
77// AllowAllPending approves every pending request and drains the
78// queue. Used by /yolo so any confirmation dialog already on screen
79// resolves immediately as "yes".
80func (d *confirmDialog) AllowAllPending() {
81 d.mu.Lock()
82 pending := d.pending
83 d.pending = nil
84 d.mu.Unlock()
85 for _, req := range pending {
86 select {
87 case req.resp <- core.ConfirmDecision{Allow: true, RememberAll: true}:
88 default:
89 }
90 }
91}
92
93// confirmOptions lists the four responses in vertical order. Keyed
94// by index so HandleKey can send the right decision.
95var confirmOptions = []struct {
96 label string
97 decision core.ConfirmDecision
98}{
99 {
100 label: "yes (run this call)",
101 decision: core.ConfirmDecision{Allow: true},
102 },
103 {
104 label: "yes, always this tool (skip prompts for this tool for the rest of the session)",
105 decision: core.ConfirmDecision{Allow: true, RememberTool: true},
106 },
107 {
108 label: "yes, always (skip all prompts for the rest of the session)",
109 decision: core.ConfirmDecision{Allow: true, RememberAll: true},
110 },
111 {
112 label: "no (refuse and let the model try something else)",
113 decision: core.ConfirmDecision{Allow: false, Reason: "user declined"},
114 },
115}
116
117// HandleKey advances the selection or resolves the dialog. Returns
118// true when a decision was sent back to the agent (the dialog may
119// remain Active if there are more queued requests behind it).
120func (d *confirmDialog) HandleKey(k tui.Key) bool {
121 d.mu.Lock()
122 if len(d.pending) == 0 {
123 d.mu.Unlock()
124 return false
125 }
126 req := d.pending[0]
127
128 switch k.Kind {
129 case tui.KeyUp:
130 if d.cursor > 0 {
131 d.cursor--
132 }
133 d.mu.Unlock()
134 return false
135 case tui.KeyDown:
136 if d.cursor < len(confirmOptions)-1 {
137 d.cursor++
138 }
139 d.mu.Unlock()
140 return false
141 case tui.KeyEsc, tui.KeyCtrlC:
142 // Treat esc/ctrl+c as "no, refuse this tool call". The
143 // active turn can separately be cancelled by the outer esc
144 // handler, but for the dialog itself, we must always answer
145 // 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 }
151 d.mu.Unlock()
152 req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"}
153 return true
154 case tui.KeyEnter:
155 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 }
161 d.mu.Unlock()
162 req.resp <- selected
163 return true
164 }
165
166 // Numeric shortcuts 1..4
167 if k.Kind == tui.KeyRune && k.Rune >= '1' && k.Rune <= '4' {
168 idx := int(k.Rune - '1')
169 if idx >= 0 && idx < len(confirmOptions) {
170 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 }
176 d.mu.Unlock()
177 req.resp <- selected
178 return true
179 }
180 }
181
182 d.mu.Unlock()
183 return false
184}
185
186// Render draws the dialog for the head-of-queue request.
187func (d *confirmDialog) Render(th tui.Theme, width int) []string {
188 d.mu.Lock()
189 defer d.mu.Unlock()
190 if len(d.pending) == 0 {
191 return nil
192 }
193 req := d.pending[0]
194 cursor := d.cursor
195 queued := len(d.pending) - 1
196
197 var lines []string
198 title := "confirm tool call"
199 if queued > 0 {
200 title = fmt.Sprintf("confirm tool call (%d more queued)", queued)
201 }
202 lines = append(lines, frameHeader(th, title, width))
203
204 toolLine := " " + th.FG256(th.Tool, req.toolName)
205 if req.preview != "" {
206 toolLine += " " + th.FG256(th.Muted, req.preview)
207 }
208 lines = append(lines, toolLine)
209 lines = append(lines, "")
210 lines = append(lines, th.FG256(th.Muted, "choose (\u2191/\u2193 or 1-4, enter to pick, esc to refuse):"))
211
212 for i, opt := range confirmOptions {
213 label := opt.label
214 prefix := fmt.Sprintf(" %d. ", i+1)
215 plain := prefix + label
216 // Truncate the tail if the line would exceed width; keeps
217 // the option numbers always visible.
218 if visibleLen(plain) > width-2 {
219 if width <= 5 {
220 plain = "..."[:max(0, width-2)]
221 } else {
222 plain = plain[:width-5] + "..."
223 }
224 }
225 if i == cursor {
226 lines = append(lines, th.PadHighlight(plain, width))
227 } else {
228 lines = append(lines, th.FG256(th.Muted, plain))
229 }
230 }
231 lines = append(lines, frameRule(th, width))
232 return lines
233}
234
235// visibleLen is a cheap char-count; ANSI codes aren't in the raw
236// text at this point so len() is fine.
237func visibleLen(s string) int { return strings.Count(s, "") - 1 }