Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions docs/plugins/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,20 @@ loading skeletons instantly while probes execute asynchronously.
| `type` | string | Yes | One of: `text`, `progress`, `badge`, `barChart` |
| `label` | string | Yes | Static label shown in the UI for this line |
| `scope` | string | Yes | `"overview"` or `"detail"` - where line appears |
| `primary` | boolean | No | If `true`, this progress line appears in tray icon |
| `primaryOrder` | number | No | Lower number = higher priority; orders this progress line among the tray-icon candidates (see below) |
| `period` | string | No | `"weekly"` marks this line as the provider's weekly metric (see below) |

- `"overview"` - shown on both Overview tab and plugin detail pages
- `"detail"` - shown only on plugin detail pages

### Primary Progress (Tray Icon)

Plugins can optionally mark one progress line as `primary: true`. This progress metric will be displayed as a horizontal bar in the system tray icon, allowing users to see usage at a glance without opening the app.
Progress lines opt into the system tray icon by setting `primaryOrder` (a number). Lines are sorted by `primaryOrder` into an ordered list of candidates, and the tray shows the **first candidate that has runtime data** — falling back to the next when an earlier one is absent. This lets a provider prefer a short-window metric but degrade gracefully when it isn't reported.

Rules:
- Only `type: "progress"` lines can be primary (the flag is ignored on other types)
- Only the **first** `primary: true` line is used (subsequent ones are ignored)
- Up to 4 enabled plugins with primary progress are shown in the tray (in plugin order)
- Only `type: "progress"` lines are candidates (`primaryOrder` is ignored on other types)
- Lower `primaryOrder` wins; the frontend walks the ordered list and uses the first one present in live data
- Up to 4 enabled plugins are shown in the tray (in plugin order)
- If no data is available yet, the bar shows as a track without fill

Example:
Expand All @@ -116,13 +117,28 @@ Example:
{
"lines": [
{ "type": "badge", "label": "Plan", "scope": "overview" },
{ "type": "progress", "label": "Plan usage", "scope": "overview", "primary": true },
{ "type": "progress", "label": "Extra", "scope": "detail" },
{ "type": "progress", "label": "Plan usage", "scope": "overview", "primaryOrder": 1 },
{ "type": "progress", "label": "Overage", "scope": "overview", "primaryOrder": 2 },
{ "type": "text", "label": "Resets", "scope": "detail" }
]
}
```

### Weekly Metric (Menubar)

A provider can mark one progress line with `"period": "weekly"`. When the user sets the menubar metric to **Weekly** (Settings → Menubar Icon), the tray icon and tooltip show this line instead of the provider's primary metric.

It is an **override of the primary metric**, not a standalone mode: the provider must still define a primary (`primaryOrder`) line — a provider with *only* a weekly line will not appear in the menubar. Providers without a weekly line keep showing their primary. `period` only recognizes `"weekly"` (other values are ignored), and only the first `"period": "weekly"` line is used.

```json
{
"lines": [
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
{ "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly" }
]
}
```

## Entry Point Structure

Plugins must register themselves on the global object:
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"lines": [
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
{ "type": "progress", "label": "Weekly", "scope": "overview", "primaryOrder": 2 },
{ "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly", "primaryOrder": 2 },
{ "type": "progress", "label": "Sonnet", "scope": "detail" },
{ "type": "progress", "label": "Claude Design", "scope": "detail" },
{ "type": "progress", "label": "Extra usage spent", "scope": "overview", "primaryOrder": 3 },
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"lines": [
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
{ "type": "progress", "label": "Weekly", "scope": "overview" },
{ "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly" },
{ "type": "progress", "label": "Spark", "scope": "detail" },
{ "type": "progress", "label": "Spark Weekly", "scope": "detail" },
{ "type": "progress", "label": "Reviews", "scope": "detail" },
Expand Down
2 changes: 1 addition & 1 deletion plugins/devin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"icon": "icon.svg",
"brandColor": "#000000",
"lines": [
{ "type": "progress", "label": "Weekly quota", "scope": "overview", "primaryOrder": 1 },
{ "type": "progress", "label": "Weekly quota", "scope": "overview", "period": "weekly", "primaryOrder": 1 },
{ "type": "progress", "label": "Daily quota", "scope": "overview" },
{ "type": "text", "label": "Extra usage balance", "scope": "detail" }
]
Expand Down
2 changes: 1 addition & 1 deletion plugins/kimi/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
"brandColor": "#000000",
"lines": [
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
{ "type": "progress", "label": "Weekly", "scope": "overview", "primaryOrder": 2 }
{ "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly", "primaryOrder": 2 }
]
}
3 changes: 2 additions & 1 deletion plugins/opencode-go/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
{
"type": "progress",
"label": "Weekly",
"scope": "overview"
"scope": "overview",
"period": "weekly"
},
{
"type": "progress",
Expand Down
2 changes: 1 addition & 1 deletion plugins/opencode-go/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe("opencode-go plugin", () => {
]);
expect(manifest.lines).toEqual([
{ type: "progress", label: "Session", scope: "overview", primaryOrder: 1 },
{ type: "progress", label: "Weekly", scope: "overview" },
{ type: "progress", label: "Weekly", scope: "overview", period: "weekly" },
{ type: "progress", label: "Monthly", scope: "detail" },
]);
});
Expand Down
2 changes: 1 addition & 1 deletion plugins/zai/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"brandColor": "#2D2D2D",
"lines": [
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
{ "type": "progress", "label": "Weekly", "scope": "overview" },
{ "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly" },
{ "type": "progress", "label": "Web Searches", "scope": "overview" }
]
}
9 changes: 9 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ pub struct PluginMeta {
/// Ordered list of primary metric candidates (sorted by primaryOrder).
/// Frontend picks the first one that exists in runtime data.
pub primary_candidates: Vec<String>,
/// Label of the progress line marked `"period": "weekly"`, if any.
/// Drives the menubar weekly-metric preference.
pub weekly_candidate: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -465,6 +468,11 @@ fn list_plugins(state: tauri::State<'_, Mutex<AppState>>) -> Vec<PluginMeta> {
let primary_candidates: Vec<String> =
candidates.iter().map(|line| line.label.clone()).collect();

// The weekly metric is the progress line declared `"period": "weekly"`.
let weekly_candidate: Option<String> =
plugin_engine::manifest::weekly_candidate(&plugin.manifest.lines)
.map(str::to_string);

PluginMeta {
id: plugin.manifest.id,
name: plugin.manifest.name,
Expand All @@ -490,6 +498,7 @@ fn list_plugins(state: tauri::State<'_, Mutex<AppState>>) -> Vec<PluginMeta> {
})
.collect(),
primary_candidates,
weekly_candidate,
}
})
.collect()
Expand Down
128 changes: 127 additions & 1 deletion src-tauri/src/plugin_engine/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ pub struct ManifestLine {
/// Lower number = higher priority for primary metric selection.
/// Only progress lines with primary_order are candidates.
pub primary_order: Option<u32>,
/// Marks this line as the provider's recurring-period metric for the
/// menubar metric preference. Currently only "weekly" is recognized.
pub period: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -69,6 +72,15 @@ pub fn load_plugins_from_dir(plugins_dir: &std::path::Path) -> Vec<LoadedPlugin>
plugins
}

/// Label of the progress line marked `"period": "weekly"`, if any.
/// Drives the menubar weekly-metric preference; first match wins.
pub fn weekly_candidate(lines: &[ManifestLine]) -> Option<&str> {
lines
.iter()
.find(|line| line.line_type == "progress" && line.period.as_deref() == Some("weekly"))
.map(|line| line.label.as_str())
}

fn load_single_plugin(
plugin_dir: &std::path::Path,
) -> Result<LoadedPlugin, Box<dyn std::error::Error>> {
Expand All @@ -77,7 +89,8 @@ fn load_single_plugin(
let mut manifest: PluginManifest = serde_json::from_str(&manifest_text)?;
manifest.links = sanitize_plugin_links(&manifest.id, std::mem::take(&mut manifest.links));

// Validate primary_order: only progress lines can have it
// Validate primary_order / period: only progress lines can carry them,
// and period currently only recognizes "weekly".
for line in manifest.lines.iter() {
if line.primary_order.is_some() && line.line_type != "progress" {
log::warn!(
Expand All @@ -87,6 +100,23 @@ fn load_single_plugin(
line.line_type
);
}
if let Some(period) = line.period.as_deref() {
if line.line_type != "progress" {
log::warn!(
"plugin {} line '{}' has period but type is '{}'; will be ignored",
manifest.id,
line.label,
line.line_type
);
} else if period != "weekly" {
log::warn!(
"plugin {} line '{}' has unsupported period '{}'; only \"weekly\" is recognized",
manifest.id,
line.label,
period
);
}
}
}

if manifest.entry.trim().is_empty() {
Expand Down Expand Up @@ -240,6 +270,102 @@ mod tests {
assert_eq!(labels, vec!["First", "Second", "Third"]);
}

#[test]
fn period_parsed_and_weekly_candidate_resolved() {
let manifest = parse_manifest(
r#"
{
"schemaVersion": 1,
"id": "x",
"name": "X",
"version": "0.0.1",
"entry": "plugin.js",
"icon": "icon.svg",
"brandColor": null,
"lines": [
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
{ "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly" }
]
}
"#,
);

assert!(manifest.lines[0].period.is_none());
assert_eq!(manifest.lines[1].period.as_deref(), Some("weekly"));

// Exercise the shipped resolver used by list_plugins.
assert_eq!(weekly_candidate(&manifest.lines), Some("Weekly"));
}

#[test]
fn weekly_candidate_absent_when_no_period() {
let manifest = parse_manifest(
r#"
{
"schemaVersion": 1,
"id": "x",
"name": "X",
"version": "0.0.1",
"entry": "plugin.js",
"icon": "icon.svg",
"brandColor": null,
"lines": [
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }
]
}
"#,
);

assert_eq!(weekly_candidate(&manifest.lines), None);
}

#[test]
fn weekly_candidate_first_match_wins() {
// Precedence is intentionally first-match; lock it in so it can't drift silently.
let manifest = parse_manifest(
r#"
{
"schemaVersion": 1,
"id": "x",
"name": "X",
"version": "0.0.1",
"entry": "plugin.js",
"icon": "icon.svg",
"brandColor": null,
"lines": [
{ "type": "progress", "label": "Weekly A", "scope": "overview", "period": "weekly" },
{ "type": "progress", "label": "Weekly B", "scope": "overview", "period": "weekly" }
]
}
"#,
);

assert_eq!(weekly_candidate(&manifest.lines), Some("Weekly A"));
}

#[test]
fn weekly_candidate_ignores_unsupported_period() {
// A typo'd period (e.g. "week") is not recognized; the provider keeps its primary metric.
let manifest = parse_manifest(
r#"
{
"schemaVersion": 1,
"id": "x",
"name": "X",
"version": "0.0.1",
"entry": "plugin.js",
"icon": "icon.svg",
"brandColor": null,
"lines": [
{ "type": "progress", "label": "Weekly", "scope": "overview", "period": "week" }
]
}
"#,
);

assert_eq!(weekly_candidate(&manifest.lines), None);
}
Comment thread
michaljuris marked this conversation as resolved.

#[test]
fn links_are_parsed_when_present() {
let manifest = parse_manifest(
Expand Down
Loading
Loading