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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rosario Weynand
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # opencode-glance
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [中文](./README.zh.md) · English
4
+
5
+ A glanceable status bar for OpenCode v2, in both the terminal and Desktop:
6
+
7
+ ```
8
+ ◷ 7 turns 135 steps · 126 tok/s ▤ 16.2M tok · 94% cached ◔ 18%
9
+ ```
10
+
11
+ One package ships a **GUI** and a **TUI** part with the same metrics:
12
+
13
+ | Metric | Definition |
14
+ | --- | --- |
15
+ | Turns / steps | User messages / assistant messages in the session (steps can count tool calls instead) |
16
+ | tok/s | `Σ(output + reasoning) / Σ(step end - step start)` over completed steps; live estimation while streaming |
17
+ | Total tokens | `input + output + reasoning + cache.read + cache.write` across the session family |
18
+ | Cache hit | `cache.read / (input + cache.read + cache.write)` |
19
+ | Context | Last assistant step's token total / model `limit.context`, matching OpenCode's built-in indicator |
20
+
21
+ ## Install
22
+
23
+ From npm:
24
+
25
+ ```bash
26
+ opencode plugin add opencode-glance
27
+ ```
28
+
29
+ Or add it to the global config directly:
30
+
31
+ ```jsonc
32
+ // ~/.config/opencode/opencode.jsonc
33
+ {
34
+ "plugins": ["opencode-glance"]
35
+ }
36
+ ```
37
+
38
+ From a local checkout or GitHub:
39
+
40
+ ```jsonc
41
+ {
42
+ "plugins": ["file:///absolute/path/to/opencode-glance"]
43
+ }
44
+ ```
45
+
46
+ The server part is loaded from `opencode.json(c)`, which also makes the TUI part available. TUI-only options must be written in `cli.json` because the server inventory does not carry options for TUI parts:
47
+
48
+ ```jsonc
49
+ // ~/.config/opencode/cli.json
50
+ {
51
+ "plugins": [
52
+ {
53
+ "package": "opencode-glance",
54
+ "options": { "locale": "auto", "rate": "auto" }
55
+ },
56
+ "-opencode.sidebar.context"
57
+ ]
58
+ }
59
+ ```
60
+
61
+ `-opencode.sidebar.context` hides the built-in Context block in the TUI so the bar becomes its replacement. On Desktop the built-in Context indicator cannot be removed by third-party plugins; the bar is additive.
62
+
63
+ ## Options
64
+
65
+ | Option | Default | Description |
66
+ | --- | --- | --- |
67
+ | `show` | `true` | Show the bar at startup; toggle it from the command palette |
68
+ | `steps` | `"assistant"` | `"assistant"` counts assistant messages; `"tool"` counts tool calls |
69
+ | `rate` | `"auto"` | `"auto"`: live estimate while streaming, last exact step while idle. `"average"`: session average. `"last"`: last exact step |
70
+ | `icons` | `true` | TUI icons (`◷ ▤ ◔`) |
71
+ | `locale` | `"auto"` | TUI locale; `auto` follows the system language. GUI follows the app language |
72
+
73
+ ## i18n
74
+
75
+ Locales: `en`, `zh`, `zht`, `ja`, `ko`, `de`, `fr`, `es`, `pt`, `it`. Unsupported locales fall back to English. Turns and steps use `Intl.PluralRules` for correct plurals. Add a new catalog in `src/i18n/index.ts` and append it to `locales`.
76
+
77
+ The GUI follows the OpenCode Desktop/Web language. The TUI uses the system language by default and can be pinned with the `locale` option in `cli.json`.
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ npm install
83
+ npm run typecheck
84
+ npm test # unit tests for the metric functions
85
+ npm run build # build the GUI bundle
86
+ npm run dev # vite build --watch
87
+ ```
88
+
89
+ `dist/gui-experimental.cjs` is committed so a clone works with `file://` without building. Rebuild it after changing GUI code; CI fails when the committed bundle differs from a fresh build.
90
+
91
+ ## Live speed
92
+
93
+ OpenCode writes `tokens` only when a step ends, so an exact per-token rate is not exposed to plugins. `rate: "auto"` estimates tokens from streamed characters (CJK = 1 token, other characters = 1/4) once per second and prefixes the value with `~`. While a tool runs, the bar shows the tool and elapsed time instead of a meaningless speed.
94
+
95
+ ## Limitations
96
+
97
+ - The GUI part uses the experimental `./gui-experimental` API and may need updates when OpenCode changes.
98
+ - The TUI bar uses `session.composer.top`. Do not `replace` the whole `sidebar.content` slot; it also holds todo, MCP, and limit blocks.
99
+
100
+ ## License
101
+
102
+ MIT.
package/README.zh.md ADDED
@@ -0,0 +1,184 @@
1
+ # opencode-glance
2
+
3
+ 中文 · [English](./README.md)
4
+
5
+ 一个 OpenCode v2 插件,用一条胶囊状态条展示当前会话的关键指标:
6
+
7
+ ```
8
+ ◷ 7 轮 135 步 · 126 tok/s ▤ 16.2M tok · 缓存命中 94% ◔ 18%
9
+ ```
10
+
11
+ 同一个包同时提供 **GUI** 和 **TUI** 两个 part,指标口径一致:
12
+
13
+ | 指标 | 口径 |
14
+ | --- | --- |
15
+ | 轮 / 步 | 当前会话的 user 消息数 / assistant 消息数(可改为工具调用数) |
16
+ | tok/s | `Σ(output + reasoning) / Σ(step 结束 - step 开始)`,只统计已完成的 step |
17
+ | 总 tok | 当前会话及已知子 agent 会话的 `input + output + reasoning + cache.read + cache.write` |
18
+ | 缓存命中 | `cache.read / (input + cache.read + cache.write)` |
19
+ | 上下文 % | 最近一次 assistant step 的 token 总量 / 模型 `limit.context`,与内置 Context 指示器同一口径 |
20
+
21
+ ## 结构
22
+
23
+ ```
24
+ src/
25
+ ├─ index.ts # 最小 server part,提供插件 id
26
+ ├─ tui.tsx # TUI part:session.composer.top 上的圆角状态条
27
+ ├─ gui.tsx # GUI part:window.bottom 上的胶囊状态条
28
+ ├─ gui/
29
+ │ ├─ glance.tsx # 状态条组件
30
+ │ └─ styles.ts # 注入的 CSS(使用 --v2-* 主题变量)
31
+ ├─ shared/
32
+ │ ├─ metrics.ts # 两端共用的纯计算
33
+ │ └─ glance.ts # 两端共用的响应式聚合与文案
34
+ └─ i18n/ # 共用语言包(en / zh / zht / ja / ko / de / fr / es / pt / it)
35
+ ```
36
+
37
+ - TUI part 直接以 TSX 加载,改源码会热重载。
38
+ - GUI part 必须先构建成单个 CommonJS 文件:`dist/gui-experimental.cjs`。
39
+
40
+ ## 开发
41
+
42
+ ```bash
43
+ npm install
44
+ npm run typecheck
45
+ npm test # 指标纯函数的单元测试
46
+ npm run build # 构建 GUI
47
+ npm run dev # vite build --watch,改 GUI 代码自动重载
48
+ ```
49
+
50
+ `dist/gui-experimental.cjs` 已提交到仓库,便于 clone 后直接以 `file://` 方式安装;修改 GUI 源码后记得重新 `npm run build` 并一起提交。CI 会校验提交的 bundle 与重新构建的结果一致。
51
+
52
+ ## 安装
53
+
54
+ 从 npm 安装(发布后):
55
+
56
+ ```jsonc
57
+ // ~/.config/opencode/opencode.jsonc
58
+ {
59
+ "plugins": ["opencode-glance"]
60
+ }
61
+ ```
62
+
63
+ 也可以直接从 GitHub / 本地目录加载。`opencode.json(c)` 是 GUI 读取插件的地方;包里有 server part,所以 TUI part 也会随 server 加载。
64
+
65
+ ```jsonc
66
+ {
67
+ "plugins": [
68
+ "file:///absolute/path/to/opencode-glance"
69
+ ]
70
+ }
71
+ ```
72
+
73
+ 需要给 GUI 配置选项时使用对象形式(TUI 选项要写在下面的 `cli.json` 里):
74
+
75
+ ```jsonc
76
+ {
77
+ "plugins": [
78
+ {
79
+ "package": "file:///absolute/path/to/opencode-glance",
80
+ "options": {
81
+ "show": true,
82
+ "steps": "assistant",
83
+ "rate": "auto"
84
+ }
85
+ }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ ### TUI:去掉旧的 Context 块(可选)
91
+
92
+ TUI 的内置 Context 块可以被配置禁用,这样状态条就是真正的替代品。TUI 的插件选项必须写在 `cli.json` 里:通过 server 清单加载的 TUI part 不会携带 `options`。
93
+
94
+ ```jsonc
95
+ // ~/.config/opencode/cli.json
96
+ {
97
+ "plugins": [
98
+ {
99
+ "package": "opencode-glance",
100
+ "options": { "locale": "zh", "rate": "auto" }
101
+ },
102
+ "-opencode.sidebar.context"
103
+ ]
104
+ }
105
+ ```
106
+
107
+ 本地目录同样适用:
108
+
109
+ ```jsonc
110
+ {
111
+ "plugins": [
112
+ {
113
+ "package": "file:///absolute/path/to/opencode-glance",
114
+ "options": { "locale": "zh", "rate": "auto" }
115
+ },
116
+ "-opencode.sidebar.context"
117
+ ]
118
+ }
119
+ ```
120
+
121
+ ### GUI 的限制
122
+
123
+ GUI 的内置 Context 指示器属于 app 内置插件,**第三方 GUI 插件无法移除它**(GUI slot 只支持追加/排序,内置插件在设置里也没有开关)。所以桌面端是“新增状态条 + 旧指示器保留”。如果必须让旧 Context 消失,请使用 TUI。
124
+
125
+ ## 命令
126
+
127
+ 两个界面都注册了 `Glance: toggle status bar` 命令,可在命令面板里显示/隐藏状态条。
128
+
129
+ ## 选项
130
+
131
+ | 选项 | 默认 | 说明 |
132
+ | --- | --- | --- |
133
+ | `show` | `true` | 启动时是否显示状态条;之后可用命令切换 |
134
+ | `steps` | `"assistant"` | `"assistant"` 按 assistant 消息计步,`"tool"` 按工具调用计步 |
135
+ | `rate` | `"auto"` | `"auto"` 运行中用流式文本估算实时速度(带 `~`),空闲显示最近一步真实速度;`"average"` 始终用全历史平均;`"last"` 始终用最近一步真实速度 |
136
+ | `icons` | `true` | TUI 是否显示 `◷ ▤ ◔` 图标 |
137
+ | `locale` | `"auto"` | TUI 文案:`"auto"` 跟随系统语言,也可指定 `zh`、`zht`、`en`、`ja`、`ko`、`de`、`fr`、`es`、`pt`、`it`。GUI 跟随 app 语言 |
138
+
139
+ ## 国际化
140
+
141
+ - **GUI**:跟随 OpenCode Desktop / Web 的界面语言,由 `Plugin.define({ i18n: messages })` 提供。
142
+ - **TUI**:默认 `locale: "auto"`,按系统语言匹配;也可在 `cli.json` 的 options 里指定。
143
+ - 当前语言包:`en`、`zh`、`zht`、`ja`、`ko`、`de`、`fr`、`es`、`pt`、`it`。
144
+ - 其他语言回退英文。新增语言只需要在 `src/i18n/index.ts` 加一份 `Catalog`,并加入 `locales`。
145
+ - 轮/步使用了 `Intl.PluralRules`,英文、法文、德文等单复数形式正确。
146
+
147
+ ## 指标细节
148
+
149
+ - `steps` 默认按 OpenCode `SessionStats` 的口径统计 assistant 消息;设置 `"steps": "tool"` 可改为统计工具调用数。
150
+ - `tok/s` 的严格实时值拿不到:OpenCode 只在 step 结束时写入 `tokens`。`rate: "auto"` 在运行中用已流出的文本估算 token(CJK 按字、其他字符按 1/4),每秒刷新并带 `~` 前缀;空闲时回落到最近一步的真实速度。运行工具时显示工具名和已用时,而不是速度。
151
+ - 运行中的状态条会优先显示当前动作:工具执行显示 `bash 8s`,等待首 token 显示 `等待响应 3s`,流式生成显示 `~131 tok/s · 12s`。
152
+ - 速度分母是 `time.streamed - time.created`,包含首 token 等待时间和请求分发,因此是端到端生成速度,不是纯解码速度。
153
+ - 总 token 和缓存命中按 session family 汇总(根会话 + 已同步的子 agent),与内置 cost 的 family 口径一致。
154
+ - 上下文百分比只取根会话最近一次有效 assistant step,且跳过已压缩内容和 undo 边界之后的消息,与内置 Context 面板一致。
155
+ - 无数据时显示 `—`,不会出现 NaN。
156
+
157
+ ## 发布
158
+
159
+ OpenCode 官方没有集中式插件市场,分发路径是:
160
+
161
+ 1. **npm**:`npm publish --access public`,用户用 `opencode plugin add opencode-glance`。
162
+ 2. **opencode.im**:发布到 npm 后,它会按 `opencode-plugin` keyword 自动发现;也可以手动提交。
163
+ 3. **官方 Ecosystem 列表**:向 `anomalyco/opencode` 的 `packages/web/src/content/docs/ecosystem.mdx` 提交 PR。
164
+ 4. **awesome-opencode**:向社区列表提交 PR。
165
+
166
+ 本仓库已包含 `opencode-plugin` keyword、MIT License 和 CI。发布前:
167
+
168
+ ```bash
169
+ npm login
170
+ npm publish --access public
171
+ ```
172
+
173
+ 也可以用 tag 触发 `.github/workflows/release.yml`:先配置 `NPM_TOKEN` secret,然后 `git tag v0.1.0 && git push --tags`。
174
+
175
+ `prepublishOnly` 会先跑 `typecheck` 和 GUI 构建。
176
+
177
+ ## 许可证
178
+
179
+ MIT。见 [LICENSE](./LICENSE)。
180
+
181
+ ## 注意
182
+
183
+ - GUI part 使用 experimental API(`./gui-experimental`),OpenCode 小版本升级可能需要跟进。
184
+ - TUI 状态条依赖 `session.composer.top` slot;不要把 `sidebar.content` 整体 `replace`,否则会连带移除内置 todo、MCP、limit 块。
@@ -0,0 +1,99 @@
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const l=require("solid-js/web"),G=require("@opencode/plugin/gui"),c=require("solid-js"),q={input:0,output:0,reasoning:0,cache:{read:0,write:0}};function B(t,e){return e?{input:t.input+e.input,output:t.output+e.output,reasoning:t.reasoning+e.reasoning,cache:{read:t.cache.read+e.cache.read,write:t.cache.write+e.cache.write}}:t}function E(t){return t.input+t.output+t.reasoning+t.cache.read+t.cache.write}function K(t){return t.reduce((e,n)=>e+(n.type==="user"?1:0),0)}function P(t,e="assistant"){return e==="tool"?t.reduce((n,r)=>r.type!=="assistant"?n:n+r.content.reduce((a,o)=>a+(o.type==="tool"?1:0),0),0):t.reduce((n,r)=>n+(r.type==="assistant"?1:0),0)}function R(t){let e=0,n=0;for(const r of t){if(r.type!=="assistant"||!r.tokens||typeof r.time.streamed!="number")continue;const a=r.tokens.output+r.tokens.reasoning;if(a<=0)continue;const o=(r.time.streamed-r.time.created)/1e3;o<=0||(e+=a,n+=o)}return n>0?Math.round(e/n):void 0}function F(t){for(let e=t.length-1;e>=0;e--){const n=t[e];if(!n||n.type!=="assistant"||!n.tokens||typeof n.time.streamed!="number")continue;const r=n.tokens.output+n.tokens.reasoning;if(r<=0)continue;const a=(n.time.streamed-n.time.created)/1e3;if(!(a<=0))return Math.round(r/a)}}function J(t){return t.findLast(e=>e.type==="assistant"&&e.time.completed===void 0)}const O=/[\u2e80-\u2eff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;function U(t){let e=0,n=0;for(const r of t)/\s/.test(r)||(O.test(r)?e++:n++);return Math.ceil(e+n/4)}function V(t){let e=0;for(const n of t.content){if(n.type==="text"||n.type==="reasoning"){e+=U(n.text);continue}if(n.type!=="tool")continue;const r=n.state.status==="streaming"?n.state.input:JSON.stringify(n.state.input);e+=Math.ceil((r?.length??0)/4)}return e}function H(t){return t.content.findLast(e=>e.type==="tool"&&e.state.status==="running")}function Z(t,e,n){if(!t)return;const r=Math.max(0,(n-t.time.created)/1e3),a=H(t);if(a){const o=a.time.ran??a.time.created;return{kind:"tool",name:a.name,elapsed:Math.max(0,(n-o)/1e3)}}return e>0?{kind:"streaming",elapsed:r,estimatedTokens:e,rate:r>=2?Math.round(e/r):void 0}:{kind:"waiting",elapsed:r}}function _(t){const e=Math.max(0,Math.floor(t));if(e<60)return`${e}s`;const n=Math.floor(e/60);if(n<60){const o=e%60;return o?`${n}m ${o}s`:`${n}m`}const r=Math.floor(n/60),a=n%60;return a?`${r}h ${a}m`:`${r}h`}function W(t){const e=t.input+t.cache.read+t.cache.write;if(!(e<=0))return Math.max(0,Math.min(100,Math.round(t.cache.read/e*100)))}function Y(t,e){const n=e?t.findIndex(o=>o.id===e):-1;if(e&&n===-1)return;const r=n===-1?t.length:n,a=t.findLastIndex((o,i)=>o.type==="compaction"&&o.status==="completed"&&i<r);return t.findLast((o,i)=>o.type==="assistant"&&o.tokens!==void 0&&i>a&&i<r)}function Q(t,e,n){const r=Y(t,n);if(!r)return;const a=E(r.tokens);if(a<=0)return;const o=e?.find(i=>i.providerID===r.model.providerID&&i.id===r.model.id);return{tokens:a,percent:o?.limit.context?Math.max(0,Math.min(100,Math.round(a/o.limit.context*100))):void 0}}function X(t){if(!Number.isFinite(t)||t<0)return String(t);const e=[[1e3,"K"],[1e6,"M"],[1e9,"B"],[1e12,"T"]];let n=-1;for(let a=0;a<e.length;a++)t>=e[a][0]&&(n=a);if(n===-1)return String(t);let r=Number((t/e[n][0]).toFixed(1));return r>=1e3&&n+1<e.length&&(n++,r=Number((t/e[n][0]).toFixed(1))),`${r}${e[n][1]}`}function ee(t){return t==="average"||t==="last"||t==="auto"?t:"auto"}function te(t){return t==="tool"?"tool":"assistant"}function ne(t,e=18){return t.length>e?`${t.slice(0,e-1)}…`:t}function ae(t){const e=t[t.length-1];return e?e.type==="assistant"?[t.length,e.id,"a",e.time.completed??0,e.time.streamed??0,e.tokens?.input??0,e.tokens?.output??0,e.tokens?.cache.read??0,e.tokens?.cache.write??0].join("|"):e.type==="compaction"?`${t.length}|${e.id}|c|${e.status}`:`${t.length}|${e.id}|${e.type}`:"0"}const re=t=>t!==void 0;function oe(t,e){const n=ae(e);return t&&t.key===n?t:{key:n,messages:e}}function ce(t,e){const[n,r]=c.createSignal(Date.now()),[a,o]=c.createSignal();c.createEffect(()=>{if(!t.running())return;r(Date.now());const s=setInterval(()=>r(Date.now()),1e3);c.onCleanup(()=>clearInterval(s))});const i=c.createMemo(s=>oe(s,t.messages())),$=c.createMemo(()=>t.family()),f=c.createMemo(()=>$().reduce((s,u)=>B(s,t.sessionTokens(u)),q)),h=c.createMemo(()=>Q(i().messages,t.models(),t.session()?.revert?.messageID)),m=c.createMemo(()=>K(i().messages)),k=c.createMemo(()=>P(i().messages,e.steps)),d=c.createMemo(()=>R(i().messages)),x=c.createMemo(()=>F(i().messages)),g=c.createMemo(()=>W(f())),p=c.createMemo(()=>t.running()),v=c.createMemo(()=>J(t.messages())),D=c.createMemo(()=>{const s=v();return s?V(s):0}),C=c.createMemo(()=>Z(v(),D(),n()));c.createEffect(()=>{const s=C();if(!p()||!s||s.kind!=="streaming"||s.rate===void 0){o(void 0);return}const u=s.rate;o(M=>M===void 0?u:Math.round(M*.65+u*.35))});const I=c.createMemo(()=>e.rate==="average"?d():x()??d()),w=c.createMemo(()=>`${e.plural("glance.turn",m())} ${e.plural("glance.step",k())}`),b=c.createMemo(()=>e.t("glance.tokens",{tokens:X(E(f()))})),T=c.createMemo(()=>{const s=g();return s===void 0?void 0:e.t("glance.cache",{percent:s})}),S=c.createMemo(()=>{const s=h()?.percent;return s===void 0?"—":e.t("glance.context",{percent:s})}),y=c.createMemo(()=>{const s=C();if(!p()){const j=I();return j===void 0?void 0:e.t("glance.rate",{rate:j})}if(!s)return e.t("glance.preparing");if(s.kind==="tool")return e.t("glance.tool",{name:ne(s.name),elapsed:_(s.elapsed)});if(s.kind==="waiting")return e.t("glance.waiting",{elapsed:_(s.elapsed)});const u=_(s.elapsed);if(e.rate==="auto"&&a()!==void 0)return e.t("glance.live",{rate:a(),elapsed:u});const M=e.rate==="average"?d():x()??d();return M===void 0?e.t("glance.streaming",{elapsed:u}):e.t("glance.rateElapsed",{rate:M,elapsed:u})}),N=c.createMemo(()=>y()?`${w()} · ${y()}`:w()),L=c.createMemo(()=>T()?`${b()} · ${T()}`:b()),z=c.createMemo(()=>`${m()}/${k()} · ${S()}`),A=c.createMemo(()=>(p()?[w(),y(),b(),S()]:[w(),y(),b(),T(),S()]).filter(re).join(" · "));return{totals:f,hit:g,usage:h,turns:m,steps:k,running:p,speed:I,activity:C,turnText:w,tokensText:b,cacheText:T,contextText:S,activityText:y,progressText:N,usageText:L,compactText:z,mediumText:A}}var se=l.template('<svg width=14 height=14 viewBox="0 0 16 16"fill=none stroke=currentColor stroke-width=1.4 stroke-linecap=round stroke-linejoin=round aria-hidden=true><circle cx=8 cy=8 r=6></circle><path d="M8 4.5V8l2.4 1.5">'),le=l.template('<svg width=14 height=14 viewBox="0 0 16 16"fill=none stroke=currentColor stroke-width=1.4 stroke-linecap=round stroke-linejoin=round aria-hidden=true><ellipse cx=8 cy=4.3 rx=5 ry=2.1></ellipse><path d="M3 4.3v7.4c0 1.2 2.2 2.1 5 2.1s5-.9 5-2.1V4.3"></path><path d="M3 8c0 1.2 2.2 2.1 5 2.1s5-.9 5-2.1">'),ie=l.template("<span class=glance-ring aria-hidden=true>"),ge=l.template('<div class=glance-root><div class=glance-bar role=status><span class=glance-group><span class=glance-icon></span><span class=glance-label></span></span><span class="glance-group center"><span class=glance-icon></span><span class=glance-label></span></span><span class="glance-group end"><span class=glance-label>');function ue(){return se()}function de(){return le()}function pe(t){const e=()=>Math.max(0,Math.min(100,t.percent??0))*3.6;return(()=>{var n=ie();return l.effect(r=>l.setStyleProperty(n,"background",`conic-gradient(currentColor ${e()}deg, var(--v2-background-bg-layer-04) 0)`)),n})()}function me(t){const e=t.ctx,n=()=>e.sessions.current(),r=ce({messages:()=>{const a=n();return a?.id?a.server.data.session.message.list(a.id):[]},session:()=>{const a=n();return a?.id?a.server.data.session.get(a.id):void 0},family:()=>{const a=n();if(!a?.id)return[];const o=a.server.data.session.family(a.id);return o.length?o:[a.id]},sessionTokens:a=>n()?.server.data.session.get(a)?.tokens,models:()=>{const a=n();return a?a.server.data.location.model.list(a.location):void 0},running:()=>{const a=n();return a?.id?a.server.data.session.status(a.id)==="running":!1}},{steps:te(e.options.steps),rate:ee(e.options.rate),t:(a,o)=>e.t(a,o),plural:(a,o)=>e.plural(a,o)});return l.createComponent(c.Show,{get when(){return n()},get children(){var a=ge(),o=a.firstChild,i=o.firstChild,$=i.firstChild,f=$.nextSibling,h=i.nextSibling,m=h.firstChild,k=m.nextSibling,d=h.nextSibling,x=d.firstChild;return l.insert($,l.createComponent(ue,{})),l.insert(f,()=>r.progressText()),l.insert(m,l.createComponent(de,{})),l.insert(k,()=>r.usageText()),l.insert(d,l.createComponent(pe,{get percent(){return r.usage()?.percent}}),x),l.insert(x,()=>r.contextText()),l.effect(g=>{var p=`${r.progressText()} · ${r.usageText()} · ${r.contextText()}`,v=`${E(r.totals()).toLocaleString()} tokens · ${(r.usage()?.tokens??0).toLocaleString()} in context`;return p!==g.e&&l.setAttribute(o,"aria-label",g.e=p),v!==g.t&&l.setAttribute(o,"title",g.t=v),g},{e:void 0,t:void 0}),a}})}const fe=`
2
+ .glance-root {
3
+ display: flex;
4
+ justify-content: center;
5
+ padding: 4px 12px 8px;
6
+ pointer-events: none;
7
+ }
8
+
9
+ .glance-bar {
10
+ pointer-events: auto;
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: space-between;
14
+ gap: 16px;
15
+ width: 100%;
16
+ max-width: 1240px;
17
+ height: 36px;
18
+ padding: 0 18px;
19
+ border-radius: 999px;
20
+ border: 0.5px solid var(--v2-border-border-muted);
21
+ background: var(--v2-background-bg-base);
22
+ box-shadow: var(--v2-elevation-raised);
23
+ color: var(--v2-text-text-base);
24
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
25
+ font-size: 13px;
26
+ font-weight: 500;
27
+ line-height: 1;
28
+ letter-spacing: -0.04px;
29
+ white-space: nowrap;
30
+ overflow: hidden;
31
+ }
32
+
33
+ .glance-group {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ gap: 7px;
37
+ min-width: 0;
38
+ }
39
+
40
+ .glance-group.center {
41
+ flex: 1;
42
+ justify-content: center;
43
+ }
44
+
45
+ .glance-group.end {
46
+ justify-content: flex-end;
47
+ }
48
+
49
+ .glance-icon {
50
+ display: inline-flex;
51
+ flex: none;
52
+ color: var(--v2-text-text-muted);
53
+ }
54
+
55
+ .glance-label {
56
+ overflow: hidden;
57
+ text-overflow: ellipsis;
58
+ }
59
+
60
+ .glance-muted {
61
+ color: var(--v2-text-text-muted);
62
+ }
63
+
64
+ .glance-ring {
65
+ position: relative;
66
+ display: inline-block;
67
+ flex: none;
68
+ width: 16px;
69
+ height: 16px;
70
+ border-radius: 50%;
71
+ color: var(--v2-icon-icon-base, var(--v2-text-text-muted));
72
+ }
73
+
74
+ .glance-ring::after {
75
+ content: "";
76
+ position: absolute;
77
+ inset: 3px;
78
+ border-radius: 50%;
79
+ background: var(--v2-background-bg-base);
80
+ }
81
+
82
+ @media (max-width: 760px) {
83
+ .glance-group.center {
84
+ display: none;
85
+ }
86
+ }
87
+
88
+ @media (max-width: 480px) {
89
+ .glance-root {
90
+ padding: 2px 8px 6px;
91
+ }
92
+
93
+ .glance-bar {
94
+ height: 30px;
95
+ padding: 0 12px;
96
+ font-size: 12px;
97
+ }
98
+ }
99
+ `,he={"glance.turn.one":"{{count}} turn","glance.turn.other":"{{count}} turns","glance.step.one":"{{count}} step","glance.step.other":"{{count}} steps","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"cache hit {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"waiting {{elapsed}}","glance.streaming":"generating {{elapsed}}","glance.preparing":"preparing","glance.command.toggle":"Glance: toggle status bar","glance.command.group":"Glance"},ke={"glance.turn.one":"{{count}} 轮","glance.turn.other":"{{count}} 轮","glance.step.one":"{{count}} 步","glance.step.other":"{{count}} 步","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"缓存命中 {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"等待响应 {{elapsed}}","glance.streaming":"生成中 {{elapsed}}","glance.preparing":"准备中","glance.command.toggle":"Glance:显示/隐藏状态条","glance.command.group":"Glance"},xe={"glance.turn.one":"{{count}} 輪","glance.turn.other":"{{count}} 輪","glance.step.one":"{{count}} 步","glance.step.other":"{{count}} 步","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"快取命中 {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"等待回應 {{elapsed}}","glance.streaming":"生成中 {{elapsed}}","glance.preparing":"準備中","glance.command.toggle":"Glance:顯示/隱藏狀態列","glance.command.group":"Glance"},ve={"glance.turn.one":"{{count}} ターン","glance.turn.other":"{{count}} ターン","glance.step.one":"{{count}} ステップ","glance.step.other":"{{count}} ステップ","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"キャッシュヒット {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"応答待ち {{elapsed}}","glance.streaming":"生成中 {{elapsed}}","glance.preparing":"準備中","glance.command.toggle":"Glance:ステータスバーを表示/非表示","glance.command.group":"Glance"},we={"glance.turn.one":"{{count}}턴","glance.turn.other":"{{count}}턴","glance.step.one":"{{count}}단계","glance.step.other":"{{count}}단계","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"캐시 적중 {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"응답 대기 {{elapsed}}","glance.streaming":"생성 중 {{elapsed}}","glance.preparing":"준비 중","glance.command.toggle":"Glance: 상태 표시줄 표시/숨기기","glance.command.group":"Glance"},be={"glance.turn.one":"{{count}} Zug","glance.turn.other":"{{count}} Züge","glance.step.one":"{{count}} Schritt","glance.step.other":"{{count}} Schritte","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"Cache-Treffer {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"warte auf Antwort {{elapsed}}","glance.streaming":"generiert {{elapsed}}","glance.preparing":"bereit","glance.command.toggle":"Glance: Statusleiste ein-/ausblenden","glance.command.group":"Glance"},ye={"glance.turn.one":"{{count}} tour","glance.turn.other":"{{count}} tours","glance.step.one":"{{count}} étape","glance.step.other":"{{count}} étapes","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"cache {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"en attente de réponse {{elapsed}}","glance.streaming":"génération {{elapsed}}","glance.preparing":"préparation","glance.command.toggle":"Glance : afficher/masquer la barre d’état","glance.command.group":"Glance"},Me={"glance.turn.one":"{{count}} turno","glance.turn.other":"{{count}} turnos","glance.step.one":"{{count}} paso","glance.step.other":"{{count}} pasos","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"aciertos de caché {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"esperando respuesta {{elapsed}}","glance.streaming":"generando {{elapsed}}","glance.preparing":"preparando","glance.command.toggle":"Glance: mostrar/ocultar la barra de estado","glance.command.group":"Glance"},$e={"glance.turn.one":"{{count}} turno","glance.turn.other":"{{count}} turnos","glance.step.one":"{{count}} passo","glance.step.other":"{{count}} passos","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"acertos de cache {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"aguardando resposta {{elapsed}}","glance.streaming":"gerando {{elapsed}}","glance.preparing":"preparando","glance.command.toggle":"Glance: mostrar/ocultar a barra de status","glance.command.group":"Glance"},Te={"glance.turn.one":"{{count}} turno","glance.turn.other":"{{count}} turni","glance.step.one":"{{count}} passo","glance.step.other":"{{count}} passi","glance.rate":"{{rate}} tok/s","glance.rateElapsed":"{{rate}} tok/s · {{elapsed}}","glance.live":"~{{rate}} tok/s · {{elapsed}}","glance.tokens":"{{tokens}} tok","glance.cache":"hit cache {{percent}}%","glance.context":"{{percent}}%","glance.tool":"{{name}} {{elapsed}}","glance.waiting":"in attesa di risposta {{elapsed}}","glance.streaming":"generazione {{elapsed}}","glance.preparing":"preparazione","glance.command.toggle":"Glance: mostra/nascondi la barra di stato","glance.command.group":"Glance"},Se={en:he,zh:ke,zht:xe,ja:ve,ko:we,de:be,fr:ye,es:Me,pt:$e,it:Te},Ge=G.Plugin.define({i18n:Se,setup(t){const[e,n]=c.createSignal(t.options.show!==!1),r={at:"window.bottom",order:0,render:()=>l.createComponent(me,{ctx:t})};t.add(G.Style,fe),t.add(G.Slot,()=>e()?r:void 0),t.add(G.Command,()=>({id:"toggle",title:t.t("glance.command.toggle"),group:t.t("glance.command.group"),run:()=>{n(a=>!a)}}))}});exports.default=Ge;
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package.json",
2
3
  "name": "opencode-glance",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
4
+ "version": "0.1.1",
5
+ "description": "Glanceable session stats for OpenCode: turns, steps, throughput, tokens, cache hit, context usage, and live tool activity.",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "Rosario Weynand",
9
+ "homepage": "https://github.com/penn0312/opencode-glance#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/penn0312/opencode-glance.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/penn0312/opencode-glance/issues"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "statusline",
21
+ "tokens",
22
+ "cache",
23
+ "context",
24
+ "tui",
25
+ "desktop"
26
+ ],
27
+ "exports": {
28
+ ".": "./src/index.ts",
29
+ "./tui": "./src/tui.tsx",
30
+ "./gui-experimental": "./dist/gui-experimental.cjs"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "dist",
35
+ "README.md",
36
+ "README.zh.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "vite build",
41
+ "dev": "vite build --watch",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit",
44
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@opencode/plugin": "^2.2.0"
51
+ },
52
+ "peerDependencies": {
53
+ "@opentui/core": ">=0.7.0",
54
+ "@opentui/solid": ">=0.7.0",
55
+ "solid-js": ">=1.9.0"
56
+ },
57
+ "devDependencies": {
58
+ "@opencode/client": "^2.2.0",
59
+ "@opentui/core": "0.7.0",
60
+ "@opentui/solid": "0.7.0",
61
+ "@types/node": "^22.0.0",
62
+ "solid-js": "^1.9.0",
63
+ "typescript": "^5.7.0",
64
+ "vite": "^7.0.0",
65
+ "vite-plugin-solid": "^2.11.0",
66
+ "vitest": "^3.2.7"
67
+ }
68
+ }
@@ -0,0 +1,127 @@
1
+ import { Show } from "solid-js"
2
+ import type { Context } from "@opencode/plugin/gui"
3
+ import { createGlance, resolveRate, resolveSteps } from "../shared/glance"
4
+ import { totalTokens } from "../shared/metrics"
5
+
6
+ function ClockIcon() {
7
+ return (
8
+ <svg
9
+ width="14"
10
+ height="14"
11
+ viewBox="0 0 16 16"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ stroke-width="1.4"
15
+ stroke-linecap="round"
16
+ stroke-linejoin="round"
17
+ aria-hidden="true"
18
+ >
19
+ <circle cx="8" cy="8" r="6" />
20
+ <path d="M8 4.5V8l2.4 1.5" />
21
+ </svg>
22
+ )
23
+ }
24
+
25
+ function DatabaseIcon() {
26
+ return (
27
+ <svg
28
+ width="14"
29
+ height="14"
30
+ viewBox="0 0 16 16"
31
+ fill="none"
32
+ stroke="currentColor"
33
+ stroke-width="1.4"
34
+ stroke-linecap="round"
35
+ stroke-linejoin="round"
36
+ aria-hidden="true"
37
+ >
38
+ <ellipse cx="8" cy="4.3" rx="5" ry="2.1" />
39
+ <path d="M3 4.3v7.4c0 1.2 2.2 2.1 5 2.1s5-.9 5-2.1V4.3" />
40
+ <path d="M3 8c0 1.2 2.2 2.1 5 2.1s5-.9 5-2.1" />
41
+ </svg>
42
+ )
43
+ }
44
+
45
+ function Ring(props: { percent?: number }) {
46
+ const degree = () => Math.max(0, Math.min(100, props.percent ?? 0)) * 3.6
47
+ return (
48
+ <span
49
+ class="glance-ring"
50
+ style={{
51
+ background: `conic-gradient(currentColor ${degree()}deg, var(--v2-background-bg-layer-04) 0)`,
52
+ }}
53
+ aria-hidden="true"
54
+ />
55
+ )
56
+ }
57
+
58
+ export function GlanceBar(props: { ctx: Context }) {
59
+ const ctx = props.ctx
60
+ const session = () => ctx.sessions.current()
61
+
62
+ const glance = createGlance(
63
+ {
64
+ messages: () => {
65
+ const current = session()
66
+ return current?.id ? current.server.data.session.message.list(current.id) : []
67
+ },
68
+ session: () => {
69
+ const current = session()
70
+ return current?.id ? current.server.data.session.get(current.id) : undefined
71
+ },
72
+ family: () => {
73
+ const current = session()
74
+ if (!current?.id) return []
75
+ const ids = current.server.data.session.family(current.id)
76
+ return ids.length ? ids : [current.id]
77
+ },
78
+ sessionTokens: (id) => session()?.server.data.session.get(id)?.tokens,
79
+ models: () => {
80
+ const current = session()
81
+ return current ? current.server.data.location.model.list(current.location) : undefined
82
+ },
83
+ running: () => {
84
+ const current = session()
85
+ return current?.id ? current.server.data.session.status(current.id) === "running" : false
86
+ },
87
+ },
88
+ {
89
+ steps: resolveSteps(ctx.options.steps),
90
+ rate: resolveRate(ctx.options.rate),
91
+ t: (key, params) => ctx.t(key, params),
92
+ plural: (base, count) => ctx.plural(base, count),
93
+ },
94
+ )
95
+
96
+ return (
97
+ <Show when={session()}>
98
+ <div class="glance-root">
99
+ <div
100
+ class="glance-bar"
101
+ role="status"
102
+ aria-label={`${glance.progressText()} · ${glance.usageText()} · ${glance.contextText()}`}
103
+ title={`${totalTokens(glance.totals()).toLocaleString()} tokens · ${(
104
+ glance.usage()?.tokens ?? 0
105
+ ).toLocaleString()} in context`}
106
+ >
107
+ <span class="glance-group">
108
+ <span class="glance-icon">
109
+ <ClockIcon />
110
+ </span>
111
+ <span class="glance-label">{glance.progressText()}</span>
112
+ </span>
113
+ <span class="glance-group center">
114
+ <span class="glance-icon">
115
+ <DatabaseIcon />
116
+ </span>
117
+ <span class="glance-label">{glance.usageText()}</span>
118
+ </span>
119
+ <span class="glance-group end">
120
+ <Ring percent={glance.usage()?.percent} />
121
+ <span class="glance-label">{glance.contextText()}</span>
122
+ </span>
123
+ </div>
124
+ </div>
125
+ </Show>
126
+ )
127
+ }