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.
- 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,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
|
+
})
|