opencode-system-metrics-tui 0.1.0

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 HexaRevenant
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 ADDED
@@ -0,0 +1,246 @@
1
+ # OpenCode System Metrics TUI
2
+
3
+ OpenCode TUI plugin that adds live CPU, RAM, GPU, GPU VRAM, temperature, and network metrics to the `sidebar_content` slot.
4
+
5
+ The README is available in **Español**, **English**, and **Português**.
6
+
7
+ ---
8
+
9
+ ## Español
10
+
11
+ ### Características
12
+
13
+ - CPU: utilización y temperatura.
14
+ - RAM: memoria usada, total y porcentaje.
15
+ - GPU: utilización y temperatura.
16
+ - GPU VRAM: memoria usada, total y porcentaje.
17
+ - Red: velocidad de descarga y subida en tiempo real.
18
+ - Actualización cada 2 segundos.
19
+ - Colores compatibles con el tema activo de OpenCode.
20
+ - Iconos Hack Nerd Font con fallback Unicode.
21
+ - Soporte para Linux, macOS y Windows cuando el sistema expone las métricas.
22
+
23
+ ### Requisitos
24
+
25
+ - OpenCode `1.18.29` o posterior con soporte para plugins TUI.
26
+ - Node.js 18 o superior.
27
+ - Bun para compilar el plugin.
28
+
29
+ ### Instalación local
30
+
31
+ ```bash
32
+ npm install
33
+ npm run build
34
+ npm run install-font
35
+ opencode
36
+ ```
37
+
38
+ `npm run install-font` instala **Hack Nerd Font** únicamente para el usuario actual. No se ejecuta automáticamente durante `npm install` y solicita confirmación antes de descargar la fuente oficial.
39
+
40
+ Comandos adicionales:
41
+
42
+ ```bash
43
+ npm run install-font -- --check # comprobar sin descargar
44
+ npm run install-font -- --yes # instalar sin confirmación interactiva
45
+ ```
46
+
47
+ ### Hack Nerd Font
48
+
49
+ Se recomienda seleccionar **Hack Nerd Font Mono** en la configuración de la terminal. El instalador utiliza la distribución oficial de [Nerd Fonts](https://www.nerdfonts.com/) y descarga los archivos desde el [repositorio oficial de Hack](https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/Hack).
50
+
51
+ La fuente se instala solo para el usuario actual:
52
+
53
+ - Linux: `~/.local/share/fonts`
54
+ - macOS: `~/Library/Fonts`
55
+ - Windows: `%LOCALAPPDATA%\\Microsoft\\Windows\\Fonts`
56
+
57
+ Después de instalarla, selecciona **Hack Nerd Font Mono** en la terminal y reinicia la terminal y OpenCode. Si no se selecciona una Nerd Font, el plugin usa iconos Unicode de fallback.
58
+
59
+ ### Publicación e instalación desde npm
60
+
61
+ Antes de publicar, cambia el nombre del paquete en `package.json` por un nombre disponible en tu cuenta u organización npm:
62
+
63
+ ```bash
64
+ npm run build
65
+ npm publish --access public
66
+ ```
67
+
68
+ En otro equipo:
69
+
70
+ ```bash
71
+ opencode plugin opencode-system-metrics-tui
72
+ ```
73
+
74
+ El instalador de OpenCode configura el plugin TUI automáticamente. Reinicia OpenCode después de instalarlo.
75
+
76
+ ### Verificación
77
+
78
+ ```bash
79
+ npm test
80
+ npm run typecheck
81
+ npm run build
82
+ ```
83
+
84
+ ---
85
+
86
+ ## English
87
+
88
+ ### Features
89
+
90
+ - CPU: utilization and temperature.
91
+ - RAM: used memory, total memory, and percentage.
92
+ - GPU: utilization and temperature.
93
+ - GPU VRAM: used memory, total memory, and percentage.
94
+ - Network: live download and upload speeds.
95
+ - Refreshes every 2 seconds.
96
+ - Uses the active OpenCode theme colors.
97
+ - Hack Nerd Font icons with Unicode fallback.
98
+ - Supports Linux, macOS, and Windows when the operating system exposes the metrics.
99
+
100
+ ### Requirements
101
+
102
+ - OpenCode `1.18.29` or newer with TUI plugin support.
103
+ - Node.js 18 or newer.
104
+ - Bun to build the plugin.
105
+
106
+ ### Local installation
107
+
108
+ ```bash
109
+ npm install
110
+ npm run build
111
+ npm run install-font
112
+ opencode
113
+ ```
114
+
115
+ `npm run install-font` installs **Hack Nerd Font** for the current user only. It is not executed automatically by `npm install` and asks for confirmation before downloading the official font.
116
+
117
+ Extra commands:
118
+
119
+ ```bash
120
+ npm run install-font -- --check # check without downloading
121
+ npm run install-font -- --yes # install without an interactive prompt
122
+ ```
123
+
124
+ ### Hack Nerd Font
125
+
126
+ Select **Hack Nerd Font Mono** in your terminal settings. The installer uses the official [Nerd Fonts](https://www.nerdfonts.com/) distribution and downloads files from the [official Hack repository](https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/Hack).
127
+
128
+ The font is installed for the current user only:
129
+
130
+ - Linux: `~/.local/share/fonts`
131
+ - macOS: `~/Library/Fonts`
132
+ - Windows: `%LOCALAPPDATA%\\Microsoft\\Windows\\Fonts`
133
+
134
+ After installation, select **Hack Nerd Font Mono** in the terminal and restart the terminal and OpenCode. If a Nerd Font is not selected, the plugin uses Unicode fallback icons.
135
+
136
+ ### Publishing and npm installation
137
+
138
+ Before publishing, change the package name in `package.json` to a name available in your npm account or organization:
139
+
140
+ ```bash
141
+ npm run build
142
+ npm publish --access public
143
+ ```
144
+
145
+ On another machine:
146
+
147
+ ```bash
148
+ opencode plugin opencode-system-metrics-tui
149
+ ```
150
+
151
+ The OpenCode installer configures the TUI plugin automatically. Restart OpenCode after installation.
152
+
153
+ ### Verification
154
+
155
+ ```bash
156
+ npm test
157
+ npm run typecheck
158
+ npm run build
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Português
164
+
165
+ ### Funcionalidades
166
+
167
+ - CPU: utilização e temperatura.
168
+ - RAM: memória usada, total e porcentagem.
169
+ - GPU: utilização e temperatura.
170
+ - GPU VRAM: memória usada, total e porcentagem.
171
+ - Rede: velocidades de download e upload em tempo real.
172
+ - Atualização a cada 2 segundos.
173
+ - Usa as cores do tema ativo do OpenCode.
174
+ - Ícones Hack Nerd Font com fallback Unicode.
175
+ - Suporte para Linux, macOS e Windows quando o sistema disponibiliza as métricas.
176
+
177
+ ### Requisitos
178
+
179
+ - OpenCode `1.18.29` ou posterior com suporte a plugins TUI.
180
+ - Node.js 18 ou superior.
181
+ - Bun para compilar o plugin.
182
+
183
+ ### Instalação local
184
+
185
+ ```bash
186
+ npm install
187
+ npm run build
188
+ npm run install-font
189
+ opencode
190
+ ```
191
+
192
+ `npm run install-font` instala a **Hack Nerd Font** somente para o usuário atual. O comando não é executado automaticamente durante `npm install` e pede confirmação antes de baixar a fonte oficial.
193
+
194
+ Comandos adicionais:
195
+
196
+ ```bash
197
+ npm run install-font -- --check # verificar sem baixar
198
+ npm run install-font -- --yes # instalar sem confirmação interativa
199
+ ```
200
+
201
+ ### Hack Nerd Font
202
+
203
+ Selecione **Hack Nerd Font Mono** nas configurações do terminal. O instalador usa a distribuição oficial da [Nerd Fonts](https://www.nerdfonts.com/) e baixa os arquivos do [repositório oficial da Hack](https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/Hack).
204
+
205
+ A fonte é instalada somente para o usuário atual:
206
+
207
+ - Linux: `~/.local/share/fonts`
208
+ - macOS: `~/Library/Fonts`
209
+ - Windows: `%LOCALAPPDATA%\\Microsoft\\Windows\\Fonts`
210
+
211
+ Depois da instalação, selecione **Hack Nerd Font Mono** no terminal e reinicie o terminal e o OpenCode. Se uma Nerd Font não estiver selecionada, o plugin usará ícones Unicode de fallback.
212
+
213
+ ### Publicação e instalação pelo npm
214
+
215
+ Antes de publicar, altere o nome do pacote em `package.json` para um nome disponível na sua conta ou organização npm:
216
+
217
+ ```bash
218
+ npm run build
219
+ npm publish --access public
220
+ ```
221
+
222
+ Em outro computador:
223
+
224
+ ```bash
225
+ opencode plugin opencode-system-metrics-tui
226
+ ```
227
+
228
+ O instalador do OpenCode configura o plugin TUI automaticamente. Reinicie o OpenCode após a instalação.
229
+
230
+ ### Verificação
231
+
232
+ ```bash
233
+ npm test
234
+ npm run typecheck
235
+ npm run build
236
+ ```
237
+
238
+ ---
239
+
240
+ ## License
241
+
242
+ This plugin is released under the [MIT License](LICENSE). The Hack Nerd Font files are third-party assets; see the [official Hack Nerd Fonts repository](https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/Hack) for their applicable license and notices.
243
+
244
+ Este plugin está publicado bajo la [licencia MIT](LICENSE). Los archivos Hack Nerd Font son recursos de terceros; consulta el [repositorio oficial](https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/Hack) para conocer su licencia y avisos.
245
+
246
+ Este plugin é distribuído sob a [licença MIT](LICENSE). Os arquivos Hack Nerd Font são recursos de terceiros; consulte o [repositório oficial](https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/Hack) para obter a licença e os avisos aplicáveis.
package/dist/tui.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { TuiPluginModule } from '@opencode-ai/plugin/tui';
2
+
3
+ declare const plugin: TuiPluginModule & {
4
+ id: string;
5
+ };
6
+
7
+ export { plugin as default };
package/dist/tui.js ADDED
@@ -0,0 +1,381 @@
1
+ // @bun
2
+ // src/tui.tsx
3
+ import { createComponent as _$createComponent } from "@opentui/solid";
4
+ import { effect as _$effect } from "@opentui/solid";
5
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
6
+ import { insertNode as _$insertNode } from "@opentui/solid";
7
+ import { insert as _$insert } from "@opentui/solid";
8
+ import { setProp as _$setProp } from "@opentui/solid";
9
+ import { createElement as _$createElement } from "@opentui/solid";
10
+ import { createSignal, onCleanup, onMount } from "solid-js";
11
+ import { TextAttributes } from "@opentui/core";
12
+
13
+ // src/format.ts
14
+ var BYTES_PER_GIB = 1024 ** 3;
15
+ function formatPercent(value) {
16
+ return value === null || !Number.isFinite(value) ? "\u2014" : `${Math.round(value)}%`;
17
+ }
18
+ function formatTemperature(value) {
19
+ return value === null || !Number.isFinite(value) ? "\u2014" : `${Math.round(value)}\xB0C`;
20
+ }
21
+ function formatGiB(bytes) {
22
+ if (bytes === null || !Number.isFinite(bytes))
23
+ return "\u2014";
24
+ return `${(bytes / BYTES_PER_GIB).toFixed(1)} GiB`;
25
+ }
26
+ function formatRate(bytesPerSecond) {
27
+ if (bytesPerSecond === null || !Number.isFinite(bytesPerSecond))
28
+ return "\u2014";
29
+ const units = ["B/s", "KiB/s", "MiB/s", "GiB/s"];
30
+ let value = Math.max(0, bytesPerSecond);
31
+ let unit = 0;
32
+ while (value >= 1024 && unit < units.length - 1) {
33
+ value /= 1024;
34
+ unit += 1;
35
+ }
36
+ return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
37
+ }
38
+
39
+ // src/metrics.ts
40
+ import si from "systeminformation";
41
+ var previousNetwork;
42
+ var unavailable = {
43
+ cpuPercent: null,
44
+ memoryUsedBytes: null,
45
+ memoryTotalBytes: null,
46
+ memoryPercent: null,
47
+ gpuPercent: null,
48
+ cpuTemperatureCelsius: null,
49
+ gpuTemperatureCelsius: null,
50
+ gpuMemoryUsedBytes: null,
51
+ gpuMemoryTotalBytes: null,
52
+ gpuMemoryPercent: null,
53
+ downloadBytesPerSecond: null,
54
+ uploadBytesPerSecond: null
55
+ };
56
+ function finite(value) {
57
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
58
+ }
59
+ async function withTimeout(promise, timeoutMs) {
60
+ let timeout;
61
+ try {
62
+ return await Promise.race([
63
+ promise,
64
+ new Promise((_, reject) => {
65
+ timeout = setTimeout(() => reject(new Error("metric timeout")), timeoutMs);
66
+ })
67
+ ]);
68
+ } finally {
69
+ if (timeout !== undefined)
70
+ clearTimeout(timeout);
71
+ }
72
+ }
73
+ async function readMetrics() {
74
+ const [load, memory] = await Promise.allSettled([
75
+ si.currentLoad(),
76
+ si.mem()
77
+ ]);
78
+ const [temperature, graphics, network] = await Promise.allSettled([
79
+ withTimeout(si.cpuTemperature(), 1500),
80
+ withTimeout(si.graphics(), 1500),
81
+ withTimeout(si.networkStats("*"), 1500)
82
+ ]);
83
+ const cpuPercent = load.status === "fulfilled" ? finite(load.value.currentLoad) : null;
84
+ const memoryTotalBytes = memory.status === "fulfilled" ? finite(memory.value.total) : null;
85
+ const memoryAvailableBytes = memory.status === "fulfilled" ? finite(memory.value.available) : null;
86
+ const memoryUsedBytes = memoryTotalBytes !== null && memoryAvailableBytes !== null ? Math.max(0, memoryTotalBytes - memoryAvailableBytes) : memory.status === "fulfilled" ? finite(memory.value.used) : null;
87
+ const memoryPercent = memoryUsedBytes !== null && memoryTotalBytes !== null && memoryTotalBytes > 0 ? memoryUsedBytes / memoryTotalBytes * 100 : null;
88
+ let gpuPercent = null;
89
+ let gpuTemperatureCelsius = null;
90
+ let gpuMemoryUsedBytes = null;
91
+ let gpuMemoryTotalBytes = null;
92
+ let gpuMemoryPercent = null;
93
+ if (graphics.status === "fulfilled") {
94
+ const controller = graphics.value.controllers.map((item) => {
95
+ const utilization = finite(item.utilizationGpu);
96
+ const temperature = finite(item.temperatureGpu);
97
+ const memoryUsed = finite(item.memoryUsed);
98
+ const memoryTotal = finite(item.memoryTotal);
99
+ const hasMemory = memoryUsed !== null && memoryTotal !== null && memoryTotal > 0;
100
+ return { item, utilization, temperature, memoryUsed, memoryTotal, hasMemory };
101
+ }).filter(({ utilization, temperature, hasMemory }) => utilization !== null || temperature !== null || hasMemory).sort((left, right) => {
102
+ const score = (value) => (value.hasMemory ? 2 : 0) + (value.utilization !== null ? 1 : 0);
103
+ const scoreDifference = score(right) - score(left);
104
+ if (scoreDifference !== 0)
105
+ return scoreDifference;
106
+ return (right.utilization ?? -Infinity) - (left.utilization ?? -Infinity);
107
+ })[0];
108
+ if (controller !== undefined) {
109
+ gpuPercent = controller.utilization ?? (controller.hasMemory ? 0 : null);
110
+ gpuTemperatureCelsius = controller.temperature;
111
+ if (controller.hasMemory) {
112
+ gpuMemoryUsedBytes = controller.memoryUsed * 1024 ** 2;
113
+ gpuMemoryTotalBytes = controller.memoryTotal * 1024 ** 2;
114
+ gpuMemoryPercent = controller.memoryUsed / controller.memoryTotal * 100;
115
+ }
116
+ }
117
+ }
118
+ let downloadBytesPerSecond = null;
119
+ let uploadBytesPerSecond = null;
120
+ if (network.status === "fulfilled") {
121
+ const samples = network.value;
122
+ const timestamp = Date.now();
123
+ const rx = samples.reduce((total, item) => total + (finite(item.rx_bytes) ?? 0), 0);
124
+ const tx = samples.reduce((total, item) => total + (finite(item.tx_bytes) ?? 0), 0);
125
+ if (previousNetwork !== undefined) {
126
+ const seconds = Math.max((timestamp - previousNetwork.timestamp) / 1000, 0.001);
127
+ downloadBytesPerSecond = Math.max(0, rx - previousNetwork.rx) / seconds;
128
+ uploadBytesPerSecond = Math.max(0, tx - previousNetwork.tx) / seconds;
129
+ }
130
+ previousNetwork = { timestamp, rx, tx };
131
+ }
132
+ return {
133
+ ...unavailable,
134
+ cpuPercent,
135
+ memoryUsedBytes,
136
+ memoryTotalBytes,
137
+ memoryPercent,
138
+ gpuPercent,
139
+ cpuTemperatureCelsius: temperature.status === "fulfilled" ? finite(temperature.value.main) ?? finite(temperature.value.max) : null,
140
+ gpuTemperatureCelsius,
141
+ gpuMemoryUsedBytes,
142
+ gpuMemoryTotalBytes,
143
+ gpuMemoryPercent,
144
+ downloadBytesPerSecond,
145
+ uploadBytesPerSecond
146
+ };
147
+ }
148
+
149
+ // src/font.ts
150
+ import { existsSync, readdirSync } from "fs";
151
+ import { homedir } from "os";
152
+ import { join as posixJoin, win32 } from "path";
153
+ var FONT_FILE_PATTERN = /^HackNerdFont(?:Mono|Propo|Console)?-[A-Za-z0-9-]+\.(?:ttf|otf)$/i;
154
+ function getFontDirectories(platform, home = homedir(), env = process.env) {
155
+ if (platform === "darwin")
156
+ return [posixJoin(home, "Library", "Fonts"), "/Library/Fonts", "/System/Library/Fonts"];
157
+ if (platform === "win32") {
158
+ const localAppData = env.LOCALAPPDATA ?? win32.join(home, "AppData", "Local");
159
+ const windows = env.WINDIR ?? "C:\\Windows";
160
+ return [win32.join(localAppData, "Microsoft", "Windows", "Fonts"), win32.join(windows, "Fonts")];
161
+ }
162
+ return [posixJoin(home, ".local", "share", "fonts"), posixJoin(home, ".fonts"), "/usr/local/share/fonts", "/usr/share/fonts"];
163
+ }
164
+ function isHackNerdFontFile(fileName) {
165
+ return FONT_FILE_PATTERN.test(fileName);
166
+ }
167
+ function hasHackNerdFont(platform = process.platform, home = homedir(), env = process.env) {
168
+ const containsFont = (directory) => {
169
+ if (!existsSync(directory))
170
+ return false;
171
+ try {
172
+ return readdirSync(directory, { withFileTypes: true }).some((entry) => {
173
+ const path = platform === "win32" ? win32.join(directory, entry.name) : posixJoin(directory, entry.name);
174
+ return entry.isFile() ? isHackNerdFontFile(entry.name) : entry.isDirectory() && containsFont(path);
175
+ });
176
+ } catch {
177
+ return false;
178
+ }
179
+ };
180
+ return getFontDirectories(platform, home, env).some(containsFont);
181
+ }
182
+ var nerdFontIcons = {
183
+ title: "\uDB80\uDF79",
184
+ cpu: "\uDB83\uDEE0",
185
+ ram: "\uDB81\uDE1A",
186
+ gpu: "\uDB82\uDCAE",
187
+ vram: "\uDB80\uDF5B",
188
+ thermometer: "\uF2C7",
189
+ network: "\uDB81\uDEF3"
190
+ };
191
+ var unicodeIcons = { title: "\u25A6", cpu: "\u25A3", ram: "\u25A4", gpu: "\u25C6", vram: "\u25C8", thermometer: "\u2668", network: "\u21C5" };
192
+ function getMetricIcons(useNerdFont) {
193
+ return useNerdFont ? nerdFontIcons : unicodeIcons;
194
+ }
195
+
196
+ // src/tui.tsx
197
+ var REFRESH_INTERVAL_MS = 2000;
198
+ var icons = getMetricIcons(hasHackNerdFont());
199
+ function systemMetricsTitle() {
200
+ const locale = process.env.LC_ALL ?? process.env.LANGUAGE?.split(":")[0] ?? process.env.LANG ?? Intl.DateTimeFormat().resolvedOptions().locale;
201
+ const language = locale.split(/[-_.]/)[0].toLowerCase();
202
+ return {
203
+ de: "Systemmetriken",
204
+ es: "M\xE9tricas del sistema",
205
+ fr: "M\xE9triques syst\xE8me",
206
+ it: "Metriche di sistema",
207
+ pt: "M\xE9tricas do sistema"
208
+ }[language] ?? "System metrics";
209
+ }
210
+ function MetricsPanel(props) {
211
+ const [metrics, setMetrics] = createSignal({
212
+ cpuPercent: null,
213
+ memoryUsedBytes: null,
214
+ memoryTotalBytes: null,
215
+ memoryPercent: null,
216
+ gpuPercent: null,
217
+ cpuTemperatureCelsius: null,
218
+ gpuTemperatureCelsius: null,
219
+ gpuMemoryUsedBytes: null,
220
+ gpuMemoryTotalBytes: null,
221
+ gpuMemoryPercent: null,
222
+ downloadBytesPerSecond: null,
223
+ uploadBytesPerSecond: null
224
+ });
225
+ let refreshing = false;
226
+ const refresh = async () => {
227
+ if (refreshing)
228
+ return;
229
+ refreshing = true;
230
+ try {
231
+ setMetrics(await readMetrics());
232
+ } finally {
233
+ refreshing = false;
234
+ }
235
+ };
236
+ let timer;
237
+ onMount(() => {
238
+ refresh();
239
+ timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
240
+ });
241
+ onCleanup(() => {
242
+ if (timer !== undefined)
243
+ clearInterval(timer);
244
+ });
245
+ return (() => {
246
+ var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createTextNode(` `), _el$4 = _$createElement("box"), _el$5 = _$createElement("text"), _el$6 = _$createElement("text"), _el$8 = _$createElement("text"), _el$9 = _$createTextNode(` \xB7 `), _el$0 = _$createTextNode(` `), _el$1 = _$createElement("box"), _el$10 = _$createElement("text"), _el$11 = _$createElement("text"), _el$13 = _$createElement("text"), _el$14 = _$createTextNode(` / `), _el$15 = _$createTextNode(` (`), _el$16 = _$createTextNode(`)`), _el$17 = _$createElement("box"), _el$18 = _$createElement("text"), _el$19 = _$createElement("text"), _el$21 = _$createElement("text"), _el$22 = _$createTextNode(` \xB7 `), _el$23 = _$createTextNode(` `), _el$24 = _$createElement("box"), _el$25 = _$createElement("text"), _el$26 = _$createElement("text"), _el$28 = _$createElement("text"), _el$29 = _$createTextNode(` / `), _el$30 = _$createTextNode(` (`), _el$31 = _$createTextNode(`)`), _el$32 = _$createElement("box"), _el$33 = _$createElement("text"), _el$34 = _$createElement("text"), _el$36 = _$createElement("text"), _el$37 = _$createTextNode(`\u2193 `), _el$38 = _$createTextNode(` \u2191 `);
247
+ _$insertNode(_el$, _el$2);
248
+ _$insertNode(_el$, _el$4);
249
+ _$insertNode(_el$, _el$1);
250
+ _$insertNode(_el$, _el$17);
251
+ _$insertNode(_el$, _el$24);
252
+ _$insertNode(_el$, _el$32);
253
+ _$setProp(_el$, "flexDirection", "column");
254
+ _$setProp(_el$, "paddingLeft", 0);
255
+ _$setProp(_el$, "paddingRight", 0);
256
+ _$insertNode(_el$2, _el$3);
257
+ _$insert(_el$2, () => icons.title, _el$3);
258
+ _$insert(_el$2, systemMetricsTitle, null);
259
+ _$insertNode(_el$4, _el$5);
260
+ _$insertNode(_el$4, _el$6);
261
+ _$insertNode(_el$4, _el$8);
262
+ _$setProp(_el$4, "flexDirection", "row");
263
+ _$insert(_el$5, () => icons.cpu);
264
+ _$insertNode(_el$6, _$createTextNode(` CPU `));
265
+ _$insertNode(_el$8, _el$9);
266
+ _$insertNode(_el$8, _el$0);
267
+ _$insert(_el$8, () => formatPercent(metrics().cpuPercent), _el$9);
268
+ _$insert(_el$8, () => icons.thermometer, _el$0);
269
+ _$insert(_el$8, () => formatTemperature(metrics().cpuTemperatureCelsius), null);
270
+ _$insertNode(_el$1, _el$10);
271
+ _$insertNode(_el$1, _el$11);
272
+ _$insertNode(_el$1, _el$13);
273
+ _$setProp(_el$1, "flexDirection", "row");
274
+ _$insert(_el$10, () => icons.ram);
275
+ _$insertNode(_el$11, _$createTextNode(` RAM `));
276
+ _$insertNode(_el$13, _el$14);
277
+ _$insertNode(_el$13, _el$15);
278
+ _$insertNode(_el$13, _el$16);
279
+ _$insert(_el$13, () => formatGiB(metrics().memoryUsedBytes), _el$14);
280
+ _$insert(_el$13, () => formatGiB(metrics().memoryTotalBytes), _el$15);
281
+ _$insert(_el$13, () => formatPercent(metrics().memoryPercent), _el$16);
282
+ _$insertNode(_el$17, _el$18);
283
+ _$insertNode(_el$17, _el$19);
284
+ _$insertNode(_el$17, _el$21);
285
+ _$setProp(_el$17, "flexDirection", "row");
286
+ _$insert(_el$18, () => icons.gpu);
287
+ _$insertNode(_el$19, _$createTextNode(` GPU `));
288
+ _$insertNode(_el$21, _el$22);
289
+ _$insertNode(_el$21, _el$23);
290
+ _$insert(_el$21, () => formatPercent(metrics().gpuPercent), _el$22);
291
+ _$insert(_el$21, () => icons.thermometer, _el$23);
292
+ _$insert(_el$21, () => formatTemperature(metrics().gpuTemperatureCelsius), null);
293
+ _$insertNode(_el$24, _el$25);
294
+ _$insertNode(_el$24, _el$26);
295
+ _$insertNode(_el$24, _el$28);
296
+ _$setProp(_el$24, "flexDirection", "row");
297
+ _$insert(_el$25, () => icons.vram);
298
+ _$insertNode(_el$26, _$createTextNode(` GPU VRAM `));
299
+ _$insertNode(_el$28, _el$29);
300
+ _$insertNode(_el$28, _el$30);
301
+ _$insertNode(_el$28, _el$31);
302
+ _$insert(_el$28, () => formatGiB(metrics().gpuMemoryUsedBytes), _el$29);
303
+ _$insert(_el$28, () => formatGiB(metrics().gpuMemoryTotalBytes), _el$30);
304
+ _$insert(_el$28, () => formatPercent(metrics().gpuMemoryPercent), _el$31);
305
+ _$insertNode(_el$32, _el$33);
306
+ _$insertNode(_el$32, _el$34);
307
+ _$insertNode(_el$32, _el$36);
308
+ _$setProp(_el$32, "flexDirection", "row");
309
+ _$insert(_el$33, () => icons.network);
310
+ _$insertNode(_el$34, _$createTextNode(` NET `));
311
+ _$insertNode(_el$36, _el$37);
312
+ _$insertNode(_el$36, _el$38);
313
+ _$insert(_el$36, () => formatRate(metrics().downloadBytesPerSecond), _el$38);
314
+ _$insert(_el$36, () => formatRate(metrics().uploadBytesPerSecond), null);
315
+ _$effect((_p$) => {
316
+ var _v$ = props.theme.text, _v$2 = TextAttributes.BOLD, _v$3 = props.theme.success, _v$4 = props.theme.text, _v$5 = props.theme.textMuted, _v$6 = props.theme.success, _v$7 = props.theme.text, _v$8 = props.theme.textMuted, _v$9 = props.theme.success, _v$0 = props.theme.text, _v$1 = props.theme.textMuted, _v$10 = props.theme.success, _v$11 = props.theme.text, _v$12 = props.theme.textMuted, _v$13 = props.theme.success, _v$14 = props.theme.text, _v$15 = props.theme.textMuted;
317
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
318
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$2, "attributes", _v$2, _p$.t));
319
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$5, "fg", _v$3, _p$.a));
320
+ _v$4 !== _p$.o && (_p$.o = _$setProp(_el$6, "fg", _v$4, _p$.o));
321
+ _v$5 !== _p$.i && (_p$.i = _$setProp(_el$8, "fg", _v$5, _p$.i));
322
+ _v$6 !== _p$.n && (_p$.n = _$setProp(_el$10, "fg", _v$6, _p$.n));
323
+ _v$7 !== _p$.s && (_p$.s = _$setProp(_el$11, "fg", _v$7, _p$.s));
324
+ _v$8 !== _p$.h && (_p$.h = _$setProp(_el$13, "fg", _v$8, _p$.h));
325
+ _v$9 !== _p$.r && (_p$.r = _$setProp(_el$18, "fg", _v$9, _p$.r));
326
+ _v$0 !== _p$.d && (_p$.d = _$setProp(_el$19, "fg", _v$0, _p$.d));
327
+ _v$1 !== _p$.l && (_p$.l = _$setProp(_el$21, "fg", _v$1, _p$.l));
328
+ _v$10 !== _p$.u && (_p$.u = _$setProp(_el$25, "fg", _v$10, _p$.u));
329
+ _v$11 !== _p$.c && (_p$.c = _$setProp(_el$26, "fg", _v$11, _p$.c));
330
+ _v$12 !== _p$.w && (_p$.w = _$setProp(_el$28, "fg", _v$12, _p$.w));
331
+ _v$13 !== _p$.m && (_p$.m = _$setProp(_el$33, "fg", _v$13, _p$.m));
332
+ _v$14 !== _p$.f && (_p$.f = _$setProp(_el$34, "fg", _v$14, _p$.f));
333
+ _v$15 !== _p$.y && (_p$.y = _$setProp(_el$36, "fg", _v$15, _p$.y));
334
+ return _p$;
335
+ }, {
336
+ e: undefined,
337
+ t: undefined,
338
+ a: undefined,
339
+ o: undefined,
340
+ i: undefined,
341
+ n: undefined,
342
+ s: undefined,
343
+ h: undefined,
344
+ r: undefined,
345
+ d: undefined,
346
+ l: undefined,
347
+ u: undefined,
348
+ c: undefined,
349
+ w: undefined,
350
+ m: undefined,
351
+ f: undefined,
352
+ y: undefined
353
+ });
354
+ return _el$;
355
+ })();
356
+ }
357
+ var tui = async (api, options) => {
358
+ if (options?.enabled === false)
359
+ return;
360
+ api.slots.register({
361
+ order: 250,
362
+ slots: {
363
+ sidebar_content(_context, props) {
364
+ props.session_id;
365
+ return _$createComponent(MetricsPanel, {
366
+ get theme() {
367
+ return _context.theme.current;
368
+ }
369
+ });
370
+ }
371
+ }
372
+ });
373
+ };
374
+ var plugin = {
375
+ id: "opencode-monitor.system-metrics",
376
+ tui
377
+ };
378
+ var tui_default = plugin;
379
+ export {
380
+ tui_default as default
381
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "opencode-system-metrics-tui",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode TUI sidebar plugin for live system metrics",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ "./tui": {
9
+ "types": "./dist/tui.d.ts",
10
+ "import": "./dist/tui.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "scripts",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "bun scripts/build.ts && tsc --emitDeclarationOnly --declaration --outDir dist",
20
+ "test": "tsx --test test/format.test.ts",
21
+ "typecheck": "tsc --noEmit",
22
+ "install-font": "tsx scripts/install-hack-font.ts"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "dependencies": {
28
+ "@opentui/core": "^0.5.10",
29
+ "@opentui/solid": "^0.5.10",
30
+ "solid-js": "^1.9.12",
31
+ "systeminformation": "^5.33.8"
32
+ },
33
+ "devDependencies": {
34
+ "@opencode-ai/plugin": "^1.18.29",
35
+ "@types/node": "^22.10.5",
36
+ "tsx": "^4.19.2",
37
+ "typescript": "^5.7.3"
38
+ }
39
+ }
@@ -0,0 +1,16 @@
1
+ import solidPlugin from "@opentui/solid/bun-plugin"
2
+
3
+ const result = await Bun.build({
4
+ entrypoints: ["src/tui.tsx"],
5
+ outdir: "dist",
6
+ target: "bun",
7
+ format: "esm",
8
+ plugins: [solidPlugin],
9
+ external: ["@opencode-ai/plugin/tui", "@opentui/core", "@opentui/solid", "solid-js", "systeminformation"],
10
+ naming: "tui.js",
11
+ })
12
+
13
+ if (!result.success) {
14
+ console.error(result.logs)
15
+ process.exit(1)
16
+ }
@@ -0,0 +1,90 @@
1
+ import { cp, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"
2
+ import { homedir, tmpdir } from "node:os"
3
+ import { basename, join } from "node:path"
4
+ import { execFile as execFileCallback } from "node:child_process"
5
+ import { promisify } from "node:util"
6
+ import { getFontDirectories, hasHackNerdFont, isHackNerdFontFile, type SupportedPlatform } from "../src/font.js"
7
+
8
+ const execFile = promisify(execFileCallback)
9
+ const RELEASE_URL = "https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/Hack.zip"
10
+
11
+ function printHelp(): void {
12
+ console.log(`Install Hack Nerd Font for the current user.
13
+
14
+ Usage:
15
+ npm run install-font Ask before downloading and installing
16
+ npm run install-font --check Detect only; never downloads or changes files
17
+ npm run install-font --help Show this help
18
+ npm run install-font --yes Skip the download confirmation`)
19
+ }
20
+
21
+ async function confirm(): Promise<boolean> {
22
+ if (!process.stdin.isTTY) return false
23
+ process.stdout.write("Download Hack Nerd Font from the official Nerd Fonts release? [y/N] ")
24
+ for await (const chunk of process.stdin) return /^y(es)?$/i.test(String(chunk).trim())
25
+ return false
26
+ }
27
+
28
+ async function findFontFiles(directory: string): Promise<string[]> {
29
+ const files: string[] = []
30
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
31
+ const path = join(directory, entry.name)
32
+ if (entry.isDirectory()) files.push(...(await findFontFiles(path)))
33
+ else if (entry.isFile() && isHackNerdFontFile(entry.name)) files.push(path)
34
+ }
35
+ return files
36
+ }
37
+
38
+ async function extractZip(zipPath: string, destination: string, platform: SupportedPlatform): Promise<void> {
39
+ try {
40
+ if (platform === "win32") {
41
+ await execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force", zipPath, destination])
42
+ } else {
43
+ await execFile("unzip", ["-q", "-o", zipPath, "-d", destination])
44
+ }
45
+ } catch (error) {
46
+ throw new Error(`Could not extract the ZIP archive. Install unzip (Linux/macOS) or use PowerShell (Windows). ${error instanceof Error ? error.message : String(error)}`)
47
+ }
48
+ }
49
+
50
+ async function registerWindowsFonts(fontFiles: string[]): Promise<void> {
51
+ const script = "$key='HKCU:\\Software\\Microsoft\\Windows NT\\CurrentVersion\\Fonts'; New-Item -Path $key -Force | Out-Null; foreach ($font in $args) { New-ItemProperty -Path $key -Name ([IO.Path]::GetFileNameWithoutExtension($font)) -Value $font -PropertyType String -Force | Out-Null }"
52
+ await execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, ...fontFiles])
53
+ }
54
+
55
+ async function install(): Promise<void> {
56
+ const platform = process.platform as SupportedPlatform
57
+ const supportedPlatforms: SupportedPlatform[] = ["linux", "darwin", "win32"]
58
+ if (!supportedPlatforms.includes(platform)) throw new Error(`Unsupported platform: ${process.platform}`)
59
+ if (hasHackNerdFont(platform)) return console.log("Hack Nerd Font is already installed; nothing to do.")
60
+ if (!(process.argv.includes("--yes") || (await confirm()))) return console.log("Installation cancelled. Use --yes to confirm non-interactively.")
61
+
62
+ const temporaryDirectory = await mkdtemp(join(tmpdir(), "opencode-monitor-font-"))
63
+ try {
64
+ console.log(`Downloading ${RELEASE_URL}`)
65
+ const response = await fetch(RELEASE_URL)
66
+ if (!response.ok) throw new Error(`Download failed with HTTP ${response.status} ${response.statusText}`)
67
+ const zipPath = join(temporaryDirectory, "Hack.zip")
68
+ await writeFile(zipPath, Buffer.from(await response.arrayBuffer()))
69
+ const extractedDirectory = join(temporaryDirectory, "extracted")
70
+ await mkdir(extractedDirectory)
71
+ await extractZip(zipPath, extractedDirectory, platform)
72
+ const fontFiles = await findFontFiles(extractedDirectory)
73
+ if (fontFiles.length === 0) throw new Error("The downloaded archive did not contain Hack Nerd Font files.")
74
+ const targetDirectory = getFontDirectories(platform)[0]
75
+ await mkdir(targetDirectory, { recursive: true })
76
+ const installedFiles = fontFiles.map((file) => join(targetDirectory, basename(file)))
77
+ for (let index = 0; index < fontFiles.length; index += 1) await cp(fontFiles[index], installedFiles[index], { force: true })
78
+ if (platform === "win32") {
79
+ try { await registerWindowsFonts(installedFiles) }
80
+ catch { console.warn("Fonts were copied, but Windows font registration failed. Select Hack Nerd Font in the terminal manually.") }
81
+ }
82
+ console.log(`Installed ${fontFiles.length} font files in ${targetDirectory}. Select Hack Nerd Font in your terminal and restart OpenCode.`)
83
+ } finally {
84
+ await rm(temporaryDirectory, { recursive: true, force: true })
85
+ }
86
+ }
87
+
88
+ if (process.argv.includes("--help")) printHelp()
89
+ else if (process.argv.includes("--check")) console.log(hasHackNerdFont() ? "Hack Nerd Font is installed." : "Hack Nerd Font is not installed.")
90
+ else install().catch((error: unknown) => { console.error(`Hack Nerd Font installation could not be completed: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1 })