opencode-extended-sidebar 0.2.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.oesignore +17 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +21 -0
  4. package/README.md +235 -0
  5. package/oes.json +11 -0
  6. package/package.json +100 -0
  7. package/scripts/install-git-hooks.mjs +29 -0
  8. package/src/pware.oc.core/constants/index.ts +18 -0
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
  19. package/src/pware.oc.core/git/index.ts +7 -0
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
  22. package/src/pware.oc.core/index.ts +18 -0
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
  34. package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
  35. package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
  36. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
  37. package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
  38. package/src/pware.oc.omo/constants/index.ts +15 -0
  39. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
  40. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
  47. package/src/pware.oc.omo/index.ts +7 -0
  48. package/src/pware.oc.omo/resolver/index.ts +19 -0
  49. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
  50. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
  62. package/src/pware.oc.opencode/constants/index.ts +10 -0
  63. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
  64. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
  67. package/src/pware.oc.opencode/index.ts +8 -0
  68. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
  69. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
  70. package/src/pware.oc.opencode/resolver/index.ts +170 -0
  71. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
  72. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
  76. package/src/pware.oc.perf/index.ts +14 -0
  77. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
  78. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
  79. package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
  80. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
  81. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
  82. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
  85. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
  86. package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
  87. package/src/pware.oc.runtime/index.ts +13 -0
  88. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
  89. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
  90. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
  91. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
  92. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
  93. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
  95. package/src/pware.oc.runtime/resolver/index.ts +224 -0
  96. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
  97. package/src/pware.oc.ui/index.ts +12 -0
  98. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
  99. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
  100. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
  101. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
  102. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
  103. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
  104. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
  105. package/src/pware.oc.ui.tsx +98 -0
@@ -0,0 +1,726 @@
1
+ /** Pulse + lifecycle mark for agent rows. Evaluated against wall clock. */
2
+ import { eventType } from "./pware.oc.core.events.js"
3
+ import { basenameOf } from "./pware.oc.core.paths.js"
4
+ import { normalizeStatus, toToolStatus } from "./pware.oc.core.status.js"
5
+ import { clipMiddleWidth, clipWidth, strWidth } from "./pware.oc.core.width.js"
6
+ import {
7
+ EVENT_MESSAGE_PART_UPDATED,
8
+ EVENT_PART_DELTA,
9
+ EVENT_REASONING_DELTA,
10
+ EVENT_REASONING_STARTED,
11
+ EVENT_SESSION_IDLE,
12
+ EVENT_SESSION_STATUS,
13
+ EVENT_STEP_ENDED,
14
+ EVENT_STEP_FAILED,
15
+ EVENT_STEP_STARTED,
16
+ EVENT_TEXT_DELTA,
17
+ EVENT_TEXT_STARTED,
18
+ EVENT_TOOL_CALLED,
19
+ EVENT_TOOL_ENDED,
20
+ EVENT_TOOL_FAILED,
21
+ EVENT_TOOL_SUCCESS,
22
+ } from "./constants/pware.oc.core.constants.eventType.js"
23
+ import { TOOL_BASH, TOOL_TASK } from "./constants/pware.oc.core.constants.toolName.js"
24
+ import {
25
+ FLOW_HINT_CLEAR,
26
+ FLOW_RECV,
27
+ FLOW_TOOL,
28
+ FLOW_WAIT,
29
+ MARK_QUEUED,
30
+ MARK_READY,
31
+ PULSE_IDLE,
32
+ PULSE_LIVE,
33
+ PULSE_STALE,
34
+ type AgentMark,
35
+ type FlowDir,
36
+ type FlowHint,
37
+ type Pulse,
38
+ } from "./constants/pware.oc.core.constants.pulse.js"
39
+ import {
40
+ STATUS_ARCHIVED,
41
+ STATUS_COMPLETED,
42
+ STATUS_ERROR,
43
+ STATUS_RUNNING,
44
+ TOOL_STATUS_COMPLETED,
45
+ TOOL_STATUS_ERROR,
46
+ TOOL_STATUS_RUNNING,
47
+ type ToolStatus,
48
+ } from "./constants/pware.oc.core.constants.status.js"
49
+
50
+ export type { AgentMark, FlowDir, FlowHint, Pulse }
51
+
52
+ /** `at` is the last event of this direction, `since` the moment the phase began. */
53
+ export type FlowEntry = { dir: FlowDir; at: number; since: number }
54
+
55
+ export const LIVE_MS = 5_000
56
+ export const STALE_MS = 10_000
57
+ /** Downstream tokens still count as recv. */
58
+ export const FLOW_RECV_MS = 2_000
59
+ /** Keep ↑ after step.started while the runner is still busy. */
60
+ export const FLOW_WAIT_MS = 15_000
61
+ /** Tool call stays → until success/fail or timeout. */
62
+ export const FLOW_TOOL_MS = 30_000
63
+ export function pulseFromAge(ageMs: number): Pulse {
64
+ if (ageMs < LIVE_MS) return PULSE_LIVE
65
+ if (ageMs < STALE_MS) return PULSE_STALE
66
+ return PULSE_IDLE
67
+ }
68
+
69
+ /** Epoch ms. OpenCode may store seconds, ms, or ISO strings. */
70
+ export function toEpochMs(v: unknown): number | null {
71
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) {
72
+ return v < 1e11 ? v * 1000 : v
73
+ }
74
+ if (typeof v === "string" && v.trim()) {
75
+ const n = Date.parse(v)
76
+ return Number.isNaN(n) ? null : n
77
+ }
78
+ return null
79
+ }
80
+
81
+ /** OpenCode may store seconds or ms. */
82
+ export function stampMs(v: number | null | undefined): number | null {
83
+ return toEpochMs(v)
84
+ }
85
+
86
+ /** Last activity: SQLite time_updated, TUI event, or boulder stamp. */
87
+ export function pulseAgeMs(
88
+ now: number,
89
+ ...stamps: Array<number | null | undefined>
90
+ ): number | null {
91
+ let last = 0
92
+ for (const s of stamps) {
93
+ const ms = stampMs(s)
94
+ if (ms != null && ms > last) last = ms
95
+ }
96
+ if (!last) return null
97
+ return Math.max(0, now - last)
98
+ }
99
+
100
+ export function composeMark(opts: {
101
+ lifecycle?: string | null
102
+ archived?: boolean
103
+ ageMs: number | null
104
+ busy?: boolean
105
+ }): AgentMark {
106
+ if (opts.archived) return STATUS_ARCHIVED
107
+ const c = normalizeStatus(opts.lifecycle)
108
+ if (c === STATUS_COMPLETED) return MARK_READY
109
+ if (c === STATUS_ERROR) return STATUS_ERROR
110
+ if (opts.busy) return opts.ageMs == null ? PULSE_LIVE : pulseFromAge(Math.min(opts.ageMs, LIVE_MS - 1))
111
+ if (opts.ageMs == null) {
112
+ if (c === STATUS_RUNNING) return PULSE_STALE
113
+ return MARK_QUEUED
114
+ }
115
+ // Running is not terminal — same age window as Agents. A leftover
116
+ // boulder `running` must not keep the spinner after the session went quiet.
117
+ return pulseFromAge(opts.ageMs)
118
+ }
119
+
120
+ /** Group header pulse: live > stale > error > idle `•`. */
121
+ export function hottestMark(marks: readonly AgentMark[]): AgentMark {
122
+ let stale = false
123
+ let error = false
124
+ for (const m of marks) {
125
+ if (m === PULSE_LIVE) return PULSE_LIVE
126
+ if (m === PULSE_STALE) stale = true
127
+ else if (m === STATUS_ERROR) error = true
128
+ }
129
+ if (stale) return PULSE_STALE
130
+ return error ? STATUS_ERROR : MARK_READY
131
+ }
132
+
133
+ export function activeFlow(
134
+ entry: FlowEntry | undefined,
135
+ now: number,
136
+ busy: boolean,
137
+ ): FlowDir | null {
138
+ if (entry) {
139
+ const age = now - entry.at
140
+ if (entry.dir === FLOW_RECV && age < FLOW_RECV_MS) return FLOW_RECV
141
+ if (entry.dir === FLOW_WAIT && (busy || age < FLOW_WAIT_MS)) return FLOW_WAIT
142
+ if (entry.dir === FLOW_TOOL && (busy || age < FLOW_TOOL_MS)) return FLOW_TOOL
143
+ }
144
+ if (busy) return FLOW_WAIT
145
+ return null
146
+ }
147
+
148
+ export function applyFlow(
149
+ prev: Record<string, FlowEntry>,
150
+ id: string,
151
+ dir: FlowHint,
152
+ now: number,
153
+ ): Record<string, FlowEntry> {
154
+ if (dir === FLOW_HINT_CLEAR) {
155
+ if (!(id in prev)) return prev
156
+ const next = { ...prev }
157
+ delete next[id]
158
+ return next
159
+ }
160
+ const cur = prev[id]
161
+ if (dir === FLOW_WAIT) {
162
+ if (cur?.dir === FLOW_RECV && now - cur.at < FLOW_RECV_MS) return prev
163
+ if (cur?.dir === FLOW_TOOL) return prev
164
+ }
165
+ const since = cur?.dir === dir ? cur.since : now
166
+ return { ...prev, [id]: { dir, at: now, since } }
167
+ }
168
+
169
+ /** How long the current phase has been running. Null when the row is quiet. */
170
+ export function phaseAgeMs(
171
+ entry: FlowEntry | undefined,
172
+ now: number,
173
+ dir: FlowDir | null,
174
+ ): number | null {
175
+ if (!entry || !dir || entry.dir !== dir) return null
176
+ return Math.max(0, now - entry.since)
177
+ }
178
+
179
+ function partKind(evt: unknown): string {
180
+ if (!evt || typeof evt !== "object") return ""
181
+ const o = evt as Record<string, unknown>
182
+ const props =
183
+ o.properties && typeof o.properties === "object"
184
+ ? (o.properties as Record<string, unknown>)
185
+ : o
186
+ const part = props.part
187
+ if (part && typeof part === "object") {
188
+ return String((part as Record<string, unknown>).type ?? "").toLowerCase()
189
+ }
190
+ return ""
191
+ }
192
+
193
+ /** Classify host event as wait / recv / tool. Never reads request or response bodies. */
194
+ export function flowFromEvent(
195
+ evt: unknown,
196
+ fallbackType = "",
197
+ ): { id: string | null; dir: FlowHint | null } {
198
+ const type = eventType(evt) || fallbackType.toLowerCase()
199
+ const id = sessionIdFromEvent(evt)
200
+ if (!type) return { id, dir: null }
201
+
202
+ if (
203
+ type.includes(EVENT_SESSION_IDLE) ||
204
+ type.endsWith("." + EVENT_STEP_ENDED) ||
205
+ type.endsWith("." + EVENT_STEP_FAILED)
206
+ ) {
207
+ return { id, dir: FLOW_HINT_CLEAR }
208
+ }
209
+
210
+ if (type.includes(EVENT_TOOL_CALLED)) return { id, dir: FLOW_TOOL }
211
+ if (
212
+ type.includes(EVENT_TOOL_SUCCESS) ||
213
+ type.includes(EVENT_TOOL_FAILED) ||
214
+ type.includes(EVENT_TOOL_ENDED)
215
+ ) {
216
+ return { id, dir: FLOW_WAIT }
217
+ }
218
+
219
+ if (
220
+ type.includes(EVENT_TEXT_DELTA) ||
221
+ type.includes(EVENT_REASONING_DELTA) ||
222
+ type.includes(EVENT_PART_DELTA) ||
223
+ type.endsWith(".delta")
224
+ ) {
225
+ return { id, dir: FLOW_RECV }
226
+ }
227
+
228
+ if (
229
+ type.includes(EVENT_STEP_STARTED) ||
230
+ type.includes(EVENT_TEXT_STARTED) ||
231
+ type.includes(EVENT_REASONING_STARTED)
232
+ ) {
233
+ return { id, dir: FLOW_WAIT }
234
+ }
235
+
236
+ if (type.includes(EVENT_MESSAGE_PART_UPDATED)) {
237
+ const kind = partKind(evt)
238
+ if (kind.includes("tool")) return { id, dir: FLOW_TOOL }
239
+ return { id, dir: FLOW_RECV }
240
+ }
241
+
242
+ if (type === EVENT_SESSION_STATUS || type.endsWith(EVENT_SESSION_STATUS)) {
243
+ const flag = sessionBusyFromEvent(evt)
244
+ if (flag.busy === false) return { id: flag.id ?? id, dir: FLOW_HINT_CLEAR }
245
+ if (flag.busy === true) return { id: flag.id ?? id, dir: FLOW_WAIT }
246
+ }
247
+
248
+ return { id, dir: null }
249
+ }
250
+
251
+ export function sessionBusyFromEvent(evt: unknown): { id: string | null; busy: boolean | null } {
252
+ if (!evt || typeof evt !== "object") return { id: null, busy: null }
253
+ const o = evt as Record<string, unknown>
254
+ const props =
255
+ o.properties && typeof o.properties === "object"
256
+ ? (o.properties as Record<string, unknown>)
257
+ : o
258
+ const id = sessionIdFromEvent(evt)
259
+ const kind = String(o.type ?? "").toLowerCase()
260
+ if (kind === EVENT_SESSION_IDLE || kind.endsWith(EVENT_SESSION_IDLE)) {
261
+ return { id, busy: false }
262
+ }
263
+ const status = props.status
264
+ if (status && typeof status === "object") {
265
+ const t = String((status as Record<string, unknown>).type || "").toLowerCase()
266
+ if (t === "busy" || t === "retry") return { id, busy: true }
267
+ if (t === "idle") return { id, busy: false }
268
+ }
269
+ const raw = String(status ?? o.status ?? "").toLowerCase()
270
+ if (raw === "busy" || raw === "running") return { id, busy: true }
271
+ if (raw === "idle") return { id, busy: false }
272
+ return { id, busy: null }
273
+ }
274
+
275
+ export function formatTokens(n: number | null | undefined): string {
276
+ if (n == null || !Number.isFinite(n)) return "—"
277
+ const v = Math.max(0, n)
278
+ if (v >= 1e6) return `${v >= 10e6 ? Math.round(v / 1e6) : (v / 1e6).toFixed(1)}M`
279
+ if (v >= 1e3) return `${v >= 10_000 ? Math.round(v / 1e3) : (v / 1e3).toFixed(1)}k`
280
+ return String(Math.round(v))
281
+ }
282
+
283
+ /** Compact ≤3-char axis label for a sparkline edge: 100 → "100", 1000 → "1k", 0 → "0". */
284
+ export function formatCompact(n: number | null | undefined): string {
285
+ if (n == null || !Number.isFinite(n)) return "0"
286
+ const v = Math.max(0, n)
287
+ if (v < 10) {
288
+ const s = (Math.round(v * 10) / 10).toString()
289
+ return s.length <= 3 ? s : String(Math.round(v))
290
+ }
291
+ if (v < 1000) return String(Math.round(v))
292
+ const units: ReadonlyArray<readonly [number, string]> = [
293
+ [1e12, "T"],
294
+ [1e9, "G"],
295
+ [1e6, "M"],
296
+ [1e3, "k"],
297
+ ]
298
+ for (const [div, suffix] of units) {
299
+ const m = Math.round(v / div)
300
+ if (m >= 1) return `${m}${suffix}`
301
+ }
302
+ return String(Math.round(v))
303
+ }
304
+
305
+ /** Models header: ↑in ↓out ∴reasoning. Reasoning omitted when zero. */
306
+ export function tokenSummary(tokens: {
307
+ tokensIn: number
308
+ tokensOut: number
309
+ tokensReasoning?: number
310
+ }): string {
311
+ const parts = [`↑${formatTokens(tokens.tokensIn)}`, `↓${formatTokens(tokens.tokensOut)}`]
312
+ if ((tokens.tokensReasoning ?? 0) > 0) parts.push(`∴${formatTokens(tokens.tokensReasoning)}`)
313
+ return parts.join(" ")
314
+ }
315
+
316
+ /** Time header: turns · duration · optional err/abort. The caller decides what span to show. */
317
+ export function timeSummary(totals: {
318
+ turns: number
319
+ durationMs: number
320
+ errors?: number
321
+ aborts?: number
322
+ }): string {
323
+ const parts = [`${totals.turns} turns`, formatSpan(totals.durationMs)]
324
+ if ((totals.errors ?? 0) > 0) parts.push(`${totals.errors} err`)
325
+ if ((totals.aborts ?? 0) > 0) parts.push(`${totals.aborts} abort`)
326
+ return parts.join(" · ")
327
+ }
328
+
329
+ /** s / m / h / d buckets shared by age, duration, and span formatters. */
330
+ function formatCoarseSec(totalSec: number, compositeHours = false): string {
331
+ const s = Math.max(0, Math.floor(totalSec))
332
+ if (s < 60) return `${s}s`
333
+ const m = Math.floor(s / 60)
334
+ if (m < 60) return `${m}m`
335
+ const h = Math.floor(m / 60)
336
+ if (h < 48) {
337
+ const rem = m % 60
338
+ return compositeHours && rem > 0 ? `${h}h${rem}m` : `${h}h`
339
+ }
340
+ return `${Math.floor(h / 24)}d`
341
+ }
342
+
343
+ export function formatAge(ageMs: number | null | undefined): string {
344
+ if (ageMs == null || !Number.isFinite(ageMs) || ageMs < 0) return ""
345
+ return formatCoarseSec(ageMs / 1000)
346
+ }
347
+
348
+ export function formatDuration(ms: number | null | undefined): string {
349
+ if (ms == null || !Number.isFinite(ms) || ms < 1) return ""
350
+ if (ms < 1000) return `${Math.round(ms)}ms`
351
+ if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`
352
+ return formatCoarseSec(Math.round(ms / 1000))
353
+ }
354
+
355
+ /** UTC `YYYY-MM-DD HH:MM:SS` for logs and detail sheets. */
356
+ export function formatWhen(ts: number | null | undefined): string {
357
+ if (ts == null || !Number.isFinite(ts)) return "—"
358
+ const d = new Date(ts)
359
+ if (Number.isNaN(d.getTime())) return "—"
360
+ return d.toISOString().replace("T", " ").slice(0, 19)
361
+ }
362
+
363
+ /** Coarser than formatDuration — minutes and hours for whole-session sums. */
364
+ export function formatSpan(ms: number | null | undefined): string {
365
+ if (ms == null || !Number.isFinite(ms) || ms < 1000) return "0s"
366
+ return formatCoarseSec(Math.round(ms / 1000), true)
367
+ }
368
+
369
+ export function formatRate(perSec: number | null | undefined): string {
370
+ if (perSec == null || !Number.isFinite(perSec) || perSec <= 0) return ""
371
+ return perSec >= 100 ? `${Math.round(perSec)}/s` : `${perSec.toFixed(1)}/s`
372
+ }
373
+
374
+ /** Token rate for the status bar: integer-rounded and always present — "0 tok/s" when idle. */
375
+ export function formatTokenRate(perSec: number | null | undefined): string {
376
+ const n = perSec == null || !Number.isFinite(perSec) || perSec < 0 ? 0 : perSec
377
+ return `${Math.round(n)} tok/s`
378
+ }
379
+
380
+ /**
381
+ * The streaming chunk of a text/reasoning delta event. Only the explicit
382
+ * `delta` field (message.part.updated / message.part.delta) or, on a delta-type
383
+ * event, a bare `text` (session.next.text.delta / reasoning.delta). Never reads
384
+ * a full message or part body — the count, not the content, is what matters.
385
+ */
386
+ export function deltaTextFromEvent(evt: unknown): string | null {
387
+ if (!evt || typeof evt !== "object") return null
388
+ const type = eventType(evt)
389
+ const bags = eventBags(evt)
390
+ for (const bag of bags) {
391
+ const d = bag.delta
392
+ if (typeof d === "string" && d.trim()) return d
393
+ }
394
+ if (!type.includes(".delta")) return null
395
+ for (const bag of bags) {
396
+ const t = bag.text
397
+ if (typeof t === "string" && t.trim()) return t
398
+ }
399
+ return null
400
+ }
401
+
402
+ /** Rough token count from a text chunk: code points / charsPerToken (≈4). */
403
+ export function estimateTokens(text: string, charsPerToken = 4): number {
404
+ const t = (text || "").replace(/\s+/g, " ").trim()
405
+ if (!t) return 0
406
+ return Math.max(1, Math.ceil([...t].length / charsPerToken))
407
+ }
408
+
409
+ /** Whether a streamed text delta is visible output (`out`) or hidden thinking (`reasoning`). */
410
+ export function deltaKindFromEvent(evt: unknown): "out" | "reasoning" {
411
+ const type = eventType(evt)
412
+ const kind = partKind(evt)
413
+ if (type.includes("reasoning") || kind.includes("reasoning")) return "reasoning"
414
+ return "out"
415
+ }
416
+
417
+ /** A single streaming token-count sample: `n` estimated tokens at `at`. */
418
+ export type TokenTick = { at: number; n: number }
419
+
420
+ /** One-row OES bar width — bucket count for {@link tokenRateBars}. */
421
+ export const TOKEN_RATE_BAR_WIDTH = 8
422
+
423
+ /** Block ramp for {@link tokenRateBars} — space = empty bucket. */
424
+ export const TOKEN_RATE_BAR_BLOCKS = " ▁▂▃▄▅▆▇█" as const
425
+
426
+ /** Append a tick and drop samples older than the sliding window (immutable). */
427
+ export function pushTokenTick(
428
+ ticks: readonly TokenTick[],
429
+ at: number,
430
+ n: number,
431
+ windowMs: number,
432
+ ): TokenTick[] {
433
+ if (!Number.isFinite(n) || n <= 0) return [...ticks]
434
+ const next = [...ticks, { at, n }]
435
+ return next.filter((t) => at - t.at <= windowMs)
436
+ }
437
+
438
+ /** Live rate over the window: tokens per second, or null when the span is too short. */
439
+ export function tokenRate(
440
+ ticks: readonly TokenTick[],
441
+ at: number,
442
+ windowMs: number,
443
+ ): number | null {
444
+ const win = ticks.filter((t) => at - t.at <= windowMs)
445
+ if (win.length < 2) return null
446
+ const sum = win.reduce((s, t) => s + t.n, 0)
447
+ const spanMs = at - win[0]!.at
448
+ if (spanMs <= 0) return null
449
+ return (sum / spanMs) * 1000
450
+ }
451
+
452
+ /**
453
+ * One-row bar strip for the OES status line: bucket estimated token deltas over
454
+ * the trailing window into `width` columns, oldest on the left and newest on
455
+ * the right. Empty buckets render as a space; the tallest bucket uses the top
456
+ * block from {@link TOKEN_RATE_BAR_BLOCKS}.
457
+ */
458
+ export function tokenRateBars(
459
+ ticks: readonly TokenTick[],
460
+ at: number,
461
+ windowMs: number,
462
+ width = TOKEN_RATE_BAR_WIDTH,
463
+ ): string {
464
+ const w = Math.max(1, Math.round(width))
465
+ const buckets = new Array<number>(w).fill(0)
466
+ for (const t of ticks) {
467
+ const age = at - t.at
468
+ if (age < 0 || age > windowMs || !Number.isFinite(t.n) || t.n <= 0) continue
469
+ const idx = Math.min(w - 1, Math.floor((age / windowMs) * w))
470
+ buckets[w - 1 - idx]! += t.n
471
+ }
472
+ const max = Math.max(1, ...buckets)
473
+ const blocks = TOKEN_RATE_BAR_BLOCKS
474
+ return buckets
475
+ .map((n) => {
476
+ if (n <= 0) return " "
477
+ const level = Math.min(
478
+ blocks.length - 1,
479
+ Math.max(1, Math.round((n / max) * (blocks.length - 1))),
480
+ )
481
+ return blocks[level]!
482
+ })
483
+ .join("")
484
+ }
485
+
486
+ export function formatPercent(share: number | null | undefined): string {
487
+ if (share == null || !Number.isFinite(share) || share < 0) return ""
488
+ const pct = share * 100
489
+ if (pct > 0 && pct < 1) return "<1%"
490
+ return `${Math.round(pct)}%`
491
+ }
492
+
493
+ /** A metric chip on a row. Higher `rank` is dropped first when the line is tight. */
494
+ export type Chip = { text: string; rank: number }
495
+
496
+ /** Keep as many chips as `max` columns allows, worst rank first, name budget reserved. */
497
+ export function packChips(nameWidth: number, chips: Chip[], max: number): Chip[] {
498
+ const keep = chips.filter((c) => c.text)
499
+ const width = () => keep.reduce((sum, c) => sum + strWidth(c.text) + 1, nameWidth)
500
+ while (keep.length > 0 && width() > max) {
501
+ let worst = 0
502
+ for (let i = 1; i < keep.length; i += 1) {
503
+ if ((keep[i]?.rank ?? 0) > (keep[worst]?.rank ?? 0)) worst = i
504
+ }
505
+ keep.splice(worst, 1)
506
+ }
507
+ return keep
508
+ }
509
+
510
+ /** Name and chips each take the full line — they do not compete for one row. */
511
+ export function packStackedRow(
512
+ name: string,
513
+ chips: Chip[],
514
+ lineMax: number,
515
+ ): { name: string; chips: Chip[] } {
516
+ const room = Math.max(4, lineMax - 2)
517
+ return { name: clipMiddleWidth(name, room), chips: packChips(0, chips, room) }
518
+ }
519
+
520
+ export function toolMark(status: string): AgentMark {
521
+ const c = normalizeStatus(status)
522
+ if (c === STATUS_RUNNING) return PULSE_LIVE
523
+ if (c === STATUS_ERROR) return STATUS_ERROR
524
+ if (c === STATUS_COMPLETED) return MARK_READY
525
+ return MARK_QUEUED
526
+ }
527
+
528
+ export function toolFlow(status: string): FlowDir | null {
529
+ return status === STATUS_RUNNING ? FLOW_TOOL : null
530
+ }
531
+
532
+ export type ToolHit = {
533
+ sessionId: string | null
534
+ id: string
535
+ name: string
536
+ status: ToolStatus
537
+ }
538
+
539
+ function firstHint(
540
+ tool: string,
541
+ parts: Array<string | null | undefined>,
542
+ maxLen = Infinity,
543
+ ): string {
544
+ const skip = tool.toLowerCase()
545
+ for (const p of parts) {
546
+ let t = (p || "").replace(/\s+/g, " ").trim()
547
+ if (!t) continue
548
+ t = t.replace(/\s*\(@[^)]*subagent\)\s*$/i, "").trim()
549
+ if (!t || t.toLowerCase() === skip) continue
550
+ // Task `description` is 3–5 words. A long hit is almost certainly prompt text.
551
+ if (t.length > maxLen) continue
552
+ return t
553
+ }
554
+ return ""
555
+ }
556
+
557
+ /** Command / file / task-description hint — never prompt or output bodies. */
558
+ export function shortToolLabel(opts: {
559
+ tool: string
560
+ title?: string | null
561
+ command?: string | null
562
+ filePath?: string | null
563
+ pattern?: string | null
564
+ description?: string | null
565
+ subagent?: string | null
566
+ /** Wider clip for logs; the panel keeps the default. */
567
+ maxHint?: number
568
+ }): string {
569
+ const tool = (opts.tool || "tool").trim() || "tool"
570
+ const max = opts.maxHint ?? 22
571
+ const fileMax = max > 22 ? 32 : 14
572
+ const patMax = max > 22 ? 40 : 12
573
+ if (opts.filePath) return `${tool} ${clipWidth(basenameOf(opts.filePath), fileMax)}`.trim()
574
+ if (opts.pattern) return `${tool} ${clipWidth(opts.pattern, patMax)}`.trim()
575
+ const raw =
576
+ firstHint(tool, [opts.description, opts.title], 80) ||
577
+ firstHint(tool, [opts.command]) ||
578
+ firstHint(tool, [opts.subagent], 80)
579
+ const stripped = raw.replace(/^cd\s+\S+\s*(?:&&|;)\s*/i, "")
580
+ if (max <= 22) {
581
+ const fileish = stripped.match(/(?:^|[\s/\\])((?:[\w.-]+[/\\])*[\w.-]+\.[a-z0-9]{1,8})\b/i)
582
+ if (fileish?.[1]) return clipWidth(basenameOf(fileish[1]), 20)
583
+ const bin = stripped.match(/\b(phpstan|rector|git|composer|npm|bun|uvx|graphify)\b/i)
584
+ if (bin?.[1]) {
585
+ const i = stripped.toLowerCase().indexOf(bin[1].toLowerCase())
586
+ return clipWidth(stripped.slice(i), max)
587
+ }
588
+ }
589
+ if (stripped) return clipWidth(stripped, max)
590
+ return tool
591
+ }
592
+
593
+ /** A later live event may only have the bare tool name — keep the specific label. */
594
+ export function preferToolLabel(next: string, prev?: string | null): string {
595
+ const a = (next || "").trim()
596
+ const b = (prev || "").trim()
597
+ if (!a) return b
598
+ if (!b) return a
599
+ const bare = (s: string) => {
600
+ const t = s.toLowerCase()
601
+ return t === TOOL_TASK || t === "tool" || t === TOOL_BASH || t === "unknown"
602
+ }
603
+ return bare(a) && !bare(b) ? b : a
604
+ }
605
+
606
+ function eventBags(evt: unknown): Record<string, unknown>[] {
607
+ if (!evt || typeof evt !== "object") return []
608
+ const o = evt as Record<string, unknown>
609
+ const bags: Record<string, unknown>[] = []
610
+ if (o.part && typeof o.part === "object") bags.push(o.part as Record<string, unknown>)
611
+ if (o.properties && typeof o.properties === "object") {
612
+ const props = o.properties as Record<string, unknown>
613
+ bags.push(props)
614
+ if (props.part && typeof props.part === "object") {
615
+ bags.push(props.part as Record<string, unknown>)
616
+ }
617
+ }
618
+ bags.push(o)
619
+ return bags
620
+ }
621
+
622
+ function toolNameFromEvent(evt: unknown): string | null {
623
+ const bags = eventBags(evt)
624
+ let tool = ""
625
+ let title: string | null = null
626
+ let command: string | null = null
627
+ let filePath: string | null = null
628
+ let pattern: string | null = null
629
+ let description: string | null = null
630
+ let subagent: string | null = null
631
+ for (const bag of bags) {
632
+ if (!tool && typeof bag.tool === "string" && bag.tool.trim()) tool = bag.tool.trim()
633
+ const state = bag.state && typeof bag.state === "object" ? (bag.state as Record<string, unknown>) : null
634
+ const input =
635
+ (state?.input && typeof state.input === "object" ? state.input : null) ||
636
+ (bag.input && typeof bag.input === "object" ? bag.input : null)
637
+ const inp = input as Record<string, unknown> | null
638
+ if (!title && typeof state?.title === "string") title = state.title
639
+ if (!command && typeof inp?.command === "string") command = inp.command
640
+ if (!filePath && typeof inp?.filePath === "string") filePath = inp.filePath
641
+ if (!pattern && typeof inp?.pattern === "string") pattern = inp.pattern
642
+ if (!description && typeof inp?.description === "string") description = inp.description
643
+ if (!subagent && typeof inp?.subagent_type === "string") subagent = inp.subagent_type
644
+ if (!subagent && typeof inp?.category === "string") subagent = inp.category
645
+ }
646
+ if (!tool && !title && !command && !filePath && !description) return null
647
+ return shortToolLabel({
648
+ tool: tool || "tool",
649
+ title,
650
+ command,
651
+ filePath,
652
+ pattern,
653
+ description,
654
+ subagent,
655
+ })
656
+ }
657
+
658
+ function toolIdFromEvent(evt: unknown): string | null {
659
+ for (const bag of eventBags(evt)) {
660
+ for (const key of ["callID", "callId", "toolCallId"]) {
661
+ const x = bag[key]
662
+ if (typeof x === "string" && x.trim()) return x.trim()
663
+ }
664
+ const id = bag.id
665
+ if (typeof id === "string" && (id.startsWith("prt_") || id.startsWith("call_"))) return id
666
+ }
667
+ return null
668
+ }
669
+
670
+ function toolStatusFromEvent(evt: unknown): ToolHit["status"] | null {
671
+ const type = eventType(evt)
672
+ if (type.includes(EVENT_TOOL_FAILED)) return TOOL_STATUS_ERROR
673
+ if (type.includes(EVENT_TOOL_SUCCESS) || type.includes(EVENT_TOOL_ENDED)) return TOOL_STATUS_COMPLETED
674
+ if (type.includes(EVENT_TOOL_CALLED)) return TOOL_STATUS_RUNNING
675
+ for (const bag of eventBags(evt)) {
676
+ const state = bag.state && typeof bag.state === "object" ? (bag.state as Record<string, unknown>) : null
677
+ const mapped = toToolStatus(String(state?.status ?? bag.status ?? ""))
678
+ if (mapped === TOOL_STATUS_ERROR || mapped === TOOL_STATUS_COMPLETED || mapped === TOOL_STATUS_RUNNING) return mapped
679
+ }
680
+ if (type.includes(EVENT_MESSAGE_PART_UPDATED) && partKind(evt).includes("tool")) return null
681
+ return null
682
+ }
683
+
684
+ /** Name + status only. Label hints (file/command/description), never prompt or output. */
685
+ export function toolHitFromEvent(evt: unknown): ToolHit | null {
686
+ const status = toolStatusFromEvent(evt)
687
+ if (!status) return null
688
+ const id = toolIdFromEvent(evt)
689
+ if (!id) return null
690
+ const name = toolNameFromEvent(evt)
691
+ if (!name) return null
692
+ return { sessionId: sessionIdFromEvent(evt), id, name, status }
693
+ }
694
+
695
+ /** OpenCode session.cost (USD). Empty when zero / missing. */
696
+ export function formatUsd(n: number | null | undefined): string {
697
+ if (n == null || !Number.isFinite(n) || n <= 0) return ""
698
+ if (n < 0.01) return `$${n.toFixed(3)}`
699
+ if (n < 10) return `$${n.toFixed(2)}`
700
+ return `$${n.toFixed(0)}`
701
+ }
702
+
703
+ export function sessionIdFromEvent(evt: unknown): string | null {
704
+ if (!evt || typeof evt !== "object") return null
705
+ const walk = (v: unknown, depth: number): string | null => {
706
+ if (!v || typeof v !== "object" || depth > 3) return null
707
+ const o = v as Record<string, unknown>
708
+ for (const key of ["sessionID", "sessionId", "session_id"]) {
709
+ const x = o[key]
710
+ if (typeof x === "string" && x.trim()) return x.trim()
711
+ }
712
+ for (const nest of ["info", "properties", "session", "payload"]) {
713
+ const found = walk(o[nest], depth + 1)
714
+ if (found) return found
715
+ }
716
+ return null
717
+ }
718
+ return walk(evt, 0)
719
+ }
720
+
721
+ export function stripSessionPrefix(id: string | null | undefined): string | null {
722
+ if (!id || typeof id !== "string") return null
723
+ const s = id.trim()
724
+ if (!s) return null
725
+ return s.startsWith("opencode:") ? s.slice("opencode:".length) : s
726
+ }