v0.3.1 5c1a9ec

confirm_dialog.go RawHistoryFolder

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 }

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