opencode-tweaks 0.0.0-stage → 0.4.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,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
+ }