Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Create new sidecar plugins implementing the plugin.Plugin interface, rendering views with Bubble Tea, handling keyboard input via keymap contexts, and integrating with the app shell (footer hints, event bus, adapters). Use when creating a new plugin, modifying plugin architecture, or debugging plugin rendering/lifecycle issues. See references/ for sidebar list and fixed footer layout details.
.claude/skills/marcus-create-plugin/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 22% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 62% | 0% |
| case-08 | ✗→✓ | ▲ Improved | -3% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 84% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 91% | 0% |
internal/app/model.go owns the active plugin index, dispatches key events, renders plugin views.internal/plugin/registry.go stores plugins, handles lifecycle with panic protection, keeps an unavailable map when Init fails (silent degradation).internal/plugin/plugin.go defines the interface every plugin must satisfy.internal/plugin/context.go provides WorkDir, ConfigDir, Adapters, EventBus, Logger, Epoch, and Keymap.internal/keymap maps keys to command IDs. Footer/help reads bindings by context using Plugin.Commands() + Plugin.FocusContext().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.
cmd/sidecar/main.go): registry.Register(myplugin.New()). No work here.ctx.Logger for warnings. Return error to degrade gracefully.tea.Batch. Never block.Msg types and tea.KeyMsg. Keep I/O in commands, not directly in Update.width/height.SetFocused called on tab switch. Pause expensive work when unfocused.sync.Once/flags.When switching projects/worktrees, async operations may deliver stale data. Use the epoch pattern:
gotype MyDataLoadedMsg struct { Epoch uint64 Data string Err error } func (m MyDataLoadedMsg) GetEpoch() uint64 { return m.Epoch }
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} } }
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.
git-status, git-diff). Return the active one from FocusContext().Commands(). These power footer hints and help overlay.internal/keymap/bindings.go.open-file, toggle-diff-mode).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
plugin-name for main viewplugin-name-detail for detail/previewplugin-name-modal for modalsplugin-name-search for search modesgofunc (p *Plugin) Init(ctx *plugin.Context) error { if ctx.Keymap != nil { ctx.Keymap.RegisterPluginBinding("g g", "go-to-top", "my-context") } return nil }
ch := ctx.EventBus.Subscribe("topic") in Start(), forward messages into Update.ctx.EventBus.Publish("topic", event.NewEvent(event.TypeRefreshNeeded, "topic", payload)).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 filePattern 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} }, ) }
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() }
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} } }
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:
View deterministic; drive dynamic data through state in Update.width/height in plugin state.\t to spaces before width checks.ansi.Truncate, lipgloss.Width) for content with escape codes.See references/sidebar-list-guide.md for scrollable list implementation patterns. See references/fixed-footer-layout-guide.md for footer and layout math details.
Use internal/state to persist layout preferences across restarts:
state.State struct with getter/setter.Init(): if saved := state.GetMyPaneWidth(); saved > 0 { p.paneWidth = saved }_ = state.SetMyPaneWidth(p.paneWidth)ctx.Adapters holds integrations. Check capability in Init before using.Update.Init; registry records them without crashing.ctx.Logger with structured fields.internal/plugins/<id>/ with plugin.go plus supporting files.plugin.Plugin interface; consider DiagnosticProvider.cmd/sidecar/main.go.internal/keymap/bindings.go.Commands() covers every binding so hints/help work.Init; degrade gracefully.Stop; keep Start/Update non-blocking.type RefreshMsg struct{}) to keep Update readable.--debug for verbose logs from registry and plugins.Other measured skills in the registry, with their headline benchmark lift.