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 | |||
| 6 | 6 | can read and write JSON lines from stdio — Go, TypeScript, Python, | |
| 7 | 7 | Rust, shell with `jq`, anything. | |
| 8 | 8 | ||
| 9 | - | Three phases shipped so far: | |
| 9 | + | Four phases shipped so far: | |
| 10 | 10 | ||
| 11 | 11 | - **Phase 1**: slash commands + chat notifications. | |
| 12 | 12 | - **Phase 2**: tools the LLM can call. | |
| 13 | 13 | - **Phase 3**: lifecycle event subscriptions + tool-call interception | |
| 14 | 14 | for guardrail extensions. | |
| 15 | + | - **Phase 4**: interactive extension-owned panels rendered inside zot. | |
| 15 | 16 | ||
| 16 | 17 | ## Quick start | |
| 17 | 18 | ||
| @@ -91,6 +92,12 @@ A project-local extension with the same name wins over a global one. | |||
| 91 | 92 | On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`; | |
| 92 | 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 | 101 | Each extension owns its own subdirectory. The `extension.json` | |
| 95 | 102 | manifest tells zot how to launch it: | |
| 96 | 103 | ||
| @@ -123,8 +130,9 @@ manifest tells zot how to launch it: | |||
| 123 | 130 | redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per | |
| 124 | 131 | extension, append-mode). | |
| 125 | 132 | 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. | |
| 128 | 136 | 4. **Registration**: the extension sends `register_command` frames. | |
| 129 | 137 | First-come-first-served: a name already taken by a built-in or by | |
| 130 | 138 | a previously-loaded extension is silently shadowed (logged in the | |
| @@ -132,7 +140,8 @@ manifest tells zot how to launch it: | |||
| 132 | 140 | 5. **Runtime**: zot dispatches `command_invoked` frames when the | |
| 133 | 141 | user runs a registered command; the extension responds with | |
| 134 | 142 | `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 | 145 | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | |
| 137 | 146 | 2s for the extension to send `shutdown_ack`. Holdouts are | |
| 138 | 147 | SIGTERM'd, then SIGKILL'd. | |
| @@ -153,7 +162,7 @@ responses. | |||
| 153 | 162 | ||
| 154 | 163 | ```json | |
| 155 | 164 | {"type":"hello","name":"weather","version":"1.0.0", | |
| 156 | - | "capabilities":["commands","tools"]} | |
| 165 | + | "capabilities":["commands","tools","panels"]} | |
| 157 | 166 | ``` | |
| 158 | 167 | ||
| 159 | 168 | #### `register_command` | |
| @@ -274,13 +283,46 @@ subsequent interceptor sees the previous one's output. | |||
| 274 | 283 | submitting. | |
| 275 | 284 | - `"display"` — appends `display` to the chat as a one-shot styled | |
| 276 | 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 | 288 | - `"noop"` — the extension handled it itself (e.g. it pushed | |
| 278 | 289 | `notify` frames or kicked off background work). zot doesn't change | |
| 279 | 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 | 304 | If `error` is non-empty, zot renders it as a red status line | |
| 282 | 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 | 326 | #### `notify` (one-way, any time) | |
| 285 | 327 | ||
| 286 | 328 | ```json | |
| @@ -302,12 +344,17 @@ Sent in response to `shutdown`. Extension should exit promptly after. | |||
| 302 | 344 | ```json | |
| 303 | 345 | {"type":"hello_ack","protocol_version":1, | |
| 304 | 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 | 352 | Sent immediately after `hello`. The extension can use these fields to | |
| 309 | 353 | decide which commands to register (e.g. only register a Python tool | |
| 310 | 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 | 359 | #### `command_invoked` | |
| 313 | 360 | ||
| @@ -369,6 +416,28 @@ Payload fields depend on the event: | |||
| 369 | 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 | 441 | #### `shutdown` | |
| 373 | 442 | ||
| 374 | 443 | 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 interception | 13 | - **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 start | 17 | ## 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 per | 130 | 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; zot | 132 | 3. **Hello handshake**: the extension sends a `hello` frame; zot | ||
| 126 | - | replies with `hello_ack` containing the protocol version and the | 133 | + | 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 by | 137 | First-come-first-served: a name already taken by a built-in or by | ||
| 130 | a previously-loaded extension is silently shadowed (logged in the | 138 | 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 the | 140 | 5. **Runtime**: zot dispatches `command_invoked` frames when the | ||
| 133 | user runs a registered command; the extension responds with | 141 | user runs a registered command; the extension responds with | ||
| 134 | `command_response`. Extensions can also push `notify` frames at | 142 | `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 to | 145 | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | ||
| 137 | 2s for the extension to send `shutdown_ack`. Holdouts are | 146 | 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 | ```json | 163 | ```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 styled | 284 | - `"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 pushed | 288 | - `"noop"` — the extension handled it itself (e.g. it pushed | ||
| 278 | `notify` frames or kicked off background work). zot doesn't change | 289 | `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 line | 304 | 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 | ```json | 328 | ```json | ||
| @@ -302,12 +344,17 @@ Sent in response to `shutdown`. Extension should exit promptly after. | |||||
| 302 | ```json | 344 | ```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 to | 352 | Sent immediately after `hello`. The extension can use these fields to | ||
| 309 | decide which commands to register (e.g. only register a Python tool | 353 | 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) { | |||
| 55 | 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 | 74 | // extToolAdapter bridges *extensions.Manager to the | |
| 60 | 75 | // ExtensionToolSource interface declared in build.go (kept narrow to | |
| @@ -192,9 +207,12 @@ type nonInteractiveExtHooks struct{} | |||
| 192 | 207 | func (nonInteractiveExtHooks) Notify(ext, level, message string) { | |
| 193 | 208 | fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message) | |
| 194 | 209 | } | |
| 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) {} | |
| 198 | 216 | ||
| 199 | 217 | // setupNonInteractiveExtensions loads --ext paths and (unless | |
| 200 | 218 | // --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 the | 74 | // extToolAdapter bridges *extensions.Manager to the | ||
| 60 | // ExtensionToolSource interface declared in build.go (kept narrow to | 75 | // 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 (unless | 217 | // setupNonInteractiveExtensions loads --ext paths and (unless | ||
| 200 | // --no-ext) runs discovery. Returns the manager so the caller can | 218 | // --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 { | |||
| 109 | 109 | // Display appends a one-shot styled note to the chat without | |
| 110 | 110 | // invoking the model and without writing to the transcript. | |
| 111 | 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 | 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 | 553 | Provider: m.provider, | |
| 550 | 554 | Model: m.model, | |
| 551 | 555 | CWD: m.cwd, | |
| 556 | + | ExtensionDir: ext.Dir, | |
| 557 | + | DataDir: ext.Dir, | |
| 552 | 558 | }) | |
| 553 | 559 | if _, err := stdin.Write(ack); err != nil { | |
| 554 | 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 | 758 | case "shutdown_ack": | |
| 743 | 759 | // Caller of Stop is waiting on the process exit, not this frame. | |
| 744 | 760 | default: | |
| @@ -864,6 +880,15 @@ func (m *Manager) HasCommand(name string) bool { | |||
| 864 | 880 | 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 | 892 | // Invoke fires the named slash command's handler in the owning | |
| 868 | 893 | // extension and waits up to timeout for the response. Returns the | |
| 869 | 894 | // 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 | 939 | // Stop cleanly terminates every extension. Sends ShutdownFromHost, | |
| 915 | 940 | // waits up to gracePeriod for each subprocess to exit, then SIGTERMs | |
| 916 | 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 | 966 | func (m *Manager) Stop(gracePeriod time.Duration) { | |
| 918 | 967 | m.mu.RLock() | |
| 919 | 968 | 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 without | 109 | // 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 ok | 880 | 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 owning | 892 | // Invoke fires the named slash command's handler in the owning | ||
| 868 | // extension and waits up to timeout for the response. Returns the | 893 | // extension and waits up to timeout for the response. Returns the | ||
| 869 | // extension's CommandResponse so the caller can act on the action | 894 | // 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 SIGTERMs | 940 | // 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "os" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/internal/extproto" | |
| 7 | 9 | "path/filepath" | |
| 8 | 10 | "runtime" | |
| 9 | 11 | "sync" | |
| @@ -30,6 +32,9 @@ func (s *stubHooks) Display(name, text string) { | |||
| 30 | 32 | defer s.mu.Unlock() | |
| 31 | 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 | 39 | // writeMockExtension creates a minimal extension on disk that uses a | |
| 35 | 40 | // 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 a | 39 | // writeMockExtension creates a minimal extension on disk that uses a | ||
| 35 | // shell script (or batch file on windows) to drive the protocol. The | 40 | // 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 ( | |||
| 14 | 14 | "github.com/patriceckhart/zot/internal/agent/tools" | |
| 15 | 15 | "github.com/patriceckhart/zot/internal/auth" | |
| 16 | 16 | "github.com/patriceckhart/zot/internal/core" | |
| 17 | + | "github.com/patriceckhart/zot/internal/extproto" | |
| 17 | 18 | "github.com/patriceckhart/zot/internal/provider" | |
| 18 | 19 | "github.com/patriceckhart/zot/internal/skills" | |
| 19 | 20 | "github.com/patriceckhart/zot/internal/tui" | |
| @@ -217,6 +218,7 @@ type Interactive struct { | |||
| 217 | 218 | telegramBridge *telegram.Bridge | |
| 218 | 219 | sessionOpsDialog *sessionOpsDialog | |
| 219 | 220 | sessionTreeDialog *sessionTreeDialog | |
| 221 | + | extPanel *extPanelDialog | |
| 220 | 222 | ||
| 221 | 223 | // pendingFork is true when the user ran /session fork: the next | |
| 222 | 224 | // jump-picker selection should branch off that message instead | |
| @@ -278,6 +280,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 278 | 280 | telegramDialog: newTelegramDialog(), | |
| 279 | 281 | sessionOpsDialog: newSessionOpsDialog(), | |
| 280 | 282 | sessionTreeDialog: newSessionTreeDialog(), | |
| 283 | + | extPanel: newExtPanelDialog(), | |
| 281 | 284 | suggest: newSlashSuggester(), | |
| 282 | 285 | spin: newSpinner(), | |
| 283 | 286 | } | |
| @@ -698,6 +701,8 @@ func (i *Interactive) redraw() { | |||
| 698 | 701 | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | |
| 699 | 702 | case i.sessionTreeDialog.Active(): | |
| 700 | 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 | 708 | // Slash-command autocomplete: popup above the status line, only | |
| @@ -861,6 +866,10 @@ func (i *Interactive) redraw() { | |||
| 861 | 866 | cursorCol = c | |
| 862 | 867 | } | |
| 863 | 868 | } | |
| 869 | + | if i.extPanel.Active() { | |
| 870 | + | cursorRow = -1 | |
| 871 | + | cursorCol = 0 | |
| 872 | + | } | |
| 864 | 873 | i.rend.Draw(frame, cursorRow, cursorCol) | |
| 865 | 874 | } | |
| 866 | 875 | ||
| @@ -957,6 +966,48 @@ func clipBottomClippedImages(lines []string) []string { | |||
| 957 | 966 | // truncateLine shortens s so it fits within n display cells, with an | |
| 958 | 967 | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | |
| 959 | 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 | 1011 | func truncateLine(s string, n int) string { | |
| 961 | 1012 | if n <= 0 { | |
| 962 | 1013 | return "" | |
| @@ -1112,6 +1163,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1112 | 1163 | i.invalidate() | |
| 1113 | 1164 | 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 | 1180 | if i.jumpDialog.Active() { | |
| 1116 | 1181 | if k.Kind == tui.KeyCtrlC { | |
| 1117 | 1182 | i.jumpDialog.Close() | |
| @@ -1428,6 +1493,16 @@ func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args str | |||
| 1428 | 1493 | return | |
| 1429 | 1494 | } | |
| 1430 | 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 | 1506 | case "prompt": | |
| 1432 | 1507 | if strings.TrimSpace(resp.Prompt) == "" { | |
| 1433 | 1508 | return | |
| @@ -1539,6 +1614,36 @@ func (i *Interactive) Display(extName, text string) { | |||
| 1539 | 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 | 1647 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 1543 | 1648 | parts := strings.Fields(cmd) | |
| 1544 | 1649 | 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.Bridge | 218 | telegramBridge *telegram.Bridge | ||
| 218 | sessionOpsDialog *sessionOpsDialog | 219 | sessionOpsDialog *sessionOpsDialog | ||
| 219 | sessionTreeDialog *sessionTreeDialog | 220 | sessionTreeDialog *sessionTreeDialog | ||
| 221 | + | extPanel *extPanelDialog | |||
| 220 | 222 | ||||
| 221 | // pendingFork is true when the user ran /session fork: the next | 223 | // pendingFork is true when the user ran /session fork: the next | ||
| 222 | // jump-picker selection should branch off that message instead | 224 | // 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, only | 708 | // Slash-command autocomplete: popup above the status line, only | ||
| @@ -861,6 +866,10 @@ func (i *Interactive) redraw() { | |||||
| 861 | cursorCol = c | 866 | 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 an | 966 | // truncateLine shortens s so it fits within n display cells, with an | ||
| 958 | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | 967 | // 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 false | 1164 | 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 | return | 1493 | 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 | return | 1508 | 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) { | |||
| 142 | 142 | }) | |
| 143 | 143 | } | |
| 144 | 144 | } | |
| 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) {} | |
| 147 | 150 | ||
| 148 | 151 | type rpcServer struct { | |
| 149 | 152 | 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 mode | 145 | + | func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode |
| 146 | - | func (h *rpcExtHooks) Insert(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) {} | |||
| 147 | 150 | ||||
| 148 | type rpcServer struct { | 151 | type rpcServer struct { | ||
| 149 | ctx context.Context | 152 | ctx context.Context | ||
M internal/extproto/extproto.go +73 -163
| @@ -19,94 +19,45 @@ package extproto | |||
| 19 | 19 | ||
| 20 | 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 | 22 | 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 | 24 | type Frame struct { | |
| 29 | 25 | Type string `json:"type"` | |
| 30 | 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 | 29 | type HelloFromExt struct { | |
| 39 | - | Type string `json:"type"` // "hello" | |
| 30 | + | Type string `json:"type"` | |
| 40 | 31 | Name string `json:"name"` | |
| 41 | 32 | Version string `json:"version"` | |
| 42 | 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 | 36 | type RegisterCommandFromExt struct { | |
| 48 | - | Type string `json:"type"` // "register_command" | |
| 37 | + | Type string `json:"type"` | |
| 49 | 38 | Name string `json:"name"` | |
| 50 | 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 | 42 | type RegisterToolFromExt struct { | |
| 62 | - | Type string `json:"type"` // "register_tool" | |
| 43 | + | Type string `json:"type"` | |
| 63 | 44 | Name string `json:"name"` | |
| 64 | 45 | Description string `json:"description,omitempty"` | |
| 65 | 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 | 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 | 53 | type SubscribeFromExt struct { | |
| 87 | - | Type string `json:"type"` // "subscribe" | |
| 54 | + | Type string `json:"type"` | |
| 88 | 55 | Events []string `json:"events,omitempty"` | |
| 89 | 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 | 59 | type EventInterceptResponseFromExt struct { | |
| 109 | - | Type string `json:"type"` // "event_intercept_response" | |
| 60 | + | Type string `json:"type"` | |
| 110 | 61 | ID string `json:"id"` | |
| 111 | 62 | Block bool `json:"block,omitempty"` | |
| 112 | 63 | Reason string `json:"reason,omitempty"` | |
| @@ -114,173 +65,132 @@ type EventInterceptResponseFromExt struct { | |||
| 114 | 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 | 68 | type ToolResultFromExt struct { | |
| 126 | - | Type string `json:"type"` // "tool_result" | |
| 69 | + | Type string `json:"type"` | |
| 127 | 70 | ID string `json:"id"` | |
| 128 | 71 | Content []ContentBlock `json:"content"` | |
| 129 | 72 | IsError bool `json:"is_error,omitempty"` | |
| 130 | 73 | } | |
| 131 | 74 | ||
| 132 | - | // ContentBlock is one entry in a tool result's content array. | |
| 133 | 75 | type ContentBlock struct { | |
| 134 | - | Type string `json:"type"` // "text" | "image" | |
| 76 | + | Type string `json:"type"` | |
| 135 | 77 | Text string `json:"text,omitempty"` | |
| 136 | 78 | MimeType string `json:"mime_type,omitempty"` | |
| 137 | - | Data string `json:"data,omitempty"` // base64 | |
| 79 | + | 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 | 82 | 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"` | |
| 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 | 113 | 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"` | |
| 163 | 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 | 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 | 123 | type HelloAckFromHost struct { | |
| 179 | - | Type string `json:"type"` // "hello_ack" | |
| 124 | + | Type string `json:"type"` | |
| 180 | 125 | ProtocolVersion int `json:"protocol_version"` | |
| 181 | 126 | ZotVersion string `json:"zot_version"` | |
| 182 | 127 | Provider string `json:"provider"` | |
| 183 | 128 | Model string `json:"model"` | |
| 184 | 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 | 134 | type CommandInvokedFromHost struct { | |
| 191 | - | Type string `json:"type"` // "command_invoked" | |
| 135 | + | Type string `json:"type"` | |
| 192 | 136 | ID string `json:"id"` | |
| 193 | 137 | Name string `json:"name"` | |
| 194 | 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 | 141 | type ToolCallFromHost struct { | |
| 202 | - | Type string `json:"type"` // "tool_call" | |
| 142 | + | Type string `json:"type"` | |
| 203 | 143 | ID string `json:"id"` | |
| 204 | 144 | Name string `json:"name"` | |
| 205 | 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 | 148 | 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"` | |
| 224 | 154 | ToolID string `json:"tool_id,omitempty"` | |
| 225 | 155 | ToolName string `json:"tool_name,omitempty"` | |
| 226 | 156 | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 227 | - | ||
| 228 | - | Text string `json:"text,omitempty"` | |
| 157 | + | 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 | 160 | 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"` | |
| 251 | 164 | ToolID string `json:"tool_id,omitempty"` | |
| 252 | 165 | ToolName string `json:"tool_name,omitempty"` | |
| 253 | 166 | 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"` | |
| 260 | 169 | } | |
| 261 | 170 | ||
| 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"` | |
| 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 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"` | |
| 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 | 194 | func Encode(v any) ([]byte, error) { | |
| 285 | 195 | b, err := json.Marshal(v) | |
| 286 | 196 | 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 = 1 | 22 | 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"` // base64 | 79 | + | 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 above | 85 | + | Action string `json:"action"` |
| 152 | - | Prompt string `json:"prompt,omitempty"` // for action=prompt | 86 | + | Prompt string `json:"prompt,omitempty"` |
| 153 | - | Insert string `json:"insert,omitempty"` // for action=insert | 87 | + | Insert string `json:"insert,omitempty"` |
| 154 | - | Display string `json:"display,omitempty"` // for action=display | 88 | + | Display string `json:"display,omitempty"` |
| 155 | - | Error string `json:"error,omitempty"` // command failed; render to user | 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"` | |||
| 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 payload | 168 | + | 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. Zot | 171 | + | type PanelKeyFromHost struct { |
| 263 | - | // sends this when the user runs /reload-ext or zot itself is exiting | 172 | + | Type string `json:"type"` |
| 264 | - | // gracefully. Extensions that don't reply within a few seconds get | 173 | + | 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 a | 185 | + | 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 { | |||
| 411 | 411 | switch b := c.(type) { | |
| 412 | 412 | case provider.TextBlock: | |
| 413 | 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 | 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 | 435 | case provider.TextBlock: | |
| 436 | 436 | md := RenderMarkdown(b.Text, v.Theme, inner) | |
| 437 | 437 | 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) | |
| 441 | 439 | } | |
| 442 | 440 | case provider.ToolCallBlock: | |
| 443 | 441 | // 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 a | 441 | // 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 { | |||
| 185 | 185 | ||
| 186 | 186 | // Response tells zot how to react to a command invocation. Construct | |
| 187 | 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 | 195 | 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 | |
| 194 | 202 | } | |
| 195 | 203 | ||
| 196 | 204 | // 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 | 215 | // burning tokens. | |
| 208 | 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 | 224 | // Noop returns a Response that signals "I handled it, no UI change". | |
| 211 | 225 | // Use after pushing your own state or notifications. | |
| 212 | 226 | func Noop() Response { return Response{Action: "noop"} } | |
| @@ -241,6 +255,8 @@ type Extension struct { | |||
| 241 | 255 | interceptOn bool | |
| 242 | 256 | interceptTurn TurnStartHandler | |
| 243 | 257 | interceptAssistant AssistantMessageHandler | |
| 258 | + | panelKeys map[string]func(key, text string) | |
| 259 | + | panelCloses map[string]func() | |
| 244 | 260 | ||
| 245 | 261 | // Caps reported in the hello frame. | |
| 246 | 262 | caps []string | |
| @@ -267,6 +283,8 @@ type HostInfo struct { | |||
| 267 | 283 | Provider string | |
| 268 | 284 | Model string | |
| 269 | 285 | CWD string | |
| 286 | + | ExtensionDir string | |
| 287 | + | DataDir string | |
| 270 | 288 | } | |
| 271 | 289 | ||
| 272 | 290 | // New constructs an Extension with the given identifier. name should | |
| @@ -281,7 +299,9 @@ func New(name, version string) *Extension { | |||
| 281 | 299 | commands: map[string]CommandHandler{}, | |
| 282 | 300 | tools: map[string]ToolHandler{}, | |
| 283 | 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 | 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 | 341 | // Command registers a slash-command handler. Call this BEFORE Run(). | |
| 300 | 342 | // Once Run is going, when the user runs /name in zot, fn is invoked | |
| 301 | 343 | // with the remaining args. | |
| @@ -474,6 +516,8 @@ func (e *Extension) Run() error { | |||
| 474 | 516 | Provider: ack.Provider, | |
| 475 | 517 | Model: ack.Model, | |
| 476 | 518 | CWD: ack.CWD, | |
| 519 | + | ExtensionDir: ack.ExtensionDir, | |
| 520 | + | DataDir: ack.DataDir, | |
| 477 | 521 | } | |
| 478 | 522 | } | |
| 479 | 523 | case "command_invoked": | |
| @@ -548,6 +592,28 @@ func (e *Extension) Run() error { | |||
| 548 | 592 | continue | |
| 549 | 593 | } | |
| 550 | 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 | 617 | case "shutdown": | |
| 552 | 618 | _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"}) | |
| 553 | 619 | return nil | |
| @@ -563,14 +629,19 @@ func (e *Extension) respond(id string, r Response) { | |||
| 563 | 629 | if r.Action == "" { | |
| 564 | 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 | 636 | _ = 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, | |
| 574 | 645 | }) | |
| 575 | 646 | } | |
| 576 | 647 | ||
| @@ -185,12 +185,20 @@ func TextErrorResult(s string) ToolResult { | |||||
| 185 | 185 | ||||
| 186 | // Response tells zot how to react to a command invocation. Construct | 186 | // 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 string | 197 | + | Prompt string |
| 191 | - | Insert string | 198 | + | Insert string |
| 192 | - | Display string | 199 | + | Display string |
| 193 | - | Error string | 200 | + | OpenPanel *Panel |
| 201 | + | Error string | |||
| 194 | } | 202 | } | ||
| 195 | 203 | ||||
| 196 | // Prompt returns a Response that submits text as a fresh user message | 204 | // 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 bool | 255 | interceptOn bool | ||
| 242 | interceptTurn TurnStartHandler | 256 | interceptTurn TurnStartHandler | ||
| 243 | interceptAssistant AssistantMessageHandler | 257 | 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 []string | 262 | caps []string | ||
| @@ -267,6 +283,8 @@ type HostInfo struct { | |||||
| 267 | Provider string | 283 | Provider string | ||
| 268 | Model string | 284 | Model string | ||
| 269 | CWD string | 285 | CWD string | ||
| 286 | + | ExtensionDir string | |||
| 287 | + | DataDir string | |||
| 270 | } | 288 | } | ||
| 271 | 289 | ||||
| 272 | // New constructs an Extension with the given identifier. name should | 290 | // 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 invoked | 342 | // 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 | continue | 592 | 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 nil | 619 | 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 | ||||