v0.3.88 ed7e2b4

confirm_dialog.go RawHistoryFolder

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 }

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 477

↑↓ moveenter openesc close