detached af6c526

Merge #20: spontaneous open_panel for extension panels

Allow extensions to open panels outside slash-command responses, enabling
human-in-the-loop tool gates and secret/input collection patterns. Removes
the review-only spontaneous panel plan before merge.

Co-authored-by: Raymond Gasper <raymondgasper@fastmail.com>
commit
af6c526cd7acf4685fa70d89b551fb7194f0bb76
author
patriceckhart · · 2026-06-08 17:37 UTC
merge of
6938d13 + e6d8408 · diff against the first parent
signature
unsigned
11 files · +923 -3
A examples/extensions/approve/README.md +77 -0
@@ -0,0 +1,77 @@
1+# approve — example zot extension
2+
3+Demonstrates the **spontaneous `open_panel`** pattern: an
4+extension-registered tool opens a panel from inside its handler
5+goroutine, blocks until the user responds, then returns the result to
6+the model.
7+
8+## What it does
9+
10+Registers one LLM-callable tool:
11+
12+```
13+approve_action(action, reason?)
14+```
15+
16+When the model calls it, a panel appears in the TUI:
17+
18+```
19+╭─ Approval required ─────────────────────╮
20+│ Action: delete /tmp/build │
21+│ Reason: cleaning up after the build │
22+│ │
23+│ y approve n / esc deny │
24+╰──────────────────────────────────────────╯
25+ y approve n deny esc cancel
26+```
27+
28+The model's tool call is held open until the user presses a key. The
29+model receives `"approved"` or `"denied: user rejected the action"` as
30+the tool result and replies accordingly.
31+
32+## Build
33+
34+```bash
35+cd examples/extensions/approve
36+go build -o approve .
37+```
38+
39+## Install
40+
41+```bash
42+zot ext install .
43+```
44+
45+## Try it
46+
47+In zot, ask:
48+
49+> Request approval to delete the temp directory.
50+
51+The model calls `approve_action`; the panel opens. Press **y** to
52+approve, **n** or **esc** to deny.
53+
54+> Ask me to approve before running any destructive command.
55+
56+The model will start calling `approve_action` before suggesting risky
57+steps; each call pauses until you respond.
58+
59+## Key points
60+
61+- The tool handler calls `e.OpenPanel(...)` directly — no slash command
62+ needed.
63+- A `chan bool` bridges the panel key handler back to the blocked tool
64+ goroutine; nothing in the wire protocol changes.
65+- Panel IDs are unique per call so multiple concurrent approvals don't
66+ collide (rare in practice, but handled correctly).
67+- The `onClose` callback on `OnPanelKey` handles the case where the
68+ user dismisses the panel from the TUI border rather than pressing a
69+ key, treating it as a denial.
70+
71+## See also
72+
73+- `examples/extensions/secret` — same pattern with masked text input
74+ (credential collection)
75+- `examples/extensions/guard` — blocks dangerous tool calls via
76+ interception (no panel)
77+- `docs/extensions.md` — full protocol reference
@@ -0,0 +1,77 @@
1+# approve — example zot extension
2+
3+Demonstrates the **spontaneous `open_panel`** pattern: an
4+extension-registered tool opens a panel from inside its handler
5+goroutine, blocks until the user responds, then returns the result to
6+the model.
7+
8+## What it does
9+
10+Registers one LLM-callable tool:
11+
12+```
13+approve_action(action, reason?)
14+```
15+
16+When the model calls it, a panel appears in the TUI:
17+
18+```
19+╭─ Approval required ─────────────────────╮
20+│ Action: delete /tmp/build │
21+│ Reason: cleaning up after the build │
22+│ │
23+│ y approve n / esc deny │
24+╰──────────────────────────────────────────╯
25+ y approve n deny esc cancel
26+```
27+
28+The model's tool call is held open until the user presses a key. The
29+model receives `"approved"` or `"denied: user rejected the action"` as
30+the tool result and replies accordingly.
31+
32+## Build
33+
34+```bash
35+cd examples/extensions/approve
36+go build -o approve .
37+```
38+
39+## Install
40+
41+```bash
42+zot ext install .
43+```
44+
45+## Try it
46+
47+In zot, ask:
48+
49+> Request approval to delete the temp directory.
50+
51+The model calls `approve_action`; the panel opens. Press **y** to
52+approve, **n** or **esc** to deny.
53+
54+> Ask me to approve before running any destructive command.
55+
56+The model will start calling `approve_action` before suggesting risky
57+steps; each call pauses until you respond.
58+
59+## Key points
60+
61+- The tool handler calls `e.OpenPanel(...)` directly — no slash command
62+ needed.
63+- A `chan bool` bridges the panel key handler back to the blocked tool
64+ goroutine; nothing in the wire protocol changes.
65+- Panel IDs are unique per call so multiple concurrent approvals don't
66+ collide (rare in practice, but handled correctly).
67+- The `onClose` callback on `OnPanelKey` handles the case where the
68+ user dismisses the panel from the TUI border rather than pressing a
69+ key, treating it as a denial.
70+
71+## See also
72+
73+- `examples/extensions/secret` — same pattern with masked text input
74+ (credential collection)
75+- `examples/extensions/guard` — blocks dangerous tool calls via
76+ interception (no panel)
77+- `docs/extensions.md` — full protocol reference
A examples/extensions/approve/extension.json +8 -0
@@ -0,0 +1,8 @@
1+{
2+ "name": "approve",
3+ "version": "1.0.0",
4+ "exec": "./approve",
5+ "language": "go",
6+ "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs",
7+ "enabled": true
8+}
@@ -0,0 +1,8 @@
1+{
2+ "name": "approve",
3+ "version": "1.0.0",
4+ "exec": "./approve",
5+ "language": "go",
6+ "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs",
7+ "enabled": true
8+}
A examples/extensions/approve/main.go +141 -0
@@ -0,0 +1,141 @@
1+// approve — demonstrates the spontaneous open_panel pattern for
2+// human-in-the-loop tool approval gates.
3+//
4+// Registers one LLM-callable tool:
5+//
6+// approve_action(action: string, reason: string)
7+//
8+// When the model calls it, a panel opens asking the user to approve
9+// or deny. The tool goroutine blocks until the user responds; the
10+// model only sees the result after the user has acted.
11+//
12+// Build:
13+//
14+// cd examples/extensions/approve
15+// go build -o approve .
16+//
17+// Install:
18+//
19+// zot ext install .
20+//
21+// Try it — ask zot something like:
22+//
23+// "Request approval to delete the temp directory."
24+//
25+// The model will call approve_action; a panel appears in the TUI. Press
26+// y to approve or n (or esc) to deny. The model receives "approved" or
27+// "denied: user rejected the action" as the tool result and responds
28+// accordingly.
29+package main
30+
31+import (
32+ "encoding/json"
33+ "fmt"
34+ "os"
35+ "strings"
36+ "sync"
37+
38+ "github.com/patriceckhart/zot/packages/agent/ext"
39+)
40+
41+const schema = `{
42+ "type": "object",
43+ "properties": {
44+ "action": {
45+ "type": "string",
46+ "description": "Short description of what is about to happen, shown to the user."
47+ },
48+ "reason": {
49+ "type": "string",
50+ "description": "Why the action is needed, shown to the user."
51+ }
52+ },
53+ "required": ["action"]
54+}`
55+
56+func main() {
57+ e := ext.New("approve", "1.0.0")
58+
59+ // counter is used to generate unique panel IDs so that concurrent
60+ // tool calls (unlikely in practice but possible) don't collide.
61+ var mu sync.Mutex
62+ var counter int
63+ nextPID := func() string {
64+ mu.Lock()
65+ defer mu.Unlock()
66+ counter++
67+ return fmt.Sprintf("approve-%d", counter)
68+ }
69+
70+ e.Tool("approve_action", "Ask the user to approve or deny an action before it proceeds.", json.RawMessage(schema),
71+ func(args json.RawMessage) ext.ToolResult {
72+ var in struct {
73+ Action string `json:"action"`
74+ Reason string `json:"reason"`
75+ }
76+ if err := json.Unmarshal(args, &in); err != nil {
77+ return ext.TextErrorResult("invalid args: " + err.Error())
78+ }
79+ if strings.TrimSpace(in.Action) == "" {
80+ return ext.TextErrorResult("action is required")
81+ }
82+
83+ pid := nextPID()
84+ decision := make(chan bool, 1)
85+
86+ buildLines := func(hint string) []string {
87+ lines := []string{
88+ " Action: " + in.Action,
89+ }
90+ if strings.TrimSpace(in.Reason) != "" {
91+ lines = append(lines, " Reason: "+in.Reason)
92+ }
93+ lines = append(lines, "")
94+ lines = append(lines, " "+hint)
95+ return lines
96+ }
97+
98+ const footer = "● this panel has focus — y approve n deny esc cancel"
99+ const prompt = "› approve this action? [y/n]"
100+ const badKey = "› unrecognised key — press y to approve or n to deny"
101+
102+ // Register key handler before opening the panel so there
103+ // is no window where a key could arrive unhandled.
104+ e.OnPanelKey(pid, func(key, text string) {
105+ switch {
106+ case key == "rune" && strings.ToLower(text) == "y":
107+ e.ClosePanel(pid)
108+ decision <- true
109+ case key == "rune" && strings.ToLower(text) == "n",
110+ key == "esc":
111+ e.ClosePanel(pid)
112+ decision <- false
113+ default:
114+ // Unknown key: re-render with a nudge so the user
115+ // knows the panel is alive and what to press.
116+ e.RenderPanel(pid, "Approval required",
117+ buildLines(badKey), footer)
118+ }
119+ }, func() {
120+ // Host closed the panel (e.g. user navigated away).
121+ select {
122+ case decision <- false:
123+ default:
124+ }
125+ })
126+
127+ // Open the panel spontaneously from inside the tool handler.
128+ e.OpenPanel(pid, "Approval required", buildLines(prompt), footer)
129+
130+ // Block until the user responds.
131+ if <-decision {
132+ return ext.TextResult("approved")
133+ }
134+ return ext.TextErrorResult("denied: user rejected the action")
135+ })
136+
137+ if err := e.Run(); err != nil {
138+ e.Logf("fatal: %v", err)
139+ os.Exit(1)
140+ }
141+}
@@ -0,0 +1,141 @@
1+// approve — demonstrates the spontaneous open_panel pattern for
2+// human-in-the-loop tool approval gates.
3+//
4+// Registers one LLM-callable tool:
5+//
6+// approve_action(action: string, reason: string)
7+//
8+// When the model calls it, a panel opens asking the user to approve
9+// or deny. The tool goroutine blocks until the user responds; the
10+// model only sees the result after the user has acted.
11+//
12+// Build:
13+//
14+// cd examples/extensions/approve
15+// go build -o approve .
16+//
17+// Install:
18+//
19+// zot ext install .
20+//
21+// Try it — ask zot something like:
22+//
23+// "Request approval to delete the temp directory."
24+//
25+// The model will call approve_action; a panel appears in the TUI. Press
26+// y to approve or n (or esc) to deny. The model receives "approved" or
27+// "denied: user rejected the action" as the tool result and responds
28+// accordingly.
29+package main
30+
31+import (
32+ "encoding/json"
33+ "fmt"
34+ "os"
35+ "strings"
36+ "sync"
37+
38+ "github.com/patriceckhart/zot/packages/agent/ext"
39+)
40+
41+const schema = `{
42+ "type": "object",
43+ "properties": {
44+ "action": {
45+ "type": "string",
46+ "description": "Short description of what is about to happen, shown to the user."
47+ },
48+ "reason": {
49+ "type": "string",
50+ "description": "Why the action is needed, shown to the user."
51+ }
52+ },
53+ "required": ["action"]
54+}`
55+
56+func main() {
57+ e := ext.New("approve", "1.0.0")
58+
59+ // counter is used to generate unique panel IDs so that concurrent
60+ // tool calls (unlikely in practice but possible) don't collide.
61+ var mu sync.Mutex
62+ var counter int
63+ nextPID := func() string {
64+ mu.Lock()
65+ defer mu.Unlock()
66+ counter++
67+ return fmt.Sprintf("approve-%d", counter)
68+ }
69+
70+ e.Tool("approve_action", "Ask the user to approve or deny an action before it proceeds.", json.RawMessage(schema),
71+ func(args json.RawMessage) ext.ToolResult {
72+ var in struct {
73+ Action string `json:"action"`
74+ Reason string `json:"reason"`
75+ }
76+ if err := json.Unmarshal(args, &in); err != nil {
77+ return ext.TextErrorResult("invalid args: " + err.Error())
78+ }
79+ if strings.TrimSpace(in.Action) == "" {
80+ return ext.TextErrorResult("action is required")
81+ }
82+
83+ pid := nextPID()
84+ decision := make(chan bool, 1)
85+
86+ buildLines := func(hint string) []string {
87+ lines := []string{
88+ " Action: " + in.Action,
89+ }
90+ if strings.TrimSpace(in.Reason) != "" {
91+ lines = append(lines, " Reason: "+in.Reason)
92+ }
93+ lines = append(lines, "")
94+ lines = append(lines, " "+hint)
95+ return lines
96+ }
97+
98+ const footer = "● this panel has focus — y approve n deny esc cancel"
99+ const prompt = "› approve this action? [y/n]"
100+ const badKey = "› unrecognised key — press y to approve or n to deny"
101+
102+ // Register key handler before opening the panel so there
103+ // is no window where a key could arrive unhandled.
104+ e.OnPanelKey(pid, func(key, text string) {
105+ switch {
106+ case key == "rune" && strings.ToLower(text) == "y":
107+ e.ClosePanel(pid)
108+ decision <- true
109+ case key == "rune" && strings.ToLower(text) == "n",
110+ key == "esc":
111+ e.ClosePanel(pid)
112+ decision <- false
113+ default:
114+ // Unknown key: re-render with a nudge so the user
115+ // knows the panel is alive and what to press.
116+ e.RenderPanel(pid, "Approval required",
117+ buildLines(badKey), footer)
118+ }
119+ }, func() {
120+ // Host closed the panel (e.g. user navigated away).
121+ select {
122+ case decision <- false:
123+ default:
124+ }
125+ })
126+
127+ // Open the panel spontaneously from inside the tool handler.
128+ e.OpenPanel(pid, "Approval required", buildLines(prompt), footer)
129+
130+ // Block until the user responds.
131+ if <-decision {
132+ return ext.TextResult("approved")
133+ }
134+ return ext.TextErrorResult("denied: user rejected the action")
135+ })
136+
137+ if err := e.Run(); err != nil {
138+ e.Logf("fatal: %v", err)
139+ os.Exit(1)
140+ }
141+}
A examples/extensions/secret/README.md +91 -0
@@ -0,0 +1,91 @@
1+# secret — example zot extension
2+
3+Demonstrates **secret collection via a masked panel**: the model asks
4+for a resource that needs a credential, the extension collects the
5+credential directly from the user inside a panel, uses it to perform
6+the operation, and returns only the outcome to the model. The secret
7+is never written to any JSON frame or the transcript.
8+
9+## What it does
10+
11+Registers one LLM-callable tool:
12+
13+```
14+fetch_with_password(url: string)
15+```
16+
17+When the model calls it, a masked password panel opens:
18+
19+```
20+╭─ Password required ─────────────────────╮
21+│ URL: https://internal.example.com │
22+│ │
23+│ Password: ●●●●●●●▌ │
24+╰──────────────────────────────────────────╯
25+ type password enter confirm esc cancel
26+```
27+
28+The panel re-renders after every keystroke, showing bullets instead of
29+characters. Pressing Enter unblocks the tool goroutine, which uses the
30+password directly (in `doFetch`) and returns only the fetch outcome to
31+the model.
32+
33+## Security property
34+
35+The password lives only in the extension process's memory. It is never
36+serialised into a JSON frame, never appears in the transcript, and
37+never reaches the model. The model sees:
38+
39+```
40+fetched https://internal.example.com successfully (password was 9 characters, not shown)
41+```
42+
43+## Build
44+
45+```bash
46+cd examples/extensions/secret
47+go build -o secret .
48+```
49+
50+## Install
51+
52+```bash
53+zot ext install .
54+```
55+
56+## Try it
57+
58+In zot, ask:
59+
60+> Fetch https://internal.example.com/report — it needs a password.
61+
62+The model calls `fetch_with_password`; the masked panel opens. Type
63+anything and press **Enter**. The model receives the result; the
64+password is gone.
65+
66+## Adapting to real use
67+
68+Replace `doFetch` in `main.go` with a real HTTP request:
69+
70+```go
71+func doFetch(url, password string) ext.ToolResult {
72+ req, _ := http.NewRequest("GET", url, nil)
73+ req.SetBasicAuth("user", password)
74+ resp, err := http.DefaultClient.Do(req)
75+ if err != nil {
76+ return ext.TextErrorResult("fetch failed: " + err.Error())
77+ }
78+ defer resp.Body.Close()
79+ body, _ := io.ReadAll(resp.Body)
80+ return ext.TextResult(string(body))
81+}
82+```
83+
84+The same pattern works for any credential type: API tokens, SSH
85+passphrases, TOTP codes, or freeform override strings.
86+
87+## See also
88+
89+- `examples/extensions/approve` — same pattern for approve/deny gates
90+ (no text input, just y/n)
91+- `docs/extensions.md` — full protocol reference
@@ -0,0 +1,91 @@
1+# secret — example zot extension
2+
3+Demonstrates **secret collection via a masked panel**: the model asks
4+for a resource that needs a credential, the extension collects the
5+credential directly from the user inside a panel, uses it to perform
6+the operation, and returns only the outcome to the model. The secret
7+is never written to any JSON frame or the transcript.
8+
9+## What it does
10+
11+Registers one LLM-callable tool:
12+
13+```
14+fetch_with_password(url: string)
15+```
16+
17+When the model calls it, a masked password panel opens:
18+
19+```
20+╭─ Password required ─────────────────────╮
21+│ URL: https://internal.example.com │
22+│ │
23+│ Password: ●●●●●●●▌ │
24+╰──────────────────────────────────────────╯
25+ type password enter confirm esc cancel
26+```
27+
28+The panel re-renders after every keystroke, showing bullets instead of
29+characters. Pressing Enter unblocks the tool goroutine, which uses the
30+password directly (in `doFetch`) and returns only the fetch outcome to
31+the model.
32+
33+## Security property
34+
35+The password lives only in the extension process's memory. It is never
36+serialised into a JSON frame, never appears in the transcript, and
37+never reaches the model. The model sees:
38+
39+```
40+fetched https://internal.example.com successfully (password was 9 characters, not shown)
41+```
42+
43+## Build
44+
45+```bash
46+cd examples/extensions/secret
47+go build -o secret .
48+```
49+
50+## Install
51+
52+```bash
53+zot ext install .
54+```
55+
56+## Try it
57+
58+In zot, ask:
59+
60+> Fetch https://internal.example.com/report — it needs a password.
61+
62+The model calls `fetch_with_password`; the masked panel opens. Type
63+anything and press **Enter**. The model receives the result; the
64+password is gone.
65+
66+## Adapting to real use
67+
68+Replace `doFetch` in `main.go` with a real HTTP request:
69+
70+```go
71+func doFetch(url, password string) ext.ToolResult {
72+ req, _ := http.NewRequest("GET", url, nil)
73+ req.SetBasicAuth("user", password)
74+ resp, err := http.DefaultClient.Do(req)
75+ if err != nil {
76+ return ext.TextErrorResult("fetch failed: " + err.Error())
77+ }
78+ defer resp.Body.Close()
79+ body, _ := io.ReadAll(resp.Body)
80+ return ext.TextResult(string(body))
81+}
82+```
83+
84+The same pattern works for any credential type: API tokens, SSH
85+passphrases, TOTP codes, or freeform override strings.
86+
87+## See also
88+
89+- `examples/extensions/approve` — same pattern for approve/deny gates
90+ (no text input, just y/n)
91+- `docs/extensions.md` — full protocol reference
A examples/extensions/secret/extension.json +8 -0
@@ -0,0 +1,8 @@
1+{
2+ "name": "secret",
3+ "version": "1.0.0",
4+ "exec": "./secret",
5+ "language": "go",
6+ "description": "collects secrets from the user via a masked panel; the model never sees the value",
7+ "enabled": true
8+}
@@ -0,0 +1,8 @@
1+{
2+ "name": "secret",
3+ "version": "1.0.0",
4+ "exec": "./secret",
5+ "language": "go",
6+ "description": "collects secrets from the user via a masked panel; the model never sees the value",
7+ "enabled": true
8+}
A examples/extensions/secret/main.go +181 -0
@@ -0,0 +1,181 @@
1+// secret — demonstrates collecting a secret from the user via a
2+// masked panel. The model never sees the value; it only sees the
3+// outcome (success or failure).
4+//
5+// Registers one LLM-callable tool:
6+//
7+// fetch_with_password(url: string)
8+//
9+// When the model calls it, a panel opens with a masked password
10+// field. The user types the password and presses Enter; the tool
11+// goroutine uses it directly to perform a (fake) authenticated fetch
12+// and returns only "fetched successfully" or an error to the model.
13+// The password exists only in the extension process's memory and is
14+// never written to any JSON frame or the transcript.
15+//
16+// Build:
17+//
18+// cd examples/extensions/secret
19+// go build -o secret .
20+//
21+// Install:
22+//
23+// zot ext install .
24+//
25+// Try it — ask zot something like:
26+//
27+// "Fetch https://internal.example.com/report — it needs a password."
28+//
29+// The model calls fetch_with_password; a masked password panel opens.
30+// Type anything and press Enter. The model receives the result without
31+// ever seeing what you typed.
32+package main
33+
34+import (
35+ "encoding/json"
36+ "fmt"
37+ "os"
38+ "strings"
39+ "sync"
40+
41+ "github.com/patriceckhart/zot/packages/agent/ext"
42+)
43+
44+const schema = `{
45+ "type": "object",
46+ "properties": {
47+ "url": {
48+ "type": "string",
49+ "description": "The URL to fetch."
50+ }
51+ },
52+ "required": ["url"]
53+}`
54+
55+func main() {
56+ e := ext.New("secret", "1.0.0")
57+
58+ var mu sync.Mutex
59+ var counter int
60+ nextPID := func() string {
61+ mu.Lock()
62+ defer mu.Unlock()
63+ counter++
64+ return fmt.Sprintf("secret-%d", counter)
65+ }
66+
67+ e.Tool("fetch_with_password",
68+ "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.",
69+ json.RawMessage(schema),
70+ func(args json.RawMessage) ext.ToolResult {
71+ var in struct {
72+ URL string `json:"url"`
73+ }
74+ if err := json.Unmarshal(args, &in); err != nil {
75+ return ext.TextErrorResult("invalid args: " + err.Error())
76+ }
77+ if strings.TrimSpace(in.URL) == "" {
78+ return ext.TextErrorResult("url is required")
79+ }
80+
81+ pid := nextPID()
82+
83+ type result struct {
84+ password string
85+ ok bool
86+ }
87+ ch := make(chan result, 1)
88+
89+ // input and inputMu are owned by the key handler goroutine.
90+ var inputMu sync.Mutex
91+ var input string
92+
93+ render := func() {
94+ inputMu.Lock()
95+ masked := strings.Repeat("●", len([]rune(input)))
96+ inputMu.Unlock()
97+ e.RenderPanel(pid, "Password required",
98+ []string{
99+ " URL: " + in.URL,
100+ "",
101+ " Password: " + masked + "▌",
102+ },
103+ "type password enter confirm esc cancel")
104+ }
105+
106+ e.OnPanelKey(pid, func(key, text string) {
107+ inputMu.Lock()
108+ switch key {
109+ case "rune":
110+ input += text
111+ inputMu.Unlock()
112+ render()
113+ return
114+ case "backspace":
115+ if len(input) > 0 {
116+ r := []rune(input)
117+ input = string(r[:len(r)-1])
118+ }
119+ inputMu.Unlock()
120+ render()
121+ return
122+ case "enter":
123+ password := input
124+ inputMu.Unlock()
125+ e.ClosePanel(pid)
126+ ch <- result{password: password, ok: true}
127+ return
128+ case "esc":
129+ inputMu.Unlock()
130+ e.ClosePanel(pid)
131+ ch <- result{}
132+ return
133+ default:
134+ inputMu.Unlock()
135+ }
136+ }, func() {
137+ // Panel closed by host (user navigated away).
138+ select {
139+ case ch <- result{}:
140+ default:
141+ }
142+ })
143+
144+ // Open the panel from inside the tool handler.
145+ e.OpenPanel(pid, "Password required",
146+ []string{
147+ " URL: " + in.URL,
148+ "",
149+ " Password: ▌",
150+ },
151+ "type password enter confirm esc cancel")
152+
153+ // Block until the user submits or cancels.
154+ r := <-ch
155+ if !r.ok {
156+ return ext.TextErrorResult("cancelled: user did not provide a password")
157+ }
158+
159+ // Use the password directly here. It never leaves this
160+ // process and is never written to any frame or transcript.
161+ return doFetch(in.URL, r.password)
162+ })
163+
164+ if err := e.Run(); err != nil {
165+ e.Logf("fatal: %v", err)
166+ os.Exit(1)
167+ }
168+}
169+
170+// doFetch is a stub. Replace with a real http.Get + BasicAuth or
171+// token header in production use.
172+func doFetch(url, password string) ext.ToolResult {
173+ if password == "" {
174+ return ext.TextErrorResult("fetch failed: empty password")
175+ }
176+ // Demonstrate that we *have* the password without logging it.
177+ return ext.TextResult(fmt.Sprintf(
178+ "fetched %s successfully (password was %d characters, not shown)",
179+ url, len([]rune(password)),
180+ ))
181+}
@@ -0,0 +1,181 @@
1+// secret — demonstrates collecting a secret from the user via a
2+// masked panel. The model never sees the value; it only sees the
3+// outcome (success or failure).
4+//
5+// Registers one LLM-callable tool:
6+//
7+// fetch_with_password(url: string)
8+//
9+// When the model calls it, a panel opens with a masked password
10+// field. The user types the password and presses Enter; the tool
11+// goroutine uses it directly to perform a (fake) authenticated fetch
12+// and returns only "fetched successfully" or an error to the model.
13+// The password exists only in the extension process's memory and is
14+// never written to any JSON frame or the transcript.
15+//
16+// Build:
17+//
18+// cd examples/extensions/secret
19+// go build -o secret .
20+//
21+// Install:
22+//
23+// zot ext install .
24+//
25+// Try it — ask zot something like:
26+//
27+// "Fetch https://internal.example.com/report — it needs a password."
28+//
29+// The model calls fetch_with_password; a masked password panel opens.
30+// Type anything and press Enter. The model receives the result without
31+// ever seeing what you typed.
32+package main
33+
34+import (
35+ "encoding/json"
36+ "fmt"
37+ "os"
38+ "strings"
39+ "sync"
40+
41+ "github.com/patriceckhart/zot/packages/agent/ext"
42+)
43+
44+const schema = `{
45+ "type": "object",
46+ "properties": {
47+ "url": {
48+ "type": "string",
49+ "description": "The URL to fetch."
50+ }
51+ },
52+ "required": ["url"]
53+}`
54+
55+func main() {
56+ e := ext.New("secret", "1.0.0")
57+
58+ var mu sync.Mutex
59+ var counter int
60+ nextPID := func() string {
61+ mu.Lock()
62+ defer mu.Unlock()
63+ counter++
64+ return fmt.Sprintf("secret-%d", counter)
65+ }
66+
67+ e.Tool("fetch_with_password",
68+ "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.",
69+ json.RawMessage(schema),
70+ func(args json.RawMessage) ext.ToolResult {
71+ var in struct {
72+ URL string `json:"url"`
73+ }
74+ if err := json.Unmarshal(args, &in); err != nil {
75+ return ext.TextErrorResult("invalid args: " + err.Error())
76+ }
77+ if strings.TrimSpace(in.URL) == "" {
78+ return ext.TextErrorResult("url is required")
79+ }
80+
81+ pid := nextPID()
82+
83+ type result struct {
84+ password string
85+ ok bool
86+ }
87+ ch := make(chan result, 1)
88+
89+ // input and inputMu are owned by the key handler goroutine.
90+ var inputMu sync.Mutex
91+ var input string
92+
93+ render := func() {
94+ inputMu.Lock()
95+ masked := strings.Repeat("●", len([]rune(input)))
96+ inputMu.Unlock()
97+ e.RenderPanel(pid, "Password required",
98+ []string{
99+ " URL: " + in.URL,
100+ "",
101+ " Password: " + masked + "▌",
102+ },
103+ "type password enter confirm esc cancel")
104+ }
105+
106+ e.OnPanelKey(pid, func(key, text string) {
107+ inputMu.Lock()
108+ switch key {
109+ case "rune":
110+ input += text
111+ inputMu.Unlock()
112+ render()
113+ return
114+ case "backspace":
115+ if len(input) > 0 {
116+ r := []rune(input)
117+ input = string(r[:len(r)-1])
118+ }
119+ inputMu.Unlock()
120+ render()
121+ return
122+ case "enter":
123+ password := input
124+ inputMu.Unlock()
125+ e.ClosePanel(pid)
126+ ch <- result{password: password, ok: true}
127+ return
128+ case "esc":
129+ inputMu.Unlock()
130+ e.ClosePanel(pid)
131+ ch <- result{}
132+ return
133+ default:
134+ inputMu.Unlock()
135+ }
136+ }, func() {
137+ // Panel closed by host (user navigated away).
138+ select {
139+ case ch <- result{}:
140+ default:
141+ }
142+ })
143+
144+ // Open the panel from inside the tool handler.
145+ e.OpenPanel(pid, "Password required",
146+ []string{
147+ " URL: " + in.URL,
148+ "",
149+ " Password: ▌",
150+ },
151+ "type password enter confirm esc cancel")
152+
153+ // Block until the user submits or cancels.
154+ r := <-ch
155+ if !r.ok {
156+ return ext.TextErrorResult("cancelled: user did not provide a password")
157+ }
158+
159+ // Use the password directly here. It never leaves this
160+ // process and is never written to any frame or transcript.
161+ return doFetch(in.URL, r.password)
162+ })
163+
164+ if err := e.Run(); err != nil {
165+ e.Logf("fatal: %v", err)
166+ os.Exit(1)
167+ }
168+}
169+
170+// doFetch is a stub. Replace with a real http.Get + BasicAuth or
171+// token header in production use.
172+func doFetch(url, password string) ext.ToolResult {
173+ if password == "" {
174+ return ext.TextErrorResult("fetch failed: empty password")
175+ }
176+ // Demonstrate that we *have* the password without logging it.
177+ return ext.TextResult(fmt.Sprintf(
178+ "fetched %s successfully (password was %d characters, not shown)",
179+ url, len([]rune(password)),
180+ ))
181+}
M packages/agent/ext/ext.go +11 -0
@@ -328,6 +328,17 @@ func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onC
328328 }
329329 }
330330
331+// OpenPanel opens an interactive panel spontaneously from extension code
332+// without requiring a slash command. Safe to call from a tool handler goroutine
333+// or any background context. The panel receives panel_key events via OnPanelKey
334+// and can be dismissed with ClosePanel, exactly as with command-response panels.
335+func (e *Extension) OpenPanel(id, title string, lines []string, footer string) {
336+ _ = e.send(extproto.OpenPanelFromExt{
337+ Type: "open_panel",
338+ Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer},
339+ })
340+}
341+
331342 // RenderPanel pushes a fresh frame for panelID.
332343 func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) {
333344 _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer})
@@ -328,6 +328,17 @@ func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onC
328 }328 }
329 }329 }
330 330
331+// OpenPanel opens an interactive panel spontaneously from extension code
332+// without requiring a slash command. Safe to call from a tool handler goroutine
333+// or any background context. The panel receives panel_key events via OnPanelKey
334+// and can be dismissed with ClosePanel, exactly as with command-response panels.
335+func (e *Extension) OpenPanel(id, title string, lines []string, footer string) {
336+ _ = e.send(extproto.OpenPanelFromExt{
337+ Type: "open_panel",
338+ Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer},
339+ })
340+}
341+
331 // RenderPanel pushes a fresh frame for panelID.342 // RenderPanel pushes a fresh frame for panelID.
332 func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) {343 func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) {
333 _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer})344 _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer})
A packages/agent/ext/ext_test.go +295 -0
@@ -0,0 +1,295 @@
1+package ext
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "io"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/agent/extproto"
12+)
13+
14+// ---------- test harness ----------
15+
16+// extHarness wires an Extension to io.Pipe pairs so a test can play
17+// the role of the host: write host→ext frames, read ext→host frames.
18+// The scanner runs in a permanent background goroutine and delivers
19+// frames over a buffered channel, avoiding the deadlock that would
20+// occur if the test goroutine alternated between writing and reading
21+// a synchronous pipe.
22+type extHarness struct {
23+ ext *Extension
24+ hostW *io.PipeWriter // test writes here → ext reads as stdin
25+ frames chan rawFrame // ext→host frames delivered here
26+}
27+
28+type rawFrame struct {
29+ hdr extproto.Frame
30+ raw []byte
31+}
32+
33+func newHarness(name string) *extHarness {
34+ extStdinR, extStdinW := io.Pipe()
35+ extStdoutR, extStdoutW := io.Pipe()
36+
37+ e := New(name, "0.0.0-test")
38+ e.in = extStdinR
39+ e.out = extStdoutW
40+ e.stderr = io.Discard
41+
42+ h := &extHarness{
43+ ext: e,
44+ hostW: extStdinW,
45+ frames: make(chan rawFrame, 64),
46+ }
47+
48+ // Background reader: scan ext's stdout and push every frame into
49+ // the channel so the test goroutine never needs to block on the pipe.
50+ go func() {
51+ scanner := bufio.NewScanner(extStdoutR)
52+ scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
53+ for scanner.Scan() {
54+ b := scanner.Bytes()
55+ cp := make([]byte, len(b))
56+ copy(cp, b)
57+ var f extproto.Frame
58+ json.Unmarshal(cp, &f)
59+ h.frames <- rawFrame{f, cp}
60+ }
61+ close(h.frames)
62+ }()
63+
64+ return h
65+}
66+
67+// next returns the next frame, timing out after 2 s.
68+func (h *extHarness) next(t *testing.T) rawFrame {
69+ t.Helper()
70+ select {
71+ case f, ok := <-h.frames:
72+ if !ok {
73+ t.Fatal("frame channel closed (ext stdout EOF)")
74+ }
75+ return f
76+ case <-time.After(2 * time.Second):
77+ t.Fatal("timeout waiting for frame from extension")
78+ return rawFrame{}
79+ }
80+}
81+
82+// drainUntil reads frames until one with type == want arrives.
83+func (h *extHarness) drainUntil(t *testing.T, want string) rawFrame {
84+ t.Helper()
85+ deadline := time.NewTimer(2 * time.Second)
86+ defer deadline.Stop()
87+ for {
88+ select {
89+ case f, ok := <-h.frames:
90+ if !ok {
91+ t.Fatalf("frame channel closed before seeing %q", want)
92+ }
93+ if f.hdr.Type == want {
94+ return f
95+ }
96+ case <-deadline.C:
97+ t.Fatalf("timeout waiting for frame type %q", want)
98+ return rawFrame{}
99+ }
100+ }
101+}
102+
103+// sendToExt writes a host→ext frame.
104+func (h *extHarness) sendToExt(t *testing.T, v any) {
105+ t.Helper()
106+ b, err := extproto.Encode(v)
107+ if err != nil {
108+ t.Fatalf("encode: %v", err)
109+ }
110+ if _, err := h.hostW.Write(b); err != nil {
111+ t.Fatalf("write to ext: %v", err)
112+ }
113+}
114+
115+// handshake performs the hello / hello_ack exchange and drains frames
116+// until "ready".
117+func (h *extHarness) handshake(t *testing.T) {
118+ t.Helper()
119+ f := h.next(t)
120+ if f.hdr.Type != "hello" {
121+ t.Fatalf("expected hello, got %q", f.hdr.Type)
122+ }
123+ h.sendToExt(t, extproto.HelloAckFromHost{
124+ Type: "hello_ack",
125+ ProtocolVersion: extproto.ProtocolVersion,
126+ ZotVersion: "0.0.0-test",
127+ Provider: "anthropic",
128+ Model: "claude-test",
129+ })
130+ for {
131+ f := h.next(t)
132+ if f.hdr.Type == "ready" {
133+ return
134+ }
135+ }
136+}
137+
138+// ---------- tests ----------
139+
140+// TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a
141+// well-formed open_panel frame with the correct PanelSpec fields.
142+func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
143+ h := newHarness("test-ext")
144+ go h.ext.Run()
145+ h.handshake(t)
146+
147+ go h.ext.OpenPanel("my-panel", "My Title", []string{"line a", "line b"}, "esc close")
148+
149+ f := h.drainUntil(t, "open_panel")
150+
151+ var op extproto.OpenPanelFromExt
152+ if err := json.Unmarshal(f.raw, &op); err != nil {
153+ t.Fatalf("unmarshal open_panel: %v", err)
154+ }
155+ if op.Panel.ID != "my-panel" {
156+ t.Errorf("panel id: want %q, got %q", "my-panel", op.Panel.ID)
157+ }
158+ if op.Panel.Title != "My Title" {
159+ t.Errorf("panel title: want %q, got %q", "My Title", op.Panel.Title)
160+ }
161+ if len(op.Panel.Lines) != 2 || op.Panel.Lines[0] != "line a" || op.Panel.Lines[1] != "line b" {
162+ t.Errorf("panel lines: got %v", op.Panel.Lines)
163+ }
164+ if op.Panel.Footer != "esc close" {
165+ t.Errorf("panel footer: want %q, got %q", "esc close", op.Panel.Footer)
166+ }
167+
168+ h.hostW.Close()
169+}
170+
171+// TestBlockingToolWaitsForPanelKey is the core integration test for
172+// the human-in-the-loop pattern: the tool handler opens a panel,
173+// blocks on a channel, and only returns a tool_result after a key
174+// event arrives.
175+func TestBlockingToolWaitsForPanelKey(t *testing.T) {
176+ h := newHarness("gate-ext")
177+
178+ const pid = "gate-panel"
179+ const toolCallID = "tc-001"
180+
181+ approved := make(chan bool, 1)
182+
183+ h.ext.OnPanelKey(pid, func(key, text string) {
184+ switch {
185+ case key == "rune" && text == "y":
186+ h.ext.ClosePanel(pid)
187+ approved <- true
188+ case key == "rune" && text == "n", key == "esc":
189+ h.ext.ClosePanel(pid)
190+ approved <- false
191+ }
192+ }, func() { approved <- false })
193+
194+ h.ext.Tool("gate", "needs approval",
195+ json.RawMessage(`{"type":"object","properties":{}}`),
196+ func(args json.RawMessage) ToolResult {
197+ h.ext.OpenPanel(pid, "Approve?",
198+ []string{" y approve", " n deny"}, "y/n")
199+ if <-approved {
200+ return TextResult("approved")
201+ }
202+ return TextErrorResult("denied")
203+ })
204+
205+ go h.ext.Run()
206+ h.handshake(t)
207+
208+ h.sendToExt(t, extproto.ToolCallFromHost{
209+ Type: "tool_call", ID: toolCallID, Name: "gate",
210+ Args: json.RawMessage(`{}`),
211+ })
212+
213+ // Tool goroutine must open the panel before it can reply.
214+ h.drainUntil(t, "open_panel")
215+
216+ // Send approval — tool should now unblock and emit tool_result.
217+ h.sendToExt(t, extproto.PanelKeyFromHost{
218+ Type: "panel_key", PanelID: pid, Key: "rune", Text: "y",
219+ })
220+
221+ f := h.drainUntil(t, "tool_result")
222+ var tr extproto.ToolResultFromExt
223+ if err := json.Unmarshal(f.raw, &tr); err != nil {
224+ t.Fatalf("unmarshal tool_result: %v", err)
225+ }
226+ if tr.ID != toolCallID {
227+ t.Errorf("tool_result id: want %q, got %q", toolCallID, tr.ID)
228+ }
229+ if tr.IsError {
230+ t.Errorf("expected success, got is_error=true")
231+ }
232+ if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "approved") {
233+ t.Errorf("expected 'approved' in content, got %+v", tr.Content)
234+ }
235+
236+ h.hostW.Close()
237+}
238+
239+// TestBlockingToolDenied mirrors TestBlockingToolWaitsForPanelKey but
240+// sends "n" so the tool returns an error result.
241+func TestBlockingToolDenied(t *testing.T) {
242+ h := newHarness("gate-ext-deny")
243+
244+ const pid = "deny-panel"
245+ const toolCallID = "tc-002"
246+
247+ approved := make(chan bool, 1)
248+
249+ h.ext.OnPanelKey(pid, func(key, text string) {
250+ switch {
251+ case key == "rune" && text == "y":
252+ h.ext.ClosePanel(pid); approved <- true
253+ case key == "rune" && text == "n", key == "esc":
254+ h.ext.ClosePanel(pid); approved <- false
255+ }
256+ }, func() { approved <- false })
257+
258+ h.ext.Tool("gate2", "needs approval",
259+ json.RawMessage(`{"type":"object","properties":{}}`),
260+ func(args json.RawMessage) ToolResult {
261+ h.ext.OpenPanel(pid, "Approve?", []string{"y/n"}, "")
262+ if <-approved {
263+ return TextResult("approved")
264+ }
265+ return TextErrorResult("denied")
266+ })
267+
268+ go h.ext.Run()
269+ h.handshake(t)
270+
271+ h.sendToExt(t, extproto.ToolCallFromHost{
272+ Type: "tool_call", ID: toolCallID, Name: "gate2",
273+ Args: json.RawMessage(`{}`),
274+ })
275+
276+ h.drainUntil(t, "open_panel")
277+
278+ h.sendToExt(t, extproto.PanelKeyFromHost{
279+ Type: "panel_key", PanelID: pid, Key: "rune", Text: "n",
280+ })
281+
282+ f := h.drainUntil(t, "tool_result")
283+ var tr extproto.ToolResultFromExt
284+ if err := json.Unmarshal(f.raw, &tr); err != nil {
285+ t.Fatalf("unmarshal tool_result: %v", err)
286+ }
287+ if !tr.IsError {
288+ t.Errorf("expected is_error=true on denial")
289+ }
290+ if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "denied") {
291+ t.Errorf("expected 'denied' in content, got %+v", tr.Content)
292+ }
293+
294+ h.hostW.Close()
295+}
@@ -0,0 +1,295 @@
1+package ext
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "io"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/patriceckhart/zot/packages/agent/extproto"
12+)
13+
14+// ---------- test harness ----------
15+
16+// extHarness wires an Extension to io.Pipe pairs so a test can play
17+// the role of the host: write host→ext frames, read ext→host frames.
18+// The scanner runs in a permanent background goroutine and delivers
19+// frames over a buffered channel, avoiding the deadlock that would
20+// occur if the test goroutine alternated between writing and reading
21+// a synchronous pipe.
22+type extHarness struct {
23+ ext *Extension
24+ hostW *io.PipeWriter // test writes here → ext reads as stdin
25+ frames chan rawFrame // ext→host frames delivered here
26+}
27+
28+type rawFrame struct {
29+ hdr extproto.Frame
30+ raw []byte
31+}
32+
33+func newHarness(name string) *extHarness {
34+ extStdinR, extStdinW := io.Pipe()
35+ extStdoutR, extStdoutW := io.Pipe()
36+
37+ e := New(name, "0.0.0-test")
38+ e.in = extStdinR
39+ e.out = extStdoutW
40+ e.stderr = io.Discard
41+
42+ h := &extHarness{
43+ ext: e,
44+ hostW: extStdinW,
45+ frames: make(chan rawFrame, 64),
46+ }
47+
48+ // Background reader: scan ext's stdout and push every frame into
49+ // the channel so the test goroutine never needs to block on the pipe.
50+ go func() {
51+ scanner := bufio.NewScanner(extStdoutR)
52+ scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
53+ for scanner.Scan() {
54+ b := scanner.Bytes()
55+ cp := make([]byte, len(b))
56+ copy(cp, b)
57+ var f extproto.Frame
58+ json.Unmarshal(cp, &f)
59+ h.frames <- rawFrame{f, cp}
60+ }
61+ close(h.frames)
62+ }()
63+
64+ return h
65+}
66+
67+// next returns the next frame, timing out after 2 s.
68+func (h *extHarness) next(t *testing.T) rawFrame {
69+ t.Helper()
70+ select {
71+ case f, ok := <-h.frames:
72+ if !ok {
73+ t.Fatal("frame channel closed (ext stdout EOF)")
74+ }
75+ return f
76+ case <-time.After(2 * time.Second):
77+ t.Fatal("timeout waiting for frame from extension")
78+ return rawFrame{}
79+ }
80+}
81+
82+// drainUntil reads frames until one with type == want arrives.
83+func (h *extHarness) drainUntil(t *testing.T, want string) rawFrame {
84+ t.Helper()
85+ deadline := time.NewTimer(2 * time.Second)
86+ defer deadline.Stop()
87+ for {
88+ select {
89+ case f, ok := <-h.frames:
90+ if !ok {
91+ t.Fatalf("frame channel closed before seeing %q", want)
92+ }
93+ if f.hdr.Type == want {
94+ return f
95+ }
96+ case <-deadline.C:
97+ t.Fatalf("timeout waiting for frame type %q", want)
98+ return rawFrame{}
99+ }
100+ }
101+}
102+
103+// sendToExt writes a host→ext frame.
104+func (h *extHarness) sendToExt(t *testing.T, v any) {
105+ t.Helper()
106+ b, err := extproto.Encode(v)
107+ if err != nil {
108+ t.Fatalf("encode: %v", err)
109+ }
110+ if _, err := h.hostW.Write(b); err != nil {
111+ t.Fatalf("write to ext: %v", err)
112+ }
113+}
114+
115+// handshake performs the hello / hello_ack exchange and drains frames
116+// until "ready".
117+func (h *extHarness) handshake(t *testing.T) {
118+ t.Helper()
119+ f := h.next(t)
120+ if f.hdr.Type != "hello" {
121+ t.Fatalf("expected hello, got %q", f.hdr.Type)
122+ }
123+ h.sendToExt(t, extproto.HelloAckFromHost{
124+ Type: "hello_ack",
125+ ProtocolVersion: extproto.ProtocolVersion,
126+ ZotVersion: "0.0.0-test",
127+ Provider: "anthropic",
128+ Model: "claude-test",
129+ })
130+ for {
131+ f := h.next(t)
132+ if f.hdr.Type == "ready" {
133+ return
134+ }
135+ }
136+}
137+
138+// ---------- tests ----------
139+
140+// TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a
141+// well-formed open_panel frame with the correct PanelSpec fields.
142+func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
143+ h := newHarness("test-ext")
144+ go h.ext.Run()
145+ h.handshake(t)
146+
147+ go h.ext.OpenPanel("my-panel", "My Title", []string{"line a", "line b"}, "esc close")
148+
149+ f := h.drainUntil(t, "open_panel")
150+
151+ var op extproto.OpenPanelFromExt
152+ if err := json.Unmarshal(f.raw, &op); err != nil {
153+ t.Fatalf("unmarshal open_panel: %v", err)
154+ }
155+ if op.Panel.ID != "my-panel" {
156+ t.Errorf("panel id: want %q, got %q", "my-panel", op.Panel.ID)
157+ }
158+ if op.Panel.Title != "My Title" {
159+ t.Errorf("panel title: want %q, got %q", "My Title", op.Panel.Title)
160+ }
161+ if len(op.Panel.Lines) != 2 || op.Panel.Lines[0] != "line a" || op.Panel.Lines[1] != "line b" {
162+ t.Errorf("panel lines: got %v", op.Panel.Lines)
163+ }
164+ if op.Panel.Footer != "esc close" {
165+ t.Errorf("panel footer: want %q, got %q", "esc close", op.Panel.Footer)
166+ }
167+
168+ h.hostW.Close()
169+}
170+
171+// TestBlockingToolWaitsForPanelKey is the core integration test for
172+// the human-in-the-loop pattern: the tool handler opens a panel,
173+// blocks on a channel, and only returns a tool_result after a key
174+// event arrives.
175+func TestBlockingToolWaitsForPanelKey(t *testing.T) {
176+ h := newHarness("gate-ext")
177+
178+ const pid = "gate-panel"
179+ const toolCallID = "tc-001"
180+
181+ approved := make(chan bool, 1)
182+
183+ h.ext.OnPanelKey(pid, func(key, text string) {
184+ switch {
185+ case key == "rune" && text == "y":
186+ h.ext.ClosePanel(pid)
187+ approved <- true
188+ case key == "rune" && text == "n", key == "esc":
189+ h.ext.ClosePanel(pid)
190+ approved <- false
191+ }
192+ }, func() { approved <- false })
193+
194+ h.ext.Tool("gate", "needs approval",
195+ json.RawMessage(`{"type":"object","properties":{}}`),
196+ func(args json.RawMessage) ToolResult {
197+ h.ext.OpenPanel(pid, "Approve?",
198+ []string{" y approve", " n deny"}, "y/n")
199+ if <-approved {
200+ return TextResult("approved")
201+ }
202+ return TextErrorResult("denied")
203+ })
204+
205+ go h.ext.Run()
206+ h.handshake(t)
207+
208+ h.sendToExt(t, extproto.ToolCallFromHost{
209+ Type: "tool_call", ID: toolCallID, Name: "gate",
210+ Args: json.RawMessage(`{}`),
211+ })
212+
213+ // Tool goroutine must open the panel before it can reply.
214+ h.drainUntil(t, "open_panel")
215+
216+ // Send approval — tool should now unblock and emit tool_result.
217+ h.sendToExt(t, extproto.PanelKeyFromHost{
218+ Type: "panel_key", PanelID: pid, Key: "rune", Text: "y",
219+ })
220+
221+ f := h.drainUntil(t, "tool_result")
222+ var tr extproto.ToolResultFromExt
223+ if err := json.Unmarshal(f.raw, &tr); err != nil {
224+ t.Fatalf("unmarshal tool_result: %v", err)
225+ }
226+ if tr.ID != toolCallID {
227+ t.Errorf("tool_result id: want %q, got %q", toolCallID, tr.ID)
228+ }
229+ if tr.IsError {
230+ t.Errorf("expected success, got is_error=true")
231+ }
232+ if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "approved") {
233+ t.Errorf("expected 'approved' in content, got %+v", tr.Content)
234+ }
235+
236+ h.hostW.Close()
237+}
238+
239+// TestBlockingToolDenied mirrors TestBlockingToolWaitsForPanelKey but
240+// sends "n" so the tool returns an error result.
241+func TestBlockingToolDenied(t *testing.T) {
242+ h := newHarness("gate-ext-deny")
243+
244+ const pid = "deny-panel"
245+ const toolCallID = "tc-002"
246+
247+ approved := make(chan bool, 1)
248+
249+ h.ext.OnPanelKey(pid, func(key, text string) {
250+ switch {
251+ case key == "rune" && text == "y":
252+ h.ext.ClosePanel(pid); approved <- true
253+ case key == "rune" && text == "n", key == "esc":
254+ h.ext.ClosePanel(pid); approved <- false
255+ }
256+ }, func() { approved <- false })
257+
258+ h.ext.Tool("gate2", "needs approval",
259+ json.RawMessage(`{"type":"object","properties":{}}`),
260+ func(args json.RawMessage) ToolResult {
261+ h.ext.OpenPanel(pid, "Approve?", []string{"y/n"}, "")
262+ if <-approved {
263+ return TextResult("approved")
264+ }
265+ return TextErrorResult("denied")
266+ })
267+
268+ go h.ext.Run()
269+ h.handshake(t)
270+
271+ h.sendToExt(t, extproto.ToolCallFromHost{
272+ Type: "tool_call", ID: toolCallID, Name: "gate2",
273+ Args: json.RawMessage(`{}`),
274+ })
275+
276+ h.drainUntil(t, "open_panel")
277+
278+ h.sendToExt(t, extproto.PanelKeyFromHost{
279+ Type: "panel_key", PanelID: pid, Key: "rune", Text: "n",
280+ })
281+
282+ f := h.drainUntil(t, "tool_result")
283+ var tr extproto.ToolResultFromExt
284+ if err := json.Unmarshal(f.raw, &tr); err != nil {
285+ t.Fatalf("unmarshal tool_result: %v", err)
286+ }
287+ if !tr.IsError {
288+ t.Errorf("expected is_error=true on denial")
289+ }
290+ if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "denied") {
291+ t.Errorf("expected 'denied' in content, got %+v", tr.Content)
292+ }
293+
294+ h.hostW.Close()
295+}
M packages/agent/extensions/manager.go +5 -0
@@ -786,6 +786,11 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
786786 }
787787 }
788788 }
789+ case "open_panel":
790+ var op extproto.OpenPanelFromExt
791+ if err := json.Unmarshal(line, &op); err == nil {
792+ m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
793+ }
789794 case "panel_render":
790795 var pr extproto.PanelRenderFromExt
791796 if err := json.Unmarshal(line, &pr); err == nil {
@@ -786,6 +786,11 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
786 }786 }
787 }787 }
788 }788 }
789+ case "open_panel":
790+ var op extproto.OpenPanelFromExt
791+ if err := json.Unmarshal(line, &op); err == nil {
792+ m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
793+ }
789 case "panel_render":794 case "panel_render":
790 var pr extproto.PanelRenderFromExt795 var pr extproto.PanelRenderFromExt
791 if err := json.Unmarshal(line, &pr); err == nil {796 if err := json.Unmarshal(line, &pr); err == nil {
M packages/agent/extensions/manager_test.go +97 -3
@@ -20,6 +20,8 @@ type stubHooks struct {
2020 notifies []string
2121 displays []string
2222 clearNotes []string
23+ panels []extproto.PanelSpec
24+ panelExts []string
2325 }
2426
2527 func (s *stubHooks) Notify(name, level, message string) {
@@ -40,7 +42,12 @@ func (s *stubHooks) ClearNotes(name string) {
4042 defer s.mu.Unlock()
4143 s.clearNotes = append(s.clearNotes, name)
4244 }
43-func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
45+func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
46+ s.mu.Lock()
47+ defer s.mu.Unlock()
48+ s.panelExts = append(s.panelExts, extName)
49+ s.panels = append(s.panels, spec)
50+}
4451 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
4552 func (s *stubHooks) ClosePanel(string, string) {}
4653
@@ -149,8 +156,15 @@ func TestManagerSpawnAndInvoke(t *testing.T) {
149156 time.Sleep(150 * time.Millisecond)
150157
151158 cmds := mgr.Commands()
152- if len(cmds) != 1 || cmds[0].Name != "ping" {
153- t.Fatalf("expected one command 'ping', got %#v", cmds)
159+ found := false
160+ for _, c := range cmds {
161+ if c.Name == "ping" {
162+ found = true
163+ break
164+ }
165+ }
166+ if !found {
167+ t.Fatalf("expected command 'ping', got %#v", cmds)
154168 }
155169 if !mgr.HasCommand("ping") {
156170 t.Fatal("HasCommand(\"ping\") = false")
@@ -167,3 +181,83 @@ func TestManagerSpawnAndInvoke(t *testing.T) {
167181 t.Errorf("expected display=pong, got %q", resp.Display)
168182 }
169183 }
184+
185+// TestSpontaneousOpenPanel verifies that an extension sending an
186+// open_panel frame outside of any command response causes the manager
187+// to call hooks.OpenPanel with the correct PanelSpec fields.
188+func TestSpontaneousOpenPanel(t *testing.T) {
189+ if runtime.GOOS == "windows" {
190+ t.Skip("mock extension uses /bin/sh; skip on windows")
191+ }
192+
193+ tmp := t.TempDir()
194+ extDir := filepath.Join(tmp, "extensions", "panel-mock")
195+ if err := os.MkdirAll(extDir, 0o755); err != nil {
196+ t.Fatal(err)
197+ }
198+
199+ // Extension emits hello + ready, then immediately fires a
200+ // spontaneous open_panel, then waits for shutdown.
201+ script := `#!/bin/sh
202+printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
203+printf '%s\n' '{"type":"ready"}'
204+printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
205+while IFS= read -r line; do
206+ case "$line" in
207+ *'"type":"shutdown"'*)
208+ printf '%s\n' '{"type":"shutdown_ack"}'
209+ exit 0
210+ ;;
211+ esac
212+done
213+`
214+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
215+ t.Fatal(err)
216+ }
217+ mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
218+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
219+ t.Fatal(err)
220+ }
221+
222+ hooks := &stubHooks{}
223+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
224+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
225+ t.Fatalf("discover errors: %v", errs)
226+ }
227+ defer mgr.Stop(2 * time.Second)
228+
229+ // Give the extension time to flush its open_panel frame.
230+ deadline := time.Now().Add(2 * time.Second)
231+ for time.Now().Before(deadline) {
232+ hooks.mu.Lock()
233+ n := len(hooks.panels)
234+ hooks.mu.Unlock()
235+ if n > 0 {
236+ break
237+ }
238+ time.Sleep(20 * time.Millisecond)
239+ }
240+
241+ hooks.mu.Lock()
242+ defer hooks.mu.Unlock()
243+
244+ if len(hooks.panels) == 0 {
245+ t.Fatal("hooks.OpenPanel was never called")
246+ }
247+ spec := hooks.panels[0]
248+ if spec.ID != "test-panel" {
249+ t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
250+ }
251+ if spec.Title != "Hello Panel" {
252+ t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
253+ }
254+ if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
255+ t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
256+ }
257+ if spec.Footer != "esc close" {
258+ t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
259+ }
260+ if hooks.panelExts[0] != "panel-mock" {
261+ t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
262+ }
263+}
@@ -20,6 +20,8 @@ type stubHooks struct {
20 notifies []string20 notifies []string
21 displays []string21 displays []string
22 clearNotes []string22 clearNotes []string
23+ panels []extproto.PanelSpec
24+ panelExts []string
23 }25 }
24 26
25 func (s *stubHooks) Notify(name, level, message string) {27 func (s *stubHooks) Notify(name, level, message string) {
@@ -40,7 +42,12 @@ func (s *stubHooks) ClearNotes(name string) {
40 defer s.mu.Unlock()42 defer s.mu.Unlock()
41 s.clearNotes = append(s.clearNotes, name)43 s.clearNotes = append(s.clearNotes, name)
42 }44 }
43-func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}45+func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
46+ s.mu.Lock()
47+ defer s.mu.Unlock()
48+ s.panelExts = append(s.panelExts, extName)
49+ s.panels = append(s.panels, spec)
50+}
44 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}51 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
45 func (s *stubHooks) ClosePanel(string, string) {}52 func (s *stubHooks) ClosePanel(string, string) {}
46 53
@@ -149,8 +156,15 @@ func TestManagerSpawnAndInvoke(t *testing.T) {
149 time.Sleep(150 * time.Millisecond)156 time.Sleep(150 * time.Millisecond)
150 157
151 cmds := mgr.Commands()158 cmds := mgr.Commands()
152- if len(cmds) != 1 || cmds[0].Name != "ping" {159+ found := false
153- t.Fatalf("expected one command 'ping', got %#v", cmds)160+ for _, c := range cmds {
161+ if c.Name == "ping" {
162+ found = true
163+ break
164+ }
165+ }
166+ if !found {
167+ t.Fatalf("expected command 'ping', got %#v", cmds)
154 }168 }
155 if !mgr.HasCommand("ping") {169 if !mgr.HasCommand("ping") {
156 t.Fatal("HasCommand(\"ping\") = false")170 t.Fatal("HasCommand(\"ping\") = false")
@@ -167,3 +181,83 @@ func TestManagerSpawnAndInvoke(t *testing.T) {
167 t.Errorf("expected display=pong, got %q", resp.Display)181 t.Errorf("expected display=pong, got %q", resp.Display)
168 }182 }
169 }183 }
184+
185+// TestSpontaneousOpenPanel verifies that an extension sending an
186+// open_panel frame outside of any command response causes the manager
187+// to call hooks.OpenPanel with the correct PanelSpec fields.
188+func TestSpontaneousOpenPanel(t *testing.T) {
189+ if runtime.GOOS == "windows" {
190+ t.Skip("mock extension uses /bin/sh; skip on windows")
191+ }
192+
193+ tmp := t.TempDir()
194+ extDir := filepath.Join(tmp, "extensions", "panel-mock")
195+ if err := os.MkdirAll(extDir, 0o755); err != nil {
196+ t.Fatal(err)
197+ }
198+
199+ // Extension emits hello + ready, then immediately fires a
200+ // spontaneous open_panel, then waits for shutdown.
201+ script := `#!/bin/sh
202+printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
203+printf '%s\n' '{"type":"ready"}'
204+printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
205+while IFS= read -r line; do
206+ case "$line" in
207+ *'"type":"shutdown"'*)
208+ printf '%s\n' '{"type":"shutdown_ack"}'
209+ exit 0
210+ ;;
211+ esac
212+done
213+`
214+ if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
215+ t.Fatal(err)
216+ }
217+ mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
218+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
219+ t.Fatal(err)
220+ }
221+
222+ hooks := &stubHooks{}
223+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
224+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
225+ t.Fatalf("discover errors: %v", errs)
226+ }
227+ defer mgr.Stop(2 * time.Second)
228+
229+ // Give the extension time to flush its open_panel frame.
230+ deadline := time.Now().Add(2 * time.Second)
231+ for time.Now().Before(deadline) {
232+ hooks.mu.Lock()
233+ n := len(hooks.panels)
234+ hooks.mu.Unlock()
235+ if n > 0 {
236+ break
237+ }
238+ time.Sleep(20 * time.Millisecond)
239+ }
240+
241+ hooks.mu.Lock()
242+ defer hooks.mu.Unlock()
243+
244+ if len(hooks.panels) == 0 {
245+ t.Fatal("hooks.OpenPanel was never called")
246+ }
247+ spec := hooks.panels[0]
248+ if spec.ID != "test-panel" {
249+ t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
250+ }
251+ if spec.Title != "Hello Panel" {
252+ t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
253+ }
254+ if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
255+ t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
256+ }
257+ if spec.Footer != "esc close" {
258+ t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
259+ }
260+ if hooks.panelExts[0] != "panel-mock" {
261+ t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
262+ }
263+}
M packages/agent/extproto/extproto.go +9 -0
@@ -97,6 +97,15 @@ type PanelSpec struct {
9797 Footer string `json:"footer,omitempty"`
9898 }
9999
100+// OpenPanelFromExt is a spontaneous one-way frame an extension can send at
101+// any time to open an interactive panel. Unlike the open_panel action inside
102+// CommandResponseFromExt, this form is uncoupled from any command invocation
103+// and may be sent from a tool handler goroutine or any background context.
104+type OpenPanelFromExt struct {
105+ Type string `json:"type"` // "open_panel"
106+ Panel PanelSpec `json:"panel"`
107+}
108+
100109 type PanelRenderFromExt struct {
101110 Type string `json:"type"`
102111 PanelID string `json:"panel_id"`
@@ -97,6 +97,15 @@ type PanelSpec struct {
97 Footer string `json:"footer,omitempty"`97 Footer string `json:"footer,omitempty"`
98 }98 }
99 99
100+// OpenPanelFromExt is a spontaneous one-way frame an extension can send at
101+// any time to open an interactive panel. Unlike the open_panel action inside
102+// CommandResponseFromExt, this form is uncoupled from any command invocation
103+// and may be sent from a tool handler goroutine or any background context.
104+type OpenPanelFromExt struct {
105+ Type string `json:"type"` // "open_panel"
106+ Panel PanelSpec `json:"panel"`
107+}
108+
100 type PanelRenderFromExt struct {109 type PanelRenderFromExt struct {
101 Type string `json:"type"`110 Type string `json:"type"`
102 PanelID string `json:"panel_id"`111 PanelID string `json:"panel_id"`

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