rtrt-agent 0.1.7 → 0.2.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.
@@ -0,0 +1,1145 @@
1
+ import { spawn as nodeSpawn } from "node:child_process"
2
+ import { performance } from "node:perf_hooks"
3
+
4
+ export const STATUSLINE_VERSION = 1
5
+ export const STATUSLINE_SEPARATOR = " · "
6
+ export const STATUSLINE_FALLBACK = "rtrt · n/a"
7
+ export const STATUSLINE_GROUP_SEPARATOR = " | "
8
+ export const STATUSLINE_WIDE_WIDTH = 100
9
+ export const STATUSLINE_MEDIUM_WIDTH = 70
10
+ export const DEFAULT_DEBOUNCE_MS = 750
11
+ export const DEFAULT_INTERVAL_MS = 15_000
12
+ export const DEFAULT_TIMEOUT_MS = 1_500
13
+ export const STATUSLINE_REFRESH_EVENTS = Object.freeze([
14
+ "server.connected",
15
+ "project.updated",
16
+ "session.created",
17
+ "session.updated",
18
+ "session.compacted",
19
+ "session.deleted",
20
+ "session.diff",
21
+ "session.error",
22
+ "session.idle",
23
+ "session.status",
24
+ ])
25
+
26
+ const MAX_OUTPUT_BYTES = 64 * 1024
27
+ const MAX_SEGMENTS = 32
28
+ const MAX_SEGMENT_LENGTH = 256
29
+ const CONTROL_CHAR = /[\u0000-\u001f\u007f-\u009f]/u
30
+ const CLI_TONES = new Set(["accent", "good", "warn", "bad", "muted"])
31
+ const monotonicNow = () => performance.now()
32
+
33
+ const fallbackPayload = Object.freeze({
34
+ version: STATUSLINE_VERSION,
35
+ stale: true,
36
+ degraded: Object.freeze(["statusline"]),
37
+ segments: Object.freeze([
38
+ Object.freeze({
39
+ id: "fallback",
40
+ text: STATUSLINE_FALLBACK,
41
+ compact: STATUSLINE_FALLBACK,
42
+ tone: "muted",
43
+ priority: 0,
44
+ }),
45
+ ]),
46
+ })
47
+
48
+ const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value)
49
+
50
+ const validText = (value, allowEmpty = false) => {
51
+ if (typeof value !== "string" || value.length > MAX_SEGMENT_LENGTH || CONTROL_CHAR.test(value)) return undefined
52
+ const text = value.trim()
53
+ if (!allowEmpty && !text) return undefined
54
+ return text
55
+ }
56
+
57
+ const eventSessionID = (event) => {
58
+ if (!isRecord(event) || !isRecord(event.properties)) return undefined
59
+ if (typeof event.properties.sessionID === "string") return event.properties.sessionID
60
+ for (const key of ["part", "message", "info"]) {
61
+ const child = event.properties[key]
62
+ if (!isRecord(child)) continue
63
+ if (typeof child.sessionID === "string") return child.sessionID
64
+ if (key === "info" && typeof event.type === "string" && event.type.startsWith("session.")) {
65
+ if (typeof child.id === "string") return child.id
66
+ }
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ export function statuslineRefreshTarget(event, activeSessionID = "") {
72
+ const sessionID = eventSessionID(event)
73
+ if (!sessionID) return { app: true }
74
+ return { app: sessionID === activeSessionID, sessionID }
75
+ }
76
+
77
+ const hasControlChars = (value) => {
78
+ const pending = [value]
79
+ const seen = new Set()
80
+ while (pending.length) {
81
+ const item = pending.pop()
82
+ if (typeof item === "string") {
83
+ if (CONTROL_CHAR.test(item)) return true
84
+ continue
85
+ }
86
+ if (!item || typeof item !== "object" || seen.has(item)) continue
87
+ seen.add(item)
88
+ if (Array.isArray(item)) {
89
+ pending.push(...item)
90
+ continue
91
+ }
92
+ for (const [key, child] of Object.entries(item)) {
93
+ if (CONTROL_CHAR.test(key)) return true
94
+ pending.push(child)
95
+ }
96
+ }
97
+ return false
98
+ }
99
+
100
+ const normalizeSegment = (segment) => {
101
+ if (!isRecord(segment)) return undefined
102
+
103
+ const id = validText(segment.id)
104
+ const text = validText(segment.text)
105
+ const sourceTone = validText(segment.tone)
106
+ const tone = sourceTone && CLI_TONES.has(sourceTone) ? sourceTone : "muted"
107
+ const priority = segment.pri
108
+ if (!id || !text || !sourceTone || !Number.isSafeInteger(priority) || priority < 0 || priority > 255) {
109
+ return undefined
110
+ }
111
+ return { id, text, compact: text, tone, priority }
112
+ }
113
+
114
+ export function parseStatuslineJson(raw) {
115
+ if (typeof raw !== "string" || !raw.trim() || Buffer.byteLength(raw) > MAX_OUTPUT_BYTES) return undefined
116
+ try {
117
+ const value = JSON.parse(raw)
118
+ if (!isRecord(value) || value.v !== STATUSLINE_VERSION || hasControlChars(value)) return undefined
119
+ if (value.stale !== undefined && typeof value.stale !== "boolean") return undefined
120
+ if (
121
+ value.degraded !== undefined &&
122
+ (!Array.isArray(value.degraded) ||
123
+ value.degraded.length > MAX_SEGMENTS ||
124
+ value.degraded.some((item) => !validText(item)))
125
+ ) {
126
+ return undefined
127
+ }
128
+ if (!Array.isArray(value.segments) || value.segments.length < 1 || value.segments.length > MAX_SEGMENTS) {
129
+ return undefined
130
+ }
131
+ const segments = value.segments.map(normalizeSegment)
132
+ if (segments.some((segment) => !segment)) return undefined
133
+ return {
134
+ version: STATUSLINE_VERSION,
135
+ stale: value.stale === true,
136
+ degraded: value.degraded?.map((item) => item.trim()) ?? [],
137
+ segments,
138
+ }
139
+ } catch {
140
+ return undefined
141
+ }
142
+ }
143
+
144
+ const normalizedWidth = (width) => {
145
+ if (!Number.isFinite(width)) return 80
146
+ return Math.max(1, Math.min(4_096, Math.floor(width)))
147
+ }
148
+
149
+ export function statuslineCommandArgs({ cwd, session, width, model }) {
150
+ const args = [
151
+ "statusline",
152
+ "--opencode",
153
+ "--cwd",
154
+ String(cwd ?? ""),
155
+ "--session",
156
+ String(session ?? ""),
157
+ "--width",
158
+ String(normalizedWidth(width)),
159
+ ]
160
+ const selectedModel = validText(model)
161
+ if (selectedModel) args.push("--model", selectedModel)
162
+ return args
163
+ }
164
+
165
+ export function statuslineBinaryCandidates(bin, env = process.env) {
166
+ const values = [bin, env?.RTRT_BIN, "rtrt"]
167
+ const seen = new Set()
168
+ return values.flatMap((value) => {
169
+ const candidate = validText(value)
170
+ if (!candidate || seen.has(candidate)) return []
171
+ seen.add(candidate)
172
+ return [candidate]
173
+ })
174
+ }
175
+
176
+ const runCandidate = ({
177
+ binary,
178
+ args,
179
+ cwd,
180
+ spawnImpl,
181
+ timeoutMs,
182
+ signal,
183
+ setTimeoutFn,
184
+ clearTimeoutFn,
185
+ }) =>
186
+ new Promise((resolve) => {
187
+ let child
188
+ let settled = false
189
+ let bytes = 0
190
+ const chunks = []
191
+ let timeout
192
+ let abort
193
+
194
+ const finish = (payload) => {
195
+ if (settled) return
196
+ settled = true
197
+ clearTimeoutFn(timeout)
198
+ if (abort) signal?.removeEventListener("abort", abort)
199
+ resolve(payload)
200
+ }
201
+
202
+ if (signal?.aborted) {
203
+ finish(undefined)
204
+ return
205
+ }
206
+
207
+ try {
208
+ child = spawnImpl(binary, args, {
209
+ cwd: cwd || undefined,
210
+ shell: false,
211
+ stdio: ["ignore", "pipe", "ignore"],
212
+ windowsHide: true,
213
+ })
214
+ } catch {
215
+ finish(undefined)
216
+ return
217
+ }
218
+
219
+ if (!child?.stdout || typeof child.once !== "function") {
220
+ try {
221
+ child?.kill?.()
222
+ } catch {}
223
+ finish(undefined)
224
+ return
225
+ }
226
+
227
+ child.stdout.on("data", (chunk) => {
228
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
229
+ bytes += data.length
230
+ if (bytes > MAX_OUTPUT_BYTES) {
231
+ try {
232
+ child.kill("SIGKILL")
233
+ } catch {}
234
+ finish(undefined)
235
+ return
236
+ }
237
+ chunks.push(data)
238
+ })
239
+ child.once("error", () => finish(undefined))
240
+ child.once("close", (code) => {
241
+ if (code !== 0) {
242
+ finish(undefined)
243
+ return
244
+ }
245
+ finish(parseStatuslineJson(Buffer.concat(chunks).toString("utf8")))
246
+ })
247
+
248
+ abort = () => {
249
+ try {
250
+ child.kill("SIGKILL")
251
+ } catch {}
252
+ finish(undefined)
253
+ }
254
+ signal?.addEventListener("abort", abort, { once: true })
255
+
256
+ timeout = setTimeoutFn(() => {
257
+ try {
258
+ child.kill("SIGKILL")
259
+ } catch {}
260
+ finish(undefined)
261
+ }, Math.max(1, timeoutMs))
262
+ timeout.unref?.()
263
+ })
264
+
265
+ export async function runStatusline({
266
+ bin,
267
+ env = process.env,
268
+ cwd,
269
+ session,
270
+ width,
271
+ model,
272
+ spawnImpl = nodeSpawn,
273
+ timeoutMs = DEFAULT_TIMEOUT_MS,
274
+ signal,
275
+ nowFn = monotonicNow,
276
+ setTimeoutFn = setTimeout,
277
+ clearTimeoutFn = clearTimeout,
278
+ }) {
279
+ const args = statuslineCommandArgs({ cwd, session, width, model })
280
+ const duration = Number.isFinite(timeoutMs) ? Math.max(1, timeoutMs) : DEFAULT_TIMEOUT_MS
281
+ const deadline = nowFn() + duration
282
+ for (const binary of statuslineBinaryCandidates(bin, env)) {
283
+ if (signal?.aborted) return undefined
284
+ const remaining = deadline - nowFn()
285
+ if (remaining <= 0) return undefined
286
+ try {
287
+ const payload = await runCandidate({
288
+ binary,
289
+ args,
290
+ cwd,
291
+ spawnImpl,
292
+ timeoutMs: Math.ceil(remaining),
293
+ signal,
294
+ setTimeoutFn,
295
+ clearTimeoutFn,
296
+ })
297
+ if (payload) return payload
298
+ } catch {}
299
+ }
300
+ return undefined
301
+ }
302
+
303
+ export function createSpawnLimiter({ concurrency = 2 } = {}) {
304
+ const limit = Number.isSafeInteger(concurrency) ? Math.max(1, Math.min(16, concurrency)) : 2
305
+ const queue = []
306
+ const running = new Set()
307
+ let active = 0
308
+ let disposed = false
309
+
310
+ const cleanup = (entry) => entry.signal?.removeEventListener("abort", entry.abort)
311
+
312
+ const finish = (entry, value) => {
313
+ if (entry.finished) return
314
+ entry.finished = true
315
+ cleanup(entry)
316
+ if (entry.started) {
317
+ active -= 1
318
+ running.delete(entry)
319
+ }
320
+ entry.resolve(value)
321
+ pump()
322
+ }
323
+
324
+ const start = (entry) => {
325
+ entry.started = true
326
+ active += 1
327
+ running.add(entry)
328
+ void (async () => {
329
+ let value
330
+ try {
331
+ value = await entry.task(entry.controller.signal)
332
+ } catch {}
333
+ finish(entry, value)
334
+ })()
335
+ }
336
+
337
+ const pump = () => {
338
+ while (!disposed && active < limit && queue.length > 0) {
339
+ let selected = 0
340
+ for (let index = 1; index < queue.length; index += 1) {
341
+ if (queue[index].priority > queue[selected].priority) selected = index
342
+ }
343
+ const [entry] = queue.splice(selected, 1)
344
+ if (!entry.finished) start(entry)
345
+ }
346
+ }
347
+
348
+ return {
349
+ run(task, { signal, priority = 0 } = {}) {
350
+ if (disposed || signal?.aborted) return Promise.resolve(undefined)
351
+ return new Promise((resolve) => {
352
+ const entry = {
353
+ task,
354
+ signal,
355
+ priority: Number.isFinite(priority) ? priority : 0,
356
+ controller: new AbortController(),
357
+ resolve,
358
+ started: false,
359
+ finished: false,
360
+ abort: undefined,
361
+ }
362
+ entry.abort = () => {
363
+ entry.controller.abort()
364
+ if (entry.started) return
365
+ const index = queue.indexOf(entry)
366
+ if (index >= 0) queue.splice(index, 1)
367
+ finish(entry, undefined)
368
+ }
369
+ signal?.addEventListener("abort", entry.abort, { once: true })
370
+ queue.push(entry)
371
+ pump()
372
+ })
373
+ },
374
+ activeCount: () => active,
375
+ queuedCount: () => queue.length,
376
+ dispose() {
377
+ if (disposed) return
378
+ disposed = true
379
+ for (const entry of queue.splice(0)) {
380
+ entry.controller.abort()
381
+ finish(entry, undefined)
382
+ }
383
+ for (const entry of running) entry.controller.abort()
384
+ },
385
+ }
386
+ }
387
+
388
+ const isWideCodePoint = (code) =>
389
+ code >= 0x1100 &&
390
+ (code <= 0x115f ||
391
+ code === 0x2329 ||
392
+ code === 0x232a ||
393
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
394
+ (code >= 0xac00 && code <= 0xd7a3) ||
395
+ (code >= 0xf900 && code <= 0xfaff) ||
396
+ (code >= 0xfe10 && code <= 0xfe19) ||
397
+ (code >= 0xfe30 && code <= 0xfe6f) ||
398
+ (code >= 0xff00 && code <= 0xff60) ||
399
+ (code >= 0xffe0 && code <= 0xffe6) ||
400
+ (code >= 0x1f300 && code <= 0x1faff) ||
401
+ (code >= 0x20000 && code <= 0x3fffd))
402
+
403
+ export function cellWidth(text) {
404
+ let width = 0
405
+ for (const character of text) {
406
+ if (/\p{Mark}/u.test(character) || character === "\u200d" || character === "\ufe0f") continue
407
+ width += isWideCodePoint(character.codePointAt(0)) ? 2 : 1
408
+ }
409
+ return width
410
+ }
411
+
412
+ const truncateToWidth = (text, width) => {
413
+ if (cellWidth(text) <= width) return text
414
+ if (width <= 1) return "…".slice(0, width)
415
+ let output = ""
416
+ let used = 0
417
+ for (const character of text) {
418
+ const size = cellWidth(character)
419
+ if (used + size > width - 1) break
420
+ output += character
421
+ used += size
422
+ }
423
+ return `${output}…`
424
+ }
425
+
426
+ const findCatalogModel = (providers, providerID, modelID) => {
427
+ if (!Array.isArray(providers) || typeof providerID !== "string" || typeof modelID !== "string") {
428
+ return undefined
429
+ }
430
+ const provider = providers.find((item) => item?.id === providerID)
431
+ if (!isRecord(provider?.models)) return undefined
432
+ const direct = provider.models[modelID]
433
+ if (isRecord(direct)) return direct
434
+ return Object.values(provider.models).find(
435
+ (model) => isRecord(model) && model.id === modelID && (!model.providerID || model.providerID === providerID),
436
+ )
437
+ }
438
+
439
+ const tokenCount = (value) => (Number.isFinite(value) && value > 0 ? value : 0)
440
+
441
+ const contextTone = (percent) => {
442
+ if (percent > 100) return "warn"
443
+ if (percent >= 90) return "bad"
444
+ if (percent >= 75) return "warn"
445
+ return "good"
446
+ }
447
+
448
+ export function deriveSessionEconomics({ session, messages = [], providers = [], status } = {}) {
449
+ let cost = { text: "N/A", tone: "muted" }
450
+ if (session?.cost === 0) {
451
+ cost = { text: "$0?", tone: "muted" }
452
+ } else if (Number.isFinite(session?.cost) && session.cost > 0) {
453
+ cost = { text: `~$${session.cost.toFixed(2)}`, tone: "accent" }
454
+ }
455
+
456
+ const selected = session?.model
457
+ const selectedModel = findCatalogModel(providers, selected?.providerID, selected?.id)
458
+ const selectedText = validText(selectedModel?.name) ?? validText(selected?.id)
459
+ const model = selectedText
460
+ ? { text: truncateToWidth(selectedText.split("/").at(-1), 22), tone: "accent" }
461
+ : { text: "N/A", tone: "muted" }
462
+ const selectedProviderID = validText(selected?.providerID)
463
+ const selectedModelID = validText(selected?.id)
464
+ const modelRef =
465
+ selectedProviderID && selectedModelID ? validText(`${selectedProviderID}/${selectedModelID}`) : undefined
466
+
467
+ let context = { text: "N/A", tone: "muted" }
468
+ if (Array.isArray(messages)) {
469
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
470
+ const message = messages[index]
471
+ if (message?.role !== "assistant") continue
472
+ const tokens = message.tokens
473
+ if (message.error || !Number.isFinite(message.time?.completed) || tokenCount(tokens?.output) <= 0) {
474
+ continue
475
+ }
476
+ const used =
477
+ tokenCount(tokens?.input) +
478
+ tokenCount(tokens?.output) +
479
+ tokenCount(tokens?.reasoning) +
480
+ tokenCount(tokens?.cache?.read) +
481
+ tokenCount(tokens?.cache?.write)
482
+ if (used <= 0) continue
483
+ const exactModel = findCatalogModel(providers, message.providerID, message.modelID)
484
+ const limit = exactModel?.limit?.context
485
+ if (Number.isFinite(limit) && limit > 0) {
486
+ const percent = Math.max(0, Math.round((used / limit) * 100))
487
+ const headroom = Math.max(0, 100 - percent)
488
+ context = { text: `${percent}%`, tone: contextTone(percent), used, limit, percent, headroom }
489
+ } else {
490
+ context = { text: "N/A", tone: "muted", used }
491
+ }
492
+ break
493
+ }
494
+ }
495
+
496
+ // OpenCode omits idle sessions from the status map.
497
+ const sessionStatus = session
498
+ ? ({
499
+ busy: { text: "BUSY", tone: "accent" },
500
+ retry: { text: "RETRY", tone: "bad" },
501
+ idle: { text: "IDLE", tone: "muted" },
502
+ }[status?.type] ?? { text: "IDLE", tone: "muted" })
503
+ : { text: "UNKNOWN", tone: "muted" }
504
+
505
+ return {
506
+ cost,
507
+ context,
508
+ model,
509
+ status: sessionStatus,
510
+ week: { text: "N/A/not exposed", tone: "muted" },
511
+ modelRef,
512
+ }
513
+ }
514
+
515
+ const renderedWidth = (segments) =>
516
+ segments.reduce((sum, segment) => sum + cellWidth(segment.text), 0) +
517
+ Math.max(0, segments.length - 1) * cellWidth(STATUSLINE_SEPARATOR)
518
+
519
+ export function selectSegments(payload, { width = 80, compact = false } = {}) {
520
+ const limit = normalizedWidth(width)
521
+ let selected = payload.segments.flatMap((segment, index) => {
522
+ const text = compact ? segment.compact : segment.text
523
+ return text ? [{ ...segment, text, index }] : []
524
+ })
525
+ while (selected.length > 1 && renderedWidth(selected) > limit) {
526
+ let remove = 0
527
+ for (let index = 1; index < selected.length; index += 1) {
528
+ if (
529
+ selected[index].priority < selected[remove].priority ||
530
+ (selected[index].priority === selected[remove].priority && selected[index].index > selected[remove].index)
531
+ ) {
532
+ remove = index
533
+ }
534
+ }
535
+ selected.splice(remove, 1)
536
+ }
537
+ if (selected.length === 1) selected[0] = { ...selected[0], text: truncateToWidth(selected[0].text, limit) }
538
+ return selected.map(({ index: _index, ...segment }) => segment)
539
+ }
540
+
541
+ export function initialStatuslineState() {
542
+ return { payload: fallbackPayload, lastGood: undefined, stale: true, unavailable: true }
543
+ }
544
+
545
+ export function nextStatuslineState(previous, payload) {
546
+ if (payload) return { payload, lastGood: payload, stale: payload.stale === true, unavailable: false }
547
+ if (previous.lastGood) {
548
+ return { payload: previous.lastGood, lastGood: previous.lastGood, stale: true, unavailable: false }
549
+ }
550
+ return initialStatuslineState()
551
+ }
552
+
553
+ export function renderSegments(state, options = {}) {
554
+ const payload =
555
+ state.stale && !state.unavailable
556
+ ? {
557
+ version: STATUSLINE_VERSION,
558
+ stale: true,
559
+ segments: [
560
+ ...state.payload.segments,
561
+ { id: "stale", text: "stale", compact: "stale", tone: "muted", priority: 1_000 },
562
+ ],
563
+ }
564
+ : state.payload
565
+ const segments = selectSegments(payload, options)
566
+ return state.stale ? segments.map((segment) => ({ ...segment, tone: "muted" })) : segments
567
+ }
568
+
569
+ const viewPart = (text, role, tone, id) => ({ text, role, tone, id })
570
+
571
+ const valueGroup = (id, text, tone = "muted", role = "value") => ({
572
+ id,
573
+ parts: [viewPart(text, role, tone, id)],
574
+ })
575
+
576
+ const metricGroup = (id, label, text, tone) => ({
577
+ id,
578
+ parts: [
579
+ viewPart(`${label} `, "label", "muted", id),
580
+ viewPart(text, "value", tone, id),
581
+ ],
582
+ })
583
+
584
+ const groupText = (group) => group.parts.map((part) => part.text).join("")
585
+ const groupsWidth = (groups, separator) =>
586
+ groups.reduce((sum, group) => sum + cellWidth(groupText(group)), 0) +
587
+ Math.max(0, groups.length - 1) * cellWidth(separator)
588
+
589
+ const rowFromGroups = (id, groups, separator) => {
590
+ const parts = []
591
+ for (const [index, group] of groups.entries()) {
592
+ if (index > 0) parts.push(viewPart(separator, "separator", "muted", "separator"))
593
+ parts.push(...group.parts)
594
+ }
595
+ return { id, parts }
596
+ }
597
+
598
+ const sourceTone = (state, segment) => (state.stale ? "muted" : segment?.tone ?? "muted")
599
+
600
+ const segmentValue = (segments, id, prefix = "") => {
601
+ const segment = segments.get(id)
602
+ if (!segment) return undefined
603
+ const text = prefix && segment.text.startsWith(prefix) ? segment.text.slice(prefix.length) : segment.text
604
+ return text ? { text, segment } : undefined
605
+ }
606
+
607
+ const savingsValues = (segments) => {
608
+ const savings = segments.get("savings")
609
+ if (savings) {
610
+ const save = /(?:^|\s)save:([^\s]+)/u.exec(savings.text)?.[1]
611
+ const command = /(?:^|\s)cmd:([^\s]+)/u.exec(savings.text)?.[1]
612
+ return {
613
+ save: save ? { text: save, segment: savings } : undefined,
614
+ command: command ? { text: command, segment: savings } : undefined,
615
+ }
616
+ }
617
+ const sigma = segments.get("sigma")
618
+ if (!sigma) return {}
619
+ const text = sigma.text.replace(/^(?:Σ|sigma):?/iu, "")
620
+ return text ? { save: { text, segment: sigma } } : {}
621
+ }
622
+
623
+ const headroomValues = (segments) => {
624
+ const value = segmentValue(segments, "headroom", "room:")
625
+ if (!value) return undefined
626
+ const boundary = value.text.lastIndexOf(":")
627
+ if (boundary < 0) return { full: value.text, compact: value.text, segment: value.segment }
628
+ const target = value.text.slice(0, boundary)
629
+ const amount = value.text.slice(boundary + 1)
630
+ return {
631
+ full: target && amount ? `${target} ${amount}` : value.text,
632
+ compact: amount || value.text,
633
+ segment: value.segment,
634
+ }
635
+ }
636
+
637
+ const reportedPercentToken = (text) => {
638
+ const match = /\d+(?:\.\d+)?%/u.exec(text)
639
+ if (!match) return undefined
640
+ const before = match.index > 0 ? text[match.index - 1] : ""
641
+ const after = text[match.index + match[0].length] ?? ""
642
+ if (
643
+ (before && /[\p{Letter}\p{Number}_.+-]/u.test(before)) ||
644
+ (after && /[\p{Letter}\p{Number}_.%]/u.test(after))
645
+ ) {
646
+ return undefined
647
+ }
648
+ const value = Number(match[0].slice(0, -1))
649
+ if (!Number.isFinite(value) || value < 0 || value > 100) return undefined
650
+ return { text: match[0], index: match.index }
651
+ }
652
+
653
+ const officialLimitValue = (segments, id) => {
654
+ const segment = segments.get(id)
655
+ if (!segment) return undefined
656
+ const percentToken = reportedPercentToken(segment.text)
657
+ if (!percentToken) return undefined
658
+ const percent = percentToken.text
659
+ let reset = segment.text.slice(percentToken.index + percent.length).trim()
660
+ reset = reset
661
+ .replace(/^[|,;/-]\s*/u, "")
662
+ .replace(/^(?:reset(?:s|_at)?|r)\s*[:=]?\s*/iu, "")
663
+ .replace(/^(?:in\s+|↻\s*|@\s*)/iu, "")
664
+ .replace(/\s+/gu, "")
665
+ return {
666
+ full: reset ? `${percent}/${reset}` : percent,
667
+ percent,
668
+ segment,
669
+ }
670
+ }
671
+
672
+ const statusGroups = (state, missing) => {
673
+ const groups = []
674
+ if (state.stale && !state.unavailable) groups.push(valueGroup("stale", "STALE", "warn"))
675
+ if (state.unavailable || missing || (state.payload.degraded?.length ?? 0) > 0) {
676
+ groups.push(valueGroup("degraded", "DEGRADED", "bad"))
677
+ }
678
+ if (groups.length === 0) groups.push(valueGroup("live", "LIVE", "good"))
679
+ return groups
680
+ }
681
+
682
+ const replaceGroupText = (groups, id, text) =>
683
+ groups.map((group) => {
684
+ if (group.id !== id) return group
685
+ const last = group.parts.length - 1
686
+ return {
687
+ ...group,
688
+ parts: group.parts.map((part, index) => (index === last ? { ...part, text } : part)),
689
+ }
690
+ })
691
+
692
+ const removeGroup = (groups, id) => groups.filter((group) => group.id !== id)
693
+
694
+ const capGroupValues = (source, limits) => {
695
+ let groups = source
696
+ for (const [id, limit] of limits) {
697
+ const group = groups.find((item) => item.id === id)
698
+ if (!group) continue
699
+ const value = group.parts.at(-1)?.text ?? ""
700
+ groups = replaceGroupText(groups, id, truncateToWidth(value, limit))
701
+ }
702
+ return groups
703
+ }
704
+
705
+ const fitWideIdentityGroups = (source, width) => {
706
+ let groups = capGroupValues(source, [
707
+ ["project", 36],
708
+ ["git", 24],
709
+ ["model", 22],
710
+ ])
711
+ if (groupsWidth(groups, " ") > width) groups = removeGroup(groups, "git")
712
+ const project = groups.find((group) => group.id === "project")
713
+ if (project && groupsWidth(groups, " ") > width) {
714
+ const overflow = groupsWidth(groups, " ") - width
715
+ const current = groupText(project)
716
+ groups = replaceGroupText(groups, "project", truncateToWidth(current, Math.max(4, cellWidth(current) - overflow)))
717
+ }
718
+ if (groupsWidth(groups, " ") > width) groups = removeGroup(groups, "project")
719
+ return groups
720
+ }
721
+
722
+ const fitWideMetricGroups = (source, width) => {
723
+ let groups = capGroupValues(source, [
724
+ ["style", 12],
725
+ ["savings", 8],
726
+ ["command", 8],
727
+ ["memory", 18],
728
+ ["headroom", 18],
729
+ ["cost", 12],
730
+ ["context", 5],
731
+ ["week", 15],
732
+ ["limit-5h", 12],
733
+ ["limit-week", 12],
734
+ ])
735
+ for (const id of ["headroom", "memory", "command", "savings", "style"]) {
736
+ if (groupsWidth(groups, STATUSLINE_GROUP_SEPARATOR) <= width) break
737
+ groups = removeGroup(groups, id)
738
+ }
739
+ return groups
740
+ }
741
+
742
+ const fitMediumGroups = (source, width) => {
743
+ let groups = capGroupValues(source, [
744
+ ["project", 18],
745
+ ["model", 16],
746
+ ["style", 12],
747
+ ["savings", 8],
748
+ ["command", 8],
749
+ ["headroom", 10],
750
+ ["cost", 12],
751
+ ["context", 5],
752
+ ["week", 15],
753
+ ["limit-5h", 12],
754
+ ["limit-week", 12],
755
+ ])
756
+ for (const id of ["week", "headroom", "command"]) {
757
+ if (groupsWidth(groups, STATUSLINE_GROUP_SEPARATOR) <= width) break
758
+ groups = removeGroup(groups, id)
759
+ }
760
+ const project = groups.find((group) => group.id === "project")
761
+ if (project && groupsWidth(groups, STATUSLINE_GROUP_SEPARATOR) > width) {
762
+ const overflow = groupsWidth(groups, STATUSLINE_GROUP_SEPARATOR) - width
763
+ const current = groupText(project)
764
+ groups = replaceGroupText(groups, "project", truncateToWidth(current, Math.max(4, cellWidth(current) - overflow)))
765
+ }
766
+ for (const id of ["model", "project", "style", "savings", "badge"]) {
767
+ if (groupsWidth(groups, STATUSLINE_GROUP_SEPARATOR) <= width) break
768
+ groups = removeGroup(groups, id)
769
+ }
770
+ if (groupsWidth(groups, STATUSLINE_GROUP_SEPARATOR) <= width) {
771
+ return { groups, separator: STATUSLINE_GROUP_SEPARATOR }
772
+ }
773
+
774
+ const fallback = []
775
+ for (const id of ["savings", "cost", "context", "session-status"]) {
776
+ const group = groups.find((item) => item.id === id)
777
+ const value = group?.parts.at(-1)
778
+ if (value) fallback.push(valueGroup(id, truncateToWidth(value.text, id === "cost" ? 12 : 8), value.tone))
779
+ }
780
+ for (const [id, label] of [
781
+ ["limit-5h", "5H"],
782
+ ["limit-week", "WEEK"],
783
+ ]) {
784
+ const group = groups.find((item) => item.id === id)
785
+ const value = group?.parts.at(-1)
786
+ if (!value) continue
787
+ fallback.push(metricGroup(id, label, truncateToWidth(value.text, 12), value.tone))
788
+ }
789
+ fallback.push(...groups.filter((group) => ["stale", "degraded", "live"].includes(group.id)))
790
+ return {
791
+ groups: fitNarrowGroups(fallback, width, ["context", "cost", "savings", "session-status", "limit-5h", "limit-week"]),
792
+ separator: " ",
793
+ }
794
+ }
795
+
796
+ const fitNarrowGroups = (source, width, removalOrder = ["cost", "context", "style", "savings"]) => {
797
+ let groups = source
798
+ for (const id of removalOrder) {
799
+ if (groupsWidth(groups, " ") <= width) break
800
+ groups = removeGroup(groups, id)
801
+ }
802
+ if (groups.length === 0) groups = [valueGroup("missing", "N/A", "bad")]
803
+ if (groupsWidth(groups, " ") <= width) return groups
804
+ const text = groups.map(groupText).join(" ")
805
+ return [valueGroup("status", truncateToWidth(text, width), "bad")]
806
+ }
807
+
808
+ export function createStatuslineViewModel(state, { width = 80, economics, promptRight = false } = {}) {
809
+ const limit = normalizedWidth(width)
810
+ const layout = limit >= STATUSLINE_WIDE_WIDTH ? "wide" : limit >= STATUSLINE_MEDIUM_WIDTH ? "medium" : "narrow"
811
+ const sessionEconomics = economics ?? deriveSessionEconomics()
812
+ const segments = new Map(state.payload.segments.map((segment) => [segment.id, segment]))
813
+ const project = segmentValue(segments, "project")
814
+ const git = segmentValue(segments, "git")
815
+ const style = segmentValue(segments, "style", "opt:")
816
+ const savings = savingsValues(segments)
817
+ const memory = segmentValue(segments, "memory", "mem:")
818
+ const headroom = headroomValues(segments)
819
+ const limitsFresh = !state.stale && !state.unavailable
820
+ const limit5h = limitsFresh ? officialLimitValue(segments, "limit_5h") : undefined
821
+ const limitWeek = limitsFresh ? officialLimitValue(segments, "limit_week") : undefined
822
+ const missing = state.unavailable || (!style && !savings.save)
823
+ const statuses = statusGroups(state, missing)
824
+
825
+ if (layout === "wide") {
826
+ const identity = [valueGroup("badge", "[RTRT]", "accent", "badge")]
827
+ if (state.unavailable) {
828
+ identity.push(valueGroup("missing", "N/A", "bad"))
829
+ } else {
830
+ if (project) identity.push(valueGroup("project", truncateToWidth(project.text, 36), sourceTone(state, project.segment)))
831
+ if (git) identity.push(valueGroup("git", truncateToWidth(git.text, 24), sourceTone(state, git.segment)))
832
+ }
833
+ identity.push(
834
+ metricGroup("model", "MODEL", sessionEconomics.model.text, sessionEconomics.model.tone),
835
+ metricGroup("session-status", "STATE", sessionEconomics.status.text, sessionEconomics.status.tone),
836
+ ...statuses,
837
+ )
838
+
839
+ const metrics = []
840
+ if (state.unavailable) {
841
+ for (const label of ["OPT", "SAVE", "CMD", "MEM", "ROOM"]) {
842
+ metrics.push(metricGroup(label.toLowerCase(), label, "N/A", "bad"))
843
+ }
844
+ } else {
845
+ if (style) metrics.push(metricGroup("style", "OPT", style.text, sourceTone(state, style.segment)))
846
+ if (savings.save) metrics.push(metricGroup("savings", "SAVE", savings.save.text, sourceTone(state, savings.save.segment)))
847
+ if (savings.command) metrics.push(metricGroup("command", "CMD", savings.command.text, sourceTone(state, savings.command.segment)))
848
+ if (memory) metrics.push(metricGroup("memory", "MEM", truncateToWidth(memory.text, 18), sourceTone(state, memory.segment)))
849
+ if (headroom) metrics.push(metricGroup("headroom", "ROOM", truncateToWidth(headroom.full, 28), sourceTone(state, headroom.segment)))
850
+ if (metrics.length === 0) metrics.push(metricGroup("metrics", "METRICS", "N/A", "bad"))
851
+ }
852
+ metrics.push(
853
+ metricGroup("cost", "COST", sessionEconomics.cost.text, sessionEconomics.cost.tone),
854
+ metricGroup("context", "CTX", sessionEconomics.context.text, sessionEconomics.context.tone),
855
+ )
856
+ if (limit5h) {
857
+ metrics.push(metricGroup("limit-5h", "5H", limit5h.full, sourceTone(state, limit5h.segment)))
858
+ }
859
+ metrics.push(
860
+ limitWeek
861
+ ? metricGroup("limit-week", "WEEK", limitWeek.full, sourceTone(state, limitWeek.segment))
862
+ : metricGroup("week", "WEEK", sessionEconomics.week.text, sessionEconomics.week.tone),
863
+ )
864
+ const fittedIdentity = fitWideIdentityGroups(identity, limit)
865
+ const fittedMetrics = fitWideMetricGroups(metrics, limit)
866
+ return {
867
+ layout,
868
+ width: limit,
869
+ rows: [
870
+ rowFromGroups("identity", fittedIdentity, " "),
871
+ rowFromGroups("metrics", fittedMetrics, STATUSLINE_GROUP_SEPARATOR),
872
+ ],
873
+ }
874
+ }
875
+
876
+ if (layout === "medium") {
877
+ let groups = [valueGroup("badge", "[RTRT]", "accent", "badge")]
878
+ if (state.unavailable) {
879
+ groups.push(valueGroup("missing", "N/A", "bad"))
880
+ } else {
881
+ if (project) groups.push(valueGroup("project", truncateToWidth(project.text, 18), sourceTone(state, project.segment)))
882
+ groups.push(metricGroup("model", "MODEL", sessionEconomics.model.text, sessionEconomics.model.tone))
883
+ groups.push(metricGroup("session-status", "STATE", sessionEconomics.status.text, sessionEconomics.status.tone))
884
+ if (style) groups.push(metricGroup("style", "OPT", style.text, sourceTone(state, style.segment)))
885
+ if (savings.save) groups.push(metricGroup("savings", "SAVE", savings.save.text, sourceTone(state, savings.save.segment)))
886
+ groups.push(metricGroup("cost", "COST", sessionEconomics.cost.text, sessionEconomics.cost.tone))
887
+ groups.push(metricGroup("context", "CTX", sessionEconomics.context.text, sessionEconomics.context.tone))
888
+ if (savings.command) groups.push(metricGroup("command", "CMD", savings.command.text, sourceTone(state, savings.command.segment)))
889
+ if (headroom) groups.push(metricGroup("headroom", "ROOM", headroom.compact, sourceTone(state, headroom.segment)))
890
+ }
891
+ if (state.unavailable) {
892
+ groups.push(
893
+ metricGroup("model", "MODEL", sessionEconomics.model.text, sessionEconomics.model.tone),
894
+ metricGroup("session-status", "STATE", sessionEconomics.status.text, sessionEconomics.status.tone),
895
+ metricGroup("cost", "COST", sessionEconomics.cost.text, sessionEconomics.cost.tone),
896
+ metricGroup("context", "CTX", sessionEconomics.context.text, sessionEconomics.context.tone),
897
+ )
898
+ }
899
+ if (limit5h) {
900
+ groups.push(metricGroup("limit-5h", "5H", limit5h.full, sourceTone(state, limit5h.segment)))
901
+ }
902
+ groups.push(
903
+ limitWeek
904
+ ? metricGroup("limit-week", "WEEK", limitWeek.full, sourceTone(state, limitWeek.segment))
905
+ : metricGroup("week", "WEEK", sessionEconomics.week.text, sessionEconomics.week.tone),
906
+ ...statuses,
907
+ )
908
+ const fitted = fitMediumGroups(groups, limit)
909
+ return { layout, width: limit, rows: [rowFromGroups("summary", fitted.groups, fitted.separator)] }
910
+ }
911
+
912
+ let groups = []
913
+ if (!state.unavailable && savings.save) {
914
+ groups.push(valueGroup("savings", savings.save.text, sourceTone(state, savings.save.segment)))
915
+ }
916
+ if (!promptRight && !state.unavailable && style) {
917
+ groups.unshift(valueGroup("style", style.text, sourceTone(state, style.segment)))
918
+ }
919
+ groups.push(valueGroup("cost", sessionEconomics.cost.text, sessionEconomics.cost.tone))
920
+ if (promptRight) groups.push(valueGroup("context", sessionEconomics.context.text, sessionEconomics.context.tone))
921
+ if (promptRight && limitWeek) {
922
+ groups.push(valueGroup("limit-week", limitWeek.percent, sourceTone(state, limitWeek.segment)))
923
+ }
924
+ if (statuses[0]?.id !== "live") groups.push(...statuses)
925
+ groups = fitNarrowGroups(
926
+ groups,
927
+ limit,
928
+ promptRight ? ["limit-week", "context", "cost", "savings"] : ["cost", "style", "savings"],
929
+ )
930
+ return { layout, width: limit, rows: [rowFromGroups("core", groups, " ")] }
931
+ }
932
+
933
+ export function renderStatuslineViewText(state, options = {}) {
934
+ return createStatuslineViewModel(state, options)
935
+ .rows.map((row) => row.parts.map((part) => part.text).join(""))
936
+ .join("\n")
937
+ }
938
+
939
+ export function renderStatuslineText(state, options = {}) {
940
+ return renderStatuslineViewText(state, options)
941
+ }
942
+
943
+ export function createRefreshScheduler({
944
+ run,
945
+ debounceMs = DEFAULT_DEBOUNCE_MS,
946
+ intervalMs = DEFAULT_INTERVAL_MS,
947
+ setTimeoutFn = setTimeout,
948
+ clearTimeoutFn = clearTimeout,
949
+ setIntervalFn = setInterval,
950
+ clearIntervalFn = clearInterval,
951
+ }) {
952
+ let started = false
953
+ let disposed = false
954
+ let queued = false
955
+ let debounce
956
+ let interval
957
+ let running
958
+
959
+ const execute = () => {
960
+ if (disposed) return Promise.resolve()
961
+ if (running) {
962
+ queued = true
963
+ return running
964
+ }
965
+ running = (async () => {
966
+ do {
967
+ queued = false
968
+ try {
969
+ await run()
970
+ } catch {}
971
+ } while (queued && !disposed)
972
+ })().finally(() => {
973
+ running = undefined
974
+ if (queued && !disposed) void execute()
975
+ })
976
+ return running
977
+ }
978
+
979
+ const clearDebounce = () => {
980
+ if (debounce === undefined) return
981
+ clearTimeoutFn(debounce)
982
+ debounce = undefined
983
+ }
984
+
985
+ const request = (immediate = false) => {
986
+ if (disposed) return
987
+ clearDebounce()
988
+ if (immediate) {
989
+ void execute()
990
+ return
991
+ }
992
+ debounce = setTimeoutFn(() => {
993
+ debounce = undefined
994
+ void execute()
995
+ }, Math.max(0, debounceMs))
996
+ debounce?.unref?.()
997
+ }
998
+
999
+ return {
1000
+ start() {
1001
+ if (started || disposed) return
1002
+ started = true
1003
+ request(true)
1004
+ if (intervalMs > 0) {
1005
+ interval = setIntervalFn(() => request(true), intervalMs)
1006
+ interval?.unref?.()
1007
+ }
1008
+ },
1009
+ request,
1010
+ flush() {
1011
+ clearDebounce()
1012
+ return execute()
1013
+ },
1014
+ dispose() {
1015
+ disposed = true
1016
+ clearDebounce()
1017
+ if (interval !== undefined) clearIntervalFn(interval)
1018
+ },
1019
+ }
1020
+ }
1021
+
1022
+ export function createStatuslineController({
1023
+ load,
1024
+ onState = () => {},
1025
+ debounceMs = DEFAULT_DEBOUNCE_MS,
1026
+ intervalMs = DEFAULT_INTERVAL_MS,
1027
+ timers = {},
1028
+ }) {
1029
+ let context = { cwd: "", session: "", width: 80, model: "" }
1030
+ let state = initialStatuslineState()
1031
+ let generation = 0
1032
+ let activeRefresh
1033
+ const lifetime = new AbortController()
1034
+ const scheduler = createRefreshScheduler({
1035
+ run: async () => {
1036
+ const snapshot = { ...context }
1037
+ const startedGeneration = generation
1038
+ const refresh = new AbortController()
1039
+ const abortRefresh = () => refresh.abort()
1040
+ activeRefresh = refresh
1041
+ lifetime.signal.addEventListener("abort", abortRefresh, { once: true })
1042
+ let payload
1043
+ try {
1044
+ payload = await load(snapshot, refresh.signal)
1045
+ } catch {}
1046
+ lifetime.signal.removeEventListener("abort", abortRefresh)
1047
+ if (activeRefresh === refresh) activeRefresh = undefined
1048
+ if (
1049
+ lifetime.signal.aborted ||
1050
+ refresh.signal.aborted ||
1051
+ startedGeneration !== generation ||
1052
+ snapshot.cwd !== context.cwd ||
1053
+ snapshot.session !== context.session ||
1054
+ snapshot.width !== context.width ||
1055
+ snapshot.model !== context.model
1056
+ ) {
1057
+ return
1058
+ }
1059
+ state = nextStatuslineState(state, payload)
1060
+ try {
1061
+ onState(state)
1062
+ } catch {}
1063
+ },
1064
+ debounceMs,
1065
+ intervalMs,
1066
+ ...timers,
1067
+ })
1068
+
1069
+ return {
1070
+ start: scheduler.start,
1071
+ request: scheduler.request,
1072
+ refreshNow: scheduler.flush,
1073
+ dispose() {
1074
+ activeRefresh?.abort()
1075
+ lifetime.abort()
1076
+ scheduler.dispose()
1077
+ },
1078
+ setContext(next) {
1079
+ const updated = {
1080
+ cwd: typeof next.cwd === "string" ? next.cwd : context.cwd,
1081
+ session: typeof next.session === "string" ? next.session : context.session,
1082
+ width: next.width === undefined ? context.width : normalizedWidth(next.width),
1083
+ model: typeof next.model === "string" ? next.model : context.model,
1084
+ }
1085
+ if (
1086
+ updated.cwd === context.cwd &&
1087
+ updated.session === context.session &&
1088
+ updated.width === context.width &&
1089
+ updated.model === context.model
1090
+ ) {
1091
+ return
1092
+ }
1093
+ context = updated
1094
+ generation += 1
1095
+ activeRefresh?.abort()
1096
+ scheduler.request()
1097
+ },
1098
+ getState: () => state,
1099
+ getContext: () => ({ ...context }),
1100
+ }
1101
+ }
1102
+
1103
+ export function createKeyedControllerPool({ create }) {
1104
+ const entries = new Map()
1105
+
1106
+ const dispose = (entry) => {
1107
+ try {
1108
+ entry.value.dispose()
1109
+ } catch {}
1110
+ }
1111
+
1112
+ return {
1113
+ acquire(key) {
1114
+ let entry = entries.get(key)
1115
+ if (!entry) {
1116
+ entry = { value: create(key), refs: 0 }
1117
+ entries.set(key, entry)
1118
+ }
1119
+ entry.refs += 1
1120
+ let released = false
1121
+ return {
1122
+ value: entry.value,
1123
+ release() {
1124
+ if (released) return
1125
+ released = true
1126
+ entry.refs -= 1
1127
+ if (entry.refs > 0 || entries.get(key) !== entry) return
1128
+ entries.delete(key)
1129
+ dispose(entry)
1130
+ },
1131
+ }
1132
+ },
1133
+ get(key) {
1134
+ return entries.get(key)?.value
1135
+ },
1136
+ forEach(callback) {
1137
+ for (const [key, entry] of entries) callback(entry.value, key)
1138
+ },
1139
+ size: () => entries.size,
1140
+ dispose() {
1141
+ for (const entry of entries.values()) dispose(entry)
1142
+ entries.clear()
1143
+ },
1144
+ }
1145
+ }