mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-28 06:13:42 +02:00
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:
parent
8d3b7ff155
commit
fdcdeb5eb1
10 changed files with 491 additions and 194 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
internal/agent/modes/ext_panel_dialog.go
Normal file
69
internal/agent/modes/ext_panel_dialog.go
Normal 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
|
||||
}
|
||||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue