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.
- package/.oesignore +17 -0
- package/CHANGELOG.md +95 -0
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/oes.json +11 -0
- package/package.json +100 -0
- package/scripts/install-git-hooks.mjs +29 -0
- package/src/pware.oc.core/constants/index.ts +18 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
- package/src/pware.oc.core/git/index.ts +7 -0
- package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
- package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
- package/src/pware.oc.core/index.ts +18 -0
- package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
- package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
- package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
- package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
- package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
- package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
- package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
- package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
- package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
- package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
- package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
- package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
- package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
- package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
- package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
- package/src/pware.oc.omo/constants/index.ts +15 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
- package/src/pware.oc.omo/index.ts +7 -0
- package/src/pware.oc.omo/resolver/index.ts +19 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
- package/src/pware.oc.opencode/constants/index.ts +10 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
- package/src/pware.oc.opencode/index.ts +8 -0
- package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
- package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
- package/src/pware.oc.opencode/resolver/index.ts +170 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
- package/src/pware.oc.perf/index.ts +14 -0
- package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
- package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
- package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
- package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
- package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
- package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
- package/src/pware.oc.runtime/index.ts +13 -0
- package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
- package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
- package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
- package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
- package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
- package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
- package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
- package/src/pware.oc.runtime/resolver/index.ts +224 -0
- package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
- package/src/pware.oc.ui/index.ts +12 -0
- package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
- package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
- package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
- package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
- package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
- package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
- package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
- 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
|
+
}
|