pantheon-opencode 1.2.1 → 1.3.0

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