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,262 @@
1
+ import type { ModelInfo, SessionMessageInfo, TokenUsageInfo } from "@opencode/client"
2
+
3
+ export type { ModelInfo, SessionMessageInfo, TokenUsageInfo }
4
+
5
+ /** A zeroed usage record. Never mutated. */
6
+ export const EMPTY_TOKENS: TokenUsageInfo = {
7
+ input: 0,
8
+ output: 0,
9
+ reasoning: 0,
10
+ cache: { read: 0, write: 0 },
11
+ }
12
+
13
+ export function addTokens(a: TokenUsageInfo, b: TokenUsageInfo | undefined): TokenUsageInfo {
14
+ if (!b) return a
15
+ return {
16
+ input: a.input + b.input,
17
+ output: a.output + b.output,
18
+ reasoning: a.reasoning + b.reasoning,
19
+ cache: {
20
+ read: a.cache.read + b.cache.read,
21
+ write: a.cache.write + b.cache.write,
22
+ },
23
+ }
24
+ }
25
+
26
+ /** Every token the provider reported: fresh input, output, reasoning, cache reads and cache writes. */
27
+ export function totalTokens(tokens: TokenUsageInfo): number {
28
+ return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
29
+ }
30
+
31
+ /** User prompts in the session history. Synthetic guidance is a different message type. */
32
+ export function countTurns(messages: ReadonlyArray<SessionMessageInfo>): number {
33
+ return messages.reduce((count, message) => count + (message.type === "user" ? 1 : 0), 0)
34
+ }
35
+
36
+ /**
37
+ * Agent steps. OpenCode's own SessionStats counts assistant messages as steps, which is the default here.
38
+ * `tool` counts tool calls instead, for people who think of a step as one tool invocation.
39
+ */
40
+ export function countSteps(
41
+ messages: ReadonlyArray<SessionMessageInfo>,
42
+ mode: "assistant" | "tool" = "assistant",
43
+ ): number {
44
+ if (mode === "tool") {
45
+ return messages.reduce((count, message) => {
46
+ if (message.type !== "assistant") return count
47
+ return count + message.content.reduce((sum, part) => sum + (part.type === "tool" ? 1 : 0), 0)
48
+ }, 0)
49
+ }
50
+ return messages.reduce((count, message) => count + (message.type === "assistant" ? 1 : 0), 0)
51
+ }
52
+
53
+ /**
54
+ * Weighted generation speed over completed steps: visible output plus reasoning tokens, divided by the time
55
+ * between the step starting and the provider stream ending. `tokens` is only written when a step ends, so this
56
+ * is an average over finished steps, not a live counter. Steps that only produced tool calls are skipped.
57
+ */
58
+ export function throughput(messages: ReadonlyArray<SessionMessageInfo>): number | undefined {
59
+ let output = 0
60
+ let seconds = 0
61
+ for (const message of messages) {
62
+ if (message.type !== "assistant" || !message.tokens) continue
63
+ if (typeof message.time.streamed !== "number") continue
64
+ const generated = message.tokens.output + message.tokens.reasoning
65
+ if (generated <= 0) continue
66
+ const elapsed = (message.time.streamed - message.time.created) / 1_000
67
+ if (elapsed <= 0) continue
68
+ output += generated
69
+ seconds += elapsed
70
+ }
71
+ return seconds > 0 ? Math.round(output / seconds) : undefined
72
+ }
73
+
74
+ /** Speed of the most recent completed step that generated output. More responsive than the session average. */
75
+ export function lastThroughput(messages: ReadonlyArray<SessionMessageInfo>): number | undefined {
76
+ for (let index = messages.length - 1; index >= 0; index--) {
77
+ const message = messages[index]
78
+ if (!message || message.type !== "assistant" || !message.tokens) continue
79
+ if (typeof message.time.streamed !== "number") continue
80
+ const generated = message.tokens.output + message.tokens.reasoning
81
+ if (generated <= 0) continue
82
+ const elapsed = (message.time.streamed - message.time.created) / 1_000
83
+ if (elapsed <= 0) continue
84
+ return Math.round(generated / elapsed)
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ type AssistantStep = Extract<SessionMessageInfo, { type: "assistant" }>
90
+ type AssistantTool = Extract<AssistantStep["content"][number], { type: "tool" }>
91
+
92
+ export type StepActivity =
93
+ | { readonly kind: "waiting"; readonly elapsed: number }
94
+ | {
95
+ readonly kind: "streaming"
96
+ readonly elapsed: number
97
+ readonly estimatedTokens: number
98
+ readonly rate?: number
99
+ }
100
+ | { readonly kind: "tool"; readonly name: string; readonly elapsed: number }
101
+
102
+ /** The assistant step still being produced, if the session is between steps it is undefined. */
103
+ export function activeStep(messages: ReadonlyArray<SessionMessageInfo>): AssistantStep | undefined {
104
+ return messages.findLast(
105
+ (message): message is AssistantStep => message.type === "assistant" && message.time.completed === undefined,
106
+ )
107
+ }
108
+
109
+ const CJK =
110
+ /[\u2e80-\u2eff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/
111
+
112
+ /**
113
+ * Rough token estimate for text that is still streaming. CJK characters count as one token each and other
114
+ * characters as a quarter. It is only used for the live speed indicator, which is prefixed with "~".
115
+ */
116
+ export function estimateTextTokens(text: string): number {
117
+ let cjk = 0
118
+ let other = 0
119
+ for (const character of text) {
120
+ if (/\s/.test(character)) continue
121
+ if (CJK.test(character)) cjk++
122
+ else other++
123
+ }
124
+ return Math.ceil(cjk + other / 4)
125
+ }
126
+
127
+ /** Estimated generated tokens in a partially streamed step: text, reasoning, and tool-call input. */
128
+ export function estimateStepTokens(message: AssistantStep): number {
129
+ let total = 0
130
+ for (const part of message.content) {
131
+ if (part.type === "text" || part.type === "reasoning") {
132
+ total += estimateTextTokens(part.text)
133
+ continue
134
+ }
135
+ if (part.type !== "tool") continue
136
+ const input = part.state.status === "streaming" ? part.state.input : JSON.stringify(part.state.input)
137
+ total += Math.ceil((input?.length ?? 0) / 4)
138
+ }
139
+ return total
140
+ }
141
+
142
+ function runningTool(message: AssistantStep): AssistantTool | undefined {
143
+ return message.content.findLast(
144
+ (part): part is AssistantTool => part.type === "tool" && part.state.status === "running",
145
+ )
146
+ }
147
+
148
+ /** What the active step is doing now: waiting for output, streaming, or running a tool. */
149
+ export function stepActivity(
150
+ message: AssistantStep | undefined,
151
+ estimatedTokens: number,
152
+ now: number,
153
+ ): StepActivity | undefined {
154
+ if (!message) return
155
+ const elapsed = Math.max(0, (now - message.time.created) / 1_000)
156
+ const tool = runningTool(message)
157
+ if (tool) {
158
+ const started = tool.time.ran ?? tool.time.created
159
+ return { kind: "tool", name: tool.name, elapsed: Math.max(0, (now - started) / 1_000) }
160
+ }
161
+ if (estimatedTokens > 0) {
162
+ return {
163
+ kind: "streaming",
164
+ elapsed,
165
+ estimatedTokens,
166
+ rate: elapsed >= 2 ? Math.round(estimatedTokens / elapsed) : undefined,
167
+ }
168
+ }
169
+ return { kind: "waiting", elapsed }
170
+ }
171
+
172
+ /** Short elapsed label for a live indicator: 12s, 2m 5s, 1h 3m. */
173
+ export function formatDuration(seconds: number): string {
174
+ const value = Math.max(0, Math.floor(seconds))
175
+ if (value < 60) return `${value}s`
176
+ const minutes = Math.floor(value / 60)
177
+ if (minutes < 60) {
178
+ const rest = value % 60
179
+ return rest ? `${minutes}m ${rest}s` : `${minutes}m`
180
+ }
181
+ const hours = Math.floor(minutes / 60)
182
+ const rest = minutes % 60
183
+ return rest ? `${hours}h ${rest}m` : `${hours}h`
184
+ }
185
+
186
+
187
+ /** Cached input as a share of all input. Cache writes were misses, so they stay in the denominator. */
188
+ export function cacheHit(tokens: TokenUsageInfo): number | undefined {
189
+ const input = tokens.input + tokens.cache.read + tokens.cache.write
190
+ if (input <= 0) return undefined
191
+ return Math.max(0, Math.min(100, Math.round((tokens.cache.read / input) * 100)))
192
+ }
193
+
194
+ type AssistantWithUsage = Extract<SessionMessageInfo, { type: "assistant" }> & {
195
+ tokens: TokenUsageInfo
196
+ }
197
+
198
+ /**
199
+ * The assistant step whose tokens currently fill the context window: the latest one after the last completed
200
+ * compaction and before the undo boundary. This is the same selection the TUI and the desktop app use.
201
+ */
202
+ export function lastAssistantWithUsage(
203
+ messages: ReadonlyArray<SessionMessageInfo>,
204
+ boundary?: string,
205
+ ): AssistantWithUsage | undefined {
206
+ const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
207
+ if (boundary && boundaryIndex === -1) return undefined
208
+ const end = boundaryIndex === -1 ? messages.length : boundaryIndex
209
+ const compactionIndex = messages.findLastIndex(
210
+ (message, index) => message.type === "compaction" && message.status === "completed" && index < end,
211
+ )
212
+ return messages.findLast(
213
+ (message, index): message is AssistantWithUsage =>
214
+ message.type === "assistant" && message.tokens !== undefined && index > compactionIndex && index < end,
215
+ )
216
+ }
217
+
218
+ export function contextUsage(
219
+ messages: ReadonlyArray<SessionMessageInfo>,
220
+ models: ReadonlyArray<ModelInfo> | undefined,
221
+ boundary?: string,
222
+ ): { tokens: number; percent?: number } | undefined {
223
+ const message = lastAssistantWithUsage(messages, boundary)
224
+ if (!message) return
225
+ const tokens = totalTokens(message.tokens)
226
+ if (tokens <= 0) return
227
+ const model = models?.find(
228
+ (item) => item.providerID === message.model.providerID && item.id === message.model.id,
229
+ )
230
+ return {
231
+ tokens,
232
+ // Clamp like cacheHit: a model limit can be exceeded, but the bar should never read 103%.
233
+ percent: model?.limit.context
234
+ ? Math.max(0, Math.min(100, Math.round((tokens / model.limit.context) * 100)))
235
+ : undefined,
236
+ }
237
+ }
238
+
239
+ /**
240
+ * 1_234 -> "1.2K", 16_200_000 -> "16.2M". Keeps the compact look of a glanceable status line.
241
+ * A value that rounds up to 1000 in its unit moves to the next one (999_999 -> "1M", not "1000K").
242
+ */
243
+ export function compactNumber(value: number): string {
244
+ if (!Number.isFinite(value) || value < 0) return String(value)
245
+ const units: ReadonlyArray<readonly [number, string]> = [
246
+ [1_000, "K"],
247
+ [1_000_000, "M"],
248
+ [1_000_000_000, "B"],
249
+ [1_000_000_000_000, "T"],
250
+ ]
251
+ let index = -1
252
+ for (let candidate = 0; candidate < units.length; candidate++) {
253
+ if (value >= units[candidate]![0]) index = candidate
254
+ }
255
+ if (index === -1) return String(value)
256
+ let scaled = Number((value / units[index]![0]).toFixed(1))
257
+ if (scaled >= 1_000 && index + 1 < units.length) {
258
+ index++
259
+ scaled = Number((value / units[index]![0]).toFixed(1))
260
+ }
261
+ return `${scaled}${units[index]![1]}`
262
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,132 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Plugin } from "@opencode/plugin/tui"
3
+ import type { BoxRenderable } from "@opentui/core"
4
+ import { createSignal, Show } from "solid-js"
5
+ import { createGlance, resolveRate, resolveSteps, type RateMode, type StepsMode } from "./shared/glance"
6
+
7
+ import { plural, resolveLocale, translate, type Locale, type MessageKey, type Params } from "./i18n"
8
+
9
+ function GlanceBar(props: {
10
+ context: Plugin.Context
11
+ sessionID: string
12
+ locale: Locale
13
+ icons: boolean
14
+ stepsMode: StepsMode
15
+ rate: RateMode
16
+ }) {
17
+ const theme = props.context.theme
18
+ const t = (key: MessageKey, params?: Params) => translate(props.locale, key, params)
19
+ // A terminal does not always report its size before the first layout pass; starting from the real
20
+ // column count keeps the first frame in the full layout instead of flashing the compact one.
21
+ const [barWidth, setBarWidth] = createSignal(process.stdout?.columns ?? 0)
22
+
23
+ const glance = createGlance(
24
+ {
25
+ messages: () => props.context.data.session.message.list(props.sessionID),
26
+ session: () => props.context.data.session.get(props.sessionID),
27
+ family: () => {
28
+ const ids = props.context.data.session.family(props.sessionID)
29
+ return ids.length ? ids : [props.sessionID]
30
+ },
31
+ sessionTokens: (id) => props.context.data.session.get(id)?.tokens,
32
+ models: () =>
33
+ props.context.data.location.model.list(
34
+ props.context.data.session.get(props.sessionID)?.location ?? props.context.location,
35
+ ),
36
+ running: () => props.context.data.session.status(props.sessionID) === "running",
37
+ },
38
+ {
39
+ steps: props.stepsMode,
40
+ rate: props.rate,
41
+ t,
42
+ plural: (base, count) => plural(props.locale, base, count),
43
+ },
44
+ )
45
+
46
+ const layout = () => (barWidth() >= 94 ? "full" : barWidth() >= 64 ? "medium" : "compact")
47
+
48
+ const Clock = () => (props.icons ? <span style={{ fg: theme.text.muted }}>◷ </span> : null)
49
+ const Database = () => (props.icons ? <span style={{ fg: theme.text.muted }}>▤ </span> : null)
50
+ const Progress = () => (props.icons ? <span style={{ fg: theme.text.muted }}>◔ </span> : null)
51
+
52
+ return (
53
+ <box
54
+ border
55
+ borderStyle="rounded"
56
+ borderColor={theme.border.base}
57
+ backgroundColor={theme.background.raised.base}
58
+ marginBottom={1}
59
+ paddingLeft={2}
60
+ paddingRight={2}
61
+ flexDirection="row"
62
+ justifyContent="space-between"
63
+ gap={2}
64
+ onSizeChange={function (this: BoxRenderable) {
65
+ setBarWidth(this.width)
66
+ }}
67
+ >
68
+ <Show
69
+ when={layout() === "full"}
70
+ fallback={
71
+ <text fg={theme.text.base} wrapMode="none">
72
+ {layout() === "medium" ? glance.mediumText() : glance.compactText()}
73
+ </text>
74
+ }
75
+ >
76
+ <text fg={theme.text.base} wrapMode="none">
77
+ <Clock />
78
+ {glance.progressText()}
79
+ </text>
80
+ <text fg={theme.text.base} wrapMode="none">
81
+ <Database />
82
+ {glance.usageText()}
83
+ </text>
84
+ <text fg={theme.text.base} wrapMode="none">
85
+ <Progress />
86
+ {glance.contextText()}
87
+ </text>
88
+ </Show>
89
+ </box>
90
+ )
91
+ }
92
+
93
+ export default Plugin.define({
94
+ id: "opencode-glance.tui",
95
+ setup(context) {
96
+ const locale = resolveLocale(context.options.locale)
97
+ const [state, update] = context.storage.memory("opencode-glance", {
98
+ initial: { visible: context.options.show !== false },
99
+ })
100
+
101
+ context.ui.slot({
102
+ append: "session.composer.top",
103
+ render: ({ sessionID }) => (
104
+ <Show when={state.visible}>
105
+ <GlanceBar
106
+ context={context}
107
+ sessionID={sessionID}
108
+ locale={locale}
109
+ icons={context.options.icons !== false}
110
+ stepsMode={resolveSteps(context.options.steps)}
111
+ rate={resolveRate(context.options.rate)}
112
+ />
113
+ </Show>
114
+ ),
115
+ })
116
+
117
+ context.keymap.layer(() => ({
118
+ mode: "global",
119
+ commands: [
120
+ {
121
+ id: "opencode-glance.toggle",
122
+ title: translate(locale, "glance.command.toggle"),
123
+ palette: true,
124
+ run: () =>
125
+ update((draft) => {
126
+ draft.visible = !draft.visible
127
+ }),
128
+ },
129
+ ],
130
+ }))
131
+ },
132
+ })