pantheon-opencode 1.1.1 → 1.2.0-beta.9.7bef03e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +74 -1
  2. package/bin/pantheon-init.mjs +79 -0
  3. package/commands/pantheon-audit.md +25 -0
  4. package/commands/pantheon-consolidate.md +11 -0
  5. package/commands/pantheon-deepwork.md +79 -0
  6. package/commands/pantheon-optimize.md +79 -0
  7. package/commands/pantheon-vision.md +67 -0
  8. package/commands/pantheon.md +49 -0
  9. package/opencode.json +12 -41
  10. package/package.json +5 -1
  11. package/scripts/doctor.mjs +4 -2
  12. package/scripts/install/health-check.mjs +101 -1
  13. package/scripts/install/model-picker.mjs +118 -0
  14. package/scripts/install/opencode.mjs +98 -17
  15. package/scripts/install-mcp.mjs +98 -13
  16. package/scripts/mcp_persistence_server.py +331 -1
  17. package/scripts/secret-scan.mjs +76 -0
  18. package/scripts/validate-routing.mjs +15 -0
  19. package/src/agents/aphrodite.md +7 -0
  20. package/src/agents/demeter.md +7 -0
  21. package/src/agents/hephaestus.md +7 -0
  22. package/src/agents/hermes.md +7 -0
  23. package/src/agents/zeus.md +31 -18
  24. package/src/instructions/agent-return-format.instructions.md +1 -0
  25. package/src/instructions/zeus-anti-stall.instructions.md +43 -5
  26. package/src/instructions/zeus-timeout-retry.instructions.md +47 -10
  27. package/src/mcp/install-mcp.mjs +6 -913
  28. package/src/mcp/mcp_persistence_server.py +332 -0
  29. package/src/mcp/pantheon_vision_server.py +495 -0
  30. package/src/mcp/requirements-vision.txt +3 -0
  31. package/src/pantheon/auto-wake.ts +91 -0
  32. package/src/pantheon/background-job-board.ts +568 -0
  33. package/src/pantheon/file-persistence.ts +95 -0
  34. package/src/pantheon/presets.d.mts +99 -0
  35. package/src/pantheon/presets.mjs +491 -0
  36. package/src/pantheon/vision.ts +1340 -0
  37. package/src/plugin.ts +40 -25
  38. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  39. package/src/plugins/tui/dist/tui.js +806 -222
  40. package/src/plugins/tui/dist/tui.tsx +866 -121
  41. package/src/plugins/tui/package.json +2 -2
  42. package/src/plugins/tui/src/index.tsx +866 -121
  43. package/src/routing.yml +179 -0
  44. package/commands/pantheon-reflect.md +0 -16
  45. package/commands/pantheon-verify.md +0 -17
@@ -2,13 +2,58 @@
2
2
  // biome-ignore-all lint/a11y/noStaticElementInteractions: TUI elements, not DOM
3
3
  /** @jsxImportSource @opentui/solid */
4
4
 
5
+ /**
6
+ * pantheon-tui — Pantheon TUI plugin for opencode.
7
+ *
8
+ * Single self-contained entry file: `tsdown && cp src/index.tsx dist/tui.tsx`
9
+ * keeps the no-build load path working (dist/tui.tsx is raw source, transpiled
10
+ * on the fly by opencode). All feature code lives in this one file.
11
+ *
12
+ * Slots:
13
+ * - sidebar_content (order 900) — Pantheon sidebar (header/version/
14
+ * branch, Sessions, Commands, Agents, Config, Memory).
15
+ * - app_bottom (order 60) — AI subscription usage gauges
16
+ * (Anthropic/OpenAI quotas, OpenCode Go/Zen dollar limits + provider
17
+ * status incidents).
18
+ *
19
+ * ─────────────────────────────────────────────────────────────────────────────
20
+ * VENDORED FEATURES (MIT) — incorporated with their license headers preserved:
21
+ *
22
+ * • satas20/opencode-usage-bar (MIT)
23
+ * https://github.com/satas20/opencode-usage-bar
24
+ *
25
+ * v1.2.0 added an OpenCode Go/Zen usage provider (`opencodego`) on top of
26
+ * the vendored base — dollar-denominated rolling limits ($12/5h, $30/7d,
27
+ * $60/month) via the same key used for inference. Still MIT-credited to
28
+ * satas20 for the original bar/config/poll code.
29
+ *
30
+ * satas20/opencode-todo-progress was vendored in v1.1.0 alongside the
31
+ * usage-bar but has since been REMOVED as redundant — the native session
32
+ * footer already surfaces todo/context state, so its todo bar slot was
33
+ * dropped entirely. Only usage-bar code remains.
34
+ * ─────────────────────────────────────────────────────────────────────────────
35
+ *
36
+ * LIMITATION — native context/tokens statusline:
37
+ * The opencode session footer natively shows a context/tokens statusline
38
+ * next to the prompt. There is NO config option to hide it: the tui.json
39
+ * schema (opencode.ai/tui.json) only exposes `theme`, `keybinds`,
40
+ * `scroll_speed`, `scroll_acceleration`, `diff_style`, `mouse` and
41
+ * `attention`, and the SDK `Config` type has no statusline/tokens display
42
+ * flag (checked against @opencode-ai/sdk `Config` and the published
43
+ * schema). The Pantheon sidebar therefore no longer duplicates that footer
44
+ * (the old ContextBar was removed) and we deliberately do NOT hack the
45
+ * native footer. Track upstream: opencode statusline config.
46
+ */
47
+
48
+ import { Buffer } from 'node:buffer'
49
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
50
+ import { homedir } from 'node:os'
51
+ import { dirname, join } from 'node:path'
5
52
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'
6
53
  import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
7
54
 
8
55
  /* ─── Constants ─────────────────────────────────────────── */
9
56
 
10
- const BAR_WIDTH = 20
11
-
12
57
  const COMMANDS = [
13
58
  { name: '/pantheon', desc: 'Council synthesis' },
14
59
  { name: '/pantheon-status', desc: 'System status' },
@@ -23,6 +68,10 @@ const COMMANDS = [
23
68
  { name: '/pantheon-forget', desc: 'Compress memories' },
24
69
  ] as const
25
70
 
71
+ /** How often the sidebar re-reads .pantheon/active-preset.json so `set-tier`
72
+ * changes made while opencode is open show up within ~30s. */
73
+ const PRESET_REFRESH_MS = 30_000
74
+
26
75
  const AGENTS = [
27
76
  { name: 'zeus', tier: 'default' as const, role: 'Orchestrator' },
28
77
  { name: 'athena', tier: 'premium' as const, role: 'Strategic planner' },
@@ -42,37 +91,68 @@ const AGENTS = [
42
91
 
43
92
  /* ─── Helpers ───────────────────────────────────────────── */
44
93
 
45
- function safeNum(v: unknown): number {
46
- return typeof v === 'number' && Number.isFinite(v) ? v : 0
47
- }
48
-
49
94
  function fmtInt(n: number): string {
50
95
  return Intl.NumberFormat('en-US').format(Math.max(0, Math.round(n)))
51
96
  }
52
97
 
53
- function fmtCost(v: number): string {
54
- if (v <= 0) return ''
55
- if (v < 0.01) return '<$0.01'
56
- return `$${v.toFixed(2)}`
57
- }
98
+ /* ─── Active Preset Detection ──────────────────────────────
99
+ * Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the TUI
100
+ * plugin is self-contained (dist/tui.tsx is a raw copy of this file with no
101
+ * relative imports), so the resolution logic is mirrored here rather than
102
+ * importing presets.mjs.
103
+ *
104
+ * Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
105
+ * Custom/unknown preset names are shown as-is (a preset may be defined that
106
+ * is not in the built-in list). "none" disables → default. */
107
+
108
+ type PresetInfo = { name: string | null; source: 'env' | 'file' | null }
58
109
 
59
- function msgTokens(msg: any): number {
60
- const t = msg?.tokens ?? msg?.info?.tokens ?? {}
61
- return safeNum(t.input) + safeNum(t.output) + safeNum(t.reasoning)
62
- + safeNum(t?.cache?.read) + safeNum(t?.cache?.write)
110
+ /** Read .pantheon/active-preset.json — mirrors the presets.mjs file leg:
111
+ * first existing candidate wins; malformed JSON or a `preset` that is
112
+ * missing, empty or "none" → null (no fall-through to lower candidates). */
113
+ async function readActivePresetFile(cwd: string): Promise<{ name: string; source: 'file' } | null> {
114
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config')
115
+ const candidates = [
116
+ join(cwd, '.pantheon', 'active-preset.json'),
117
+ join(xdgConfig, 'opencode', '.pantheon', 'active-preset.json'),
118
+ join(homedir(), '.opencode', '.pantheon', 'active-preset.json'),
119
+ ]
120
+ for (const candidate of candidates) {
121
+ let raw: string
122
+ try {
123
+ raw = await readFile(candidate, 'utf8')
124
+ } catch {
125
+ continue // candidate does not exist — try the next one
126
+ }
127
+ try {
128
+ const parsed = JSON.parse(raw) as { preset?: unknown }
129
+ const name = parsed && typeof parsed === 'object' ? parsed.preset : undefined
130
+ if (typeof name !== 'string' || name.length === 0 || name === 'none') return null
131
+ return { name, source: 'file' }
132
+ } catch {
133
+ return null // malformed file — treat as default (mirrors presets.mjs)
134
+ }
135
+ }
136
+ return null
63
137
  }
64
138
 
65
- function msgCost(src: any): number {
66
- for (const c of [src?.cost, src?.info?.cost, src?.usage?.cost]) {
67
- if (typeof c === 'number' && c > 0) return c
139
+ /** Env leg of resolution: PANTHEON_MODEL_PRESET set and !== 'none' wins. */
140
+ function presetFromEnv(env: Record<string, string | undefined>): PresetInfo {
141
+ const name = env.PANTHEON_MODEL_PRESET
142
+ if (name !== undefined && name !== '' && name !== 'none') {
143
+ return { name, source: 'env' }
68
144
  }
69
- return 0
145
+ return { name: null, source: null }
70
146
  }
71
147
 
72
- function buildBar(pct: number): string {
73
- const clamped = Math.max(0, Math.min(100, pct))
74
- const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((clamped / 100) * BAR_WIDTH)))
75
- return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
148
+ /** Resolve the active preset for the sidebar: env > file > default. */
149
+ async function resolvePresetForTui(
150
+ env: Record<string, string | undefined>,
151
+ cwd: string,
152
+ ): Promise<PresetInfo> {
153
+ const envPreset = presetFromEnv(env)
154
+ if (envPreset.source === 'env') return envPreset
155
+ return (await readActivePresetFile(cwd)) ?? { name: null, source: null }
76
156
  }
77
157
 
78
158
  /* ─── Version Detection ────────────────────────────────────
@@ -90,21 +170,33 @@ async function detectVersion(api: TuiPluginApi): Promise<string | null> {
90
170
  try {
91
171
  const wt = ((api.state as any).path?.worktree ?? '') as string
92
172
  const fp = wt ? `${wt}/package.json` : 'package.json'
93
- const result = await api.client.file.read({ query: { path: fp } })
94
- const match = String(result?.content ?? '').match(/"version":\s*"([^"]+)"/)
173
+ const result = await api.client.file.read({ path: fp })
174
+ const content = String(result?.data?.content ?? '')
175
+ const match = content.match(/"version":\s*"([^"]+)"/)
95
176
  if (match?.[1]) return match[1]
96
- } catch { /* fall through */ }
177
+ } catch {
178
+ /* fall through */
179
+ }
97
180
 
98
181
  // Try 2: git describe --tags
99
182
  try {
100
183
  const proc = (api as any).client?.process
101
184
  if (typeof proc?.exec === 'function') {
102
- const r = await proc.exec({ command: 'git', args: ['describe', '--tags', '--always'], timeoutMs: 3000 })
185
+ const r = await proc.exec({
186
+ command: 'git',
187
+ args: ['describe', '--tags', '--always'],
188
+ timeoutMs: 3000,
189
+ })
103
190
  const stdout = (r.stdout ?? r.output ?? '') as string
104
- const tag = stdout.trim().replace(/^v/, '').replace(/-\d+-g[0-9a-f]+$/, '')
191
+ const tag = stdout
192
+ .trim()
193
+ .replace(/^v/, '')
194
+ .replace(/-\d+-g[0-9a-f]+$/, '')
105
195
  if (tag && tag !== '5.0.0') return tag
106
196
  }
107
- } catch { /* fall through */ }
197
+ } catch {
198
+ /* fall through */
199
+ }
108
200
 
109
201
  // Try 3: opencode --version
110
202
  try {
@@ -115,71 +207,655 @@ async function detectVersion(api: TuiPluginApi): Promise<string | null> {
115
207
  const ver = stdout.trim().replace(/^v/, '')
116
208
  if (ver) return ver
117
209
  }
118
- } catch { /* fall through */ }
210
+ } catch {
211
+ /* fall through */
212
+ }
119
213
 
120
214
  return null // sem fallback — versão não aparece
121
215
  }
122
216
 
123
- /* ─── Context Bar ──────────────────────────────────────────
124
- * Inspired by streetturtle/opencode-better-sidebar (context-progress)
125
- * Shows: progress bar, token usage / limit, session cost */
217
+ /* ──────────────────────────────────────────────────────────
218
+ * VENDORED: opencode-usage-bar (MIT) — satas20
219
+ * https://github.com/satas20/opencode-usage-bar
220
+ * ────────────────────────────────────────────────────────── */
126
221
 
127
- function ContextBar(props: { api: TuiPluginApi; sessionID: string }) {
128
- const messages = createMemo((): any[] => {
129
- try { return (props.api.state.session as any).messages(props.sessionID) ?? [] }
130
- catch { return [] }
131
- })
222
+ /**
223
+ * usage-bar — AI subscription usage gauge for the opencode TUI.
224
+ *
225
+ * Renders a compact usage strip in the `app_bottom` slot — a full-width row
226
+ * just below the session footer:
227
+ *
228
+ * ▓▓▓▓░░ 65% · 0h 11m (one window)
229
+ * cld ▓▓▓▓░ 65% · 0h 11m 7d ▓░░░░ 19% · 1d 11h oai ▓░░░░ 12% · 3h 4m
230
+ * ! cld ▓▓▓▓░ 65% · 0h 11m (anthropic incident)
231
+ *
232
+ * Providers:
233
+ * anthropic — Claude Pro/Max via the OAuth token in ~/.claude/.credentials.json
234
+ * openai — ChatGPT Plus/Pro via the Codex CLI login in ~/.codex/auth.json
235
+ * opencodego— OpenCode Go/Zen dollar limits ($12/5h, $30/7d, $60/mo) via
236
+ * PANTHEON_OPENCODE_API_KEY (fallback OPENCODE_API_KEY). No
237
+ * status page — the provider hides silently when unconfigured
238
+ * or when the (not yet public) usage endpoint is unavailable.
239
+ *
240
+ * When a provider's public status page reports an incident, a colored `!`
241
+ * marker appears next to its prefix (red = major/critical, amber = minor,
242
+ * cyan = maintenance). The usage bar itself is unaffected. Disable with
243
+ * `show_status = false` under `[ui]`.
244
+ *
245
+ * Configured via ~/.config/opencode/usage-bar.toml (auto-created with
246
+ * commented defaults on first run; read once at startup). Tokens/keys are
247
+ * only ever sent to their own provider's API host.
248
+ *
249
+ * Loaded via tui.json, e.g.:
250
+ * { "plugin": ["@satas/opencode-usage-bar"] } // published npm package
251
+ * { "plugin": ["/abs/path/to/src/index.tsx"] } // local file (no build)
252
+ */
132
253
 
133
- const usage = createMemo(() => {
134
- const msgs = messages()
135
- const lastAssistant = [...msgs].reverse().find((m: any) => {
136
- const role = m?.role ?? m?.info?.role
137
- return role === 'assistant' && safeNum(m?.tokens?.output) > 0
138
- })
139
- if (!lastAssistant) return null
140
-
141
- const tokens = msgTokens(lastAssistant)
142
- const pid = lastAssistant?.providerID ?? lastAssistant?.info?.providerID
143
- const mid = lastAssistant?.modelID ?? lastAssistant?.info?.modelID
144
- const provider = pid
145
- ? ((props.api.state as any).provider ?? []).find((p: any) => p.id === pid)
146
- : null
147
- const ctxLimit = safeNum(provider?.models?.[mid ?? '']?.limit?.context)
148
- const limit = ctxLimit > 0 ? ctxLimit : 200000
149
- return { tokens, limit, percent: ctxLimit > 0 ? Math.round((tokens / ctxLimit) * 100) : 0 }
150
- })
254
+ const POLL_MS = 120_000
255
+ const FETCH_TIMEOUT_MS = 10_000
256
+ const CONFIG_FILE = 'usage-bar.toml'
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Types
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /** Which quota window a value belongs to; toggled per provider in config. */
263
+ type WindowCategory = '5h' | '7d' | '1m' | 'model'
264
+
265
+ /** Provider health from the vendor's public status page (Statuspage schema). */
266
+ type StatusIndicator = 'none' | 'minor' | 'major' | 'critical' | 'maintenance'
267
+
268
+ type UsageWindow = {
269
+ category: WindowCategory
270
+ /** short display label, e.g. "5h", "7d", "Fable" */
271
+ label: string
272
+ /** 0–100 percent of the window used */
273
+ percent: number
274
+ /** epoch ms when the window resets */
275
+ resetsAt: number
276
+ }
277
+
278
+ type ProviderId = 'anthropic' | 'openai' | 'opencodego'
279
+
280
+ type ProviderConfig = {
281
+ enabled: boolean
282
+ show: Record<WindowCategory, boolean>
283
+ /** anthropic: path to .credentials.json */
284
+ credentialsPath?: string
285
+ /** openai: path to Codex auth.json */
286
+ codexAuthPath?: string
287
+ }
288
+
289
+ type UsageBarConfig = {
290
+ showBars: boolean
291
+ showStatus: boolean
292
+ barWidth?: number
293
+ providers: Record<ProviderId, ProviderConfig>
294
+ }
295
+
296
+ type Provider = {
297
+ id: ProviderId
298
+ /** short prefix shown when multiple providers are visible */
299
+ short: string
300
+ /** vendor status page JSON endpoint (Statuspage `status.json`); optional */
301
+ statusUrl?: string
302
+ fetchUsage(cfg: ProviderConfig): Promise<UsageWindow[] | null>
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Helpers
307
+ // ---------------------------------------------------------------------------
308
+
309
+ function expandTilde(p: string) {
310
+ return p === '~' ? homedir() : p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
311
+ }
312
+
313
+ function fmtDuration(ms: number) {
314
+ const totalMinutes = Math.max(0, Math.ceil(ms / 60_000))
315
+ const days = Math.floor(totalMinutes / 1440)
316
+ const hours = Math.floor((totalMinutes % 1440) / 60)
317
+ const minutes = totalMinutes % 60
318
+ if (days > 0) return `${days}d ${hours}h`
319
+ return `${hours}h ${minutes}m`
320
+ }
321
+
322
+ /** Decode a JWT's `exp` claim (unix seconds) without verifying. 0 on failure. */
323
+ function jwtExp(token: string): number {
324
+ try {
325
+ const payload = token.split('.')[1]
326
+ if (!payload) return 0
327
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
328
+ exp?: number
329
+ }
330
+ return typeof claims.exp === 'number' ? claims.exp : 0
331
+ } catch {
332
+ return 0
333
+ }
334
+ }
335
+
336
+ async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown | null> {
337
+ try {
338
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
339
+ if (!res.ok) return null
340
+ return await res.json()
341
+ } catch {
342
+ return null
343
+ }
344
+ }
345
+
346
+ /** Fetch a vendor's overall status from its public Statuspage JSON endpoint.
347
+ * Returns the `indicator` ("none" when healthy), or `null` when the fetch
348
+ * itself failed — so a network blip never clears a known incident. */
349
+ async function fetchStatus(url: string): Promise<StatusIndicator | null> {
350
+ const data = (await fetchJson(url, {})) as {
351
+ status?: { indicator?: string }
352
+ } | null
353
+ if (!data?.status) return null
354
+ const indicator = data.status.indicator
355
+ return indicator === 'minor' ||
356
+ indicator === 'major' ||
357
+ indicator === 'critical' ||
358
+ indicator === 'maintenance'
359
+ ? indicator
360
+ : 'none'
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // opencode auth store (fallback credential source)
365
+ // ---------------------------------------------------------------------------
366
+
367
+ type OpencodeAuthEntry = {
368
+ type?: string
369
+ access?: string
370
+ expires?: number // epoch ms; 0 = no expiry
371
+ accountId?: string
372
+ }
373
+
374
+ /** Set from `api.state.path.state` at startup; default matches opencode's
375
+ * XDG data dir. */
376
+ let opencodeAuthFile = join(homedir(), '.local', 'share', 'opencode', 'auth.json')
377
+
378
+ /** Read a provider's entry from opencode's own auth store
379
+ * (`opencode auth login`). Returns null when missing/unreadable. */
380
+ async function opencodeAuth(...ids: string[]): Promise<OpencodeAuthEntry | null> {
381
+ try {
382
+ const auth = JSON.parse(await readFile(opencodeAuthFile, 'utf8')) as Record<
383
+ string,
384
+ OpencodeAuthEntry
385
+ >
386
+ for (const id of ids) {
387
+ const entry = auth[id]
388
+ if (entry) return entry
389
+ }
390
+ return null
391
+ } catch {
392
+ return null
393
+ }
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Providers
398
+ // ---------------------------------------------------------------------------
399
+
400
+ /** Claude Pro/Max — Anthropic's OAuth usage endpoint (same one Claude Code's
401
+ * `/usage` uses). Token from ~/.claude/.credentials.json, falling back to
402
+ * opencode's own auth store; sent only to api.anthropic.com. */
403
+ const anthropicProvider: Provider = {
404
+ id: 'anthropic',
405
+ short: 'cld',
406
+ statusUrl: 'https://status.anthropic.com/api/v2/status.json',
407
+ async fetchUsage(cfg) {
408
+ let token: string | undefined
409
+ try {
410
+ const path = expandTilde(
411
+ cfg.credentialsPath ?? join(homedir(), '.claude', '.credentials.json'),
412
+ )
413
+ const creds = JSON.parse(await readFile(path, 'utf8')) as {
414
+ claudeAiOauth?: { accessToken?: string; expiresAt?: number }
415
+ }
416
+ const oauth = creds.claudeAiOauth
417
+ if (oauth?.accessToken && !(oauth.expiresAt && Date.now() >= oauth.expiresAt))
418
+ token = oauth.accessToken
419
+ } catch {
420
+ // fall through to opencode auth
421
+ }
422
+ if (!token) {
423
+ const entry = await opencodeAuth('anthropic')
424
+ if (entry?.access && !(entry.expires && Date.now() >= entry.expires)) token = entry.access
425
+ }
426
+ if (!token) return null
427
+
428
+ const data = (await fetchJson('https://api.anthropic.com/api/oauth/usage', {
429
+ authorization: `Bearer ${token}`,
430
+ 'anthropic-beta': 'oauth-2025-04-20',
431
+ })) as {
432
+ limits?: Array<{
433
+ kind?: string
434
+ percent?: number
435
+ resets_at?: string
436
+ scope?: { model?: { display_name?: string | null } | null } | null
437
+ }>
438
+ } | null
439
+ if (!data || !Array.isArray(data.limits)) return null
440
+
441
+ const windows: UsageWindow[] = []
442
+ for (const limit of data.limits) {
443
+ if (!limit || typeof limit.percent !== 'number' || !Number.isFinite(limit.percent)) continue
444
+ if (!limit.kind || !limit.resets_at) continue
445
+ const resetsAt = Date.parse(limit.resets_at)
446
+ if (Number.isNaN(resetsAt)) continue
447
+ const category: WindowCategory =
448
+ limit.kind === 'session' ? '5h' : limit.kind === 'weekly_all' ? '7d' : 'model'
449
+ const label = category === 'model' ? (limit.scope?.model?.display_name ?? 'model') : category
450
+ windows.push({ category, label, percent: limit.percent, resetsAt })
451
+ }
452
+ // Session window first, then the rest in API order.
453
+ windows.sort((a, b) => Number(b.category === '5h') - Number(a.category === '5h'))
454
+ return windows.length > 0 ? windows : null
455
+ },
456
+ }
457
+
458
+ /** ChatGPT Plus/Pro (Codex) — reads the Codex CLI login and asks the wham
459
+ * usage endpoint. Read-only: never refreshes/rewrites auth.json; when the
460
+ * token is expired we simply hide (Codex CLI refreshes the file itself). */
461
+ const openaiProvider: Provider = {
462
+ id: 'openai',
463
+ short: 'oai',
464
+ statusUrl: 'https://status.openai.com/api/v2/status.json',
465
+ async fetchUsage(cfg) {
466
+ let accessToken: string | undefined
467
+ let accountId: string | undefined
468
+ try {
469
+ const path = expandTilde(cfg.codexAuthPath ?? join(homedir(), '.codex', 'auth.json'))
470
+ const auth = JSON.parse(await readFile(path, 'utf8')) as {
471
+ tokens?: { access_token?: string; id_token?: string; account_id?: string }
472
+ }
473
+ const tokens = auth.tokens
474
+ // Check the expiry of the token we actually send (the access token);
475
+ // the id_token expires much earlier and would cause false negatives.
476
+ const exp = tokens?.access_token ? jwtExp(tokens.access_token) : 0
477
+ if (tokens?.access_token && !(exp > 0 && exp * 1000 <= Date.now() + 60_000)) {
478
+ accessToken = tokens.access_token
479
+ accountId = tokens.account_id
480
+ }
481
+ } catch {
482
+ // fall through to opencode auth
483
+ }
484
+ if (!accessToken) {
485
+ const entry = await opencodeAuth('openai')
486
+ if (entry?.access && !(entry.expires && Date.now() >= entry.expires)) {
487
+ accessToken = entry.access
488
+ accountId = entry.accountId
489
+ }
490
+ }
491
+ if (!accessToken) return null
492
+
493
+ const headers: Record<string, string> = {
494
+ authorization: `Bearer ${accessToken}`,
495
+ 'user-agent': 'codex-cli',
496
+ }
497
+ if (accountId) headers['chatgpt-account-id'] = accountId
151
498
 
152
- const totalCost = createMemo(() => {
153
- const state = ((props.api.state as any).session)?.get?.(props.sessionID)
154
- const fromState = msgCost(state)
155
- if (fromState > 0) return fromState
156
- return messages().reduce((sum: number, m: any) => sum + msgCost(m), 0)
499
+ const data = (await fetchJson('https://chatgpt.com/backend-api/wham/usage', headers)) as {
500
+ rate_limit?: {
501
+ primary_window?: WhamWindow | null
502
+ secondary_window?: WhamWindow | null
503
+ }
504
+ } | null
505
+ if (!data?.rate_limit) return null
506
+
507
+ // Classify by window duration when present (some accounts return the
508
+ // weekly window as primary_window), falling back to position.
509
+ const windows: UsageWindow[] = []
510
+ const primary = parseWhamWindow(data.rate_limit.primary_window, '5h')
511
+ if (primary) windows.push(primary)
512
+ const secondary = parseWhamWindow(data.rate_limit.secondary_window, '7d')
513
+ if (secondary) windows.push(secondary)
514
+ windows.sort((a, b) => Number(b.category === '5h') - Number(a.category === '5h'))
515
+ return windows.length > 0 ? windows : null
516
+ },
517
+ }
518
+
519
+ type WhamWindow = {
520
+ used_percent?: number
521
+ limit_window_seconds?: number
522
+ reset_at?: number
523
+ reset_after_seconds?: number
524
+ }
525
+
526
+ function parseWhamWindow(
527
+ w: WhamWindow | null | undefined,
528
+ fallback: WindowCategory,
529
+ ): UsageWindow | null {
530
+ if (!w || typeof w.used_percent !== 'number' || !Number.isFinite(w.used_percent)) return null
531
+ let resetsAt: number | undefined
532
+ if (typeof w.reset_at === 'number') resetsAt = w.reset_at * 1000
533
+ else if (typeof w.reset_after_seconds === 'number')
534
+ resetsAt = Date.now() + w.reset_after_seconds * 1000
535
+ if (!resetsAt || !Number.isFinite(resetsAt)) return null
536
+ const category: WindowCategory =
537
+ typeof w.limit_window_seconds === 'number' && w.limit_window_seconds > 0
538
+ ? w.limit_window_seconds <= 21_600 // ≤ 6h → session window
539
+ ? '5h'
540
+ : '7d'
541
+ : fallback
542
+ return { category, label: category, percent: w.used_percent, resetsAt }
543
+ }
544
+
545
+ /** OpenCode Go/Zen — dollar-denominated rolling usage limits ($12 / 5h,
546
+ * $30 / 7d, $60 / subscription month). Token is the same key used for
547
+ * inference: PANTHEON_OPENCODE_API_KEY, falling back to OPENCODE_API_KEY.
548
+ * It is only ever sent in the Authorization header to opencode.ai — never
549
+ * logged or written to disk. The usage endpoint (opencode.ai/zen/go/v1/
550
+ * usage) is not yet part of the public docs, so any non-ok response
551
+ * (404/401/403/500) hides the provider silently instead of erroring out. */
552
+ const opencodeGoProvider: Provider = {
553
+ id: 'opencodego',
554
+ short: 'go',
555
+ // No statusUrl — opencode.ai has no verified public status page to poll.
556
+ async fetchUsage(_cfg) {
557
+ const token = process.env.PANTHEON_OPENCODE_API_KEY ?? process.env.OPENCODE_API_KEY
558
+ if (!token) return null
559
+
560
+ const data = (await fetchJson('https://opencode.ai/zen/go/v1/usage', {
561
+ authorization: `Bearer ${token}`,
562
+ })) as {
563
+ rolling5h?: GoUsageWindow
564
+ weekly?: GoUsageWindow
565
+ monthly?: GoUsageWindow
566
+ } | null
567
+ if (!data) return null
568
+
569
+ const windows: UsageWindow[] = []
570
+ // Mirror the defensive parsing used by the other providers: a window
571
+ // missing its percent or reset time is skipped rather than rendered.
572
+ const push = (w: GoUsageWindow | undefined, category: '5h' | '7d' | '1m') => {
573
+ if (!w) return
574
+ if (typeof w.usagePercent !== 'number' || !Number.isFinite(w.usagePercent)) return
575
+ if (typeof w.resetInSec !== 'number' || !Number.isFinite(w.resetInSec)) return
576
+ windows.push({
577
+ category,
578
+ label: category,
579
+ percent: Math.round(w.usagePercent),
580
+ resetsAt: Date.now() + w.resetInSec * 1000,
581
+ })
582
+ }
583
+ push(data.rolling5h, '5h')
584
+ push(data.weekly, '7d')
585
+ push(data.monthly, '1m')
586
+ return windows.length > 0 ? windows : null
587
+ },
588
+ }
589
+
590
+ type GoUsageWindow = {
591
+ usageDollars?: number
592
+ limitDollars?: number
593
+ usagePercent?: number
594
+ resetInSec?: number
595
+ }
596
+
597
+ const providers: Provider[] = [anthropicProvider, openaiProvider, opencodeGoProvider]
598
+
599
+ // ---------------------------------------------------------------------------
600
+ // Config
601
+ // ---------------------------------------------------------------------------
602
+
603
+ const DEFAULT_TOML = `# opencode-usage-bar configuration
604
+ # Read once at startup — restart opencode after editing.
605
+
606
+ [ui]
607
+ show_bars = true # render ▓▓░░ mini-bars (false = text only)
608
+ show_status = true # show a ! marker next to a provider during incidents
609
+ # bar_width = 6 # override bar width (default: 6 for a single window, 5 otherwise)
610
+
611
+ [anthropic]
612
+ enabled = true # Claude Pro/Max via ~/.claude/.credentials.json
613
+ show_5h = true # rolling 5-hour session window
614
+ show_7d = false # weekly cap across all models
615
+ show_model = false # per-model weekly windows (e.g. Fable)
616
+ # credentials_path = "~/.claude/.credentials.json"
617
+
618
+ [openai]
619
+ enabled = false # ChatGPT Plus/Pro via the Codex CLI login
620
+ show_5h = true
621
+ show_7d = false
622
+ # codex_auth_path = "~/.codex/auth.json"
623
+
624
+ [opencodego]
625
+ enabled = true # OpenCode Go/Zen dollar usage via PANTHEON_OPENCODE_API_KEY
626
+ show_5h = true # rolling 5-hour window ($12)
627
+ show_7d = true # rolling 7-day window ($30)
628
+ show_1m = false # subscription-month window ($60) — opt-in
629
+ # Falls back to OPENCODE_API_KEY when PANTHEON_OPENCODE_API_KEY is unset.
630
+ `
631
+
632
+ function defaultConfig(): UsageBarConfig {
633
+ const show = (over: Partial<Record<WindowCategory, boolean>> = {}) => ({
634
+ '5h': true,
635
+ '7d': false,
636
+ '1m': false,
637
+ model: false,
638
+ ...over,
157
639
  })
640
+ return {
641
+ showBars: true,
642
+ showStatus: true,
643
+ providers: {
644
+ anthropic: { enabled: true, show: show() },
645
+ openai: { enabled: false, show: show() },
646
+ // 5h+7d on by default; monthly window opt-in; model never for Go.
647
+ opencodego: { enabled: true, show: show({ '7d': true }) },
648
+ },
649
+ }
650
+ }
158
651
 
159
- const theme = () => props.api.theme.current
652
+ type TomlTable = Record<string, unknown>
653
+
654
+ function asTable(v: unknown): TomlTable {
655
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as TomlTable) : {}
656
+ }
657
+
658
+ function bool(v: unknown, fallback: boolean) {
659
+ return typeof v === 'boolean' ? v : fallback
660
+ }
661
+
662
+ function str(v: unknown): string | undefined {
663
+ return typeof v === 'string' && v.length > 0 ? v : undefined
664
+ }
665
+
666
+ function parseConfig(raw: string): UsageBarConfig {
667
+ const toml = (globalThis as Record<string, any>).Bun?.TOML
668
+ if (!toml?.parse) return defaultConfig()
669
+ const root = asTable(toml.parse(raw))
670
+ const cfg = defaultConfig()
671
+
672
+ const ui = asTable(root['ui'])
673
+ cfg.showBars = bool(ui['show_bars'], cfg.showBars)
674
+ cfg.showStatus = bool(ui['show_status'], cfg.showStatus)
675
+ const rawWidth = ui['bar_width']
676
+ if (typeof rawWidth === 'number' && Number.isFinite(rawWidth) && rawWidth >= 1)
677
+ cfg.barWidth = Math.min(40, Math.floor(rawWidth))
678
+
679
+ for (const id of ['anthropic', 'openai', 'opencodego'] as ProviderId[]) {
680
+ const t = asTable(root[id])
681
+ const p = cfg.providers[id]
682
+ p.enabled = bool(t['enabled'], p.enabled)
683
+ p.show['5h'] = bool(t['show_5h'], p.show['5h'])
684
+ p.show['7d'] = bool(t['show_7d'], p.show['7d'])
685
+ p.show['1m'] = bool(t['show_1m'], p.show['1m'])
686
+ p.show.model = bool(t['show_model'], p.show.model)
687
+ // Assign only when present — keeps `exactOptionalPropertyTypes` happy.
688
+ const credentialsPath = str(t['credentials_path'])
689
+ if (credentialsPath !== undefined) p.credentialsPath = credentialsPath
690
+ const codexAuthPath = str(t['codex_auth_path'])
691
+ if (codexAuthPath !== undefined) p.codexAuthPath = codexAuthPath
692
+ }
693
+ return cfg
694
+ }
695
+
696
+ /** Load `<config dir>/usage-bar.toml`, creating it with commented defaults on
697
+ * first run. Any failure falls back to defaults without touching an existing
698
+ * file. */
699
+ async function loadConfig(configPath: string | undefined): Promise<UsageBarConfig> {
700
+ const dir =
701
+ configPath && !configPath.endsWith('.json')
702
+ ? configPath
703
+ : configPath
704
+ ? dirname(configPath)
705
+ : join(homedir(), '.config', 'opencode')
706
+ const file = join(dir, CONFIG_FILE)
707
+
708
+ try {
709
+ const raw = await readFile(file, 'utf8')
710
+ try {
711
+ return parseConfig(raw)
712
+ } catch {
713
+ return defaultConfig() // malformed TOML — keep the file, use defaults
714
+ }
715
+ } catch (err) {
716
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
717
+ try {
718
+ await mkdir(dir, { recursive: true })
719
+ await writeFile(file, DEFAULT_TOML, { flag: 'wx' })
720
+ } catch {
721
+ // ignore — config stays default in memory
722
+ }
723
+ }
724
+ return defaultConfig()
725
+ }
726
+ }
727
+
728
+ /** Vendored usage-bar plugin init: reads config, seeds the kv cache, starts
729
+ * the poll loop and registers the `app_bottom` slot. Skips everything (and
730
+ * registers nothing) when no provider is enabled — mirrors the standalone
731
+ * plugin's early return. */
732
+ async function setupUsageBar(api: TuiPluginApi) {
733
+ const config = await loadConfig(api.state.path?.config)
734
+ if (api.state.path?.state) opencodeAuthFile = join(api.state.path.state, 'auth.json')
735
+
736
+ const enabled = providers.filter((p) => config.providers[p.id].enabled)
737
+ if (enabled.length === 0) return // nothing to poll or render
738
+
739
+ const seed: Record<string, UsageWindow[]> = {}
740
+ for (const p of enabled) {
741
+ const cached = api.kv.get<UsageWindow[] | undefined>(`usage-bar.${p.id}.windows`, undefined)
742
+ if (cached) seed[p.id] = cached
743
+ }
744
+ const [byProvider, setByProvider] = createSignal<Record<string, UsageWindow[]>>(seed)
745
+ // Status is deliberately not cached across restarts: incidents are
746
+ // short-lived, and the first poll lands seconds after startup anyway.
747
+ const [byStatus, setByStatus] = createSignal<Record<string, StatusIndicator>>({})
748
+ const [now, setNow] = createSignal(Date.now())
749
+
750
+ setInterval(() => setNow(Date.now()), 1_000)
751
+
752
+ for (const p of enabled) {
753
+ const cfg = config.providers[p.id]
754
+ const poll = async () => {
755
+ // Fetch usage and status concurrently; status pages are CDN-backed and
756
+ // never throttle, so we poll them on the same cadence as usage.
757
+ const statusP = config.showStatus && p.statusUrl ? fetchStatus(p.statusUrl) : null
758
+ const [all, status] = await Promise.all([p.fetchUsage(cfg), statusP])
759
+ if (all) {
760
+ const windows = all.filter((w) => cfg.show[w.category] && w.resetsAt > Date.now())
761
+ setByProvider((prev) => ({ ...prev, [p.id]: windows }))
762
+ api.kv.set(`usage-bar.${p.id}.windows`, windows)
763
+ }
764
+ // `null` means the status fetch failed — keep the last known indicator.
765
+ if (status !== null) setByStatus((prev) => ({ ...prev, [p.id]: status }))
766
+ // Back off when the usage fetch failed (e.g. 429 — these endpoints throttle).
767
+ setTimeout(poll, all ? POLL_MS : POLL_MS * 3)
768
+ }
769
+ void poll()
770
+ }
771
+
772
+ api.slots.register({
773
+ order: 60,
774
+ slots: {
775
+ // `app_bottom` renders as a full-width row below the session footer.
776
+ app_bottom() {
777
+ const theme = () => api.theme.current
778
+
779
+ type Group = { short: string; status: StatusIndicator; windows: UsageWindow[] }
780
+ const groups = createMemo<Group[]>(() => {
781
+ const map = byProvider()
782
+ const statusMap = byStatus()
783
+ const out: Group[] = []
784
+ for (const p of enabled) {
785
+ // Drop windows that have reset since the last poll.
786
+ const windows = (map[p.id] ?? []).filter((w) => w.resetsAt > now())
787
+ if (windows.length > 0)
788
+ out.push({ short: p.short, status: statusMap[p.id] ?? 'none', windows })
789
+ }
790
+ return out
791
+ })
792
+ const totalWindows = createMemo(() =>
793
+ groups().reduce((sum, g) => sum + g.windows.length, 0),
794
+ )
795
+ const barWidth = createMemo(() => config.barWidth ?? (totalWindows() === 1 ? 6 : 5))
796
+ const multiProvider = createMemo(() => groups().length >= 2)
797
+
798
+ const pctOf = (w: UsageWindow) => Math.min(100, Math.max(0, Math.round(w.percent)))
799
+ const filledOf = (w: UsageWindow) =>
800
+ Math.min(barWidth(), Math.max(0, Math.round((pctOf(w) / 100) * barWidth())))
801
+ const colorOf = (w: UsageWindow) => {
802
+ const t = theme()
803
+ const pct = pctOf(w)
804
+ if (pct > 85) return t.error
805
+ if (pct >= 50) return t.warning
806
+ return t.success
807
+ }
808
+ // Status marker color: red for severe incidents, amber for minor,
809
+ // cyan for scheduled maintenance.
810
+ const statusColor = (s: StatusIndicator) => {
811
+ const t = theme()
812
+ if (s === 'critical' || s === 'major') return t.error
813
+ if (s === 'minor') return t.warning
814
+ return t.info
815
+ }
160
816
 
161
- return (
162
- <Show when={usage()}>
163
- {(u) => {
164
- const pct = u().percent
165
- const c = theme()
166
- const barColor = pct >= 90 ? c.error : pct >= 70 ? c.warning : c.accent
167
- const costStr = fmtCost(totalCost())
168
817
  return (
169
- <box flexDirection="column">
170
- <box flexDirection="row" gap={1}>
171
- <text fg={c.text} attributes={{ bold: true }}>Context</text>
172
- <text fg={barColor}>{buildBar(pct)}</text>
173
- <text fg={barColor}>{`${pct}%`}</text>
818
+ <Show when={groups().length > 0}>
819
+ <box flexDirection="row" gap={3} alignItems="center" width="100%" paddingLeft={1}>
820
+ <For each={groups()}>
821
+ {(g) => (
822
+ <box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
823
+ <Show when={config.showStatus && g.status !== 'none'}>
824
+ <text fg={statusColor(g.status)}>!</text>
825
+ </Show>
826
+ <Show when={multiProvider()}>
827
+ <text fg={theme().textMuted}>{g.short}</text>
828
+ </Show>
829
+ <For each={g.windows}>
830
+ {(w) => (
831
+ <box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
832
+ <Show when={g.windows.length >= 2}>
833
+ <text fg={theme().textMuted}>{w.label}</text>
834
+ </Show>
835
+ <Show when={config.showBars}>
836
+ <box flexDirection="row">
837
+ <text fg={colorOf(w)}>{'▓'.repeat(filledOf(w))}</text>
838
+ <text fg={theme().textMuted}>
839
+ {'░'.repeat(barWidth() - filledOf(w))}
840
+ </text>
841
+ </box>
842
+ </Show>
843
+ <text fg={theme().text}>{pctOf(w)}%</text>
844
+ <text
845
+ fg={theme().textMuted}
846
+ >{`· ${fmtDuration(w.resetsAt - now())}`}</text>
847
+ </box>
848
+ )}
849
+ </For>
850
+ </box>
851
+ )}
852
+ </For>
174
853
  </box>
175
- <text fg={c.textMuted}>
176
- {`${fmtInt(u().tokens)} / ${fmtInt(u().limit)} tok${costStr ? ` ${costStr}` : ''}`}
177
- </text>
178
- </box>
854
+ </Show>
179
855
  )
180
- }}
181
- </Show>
182
- )
856
+ },
857
+ },
858
+ })
183
859
  }
184
860
 
185
861
  /* ─── Session Row (single session with live status indicator) ─ */
@@ -191,15 +867,20 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
191
867
  try {
192
868
  const s = (props.api.state as any).session?.status?.(props.session.id)
193
869
  if (s?.type) return s.type as string
194
- } catch { /* ignore */ }
870
+ } catch {
871
+ /* ignore */
872
+ }
195
873
  return null
196
874
  })
197
875
 
198
876
  const statusIcon = createMemo(() => {
199
877
  switch (status()) {
200
- case 'busy': return '\u25cf ' // ● — running
201
- case 'retry': return '\u26a0 ' // ⚠ — needs attention
202
- default: return '' // idle/unknown — no icon
878
+ case 'busy':
879
+ return '\u25cf ' // ● — running
880
+ case 'retry':
881
+ return '\u26a0 ' // ⚠ — needs attention
882
+ default:
883
+ return '' // idle/unknown — no icon
203
884
  }
204
885
  })
205
886
 
@@ -224,6 +905,31 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
224
905
  props.api.state.vcs?.branch ? `\u2387 ${props.api.state.vcs.branch}` : null,
225
906
  )
226
907
 
908
+ // ── Active preset indicator ──
909
+ // Seeded synchronously from env (never blocks first paint), then re-checked
910
+ // against .pantheon/active-preset.json on mount and every 30s so `set-tier`
911
+ // changes made while opencode is open become visible.
912
+ const [preset, setPreset] = createSignal<PresetInfo>(presetFromEnv(process.env))
913
+
914
+ onMount(() => {
915
+ const cwd = ((props.api.state as any).path?.worktree ?? '') || process.cwd()
916
+ let cancelled = false
917
+ const refresh = async () => {
918
+ try {
919
+ const info = await resolvePresetForTui(process.env, cwd)
920
+ if (!cancelled) setPreset(info)
921
+ } catch {
922
+ // transient read error — keep the last known value
923
+ }
924
+ }
925
+ void refresh()
926
+ const timer = setInterval(() => void refresh(), PRESET_REFRESH_MS)
927
+ onCleanup(() => {
928
+ cancelled = true
929
+ clearInterval(timer)
930
+ })
931
+ })
932
+
227
933
  // ── Sessions: fetch via api.client.session.list (not proc.exec) ──
228
934
  const [sessionList, { refetch: refetchSessions }] = createResource(async () => {
229
935
  try {
@@ -272,22 +978,30 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
272
978
  cleanup.push(props.api.event.on('session.created', refetchSessions))
273
979
  cleanup.push(props.api.event.on('session.updated', refetchSessions))
274
980
  cleanup.push(props.api.event.on('session.deleted', refetchSessions))
275
- } catch { /* events API not available in this runtime */ }
981
+ } catch {
982
+ /* events API not available in this runtime */
983
+ }
276
984
 
277
985
  // Memory events — tracks running counter (no api.state.memory)
278
986
  try {
279
- const ev = (props.api.event as any)
987
+ const ev = props.api.event as any
280
988
  if (typeof ev?.on === 'function') {
989
+ cleanup.push(ev.on('memory.created', () => setMemoryCount((c) => (c !== null ? c + 1 : 1))))
281
990
  cleanup.push(
282
- ev.on('memory.created', () => setMemoryCount((c) => (c !== null ? c + 1 : 1))),
283
- )
284
- cleanup.push(
285
- ev.on('memory.deleted', () => setMemoryCount((c) => (c !== null ? Math.max(0, c - 1) : 0))),
991
+ ev.on('memory.deleted', () =>
992
+ setMemoryCount((c) => (c !== null ? Math.max(0, c - 1) : 0)),
993
+ ),
286
994
  )
287
995
  }
288
- } catch { /* memory events not available */ }
996
+ } catch {
997
+ /* memory events not available */
998
+ }
289
999
 
290
- onCleanup(() => cleanup.forEach((fn) => fn()))
1000
+ onCleanup(() =>
1001
+ cleanup.forEach((fn) => {
1002
+ fn()
1003
+ }),
1004
+ )
291
1005
  })
292
1006
 
293
1007
  // ── Config summary ──
@@ -306,23 +1020,34 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
306
1020
  return (
307
1021
  <box flexDirection="column" width="100%">
308
1022
  {/* ── Header ── */}
309
- <text fg={theme().accent} attributes={{ bold: true }}>
1023
+ <text fg={theme().accent} attributes={1}>
310
1024
  {`Pantheon${props.version ? ` v${props.version}` : ''}`}
311
1025
  </text>
312
- <Show when={branch()}>
313
- {(b) => <text fg={theme().textMuted}>{b()}</text>}
314
- </Show>
315
-
316
- <HR />
1026
+ <Show when={branch()}>{(b) => <text fg={theme().textMuted}>{b()}</text>}</Show>
317
1027
 
318
- {/* ── Context Bar (always visible) ── */}
319
- <ContextBar api={props.api} sessionID={props.sessionID} />
1028
+ {/* ── Active preset indicator ── */}
1029
+ <Show
1030
+ when={preset().name}
1031
+ fallback={
1032
+ <box flexDirection="row" gap={1}>
1033
+ <text fg={theme().textMuted}>Preset: default</text>
1034
+ </box>
1035
+ }
1036
+ >
1037
+ {(name) => (
1038
+ <box flexDirection="row" gap={1}>
1039
+ <text fg={theme().textMuted}>{'\u26a1 Preset:'}</text>
1040
+ <text fg={theme().accent}>{name()}</text>
1041
+ <text fg={theme().textMuted}>{`(${preset().source ?? ''})`}</text>
1042
+ </box>
1043
+ )}
1044
+ </Show>
320
1045
 
321
1046
  <HR />
322
1047
 
323
1048
  {/* ── Sessions ── */}
324
1049
  <box onMouseDown={() => setShowSessions((x) => !x)}>
325
- <text fg={theme().text} attributes={{ bold: true }}>
1050
+ <text fg={theme().text} attributes={1}>
326
1051
  {`${showSessions() ? '\u25bc' : '\u25b6'} Sessions`}
327
1052
  </text>
328
1053
  <text fg={theme().textMuted}>{` (${String(totalSessions())})`}</text>
@@ -330,7 +1055,11 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
330
1055
  <Show when={showSessions()}>
331
1056
  <Show
332
1057
  when={recentSessions().length > 0}
333
- fallback={<box marginLeft={1}><text fg={theme().textMuted}>No recent sessions</text></box>}
1058
+ fallback={
1059
+ <box marginLeft={1}>
1060
+ <text fg={theme().textMuted}>No recent sessions</text>
1061
+ </box>
1062
+ }
334
1063
  >
335
1064
  <box marginLeft={1} flexDirection="column">
336
1065
  <For each={recentSessions()}>
@@ -342,7 +1071,7 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
342
1071
 
343
1072
  {/* ── Commands ── */}
344
1073
  <box onMouseDown={() => setShowCommands((x) => !x)}>
345
- <text fg={theme().text} attributes={{ bold: true }}>
1074
+ <text fg={theme().text} attributes={1}>
346
1075
  {`${showCommands() ? '\u25bc' : '\u25b6'} Commands`}
347
1076
  </text>
348
1077
  <text fg={theme().textMuted}>{` (${String(COMMANDS.length)})`}</text>
@@ -351,15 +1080,19 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
351
1080
  <box marginLeft={1} flexDirection="column">
352
1081
  <For each={COMMANDS}>
353
1082
  {(cmd) => (
354
- <box onMouseDown={(e) => {
355
- e.stopPropagation()
356
- try {
357
- const cmdApi = (props.api as any).command
358
- const cmdName = cmd.name.replace('/', '')
359
- if (cmdApi?.trigger?.(cmdName)) return
360
- } catch { /* fall through to toast */ }
361
- props.api.ui?.toast?.({ title: 'Command', message: `Type ${cmd.name} in chat` })
362
- }}>
1083
+ <box
1084
+ onMouseDown={(e) => {
1085
+ e.stopPropagation()
1086
+ try {
1087
+ const cmdApi = (props.api as any).command
1088
+ const cmdName = cmd.name.replace('/', '')
1089
+ if (cmdApi?.trigger?.(cmdName)) return
1090
+ } catch {
1091
+ /* fall through to toast */
1092
+ }
1093
+ props.api.ui?.toast?.({ title: 'Command', message: `Type ${cmd.name} in chat` })
1094
+ }}
1095
+ >
363
1096
  <text fg={cmd.name === '/pantheon' ? theme().accent : theme().textMuted}>
364
1097
  {cmd.name}
365
1098
  </text>
@@ -372,7 +1105,7 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
372
1105
 
373
1106
  {/* ── Agents ── */}
374
1107
  <box onMouseDown={() => setShowAgents((x) => !x)}>
375
- <text fg={theme().text} attributes={{ bold: true }}>
1108
+ <text fg={theme().text} attributes={1}>
376
1109
  {`${showAgents() ? '\u25bc' : '\u25b6'} Agents`}
377
1110
  </text>
378
1111
  <text fg={theme().textMuted}>{` (${String(AGENTS.length)})`}</text>
@@ -394,16 +1127,27 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
394
1127
 
395
1128
  {/* ── Config ── */}
396
1129
  <box onMouseDown={() => setShowConfig((x) => !x)}>
397
- <text fg={theme().text} attributes={{ bold: true }}>
1130
+ <text fg={theme().text} attributes={1}>
398
1131
  {`${showConfig() ? '\u25bc' : '\u25b6'} Config`}
399
1132
  </text>
400
1133
  </box>
401
1134
  <Show when={showConfig()}>
402
- <Show when={configSummary()} fallback={<box marginLeft={1}><text fg={theme().textMuted}>No config data</text></box>}>
1135
+ <Show
1136
+ when={configSummary()}
1137
+ fallback={
1138
+ <box marginLeft={1}>
1139
+ <text fg={theme().textMuted}>No config data</text>
1140
+ </box>
1141
+ }
1142
+ >
403
1143
  {(cfg) => (
404
1144
  <box marginLeft={1} flexDirection="column">
405
- <text fg={theme().textMuted}>{`MCP: ${String(cfg().mcpCount)} Plugins: ${String(cfg().pluginCount)}`}</text>
406
- <text fg={theme().textMuted}>{`Auto-compaction: ${cfg().autoCompaction ? 'ON' : 'OFF'}`}</text>
1145
+ <text
1146
+ fg={theme().textMuted}
1147
+ >{`MCP: ${String(cfg().mcpCount)} Plugins: ${String(cfg().pluginCount)}`}</text>
1148
+ <text
1149
+ fg={theme().textMuted}
1150
+ >{`Auto-compaction: ${cfg().autoCompaction ? 'ON' : 'OFF'}`}</text>
407
1151
  </box>
408
1152
  )}
409
1153
  </Show>
@@ -412,9 +1156,7 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
412
1156
  {/* ── Memory (always visible summary) ── */}
413
1157
  <box marginTop={1}>
414
1158
  <text fg={theme().textMuted}>
415
- {memoryCount() !== null
416
- ? `Memory: ${fmtInt(memoryCount()!)} entries`
417
- : 'Memory: N/A'}
1159
+ {memoryCount() !== null ? `Memory: ${fmtInt(memoryCount()!)} entries` : 'Memory: N/A'}
418
1160
  </text>
419
1161
  </box>
420
1162
  </box>
@@ -426,6 +1168,9 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
426
1168
  const tui: TuiPlugin = async (api, _options, _meta) => {
427
1169
  const version = await detectVersion(api)
428
1170
 
1171
+ // Vendored feature (MIT): AI subscription usage gauges.
1172
+ void setupUsageBar(api) // async init — never blocks sidebar registration
1173
+
429
1174
  api.slots.register({
430
1175
  order: 900,
431
1176
  slots: {