detached fdcdeb5

feat(ext): interactive extension panels + persistence

Add open_panel / panel_render / panel_close / panel_key to the extension protocol, expose extension_dir + data_dir in hello_ack, wire panel rendering and key routing through the interactive TUI, extend the Go SDK, and document the new capability. Also fix doubled user-message indent and redundant assistant wrap.
commit
fdcdeb5eb19ee3edacca7f266d3f423949195f4a
author
patriceckhart · · 2026-04-22 06:53 UTC
parent
8d3b7ff
signature
unsigned
refs
tag: v0.0.90
10 files · +488 -191
M docs/extensions.md +75 -6
@@ -6,12 +6,13 @@ its stdin/stdout. Extensions can be written in **any language** that
66 can read and write JSON lines from stdio — Go, TypeScript, Python,
77 Rust, shell with `jq`, anything.
88
9-Three phases shipped so far:
9+Four phases shipped so far:
1010
1111 - **Phase 1**: slash commands + chat notifications.
1212 - **Phase 2**: tools the LLM can call.
1313 - **Phase 3**: lifecycle event subscriptions + tool-call interception
1414 for guardrail extensions.
15+- **Phase 4**: interactive extension-owned panels rendered inside zot.
1516
1617 ## Quick start
1718
@@ -91,6 +92,12 @@ A project-local extension with the same name wins over a global one.
9192 On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;
9293 on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.
9394
95+Because each extension owns its own directory, the recommended place
96+for extension state is inside that directory itself (for example
97+`todos.json`, `settings.json`, or an auth/cache file used only by that
98+extension). The host also passes this path back in `hello_ack` as
99+`extension_dir` / `data_dir` so runtime code does not need to guess it.
100+
94101 Each extension owns its own subdirectory. The `extension.json`
95102 manifest tells zot how to launch it:
96103
@@ -123,8 +130,9 @@ manifest tells zot how to launch it:
123130 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
124131 extension, append-mode).
125132 3. **Hello handshake**: the extension sends a `hello` frame; zot
126- replies with `hello_ack` containing the protocol version and the
127- active provider/model/cwd.
133+ replies with `hello_ack` containing the protocol version, the
134+ active provider/model/cwd, and the extension's own data directory
135+ so it can persist files beside its manifest.
128136 4. **Registration**: the extension sends `register_command` frames.
129137 First-come-first-served: a name already taken by a built-in or by
130138 a previously-loaded extension is silently shadowed (logged in the
@@ -132,7 +140,8 @@ manifest tells zot how to launch it:
132140 5. **Runtime**: zot dispatches `command_invoked` frames when the
133141 user runs a registered command; the extension responds with
134142 `command_response`. Extensions can also push `notify` frames at
135- any time.
143+ any time. Panel-capable extensions may open an interactive panel,
144+ receive key events, and push redraws while the panel is focused.
136145 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
137146 2s for the extension to send `shutdown_ack`. Holdouts are
138147 SIGTERM'd, then SIGKILL'd.
@@ -153,7 +162,7 @@ responses.
153162
154163 ```json
155164 {"type":"hello","name":"weather","version":"1.0.0",
156- "capabilities":["commands","tools"]}
165+ "capabilities":["commands","tools","panels"]}
157166 ```
158167
159168 #### `register_command`
@@ -274,13 +283,46 @@ subsequent interceptor sees the previous one's output.
274283 submitting.
275284 - `"display"` — appends `display` to the chat as a one-shot styled
276285 note. No model call, nothing written to the transcript.
286+- `"open_panel"` — opens an extension-owned interactive panel inside
287+ zot. The panel content lives in `open_panel`.
277288 - `"noop"` — the extension handled it itself (e.g. it pushed
278289 `notify` frames or kicked off background work). zot doesn't change
279290 the UI in response.
280291
292+Example:
293+
294+```json
295+{"type":"command_response","id":"...","action":"open_panel",
296+ "open_panel":{
297+ "id":"todos-main",
298+ "title":"Todos",
299+ "lines":["□ ship panel api","✓ persist state"],
300+ "footer":"↑/↓ navigate - a add - x complete - esc close"
301+ }}
302+```
303+
281304 If `error` is non-empty, zot renders it as a red status line
282305 regardless of `action`.
283306
307+#### `panel_render` (one-way, while a panel is open)
308+
309+Pushes a fresh frame for an already-open panel.
310+
311+```json
312+{"type":"panel_render","panel_id":"todos-main",
313+ "title":"Todos",
314+ "lines":["□ ship panel api","✓ persist state"],
315+ "footer":"↑/↓ navigate - a add - x complete - esc close"}
316+```
317+
318+#### `panel_close`
319+
320+Closes a previously-open panel.
321+
322+```json
323+{"type":"panel_close","panel_id":"todos-main"}
324+```
325+
284326 #### `notify` (one-way, any time)
285327
286328 ```json
@@ -302,12 +344,17 @@ Sent in response to `shutdown`. Extension should exit promptly after.
302344 ```json
303345 {"type":"hello_ack","protocol_version":1,
304346 "zot_version":"0.0.7","provider":"anthropic",
305- "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot"}
347+ "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
348+ "extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
349+ "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
306350 ```
307351
308352 Sent immediately after `hello`. The extension can use these fields to
309353 decide which commands to register (e.g. only register a Python tool
310354 on macOS, only register a model-specific shortcut for opus, etc.).
355+`extension_dir` / `data_dir` are where the extension should persist
356+its own state (for example `todos.json`, cached metadata, or auth
357+tokens scoped to that extension).
311358
312359 #### `command_invoked`
313360
@@ -369,6 +416,28 @@ Payload fields depend on the event:
369416 "text":"here is your api key: sk-ant-..."}
370417 ```
371418
419+#### `panel_key`
420+
421+Sent while an extension-owned panel is focused. `key` is a normalized
422+name (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `pageup`,
423+`pagedown`, `home`, `end`, `backspace`, `delete`, `rune`). For
424+`key:"rune"`, `text` carries the typed character.
425+
426+```json
427+{"type":"panel_key","panel_id":"todos-main","key":"down"}
428+{"type":"panel_key","panel_id":"todos-main","key":"rune","text":"x"}
429+```
430+
431+#### `panel_close`
432+
433+Sent when the user closes the focused panel from zot (for example with
434+Esc or Ctrl+C). The extension should treat this as the panel lifetime
435+ending and stop sending `panel_render` updates for that `panel_id`.
436+
437+```json
438+{"type":"panel_close","panel_id":"todos-main"}
439+```
440+
372441 #### `shutdown`
373442
374443 Sent during graceful zot exit (or `/reload-ext` once that lands).
@@ -6,12 +6,13 @@ its stdin/stdout. Extensions can be written in **any language** that
6 can read and write JSON lines from stdio — Go, TypeScript, Python,6 can read and write JSON lines from stdio — Go, TypeScript, Python,
7 Rust, shell with `jq`, anything.7 Rust, shell with `jq`, anything.
8 8
9-Three phases shipped so far:9+Four phases shipped so far:
10 10
11 - **Phase 1**: slash commands + chat notifications.11 - **Phase 1**: slash commands + chat notifications.
12 - **Phase 2**: tools the LLM can call.12 - **Phase 2**: tools the LLM can call.
13 - **Phase 3**: lifecycle event subscriptions + tool-call interception13 - **Phase 3**: lifecycle event subscriptions + tool-call interception
14 for guardrail extensions.14 for guardrail extensions.
15+- **Phase 4**: interactive extension-owned panels rendered inside zot.
15 16
16 ## Quick start17 ## Quick start
17 18
@@ -91,6 +92,12 @@ A project-local extension with the same name wins over a global one.
91 On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;92 On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;
92 on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.93 on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.
93 94
95+Because each extension owns its own directory, the recommended place
96+for extension state is inside that directory itself (for example
97+`todos.json`, `settings.json`, or an auth/cache file used only by that
98+extension). The host also passes this path back in `hello_ack` as
99+`extension_dir` / `data_dir` so runtime code does not need to guess it.
100+
94 Each extension owns its own subdirectory. The `extension.json`101 Each extension owns its own subdirectory. The `extension.json`
95 manifest tells zot how to launch it:102 manifest tells zot how to launch it:
96 103
@@ -123,8 +130,9 @@ manifest tells zot how to launch it:
123 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per130 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
124 extension, append-mode).131 extension, append-mode).
125 3. **Hello handshake**: the extension sends a `hello` frame; zot132 3. **Hello handshake**: the extension sends a `hello` frame; zot
126- replies with `hello_ack` containing the protocol version and the133+ replies with `hello_ack` containing the protocol version, the
127- active provider/model/cwd.134+ active provider/model/cwd, and the extension's own data directory
135+ so it can persist files beside its manifest.
128 4. **Registration**: the extension sends `register_command` frames.136 4. **Registration**: the extension sends `register_command` frames.
129 First-come-first-served: a name already taken by a built-in or by137 First-come-first-served: a name already taken by a built-in or by
130 a previously-loaded extension is silently shadowed (logged in the138 a previously-loaded extension is silently shadowed (logged in the
@@ -132,7 +140,8 @@ manifest tells zot how to launch it:
132 5. **Runtime**: zot dispatches `command_invoked` frames when the140 5. **Runtime**: zot dispatches `command_invoked` frames when the
133 user runs a registered command; the extension responds with141 user runs a registered command; the extension responds with
134 `command_response`. Extensions can also push `notify` frames at142 `command_response`. Extensions can also push `notify` frames at
135- any time.143+ any time. Panel-capable extensions may open an interactive panel,
144+ receive key events, and push redraws while the panel is focused.
136 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to145 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
137 2s for the extension to send `shutdown_ack`. Holdouts are146 2s for the extension to send `shutdown_ack`. Holdouts are
138 SIGTERM'd, then SIGKILL'd.147 SIGTERM'd, then SIGKILL'd.
@@ -153,7 +162,7 @@ responses.
153 162
154 ```json163 ```json
155 {"type":"hello","name":"weather","version":"1.0.0",164 {"type":"hello","name":"weather","version":"1.0.0",
156- "capabilities":["commands","tools"]}165+ "capabilities":["commands","tools","panels"]}
157 ```166 ```
158 167
159 #### `register_command`168 #### `register_command`
@@ -274,13 +283,46 @@ subsequent interceptor sees the previous one's output.
274 submitting.283 submitting.
275 - `"display"` — appends `display` to the chat as a one-shot styled284 - `"display"` — appends `display` to the chat as a one-shot styled
276 note. No model call, nothing written to the transcript.285 note. No model call, nothing written to the transcript.
286+- `"open_panel"` — opens an extension-owned interactive panel inside
287+ zot. The panel content lives in `open_panel`.
277 - `"noop"` — the extension handled it itself (e.g. it pushed288 - `"noop"` — the extension handled it itself (e.g. it pushed
278 `notify` frames or kicked off background work). zot doesn't change289 `notify` frames or kicked off background work). zot doesn't change
279 the UI in response.290 the UI in response.
280 291
292+Example:
293+
294+```json
295+{"type":"command_response","id":"...","action":"open_panel",
296+ "open_panel":{
297+ "id":"todos-main",
298+ "title":"Todos",
299+ "lines":["□ ship panel api","✓ persist state"],
300+ "footer":"↑/↓ navigate - a add - x complete - esc close"
301+ }}
302+```
303+
281 If `error` is non-empty, zot renders it as a red status line304 If `error` is non-empty, zot renders it as a red status line
282 regardless of `action`.305 regardless of `action`.
283 306
307+#### `panel_render` (one-way, while a panel is open)
308+
309+Pushes a fresh frame for an already-open panel.
310+
311+```json
312+{"type":"panel_render","panel_id":"todos-main",
313+ "title":"Todos",
314+ "lines":["□ ship panel api","✓ persist state"],
315+ "footer":"↑/↓ navigate - a add - x complete - esc close"}
316+```
317+
318+#### `panel_close`
319+
320+Closes a previously-open panel.
321+
322+```json
323+{"type":"panel_close","panel_id":"todos-main"}
324+```
325+
284 #### `notify` (one-way, any time)326 #### `notify` (one-way, any time)
285 327
286 ```json328 ```json
@@ -302,12 +344,17 @@ Sent in response to `shutdown`. Extension should exit promptly after.
302 ```json344 ```json
303 {"type":"hello_ack","protocol_version":1,345 {"type":"hello_ack","protocol_version":1,
304 "zot_version":"0.0.7","provider":"anthropic",346 "zot_version":"0.0.7","provider":"anthropic",
305- "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot"}347+ "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
348+ "extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
349+ "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
306 ```350 ```
307 351
308 Sent immediately after `hello`. The extension can use these fields to352 Sent immediately after `hello`. The extension can use these fields to
309 decide which commands to register (e.g. only register a Python tool353 decide which commands to register (e.g. only register a Python tool
310 on macOS, only register a model-specific shortcut for opus, etc.).354 on macOS, only register a model-specific shortcut for opus, etc.).
355+`extension_dir` / `data_dir` are where the extension should persist
356+its own state (for example `todos.json`, cached metadata, or auth
357+tokens scoped to that extension).
311 358
312 #### `command_invoked`359 #### `command_invoked`
313 360
@@ -369,6 +416,28 @@ Payload fields depend on the event:
369 "text":"here is your api key: sk-ant-..."}416 "text":"here is your api key: sk-ant-..."}
370 ```417 ```
371 418
419+#### `panel_key`
420+
421+Sent while an extension-owned panel is focused. `key` is a normalized
422+name (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `pageup`,
423+`pagedown`, `home`, `end`, `backspace`, `delete`, `rune`). For
424+`key:"rune"`, `text` carries the typed character.
425+
426+```json
427+{"type":"panel_key","panel_id":"todos-main","key":"down"}
428+{"type":"panel_key","panel_id":"todos-main","key":"rune","text":"x"}
429+```
430+
431+#### `panel_close`
432+
433+Sent when the user closes the focused panel from zot (for example with
434+Esc or Ctrl+C). The extension should treat this as the panel lifetime
435+ending and stop sending `panel_render` updates for that `panel_id`.
436+
437+```json
438+{"type":"panel_close","panel_id":"todos-main"}
439+```
440+
372 #### `shutdown`441 #### `shutdown`
373 442
374 Sent during graceful zot exit (or `/reload-ext` once that lands).443 Sent during graceful zot exit (or `/reload-ext` once that lands).
M internal/agent/cli.go +21 -3
@@ -55,6 +55,21 @@ func (h *interactiveExtHooks) Display(extName, text string) {
5555 iv.Display(extName, text)
5656 }
5757 }
58+func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
59+ if iv := h.iv(); iv != nil {
60+ iv.OpenPanel(extName, spec)
61+ }
62+}
63+func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
64+ if iv := h.iv(); iv != nil {
65+ iv.UpdatePanel(extName, panelID, title, lines, footer)
66+ }
67+}
68+func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
69+ if iv := h.iv(); iv != nil {
70+ iv.ClosePanel(extName, panelID)
71+ }
72+}
5873
5974 // extToolAdapter bridges *extensions.Manager to the
6075 // ExtensionToolSource interface declared in build.go (kept narrow to
@@ -192,9 +207,12 @@ type nonInteractiveExtHooks struct{}
192207 func (nonInteractiveExtHooks) Notify(ext, level, message string) {
193208 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
194209 }
195-func (nonInteractiveExtHooks) Submit(string) {}
196-func (nonInteractiveExtHooks) Insert(string) {}
197-func (nonInteractiveExtHooks) Display(string, string) {}
210+func (nonInteractiveExtHooks) Submit(string) {}
211+func (nonInteractiveExtHooks) Insert(string) {}
212+func (nonInteractiveExtHooks) Display(string, string) {}
213+func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
214+func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
215+func (nonInteractiveExtHooks) ClosePanel(string, string) {}
198216
199217 // setupNonInteractiveExtensions loads --ext paths and (unless
200218 // --no-ext) runs discovery. Returns the manager so the caller can
@@ -55,6 +55,21 @@ func (h *interactiveExtHooks) Display(extName, text string) {
55 iv.Display(extName, text)55 iv.Display(extName, text)
56 }56 }
57 }57 }
58+func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
59+ if iv := h.iv(); iv != nil {
60+ iv.OpenPanel(extName, spec)
61+ }
62+}
63+func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
64+ if iv := h.iv(); iv != nil {
65+ iv.UpdatePanel(extName, panelID, title, lines, footer)
66+ }
67+}
68+func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
69+ if iv := h.iv(); iv != nil {
70+ iv.ClosePanel(extName, panelID)
71+ }
72+}
58 73
59 // extToolAdapter bridges *extensions.Manager to the74 // extToolAdapter bridges *extensions.Manager to the
60 // ExtensionToolSource interface declared in build.go (kept narrow to75 // ExtensionToolSource interface declared in build.go (kept narrow to
@@ -192,9 +207,12 @@ type nonInteractiveExtHooks struct{}
192 func (nonInteractiveExtHooks) Notify(ext, level, message string) {207 func (nonInteractiveExtHooks) Notify(ext, level, message string) {
193 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)208 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
194 }209 }
195-func (nonInteractiveExtHooks) Submit(string) {}210+func (nonInteractiveExtHooks) Submit(string) {}
196-func (nonInteractiveExtHooks) Insert(string) {}211+func (nonInteractiveExtHooks) Insert(string) {}
197-func (nonInteractiveExtHooks) Display(string, string) {}212+func (nonInteractiveExtHooks) Display(string, string) {}
213+func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
214+func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
215+func (nonInteractiveExtHooks) ClosePanel(string, string) {}
198 216
199 // setupNonInteractiveExtensions loads --ext paths and (unless217 // setupNonInteractiveExtensions loads --ext paths and (unless
200 // --no-ext) runs discovery. Returns the manager so the caller can218 // --no-ext) runs discovery. Returns the manager so the caller can
M internal/agent/extensions/manager.go +49 -0
@@ -109,6 +109,10 @@ type HostHooks interface {
109109 // Display appends a one-shot styled note to the chat without
110110 // invoking the model and without writing to the transcript.
111111 Display(extName, text string)
112+
113+ OpenPanel(extName string, spec extproto.PanelSpec)
114+ UpdatePanel(extName, panelID, title string, lines []string, footer string)
115+ ClosePanel(extName, panelID string)
112116 }
113117
114118 // Manager owns every extension subprocess for the lifetime of zot.
@@ -549,6 +553,8 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
549553 Provider: m.provider,
550554 Model: m.model,
551555 CWD: m.cwd,
556+ ExtensionDir: ext.Dir,
557+ DataDir: ext.Dir,
552558 })
553559 if _, err := stdin.Write(ack); err != nil {
554560 return fmt.Errorf("send hello_ack: %w", err)
@@ -739,6 +745,16 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
739745 }
740746 }
741747 }
748+ case "panel_render":
749+ var pr extproto.PanelRenderFromExt
750+ if err := json.Unmarshal(line, &pr); err == nil {
751+ m.hooks.UpdatePanel(ext.Manifest.Name, pr.PanelID, pr.Title, pr.Lines, pr.Footer)
752+ }
753+ case "panel_close":
754+ var pc extproto.PanelCloseFromExt
755+ if err := json.Unmarshal(line, &pc); err == nil {
756+ m.hooks.ClosePanel(ext.Manifest.Name, pc.PanelID)
757+ }
742758 case "shutdown_ack":
743759 // Caller of Stop is waiting on the process exit, not this frame.
744760 default:
@@ -864,6 +880,15 @@ func (m *Manager) HasCommand(name string) bool {
864880 return ok
865881 }
866882
883+func (m *Manager) CommandOwner(name string) string {
884+ m.mu.RLock()
885+ defer m.mu.RUnlock()
886+ if ext, ok := m.commandIndex[name]; ok && ext != nil {
887+ return ext.Manifest.Name
888+ }
889+ return ""
890+}
891+
867892 // Invoke fires the named slash command's handler in the owning
868893 // extension and waits up to timeout for the response. Returns the
869894 // extension's CommandResponse so the caller can act on the action
@@ -914,6 +939,30 @@ func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Du
914939 // Stop cleanly terminates every extension. Sends ShutdownFromHost,
915940 // waits up to gracePeriod for each subprocess to exit, then SIGTERMs
916941 // (and SIGKILLs after another second) the holdouts.
942+func (m *Manager) SendPanelKey(extName, panelID, key, text string) error {
943+ m.mu.RLock()
944+ ext, ok := m.ext[extName]
945+ m.mu.RUnlock()
946+ if !ok {
947+ return fmt.Errorf("no extension %q", extName)
948+ }
949+ frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})
950+ _, err := ext.stdin.Write(frame)
951+ return err
952+}
953+
954+func (m *Manager) SendPanelClose(extName, panelID string) error {
955+ m.mu.RLock()
956+ ext, ok := m.ext[extName]
957+ m.mu.RUnlock()
958+ if !ok {
959+ return fmt.Errorf("no extension %q", extName)
960+ }
961+ frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})
962+ _, err := ext.stdin.Write(frame)
963+ return err
964+}
965+
917966 func (m *Manager) Stop(gracePeriod time.Duration) {
918967 m.mu.RLock()
919968 exts := make([]*Extension, 0, len(m.ext))
@@ -109,6 +109,10 @@ type HostHooks interface {
109 // Display appends a one-shot styled note to the chat without109 // Display appends a one-shot styled note to the chat without
110 // invoking the model and without writing to the transcript.110 // invoking the model and without writing to the transcript.
111 Display(extName, text string)111 Display(extName, text string)
112+
113+ OpenPanel(extName string, spec extproto.PanelSpec)
114+ UpdatePanel(extName, panelID, title string, lines []string, footer string)
115+ ClosePanel(extName, panelID string)
112 }116 }
113 117
114 // Manager owns every extension subprocess for the lifetime of zot.118 // Manager owns every extension subprocess for the lifetime of zot.
@@ -549,6 +553,8 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
549 Provider: m.provider,553 Provider: m.provider,
550 Model: m.model,554 Model: m.model,
551 CWD: m.cwd,555 CWD: m.cwd,
556+ ExtensionDir: ext.Dir,
557+ DataDir: ext.Dir,
552 })558 })
553 if _, err := stdin.Write(ack); err != nil {559 if _, err := stdin.Write(ack); err != nil {
554 return fmt.Errorf("send hello_ack: %w", err)560 return fmt.Errorf("send hello_ack: %w", err)
@@ -739,6 +745,16 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
739 }745 }
740 }746 }
741 }747 }
748+ case "panel_render":
749+ var pr extproto.PanelRenderFromExt
750+ if err := json.Unmarshal(line, &pr); err == nil {
751+ m.hooks.UpdatePanel(ext.Manifest.Name, pr.PanelID, pr.Title, pr.Lines, pr.Footer)
752+ }
753+ case "panel_close":
754+ var pc extproto.PanelCloseFromExt
755+ if err := json.Unmarshal(line, &pc); err == nil {
756+ m.hooks.ClosePanel(ext.Manifest.Name, pc.PanelID)
757+ }
742 case "shutdown_ack":758 case "shutdown_ack":
743 // Caller of Stop is waiting on the process exit, not this frame.759 // Caller of Stop is waiting on the process exit, not this frame.
744 default:760 default:
@@ -864,6 +880,15 @@ func (m *Manager) HasCommand(name string) bool {
864 return ok880 return ok
865 }881 }
866 882
883+func (m *Manager) CommandOwner(name string) string {
884+ m.mu.RLock()
885+ defer m.mu.RUnlock()
886+ if ext, ok := m.commandIndex[name]; ok && ext != nil {
887+ return ext.Manifest.Name
888+ }
889+ return ""
890+}
891+
867 // Invoke fires the named slash command's handler in the owning892 // Invoke fires the named slash command's handler in the owning
868 // extension and waits up to timeout for the response. Returns the893 // extension and waits up to timeout for the response. Returns the
869 // extension's CommandResponse so the caller can act on the action894 // extension's CommandResponse so the caller can act on the action
@@ -914,6 +939,30 @@ func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Du
914 // Stop cleanly terminates every extension. Sends ShutdownFromHost,939 // Stop cleanly terminates every extension. Sends ShutdownFromHost,
915 // waits up to gracePeriod for each subprocess to exit, then SIGTERMs940 // waits up to gracePeriod for each subprocess to exit, then SIGTERMs
916 // (and SIGKILLs after another second) the holdouts.941 // (and SIGKILLs after another second) the holdouts.
942+func (m *Manager) SendPanelKey(extName, panelID, key, text string) error {
943+ m.mu.RLock()
944+ ext, ok := m.ext[extName]
945+ m.mu.RUnlock()
946+ if !ok {
947+ return fmt.Errorf("no extension %q", extName)
948+ }
949+ frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})
950+ _, err := ext.stdin.Write(frame)
951+ return err
952+}
953+
954+func (m *Manager) SendPanelClose(extName, panelID string) error {
955+ m.mu.RLock()
956+ ext, ok := m.ext[extName]
957+ m.mu.RUnlock()
958+ if !ok {
959+ return fmt.Errorf("no extension %q", extName)
960+ }
961+ frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})
962+ _, err := ext.stdin.Write(frame)
963+ return err
964+}
965+
917 func (m *Manager) Stop(gracePeriod time.Duration) {966 func (m *Manager) Stop(gracePeriod time.Duration) {
918 m.mu.RLock()967 m.mu.RLock()
919 exts := make([]*Extension, 0, len(m.ext))968 exts := make([]*Extension, 0, len(m.ext))
M internal/agent/extensions/manager_test.go +5 -0
@@ -4,6 +4,8 @@ import (
44 "context"
55 "encoding/json"
66 "os"
7+
8+ "github.com/patriceckhart/zot/internal/extproto"
79 "path/filepath"
810 "runtime"
911 "sync"
@@ -30,6 +32,9 @@ func (s *stubHooks) Display(name, text string) {
3032 defer s.mu.Unlock()
3133 s.displays = append(s.displays, name+":"+text)
3234 }
35+func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
36+func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
37+func (s *stubHooks) ClosePanel(string, string) {}
3338
3439 // writeMockExtension creates a minimal extension on disk that uses a
3540 // shell script (or batch file on windows) to drive the protocol. The
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "os"6 "os"
7+
8+ "github.com/patriceckhart/zot/internal/extproto"
7 "path/filepath"9 "path/filepath"
8 "runtime"10 "runtime"
9 "sync"11 "sync"
@@ -30,6 +32,9 @@ func (s *stubHooks) Display(name, text string) {
30 defer s.mu.Unlock()32 defer s.mu.Unlock()
31 s.displays = append(s.displays, name+":"+text)33 s.displays = append(s.displays, name+":"+text)
32 }34 }
35+func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
36+func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
37+func (s *stubHooks) ClosePanel(string, string) {}
33 38
34 // writeMockExtension creates a minimal extension on disk that uses a39 // writeMockExtension creates a minimal extension on disk that uses a
35 // shell script (or batch file on windows) to drive the protocol. The40 // shell script (or batch file on windows) to drive the protocol. The
A internal/agent/modes/ext_panel_dialog.go +69 -0
@@ -0,0 +1,69 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/patriceckhart/zot/internal/tui"
7+)
8+
9+type extPanelDialog struct {
10+ active bool
11+ ext string
12+ id string
13+ title string
14+ lines []string
15+ footer string
16+}
17+
18+func newExtPanelDialog() *extPanelDialog { return &extPanelDialog{} }
19+
20+func (d *extPanelDialog) Active() bool { return d != nil && d.active }
21+
22+func (d *extPanelDialog) Open(ext, id, title string, lines []string, footer string) {
23+ d.active = true
24+ d.ext = ext
25+ d.id = id
26+ d.title = title
27+ d.lines = append([]string(nil), lines...)
28+ d.footer = footer
29+}
30+
31+func (d *extPanelDialog) Update(title string, lines []string, footer string) {
32+ if !d.active {
33+ return
34+ }
35+ if title != "" {
36+ d.title = title
37+ }
38+ d.lines = append(d.lines[:0], lines...)
39+ d.footer = footer
40+}
41+
42+func (d *extPanelDialog) Close() {
43+ d.active = false
44+ d.ext = ""
45+ d.id = ""
46+ d.title = ""
47+ d.lines = nil
48+ d.footer = ""
49+}
50+
51+func (d *extPanelDialog) Render(th tui.Theme, width int) []string {
52+ if !d.Active() {
53+ return nil
54+ }
55+ title := d.title
56+ if title == "" {
57+ title = d.ext
58+ }
59+ out := []string{frameHeader(th, title, width)}
60+ for _, l := range d.lines {
61+ out = append(out, l)
62+ }
63+ if strings.TrimSpace(d.footer) != "" {
64+ out = append(out, "")
65+ out = append(out, th.FG256(th.Muted, d.footer))
66+ }
67+ out = append(out, frameRule(th, width))
68+ return out
69+}
@@ -0,0 +1,69 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/patriceckhart/zot/internal/tui"
7+)
8+
9+type extPanelDialog struct {
10+ active bool
11+ ext string
12+ id string
13+ title string
14+ lines []string
15+ footer string
16+}
17+
18+func newExtPanelDialog() *extPanelDialog { return &extPanelDialog{} }
19+
20+func (d *extPanelDialog) Active() bool { return d != nil && d.active }
21+
22+func (d *extPanelDialog) Open(ext, id, title string, lines []string, footer string) {
23+ d.active = true
24+ d.ext = ext
25+ d.id = id
26+ d.title = title
27+ d.lines = append([]string(nil), lines...)
28+ d.footer = footer
29+}
30+
31+func (d *extPanelDialog) Update(title string, lines []string, footer string) {
32+ if !d.active {
33+ return
34+ }
35+ if title != "" {
36+ d.title = title
37+ }
38+ d.lines = append(d.lines[:0], lines...)
39+ d.footer = footer
40+}
41+
42+func (d *extPanelDialog) Close() {
43+ d.active = false
44+ d.ext = ""
45+ d.id = ""
46+ d.title = ""
47+ d.lines = nil
48+ d.footer = ""
49+}
50+
51+func (d *extPanelDialog) Render(th tui.Theme, width int) []string {
52+ if !d.Active() {
53+ return nil
54+ }
55+ title := d.title
56+ if title == "" {
57+ title = d.ext
58+ }
59+ out := []string{frameHeader(th, title, width)}
60+ for _, l := range d.lines {
61+ out = append(out, l)
62+ }
63+ if strings.TrimSpace(d.footer) != "" {
64+ out = append(out, "")
65+ out = append(out, th.FG256(th.Muted, d.footer))
66+ }
67+ out = append(out, frameRule(th, width))
68+ return out
69+}
M internal/agent/modes/interactive.go +105 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/patriceckhart/zot/internal/agent/tools"
1515 "github.com/patriceckhart/zot/internal/auth"
1616 "github.com/patriceckhart/zot/internal/core"
17+ "github.com/patriceckhart/zot/internal/extproto"
1718 "github.com/patriceckhart/zot/internal/provider"
1819 "github.com/patriceckhart/zot/internal/skills"
1920 "github.com/patriceckhart/zot/internal/tui"
@@ -217,6 +218,7 @@ type Interactive struct {
217218 telegramBridge *telegram.Bridge
218219 sessionOpsDialog *sessionOpsDialog
219220 sessionTreeDialog *sessionTreeDialog
221+ extPanel *extPanelDialog
220222
221223 // pendingFork is true when the user ran /session fork: the next
222224 // jump-picker selection should branch off that message instead
@@ -278,6 +280,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
278280 telegramDialog: newTelegramDialog(),
279281 sessionOpsDialog: newSessionOpsDialog(),
280282 sessionTreeDialog: newSessionTreeDialog(),
283+ extPanel: newExtPanelDialog(),
281284 suggest: newSlashSuggester(),
282285 spin: newSpinner(),
283286 }
@@ -698,6 +701,8 @@ func (i *Interactive) redraw() {
698701 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
699702 case i.sessionTreeDialog.Active():
700703 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
704+ case i.extPanel.Active():
705+ dialog = i.extPanel.Render(i.cfg.Theme, cols)
701706 }
702707
703708 // Slash-command autocomplete: popup above the status line, only
@@ -861,6 +866,10 @@ func (i *Interactive) redraw() {
861866 cursorCol = c
862867 }
863868 }
869+ if i.extPanel.Active() {
870+ cursorRow = -1
871+ cursorCol = 0
872+ }
864873 i.rend.Draw(frame, cursorRow, cursorCol)
865874 }
866875
@@ -957,6 +966,48 @@ func clipBottomClippedImages(lines []string) []string {
957966 // truncateLine shortens s so it fits within n display cells, with an
958967 // ellipsis if trimmed. Used by the "sliding in" chips so a pasted
959968 // novel doesn't blow past the status line.
969+func panelKeyName(k tui.Key) string {
970+ switch k.Kind {
971+ case tui.KeyUp:
972+ return "up"
973+ case tui.KeyDown:
974+ return "down"
975+ case tui.KeyLeft:
976+ return "left"
977+ case tui.KeyRight:
978+ return "right"
979+ case tui.KeyEnter:
980+ return "enter"
981+ case tui.KeyEsc:
982+ return "esc"
983+ case tui.KeyTab:
984+ return "tab"
985+ case tui.KeyBackspace:
986+ return "backspace"
987+ case tui.KeyDelete:
988+ return "delete"
989+ case tui.KeyHome:
990+ return "home"
991+ case tui.KeyEnd:
992+ return "end"
993+ case tui.KeyPageUp:
994+ return "pageup"
995+ case tui.KeyPageDown:
996+ return "pagedown"
997+ case tui.KeyRune:
998+ return "rune"
999+ default:
1000+ return "unknown"
1001+ }
1002+}
1003+
1004+func panelKeyText(k tui.Key) string {
1005+ if k.Kind == tui.KeyRune {
1006+ return string(k.Rune)
1007+ }
1008+ return ""
1009+}
1010+
9601011 func truncateLine(s string, n int) string {
9611012 if n <= 0 {
9621013 return ""
@@ -1112,6 +1163,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
11121163 i.invalidate()
11131164 return false
11141165 }
1166+ if i.extPanel.Active() {
1167+ if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
1168+ if i.cfg.Extensions != nil {
1169+ _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
1170+ }
1171+ i.extPanel.Close()
1172+ i.invalidate()
1173+ return false
1174+ }
1175+ if i.cfg.Extensions != nil {
1176+ _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
1177+ }
1178+ return false
1179+ }
11151180 if i.jumpDialog.Active() {
11161181 if k.Kind == tui.KeyCtrlC {
11171182 i.jumpDialog.Close()
@@ -1428,6 +1493,16 @@ func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args str
14281493 return
14291494 }
14301495 switch resp.Action {
1496+ case "open_panel":
1497+ if resp.OpenPanel != nil {
1498+ extName := name
1499+ if i.cfg.Extensions != nil {
1500+ if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
1501+ extName = owner
1502+ }
1503+ }
1504+ i.OpenPanel(extName, *resp.OpenPanel)
1505+ }
14311506 case "prompt":
14321507 if strings.TrimSpace(resp.Prompt) == "" {
14331508 return
@@ -1539,6 +1614,36 @@ func (i *Interactive) Display(extName, text string) {
15391614 i.invalidate()
15401615 }
15411616
1617+func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
1618+ i.mu.Lock()
1619+ defer i.mu.Unlock()
1620+ i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
1621+ if i.cfg.Extensions != nil {
1622+ cols, rows := i.cfg.Terminal.Size()
1623+ _ = cols
1624+ _ = rows
1625+ }
1626+ i.invalidate()
1627+}
1628+
1629+func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
1630+ i.mu.Lock()
1631+ defer i.mu.Unlock()
1632+ if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
1633+ i.extPanel.Update(title, lines, footer)
1634+ i.invalidate()
1635+ }
1636+}
1637+
1638+func (i *Interactive) ClosePanel(extName, panelID string) {
1639+ i.mu.Lock()
1640+ defer i.mu.Unlock()
1641+ if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
1642+ i.extPanel.Close()
1643+ i.invalidate()
1644+ }
1645+}
1646+
15421647 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
15431648 parts := strings.Fields(cmd)
15441649 switch parts[0] {
@@ -14,6 +14,7 @@ import (
14 "github.com/patriceckhart/zot/internal/agent/tools"14 "github.com/patriceckhart/zot/internal/agent/tools"
15 "github.com/patriceckhart/zot/internal/auth"15 "github.com/patriceckhart/zot/internal/auth"
16 "github.com/patriceckhart/zot/internal/core"16 "github.com/patriceckhart/zot/internal/core"
17+ "github.com/patriceckhart/zot/internal/extproto"
17 "github.com/patriceckhart/zot/internal/provider"18 "github.com/patriceckhart/zot/internal/provider"
18 "github.com/patriceckhart/zot/internal/skills"19 "github.com/patriceckhart/zot/internal/skills"
19 "github.com/patriceckhart/zot/internal/tui"20 "github.com/patriceckhart/zot/internal/tui"
@@ -217,6 +218,7 @@ type Interactive struct {
217 telegramBridge *telegram.Bridge218 telegramBridge *telegram.Bridge
218 sessionOpsDialog *sessionOpsDialog219 sessionOpsDialog *sessionOpsDialog
219 sessionTreeDialog *sessionTreeDialog220 sessionTreeDialog *sessionTreeDialog
221+ extPanel *extPanelDialog
220 222
221 // pendingFork is true when the user ran /session fork: the next223 // pendingFork is true when the user ran /session fork: the next
222 // jump-picker selection should branch off that message instead224 // jump-picker selection should branch off that message instead
@@ -278,6 +280,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
278 telegramDialog: newTelegramDialog(),280 telegramDialog: newTelegramDialog(),
279 sessionOpsDialog: newSessionOpsDialog(),281 sessionOpsDialog: newSessionOpsDialog(),
280 sessionTreeDialog: newSessionTreeDialog(),282 sessionTreeDialog: newSessionTreeDialog(),
283+ extPanel: newExtPanelDialog(),
281 suggest: newSlashSuggester(),284 suggest: newSlashSuggester(),
282 spin: newSpinner(),285 spin: newSpinner(),
283 }286 }
@@ -698,6 +701,8 @@ func (i *Interactive) redraw() {
698 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)701 dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
699 case i.sessionTreeDialog.Active():702 case i.sessionTreeDialog.Active():
700 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)703 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
704+ case i.extPanel.Active():
705+ dialog = i.extPanel.Render(i.cfg.Theme, cols)
701 }706 }
702 707
703 // Slash-command autocomplete: popup above the status line, only708 // Slash-command autocomplete: popup above the status line, only
@@ -861,6 +866,10 @@ func (i *Interactive) redraw() {
861 cursorCol = c866 cursorCol = c
862 }867 }
863 }868 }
869+ if i.extPanel.Active() {
870+ cursorRow = -1
871+ cursorCol = 0
872+ }
864 i.rend.Draw(frame, cursorRow, cursorCol)873 i.rend.Draw(frame, cursorRow, cursorCol)
865 }874 }
866 875
@@ -957,6 +966,48 @@ func clipBottomClippedImages(lines []string) []string {
957 // truncateLine shortens s so it fits within n display cells, with an966 // truncateLine shortens s so it fits within n display cells, with an
958 // ellipsis if trimmed. Used by the "sliding in" chips so a pasted967 // ellipsis if trimmed. Used by the "sliding in" chips so a pasted
959 // novel doesn't blow past the status line.968 // novel doesn't blow past the status line.
969+func panelKeyName(k tui.Key) string {
970+ switch k.Kind {
971+ case tui.KeyUp:
972+ return "up"
973+ case tui.KeyDown:
974+ return "down"
975+ case tui.KeyLeft:
976+ return "left"
977+ case tui.KeyRight:
978+ return "right"
979+ case tui.KeyEnter:
980+ return "enter"
981+ case tui.KeyEsc:
982+ return "esc"
983+ case tui.KeyTab:
984+ return "tab"
985+ case tui.KeyBackspace:
986+ return "backspace"
987+ case tui.KeyDelete:
988+ return "delete"
989+ case tui.KeyHome:
990+ return "home"
991+ case tui.KeyEnd:
992+ return "end"
993+ case tui.KeyPageUp:
994+ return "pageup"
995+ case tui.KeyPageDown:
996+ return "pagedown"
997+ case tui.KeyRune:
998+ return "rune"
999+ default:
1000+ return "unknown"
1001+ }
1002+}
1003+
1004+func panelKeyText(k tui.Key) string {
1005+ if k.Kind == tui.KeyRune {
1006+ return string(k.Rune)
1007+ }
1008+ return ""
1009+}
1010+
960 func truncateLine(s string, n int) string {1011 func truncateLine(s string, n int) string {
961 if n <= 0 {1012 if n <= 0 {
962 return ""1013 return ""
@@ -1112,6 +1163,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1112 i.invalidate()1163 i.invalidate()
1113 return false1164 return false
1114 }1165 }
1166+ if i.extPanel.Active() {
1167+ if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
1168+ if i.cfg.Extensions != nil {
1169+ _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
1170+ }
1171+ i.extPanel.Close()
1172+ i.invalidate()
1173+ return false
1174+ }
1175+ if i.cfg.Extensions != nil {
1176+ _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
1177+ }
1178+ return false
1179+ }
1115 if i.jumpDialog.Active() {1180 if i.jumpDialog.Active() {
1116 if k.Kind == tui.KeyCtrlC {1181 if k.Kind == tui.KeyCtrlC {
1117 i.jumpDialog.Close()1182 i.jumpDialog.Close()
@@ -1428,6 +1493,16 @@ func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args str
1428 return1493 return
1429 }1494 }
1430 switch resp.Action {1495 switch resp.Action {
1496+ case "open_panel":
1497+ if resp.OpenPanel != nil {
1498+ extName := name
1499+ if i.cfg.Extensions != nil {
1500+ if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
1501+ extName = owner
1502+ }
1503+ }
1504+ i.OpenPanel(extName, *resp.OpenPanel)
1505+ }
1431 case "prompt":1506 case "prompt":
1432 if strings.TrimSpace(resp.Prompt) == "" {1507 if strings.TrimSpace(resp.Prompt) == "" {
1433 return1508 return
@@ -1539,6 +1614,36 @@ func (i *Interactive) Display(extName, text string) {
1539 i.invalidate()1614 i.invalidate()
1540 }1615 }
1541 1616
1617+func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
1618+ i.mu.Lock()
1619+ defer i.mu.Unlock()
1620+ i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
1621+ if i.cfg.Extensions != nil {
1622+ cols, rows := i.cfg.Terminal.Size()
1623+ _ = cols
1624+ _ = rows
1625+ }
1626+ i.invalidate()
1627+}
1628+
1629+func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
1630+ i.mu.Lock()
1631+ defer i.mu.Unlock()
1632+ if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
1633+ i.extPanel.Update(title, lines, footer)
1634+ i.invalidate()
1635+ }
1636+}
1637+
1638+func (i *Interactive) ClosePanel(extName, panelID string) {
1639+ i.mu.Lock()
1640+ defer i.mu.Unlock()
1641+ if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
1642+ i.extPanel.Close()
1643+ i.invalidate()
1644+ }
1645+}
1646+
1542 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {1647 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1543 parts := strings.Fields(cmd)1648 parts := strings.Fields(cmd)
1544 switch parts[0] {1649 switch parts[0] {
M internal/agent/rpc.go +5 -2
@@ -142,8 +142,11 @@ func (h *rpcExtHooks) Display(extName, text string) {
142142 })
143143 }
144144 }
145-func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146-func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
145+func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146+func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
147+func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
148+func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
149+func (h *rpcExtHooks) ClosePanel(string, string) {}
147150
148151 type rpcServer struct {
149152 ctx context.Context
@@ -142,8 +142,11 @@ func (h *rpcExtHooks) Display(extName, text string) {
142 })142 })
143 }143 }
144 }144 }
145-func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode145+func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146-func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode146+func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
147+func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
148+func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
149+func (h *rpcExtHooks) ClosePanel(string, string) {}
147 150
148 type rpcServer struct {151 type rpcServer struct {
149 ctx context.Context152 ctx context.Context
M internal/extproto/extproto.go +73 -163
@@ -19,94 +19,45 @@ package extproto
1919
2020 import "encoding/json"
2121
22-// ProtocolVersion is the major version of this wire format. Bumped
23-// for breaking changes; minor additions don't bump.
2422 const ProtocolVersion = 1
2523
26-// Frame is the lowest-common-denominator parse target so a reader can
27-// peek at the type before unmarshalling the full payload.
2824 type Frame struct {
2925 Type string `json:"type"`
3026 ID string `json:"id,omitempty"`
3127 }
3228
33-// ---- extension -> host ----
34-
35-// HelloFromExt is the first frame the extension sends after start.
36-// Zot replies with HelloAckFromHost, then registration frames
37-// (RegisterCommandFromExt, etc.) flow.
3829 type HelloFromExt struct {
39- Type string `json:"type"` // "hello"
30+ Type string `json:"type"`
4031 Name string `json:"name"`
4132 Version string `json:"version"`
4233 Capabilities []string `json:"capabilities,omitempty"`
4334 }
4435
45-// RegisterCommandFromExt asks zot to bind /name to this extension.
46-// Description appears in the slash autocomplete + /help.
4736 type RegisterCommandFromExt struct {
48- Type string `json:"type"` // "register_command"
37+ Type string `json:"type"`
4938 Name string `json:"name"`
5039 Description string `json:"description,omitempty"`
5140 }
5241
53-// RegisterToolFromExt asks zot to expose a tool to the LLM. The
54-// schema is a JSON Schema object describing Args; zot doesn't validate
55-// the model's arguments against it (the model providers do that), but
56-// it must parse as valid JSON or registration is rejected.
57-//
58-// Tool names live in the same namespace as built-in tools (read,
59-// write, edit, bash, skill). Conflicts are silently shadowed by the
60-// built-in; check the extension's log for a warning.
6142 type RegisterToolFromExt struct {
62- Type string `json:"type"` // "register_tool"
43+ Type string `json:"type"`
6344 Name string `json:"name"`
6445 Description string `json:"description,omitempty"`
6546 Schema json.RawMessage `json:"schema"`
6647 }
6748
68-// ReadyFromExt signals "all initial registrations sent". The host
69-// waits for this (with a short timeout) before building the agent's
70-// tool registry, so model calls don't race extension tool
71-// registration.
7249 type ReadyFromExt struct {
73- Type string `json:"type"` // "ready"
50+ Type string `json:"type"`
7451 }
7552
76-// SubscribeFromExt declares which lifecycle events the extension
77-// wants to observe (one-way `event` frames) and which it wants to
78-// intercept (round-trip `event_intercept` frames). Send once after
79-// hello, before ready.
80-//
81-// Recognised event names: "session_start", "turn_start",
82-// "turn_end", "tool_call", "assistant_message".
83-//
84-// Only "tool_call" supports interception in this version; values
85-// listed in Intercept that aren't "tool_call" are ignored.
8653 type SubscribeFromExt struct {
87- Type string `json:"type"` // "subscribe"
54+ Type string `json:"type"`
8855 Events []string `json:"events,omitempty"`
8956 Intercept []string `json:"intercept,omitempty"`
9057 }
9158
92-// EventInterceptResponseFromExt is the extension's reply to an
93-// EventInterceptFromHost. block=true refuses the underlying action;
94-// reason is shown to the model (or the user) as the refusal text.
95-// All fields default to "allow, pass through unmodified".
96-//
97-// Optional rewrite fields, their meaning depends on the event:
98-//
99-// - ModifiedArgs: for event="tool_call", replaces the args the
100-// tool will see. Must be a JSON object literal or the rewrite is
101-// dropped and a warning logged.
102-// - ReplaceText: for event="assistant_message", replaces the user-
103-// visible text. The model's original text stays in the transcript
104-// (so the model can reference what it "said"); only the rendered
105-// output to the user is swapped.
106-//
107-// When block=true, rewrite fields are ignored.
10859 type EventInterceptResponseFromExt struct {
109- Type string `json:"type"` // "event_intercept_response"
60+ Type string `json:"type"`
11061 ID string `json:"id"`
11162 Block bool `json:"block,omitempty"`
11263 Reason string `json:"reason,omitempty"`
@@ -114,173 +65,132 @@ type EventInterceptResponseFromExt struct {
11465 ReplaceText string `json:"replace_text,omitempty"`
11566 }
11667
117-// ToolResultFromExt is the extension's reply to a ToolCallFromHost.
118-// Content[] follows the same shape as elsewhere in zot:
119-//
120-// {"type":"text", "text":"..."}
121-// {"type":"image", "mime_type":"image/png", "data":"<base64>"}
122-//
123-// Set IsError true to mark the tool call as failed; the model sees
124-// the content as the error explanation.
12568 type ToolResultFromExt struct {
126- Type string `json:"type"` // "tool_result"
69+ Type string `json:"type"`
12770 ID string `json:"id"`
12871 Content []ContentBlock `json:"content"`
12972 IsError bool `json:"is_error,omitempty"`
13073 }
13174
132-// ContentBlock is one entry in a tool result's content array.
13375 type ContentBlock struct {
134- Type string `json:"type"` // "text" | "image"
76+ Type string `json:"type"`
13577 Text string `json:"text,omitempty"`
13678 MimeType string `json:"mime_type,omitempty"`
137- Data string `json:"data,omitempty"` // base64
79+ Data string `json:"data,omitempty"`
13880 }
13981
140-// CommandResponseFromExt is the extension's answer to a
141-// CommandInvokedFromHost. Action drives what zot does next:
142-//
143-// - "prompt" → submit Prompt as a fresh user message to the agent
144-// - "insert" → insert Insert into the editor buffer at the cursor
145-// - "display" → append Display to the chat as a one-shot note
146-// (no model call, no transcript entry)
147-// - "noop" → command handled internally, no UI change
14882 type CommandResponseFromExt struct {
149- Type string `json:"type"` // "command_response"
150- ID string `json:"id"`
151- Action string `json:"action"` // see above
152- Prompt string `json:"prompt,omitempty"` // for action=prompt
153- Insert string `json:"insert,omitempty"` // for action=insert
154- Display string `json:"display,omitempty"` // for action=display
155- Error string `json:"error,omitempty"` // command failed; render to user
83+ Type string `json:"type"`
84+ ID string `json:"id"`
85+ Action string `json:"action"`
86+ Prompt string `json:"prompt,omitempty"`
87+ Insert string `json:"insert,omitempty"`
88+ Display string `json:"display,omitempty"`
89+ OpenPanel *PanelSpec `json:"open_panel,omitempty"`
90+ Error string `json:"error,omitempty"`
91+}
92+
93+type PanelSpec struct {
94+ ID string `json:"id"`
95+ Title string `json:"title,omitempty"`
96+ Lines []string `json:"lines,omitempty"`
97+ Footer string `json:"footer,omitempty"`
98+}
99+
100+type PanelRenderFromExt struct {
101+ Type string `json:"type"`
102+ PanelID string `json:"panel_id"`
103+ Title string `json:"title,omitempty"`
104+ Lines []string `json:"lines,omitempty"`
105+ Footer string `json:"footer,omitempty"`
106+}
107+
108+type PanelCloseFromExt struct {
109+ Type string `json:"type"`
110+ PanelID string `json:"panel_id"`
156111 }
157112
158-// NotifyFromExt is a one-way status message the extension can push at
159-// any time. Zot renders it in the chat as a styled note.
160113 type NotifyFromExt struct {
161- Type string `json:"type"` // "notify"
162- Level string `json:"level"` // "info" | "warn" | "error" | "success"
114+ Type string `json:"type"`
115+ Level string `json:"level"`
163116 Message string `json:"message"`
164117 }
165118
166-// ShutdownAckFromExt acknowledges the host's shutdown request. The
167-// extension should exit shortly after sending this; zot waits a few
168-// seconds before SIGTERM.
169119 type ShutdownAckFromExt struct {
170- Type string `json:"type"` // "shutdown_ack"
120+ Type string `json:"type"`
171121 }
172122
173-// ---- host -> extension ----
174-
175-// HelloAckFromHost is zot's reply to HelloFromExt. The extension may
176-// inspect the host version + currently-active provider/model to decide
177-// whether to register particular commands.
178123 type HelloAckFromHost struct {
179- Type string `json:"type"` // "hello_ack"
124+ Type string `json:"type"`
180125 ProtocolVersion int `json:"protocol_version"`
181126 ZotVersion string `json:"zot_version"`
182127 Provider string `json:"provider"`
183128 Model string `json:"model"`
184129 CWD string `json:"cwd"`
130+ ExtensionDir string `json:"extension_dir,omitempty"`
131+ DataDir string `json:"data_dir,omitempty"`
185132 }
186133
187-// CommandInvokedFromHost is sent when the user runs a slash command
188-// the extension previously registered. Args contains everything after
189-// the command name (already trimmed).
190134 type CommandInvokedFromHost struct {
191- Type string `json:"type"` // "command_invoked"
135+ Type string `json:"type"`
192136 ID string `json:"id"`
193137 Name string `json:"name"`
194138 Args string `json:"args,omitempty"`
195139 }
196140
197-// ToolCallFromHost is sent when the LLM invokes a tool the extension
198-// registered. Args is the raw JSON object the model produced; the
199-// extension is responsible for validating/coercing it. Reply with
200-// ToolResultFromExt within the host's tool timeout (default 60s).
201141 type ToolCallFromHost struct {
202- Type string `json:"type"` // "tool_call"
142+ Type string `json:"type"`
203143 ID string `json:"id"`
204144 Name string `json:"name"`
205145 Args json.RawMessage `json:"args"`
206146 }
207147
208-// EventFromHost is a one-way lifecycle notification. The payload
209-// fields populated depend on Event:
210-//
211-// session_start : (no extra fields)
212-// turn_start : Step
213-// turn_end : Stop, optional Error
214-// tool_call : ToolID, ToolName, ToolArgs
215-// assistant_message: Text
216148 type EventFromHost struct {
217- Type string `json:"type"` // "event"
218- Event string `json:"event"`
219-
220- Step int `json:"step,omitempty"`
221- Stop string `json:"stop,omitempty"`
222- Error string `json:"error,omitempty"`
223-
149+ Type string `json:"type"`
150+ Event string `json:"event"`
151+ Step int `json:"step,omitempty"`
152+ Stop string `json:"stop,omitempty"`
153+ Error string `json:"error,omitempty"`
224154 ToolID string `json:"tool_id,omitempty"`
225155 ToolName string `json:"tool_name,omitempty"`
226156 ToolArgs json.RawMessage `json:"tool_args,omitempty"`
227-
228- Text string `json:"text,omitempty"`
157+ Text string `json:"text,omitempty"`
229158 }
230159
231-// EventInterceptFromHost is sent when zot wants to give the
232-// extension a chance to block, modify, or annotate a lifecycle
233-// event before it happens. Reply with EventInterceptResponseFromExt
234-// within the host's intercept timeout (default 5s); missing the
235-// deadline is treated as "allow".
236-//
237-// Supported events and their effect on block=true:
238-//
239-// - tool_call: cancel the tool; model sees reason as error.
240-// Can also modify args via ModifiedArgs.
241-// - turn_start: cancel the turn before the model call.
242-// Reason is shown as a chat status line.
243-// - assistant_message: suppress the message. Can also rewrite
244-// the user-visible text via ReplaceText.
245160 type EventInterceptFromHost struct {
246- Type string `json:"type"` // "event_intercept"
247- ID string `json:"id"`
248- Event string `json:"event"`
249-
250- // tool_call payload
161+ Type string `json:"type"`
162+ ID string `json:"id"`
163+ Event string `json:"event"`
251164 ToolID string `json:"tool_id,omitempty"`
252165 ToolName string `json:"tool_name,omitempty"`
253166 ToolArgs json.RawMessage `json:"tool_args,omitempty"`
254-
255- // turn_start payload
256- Step int `json:"step,omitempty"`
257-
258- // assistant_message payload
259- Text string `json:"text,omitempty"`
167+ Step int `json:"step,omitempty"`
168+ Text string `json:"text,omitempty"`
260169 }
261170
262-// ShutdownFromHost asks the extension to clean up and exit. Zot
263-// sends this when the user runs /reload-ext or zot itself is exiting
264-// gracefully. Extensions that don't reply within a few seconds get
265-// SIGTERM; SIGKILL after a few more.
266-type ShutdownFromHost struct {
267- Type string `json:"type"` // "shutdown"
171+type PanelKeyFromHost struct {
172+ Type string `json:"type"`
173+ PanelID string `json:"panel_id"`
174+ Key string `json:"key"`
175+ Text string `json:"text,omitempty"`
268176 }
269177
270-// ---- error frame (either direction) ----
178+type PanelResizeFromHost struct {
179+ Type string `json:"type"`
180+ PanelID string `json:"panel_id"`
181+ Width int `json:"width"`
182+ Height int `json:"height"`
183+}
271184
272-// Error is a generic failure response. Used by either side when a
273-// frame can't be processed (malformed JSON, unknown type, etc.).
274-type Error struct {
275- Type string `json:"type"` // "error"
276- ID string `json:"id,omitempty"`
277- Message string `json:"message"`
185+type PanelCloseFromHost struct {
186+ Type string `json:"type"`
187+ PanelID string `json:"panel_id"`
278188 }
279189
280-// ---- helpers ----
190+type ShutdownFromHost struct {
191+ Type string `json:"type"`
192+}
281193
282-// Encode marshals v and appends a trailing LF, ready to write to the
283-// peer's pipe. Returns the marshalling error, if any.
284194 func Encode(v any) ([]byte, error) {
285195 b, err := json.Marshal(v)
286196 if err != nil {
@@ -19,94 +19,45 @@ package extproto
19 19
20 import "encoding/json"20 import "encoding/json"
21 21
22-// ProtocolVersion is the major version of this wire format. Bumped
23-// for breaking changes; minor additions don't bump.
24 const ProtocolVersion = 122 const ProtocolVersion = 1
25 23
26-// Frame is the lowest-common-denominator parse target so a reader can
27-// peek at the type before unmarshalling the full payload.
28 type Frame struct {24 type Frame struct {
29 Type string `json:"type"`25 Type string `json:"type"`
30 ID string `json:"id,omitempty"`26 ID string `json:"id,omitempty"`
31 }27 }
32 28
33-// ---- extension -> host ----
34-
35-// HelloFromExt is the first frame the extension sends after start.
36-// Zot replies with HelloAckFromHost, then registration frames
37-// (RegisterCommandFromExt, etc.) flow.
38 type HelloFromExt struct {29 type HelloFromExt struct {
39- Type string `json:"type"` // "hello"30+ Type string `json:"type"`
40 Name string `json:"name"`31 Name string `json:"name"`
41 Version string `json:"version"`32 Version string `json:"version"`
42 Capabilities []string `json:"capabilities,omitempty"`33 Capabilities []string `json:"capabilities,omitempty"`
43 }34 }
44 35
45-// RegisterCommandFromExt asks zot to bind /name to this extension.
46-// Description appears in the slash autocomplete + /help.
47 type RegisterCommandFromExt struct {36 type RegisterCommandFromExt struct {
48- Type string `json:"type"` // "register_command"37+ Type string `json:"type"`
49 Name string `json:"name"`38 Name string `json:"name"`
50 Description string `json:"description,omitempty"`39 Description string `json:"description,omitempty"`
51 }40 }
52 41
53-// RegisterToolFromExt asks zot to expose a tool to the LLM. The
54-// schema is a JSON Schema object describing Args; zot doesn't validate
55-// the model's arguments against it (the model providers do that), but
56-// it must parse as valid JSON or registration is rejected.
57-//
58-// Tool names live in the same namespace as built-in tools (read,
59-// write, edit, bash, skill). Conflicts are silently shadowed by the
60-// built-in; check the extension's log for a warning.
61 type RegisterToolFromExt struct {42 type RegisterToolFromExt struct {
62- Type string `json:"type"` // "register_tool"43+ Type string `json:"type"`
63 Name string `json:"name"`44 Name string `json:"name"`
64 Description string `json:"description,omitempty"`45 Description string `json:"description,omitempty"`
65 Schema json.RawMessage `json:"schema"`46 Schema json.RawMessage `json:"schema"`
66 }47 }
67 48
68-// ReadyFromExt signals "all initial registrations sent". The host
69-// waits for this (with a short timeout) before building the agent's
70-// tool registry, so model calls don't race extension tool
71-// registration.
72 type ReadyFromExt struct {49 type ReadyFromExt struct {
73- Type string `json:"type"` // "ready"50+ Type string `json:"type"`
74 }51 }
75 52
76-// SubscribeFromExt declares which lifecycle events the extension
77-// wants to observe (one-way `event` frames) and which it wants to
78-// intercept (round-trip `event_intercept` frames). Send once after
79-// hello, before ready.
80-//
81-// Recognised event names: "session_start", "turn_start",
82-// "turn_end", "tool_call", "assistant_message".
83-//
84-// Only "tool_call" supports interception in this version; values
85-// listed in Intercept that aren't "tool_call" are ignored.
86 type SubscribeFromExt struct {53 type SubscribeFromExt struct {
87- Type string `json:"type"` // "subscribe"54+ Type string `json:"type"`
88 Events []string `json:"events,omitempty"`55 Events []string `json:"events,omitempty"`
89 Intercept []string `json:"intercept,omitempty"`56 Intercept []string `json:"intercept,omitempty"`
90 }57 }
91 58
92-// EventInterceptResponseFromExt is the extension's reply to an
93-// EventInterceptFromHost. block=true refuses the underlying action;
94-// reason is shown to the model (or the user) as the refusal text.
95-// All fields default to "allow, pass through unmodified".
96-//
97-// Optional rewrite fields, their meaning depends on the event:
98-//
99-// - ModifiedArgs: for event="tool_call", replaces the args the
100-// tool will see. Must be a JSON object literal or the rewrite is
101-// dropped and a warning logged.
102-// - ReplaceText: for event="assistant_message", replaces the user-
103-// visible text. The model's original text stays in the transcript
104-// (so the model can reference what it "said"); only the rendered
105-// output to the user is swapped.
106-//
107-// When block=true, rewrite fields are ignored.
108 type EventInterceptResponseFromExt struct {59 type EventInterceptResponseFromExt struct {
109- Type string `json:"type"` // "event_intercept_response"60+ Type string `json:"type"`
110 ID string `json:"id"`61 ID string `json:"id"`
111 Block bool `json:"block,omitempty"`62 Block bool `json:"block,omitempty"`
112 Reason string `json:"reason,omitempty"`63 Reason string `json:"reason,omitempty"`
@@ -114,173 +65,132 @@ type EventInterceptResponseFromExt struct {
114 ReplaceText string `json:"replace_text,omitempty"`65 ReplaceText string `json:"replace_text,omitempty"`
115 }66 }
116 67
117-// ToolResultFromExt is the extension's reply to a ToolCallFromHost.
118-// Content[] follows the same shape as elsewhere in zot:
119-//
120-// {"type":"text", "text":"..."}
121-// {"type":"image", "mime_type":"image/png", "data":"<base64>"}
122-//
123-// Set IsError true to mark the tool call as failed; the model sees
124-// the content as the error explanation.
125 type ToolResultFromExt struct {68 type ToolResultFromExt struct {
126- Type string `json:"type"` // "tool_result"69+ Type string `json:"type"`
127 ID string `json:"id"`70 ID string `json:"id"`
128 Content []ContentBlock `json:"content"`71 Content []ContentBlock `json:"content"`
129 IsError bool `json:"is_error,omitempty"`72 IsError bool `json:"is_error,omitempty"`
130 }73 }
131 74
132-// ContentBlock is one entry in a tool result's content array.
133 type ContentBlock struct {75 type ContentBlock struct {
134- Type string `json:"type"` // "text" | "image"76+ Type string `json:"type"`
135 Text string `json:"text,omitempty"`77 Text string `json:"text,omitempty"`
136 MimeType string `json:"mime_type,omitempty"`78 MimeType string `json:"mime_type,omitempty"`
137- Data string `json:"data,omitempty"` // base6479+ Data string `json:"data,omitempty"`
138 }80 }
139 81
140-// CommandResponseFromExt is the extension's answer to a
141-// CommandInvokedFromHost. Action drives what zot does next:
142-//
143-// - "prompt" → submit Prompt as a fresh user message to the agent
144-// - "insert" → insert Insert into the editor buffer at the cursor
145-// - "display" → append Display to the chat as a one-shot note
146-// (no model call, no transcript entry)
147-// - "noop" → command handled internally, no UI change
148 type CommandResponseFromExt struct {82 type CommandResponseFromExt struct {
149- Type string `json:"type"` // "command_response"83+ Type string `json:"type"`
150- ID string `json:"id"`84+ ID string `json:"id"`
151- Action string `json:"action"` // see above85+ Action string `json:"action"`
152- Prompt string `json:"prompt,omitempty"` // for action=prompt86+ Prompt string `json:"prompt,omitempty"`
153- Insert string `json:"insert,omitempty"` // for action=insert87+ Insert string `json:"insert,omitempty"`
154- Display string `json:"display,omitempty"` // for action=display88+ Display string `json:"display,omitempty"`
155- Error string `json:"error,omitempty"` // command failed; render to user89+ OpenPanel *PanelSpec `json:"open_panel,omitempty"`
90+ Error string `json:"error,omitempty"`
91+}
92+
93+type PanelSpec struct {
94+ ID string `json:"id"`
95+ Title string `json:"title,omitempty"`
96+ Lines []string `json:"lines,omitempty"`
97+ Footer string `json:"footer,omitempty"`
98+}
99+
100+type PanelRenderFromExt struct {
101+ Type string `json:"type"`
102+ PanelID string `json:"panel_id"`
103+ Title string `json:"title,omitempty"`
104+ Lines []string `json:"lines,omitempty"`
105+ Footer string `json:"footer,omitempty"`
106+}
107+
108+type PanelCloseFromExt struct {
109+ Type string `json:"type"`
110+ PanelID string `json:"panel_id"`
156 }111 }
157 112
158-// NotifyFromExt is a one-way status message the extension can push at
159-// any time. Zot renders it in the chat as a styled note.
160 type NotifyFromExt struct {113 type NotifyFromExt struct {
161- Type string `json:"type"` // "notify"114+ Type string `json:"type"`
162- Level string `json:"level"` // "info" | "warn" | "error" | "success"115+ Level string `json:"level"`
163 Message string `json:"message"`116 Message string `json:"message"`
164 }117 }
165 118
166-// ShutdownAckFromExt acknowledges the host's shutdown request. The
167-// extension should exit shortly after sending this; zot waits a few
168-// seconds before SIGTERM.
169 type ShutdownAckFromExt struct {119 type ShutdownAckFromExt struct {
170- Type string `json:"type"` // "shutdown_ack"120+ Type string `json:"type"`
171 }121 }
172 122
173-// ---- host -> extension ----
174-
175-// HelloAckFromHost is zot's reply to HelloFromExt. The extension may
176-// inspect the host version + currently-active provider/model to decide
177-// whether to register particular commands.
178 type HelloAckFromHost struct {123 type HelloAckFromHost struct {
179- Type string `json:"type"` // "hello_ack"124+ Type string `json:"type"`
180 ProtocolVersion int `json:"protocol_version"`125 ProtocolVersion int `json:"protocol_version"`
181 ZotVersion string `json:"zot_version"`126 ZotVersion string `json:"zot_version"`
182 Provider string `json:"provider"`127 Provider string `json:"provider"`
183 Model string `json:"model"`128 Model string `json:"model"`
184 CWD string `json:"cwd"`129 CWD string `json:"cwd"`
130+ ExtensionDir string `json:"extension_dir,omitempty"`
131+ DataDir string `json:"data_dir,omitempty"`
185 }132 }
186 133
187-// CommandInvokedFromHost is sent when the user runs a slash command
188-// the extension previously registered. Args contains everything after
189-// the command name (already trimmed).
190 type CommandInvokedFromHost struct {134 type CommandInvokedFromHost struct {
191- Type string `json:"type"` // "command_invoked"135+ Type string `json:"type"`
192 ID string `json:"id"`136 ID string `json:"id"`
193 Name string `json:"name"`137 Name string `json:"name"`
194 Args string `json:"args,omitempty"`138 Args string `json:"args,omitempty"`
195 }139 }
196 140
197-// ToolCallFromHost is sent when the LLM invokes a tool the extension
198-// registered. Args is the raw JSON object the model produced; the
199-// extension is responsible for validating/coercing it. Reply with
200-// ToolResultFromExt within the host's tool timeout (default 60s).
201 type ToolCallFromHost struct {141 type ToolCallFromHost struct {
202- Type string `json:"type"` // "tool_call"142+ Type string `json:"type"`
203 ID string `json:"id"`143 ID string `json:"id"`
204 Name string `json:"name"`144 Name string `json:"name"`
205 Args json.RawMessage `json:"args"`145 Args json.RawMessage `json:"args"`
206 }146 }
207 147
208-// EventFromHost is a one-way lifecycle notification. The payload
209-// fields populated depend on Event:
210-//
211-// session_start : (no extra fields)
212-// turn_start : Step
213-// turn_end : Stop, optional Error
214-// tool_call : ToolID, ToolName, ToolArgs
215-// assistant_message: Text
216 type EventFromHost struct {148 type EventFromHost struct {
217- Type string `json:"type"` // "event"149+ Type string `json:"type"`
218- Event string `json:"event"`150+ Event string `json:"event"`
219-151+ Step int `json:"step,omitempty"`
220- Step int `json:"step,omitempty"`152+ Stop string `json:"stop,omitempty"`
221- Stop string `json:"stop,omitempty"`153+ Error string `json:"error,omitempty"`
222- Error string `json:"error,omitempty"`
223-
224 ToolID string `json:"tool_id,omitempty"`154 ToolID string `json:"tool_id,omitempty"`
225 ToolName string `json:"tool_name,omitempty"`155 ToolName string `json:"tool_name,omitempty"`
226 ToolArgs json.RawMessage `json:"tool_args,omitempty"`156 ToolArgs json.RawMessage `json:"tool_args,omitempty"`
227-157+ Text string `json:"text,omitempty"`
228- Text string `json:"text,omitempty"`
229 }158 }
230 159
231-// EventInterceptFromHost is sent when zot wants to give the
232-// extension a chance to block, modify, or annotate a lifecycle
233-// event before it happens. Reply with EventInterceptResponseFromExt
234-// within the host's intercept timeout (default 5s); missing the
235-// deadline is treated as "allow".
236-//
237-// Supported events and their effect on block=true:
238-//
239-// - tool_call: cancel the tool; model sees reason as error.
240-// Can also modify args via ModifiedArgs.
241-// - turn_start: cancel the turn before the model call.
242-// Reason is shown as a chat status line.
243-// - assistant_message: suppress the message. Can also rewrite
244-// the user-visible text via ReplaceText.
245 type EventInterceptFromHost struct {160 type EventInterceptFromHost struct {
246- Type string `json:"type"` // "event_intercept"161+ Type string `json:"type"`
247- ID string `json:"id"`162+ ID string `json:"id"`
248- Event string `json:"event"`163+ Event string `json:"event"`
249-
250- // tool_call payload
251 ToolID string `json:"tool_id,omitempty"`164 ToolID string `json:"tool_id,omitempty"`
252 ToolName string `json:"tool_name,omitempty"`165 ToolName string `json:"tool_name,omitempty"`
253 ToolArgs json.RawMessage `json:"tool_args,omitempty"`166 ToolArgs json.RawMessage `json:"tool_args,omitempty"`
254-167+ Step int `json:"step,omitempty"`
255- // turn_start payload168+ Text string `json:"text,omitempty"`
256- Step int `json:"step,omitempty"`
257-
258- // assistant_message payload
259- Text string `json:"text,omitempty"`
260 }169 }
261 170
262-// ShutdownFromHost asks the extension to clean up and exit. Zot171+type PanelKeyFromHost struct {
263-// sends this when the user runs /reload-ext or zot itself is exiting172+ Type string `json:"type"`
264-// gracefully. Extensions that don't reply within a few seconds get173+ PanelID string `json:"panel_id"`
265-// SIGTERM; SIGKILL after a few more.174+ Key string `json:"key"`
266-type ShutdownFromHost struct {175+ Text string `json:"text,omitempty"`
267- Type string `json:"type"` // "shutdown"
268 }176 }
269 177
270-// ---- error frame (either direction) ----178+type PanelResizeFromHost struct {
179+ Type string `json:"type"`
180+ PanelID string `json:"panel_id"`
181+ Width int `json:"width"`
182+ Height int `json:"height"`
183+}
271 184
272-// Error is a generic failure response. Used by either side when a185+type PanelCloseFromHost struct {
273-// frame can't be processed (malformed JSON, unknown type, etc.).186+ Type string `json:"type"`
274-type Error struct {187+ PanelID string `json:"panel_id"`
275- Type string `json:"type"` // "error"
276- ID string `json:"id,omitempty"`
277- Message string `json:"message"`
278 }188 }
279 189
280-// ---- helpers ----190+type ShutdownFromHost struct {
191+ Type string `json:"type"`
192+}
281 193
282-// Encode marshals v and appends a trailing LF, ready to write to the
283-// peer's pipe. Returns the marshalling error, if any.
284 func Encode(v any) ([]byte, error) {194 func Encode(v any) ([]byte, error) {
285 b, err := json.Marshal(v)195 b, err := json.Marshal(v)
286 if err != nil {196 if err != nil {
M internal/tui/view.go +2 -4
@@ -411,7 +411,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
411411 switch b := c.(type) {
412412 case provider.TextBlock:
413413 for _, l := range strings.Split(b.Text, "\n") {
414- for _, w := range wrapLine(l, width-2, " ") {
414+ for _, w := range wrapLine(l, width-4, "") {
415415 lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
416416 }
417417 }
@@ -435,9 +435,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
435435 case provider.TextBlock:
436436 md := RenderMarkdown(b.Text, v.Theme, inner)
437437 for _, l := range strings.Split(md, "\n") {
438- for _, w := range wrapLine(l, inner, "") {
439- lines = append(lines, indent+w)
440- }
438+ lines = append(lines, indent+l)
441439 }
442440 case provider.ToolCallBlock:
443441 // Rule above the tool header frames the call as a
@@ -411,7 +411,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
411 switch b := c.(type) {411 switch b := c.(type) {
412 case provider.TextBlock:412 case provider.TextBlock:
413 for _, l := range strings.Split(b.Text, "\n") {413 for _, l := range strings.Split(b.Text, "\n") {
414- for _, w := range wrapLine(l, width-2, " ") {414+ for _, w := range wrapLine(l, width-4, "") {
415 lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))415 lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
416 }416 }
417 }417 }
@@ -435,9 +435,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
435 case provider.TextBlock:435 case provider.TextBlock:
436 md := RenderMarkdown(b.Text, v.Theme, inner)436 md := RenderMarkdown(b.Text, v.Theme, inner)
437 for _, l := range strings.Split(md, "\n") {437 for _, l := range strings.Split(md, "\n") {
438- for _, w := range wrapLine(l, inner, "") {438+ lines = append(lines, indent+l)
439- lines = append(lines, indent+w)
440- }
441 }439 }
442 case provider.ToolCallBlock:440 case provider.ToolCallBlock:
443 // Rule above the tool header frames the call as a441 // Rule above the tool header frames the call as a
M pkg/zotext/zotext.go +84 -13
@@ -185,12 +185,20 @@ func TextErrorResult(s string) ToolResult {
185185
186186 // Response tells zot how to react to a command invocation. Construct
187187 // one with Prompt(), Insert(), Display(), or Noop().
188+type Panel struct {
189+ ID string
190+ Title string
191+ Lines []string
192+ Footer string
193+}
194+
188195 type Response struct {
189- Action string // "prompt", "insert", "display", "noop"
190- Prompt string
191- Insert string
192- Display string
193- Error string
196+ Action string // "prompt", "insert", "display", "open_panel", "noop"
197+ Prompt string
198+ Insert string
199+ Display string
200+ OpenPanel *Panel
201+ Error string
194202 }
195203
196204 // Prompt returns a Response that submits text as a fresh user message
@@ -207,6 +215,12 @@ func Insert(text string) Response { return Response{Action: "insert", Insert: te
207215 // burning tokens.
208216 func Display(text string) Response { return Response{Action: "display", Display: text} }
209217
218+// OpenPanel returns a Response that opens an interactive extension-owned
219+// panel inside zot.
220+func OpenPanel(id, title string, lines []string, footer string) Response {
221+ return Response{Action: "open_panel", OpenPanel: &Panel{ID: id, Title: title, Lines: lines, Footer: footer}}
222+}
223+
210224 // Noop returns a Response that signals "I handled it, no UI change".
211225 // Use after pushing your own state or notifications.
212226 func Noop() Response { return Response{Action: "noop"} }
@@ -241,6 +255,8 @@ type Extension struct {
241255 interceptOn bool
242256 interceptTurn TurnStartHandler
243257 interceptAssistant AssistantMessageHandler
258+ panelKeys map[string]func(key, text string)
259+ panelCloses map[string]func()
244260
245261 // Caps reported in the hello frame.
246262 caps []string
@@ -267,6 +283,8 @@ type HostInfo struct {
267283 Provider string
268284 Model string
269285 CWD string
286+ ExtensionDir string
287+ DataDir string
270288 }
271289
272290 // New constructs an Extension with the given identifier. name should
@@ -281,7 +299,9 @@ func New(name, version string) *Extension {
281299 commands: map[string]CommandHandler{},
282300 tools: map[string]ToolHandler{},
283301 eventHandlers: map[string]EventHandler{},
284- caps: []string{"commands", "tools", "events"},
302+ panelKeys: map[string]func(key, text string){},
303+ panelCloses: map[string]func(){},
304+ caps: []string{"commands", "tools", "events", "panels"},
285305 }
286306 }
287307
@@ -296,6 +316,28 @@ func (e *Extension) Logf(format string, args ...any) {
296316 fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...)
297317 }
298318
319+// OnPanelKey registers callbacks for panel key + close events.
320+func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onClose func()) {
321+ e.mu.Lock()
322+ defer e.mu.Unlock()
323+ if onKey != nil {
324+ e.panelKeys[panelID] = onKey
325+ }
326+ if onClose != nil {
327+ e.panelCloses[panelID] = onClose
328+ }
329+}
330+
331+// RenderPanel pushes a fresh frame for panelID.
332+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})
334+}
335+
336+// ClosePanel tells zot to close panelID.
337+func (e *Extension) ClosePanel(panelID string) {
338+ _ = e.send(extproto.PanelCloseFromExt{Type: "panel_close", PanelID: panelID})
339+}
340+
299341 // Command registers a slash-command handler. Call this BEFORE Run().
300342 // Once Run is going, when the user runs /name in zot, fn is invoked
301343 // with the remaining args.
@@ -474,6 +516,8 @@ func (e *Extension) Run() error {
474516 Provider: ack.Provider,
475517 Model: ack.Model,
476518 CWD: ack.CWD,
519+ ExtensionDir: ack.ExtensionDir,
520+ DataDir: ack.DataDir,
477521 }
478522 }
479523 case "command_invoked":
@@ -548,6 +592,28 @@ func (e *Extension) Run() error {
548592 continue
549593 }
550594 go e.dispatchIntercept(ei)
595+ case "panel_key":
596+ var pk extproto.PanelKeyFromHost
597+ if err := json.Unmarshal(line, &pk); err != nil {
598+ continue
599+ }
600+ e.mu.Lock()
601+ h := e.panelKeys[pk.PanelID]
602+ e.mu.Unlock()
603+ if h != nil {
604+ go h(pk.Key, pk.Text)
605+ }
606+ case "panel_close":
607+ var pc extproto.PanelCloseFromHost
608+ if err := json.Unmarshal(line, &pc); err != nil {
609+ continue
610+ }
611+ e.mu.Lock()
612+ h := e.panelCloses[pc.PanelID]
613+ e.mu.Unlock()
614+ if h != nil {
615+ go h()
616+ }
551617 case "shutdown":
552618 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
553619 return nil
@@ -563,14 +629,19 @@ func (e *Extension) respond(id string, r Response) {
563629 if r.Action == "" {
564630 r.Action = "noop"
565631 }
632+ var panel *extproto.PanelSpec
633+ if r.OpenPanel != nil {
634+ panel = &extproto.PanelSpec{ID: r.OpenPanel.ID, Title: r.OpenPanel.Title, Lines: r.OpenPanel.Lines, Footer: r.OpenPanel.Footer}
635+ }
566636 _ = e.send(extproto.CommandResponseFromExt{
567- Type: "command_response",
568- ID: id,
569- Action: r.Action,
570- Prompt: r.Prompt,
571- Insert: r.Insert,
572- Display: r.Display,
573- Error: r.Error,
637+ Type: "command_response",
638+ ID: id,
639+ Action: r.Action,
640+ Prompt: r.Prompt,
641+ Insert: r.Insert,
642+ Display: r.Display,
643+ OpenPanel: panel,
644+ Error: r.Error,
574645 })
575646 }
576647
@@ -185,12 +185,20 @@ func TextErrorResult(s string) ToolResult {
185 185
186 // Response tells zot how to react to a command invocation. Construct186 // Response tells zot how to react to a command invocation. Construct
187 // one with Prompt(), Insert(), Display(), or Noop().187 // one with Prompt(), Insert(), Display(), or Noop().
188+type Panel struct {
189+ ID string
190+ Title string
191+ Lines []string
192+ Footer string
193+}
194+
188 type Response struct {195 type Response struct {
189- Action string // "prompt", "insert", "display", "noop"196+ Action string // "prompt", "insert", "display", "open_panel", "noop"
190- Prompt string197+ Prompt string
191- Insert string198+ Insert string
192- Display string199+ Display string
193- Error string200+ OpenPanel *Panel
201+ Error string
194 }202 }
195 203
196 // Prompt returns a Response that submits text as a fresh user message204 // Prompt returns a Response that submits text as a fresh user message
@@ -207,6 +215,12 @@ func Insert(text string) Response { return Response{Action: "insert", Insert: te
207 // burning tokens.215 // burning tokens.
208 func Display(text string) Response { return Response{Action: "display", Display: text} }216 func Display(text string) Response { return Response{Action: "display", Display: text} }
209 217
218+// OpenPanel returns a Response that opens an interactive extension-owned
219+// panel inside zot.
220+func OpenPanel(id, title string, lines []string, footer string) Response {
221+ return Response{Action: "open_panel", OpenPanel: &Panel{ID: id, Title: title, Lines: lines, Footer: footer}}
222+}
223+
210 // Noop returns a Response that signals "I handled it, no UI change".224 // Noop returns a Response that signals "I handled it, no UI change".
211 // Use after pushing your own state or notifications.225 // Use after pushing your own state or notifications.
212 func Noop() Response { return Response{Action: "noop"} }226 func Noop() Response { return Response{Action: "noop"} }
@@ -241,6 +255,8 @@ type Extension struct {
241 interceptOn bool255 interceptOn bool
242 interceptTurn TurnStartHandler256 interceptTurn TurnStartHandler
243 interceptAssistant AssistantMessageHandler257 interceptAssistant AssistantMessageHandler
258+ panelKeys map[string]func(key, text string)
259+ panelCloses map[string]func()
244 260
245 // Caps reported in the hello frame.261 // Caps reported in the hello frame.
246 caps []string262 caps []string
@@ -267,6 +283,8 @@ type HostInfo struct {
267 Provider string283 Provider string
268 Model string284 Model string
269 CWD string285 CWD string
286+ ExtensionDir string
287+ DataDir string
270 }288 }
271 289
272 // New constructs an Extension with the given identifier. name should290 // New constructs an Extension with the given identifier. name should
@@ -281,7 +299,9 @@ func New(name, version string) *Extension {
281 commands: map[string]CommandHandler{},299 commands: map[string]CommandHandler{},
282 tools: map[string]ToolHandler{},300 tools: map[string]ToolHandler{},
283 eventHandlers: map[string]EventHandler{},301 eventHandlers: map[string]EventHandler{},
284- caps: []string{"commands", "tools", "events"},302+ panelKeys: map[string]func(key, text string){},
303+ panelCloses: map[string]func(){},
304+ caps: []string{"commands", "tools", "events", "panels"},
285 }305 }
286 }306 }
287 307
@@ -296,6 +316,28 @@ func (e *Extension) Logf(format string, args ...any) {
296 fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...)316 fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...)
297 }317 }
298 318
319+// OnPanelKey registers callbacks for panel key + close events.
320+func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onClose func()) {
321+ e.mu.Lock()
322+ defer e.mu.Unlock()
323+ if onKey != nil {
324+ e.panelKeys[panelID] = onKey
325+ }
326+ if onClose != nil {
327+ e.panelCloses[panelID] = onClose
328+ }
329+}
330+
331+// RenderPanel pushes a fresh frame for panelID.
332+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})
334+}
335+
336+// ClosePanel tells zot to close panelID.
337+func (e *Extension) ClosePanel(panelID string) {
338+ _ = e.send(extproto.PanelCloseFromExt{Type: "panel_close", PanelID: panelID})
339+}
340+
299 // Command registers a slash-command handler. Call this BEFORE Run().341 // Command registers a slash-command handler. Call this BEFORE Run().
300 // Once Run is going, when the user runs /name in zot, fn is invoked342 // Once Run is going, when the user runs /name in zot, fn is invoked
301 // with the remaining args.343 // with the remaining args.
@@ -474,6 +516,8 @@ func (e *Extension) Run() error {
474 Provider: ack.Provider,516 Provider: ack.Provider,
475 Model: ack.Model,517 Model: ack.Model,
476 CWD: ack.CWD,518 CWD: ack.CWD,
519+ ExtensionDir: ack.ExtensionDir,
520+ DataDir: ack.DataDir,
477 }521 }
478 }522 }
479 case "command_invoked":523 case "command_invoked":
@@ -548,6 +592,28 @@ func (e *Extension) Run() error {
548 continue592 continue
549 }593 }
550 go e.dispatchIntercept(ei)594 go e.dispatchIntercept(ei)
595+ case "panel_key":
596+ var pk extproto.PanelKeyFromHost
597+ if err := json.Unmarshal(line, &pk); err != nil {
598+ continue
599+ }
600+ e.mu.Lock()
601+ h := e.panelKeys[pk.PanelID]
602+ e.mu.Unlock()
603+ if h != nil {
604+ go h(pk.Key, pk.Text)
605+ }
606+ case "panel_close":
607+ var pc extproto.PanelCloseFromHost
608+ if err := json.Unmarshal(line, &pc); err != nil {
609+ continue
610+ }
611+ e.mu.Lock()
612+ h := e.panelCloses[pc.PanelID]
613+ e.mu.Unlock()
614+ if h != nil {
615+ go h()
616+ }
551 case "shutdown":617 case "shutdown":
552 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})618 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
553 return nil619 return nil
@@ -563,14 +629,19 @@ func (e *Extension) respond(id string, r Response) {
563 if r.Action == "" {629 if r.Action == "" {
564 r.Action = "noop"630 r.Action = "noop"
565 }631 }
632+ var panel *extproto.PanelSpec
633+ if r.OpenPanel != nil {
634+ panel = &extproto.PanelSpec{ID: r.OpenPanel.ID, Title: r.OpenPanel.Title, Lines: r.OpenPanel.Lines, Footer: r.OpenPanel.Footer}
635+ }
566 _ = e.send(extproto.CommandResponseFromExt{636 _ = e.send(extproto.CommandResponseFromExt{
567- Type: "command_response",637+ Type: "command_response",
568- ID: id,638+ ID: id,
569- Action: r.Action,639+ Action: r.Action,
570- Prompt: r.Prompt,640+ Prompt: r.Prompt,
571- Insert: r.Insert,641+ Insert: r.Insert,
572- Display: r.Display,642+ Display: r.Display,
573- Error: r.Error,643+ OpenPanel: panel,
644+ Error: r.Error,
574 })645 })
575 }646 }
576 647

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