pantheon-opencode 1.1.1-beta.7 → 1.1.1-beta.8

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.
@@ -3,30 +3,11 @@
3
3
  /** @jsxImportSource @opentui/solid */
4
4
 
5
5
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'
6
- import { createMemo, createSignal, createResource, For, Show } from 'solid-js'
6
+ import { createMemo, createSignal, For, Show } from 'solid-js'
7
7
 
8
- async function readPantheonVersion(api: TuiPluginApi): Promise<string> {
9
- try {
10
- const worktree = ((api.state as any).path?.worktree ?? '') as string
11
- const filePath = worktree ? `${worktree}/package.json` : 'package.json'
12
- const result = await api.client.file.read({ query: { path: filePath } })
13
- const content = typeof result.content === 'string' ? result.content : String(result.content ?? '')
14
- const match = content.match(/"version":\s*"([^"]+)"/)
15
- if (match?.[1]) return match[1]
16
- } catch { /* fall through */ }
8
+ /* ─── Constants ─────────────────────────────────────────── */
17
9
 
18
- try {
19
- const proc = (api as any).client?.process
20
- if (typeof proc?.exec === 'function') {
21
- const result = await proc.exec({ command: 'git', args: ['describe', '--tags', '--always'] })
22
- const stdout = (result.stdout ?? result.output ?? '') as string
23
- const tag = stdout.trim().replace(/^v/, '')
24
- if (tag) return tag
25
- }
26
- } catch { /* fall through */ }
27
-
28
- return '1.0.1'
29
- }
10
+ const BAR_WIDTH = 20
30
11
 
31
12
  const COMMANDS = [
32
13
  { name: '/pantheon', desc: 'Council synthesis' },
@@ -59,15 +40,110 @@ const AGENTS = [
59
40
  { name: 'talos', tier: 'fast' as const, role: 'Hotfixes' },
60
41
  ] as const
61
42
 
43
+ /* ─── Helpers ───────────────────────────────────────────── */
44
+
62
45
  function safeNum(v: unknown): number {
63
46
  return typeof v === 'number' && Number.isFinite(v) ? v : 0
64
47
  }
65
48
 
49
+ function fmtInt(n: number): string {
50
+ return Intl.NumberFormat('en-US').format(Math.max(0, Math.round(n)))
51
+ }
52
+
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
+ }
58
+
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)
63
+ }
64
+
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
68
+ }
69
+ return 0
70
+ }
71
+
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)
76
+ }
77
+
78
+ /* ─── Context Bar ──────────────────────────────────────────
79
+ * Inspired by streetturtle/opencode-better-sidebar (context-progress)
80
+ * Shows: progress bar, token usage / limit, session cost
81
+ * Always visible at the top of the sidebar */
82
+
83
+ function ContextBar(props: { api: TuiPluginApi; sessionID: string }) {
84
+ const messages = createMemo((): any[] => {
85
+ try { return (props.api.state.session as any).messages(props.sessionID) ?? [] }
86
+ catch { return [] }
87
+ })
88
+
89
+ const usage = createMemo(() => {
90
+ const msgs = messages()
91
+ const lastAssistant = [...msgs].reverse().find((m: any) => {
92
+ const role = m?.role ?? m?.info?.role
93
+ return role === 'assistant' && safeNum(m?.tokens?.output) > 0
94
+ })
95
+ if (!lastAssistant) return null
96
+
97
+ const tokens = msgTokens(lastAssistant)
98
+ const pid = lastAssistant?.providerID ?? lastAssistant?.info?.providerID
99
+ const mid = lastAssistant?.modelID ?? lastAssistant?.info?.modelID
100
+ const provider = pid
101
+ ? ((props.api.state as any).provider ?? []).find((p: any) => p.id === pid)
102
+ : null
103
+ const ctxLimit = safeNum(provider?.models?.[mid ?? '']?.limit?.context)
104
+ const limit = ctxLimit > 0 ? ctxLimit : 128000
105
+ return { tokens, limit, percent: ctxLimit > 0 ? Math.round((tokens / ctxLimit) * 100) : 0 }
106
+ })
107
+
108
+ const totalCost = createMemo(() => {
109
+ const state = ((props.api.state as any).session)?.get?.(props.sessionID)
110
+ const fromState = msgCost(state)
111
+ if (fromState > 0) return fromState
112
+ return messages().reduce((sum: number, m: any) => sum + msgCost(m), 0)
113
+ })
114
+
115
+ const theme = () => props.api.theme.current
116
+
117
+ return (
118
+ <Show when={usage()}>
119
+ {(u) => {
120
+ const pct = u().percent
121
+ const c = theme()
122
+ const barColor = pct >= 90 ? c.error : pct >= 70 ? c.warning : c.accent
123
+ const costStr = fmtCost(totalCost())
124
+ return (
125
+ <box flexDirection="column">
126
+ <box flexDirection="row" gap={1}>
127
+ <text fg={c.text} attributes={{ bold: true }}>Context</text>
128
+ <text fg={barColor}>{buildBar(pct)}</text>
129
+ <text fg={barColor}>{`${pct}%`}</text>
130
+ </box>
131
+ <text fg={c.textMuted}>
132
+ {`${fmtInt(u().tokens)} / ${fmtInt(u().limit)} tok${costStr ? ` ${costStr}` : ''}`}
133
+ </text>
134
+ </box>
135
+ )
136
+ }}
137
+ </Show>
138
+ )
139
+ }
140
+
141
+ /* ─── Main Sidebar View ──────────────────────────────────── */
142
+
66
143
  function View(props: { api: TuiPluginApi; sessionID: string; version: string }) {
67
144
  const [showCommands, setShowCommands] = createSignal(false)
68
145
  const [showAgents, setShowAgents] = createSignal(false)
69
146
  const [showConfig, setShowConfig] = createSignal(false)
70
- const [showMemory, setShowMemory] = createSignal(false)
71
147
  const [showSubagents, setShowSubagents] = createSignal(false)
72
148
 
73
149
  const theme = () => props.api.theme.current
@@ -76,166 +152,149 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string })
76
152
  props.api.state.vcs?.branch ? `\u2387 ${props.api.state.vcs.branch}` : null,
77
153
  )
78
154
 
79
- const configInfo = createMemo(() => {
155
+ const sessionCount = createMemo(() => {
156
+ try { return props.api.state.session.count() } catch { return 0 }
157
+ })
158
+
159
+ const memoryCount = createMemo(() => {
160
+ const mem = (props.api.state as any).memory
161
+ return safeNum(mem?.entries) || safeNum(mem?.count)
162
+ })
163
+
164
+ const configSummary = createMemo(() => {
80
165
  const cfg = (props.api.state as any).config
81
166
  if (!cfg) return null
82
167
  return {
83
- plugins: Array.isArray(cfg.plugin) ? cfg.plugin : [],
84
168
  mcpCount: cfg.mcp ? Object.keys(cfg.mcp).length : 0,
169
+ pluginCount: Array.isArray(cfg.plugin) ? cfg.plugin.length : 0,
85
170
  autoCompaction: cfg.compaction?.auto === true,
86
171
  }
87
172
  })
88
173
 
89
- const memoryInfo = createMemo(() => {
90
- const mem = (props.api.state as any).memory
91
- if (mem) {
92
- const entries = safeNum(mem?.entries) || safeNum(mem?.count)
93
- return entries > 0 ? { entries } : null
94
- }
95
- return null
96
- })
97
-
98
- const [subagents] = createResource(
99
- () => showSubagents(),
100
- async () => {
101
- try {
102
- const proc = (props.api as any).client?.process
103
- if (typeof proc?.exec !== 'function') return null
104
- const result = await proc.exec({
105
- command: 'opencode',
106
- args: ['session', 'list', '--format', 'json'],
107
- timeoutMs: 5000,
108
- })
109
- const stdout = (result.stdout ?? result.output ?? '') as string
110
- const sessions = JSON.parse(stdout)
111
- if (!Array.isArray(sessions)) return null
112
- return sessions
113
- .filter((s: any) => s.id !== props.sessionID)
114
- .sort((a: any, b: any) => (b.updated ?? 0) - (a.updated ?? 0))
115
- .slice(0, 10)
116
- } catch { return null }
117
- },
118
- )
174
+ const HR = () => <text fg={theme().textMuted}>{'\u2500'.repeat(28)}</text>
119
175
 
120
176
  return (
121
177
  <box flexDirection="column" width="100%">
122
- {/* Header */}
178
+ {/* ── Header ── */}
123
179
  <text fg={theme().accent} attributes={{ bold: true }}>
124
180
  {`Pantheon v${props.version}`}
125
181
  </text>
126
-
127
182
  <Show when={branch()}>
128
- {(b) => (<box marginTop={1}><text fg={theme().textMuted}>{b()}</text></box>)}
183
+ {(b) => <text fg={theme().textMuted}>{b()}</text>}
129
184
  </Show>
130
185
 
131
- {/* Subagents */}
132
- <box marginTop={0} onMouseDown={() => setShowSubagents((x) => !x)}>
186
+ <HR />
187
+
188
+ {/* ── Context Bar (always visible) ── */}
189
+ <ContextBar api={props.api} sessionID={props.sessionID} />
190
+
191
+ <HR />
192
+
193
+ {/* ── Subagents ── */}
194
+ <box onMouseDown={() => setShowSubagents((x) => !x)}>
133
195
  <text fg={theme().text} attributes={{ bold: true }}>
134
196
  {`${showSubagents() ? '\u25bc' : '\u25b6'} Subagents`}
135
197
  </text>
136
- <text fg={theme().textMuted}>{` (${String(props.api.state.session.count())})`}</text>
198
+ <text fg={theme().textMuted}>{` (${String(sessionCount())})`}</text>
137
199
  </box>
138
-
139
200
  <Show when={showSubagents()}>
140
- <Show when={subagents()} fallback={<box marginLeft={1}><text fg={theme().textMuted}>{`sessions: ${String(props.api.state.session.count())}`}</text></box>}>
141
- {(sessions) => (
142
- <Show when={sessions().length > 0} fallback={<box marginLeft={1}><text fg={theme().textMuted}>(idle)</text></box>}>
143
- <box marginLeft={1} flexDirection="column">
144
- <For each={sessions().slice(0, 5)}>
145
- {(s: any) => (
146
- <text fg={theme().textMuted}>{`${s.id.slice(0, 8)}... ${s.title?.slice(0, 30) ?? ''}`}</text>
147
- )}
148
- </For>
149
- </box>
150
- </Show>
151
- )}
152
- </Show>
201
+ <box marginLeft={1}>
202
+ <text fg={theme().textMuted}>{`Active sessions: ${String(sessionCount())}`}</text>
203
+ </box>
153
204
  </Show>
154
205
 
155
- {/* Commands */}
156
- <box marginTop={0} onMouseDown={() => setShowCommands((x) => !x)}>
206
+ {/* ── Commands ── */}
207
+ <box onMouseDown={() => setShowCommands((x) => !x)}>
157
208
  <text fg={theme().text} attributes={{ bold: true }}>
158
209
  {`${showCommands() ? '\u25bc' : '\u25b6'} Commands`}
159
210
  </text>
160
211
  <text fg={theme().textMuted}>{` (${String(COMMANDS.length)})`}</text>
161
212
  </box>
162
-
163
213
  <Show when={showCommands()}>
164
- <For each={COMMANDS}>
165
- {(cmd) => (
166
- <box marginLeft={1} onMouseDown={(e) => {
167
- e.stopPropagation()
168
- try {
169
- const cmdApi = (props.api as any).command
170
- const cmdName = cmd.name.replace('/', '')
171
- if (cmdApi?.trigger?.(cmdName)) return
172
- } catch {}
173
- props.api.ui?.toast?.({ title: 'Command', message: `Type ${cmd.name} in chat` })
174
- }}>
175
- <text fg={cmd.name === '/pantheon' ? theme().accent : theme().textMuted}>{cmd.name}</text>
176
- <text fg={theme().textMuted}>{` — ${cmd.desc}`}</text>
177
- </box>
178
- )}
179
- </For>
214
+ <box marginLeft={1} flexDirection="column">
215
+ <For each={COMMANDS}>
216
+ {(cmd) => (
217
+ <box onMouseDown={(e) => {
218
+ e.stopPropagation()
219
+ try {
220
+ const cmdApi = (props.api as any).command
221
+ const cmdName = cmd.name.replace('/', '')
222
+ if (cmdApi?.trigger?.(cmdName)) return
223
+ } catch { /* fall through to toast */ }
224
+ props.api.ui?.toast?.({ title: 'Command', message: `Type ${cmd.name} in chat` })
225
+ }}>
226
+ <text fg={cmd.name === '/pantheon' ? theme().accent : theme().textMuted}>
227
+ {cmd.name}
228
+ </text>
229
+ <text fg={theme().textMuted}>{` \u2014 ${cmd.desc}`}</text>
230
+ </box>
231
+ )}
232
+ </For>
233
+ </box>
180
234
  </Show>
181
235
 
182
- {/* Agents */}
183
- <box marginTop={0} onMouseDown={() => setShowAgents((x) => !x)}>
236
+ {/* ── Agents ── */}
237
+ <box onMouseDown={() => setShowAgents((x) => !x)}>
184
238
  <text fg={theme().text} attributes={{ bold: true }}>
185
239
  {`${showAgents() ? '\u25bc' : '\u25b6'} Agents`}
186
240
  </text>
187
241
  <text fg={theme().textMuted}>{` (${String(AGENTS.length)})`}</text>
188
242
  </box>
189
-
190
243
  <Show when={showAgents()}>
191
- <For each={AGENTS}>
192
- {(agent) => (
193
- <box marginLeft={1}>
194
- <text fg={agent.tier === 'premium' ? theme().accent : theme().textMuted}>
195
- {`${agent.tier === 'premium' ? '\u2726 ' : '\u00b7 '}${agent.name}`}
196
- </text>
197
- <text fg={theme().textMuted}>{` — ${agent.role}`}</text>
198
- </box>
199
- )}
200
- </For>
244
+ <box marginLeft={1} flexDirection="column">
245
+ <For each={AGENTS}>
246
+ {(agent) => (
247
+ <box>
248
+ <text fg={agent.tier === 'premium' ? theme().accent : theme().textMuted}>
249
+ {`${agent.tier === 'premium' ? '\u2726 ' : '\u00b7 '}${agent.name}`}
250
+ </text>
251
+ <text fg={theme().textMuted}>{` \u2014 ${agent.role}`}</text>
252
+ </box>
253
+ )}
254
+ </For>
255
+ </box>
201
256
  </Show>
202
257
 
203
- {/* Config */}
204
- <box marginTop={0} onMouseDown={() => setShowConfig((x) => !x)}>
258
+ {/* ── Config ── */}
259
+ <box onMouseDown={() => setShowConfig((x) => !x)}>
205
260
  <text fg={theme().text} attributes={{ bold: true }}>
206
261
  {`${showConfig() ? '\u25bc' : '\u25b6'} Config`}
207
262
  </text>
208
263
  </box>
209
-
210
264
  <Show when={showConfig()}>
211
- <Show when={configInfo()} fallback={<box marginLeft={1}><text fg={theme().textMuted}>(no config data)</text></box>}>
265
+ <Show when={configSummary()} fallback={<box marginLeft={1}><text fg={theme().textMuted}>No config data</text></box>}>
212
266
  {(cfg) => (
213
267
  <box marginLeft={1} flexDirection="column">
214
- <text fg={theme().textMuted}>{`MCP servers: ${String(cfg().mcpCount)}`}</text>
268
+ <text fg={theme().textMuted}>{`MCP servers: ${String(cfg().mcpCount)} Plugins: ${String(cfg().pluginCount)}`}</text>
215
269
  <text fg={theme().textMuted}>{`Auto-compaction: ${cfg().autoCompaction ? 'ON' : 'OFF'}`}</text>
216
270
  </box>
217
271
  )}
218
272
  </Show>
219
273
  </Show>
220
274
 
221
- {/* Memory */}
222
- <box marginTop={0} onMouseDown={() => setShowMemory((x) => !x)}>
223
- <text fg={theme().text} attributes={{ bold: true }}>
224
- {`${showMemory() ? '\u25bc' : '\u25b6'} Memory`}
275
+ {/* ── Memory (always visible summary) ── */}
276
+ <box marginTop={1}>
277
+ <text fg={theme().textMuted}>
278
+ {`Memory: ${memoryCount() > 0 ? `${fmtInt(memoryCount())} entries` : '0 entries'}`}
225
279
  </text>
226
280
  </box>
227
-
228
- <Show when={showMemory()}>
229
- <Show when={memoryInfo()} fallback={<box marginLeft={1}><text fg={theme().textMuted}>(no data)</text></box>}>
230
- {(mem) => (<box marginLeft={1}><text fg={theme().textMuted}>{`Entries: ${String(mem().entries)}`}</text></box>)}
231
- </Show>
232
- </Show>
233
281
  </box>
234
282
  )
235
283
  }
236
284
 
285
+ /* ─── Plugin Registration ────────────────────────────────── */
286
+
237
287
  const tui: TuiPlugin = async (api, _options, _meta) => {
238
- const version = await readPantheonVersion(api)
288
+ let version = '5.0.0'
289
+ try {
290
+ const wt = ((api.state as any).path?.worktree ?? '') as string
291
+ const fp = wt ? `${wt}/package.json` : 'package.json'
292
+ const result = await api.client.file.read({ query: { path: fp } })
293
+ const content = String(result?.content ?? '')
294
+ const match = content.match(/"version":\s*"([^"]+)"/)
295
+ if (match?.[1]) version = match[1]
296
+ } catch { /* use default */ }
297
+
239
298
  api.slots.register({
240
299
  order: 900,
241
300
  slots: {