opencode-tui-stats 0.3.0 → 0.4.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/package.json +1 -1
- package/tracker.ts +171 -14
- package/tui.tsx +61 -14
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "opencode-tui-stats",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "OpenCode 1 TUI plugin that shows live TPS, average TPS, and average time to first token in the session prompt.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
package/tracker.ts
CHANGED
|
@@ -41,6 +41,8 @@ export type ModelAggregate = {
|
|
|
41
41
|
totalDurationMs: number
|
|
42
42
|
totalTtftMs: number
|
|
43
43
|
stepCount: number
|
|
44
|
+
/** Last time this model was observed in this session (merged with `max` across a subtree). */
|
|
45
|
+
lastAt: number
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export type SessionModels = {
|
|
@@ -60,6 +62,8 @@ export type Tracker = {
|
|
|
60
62
|
stats: Record<string, SessionStats>
|
|
61
63
|
dedup: Record<string, Set<string>>
|
|
62
64
|
models: Record<string, SessionModels>
|
|
65
|
+
/** `sessionID -> parentID`; a key that is present with an `undefined` value is a confirmed root. */
|
|
66
|
+
parentOf: Record<string, string | undefined>
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
const STREAM_WINDOW_MS = 5_000
|
|
@@ -72,6 +76,8 @@ const DEDUP_WINDOW_LIMIT = 64
|
|
|
72
76
|
/** Cap on `messageID -> model` entries retained per session (FIFO). */
|
|
73
77
|
const MESSAGE_MODEL_LIMIT = 256
|
|
74
78
|
const SESSION_LIMIT = 64
|
|
79
|
+
/** Maximum number of descendant sessions folded into one panel (cycle-safe traversal bound). */
|
|
80
|
+
const SUBTREE_LIMIT = 64
|
|
75
81
|
/** Maximum number of model rows rendered in the sidebar panel. */
|
|
76
82
|
export const MODEL_ROW_LIMIT = 4
|
|
77
83
|
|
|
@@ -89,6 +95,7 @@ function emptyAggregate(): ModelAggregate {
|
|
|
89
95
|
totalDurationMs: 0,
|
|
90
96
|
totalTtftMs: 0,
|
|
91
97
|
stepCount: 0,
|
|
98
|
+
lastAt: 0,
|
|
92
99
|
}
|
|
93
100
|
}
|
|
94
101
|
|
|
@@ -145,7 +152,8 @@ export function cacheText(stats: { cacheRead: number; cachePrompt: number } | un
|
|
|
145
152
|
|
|
146
153
|
export function costText(stats: { cost: number; steps: number } | undefined) {
|
|
147
154
|
if (!stats || stats.steps <= 0) return undefined
|
|
148
|
-
|
|
155
|
+
// Always four decimals so the cost column keeps a stable width as spend grows.
|
|
156
|
+
return `$${stats.cost.toFixed(4)}`
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
/** FIFO-bound a Set so it never grows past `limit`. */
|
|
@@ -172,6 +180,8 @@ export type ModelRow = {
|
|
|
172
180
|
key: string
|
|
173
181
|
label: string
|
|
174
182
|
active: boolean
|
|
183
|
+
/** True when any descendant (subagent) session contributed data to this row. */
|
|
184
|
+
subagent?: boolean
|
|
175
185
|
tps?: string
|
|
176
186
|
avg?: string
|
|
177
187
|
ttft?: string
|
|
@@ -221,7 +231,15 @@ export function getLastInternals(): TrackerInternals | undefined {
|
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
|
|
224
|
-
const tracker: Tracker = {
|
|
234
|
+
const tracker: Tracker = {
|
|
235
|
+
samples: {},
|
|
236
|
+
timings: {},
|
|
237
|
+
averages: {},
|
|
238
|
+
stats: {},
|
|
239
|
+
dedup: {},
|
|
240
|
+
models: {},
|
|
241
|
+
parentOf: {},
|
|
242
|
+
}
|
|
225
243
|
const partDecisions = new Map<string, boolean>()
|
|
226
244
|
const mounted = new Set<string>()
|
|
227
245
|
const order: string[] = []
|
|
@@ -251,6 +269,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
251
269
|
delete tracker.samples[sessionID]
|
|
252
270
|
delete tracker.dedup[sessionID]
|
|
253
271
|
delete tracker.models[sessionID]
|
|
272
|
+
delete tracker.parentOf[sessionID]
|
|
254
273
|
clearTimings(sessionID)
|
|
255
274
|
}
|
|
256
275
|
|
|
@@ -305,7 +324,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
305
324
|
}
|
|
306
325
|
|
|
307
326
|
/** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping. */
|
|
308
|
-
const
|
|
327
|
+
const sessionLiveRate = (sessionID: string, key: string) => {
|
|
309
328
|
const status = api.state.session.status(sessionID)
|
|
310
329
|
if (!status || status.type === "idle") return undefined
|
|
311
330
|
|
|
@@ -321,34 +340,158 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
321
340
|
return formatRate(tokens / (activeDuration(samples, now) / 1_000))
|
|
322
341
|
}
|
|
323
342
|
|
|
324
|
-
|
|
343
|
+
/** Fold a session's parent from `session.created`/`session.updated`, or lazily from state.
|
|
344
|
+
* A successful lookup is cached; an unknown session is retried on the next render. */
|
|
345
|
+
const resolveParent = (sessionID: string) => {
|
|
346
|
+
if (sessionID in tracker.parentOf) return
|
|
347
|
+
const info = api.state.session.get(sessionID)
|
|
348
|
+
if (!info) return
|
|
349
|
+
tracker.parentOf[sessionID] = info.parentID
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const rememberParent = (sessionID: string, parentID: string | undefined) => {
|
|
353
|
+
if (sessionID in tracker.parentOf && tracker.parentOf[sessionID] === parentID) return
|
|
354
|
+
tracker.parentOf[sessionID] = parentID
|
|
355
|
+
bump()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const knownSessions = () => {
|
|
359
|
+
const ids = new Set<string>(Object.keys(tracker.models))
|
|
360
|
+
for (const id of Object.keys(tracker.stats)) ids.add(id)
|
|
361
|
+
for (const id of Object.keys(tracker.averages)) ids.add(id)
|
|
362
|
+
return ids
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Breadth-first descendants of `sessionID` (excluding itself), cycle-safe and capped. */
|
|
366
|
+
const descendants = (sessionID: string) => {
|
|
367
|
+
const children = new Map<string, string[]>()
|
|
368
|
+
for (const [child, parent] of Object.entries(tracker.parentOf)) {
|
|
369
|
+
if (!parent) continue
|
|
370
|
+
const siblings = children.get(parent)
|
|
371
|
+
if (siblings) siblings.push(child)
|
|
372
|
+
else children.set(parent, [child])
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const seen = new Set<string>([sessionID])
|
|
376
|
+
const found: string[] = []
|
|
377
|
+
const queue = [sessionID]
|
|
378
|
+
while (queue.length > 0 && found.length < SUBTREE_LIMIT) {
|
|
379
|
+
const current = queue.shift()!
|
|
380
|
+
for (const child of children.get(current) ?? []) {
|
|
381
|
+
if (seen.has(child)) continue
|
|
382
|
+
seen.add(child)
|
|
383
|
+
found.push(child)
|
|
384
|
+
queue.push(child)
|
|
385
|
+
if (found.length >= SUBTREE_LIMIT) break
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return found
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const mergeAggregate = (target: ModelAggregate, source: ModelAggregate) => {
|
|
392
|
+
target.cacheRead += source.cacheRead
|
|
393
|
+
target.cachePrompt += source.cachePrompt
|
|
394
|
+
target.cost += source.cost
|
|
395
|
+
target.steps += source.steps
|
|
396
|
+
target.totalTokens += source.totalTokens
|
|
397
|
+
target.totalDurationMs += source.totalDurationMs
|
|
398
|
+
target.totalTtftMs += source.totalTtftMs
|
|
399
|
+
target.stepCount += source.stepCount
|
|
400
|
+
target.lastAt = Math.max(target.lastAt, source.lastAt)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const sessionModelAt = (sessionID: string) => {
|
|
325
404
|
const models = tracker.models[sessionID]
|
|
405
|
+
if (!models) return 0
|
|
406
|
+
let at = 0
|
|
407
|
+
for (const key of models.order) {
|
|
408
|
+
const aggregate = models.aggregates[key]
|
|
409
|
+
if (aggregate && aggregate.lastAt > at) at = aggregate.lastAt
|
|
410
|
+
}
|
|
411
|
+
return at
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Live rate for a merged row: the parent and its subagents share one row, so only the
|
|
415
|
+
* session/model pair that most recently saw activity contributes. Concurrent streams in a
|
|
416
|
+
* parent and a child are therefore approximated by the newer one instead of being summed. */
|
|
417
|
+
const liveRateFor = (sessions: string[], key: string) => {
|
|
418
|
+
let latest: { sessionID: string; at: number } | undefined
|
|
419
|
+
for (const sessionID of sessions) {
|
|
420
|
+
const aggregate = tracker.models[sessionID]?.aggregates[key]
|
|
421
|
+
if (!aggregate) continue
|
|
422
|
+
if (!latest || aggregate.lastAt >= latest.at) latest = { sessionID, at: aggregate.lastAt }
|
|
423
|
+
}
|
|
424
|
+
if (!latest) return undefined
|
|
425
|
+
return sessionLiveRate(latest.sessionID, key)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const modelRows = (sessionID: string): ModelPanel => {
|
|
429
|
+
// Link sessions that only announced themselves through message events (a child can stream
|
|
430
|
+
// before its `session.created` event is delivered).
|
|
431
|
+
for (const id of knownSessions()) resolveParent(id)
|
|
432
|
+
|
|
433
|
+
const subtree = [sessionID, ...descendants(sessionID)]
|
|
326
434
|
const retries = tracker.stats[sessionID]?.retries ?? 0
|
|
327
|
-
const cache = cacheText(tracker.stats[sessionID])
|
|
328
|
-
if (!models) return { rows: [], overflow: 0, retries, cache }
|
|
329
435
|
|
|
330
|
-
|
|
436
|
+
// Session CACHE uses the same subtree scope as the model rows.
|
|
437
|
+
let cacheRead = 0
|
|
438
|
+
let cachePrompt = 0
|
|
439
|
+
for (const id of subtree) {
|
|
440
|
+
const stats = tracker.stats[id]
|
|
441
|
+
if (!stats) continue
|
|
442
|
+
cacheRead += stats.cacheRead
|
|
443
|
+
cachePrompt += stats.cachePrompt
|
|
444
|
+
}
|
|
445
|
+
const cache = cacheText({ cacheRead, cachePrompt })
|
|
446
|
+
|
|
447
|
+
// Fold the subtree oldest→newest so `order` keeps its "oldest first" meaning across sessions.
|
|
448
|
+
const sessions = subtree
|
|
449
|
+
.filter((id) => tracker.models[id])
|
|
450
|
+
.sort((left, right) => sessionModelAt(left) - sessionModelAt(right))
|
|
451
|
+
if (sessions.length === 0) return { rows: [], overflow: 0, retries, cache }
|
|
452
|
+
|
|
453
|
+
const order: string[] = []
|
|
454
|
+
const aggregates: Record<string, ModelAggregate> = {}
|
|
455
|
+
const subagentModels = new Set<string>()
|
|
456
|
+
for (const id of sessions) {
|
|
457
|
+
const models = tracker.models[id]
|
|
458
|
+
for (const key of models.order) {
|
|
459
|
+
const aggregate = models.aggregates[key]
|
|
460
|
+
if (!aggregate) continue
|
|
461
|
+
let merged = aggregates[key]
|
|
462
|
+
if (!merged) {
|
|
463
|
+
merged = emptyAggregate()
|
|
464
|
+
aggregates[key] = merged
|
|
465
|
+
order.push(key)
|
|
466
|
+
}
|
|
467
|
+
mergeAggregate(merged, aggregate)
|
|
468
|
+
if (id !== sessionID) subagentModels.add(key)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let active: string | undefined
|
|
473
|
+
for (const id of sessions) active = tracker.models[id].active ?? active
|
|
474
|
+
|
|
331
475
|
// `order` is oldest→newest; reverse for most-recent-first ordering of the non-active rows.
|
|
332
|
-
const recency = [...
|
|
333
|
-
const ordered =
|
|
334
|
-
active && active in models.aggregates ? [active, ...recency.filter((key) => key !== active)] : recency
|
|
476
|
+
const recency = [...order].reverse()
|
|
477
|
+
const ordered = active && active in aggregates ? [active, ...recency.filter((key) => key !== active)] : recency
|
|
335
478
|
|
|
336
479
|
const visible = ordered.slice(0, MODEL_ROW_LIMIT)
|
|
337
480
|
const overflow = Math.max(0, ordered.length - visible.length)
|
|
338
481
|
|
|
339
482
|
const rows = visible.map<ModelRow>((key) => {
|
|
340
|
-
const aggregate =
|
|
483
|
+
const aggregate = aggregates[key]
|
|
341
484
|
const isActive = key === active
|
|
342
|
-
const cost = costText(aggregate)
|
|
343
485
|
return {
|
|
344
486
|
key,
|
|
345
487
|
label: key,
|
|
346
488
|
active: isActive,
|
|
347
|
-
|
|
489
|
+
subagent: subagentModels.has(key),
|
|
490
|
+
tps: isActive ? liveRateFor(subtree, key) : undefined,
|
|
348
491
|
avg: averageRate(aggregate),
|
|
349
492
|
ttft: averageTtft(aggregate),
|
|
350
493
|
cache: cacheText(aggregate),
|
|
351
|
-
cost:
|
|
494
|
+
cost: costText(aggregate),
|
|
352
495
|
}
|
|
353
496
|
})
|
|
354
497
|
|
|
@@ -393,6 +536,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
393
536
|
tracker.models[sessionID] ?? (tracker.models[sessionID] = { order: [], aggregates: {}, messageModels: new Map() })
|
|
394
537
|
touchModel(models, key)
|
|
395
538
|
models.active = key
|
|
539
|
+
models.aggregates[key] = { ...models.aggregates[key], lastAt: Date.now() }
|
|
396
540
|
boundedSet(models.messageModels, info.id, key, MESSAGE_MODEL_LIMIT)
|
|
397
541
|
|
|
398
542
|
if (!info.time.completed) {
|
|
@@ -527,6 +671,19 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
527
671
|
}),
|
|
528
672
|
)
|
|
529
673
|
|
|
674
|
+
// Subagent sessions announce their parent here; the tree is also backfilled lazily from state.
|
|
675
|
+
listeners.push(
|
|
676
|
+
api.event.on("session.created", (event) => {
|
|
677
|
+
rememberParent(event.properties.info.id, event.properties.info.parentID)
|
|
678
|
+
}),
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
listeners.push(
|
|
682
|
+
api.event.on("session.updated", (event) => {
|
|
683
|
+
rememberParent(event.properties.info.id, event.properties.info.parentID)
|
|
684
|
+
}),
|
|
685
|
+
)
|
|
686
|
+
|
|
530
687
|
// The 1s tick re-evaluates live TPS staleness and prunes stale stream samples.
|
|
531
688
|
const timer = setInterval(() => {
|
|
532
689
|
prune()
|
package/tui.tsx
CHANGED
|
@@ -9,7 +9,40 @@ import {
|
|
|
9
9
|
type TrackerHandle,
|
|
10
10
|
} from "./tracker"
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/** Detail lines sit three cells in from the fold marker, visually owned by the row above. */
|
|
13
|
+
const DETAIL_INDENT = " "
|
|
14
|
+
/** Fixed cells keep every label on the same column across rows without measuring the panel. */
|
|
15
|
+
const RATE_CELL = 4
|
|
16
|
+
const TTFT_CELL = 6
|
|
17
|
+
const CACHE_CELL = 4
|
|
18
|
+
|
|
19
|
+
/** Split `provider/model` so the provider can be muted. Model ids may contain slashes, so only the
|
|
20
|
+
* first slash separates the provider. When the model already starts with the provider name
|
|
21
|
+
* (`deepseek/deepseek-flash`) the redundant prefix is dropped. */
|
|
22
|
+
function splitLabel(label: string) {
|
|
23
|
+
const slash = label.indexOf("/")
|
|
24
|
+
if (slash <= 0) return { provider: "", model: label }
|
|
25
|
+
const provider = label.slice(0, slash)
|
|
26
|
+
const model = label.slice(slash + 1)
|
|
27
|
+
if (model.toLowerCase().startsWith(provider.toLowerCase())) return { provider: "", model }
|
|
28
|
+
return { provider: `${provider}/`, model }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rateCell(value: string | undefined) {
|
|
32
|
+
return value === undefined ? "—" : `${value.padStart(RATE_CELL)}t/s`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ttftCell(value: string | undefined) {
|
|
36
|
+
return (value ?? "—").padEnd(TTFT_CELL)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cacheCell(value: string | undefined) {
|
|
40
|
+
if (value === undefined) return "—".padStart(CACHE_CELL)
|
|
41
|
+
const percent = value.endsWith("%") ? value.slice(0, -1) : value
|
|
42
|
+
return `${percent.padStart(3)}%`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A single model row: fold marker + summary line, plus its detail lines when expanded. */
|
|
13
46
|
function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boolean; onToggle: () => void }) {
|
|
14
47
|
// Guard against a drag-select being read as a click: only toggle when the press and the
|
|
15
48
|
// release land on the same cell.
|
|
@@ -27,29 +60,37 @@ function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boole
|
|
|
27
60
|
}
|
|
28
61
|
|
|
29
62
|
const theme = () => props.api.theme.current
|
|
63
|
+
const parts = () => splitLabel(props.row.label)
|
|
30
64
|
|
|
31
65
|
return (
|
|
32
66
|
<box flexDirection="column" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
|
|
33
67
|
<text wrapMode="none" truncate>
|
|
34
68
|
<span style={{ fg: theme().textMuted } as Record<string, unknown>}>{`${props.expanded ? "▼" : "▶"} `}</span>
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
69
|
+
<Show when={props.row.subagent}>
|
|
70
|
+
<span style={{ fg: theme().textMuted } as Record<string, unknown>}>{"↳ "}</span>
|
|
71
|
+
</Show>
|
|
72
|
+
<Show when={parts().provider}>
|
|
73
|
+
<span style={{ fg: theme().textMuted } as Record<string, unknown>}>{parts().provider}</span>
|
|
38
74
|
</Show>
|
|
75
|
+
<span style={{ fg: theme().text } as Record<string, unknown>}>{parts().model}</span>
|
|
39
76
|
</text>
|
|
40
77
|
<Show when={props.expanded}>
|
|
41
|
-
<text fg={theme().
|
|
42
|
-
{
|
|
78
|
+
<text fg={theme().textMuted} wrapMode="none" truncate>
|
|
79
|
+
{`${DETAIL_INDENT}AVG ${rateCell(props.row.avg)}${
|
|
80
|
+
props.row.tps === undefined ? "" : ` · TPS ${rateCell(props.row.tps)}`
|
|
81
|
+
}`}
|
|
43
82
|
</text>
|
|
44
|
-
<text fg={theme().
|
|
45
|
-
{
|
|
83
|
+
<text fg={theme().textMuted} wrapMode="none" truncate>
|
|
84
|
+
{`${DETAIL_INDENT}TTFT ${ttftCell(props.row.ttft)} · CACHE ${cacheCell(props.row.cache)} · ${
|
|
85
|
+
props.row.cost ?? "—"
|
|
86
|
+
}`}
|
|
46
87
|
</text>
|
|
47
88
|
</Show>
|
|
48
89
|
</box>
|
|
49
90
|
)
|
|
50
91
|
}
|
|
51
92
|
|
|
52
|
-
/** Per-session model panel, mounted at `
|
|
93
|
+
/** Per-session model panel, mounted at `sidebar_footer`. */
|
|
53
94
|
function ModelPanelView(props: {
|
|
54
95
|
api: TuiPluginApi
|
|
55
96
|
sessionID: string
|
|
@@ -112,9 +153,13 @@ function ModelPanelView(props: {
|
|
|
112
153
|
|
|
113
154
|
return (
|
|
114
155
|
<box flexDirection="column">
|
|
115
|
-
<text fg={props.api.theme.current.
|
|
156
|
+
<text fg={props.api.theme.current.text} wrapMode="none" truncate>
|
|
157
|
+
<b>Performance · observed</b>
|
|
158
|
+
</text>
|
|
116
159
|
<Show when={panel().rows.length === 0}>
|
|
117
|
-
<text fg={props.api.theme.current.textMuted}
|
|
160
|
+
<text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
|
|
161
|
+
—
|
|
162
|
+
</text>
|
|
118
163
|
</Show>
|
|
119
164
|
<For each={panel().rows}>
|
|
120
165
|
{(row) => (
|
|
@@ -122,9 +167,11 @@ function ModelPanelView(props: {
|
|
|
122
167
|
)}
|
|
123
168
|
</For>
|
|
124
169
|
<Show when={panel().overflow > 0}>
|
|
125
|
-
<text fg={props.api.theme.current.textMuted}
|
|
170
|
+
<text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
|
|
171
|
+
{`+${panel().overflow} more models`}
|
|
172
|
+
</text>
|
|
126
173
|
</Show>
|
|
127
|
-
<text fg={props.api.theme.current.
|
|
174
|
+
<text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
|
|
128
175
|
{`Session CACHE ${panel().cache ?? "—"}${panel().retries > 0 ? ` · retries ${panel().retries}` : ""}`}
|
|
129
176
|
</text>
|
|
130
177
|
</box>
|
|
@@ -139,7 +186,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
139
186
|
|
|
140
187
|
api.slots.register({
|
|
141
188
|
slots: {
|
|
142
|
-
|
|
189
|
+
sidebar_footer(_context, props) {
|
|
143
190
|
return <ModelPanelView api={api} sessionID={props.session_id} tracker={tracker} revision={revision} />
|
|
144
191
|
},
|
|
145
192
|
},
|