opencode-tweaks 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/README.md +222 -2
- package/package.json +62 -4
- package/src/index.ts +20 -0
- package/src/quota/format.ts +97 -0
- package/src/quota/pace.ts +180 -0
- package/src/quota/parse.ts +101 -0
- package/src/quota/service.ts +224 -0
- package/src/rpc.ts +84 -0
- package/src/tui.tsx +66 -0
- package/src/tweaks/menu-dialog.tsx +424 -0
- package/src/tweaks/menu-model.ts +55 -0
- package/src/tweaks/menu.ts +1 -0
- package/src/tweaks/quota-panel.tsx +514 -0
- package/src/tweaks/quota.ts +163 -0
- package/src/tweaks/registry.ts +10 -0
- package/src/tweaks/settings.ts +73 -0
- package/src/tweaks/token-inject.ts +724 -0
- package/src/tweaks/token-slots.tsx +327 -0
- package/src/tweaks/tokens-model.ts +213 -0
- package/src/tweaks/tokens.ts +113 -0
- package/src/tweaks/types.ts +187 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The host draws the `agent · model · duration · tok/s` line under every
|
|
5
|
+
* assistant message itself, and publishes no slot inside it. This module is the
|
|
6
|
+
* deliberate exception to the plugin's "no interface fighting" rule: it splices
|
|
7
|
+
* the counts into that line's text nodes, then repairs itself on a timer because
|
|
8
|
+
* the host rebuilds the line whenever a turn advances.
|
|
9
|
+
*
|
|
10
|
+
* The host creates one such line per finished turn, so the figures belong to the
|
|
11
|
+
* line they were first written on. When a newer line appears the figures move to
|
|
12
|
+
* it, and the line they leave behind keeps the values it had rather than showing
|
|
13
|
+
* the next turn's.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here imports the renderer or the reactive runtime. The TUI resolves
|
|
16
|
+
* bare imports in `.tsx` sources only, so this module takes everything it needs
|
|
17
|
+
* from the tree it edits.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is best effort. Any surprise leaves the line alone and the
|
|
20
|
+
* counts simply stay out of it.
|
|
21
|
+
*/
|
|
22
|
+
export interface HostText {
|
|
23
|
+
readonly plainText: string
|
|
24
|
+
getTextChildren(): readonly unknown[]
|
|
25
|
+
insertBefore(node: unknown, anchor: unknown): number
|
|
26
|
+
remove(node: unknown): void
|
|
27
|
+
add?(node: unknown, index?: number): unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface HostNode {
|
|
31
|
+
children: (string | HostNode)[]
|
|
32
|
+
parent: unknown
|
|
33
|
+
fg?: unknown
|
|
34
|
+
attributes?: number
|
|
35
|
+
add(text: string): number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type HostNodeConstructor = new (options: {
|
|
39
|
+
id?: string
|
|
40
|
+
fg?: unknown
|
|
41
|
+
attributes?: number
|
|
42
|
+
}) => HostNode
|
|
43
|
+
|
|
44
|
+
/** What the host writes before the throughput, and the only thing we can key on. */
|
|
45
|
+
const THROUGHPUT = / \d[\d.]* tok\/s/
|
|
46
|
+
/**
|
|
47
|
+
* The context usage the host prints on the prompt footer, such as `114.9K
|
|
48
|
+
* (29%)`. A shell or goal adds a leading separator, the host may append its own
|
|
49
|
+
* reported cost, and the tweak puts its figures in front, so the text is matched
|
|
50
|
+
* before the tweak's own node is taken into account.
|
|
51
|
+
*/
|
|
52
|
+
const FOOTER_USAGE = /^(?: · )?\d[\d.,]*\s?[KMG]?\s?\(\d+%\)(?:\s·\s\$[\d.,]+){0,2}$/
|
|
53
|
+
/** The line is rebuilt on every token, so a slow repair would lag behind it. */
|
|
54
|
+
const REPAIR_MS = 400
|
|
55
|
+
const BEFORE_ID = "opencode-tweaks.tokens.before"
|
|
56
|
+
const AFTER_ID = "opencode-tweaks.tokens.after"
|
|
57
|
+
const FOOTER_ID = "opencode-tweaks.tokens.footer"
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hot reload evaluates this module again while the old repair timers keep
|
|
61
|
+
* running. The registry lets the new copy stop them, even when the old copy
|
|
62
|
+
* never reached its own dispose.
|
|
63
|
+
*/
|
|
64
|
+
const timers = new Set<ReturnType<typeof setInterval>>()
|
|
65
|
+
const previousTimers = (globalThis as { tokenInjectorTimers?: Set<ReturnType<typeof setInterval>> })
|
|
66
|
+
.tokenInjectorTimers
|
|
67
|
+
for (const timer of previousTimers ?? []) clearInterval(timer)
|
|
68
|
+
;(globalThis as { tokenInjectorTimers?: Set<ReturnType<typeof setInterval>> }).tokenInjectorTimers =
|
|
69
|
+
timers
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A text node is the only renderable with a string-or-node child list, which is
|
|
73
|
+
* also how the host tells it apart from the element it hangs from.
|
|
74
|
+
*/
|
|
75
|
+
function isTextNode(value: unknown): value is HostNode {
|
|
76
|
+
const node = value as Partial<HostNode> | null
|
|
77
|
+
if (!node || typeof node.add !== "function") return false
|
|
78
|
+
if (Array.isArray(node.children)) return true
|
|
79
|
+
const text = node as { getTextChildren?: unknown }
|
|
80
|
+
return typeof text.getTextChildren === "function"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The children of a node, whichever way the host exposes them. */
|
|
84
|
+
function childNodes(node: HostNode): (string | HostNode)[] {
|
|
85
|
+
const text = node as { getTextChildren?: () => readonly unknown[] }
|
|
86
|
+
if (typeof text.getTextChildren === "function") {
|
|
87
|
+
return text.getTextChildren() as (string | HostNode)[]
|
|
88
|
+
}
|
|
89
|
+
return Array.isArray(node.children) ? node.children : []
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function asHostText(value: unknown): HostText | undefined {
|
|
93
|
+
const text = value as Partial<HostText> | null
|
|
94
|
+
if (!text || typeof text.plainText !== "string") return undefined
|
|
95
|
+
if (typeof text.getTextChildren !== "function") return undefined
|
|
96
|
+
if (typeof text.insertBefore !== "function" || typeof text.remove !== "function") {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
return text as HostText
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The text of a node subtree, which the host only exposes as nested chunks. */
|
|
103
|
+
function textOf(node: unknown): string {
|
|
104
|
+
if (typeof node === "string") return node
|
|
105
|
+
if (!isTextNode(node)) return ""
|
|
106
|
+
return childNodes(node).map(textOf).join("")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every footer in tree order, oldest first. A line always carries spans, and
|
|
111
|
+
* requiring them keeps the prompt's textarea, which also exposes text, out.
|
|
112
|
+
*/
|
|
113
|
+
export function throughputTexts(node: { getChildren(): readonly unknown[] }): HostText[] {
|
|
114
|
+
const found: HostText[] = []
|
|
115
|
+
|
|
116
|
+
const walk = (branch: { getChildren(): readonly unknown[] }) => {
|
|
117
|
+
for (const child of branch.getChildren()) {
|
|
118
|
+
const text = asHostText(child)
|
|
119
|
+
if (text) {
|
|
120
|
+
if (THROUGHPUT.test(text.plainText) && text.getTextChildren().length > 0) found.push(text)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const next = child as { getChildren?: unknown }
|
|
125
|
+
if (typeof next.getChildren === "function") {
|
|
126
|
+
walk(next as { getChildren(): readonly unknown[] })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
walk(node)
|
|
132
|
+
return found
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The newest footer, which is the last one in tree order. */
|
|
136
|
+
export function lastThroughputText(node: {
|
|
137
|
+
getChildren(): readonly unknown[]
|
|
138
|
+
}): HostText | undefined {
|
|
139
|
+
return throughputTexts(node).at(-1)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Every text node under a branch, in tree order. */
|
|
143
|
+
export function hostTexts(node: { getChildren(): readonly unknown[] }): HostText[] {
|
|
144
|
+
const found: HostText[] = []
|
|
145
|
+
|
|
146
|
+
const walk = (branch: { getChildren(): readonly unknown[] }) => {
|
|
147
|
+
for (const child of branch.getChildren()) {
|
|
148
|
+
const text = asHostText(child)
|
|
149
|
+
if (text) {
|
|
150
|
+
found.push(text)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const next = child as { getChildren?: unknown }
|
|
155
|
+
if (typeof next.getChildren === "function") {
|
|
156
|
+
walk(next as { getChildren(): readonly unknown[] })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
walk(node)
|
|
162
|
+
return found
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The host's text without the node the tweak added, for matching. */
|
|
166
|
+
function withoutInjected(text: HostText, node: HostNode | undefined): string {
|
|
167
|
+
const plain = text.plainText
|
|
168
|
+
if (!node) return plain
|
|
169
|
+
|
|
170
|
+
const value = textOf(node)
|
|
171
|
+
if (!value) return plain
|
|
172
|
+
if (plain.startsWith(value)) return plain.slice(value.length)
|
|
173
|
+
if (plain.endsWith(value)) return plain.slice(0, -value.length)
|
|
174
|
+
return plain
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isFooterUsage(text: string): boolean {
|
|
178
|
+
return FOOTER_USAGE.test(text)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The prompt footer's context usage text. It is the only text that carries the
|
|
183
|
+
* `used (percent)` shape the host prints next to the shell and goal indicators;
|
|
184
|
+
* the transcript can mention the same words, so the last one wins, which is the
|
|
185
|
+
* footer the composer draws.
|
|
186
|
+
*/
|
|
187
|
+
export function footerUsageText(node: { getChildren(): readonly unknown[] }): HostText | undefined {
|
|
188
|
+
let found: HostText | undefined
|
|
189
|
+
for (const candidate of hostTexts(node)) {
|
|
190
|
+
if (isFooterUsage(candidate.plainText)) found = candidate
|
|
191
|
+
}
|
|
192
|
+
return found
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The ` · 62.2 tok/s` span the labels are placed around. */
|
|
196
|
+
function throughputSpan(text: HostText): HostNode | undefined {
|
|
197
|
+
for (const child of text.getTextChildren()) {
|
|
198
|
+
if (isTextNode(child) && THROUGHPUT.test(textOf(child))) return child
|
|
199
|
+
}
|
|
200
|
+
return undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The host's plain text-node class, taken from a leaf node the span already
|
|
205
|
+
* contains. The compiled host minifies class names, so the tree is the only
|
|
206
|
+
* reliable source of a constructor; a leaf is the class that takes plain text.
|
|
207
|
+
*/
|
|
208
|
+
function textNodeConstructor(span: HostNode): HostNodeConstructor | undefined {
|
|
209
|
+
const pending: HostNode[] = [span]
|
|
210
|
+
while (pending.length > 0) {
|
|
211
|
+
const node = pending.shift()
|
|
212
|
+
if (!node) break
|
|
213
|
+
for (const child of childNodes(node)) {
|
|
214
|
+
if (typeof child === "string" || !isTextNode(child)) continue
|
|
215
|
+
const children = childNodes(child)
|
|
216
|
+
if (children.some((item) => typeof item === "string")) {
|
|
217
|
+
return child.constructor as HostNodeConstructor
|
|
218
|
+
}
|
|
219
|
+
pending.push(child)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface InjectorLabels {
|
|
226
|
+
/** Text just before the throughput figure, such as the turn token count. */
|
|
227
|
+
readonly before?: string
|
|
228
|
+
/** Text just after it, such as the turn cost. */
|
|
229
|
+
readonly after?: string
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface TokenInjector {
|
|
233
|
+
/**
|
|
234
|
+
* The figures for the newest footer, tagged with the turn they belong to.
|
|
235
|
+
* Empty fields hide them there.
|
|
236
|
+
*/
|
|
237
|
+
setLabels(labels: InjectorLabels, key?: string): void
|
|
238
|
+
/** Hides the host's ttft figure on every footer, or brings it back. */
|
|
239
|
+
setTtft(visible: boolean): void
|
|
240
|
+
/**
|
|
241
|
+
* The session figures to print on the prompt footer, such as
|
|
242
|
+
* `649.3K tok total · $0.91`, without the surrounding separators.
|
|
243
|
+
* `onPlaced` reports whether the host line accepted them.
|
|
244
|
+
*/
|
|
245
|
+
setFooterFigures(label?: string, onPlaced?: (placed: boolean) => void): void
|
|
246
|
+
/** Drops every figure, including the ones frozen on earlier lines. */
|
|
247
|
+
clear(): void
|
|
248
|
+
dispose(): void
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The nodes written on one footer, and the values they currently hold. */
|
|
252
|
+
interface Placed {
|
|
253
|
+
values: InjectorLabels
|
|
254
|
+
/** The host's own throughput text, which changes if the line is reused. */
|
|
255
|
+
readonly signature: string
|
|
256
|
+
beforeNode?: HostNode
|
|
257
|
+
afterNode?: HostNode
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function createTokenInjector(context: Context): TokenInjector {
|
|
261
|
+
let labels: InjectorLabels = {}
|
|
262
|
+
let key: string | undefined
|
|
263
|
+
let active: HostText | undefined
|
|
264
|
+
let activeKey: string | undefined
|
|
265
|
+
/** Whether the host's own ttft figure stays on the line. */
|
|
266
|
+
let ttft = true
|
|
267
|
+
/** The session figures the prompt footer should carry. */
|
|
268
|
+
let footerLabel: string | undefined
|
|
269
|
+
let footerFigures: { text: HostText; node: HostNode } | undefined
|
|
270
|
+
let footerListener: ((placed: boolean) => void) | undefined
|
|
271
|
+
let footerReported: boolean | undefined
|
|
272
|
+
const placed = new Map<HostText, Placed>()
|
|
273
|
+
/** The ttft spans taken out of a line, with what followed them. */
|
|
274
|
+
const hidden = new Map<HostText, { node: HostNode; anchor: unknown }>()
|
|
275
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
276
|
+
/** Keeps the host's renderable ids unique across the lines we annotate. */
|
|
277
|
+
let serial = 0
|
|
278
|
+
|
|
279
|
+
/** Dropping a node from the tree is enough; a rebuilt line has no parent. */
|
|
280
|
+
function release(node: HostNode | undefined) {
|
|
281
|
+
if (!node) return
|
|
282
|
+
const parent = node.parent as { remove?: (child: HostNode) => void } | null
|
|
283
|
+
try {
|
|
284
|
+
parent?.remove?.(node)
|
|
285
|
+
} catch {
|
|
286
|
+
// The host already dropped the node; nothing left to clean up.
|
|
287
|
+
}
|
|
288
|
+
node.parent = null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function releasePlaced(record: Placed) {
|
|
292
|
+
release(record.beforeNode)
|
|
293
|
+
release(record.afterNode)
|
|
294
|
+
record.beforeNode = undefined
|
|
295
|
+
record.afterNode = undefined
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function detachAll() {
|
|
299
|
+
for (const record of placed.values()) releasePlaced(record)
|
|
300
|
+
placed.clear()
|
|
301
|
+
active = undefined
|
|
302
|
+
activeKey = undefined
|
|
303
|
+
releaseFooter()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function reportFooter(placed: boolean) {
|
|
307
|
+
if (footerReported === placed) return
|
|
308
|
+
footerReported = placed
|
|
309
|
+
try {
|
|
310
|
+
footerListener?.(placed)
|
|
311
|
+
} catch {
|
|
312
|
+
// A component that unmounted mid-repair must not break the timer.
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function releaseFooter() {
|
|
317
|
+
release(footerFigures?.node)
|
|
318
|
+
footerFigures = undefined
|
|
319
|
+
reportFooter(false)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function makeNode(span: HostNode, value: string, id: string): HostNode | undefined {
|
|
323
|
+
const Constructor = textNodeConstructor(span)
|
|
324
|
+
if (!Constructor) return undefined
|
|
325
|
+
|
|
326
|
+
serial += 1
|
|
327
|
+
const node = new Constructor({
|
|
328
|
+
id: `${id}.${serial}`,
|
|
329
|
+
fg: span.fg,
|
|
330
|
+
attributes: span.attributes,
|
|
331
|
+
})
|
|
332
|
+
node.add(value)
|
|
333
|
+
return node
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A child text node for the host's footer, styled like its parent text. */
|
|
337
|
+
function makeFooterNode(target: HostText, source: HostText, value: string): HostNode | undefined {
|
|
338
|
+
let Constructor: HostNodeConstructor | undefined
|
|
339
|
+
try {
|
|
340
|
+
Constructor = textNodeConstructor(source as unknown as HostNode)
|
|
341
|
+
} catch {
|
|
342
|
+
Constructor = undefined
|
|
343
|
+
}
|
|
344
|
+
Constructor ??= source.constructor as HostNodeConstructor | undefined
|
|
345
|
+
if (!Constructor) return undefined
|
|
346
|
+
const parent = target as unknown as { fg?: unknown; attributes?: number }
|
|
347
|
+
|
|
348
|
+
serial += 1
|
|
349
|
+
const node = new Constructor({
|
|
350
|
+
id: `${FOOTER_ID}.${serial}`,
|
|
351
|
+
fg: parent.fg,
|
|
352
|
+
attributes: parent.attributes,
|
|
353
|
+
})
|
|
354
|
+
if (typeof node?.add !== "function") return undefined
|
|
355
|
+
node.add(value)
|
|
356
|
+
return node
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function setText(node: HostNode, value: string) {
|
|
360
|
+
if (textOf(node) !== value) node.children = [value]
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Keeps the leading node directly in front of the throughput span. */
|
|
364
|
+
function placeBefore(target: HostText, span: HostNode, record: Placed): boolean {
|
|
365
|
+
const value = record.values.before
|
|
366
|
+
if (!value) {
|
|
367
|
+
release(record.beforeNode)
|
|
368
|
+
record.beforeNode = undefined
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!record.beforeNode) {
|
|
373
|
+
record.beforeNode = makeNode(span, value, BEFORE_ID)
|
|
374
|
+
if (!record.beforeNode) return false
|
|
375
|
+
} else {
|
|
376
|
+
setText(record.beforeNode, value)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const children = target.getTextChildren()
|
|
380
|
+
const index = children.indexOf(record.beforeNode)
|
|
381
|
+
if (index !== -1 && children[index + 1] === span) return true
|
|
382
|
+
if (index !== -1) target.remove(record.beforeNode)
|
|
383
|
+
target.insertBefore(record.beforeNode, span)
|
|
384
|
+
return true
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Keeps the trailing node directly after the throughput span. */
|
|
388
|
+
function placeAfter(target: HostText, span: HostNode, record: Placed): boolean {
|
|
389
|
+
const value = record.values.after
|
|
390
|
+
if (!value) {
|
|
391
|
+
release(record.afterNode)
|
|
392
|
+
record.afterNode = undefined
|
|
393
|
+
return true
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!record.afterNode) {
|
|
397
|
+
record.afterNode = makeNode(span, value, AFTER_ID)
|
|
398
|
+
if (!record.afterNode) return false
|
|
399
|
+
} else {
|
|
400
|
+
setText(record.afterNode, value)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const children = target.getTextChildren()
|
|
404
|
+
const index = children.indexOf(record.afterNode)
|
|
405
|
+
const spanIndex = children.indexOf(span)
|
|
406
|
+
if (index !== -1 && spanIndex !== -1 && index === spanIndex + 1) return true
|
|
407
|
+
if (index !== -1) target.remove(record.afterNode)
|
|
408
|
+
|
|
409
|
+
const next = target.getTextChildren()
|
|
410
|
+
const anchor = next[next.indexOf(span) + 1]
|
|
411
|
+
if (anchor) {
|
|
412
|
+
target.insertBefore(record.afterNode, anchor)
|
|
413
|
+
} else if (typeof target.add === "function") {
|
|
414
|
+
target.add(record.afterNode)
|
|
415
|
+
} else {
|
|
416
|
+
// Without an append, keeping the text on the line beats dropping it.
|
|
417
|
+
target.insertBefore(record.afterNode, span)
|
|
418
|
+
}
|
|
419
|
+
return true
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Keeps a node first among a text's children. */
|
|
423
|
+
function placeFirst(target: HostText, node: HostNode) {
|
|
424
|
+
const children = target.getTextChildren()
|
|
425
|
+
if (children[0] === node) return
|
|
426
|
+
if (children.includes(node)) target.remove(node)
|
|
427
|
+
const first = target.getTextChildren()[0]
|
|
428
|
+
if (first) target.insertBefore(node, first)
|
|
429
|
+
else target.add?.(node)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Keeps the session figures in front of the prompt footer's context usage.
|
|
434
|
+
* The host draws the shell indicator as a separate text just before it, so
|
|
435
|
+
* prefixing the usage gives the requested order without touching the shell:
|
|
436
|
+
*
|
|
437
|
+
* `1 shell · 649.3K tok total · $0.91 · 219.6K (56%)`
|
|
438
|
+
*
|
|
439
|
+
* The host rebuilds the usage text while a turn runs, so the repair pass puts
|
|
440
|
+
* the node back when it disappears.
|
|
441
|
+
*/
|
|
442
|
+
function placeFooterFigures(label: string | undefined) {
|
|
443
|
+
if (!label) {
|
|
444
|
+
releaseFooter()
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let texts: HostText[]
|
|
449
|
+
try {
|
|
450
|
+
texts = hostTexts(context.renderer.root)
|
|
451
|
+
} catch {
|
|
452
|
+
releaseFooter()
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const previous = footerFigures?.text
|
|
457
|
+
const previousNode = footerFigures?.node
|
|
458
|
+
const previousBase =
|
|
459
|
+
previous && texts.includes(previous) ? withoutInjected(previous, previousNode) : undefined
|
|
460
|
+
|
|
461
|
+
let usage: HostText | undefined
|
|
462
|
+
if (previousBase !== undefined && isFooterUsage(previousBase)) {
|
|
463
|
+
usage = previous
|
|
464
|
+
} else {
|
|
465
|
+
for (const text of texts) {
|
|
466
|
+
if (isFooterUsage(text.plainText)) usage = text
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!usage) {
|
|
471
|
+
releaseFooter()
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const leading = withoutInjected(
|
|
476
|
+
usage,
|
|
477
|
+
footerFigures?.text === usage ? footerFigures.node : undefined,
|
|
478
|
+
).startsWith(" · ")
|
|
479
|
+
const value = leading ? ` · ${label}` : `${label} · `
|
|
480
|
+
|
|
481
|
+
if (footerFigures && footerFigures.text !== usage) releaseFooter()
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
if (footerFigures) {
|
|
485
|
+
setText(footerFigures.node, value)
|
|
486
|
+
} else {
|
|
487
|
+
const node = makeFooterNode(usage, usage, value)
|
|
488
|
+
if (!node) return
|
|
489
|
+
footerFigures = { text: usage, node }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
placeFirst(usage, footerFigures.node)
|
|
493
|
+
reportFooter(true)
|
|
494
|
+
} catch {
|
|
495
|
+
releaseFooter()
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Writes one footer's values, creating or moving its nodes as needed. */
|
|
500
|
+
function place(text: HostText, values: InjectorLabels) {
|
|
501
|
+
const record = placed.get(text)
|
|
502
|
+
if (!values.before && !values.after) {
|
|
503
|
+
if (record) releasePlaced(record)
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const span = throughputSpan(text)
|
|
508
|
+
if (!span) {
|
|
509
|
+
// The host rebuilt the line without throughput; nothing to hang on to.
|
|
510
|
+
if (record) {
|
|
511
|
+
releasePlaced(record)
|
|
512
|
+
placed.delete(text)
|
|
513
|
+
}
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let target = record
|
|
518
|
+
if (!target) {
|
|
519
|
+
target = { values, signature: textOf(span) }
|
|
520
|
+
placed.set(text, target)
|
|
521
|
+
}
|
|
522
|
+
target.values = values
|
|
523
|
+
|
|
524
|
+
if (!placeBefore(text, span, target) || !placeAfter(text, span, target)) {
|
|
525
|
+
releasePlaced(target)
|
|
526
|
+
placed.delete(text)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Whether anything still needs the repair timer. */
|
|
531
|
+
function pending(): boolean {
|
|
532
|
+
if (!ttft) return true
|
|
533
|
+
if (footerLabel !== undefined) return true
|
|
534
|
+
if (labels.before || labels.after) return true
|
|
535
|
+
for (const record of placed.values()) {
|
|
536
|
+
if (record.beforeNode || record.afterNode) return true
|
|
537
|
+
}
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Takes the host's ` · 1.7s ttft` span out of every footer on screen. */
|
|
542
|
+
function hideTtft(texts: readonly HostText[]) {
|
|
543
|
+
for (const text of texts) {
|
|
544
|
+
const children = text.getTextChildren()
|
|
545
|
+
const index = children.findIndex((child) => isTextNode(child) && / ttft$/.test(textOf(child)))
|
|
546
|
+
if (index === -1) continue
|
|
547
|
+
|
|
548
|
+
const span = children[index] as HostNode
|
|
549
|
+
hidden.set(text, { node: span, anchor: children[index + 1] })
|
|
550
|
+
text.remove(span)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const text of hidden.keys()) {
|
|
554
|
+
if (!texts.includes(text)) hidden.delete(text)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Puts the ttft spans back where they were, before the line is rebuilt. */
|
|
559
|
+
function restoreTtft(texts: readonly HostText[]) {
|
|
560
|
+
for (const [text, record] of hidden) {
|
|
561
|
+
hidden.delete(text)
|
|
562
|
+
if (!texts.includes(text)) continue
|
|
563
|
+
|
|
564
|
+
const children = text.getTextChildren()
|
|
565
|
+
if (record.anchor && children.includes(record.anchor)) {
|
|
566
|
+
text.insertBefore(record.node, record.anchor)
|
|
567
|
+
} else {
|
|
568
|
+
text.add?.(record.node)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Restores every hidden figure, wherever the host still shows the line. */
|
|
574
|
+
function restoreAll() {
|
|
575
|
+
try {
|
|
576
|
+
restoreTtft(throughputTexts(context.renderer.root))
|
|
577
|
+
} catch {
|
|
578
|
+
hidden.clear()
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function repair() {
|
|
583
|
+
try {
|
|
584
|
+
const texts = throughputTexts(context.renderer.root)
|
|
585
|
+
|
|
586
|
+
// The host's ttft figure is optional, and it is the only part of the line
|
|
587
|
+
// the tweak removes rather than writes.
|
|
588
|
+
if (ttft) {
|
|
589
|
+
restoreTtft(texts)
|
|
590
|
+
} else {
|
|
591
|
+
hideTtft(texts)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// The prompt footer is independent of the per-turn throughput lines.
|
|
595
|
+
placeFooterFigures(footerLabel)
|
|
596
|
+
|
|
597
|
+
// Forget the footers the host dropped, and the ones it reused for another
|
|
598
|
+
// turn, so no figure ends up on a line it does not belong to.
|
|
599
|
+
for (const [text, record] of placed) {
|
|
600
|
+
const span = texts.includes(text) ? throughputSpan(text) : undefined
|
|
601
|
+
if (span && textOf(span) === record.signature) continue
|
|
602
|
+
releasePlaced(record)
|
|
603
|
+
placed.delete(text)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const last = texts.at(-1)
|
|
607
|
+
if (!last) {
|
|
608
|
+
// No footer is mounted right now. Keep the timer alive while labels are
|
|
609
|
+
// pending, so the first line to appear still gets them.
|
|
610
|
+
if (!pending()) stop()
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (labels.before || labels.after) {
|
|
615
|
+
if (active && texts.includes(active)) {
|
|
616
|
+
// A newer footer only takes the figures when its turn has finished and
|
|
617
|
+
// the host rendered the matching line. Without a key, the newest line
|
|
618
|
+
// is the best guess, which is how the injector behaves in isolation.
|
|
619
|
+
const retarget =
|
|
620
|
+
key === undefined
|
|
621
|
+
? last !== active && !placed.has(last)
|
|
622
|
+
: activeKey !== key && last !== active && !placed.has(last)
|
|
623
|
+
if (retarget) {
|
|
624
|
+
active = last
|
|
625
|
+
activeKey = key
|
|
626
|
+
}
|
|
627
|
+
} else {
|
|
628
|
+
// The line we were refreshing left the tree, or there never was one.
|
|
629
|
+
active = undefined
|
|
630
|
+
activeKey = undefined
|
|
631
|
+
if (!placed.has(last)) {
|
|
632
|
+
active = last
|
|
633
|
+
activeKey = key
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
for (const text of texts) {
|
|
639
|
+
const record = placed.get(text)
|
|
640
|
+
if (text === active && activeKey === key) {
|
|
641
|
+
place(text, labels)
|
|
642
|
+
} else if (record) {
|
|
643
|
+
// Every other line keeps the values it was written with.
|
|
644
|
+
place(text, record.values)
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (!pending()) stop()
|
|
649
|
+
} catch {
|
|
650
|
+
detachAll()
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function start() {
|
|
655
|
+
if (!timer) {
|
|
656
|
+
timer = setInterval(repair, REPAIR_MS)
|
|
657
|
+
timers.add(timer)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function stop() {
|
|
662
|
+
if (!timer) return
|
|
663
|
+
clearInterval(timer)
|
|
664
|
+
timers.delete(timer)
|
|
665
|
+
timer = undefined
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
setLabels(next, nextKey) {
|
|
670
|
+
labels = { before: next.before, after: next.after }
|
|
671
|
+
key = nextKey
|
|
672
|
+
// The timer exists only while there is something to splice, so a disabled
|
|
673
|
+
// tweak runs no work at all.
|
|
674
|
+
if (!labels.before && !labels.after && !pending()) {
|
|
675
|
+
stop()
|
|
676
|
+
detachAll()
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
start()
|
|
680
|
+
repair()
|
|
681
|
+
},
|
|
682
|
+
setTtft(visible) {
|
|
683
|
+
ttft = visible
|
|
684
|
+
if (visible) restoreAll()
|
|
685
|
+
if (!pending()) {
|
|
686
|
+
stop()
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
start()
|
|
690
|
+
repair()
|
|
691
|
+
},
|
|
692
|
+
setFooterFigures(label, onPlaced) {
|
|
693
|
+
footerLabel = label
|
|
694
|
+
footerListener = onPlaced
|
|
695
|
+
footerReported = undefined
|
|
696
|
+
if (!pending()) {
|
|
697
|
+
releaseFooter()
|
|
698
|
+
reportFooter(false)
|
|
699
|
+
stop()
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
start()
|
|
703
|
+
repair()
|
|
704
|
+
},
|
|
705
|
+
clear() {
|
|
706
|
+
labels = {}
|
|
707
|
+
key = undefined
|
|
708
|
+
footerLabel = undefined
|
|
709
|
+
restoreAll()
|
|
710
|
+
detachAll()
|
|
711
|
+
footerListener = undefined
|
|
712
|
+
stop()
|
|
713
|
+
},
|
|
714
|
+
dispose() {
|
|
715
|
+
labels = {}
|
|
716
|
+
key = undefined
|
|
717
|
+
footerLabel = undefined
|
|
718
|
+
restoreAll()
|
|
719
|
+
detachAll()
|
|
720
|
+
footerListener = undefined
|
|
721
|
+
stop()
|
|
722
|
+
},
|
|
723
|
+
}
|
|
724
|
+
}
|