feat(plugins): status bar items plugins can update live (#37291)

* plugins: contributes.statusBarItems with worker updates (statusBar.update)

A plugin declares status bar items { id, text?, icon?, tone?, tooltip?,
command? } (declarative profile, engines >=1.4.247); pressing one runs
its own command. A trusted worker changes them with the new worker-only
host method statusBar.update; main keeps the session's changes and
pushes snapshots to desktop windows over plugins:statusBarChanged.
Regenerates schemas, references, typings and the authoring skill.

* plugins: statusBarItems ship in 1.4.248, beside contributes.hooks

* plugins: clear a plugin's status bar changes when it is disabled, removed or updated

* plugins: list status bar items in the plugin page's Adds section

* plugins: count statusBarItems' since in the host's newest readable version

* i18n: add the plugin page's status bar row label to en.json

* plugins: status bar items respect workspace commands, dev reloads and catalog summaries

- An item whose command needs a workspace is disabled with no open workspace.
- A dev code edit (new devRevision) drops the old worker's status bar changes.
- Catalog summaries list status bar items, and marketplace details show them.

* plugins: authoring skill lists status bar items as buildable; approval lists them

The skill no longer calls status bar items reserved and gains a row for them. The
dev-load/inspect approval already lists them via the contribution summary; a test pins
that and the fingerprint binding.

* chore(plugins): regenerate the testing bundle after rebasing on main
This commit is contained in:
Neil
2026-11-02 04:26:55 +00:00
committed by GitHub
parent fbe891ce8b
commit a9b1041ecc
51 changed files with 1308 additions and 60 deletions
-1
View File
@@ -627,7 +627,6 @@ arbitrary plugin UI:
- `taskSources`: issue and task providers such as Asana, Linear or Jira, filled by a worker.
- `connections`: account providers with a host-run sign-in.
- `agentHarnesses`: new coding-agent harnesses described as data.
- `statusBarItems`: declarative status bar widgets whose values a worker updates.
- `sidebarCards`: declarative left-sidebar cards whose values a worker updates.
- `mcpServers`: MCP server definitions offered to the agents Orca launches.
- `skills`: agent skills shipped as files.
+1 -1
View File
@@ -108,7 +108,7 @@ One thing the release adds to Orca.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `kind` | `"panels"` \| `"commands"` \| `"events"` \| `"languagePacks"` \| `"keybindings"` \| `"vmRecipes"` \| `"themes"` \| `"terminalThemes"` \| `"iconThemes"` \| `"syntaxes"` \| `"customEditors"` \| `"newFiles"` \| `"settings"` | yes | 1.4.245 | Contribution point. Example: `"themes"` |
| `kind` | `"panels"` \| `"commands"` \| `"events"` \| `"languagePacks"` \| `"keybindings"` \| `"vmRecipes"` \| `"themes"` \| `"terminalThemes"` \| `"iconThemes"` \| `"syntaxes"` \| `"customEditors"` \| `"newFiles"` \| `"settings"` \| `"statusBarItems"` | yes | 1.4.245 | Contribution point. Example: `"themes"` |
| `name` | string | yes | 1.4.245 | What the item is called in Orca. Example: `"Dusk"` |
| `detail` | string | no | 1.4.245 | Extra context, such as file extensions. |
+9
View File
@@ -30,6 +30,7 @@ Methods whose capability is `none` need no capability but still need the plugin
| [`plugin.request`](#pluginrequest) | none | panel, editor | no | 1.2 | Asks the plugin's own worker to run the handler it registered with `orca.panels.handle(name)`, starting the worker if needed; resolves with what the handler returned. `unknown_method` when no handler has that name. |
| [`agents.list`](#agentslist) | `agent:read` | worker, panel, editor | no | 1.2 | The agents in the focused workspace: their terminal, title, agent kind and status. `agent.status.changed` reports each change after. |
| [`events.subscribe`](#eventssubscribe) | none | panel, editor | no | 1.2 | Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you. |
| [`statusBar.update`](#statusbarupdate) | none | worker | no | 1.2 | Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled. |
### `workspace.readContext`
@@ -175,6 +176,14 @@ Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the
(params: { event: "workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed" }) => { ok: true }
```
### `statusBar.update`
Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled.
```ts
(params: { id: string; text?: string; icon?: "activity" | "bar-chart-3" | "bell" | "blocks" | "book" | "bot" | "bug" | "calendar" | "cloud" | "code" | "database" | "file-text" | "flag" | "folder" | "gauge" | "globe" | "hammer" | "layers" | "lightbulb" | "package" | "plug" | "puzzle" | "rocket" | "star" | "terminal" | "wrench" | "zap" | "add" | "remove" | "delete" | "edit" | "refresh" | "search" | "settings" | "check" | "close" | "info" | "warning" | "error" | "open-external" | "copy" | "download" | "upload" | "play" | "pause" | "stop" | "more" | "expand" | "collapse" | "next" | "filter" | "branch" | "file" | "agent" | "link"; tone?: "neutral" | "accent" | "success" | "warning" | "danger"; tooltip?: string; visible?: boolean }) => { ok: true }
```
## Events
A worker receives the events its manifest lists in `contributes.events` through `orca.events.on(event, handler)`, which returns `{ dispose() }`. Each needs `main`, the `trusted-worker` profile and its domain's capability (`workspace:read` or `agent:read`). `orca plugin types` writes the payloads as `PluginEvents` in `orca/host`.
+15 -3
View File
@@ -43,13 +43,13 @@ Add `"$schema": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schem
| `contributes.customEditors` | [CustomEditorContribution](#customeditorcontribution)[] | no | 1.4.245 | Editors that open files of given types in the editor tab. At most 16. |
| `contributes.newFiles` | [NewFileContribution](#newfilecontribution)[] | no | 1.4.245 | Untitled files the user can create from the + menu and the command palette. At most 16. |
| `contributes.settings` | [SettingContribution](#settingcontribution)[] | no | 1.4.246 | Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64. |
| `contributes.statusBarItems` | [StatusBarItemContribution](#statusbaritemcontribution)[] | no | 1.4.248 | Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`. At most 8. |
| `contributes.agentHarnesses` | any[] | no | 1.4.245 | Reserved for coding-agent harness definitions; must be empty until their format is defined. |
| `contributes.skills` | any[] | no | 1.4.245 | Reserved for agent skills; must be empty until their format is defined. |
| `contributes.mcpServers` | any[] | no | 1.4.245 | Reserved for MCP server definitions; must be empty until their format is defined. |
| `contributes.hooks` | any[] | no | 1.4.245 | Reserved for agent hooks; must be empty until their format is defined. |
| `contributes.taskSources` | any[] | no | 1.4.245 | Reserved for task sources; must be empty until their format is defined. |
| `contributes.connections` | any[] | no | 1.4.245 | Reserved for account connections; must be empty until their format is defined. |
| `contributes.statusBarItems` | any[] | no | 1.4.245 | Reserved for status bar items; must be empty until their format is defined. |
| `contributes.sidebarCards` | any[] | no | 1.4.245 | Reserved for sidebar cards; must be empty until their format is defined. |
| `capabilities` | [Capability](#capability)[] | no | 1.4.160 | Host APIs the plugin asks the user to grant. At most 32. |
| `cover` | object | no | 1.4.245 | Wide image at the top of the plugin page. |
@@ -188,6 +188,19 @@ A setting shown under the plugin in Settings. Requires the `settings:own` capabi
| `multiline` | boolean | no | 1.4.246 | Strings: a multi-line text box. |
| `placeholder` | string | no | 1.4.246 | Strings and numbers: hint shown while empty. |
## StatusBarItemContribution
An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`.
| Field | Type | Required | Since | Description |
| --- | --- | --- | --- | --- |
| `id` | string | yes | 1.4.248 | Item id, unique among this plugin's status bar items; `statusBar.update` names it. Example: `"blocked-count"` |
| `text` | string | no | 1.4.248 | Short label shown in the status bar. Example: `"3 blocked"` |
| `icon` | `"activity"` \| `"bar-chart-3"` \| `"bell"` \| `"blocks"` \| `"book"` \| `"bot"` \| `"bug"` \| `"calendar"` \| `"cloud"` \| `"code"` \| `"database"` \| `"file-text"` \| `"flag"` \| `"folder"` \| `"gauge"` \| `"globe"` \| `"hammer"` \| `"layers"` \| `"lightbulb"` \| `"package"` \| `"plug"` \| `"puzzle"` \| `"rocket"` \| `"star"` \| `"terminal"` \| `"wrench"` \| `"zap"` \| `"add"` \| `"remove"` \| `"delete"` \| `"edit"` \| `"refresh"` \| `"search"` \| `"settings"` \| `"check"` \| `"close"` \| `"info"` \| `"warning"` \| `"error"` \| `"open-external"` \| `"copy"` \| `"download"` \| `"upload"` \| `"play"` \| `"pause"` \| `"stop"` \| `"more"` \| `"expand"` \| `"collapse"` \| `"next"` \| `"filter"` \| `"branch"` \| `"file"` \| `"agent"` \| `"link"` | no | 1.4.248 | Icon shown before the text, from the panel icon set. Example: `"bell"` |
| `tone` | `"neutral"` \| `"accent"` \| `"success"` \| `"warning"` \| `"danger"` | no | 1.4.248 | Colour of the item. Defaults to `neutral`. Example: `"warning"` |
| `tooltip` | string | no | 1.4.248 | Shown on hover. Defaults to the plugin name. |
| `command` | string | no | 1.4.248 | Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed. Example: `"guard.showLog"` |
## Capability
A host API the plugin may call once the user consents.
@@ -215,7 +228,7 @@ One screenshot of the plugin in use.
- **`terminal:send` needs `trusted-worker`.** Panels get no host method that types into a terminal, so Orca refuses the capability on any other profile instead of asking the user to grant something the plugin can never use.
- **New fields, contribution points and values are additive.** They arrive with a `Since` version; nothing else changes. `manifestVersion` rises only if an existing field changes meaning.
- **Deprecation.** A field to be removed is marked deprecated here and keeps working for at least one release; it is removed only together with a higher `engines.orca` requirement, and its name is never reused.
- **Reserved contribution points.** `contributes.agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems` and `sidebarCards` are held for planned features and must stay empty until their format is defined. Each will arrive as an additive point with its own `Since`, without a new manifest format. `skills`, `mcpServers` and `hooks` keep the meaning agent-plugin formats such as Claude Code's give them; `agents` stays unclaimed because those formats use it for subagent files.
- **Reserved contribution points.** `contributes.agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections` and `sidebarCards` are held for planned features and must stay empty until their format is defined. Each will arrive as an additive point with its own `Since`, without a new manifest format. `skills`, `mcpServers` and `hooks` keep the meaning agent-plugin formats such as Claude Code's give them; `agents` stays unclaimed because those formats use it for subagent files.
## Planned contribution points
@@ -224,7 +237,6 @@ Each reserved point is planned as declared data that Orca renders and enforces,
- `taskSources`: Issue and task providers such as Asana, Linear or Jira, declared as data that a worker fills with tasks.
- `connections`: Account providers with a host-run sign-in, so a plugin never handles credentials itself.
- `agentHarnesses`: New coding-agent harnesses described as data: how to launch them and read their status.
- `statusBarItems`: Declarative status bar widgets (label, icon, tooltip) whose values a worker updates.
- `sidebarCards`: Declarative left-sidebar cards with fixed layouts whose values a worker updates.
- `mcpServers`: MCP server definitions that Orca offers to the agents it launches.
- `skills`: Agent skills shipped as files that Orca installs for the agents it launches.
@@ -563,7 +563,8 @@
"syntaxes",
"customEditors",
"newFiles",
"settings"
"settings",
"statusBarItems"
],
"description": "Contribution point.",
"x-orca-since": "1.4.245",
+136 -7
View File
@@ -315,6 +315,16 @@
"$ref": "#/$defs/SettingContribution"
}
},
"statusBarItems": {
"default": [],
"description": "Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`. At most 8.",
"x-orca-since": "1.4.248",
"maxItems": 8,
"type": "array",
"items": {
"$ref": "#/$defs/StatusBarItemContribution"
}
},
"agentHarnesses": {
"description": "Reserved for coding-agent harness definitions; must be empty until their format is defined.",
"x-orca-since": "1.4.245",
@@ -357,13 +367,6 @@
"type": "array",
"items": {}
},
"statusBarItems": {
"description": "Reserved for status bar items; must be empty until their format is defined.",
"x-orca-since": "1.4.245",
"maxItems": 0,
"type": "array",
"items": {}
},
"sidebarCards": {
"description": "Reserved for sidebar cards; must be empty until their format is defined.",
"x-orca-since": "1.4.245",
@@ -1272,6 +1275,132 @@
"additionalProperties": false,
"description": "A setting shown under the plugin in Settings. Requires the `settings:own` capability, which is also how code reads it."
},
"StatusBarItemContribution": {
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 64,
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
"description": "Item id, unique among this plugin's status bar items; `statusBar.update` names it.",
"x-orca-since": "1.4.248",
"examples": [
"blocked-count"
]
},
"text": {
"description": "Short label shown in the status bar.",
"x-orca-since": "1.4.248",
"examples": [
"3 blocked"
],
"type": "string",
"maxLength": 64
},
"icon": {
"description": "Icon shown before the text, from the panel icon set.",
"x-orca-since": "1.4.248",
"examples": [
"bell"
],
"type": "string",
"enum": [
"activity",
"bar-chart-3",
"bell",
"blocks",
"book",
"bot",
"bug",
"calendar",
"cloud",
"code",
"database",
"file-text",
"flag",
"folder",
"gauge",
"globe",
"hammer",
"layers",
"lightbulb",
"package",
"plug",
"puzzle",
"rocket",
"star",
"terminal",
"wrench",
"zap",
"add",
"remove",
"delete",
"edit",
"refresh",
"search",
"settings",
"check",
"close",
"info",
"warning",
"error",
"open-external",
"copy",
"download",
"upload",
"play",
"pause",
"stop",
"more",
"expand",
"collapse",
"next",
"filter",
"branch",
"file",
"agent",
"link"
]
},
"tone": {
"description": "Colour of the item. Defaults to `neutral`.",
"x-orca-since": "1.4.248",
"examples": [
"warning"
],
"type": "string",
"enum": [
"neutral",
"accent",
"success",
"warning",
"danger"
]
},
"tooltip": {
"description": "Shown on hover. Defaults to the plugin name.",
"x-orca-since": "1.4.248",
"type": "string",
"maxLength": 256
},
"command": {
"description": "Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed.",
"x-orca-since": "1.4.248",
"examples": [
"guard.showLog"
],
"type": "string",
"minLength": 1,
"maxLength": 256,
"pattern": "^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$"
}
},
"required": [
"id"
],
"additionalProperties": false,
"description": "An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`."
},
"Capability": {
"type": "object",
"properties": {
File diff suppressed because one or more lines are too long
+6
View File
@@ -142,6 +142,12 @@ declare module 'orca/host' {
result: { ok: true }
surfaces: "panel" | "editor"
}
/** Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled. Since host API 1.2. */
"statusBar.update": {
params: { id: string; text?: string; icon?: "activity" | "bar-chart-3" | "bell" | "blocks" | "book" | "bot" | "bug" | "calendar" | "cloud" | "code" | "database" | "file-text" | "flag" | "folder" | "gauge" | "globe" | "hammer" | "layers" | "lightbulb" | "package" | "plug" | "puzzle" | "rocket" | "star" | "terminal" | "wrench" | "zap" | "add" | "remove" | "delete" | "edit" | "refresh" | "search" | "settings" | "check" | "close" | "info" | "warning" | "error" | "open-external" | "copy" | "download" | "upload" | "play" | "pause" | "stop" | "more" | "expand" | "collapse" | "next" | "filter" | "branch" | "file" | "agent" | "link"; tone?: "neutral" | "accent" | "success" | "warning" | "danger"; tooltip?: string; visible?: boolean }
result: { ok: true }
surfaces: "worker"
}
}
export type HostMethodName = keyof HostMethods
/** Methods a surface may call; any other gets `not_permitted`. */
+2 -1
View File
@@ -43,8 +43,9 @@ Use the lowest `execution` profile that covers the request; it decides what the
| Settings the user edits in Orca's Settings | `contributes.settings` and `settings:own` | `references/manifest.md` |
| Reacting to workspace or agent status changes | `trusted-worker`; `main`, `contributes.events`, `*:read` capability | example `hello-orca` |
| A virtual machine environment recipe | `declarative`; `contributes.vmRecipes` | hand-written; fields in `references/manifest.md` |
| A status bar item (a worker can update it) | `declarative`; `contributes.statusBarItems` | `references/recipes.md` |
Examples are in `examples/plugins/` of github.com/stablyai/orca. Status bar items, sidebar cards,
Examples are in `examples/plugins/` of github.com/stablyai/orca. Sidebar cards,
task sources, connections, MCP servers, skills and hooks are reserved and not buildable yet: say
so instead of faking them. A panel cannot type into a terminal; only a worker
with `terminal:send` can.
@@ -6,7 +6,7 @@ Host API 1.2 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a
## Methods per surface
- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`
- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`
- Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`
- Custom editor: global `orca` in the editor frame: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`
@@ -79,6 +79,8 @@ Host API 1.2 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a
`(params?: {}) => { agents: { terminalId: string | null; title: string | null; agent: string | null; state: "working" | "blocked" | "waiting" | "done"; stateStartedAt: number }[] }`
- `events.subscribe` (no capability; panel, editor; since 1.2): Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you.
`(params: { event: "workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed" }) => { ok: true }`
- `statusBar.update` (no capability; worker; since 1.2): Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled.
`(params: { id: string; text?: string; icon?: "activity" | "bar-chart-3" | "bell" | "blocks" | "book" | "bot" | "bug" | "calendar" | "cloud" | "code" | "database" | "file-text" | "flag" | "folder" | "gauge" | "globe" | "hammer" | "layers" | "lightbulb" | "package" | "plug" | "puzzle" | "rocket" | "star" | "terminal" | "wrench" | "zap" | "add" | "remove" | "delete" | "edit" | "refresh" | "search" | "settings" | "check" | "close" | "info" | "warning" | "error" | "open-external" | "copy" | "download" | "upload" | "play" | "pause" | "stop" | "more" | "expand" | "collapse" | "next" | "filter" | "branch" | "file" | "agent" | "link"; tone?: "neutral" | "accent" | "success" | "warning" | "danger"; tooltip?: string; visible?: boolean }) => { ok: true }`
## Capabilities
@@ -46,8 +46,9 @@
- `customEditors` (CustomEditorContribution[], since 1.4.245): Editors that open files of given types in the editor tab. At most 16.
- `newFiles` (NewFileContribution[], since 1.4.245): Untitled files the user can create from the + menu and the command palette. At most 16.
- `settings` (SettingContribution[], since 1.4.246): Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64.
- `statusBarItems` (StatusBarItemContribution[], since 1.4.248): Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`. At most 8.
Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems`, `sidebarCards`.
Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `sidebarCards`.
## PanelContribution
@@ -157,6 +158,17 @@ A setting shown under the plugin in Settings. Requires the `settings:own` capabi
- `multiline` (boolean, since 1.4.246): Strings: a multi-line text box.
- `placeholder` (string, since 1.4.246): Strings and numbers: hint shown while empty.
## StatusBarItemContribution
An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`.
- `id` (string, required, since 1.4.248): Item id, unique among this plugin's status bar items; `statusBar.update` names it.
- `text` (string, since 1.4.248): Short label shown in the status bar.
- `icon` (one of activity, bar-chart-3, bell, blocks, book, bot, bug, calendar, cloud, code, database, file-text, flag, folder, gauge, globe, hammer, layers, lightbulb, package, plug, puzzle, rocket, star, terminal, wrench, zap, add, remove, delete, edit, refresh, search, settings, check, close, info, warning, error, open-external, copy, download, upload, play, pause, stop, more, expand, collapse, next, filter, branch, file, agent, link, since 1.4.248): Icon shown before the text, from the panel icon set.
- `tone` ("neutral" | "accent" | "success" | "warning" | "danger", since 1.4.248): Colour of the item. Defaults to `neutral`.
- `tooltip` (string, since 1.4.248): Shown on hover. Defaults to the plugin name.
- `command` (string, since 1.4.248): Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed.
## Capability
A host API the plugin may call once the user consents.
@@ -60,3 +60,12 @@ List each in `contributes.events` (`{ "on": "<event>" }`) with the capability `r
shows, then `orca.events.on(event, handler)` in `activate(orca)`. Fire one at the dev plugin with
`ORCA plugin dev emit . <event> --data '<json>'`, or unit-test it with `mountWorker` (see
`references/workflow.md`).
## A status bar item a worker updates
Declare it in `contributes.statusBarItems` (`{ "id", "text"?, "icon"?, "tone"?, "tooltip"?,
"command"? }`, `engines.orca` `>=1.4.248`); a static item needs only `declarative`, and pressing it
runs `command`, one of the plugin's own `contributes.commands`. From a worker, change it with
`orca.host.call('statusBar.update', { id, text, tone, tooltip, visible })`; fields you leave out
keep their value and `visible: false` hides it. Bind a key to the same command with
`contributes.keybindings` (`{ "command", "key": "Mod+Shift+K" }`).
+27 -3
View File
@@ -2608,8 +2608,9 @@ const ORCA_PLUGIN_AUTHORING_MARKDOWN = [
"| Settings the user edits in Orca's Settings | `contributes.settings` and `settings:own` | `references/manifest.md` |",
"| Reacting to workspace or agent status changes | `trusted-worker`; `main`, `contributes.events`, `*:read` capability | example `hello-orca` |",
"| A virtual machine environment recipe | `declarative`; `contributes.vmRecipes` | hand-written; fields in `references/manifest.md` |",
"| A status bar item (a worker can update it) | `declarative`; `contributes.statusBarItems` | `references/recipes.md` |",
"",
"Examples are in `examples/plugins/` of github.com/stablyai/orca. Status bar items, sidebar cards,",
"Examples are in `examples/plugins/` of github.com/stablyai/orca. Sidebar cards,",
"task sources, connections, MCP servers, skills and hooks are reserved and not buildable yet: say",
"so instead of faking them. A panel cannot type into a terminal; only a worker",
"with `terminal:send` can.",
@@ -2878,7 +2879,7 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
"",
"## Methods per surface",
"",
"- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`",
"- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`",
"- Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`",
"- Custom editor: global `orca` in the editor frame: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`",
"",
@@ -2951,6 +2952,8 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
" `(params?: {}) => { agents: { terminalId: string | null; title: string | null; agent: string | null; state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; stateStartedAt: number }[] }`",
"- `events.subscribe` (no capability; panel, editor; since 1.2): Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you.",
" `(params: { event: \"workspace.created\" | \"workspace.removed\" | \"agent.status.changed\" | \"workspace.changed\" }) => { ok: true }`",
"- `statusBar.update` (no capability; worker; since 1.2): Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled.",
" `(params: { id: string; text?: string; icon?: \"activity\" | \"bar-chart-3\" | \"bell\" | \"blocks\" | \"book\" | \"bot\" | \"bug\" | \"calendar\" | \"cloud\" | \"code\" | \"database\" | \"file-text\" | \"flag\" | \"folder\" | \"gauge\" | \"globe\" | \"hammer\" | \"layers\" | \"lightbulb\" | \"package\" | \"plug\" | \"puzzle\" | \"rocket\" | \"star\" | \"terminal\" | \"wrench\" | \"zap\" | \"add\" | \"remove\" | \"delete\" | \"edit\" | \"refresh\" | \"search\" | \"settings\" | \"check\" | \"close\" | \"info\" | \"warning\" | \"error\" | \"open-external\" | \"copy\" | \"download\" | \"upload\" | \"play\" | \"pause\" | \"stop\" | \"more\" | \"expand\" | \"collapse\" | \"next\" | \"filter\" | \"branch\" | \"file\" | \"agent\" | \"link\"; tone?: \"neutral\" | \"accent\" | \"success\" | \"warning\" | \"danger\"; tooltip?: string; visible?: boolean }) => { ok: true }`",
"",
"## Capabilities",
"",
@@ -3032,8 +3035,9 @@ const ORCA_PLUGIN_AUTHORING_MANIFEST_REFERENCE_MARKDOWN = [
"- `customEditors` (CustomEditorContribution[], since 1.4.245): Editors that open files of given types in the editor tab. At most 16.",
"- `newFiles` (NewFileContribution[], since 1.4.245): Untitled files the user can create from the + menu and the command palette. At most 16.",
"- `settings` (SettingContribution[], since 1.4.246): Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64.",
"- `statusBarItems` (StatusBarItemContribution[], since 1.4.248): Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`. At most 8.",
"",
"Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems`, `sidebarCards`.",
"Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `sidebarCards`.",
"",
"## PanelContribution",
"",
@@ -3143,6 +3147,17 @@ const ORCA_PLUGIN_AUTHORING_MANIFEST_REFERENCE_MARKDOWN = [
"- `multiline` (boolean, since 1.4.246): Strings: a multi-line text box.",
"- `placeholder` (string, since 1.4.246): Strings and numbers: hint shown while empty.",
"",
"## StatusBarItemContribution",
"",
"An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`.",
"",
"- `id` (string, required, since 1.4.248): Item id, unique among this plugin's status bar items; `statusBar.update` names it.",
"- `text` (string, since 1.4.248): Short label shown in the status bar.",
"- `icon` (one of activity, bar-chart-3, bell, blocks, book, bot, bug, calendar, cloud, code, database, file-text, flag, folder, gauge, globe, hammer, layers, lightbulb, package, plug, puzzle, rocket, star, terminal, wrench, zap, add, remove, delete, edit, refresh, search, settings, check, close, info, warning, error, open-external, copy, download, upload, play, pause, stop, more, expand, collapse, next, filter, branch, file, agent, link, since 1.4.248): Icon shown before the text, from the panel icon set.",
"- `tone` (\"neutral\" | \"accent\" | \"success\" | \"warning\" | \"danger\", since 1.4.248): Colour of the item. Defaults to `neutral`.",
"- `tooltip` (string, since 1.4.248): Shown on hover. Defaults to the plugin name.",
"- `command` (string, since 1.4.248): Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed.",
"",
"## Capability",
"",
"A host API the plugin may call once the user consents.",
@@ -3225,6 +3240,15 @@ const ORCA_PLUGIN_AUTHORING_RECIPES_REFERENCE_MARKDOWN = [
"`ORCA plugin dev emit . <event> --data '<json>'`, or unit-test it with `mountWorker` (see",
"`references/workflow.md`).",
"",
"## A status bar item a worker updates",
"",
"Declare it in `contributes.statusBarItems` (`{ \"id\", \"text\"?, \"icon\"?, \"tone\"?, \"tooltip\"?,",
"\"command\"? }`, `engines.orca` `>=1.4.248`); a static item needs only `declarative`, and pressing it",
"runs `command`, one of the plugin's own `contributes.commands`. From a worker, change it with",
"`orca.host.call('statusBar.update', { id, text, tone, tooltip, visible })`; fields you leave out",
"keep their value and `visible: false` hides it. Bind a key to the same command with",
"`contributes.keybindings` (`{ \"command\", \"key\": \"Mod+Shift+K\" }`).",
"",
].join('\n')
// oxfmt-ignore
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
const send = vi.fn()
vi.mock('electron', () => ({
ipcMain: { handle: vi.fn() },
BrowserWindow: { getAllWindows: () => [{ isDestroyed: () => false, webContents: { send } }] }
}))
import { registerPluginStatusBarHandlers } from './plugin-status-bar-ipc'
import type { PluginService } from '../plugins/plugin-service'
import {
PluginStatusBarState,
pluginStatusBarRevision
} from '../plugins/status-bar/plugin-status-bar-state'
type Install = { rootDir: string; manifest: { version: string }; devRevision?: string }
function setup() {
const statusBar = new PluginStatusBarState()
const installs = new Map<string, Install>([
['acme.guard', { rootDir: '/p/guard', manifest: { version: '1.0.0' } }],
['acme.other', { rootDir: '/p/other', manifest: { version: '1.0.0' } }]
])
let changed: () => void = () => {}
const service = {
options: { statusBar },
findApprovedPlugin: (key: string) => installs.get(key) ?? null,
onChanged: (listener: () => void) => {
changed = listener
return () => {}
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only options, findApprovedPlugin and onChanged.
registerPluginStatusBarHandlers(service as unknown as PluginService)
const set = (key: string, text: string) => {
const install = installs.get(key)
statusBar.update(key, install ? pluginStatusBarRevision(install) : '', 'item', { text })
}
return { statusBar, installs, set, changed: () => changed() }
}
describe('plugin status bar changes', () => {
it('pushes every change to the windows', () => {
const { set } = setup()
set('acme.guard', '1 blocked')
expect(send).toHaveBeenLastCalledWith('plugins:statusBarChanged', {
'acme.guard': { item: { text: '1 blocked' } }
})
})
it('starts a disabled, removed or updated plugin over from its manifest values', () => {
const { statusBar, installs, set, changed } = setup()
set('acme.guard', '1 blocked')
set('acme.other', 'kept')
changed()
expect(Object.keys(statusBar.snapshot())).toEqual(['acme.guard', 'acme.other'])
installs.set('acme.guard', { rootDir: '/p/guard', manifest: { version: '1.1.0' } })
changed()
expect(statusBar.snapshot()).toEqual({ 'acme.other': { item: { text: 'kept' } } })
installs.delete('acme.other')
changed()
expect(statusBar.snapshot()).toEqual({})
})
it('starts over when a dev code edit restarts the worker', () => {
const { statusBar, installs, set, changed } = setup()
installs.set('acme.guard', {
rootDir: '/p/guard',
manifest: { version: '1.0.0' },
devRevision: 'a'
})
set('acme.guard', 'hidden')
installs.set('acme.guard', {
rootDir: '/p/guard',
manifest: { version: '1.0.0' },
devRevision: 'b'
})
changed()
expect(statusBar.snapshot()['acme.guard']).toBeUndefined()
})
it('drops older changes when a new install writes first', () => {
const { statusBar, installs, set } = setup()
set('acme.guard', 'old')
statusBar.update(
'acme.guard',
pluginStatusBarRevision({ rootDir: '/p/guard', manifest: { version: '1.0.0' } }),
'b',
{
tone: 'danger'
}
)
installs.set('acme.guard', { rootDir: '/p/guard2', manifest: { version: '2.0.0' } })
set('acme.guard', 'new')
expect(statusBar.snapshot()).toEqual({ 'acme.guard': { item: { text: 'new' } } })
})
})
+27
View File
@@ -0,0 +1,27 @@
import { BrowserWindow, ipcMain } from 'electron'
import type { PluginService } from '../plugins/plugin-service'
import { pluginStatusBarRevision } from '../plugins/status-bar/plugin-status-bar-state'
import { PLUGIN_STATUS_BAR_CHANGED_CHANNEL } from '../../shared/plugins/contributions/plugin-status-bar-state'
/** Desktop windows read the workers' status bar changes, then hear each new snapshot. */
export function registerPluginStatusBarHandlers(pluginService: PluginService): void {
const { statusBar } = pluginService.options
ipcMain.handle('plugins:statusBarState', () => statusBar?.snapshot() ?? {})
if (!statusBar) {
return
}
// Disable, removal and update all notify a change; a refresh in flight is not one of them.
pluginService.onChanged(() =>
statusBar.prune((pluginKey) => {
const plugin = pluginService.findApprovedPlugin(pluginKey, true)
return plugin ? pluginStatusBarRevision(plugin) : null
})
)
statusBar.onChanged((overrides) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send(PLUGIN_STATUS_BAR_CHANGED_CHANNEL, overrides)
}
}
})
}
@@ -121,7 +121,8 @@ async function harness(
panels,
whenReady: options.whenReady ?? (async () => {}),
findApprovedPlugin: () => null,
getDiscovered: () => []
getDiscovered: () => [],
options: {}
}
registerPluginHandlers(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: frame entry handlers read only getSettings for the isolation switch.
+2
View File
@@ -18,6 +18,7 @@ import type { PluginService } from '../plugins/plugin-service'
import { registerPluginFrameEntryHandlers, rendererPanelOwner } from './plugin-frame-entry-ipc'
import { registerPluginPanelPushHandlers } from './plugin-panel-push-ipc'
import { registerPluginSettingsHandlers } from './plugin-settings-ipc'
import { registerPluginStatusBarHandlers } from './plugin-status-bar-ipc'
import { readPluginPanelImage } from '../plugins/panels/plugin-panel-image'
import {
bindPluginFrameGuests,
@@ -153,6 +154,7 @@ export function registerPluginHandlers(
bindPluginFrameGuests(pluginService)
registerPluginFrameEntryHandlers(store, pluginService)
registerPluginPanelPushHandlers(pluginService)
registerPluginStatusBarHandlers(pluginService)
registerPluginSettingsHandlers(pluginService)
ipcMain.handle('plugins:readNewFileTemplate', async (_event, args: unknown) => {
@@ -202,6 +202,7 @@ export async function buildPluginList(
handler: command.handler,
keybindings: command.keybindings
})),
statusBarItems: plugin.manifest.contributes.statusBarItems,
hasWorker: Boolean(plugin.manifest.main),
vmRecipes: service.contentPacks.vmRecipes.preview(plugin.pluginKey).map(({ recipe }) => ({
id: recipe.id,
+11 -1
View File
@@ -11,6 +11,10 @@ import { pluginHostCallRevoked, type PluginHostCallScope } from './workers/plugi
import type { PluginPanelCaller } from '../../shared/plugins/workers/plugin-panel-bridge-protocol'
import type { PluginPanelBridgeServices } from './workers/plugin-host-panel-method-bindings'
import type { PluginPanelBridge } from './panels/plugin-panel-bridge'
import {
pluginStatusBarRevision,
type PluginStatusBarState
} from './status-bar/plugin-status-bar-state'
import type {
PluginHostCallOutcome,
PluginSurface
@@ -61,6 +65,7 @@ export function resolvePluginServiceHostCallPolicy(input: {
pluginsDataDir: string
desktop: PluginDesktopFrameServices | null | undefined
panels: PluginPanelBridgeServices | null
statusBar?: PluginStatusBarState | null
audit: PluginAuditLog
log?: (pluginKey: string) => (line: string) => void
}): PluginHostCallPolicy {
@@ -73,7 +78,12 @@ export function resolvePluginServiceHostCallPolicy(input: {
pluginsDataDir: input.pluginsDataDir,
desktop: input.desktop,
panels: input.panels,
declaredSettings: plugin?.manifest.contributes.settings
declaredSettings: plugin?.manifest.contributes.settings,
statusBar: input.statusBar,
declaredStatusBarItemIds: plugin?.manifest.contributes.statusBarItems.map(
(item) => item.id
),
statusBarRevision: plugin ? pluginStatusBarRevision(plugin) : undefined
})
: null,
audit: input.audit,
@@ -2,6 +2,7 @@ import type { PluginWorkerFactory } from './workers/plugin-worker-manager'
import type { KeybindingOverrides } from '../../shared/keybindings'
import type { PluginKillListEntry } from '../../shared/plugins/catalog/plugin-kill-list'
import type { PluginDesktopFrameServices } from './workers/plugin-host-method-bindings'
import type { PluginStatusBarState } from './status-bar/plugin-status-bar-state'
export type PluginServiceOptions = {
userDataPath: string
@@ -20,4 +21,6 @@ export type PluginServiceOptions = {
idleReapMs?: number
/** Desktop main only: the sinks behind `shell.openExternal` and `files.saveAs`. */
getDesktopFrameServices?: () => PluginDesktopFrameServices | null
/** Desktop main only: what `statusBar.update` writes for the windows' status bars. */
statusBar?: PluginStatusBarState
}
+1
View File
@@ -94,6 +94,7 @@ export class PluginService {
pluginsDataDir: getPluginsDataDir(this.options.userDataPath),
desktop: this.options.getDesktopFrameServices?.(),
panels: pluginPanelBridgeServices(this.panelBridge),
statusBar: this.options.statusBar,
audit: this.audit,
log: (pluginKey) => this.installed.captureLog(pluginKey, 'info')
})
@@ -0,0 +1,64 @@
import type { PluginStatusBarItemPatch } from '../../../shared/plugins/contributions/plugin-status-bar-item-contribution'
import type { PluginStatusBarOverrides } from '../../../shared/plugins/contributions/plugin-status-bar-state'
/** Which install of a plugin set the changes: a new folder, manifest (an update) or dev code
* edit (a worker restart) starts over. */
export function pluginStatusBarRevision(plugin: {
rootDir: string
manifest: unknown
devRevision?: string | null
}): string {
return `${plugin.rootDir}\0${JSON.stringify(plugin.manifest)}\0${plugin.devRevision ?? ''}`
}
/** What workers set on their status bar items, layered over the manifest's values. Session-only. */
export class PluginStatusBarState {
private overrides: PluginStatusBarOverrides = {}
private readonly revisions = new Map<string, string>()
private readonly listeners = new Set<(overrides: PluginStatusBarOverrides) => void>()
update(
pluginKey: string,
revision: string,
itemId: string,
patch: PluginStatusBarItemPatch
): void {
const items = this.revisions.get(pluginKey) === revision ? this.overrides[pluginKey] : {}
this.revisions.set(pluginKey, revision)
const merged = { ...items?.[itemId], ...patch }
// Why replace, not mutate: listeners and the renderer compare snapshots by identity.
this.overrides = { ...this.overrides, [pluginKey]: { ...items, [itemId]: merged } }
this.emit()
}
/** Drops changes of plugins now disabled, removed or updated (`revisionOf` null or different),
* so a re-enabled plugin starts from its manifest values. */
prune(revisionOf: (pluginKey: string) => string | null): void {
const stale = [...this.revisions].filter(([key, revision]) => revisionOf(key) !== revision)
if (stale.length === 0) {
return
}
const next = { ...this.overrides }
for (const [key] of stale) {
this.revisions.delete(key)
delete next[key]
}
this.overrides = next
this.emit()
}
snapshot(): PluginStatusBarOverrides {
return this.overrides
}
onChanged(listener: (overrides: PluginStatusBarOverrides) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private emit(): void {
for (const listener of this.listeners) {
listener(this.overrides)
}
}
}
@@ -18,6 +18,10 @@ import {
type PluginPanelBridgeServices
} from './plugin-host-panel-method-bindings'
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
import {
PLUGIN_STATUS_BAR_HANDLERS,
type PluginStatusBarServices
} from './plugin-host-status-bar-method-bindings'
export type { PluginHostCallScope } from './plugin-host-call-scope'
@@ -77,6 +81,8 @@ export type PluginHostServices = {
desktop?: PluginDesktopFrameServices | null
/** The plugin's open panels and its worker; absent where no panel host runs. */
panels?: PluginPanelBridgeServices | null
/** Absent where no Orca window draws a status bar. */
statusBar?: PluginStatusBarServices | null
settings: {
getAll(pluginId: string): Record<string, PluginJsonValue>
set(
@@ -191,7 +197,8 @@ const HANDLERS: Record<PluginHostMethodName, BoundPluginHostMethod> = {
const saved = await requireDesktop(services).saveFile(pluginId, params, scope)
return { status: saved ? ('saved' as const) : ('cancelled' as const) }
}),
...PLUGIN_PANEL_BRIDGE_HANDLERS
...PLUGIN_PANEL_BRIDGE_HANDLERS,
...PLUGIN_STATUS_BAR_HANDLERS
}
function requireDesktop(services: PluginHostServices): PluginDesktopFrameServices {
@@ -2,6 +2,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../../shared/plugins/workers
import type { PluginHostServices } from './plugin-host-methods'
import type { PluginDesktopFrameServices } from './plugin-host-method-bindings'
import type { PluginPanelBridgeServices } from './plugin-host-panel-method-bindings'
import type { PluginStatusBarState } from '../status-bar/plugin-status-bar-state'
import {
pluginSettingValueIssue,
type PluginSettingContribution
@@ -53,12 +54,19 @@ export function bindPluginHostServices(input: {
panels?: PluginPanelBridgeServices | null
/** The calling plugin's `contributes.settings`: defaults and value checks. */
declaredSettings?: readonly PluginSettingContribution[]
statusBar?: PluginStatusBarState | null
/** Ids of the calling plugin's `contributes.statusBarItems`, and its install's revision. */
declaredStatusBarItemIds?: readonly string[]
statusBarRevision?: string
}): PluginHostServices {
const declared = new Map((input.declaredSettings ?? []).map((setting) => [setting.key, setting]))
const { delegate, pluginsDataDir } = input
return {
desktop: input.desktop ?? null,
panels: input.panels ?? null,
statusBar: input.statusBar
? statusBarServices(input.statusBar, input.declaredStatusBarItemIds, input.statusBarRevision)
: null,
resolveActiveWorktreeContext: async () => {
const context = await delegate.resolveActiveWorktreeContext()
if (!context) {
@@ -120,3 +128,20 @@ export function bindPluginHostServices(input: {
}
}
}
function statusBarServices(
state: PluginStatusBarState,
declaredIds: readonly string[] = [],
revision = ''
): NonNullable<PluginHostServices['statusBar']> {
const declared = new Set(declaredIds)
return {
update: (pluginKey, itemId, patch) => {
if (!declared.has(itemId)) {
return false
}
state.update(pluginKey, revision, itemId, patch)
return true
}
}
}
@@ -0,0 +1,29 @@
import { PLUGIN_HOST_API } from '../../../shared/plugins/workers/plugin-host-api'
import type { PluginStatusBarItemPatch } from '../../../shared/plugins/contributions/plugin-status-bar-item-contribution'
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
import { PluginHostCallError } from './plugin-host-call-scope'
/** The calling plugin's status bar items; false when it declares no item with that id. */
export type PluginStatusBarServices = {
update(pluginId: string, itemId: string, patch: PluginStatusBarItemPatch): boolean
}
const api = PLUGIN_HOST_API
export const PLUGIN_STATUS_BAR_HANDLERS = {
'statusBar.update': bind(
api['statusBar.update'],
async ({ id, ...patch }, { pluginId, services }) => {
if (!services.statusBar) {
throw new PluginHostCallError('unavailable', 'this Orca has no status bar')
}
if (!services.statusBar.update(pluginId, id, patch)) {
throw new PluginHostCallError(
'invalid_params',
`id: no contributes.statusBarItems entry is named ${id}`
)
}
return { ok: true as const }
}
)
} satisfies Record<string, BoundPluginHostMethod>
@@ -0,0 +1,66 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { executePluginHostCall } from './plugin-host-methods'
import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings'
import { PluginStatusBarState } from '../status-bar/plugin-status-bar-state'
function call(
params: unknown,
options: { state?: PluginStatusBarState | null; surface?: 'worker' | 'panel' } = {}
) {
return executePluginHostCall({
pluginId: 'acme.guard',
method: 'statusBar.update',
params,
surface: options.surface ?? 'worker',
grantedCapabilities: [],
services: bindPluginHostServices({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: statusBar.update never reaches the runtime delegate.
delegate: {} as PluginRuntimeDelegate,
pluginsDataDir: mkdtempSync(join(tmpdir(), 'orca-status-bar-methods-')),
statusBar: options.state === undefined ? new PluginStatusBarState() : options.state,
declaredStatusBarItemIds: ['blocked']
}),
audit: { record: vi.fn().mockResolvedValue(undefined) },
scope: { assertActive: () => undefined }
})
}
describe('statusBar.update', () => {
it("layers a worker's changes over its declared item and tells listeners", async () => {
const state = new PluginStatusBarState()
const listener = vi.fn()
state.onChanged(listener)
await expect(
call({ id: 'blocked', text: '1 blocked', tone: 'warning' }, { state })
).resolves.toEqual({ ok: true, value: { ok: true } })
await call({ id: 'blocked', tooltip: 'Blocked commands' }, { state })
expect(state.snapshot()).toEqual({
'acme.guard': {
blocked: { text: '1 blocked', tone: 'warning', tooltip: 'Blocked commands' }
}
})
expect(listener).toHaveBeenCalledTimes(2)
})
it('refuses an undeclared item, a panel caller and a host without a status bar', async () => {
await expect(call({ id: 'other', text: 'x' })).resolves.toMatchObject({
ok: false,
code: 'invalid_params'
})
await expect(call({ id: 'blocked', text: 'x' }, { surface: 'panel' })).resolves.toMatchObject({
ok: false,
code: 'not_permitted'
})
await expect(call({ id: 'blocked', text: 'x' }, { state: null })).resolves.toMatchObject({
ok: false,
code: 'unavailable'
})
await expect(call({ id: 'blocked', command: 'x' })).resolves.toMatchObject({
ok: false,
code: 'invalid_params'
})
})
})
+2
View File
@@ -44,6 +44,7 @@ import {
import { emitPluginWorktreeLifecycle } from './main-process-pty-startup'
import { mainProcessState as state } from './main-process-state'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { PluginStatusBarState } from '../plugins/status-bar/plugin-status-bar-state'
const PLUGIN_AUTO_UPDATE_STARTUP_DELAY_MS = 60_000
// Why: the official catalog fetch must not compete with first paint; opening the marketplace seeds sooner.
@@ -117,6 +118,7 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService):
getPluginKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null,
hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged),
getDesktopFrameServices: getPluginDesktopFrameServices,
statusBar: new PluginStatusBarState(),
bundledPluginRoot
})
const pluginService = state.pluginService
+4
View File
@@ -46,6 +46,7 @@ export type {
PluginHostStatus
} from '../../shared/plugins/plugin-list-entry'
import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
import type { PluginStatusBarOverrides } from '../../shared/plugins/contributions/plugin-status-bar-state'
import type { PluginDeclaredSettingsSnapshot } from '../../shared/plugins/contributions/plugin-settings-values'
import type { PluginJsonValue } from '../../shared/plugins/workers/plugin-host-api'
@@ -260,6 +261,9 @@ export type PluginsApi = {
onInstallCountsChanged: (callback: () => void) => () => void
/** Fires after each automatic update pass, with the plugins it just updated. */
onAutoUpdateChanged: (callback: (event: PluginAutoUpdateEvent) => void) => () => void
/** What workers set with `statusBar.update`. Desktop only; older hosts reject. */
statusBarState?: () => Promise<PluginStatusBarOverrides>
onStatusBarChanged?: (callback: (overrides: PluginStatusBarOverrides) => void) => () => void
}
export type PluginAutoUpdateEvent = {
+13
View File
@@ -7,6 +7,10 @@ import type {
import type { PluginHostCallOutcome } from '../../shared/plugins/frames/plugin-protocol'
import type { PluginConsentRequest } from '../../shared/plugins/approval/plugin-consent-request'
import type { PluginChangeEvent } from '../../shared/plugins/events/plugin-change-event'
import {
PLUGIN_STATUS_BAR_CHANGED_CHANNEL,
type PluginStatusBarOverrides
} from '../../shared/plugins/contributions/plugin-status-bar-state'
import {
PLUGIN_VIEW_REPLY_CHANNEL,
PLUGIN_VIEW_REQUEST_CHANNEL,
@@ -108,6 +112,15 @@ export const pluginsApi = {
ipcRenderer.removeListener('plugins:installCountsChanged', listener)
}
},
statusBarState: () => ipcRenderer.invoke('plugins:statusBarState'),
onStatusBarChanged: (callback): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, overrides: PluginStatusBarOverrides) =>
callback(overrides)
ipcRenderer.on(PLUGIN_STATUS_BAR_CHANGED_CHANNEL, listener)
return () => {
ipcRenderer.removeListener(PLUGIN_STATUS_BAR_CHANGED_CHANNEL, listener)
}
},
onAutoUpdateChanged: (callback): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, change: PluginAutoUpdateEvent): void =>
callback(change)
@@ -108,7 +108,8 @@ const preview: PluginMarketplaceHostInstallPreview = {
syntaxes: [],
customEditors: [],
newFiles: [],
settings: []
settings: [],
statusBarItems: []
},
capabilities: [{ kind: 'workspace:read' }]
}
@@ -7,6 +7,7 @@ import {
Highlighter,
Languages,
Palette,
PanelBottom,
PanelRight,
PenTool,
Server,
@@ -103,7 +104,8 @@ const CONTRIBUTION_ICON: Record<PluginContributionKind, React.ReactNode> = {
panels: <PanelRight />,
customEditors: <PenTool />,
vmRecipes: <Server />,
newFiles: <FilePlus />
newFiles: <FilePlus />,
statusBarItems: <PanelBottom />
}
type AddsCommand = {
@@ -249,6 +251,7 @@ function useContributionRowActions(
case 'terminalThemes':
case 'customEditors':
case 'vmRecipes':
case 'statusBarItems':
// Themes have "Use now" above the list; editors open from their new files; recipes have nothing to try here.
return null
}
@@ -0,0 +1,33 @@
import { translate } from '@/i18n/i18n'
import type { PluginContribution } from './plugin-contributions'
export function withMode(
kindLabel: string,
mode: 'light' | 'dark' | 'unknown' | undefined
): string {
if (mode === 'dark') {
return `${kindLabel} · ${translate('auto.components.settings.pluginContributions.dark', 'Dark')}`
}
if (mode === 'light') {
return `${kindLabel} · ${translate('auto.components.settings.pluginContributions.light', 'Light')}`
}
return kindLabel
}
/** Each item's text, or its id when it shows only an icon. */
export function statusBarItemTitles(
items: readonly { id: string; text?: string }[] | undefined
): string[] {
return (items ?? []).map((item) => item.text || item.id)
}
export function statusBarContributionRows(titles: readonly string[]): PluginContribution[] {
const detail = translate('auto.components.settings.pluginContributions.statusBar', 'Status bar')
return titles.map((title, index) => ({
key: `statusBarItems:${index}`,
kind: 'statusBarItems',
title,
detail,
tone: 'default'
}))
}
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { EMPTY_CONTRIBUTION_SOURCE, describePluginContributions } from './plugin-contributions'
import {
EMPTY_CONTRIBUTION_SOURCE,
describePluginContributions,
summaryContributionSource
} from './plugin-contributions'
import { statusBarItemTitles } from './plugin-contribution-row-labels'
vi.mock('@/i18n/i18n', () => ({
getIntlLocale: () => 'en',
@@ -37,3 +42,27 @@ describe('custom editor rows', () => {
expect(editorRow({ extensions: ['.excalidraw'], priority: 'default' }).tone).toBe('default')
})
})
describe('status bar item rows', () => {
it('lists each item by its text, or its id when it shows only an icon', () => {
const source = {
...EMPTY_CONTRIBUTION_SOURCE,
statusBarItems: statusBarItemTitles([{ id: 'blocked', text: '0 blocked' }, { id: 'quiet' }])
}
expect(describePluginContributions(source)).toEqual([
expect.objectContaining({
key: 'statusBarItems:0',
title: '0 blocked',
detail: 'Status bar'
}),
expect.objectContaining({ key: 'statusBarItems:1', title: 'quiet', kind: 'statusBarItems' })
])
// Older hosts list no status bar items.
expect(statusBarItemTitles(undefined)).toEqual([])
})
it('lists the items a catalog summary names', () => {
const source = summaryContributionSource([{ kind: 'statusBarItems', name: '0 blocked' }])
expect(source.statusBarItems).toEqual(['0 blocked'])
})
})
@@ -9,6 +9,11 @@ import type { PluginManifest } from '../../../../../shared/plugins/manifest/plug
import { getIntlLocale, translate } from '@/i18n/i18n'
import { customEditorTakeover } from './plugin-custom-editor-takeover'
import { fileList } from './plugin-contribution-file-list'
import {
statusBarContributionRows,
statusBarItemTitles,
withMode
} from './plugin-contribution-row-labels'
/** The manifest points shown as named rows; commands, shortcuts, events and settings have their
* own lists. */
@@ -50,6 +55,7 @@ export type PluginContributionSource = {
}[]
vmRecipes: readonly string[]
newFiles: readonly { title: string; extension: string }[]
statusBarItems: readonly string[]
}
export const EMPTY_CONTRIBUTION_SOURCE: PluginContributionSource = {
@@ -61,7 +67,8 @@ export const EMPTY_CONTRIBUTION_SOURCE: PluginContributionSource = {
panels: [],
customEditors: [],
vmRecipes: [],
newFiles: []
newFiles: [],
statusBarItems: []
}
/** "recipes/ubuntu-dev.json" → "Ubuntu dev"; manifests name these files by path only. */
@@ -102,7 +109,8 @@ export function manifestContributionSource(manifest: PluginManifest): PluginCont
priority: editor.priority ?? 'default'
})),
vmRecipes: contributes.vmRecipes.map((recipe) => nameFromContributionPath(recipe.path)),
newFiles: contributes.newFiles
newFiles: contributes.newFiles,
statusBarItems: statusBarItemTitles(contributes.statusBarItems)
}
}
@@ -140,20 +148,11 @@ export function summaryContributionSource(
filenames: []
})),
vmRecipes: of('vmRecipes').map((item) => nameFromContributionPath(item.name)),
newFiles: of('newFiles').map((item) => ({ title: item.name, extension: item.detail ?? '' }))
newFiles: of('newFiles').map((item) => ({ title: item.name, extension: item.detail ?? '' })),
statusBarItems: of('statusBarItems').map((item) => item.name)
}
}
function withMode(kindLabel: string, mode: 'light' | 'dark' | 'unknown' | undefined): string {
if (mode === 'dark') {
return `${kindLabel} · ${translate('auto.components.settings.pluginContributions.dark', 'Dark')}`
}
if (mode === 'light') {
return `${kindLabel} · ${translate('auto.components.settings.pluginContributions.light', 'Light')}`
}
return kindLabel
}
function languageDetail(coverage: number | undefined): Pick<PluginContribution, 'detail' | 'tone'> {
const label = translate(
'auto.components.settings.PluginDetailSections.typeLanguagePack',
@@ -290,6 +289,7 @@ export function describePluginContributions(
tone: 'default'
})
}
rows.push(...statusBarContributionRows(source.statusBarItems))
for (const [index, title] of source.vmRecipes.entries()) {
rows.push({
key: `vmRecipes:${index}`,
@@ -17,6 +17,7 @@ import {
manifestContributionSource,
type PluginContributionSource
} from '../presentation/plugin-contributions'
import { statusBarItemTitles } from '../presentation/plugin-contribution-row-labels'
/** What an installed plugin adds right now, from Orca's live registrations; empty for others. */
function useInstalledPluginContributionSource(
@@ -49,7 +50,8 @@ function useInstalledPluginContributionSource(
panels: plugin.panels.map((panel) => panel.title),
customEditors: plugin.customEditors ?? [],
vmRecipes: plugin.vmRecipes?.map((recipe) => recipe.name) ?? [],
newFiles: plugin.newFiles ?? []
newFiles: plugin.newFiles ?? [],
statusBarItems: statusBarItemTitles(plugin.statusBarItems)
}
}, [plugin, appThemes, terminalThemes, iconThemes, syntaxes, languagePacks, failures])
}
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActivePluginStatusBarItem } from '@/store/plugin-status-bar-items'
import { TooltipProvider } from '@/components/ui/tooltip'
const items: ActivePluginStatusBarItem[] = []
const executePluginCommand = vi.fn()
vi.mock('@/store/plugin-status-bar-items', () => ({ usePluginStatusBarItems: () => items }))
vi.mock('@/lib/plugin-command-execution', () => ({
executePluginCommand: (...args: unknown[]) => executePluginCommand(...args)
}))
const appState: { activeWorktreeId: string | null } = { activeWorktreeId: 'wt-1' }
vi.mock('@/store', () => ({
useAppStore: (select: (state: typeof appState) => unknown) => select(appState)
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
import { PluginStatusBarItems } from './PluginStatusBarItems'
const command = {
id: 'guard.log',
title: 'Show log',
context: 'global' as const,
handler: { type: 'worker' as const },
keybindings: [],
pluginKey: 'acme.guard',
pluginName: 'Guard'
}
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
executePluginCommand.mockReset().mockResolvedValue(undefined)
items.length = 0
appState.activeWorktreeId = 'wt-1'
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
})
async function render(iconOnly = false): Promise<void> {
await act(async () =>
root.render(
<TooltipProvider>
<PluginStatusBarItems iconOnly={iconOnly} />
</TooltipProvider>
)
)
}
describe('PluginStatusBarItems', () => {
it('runs the item command when pressed and shows its tone', async () => {
items.push({
key: 'acme.guard/blocked',
pluginName: 'Guard',
text: '2 blocked',
icon: 'flag',
tone: 'warning',
tooltip: 'Blocked commands',
command
})
await render()
const button = container.querySelector('button')
expect(button?.textContent).toBe('2 blocked')
expect(button?.dataset.tone).toBe('warning')
expect(button?.getAttribute('aria-label')).toBe('2 blocked, Blocked commands')
await act(async () => button?.click())
expect(executePluginCommand).toHaveBeenCalledWith(command, 'plugin-palette')
})
it('does not run a workspace command with no open workspace', async () => {
appState.activeWorktreeId = null
items.push({
key: 'acme.guard/blocked',
pluginName: 'Guard',
text: '2 blocked',
tone: 'neutral',
tooltip: 'Blocked commands',
command: { ...command, context: 'workspace' }
})
await render()
const button = container.querySelector('button')
expect(button?.getAttribute('aria-disabled')).toBe('true')
expect(button?.getAttribute('aria-label')).toBe(
'2 blocked, Blocked commands · Needs an open workspace'
)
await act(async () => button?.click())
expect(executePluginCommand).not.toHaveBeenCalled()
})
it('draws an item without a command as static text, icon only when the bar is narrow', async () => {
items.push({
key: 'acme.guard/quiet',
pluginName: 'Guard',
text: 'Quiet',
icon: 'bell',
tone: 'neutral',
tooltip: 'Guard',
command: null
})
await render(true)
expect(container.querySelector('button')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
expect(container.textContent).toBe('')
})
})
@@ -0,0 +1,87 @@
import { toast } from 'sonner'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { PluginDeclarativeIconGlyph } from '@/components/plugins/declarative/plugin-declarative-icons'
import { executePluginCommand } from '@/lib/plugin-command-execution'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { pluginCommandUnavailableReason } from '@/components/plugins/page/plugin-contribution-action-availability'
import {
usePluginStatusBarItems,
type ActivePluginStatusBarItem
} from '@/store/plugin-status-bar-items'
const ITEM_CLASS =
'inline-flex items-center gap-1 rounded px-1 py-0.5 text-muted-foreground data-[pressable=true]:cursor-pointer data-[pressable=true]:hover:bg-accent/70 data-[pressable=true]:hover:text-foreground data-[tone=accent]:text-primary data-[tone=danger]:text-destructive data-[tone=success]:text-status-success data-[tone=warning]:text-status-warning-text'
function runItemCommand(item: ActivePluginStatusBarItem): void {
if (!item.command) {
return
}
void executePluginCommand(item.command, 'plugin-palette').catch(() => {
toast.error(translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.'))
})
}
function PluginStatusBarItem({
item,
iconOnly
}: {
item: ActivePluginStatusBarItem
iconOnly: boolean
}): React.JSX.Element {
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
// Why: palette and shortcuts skip workspace commands with no workspace; pressing must too.
const unavailable = item.command
? pluginCommandUnavailableReason(item.command, activeWorktreeId)
: null
const tooltip = unavailable ? `${item.tooltip} · ${unavailable}` : item.tooltip
const showText = item.text !== undefined && (!iconOnly || !item.icon)
const content = (
<>
{item.icon ? <PluginDeclarativeIconGlyph name={item.icon} className="size-3" /> : null}
{showText ? <span className="text-[11px]">{item.text}</span> : null}
</>
)
return (
<Tooltip>
<TooltipTrigger asChild>
{item.command ? (
<button
type="button"
className={ITEM_CLASS}
data-tone={item.tone}
data-pressable={unavailable ? 'false' : 'true'}
aria-disabled={unavailable ? true : undefined}
aria-label={item.text ? `${item.text}, ${tooltip}` : tooltip}
onClick={() => {
if (!unavailable) {
runItemCommand(item)
}
}}
>
{content}
</button>
) : (
<span className={ITEM_CLASS} data-tone={item.tone} aria-label={item.tooltip}>
{content}
</span>
)}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
/** Items enabled plugins declare in `contributes.statusBarItems`. */
export function PluginStatusBarItems({ iconOnly }: { iconOnly: boolean }): React.JSX.Element {
const items = usePluginStatusBarItems()
return (
<>
{items.map((item) => (
<PluginStatusBarItem key={item.key} item={item} iconOnly={iconOnly} />
))}
</>
)
}
@@ -21,6 +21,7 @@ import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment'
import { NativeChatResumeStatusSegment } from './NativeChatResumeStatusSegment'
import { CaffeinateStatusSegment } from './CaffeinateStatusSegment'
import { PluginsStatusSegment } from './PluginsStatusSegment'
import { PluginStatusBarItems } from './PluginStatusBarItems'
import { RemoteServerUpdateStatusSegment } from './RemoteServerUpdateStatusSegment'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu'
@@ -294,6 +295,7 @@ export function StatusBarSurface({
{!isPairedWebClientWindow() ? (
<>
<PluginsStatusSegment />
<PluginStatusBarItems iconOnly={segmentsIconOnly} />
<CaffeinateStatusSegment iconOnly={segmentsIconOnly} />
</>
) : null}
+2 -1
View File
@@ -13332,7 +13332,8 @@
"languageCoverage": "Interface language · {{value0}}% translated",
"newFileMenu": "Adds to the + menu",
"editorReplacesViewer": "Opens {{value0}} instead of Orca’s viewer",
"editorMayReplaceViewer": "May open {{value0}} instead of Orca’s viewer"
"editorMayReplaceViewer": "May open {{value0}} instead of Orca’s viewer",
"statusBar": "Status bar"
},
"PluginCardAction": {
"installed": "Installed",
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import type { PluginHostListEntry } from '../../../preload/api-types'
import { collectPluginStatusBarItems } from './plugin-status-bar-items'
function plugin(overrides: Partial<PluginHostListEntry> = {}): PluginHostListEntry {
return {
pluginKey: 'acme.guard',
consentFingerprint: null,
name: 'Guard',
version: '1.0.0',
publisher: 'acme',
status: 'running',
needsReconsent: false,
isDev: false,
official: false,
bundled: false,
capabilities: [],
panels: [],
commands: [
{
id: 'guard.log',
title: 'Show log',
context: 'global',
handler: { type: 'worker' },
keybindings: []
}
],
statusBarItems: [
{ id: 'blocked', text: '0 blocked', icon: 'flag', command: 'guard.log' },
{ id: 'quiet', icon: 'bell' }
],
hasWorker: true,
restarts: 0,
...overrides
}
}
describe('collectPluginStatusBarItems', () => {
it("applies a worker's changes over the manifest and resolves the command", () => {
const items = collectPluginStatusBarItems([plugin()], {
'acme.guard': { blocked: { text: '2 blocked', tone: 'danger' } }
})
expect(items).toEqual([
expect.objectContaining({
key: 'acme.guard/blocked',
text: '2 blocked',
icon: 'flag',
tone: 'danger',
tooltip: 'Guard',
command: expect.objectContaining({ id: 'guard.log', pluginKey: 'acme.guard' })
}),
expect.objectContaining({ key: 'acme.guard/quiet', tone: 'neutral', command: null })
])
})
it('hides items a worker hid, empty items and disabled plugins', () => {
expect(
collectPluginStatusBarItems([plugin()], {
'acme.guard': { blocked: { visible: false } }
}).map((item) => item.key)
).toEqual(['acme.guard/quiet'])
expect(
collectPluginStatusBarItems([plugin({ statusBarItems: [{ id: 'empty' }] })], {})
).toEqual([])
expect(collectPluginStatusBarItems([plugin({ status: 'disabled' })], {})).toEqual([])
// Older hosts list no status bar items.
expect(collectPluginStatusBarItems([plugin({ statusBarItems: undefined })], {})).toEqual([])
})
})
@@ -0,0 +1,80 @@
import { useEffect, useMemo } from 'react'
import { create } from 'zustand'
import type { PluginHostListEntry } from '../../../preload/api-types'
import type { PluginStatusBarOverrides } from '../../../shared/plugins/contributions/plugin-status-bar-state'
import type { PluginDeclarativeTone } from '../../../shared/plugins/declarative/plugin-declarative-catalog'
import {
fromPlugin,
isLivePlugin,
usePluginPanelsStore,
ensurePluginPanelsLoaded,
type ActivePluginCommand
} from './plugin-panels'
/** A status bar item of an enabled plugin, with its worker's changes applied. */
export type ActivePluginStatusBarItem = {
key: string
pluginName: string
text?: string
icon?: string
tone: PluginDeclarativeTone
tooltip: string
command: ActivePluginCommand | null
}
/** Mirrors main's snapshot of `statusBar.update` changes. */
export const usePluginStatusBarStore = create<{ overrides: PluginStatusBarOverrides }>(() => ({
overrides: {}
}))
let watching = false
function watchPluginStatusBar(): void {
const plugins = window.api?.plugins
if (watching || !plugins?.statusBarState || !plugins.onStatusBarChanged) {
return
}
watching = true
plugins.onStatusBarChanged((overrides) => usePluginStatusBarStore.setState({ overrides }))
void plugins
.statusBarState()
.then((overrides) => usePluginStatusBarStore.setState({ overrides }))
// Why ignored: an older host has no runtime changes; items keep their manifest values.
.catch(() => undefined)
}
export function collectPluginStatusBarItems(
plugins: readonly PluginHostListEntry[],
overrides: PluginStatusBarOverrides
): ActivePluginStatusBarItem[] {
return plugins.filter(isLivePlugin).flatMap((plugin) =>
(plugin.statusBarItems ?? []).flatMap((declared) => {
const item = { ...declared, ...overrides[plugin.pluginKey]?.[declared.id] }
if (item.visible === false || (!item.text && !item.icon)) {
return []
}
const command = plugin.commands.find((candidate) => candidate.id === declared.command)
return [
{
key: `${plugin.pluginKey}/${declared.id}`,
pluginName: plugin.name,
...(item.text ? { text: item.text } : {}),
...(item.icon ? { icon: item.icon } : {}),
tone: item.tone ?? 'neutral',
tooltip: item.tooltip || plugin.name,
command: command ? fromPlugin(plugin, command) : null
}
]
})
)
}
export function usePluginStatusBarItems(): ActivePluginStatusBarItem[] {
const plugins = usePluginPanelsStore((state) => state.plugins)
const overrides = usePluginStatusBarStore((state) => state.overrides)
useEffect(() => {
ensurePluginPanelsLoaded()
watchPluginStatusBar()
}, [])
return useMemo(() => collectPluginStatusBarItems(plugins, overrides), [plugins, overrides])
}
@@ -38,6 +38,11 @@ import {
PLUGIN_PANEL_BRIDGE_SINCE,
PLUGIN_SCHEMA_SINCE_V1
} from '../manifest/plugin-schema-field-docs'
import {
PLUGIN_STATUS_BAR_ITEM_LIMIT,
PLUGIN_STATUS_BAR_SINCE,
pluginStatusBarItemContributionSchema
} from './plugin-status-bar-item-contribution'
import {
PLUGIN_SETTING_LIMIT,
pluginSettingContributionSchema
@@ -57,7 +62,8 @@ export const PLUGIN_CONTRIBUTION_POINTS = [
'syntaxes',
'customEditors',
'newFiles',
'settings'
'settings',
'statusBarItems'
] as const
export type PluginContributionPoint = (typeof PLUGIN_CONTRIBUTION_POINTS)[number]
@@ -73,7 +79,6 @@ export const PLUGIN_RESERVED_CONTRIBUTION_POINTS = {
hooks: 'agent hooks',
taskSources: 'task sources',
connections: 'account connections',
statusBarItems: 'status bar items',
sidebarCards: 'sidebar cards'
} as const
@@ -89,8 +94,6 @@ export const PLUGIN_PLANNED_CONTRIBUTION_POINTS: Readonly<
'Account providers with a host-run sign-in, so a plugin never handles credentials itself.',
agentHarnesses:
'New coding-agent harnesses described as data: how to launch them and read their status.',
statusBarItems:
'Declarative status bar widgets (label, icon, tooltip) whose values a worker updates.',
sidebarCards: 'Declarative left-sidebar cards with fixed layouts whose values a worker updates.',
mcpServers: 'MCP server definitions that Orca offers to the agents it launches.',
skills: 'Agent skills shipped as files that Orca installs for the agents it launches.',
@@ -207,6 +210,12 @@ export const pluginContributesFields = {
PLUGIN_SETTING_LIMIT,
'Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`).',
PLUGIN_PANEL_BRIDGE_SINCE
),
statusBarItems: contributionList(
pluginStatusBarItemContributionSchema,
PLUGIN_STATUS_BAR_ITEM_LIMIT,
'Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`.',
PLUGIN_STATUS_BAR_SINCE
)
} satisfies Record<PluginContributionPoint, z.ZodType>
@@ -217,7 +226,6 @@ export const pluginReservedContributesFields = {
hooks: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.hooks),
taskSources: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.taskSources),
connections: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.connections),
statusBarItems: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.statusBarItems),
sidebarCards: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.sidebarCards)
} satisfies Record<PluginReservedContributionPoint, z.ZodType>
@@ -252,5 +260,7 @@ export const PLUGIN_CONTRIBUTION_POINT_POLICY: Record<
// whatever opens that file (a syntax, an editor) carries its own type.
newFiles: { execution: 'data', catalogType: null, bindsContent: false },
// Why sandboxed-ui: only code reads a setting, and panels are the least code there is.
settings: { execution: 'sandboxed-ui', catalogType: null, bindsContent: false }
settings: { execution: 'sandboxed-ui', catalogType: null, bindsContent: false },
// Why declarative: a static item only names its own command; updating it needs a worker.
statusBarItems: { execution: 'declarative', catalogType: null, bindsContent: false }
}
@@ -89,7 +89,9 @@ export function pluginContributionSummary(
c.newFiles,
(newFile) => newFile.title,
(newFile) => newFile.extension
)
),
// Each item's text, or its id when it shows only an icon.
...named('statusBarItems', c.statusBarItems, (item) => item.text || item.id)
].slice(0, PLUGIN_CONTRIBUTION_SUMMARY_LIMIT)
}
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { parsePluginManifest } from '../manifest/plugin-manifest'
import { derivePluginExecutionProfile } from './plugin-execution-profile'
import { pluginStatusBarItemPatchSchema } from './plugin-status-bar-item-contribution'
import { pluginContributionSummary } from './plugin-contribution-summary'
import {
buildPluginApprovalRequest,
formatPluginApprovalMessage
} from '../approval/plugin-cli-approval'
function manifest(statusBarItems: unknown[], engines = '>=1.4.248') {
return {
manifestVersion: 2,
execution: 'declarative',
id: 'status-demo',
name: 'Status demo',
version: '1.0.0',
engines: { orca: engines },
contributes: {
commands: [{ id: 'demo.tasks', title: 'Open tasks', action: 'view.tasks' }],
statusBarItems
}
}
}
function issues(raw: unknown): string {
const parsed = parsePluginManifest(raw)
return parsed.ok ? '' : parsed.error
}
describe('contributes.statusBarItems', () => {
it('accepts a static item that runs one of the plugin commands', () => {
const parsed = parsePluginManifest(
manifest([
{ id: 'tasks', text: '3 open', icon: 'check', tone: 'warning', command: 'demo.tasks' }
])
)
expect(parsed.ok && parsed.manifest.contributes.statusBarItems).toEqual([
{ id: 'tasks', text: '3 open', icon: 'check', tone: 'warning', command: 'demo.tasks' }
])
})
it('refuses duplicate ids, unknown commands, icons and fields', () => {
expect(
issues(
manifest([
{ id: 'a', text: 'x' },
{ id: 'a', text: 'y' }
])
)
).toContain('duplicate statusBarItems id: a')
expect(issues(manifest([{ id: 'a', command: 'demo.missing' }]))).toContain(
'unknown contributed command: demo.missing'
)
expect(issues(manifest([{ id: 'a', icon: 'not-an-icon' }]))).not.toBe('')
expect(issues(manifest([{ id: 'a', onPress: 'x' }]))).not.toBe('')
})
it('lists items in the catalog summary by text, or id when icon-only', () => {
const parsed = parsePluginManifest(
manifest([
{ id: 'tasks', text: '3 open' },
{ id: 'quiet', icon: 'check' }
])
)
expect(parsed.ok && pluginContributionSummary(parsed.manifest)).toEqual([
{ kind: 'commands', name: 'Open tasks' },
{ kind: 'statusBarItems', name: '3 open' },
{ kind: 'statusBarItems', name: 'quiet' }
])
})
it('lists items in the dev-load and inspect approval, and binds the fingerprint to them', () => {
const subject = (items: unknown[]) => {
const parsed = parsePluginManifest(manifest(items))
if (!parsed.ok) {
throw new Error(parsed.error)
}
return {
pluginKey: 'dev:status-demo',
manifest: parsed.manifest,
consentFingerprint: 'c',
contentIdentity: 'h',
isDev: true
}
}
const request = buildPluginApprovalRequest(
subject([{ id: 'tasks', text: '3 open' }]),
'dev-load'
)
expect(formatPluginApprovalMessage(request)).toContain('statusBarItems "3 open"')
expect(
buildPluginApprovalRequest(subject([{ id: 'tasks', text: '4 open' }]), 'dev-load')
.approvalFingerprint
).not.toBe(request.approvalFingerprint)
})
it('needs an engines floor that reads the field', () => {
expect(issues(manifest([{ id: 'a', text: 'x' }], '>=1.4.247'))).toContain('1.4.248')
})
it('needs only the declarative profile', () => {
const parsed = parsePluginManifest(manifest([{ id: 'a', text: 'x' }]))
expect(parsed.ok && derivePluginExecutionProfile(parsed.manifest)).toBe('declarative')
expect(issues({ ...manifest([{ id: 'a', text: 'x' }]), execution: 'data' })).not.toBe('')
})
it('patches only display fields', () => {
expect(pluginStatusBarItemPatchSchema.safeParse({ text: '4', visible: false }).success).toBe(
true
)
expect(pluginStatusBarItemPatchSchema.safeParse({ command: 'demo.tasks' }).success).toBe(false)
})
})
@@ -0,0 +1,96 @@
import { z, type RefinementCtx } from 'zod'
import { safePluginDisplayText } from '../manifest/plugin-display-text'
import { pluginCommandIdSchema, pluginIdSchema } from '../manifest/plugin-manifest-fields'
import { pluginFieldDoc } from '../manifest/plugin-schema-field-docs'
import { PLUGIN_DECLARATIVE_TONES } from '../declarative/plugin-declarative-prop-schemas'
import { PLUGIN_PANEL_ICONS } from './plugin-panel-contribution'
/** First release that reads `contributes.statusBarItems` and `statusBar.update`. */
export const PLUGIN_STATUS_BAR_SINCE = '1.4.248'
export const PLUGIN_STATUS_BAR_ITEM_LIMIT = 8
export const PLUGIN_STATUS_BAR_TEXT_MAX_LENGTH = 64
export const PLUGIN_STATUS_BAR_TOOLTIP_MAX_LENGTH = 256
const SINCE = PLUGIN_STATUS_BAR_SINCE
/** The fields a worker may change at runtime; the manifest declares their first values. */
export const pluginStatusBarItemDisplayFields = {
text: safePluginDisplayText(z.string().max(PLUGIN_STATUS_BAR_TEXT_MAX_LENGTH))
.optional()
.meta(pluginFieldDoc('Short label shown in the status bar.', SINCE, ['3 blocked'])),
icon: z
.enum(PLUGIN_PANEL_ICONS)
.optional()
.meta(pluginFieldDoc('Icon shown before the text, from the panel icon set.', SINCE, ['bell'])),
tone: z
.enum(PLUGIN_DECLARATIVE_TONES)
.optional()
.meta(pluginFieldDoc('Colour of the item. Defaults to `neutral`.', SINCE, ['warning'])),
tooltip: safePluginDisplayText(z.string().max(PLUGIN_STATUS_BAR_TOOLTIP_MAX_LENGTH))
.optional()
.meta(pluginFieldDoc('Shown on hover. Defaults to the plugin name.', SINCE))
}
export const pluginStatusBarItemContributionSchema = z
.object({
id: pluginIdSchema.meta(
pluginFieldDoc(
"Item id, unique among this plugin's status bar items; `statusBar.update` names it.",
SINCE,
['blocked-count']
)
),
...pluginStatusBarItemDisplayFields,
command: pluginCommandIdSchema
.optional()
.meta(
pluginFieldDoc(
'Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed.',
SINCE,
['guard.showLog']
)
)
})
.strict()
.meta({
id: 'StatusBarItemContribution',
description:
'An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`.'
})
export type PluginStatusBarItemContribution = z.infer<typeof pluginStatusBarItemContributionSchema>
/** What `statusBar.update` sets; an absent field keeps its value, `visible: false` hides it. */
export const pluginStatusBarItemPatchSchema = z.strictObject({
...pluginStatusBarItemDisplayFields,
visible: z.boolean().optional()
})
export type PluginStatusBarItemPatch = z.infer<typeof pluginStatusBarItemPatchSchema>
export function validatePluginStatusBarItems(
contributes: {
commands: readonly { id: string }[]
statusBarItems: readonly { id: string; command?: string }[]
},
ctx: RefinementCtx
): void {
const ids = new Set<string>()
const commands = new Set(contributes.commands.map((command) => command.id))
for (const [index, item] of contributes.statusBarItems.entries()) {
if (ids.has(item.id)) {
ctx.addIssue({
code: 'custom',
path: ['contributes', 'statusBarItems', index],
message: `duplicate statusBarItems id: ${item.id}`
})
}
ids.add(item.id)
if (item.command !== undefined && !commands.has(item.command)) {
ctx.addIssue({
code: 'custom',
path: ['contributes', 'statusBarItems', index, 'command'],
message: `unknown contributed command: ${item.command}`
})
}
}
}
@@ -0,0 +1,9 @@
import type { PluginStatusBarItemPatch } from './plugin-status-bar-item-contribution'
/** Runtime changes workers made, by plugin key then item id; main pushes it to the renderer. */
export type PluginStatusBarOverrides = Readonly<
Record<string, Readonly<Record<string, PluginStatusBarItemPatch>>>
>
/** main → renderer: a new PluginStatusBarOverrides snapshot. */
export const PLUGIN_STATUS_BAR_CHANGED_CHANNEL = 'plugins:statusBarChanged'
@@ -7,6 +7,7 @@ import {
PLUGIN_PANEL_UI_SINCE,
PLUGIN_SCHEMA_SINCE_V1
} from '../manifest/plugin-schema-field-docs'
import { PLUGIN_STATUS_BAR_SINCE } from '../contributions/plugin-status-bar-item-contribution'
/** The newest "since" this build's schemas cite: everything up to it is readable here. */
export const PLUGIN_SCHEMA_NEWEST_SINCE = [
@@ -15,7 +16,8 @@ export const PLUGIN_SCHEMA_NEWEST_SINCE = [
PLUGIN_DERIVED_PUBLISHER_SINCE,
PLUGIN_PANEL_UI_SINCE,
PLUGIN_COMPONENTS_CATALOG_2_SINCE,
PLUGIN_EDITOR_FRAME_SINCE
PLUGIN_EDITOR_FRAME_SINCE,
PLUGIN_STATUS_BAR_SINCE
].reduce((newest, version) => (satisfiesOrcaEngineRange(version, `>=${newest}`) ? version : newest))
/**
@@ -6,6 +6,7 @@ import {
type PluginEventName
} from '../contributions/plugin-event-contribution'
import type { PluginFileMatch } from '../contributions/plugin-file-match'
import { validatePluginStatusBarItems } from '../contributions/plugin-status-bar-item-contribution'
type IdentifiedContribution = { id: string }
type PathContribution = { path: string }
@@ -26,6 +27,7 @@ type ContributionValidationManifest = {
customEditors: (IdentifiedContribution & PluginFileMatch)[]
newFiles: IdentifiedContribution[]
settings: { key: string }[]
statusBarItems: { id: string; command?: string }[]
}
capabilities: { kind: string }[]
}
@@ -149,6 +151,7 @@ export function validatePluginManifestContributions(
identities.forEach((identity) => keybindingIdentities.add(identity))
}
validatePluginStatusBarItems(manifest.contributes, ctx)
const commands = new Map(manifest.contributes.commands.map((command) => [command.id, command]))
for (const [index, command] of manifest.contributes.commands.entries()) {
if (command.action !== undefined && !isPluginCommandAliasActionId(command.action)) {
+3
View File
@@ -1,6 +1,7 @@
import type { PluginCatalogType } from './catalog/plugin-catalog-types'
import type { PluginExecutionProfile } from './contributions/plugin-execution-profile'
import type { PluginHostImages } from './images/plugin-image-url'
import type { PluginStatusBarItemContribution } from './contributions/plugin-status-bar-item-contribution'
/** One installed plugin as main projects it for every client (preload IPC and the `plugins.list`
* RPC). Optional fields are absent on older hosts, so paired clients must tolerate them. */
@@ -87,6 +88,8 @@ export type PluginHostListEntry = {
handler: { type: 'built-in'; action: string } | { type: 'worker' }
keybindings: { key: string; when: 'global' | 'workspace' | 'worktree' }[]
}[]
/** The manifest's status bar items; a worker's changes arrive separately. Absent on older hosts. */
statusBarItems?: PluginStatusBarItemContribution[]
hasWorker: boolean
vmRecipes?: {
id: string
@@ -0,0 +1,26 @@
import { z } from 'zod'
import { pluginIdSchema } from '../manifest/plugin-manifest-fields'
import { pluginStatusBarItemPatchSchema } from '../contributions/plugin-status-bar-item-contribution'
import { spec, WORKER_ONLY } from './plugin-host-method-spec'
const statusBarUpdateParams = z.strictObject({
id: pluginIdSchema,
...pluginStatusBarItemPatchSchema.shape
})
const statusBarUpdateResult = z.object({ ok: z.literal(true) })
/** The plugin's own status bar items; spread into PLUGIN_HOST_API. */
export const PLUGIN_STATUS_BAR_API = {
'statusBar.update': spec({
summary:
"Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled.",
since: 2,
scope: 'plugin-private',
capability: null,
// Why not audited: it only redraws the plugin's own item, possibly many times a minute.
mutation: false,
surfaces: WORKER_ONLY,
params: statusBarUpdateParams,
result: statusBarUpdateResult
})
}
@@ -2,6 +2,7 @@ import { z } from 'zod'
import type { PluginSurface } from '../frames/plugin-protocol'
import * as stored from './plugin-host-api-storage-schemas'
import { PLUGIN_PANEL_BRIDGE_API } from './plugin-host-api-panel-methods'
import { PLUGIN_STATUS_BAR_API } from './plugin-host-api-status-bar'
import {
ALL_SURFACES,
FRAMES_ONLY,
@@ -299,7 +300,8 @@ export const PLUGIN_HOST_API = {
params: filesSaveAsParams,
result: filesSaveAsResult
}),
...PLUGIN_PANEL_BRIDGE_API
...PLUGIN_PANEL_BRIDGE_API,
...PLUGIN_STATUS_BAR_API
}
export type PluginHostApi = typeof PLUGIN_HOST_API