Create Plugin
This skill is the embedded class: a Go package compiled into Sidecar with its own Bubble Tea UI. If what you want is a tool's data in Sidecar — a searchable tab, rows that open into documents, panes beside a terminal — you almost certainly want a protocol plugin instead: an executable in any language answering five JSON methods, which Sidecar renders itself and which ships as a config entry rather than a Sidecar release. Read docs/guides/active/creating-plugins.md and stop here. Come back only when the screen itself is the point — a board, a queue, a layout the collection-and-document vocabulary cannot express — because everything below costs a release, a keymap pass, and a permanent place in the repository.
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:
goID() 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
gotype MyDataLoadedMsg struct { Epoch uint64 Data string Err error } func (m MyDataLoadedMsg) GetEpoch() uint64 { return m.Epoch }
Step 2: Capture epoch in command creators
gofunc (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
gocase 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
goplugin.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 viewplugin-name-detailfor detail/previewplugin-name-modalfor modalsplugin-name-searchfor search modes
Dynamic binding registration
gofunc (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:
gofunc (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:
gocase app.PluginFocusedMsg: if p.pendingRefresh { p.pendingRefresh = false return p, p.refresh() }
External Editor Integration
gofunc (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.
golipgloss.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.


