Create Plugin
Architecture Overview
- •Bubble Tea model:
internal/app/model.goowns the active plugin index, dispatches key events, renders plugin views. - •Registry:
internal/plugin/registry.gostores plugins, handles lifecycle with panic protection, keeps anunavailablemap whenInitfails (silent degradation). - •Plugin contract:
internal/plugin/plugin.godefines the interface every plugin must satisfy. - •Context:
internal/plugin/context.goprovidesWorkDir,ConfigDir,Adapters,EventBus,Logger,Epoch, andKeymap. - •Keymap:
internal/keymapmaps keys to command IDs. Footer/help reads bindings by context usingPlugin.Commands()+Plugin.FocusContext().
Plugin Interface
Every plugin must implement all of these methods:
ID() string // Stable kebab-case identifier Name() string // Short human label for headers/help Icon() string // Single-character glyph for tab strip Init(ctx *Context) error // Lightweight setup; return error to degrade gracefully Start() tea.Cmd // Kick off async work (non-blocking) Update(msg tea.Msg) (Plugin, tea.Cmd) // Pure state transition View(width, height int) string // Render within provided dimensions IsFocused() bool // Check focus state SetFocused(bool) // App calls this on tab switch Commands() []plugin.Command // Footer hints per context FocusContext() string // Current context name for keymap Stop() // Idempotent cleanup
Optional: implement Diagnostics() []plugin.Diagnostic for the diagnostics overlay.
Lifecycle Order
- •Registration (
cmd/sidecar/main.go):registry.Register(myplugin.New()). No work here. - •Init: Detect prerequisites (repos, adapters, env vars). Use
ctx.Loggerfor warnings. Return error to degrade gracefully. - •Start: Batch initial commands with
tea.Batch. Never block. - •Update: Pattern-match on custom
Msgtypes andtea.KeyMsg. Keep I/O in commands, not directly in Update. - •View: Render only; no side-effects. Honor
width/height. - •Focus/Blur:
SetFocusedcalled on tab switch. Pause expensive work when unfocused. - •Stop: Close watchers, timers, channels. Guard with
sync.Once/flags.
Epoch Pattern (Stale Message Detection)
When switching projects/worktrees, async operations may deliver stale data. Use the epoch pattern:
Step 1: Add Epoch to message type
type MyDataLoadedMsg struct {
Epoch uint64
Data string
Err error
}
func (m MyDataLoadedMsg) GetEpoch() uint64 { return m.Epoch }
Step 2: Capture epoch in command creators
func (p *Plugin) loadData() tea.Cmd {
epoch := p.ctx.Epoch // Capture synchronously before closure
return func() tea.Msg {
data, err := fetchData()
return MyDataLoadedMsg{Epoch: epoch, Data: data, Err: err}
}
}
Step 3: Check staleness in Update
case MyDataLoadedMsg:
if plugin.IsStale(p.ctx, msg) {
return p, nil // Discard stale message
}
p.data = msg.Data
Apply this to any async message that fetches data from filesystem/external sources or updates project-specific state.
Keymap, Contexts, and Commands
- •Define contexts mirroring your view modes (e.g.,
git-status,git-diff). Return the active one fromFocusContext(). - •Expose commands with matching contexts via
Commands(). These power footer hints and help overlay. - •Add default bindings in
internal/keymap/bindings.go. - •Keep command IDs stable (verbs preferred:
open-file,toggle-diff-mode).
Command structure
plugin.Command{
ID: "stage-file",
Name: "Stage", // Keep 1-2 words max
Category: plugin.CategoryGit,
Priority: 10, // Lower = higher priority; 0 treated as 99
Context: "git-status",
}
Categories: CategoryNavigation, CategoryActions, CategoryView, CategorySearch, CategoryEdit, CategoryGit, CategorySystem
Context naming convention
- •
plugin-namefor main view - •
plugin-name-detailfor detail/preview - •
plugin-name-modalfor modals - •
plugin-name-searchfor search modes
Dynamic binding registration
func (p *Plugin) Init(ctx *plugin.Context) error {
if ctx.Keymap != nil {
ctx.Keymap.RegisterPluginBinding("g g", "go-to-top", "my-context")
}
return nil
}
Event Bus (Cross-Plugin Communication)
- •Subscribe:
ch := ctx.EventBus.Subscribe("topic")inStart(), forward messages intoUpdate. - •Publish:
ctx.EventBus.Publish("topic", event.NewEvent(event.TypeRefreshNeeded, "topic", payload)). - •Best-effort, buffered (size 16), drops when full. Design listeners to be resilient.
Inter-Plugin Messages
App-level messages (internal/app/commands.go):
- •
FocusPluginByIDMsg{PluginID}/app.FocusPlugin(id)
File browser messages (internal/plugins/filebrowser/plugin.go):
- •
NavigateToFileMsg{Path}- navigate to and preview a file
Pattern for cross-plugin navigation:
func (p *Plugin) openInFileBrowser(path string) tea.Cmd {
return tea.Batch(
app.FocusPlugin("file-browser"),
func() tea.Msg { return filebrowser.NavigateToFileMsg{Path: path} },
)
}
Plugin Focus Events
PluginFocusedMsg (from internal/app): sent when your plugin becomes active tab. Use to refresh data only needed when visible:
case app.PluginFocusedMsg:
if p.pendingRefresh {
p.pendingRefresh = false
return p, p.refresh()
}
External Editor Integration
func (p *Plugin) openFile(path string, lineNo int) tea.Cmd {
editor := p.ctx.Config.EditorCommand
return func() tea.Msg {
return plugin.OpenFileMsg{Editor: editor, Path: path, LineNo: lineNo}
}
}
Rendering Rules
CRITICAL: Always constrain plugin output height. The app header/footer are always visible. Plugins must not exceed allocated height.
lipgloss.NewStyle().Width(width).Height(height).MaxHeight(height).Render(content)
Do NOT render footers in plugin View(). The app renders footer using Commands() and keymap bindings.
Additional rendering rules:
- •Keep
Viewdeterministic; drive dynamic data through state inUpdate. - •Cache
width/heightin plugin state. - •Expand
\tto spaces before width checks. - •Use ANSI-aware helpers (
ansi.Truncate,lipgloss.Width) for content with escape codes. - •Use small helper render functions per view mode.
See references/sidebar-list-guide.md for scrollable list implementation patterns.
See references/fixed-footer-layout-guide.md for footer and layout math details.
Persisting User Preferences
Use internal/state to persist layout preferences across restarts:
- •Add field to
state.Statestruct with getter/setter. - •Load in
Init():if saved := state.GetMyPaneWidth(); saved > 0 { p.paneWidth = saved } - •Save on user action:
_ = state.SetMyPaneWidth(p.paneWidth)
Adapters
- •
ctx.Adaptersholds integrations. Check capability inInitbefore using. - •Watcher data from adapters should feed messages through
Update.
Error Handling
- •Return lightweight errors from
Init; registry records them without crashing. - •Use
ctx.Loggerwith structured fields. - •Surface recoverable issues as status/toast messages, not panics.
New Plugin Checklist
- •Create
internal/plugins/<id>/withplugin.goplus supporting files. - •Implement the
plugin.Plugininterface; considerDiagnosticProvider. - •Register in
cmd/sidecar/main.go. - •Add default key bindings in
internal/keymap/bindings.go. - •Ensure
Commands()covers every binding so hints/help work. - •Wire external needs (adapters, env detection) in
Init; degrade gracefully. - •Provide cleanup in
Stop; keepStart/Updatenon-blocking.
Testing
- •Keep business logic in testable helpers; wire Bubble Tea plumbing around it.
- •Use small typed messages (
type RefreshMsg struct{}) to keepUpdatereadable. - •Enable
--debugfor verbose logs from registry and plugins.