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.
This commit is contained in:
patriceckhart 2026-04-22 08:53:21 +02:00
parent 8d3b7ff155
commit fdcdeb5eb1
10 changed files with 491 additions and 194 deletions

View file

@ -6,12 +6,13 @@ its stdin/stdout. Extensions can be written in **any language** that
can read and write JSON lines from stdio — Go, TypeScript, Python,
Rust, shell with `jq`, anything.
Three phases shipped so far:
Four phases shipped so far:
- **Phase 1**: slash commands + chat notifications.
- **Phase 2**: tools the LLM can call.
- **Phase 3**: lifecycle event subscriptions + tool-call interception
for guardrail extensions.
- **Phase 4**: interactive extension-owned panels rendered inside zot.
## Quick start
@ -91,6 +92,12 @@ A project-local extension with the same name wins over a global one.
On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;
on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.
Because each extension owns its own directory, the recommended place
for extension state is inside that directory itself (for example
`todos.json`, `settings.json`, or an auth/cache file used only by that
extension). The host also passes this path back in `hello_ack` as
`extension_dir` / `data_dir` so runtime code does not need to guess it.
Each extension owns its own subdirectory. The `extension.json`
manifest tells zot how to launch it:
@ -123,8 +130,9 @@ manifest tells zot how to launch it:
redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
extension, append-mode).
3. **Hello handshake**: the extension sends a `hello` frame; zot
replies with `hello_ack` containing the protocol version and the
active provider/model/cwd.
replies with `hello_ack` containing the protocol version, the
active provider/model/cwd, and the extension's own data directory
so it can persist files beside its manifest.
4. **Registration**: the extension sends `register_command` frames.
First-come-first-served: a name already taken by a built-in or by
a previously-loaded extension is silently shadowed (logged in the
@ -132,7 +140,8 @@ manifest tells zot how to launch it:
5. **Runtime**: zot dispatches `command_invoked` frames when the
user runs a registered command; the extension responds with
`command_response`. Extensions can also push `notify` frames at
any time.
any time. Panel-capable extensions may open an interactive panel,
receive key events, and push redraws while the panel is focused.
6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
2s for the extension to send `shutdown_ack`. Holdouts are
SIGTERM'd, then SIGKILL'd.
@ -153,7 +162,7 @@ responses.
```json
{"type":"hello","name":"weather","version":"1.0.0",
"capabilities":["commands","tools"]}
"capabilities":["commands","tools","panels"]}
```
#### `register_command`
@ -274,13 +283,46 @@ subsequent interceptor sees the previous one's output.
submitting.
- `"display"` — appends `display` to the chat as a one-shot styled
note. No model call, nothing written to the transcript.
- `"open_panel"` — opens an extension-owned interactive panel inside
zot. The panel content lives in `open_panel`.
- `"noop"` — the extension handled it itself (e.g. it pushed
`notify` frames or kicked off background work). zot doesn't change
the UI in response.
Example:
```json
{"type":"command_response","id":"...","action":"open_panel",
"open_panel":{
"id":"todos-main",
"title":"Todos",
"lines":["□ ship panel api","✓ persist state"],
"footer":"↑/↓ navigate - a add - x complete - esc close"
}}
```
If `error` is non-empty, zot renders it as a red status line
regardless of `action`.
#### `panel_render` (one-way, while a panel is open)
Pushes a fresh frame for an already-open panel.
```json
{"type":"panel_render","panel_id":"todos-main",
"title":"Todos",
"lines":["□ ship panel api","✓ persist state"],
"footer":"↑/↓ navigate - a add - x complete - esc close"}
```
#### `panel_close`
Closes a previously-open panel.
```json
{"type":"panel_close","panel_id":"todos-main"}
```
#### `notify` (one-way, any time)
```json
@ -302,12 +344,17 @@ Sent in response to `shutdown`. Extension should exit promptly after.
```json
{"type":"hello_ack","protocol_version":1,
"zot_version":"0.0.7","provider":"anthropic",
"model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot"}
"model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
"extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
"data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
```
Sent immediately after `hello`. The extension can use these fields to
decide which commands to register (e.g. only register a Python tool
on macOS, only register a model-specific shortcut for opus, etc.).
`extension_dir` / `data_dir` are where the extension should persist
its own state (for example `todos.json`, cached metadata, or auth
tokens scoped to that extension).
#### `command_invoked`
@ -369,6 +416,28 @@ Payload fields depend on the event:
"text":"here is your api key: sk-ant-..."}
```
#### `panel_key`
Sent while an extension-owned panel is focused. `key` is a normalized
name (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `pageup`,
`pagedown`, `home`, `end`, `backspace`, `delete`, `rune`). For
`key:"rune"`, `text` carries the typed character.
```json
{"type":"panel_key","panel_id":"todos-main","key":"down"}
{"type":"panel_key","panel_id":"todos-main","key":"rune","text":"x"}
```
#### `panel_close`
Sent when the user closes the focused panel from zot (for example with
Esc or Ctrl+C). The extension should treat this as the panel lifetime
ending and stop sending `panel_render` updates for that `panel_id`.
```json
{"type":"panel_close","panel_id":"todos-main"}
```
#### `shutdown`
Sent during graceful zot exit (or `/reload-ext` once that lands).

View file

@ -55,6 +55,21 @@ func (h *interactiveExtHooks) Display(extName, text string) {
iv.Display(extName, text)
}
}
func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
if iv := h.iv(); iv != nil {
iv.OpenPanel(extName, spec)
}
}
func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
if iv := h.iv(); iv != nil {
iv.UpdatePanel(extName, panelID, title, lines, footer)
}
}
func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
if iv := h.iv(); iv != nil {
iv.ClosePanel(extName, panelID)
}
}
// extToolAdapter bridges *extensions.Manager to the
// ExtensionToolSource interface declared in build.go (kept narrow to
@ -192,9 +207,12 @@ type nonInteractiveExtHooks struct{}
func (nonInteractiveExtHooks) Notify(ext, level, message string) {
fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
}
func (nonInteractiveExtHooks) Submit(string) {}
func (nonInteractiveExtHooks) Insert(string) {}
func (nonInteractiveExtHooks) Display(string, string) {}
func (nonInteractiveExtHooks) Submit(string) {}
func (nonInteractiveExtHooks) Insert(string) {}
func (nonInteractiveExtHooks) Display(string, string) {}
func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
func (nonInteractiveExtHooks) ClosePanel(string, string) {}
// setupNonInteractiveExtensions loads --ext paths and (unless
// --no-ext) runs discovery. Returns the manager so the caller can

View file

@ -109,6 +109,10 @@ type HostHooks interface {
// Display appends a one-shot styled note to the chat without
// invoking the model and without writing to the transcript.
Display(extName, text string)
OpenPanel(extName string, spec extproto.PanelSpec)
UpdatePanel(extName, panelID, title string, lines []string, footer string)
ClosePanel(extName, panelID string)
}
// Manager owns every extension subprocess for the lifetime of zot.
@ -549,6 +553,8 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
Provider: m.provider,
Model: m.model,
CWD: m.cwd,
ExtensionDir: ext.Dir,
DataDir: ext.Dir,
})
if _, err := stdin.Write(ack); err != nil {
return fmt.Errorf("send hello_ack: %w", err)
@ -739,6 +745,16 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
}
}
}
case "panel_render":
var pr extproto.PanelRenderFromExt
if err := json.Unmarshal(line, &pr); err == nil {
m.hooks.UpdatePanel(ext.Manifest.Name, pr.PanelID, pr.Title, pr.Lines, pr.Footer)
}
case "panel_close":
var pc extproto.PanelCloseFromExt
if err := json.Unmarshal(line, &pc); err == nil {
m.hooks.ClosePanel(ext.Manifest.Name, pc.PanelID)
}
case "shutdown_ack":
// Caller of Stop is waiting on the process exit, not this frame.
default:
@ -864,6 +880,15 @@ func (m *Manager) HasCommand(name string) bool {
return ok
}
func (m *Manager) CommandOwner(name string) string {
m.mu.RLock()
defer m.mu.RUnlock()
if ext, ok := m.commandIndex[name]; ok && ext != nil {
return ext.Manifest.Name
}
return ""
}
// Invoke fires the named slash command's handler in the owning
// extension and waits up to timeout for the response. Returns the
// 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
// Stop cleanly terminates every extension. Sends ShutdownFromHost,
// waits up to gracePeriod for each subprocess to exit, then SIGTERMs
// (and SIGKILLs after another second) the holdouts.
func (m *Manager) SendPanelKey(extName, panelID, key, text string) error {
m.mu.RLock()
ext, ok := m.ext[extName]
m.mu.RUnlock()
if !ok {
return fmt.Errorf("no extension %q", extName)
}
frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})
_, err := ext.stdin.Write(frame)
return err
}
func (m *Manager) SendPanelClose(extName, panelID string) error {
m.mu.RLock()
ext, ok := m.ext[extName]
m.mu.RUnlock()
if !ok {
return fmt.Errorf("no extension %q", extName)
}
frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})
_, err := ext.stdin.Write(frame)
return err
}
func (m *Manager) Stop(gracePeriod time.Duration) {
m.mu.RLock()
exts := make([]*Extension, 0, len(m.ext))

View file

@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"os"
"github.com/patriceckhart/zot/internal/extproto"
"path/filepath"
"runtime"
"sync"
@ -30,6 +32,9 @@ func (s *stubHooks) Display(name, text string) {
defer s.mu.Unlock()
s.displays = append(s.displays, name+":"+text)
}
func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
func (s *stubHooks) ClosePanel(string, string) {}
// writeMockExtension creates a minimal extension on disk that uses a
// shell script (or batch file on windows) to drive the protocol. The

View file

@ -0,0 +1,69 @@
package modes
import (
"strings"
"github.com/patriceckhart/zot/internal/tui"
)
type extPanelDialog struct {
active bool
ext string
id string
title string
lines []string
footer string
}
func newExtPanelDialog() *extPanelDialog { return &extPanelDialog{} }
func (d *extPanelDialog) Active() bool { return d != nil && d.active }
func (d *extPanelDialog) Open(ext, id, title string, lines []string, footer string) {
d.active = true
d.ext = ext
d.id = id
d.title = title
d.lines = append([]string(nil), lines...)
d.footer = footer
}
func (d *extPanelDialog) Update(title string, lines []string, footer string) {
if !d.active {
return
}
if title != "" {
d.title = title
}
d.lines = append(d.lines[:0], lines...)
d.footer = footer
}
func (d *extPanelDialog) Close() {
d.active = false
d.ext = ""
d.id = ""
d.title = ""
d.lines = nil
d.footer = ""
}
func (d *extPanelDialog) Render(th tui.Theme, width int) []string {
if !d.Active() {
return nil
}
title := d.title
if title == "" {
title = d.ext
}
out := []string{frameHeader(th, title, width)}
for _, l := range d.lines {
out = append(out, l)
}
if strings.TrimSpace(d.footer) != "" {
out = append(out, "")
out = append(out, th.FG256(th.Muted, d.footer))
}
out = append(out, frameRule(th, width))
return out
}

View file

@ -14,6 +14,7 @@ import (
"github.com/patriceckhart/zot/internal/agent/tools"
"github.com/patriceckhart/zot/internal/auth"
"github.com/patriceckhart/zot/internal/core"
"github.com/patriceckhart/zot/internal/extproto"
"github.com/patriceckhart/zot/internal/provider"
"github.com/patriceckhart/zot/internal/skills"
"github.com/patriceckhart/zot/internal/tui"
@ -217,6 +218,7 @@ type Interactive struct {
telegramBridge *telegram.Bridge
sessionOpsDialog *sessionOpsDialog
sessionTreeDialog *sessionTreeDialog
extPanel *extPanelDialog
// pendingFork is true when the user ran /session fork: the next
// jump-picker selection should branch off that message instead
@ -278,6 +280,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
telegramDialog: newTelegramDialog(),
sessionOpsDialog: newSessionOpsDialog(),
sessionTreeDialog: newSessionTreeDialog(),
extPanel: newExtPanelDialog(),
suggest: newSlashSuggester(),
spin: newSpinner(),
}
@ -698,6 +701,8 @@ func (i *Interactive) redraw() {
dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
case i.sessionTreeDialog.Active():
dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
case i.extPanel.Active():
dialog = i.extPanel.Render(i.cfg.Theme, cols)
}
// Slash-command autocomplete: popup above the status line, only
@ -861,6 +866,10 @@ func (i *Interactive) redraw() {
cursorCol = c
}
}
if i.extPanel.Active() {
cursorRow = -1
cursorCol = 0
}
i.rend.Draw(frame, cursorRow, cursorCol)
}
@ -957,6 +966,48 @@ func clipBottomClippedImages(lines []string) []string {
// truncateLine shortens s so it fits within n display cells, with an
// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
// novel doesn't blow past the status line.
func panelKeyName(k tui.Key) string {
switch k.Kind {
case tui.KeyUp:
return "up"
case tui.KeyDown:
return "down"
case tui.KeyLeft:
return "left"
case tui.KeyRight:
return "right"
case tui.KeyEnter:
return "enter"
case tui.KeyEsc:
return "esc"
case tui.KeyTab:
return "tab"
case tui.KeyBackspace:
return "backspace"
case tui.KeyDelete:
return "delete"
case tui.KeyHome:
return "home"
case tui.KeyEnd:
return "end"
case tui.KeyPageUp:
return "pageup"
case tui.KeyPageDown:
return "pagedown"
case tui.KeyRune:
return "rune"
default:
return "unknown"
}
}
func panelKeyText(k tui.Key) string {
if k.Kind == tui.KeyRune {
return string(k.Rune)
}
return ""
}
func truncateLine(s string, n int) string {
if n <= 0 {
return ""
@ -1112,6 +1163,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
i.invalidate()
return false
}
if i.extPanel.Active() {
if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
if i.cfg.Extensions != nil {
_ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
}
i.extPanel.Close()
i.invalidate()
return false
}
if i.cfg.Extensions != nil {
_ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
}
return false
}
if i.jumpDialog.Active() {
if k.Kind == tui.KeyCtrlC {
i.jumpDialog.Close()
@ -1428,6 +1493,16 @@ func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args str
return
}
switch resp.Action {
case "open_panel":
if resp.OpenPanel != nil {
extName := name
if i.cfg.Extensions != nil {
if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
extName = owner
}
}
i.OpenPanel(extName, *resp.OpenPanel)
}
case "prompt":
if strings.TrimSpace(resp.Prompt) == "" {
return
@ -1539,6 +1614,36 @@ func (i *Interactive) Display(extName, text string) {
i.invalidate()
}
func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
i.mu.Lock()
defer i.mu.Unlock()
i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
if i.cfg.Extensions != nil {
cols, rows := i.cfg.Terminal.Size()
_ = cols
_ = rows
}
i.invalidate()
}
func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
i.mu.Lock()
defer i.mu.Unlock()
if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
i.extPanel.Update(title, lines, footer)
i.invalidate()
}
}
func (i *Interactive) ClosePanel(extName, panelID string) {
i.mu.Lock()
defer i.mu.Unlock()
if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
i.extPanel.Close()
i.invalidate()
}
}
func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
parts := strings.Fields(cmd)
switch parts[0] {

View file

@ -142,8 +142,11 @@ func (h *rpcExtHooks) Display(extName, text string) {
})
}
}
func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
func (h *rpcExtHooks) ClosePanel(string, string) {}
type rpcServer struct {
ctx context.Context

View file

@ -19,94 +19,45 @@ package extproto
import "encoding/json"
// ProtocolVersion is the major version of this wire format. Bumped
// for breaking changes; minor additions don't bump.
const ProtocolVersion = 1
// Frame is the lowest-common-denominator parse target so a reader can
// peek at the type before unmarshalling the full payload.
type Frame struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
// ---- extension -> host ----
// HelloFromExt is the first frame the extension sends after start.
// Zot replies with HelloAckFromHost, then registration frames
// (RegisterCommandFromExt, etc.) flow.
type HelloFromExt struct {
Type string `json:"type"` // "hello"
Type string `json:"type"`
Name string `json:"name"`
Version string `json:"version"`
Capabilities []string `json:"capabilities,omitempty"`
}
// RegisterCommandFromExt asks zot to bind /name to this extension.
// Description appears in the slash autocomplete + /help.
type RegisterCommandFromExt struct {
Type string `json:"type"` // "register_command"
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// RegisterToolFromExt asks zot to expose a tool to the LLM. The
// schema is a JSON Schema object describing Args; zot doesn't validate
// the model's arguments against it (the model providers do that), but
// it must parse as valid JSON or registration is rejected.
//
// Tool names live in the same namespace as built-in tools (read,
// write, edit, bash, skill). Conflicts are silently shadowed by the
// built-in; check the extension's log for a warning.
type RegisterToolFromExt struct {
Type string `json:"type"` // "register_tool"
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Schema json.RawMessage `json:"schema"`
}
// ReadyFromExt signals "all initial registrations sent". The host
// waits for this (with a short timeout) before building the agent's
// tool registry, so model calls don't race extension tool
// registration.
type ReadyFromExt struct {
Type string `json:"type"` // "ready"
Type string `json:"type"`
}
// SubscribeFromExt declares which lifecycle events the extension
// wants to observe (one-way `event` frames) and which it wants to
// intercept (round-trip `event_intercept` frames). Send once after
// hello, before ready.
//
// Recognised event names: "session_start", "turn_start",
// "turn_end", "tool_call", "assistant_message".
//
// Only "tool_call" supports interception in this version; values
// listed in Intercept that aren't "tool_call" are ignored.
type SubscribeFromExt struct {
Type string `json:"type"` // "subscribe"
Type string `json:"type"`
Events []string `json:"events,omitempty"`
Intercept []string `json:"intercept,omitempty"`
}
// EventInterceptResponseFromExt is the extension's reply to an
// EventInterceptFromHost. block=true refuses the underlying action;
// reason is shown to the model (or the user) as the refusal text.
// All fields default to "allow, pass through unmodified".
//
// Optional rewrite fields, their meaning depends on the event:
//
// - ModifiedArgs: for event="tool_call", replaces the args the
// tool will see. Must be a JSON object literal or the rewrite is
// dropped and a warning logged.
// - ReplaceText: for event="assistant_message", replaces the user-
// visible text. The model's original text stays in the transcript
// (so the model can reference what it "said"); only the rendered
// output to the user is swapped.
//
// When block=true, rewrite fields are ignored.
type EventInterceptResponseFromExt struct {
Type string `json:"type"` // "event_intercept_response"
Type string `json:"type"`
ID string `json:"id"`
Block bool `json:"block,omitempty"`
Reason string `json:"reason,omitempty"`
@ -114,173 +65,132 @@ type EventInterceptResponseFromExt struct {
ReplaceText string `json:"replace_text,omitempty"`
}
// ToolResultFromExt is the extension's reply to a ToolCallFromHost.
// Content[] follows the same shape as elsewhere in zot:
//
// {"type":"text", "text":"..."}
// {"type":"image", "mime_type":"image/png", "data":"<base64>"}
//
// Set IsError true to mark the tool call as failed; the model sees
// the content as the error explanation.
type ToolResultFromExt struct {
Type string `json:"type"` // "tool_result"
Type string `json:"type"`
ID string `json:"id"`
Content []ContentBlock `json:"content"`
IsError bool `json:"is_error,omitempty"`
}
// ContentBlock is one entry in a tool result's content array.
type ContentBlock struct {
Type string `json:"type"` // "text" | "image"
Type string `json:"type"`
Text string `json:"text,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Data string `json:"data,omitempty"` // base64
Data string `json:"data,omitempty"`
}
// CommandResponseFromExt is the extension's answer to a
// CommandInvokedFromHost. Action drives what zot does next:
//
// - "prompt" → submit Prompt as a fresh user message to the agent
// - "insert" → insert Insert into the editor buffer at the cursor
// - "display" → append Display to the chat as a one-shot note
// (no model call, no transcript entry)
// - "noop" → command handled internally, no UI change
type CommandResponseFromExt struct {
Type string `json:"type"` // "command_response"
ID string `json:"id"`
Action string `json:"action"` // see above
Prompt string `json:"prompt,omitempty"` // for action=prompt
Insert string `json:"insert,omitempty"` // for action=insert
Display string `json:"display,omitempty"` // for action=display
Error string `json:"error,omitempty"` // command failed; render to user
Type string `json:"type"`
ID string `json:"id"`
Action string `json:"action"`
Prompt string `json:"prompt,omitempty"`
Insert string `json:"insert,omitempty"`
Display string `json:"display,omitempty"`
OpenPanel *PanelSpec `json:"open_panel,omitempty"`
Error string `json:"error,omitempty"`
}
type PanelSpec struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Lines []string `json:"lines,omitempty"`
Footer string `json:"footer,omitempty"`
}
type PanelRenderFromExt struct {
Type string `json:"type"`
PanelID string `json:"panel_id"`
Title string `json:"title,omitempty"`
Lines []string `json:"lines,omitempty"`
Footer string `json:"footer,omitempty"`
}
type PanelCloseFromExt struct {
Type string `json:"type"`
PanelID string `json:"panel_id"`
}
// NotifyFromExt is a one-way status message the extension can push at
// any time. Zot renders it in the chat as a styled note.
type NotifyFromExt struct {
Type string `json:"type"` // "notify"
Level string `json:"level"` // "info" | "warn" | "error" | "success"
Type string `json:"type"`
Level string `json:"level"`
Message string `json:"message"`
}
// ShutdownAckFromExt acknowledges the host's shutdown request. The
// extension should exit shortly after sending this; zot waits a few
// seconds before SIGTERM.
type ShutdownAckFromExt struct {
Type string `json:"type"` // "shutdown_ack"
Type string `json:"type"`
}
// ---- host -> extension ----
// HelloAckFromHost is zot's reply to HelloFromExt. The extension may
// inspect the host version + currently-active provider/model to decide
// whether to register particular commands.
type HelloAckFromHost struct {
Type string `json:"type"` // "hello_ack"
Type string `json:"type"`
ProtocolVersion int `json:"protocol_version"`
ZotVersion string `json:"zot_version"`
Provider string `json:"provider"`
Model string `json:"model"`
CWD string `json:"cwd"`
ExtensionDir string `json:"extension_dir,omitempty"`
DataDir string `json:"data_dir,omitempty"`
}
// CommandInvokedFromHost is sent when the user runs a slash command
// the extension previously registered. Args contains everything after
// the command name (already trimmed).
type CommandInvokedFromHost struct {
Type string `json:"type"` // "command_invoked"
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Args string `json:"args,omitempty"`
}
// ToolCallFromHost is sent when the LLM invokes a tool the extension
// registered. Args is the raw JSON object the model produced; the
// extension is responsible for validating/coercing it. Reply with
// ToolResultFromExt within the host's tool timeout (default 60s).
type ToolCallFromHost struct {
Type string `json:"type"` // "tool_call"
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Args json.RawMessage `json:"args"`
}
// EventFromHost is a one-way lifecycle notification. The payload
// fields populated depend on Event:
//
// session_start : (no extra fields)
// turn_start : Step
// turn_end : Stop, optional Error
// tool_call : ToolID, ToolName, ToolArgs
// assistant_message: Text
type EventFromHost struct {
Type string `json:"type"` // "event"
Event string `json:"event"`
Step int `json:"step,omitempty"`
Stop string `json:"stop,omitempty"`
Error string `json:"error,omitempty"`
Type string `json:"type"`
Event string `json:"event"`
Step int `json:"step,omitempty"`
Stop string `json:"stop,omitempty"`
Error string `json:"error,omitempty"`
ToolID string `json:"tool_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs json.RawMessage `json:"tool_args,omitempty"`
Text string `json:"text,omitempty"`
Text string `json:"text,omitempty"`
}
// EventInterceptFromHost is sent when zot wants to give the
// extension a chance to block, modify, or annotate a lifecycle
// event before it happens. Reply with EventInterceptResponseFromExt
// within the host's intercept timeout (default 5s); missing the
// deadline is treated as "allow".
//
// Supported events and their effect on block=true:
//
// - tool_call: cancel the tool; model sees reason as error.
// Can also modify args via ModifiedArgs.
// - turn_start: cancel the turn before the model call.
// Reason is shown as a chat status line.
// - assistant_message: suppress the message. Can also rewrite
// the user-visible text via ReplaceText.
type EventInterceptFromHost struct {
Type string `json:"type"` // "event_intercept"
ID string `json:"id"`
Event string `json:"event"`
// tool_call payload
Type string `json:"type"`
ID string `json:"id"`
Event string `json:"event"`
ToolID string `json:"tool_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs json.RawMessage `json:"tool_args,omitempty"`
// turn_start payload
Step int `json:"step,omitempty"`
// assistant_message payload
Text string `json:"text,omitempty"`
Step int `json:"step,omitempty"`
Text string `json:"text,omitempty"`
}
type PanelKeyFromHost struct {
Type string `json:"type"`
PanelID string `json:"panel_id"`
Key string `json:"key"`
Text string `json:"text,omitempty"`
}
type PanelResizeFromHost struct {
Type string `json:"type"`
PanelID string `json:"panel_id"`
Width int `json:"width"`
Height int `json:"height"`
}
type PanelCloseFromHost struct {
Type string `json:"type"`
PanelID string `json:"panel_id"`
}
// ShutdownFromHost asks the extension to clean up and exit. Zot
// sends this when the user runs /reload-ext or zot itself is exiting
// gracefully. Extensions that don't reply within a few seconds get
// SIGTERM; SIGKILL after a few more.
type ShutdownFromHost struct {
Type string `json:"type"` // "shutdown"
Type string `json:"type"`
}
// ---- error frame (either direction) ----
// Error is a generic failure response. Used by either side when a
// frame can't be processed (malformed JSON, unknown type, etc.).
type Error struct {
Type string `json:"type"` // "error"
ID string `json:"id,omitempty"`
Message string `json:"message"`
}
// ---- helpers ----
// Encode marshals v and appends a trailing LF, ready to write to the
// peer's pipe. Returns the marshalling error, if any.
func Encode(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {

View file

@ -411,7 +411,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
switch b := c.(type) {
case provider.TextBlock:
for _, l := range strings.Split(b.Text, "\n") {
for _, w := range wrapLine(l, width-2, " ") {
for _, w := range wrapLine(l, width-4, "") {
lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
}
}
@ -435,9 +435,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
case provider.TextBlock:
md := RenderMarkdown(b.Text, v.Theme, inner)
for _, l := range strings.Split(md, "\n") {
for _, w := range wrapLine(l, inner, "") {
lines = append(lines, indent+w)
}
lines = append(lines, indent+l)
}
case provider.ToolCallBlock:
// Rule above the tool header frames the call as a

View file

@ -185,12 +185,20 @@ func TextErrorResult(s string) ToolResult {
// Response tells zot how to react to a command invocation. Construct
// one with Prompt(), Insert(), Display(), or Noop().
type Panel struct {
ID string
Title string
Lines []string
Footer string
}
type Response struct {
Action string // "prompt", "insert", "display", "noop"
Prompt string
Insert string
Display string
Error string
Action string // "prompt", "insert", "display", "open_panel", "noop"
Prompt string
Insert string
Display string
OpenPanel *Panel
Error string
}
// 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
// burning tokens.
func Display(text string) Response { return Response{Action: "display", Display: text} }
// OpenPanel returns a Response that opens an interactive extension-owned
// panel inside zot.
func OpenPanel(id, title string, lines []string, footer string) Response {
return Response{Action: "open_panel", OpenPanel: &Panel{ID: id, Title: title, Lines: lines, Footer: footer}}
}
// Noop returns a Response that signals "I handled it, no UI change".
// Use after pushing your own state or notifications.
func Noop() Response { return Response{Action: "noop"} }
@ -241,6 +255,8 @@ type Extension struct {
interceptOn bool
interceptTurn TurnStartHandler
interceptAssistant AssistantMessageHandler
panelKeys map[string]func(key, text string)
panelCloses map[string]func()
// Caps reported in the hello frame.
caps []string
@ -267,6 +283,8 @@ type HostInfo struct {
Provider string
Model string
CWD string
ExtensionDir string
DataDir string
}
// New constructs an Extension with the given identifier. name should
@ -281,7 +299,9 @@ func New(name, version string) *Extension {
commands: map[string]CommandHandler{},
tools: map[string]ToolHandler{},
eventHandlers: map[string]EventHandler{},
caps: []string{"commands", "tools", "events"},
panelKeys: map[string]func(key, text string){},
panelCloses: map[string]func(){},
caps: []string{"commands", "tools", "events", "panels"},
}
}
@ -296,6 +316,28 @@ func (e *Extension) Logf(format string, args ...any) {
fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...)
}
// OnPanelKey registers callbacks for panel key + close events.
func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onClose func()) {
e.mu.Lock()
defer e.mu.Unlock()
if onKey != nil {
e.panelKeys[panelID] = onKey
}
if onClose != nil {
e.panelCloses[panelID] = onClose
}
}
// RenderPanel pushes a fresh frame for panelID.
func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) {
_ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer})
}
// ClosePanel tells zot to close panelID.
func (e *Extension) ClosePanel(panelID string) {
_ = e.send(extproto.PanelCloseFromExt{Type: "panel_close", PanelID: panelID})
}
// Command registers a slash-command handler. Call this BEFORE Run().
// Once Run is going, when the user runs /name in zot, fn is invoked
// with the remaining args.
@ -474,6 +516,8 @@ func (e *Extension) Run() error {
Provider: ack.Provider,
Model: ack.Model,
CWD: ack.CWD,
ExtensionDir: ack.ExtensionDir,
DataDir: ack.DataDir,
}
}
case "command_invoked":
@ -548,6 +592,28 @@ func (e *Extension) Run() error {
continue
}
go e.dispatchIntercept(ei)
case "panel_key":
var pk extproto.PanelKeyFromHost
if err := json.Unmarshal(line, &pk); err != nil {
continue
}
e.mu.Lock()
h := e.panelKeys[pk.PanelID]
e.mu.Unlock()
if h != nil {
go h(pk.Key, pk.Text)
}
case "panel_close":
var pc extproto.PanelCloseFromHost
if err := json.Unmarshal(line, &pc); err != nil {
continue
}
e.mu.Lock()
h := e.panelCloses[pc.PanelID]
e.mu.Unlock()
if h != nil {
go h()
}
case "shutdown":
_ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
return nil
@ -563,14 +629,19 @@ func (e *Extension) respond(id string, r Response) {
if r.Action == "" {
r.Action = "noop"
}
var panel *extproto.PanelSpec
if r.OpenPanel != nil {
panel = &extproto.PanelSpec{ID: r.OpenPanel.ID, Title: r.OpenPanel.Title, Lines: r.OpenPanel.Lines, Footer: r.OpenPanel.Footer}
}
_ = e.send(extproto.CommandResponseFromExt{
Type: "command_response",
ID: id,
Action: r.Action,
Prompt: r.Prompt,
Insert: r.Insert,
Display: r.Display,
Error: r.Error,
Type: "command_response",
ID: id,
Action: r.Action,
Prompt: r.Prompt,
Insert: r.Insert,
Display: r.Display,
OpenPanel: panel,
Error: r.Error,
})
}