Files
kit/internal/extensions/runner.go
T
Ed Zynda 53ae47a1bd feat: add custom header/footer regions for extensions
Extensions can now place persistent header (above stream) and footer
(below status bar) regions via ctx.SetHeader/SetFooter. Single-instance
per slot, reuses WidgetContent/WidgetStyle types and WidgetUpdateEvent
for notifications. Includes thread-safe Runner storage, SDK methods,
UI rendering with height distribution, and example extension.
2026-02-28 14:11:52 +03:00

260 lines
7.1 KiB
Go

package extensions
import (
"fmt"
"sort"
"sync"
"github.com/charmbracelet/log"
)
// Runner manages loaded extensions and dispatches events to their handlers
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
// load order; for cancellable events the first blocking result wins.
type Runner struct {
extensions []LoadedExtension
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
mu sync.RWMutex
}
// LoadedExtension represents a single extension that has been discovered,
// loaded, and initialised. It holds the registered handlers and any custom
// tools or commands the extension provided.
type LoadedExtension struct {
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
}
// NewRunner creates a Runner from a set of loaded extensions.
func NewRunner(exts []LoadedExtension) *Runner {
return &Runner{extensions: exts}
}
// SetContext updates the runtime context (session ID, model, etc.) that is
// passed to every handler invocation. Thread-safe.
func (r *Runner) SetContext(ctx Context) {
r.mu.Lock()
defer r.mu.Unlock()
r.ctx = ctx
}
// HasHandlers returns true if any loaded extension has at least one handler
// registered for the given event type.
func (r *Runner) HasHandlers(event EventType) bool {
for i := range r.extensions {
if len(r.extensions[i].Handlers[event]) > 0 {
return true
}
}
return false
}
// Emit dispatches an event to all matching handlers sequentially. It returns
// the accumulated result from all handlers, or nil if no handler responded.
//
// For blocking events (ToolCall, Input), the first blocking result short-circuits:
// - ToolCallResult{Block: true} stops iteration and returns immediately.
// - InputResult{Action: "handled"} stops iteration and returns immediately.
//
// For chainable events (ToolResult), each handler sees the accumulated result
// from previous handlers. The final merged result is returned.
//
// Panics in handlers are recovered and logged; they do not crash the process.
func (r *Runner) Emit(event Event) (Result, error) {
r.mu.RLock()
ctx := r.ctx
r.mu.RUnlock()
var accumulated Result
for i := range r.extensions {
ext := &r.extensions[i]
handlers := ext.Handlers[event.Type()]
for _, handler := range handlers {
result, err := safeCall(handler, event, ctx)
if err != nil {
log.Warn("extension handler error",
"path", ext.Path,
"event", event.Type(),
"err", err)
continue
}
if result == nil {
continue
}
// Check for blocking/short-circuit results.
if isBlocking(result) {
return result, nil
}
// Chain: keep the latest non-nil result. For ToolResultResult
// the caller is responsible for applying the modifications.
accumulated = result
}
}
return accumulated, nil
}
// RegisteredTools returns all custom tools registered by loaded extensions.
func (r *Runner) RegisteredTools() []ToolDef {
var tools []ToolDef
for i := range r.extensions {
tools = append(tools, r.extensions[i].Tools...)
}
return tools
}
// RegisteredCommands returns all slash commands registered by loaded extensions.
func (r *Runner) RegisteredCommands() []CommandDef {
var cmds []CommandDef
for i := range r.extensions {
cmds = append(cmds, r.extensions[i].Commands...)
}
return cmds
}
// GetContext returns the current runtime context. Thread-safe.
func (r *Runner) GetContext() Context {
r.mu.RLock()
defer r.mu.RUnlock()
return r.ctx
}
// Extensions returns the loaded extensions for inspection (e.g. CLI list).
func (r *Runner) Extensions() []LoadedExtension {
return r.extensions
}
// ---------------------------------------------------------------------------
// Widget management
// ---------------------------------------------------------------------------
// SetWidget places or updates a persistent widget. The widget is identified
// by config.ID; calling SetWidget with the same ID replaces the previous
// content. Thread-safe.
func (r *Runner) SetWidget(config WidgetConfig) {
r.mu.Lock()
defer r.mu.Unlock()
if r.widgets == nil {
r.widgets = make(map[string]WidgetConfig)
}
r.widgets[config.ID] = config
}
// RemoveWidget removes a widget by ID. No-op if the ID does not exist.
// Thread-safe.
func (r *Runner) RemoveWidget(id string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.widgets, id)
}
// GetWidgets returns all widgets matching the given placement, sorted by
// priority (ascending). Thread-safe.
func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
r.mu.RLock()
defer r.mu.RUnlock()
var result []WidgetConfig
for _, w := range r.widgets {
if w.Placement == placement {
result = append(result, w)
}
}
sort.Slice(result, func(i, j int) bool {
if result[i].Priority != result[j].Priority {
return result[i].Priority < result[j].Priority
}
return result[i].ID < result[j].ID // stable tie-break
})
return result
}
// ---------------------------------------------------------------------------
// Header/Footer management
// ---------------------------------------------------------------------------
// SetHeader places or replaces the custom header. Thread-safe.
func (r *Runner) SetHeader(config HeaderFooterConfig) {
r.mu.Lock()
defer r.mu.Unlock()
r.header = &config
}
// RemoveHeader removes the custom header. No-op if none is set. Thread-safe.
func (r *Runner) RemoveHeader() {
r.mu.Lock()
defer r.mu.Unlock()
r.header = nil
}
// GetHeader returns the current custom header, or nil if none is set.
// Thread-safe.
func (r *Runner) GetHeader() *HeaderFooterConfig {
r.mu.RLock()
defer r.mu.RUnlock()
if r.header == nil {
return nil
}
// Return a copy to avoid races on the caller side.
h := *r.header
return &h
}
// SetFooter places or replaces the custom footer. Thread-safe.
func (r *Runner) SetFooter(config HeaderFooterConfig) {
r.mu.Lock()
defer r.mu.Unlock()
r.footer = &config
}
// RemoveFooter removes the custom footer. No-op if none is set. Thread-safe.
func (r *Runner) RemoveFooter() {
r.mu.Lock()
defer r.mu.Unlock()
r.footer = nil
}
// GetFooter returns the current custom footer, or nil if none is set.
// Thread-safe.
func (r *Runner) GetFooter() *HeaderFooterConfig {
r.mu.RLock()
defer r.mu.RUnlock()
if r.footer == nil {
return nil
}
// Return a copy to avoid races on the caller side.
f := *r.footer
return &f
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// safeCall invokes a handler, recovering from panics.
func safeCall(handler HandlerFunc, event Event, ctx Context) (result Result, err error) {
defer func() {
if rec := recover(); rec != nil {
err = fmt.Errorf("extension panicked: %v", rec)
}
}()
return handler(event, ctx), nil
}
// isBlocking returns true if the result should short-circuit further handlers.
func isBlocking(result Result) bool {
switch r := result.(type) {
case ToolCallResult:
return r.Block
case InputResult:
return r.Action == "handled"
}
return false
}