Skip to content
Logo

Plugin Development

This guide covers how to create plugins for Boo — both external JavaScript plugins and built-in Swift plugins.

External Plugins

External plugins live in ~/.boo/plugins/ and are hot-loaded at runtime. No restart required — save a file and it appears immediately.

A plugin can contribute a sidebar tab, a status bar segment, or both. A plugin with only statusBarSegment: true lives entirely in the status bar — no sidebar tab required.

Plugins are JavaScript, running in-process via JavaScriptCore — no dependencies, no shell, instant execution.

Directory Structure

~/.boo/plugins/my-plugin/
  plugin.json       # Required manifest
  main.js           # Plugin logic

Manifest (plugin.json)

{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "icon": "star",
  "description": "A custom sidebar plugin",
  "runtime": "js"
}

That's all you need to get started — external plugins automatically get a sidebar tab. The full set of fields:

Manifest Fields

FieldRequiredDescription
idYesUnique plugin identifier
nameYesDisplay name in sidebar and status bar
versionYesSemver version string
iconYesSF Symbol name
descriptionNoHuman-readable description
whenNoWhen-clause expression for visibility (null = always visible)
runtimeNo"js" (only supported value)
capabilities.statusBarSegmentNoShow in status bar (can be the only capability — no sidebar tab required)
statusBar.positionNo"left" or "right"
statusBar.priorityNoLower = closer to edge
statusBar.templateNoTemplate string for status bar text
statusBar.onClickNo"showTab" — clicking the status bar segment focuses the plugin's sidebar tab
menusNoArray of menu item declarations (see Menu Contributions)
settingsNoArray of setting declarations (see Plugin Settings)

Plugin Settings

Plugins can declare their own settings, which appear in the Settings → Plugins UI under an expandable section. Each setting in the settings array has:

FieldRequiredDescription
keyYesSetting key (unique within this plugin)
typeYes"bool", "string", "int", or "double"
labelYesHuman-readable label shown in UI
descriptionNoHelp text shown below the control
defaultNoDefault value
optionsNoUI hint for string settings — see table below
groupNoSection header label; settings sharing a group are grouped together
minNoMinimum value for double sliders
maxNoMaximum value for double sliders
stepNoStep increment for double sliders

Special options values for string settings:

ValueDescription
"fontPicker:system"System font picker dropdown
"fontPicker:mono"Monospace font picker dropdown
"editorFilePatterns"Comma-separated file patterns (e.g. *.ts, .env*)
"dockerSocket"Socket path with auto-detect fallback
"gitDiffTool"Diff tool command; use {file} for the full path
"markdownOpenMode"Picker bound to the global markdown open mode setting
"imageOpenMode"Picker bound to the global image open mode setting
"textOpenMode"Picker bound to the global text open mode setting

Example manifest with settings:

{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "icon": "star",
  "runtime": "js",
  "settings": [
    { "key": "enabled", "type": "bool", "label": "Enable feature", "default": true, "description": "Turn the feature on or off" },
    { "key": "fontSize", "type": "double", "label": "Font size", "default": 12.0, "min": 8, "max": 32, "step": 1, "group": "Appearance" },
    {
      "key": "fontName",
      "type": "string",
      "label": "Font",
      "default": "",
      "options": "fontPicker:system",
      "group": "Appearance"
    }
  ]
}

Settings are stored per-plugin in AppSettings and accessed via:

AppSettings.shared.pluginBool("my-plugin", "enabled", default: true)
AppSettings.shared.pluginDouble("my-plugin", "fontSize", default: 12.0)
AppSettings.shared.pluginString("my-plugin", "fontName", default: "")
AppSettings.shared.setPluginSetting("my-plugin", "fontSize", 14.0)

Status Bar Templates

Templates substitute values from the terminal context:

TokenValue
{cwd}Current working directory
{process.name}Foreground process name
{git.branch}Current git branch
{git.changedCount}Number of changed files
{remote.host}Remote host, container, pod, VM, or device name

Writing a Plugin

Provide main.js with a render function. It receives the terminal context and returns DSL elements. Runs in JavaScriptCore — no process spawn, no dependencies.

Minimal example

function render(ctx) {
  var dir = ctx.cwd.split("/").pop();
  return {
    type: "vstack",
    children: [
      { type: "label", text: "Hello from " + dir, style: "bold" },
      { type: "button", label: "Open in Finder", action: { type: "reveal", path: ctx.cwd } },
    ],
  };
}

Both render and transform are accepted as the entry function name. You can return an object or call JSON.stringify() — both work.

Context object (ctx)

Font rendering for JS plugins is handled automatically by the DSL renderer — it applies the user's sidebar font scale to all label, list, and button elements. No font configuration is needed in your JS code.

FieldTypeExample
ctx.cwdstring"/Users/jane/project"
ctx.processNamestring"node"
ctx.isRemoteboolfalse
ctx.envTypestring"local", "ssh", "mosh", "docker", "kubectl"
ctx.remoteHoststring"server.example.com"
ctx.paneCountnumber2
ctx.tabCountnumber3
ctx.git.branchstring"main"
ctx.git.repoRootstring"/Users/jane/project"
ctx.git.isDirtybooltrue
ctx.git.changedFileCountnumber5
ctx.terminalIDstringUUID of the active terminal
ctx.environmentLabelstringe.g. "local", "ssh: server.example.com"
ctx.remoteCwdstringRemote working directory when in a remote session
ctx.git.stagedCountnumberNumber of staged files
ctx.git.aheadCountnumberCommits ahead of remote
ctx.git.behindCountnumberCommits behind remote
ctx.git.lastCommitShortstringShort hash of last commit
ctx.enrichedDataobjectData from other plugins
ctx.settingsobjectPlugin settings declared in manifest (see Plugin Settings)

ctx.envType mirrors the remote session's tool label. Common values include local, ssh, mosh, docker, podman, kubectl, openshift, limactl, colima, distrobox, toolbox, vagrant, and adb.

Host functions

JS plugins have access to these functions provided by the host:

FunctionReturnsDescription
readFile(path)string or nullRead a file relative to ctx.cwd. Returns null if not found.
fileExists(path)boolCheck if a file exists relative to ctx.cwd.
listDir(path)[{name: string, isDirectory: bool}] or nullList directory contents relative to ctx.cwd. Returns null if not found. Sandboxed to cwd, max 500 entries.
readJSON(path)object or nullShorthand for JSON.parse(readFile(path)). Returns null if the file is missing or not valid JSON.
getEnv(name)string or nullRead an environment variable. Restricted to an allowlist: HOME, USER, SHELL, TERM, LANG, PATH, EDITOR, VISUAL, XDG_CONFIG_HOME.
log(message)voidWrite a message to Boo's debug log (visible in Console.app).

Absolute paths are allowed but must be under ctx.cwd (security boundary).

Example: reading a project file

function render(ctx) {
  var raw = readFile("package.json");
  if (!raw) return { type: "label", text: "No package.json", style: "muted" };
 
  var pkg = JSON.parse(raw);
  var scripts = pkg.scripts || {};
  var names = Object.keys(scripts);
  if (!names.length) return { type: "label", text: "No scripts defined", style: "muted" };
 
  return {
    type: "list",
    items: names.map(function (name) {
      return {
        label: name,
        icon: "play.circle",
        detail: scripts[name],
        action: { type: "exec", command: "npm run " + name },
      };
    }),
  };
}

DSL Elements

Your render function returns a JSON object or array of elements. Each element has a type and type-specific properties:

TypePropertiesDescription
labeltext, style?, tint?Text label. Styles: "bold", "muted", "mono". Alias: text (with content field)
buttonlabel, action, style?, contextMenu?Clickable button. Styles: "primary", "secondary", "destructive"
divider—Horizontal line
spacer—Vertical spacing
iconname, tint?SF Symbol icon. Tints: "success", "error", "warning", "accent", "muted"
listitemsList of items with label, detail?, icon?, action?, contextMenu?
vstackchildrenVertical layout of child elements
hstackchildrenHorizontal layout of child elements. Alias: row
badgetext, tint?Colored pill badge

DSL Actions

Actions are attached to interactive elements (buttons, list items):

Action TypeParametersEffect
"cd"pathChanges terminal directory
"open"pathOpens file in $EDITOR
"exec"commandSends command to terminal
"copy"text or pathCopies to clipboard
"reveal"pathOpens in Finder
"url"url or textOpens URL in default browser
"newTab"path (optional)Opens a new terminal tab
"newPane"path (optional)Opens a new split pane
"paste"textInserts text at cursor without executing
"notification"text, title (optional, defaults to "Boo")Shows macOS notification

Context Menus

List items and buttons can have a contextMenu array for right-click actions:

{
  label: "main.js",
  icon: "doc",
  action: { type: "open", path: "main.js" },
  contextMenu: [
    { label: "Open in editor", icon: "pencil", action: { type: "open", path: "main.js" } },
    { label: "Copy path", icon: "doc.on.doc", action: { type: "copy", text: "main.js" } },
    { label: "Delete", style: "destructive", action: { type: "exec", command: "rm main.js" } }
  ]
}

Each menu item has label (required), action (required), and optional icon (SF Symbol) and style ("destructive" for red text).

When-Clauses

Control when your plugin is visible based on terminal state:

ExpressionMeaning
nullAlways visible
"git.active"Only in git repos
"!remote"Only in local sessions
"remote"Only in remote sessions
"env.ssh"Only in SSH or Mosh sessions
"env.docker"Only in Docker sessions
"env.container"Only in any container / VM / device shell
"env.type == 'kubectl'"Only in kubectl sessions
"git.active && !remote"Git repos, local only
"process.name == 'node'"Only when Node.js is running
"process.editor"Only when an editor is active
"process.category == 'database'"Only for database clients

Process categories: editor, vcs, runtime, build, package, container, database, monitor, filemanager, multiplexer.

Operators: && (and), || (or), ! (not), ==, !=, () (grouping).

Use env.type for string comparisons and remote.host to match the current host or target name.

See When-Clauses for the full list of variables.

Responding to directory changes

JS plugins don't have a lifecycle hook — they respond to cd through their render() function, which is called automatically whenever the working directory changes. ctx.cwd always reflects the current directory:

function render(ctx) {
  // ctx.cwd is already the new directory — render() is called on every cd
  var hasMakefile = fileExists("Makefile");
  if (!hasMakefile) return { type: "label", text: "No Makefile", style: "muted" };
 
  // Offer an action that runs in the new directory
  return {
    type: "button",
    label: "Run make",
    action: { type: "exec", command: "make" },
  };
}

To send a command to the terminal in response to a directory change, use the "exec" action on a button or list item — the user clicks it to trigger the command. For fully automatic reactions (no click required), use a Swift plugin with cwdChanged + hostActions, or an IPC socket tool that subscribes to cwd_changed and sends send_text (see IPC Socket).

Plugins can add items to a "Plugins" top-level menu in the menu bar by declaring a menus array in plugin.json:

{
  "menus": [
    {
      "label": "Run Tests",
      "action": "runTests",
      "shortcut": "shift+cmd+t",
      "icon": "checkmark.circle"
    },
    { "separator": true },
    { "label": "Open Coverage", "action": "openCoverage" }
  ]
}
FieldTypeRequiredDescription
labelstringYesMenu item text
actionstringYesName passed to onAction() in main.js
shortcutstringNoKeyboard shortcut in modifier+key format (e.g. "shift+cmd+t", "ctrl+alt+r")
iconstringNoSF Symbol name
separatorboolNoSet to true for a separator item (no other fields needed)

Shortcuts use modifier+key format. Conflicts with built-in shortcuts are detected and skipped.

Handling menu actions

Implement onAction(name, ctx) in main.js. It receives the action name from the menu item and the current context, and returns a DSL action object (the same format used by button and list item actions):

function onAction(name, ctx) {
  if (name === "runTests") return { type: "exec", command: "npm test" };
  if (name === "openCoverage") return { type: "open", path: ctx.cwd + "/coverage/index.html" };
  return null;
}

Returning null is safe and means no action is taken. onAction() has a 1-second timeout.

Status Bar Click

The statusBar.onClick field controls what happens when the user clicks an external plugin's status bar segment:

{
  "statusBar": {
    "template": "{git.branch} ({git.changedCount})",
    "onClick": "showTab"
  }
}

Setting onClick to "showTab" focuses the plugin's sidebar tab when clicked.

Debugging

A few tools to help when developing plugins:

  • log(message) writes to Boo's debug log, visible in Console.app. Use it to trace values and execution flow.
  • If render() throws an exception, an error view is shown in the sidebar instead of the plugin content.
  • If render() takes longer than 1 second, a timeout error is shown.
  • onAction() also has a 1-second timeout. Returning null is always safe.

Example: Makefile targets plugin

A JS plugin that reads a Makefile and lists its targets as clickable items:

~/.boo/plugins/make-targets/plugin.json

{
  "id": "make-targets",
  "name": "Make Targets",
  "version": "1.0.0",
  "icon": "hammer",
  "description": "Lists Makefile targets. Click to run.",
  "when": "env.local",
  "runtime": "js"
}

~/.boo/plugins/make-targets/main.js

function render(ctx) {
  var src = readFile("Makefile");
  if (!src) return { type: "label", text: "No Makefile", style: "muted" };
 
  var targets = [];
  var lines = src.split("\n");
  for (var i = 0; i < lines.length; i++) {
    var m = lines[i].match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*:/);
    if (m) targets.push(m[1]);
  }
  if (!targets.length) return { type: "label", text: "No targets found", style: "muted" };
 
  return {
    type: "list",
    items: targets.map(function (t) {
      return { label: t, icon: "terminal", action: { type: "exec", command: "make " + t } };
    }),
  };
}

Example plugins

All example plugins are in the examples/plugins/ directory:

PluginWhat it demonstrates
hello-worldMinimal starting point — plugin.json + 12 lines of JS
make-targetsreadFile(), regex parsing, when-clause, list with actions
project-infofileExists() for project type detection, list with open actions
node-projectShowcase — every feature: settings, readFile/fileExists, all DSL elements/styles/tints/actions, context menus, badges
terminal-inspectorShowcase — every ctx field, settings, file detection, all DSL elements/actions, context menus

Start with hello-world, then study node-project or terminal-inspector for the full API.


Built-in Swift Plugins

Built-in plugins are Swift classes compiled into the app. They have full access to AppKit, SwiftUI, and all Boo APIs.

File Organization

All code for a plugin lives in one directory:

Boo/Plugins/YourPlugin/
  YourPlugin.swift          # Plugin class (BooPluginProtocol)
  YourPluginDetailView.swift  # SwiftUI detail view (optional)
  YourService.swift          # Supporting service (optional)

Tests go in the matching directory:

Tests/BooTests/Plugins/YourPlugin/
  YourPluginTests.swift

No Package.swift changes needed — SPM resolves recursively within the target directories.

Reference Example: SystemInfoPlugin

The SystemInfoPlugin (Boo/Plugins/SystemInfo/SystemInfoPlugin.swift) is a working example that demonstrates all plugin patterns:

  • Manifest with when: "!remote" (hidden in remote sessions)
  • Status bar via makeStatusBarContent showing memory usage with color-coded tint
  • Sidebar panel via makeDetailView with SwiftUI progress bars and action buttons
  • Dynamic section title via sectionTitle showing live memory percentage
  • Background refresh via a 10-second timer calling onRequestCycleRerun
  • Lifecycle callback (cwdChanged) to refresh disk usage on directory change
  • Host actions via DSLActionHandler to run df -h and top in the terminal
  • Tests in Tests/BooTests/Plugins/SystemInfo/SystemInfoPluginTests.swift

Minimal Plugin

import SwiftUI
 
@MainActor
final class MyPlugin: BooPluginProtocol {
    var hostActions: PluginHostActions?
    var onRequestCycleRerun: (() -> Void)?
 
    let manifest = PluginManifest(
        id: "my-plugin",       // Also used as pluginID (derived automatically)
        name: "My Plugin",
        version: "1.0.0",
        icon: "star",
        description: "Example built-in plugin",
        when: nil,             // nil = always visible; or use "git.active", "!remote", etc.
        runtime: nil,
        capabilities: PluginManifest.Capabilities(statusBarSegment: true, sidebarTab: true),
        statusBar: PluginManifest.StatusBarManifest(position: "right", priority: 25, template: nil),
        settings: [
            PluginManifest.SettingManifest(key: "greeting", type: .string, label: "Greeting text", defaultValue: AnyCodableValue("Hello"), options: nil)
        ]
    )
 
    // MARK: - Status Bar
 
    func makeStatusBarContent(context: PluginContext) -> StatusBarContent? {
        StatusBarContent(
            text: "Hello",
            icon: "star",
            tint: nil,
            accessibilityLabel: "My Plugin"
        )
    }
 
    // MARK: - Detail View
 
    func makeDetailView(context: PluginContext) -> AnyView? {
        AnyView(
            Text("Hello from My Plugin")
                .font(context.fontScale.font(.base))
                .foregroundColor(Color(nsColor: context.theme.chromeText))
        )
    }
}

PluginContext

Every makeDetailView call receives a PluginContext — the single source of truth for the current state. Prefer it over reaching into AppSettings.shared directly.

PropertyTypeDescription
context.terminalTerminalContextCWD, process, git, remote session info
context.themeThemeSnapshotResolved base theme colors (chromeText, chromeMuted, accentColor, etc.)
context.densitySidebarDensity.comfortable or .compact
context.settingsPluginSettingsReaderScoped reader for this plugin's settings
context.fontScaleSidebarFontScaleTailwind-inspired font scale (see below)

Theme colors: ThemeSnapshot currently exposes only the raw base fields (chromeText, chromeMuted, accentColor, sidebarBg, chromeBg, foreground, background, ansiColors). The derived semantic tokens described in Theming (textSecondary, separator, hoverFill, …) live on TerminalTheme and the app's Tokens environment and are not part of the plugin context yet. Until they are, derive the same role from the base field with the documented alpha, e.g. chromeMuted.withAlphaComponent(0.7) for secondary text.

Font Scale

context.fontScale gives every plugin a Tailwind-inspired type scale that automatically reflects the sidebar font size from Settings → Appearance → Sidebar Font.

func makeDetailView(context: PluginContext) -> AnyView? {
    let scale = context.fontScale
 
    return AnyView(
        VStack(alignment: .leading, spacing: 4) {
            // Named scale steps
            Text("File name")  .font(scale.font(.base))   // × 1.0  (default 12 pt)
            Text("Directory")  .font(scale.font(.sm))     // × 0.875 (default ~10 pt)
            Text("Status")     .font(scale.font(.xs))     // × 0.75  (default  9 pt)
            Text("Section")    .font(scale.font(.lg))     // × 1.125 (default ~13 pt)
            Text("Header")     .font(scale.font(.xl))     // × 1.25  (default 15 pt)
 
            // Pass a design to override the font family (e.g. for code/paths)
            Text("abc123").font(scale.font(.sm, design: .monospaced))
 
            // Apply weight on top of the scale font
            Text("Bold label").font(scale.font(.base).weight(.semibold))
 
            // Read the raw point size if you need it (e.g. for image frames)
            Image(systemName: "star")
                .font(.system(size: scale.size(.sm)))
                .frame(width: scale.size(.base))
        }
    )
}

Scale reference (base = 12 pt)

StepMultiplierDefault ptTypical use
xs× 0.759 ptTimestamps, badges, secondary icons
sm× 0.87510 ptSub-labels, directory paths, detail text
base× 1.012 ptPrimary content — file names, list items
lg× 1.12513 ptSection sub-headers
xl× 1.2515 ptEmphasis, large labels

Section/panel headers rendered by the sidebar host (the bold title bars with chevrons) are not affected by fontScale — they always use the system font at a fixed size. Only your plugin's returned view is scaled.

Registration

Add your plugin to PluginRegistry.registerBuiltins():

func registerBuiltins() {
    register(LocalFileTreePlugin())
    register(RemoteFileTreePlugin())
    register(GitPlugin())
    register(AgentsPlugin())
    register(DockerPluginNew())
    register(BookmarksPluginNew())
    register(SnippetsPlugin())
    register(SystemInfoPlugin())
    register(DebugPlugin())
    register(MyPlugin())  // Add here
}

Using PluginActions

PluginActions is the unified action dispatch, available via the plugin context:

// Convenience methods
pluginActions.exec("ls -la")              // Send command + Enter
pluginActions.cd(to: "/some/dir")         // cd + Enter
pluginActions.pastePath("/some/path")     // Insert path without executing
pluginActions.copy("text")                // Copy to clipboard
pluginActions.reveal("/some/file")        // Show in Finder
pluginActions.openInEditor("/some/file")  // Open with default app
 
// Navigation closures
pluginActions.openDirectoryInNewTab?("/some/dir")
pluginActions.openDirectoryInNewPane?("/some/dir")
pluginActions.sendToTerminal?("raw text\r")
 
// Multi-content tab (reusable tab type)
// Register a factory once (e.g. in pluginDidActivate)
pluginActions.registerMultiContentTab?("my-panel") { ctx in
    AnyView(MyPanelView(title: ctx.title, key: ctx.key))
}
// Open an instance
pluginActions.openMultiContentTab?("my-panel", PluginTabContext(title: "Output", icon: "terminal", key: "run-1"))

PluginTabContext fields: title (String), icon (SF Symbol name), key (String, optional — use to distinguish instances).

For a one-shot custom tab (simpler, non-reusable), use openTab?(.customView(...)) instead.

Agent session ID

Plugins that manage AI agent sessions can store and retrieve a session identifier:

pluginActions.setAgentSessionID?("session-abc123")
let id = pluginActions.getAgentSessionID?()

Triggering Refreshes

When your plugin's data changes asynchronously (e.g., from a background process, file watcher, or network callback), call onRequestCycleRerun?():

// In your async callback
SomeService.shared.onDataChanged = { [weak self] in
    DispatchQueue.main.async {
        self?.onRequestCycleRerun?()
    }
}

This triggers a full plugin cycle, which rebuilds the sidebar and status bar.

Lifecycle Callbacks

Plugins declare which events they handle via subscribedEvents. Only declared callbacks fire — undeclared ones are skipped entirely:

var subscribedEvents: Set<PluginEvent> {
    [.cwdChanged, .remoteSessionChanged]
}

Available PluginEvent cases:

CaseFires when
.cwdChangedTerminal working directory changed
.processChangedForeground process changed
.remoteSessionChangedRemote session started or ended
.focusChangedFocus moved to a different pane
.terminalCreatedA new terminal was created
.terminalClosedA terminal was closed
.remoteDirectoryListedRemote directory listing completed
.commandStartedA shell command started running
.commandEndedA shell command finished

Override the corresponding methods to respond to terminal state changes:

// Terminal changed working directory
func cwdChanged(newPath: String, context: TerminalContext) {
    refreshData(for: newPath)
}
 
// Remote session started or ended
func remoteSessionChanged(session: RemoteSessionType?, context: TerminalContext) {
    if session != nil {
        // Entering remote session
    } else {
        // Back to local
    }
}
 
// Focus moved to a different pane
func terminalFocusChanged(terminalID: UUID, context: TerminalContext) {
    // Update cached state for the new pane
}
 
// Shell command started / finished (subscribe to .commandStarted / .commandEnded)
func commandStarted(command: String, context: TerminalContext) {}
 
func commandEnded(result: CommandResult, context: TerminalContext) {
    // result.command, result.exitCode, result.duration
}

Tab activation callbacks

pluginDidActivate() and pluginDidDeactivate() fire when the user switches sidebar tabs. Both have empty default implementations — override only when needed:

func pluginDidActivate() {
    // Tab selected — start watchers, timers, background work
    startRefreshTimer()
}
 
func pluginDidDeactivate() {
    // Tab deselected — pause background work to save resources
    stopRefreshTimer()
}

For statusbar-only plugins (no sidebar tab), these are called when the plugin becomes visible or hidden.

Sending commands on directory change

Use cwdChanged + PluginActions to send a command to the terminal whenever the user cds into a folder:

func cwdChanged(newPath: String, context: TerminalContext) {
    let envFile = (newPath as NSString).appendingPathComponent(".envrc")
    if FileManager.default.fileExists(atPath: envFile) {
        pluginActions.exec("direnv allow .")
    }
}

Enrichment

If your plugin contributes data that other plugins need, implement enrich():

func enrich(context: EnrichmentContext) {
    // Write data visible to all plugins in Phase 2
    context.setData(myValue, forKey: "my-plugin.myKey")
}

Key convention: prefix with your plugin ID to avoid collisions ("plugin-id.keyName").

Other plugins read this in their react() phase via the frozen TerminalContext:

func react(context: TerminalContext) {
    if let value = context.enrichedData["my-plugin.myKey"] {
        // Use the value contributed by the other plugin
    }
}

JavaScript plugins can access enriched data via ctx.enrichedData["my-plugin.myKey"].

Conditional Visibility

Set the when field in your manifest to control when your plugin appears. The whenClause property is automatically derived from manifest.when — no override needed:

let manifest = PluginManifest(
    id: "my-plugin",
    ...
    when: "git.active && !remote",  // Only visible in local git repos
    ...
)
// whenClause is automatically parsed from manifest.when

Scroll Behavior

By default, the sidebar wraps your detail view in an NSScrollView. If your view handles its own scrolling (like FileTreeView does), return false:

var prefersOuterScrollView: Bool { false }

Dynamic Section Titles

Override sectionTitle(context:) to show context-dependent headers:

func sectionTitle(context: TerminalContext) -> String? {
    let dirName = (context.cwd as NSString).lastPathComponent
    return dirName.isEmpty ? nil : dirName
}

Return nil to use the static manifest.name.

Status Bar Segments

For adding standalone segments to the status bar (not tied to a sidebar plugin), use the StatusBarPlugin protocol:

final class MySegment: StatusBarPlugin {
    let id = "my-segment"
    let position: StatusBarPosition = .left
    let priority = 25
 
    func isVisible(settings: AppSettings, state: StatusBarState) -> Bool { true }
 
    func draw(at x: CGFloat, y: CGFloat, theme: TerminalTheme,
              settings: AppSettings, state: StatusBarState, ctx: CGContext) -> CGFloat {
        let attrs: [NSAttributedString.Key: Any] = [
            .font: NSFont.systemFont(ofSize: 10),
            .foregroundColor: theme.textSecondary  // semantic token on TerminalTheme
        ]
        let str = "Hello" as NSString
        str.draw(at: NSPoint(x: x, y: y), withAttributes: attrs)
        return str.size(withAttributes: attrs).width
    }
 
    func handleClick(at point: NSPoint, in barView: StatusBarView) -> Bool { false }
    func update(state: StatusBarState) {}
 
    // Optional (default nil): hover tooltip and VoiceOver label
    var tooltipText: String? { "My segment" }
}

Register in StatusBarView.registerDefaultPlugins().

Sidebar plugins do not need to create their own status bar segments — the registry auto-registers PluginIconSegment toggle icons for any plugin with capabilities.sidebarTab = true.

A plugin can also contribute only a status bar segment with no sidebar tab at all. Set capabilities.statusBarSegment = true and omit sidebarTab. The plugin will still receive lifecycle callbacks (pluginDidActivate / pluginDidDeactivate) when it becomes visible or hidden.

A plugin can contribute a dedicated sidebar tab — a button in the tab bar at the top of the sidebar. When the user clicks it, the plugin's content fills the full sidebar height instead of appearing as a collapsible section in the Explorer panel.

Declaring the capability

Set sidebarTab: true in your manifest capabilities:

capabilities: PluginManifest.Capabilities(
    statusBarSegment: true,
    sidebarTab: true
)

Implementing makeSidebarTab

Override makeSidebarTab(context:) in your plugin class:

func makeSidebarTab(context: PluginContext) -> SidebarTab? {
    SidebarTab(
        id: SidebarTabID("my-plugin"),
        icon: "magnifyingglass",
        label: "My Plugin",
        sections: [
            SidebarSection(
                id: "my-plugin-main",
                name: "My Plugin",
                icon: "magnifyingglass",
                content: makeDetailView(context: context) ?? AnyView(EmptyView()),
                prefersOuterScrollView: true,
                generation: 0
            )
        ]
    )
}
  • id — use SidebarTabID("your-plugin-id") matching your manifest's id field.
  • icon — SF Symbol name shown in the tab bar button.
  • label — accessibility label and tooltip text.
  • sections — one or more SidebarSection entries; a single section skips the header.

The default implementation returns nil, so plugins that don't override this are unaffected.

Tab lifecycle

  • The tab bar is rebuilt on every plugin cycle, so tabs appear and disappear automatically based on plugin visibility and the when clause.
  • pluginDidActivate() / pluginDidDeactivate() are called when the user switches tabs.
  • For plugins without a sidebar tab (statusbar-only), pluginDidActivate() is called automatically when the plugin becomes visible, and pluginDidDeactivate() when it becomes hidden. This ensures background work (watchers, timers) starts and stops correctly even without a sidebar tab.

Example: Debug plugin

The built-in DebugPlugin uses sidebarTab: true to show a dedicated ladybug tab:

capabilities: PluginManifest.Capabilities(
    statusBarSegment: true,
    sidebarTab: true
)