opencode-glance 0.0.0-stage → 0.1.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 +101 -2
- package/README.zh.md +184 -0
- package/dist/gui-experimental.cjs +99 -0
- package/package.json +66 -4
- package/src/gui/glance.tsx +127 -0
- package/src/gui/styles.ts +104 -0
- package/src/gui.tsx +31 -0
- package/src/i18n/index.ts +233 -0
- package/src/index.ts +11 -0
- package/src/shared/glance.ts +240 -0
- package/src/shared/metrics.ts +262 -0
- package/src/tui.tsx +132 -0
|
@@ -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.
|
|
145
|
+
const active = createMemo(() => activeStep(source.messages()))
|
|
146
|
+
const estimated = createMemo(() => {
|
|
147
|
+
const step = active()
|
|
148
|
+
return step ? estimateStepTokens(step) : 0
|
|
149
|
+
})
|
|
150
|
+
const activity = createMemo(() => stepActivity(active(), estimated(), now()))
|
|
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>
|