detached 2d46ef9

feat(panels): spontaneous open_panel frame for human-in-the-loop tool gates (#19)

Allow extensions to emit an open_panel frame at any time, not just as
the action of a command_response. This makes it possible to build
approval gates, secret collection, and freeform user-input prompts
directly inside tool handlers.

Changes:
- extproto: add OpenPanelFromExt wire type
- extensions/manager: route spontaneous open_panel frames to hooks.OpenPanel
- ext/ext.go: add Extension.OpenPanel() SDK method
- tests: TestSpontaneousOpenPanel (manager), TestOpenPanelEmitsCorrectFrame,
  TestBlockingToolWaitsForPanelKey, TestBlockingToolDenied (SDK)
- docs/plans: add spontaneous-panel.md design doc

The blocking tool pattern (open panel → block on channel → key event →
tool_result) requires no additional wire changes; it falls out of
standard Go concurrency on the extension side.

Part 3 (intercept timeout for built-in tool gating) is out of scope
and tracked separately.
commit
2d46ef9b09a6dc991b5468556e8b203455992e65
author
Raymond Gasper · · 2026-06-08 16:13 UTC
parent
6938d13
signature
unsigned
6 files · +662 -3
A docs/plans/spontaneous-panel.md +245 -0
@@ -0,0 +1,245 @@
1+# Plan: Spontaneous `open_panel` + Human-in-the-Loop Tool Gate
2+
3+## Problem
4+
5+`open_panel` is only valid as the `action` of a `command_response`, which must
6+be a direct reply to a `command_invoked` frame. A panel can therefore only open
7+when the user types a slash command. There is no way for an extension to open a
8+panel in response to a tool call — making human-in-the-loop approval gates,
9+secret collection, and freeform user-input patterns impossible without awkward
10+workarounds (`/approve` slash commands, `threading.Event` polling, etc.).
11+
12+---
13+
14+## What we are building
15+
16+**Part 1 (required):** A new top-level `open_panel` frame an extension can send
17+at any time, uncoupled from any command invocation.
18+
19+**Part 2 (falls out of Part 1 for free):** A blocking tool result pattern where
20+a tool goroutine opens a panel, waits on a Go channel for the user's response,
21+and only then returns a `tool_result`. No new wire frames needed — standard
22+concurrency on the extension side.
23+
24+**Part 3 (separate, required for intercepting built-in tools):** Raise or
25+remove the 5-second `event_intercept_response` timeout so that human-interactive
26+intercept handlers don't time out before the user can respond.
27+
28+---
29+
30+## Implementation plan
31+
32+### 1. `packages/agent/extproto/extproto.go`
33+
34+Add one new struct (≈5 lines):
35+
36+```go
37+// OpenPanelFromExt is a spontaneous one-way frame an extension can send
38+// at any time to open an interactive panel without a prior command invocation.
39+type OpenPanelFromExt struct {
40+ Type string `json:"type"` // "open_panel"
41+ Panel PanelSpec `json:"panel"`
42+}
43+```
44+
45+### 2. `packages/agent/extensions/manager.go`
46+
47+Add one case to the `readLoop` switch (≈5 lines):
48+
49+```go
50+case "open_panel":
51+ var op extproto.OpenPanelFromExt
52+ if err := json.Unmarshal(line, &op); err == nil {
53+ m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
54+ }
55+```
56+
57+`HostHooks.OpenPanel` already exists. `interactive.go` requires **zero changes**.
58+
59+### 3. `packages/agent/ext/ext.go`
60+
61+Add one method on `Extension` (≈5 lines):
62+
63+```go
64+// OpenPanel opens an interactive panel spontaneously from extension code
65+// without requiring a slash command. Safe to call from a tool handler goroutine.
66+func (e *Extension) OpenPanel(id, title string, lines []string, footer string) {
67+ _ = e.send(extproto.OpenPanelFromExt{
68+ Type: "open_panel",
69+ Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer},
70+ })
71+}
72+```
73+
74+### 4. Part 3 — intercept timeout (built-in tool gating)
75+
76+Locate the hardcoded 5-second deadline in `packages/agent/extensions/manager.go`
77+(the `InterceptEvent` / `pendingIntercept` timeout). Two options, pick one:
78+
79+- **Option A (preferred):** Add `intercept_timeout_sec` to `Manifest` in
80+ `manager.go` and honour it when building the intercept deadline. Zero/absent
81+ means keep 5s default.
82+- **Option B:** Add a `timeout_ms` field to `EventInterceptFromHost` in
83+ `extproto.go` that the host sets per-call when the target extension declares
84+ the `panels` capability.
85+
86+### 5. `docs/extensions.md`
87+
88+- Add `open_panel` to the Extension → host frame table.
89+- Add a short prose section under Phase 4 describing the spontaneous form,
90+ the blocking tool pattern, and the secret-collection pattern.
91+- Note the concurrent-panel limitation.
92+
93+### 6. `examples/extensions/` (new example)
94+
95+Add `examples/extensions/approval/` — a minimal extension demonstrating:
96+- An LLM-callable tool that opens an approval panel before proceeding.
97+- A secret-collection variant (masked password input).
98+
99+No changes to any other file.
100+
101+---
102+
103+## Example usages
104+
105+### Simple approve / deny
106+
107+```go
108+e.Tool("risky_op", "Performs a risky operation.", schema, func(args json.RawMessage) ext.ToolResult {
109+ result := make(chan bool, 1)
110+ pid := "approve-" + randomID()
111+
112+ e.OnPanelKey(pid, func(key, text string) {
113+ switch {
114+ case key == "rune" && text == "y":
115+ e.ClosePanel(pid); result <- true
116+ case key == "rune" && text == "n", key == "esc":
117+ e.ClosePanel(pid); result <- false
118+ }
119+ }, func() { result <- false })
120+
121+ e.OpenPanel(pid, "Approve?",
122+ []string{"Agent wants to run: " + summary(args), "", " y approve", " n deny"},
123+ "y approve n deny esc cancel")
124+
125+ if !<-result {
126+ return ext.TextErrorResult("user denied")
127+ }
128+ return doWork(args)
129+})
130+```
131+
132+### Secret / credential collection
133+
134+The secret is used directly inside the extension and never written to any JSON
135+frame or the transcript. The model receives only a success/failure status.
136+
137+```go
138+e.Tool("fetch_authenticated", "Fetch a URL that requires a password.", schema,
139+ func(args json.RawMessage) ext.ToolResult {
140+ var in struct{ URL string `json:"url"` }
141+ json.Unmarshal(args, &in)
142+
143+ type result struct{ secret string; ok bool }
144+ ch := make(chan result, 1)
145+ pid := "secret-" + randomID()
146+ var mu sync.Mutex
147+ var input string
148+
149+ render := func() {
150+ mu.Lock(); masked := strings.Repeat("●", len([]rune(input))); mu.Unlock()
151+ e.RenderPanel(pid, "Password required",
152+ []string{" URL: " + in.URL, "", " Password: " + masked + "▌"},
153+ "enter confirm esc cancel")
154+ }
155+ e.OnPanelKey(pid, func(key, text string) {
156+ mu.Lock()
157+ switch key {
158+ case "rune": input += text
159+ case "backspace": if len(input) > 0 { r := []rune(input); input = string(r[:len(r)-1]) }
160+ case "enter": secret := input; mu.Unlock(); e.ClosePanel(pid); ch <- result{secret, true}; return
161+ case "esc": mu.Unlock(); e.ClosePanel(pid); ch <- result{}; return
162+ }
163+ mu.Unlock(); render()
164+ }, func() { ch <- result{} })
165+
166+ e.OpenPanel(pid, "Password required",
167+ []string{" URL: " + in.URL, "", " Password: ▌"},
168+ "enter confirm esc cancel")
169+
170+ r := <-ch
171+ if !r.ok { return ext.TextErrorResult("cancelled") }
172+ return doFetch(in.URL, r.secret) // secret never leaves the extension process
173+ })
174+```
175+
176+### Freeform text / override justification
177+
178+Same pattern as secret collection but without masking and with the result
179+injected into the tool's output rather than used as a credential — e.g. a
180+human-written review comment, an override reason for a blocked action, or a
181+value the model should not control.
182+
183+### Intercepting a built-in tool (requires Part 3)
184+
185+```go
186+e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
187+ if tool != "bash" { return ext.ToolCallDecision{} }
188+ ch := make(chan ext.ToolCallDecision, 1)
189+ pid := "guard-" + randomID()
190+ e.OnPanelKey(pid, func(key, text string) {
191+ if key == "rune" && text == "y" { e.ClosePanel(pid); ch <- ext.ToolCallDecision{} }
192+ if key == "rune" && text == "n" || key == "esc" {
193+ e.ClosePanel(pid); ch <- ext.ToolCallDecision{Block: true, Reason: "user denied"}
194+ }
195+ }, func() { ch <- ext.ToolCallDecision{Block: true, Reason: "panel closed"} })
196+ e.OpenPanel(pid, "Approve bash?", renderBashLines(args), "y approve n deny")
197+ return <-ch // blocks intercept goroutine — requires Part 3 timeout increase
198+})
199+```
200+
201+---
202+
203+## Out-of-scope risks
204+
205+**Intercept timeout (5s) blocks Part 3.**
206+The `event_intercept_response` timeout is hardcoded at 5 seconds. Human
207+interaction always exceeds this. Part 3 must be resolved before
208+`InterceptToolCallX` can be used as an approval gate for built-in tools.
209+Extension-registered tools are unaffected (no timeout on the tool goroutine).
210+
211+**Only one panel open at a time.**
212+`extPanelDialog` is a single slot. A second spontaneous `open_panel` while
213+another panel is active will replace it. Extensions that may receive concurrent
214+tool calls must serialise approvals internally. Multi-panel stacking is a
215+separate future concern.
216+
217+**Goroutine leak if panel is abandoned.**
218+If the user quits zot or the process is interrupted while a tool goroutine is
219+blocked on a channel, that goroutine leaks until process exit. Mitigation:
220+extension authors should `select` on a context cancellation channel alongside
221+the result channel. The SDK's `ToolHandler` signature does not currently expose
222+a context — passing one through is a separate improvement.
223+
224+**No panel scrolling / wrapping.**
225+Panel lines are plain strings (ANSI colour permitted). There is no built-in word
226+wrap or scroll. Long prompts must be pre-wrapped by the extension. Adequate for
227+approve/deny and credential collection; insufficient for displaying large
228+structured content.
229+
230+**Panel ID collisions under concurrent tool calls.**
231+If two tool calls for the same tool arrive concurrently, naive panel ID
232+generation could produce the same ID and stomp state. Use the tool-call ID (from
233+`ToolCallFromHost.ID`) as a suffix: `"approve-" + toolCallID`.
234+
235+---
236+
237+## Files changed
238+
239+| File | Change |
240+|---|---|
241+| `packages/agent/extproto/extproto.go` | Add `OpenPanelFromExt` struct |
242+| `packages/agent/extensions/manager.go` | Add `case "open_panel":` in `readLoop`; Part 3: raise intercept timeout |
243+| `packages/agent/ext/ext.go` | Add `Extension.OpenPanel(...)` method |
244+| `docs/extensions.md` | Document spontaneous frame, blocking pattern, limitations |
245+| `examples/extensions/approval/` | New example extension (approval + secret collection) |
@@ -0,0 +1,245 @@
1+# Plan: Spontaneous `open_panel` + Human-in-the-Loop Tool Gate
2+
3+## Problem
4+
5+`open_panel` is only valid as the `action` of a `command_response`, which must
6+be a direct reply to a `command_invoked` frame. A panel can therefore only open
7+when the user types a slash command. There is no way for an extension to open a
8+panel in response to a tool call — making human-in-the-loop approval gates,
9+secret collection, and freeform user-input patterns impossible without awkward
10+workarounds (`/approve` slash commands, `threading.Event` polling, etc.).
11+
12+---
13+
14+## What we are building
15+
16+**Part 1 (required):** A new top-level `open_panel` frame an extension can send
17+at any time, uncoupled from any command invocation.
18+
19+**Part 2 (falls out of Part 1 for free):** A blocking tool result pattern where
20+a tool goroutine opens a panel, waits on a Go channel for the user's response,
21+and only then returns a `tool_result`. No new wire frames needed — standard
22+concurrency on the extension side.
23+
24+**Part 3 (separate, required for intercepting built-in tools):** Raise or
25+remove the 5-second `event_intercept_response` timeout so that human-interactive
26+intercept handlers don't time out before the user can respond.
27+
28+---
29+
30+## Implementation plan
31+
32+### 1. `packages/agent/extproto/extproto.go`
33+
34+Add one new struct (≈5 lines):
35+
36+```go
37+// OpenPanelFromExt is a spontaneous one-way frame an extension can send
38+// at any time to open an interactive panel without a prior command invocation.
39+type OpenPanelFromExt struct {
40+ Type string `json:"type"` // "open_panel"
41+ Panel PanelSpec `json:"panel"`
42+}
43+```
44+
45+### 2. `packages/agent/extensions/manager.go`
46+
47+Add one case to the `readLoop` switch (≈5 lines):
48+
49+```go
50+case "open_panel":
51+ var op extproto.OpenPanelFromExt
52+ if err := json.Unmarshal(line, &op); err == nil {
53+ m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
54+ }
55+```
56+
57+`HostHooks.OpenPanel` already exists. `interactive.go` requires **zero changes**.
58+
59+### 3. `packages/agent/ext/ext.go`
60+
61+Add one method on `Extension` (≈5 lines):
62+
63+```go
64+// OpenPanel opens an interactive panel spontaneously from extension code
65+// without requiring a slash command. Safe to call from a tool handler goroutine.
66+func (e *Extension) OpenPanel(id, title string, lines []string, footer string) {
67+ _ = e.send(extproto.OpenPanelFromExt{
68+ Type: "open_panel",
69+ Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer},
70+ })
71+}
72+```
73+
74+### 4. Part 3 — intercept timeout (built-in tool gating)
75+
76+Locate the hardcoded 5-second deadline in `packages/agent/extensions/manager.go`
77+(the `InterceptEvent` / `pendingIntercept` timeout). Two options, pick one:
78+
79+- **Option A (preferred):** Add `intercept_timeout_sec` to `Manifest` in
80+ `manager.go` and honour it when building the intercept deadline. Zero/absent
81+ means keep 5s default.
82+- **Option B:** Add a `timeout_ms` field to `EventInterceptFromHost` in
83+ `extproto.go` that the host sets per-call when the target extension declares
84+ the `panels` capability.
85+
86+### 5. `docs/extensions.md`
87+
88+- Add `open_panel` to the Extension → host frame table.
89+- Add a short prose section under Phase 4 describing the spontaneous form,
90+ the blocking tool pattern, and the secret-collection pattern.
91+- Note the concurrent-panel limitation.
92+
93+### 6. `examples/extensions/` (new example)
94+
95+Add `examples/extensions/approval/` — a minimal extension demonstrating:
96+- An LLM-callable tool that opens an approval panel before proceeding.
97+- A secret-collection variant (masked password input).
98+
99+No changes to any other file.
100+
101+---
102+
103+## Example usages
104+
105+### Simple approve / deny
106+
107+```go
108+e.Tool("risky_op", "Performs a risky operation.", schema, func(args json.RawMessage) ext.ToolResult {
109+ result := make(chan bool, 1)
110+ pid := "approve-" + randomID()
111+
112+ e.OnPanelKey(pid, func(key, text string) {
113+ switch {
114+ case key == "rune" && text == "y":
115+ e.ClosePanel(pid); result <- true
116+ case key == "rune" && text == "n", key == "esc":
117+ e.ClosePanel(pid); result <- false
118+ }
119+ }, func() { result <- false })
120+
121+ e.OpenPanel(pid, "Approve?",
122+ []string{"Agent wants to run: " + summary(args), "", " y approve", " n deny"},
123+ "y approve n deny esc cancel")
124+
125+ if !<-result {
126+ return ext.TextErrorResult("user denied")
127+ }
128+ return doWork(args)
129+})
130+```
131+
132+### Secret / credential collection
133+
134+The secret is used directly inside the extension and never written to any JSON
135+frame or the transcript. The model receives only a success/failure status.
136+
137+```go
138+e.Tool("fetch_authenticated", "Fetch a URL that requires a password.", schema,
139+ func(args json.RawMessage) ext.ToolResult {
140+ var in struct{ URL string `json:"url"` }
141+ json.Unmarshal(args, &in)
142+
143+ type result struct{ secret string; ok bool }
144+ ch := make(chan result, 1)
145+ pid := "secret-" + randomID()
146+ var mu sync.Mutex
147+ var input string
148+
149+ render := func() {
150+ mu.Lock(); masked := strings.Repeat("●", len([]rune(input))); mu.Unlock()
151+ e.RenderPanel(pid, "Password required",
152+ []string{" URL: " + in.URL, "", " Password: " + masked + "▌"},
153+ "enter confirm esc cancel")
154+ }
155+ e.OnPanelKey(pid, func(key, text string) {
156+ mu.Lock()
157+ switch key {
158+ case "rune": input += text
159+ case "backspace": if len(input) > 0 { r := []rune(input); input = string(r[:len(r)-1]) }
160+ case "enter": secret := input; mu.Unlock(); e.ClosePanel(pid); ch <- result{secret, true}; return
161+ case "esc": mu.Unlock(); e.ClosePanel(pid); ch <- result{}; return
162+ }
163+ mu.Unlock(); render()
164+ }, func() { ch <- result{} })
165+
166+ e.OpenPanel(pid, "Password required",
167+ []string{" URL: " + in.URL, "", " Password: ▌"},
168+ "enter confirm esc cancel")
169+
170+ r := <-ch
171+ if !r.ok { return ext.TextErrorResult("cancelled") }
172+ return doFetch(in.URL, r.secret) // secret never leaves the extension process
173+ })
174+```
175+
176+### Freeform text / override justification
177+
178+Same pattern as secret collection but without masking and with the result
179+injected into the tool's output rather than used as a credential — e.g. a
180+human-written review comment, an override reason for a blocked action, or a
181+value the model should not control.
182+
183+### Intercepting a built-in tool (requires Part 3)
184+
185+```go
186+e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
187+ if tool != "bash" { return ext.ToolCallDecision{} }
188+ ch := make(chan ext.ToolCallDecision, 1)
189+ pid := "guard-" + randomID()
190+ e.OnPanelKey(pid, func(key, text string) {
191+ if key == "rune" && text == "y" { e.ClosePanel(pid); ch <- ext.ToolCallDecision{} }
192+ if key == "rune" && text == "n" || key == "esc" {
193+ e.ClosePanel(pid); ch <- ext.ToolCallDecision{Block: true, Reason: "user denied"}
194+ }
195+ }, func() { ch <- ext.ToolCallDecision{Block: true, Reason: "panel closed"} })
196+ e.OpenPanel(pid, "Approve bash?", renderBashLines(args), "y approve n deny")
197+ return <-ch // blocks intercept goroutine — requires Part 3 timeout increase
198+})
199+```
200+
201+---
202+
203+## Out-of-scope risks
204+
205+**Intercept timeout (5s) blocks Part 3.**
206+The `event_intercept_response` timeout is hardcoded at 5 seconds. Human
207+interaction always exceeds this. Part 3 must be resolved before
208+`InterceptToolCallX` can be used as an approval gate for built-in tools.
209+Extension-registered tools are unaffected (no timeout on the tool goroutine).
210+
211+**Only one panel open at a time.**
212+`extPanelDialog` is a single slot. A second spontaneous `open_panel` while
213+another panel is active will replace it. Extensions that may receive concurrent
214+tool calls must serialise approvals internally. Multi-panel stacking is a
215+separate future concern.
216+
217+**Goroutine leak if panel is abandoned.**
218+If the user quits zot or the process is interrupted while a tool goroutine is
219+blocked on a channel, that goroutine leaks until process exit. Mitigation:
220+extension authors should `select` on a context cancellation channel alongside
221+the result channel. The SDK's `ToolHandler` signature does not currently expose
222+a context — passing one through is a separate improvement.
223+
224+**No panel scrolling / wrapping.**
225+Panel lines are plain strings (ANSI colour permitted). There is no built-in word
226+wrap or scroll. Long prompts must be pre-wrapped by the extension. Adequate for
227+approve/deny and credential collection; insufficient for displaying large
228+structured content.
229+
230+**Panel ID collisions under concurrent tool calls.**
231+If two tool calls for the same tool arrive concurrently, naive panel ID
232+generation could produce the same ID and stomp state. Use the tool-call ID (from
233+`ToolCallFromHost.ID`) as a suffix: `"approve-" + toolCallID`.
234+
235+---
236+
237+## Files changed
238+
239+| File | Change |
240+|---|---|
241+| `packages/agent/extproto/extproto.go` | Add `OpenPanelFromExt` struct |
242+| `packages/agent/extensions/manager.go` | Add `case "open_panel":` in `readLoop`; Part 3: raise intercept timeout |
243+| `packages/agent/ext/ext.go` | Add `Extension.OpenPanel(...)` method |
244+| `docs/extensions.md` | Document spontaneous frame, blocking pattern, limitations |
245+| `examples/extensions/approval/` | New example extension (approval + secret collection) |
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