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.
- package/README.ko.md +45 -10
- package/README.md +45 -10
- package/THIRD_PARTY_NOTICES/INDEX.md +24 -11
- package/THIRD_PARTY_NOTICES/option-ext@0.2.0/SOURCE.crate +0 -0
- package/package.json +43 -12
- package/server.js +98 -0
- package/tui/index.js +1 -0
- package/tui/rtrt-statusline-core.d.mts +205 -0
- package/tui/rtrt-statusline-core.mjs +1145 -0
- package/tui/v2/native-runtime.mjs +81 -0
- package/tui/v2/native-state.mjs +66 -0
- package/tui/v2/rtrt-statusline.tsx +116 -0
- package/v2/permissions.js +119 -0
|
@@ -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
|
+
}
|