opencode-subagent-magazine 1.1.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.
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/_version.d.ts +1 -0
- package/dist/_version.js +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1128 -0
- package/install.mjs +103 -0
- package/package.json +56 -0
- package/src/_version.ts +2 -0
- package/src/index.tsx +1380 -0
package/src/index.tsx
ADDED
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "@opentui/solid"
|
|
4
|
+
import type {
|
|
5
|
+
TuiPlugin,
|
|
6
|
+
TuiPluginApi,
|
|
7
|
+
TuiSlotContext,
|
|
8
|
+
TuiSlotPlugin,
|
|
9
|
+
TuiPluginModule,
|
|
10
|
+
TuiThemeCurrent,
|
|
11
|
+
} from "@opencode-ai/plugin/tui"
|
|
12
|
+
import {
|
|
13
|
+
createMemo,
|
|
14
|
+
createSignal,
|
|
15
|
+
createEffect,
|
|
16
|
+
onMount,
|
|
17
|
+
onCleanup,
|
|
18
|
+
untrack,
|
|
19
|
+
Show,
|
|
20
|
+
For,
|
|
21
|
+
} from "solid-js"
|
|
22
|
+
import { PLUGIN_VERSION } from "./_version"
|
|
23
|
+
|
|
24
|
+
// ===================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// ===================================================================
|
|
27
|
+
|
|
28
|
+
type SubStatus = "running" | "done" | "error"
|
|
29
|
+
|
|
30
|
+
interface SubEntry {
|
|
31
|
+
id: string
|
|
32
|
+
title: string
|
|
33
|
+
agent: string
|
|
34
|
+
prompt: string
|
|
35
|
+
error?: string
|
|
36
|
+
tokens?: number
|
|
37
|
+
cost?: number
|
|
38
|
+
status: SubStatus
|
|
39
|
+
sessionId?: string
|
|
40
|
+
startedAt: number
|
|
41
|
+
endedAt?: number
|
|
42
|
+
model?: string
|
|
43
|
+
todoTotal?: number
|
|
44
|
+
todoDone?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Lang = "zh" | "en"
|
|
48
|
+
|
|
49
|
+
/** OpenCode built-in tool names that spawn sub-agents or delegate tasks. */
|
|
50
|
+
const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"])
|
|
51
|
+
|
|
52
|
+
// ===================================================================
|
|
53
|
+
// i18n
|
|
54
|
+
// ===================================================================
|
|
55
|
+
|
|
56
|
+
const I18N: Record<Lang, Record<string, string>> = {
|
|
57
|
+
zh: {
|
|
58
|
+
"panel.title": "子代理",
|
|
59
|
+
"status.none": "暂无子代理",
|
|
60
|
+
"agent.label": "代理",
|
|
61
|
+
"time.label": "耗时",
|
|
62
|
+
"tokens.label": "上下文",
|
|
63
|
+
"error.label": "错误",
|
|
64
|
+
"model.label": "模型",
|
|
65
|
+
"todo.label": "进度",
|
|
66
|
+
"open.label": "进入会话",
|
|
67
|
+
"cost.label": "费用",
|
|
68
|
+
"scroll.more": "更多",
|
|
69
|
+
"scroll.top": "回顶",
|
|
70
|
+
},
|
|
71
|
+
en: {
|
|
72
|
+
"panel.title": "SubAgent",
|
|
73
|
+
"status.none": "No sub-agents yet",
|
|
74
|
+
"agent.label": "agent",
|
|
75
|
+
"time.label": "time",
|
|
76
|
+
"tokens.label": "tokens",
|
|
77
|
+
"error.label": "error",
|
|
78
|
+
"model.label": "model",
|
|
79
|
+
"todo.label": "todo",
|
|
80
|
+
"open.label": "Open session",
|
|
81
|
+
"cost.label": "cost",
|
|
82
|
+
"scroll.more": "more",
|
|
83
|
+
"scroll.top": "Top",
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
declare const process: { env: Record<string, string | undefined> } | undefined
|
|
88
|
+
|
|
89
|
+
function detectLang(): Lang {
|
|
90
|
+
const env = process?.env?.OPENCODE_LANG ?? process?.env?.LANG ?? ""
|
|
91
|
+
if (env.startsWith("zh")) return "zh"
|
|
92
|
+
return "en"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ===================================================================
|
|
96
|
+
// Helpers — visual width
|
|
97
|
+
// ===================================================================
|
|
98
|
+
|
|
99
|
+
function charColumns(c: string): number {
|
|
100
|
+
const code = c.codePointAt(0) ?? 0
|
|
101
|
+
if (code < 0x20) return 0
|
|
102
|
+
if (code < 0x7f) return 1
|
|
103
|
+
if (code < 0xa0) return 0
|
|
104
|
+
if (
|
|
105
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
106
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
107
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
108
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
109
|
+
(code >= 0xfe10 && code <= 0xfe6f) ||
|
|
110
|
+
(code >= 0xff01 && code <= 0xff60) ||
|
|
111
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
112
|
+
(code >= 0x1f300 && code <= 0x1f64f) ||
|
|
113
|
+
(code >= 0x20000 && code <= 0x3fffd)
|
|
114
|
+
)
|
|
115
|
+
return 2
|
|
116
|
+
return 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function visualWidth(s: string): number {
|
|
120
|
+
let w = 0
|
|
121
|
+
for (const c of s) w += charColumns(c)
|
|
122
|
+
return w
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function truncate(text: string, maxCols: number): string {
|
|
126
|
+
if (visualWidth(text) <= maxCols) return text
|
|
127
|
+
let cols = 0
|
|
128
|
+
let i = 0
|
|
129
|
+
for (const c of text) {
|
|
130
|
+
const w = charColumns(c)
|
|
131
|
+
if (cols + w > maxCols - 1) break
|
|
132
|
+
cols += w
|
|
133
|
+
i += c.length
|
|
134
|
+
}
|
|
135
|
+
return text.slice(0, i) + "\u2026"
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function fmtDurationShort(ms: number, running: boolean): string {
|
|
139
|
+
if (running && ms < 2000) return ""
|
|
140
|
+
if (ms < 1000) return (ms / 1000).toFixed(2) + "s"
|
|
141
|
+
if (ms < 60000) return (ms / 1000).toFixed(2) + "s"
|
|
142
|
+
const m = Math.floor(ms / 60000)
|
|
143
|
+
const s = Math.round((ms % 60000) / 1000)
|
|
144
|
+
return `${m}m${s}s`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fmtTokens(n: number): string {
|
|
148
|
+
if (n < 1000) return `${n}`
|
|
149
|
+
if (n < 1000000) return `${(n / 1000).toFixed(1)}k`
|
|
150
|
+
return `${(n / 1000000).toFixed(1)}M`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ===================================================================
|
|
154
|
+
// Color helpers — Morandi palette
|
|
155
|
+
// ===================================================================
|
|
156
|
+
|
|
157
|
+
function rgb(raw: unknown): { r: number; g: number; b: number } | null {
|
|
158
|
+
if (typeof raw === "string" && raw.startsWith("#")) {
|
|
159
|
+
const h = raw.slice(1)
|
|
160
|
+
return {
|
|
161
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
162
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
163
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (raw && typeof raw === "object") {
|
|
167
|
+
const o = raw as Record<string, unknown>
|
|
168
|
+
if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
|
|
169
|
+
const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
|
|
170
|
+
return { r: Math.round(o.r * scale), g: Math.round(o.g * scale), b: Math.round(o.b * scale) }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function saturation(r: number, g: number, b: number): number {
|
|
177
|
+
const max = Math.max(r, g, b) / 255
|
|
178
|
+
const min = Math.min(r, g, b) / 255
|
|
179
|
+
const delta = max - min
|
|
180
|
+
if (delta === 0) return 0
|
|
181
|
+
const L = (max + min) / 2
|
|
182
|
+
return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
|
|
186
|
+
const c = rgb(raw)
|
|
187
|
+
if (!c) return fallback
|
|
188
|
+
const sat = saturation(c.r, c.g, c.b)
|
|
189
|
+
if (sat <= maxSat) {
|
|
190
|
+
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
|
|
191
|
+
}
|
|
192
|
+
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
|
|
193
|
+
let lo = 0, hi = 1
|
|
194
|
+
for (let i = 0; i < 12; i++) {
|
|
195
|
+
const mid = (lo + hi) / 2
|
|
196
|
+
const nr = Math.round(c.r + (luma - c.r) * mid)
|
|
197
|
+
const ng = Math.round(c.g + (luma - c.g) * mid)
|
|
198
|
+
const nb = Math.round(c.b + (luma - c.b) * mid)
|
|
199
|
+
if (saturation(nr, ng, nb) > maxSat) lo = mid
|
|
200
|
+
else hi = mid
|
|
201
|
+
}
|
|
202
|
+
const nr = Math.round(c.r + (luma - c.r) * hi)
|
|
203
|
+
const ng = Math.round(c.g + (luma - c.g) * hi)
|
|
204
|
+
const nb = Math.round(c.b + (luma - c.b) * hi)
|
|
205
|
+
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function dimColor(hex: string, factor = 0.5): string {
|
|
209
|
+
const c = rgb(hex)
|
|
210
|
+
if (!c) return hex
|
|
211
|
+
const r = Math.round(c.r * factor)
|
|
212
|
+
const g = Math.round(c.g * factor)
|
|
213
|
+
const b = Math.round(c.b * factor)
|
|
214
|
+
return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const FALLBACK = {
|
|
218
|
+
primary: "#8B9DAF", text: "#C5C5BB", muted: "#7A7A72",
|
|
219
|
+
success: "#9CAF8B", warning: "#C5B88D", error: "#B08A8A", border: "#6B6B63",
|
|
220
|
+
} as const
|
|
221
|
+
|
|
222
|
+
const MAX_SAT = 0.28
|
|
223
|
+
|
|
224
|
+
/** Entry line left prefix: icon + space + status dot + space */
|
|
225
|
+
const LEFT_PAD = 4
|
|
226
|
+
/** Detail row indent: two spaces */
|
|
227
|
+
const INDENT = 2
|
|
228
|
+
|
|
229
|
+
function safeErrorMsg(err: unknown): string {
|
|
230
|
+
if (!err) return ""
|
|
231
|
+
if (typeof err === "string") return err
|
|
232
|
+
if (typeof err === "object") return String((err as any).message || (err as any).code || "")
|
|
233
|
+
return ""
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ===================================================================
|
|
237
|
+
// Sidebar component
|
|
238
|
+
// ===================================================================
|
|
239
|
+
|
|
240
|
+
function SubAgentPanel(props: {
|
|
241
|
+
theme: TuiThemeCurrent
|
|
242
|
+
api: TuiPluginApi
|
|
243
|
+
lang: () => Lang
|
|
244
|
+
maxEntries: () => number
|
|
245
|
+
sessionId: string
|
|
246
|
+
}): JSX.Element {
|
|
247
|
+
const t = (key: string) => I18N[props.lang()][key] ?? key
|
|
248
|
+
|
|
249
|
+
// ── session data (single-key, true deletion on cleanup) ──
|
|
250
|
+
const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`
|
|
251
|
+
const TTL_MS = 3 * 24 * 60 * 60 * 1000
|
|
252
|
+
|
|
253
|
+
interface SessionRecord {
|
|
254
|
+
ts: number
|
|
255
|
+
entries: SubEntry[]
|
|
256
|
+
scroll: number
|
|
257
|
+
expanded: string
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const loadSessionData = (): Record<string, SessionRecord> => {
|
|
261
|
+
try {
|
|
262
|
+
const raw = props.api.kv.get(SESSION_DATA_KEY, "{}")
|
|
263
|
+
return JSON.parse(String(raw))
|
|
264
|
+
} catch { return {} }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const saveSessionData = (data: Record<string, SessionRecord>) => {
|
|
268
|
+
try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const loadEntries = (sid: string): Map<string, SubEntry> => {
|
|
272
|
+
const m = new Map<string, SubEntry>()
|
|
273
|
+
try {
|
|
274
|
+
const rec = loadSessionData()[sid]
|
|
275
|
+
if (rec?.entries) {
|
|
276
|
+
for (const e of rec.entries) m.set(e.id, e)
|
|
277
|
+
}
|
|
278
|
+
} catch {}
|
|
279
|
+
return m
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let persistTimer: ReturnType<typeof setTimeout> | undefined
|
|
283
|
+
const persistEntries = (sid: string, entries: Map<string, SubEntry>) => {
|
|
284
|
+
clearTimeout(persistTimer)
|
|
285
|
+
persistTimer = setTimeout(() => {
|
|
286
|
+
try {
|
|
287
|
+
const data = loadSessionData()
|
|
288
|
+
data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()] }
|
|
289
|
+
saveSessionData(data)
|
|
290
|
+
} catch {}
|
|
291
|
+
}, 200)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const persistScroll = (sid: string, scroll: number) => {
|
|
295
|
+
try {
|
|
296
|
+
const data = loadSessionData()
|
|
297
|
+
data[sid] = { ...data[sid], ts: Date.now(), scroll }
|
|
298
|
+
saveSessionData(data)
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const persistExpanded = (sid: string, expanded: string) => {
|
|
303
|
+
try {
|
|
304
|
+
const data = loadSessionData()
|
|
305
|
+
data[sid] = { ...data[sid], ts: Date.now(), expanded }
|
|
306
|
+
saveSessionData(data)
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const cleanupOldSessions = () => {
|
|
311
|
+
try {
|
|
312
|
+
const data = loadSessionData()
|
|
313
|
+
const cutoff = Date.now() - TTL_MS
|
|
314
|
+
let changed = false
|
|
315
|
+
for (const sid of Object.keys(data)) {
|
|
316
|
+
if (data[sid].ts < cutoff) {
|
|
317
|
+
delete data[sid]
|
|
318
|
+
changed = true
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (changed) saveSessionData(data)
|
|
322
|
+
} catch {}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
cleanupOldSessions()
|
|
326
|
+
|
|
327
|
+
const [entryMap, setEntryMapRaw] = createSignal(loadEntries(props.sessionId))
|
|
328
|
+
|
|
329
|
+
// Wrapped setter — also persists to kv on every mutation
|
|
330
|
+
const setEntryMap = (
|
|
331
|
+
arg: Map<string, SubEntry> | ((prev: Map<string, SubEntry>) => Map<string, SubEntry>),
|
|
332
|
+
) => {
|
|
333
|
+
setEntryMapRaw((prev) => {
|
|
334
|
+
const next = typeof arg === "function" ? (arg as Function)(prev) : arg
|
|
335
|
+
persistEntries(props.sessionId, next)
|
|
336
|
+
return next
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const [panelWidth, setPanelWidth] = createSignal(28)
|
|
341
|
+
const [open, setOpen] = createSignal(
|
|
342
|
+
(() => { try { return props.api.kv.get(`${KV_PREFIX}.open`, true) as boolean } catch { return true } })()
|
|
343
|
+
)
|
|
344
|
+
const [expanded, setExpanded] = createSignal<string | undefined>(
|
|
345
|
+
(() => { try { return loadSessionData()[props.sessionId]?.expanded || undefined } catch { return undefined } })()
|
|
346
|
+
)
|
|
347
|
+
const [hoveredOpen, setHoveredOpen] = createSignal<string | undefined>(undefined)
|
|
348
|
+
const [hoveredTop, setHoveredTop] = createSignal(false)
|
|
349
|
+
const [scrollOffset, setScrollOffset] = createSignal(
|
|
350
|
+
(() => { try { return loadSessionData()[props.sessionId]?.scroll ?? 0 } catch { return 0 } })()
|
|
351
|
+
)
|
|
352
|
+
const [now, setNow] = createSignal(Date.now())
|
|
353
|
+
const [renderTick, setRenderTick] = createSignal(0)
|
|
354
|
+
|
|
355
|
+
let boxEl: any
|
|
356
|
+
let disposed = false
|
|
357
|
+
|
|
358
|
+
/** Total context tokens for a sub-agent session.
|
|
359
|
+
* Matches opencode-visual-cache's "总计": last assistant message's input + cache.read. */
|
|
360
|
+
const readSessionTokens = (sid: string): number | undefined => {
|
|
361
|
+
if (!sid) return undefined
|
|
362
|
+
try {
|
|
363
|
+
const msgs = props.api.state.session.messages(sid)
|
|
364
|
+
if (msgs) {
|
|
365
|
+
for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
|
|
366
|
+
const m = (msgs as any[])[i]
|
|
367
|
+
if (m.role !== "assistant") continue
|
|
368
|
+
const t = m.tokens
|
|
369
|
+
if (!t) continue
|
|
370
|
+
const cache = t.cache as { read?: number; write?: number } | undefined
|
|
371
|
+
const ctx = (Number(t.input) || 0) + (cache?.read ?? 0)
|
|
372
|
+
if (ctx > 0) return ctx
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return undefined
|
|
376
|
+
} catch {
|
|
377
|
+
return undefined
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Sum USD cost from a session's messages.
|
|
382
|
+
* Prefers the database-level aggregate (`session.cost`) which is not affected
|
|
383
|
+
* by the sync layer's `limit: 100` message window. Falls back to message
|
|
384
|
+
* traversal when the aggregate is unavailable (older SDK versions). */
|
|
385
|
+
const readSessionCost = (sid: string): number | undefined => {
|
|
386
|
+
if (!sid) return undefined
|
|
387
|
+
try {
|
|
388
|
+
const session = props.api.state.session.get(sid)
|
|
389
|
+
if (session?.cost != null && session.cost > 0) return session.cost
|
|
390
|
+
const msgs = props.api.state.session.messages(sid)
|
|
391
|
+
if (!msgs) return undefined
|
|
392
|
+
let total = 0
|
|
393
|
+
for (const m of msgs as any[]) {
|
|
394
|
+
if (m.role === "assistant" && typeof m.cost === "number") total += m.cost
|
|
395
|
+
}
|
|
396
|
+
return total > 0 ? total : undefined
|
|
397
|
+
} catch {
|
|
398
|
+
return undefined
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Last assistant message's modelID for a sub-agent session. */
|
|
403
|
+
const readSessionModel = (sid: string): string | undefined => {
|
|
404
|
+
if (!sid) return undefined
|
|
405
|
+
try {
|
|
406
|
+
const msgs = props.api.state.session.messages(sid)
|
|
407
|
+
if (msgs) {
|
|
408
|
+
for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
|
|
409
|
+
const m = (msgs as any[])[i]
|
|
410
|
+
if (m.role === "assistant" && m.modelID) return String(m.modelID)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return undefined
|
|
414
|
+
} catch {
|
|
415
|
+
return undefined
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Todo completion stats for a sub-agent session.
|
|
420
|
+
* `done` counts completed + cancelled items. */
|
|
421
|
+
const readSessionTodo = (sid: string): { total: number; done: number } | undefined => {
|
|
422
|
+
if (!sid) return undefined
|
|
423
|
+
try {
|
|
424
|
+
const todos = props.api.state.session.todo(sid)
|
|
425
|
+
if (!todos || todos.length === 0) return undefined
|
|
426
|
+
let done = 0
|
|
427
|
+
for (const t of todos) {
|
|
428
|
+
if (t.status === "completed" || t.status === "cancelled") done++
|
|
429
|
+
}
|
|
430
|
+
return { total: todos.length, done }
|
|
431
|
+
} catch {
|
|
432
|
+
return undefined
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── upsert ──
|
|
437
|
+
const upsertEntry = (
|
|
438
|
+
partial: Omit<SubEntry, "startedAt" | "endedAt"> & { startedAt?: number }
|
|
439
|
+
) => {
|
|
440
|
+
setEntryMap((prev) => {
|
|
441
|
+
const existing = prev.get(partial.id)
|
|
442
|
+
const next = new Map(prev)
|
|
443
|
+
const nowTs = Date.now()
|
|
444
|
+
const e = partial.status
|
|
445
|
+
const ended = e === "done" || e === "error"
|
|
446
|
+
next.set(partial.id, {
|
|
447
|
+
...(existing ?? { startedAt: nowTs }),
|
|
448
|
+
...partial,
|
|
449
|
+
startedAt: existing?.startedAt || partial.startedAt || nowTs,
|
|
450
|
+
endedAt: ended ? (existing?.endedAt || nowTs) : undefined,
|
|
451
|
+
})
|
|
452
|
+
return next
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── event handlers ──
|
|
457
|
+
const handlePartUpdated = (event: unknown) => {
|
|
458
|
+
const e = event as Record<string, unknown>
|
|
459
|
+
const props_ = e.properties as Record<string, unknown> | undefined
|
|
460
|
+
const part = props_?.part as Record<string, unknown> | undefined
|
|
461
|
+
if (!part) return
|
|
462
|
+
|
|
463
|
+
// SubtaskPart
|
|
464
|
+
if (part.type === "subtask") {
|
|
465
|
+
const agent = String(part.agent ?? "?")
|
|
466
|
+
const prompt = String(part.prompt ?? "")
|
|
467
|
+
const desc = String(part.description ?? "")
|
|
468
|
+
const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40)
|
|
469
|
+
|
|
470
|
+
const id = `sub:${String(part.id ?? crypto.randomUUID())}`
|
|
471
|
+
const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined
|
|
472
|
+
const partModel = part.model as { modelID?: string } | undefined
|
|
473
|
+
const modelId = partModel?.modelID ? String(partModel.modelID) : undefined
|
|
474
|
+
upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId })
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ToolPart
|
|
478
|
+
if (part.type === "tool") {
|
|
479
|
+
const tool = String(part.tool ?? "")
|
|
480
|
+
if (!SUBAGENT_TOOLS.has(tool)) return
|
|
481
|
+
const st = part.state as Record<string, unknown> | undefined
|
|
482
|
+
const rawStatus = String(st?.status ?? "")
|
|
483
|
+
|
|
484
|
+
// Only create entries for tool calls that actually entered execution.
|
|
485
|
+
// "pending" / empty → state unknown yet, wait for next event
|
|
486
|
+
if (rawStatus === "pending" || rawStatus === "") return
|
|
487
|
+
|
|
488
|
+
// "error" → tool call failed, sub-agent never spawned.
|
|
489
|
+
// Only update an existing entry (e.g. previously running → now error),
|
|
490
|
+
// never create a new one.
|
|
491
|
+
if (rawStatus === "error") {
|
|
492
|
+
const id = `tool:${String(part.id ?? "")}`
|
|
493
|
+
if (!part.id) return
|
|
494
|
+
const existing = entryMap().get(id)
|
|
495
|
+
if (existing) {
|
|
496
|
+
upsertEntry({ id, title: existing.title, agent: existing.agent, prompt: existing.prompt, status: "error" })
|
|
497
|
+
}
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// rawStatus is "running" or "completed" — tool entered execution, track it.
|
|
502
|
+
const input = st?.input as Record<string, unknown> | undefined
|
|
503
|
+
let status: SubStatus = "running"
|
|
504
|
+
if (rawStatus === "completed") status = "done"
|
|
505
|
+
// Background tasks: tool completion ≠ agent completion — keep running until session.idle
|
|
506
|
+
// Only keep running if state metadata confirms a child session was spawned;
|
|
507
|
+
// otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
|
|
508
|
+
if (input?.run_in_background === true && status === "done") {
|
|
509
|
+
const stMetaCheck = st?.metadata as Record<string, unknown> | undefined
|
|
510
|
+
const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined
|
|
511
|
+
if (hasChild) status = "running"
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const agent = String((part as any).subagent_type ?? input?.subagent_type ?? input?.category ?? tool)
|
|
515
|
+
const prompt = String(input?.prompt ?? (part as any).description ?? "")
|
|
516
|
+
const desc = input?.description !== undefined ? String(input.description) : ""
|
|
517
|
+
const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40)
|
|
518
|
+
|
|
519
|
+
const id = `tool:${String(part.id ?? crypto.randomUUID())}`
|
|
520
|
+
// Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
|
|
521
|
+
// injected by the tool executor. ToolPart.sessionID is the parent session.
|
|
522
|
+
const stMeta = st?.metadata as Record<string, unknown> | undefined
|
|
523
|
+
const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
|
|
524
|
+
: stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
|
|
525
|
+
: undefined
|
|
526
|
+
upsertEntry({ id, title, agent, prompt, sessionId: subSid, status })
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const handleSessionEnd = (event: unknown, status: SubStatus) => {
|
|
531
|
+
const e = event as Record<string, unknown>
|
|
532
|
+
const props_ = e.properties as Record<string, unknown> | undefined
|
|
533
|
+
const sid = String(props_?.sessionID ?? "")
|
|
534
|
+
if (!sid) return
|
|
535
|
+
|
|
536
|
+
const sessionTokens = readSessionTokens(sid)
|
|
537
|
+
const sessionCost = readSessionCost(sid)
|
|
538
|
+
const sessionModel = readSessionModel(sid)
|
|
539
|
+
const sessionTodo = readSessionTodo(sid)
|
|
540
|
+
let sessionAgent: string | undefined
|
|
541
|
+
let errorMsg: string | undefined
|
|
542
|
+
try {
|
|
543
|
+
const s = props.api.state.session.get(sid)
|
|
544
|
+
sessionAgent = s?.agent
|
|
545
|
+
if (status === "error") {
|
|
546
|
+
const evtErr = props_?.error as Record<string, unknown> | undefined
|
|
547
|
+
errorMsg = safeErrorMsg(evtErr) || safeErrorMsg(props_?.message)
|
|
548
|
+
if (!errorMsg) {
|
|
549
|
+
const msgs = props.api.state.session.messages(sid)
|
|
550
|
+
if (msgs) {
|
|
551
|
+
for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
|
|
552
|
+
const m = (msgs as any[])[i]
|
|
553
|
+
if (m.role === "assistant" && m.error) {
|
|
554
|
+
errorMsg = safeErrorMsg(m.error)
|
|
555
|
+
break
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} catch {}
|
|
562
|
+
|
|
563
|
+
setEntryMap((prev) => {
|
|
564
|
+
let changed = false
|
|
565
|
+
const next = new Map(prev)
|
|
566
|
+
for (const [id, entry] of next) {
|
|
567
|
+
if (entry.sessionId !== sid) continue
|
|
568
|
+
if (entry.status !== "running" && entry.status !== "done") continue
|
|
569
|
+
// Skip parent session idle — subagent entries belong to child sessions only
|
|
570
|
+
if (sid === props.sessionId) continue
|
|
571
|
+
// For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost
|
|
572
|
+
const alreadySettled = entry.status !== "running"
|
|
573
|
+
next.set(id, {
|
|
574
|
+
...entry,
|
|
575
|
+
...(alreadySettled ? {} : { status, endedAt: Date.now() }),
|
|
576
|
+
tokens: entry.tokens ?? sessionTokens,
|
|
577
|
+
cost: entry.cost ?? sessionCost,
|
|
578
|
+
model: entry.model ?? sessionModel,
|
|
579
|
+
todoTotal: entry.todoTotal ?? sessionTodo?.total,
|
|
580
|
+
todoDone: entry.todoDone ?? sessionTodo?.done,
|
|
581
|
+
error: errorMsg || entry.error,
|
|
582
|
+
})
|
|
583
|
+
changed = true
|
|
584
|
+
}
|
|
585
|
+
if (!changed && sessionAgent) {
|
|
586
|
+
const nowTs = Date.now()
|
|
587
|
+
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "")
|
|
588
|
+
const saNorm = normalize(sessionAgent)
|
|
589
|
+
let best: { id: string; gap: number } | null = null
|
|
590
|
+
|
|
591
|
+
// Phase 1: try matching by agent name(agent 名有交集)
|
|
592
|
+
for (const [id, entry] of next) {
|
|
593
|
+
if (entry.status !== "running") continue
|
|
594
|
+
const eaNorm = normalize(entry.agent)
|
|
595
|
+
if (!eaNorm || !saNorm) continue
|
|
596
|
+
if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
|
|
597
|
+
const gap = nowTs - (entry.startedAt || 0)
|
|
598
|
+
if (!best || gap > best.gap) best = { id, gap }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Phase 2: if agent name has no overlap (e.g. category calls: agent="deep" vs sessionAgent="Sisyphus-Junior"),
|
|
602
|
+
// fall back to time proximity for entries that have no sessionId yet
|
|
603
|
+
if (!best) {
|
|
604
|
+
for (const [id, entry] of next) {
|
|
605
|
+
if (entry.status !== "running") continue
|
|
606
|
+
if (entry.sessionId) continue
|
|
607
|
+
const gap = nowTs - (entry.startedAt || 0)
|
|
608
|
+
if (!best || gap > best.gap) best = { id, gap }
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (best) {
|
|
613
|
+
const entry = next.get(best.id)!
|
|
614
|
+
next.set(best.id, {
|
|
615
|
+
...entry, status, endedAt: nowTs,
|
|
616
|
+
tokens: sessionTokens || entry.tokens,
|
|
617
|
+
cost: sessionCost || entry.cost,
|
|
618
|
+
sessionId: sid,
|
|
619
|
+
error: errorMsg || entry.error,
|
|
620
|
+
})
|
|
621
|
+
changed = true
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return changed ? next : prev
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
// Delayed backfill: re-read data after state sync catches up, to capture the final
|
|
628
|
+
// token/cost values that may not have been available when session.idle fired.
|
|
629
|
+
setTimeout(() => {
|
|
630
|
+
if (disposed) return
|
|
631
|
+
const finalTokens = readSessionTokens(sid)
|
|
632
|
+
const finalCost = readSessionCost(sid)
|
|
633
|
+
const finalModel = readSessionModel(sid)
|
|
634
|
+
const finalTodo = readSessionTodo(sid)
|
|
635
|
+
setEntryMap((prev) => {
|
|
636
|
+
let changed = false
|
|
637
|
+
const next = new Map(prev)
|
|
638
|
+
for (const [id, entry] of next) {
|
|
639
|
+
if (entry.sessionId !== sid) continue
|
|
640
|
+
const t = finalTokens ?? entry.tokens
|
|
641
|
+
const c = finalCost ?? entry.cost
|
|
642
|
+
const m = finalModel ?? entry.model
|
|
643
|
+
const tt = finalTodo?.total ?? entry.todoTotal
|
|
644
|
+
const td = finalTodo?.done ?? entry.todoDone
|
|
645
|
+
if (t !== entry.tokens || c !== entry.cost || m !== entry.model ||
|
|
646
|
+
tt !== entry.todoTotal || td !== entry.todoDone) {
|
|
647
|
+
next.set(id, { ...entry, tokens: t, cost: c, model: m, todoTotal: tt, todoDone: td })
|
|
648
|
+
changed = true
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return changed ? next : prev
|
|
652
|
+
})
|
|
653
|
+
bump()
|
|
654
|
+
}, 150)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── bumpRenderTick: force re-render (visual-cache pattern) ──
|
|
658
|
+
const bump = () => setRenderTick((v) => v + 1)
|
|
659
|
+
|
|
660
|
+
onMount(() => {
|
|
661
|
+
// Fast clock for smooth time display, separate from token polling
|
|
662
|
+
const clock = setInterval(() => { setNow(Date.now()); bump() }, 100)
|
|
663
|
+
// Token poll — runs every 500ms for running entries
|
|
664
|
+
const tokenTimer = setInterval(() => {
|
|
665
|
+
untrack(() => {
|
|
666
|
+
setEntryMapRaw((prev) => {
|
|
667
|
+
let changed = false
|
|
668
|
+
const next = new Map(prev)
|
|
669
|
+
for (const [id, entry] of next) {
|
|
670
|
+
if (entry.status === "running" && entry.sessionId) {
|
|
671
|
+
// Only read from child sessions, never the parent
|
|
672
|
+
let isChild = false
|
|
673
|
+
try {
|
|
674
|
+
const s = props.api.state.session.get(entry.sessionId)
|
|
675
|
+
isChild = s?.parentID === props.sessionId
|
|
676
|
+
} catch {}
|
|
677
|
+
if (!isChild) continue
|
|
678
|
+
const total = readSessionTokens(entry.sessionId)
|
|
679
|
+
const todo = readSessionTodo(entry.sessionId)
|
|
680
|
+
const model = entry.model ?? readSessionModel(entry.sessionId)
|
|
681
|
+
const nextEntry: SubEntry = { ...entry }
|
|
682
|
+
if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; changed = true }
|
|
683
|
+
if (todo !== undefined) {
|
|
684
|
+
if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) {
|
|
685
|
+
nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; changed = true
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (model && !entry.model) { nextEntry.model = model; changed = true }
|
|
689
|
+
if (changed) next.set(id, nextEntry)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return changed ? next : prev
|
|
693
|
+
})
|
|
694
|
+
})
|
|
695
|
+
bump()
|
|
696
|
+
}, 500)
|
|
697
|
+
bump()
|
|
698
|
+
|
|
699
|
+
const unsubPart = props.api.event.on("message.part.updated", (e) => {
|
|
700
|
+
handlePartUpdated(e)
|
|
701
|
+
bump()
|
|
702
|
+
})
|
|
703
|
+
const unsubMsg = props.api.event.on("message.updated", () => bump())
|
|
704
|
+
const unsubIdle = props.api.event.on("session.idle", (e) => {
|
|
705
|
+
handleSessionEnd(e, "done")
|
|
706
|
+
bump()
|
|
707
|
+
})
|
|
708
|
+
const unsubError = props.api.event.on("session.error", (e) => {
|
|
709
|
+
handleSessionEnd(e, "error")
|
|
710
|
+
bump()
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
onCleanup(() => {
|
|
714
|
+
disposed = true
|
|
715
|
+
clearInterval(clock)
|
|
716
|
+
clearInterval(tokenTimer)
|
|
717
|
+
unsubPart()
|
|
718
|
+
unsubMsg()
|
|
719
|
+
unsubIdle()
|
|
720
|
+
unsubError()
|
|
721
|
+
})
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
// ── session‑switch & initial‑load scan ──
|
|
725
|
+
// On session change: load from kv (entries survive component unmount), then scan+merge.
|
|
726
|
+
// On same session: only scan+merge (keep event‑driven running entries).
|
|
727
|
+
let lastSid = props.sessionId
|
|
728
|
+
createEffect(() => {
|
|
729
|
+
const sid = props.sessionId
|
|
730
|
+
const switched = sid !== lastSid
|
|
731
|
+
lastSid = sid
|
|
732
|
+
const t = setTimeout(() => {
|
|
733
|
+
untrack(() => {
|
|
734
|
+
if (switched) {
|
|
735
|
+
const saved = loadSessionData()[sid]?.scroll ?? 0
|
|
736
|
+
setScrollOffset(saved)
|
|
737
|
+
}
|
|
738
|
+
// scan uses setEntryMapRaw — ephemeral data, not persisted to kv.
|
|
739
|
+
// Only event-driven changes (handlePartUpdated, handleSessionEnd) persist.
|
|
740
|
+
setEntryMapRaw((prev) => {
|
|
741
|
+
const next = switched ? loadEntries(sid) : new Map(prev)
|
|
742
|
+
try {
|
|
743
|
+
const msgs = props.api.state.session.messages(sid)
|
|
744
|
+
if (msgs && (msgs as any[]).length) {
|
|
745
|
+
for (const msg of msgs) {
|
|
746
|
+
const parts = props.api.state.part(msg.id) ?? []
|
|
747
|
+
for (const partRaw of parts) {
|
|
748
|
+
const part = partRaw as Record<string, unknown>
|
|
749
|
+
|
|
750
|
+
// Subtask entries are purely event-driven — never created by scan.
|
|
751
|
+
// (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
|
|
752
|
+
if (part.type === "tool") {
|
|
753
|
+
const tool = String((part as any).tool ?? "")
|
|
754
|
+
if (!SUBAGENT_TOOLS.has(tool)) continue
|
|
755
|
+
const id = `tool:${String(part.id ?? "")}`
|
|
756
|
+
if (!part.id) continue
|
|
757
|
+
|
|
758
|
+
const st = (part as any).state as Record<string, unknown> | undefined
|
|
759
|
+
const rawStatus = String(st?.status ?? "")
|
|
760
|
+
const exists = next.get(id)
|
|
761
|
+
|
|
762
|
+
// Only create entries for tool calls that entered execution.
|
|
763
|
+
// "pending" / empty: skip new entries; allow heuristics for existing ones below.
|
|
764
|
+
if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
|
|
765
|
+
|
|
766
|
+
// "error": only update existing, never create a new entry
|
|
767
|
+
if (rawStatus === "error") {
|
|
768
|
+
if (exists && exists.status === "running") {
|
|
769
|
+
next.set(id, { ...exists, status: "error", endedAt: Date.now() })
|
|
770
|
+
}
|
|
771
|
+
continue
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let status: SubStatus = "running"
|
|
775
|
+
if (rawStatus === "completed") status = "done"
|
|
776
|
+
// Background tasks: tool completion ≠ agent completion — keep running until session.idle
|
|
777
|
+
// Only keep running if state metadata confirms a child session was spawned.
|
|
778
|
+
if ((st?.input as Record<string, unknown> | undefined)?.run_in_background === true && status === "done") {
|
|
779
|
+
const scanStMeta = st?.metadata as Record<string, unknown> | undefined
|
|
780
|
+
const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined
|
|
781
|
+
if (scanHasChild) status = "running"
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Already settled → skip
|
|
785
|
+
if (exists && exists.status !== "running") continue
|
|
786
|
+
// Running entry with no explicit status improvement from part:
|
|
787
|
+
// try message-level heuristics first, then time-based fallback.
|
|
788
|
+
if (exists && status === "running") {
|
|
789
|
+
if (!rawStatus) {
|
|
790
|
+
const msgTokens = (msg as any)?.tokens as Record<string, unknown> | undefined
|
|
791
|
+
if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
|
|
792
|
+
status = "done" // LLM returned tokens → agent completed
|
|
793
|
+
} else if (Date.now() - exists.startedAt > 30 * 60 * 1000) {
|
|
794
|
+
status = "done" // >30 min idle → assume completed
|
|
795
|
+
} else {
|
|
796
|
+
continue
|
|
797
|
+
}
|
|
798
|
+
} else {
|
|
799
|
+
continue
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// If already tracked as running but tool state says completed/error → update
|
|
804
|
+
// If not tracked → add fresh
|
|
805
|
+
|
|
806
|
+
const input = st?.input as Record<string, unknown> | undefined
|
|
807
|
+
const agent = String((part as any).subagent_type ?? input?.subagent_type ?? tool)
|
|
808
|
+
const prompt = String(input?.prompt ?? (part as any).description ?? "")
|
|
809
|
+
const desc = input?.description !== undefined ? String(input.description) : ""
|
|
810
|
+
const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40)
|
|
811
|
+
|
|
812
|
+
let tokens: number | undefined
|
|
813
|
+
const scanStMeta2 = st?.metadata as Record<string, unknown> | undefined
|
|
814
|
+
const scanSubSid = scanStMeta2?.session_id !== undefined ? String(scanStMeta2.session_id)
|
|
815
|
+
: scanStMeta2?.sessionId !== undefined ? String(scanStMeta2.sessionId)
|
|
816
|
+
: undefined
|
|
817
|
+
if (scanSubSid) tokens = readSessionTokens(scanSubSid)
|
|
818
|
+
|
|
819
|
+
const ended = status === "done" // "error" handled above, never reaches here
|
|
820
|
+
next.set(id, {
|
|
821
|
+
id, title, agent, prompt,
|
|
822
|
+
// Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
|
|
823
|
+
tokens: exists?.tokens ?? tokens,
|
|
824
|
+
sessionId: exists?.sessionId ?? scanSubSid,
|
|
825
|
+
status,
|
|
826
|
+
startedAt: exists?.startedAt || Date.now(),
|
|
827
|
+
endedAt: ended ? (exists?.endedAt || Date.now()) : undefined,
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
} catch {}
|
|
834
|
+
return next
|
|
835
|
+
})
|
|
836
|
+
// Reconcile: check running entries against live child session status.
|
|
837
|
+
// Covers session.idle events missed while user was inside a child session.
|
|
838
|
+
setEntryMapRaw((prev) => {
|
|
839
|
+
let changed = false
|
|
840
|
+
const next = new Map(prev)
|
|
841
|
+
for (const [id, entry] of next) {
|
|
842
|
+
if (entry.status !== "running" || !entry.sessionId) continue
|
|
843
|
+
try {
|
|
844
|
+
const st = props.api.state.session.status(entry.sessionId)
|
|
845
|
+
if (!st || st.type !== "idle") continue
|
|
846
|
+
const tokens = readSessionTokens(entry.sessionId)
|
|
847
|
+
const cost = readSessionCost(entry.sessionId)
|
|
848
|
+
next.set(id, {
|
|
849
|
+
...entry, status: "done" as SubStatus, endedAt: Date.now(),
|
|
850
|
+
tokens: tokens ?? entry.tokens,
|
|
851
|
+
cost: cost ?? entry.cost,
|
|
852
|
+
})
|
|
853
|
+
changed = true
|
|
854
|
+
} catch {}
|
|
855
|
+
}
|
|
856
|
+
return changed ? next : prev
|
|
857
|
+
})
|
|
858
|
+
bump()
|
|
859
|
+
})
|
|
860
|
+
}, 150)
|
|
861
|
+
onCleanup(() => clearTimeout(t))
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
// ── palette ──
|
|
865
|
+
const pal = createMemo(() => {
|
|
866
|
+
const th = props.theme as Record<string, unknown>
|
|
867
|
+
const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
|
|
868
|
+
return {
|
|
869
|
+
primary: sat("primary", FALLBACK.primary),
|
|
870
|
+
text: sat("text", FALLBACK.text),
|
|
871
|
+
muted: sat("textMuted", FALLBACK.muted),
|
|
872
|
+
success: sat("success", FALLBACK.success),
|
|
873
|
+
warning: sat("warning", FALLBACK.warning),
|
|
874
|
+
error: sat("error", FALLBACK.error),
|
|
875
|
+
border: sat("border", FALLBACK.border),
|
|
876
|
+
}
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
// ── derived signals ──
|
|
880
|
+
// Stable list — only changes when entryMap changes
|
|
881
|
+
const entryList = createMemo(() => {
|
|
882
|
+
return [...entryMap().values()].sort((a, b) => b.startedAt - a.startedAt)
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
const max = props.maxEntries
|
|
886
|
+
const clampedOffset = createMemo(() => {
|
|
887
|
+
const total = entryList().length
|
|
888
|
+
const m = max()
|
|
889
|
+
if (total <= m) return 0
|
|
890
|
+
return Math.min(scrollOffset(), total - m)
|
|
891
|
+
})
|
|
892
|
+
const visibleList = createMemo(() => entryList().slice(clampedOffset(), clampedOffset() + max()))
|
|
893
|
+
const hiddenAbove = createMemo(() => clampedOffset())
|
|
894
|
+
const hiddenBelow = createMemo(() => Math.max(0, entryList().length - clampedOffset() - max()))
|
|
895
|
+
|
|
896
|
+
const entries = createMemo(() => {
|
|
897
|
+
const nowVal = now()
|
|
898
|
+
return entryList().map((e) => ({
|
|
899
|
+
...e,
|
|
900
|
+
elapsed: (e.endedAt ?? nowVal) - e.startedAt,
|
|
901
|
+
}))
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
const doneCount = createMemo(() => entryList().filter((e) => e.status === "done").length)
|
|
905
|
+
const runningCount = createMemo(() => entryList().filter((e) => e.status === "running").length)
|
|
906
|
+
const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length)
|
|
907
|
+
const anyEntry = () => entryList().length > 0
|
|
908
|
+
|
|
909
|
+
const totalTokens = createMemo(() => {
|
|
910
|
+
let sum = 0
|
|
911
|
+
for (const e of entryList()) { if (e.tokens) sum += e.tokens }
|
|
912
|
+
return sum
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
const totalCost = createMemo(() => {
|
|
916
|
+
let sum = 0
|
|
917
|
+
for (const e of entryList()) { if (e.cost) sum += e.cost }
|
|
918
|
+
return sum
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
const toggleExpand = (id: string) => {
|
|
922
|
+
setExpanded((prev) => {
|
|
923
|
+
const next = prev === id ? undefined : id
|
|
924
|
+
try { persistExpanded(props.sessionId, next ?? "") } catch {}
|
|
925
|
+
return next
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const sep = () => "\u2500".repeat(Math.max(1, panelWidth()))
|
|
930
|
+
|
|
931
|
+
// ── expanded detail right-align ──
|
|
932
|
+
const expandedMaxLabelW = createMemo(() => {
|
|
933
|
+
const labels = [
|
|
934
|
+
t("agent.label"), t("time.label"), t("tokens.label"),
|
|
935
|
+
t("error.label"), t("cost.label"), t("model.label"), t("todo.label"),
|
|
936
|
+
]
|
|
937
|
+
return Math.max(...labels.map(l => visualWidth(l + ": ")))
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
const expandedPad = (label: string) => Math.max(0, expandedMaxLabelW() - visualWidth(label + ": "))
|
|
941
|
+
|
|
942
|
+
const expandedValAvail = () => Math.max(6, panelWidth() - INDENT - expandedMaxLabelW())
|
|
943
|
+
|
|
944
|
+
// ── header parts for colored spans ──
|
|
945
|
+
const summaryParts = createMemo(() => {
|
|
946
|
+
if (!anyEntry()) return null
|
|
947
|
+
const dot = "\u25cf"
|
|
948
|
+
const cost = totalCost()
|
|
949
|
+
return {
|
|
950
|
+
done: `${dot}${doneCount()}`,
|
|
951
|
+
running: runningCount() > 0 ? `${dot}${runningCount()}` : null,
|
|
952
|
+
err: errCount() > 0 ? `${dot}${errCount()}` : null,
|
|
953
|
+
duration: totalTokens() > 0 ? fmtTokens(totalTokens()) : "",
|
|
954
|
+
cost: cost > 0 ? `$${cost.toFixed(2)}` : "",
|
|
955
|
+
}
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
const summaryCols = createMemo(() => {
|
|
959
|
+
const p = summaryParts()
|
|
960
|
+
if (!p) return 0
|
|
961
|
+
let w = visualWidth(p.done)
|
|
962
|
+
if (p.running) w += 1 + visualWidth(p.running)
|
|
963
|
+
if (p.err) w += 1 + visualWidth(p.err)
|
|
964
|
+
w += p.duration ? 1 + visualWidth(p.duration) : 0
|
|
965
|
+
w += p.cost ? 1 + visualWidth(p.cost) : 0
|
|
966
|
+
return w
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
const versionText = ` v${PLUGIN_VERSION}`
|
|
970
|
+
const versionW = visualWidth(versionText)
|
|
971
|
+
|
|
972
|
+
const showVersion = createMemo(() => {
|
|
973
|
+
if (!open()) return false
|
|
974
|
+
const icon = "\u25bc"
|
|
975
|
+
const need = visualWidth(icon) + 1 + visualWidth(t("panel.title")) + versionW + summaryCols()
|
|
976
|
+
return need <= panelWidth()
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
const leftCols = createMemo(() => {
|
|
980
|
+
const icon = open() ? "\u25bc" : "\u25b6"
|
|
981
|
+
let w = visualWidth(icon) + 1 + visualWidth(t("panel.title"))
|
|
982
|
+
if (showVersion()) w += versionW
|
|
983
|
+
return w
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
const spacerCols = createMemo(() => {
|
|
987
|
+
if (!anyEntry()) return 0
|
|
988
|
+
return Math.max(0, panelWidth() - leftCols() - summaryCols())
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
const valueCols = (label: string) =>
|
|
992
|
+
Math.max(4, panelWidth() - INDENT - visualWidth(label + ": "))
|
|
993
|
+
|
|
994
|
+
// ── render ──
|
|
995
|
+
return (
|
|
996
|
+
<box
|
|
997
|
+
border={false}
|
|
998
|
+
paddingTop={0} paddingBottom={0} paddingLeft={0} paddingRight={0}
|
|
999
|
+
flexDirection="column" gap={0}
|
|
1000
|
+
ref={boxEl}
|
|
1001
|
+
onSizeChange={() => {
|
|
1002
|
+
const w = boxEl ? Math.max(20, boxEl.width ?? 0) : 28
|
|
1003
|
+
setPanelWidth((prev) => (prev === w ? prev : w))
|
|
1004
|
+
}}
|
|
1005
|
+
>
|
|
1006
|
+
{/* ── header: same pattern as visual-cache's fold toggle ── */}
|
|
1007
|
+
{/* renderTick in span forces the text element to re-evaluate */}
|
|
1008
|
+
<text
|
|
1009
|
+
onMouseUp={() => {
|
|
1010
|
+
setOpen((o) => {
|
|
1011
|
+
const n = !o
|
|
1012
|
+
try { props.api.kv.set(`${KV_PREFIX}.open`, n) } catch {}
|
|
1013
|
+
return n
|
|
1014
|
+
})
|
|
1015
|
+
bump()
|
|
1016
|
+
}}
|
|
1017
|
+
>
|
|
1018
|
+
<span style={{ fg: pal().muted }}>{renderTick() >= 0 && open() ? "\u25bc " : "\u25b6 "}</span>
|
|
1019
|
+
<span style={{ fg: pal().primary }}>{t("panel.title")}</span>
|
|
1020
|
+
<Show when={showVersion()}><span style={{ fg: dimColor(pal().muted, 0.75) }}>{versionText}</span></Show>
|
|
1021
|
+
{anyEntry() ? (
|
|
1022
|
+
<>
|
|
1023
|
+
<span style={{ fg: pal().muted }}>{" ".repeat(spacerCols())}</span>
|
|
1024
|
+
<span style={{ fg: pal().success }}>{summaryParts()!.done}</span>
|
|
1025
|
+
{runningCount() > 0 && (
|
|
1026
|
+
<span style={{ fg: pal().warning }}> {summaryParts()!.running}</span>
|
|
1027
|
+
)}
|
|
1028
|
+
{errCount() > 0 && (
|
|
1029
|
+
<span style={{ fg: pal().error }}> {summaryParts()!.err}</span>
|
|
1030
|
+
)}
|
|
1031
|
+
{summaryParts()!.duration ? (
|
|
1032
|
+
<span style={{ fg: pal().muted }}> {summaryParts()!.duration}</span>
|
|
1033
|
+
) : null}
|
|
1034
|
+
{summaryParts()!.cost ? (
|
|
1035
|
+
<span style={{ fg: pal().warning }}> {summaryParts()!.cost}</span>
|
|
1036
|
+
) : null}
|
|
1037
|
+
</>
|
|
1038
|
+
) : null}
|
|
1039
|
+
</text>
|
|
1040
|
+
|
|
1041
|
+
{/* ── panel body ── */}
|
|
1042
|
+
<Show when={open()}>
|
|
1043
|
+
<text fg={pal().muted}>{sep()}</text>
|
|
1044
|
+
|
|
1045
|
+
<Show
|
|
1046
|
+
when={anyEntry()}
|
|
1047
|
+
fallback={
|
|
1048
|
+
<text style={{ fg: pal().muted }}>
|
|
1049
|
+
{" "}> {t("status.none")} {/* empty indent kept for visual balance */}
|
|
1050
|
+
</text>
|
|
1051
|
+
}
|
|
1052
|
+
>
|
|
1053
|
+
<box
|
|
1054
|
+
onMouseScroll={(e) => {
|
|
1055
|
+
const total = entryList().length
|
|
1056
|
+
const m = max()
|
|
1057
|
+
if (total <= m) return
|
|
1058
|
+
const dir = e.button === 0 ? 1 : -1
|
|
1059
|
+
setScrollOffset((prev) => {
|
|
1060
|
+
const next = Math.max(0, Math.min(prev + dir, total - m))
|
|
1061
|
+
try { persistScroll(props.sessionId, next) } catch {}
|
|
1062
|
+
return next
|
|
1063
|
+
})
|
|
1064
|
+
}}
|
|
1065
|
+
>
|
|
1066
|
+
<Show when={hiddenAbove() > 0}>
|
|
1067
|
+
<text style={{ fg: pal().muted }}>
|
|
1068
|
+
{" "}↑ {hiddenAbove()} {t("scroll.more")}
|
|
1069
|
+
</text>
|
|
1070
|
+
</Show>
|
|
1071
|
+
<For each={visibleList()}>
|
|
1072
|
+
{(entry) => {
|
|
1073
|
+
const isExpanded = () => expanded() === entry.id
|
|
1074
|
+
const isRunning = entry.status === "running"
|
|
1075
|
+
const isError = entry.status === "error"
|
|
1076
|
+
const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt
|
|
1077
|
+
|
|
1078
|
+
const statusDot = () => "\u25cf"
|
|
1079
|
+
const statusColor = () => {
|
|
1080
|
+
if (!isRunning) return isError ? pal().error : pal().success
|
|
1081
|
+
const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2
|
|
1082
|
+
const a = rgb(pal().muted), b = rgb(pal().warning)
|
|
1083
|
+
if (!a || !b) return pal().warning
|
|
1084
|
+
const r = Math.round(a.r + (b.r - a.r) * t)
|
|
1085
|
+
const g = Math.round(a.g + (b.g - a.g) * t)
|
|
1086
|
+
const bl = Math.round(a.b + (b.b - a.b) * t)
|
|
1087
|
+
return "#" + [r, g, bl].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const timeColor = () =>
|
|
1091
|
+
isRunning ? pal().warning : isError ? pal().error : pal().muted
|
|
1092
|
+
|
|
1093
|
+
// Entry label: collapsed shows title only, expanded shows title only too
|
|
1094
|
+
const tokenText = () =>
|
|
1095
|
+
!isExpanded() && entry.tokens !== undefined && entry.tokens > 0
|
|
1096
|
+
? ` ${fmtTokens(entry.tokens!)}`
|
|
1097
|
+
: ""
|
|
1098
|
+
const timeText = () =>
|
|
1099
|
+
!isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
|
|
1100
|
+
? fmtDurationShort(elapsed(), isRunning)
|
|
1101
|
+
: ""
|
|
1102
|
+
const suffixW = () => {
|
|
1103
|
+
let w = 0
|
|
1104
|
+
const t = timeText()
|
|
1105
|
+
if (t) w += 1 + visualWidth(t)
|
|
1106
|
+
const tk = tokenText()
|
|
1107
|
+
if (tk) w += visualWidth(tk)
|
|
1108
|
+
return w
|
|
1109
|
+
}
|
|
1110
|
+
const labelAvail = () => Math.max(6, panelWidth() - LEFT_PAD - suffixW())
|
|
1111
|
+
const labelText = () => {
|
|
1112
|
+
const max = labelAvail()
|
|
1113
|
+
const text = entry.title || entry.agent
|
|
1114
|
+
const truncated = truncate(text, max)
|
|
1115
|
+
const pad = Math.max(0, max - visualWidth(truncated))
|
|
1116
|
+
return truncated + " ".repeat(pad)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
return (
|
|
1120
|
+
<>
|
|
1121
|
+
{/* entry line — left-aligned */}
|
|
1122
|
+
<text onMouseUp={() => toggleExpand(entry.id)}>
|
|
1123
|
+
<span style={{ fg: pal().muted }}>
|
|
1124
|
+
{isExpanded() ? "\u25bc" : "\u25b6"}
|
|
1125
|
+
</span>
|
|
1126
|
+
{" "}
|
|
1127
|
+
<span style={{ fg: statusColor() }}>{statusDot()}</span>
|
|
1128
|
+
{" "}
|
|
1129
|
+
<span style={{ fg: pal().text }}>{labelText()}</span>
|
|
1130
|
+
{timeText() ? (
|
|
1131
|
+
<>
|
|
1132
|
+
{" "}
|
|
1133
|
+
<span style={{ fg: timeColor() }}>{timeText()}</span>
|
|
1134
|
+
</>
|
|
1135
|
+
) : null}
|
|
1136
|
+
{tokenText() ? (
|
|
1137
|
+
<span style={{ fg: pal().muted }}>{tokenText()}</span>
|
|
1138
|
+
) : null}
|
|
1139
|
+
</text>
|
|
1140
|
+
|
|
1141
|
+
{/* expanded detail — right-aligned values */}
|
|
1142
|
+
<Show when={isExpanded()}>
|
|
1143
|
+
<text>
|
|
1144
|
+
{" "}
|
|
1145
|
+
<span style={{ fg: pal().primary }}>{t("agent.label")}: </span>
|
|
1146
|
+
<span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("agent.label")))}</span>
|
|
1147
|
+
<span style={{ fg: pal().muted }}>{entry.agent}</span>
|
|
1148
|
+
</text>
|
|
1149
|
+
<Show when={elapsed() >= 2000 || entry.endedAt !== undefined}>
|
|
1150
|
+
<text>
|
|
1151
|
+
{" "}
|
|
1152
|
+
<span style={{ fg: pal().primary }}>{t("time.label")}: </span>
|
|
1153
|
+
<span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("time.label")))}</span>
|
|
1154
|
+
<span style={{ fg: pal().muted }}>
|
|
1155
|
+
{fmtDurationShort(elapsed(), isRunning)}
|
|
1156
|
+
</span>
|
|
1157
|
+
</text>
|
|
1158
|
+
</Show>
|
|
1159
|
+
<Show when={entry.tokens !== undefined}>
|
|
1160
|
+
<text>
|
|
1161
|
+
{" "}
|
|
1162
|
+
<span style={{ fg: pal().primary }}>{t("tokens.label")}: </span>
|
|
1163
|
+
<span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("tokens.label")))}</span>
|
|
1164
|
+
<span style={{ fg: pal().muted }}>{fmtTokens(entry.tokens!)}</span>
|
|
1165
|
+
</text>
|
|
1166
|
+
</Show>
|
|
1167
|
+
<Show when={entry.error}>
|
|
1168
|
+
<text>
|
|
1169
|
+
{" "}
|
|
1170
|
+
<span style={{ fg: pal().error }}>{t("error.label")}: </span>
|
|
1171
|
+
<span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("error.label")))}</span>
|
|
1172
|
+
<span style={{ fg: pal().error }}>{truncate(String(entry.error), expandedValAvail())}</span>
|
|
1173
|
+
</text>
|
|
1174
|
+
</Show>
|
|
1175
|
+
<Show when={entry.cost !== undefined}>
|
|
1176
|
+
<text>
|
|
1177
|
+
{" "}
|
|
1178
|
+
<span style={{ fg: pal().primary }}>{t("cost.label")}: </span>
|
|
1179
|
+
<span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("cost.label")))}</span>
|
|
1180
|
+
<span style={{ fg: pal().muted }}>${entry.cost!.toFixed(4)}</span>
|
|
1181
|
+
</text>
|
|
1182
|
+
</Show>
|
|
1183
|
+
<Show when={entry.model}>
|
|
1184
|
+
<text>
|
|
1185
|
+
{" "}
|
|
1186
|
+
<span style={{ fg: pal().primary }}>{t("model.label")}: </span>
|
|
1187
|
+
<span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("model.label")))}</span>
|
|
1188
|
+
<span style={{ fg: pal().muted }}>{truncate(entry.model!, expandedValAvail())}</span>
|
|
1189
|
+
</text>
|
|
1190
|
+
</Show>
|
|
1191
|
+
<Show when={entry.todoTotal !== undefined}>
|
|
1192
|
+
<text>
|
|
1193
|
+
{" "}
|
|
1194
|
+
<span style={{ fg: pal().primary }}>{t("todo.label")}: </span>
|
|
1195
|
+
<span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("todo.label")))}</span>
|
|
1196
|
+
<span style={{ fg: pal().muted }}>{entry.todoDone}/{entry.todoTotal}</span>
|
|
1197
|
+
</text>
|
|
1198
|
+
</Show>
|
|
1199
|
+
<Show when={entry.sessionId}>
|
|
1200
|
+
<text
|
|
1201
|
+
onMouseOver={() => setHoveredOpen(entry.id)}
|
|
1202
|
+
onMouseOut={() => setHoveredOpen(undefined)}
|
|
1203
|
+
onMouseUp={() => {
|
|
1204
|
+
if (entry.sessionId) {
|
|
1205
|
+
props.api.route.navigate("session", { sessionID: entry.sessionId })
|
|
1206
|
+
}
|
|
1207
|
+
}}
|
|
1208
|
+
>
|
|
1209
|
+
{" "}
|
|
1210
|
+
<span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{"\u2192 "}</span>
|
|
1211
|
+
<span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{t("open.label")}</span>
|
|
1212
|
+
</text>
|
|
1213
|
+
</Show>
|
|
1214
|
+
</Show>
|
|
1215
|
+
</>
|
|
1216
|
+
)
|
|
1217
|
+
}}
|
|
1218
|
+
</For>
|
|
1219
|
+
<Show when={hiddenBelow() > 0 || scrollOffset() > 0}>
|
|
1220
|
+
{(() => {
|
|
1221
|
+
const showMore = hiddenBelow() > 0
|
|
1222
|
+
const showTop = scrollOffset() > 0
|
|
1223
|
+
const left = showMore ? ` \u2193 ${hiddenBelow()} ${t("scroll.more")}` : " "
|
|
1224
|
+
const right = `\u2191 ${t("scroll.top")}`
|
|
1225
|
+
const pad = showTop ? Math.max(1, panelWidth() - visualWidth(left) - visualWidth(right)) : 0
|
|
1226
|
+
return (
|
|
1227
|
+
<box flexDirection="row">
|
|
1228
|
+
<text style={{ fg: pal().muted }}>{left}</text>
|
|
1229
|
+
{showTop ? (
|
|
1230
|
+
<>
|
|
1231
|
+
<text style={{ fg: pal().muted }}>{" ".repeat(pad)}</text>
|
|
1232
|
+
<text
|
|
1233
|
+
onMouseOver={() => setHoveredTop(true)}
|
|
1234
|
+
onMouseOut={() => setHoveredTop(false)}
|
|
1235
|
+
onMouseUp={() => { setScrollOffset(0); setHoveredTop(false) }}
|
|
1236
|
+
>
|
|
1237
|
+
<span style={{ fg: hoveredTop() ? pal().warning : pal().muted }}>{right}</span>
|
|
1238
|
+
</text>
|
|
1239
|
+
</>
|
|
1240
|
+
) : null}
|
|
1241
|
+
</box>
|
|
1242
|
+
)
|
|
1243
|
+
})()}
|
|
1244
|
+
</Show>
|
|
1245
|
+
</box>
|
|
1246
|
+
</Show>
|
|
1247
|
+
</Show>
|
|
1248
|
+
</box>
|
|
1249
|
+
)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ===================================================================
|
|
1253
|
+
// Plugin entry
|
|
1254
|
+
// ===================================================================
|
|
1255
|
+
|
|
1256
|
+
interface SharedSignals {
|
|
1257
|
+
lang: () => Lang
|
|
1258
|
+
setLang: (l: Lang) => void
|
|
1259
|
+
maxEntries: () => number
|
|
1260
|
+
setMaxEntries: (n: number) => void
|
|
1261
|
+
sessionId: string
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function createSidebarSlot(api: TuiPluginApi, sig: SharedSignals): TuiSlotPlugin {
|
|
1265
|
+
return {
|
|
1266
|
+
order: 60,
|
|
1267
|
+
slots: {
|
|
1268
|
+
sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
|
|
1269
|
+
sig.sessionId = input.session_id
|
|
1270
|
+
return (
|
|
1271
|
+
<SubAgentPanel
|
|
1272
|
+
theme={ctx.theme.current}
|
|
1273
|
+
api={api}
|
|
1274
|
+
lang={sig.lang}
|
|
1275
|
+
maxEntries={sig.maxEntries}
|
|
1276
|
+
sessionId={input.session_id}
|
|
1277
|
+
/>
|
|
1278
|
+
)
|
|
1279
|
+
},
|
|
1280
|
+
},
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const KV_PREFIX = "subagent_magazine"
|
|
1285
|
+
|
|
1286
|
+
const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
1287
|
+
// ── language ──
|
|
1288
|
+
const stored = String(api.kv.get(`${KV_PREFIX}.lang`, ""))
|
|
1289
|
+
const initialLang: Lang =
|
|
1290
|
+
stored === "zh" || stored === "en" ? stored : detectLang()
|
|
1291
|
+
const [lang, setLang] = createSignal<Lang>(initialLang)
|
|
1292
|
+
const [maxEntries, setMaxEntries] = createSignal(
|
|
1293
|
+
parseInt(String(api.kv.get(`${KV_PREFIX}.max_entries`, "10")), 10) || 10
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sessionId: "" }
|
|
1297
|
+
|
|
1298
|
+
api.slots.register(createSidebarSlot(api, signals))
|
|
1299
|
+
|
|
1300
|
+
// ── slash command: /subagent-lang ──
|
|
1301
|
+
api.command?.register(() => [
|
|
1302
|
+
{
|
|
1303
|
+
title: "SubAgent Magazine: Language",
|
|
1304
|
+
value: "subagent-lang",
|
|
1305
|
+
description: "Switch display language (中文 / English)",
|
|
1306
|
+
slash: { name: "subagent-lang" },
|
|
1307
|
+
onSelect: (dialog) => {
|
|
1308
|
+
dialog?.replace(() => (
|
|
1309
|
+
<api.ui.DialogSelect
|
|
1310
|
+
title="Language / 语言"
|
|
1311
|
+
options={[
|
|
1312
|
+
{ title: "中文", value: "zh" },
|
|
1313
|
+
{ title: "English", value: "en" },
|
|
1314
|
+
]}
|
|
1315
|
+
onSelect={(opt) => {
|
|
1316
|
+
const l = opt.value as Lang
|
|
1317
|
+
setLang(l)
|
|
1318
|
+
api.kv.set(`${KV_PREFIX}.lang`, l)
|
|
1319
|
+
api.ui.toast({
|
|
1320
|
+
message: l === "zh" ? "语言: 中文" : "Language: English",
|
|
1321
|
+
})
|
|
1322
|
+
dialog?.clear()
|
|
1323
|
+
}}
|
|
1324
|
+
/>
|
|
1325
|
+
))
|
|
1326
|
+
},
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
title: "SubAgent Magazine: Max Entries",
|
|
1330
|
+
value: "subagent-max",
|
|
1331
|
+
description: "Set max visible sub-agent entries in sidebar",
|
|
1332
|
+
slash: { name: "subagent-max" },
|
|
1333
|
+
onSelect: (dialog) => {
|
|
1334
|
+
dialog?.replace(() => (
|
|
1335
|
+
<api.ui.DialogPrompt
|
|
1336
|
+
title="Max Visible Entries"
|
|
1337
|
+
description={() => (
|
|
1338
|
+
<text>Number of entries to show in the sidebar (1–50)</text>
|
|
1339
|
+
)}
|
|
1340
|
+
value={String(maxEntries())}
|
|
1341
|
+
onConfirm={(val) => {
|
|
1342
|
+
const n = Math.max(1, Math.min(50, parseInt(val, 10) || 10))
|
|
1343
|
+
setMaxEntries(n)
|
|
1344
|
+
api.kv.set(`${KV_PREFIX}.max_entries`, n)
|
|
1345
|
+
api.ui.toast({ message: `Max entries: ${n}` })
|
|
1346
|
+
dialog?.clear()
|
|
1347
|
+
}}
|
|
1348
|
+
/>
|
|
1349
|
+
))
|
|
1350
|
+
},
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
title: "SubAgent Magazine: Version",
|
|
1354
|
+
value: "subagent-version",
|
|
1355
|
+
description: "Show plugin version",
|
|
1356
|
+
slash: { name: "subagent-version" },
|
|
1357
|
+
onSelect: (dialog) => {
|
|
1358
|
+
api.ui.toast({ message: `opencode-subagent-magazine v${PLUGIN_VERSION}` })
|
|
1359
|
+
dialog?.clear()
|
|
1360
|
+
},
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
title: "SubAgent Magazine: Session",
|
|
1364
|
+
value: "subagent-session",
|
|
1365
|
+
description: "Show current session ID",
|
|
1366
|
+
slash: { name: "subagent-session" },
|
|
1367
|
+
onSelect: (dialog) => {
|
|
1368
|
+
api.ui.toast({ message: `Session: ${signals.sessionId}` })
|
|
1369
|
+
dialog?.clear()
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
])
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const mod: TuiPluginModule & { id: string } = {
|
|
1376
|
+
id: "opencode-subagent-magazine",
|
|
1377
|
+
tui,
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
export default mod
|