pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11
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.
- package/AGENTS.global.md +206 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +350 -0
- package/README.md +561 -52
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +70 -4
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +24 -17
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +428 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +16 -2
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +1175 -215
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +62 -14
- package/scripts/install/shared.mjs +190 -48
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +171 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +7 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -172
- package/src/instructions/agent-return-format.instructions.md +34 -17
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -110
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +20 -67
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
- package/src/instructions/zeus-memory-operations.instructions.md +30 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +403 -102
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +418 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1158 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +52 -35
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/opencode.json +0 -58
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: OpenCode V2 exposes its TUI API at runtime.
|
|
2
|
+
// biome-ignore-all lint/a11y/noStaticElementInteractions: TUI elements, not DOM
|
|
3
|
+
/** @jsxImportSource @opentui/solid */
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { Plugin } from '@opencode/plugin/tui'
|
|
9
|
+
import type { Context } from '@opencode/plugin/tui/context'
|
|
10
|
+
import type { Accessor } from 'solid-js'
|
|
11
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
|
|
12
|
+
|
|
13
|
+
const SINGLETON = Symbol.for('pantheon.tui.v2.loaded')
|
|
14
|
+
|
|
15
|
+
/* ─── Constants ─────────────────────────────────────────── */
|
|
16
|
+
|
|
17
|
+
/** How often the sidebar re-reads .pantheon/active-preset.json (V1 parity) so
|
|
18
|
+
* `set-tier` changes made while opencode is open show up within ~30s. */
|
|
19
|
+
const PRESET_REFRESH_MS = 30_000
|
|
20
|
+
/** Running delegations rendered before the "… +N more" line (V1 parity). */
|
|
21
|
+
export const DELEGATIONS_VISIBLE_CEILING = 8
|
|
22
|
+
/** Running-row spinner cadence — matches V1's animation tick. */
|
|
23
|
+
const SPINNER_INTERVAL_MS = 80
|
|
24
|
+
/** Bounded warm-up: sync at most this many sessions at plugin load. */
|
|
25
|
+
const WARMUP_SESSION_LIMIT = 24
|
|
26
|
+
/** Sidebar body shown while the host has not published a session yet. */
|
|
27
|
+
export const SIDEBAR_WAITING_LABEL = 'Aguardando sessão…'
|
|
28
|
+
/** Delegate-completion toast title. */
|
|
29
|
+
const TOAST_TITLE = 'Pantheon'
|
|
30
|
+
/** One event name the host publishes on `data.on` — the OpenCode V2 union. */
|
|
31
|
+
export type SidebarDataEvent = Parameters<Context['data']['on']>[0]
|
|
32
|
+
/** V2 events that refresh the sidebar. V1 also listened to `session.updated`,
|
|
33
|
+
* but that name is absent from the OpenCode V2 event union (verified against
|
|
34
|
+
* `@opencode/client`'s `OpenCodeEvent`), so the valid events that carry the
|
|
35
|
+
* same state changes are used instead: `session.renamed` /
|
|
36
|
+
* `session.metadata.updated` / `session.moved` (the mutations `session.updated`
|
|
37
|
+
* used to stand for) and `session.idle` (a child finishing). `satisfies` keeps
|
|
38
|
+
* the literals while making a name outside the union a type error (surfaced by
|
|
39
|
+
* `tsc -p tsconfig.build.json`), and the sidebar test asserts the same names
|
|
40
|
+
* against the installed union at runtime. */
|
|
41
|
+
export const SIDEBAR_REFRESH_EVENTS = [
|
|
42
|
+
'session.created',
|
|
43
|
+
'session.renamed',
|
|
44
|
+
'session.metadata.updated',
|
|
45
|
+
'session.moved',
|
|
46
|
+
'session.idle',
|
|
47
|
+
'session.status',
|
|
48
|
+
'session.deleted',
|
|
49
|
+
] as const satisfies readonly SidebarDataEvent[]
|
|
50
|
+
|
|
51
|
+
/* ─── Active Preset Detection ──────────────────────────────
|
|
52
|
+
* Inline replica of the V1 sidebar resolver (src/plugins/tui/src/index.tsx,
|
|
53
|
+
* which itself mirrors src/pantheon/presets.mjs). The copied plugin ships only
|
|
54
|
+
* dist/ + package.json, so it can reach neither module at runtime — the
|
|
55
|
+
* resolution logic is mirrored here rather than imported.
|
|
56
|
+
*
|
|
57
|
+
* Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
|
|
58
|
+
* Custom/unknown preset names are shown as-is. "none" disables → default. */
|
|
59
|
+
|
|
60
|
+
export type PresetInfo = { name: string | null; source: 'env' | 'file' | null }
|
|
61
|
+
|
|
62
|
+
/** Read .pantheon/active-preset.json — first existing candidate wins; malformed
|
|
63
|
+
* JSON or a `preset` that is missing, empty or "none" → null (no fall-through
|
|
64
|
+
* to lower candidates). */
|
|
65
|
+
async function readActivePresetFile(cwd: string): Promise<{ name: string; source: 'file' } | null> {
|
|
66
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config')
|
|
67
|
+
const candidates = [
|
|
68
|
+
join(cwd, '.pantheon', 'active-preset.json'),
|
|
69
|
+
join(xdgConfig, 'opencode', '.pantheon', 'active-preset.json'),
|
|
70
|
+
join(homedir(), '.opencode', '.pantheon', 'active-preset.json'),
|
|
71
|
+
]
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
let raw: string
|
|
74
|
+
try {
|
|
75
|
+
raw = await readFile(candidate, 'utf8')
|
|
76
|
+
} catch {
|
|
77
|
+
continue // candidate does not exist — try the next one
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(raw) as { preset?: unknown }
|
|
81
|
+
const name = parsed && typeof parsed === 'object' ? parsed.preset : undefined
|
|
82
|
+
if (typeof name !== 'string' || name.length === 0 || name === 'none') return null
|
|
83
|
+
return { name, source: 'file' }
|
|
84
|
+
} catch {
|
|
85
|
+
return null // malformed file — treat as default (mirrors presets.mjs)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Env leg of resolution: PANTHEON_MODEL_PRESET set and !== 'none' wins. */
|
|
92
|
+
export function presetFromEnv(env: Record<string, string | undefined>): PresetInfo {
|
|
93
|
+
const name = env.PANTHEON_MODEL_PRESET
|
|
94
|
+
if (name !== undefined && name !== '' && name !== 'none') {
|
|
95
|
+
return { name, source: 'env' }
|
|
96
|
+
}
|
|
97
|
+
return { name: null, source: null }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve the active preset for the sidebar: env > file > default. */
|
|
101
|
+
export async function resolvePresetForTui(
|
|
102
|
+
env: Record<string, string | undefined>,
|
|
103
|
+
cwd: string,
|
|
104
|
+
): Promise<PresetInfo> {
|
|
105
|
+
const envPreset = presetFromEnv(env)
|
|
106
|
+
if (envPreset.source === 'env') return envPreset
|
|
107
|
+
return (await readActivePresetFile(cwd)) ?? { name: null, source: null }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ─── Session helpers ────────────────────────────────────── */
|
|
111
|
+
|
|
112
|
+
/** `SessionInfo.id` is always a `ses…` handle. Guarded so a placeholder or a
|
|
113
|
+
* foreign id never reaches the status/navigate APIs. */
|
|
114
|
+
export function isValidSessionID(id: unknown): id is string {
|
|
115
|
+
return typeof id === 'string' && id.startsWith('ses')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function numberOrNull(value: unknown): number | null {
|
|
119
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ─── Delegation helpers ─────────────────────────────────── */
|
|
123
|
+
|
|
124
|
+
export type DelegationState = 'running' | 'completed' | 'failed' | 'interrupted'
|
|
125
|
+
export type DelegationTone = 'warning' | 'error' | 'success'
|
|
126
|
+
|
|
127
|
+
/** Grace window for a child that has not started yet. V2's `session.status()`
|
|
128
|
+
* returns `idle` both for "created but not started" and for "finished", so an
|
|
129
|
+
* idle/unknown/absent child whose own last activity (`time.updated`, falling
|
|
130
|
+
* back to `time.created`) is this recent still reads as running — otherwise a
|
|
131
|
+
* just-spawned child renders a premature ✓. Trade-off (V1 parity: it had the
|
|
132
|
+
* same window for its absent statuses): a child that finished within the window
|
|
133
|
+
* and reports no `outcome` reads as running until the window closes, at which
|
|
134
|
+
* point it turns terminal and its completion toast fires. A child that reports
|
|
135
|
+
* a terminal `outcome` is never held back by the window. */
|
|
136
|
+
export const DELEGATION_CHILD_STATUS_GRACE_MS = 60 * 1000
|
|
137
|
+
|
|
138
|
+
/** One delegated child job as the sidebar renders it. */
|
|
139
|
+
export type DelegationView = {
|
|
140
|
+
/** Child session id — also the click-through target. */
|
|
141
|
+
id: string
|
|
142
|
+
/** Alias shown on the row: the child title, else agent, else short id. */
|
|
143
|
+
label: string
|
|
144
|
+
state: DelegationState
|
|
145
|
+
/** Epoch ms of the child's `time.created`. */
|
|
146
|
+
startedAt: number | null
|
|
147
|
+
/** Epoch ms of the child's `time.updated`. */
|
|
148
|
+
updatedAt: number | null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Map the host's live session status to a display state. V2 exposes only
|
|
152
|
+
* `"idle" | "running"` through the TUI context (the client union adds `retry`
|
|
153
|
+
* and `busy`); `error`/`failed` are handled defensively for host drift.
|
|
154
|
+
* `idle`, `retry` and unknown/absent statuses are terminal only once the
|
|
155
|
+
* child's own activity is older than the grace window — within it the child
|
|
156
|
+
* reads as running (see `DELEGATION_CHILD_STATUS_GRACE_MS`). An interruption is
|
|
157
|
+
* not part of this union: it arrives as a terminal `outcome`. Pure. */
|
|
158
|
+
export function delegationStateFromStatus(
|
|
159
|
+
status: string | undefined,
|
|
160
|
+
time?: { created?: number; updated?: number },
|
|
161
|
+
now = Date.now(),
|
|
162
|
+
graceMs = DELEGATION_CHILD_STATUS_GRACE_MS,
|
|
163
|
+
): DelegationState {
|
|
164
|
+
switch (status) {
|
|
165
|
+
case 'running':
|
|
166
|
+
case 'busy':
|
|
167
|
+
return 'running'
|
|
168
|
+
case 'error':
|
|
169
|
+
case 'failed':
|
|
170
|
+
return 'failed'
|
|
171
|
+
default: {
|
|
172
|
+
const lastActivity = Math.max(time?.updated ?? 0, time?.created ?? 0)
|
|
173
|
+
if (lastActivity > 0 && now - lastActivity < graceMs) return 'running'
|
|
174
|
+
return 'completed'
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Map a V2 child `outcome` to a display state. `interrupted` (user abort,
|
|
180
|
+
* shutdown, superseded, inactivity) is distinct from both success and failure,
|
|
181
|
+
* so an aborted child never renders the ✓ of a completed one. Returns null for
|
|
182
|
+
* an absent/unrecognised outcome so the live status decides. Pure. */
|
|
183
|
+
export function delegationStateFromOutcome(outcome: unknown): DelegationState | null {
|
|
184
|
+
switch (outcome) {
|
|
185
|
+
case 'succeeded':
|
|
186
|
+
return 'completed'
|
|
187
|
+
case 'failed':
|
|
188
|
+
return 'failed'
|
|
189
|
+
case 'interrupted':
|
|
190
|
+
return 'interrupted'
|
|
191
|
+
default:
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Build a delegation row from a child session + its live status. A terminal
|
|
197
|
+
* `outcome` wins when present (the only V2 primitive that surfaces a
|
|
198
|
+
* delegation failure/interruption once the status is back to idle); otherwise
|
|
199
|
+
* the live status decides, with the absent/idle grace window applied. Pure. */
|
|
200
|
+
export function toDelegationView(
|
|
201
|
+
child: any,
|
|
202
|
+
status: string | undefined,
|
|
203
|
+
now = Date.now(),
|
|
204
|
+
): DelegationView {
|
|
205
|
+
const id = typeof child?.id === 'string' ? child.id : ''
|
|
206
|
+
const title = typeof child?.title === 'string' && child.title !== '' ? child.title : ''
|
|
207
|
+
const agent = typeof child?.agent === 'string' && child.agent !== '' ? child.agent : ''
|
|
208
|
+
const label = title !== '' ? title : agent !== '' ? agent : id.slice(0, 8)
|
|
209
|
+
const state: DelegationState =
|
|
210
|
+
delegationStateFromOutcome(child?.outcome) ??
|
|
211
|
+
delegationStateFromStatus(status, child?.time, now)
|
|
212
|
+
return {
|
|
213
|
+
id,
|
|
214
|
+
label,
|
|
215
|
+
state,
|
|
216
|
+
startedAt: numberOrNull(child?.time?.created),
|
|
217
|
+
updatedAt: numberOrNull(child?.time?.updated),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const DELEGATION_TONES: Record<DelegationState, DelegationTone> = {
|
|
222
|
+
running: 'warning',
|
|
223
|
+
completed: 'success',
|
|
224
|
+
failed: 'error',
|
|
225
|
+
interrupted: 'warning',
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Whole-row tone: red = failed, green = terminal, yellow = in flight. The
|
|
229
|
+
* glyph stays a redundant channel so the state reads in monochrome. Pure. */
|
|
230
|
+
export function delegationStateTone(state: DelegationState): DelegationTone {
|
|
231
|
+
return DELEGATION_TONES[state]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
|
|
235
|
+
|
|
236
|
+
/** Spinner glyph for a frame counter (incremented every ~80ms while any job is
|
|
237
|
+
* running). Pure. */
|
|
238
|
+
export function spinnerFrame(frame: number): string {
|
|
239
|
+
const index = Math.abs(Math.floor(frame)) % SPINNER_FRAMES.length
|
|
240
|
+
return SPINNER_FRAMES[index] ?? SPINNER_FRAMES[0]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Row marker (`<glyph> `): the animated spinner while running, ✓ when done,
|
|
244
|
+
* ⊘ when interrupted, ✕ on failure. Pure. */
|
|
245
|
+
export function delegationRowMarker(state: DelegationState, frame = 0): string {
|
|
246
|
+
if (state === 'running') return `${spinnerFrame(frame)} `
|
|
247
|
+
if (state === 'failed') return '\u2715 '
|
|
248
|
+
if (state === 'interrupted') return '\u2298 '
|
|
249
|
+
return '\u2713 '
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Compact single-unit elapsed label: "12s"/"3m"/"1h"/"2d". Pure. */
|
|
253
|
+
export function fmtElapsed(ms: number): string {
|
|
254
|
+
const total = Math.max(0, Math.floor(ms / 1000))
|
|
255
|
+
const days = Math.floor(total / 86_400)
|
|
256
|
+
const hours = Math.floor((total % 86_400) / 3_600)
|
|
257
|
+
const minutes = Math.floor((total % 3_600) / 60)
|
|
258
|
+
const seconds = total % 60
|
|
259
|
+
if (days > 0) return `${days}d`
|
|
260
|
+
if (hours > 0) return `${hours}h`
|
|
261
|
+
if (minutes > 0) return `${minutes}m`
|
|
262
|
+
return `${seconds}s`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Elapsed label for one row: a running job ticks `now - startedAt`; a terminal
|
|
266
|
+
* one is fixed at `updatedAt - startedAt` (em dash when a timestamp is
|
|
267
|
+
* missing). Pure. */
|
|
268
|
+
export function delegationElapsed(view: DelegationView, now: number): string {
|
|
269
|
+
if (view.state === 'running') {
|
|
270
|
+
return view.startedAt === null ? '\u2014' : fmtElapsed(now - view.startedAt)
|
|
271
|
+
}
|
|
272
|
+
if (view.startedAt !== null && view.updatedAt !== null)
|
|
273
|
+
return fmtElapsed(view.updatedAt - view.startedAt)
|
|
274
|
+
return '\u2014'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Live rows first (a running job never falls behind the cap), then by most
|
|
278
|
+
* recent activity. Pure. */
|
|
279
|
+
export function orderDelegations(views: readonly DelegationView[]): DelegationView[] {
|
|
280
|
+
return [...views].sort((a, b) => {
|
|
281
|
+
const activeA = a.state === 'running' ? 1 : 0
|
|
282
|
+
const activeB = b.state === 'running' ? 1 : 0
|
|
283
|
+
if (activeA !== activeB) return activeB - activeA
|
|
284
|
+
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Cap the panel: at most `maxVisible` rows, the remainder collapsing into the
|
|
289
|
+
* "… +N more" line. Pure. */
|
|
290
|
+
export function ceilingDelegations(
|
|
291
|
+
views: readonly DelegationView[],
|
|
292
|
+
maxVisible = DELEGATIONS_VISIBLE_CEILING,
|
|
293
|
+
): { visible: DelegationView[]; hidden: number } {
|
|
294
|
+
const limit = Math.max(0, maxVisible)
|
|
295
|
+
const visible = views.slice(0, limit)
|
|
296
|
+
return { visible, hidden: Math.max(0, views.length - visible.length) }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Session ids to eagerly sync at load (deduped, bounded). Pure. */
|
|
300
|
+
export function collectWarmupTargets(
|
|
301
|
+
sessions: readonly any[],
|
|
302
|
+
limit = WARMUP_SESSION_LIMIT,
|
|
303
|
+
): string[] {
|
|
304
|
+
const ids: string[] = []
|
|
305
|
+
for (const session of sessions) {
|
|
306
|
+
if (!isValidSessionID(session?.id) || ids.includes(session.id)) continue
|
|
307
|
+
ids.push(session.id)
|
|
308
|
+
if (ids.length >= limit) break
|
|
309
|
+
}
|
|
310
|
+
return ids
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* ─── Known Issue guard ──────────────────────────────────── */
|
|
314
|
+
|
|
315
|
+
/** Body a sidebar instance renders when the host has not published a real
|
|
316
|
+
* session id yet. OpenCode V2 scopes the `sidebar.content` slot to a session
|
|
317
|
+
* (`SlotMap["sidebar.content"] = { sessionID: string }`), so before the first
|
|
318
|
+
* prompt the slot may not mount at all — see the `home.footer.status`
|
|
319
|
+
* contribution for the pre-prompt degradation. Pure. */
|
|
320
|
+
export function sidebarPlaceholder(sessionID: string | undefined): string | null {
|
|
321
|
+
return isValidSessionID(sessionID) ? null : SIDEBAR_WAITING_LABEL
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* ─── Click-through ──────────────────────────────────────── */
|
|
325
|
+
|
|
326
|
+
/** Navigate to a session through the V2 router. Fail-open: an absent router or
|
|
327
|
+
* a removed session must never break the sidebar. Returns whether a valid
|
|
328
|
+
* navigation was dispatched. Pure. */
|
|
329
|
+
export function navigateToSession(
|
|
330
|
+
router: { navigate?: (destination: any) => void } | undefined,
|
|
331
|
+
sessionID: string | undefined,
|
|
332
|
+
): boolean {
|
|
333
|
+
if (typeof router?.navigate !== 'function' || !isValidSessionID(sessionID)) return false
|
|
334
|
+
try {
|
|
335
|
+
router.navigate({ type: 'session', sessionID })
|
|
336
|
+
return true
|
|
337
|
+
} catch {
|
|
338
|
+
return false
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ─── Delegate-completion detection (toasts) ─────────────── */
|
|
343
|
+
|
|
344
|
+
export type DelegationCompletion = { id: string; label: string; state: DelegationState }
|
|
345
|
+
|
|
346
|
+
/** Diff two delegation observations: entries that were `running` and are no
|
|
347
|
+
* longer running count as completions, fired once per transition. Returns the
|
|
348
|
+
* next snapshot so callers can keep a single tracker. First observation never
|
|
349
|
+
* reports a completion (no toast spam on mount). Pure. */
|
|
350
|
+
export function detectDelegationCompletions(
|
|
351
|
+
previous: ReadonlyMap<string, DelegationState>,
|
|
352
|
+
current: ReadonlyArray<DelegationCompletion>,
|
|
353
|
+
): { completed: DelegationCompletion[]; snapshot: Map<string, DelegationState> } {
|
|
354
|
+
const snapshot = new Map<string, DelegationState>()
|
|
355
|
+
const completed: DelegationCompletion[] = []
|
|
356
|
+
for (const entry of current) {
|
|
357
|
+
snapshot.set(entry.id, entry.state)
|
|
358
|
+
if (previous.get(entry.id) === 'running' && entry.state !== 'running') completed.push(entry)
|
|
359
|
+
}
|
|
360
|
+
return { completed, snapshot }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* ─── Runtime helpers ────────────────────────────────────── */
|
|
364
|
+
|
|
365
|
+
function resolveLocation(context: any): any {
|
|
366
|
+
try {
|
|
367
|
+
return context?.location ?? context?.data?.location?.default()
|
|
368
|
+
} catch {
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function resolveSidebarCwd(context: any): string {
|
|
374
|
+
const location = resolveLocation(context)
|
|
375
|
+
if (location && typeof location.directory === 'string' && location.directory !== '') {
|
|
376
|
+
return location.directory
|
|
377
|
+
}
|
|
378
|
+
return process.cwd()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function sessionStatusOf(context: any, sessionID: string): string | undefined {
|
|
382
|
+
try {
|
|
383
|
+
const status = context?.data?.session?.status?.(sessionID)
|
|
384
|
+
return typeof status === 'string' ? status : undefined
|
|
385
|
+
} catch {
|
|
386
|
+
return undefined
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Populate the cached data at load so the sidebar has content before the first
|
|
391
|
+
* prompt (Known Issue guard). Fire-and-forget: a missing API or a failed sync
|
|
392
|
+
* degrades silently and the sidebar still renders its static header. */
|
|
393
|
+
function warmUpSidebarData(context: any, location: any): void {
|
|
394
|
+
try {
|
|
395
|
+
void Promise.resolve(context?.data?.location?.vcs?.sync?.(location)).catch(() => {})
|
|
396
|
+
} catch {
|
|
397
|
+
/* vcs API unavailable */
|
|
398
|
+
}
|
|
399
|
+
let listed: any[] = []
|
|
400
|
+
try {
|
|
401
|
+
const result = context?.data?.session?.list?.()
|
|
402
|
+
if (Array.isArray(result)) listed = result
|
|
403
|
+
} catch {
|
|
404
|
+
/* session API unavailable */
|
|
405
|
+
}
|
|
406
|
+
for (const id of collectWarmupTargets(listed)) {
|
|
407
|
+
try {
|
|
408
|
+
void Promise.resolve(context?.data?.session?.sync?.(id)).catch(() => {})
|
|
409
|
+
} catch {
|
|
410
|
+
/* per-session sync unavailable */
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function showDelegationToast(context: any, done: DelegationCompletion): void {
|
|
416
|
+
const variant =
|
|
417
|
+
done.state === 'failed' ? 'error' : done.state === 'interrupted' ? 'warning' : 'success'
|
|
418
|
+
const verb =
|
|
419
|
+
done.state === 'failed' ? 'falhou' : done.state === 'interrupted' ? 'interrompida' : 'concluída'
|
|
420
|
+
try {
|
|
421
|
+
context?.ui?.toast?.show?.({
|
|
422
|
+
title: TOAST_TITLE,
|
|
423
|
+
message: `${done.label} ${verb}`,
|
|
424
|
+
variant,
|
|
425
|
+
sessionID: done.id,
|
|
426
|
+
})
|
|
427
|
+
} catch {
|
|
428
|
+
/* toast API unavailable — the row already carries the state tone */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Active preset signal with mount + 30s refresh. Shared by the sidebar and the
|
|
433
|
+
* home-screen status contribution. */
|
|
434
|
+
function useActivePreset(context: any): () => PresetInfo {
|
|
435
|
+
const [preset, setPreset] = createSignal<PresetInfo>(presetFromEnv(process.env))
|
|
436
|
+
onMount(() => {
|
|
437
|
+
const cwd = resolveSidebarCwd(context)
|
|
438
|
+
let cancelled = false
|
|
439
|
+
const refresh = async () => {
|
|
440
|
+
try {
|
|
441
|
+
const info = await resolvePresetForTui(process.env, cwd)
|
|
442
|
+
if (!cancelled) setPreset(info)
|
|
443
|
+
} catch {
|
|
444
|
+
/* transient read error — keep the last known value */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
void refresh()
|
|
448
|
+
const timer = setInterval(() => void refresh(), PRESET_REFRESH_MS)
|
|
449
|
+
onCleanup(() => {
|
|
450
|
+
cancelled = true
|
|
451
|
+
clearInterval(timer)
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
return preset
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* ─── Reactive model ─────────────────────────────────────── */
|
|
458
|
+
|
|
459
|
+
/** Reactive state the sidebar view renders. Accessors keep the view reactive
|
|
460
|
+
* while the model itself stays free of JSX, so its effects are exercisable
|
|
461
|
+
* without a TUI renderer. */
|
|
462
|
+
export type SidebarModel = {
|
|
463
|
+
activeSessionID: Accessor<string>
|
|
464
|
+
session: Accessor<any>
|
|
465
|
+
branch: Accessor<string | null>
|
|
466
|
+
delegations: Accessor<DelegationView[]>
|
|
467
|
+
ceiling: Accessor<{ visible: DelegationView[]; hidden: number }>
|
|
468
|
+
hasRunning: Accessor<boolean>
|
|
469
|
+
frame: Accessor<number>
|
|
470
|
+
preset: Accessor<PresetInfo>
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Reactive wiring behind the sidebar: refresh subscription, warm syncs, the
|
|
474
|
+
* running-only spinner and the completion-toast effect. `context` stays loosely
|
|
475
|
+
* typed (the host API only exists at runtime) but the refresh event names are
|
|
476
|
+
* narrowed to the V2 union at the subscription site. */
|
|
477
|
+
export function createSidebarModel(
|
|
478
|
+
context: any,
|
|
479
|
+
sessionID: () => string | undefined,
|
|
480
|
+
): SidebarModel {
|
|
481
|
+
const [revision, setRevision] = createSignal(0)
|
|
482
|
+
const [frame, setFrame] = createSignal(0)
|
|
483
|
+
const location = resolveLocation(context)
|
|
484
|
+
const preset = useActivePreset(context)
|
|
485
|
+
let observedStates = new Map<string, DelegationState>()
|
|
486
|
+
|
|
487
|
+
const refresh = () => setRevision((value) => value + 1)
|
|
488
|
+
const activeSessionID = () => {
|
|
489
|
+
const id = sessionID()
|
|
490
|
+
return isValidSessionID(id) ? id : ''
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const session = createMemo(() => {
|
|
494
|
+
revision()
|
|
495
|
+
const id = activeSessionID()
|
|
496
|
+
if (id === '') return undefined
|
|
497
|
+
try {
|
|
498
|
+
return context.data.session.get(id)
|
|
499
|
+
} catch {
|
|
500
|
+
/* session API unavailable */
|
|
501
|
+
return undefined
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const branch = createMemo(() => {
|
|
506
|
+
revision()
|
|
507
|
+
try {
|
|
508
|
+
return context.data.location.vcs.info(location)?.branch?.current ?? null
|
|
509
|
+
} catch {
|
|
510
|
+
/* vcs API unavailable */
|
|
511
|
+
return null
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
// Delegations = the children of the active session (native task() children),
|
|
516
|
+
// enriched with each child's live status + outcome. The V2 family API returns
|
|
517
|
+
// the id set; `parentID` narrows it to direct children exactly like V1.
|
|
518
|
+
const allDelegations = createMemo(() => {
|
|
519
|
+
revision()
|
|
520
|
+
const id = activeSessionID()
|
|
521
|
+
if (id === '') return []
|
|
522
|
+
let family: string[] = []
|
|
523
|
+
try {
|
|
524
|
+
const result = context.data.session.family?.(id)
|
|
525
|
+
if (Array.isArray(result)) family = result
|
|
526
|
+
} catch {
|
|
527
|
+
return []
|
|
528
|
+
}
|
|
529
|
+
const now = Date.now()
|
|
530
|
+
const views: DelegationView[] = []
|
|
531
|
+
for (const childID of family) {
|
|
532
|
+
if (childID === id || !isValidSessionID(childID)) continue
|
|
533
|
+
const child = context.data.session.get(childID)
|
|
534
|
+
if (child?.parentID !== id) continue
|
|
535
|
+
views.push(toDelegationView(child, sessionStatusOf(context, childID), now))
|
|
536
|
+
}
|
|
537
|
+
return orderDelegations(views)
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
// The sidebar lists in-flight work only; terminal jobs are covered by the
|
|
541
|
+
// completion toast, so they are dropped from the displayed set. The full set
|
|
542
|
+
// is kept for the running→terminal detection that feeds those toasts.
|
|
543
|
+
const delegations = createMemo(() => allDelegations().filter((view) => view.state === 'running'))
|
|
544
|
+
|
|
545
|
+
const ceiling = createMemo(() => ceilingDelegations(delegations(), DELEGATIONS_VISIBLE_CEILING))
|
|
546
|
+
const hasRunning = createMemo(() => delegations().length > 0)
|
|
547
|
+
|
|
548
|
+
onMount(() => {
|
|
549
|
+
const stop: Array<() => void> = []
|
|
550
|
+
// The event names come from the V2 union; this cast is what turns an unknown
|
|
551
|
+
// name into a compile error instead of a silent no-op subscription.
|
|
552
|
+
const data = context?.data as Context['data'] | undefined
|
|
553
|
+
if (typeof data?.on === 'function') {
|
|
554
|
+
for (const type of SIDEBAR_REFRESH_EVENTS) {
|
|
555
|
+
try {
|
|
556
|
+
const unsubscribe = data.on(type, refresh)
|
|
557
|
+
if (typeof unsubscribe === 'function') stop.push(unsubscribe)
|
|
558
|
+
} catch {
|
|
559
|
+
// Sidebar stays useful with the initial cached state if an event is unavailable.
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const id = activeSessionID()
|
|
564
|
+
if (id !== '') {
|
|
565
|
+
try {
|
|
566
|
+
void Promise.resolve(context.data.session.sync(id)).then(refresh)
|
|
567
|
+
} catch {
|
|
568
|
+
/* sync unavailable */
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
void Promise.resolve(context.data.location.vcs.sync(location)).then(refresh)
|
|
573
|
+
} catch {
|
|
574
|
+
/* vcs sync unavailable */
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Spinner ticks only while a job is running; terminal rows stay static. The
|
|
578
|
+
// interval is armed on the running edge and cleared on the terminal one, so
|
|
579
|
+
// an idle sidebar schedules no work at all.
|
|
580
|
+
createEffect(() => {
|
|
581
|
+
if (!hasRunning()) return
|
|
582
|
+
const spinner = setInterval(() => setFrame((value) => value + 1), SPINNER_INTERVAL_MS)
|
|
583
|
+
onCleanup(() => clearInterval(spinner))
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
onCleanup(() => {
|
|
587
|
+
for (const unsubscribe of stop) {
|
|
588
|
+
if (typeof unsubscribe === 'function') unsubscribe()
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
// Discrete completion toast: exactly one per running→terminal transition.
|
|
594
|
+
createEffect(() => {
|
|
595
|
+
const views = allDelegations()
|
|
596
|
+
if (activeSessionID() === '') {
|
|
597
|
+
observedStates = new Map()
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
const { completed, snapshot } = detectDelegationCompletions(
|
|
601
|
+
observedStates,
|
|
602
|
+
views.map((view) => ({ id: view.id, label: view.label, state: view.state })),
|
|
603
|
+
)
|
|
604
|
+
observedStates = snapshot
|
|
605
|
+
for (const done of completed) showDelegationToast(context, done)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
activeSessionID,
|
|
610
|
+
session,
|
|
611
|
+
branch,
|
|
612
|
+
delegations,
|
|
613
|
+
ceiling,
|
|
614
|
+
hasRunning,
|
|
615
|
+
frame,
|
|
616
|
+
preset,
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/* ─── Components ─────────────────────────────────────────── */
|
|
621
|
+
|
|
622
|
+
function Sidebar(props: { context: any; sessionID?: string }) {
|
|
623
|
+
const { context } = props
|
|
624
|
+
const model = createSidebarModel(context, () => props.sessionID)
|
|
625
|
+
const text = () => context.theme.text?.base
|
|
626
|
+
const muted = () => context.theme.text?.muted ?? text()
|
|
627
|
+
const tone = (value: DelegationTone) => context.theme?.text?.feedback?.[value]?.base ?? text()
|
|
628
|
+
const open = (sessionID: string) => () => navigateToSession(context.ui?.router, sessionID)
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<box flexDirection="column" width="100%" padding={1}>
|
|
632
|
+
<text fg={text()} attributes={1}>{`Pantheon v${__PANTHEON_VERSION__}`}</text>
|
|
633
|
+
<text fg={muted()}>OpenCode V2 · plugin TUI ativo</text>
|
|
634
|
+
<Show when={model.branch()}>
|
|
635
|
+
{(value) => <text fg={muted()}>{`Branch: ${value()}`}</text>}
|
|
636
|
+
</Show>
|
|
637
|
+
<Show
|
|
638
|
+
when={model.preset().name}
|
|
639
|
+
fallback={
|
|
640
|
+
<box flexDirection="row">
|
|
641
|
+
<text fg={muted()}>Preset: default</text>
|
|
642
|
+
</box>
|
|
643
|
+
}
|
|
644
|
+
>
|
|
645
|
+
{(name) => (
|
|
646
|
+
<box flexDirection="row">
|
|
647
|
+
<text fg={muted()}>{'⚡ Preset: '}</text>
|
|
648
|
+
<text fg={text()}>{name()}</text>
|
|
649
|
+
<text fg={muted()}>{` (${model.preset().source ?? ''})`}</text>
|
|
650
|
+
</box>
|
|
651
|
+
)}
|
|
652
|
+
</Show>
|
|
653
|
+
|
|
654
|
+
<Show
|
|
655
|
+
when={model.activeSessionID() !== '' && model.session()}
|
|
656
|
+
fallback={<text fg={muted()}>{sidebarPlaceholder(props.sessionID)}</text>}
|
|
657
|
+
>
|
|
658
|
+
{(current: any) => (
|
|
659
|
+
<>
|
|
660
|
+
<text fg={text()}>{`Sessão: ${current().title || current().id}`}</text>
|
|
661
|
+
<Show when={current().agent}>
|
|
662
|
+
<text fg={muted()}>{`Agente: ${current().agent}`}</text>
|
|
663
|
+
</Show>
|
|
664
|
+
</>
|
|
665
|
+
)}
|
|
666
|
+
</Show>
|
|
667
|
+
|
|
668
|
+
<Show when={model.delegations().length > 0}>
|
|
669
|
+
<text fg={text()} attributes={1}>
|
|
670
|
+
Delegações
|
|
671
|
+
</text>
|
|
672
|
+
<For each={model.ceiling().visible}>
|
|
673
|
+
{(view: DelegationView) => (
|
|
674
|
+
<box onMouseDown={open(view.id)}>
|
|
675
|
+
<text fg={tone(delegationStateTone(view.state))}>
|
|
676
|
+
{`${delegationRowMarker(view.state, model.frame())}${view.label} ${delegationElapsed(view, Date.now())}`}
|
|
677
|
+
</text>
|
|
678
|
+
</box>
|
|
679
|
+
)}
|
|
680
|
+
</For>
|
|
681
|
+
<Show when={model.ceiling().hidden > 0}>
|
|
682
|
+
<text fg={muted()}>{`… +${model.ceiling().hidden} more`}</text>
|
|
683
|
+
</Show>
|
|
684
|
+
</Show>
|
|
685
|
+
</box>
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Home-screen status contribution. The sidebar slot is session-scoped, so
|
|
690
|
+
* before the first prompt the sidebar may not mount at all (Known Issue) —
|
|
691
|
+
* this keeps a compact Pantheon line visible on the home footer meanwhile. */
|
|
692
|
+
function HomeStatus(props: { context: any }) {
|
|
693
|
+
const preset = useActivePreset(props.context)
|
|
694
|
+
const muted = () => props.context.theme?.text?.muted ?? props.context.theme?.text?.base
|
|
695
|
+
return (
|
|
696
|
+
<text
|
|
697
|
+
fg={muted()}
|
|
698
|
+
>{`Pantheon v${__PANTHEON_VERSION__}${preset().name ? ` ⚡ ${preset().name}` : ''}`}</text>
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/* ─── Plugin registration ────────────────────────────────── */
|
|
703
|
+
|
|
704
|
+
const plugin = Plugin.define({
|
|
705
|
+
id: 'pantheon.tui',
|
|
706
|
+
setup(context: Context) {
|
|
707
|
+
const globalState = globalThis as unknown as Record<PropertyKey, unknown>
|
|
708
|
+
if (globalState[SINGLETON]) return
|
|
709
|
+
globalState[SINGLETON] = true
|
|
710
|
+
|
|
711
|
+
const location = resolveLocation(context)
|
|
712
|
+
// Eager load/sync at setup so the cache is warm before the slot's first
|
|
713
|
+
// render (Known Issue: empty sidebar before the first prompt).
|
|
714
|
+
warmUpSidebarData(context, location)
|
|
715
|
+
|
|
716
|
+
const unregisterSidebar = context.ui.slot({
|
|
717
|
+
append: 'sidebar.content',
|
|
718
|
+
render: ({ sessionID }: { sessionID: string }) => (
|
|
719
|
+
<Sidebar context={context} sessionID={sessionID} />
|
|
720
|
+
),
|
|
721
|
+
})
|
|
722
|
+
const unregisterHome = context.ui.slot({
|
|
723
|
+
append: 'home.footer.status',
|
|
724
|
+
render: () => <HomeStatus context={context} />,
|
|
725
|
+
})
|
|
726
|
+
return () => {
|
|
727
|
+
if (typeof unregisterSidebar === 'function') unregisterSidebar()
|
|
728
|
+
if (typeof unregisterHome === 'function') unregisterHome()
|
|
729
|
+
delete globalState[SINGLETON]
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
export default plugin
|