opencode-glance 0.0.0-stage → 0.1.2

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,104 @@
1
+ /**
2
+ * The bar is injected as plain CSS instead of relying on Tailwind classes. A plugin bundle is not part of the
3
+ * app's Tailwind source scan, so utility class names that only appear here would not be generated. The
4
+ * `--v2-*` variables below are the same semantic tokens the app's own capsule pills use.
5
+ */
6
+ export const css = `
7
+ .glance-root {
8
+ display: flex;
9
+ justify-content: center;
10
+ padding: 4px 12px 8px;
11
+ pointer-events: none;
12
+ }
13
+
14
+ .glance-bar {
15
+ pointer-events: auto;
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: space-between;
19
+ gap: 16px;
20
+ width: 100%;
21
+ max-width: 1240px;
22
+ height: 36px;
23
+ padding: 0 18px;
24
+ border-radius: 999px;
25
+ border: 0.5px solid var(--v2-border-border-muted);
26
+ background: var(--v2-background-bg-base);
27
+ box-shadow: var(--v2-elevation-raised);
28
+ color: var(--v2-text-text-base);
29
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
30
+ font-size: 13px;
31
+ font-weight: 500;
32
+ line-height: 1;
33
+ letter-spacing: -0.04px;
34
+ white-space: nowrap;
35
+ overflow: hidden;
36
+ }
37
+
38
+ .glance-group {
39
+ display: inline-flex;
40
+ align-items: center;
41
+ gap: 7px;
42
+ min-width: 0;
43
+ }
44
+
45
+ .glance-group.center {
46
+ flex: 1;
47
+ justify-content: center;
48
+ }
49
+
50
+ .glance-group.end {
51
+ justify-content: flex-end;
52
+ }
53
+
54
+ .glance-icon {
55
+ display: inline-flex;
56
+ flex: none;
57
+ color: var(--v2-text-text-muted);
58
+ }
59
+
60
+ .glance-label {
61
+ overflow: hidden;
62
+ text-overflow: ellipsis;
63
+ }
64
+
65
+ .glance-muted {
66
+ color: var(--v2-text-text-muted);
67
+ }
68
+
69
+ .glance-ring {
70
+ position: relative;
71
+ display: inline-block;
72
+ flex: none;
73
+ width: 16px;
74
+ height: 16px;
75
+ border-radius: 50%;
76
+ color: var(--v2-icon-icon-base, var(--v2-text-text-muted));
77
+ }
78
+
79
+ .glance-ring::after {
80
+ content: "";
81
+ position: absolute;
82
+ inset: 3px;
83
+ border-radius: 50%;
84
+ background: var(--v2-background-bg-base);
85
+ }
86
+
87
+ @media (max-width: 760px) {
88
+ .glance-group.center {
89
+ display: none;
90
+ }
91
+ }
92
+
93
+ @media (max-width: 480px) {
94
+ .glance-root {
95
+ padding: 2px 8px 6px;
96
+ }
97
+
98
+ .glance-bar {
99
+ height: 30px;
100
+ padding: 0 12px;
101
+ font-size: 12px;
102
+ }
103
+ }
104
+ `
package/src/gui.tsx ADDED
@@ -0,0 +1,31 @@
1
+ import { Command, Plugin, Slot, Style } from "@opencode/plugin/gui"
2
+ import { createSignal } from "solid-js"
3
+ import { GlanceBar } from "./gui/glance"
4
+ import { css } from "./gui/styles"
5
+ import { messages } from "./i18n"
6
+
7
+ export default Plugin.define({
8
+ i18n: messages,
9
+ setup(ctx) {
10
+ const [visible, setVisible] = createSignal(ctx.options.show !== false)
11
+
12
+ // Keep the slot object stable so toggling visibility adds or removes the same contribution
13
+ // instead of remounting it on every reactive pass.
14
+ const bar = {
15
+ at: "window.bottom" as const,
16
+ order: 0,
17
+ render: () => <GlanceBar ctx={ctx} />,
18
+ }
19
+
20
+ ctx.add(Style, css)
21
+ ctx.add(Slot, () => (visible() ? bar : undefined))
22
+ ctx.add(Command, () => ({
23
+ id: "toggle",
24
+ title: ctx.t("glance.command.toggle"),
25
+ group: ctx.t("glance.command.group"),
26
+ run: () => {
27
+ setVisible((value) => !value)
28
+ },
29
+ }))
30
+ },
31
+ })
@@ -0,0 +1,233 @@
1
+ const en = {
2
+ "glance.turn.one": "{{count}} turn",
3
+ "glance.turn.other": "{{count}} turns",
4
+ "glance.step.one": "{{count}} step",
5
+ "glance.step.other": "{{count}} steps",
6
+ "glance.rate": "{{rate}} tok/s",
7
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
8
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
9
+ "glance.tokens": "{{tokens}} tok",
10
+ "glance.cache": "cache hit {{percent}}%",
11
+ "glance.context": "{{percent}}%",
12
+ "glance.tool": "{{name}} {{elapsed}}",
13
+ "glance.waiting": "waiting {{elapsed}}",
14
+ "glance.streaming": "generating {{elapsed}}",
15
+ "glance.preparing": "preparing",
16
+ "glance.command.toggle": "Glance: toggle status bar",
17
+ "glance.command.group": "Glance",
18
+ } as const
19
+
20
+ export type MessageKey = keyof typeof en
21
+ export type Catalog = Record<MessageKey, string>
22
+ export type Locale = "en" | "zh" | "zht" | "ja" | "ko" | "de" | "fr" | "es" | "pt" | "it"
23
+
24
+ export const locales: readonly Locale[] = ["en", "zh", "zht", "ja", "ko", "de", "fr", "es", "pt", "it"]
25
+
26
+ const zh: Catalog = {
27
+ "glance.turn.one": "{{count}} 轮",
28
+ "glance.turn.other": "{{count}} 轮",
29
+ "glance.step.one": "{{count}} 步",
30
+ "glance.step.other": "{{count}} 步",
31
+ "glance.rate": "{{rate}} tok/s",
32
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
33
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
34
+ "glance.tokens": "{{tokens}} tok",
35
+ "glance.cache": "缓存命中 {{percent}}%",
36
+ "glance.context": "{{percent}}%",
37
+ "glance.tool": "{{name}} {{elapsed}}",
38
+ "glance.waiting": "等待响应 {{elapsed}}",
39
+ "glance.streaming": "生成中 {{elapsed}}",
40
+ "glance.preparing": "准备中",
41
+ "glance.command.toggle": "Glance:显示/隐藏状态条",
42
+ "glance.command.group": "Glance",
43
+ }
44
+
45
+ const zht: Catalog = {
46
+ "glance.turn.one": "{{count}} 輪",
47
+ "glance.turn.other": "{{count}} 輪",
48
+ "glance.step.one": "{{count}} 步",
49
+ "glance.step.other": "{{count}} 步",
50
+ "glance.rate": "{{rate}} tok/s",
51
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
52
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
53
+ "glance.tokens": "{{tokens}} tok",
54
+ "glance.cache": "快取命中 {{percent}}%",
55
+ "glance.context": "{{percent}}%",
56
+ "glance.tool": "{{name}} {{elapsed}}",
57
+ "glance.waiting": "等待回應 {{elapsed}}",
58
+ "glance.streaming": "生成中 {{elapsed}}",
59
+ "glance.preparing": "準備中",
60
+ "glance.command.toggle": "Glance:顯示/隱藏狀態列",
61
+ "glance.command.group": "Glance",
62
+ }
63
+
64
+ const ja: Catalog = {
65
+ "glance.turn.one": "{{count}} ターン",
66
+ "glance.turn.other": "{{count}} ターン",
67
+ "glance.step.one": "{{count}} ステップ",
68
+ "glance.step.other": "{{count}} ステップ",
69
+ "glance.rate": "{{rate}} tok/s",
70
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
71
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
72
+ "glance.tokens": "{{tokens}} tok",
73
+ "glance.cache": "キャッシュヒット {{percent}}%",
74
+ "glance.context": "{{percent}}%",
75
+ "glance.tool": "{{name}} {{elapsed}}",
76
+ "glance.waiting": "応答待ち {{elapsed}}",
77
+ "glance.streaming": "生成中 {{elapsed}}",
78
+ "glance.preparing": "準備中",
79
+ "glance.command.toggle": "Glance:ステータスバーを表示/非表示",
80
+ "glance.command.group": "Glance",
81
+ }
82
+
83
+ const ko: Catalog = {
84
+ "glance.turn.one": "{{count}}턴",
85
+ "glance.turn.other": "{{count}}턴",
86
+ "glance.step.one": "{{count}}단계",
87
+ "glance.step.other": "{{count}}단계",
88
+ "glance.rate": "{{rate}} tok/s",
89
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
90
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
91
+ "glance.tokens": "{{tokens}} tok",
92
+ "glance.cache": "캐시 적중 {{percent}}%",
93
+ "glance.context": "{{percent}}%",
94
+ "glance.tool": "{{name}} {{elapsed}}",
95
+ "glance.waiting": "응답 대기 {{elapsed}}",
96
+ "glance.streaming": "생성 중 {{elapsed}}",
97
+ "glance.preparing": "준비 중",
98
+ "glance.command.toggle": "Glance: 상태 표시줄 표시/숨기기",
99
+ "glance.command.group": "Glance",
100
+ }
101
+
102
+ const de: Catalog = {
103
+ "glance.turn.one": "{{count}} Zug",
104
+ "glance.turn.other": "{{count}} Züge",
105
+ "glance.step.one": "{{count}} Schritt",
106
+ "glance.step.other": "{{count}} Schritte",
107
+ "glance.rate": "{{rate}} tok/s",
108
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
109
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
110
+ "glance.tokens": "{{tokens}} tok",
111
+ "glance.cache": "Cache-Treffer {{percent}}%",
112
+ "glance.context": "{{percent}}%",
113
+ "glance.tool": "{{name}} {{elapsed}}",
114
+ "glance.waiting": "warte auf Antwort {{elapsed}}",
115
+ "glance.streaming": "generiert {{elapsed}}",
116
+ "glance.preparing": "bereit",
117
+ "glance.command.toggle": "Glance: Statusleiste ein-/ausblenden",
118
+ "glance.command.group": "Glance",
119
+ }
120
+
121
+ const fr: Catalog = {
122
+ "glance.turn.one": "{{count}} tour",
123
+ "glance.turn.other": "{{count}} tours",
124
+ "glance.step.one": "{{count}} étape",
125
+ "glance.step.other": "{{count}} étapes",
126
+ "glance.rate": "{{rate}} tok/s",
127
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
128
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
129
+ "glance.tokens": "{{tokens}} tok",
130
+ "glance.cache": "cache {{percent}}%",
131
+ "glance.context": "{{percent}}%",
132
+ "glance.tool": "{{name}} {{elapsed}}",
133
+ "glance.waiting": "en attente de réponse {{elapsed}}",
134
+ "glance.streaming": "génération {{elapsed}}",
135
+ "glance.preparing": "préparation",
136
+ "glance.command.toggle": "Glance : afficher/masquer la barre d’état",
137
+ "glance.command.group": "Glance",
138
+ }
139
+
140
+ const es: Catalog = {
141
+ "glance.turn.one": "{{count}} turno",
142
+ "glance.turn.other": "{{count}} turnos",
143
+ "glance.step.one": "{{count}} paso",
144
+ "glance.step.other": "{{count}} pasos",
145
+ "glance.rate": "{{rate}} tok/s",
146
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
147
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
148
+ "glance.tokens": "{{tokens}} tok",
149
+ "glance.cache": "aciertos de caché {{percent}}%",
150
+ "glance.context": "{{percent}}%",
151
+ "glance.tool": "{{name}} {{elapsed}}",
152
+ "glance.waiting": "esperando respuesta {{elapsed}}",
153
+ "glance.streaming": "generando {{elapsed}}",
154
+ "glance.preparing": "preparando",
155
+ "glance.command.toggle": "Glance: mostrar/ocultar la barra de estado",
156
+ "glance.command.group": "Glance",
157
+ }
158
+
159
+ const pt: Catalog = {
160
+ "glance.turn.one": "{{count}} turno",
161
+ "glance.turn.other": "{{count}} turnos",
162
+ "glance.step.one": "{{count}} passo",
163
+ "glance.step.other": "{{count}} passos",
164
+ "glance.rate": "{{rate}} tok/s",
165
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
166
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
167
+ "glance.tokens": "{{tokens}} tok",
168
+ "glance.cache": "acertos de cache {{percent}}%",
169
+ "glance.context": "{{percent}}%",
170
+ "glance.tool": "{{name}} {{elapsed}}",
171
+ "glance.waiting": "aguardando resposta {{elapsed}}",
172
+ "glance.streaming": "gerando {{elapsed}}",
173
+ "glance.preparing": "preparando",
174
+ "glance.command.toggle": "Glance: mostrar/ocultar a barra de status",
175
+ "glance.command.group": "Glance",
176
+ }
177
+
178
+ const it: Catalog = {
179
+ "glance.turn.one": "{{count}} turno",
180
+ "glance.turn.other": "{{count}} turni",
181
+ "glance.step.one": "{{count}} passo",
182
+ "glance.step.other": "{{count}} passi",
183
+ "glance.rate": "{{rate}} tok/s",
184
+ "glance.rateElapsed": "{{rate}} tok/s · {{elapsed}}",
185
+ "glance.live": "~{{rate}} tok/s · {{elapsed}}",
186
+ "glance.tokens": "{{tokens}} tok",
187
+ "glance.cache": "hit cache {{percent}}%",
188
+ "glance.context": "{{percent}}%",
189
+ "glance.tool": "{{name}} {{elapsed}}",
190
+ "glance.waiting": "in attesa di risposta {{elapsed}}",
191
+ "glance.streaming": "generazione {{elapsed}}",
192
+ "glance.preparing": "preparazione",
193
+ "glance.command.toggle": "Glance: mostra/nascondi la barra di stato",
194
+ "glance.command.group": "Glance",
195
+ }
196
+
197
+ export const messages: Record<Locale, Catalog> = { en, zh, zht, ja, ko, de, fr, es, pt, it }
198
+
199
+ export type Params = Record<string, string | number | boolean>
200
+
201
+ /** Replaces {{name}} placeholders; unknown placeholders become empty strings. */
202
+ export function translate(locale: Locale, key: MessageKey, params?: Params): string {
203
+ const template = messages[locale]?.[key] ?? messages.en[key]
204
+ if (!params) return template
205
+ return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(params[name] ?? ""))
206
+ }
207
+
208
+ /** Plural form for a count, using the locale's categories and falling back to `.other`. */
209
+ export function plural(locale: Locale, base: "glance.turn" | "glance.step", count: number): string {
210
+ const category = new Intl.PluralRules(locale === "zht" ? "zh-TW" : locale).select(count)
211
+ const key = `${base}.${category}` as MessageKey
212
+ const resolved = Object.hasOwn(messages.en, key) ? key : (`${base}.other` as MessageKey)
213
+ return translate(locale, resolved, { count })
214
+ }
215
+
216
+ function matchLocale(tag: string): Locale {
217
+ const lower = tag.toLowerCase()
218
+ if (lower.includes("hant") || lower.startsWith("zh-tw") || lower.startsWith("zh-hk")) return "zht"
219
+ const base = lower.split("-")[0]
220
+ return (locales.includes(base as Locale) ? base : "en") as Locale
221
+ }
222
+
223
+ /** Resolves an explicit locale, or the system locale for "auto"/undefined. */
224
+ export function resolveLocale(value: unknown): Locale {
225
+ if (value === undefined || value === "auto") {
226
+ return matchLocale(Intl.DateTimeFormat().resolvedOptions().locale)
227
+ }
228
+ if (typeof value !== "string") return "en"
229
+ const lower = value.toLowerCase()
230
+ if (lower === "zht" || lower === "zh-tw" || lower === "zh-hk" || lower === "zh-hant") return "zht"
231
+ const base = lower.split("-")[0]
232
+ return locales.includes(base as Locale) ? (base as Locale) : "en"
233
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ /**
4
+ * The server part exists so the package has a stable plugin id, and so its GUI part is discovered by
5
+ * OpenCode Desktop. The statistics themselves are read from the client-side session store, so there is
6
+ * nothing to run on the server yet.
7
+ */
8
+ export default Plugin.define({
9
+ id: "opencode-glance",
10
+ async setup() {},
11
+ })
@@ -0,0 +1,240 @@
1
+ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
2
+ import type { ModelInfo, SessionInfo, SessionMessageInfo, TokenUsageInfo } from "@opencode/client"
3
+ import type { MessageKey, Params } from "../i18n"
4
+ import {
5
+ activeStep,
6
+ addTokens,
7
+ cacheHit,
8
+ compactNumber,
9
+ contextUsage,
10
+ countSteps,
11
+ countTurns,
12
+ EMPTY_TOKENS,
13
+ estimateStepTokens,
14
+ formatDuration,
15
+ lastThroughput,
16
+ stepActivity,
17
+ throughput,
18
+ totalTokens,
19
+ } from "./metrics"
20
+
21
+ export type StepsMode = "assistant" | "tool"
22
+ export type RateMode = "auto" | "average" | "last"
23
+
24
+ export type Translator = (key: MessageKey, params?: Params) => string
25
+ export type Pluralizer = (base: "glance.turn" | "glance.step", count: number) => string
26
+
27
+ /**
28
+ * The session data a front end can read, as reactive getters. The TUI reads the plugin context's data
29
+ * layer and the GUI reads the mounted session's, so the shapes differ; both adapt into this interface.
30
+ * Every getter is called inside a memo, so it must read the front end's reactive store.
31
+ */
32
+ export type GlanceSource = {
33
+ messages(): ReadonlyArray<SessionMessageInfo>
34
+ session(): SessionInfo | undefined
35
+ /** Session ids whose tokens belong to this session, including subagent families. */
36
+ family(): ReadonlyArray<string>
37
+ sessionTokens(id: string): TokenUsageInfo | undefined
38
+ models(): ReadonlyArray<ModelInfo> | undefined
39
+ running(): boolean
40
+ }
41
+
42
+ export type GlanceOptions = {
43
+ steps: StepsMode
44
+ rate: RateMode
45
+ t: Translator
46
+ plural: Pluralizer
47
+ }
48
+
49
+ export function resolveRate(value: unknown): RateMode {
50
+ if (value === "average" || value === "last" || value === "auto") return value
51
+ return "auto"
52
+ }
53
+
54
+ export function resolveSteps(value: unknown): StepsMode {
55
+ return value === "tool" ? "tool" : "assistant"
56
+ }
57
+
58
+ /** Truncates a tool name so a long MCP tool does not push the bar past its slot. */
59
+ export function shortName(name: string, length = 18): string {
60
+ return name.length > length ? `${name.slice(0, length - 1)}…` : name
61
+ }
62
+
63
+ /**
64
+ * A cheap identity for everything the history scans depend on. Message history is append-only and only the
65
+ * tail message mutates while it streams: it gains tokens and a completion time when its step ends. Earlier
66
+ * messages are settled, so the tail plus the length is enough to know when the scans must run again. This
67
+ * keeps a streaming delta from re-scanning thousands of settled messages on every token.
68
+ */
69
+ export function historyKey(messages: ReadonlyArray<SessionMessageInfo>): string {
70
+ const last = messages[messages.length - 1]
71
+ if (!last) return "0"
72
+ if (last.type === "assistant") {
73
+ return [
74
+ messages.length,
75
+ last.id,
76
+ "a",
77
+ last.time.completed ?? 0,
78
+ last.time.streamed ?? 0,
79
+ last.tokens?.input ?? 0,
80
+ last.tokens?.output ?? 0,
81
+ last.tokens?.cache.read ?? 0,
82
+ last.tokens?.cache.write ?? 0,
83
+ ].join("|")
84
+ }
85
+ if (last.type === "compaction") return `${messages.length}|${last.id}|c|${last.status}`
86
+ return `${messages.length}|${last.id}|${last.type}`
87
+ }
88
+
89
+ const present = (value: string | undefined): value is string => value !== undefined
90
+
91
+ export type SettledHistory = {
92
+ /** Identity of the message shape this snapshot was taken from. */
93
+ key: string
94
+ messages: ReadonlyArray<SessionMessageInfo>
95
+ }
96
+
97
+ /**
98
+ * Reuses the previous snapshot when the history key still holds, so a memo over it keeps its identity and the
99
+ * scans that depend on it stay inert while the active step streams.
100
+ */
101
+ export function settleHistory(
102
+ previous: SettledHistory | undefined,
103
+ messages: ReadonlyArray<SessionMessageInfo>,
104
+ ): SettledHistory {
105
+ const key = historyKey(messages)
106
+ return previous && previous.key === key ? previous : { key, messages }
107
+ }
108
+
109
+ /**
110
+ * Everything the TUI and the GUI render, from one shared source. Both front ends had their own copy of this
111
+ * layer; keeping it here means a new metric or a changed wording only has to be written once.
112
+ */
113
+ export function createGlance(source: GlanceSource, options: GlanceOptions) {
114
+ const [now, setNow] = createSignal(Date.now())
115
+ const [live, setLive] = createSignal<number>()
116
+
117
+ // Only tick while the session runs; this drives the live tool/step elapsed label.
118
+ createEffect(() => {
119
+ if (!source.running()) return
120
+ setNow(Date.now())
121
+ const timer = setInterval(() => setNow(Date.now()), 1_000)
122
+ onCleanup(() => clearInterval(timer))
123
+ })
124
+
125
+ // Settled history: re-read only when the message shape or the tail changes, not on every streamed delta.
126
+ // The snapshot object keeps its identity while the key holds, so the scans below do not rerun either.
127
+ const history = createMemo<SettledHistory>((previous) => settleHistory(previous, source.messages()))
128
+
129
+ const family = createMemo(() => source.family())
130
+ const totals = createMemo(() =>
131
+ family().reduce((total, id) => addTokens(total, source.sessionTokens(id)), EMPTY_TOKENS),
132
+ )
133
+
134
+ const usage = createMemo(() =>
135
+ contextUsage(history().messages, source.models(), source.session()?.revert?.messageID),
136
+ )
137
+ const turns = createMemo(() => countTurns(history().messages))
138
+ const steps = createMemo(() => countSteps(history().messages, options.steps))
139
+ const averageRate = createMemo(() => throughput(history().messages))
140
+ const latestRate = createMemo(() => lastThroughput(history().messages))
141
+ const hit = createMemo(() => cacheHit(totals()))
142
+ const running = createMemo(() => source.running())
143
+
144
+ // The active step must be read live: its content changes on every token. Reading the step and its
145
+ // content in one computation matters because the step object keeps its identity while it streams, so a
146
+ // memo that only depended on the step would stop tracking the text after the first delta.
147
+ const activity = createMemo(() => {
148
+ const step = activeStep(source.messages())
149
+ return stepActivity(step, step ? estimateStepTokens(step) : 0, now())
150
+ })
151
+
152
+ // Smooth the character-based estimate so the number does not jump on every delta.
153
+ createEffect(() => {
154
+ const current = activity()
155
+ if (!running() || !current || current.kind !== "streaming" || current.rate === undefined) {
156
+ setLive(undefined)
157
+ return
158
+ }
159
+ const raw = current.rate
160
+ setLive((previous) => (previous === undefined ? raw : Math.round(previous * 0.65 + raw * 0.35)))
161
+ })
162
+
163
+ const speed = createMemo(() => {
164
+ if (options.rate === "average") return averageRate()
165
+ return latestRate() ?? averageRate()
166
+ })
167
+
168
+ const turnText = createMemo(
169
+ () => `${options.plural("glance.turn", turns())} ${options.plural("glance.step", steps())}`,
170
+ )
171
+ const tokensText = createMemo(() =>
172
+ options.t("glance.tokens", { tokens: compactNumber(totalTokens(totals())) }),
173
+ )
174
+ const cacheText = createMemo(() => {
175
+ const value = hit()
176
+ return value === undefined ? undefined : options.t("glance.cache", { percent: value })
177
+ })
178
+ const contextText = createMemo(() => {
179
+ const value = usage()?.percent
180
+ return value === undefined ? "—" : options.t("glance.context", { percent: value })
181
+ })
182
+
183
+ const activityText = createMemo(() => {
184
+ const current = activity()
185
+ if (!running()) {
186
+ const value = speed()
187
+ return value === undefined ? undefined : options.t("glance.rate", { rate: value })
188
+ }
189
+ if (!current) return options.t("glance.preparing")
190
+ if (current.kind === "tool")
191
+ return options.t("glance.tool", { name: shortName(current.name), elapsed: formatDuration(current.elapsed) })
192
+ if (current.kind === "waiting")
193
+ return options.t("glance.waiting", { elapsed: formatDuration(current.elapsed) })
194
+ const elapsed = formatDuration(current.elapsed)
195
+ if (options.rate === "auto" && live() !== undefined)
196
+ return options.t("glance.live", { rate: live()!, elapsed })
197
+ const exact = options.rate === "average" ? averageRate() : latestRate() ?? averageRate()
198
+ return exact === undefined
199
+ ? options.t("glance.streaming", { elapsed })
200
+ : options.t("glance.rateElapsed", { rate: exact, elapsed })
201
+ })
202
+
203
+ const progressText = createMemo(() =>
204
+ activityText() ? `${turnText()} · ${activityText()}` : turnText(),
205
+ )
206
+ const usageText = createMemo(() =>
207
+ cacheText() ? `${tokensText()} · ${cacheText()}` : tokensText(),
208
+ )
209
+ const compactText = createMemo(() => `${turns()}/${steps()} · ${contextText()}`)
210
+ const mediumText = createMemo(() =>
211
+ (running()
212
+ ? [turnText(), activityText(), tokensText(), contextText()]
213
+ : [turnText(), activityText(), tokensText(), cacheText(), contextText()]
214
+ )
215
+ .filter(present)
216
+ .join(" · "),
217
+ )
218
+
219
+ return {
220
+ totals,
221
+ hit,
222
+ usage,
223
+ turns,
224
+ steps,
225
+ running,
226
+ speed,
227
+ activity,
228
+ turnText,
229
+ tokensText,
230
+ cacheText,
231
+ contextText,
232
+ activityText,
233
+ progressText,
234
+ usageText,
235
+ compactText,
236
+ mediumText,
237
+ }
238
+ }
239
+
240
+ export type Glance = ReturnType<typeof createGlance>