From fdcdeb5eb19ee3edacca7f266d3f423949195f4a Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Wed, 22 Apr 2026 08:53:21 +0200 Subject: [PATCH] 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. --- docs/extensions.md | 81 +++++++- internal/agent/cli.go | 24 ++- internal/agent/extensions/manager.go | 49 +++++ internal/agent/extensions/manager_test.go | 5 + internal/agent/modes/ext_panel_dialog.go | 69 ++++++ internal/agent/modes/interactive.go | 105 ++++++++++ internal/agent/rpc.go | 7 +- internal/extproto/extproto.go | 242 +++++++--------------- internal/tui/view.go | 6 +- pkg/zotext/zotext.go | 97 +++++++-- 10 files changed, 491 insertions(+), 194 deletions(-) create mode 100644 internal/agent/modes/ext_panel_dialog.go diff --git a/docs/extensions.md b/docs/extensions.md index 983ebc3..22303b2 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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-.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). diff --git a/internal/agent/cli.go b/internal/agent/cli.go index 974854e..7252214 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -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 diff --git a/internal/agent/extensions/manager.go b/internal/agent/extensions/manager.go index d4c5632..8034cad 100644 --- a/internal/agent/extensions/manager.go +++ b/internal/agent/extensions/manager.go @@ -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)) diff --git a/internal/agent/extensions/manager_test.go b/internal/agent/extensions/manager_test.go index 014c7bd..9592c28 100644 --- a/internal/agent/extensions/manager_test.go +++ b/internal/agent/extensions/manager_test.go @@ -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 diff --git a/internal/agent/modes/ext_panel_dialog.go b/internal/agent/modes/ext_panel_dialog.go new file mode 100644 index 0000000..e35fdb6 --- /dev/null +++ b/internal/agent/modes/ext_panel_dialog.go @@ -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 +} diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index b960635..0d8a751 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -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] { diff --git a/internal/agent/rpc.go b/internal/agent/rpc.go index c379e76..2cfbd06 100644 --- a/internal/agent/rpc.go +++ b/internal/agent/rpc.go @@ -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 diff --git a/internal/extproto/extproto.go b/internal/extproto/extproto.go index 8fd2aa6..3687ae9 100644 --- a/internal/extproto/extproto.go +++ b/internal/extproto/extproto.go @@ -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":""} -// -// 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 { diff --git a/internal/tui/view.go b/internal/tui/view.go index 9efc65a..886ddf5 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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 diff --git a/pkg/zotext/zotext.go b/pkg/zotext/zotext.go index 47a4cc0..532ef4e 100644 --- a/pkg/zotext/zotext.go +++ b/pkg/zotext/zotext.go @@ -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, }) }