mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
53ae47a1bd
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.
260 lines
7.1 KiB
Go
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
|
|
}
|