detached ac6d556

feat(tool-gate): --no-yolo flag, confirm dialog, /yolo runtime toggle

Adds a per-tool-call confirmation gate. Default stays yolo mode
(tools run freely, same as today). Pass --no-yolo to require
explicit user approval before each tool invocation.

Interactive TUI:
  A dialog appears before every tool call. Shows the tool name and
  a one-line preview of its args (command / path / url / etc.)
  with four choices, selectable by arrow keys or numeric shortcut:

    1. yes                     (run this call)
    2. yes, always this tool   (skip prompts for this tool,
                                session-scoped)
    3. yes, always             (skip prompts for every tool,
                                session-scoped)
    4. no                      (refuse and let the model try
                                something else)

  Esc/ctrl+c refuses the current prompt. Esc during a running turn
  both cancels the turn AND drains any pending confirm so the
  agent goroutine doesn't deadlock. Multiple pending confirms are
  queued and answered one at a time with a count visible in the
  header.

  Type /yolo to disable the gate for the rest of the session
  (equivalent to the "yes, always" choice but without needing a
  pending prompt). Any currently-open confirm auto-allows so the
  agent keeps moving.

Print / JSON / RPC modes:
  No interactive prompt is available, so every tool call is
  auto-refused with a reason the model can learn from:
  "tool call refused: --no-yolo is active and there is no
  interactive prompt in this mode; ask the user what to do
  instead". Observed behaviour: the model pivots to asking the
  user directly instead of looping on the same tool.

Implementation:
  internal/core/confirm.go
    - ConfirmDecision, Confirmer interface
    - ConfirmGate with session-scoped memory for "always this tool"
      and "always everything" decisions, both concurrency-safe
    - BuildPreview: turns {"command":"ls"} into "ls", etc.
    - Lives in core to avoid a modes -> agent import cycle
  internal/core/confirm_test.go
    - Tests: nil gate allows, nil-inner refuses with reason, one-
      shot allow doesn't remember, remember-tool short-circuits
      only same tool, remember-all short-circuits everything,
      refusal reasons surface, empty-reason gets a default,
      runtime AllowAll works, BuildPreview handles each field
  internal/agent/modes/confirm_dialog.go
    - Queue-based dialog, HandleKey wiring, CancelAll and
      AllowAllPending for the two exit cases
  internal/agent/modes/interactive.go
    - InteractiveConfig gains NoYolo + ConfirmGate fields
    - Interactive implements core.Confirmer via a response channel
    - Confirm dialog dispatched FIRST in the key-handler chain so
      keys never leak to other dialogs while the agent is blocked
    - Esc-while-busy also calls confirmDialog.CancelAll so the
      agent unblocks
    - /yolo slash command handled in runSlash
  internal/agent/cli.go
    - Constructs the ConfirmGate when args.NoYolo is set,
      BeforeToolExecute calls it first, extensions only see calls
      the user already approved
    - After iv is built, SetConfirmer(iv) wires the gate's inner
      so interactive + gate share the same struct
    - wireNoYoloAutoRefuse() for print / json modes
  internal/agent/args.go
    - --no-yolo flag and help text
  internal/agent/modes/slash_suggest.go
    - /yolo added to slashCatalog

Verified end-to-end: fresh zot --no-yolo -p "read sample.ts" now
returns "I can't read files in this mode (--no-yolo without an
interactive prompt). How would you like to proceed" instead of
actually reading.
commit
ac6d556f0a2559342461a2bee5addbf23f75796e
author
patriceckhart · · 2026-04-19 17:12 UTC
parent
f371687
signature
unsigned
refs
tag: v0.0.42
8 files · +748 -1
M README.md +2 -0
@@ -152,6 +152,7 @@ zot --help
152152 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
153153 | `--with-skills` | Also load user-installed skills. Without this, only the built-in skills shipped in the binary are loaded. |
154154 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
155+| `--no-yolo` | Confirm every tool call before it runs. In the TUI, a dialog shows the tool name and a one-line preview of its args with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. In print / json / rpc modes (no interactive prompt) every tool call is auto-refused with a reason the model can learn from. Type `/yolo` in the TUI to disable the gate for the rest of the session. |
155156
156157 ## Tools
157158
@@ -196,6 +197,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
196197 | `/lock` | Confine tools to the current directory. |
197198 | `/unlock` | Allow tools to touch paths outside again. |
198199 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
200+| `/yolo` | Turn off `--no-yolo` confirmation for the rest of this session. |
199201 | `/clear` | Clear the chat transcript. |
200202 | `/exit` | Exit zot. |
201203
@@ -152,6 +152,7 @@ zot --help
152 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |152 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
153 | `--with-skills` | Also load user-installed skills. Without this, only the built-in skills shipped in the binary are loaded. |153 | `--with-skills` | Also load user-installed skills. Without this, only the built-in skills shipped in the binary are loaded. |
154 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |154 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
155+| `--no-yolo` | Confirm every tool call before it runs. In the TUI, a dialog shows the tool name and a one-line preview of its args with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. In print / json / rpc modes (no interactive prompt) every tool call is auto-refused with a reason the model can learn from. Type `/yolo` in the TUI to disable the gate for the rest of the session. |
155 156
156 ## Tools157 ## Tools
157 158
@@ -196,6 +197,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
196 | `/lock` | Confine tools to the current directory. |197 | `/lock` | Confine tools to the current directory. |
197 | `/unlock` | Allow tools to touch paths outside again. |198 | `/unlock` | Allow tools to touch paths outside again. |
198 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |199 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
200+| `/yolo` | Turn off `--no-yolo` confirmation for the rest of this session. |
199 | `/clear` | Clear the chat transcript. |201 | `/clear` | Clear the chat transcript. |
200 | `/exit` | Exit zot. |202 | `/exit` | Exit zot. |
201 203
M internal/agent/args.go +16 -0
@@ -64,6 +64,16 @@ type Args struct {
6464 // around in the user's home directory.
6565 WithSkills bool
6666
67+ // NoYolo turns on per-tool confirmation. Before each tool
68+ // invocation the TUI prompts the user with the tool name + args
69+ // and waits for an explicit yes/no. The user can also pick
70+ // "always for this tool this session" or "always for anything
71+ // this session" to stop being prompted again. Defaults off
72+ // (yolo mode): tools run without asking. In -p / --json / rpc
73+ // modes there's no interactive TUI, so --no-yolo auto-refuses
74+ // every tool call with a reason the model can learn from.
75+ NoYolo bool
76+
6777 ListModels bool
6878 Help bool
6979 Version bool
@@ -161,6 +171,8 @@ func ParseArgs(in []string) (Args, error) {
161171 a.NoSkill = true
162172 case "--with-skills", "--with-skill":
163173 a.WithSkills = true
174+ case "--no-yolo":
175+ a.NoYolo = true
164176 case "--reasoning":
165177 v, err := want(&i, arg)
166178 if err != nil {
@@ -274,6 +286,10 @@ flags:
274286 .claude/skills/ + .agents/skills/.
275287 default: only built-in skills load
276288
289+ --no-yolo ask before running every tool call
290+ (interactive mode only; in -p / --json
291+ / rpc this refuses every tool call)
292+
277293 --max-steps N agent loop iteration cap (default 50)
278294 --list-models print known models and exit
279295 -h, --help this message
@@ -64,6 +64,16 @@ type Args struct {
64 // around in the user's home directory.64 // around in the user's home directory.
65 WithSkills bool65 WithSkills bool
66 66
67+ // NoYolo turns on per-tool confirmation. Before each tool
68+ // invocation the TUI prompts the user with the tool name + args
69+ // and waits for an explicit yes/no. The user can also pick
70+ // "always for this tool this session" or "always for anything
71+ // this session" to stop being prompted again. Defaults off
72+ // (yolo mode): tools run without asking. In -p / --json / rpc
73+ // modes there's no interactive TUI, so --no-yolo auto-refuses
74+ // every tool call with a reason the model can learn from.
75+ NoYolo bool
76+
67 ListModels bool77 ListModels bool
68 Help bool78 Help bool
69 Version bool79 Version bool
@@ -161,6 +171,8 @@ func ParseArgs(in []string) (Args, error) {
161 a.NoSkill = true171 a.NoSkill = true
162 case "--with-skills", "--with-skill":172 case "--with-skills", "--with-skill":
163 a.WithSkills = true173 a.WithSkills = true
174+ case "--no-yolo":
175+ a.NoYolo = true
164 case "--reasoning":176 case "--reasoning":
165 v, err := want(&i, arg)177 v, err := want(&i, arg)
166 if err != nil {178 if err != nil {
@@ -274,6 +286,10 @@ flags:
274 .claude/skills/ + .agents/skills/.286 .claude/skills/ + .agents/skills/.
275 default: only built-in skills load287 default: only built-in skills load
276 288
289+ --no-yolo ask before running every tool call
290+ (interactive mode only; in -p / --json
291+ / rpc this refuses every tool call)
292+
277 --max-steps N agent loop iteration cap (default 50)293 --max-steps N agent loop iteration cap (default 50)
278 --list-models print known models and exit294 --list-models print known models and exit
279 -h, --help this message295 -h, --help this message
M internal/agent/cli.go +53 -0
@@ -189,6 +189,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
189189 return err
190190 }
191191 ag := r.NewAgent()
192+ wireNoYoloAutoRefuse(ag, args)
192193 sess, _ := openOrCreateSession(args, r, ag, version)
193194 defer sess.Close()
194195
@@ -207,12 +208,36 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
207208 return err
208209 }
209210
211+// wireNoYoloAutoRefuse installs a BeforeToolExecute hook that
212+// refuses every tool call when --no-yolo is active but there's no
213+// interactive UI to prompt (print / json / rpc modes). The reason
214+// is written in a way the model can learn from so it proposes a
215+// different action rather than looping on the same tool.
216+func wireNoYoloAutoRefuse(ag *core.Agent, args Args) {
217+ if !args.NoYolo || ag == nil {
218+ return
219+ }
220+ gate := core.NewConfirmGate(nil) // nil inner = auto-refuse
221+ prev := ag.BeforeToolExecute
222+ ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
223+ ok, reason, _ := gate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
224+ if !ok {
225+ return false, reason, nil
226+ }
227+ if prev != nil {
228+ return prev(call)
229+ }
230+ return true, "", nil
231+ }
232+}
233+
210234 func runJSONMode(ctx context.Context, args Args, version string) error {
211235 r, err := Resolve(args, true)
212236 if err != nil {
213237 return err
214238 }
215239 ag := r.NewAgent()
240+ wireNoYoloAutoRefuse(ag, args)
216241 sess, _ := openOrCreateSession(args, r, ag, version)
217242 defer sess.Close()
218243
@@ -283,6 +308,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
283308 extToolAdapter := &extToolAdapter{mgr: extMgr}
284309 r.MergeExtensionTools(extToolAdapter)
285310
311+ // Confirmation gate: when --no-yolo is on, the agent must ask
312+ // the user before every tool call. In interactive mode the TUI
313+ // provides the Confirmer; in print/json/rpc modes there's no
314+ // way to prompt, so the gate is constructed with a nil inner
315+ // which auto-refuses every call with a helpful reason.
316+ var confirmGate *core.ConfirmGate
317+ if args.NoYolo {
318+ confirmGate = core.NewConfirmGate(nil) // set below for interactive
319+ }
320+
286321 // Capture current args in a closure so BuildAgent can re-resolve
287322 // after a successful login (picks up the newly stored credential).
288323 wireAgentExt := func(a *core.Agent) *core.Agent {
@@ -290,6 +325,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
290325 return a
291326 }
292327 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
328+ // Confirm gate runs FIRST: if the user refused, we don't
329+ // waste extension-intercept time or let guards see the call.
330+ if confirmGate != nil {
331+ ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
332+ if !ok {
333+ return false, reason, nil
334+ }
335+ }
293336 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
294337 if r.Block {
295338 return false, r.Reason, nil
@@ -492,6 +535,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
492535 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
493536 return skills.VisibleSkills(list)
494537 },
538+ NoYolo: args.NoYolo,
539+ ConfirmGate: confirmGate,
495540 PersistModel: func(providerName, model string) {
496541 // Update config.json so next launch uses the same pick.
497542 cfg, _ := LoadConfig()
@@ -505,6 +550,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
505550 },
506551 })
507552
553+ // Bind the interactive TUI as the Confirmer. We deferred this
554+ // until now because the gate is constructed before the TUI
555+ // (the BeforeToolExecute closure captures it). SetConfirmer
556+ // is mutex-guarded on the gate so this is safe.
557+ if confirmGate != nil {
558+ confirmGate.SetConfirmer(iv)
559+ }
560+
508561 runErr := iv.Run(ctx)
509562
510563 // Flush final transcript to session (only if we had / ended up with an agent).
@@ -189,6 +189,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
189 return err189 return err
190 }190 }
191 ag := r.NewAgent()191 ag := r.NewAgent()
192+ wireNoYoloAutoRefuse(ag, args)
192 sess, _ := openOrCreateSession(args, r, ag, version)193 sess, _ := openOrCreateSession(args, r, ag, version)
193 defer sess.Close()194 defer sess.Close()
194 195
@@ -207,12 +208,36 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
207 return err208 return err
208 }209 }
209 210
211+// wireNoYoloAutoRefuse installs a BeforeToolExecute hook that
212+// refuses every tool call when --no-yolo is active but there's no
213+// interactive UI to prompt (print / json / rpc modes). The reason
214+// is written in a way the model can learn from so it proposes a
215+// different action rather than looping on the same tool.
216+func wireNoYoloAutoRefuse(ag *core.Agent, args Args) {
217+ if !args.NoYolo || ag == nil {
218+ return
219+ }
220+ gate := core.NewConfirmGate(nil) // nil inner = auto-refuse
221+ prev := ag.BeforeToolExecute
222+ ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
223+ ok, reason, _ := gate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
224+ if !ok {
225+ return false, reason, nil
226+ }
227+ if prev != nil {
228+ return prev(call)
229+ }
230+ return true, "", nil
231+ }
232+}
233+
210 func runJSONMode(ctx context.Context, args Args, version string) error {234 func runJSONMode(ctx context.Context, args Args, version string) error {
211 r, err := Resolve(args, true)235 r, err := Resolve(args, true)
212 if err != nil {236 if err != nil {
213 return err237 return err
214 }238 }
215 ag := r.NewAgent()239 ag := r.NewAgent()
240+ wireNoYoloAutoRefuse(ag, args)
216 sess, _ := openOrCreateSession(args, r, ag, version)241 sess, _ := openOrCreateSession(args, r, ag, version)
217 defer sess.Close()242 defer sess.Close()
218 243
@@ -283,6 +308,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
283 extToolAdapter := &extToolAdapter{mgr: extMgr}308 extToolAdapter := &extToolAdapter{mgr: extMgr}
284 r.MergeExtensionTools(extToolAdapter)309 r.MergeExtensionTools(extToolAdapter)
285 310
311+ // Confirmation gate: when --no-yolo is on, the agent must ask
312+ // the user before every tool call. In interactive mode the TUI
313+ // provides the Confirmer; in print/json/rpc modes there's no
314+ // way to prompt, so the gate is constructed with a nil inner
315+ // which auto-refuses every call with a helpful reason.
316+ var confirmGate *core.ConfirmGate
317+ if args.NoYolo {
318+ confirmGate = core.NewConfirmGate(nil) // set below for interactive
319+ }
320+
286 // Capture current args in a closure so BuildAgent can re-resolve321 // Capture current args in a closure so BuildAgent can re-resolve
287 // after a successful login (picks up the newly stored credential).322 // after a successful login (picks up the newly stored credential).
288 wireAgentExt := func(a *core.Agent) *core.Agent {323 wireAgentExt := func(a *core.Agent) *core.Agent {
@@ -290,6 +325,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
290 return a325 return a
291 }326 }
292 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {327 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
328+ // Confirm gate runs FIRST: if the user refused, we don't
329+ // waste extension-intercept time or let guards see the call.
330+ if confirmGate != nil {
331+ ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
332+ if !ok {
333+ return false, reason, nil
334+ }
335+ }
293 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)336 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
294 if r.Block {337 if r.Block {
295 return false, r.Reason, nil338 return false, r.Reason, nil
@@ -492,6 +535,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
492 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)535 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
493 return skills.VisibleSkills(list)536 return skills.VisibleSkills(list)
494 },537 },
538+ NoYolo: args.NoYolo,
539+ ConfirmGate: confirmGate,
495 PersistModel: func(providerName, model string) {540 PersistModel: func(providerName, model string) {
496 // Update config.json so next launch uses the same pick.541 // Update config.json so next launch uses the same pick.
497 cfg, _ := LoadConfig()542 cfg, _ := LoadConfig()
@@ -505,6 +550,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
505 },550 },
506 })551 })
507 552
553+ // Bind the interactive TUI as the Confirmer. We deferred this
554+ // until now because the gate is constructed before the TUI
555+ // (the BeforeToolExecute closure captures it). SetConfirmer
556+ // is mutex-guarded on the gate so this is safe.
557+ if confirmGate != nil {
558+ confirmGate.SetConfirmer(iv)
559+ }
560+
508 runErr := iv.Run(ctx)561 runErr := iv.Run(ctx)
509 562
510 // Flush final transcript to session (only if we had / ended up with an agent).563 // Flush final transcript to session (only if we had / ended up with an agent).
A internal/agent/modes/confirm_dialog.go +233 -0
@@ -0,0 +1,233 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "sync"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/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`.
16+type 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.
25+type 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+
35+func 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.
40+func (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).
52+func (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.
64+func (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".
80+func (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.
95+var 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).
120+func (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.
187+func (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+ plain = plain[:width-3] + "\u2026"
220+ }
221+ if i == cursor {
222+ lines = append(lines, th.PadHighlight(plain, width))
223+ } else {
224+ lines = append(lines, th.FG256(th.Muted, plain))
225+ }
226+ }
227+ lines = append(lines, frameRule(th, width))
228+ return lines
229+}
230+
231+// visibleLen is a cheap char-count; ANSI codes aren't in the raw
232+// text at this point so len() is fine.
233+func visibleLen(s string) int { return strings.Count(s, "") - 1 }
@@ -0,0 +1,233 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "sync"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/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`.
16+type 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.
25+type 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+
35+func 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.
40+func (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).
52+func (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.
64+func (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".
80+func (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.
95+var 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).
120+func (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.
187+func (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+ plain = plain[:width-3] + "\u2026"
220+ }
221+ if i == cursor {
222+ lines = append(lines, th.PadHighlight(plain, width))
223+ } else {
224+ lines = append(lines, th.FG256(th.Muted, plain))
225+ }
226+ }
227+ lines = append(lines, frameRule(th, width))
228+ return lines
229+}
230+
231+// visibleLen is a cheap char-count; ANSI codes aren't in the raw
232+// text at this point so len() is fine.
233+func visibleLen(s string) int { return strings.Count(s, "") - 1 }
M internal/agent/modes/interactive.go +77 -1
@@ -102,6 +102,19 @@ type InteractiveConfig struct {
102102 // MarkChangelogShown call so the same version doesn't show
103103 // again on the next launch.
104104 OnChangelogDismiss func()
105+
106+ // NoYolo is true when --no-yolo was passed. Interactive opens
107+ // a confirmation dialog before every tool call and blocks the
108+ // tool until the user picks yes / always-this-tool /
109+ // always-all / no. When false (default), tools run freely.
110+ NoYolo bool
111+
112+ // ConfirmGate is the session-scoped gate wrapping this
113+ // interactive's Confirmer. When non-nil, /yolo can call
114+ // AllowAll() on it to disable confirmation for the rest of the
115+ // session. When nil (yolo mode), /yolo reports that there's
116+ // nothing to disable.
117+ ConfirmGate *core.ConfirmGate
105118 }
106119
107120 // ChangelogPayload mirrors agent.ChangelogInfo without the import
@@ -162,6 +175,7 @@ type Interactive struct {
162175 btwDialog *btwDialog
163176 skillsDialog *skillsDialog
164177 changelogDialog *changelogDialog
178+ confirmDialog *confirmDialog
165179 suggest *slashSuggester
166180 spin *spinner
167181
@@ -212,6 +226,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
212226 btwDialog: newBtwDialog(),
213227 skillsDialog: newSkillsDialog(),
214228 changelogDialog: newChangelogDialog(),
229+ confirmDialog: newConfirmDialog(),
215230 suggest: newSlashSuggester(),
216231 spin: newSpinner(),
217232 }
@@ -401,7 +416,7 @@ func (i *Interactive) Run(ctx context.Context) error {
401416 // and the AfterFunc-driven invalidate got dropped on a
402417 // full channel.
403418 drainPending()
404- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() {
419+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() {
405420 requestRedraw() // keep the spinner / dialog animation moving
406421 }
407422 }
@@ -559,6 +574,8 @@ func (i *Interactive) redraw() {
559574 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
560575 case i.changelogDialog.Active():
561576 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
577+ case i.confirmDialog.Active():
578+ dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
562579 }
563580
564581 // Slash-command autocomplete: popup above the status line, only
@@ -757,6 +774,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
757774 }
758775
759776 // Dialogs consume keys while open (except ctrl+c, which always closes them).
777+
778+ // Confirm dialog has highest priority: the agent goroutine is
779+ // blocked waiting for an answer, so we must not let keys leak
780+ // anywhere else while it's up.
781+ if i.confirmDialog.Active() {
782+ i.confirmDialog.HandleKey(k)
783+ i.invalidate()
784+ return false
785+ }
760786 if i.dialog.Active() {
761787 if k.Kind == tui.KeyCtrlC {
762788 i.dialog.Close()
@@ -879,6 +905,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
879905 // editor can clear itself.
880906 if i.busy && i.cancelTurn != nil {
881907 i.cancelTurn()
908+ // If a confirm dialog is pending, refuse it so the agent
909+ // goroutine unblocks and the context cancellation can
910+ // actually take effect.
911+ i.confirmDialog.CancelAll("turn cancelled")
882912 return false
883913 }
884914 case tui.KeyCtrlD:
@@ -1209,6 +1239,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
12091239 i.mu.Unlock()
12101240 case "/reload-ext":
12111241 i.runReloadExt(ctx)
1242+ case "/yolo":
1243+ i.runYoloOn()
12121244 default:
12131245 // Last-resort fallback: try the extension manager. Built-in
12141246 // cases above always win; this branch only fires for slash
@@ -1978,3 +2010,47 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
19782010 i.invalidate()
19792011 }()
19802012 }
2013+
2014+// Confirm implements core.Confirmer. The agent goroutine calls
2015+// this synchronously before every tool invocation when --no-yolo is
2016+// active. We push the request onto the confirmDialog queue, trigger
2017+// a redraw, and block the caller until the user answers.
2018+//
2019+// If the session is cancelled or the TUI exits mid-prompt, any
2020+// pending request is refused via CancelAll so the agent doesn't
2021+// deadlock.
2022+func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2023+ resp := make(chan core.ConfirmDecision, 1)
2024+ i.confirmDialog.Enqueue(&confirmRequest{
2025+ toolName: toolName,
2026+ preview: preview,
2027+ resp: resp,
2028+ })
2029+ i.invalidate()
2030+ return <-resp
2031+}
2032+
2033+// runYoloOn disables --no-yolo for the rest of the session. Tool
2034+// calls run without prompting after this; there's intentionally no
2035+// way to re-enable gating mid-session, if the user wants that back
2036+// they can exit and restart with --no-yolo.
2037+func (i *Interactive) runYoloOn() {
2038+ if i.cfg.ConfirmGate == nil {
2039+ i.mu.Lock()
2040+ i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2041+ i.statusErr = ""
2042+ i.mu.Unlock()
2043+ i.invalidate()
2044+ return
2045+ }
2046+ i.cfg.ConfirmGate.AllowAll()
2047+ // Also auto-allow any currently pending confirmation so the
2048+ // agent doesn't deadlock if /yolo is typed while a prompt is
2049+ // open.
2050+ i.confirmDialog.AllowAllPending()
2051+ i.mu.Lock()
2052+ i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2053+ i.statusErr = ""
2054+ i.mu.Unlock()
2055+ i.invalidate()
2056+}
@@ -102,6 +102,19 @@ type InteractiveConfig struct {
102 // MarkChangelogShown call so the same version doesn't show102 // MarkChangelogShown call so the same version doesn't show
103 // again on the next launch.103 // again on the next launch.
104 OnChangelogDismiss func()104 OnChangelogDismiss func()
105+
106+ // NoYolo is true when --no-yolo was passed. Interactive opens
107+ // a confirmation dialog before every tool call and blocks the
108+ // tool until the user picks yes / always-this-tool /
109+ // always-all / no. When false (default), tools run freely.
110+ NoYolo bool
111+
112+ // ConfirmGate is the session-scoped gate wrapping this
113+ // interactive's Confirmer. When non-nil, /yolo can call
114+ // AllowAll() on it to disable confirmation for the rest of the
115+ // session. When nil (yolo mode), /yolo reports that there's
116+ // nothing to disable.
117+ ConfirmGate *core.ConfirmGate
105 }118 }
106 119
107 // ChangelogPayload mirrors agent.ChangelogInfo without the import120 // ChangelogPayload mirrors agent.ChangelogInfo without the import
@@ -162,6 +175,7 @@ type Interactive struct {
162 btwDialog *btwDialog175 btwDialog *btwDialog
163 skillsDialog *skillsDialog176 skillsDialog *skillsDialog
164 changelogDialog *changelogDialog177 changelogDialog *changelogDialog
178+ confirmDialog *confirmDialog
165 suggest *slashSuggester179 suggest *slashSuggester
166 spin *spinner180 spin *spinner
167 181
@@ -212,6 +226,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
212 btwDialog: newBtwDialog(),226 btwDialog: newBtwDialog(),
213 skillsDialog: newSkillsDialog(),227 skillsDialog: newSkillsDialog(),
214 changelogDialog: newChangelogDialog(),228 changelogDialog: newChangelogDialog(),
229+ confirmDialog: newConfirmDialog(),
215 suggest: newSlashSuggester(),230 suggest: newSlashSuggester(),
216 spin: newSpinner(),231 spin: newSpinner(),
217 }232 }
@@ -401,7 +416,7 @@ func (i *Interactive) Run(ctx context.Context) error {
401 // and the AfterFunc-driven invalidate got dropped on a416 // and the AfterFunc-driven invalidate got dropped on a
402 // full channel.417 // full channel.
403 drainPending()418 drainPending()
404- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() {419+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() {
405 requestRedraw() // keep the spinner / dialog animation moving420 requestRedraw() // keep the spinner / dialog animation moving
406 }421 }
407 }422 }
@@ -559,6 +574,8 @@ func (i *Interactive) redraw() {
559 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)574 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
560 case i.changelogDialog.Active():575 case i.changelogDialog.Active():
561 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)576 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
577+ case i.confirmDialog.Active():
578+ dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
562 }579 }
563 580
564 // Slash-command autocomplete: popup above the status line, only581 // Slash-command autocomplete: popup above the status line, only
@@ -757,6 +774,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
757 }774 }
758 775
759 // Dialogs consume keys while open (except ctrl+c, which always closes them).776 // Dialogs consume keys while open (except ctrl+c, which always closes them).
777+
778+ // Confirm dialog has highest priority: the agent goroutine is
779+ // blocked waiting for an answer, so we must not let keys leak
780+ // anywhere else while it's up.
781+ if i.confirmDialog.Active() {
782+ i.confirmDialog.HandleKey(k)
783+ i.invalidate()
784+ return false
785+ }
760 if i.dialog.Active() {786 if i.dialog.Active() {
761 if k.Kind == tui.KeyCtrlC {787 if k.Kind == tui.KeyCtrlC {
762 i.dialog.Close()788 i.dialog.Close()
@@ -879,6 +905,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
879 // editor can clear itself.905 // editor can clear itself.
880 if i.busy && i.cancelTurn != nil {906 if i.busy && i.cancelTurn != nil {
881 i.cancelTurn()907 i.cancelTurn()
908+ // If a confirm dialog is pending, refuse it so the agent
909+ // goroutine unblocks and the context cancellation can
910+ // actually take effect.
911+ i.confirmDialog.CancelAll("turn cancelled")
882 return false912 return false
883 }913 }
884 case tui.KeyCtrlD:914 case tui.KeyCtrlD:
@@ -1209,6 +1239,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1209 i.mu.Unlock()1239 i.mu.Unlock()
1210 case "/reload-ext":1240 case "/reload-ext":
1211 i.runReloadExt(ctx)1241 i.runReloadExt(ctx)
1242+ case "/yolo":
1243+ i.runYoloOn()
1212 default:1244 default:
1213 // Last-resort fallback: try the extension manager. Built-in1245 // Last-resort fallback: try the extension manager. Built-in
1214 // cases above always win; this branch only fires for slash1246 // cases above always win; this branch only fires for slash
@@ -1978,3 +2010,47 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
1978 i.invalidate()2010 i.invalidate()
1979 }()2011 }()
1980 }2012 }
2013+
2014+// Confirm implements core.Confirmer. The agent goroutine calls
2015+// this synchronously before every tool invocation when --no-yolo is
2016+// active. We push the request onto the confirmDialog queue, trigger
2017+// a redraw, and block the caller until the user answers.
2018+//
2019+// If the session is cancelled or the TUI exits mid-prompt, any
2020+// pending request is refused via CancelAll so the agent doesn't
2021+// deadlock.
2022+func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
2023+ resp := make(chan core.ConfirmDecision, 1)
2024+ i.confirmDialog.Enqueue(&confirmRequest{
2025+ toolName: toolName,
2026+ preview: preview,
2027+ resp: resp,
2028+ })
2029+ i.invalidate()
2030+ return <-resp
2031+}
2032+
2033+// runYoloOn disables --no-yolo for the rest of the session. Tool
2034+// calls run without prompting after this; there's intentionally no
2035+// way to re-enable gating mid-session, if the user wants that back
2036+// they can exit and restart with --no-yolo.
2037+func (i *Interactive) runYoloOn() {
2038+ if i.cfg.ConfirmGate == nil {
2039+ i.mu.Lock()
2040+ i.statusOK = "yolo mode is already on (no --no-yolo in this session)"
2041+ i.statusErr = ""
2042+ i.mu.Unlock()
2043+ i.invalidate()
2044+ return
2045+ }
2046+ i.cfg.ConfirmGate.AllowAll()
2047+ // Also auto-allow any currently pending confirmation so the
2048+ // agent doesn't deadlock if /yolo is typed while a prompt is
2049+ // open.
2050+ i.confirmDialog.AllowAllPending()
2051+ i.mu.Lock()
2052+ i.statusOK = "yolo engaged: no more tool-call confirmations this session"
2053+ i.statusErr = ""
2054+ i.mu.Unlock()
2055+ i.invalidate()
2056+}
M internal/agent/modes/slash_suggest.go +1 -0
@@ -44,6 +44,7 @@ var slashCatalog = []slashCommand{
4444 {Name: "/lock", Desc: "confine tools to the current directory"},
4545 {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},
4646 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
47+ {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"},
4748 {Name: "/clear", Desc: "clear the chat transcript"},
4849 {Name: "/exit", Desc: "exit zot"},
4950 }
@@ -44,6 +44,7 @@ var slashCatalog = []slashCommand{
44 {Name: "/lock", Desc: "confine tools to the current directory"},44 {Name: "/lock", Desc: "confine tools to the current directory"},
45 {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},45 {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},
46 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},46 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
47+ {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"},
47 {Name: "/clear", Desc: "clear the chat transcript"},48 {Name: "/clear", Desc: "clear the chat transcript"},
48 {Name: "/exit", Desc: "exit zot"},49 {Name: "/exit", Desc: "exit zot"},
49 }50 }
A internal/core/confirm.go +182 -0
@@ -0,0 +1,182 @@
1+package core
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "sync"
7+)
8+
9+// ConfirmDecision is the outcome of a confirmation prompt for a
10+// tool call. It answers two questions: should this call run, and
11+// should future calls of this shape skip the prompt for the rest
12+// of the session.
13+type ConfirmDecision struct {
14+ Allow bool
15+ // Reason is shown to the model as the tool error when
16+ // Allow=false. Examples: "user declined", "user refused: rm -rf
17+ // looks dangerous".
18+ Reason string
19+ // RememberTool, when true, auto-allows every future call of the
20+ // same tool name for the rest of the session without prompting.
21+ RememberTool bool
22+ // RememberAll, when true, auto-allows every future call of any
23+ // tool for the rest of the session without prompting.
24+ // Effectively turns yolo back on for this session.
25+ RememberAll bool
26+}
27+
28+// Confirmer asks the user to approve or refuse a single tool call.
29+// Implementations block until the user responds (or the agent's
30+// context is cancelled, in which case they should return
31+// Allow=false with a cancellation reason).
32+//
33+// preview is a short one-line summary of the args (the shell
34+// command, the file path, the URL) that the TUI shows alongside
35+// the tool name. It is intentionally short: no full tool outputs.
36+type Confirmer interface {
37+ Confirm(toolName string, preview string) ConfirmDecision
38+}
39+
40+// ConfirmGate wraps a Confirmer with session-scoped memory for the
41+// "allow, always" decisions. Once the user picks RememberTool on a
42+// given tool name, the gate short-circuits subsequent calls of that
43+// name. Once the user picks RememberAll, the gate short-circuits
44+// everything for the rest of the session.
45+//
46+// Safe for concurrent use: the allow-lists are guarded by a mutex
47+// because the agent can queue tool calls from different goroutines.
48+type ConfirmGate struct {
49+ inner Confirmer
50+
51+ mu sync.Mutex
52+ allowAll bool
53+ allowedTool map[string]bool
54+}
55+
56+// NewConfirmGate returns a gate backed by inner. When inner is nil,
57+// the gate operates in auto-refuse mode: every tool call is denied
58+// with a fixed reason. That's what non-interactive modes (-p /
59+// --json / rpc) use when --no-yolo is on, since they have no way
60+// to prompt the user.
61+func NewConfirmGate(inner Confirmer) *ConfirmGate {
62+ return &ConfirmGate{
63+ inner: inner,
64+ allowedTool: map[string]bool{},
65+ }
66+}
67+
68+// Check is the BeforeToolExecute-style entry point. Returns
69+// allowed, reason, modifiedArgs. modifiedArgs is always nil: the
70+// gate never rewrites args; it only allows or denies.
71+//
72+// A nil ConfirmGate always allows (treat as yolo mode).
73+func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMessage) {
74+ if g == nil {
75+ return true, "", nil
76+ }
77+ g.mu.Lock()
78+ if g.allowAll {
79+ g.mu.Unlock()
80+ return true, "", nil
81+ }
82+ if g.allowedTool[toolName] {
83+ g.mu.Unlock()
84+ return true, "", nil
85+ }
86+ g.mu.Unlock()
87+
88+ g.mu.Lock()
89+ inner := g.inner
90+ g.mu.Unlock()
91+ if inner == nil {
92+ return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil
93+ }
94+
95+ decision := inner.Confirm(toolName, preview)
96+
97+ g.mu.Lock()
98+ if decision.Allow {
99+ if decision.RememberAll {
100+ g.allowAll = true
101+ }
102+ if decision.RememberTool {
103+ g.allowedTool[toolName] = true
104+ }
105+ }
106+ g.mu.Unlock()
107+
108+ reason := strings.TrimSpace(decision.Reason)
109+ if !decision.Allow && reason == "" {
110+ reason = "tool call refused by user"
111+ }
112+ return decision.Allow, reason, nil
113+}
114+
115+// Reset clears the session memory. Invoked when the user toggles
116+// yolo mode back on via /yolo or closes the session.
117+func (g *ConfirmGate) Reset() {
118+ if g == nil {
119+ return
120+ }
121+ g.mu.Lock()
122+ g.allowAll = false
123+ g.allowedTool = map[string]bool{}
124+ g.mu.Unlock()
125+}
126+
127+// AllowAll flips the gate into "always allow" for the rest of the
128+// session. Used by /yolo on to turn confirmation off at runtime
129+// without having to restart with the flag flipped.
130+func (g *ConfirmGate) AllowAll() {
131+ if g == nil {
132+ return
133+ }
134+ g.mu.Lock()
135+ g.allowAll = true
136+ g.mu.Unlock()
137+}
138+
139+// SetConfirmer replaces the inner Confirmer. Used by the cli to
140+// hand the Interactive TUI to a gate that was constructed before
141+// the TUI existed (a construction-order knot we unwind here).
142+// Safe to call from any goroutine.
143+func (g *ConfirmGate) SetConfirmer(c Confirmer) {
144+ if g == nil {
145+ return
146+ }
147+ g.mu.Lock()
148+ g.inner = c
149+ g.mu.Unlock()
150+}
151+
152+// BuildPreview turns a tool call's JSON args into a short one-line
153+// summary the TUI can show in the confirmation prompt. Prioritises
154+// obvious human-readable fields (command, path, url) over raw JSON.
155+// Returns at most maxLen characters.
156+func BuildPreview(args json.RawMessage, maxLen int) string {
157+ if maxLen <= 0 {
158+ maxLen = 120
159+ }
160+ if len(args) == 0 {
161+ return ""
162+ }
163+ var m map[string]any
164+ if err := json.Unmarshal(args, &m); err != nil {
165+ return truncatePreview(string(args), maxLen)
166+ }
167+ for _, k := range []string{"command", "path", "file_path", "url", "query", "name"} {
168+ if v, ok := m[k].(string); ok && v != "" {
169+ return truncatePreview(v, maxLen)
170+ }
171+ }
172+ b, _ := json.Marshal(m)
173+ return truncatePreview(string(b), maxLen)
174+}
175+
176+func truncatePreview(s string, n int) string {
177+ s = strings.TrimSpace(s)
178+ if len(s) <= n {
179+ return s
180+ }
181+ return s[:n-1] + "\u2026"
182+}
@@ -0,0 +1,182 @@
1+package core
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "sync"
7+)
8+
9+// ConfirmDecision is the outcome of a confirmation prompt for a
10+// tool call. It answers two questions: should this call run, and
11+// should future calls of this shape skip the prompt for the rest
12+// of the session.
13+type ConfirmDecision struct {
14+ Allow bool
15+ // Reason is shown to the model as the tool error when
16+ // Allow=false. Examples: "user declined", "user refused: rm -rf
17+ // looks dangerous".
18+ Reason string
19+ // RememberTool, when true, auto-allows every future call of the
20+ // same tool name for the rest of the session without prompting.
21+ RememberTool bool
22+ // RememberAll, when true, auto-allows every future call of any
23+ // tool for the rest of the session without prompting.
24+ // Effectively turns yolo back on for this session.
25+ RememberAll bool
26+}
27+
28+// Confirmer asks the user to approve or refuse a single tool call.
29+// Implementations block until the user responds (or the agent's
30+// context is cancelled, in which case they should return
31+// Allow=false with a cancellation reason).
32+//
33+// preview is a short one-line summary of the args (the shell
34+// command, the file path, the URL) that the TUI shows alongside
35+// the tool name. It is intentionally short: no full tool outputs.
36+type Confirmer interface {
37+ Confirm(toolName string, preview string) ConfirmDecision
38+}
39+
40+// ConfirmGate wraps a Confirmer with session-scoped memory for the
41+// "allow, always" decisions. Once the user picks RememberTool on a
42+// given tool name, the gate short-circuits subsequent calls of that
43+// name. Once the user picks RememberAll, the gate short-circuits
44+// everything for the rest of the session.
45+//
46+// Safe for concurrent use: the allow-lists are guarded by a mutex
47+// because the agent can queue tool calls from different goroutines.
48+type ConfirmGate struct {
49+ inner Confirmer
50+
51+ mu sync.Mutex
52+ allowAll bool
53+ allowedTool map[string]bool
54+}
55+
56+// NewConfirmGate returns a gate backed by inner. When inner is nil,
57+// the gate operates in auto-refuse mode: every tool call is denied
58+// with a fixed reason. That's what non-interactive modes (-p /
59+// --json / rpc) use when --no-yolo is on, since they have no way
60+// to prompt the user.
61+func NewConfirmGate(inner Confirmer) *ConfirmGate {
62+ return &ConfirmGate{
63+ inner: inner,
64+ allowedTool: map[string]bool{},
65+ }
66+}
67+
68+// Check is the BeforeToolExecute-style entry point. Returns
69+// allowed, reason, modifiedArgs. modifiedArgs is always nil: the
70+// gate never rewrites args; it only allows or denies.
71+//
72+// A nil ConfirmGate always allows (treat as yolo mode).
73+func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMessage) {
74+ if g == nil {
75+ return true, "", nil
76+ }
77+ g.mu.Lock()
78+ if g.allowAll {
79+ g.mu.Unlock()
80+ return true, "", nil
81+ }
82+ if g.allowedTool[toolName] {
83+ g.mu.Unlock()
84+ return true, "", nil
85+ }
86+ g.mu.Unlock()
87+
88+ g.mu.Lock()
89+ inner := g.inner
90+ g.mu.Unlock()
91+ if inner == nil {
92+ return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil
93+ }
94+
95+ decision := inner.Confirm(toolName, preview)
96+
97+ g.mu.Lock()
98+ if decision.Allow {
99+ if decision.RememberAll {
100+ g.allowAll = true
101+ }
102+ if decision.RememberTool {
103+ g.allowedTool[toolName] = true
104+ }
105+ }
106+ g.mu.Unlock()
107+
108+ reason := strings.TrimSpace(decision.Reason)
109+ if !decision.Allow && reason == "" {
110+ reason = "tool call refused by user"
111+ }
112+ return decision.Allow, reason, nil
113+}
114+
115+// Reset clears the session memory. Invoked when the user toggles
116+// yolo mode back on via /yolo or closes the session.
117+func (g *ConfirmGate) Reset() {
118+ if g == nil {
119+ return
120+ }
121+ g.mu.Lock()
122+ g.allowAll = false
123+ g.allowedTool = map[string]bool{}
124+ g.mu.Unlock()
125+}
126+
127+// AllowAll flips the gate into "always allow" for the rest of the
128+// session. Used by /yolo on to turn confirmation off at runtime
129+// without having to restart with the flag flipped.
130+func (g *ConfirmGate) AllowAll() {
131+ if g == nil {
132+ return
133+ }
134+ g.mu.Lock()
135+ g.allowAll = true
136+ g.mu.Unlock()
137+}
138+
139+// SetConfirmer replaces the inner Confirmer. Used by the cli to
140+// hand the Interactive TUI to a gate that was constructed before
141+// the TUI existed (a construction-order knot we unwind here).
142+// Safe to call from any goroutine.
143+func (g *ConfirmGate) SetConfirmer(c Confirmer) {
144+ if g == nil {
145+ return
146+ }
147+ g.mu.Lock()
148+ g.inner = c
149+ g.mu.Unlock()
150+}
151+
152+// BuildPreview turns a tool call's JSON args into a short one-line
153+// summary the TUI can show in the confirmation prompt. Prioritises
154+// obvious human-readable fields (command, path, url) over raw JSON.
155+// Returns at most maxLen characters.
156+func BuildPreview(args json.RawMessage, maxLen int) string {
157+ if maxLen <= 0 {
158+ maxLen = 120
159+ }
160+ if len(args) == 0 {
161+ return ""
162+ }
163+ var m map[string]any
164+ if err := json.Unmarshal(args, &m); err != nil {
165+ return truncatePreview(string(args), maxLen)
166+ }
167+ for _, k := range []string{"command", "path", "file_path", "url", "query", "name"} {
168+ if v, ok := m[k].(string); ok && v != "" {
169+ return truncatePreview(v, maxLen)
170+ }
171+ }
172+ b, _ := json.Marshal(m)
173+ return truncatePreview(string(b), maxLen)
174+}
175+
176+func truncatePreview(s string, n int) string {
177+ s = strings.TrimSpace(s)
178+ if len(s) <= n {
179+ return s
180+ }
181+ return s[:n-1] + "\u2026"
182+}
A internal/core/confirm_test.go +184 -0
@@ -0,0 +1,184 @@
1+package core
2+
3+import (
4+ "encoding/json"
5+ "sync"
6+ "testing"
7+)
8+
9+type recordingConfirmer struct {
10+ mu sync.Mutex
11+ calls []string
12+ replies []ConfirmDecision
13+ idx int
14+}
15+
16+func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision {
17+ r.mu.Lock()
18+ defer r.mu.Unlock()
19+ r.calls = append(r.calls, toolName+"/"+preview)
20+ if r.idx >= len(r.replies) {
21+ return ConfirmDecision{Allow: false, Reason: "no reply queued"}
22+ }
23+ d := r.replies[r.idx]
24+ r.idx++
25+ return d
26+}
27+
28+func TestConfirmGateNilAllowsEverything(t *testing.T) {
29+ var g *ConfirmGate
30+ allow, reason, args := g.Check("bash", "rm -rf /")
31+ if !allow || reason != "" || args != nil {
32+ t.Fatalf("nil gate should allow, got allow=%v reason=%q args=%s", allow, reason, args)
33+ }
34+}
35+
36+func TestConfirmGateNilInnerRefuses(t *testing.T) {
37+ g := NewConfirmGate(nil)
38+ allow, reason, _ := g.Check("bash", "ls")
39+ if allow {
40+ t.Fatal("gate with nil inner should refuse")
41+ }
42+ if reason == "" {
43+ t.Fatal("refusal must carry a reason for the model to learn from")
44+ }
45+}
46+
47+func TestConfirmGateAllowOnce(t *testing.T) {
48+ rc := &recordingConfirmer{replies: []ConfirmDecision{
49+ {Allow: true},
50+ {Allow: true},
51+ }}
52+ g := NewConfirmGate(rc)
53+ if allow, _, _ := g.Check("bash", "ls"); !allow {
54+ t.Fatal("call 1 should allow")
55+ }
56+ if allow, _, _ := g.Check("bash", "ls"); !allow {
57+ t.Fatal("call 2 should allow")
58+ }
59+ // Two calls, two confirmer invocations (no remember).
60+ if len(rc.calls) != 2 {
61+ t.Errorf("want 2 confirmer calls, got %d", len(rc.calls))
62+ }
63+}
64+
65+func TestConfirmGateRememberTool(t *testing.T) {
66+ rc := &recordingConfirmer{replies: []ConfirmDecision{
67+ {Allow: true, RememberTool: true},
68+ }}
69+ g := NewConfirmGate(rc)
70+
71+ // First call prompts and remembers.
72+ if allow, _, _ := g.Check("bash", "ls"); !allow {
73+ t.Fatal("call 1 should allow")
74+ }
75+ // Second call short-circuits; confirmer must NOT be invoked.
76+ if allow, _, _ := g.Check("bash", "pwd"); !allow {
77+ t.Fatal("call 2 should allow from memory")
78+ }
79+ // Different tool still prompts.
80+ rc.replies = append(rc.replies, ConfirmDecision{Allow: false, Reason: "no"})
81+ if allow, reason, _ := g.Check("read", "foo.txt"); allow || reason != "no" {
82+ t.Errorf("different tool should re-prompt; got allow=%v reason=%q", allow, reason)
83+ }
84+ if len(rc.calls) != 2 {
85+ t.Errorf("want 2 confirmer calls (bash+read), got %d: %v", len(rc.calls), rc.calls)
86+ }
87+}
88+
89+func TestConfirmGateRememberAll(t *testing.T) {
90+ rc := &recordingConfirmer{replies: []ConfirmDecision{
91+ {Allow: true, RememberAll: true},
92+ }}
93+ g := NewConfirmGate(rc)
94+ if allow, _, _ := g.Check("bash", "ls"); !allow {
95+ t.Fatal("call 1 should allow")
96+ }
97+ // From now on everything short-circuits.
98+ if allow, _, _ := g.Check("read", "foo.txt"); !allow {
99+ t.Fatal("call 2 should allow")
100+ }
101+ if allow, _, _ := g.Check("write", "bar.txt"); !allow {
102+ t.Fatal("call 3 should allow")
103+ }
104+ if len(rc.calls) != 1 {
105+ t.Errorf("want 1 confirmer call (remember-all short-circuits the rest), got %d", len(rc.calls))
106+ }
107+}
108+
109+func TestConfirmGateRefuseSurfacesReason(t *testing.T) {
110+ rc := &recordingConfirmer{replies: []ConfirmDecision{
111+ {Allow: false, Reason: "do not run sudo"},
112+ }}
113+ g := NewConfirmGate(rc)
114+ allow, reason, _ := g.Check("bash", "sudo rm -rf /")
115+ if allow || reason != "do not run sudo" {
116+ t.Errorf("want block + reason, got allow=%v reason=%q", allow, reason)
117+ }
118+}
119+
120+func TestConfirmGateRefuseEmptyReasonGetsDefault(t *testing.T) {
121+ rc := &recordingConfirmer{replies: []ConfirmDecision{
122+ {Allow: false},
123+ }}
124+ g := NewConfirmGate(rc)
125+ _, reason, _ := g.Check("bash", "x")
126+ if reason == "" {
127+ t.Fatal("empty reason must be replaced with a sensible default")
128+ }
129+}
130+
131+func TestConfirmGateAllowAllRuntime(t *testing.T) {
132+ rc := &recordingConfirmer{replies: []ConfirmDecision{
133+ {Allow: false, Reason: "no"},
134+ }}
135+ g := NewConfirmGate(rc)
136+ // Refuse first call
137+ if allow, _, _ := g.Check("bash", "ls"); allow {
138+ t.Fatal("call 1 should refuse")
139+ }
140+ // User types /yolo
141+ g.AllowAll()
142+ // All subsequent calls allowed without prompting.
143+ if allow, _, _ := g.Check("bash", "rm -rf tmp"); !allow {
144+ t.Fatal("after AllowAll, should allow")
145+ }
146+ if allow, _, _ := g.Check("read", "x"); !allow {
147+ t.Fatal("after AllowAll, should allow any tool")
148+ }
149+ if len(rc.calls) != 1 {
150+ t.Errorf("want 1 confirmer call (before AllowAll), got %d", len(rc.calls))
151+ }
152+}
153+
154+func TestBuildPreview(t *testing.T) {
155+ cases := []struct {
156+ name string
157+ args string
158+ want string
159+ }{
160+ {"bash command", `{"command":"ls -la"}`, "ls -la"},
161+ {"path", `{"path":"/tmp/x.txt"}`, "/tmp/x.txt"},
162+ {"file_path", `{"file_path":"a.go"}`, "a.go"},
163+ {"url", `{"url":"https://example.com"}`, "https://example.com"},
164+ {"truncation", `{"command":"` + string(make([]byte, 200)) + `"}`, ""},
165+ {"unparseable", `{not json`, `{not json`},
166+ {"empty", ``, ``},
167+ }
168+ for _, c := range cases {
169+ t.Run(c.name, func(t *testing.T) {
170+ got := BuildPreview(json.RawMessage(c.args), 50)
171+ if c.name == "truncation" && !hasEllipsis(got) {
172+ t.Errorf("%s: expected ellipsis truncation, got %q", c.name, got)
173+ return
174+ }
175+ if c.want != "" && got != c.want {
176+ t.Errorf("%s: want %q, got %q", c.name, c.want, got)
177+ }
178+ })
179+ }
180+}
181+
182+func hasEllipsis(s string) bool {
183+ return len(s) > 0 && s[len(s)-len("\u2026"):] == "\u2026"
184+}
@@ -0,0 +1,184 @@
1+package core
2+
3+import (
4+ "encoding/json"
5+ "sync"
6+ "testing"
7+)
8+
9+type recordingConfirmer struct {
10+ mu sync.Mutex
11+ calls []string
12+ replies []ConfirmDecision
13+ idx int
14+}
15+
16+func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision {
17+ r.mu.Lock()
18+ defer r.mu.Unlock()
19+ r.calls = append(r.calls, toolName+"/"+preview)
20+ if r.idx >= len(r.replies) {
21+ return ConfirmDecision{Allow: false, Reason: "no reply queued"}
22+ }
23+ d := r.replies[r.idx]
24+ r.idx++
25+ return d
26+}
27+
28+func TestConfirmGateNilAllowsEverything(t *testing.T) {
29+ var g *ConfirmGate
30+ allow, reason, args := g.Check("bash", "rm -rf /")
31+ if !allow || reason != "" || args != nil {
32+ t.Fatalf("nil gate should allow, got allow=%v reason=%q args=%s", allow, reason, args)
33+ }
34+}
35+
36+func TestConfirmGateNilInnerRefuses(t *testing.T) {
37+ g := NewConfirmGate(nil)
38+ allow, reason, _ := g.Check("bash", "ls")
39+ if allow {
40+ t.Fatal("gate with nil inner should refuse")
41+ }
42+ if reason == "" {
43+ t.Fatal("refusal must carry a reason for the model to learn from")
44+ }
45+}
46+
47+func TestConfirmGateAllowOnce(t *testing.T) {
48+ rc := &recordingConfirmer{replies: []ConfirmDecision{
49+ {Allow: true},
50+ {Allow: true},
51+ }}
52+ g := NewConfirmGate(rc)
53+ if allow, _, _ := g.Check("bash", "ls"); !allow {
54+ t.Fatal("call 1 should allow")
55+ }
56+ if allow, _, _ := g.Check("bash", "ls"); !allow {
57+ t.Fatal("call 2 should allow")
58+ }
59+ // Two calls, two confirmer invocations (no remember).
60+ if len(rc.calls) != 2 {
61+ t.Errorf("want 2 confirmer calls, got %d", len(rc.calls))
62+ }
63+}
64+
65+func TestConfirmGateRememberTool(t *testing.T) {
66+ rc := &recordingConfirmer{replies: []ConfirmDecision{
67+ {Allow: true, RememberTool: true},
68+ }}
69+ g := NewConfirmGate(rc)
70+
71+ // First call prompts and remembers.
72+ if allow, _, _ := g.Check("bash", "ls"); !allow {
73+ t.Fatal("call 1 should allow")
74+ }
75+ // Second call short-circuits; confirmer must NOT be invoked.
76+ if allow, _, _ := g.Check("bash", "pwd"); !allow {
77+ t.Fatal("call 2 should allow from memory")
78+ }
79+ // Different tool still prompts.
80+ rc.replies = append(rc.replies, ConfirmDecision{Allow: false, Reason: "no"})
81+ if allow, reason, _ := g.Check("read", "foo.txt"); allow || reason != "no" {
82+ t.Errorf("different tool should re-prompt; got allow=%v reason=%q", allow, reason)
83+ }
84+ if len(rc.calls) != 2 {
85+ t.Errorf("want 2 confirmer calls (bash+read), got %d: %v", len(rc.calls), rc.calls)
86+ }
87+}
88+
89+func TestConfirmGateRememberAll(t *testing.T) {
90+ rc := &recordingConfirmer{replies: []ConfirmDecision{
91+ {Allow: true, RememberAll: true},
92+ }}
93+ g := NewConfirmGate(rc)
94+ if allow, _, _ := g.Check("bash", "ls"); !allow {
95+ t.Fatal("call 1 should allow")
96+ }
97+ // From now on everything short-circuits.
98+ if allow, _, _ := g.Check("read", "foo.txt"); !allow {
99+ t.Fatal("call 2 should allow")
100+ }
101+ if allow, _, _ := g.Check("write", "bar.txt"); !allow {
102+ t.Fatal("call 3 should allow")
103+ }
104+ if len(rc.calls) != 1 {
105+ t.Errorf("want 1 confirmer call (remember-all short-circuits the rest), got %d", len(rc.calls))
106+ }
107+}
108+
109+func TestConfirmGateRefuseSurfacesReason(t *testing.T) {
110+ rc := &recordingConfirmer{replies: []ConfirmDecision{
111+ {Allow: false, Reason: "do not run sudo"},
112+ }}
113+ g := NewConfirmGate(rc)
114+ allow, reason, _ := g.Check("bash", "sudo rm -rf /")
115+ if allow || reason != "do not run sudo" {
116+ t.Errorf("want block + reason, got allow=%v reason=%q", allow, reason)
117+ }
118+}
119+
120+func TestConfirmGateRefuseEmptyReasonGetsDefault(t *testing.T) {
121+ rc := &recordingConfirmer{replies: []ConfirmDecision{
122+ {Allow: false},
123+ }}
124+ g := NewConfirmGate(rc)
125+ _, reason, _ := g.Check("bash", "x")
126+ if reason == "" {
127+ t.Fatal("empty reason must be replaced with a sensible default")
128+ }
129+}
130+
131+func TestConfirmGateAllowAllRuntime(t *testing.T) {
132+ rc := &recordingConfirmer{replies: []ConfirmDecision{
133+ {Allow: false, Reason: "no"},
134+ }}
135+ g := NewConfirmGate(rc)
136+ // Refuse first call
137+ if allow, _, _ := g.Check("bash", "ls"); allow {
138+ t.Fatal("call 1 should refuse")
139+ }
140+ // User types /yolo
141+ g.AllowAll()
142+ // All subsequent calls allowed without prompting.
143+ if allow, _, _ := g.Check("bash", "rm -rf tmp"); !allow {
144+ t.Fatal("after AllowAll, should allow")
145+ }
146+ if allow, _, _ := g.Check("read", "x"); !allow {
147+ t.Fatal("after AllowAll, should allow any tool")
148+ }
149+ if len(rc.calls) != 1 {
150+ t.Errorf("want 1 confirmer call (before AllowAll), got %d", len(rc.calls))
151+ }
152+}
153+
154+func TestBuildPreview(t *testing.T) {
155+ cases := []struct {
156+ name string
157+ args string
158+ want string
159+ }{
160+ {"bash command", `{"command":"ls -la"}`, "ls -la"},
161+ {"path", `{"path":"/tmp/x.txt"}`, "/tmp/x.txt"},
162+ {"file_path", `{"file_path":"a.go"}`, "a.go"},
163+ {"url", `{"url":"https://example.com"}`, "https://example.com"},
164+ {"truncation", `{"command":"` + string(make([]byte, 200)) + `"}`, ""},
165+ {"unparseable", `{not json`, `{not json`},
166+ {"empty", ``, ``},
167+ }
168+ for _, c := range cases {
169+ t.Run(c.name, func(t *testing.T) {
170+ got := BuildPreview(json.RawMessage(c.args), 50)
171+ if c.name == "truncation" && !hasEllipsis(got) {
172+ t.Errorf("%s: expected ellipsis truncation, got %q", c.name, got)
173+ return
174+ }
175+ if c.want != "" && got != c.want {
176+ t.Errorf("%s: want %q, got %q", c.name, c.want, got)
177+ }
178+ })
179+ }
180+}
181+
182+func hasEllipsis(s string) bool {
183+ return len(s) > 0 && s[len(s)-len("\u2026"):] == "\u2026"
184+}

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 476

↑↓ moveenter openesc close