opencode-system-metrics-tui 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # OpenCode System Metrics TUI
2
2
 
3
+ ## Install globally in OpenCode
4
+
5
+ ```powershell
6
+ opencode plugin -g opencode-system-metrics-tui@0.1.4 --force
7
+ ```
8
+
9
+ This is the primary installation path. The version must match `package.json`; `--force` asks OpenCode to refresh its package cache. Verify the actual installed artifact afterward:
10
+
11
+ ```powershell
12
+ npm run install-plugin
13
+ npm run verify-installation
14
+ ```
15
+
16
+ `npm run install-plugin` runs the same global OpenCode installer command with the current `package.json` version. The explicit command above is provided for users installing without this checkout.
17
+
18
+ Restart OpenCode after installation. This installs the plugin globally for all projects. The OpenCode cache directory name, including `@latest`, is only a package-specifier label and does not prove the package version. Verification reads the nested package's own `package.json` and checks `dist/tui.js`; it reports stale entries without deleting anything.
19
+
3
20
  OpenCode TUI plugin that adds live CPU, RAM, GPU, GPU VRAM, temperature, and network metrics to the `sidebar_content` slot.
4
21
 
5
22
  The README is available in **Español**, **English**, and **Português**.
@@ -23,6 +40,8 @@ The README is available in **Español**, **English**, and **Português**.
23
40
  - En Windows, la temperatura de CPU requiere LibreHardwareMonitor ejecutándose como administrador con **Remote Web Server** activo en `http://127.0.0.1:8085`.
24
41
  - En Windows, la velocidad de red usa PowerShell y `Get-NetAdapterStatistics`; no agrega una dependencia npm.
25
42
  - Las lecturas opcionales de Windows se hacen bajo demanda, con timeout y caché; no hay timers globales de PowerShell.
43
+ - La detección de Nerd Font en Linux/macOS está limitada a rutas conocidas, 2 niveles de profundidad y 256 entradas por directorio; una ruta ausente o inaccesible usa Unicode. Windows no escanea fuentes salvo con `OPENCODE_MONITOR_NERD_FONT=1`.
44
+ - Si una fuente de métricas no responde a tiempo, su intento en caché se abandona de forma segura para permitir reintentos sin cancelar APIs que no aceptan `AbortSignal`.
26
45
 
27
46
  ### Requisitos
28
47
 
@@ -72,7 +91,7 @@ npm publish --access public
72
91
  En otro equipo:
73
92
 
74
93
  ```bash
75
- opencode plugin opencode-system-metrics-tui
94
+ opencode plugin opencode-system-metrics-tui@0.1.4
76
95
  ```
77
96
 
78
97
  El paquete exporta tanto la raíz (`opencode-system-metrics-tui`) como `./tui`; ambas rutas cargan el mismo bundle TUI.
@@ -82,12 +101,14 @@ El instalador de OpenCode configura el plugin TUI automáticamente. Reinicia Ope
82
101
  Publicar en npm e instalar en OpenCode son pasos distintos:
83
102
 
84
103
  - `npm publish --access public` publica el paquete en npm.
85
- - `opencode plugin opencode-system-metrics-tui` lo instala solo en el proyecto actual.
86
- - `opencode plugin -g opencode-system-metrics-tui` lo instala globalmente para todos los proyectos.
104
+ - `opencode plugin opencode-system-metrics-tui@0.1.4` lo instala solo en el proyecto actual.
105
+ - `opencode plugin -g opencode-system-metrics-tui@0.1.4 --force` lo instala globalmente para todos los proyectos.
87
106
 
88
107
  ### Verificación
89
108
 
90
109
  ```bash
110
+ npm run install-plugin
111
+ npm run verify-installation
91
112
  npm test
92
113
  npm run typecheck
93
114
  npm run build
@@ -112,6 +133,8 @@ npm run build
112
133
  - On Windows, CPU temperature requires LibreHardwareMonitor running as administrator with **Remote Web Server** enabled at `http://127.0.0.1:8085`.
113
134
  - On Windows, network speed uses PowerShell and `Get-NetAdapterStatistics`; it adds no npm dependency.
114
135
  - Optional Windows reads are on demand, timeout-bounded, and cached; no global PowerShell timers are used.
136
+ - Linux/macOS Nerd Font detection is limited to known paths, two directory levels, and 256 entries per directory; missing or inaccessible paths safely fall back to Unicode. Windows does not scan fonts unless `OPENCODE_MONITOR_NERD_FONT=1` is set.
137
+ - When a metric source does not respond in time, its cache attempt is safely abandoned so retries remain possible without cancelling APIs that do not accept `AbortSignal`.
115
138
 
116
139
  ### Requirements
117
140
 
@@ -161,7 +184,7 @@ npm publish --access public
161
184
  On another machine:
162
185
 
163
186
  ```bash
164
- opencode plugin opencode-system-metrics-tui
187
+ opencode plugin opencode-system-metrics-tui@0.1.4
165
188
  ```
166
189
 
167
190
  The package exports both the root (`opencode-system-metrics-tui`) and `./tui`; both paths load the same TUI bundle.
@@ -171,12 +194,14 @@ The OpenCode installer configures the TUI plugin automatically. Restart OpenCode
171
194
  Publishing to npm and installing in OpenCode are separate steps:
172
195
 
173
196
  - `npm publish --access public` publishes the package to npm.
174
- - `opencode plugin opencode-system-metrics-tui` installs it for the current project only.
175
- - `opencode plugin -g opencode-system-metrics-tui` installs it globally for all projects.
197
+ - `opencode plugin opencode-system-metrics-tui@0.1.4` installs it for the current project only.
198
+ - `opencode plugin -g opencode-system-metrics-tui@0.1.4 --force` installs it globally for all projects.
176
199
 
177
200
  ### Verification
178
201
 
179
202
  ```bash
203
+ npm run install-plugin
204
+ npm run verify-installation
180
205
  npm test
181
206
  npm run typecheck
182
207
  npm run build
@@ -201,6 +226,8 @@ npm run build
201
226
  - No Windows, a temperatura da CPU requer o LibreHardwareMonitor executado como administrador com o **Remote Web Server** ativo em `http://127.0.0.1:8085`.
202
227
  - No Windows, a velocidade da rede usa o PowerShell e `Get-NetAdapterStatistics`; nenhuma dependência npm é adicionada.
203
228
  - As leituras opcionais do Windows são sob demanda, têm timeout e cache; não há timers globais do PowerShell.
229
+ - A detecção de Nerd Font no Linux/macOS é limitada a caminhos conhecidos, dois níveis de profundidade e 256 entradas por diretório; caminhos ausentes ou inacessíveis usam Unicode. O Windows não verifica fontes sem `OPENCODE_MONITOR_NERD_FONT=1`.
230
+ - Quando uma fonte de métricas não responde a tempo, a tentativa em cache é abandonada com segurança para permitir novas tentativas sem cancelar APIs que não aceitam `AbortSignal`.
204
231
 
205
232
  ### Requisitos
206
233
 
@@ -250,7 +277,7 @@ npm publish --access public
250
277
  Em outro computador:
251
278
 
252
279
  ```bash
253
- opencode plugin opencode-system-metrics-tui
280
+ opencode plugin opencode-system-metrics-tui@0.1.4
254
281
  ```
255
282
 
256
283
  O pacote exporta tanto a raiz (`opencode-system-metrics-tui`) quanto `./tui`; os dois caminhos carregam o mesmo bundle TUI.
@@ -260,8 +287,8 @@ O instalador do OpenCode configura o plugin TUI automaticamente. Reinicie o Open
260
287
  Publicar no npm e instalar no OpenCode são etapas diferentes:
261
288
 
262
289
  - `npm publish --access public` publica o pacote no npm.
263
- - `opencode plugin opencode-system-metrics-tui` instala o plugin apenas no projeto atual.
264
- - `opencode plugin -g opencode-system-metrics-tui` instala o plugin globalmente para todos os projetos.
290
+ - `opencode plugin opencode-system-metrics-tui@0.1.4` instala o plugin apenas no projeto atual.
291
+ - `opencode plugin -g opencode-system-metrics-tui@0.1.4 --force` instala o plugin globalmente para todos os projetos.
265
292
 
266
293
  ### Verificação
267
294
 
@@ -10,6 +10,8 @@ import { existsSync, readdirSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { join as posixJoin, win32 } from "node:path";
12
12
  var NERD_FONT_FILE_PATTERN = /NerdFont[A-Za-z0-9-]*[-_][A-Za-z0-9-]+\.(?:ttf|otf)$/i;
13
+ var MAX_FONT_SCAN_DEPTH = 2;
14
+ var MAX_FONT_SCAN_ENTRIES = 256;
13
15
  function getFontDirectories(platform, home = homedir(), env = process.env) {
14
16
  if (platform === "darwin")
15
17
  return [posixJoin(home, "Library", "Fonts"), "/Library/Fonts", "/System/Library/Fonts"];
@@ -25,19 +27,23 @@ function isNerdFontFile(fileName) {
25
27
  }
26
28
  var isHackNerdFontFile = isNerdFontFile;
27
29
  function hasNerdFont(platform = process.platform, home = homedir(), env = process.env) {
28
- const containsFont = (directory) => {
30
+ if (platform === "win32" && env.OPENCODE_MONITOR_NERD_FONT !== "1")
31
+ return false;
32
+ const containsFont = (directory, depth) => {
33
+ if (depth > MAX_FONT_SCAN_DEPTH)
34
+ return false;
29
35
  if (!existsSync(directory))
30
36
  return false;
31
37
  try {
32
- return readdirSync(directory, { withFileTypes: true }).some((entry) => {
38
+ return readdirSync(directory, { withFileTypes: true }).slice(0, MAX_FONT_SCAN_ENTRIES).some((entry) => {
33
39
  const path = platform === "win32" ? win32.join(directory, entry.name) : posixJoin(directory, entry.name);
34
- return entry.isFile() ? isNerdFontFile(entry.name) : entry.isDirectory() && containsFont(path);
40
+ return entry.isFile() ? isNerdFontFile(entry.name) : entry.isDirectory() && containsFont(path, depth + 1);
35
41
  });
36
42
  } catch {
37
43
  return false;
38
44
  }
39
45
  };
40
- return getFontDirectories(platform, home, env).some(containsFont);
46
+ return getFontDirectories(platform, home, env).some((directory) => containsFont(directory, 0));
41
47
  }
42
48
  var hasHackNerdFont = hasNerdFont;
43
49
  var nerdFontIcons = {
package/dist/tui.js CHANGED
@@ -52,22 +52,31 @@ async function readLibreHardwareMonitorTemperature() {
52
52
  if (!response.ok)
53
53
  return;
54
54
  const root = await response.json();
55
+ if (root === null || typeof root !== "object")
56
+ return;
55
57
  const sensors = [];
56
58
  const visit = (node) => {
57
59
  if (node.Type === "Temperature" && node.Text !== undefined)
58
60
  sensors.push(node);
59
- for (const child of node.Children ?? [])
60
- visit(child);
61
+ for (const child of Array.isArray(node.Children) ? node.Children : []) {
62
+ if (child !== null && typeof child === "object")
63
+ visit(child);
64
+ }
61
65
  };
62
66
  visit(root);
63
67
  const cpuSensor = sensors.find((sensor) => sensor.Text === "CPU Package") ?? sensors.find((sensor) => sensor.Text === "Core Average") ?? sensors.find((sensor) => sensor.Text === "Core Max");
64
68
  return parseMonitorTemperature(cpuSensor?.Value);
65
69
  }
66
70
  function parseWindowsNetworkOutput(stdout) {
67
- const parsed = JSON.parse(stdout.trim());
68
- const rows = Array.isArray(parsed) ? parsed : [parsed];
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(stdout.trim());
74
+ } catch {
75
+ return [];
76
+ }
77
+ const rows = (Array.isArray(parsed) ? parsed : [parsed]).filter((item) => item !== null && typeof item === "object");
69
78
  return rows.map((item) => ({
70
- iface: item.Name,
79
+ iface: typeof item.Name === "string" ? item.Name : undefined,
71
80
  rx_bytes: finite(item.ReceivedBytes) ?? 0,
72
81
  tx_bytes: finite(item.SentBytes) ?? 0
73
82
  }));
@@ -126,14 +135,15 @@ function selectGpuMetrics(graphics) {
126
135
  gpuMemoryTotalBytes: null,
127
136
  gpuMemoryPercent: null
128
137
  };
129
- if (graphics === undefined)
138
+ if (graphics === undefined || graphics === null || !Array.isArray(graphics.controllers))
130
139
  return empty;
131
140
  const controller = graphics.controllers.map((item) => {
132
- const utilization = finite(item.utilizationGpu);
133
- const temperature = finite(item.temperatureGpu);
134
- const memoryTotal = finite(item.memoryTotal);
135
- const memoryFree = finite(item.memoryFree);
136
- const memoryUsed = finite(item.memoryUsed) ?? (memoryTotal !== null && memoryFree !== null ? Math.max(0, memoryTotal - memoryFree) : null);
141
+ const source = item ?? {};
142
+ const utilization = finite(source.utilizationGpu);
143
+ const temperature = finite(source.temperatureGpu);
144
+ const memoryTotal = finite(source.memoryTotal);
145
+ const memoryFree = finite(source.memoryFree);
146
+ const memoryUsed = finite(source.memoryUsed) ?? (memoryTotal !== null && memoryFree !== null ? Math.max(0, memoryTotal - memoryFree) : null);
137
147
  const hasMemory = memoryUsed !== null && memoryTotal !== null && memoryTotal > 0;
138
148
  return { utilization, temperature, memoryUsed, memoryTotal, hasMemory };
139
149
  }).filter(({ utilization, temperature, hasMemory }) => utilization !== null || temperature !== null || hasMemory).sort((left, right) => {
@@ -156,7 +166,7 @@ function selectGpuMetrics(graphics) {
156
166
  }
157
167
  return result;
158
168
  }
159
- function readCachedMetric(state, reader, cacheMs = SLOW_METRIC_CACHE_MS, now = Date.now(), unavailableCacheMs = cacheMs) {
169
+ function readCachedMetric(state, reader, cacheMs = SLOW_METRIC_CACHE_MS, now = Date.now(), unavailableCacheMs = cacheMs, timeoutMs) {
160
170
  if (state.inFlight !== undefined)
161
171
  return state.inFlight;
162
172
  if (state.lastFailureAt !== undefined && now - state.lastFailureAt < unavailableCacheMs) {
@@ -165,17 +175,23 @@ function readCachedMetric(state, reader, cacheMs = SLOW_METRIC_CACHE_MS, now = D
165
175
  if (now - state.lastAttemptAt < cacheMs)
166
176
  return Promise.resolve(state.value);
167
177
  state.lastAttemptAt = now;
168
- state.inFlight = reader().then((value) => {
169
- state.value = value;
170
- state.lastFailureAt = undefined;
171
- return value;
172
- }).catch(() => {
173
- state.lastFailureAt = now;
174
- return state.value;
175
- }).finally(() => {
176
- state.inFlight = undefined;
177
- });
178
- return state.inFlight;
178
+ let flight;
179
+ flight = (async () => {
180
+ try {
181
+ const value = timeoutMs === undefined ? await reader() : await withTimeout(reader(), timeoutMs);
182
+ state.value = value;
183
+ state.lastFailureAt = undefined;
184
+ return value;
185
+ } catch {
186
+ state.lastFailureAt = now;
187
+ return state.value;
188
+ } finally {
189
+ if (state.inFlight === flight)
190
+ state.inFlight = undefined;
191
+ }
192
+ })();
193
+ state.inFlight = flight;
194
+ return flight;
179
195
  }
180
196
  var cachedCpuTemperature = {
181
197
  value: undefined,
@@ -238,9 +254,9 @@ async function readMetrics() {
238
254
  withTimeout(si.mem(), 1500)
239
255
  ]);
240
256
  const [temperature, graphics, network] = await Promise.allSettled([
241
- withTimeout(readCachedMetric(cachedCpuTemperature, isWindows ? readWindowsCpuTemperature : () => si.cpuTemperature(), TEMPERATURE_CACHE_MS), 2500),
242
- withTimeout(readCachedMetric(cachedGraphics, () => si.graphics(), SLOW_METRIC_CACHE_MS), 2500),
243
- withTimeout(readCachedMetric(cachedNetwork, isWindows ? readWindowsNetworkMetrics : () => si.networkStats("*"), isWindows ? NETWORK_CACHE_MS : 2000, Date.now(), OPTIONAL_SOURCE_BACKOFF_MS), 1500)
257
+ readCachedMetric(cachedCpuTemperature, isWindows ? readWindowsCpuTemperature : () => si.cpuTemperature(), TEMPERATURE_CACHE_MS, Date.now(), TEMPERATURE_CACHE_MS, 2500),
258
+ readCachedMetric(cachedGraphics, () => si.graphics(), SLOW_METRIC_CACHE_MS, Date.now(), SLOW_METRIC_CACHE_MS, 2500),
259
+ readCachedMetric(cachedNetwork, isWindows ? readWindowsNetworkMetrics : () => si.networkStats("*"), isWindows ? NETWORK_CACHE_MS : 2000, Date.now(), OPTIONAL_SOURCE_BACKOFF_MS, 1500)
244
260
  ]);
245
261
  const cpuPercent = load.status === "fulfilled" ? finite(load.value.currentLoad) : null;
246
262
  const memoryTotalBytes = memory.status === "fulfilled" ? finite(memory.value.total) : null;
@@ -251,7 +267,7 @@ async function readMetrics() {
251
267
  let downloadBytesPerSecond = null;
252
268
  let uploadBytesPerSecond = null;
253
269
  if (network.status === "fulfilled" && network.value !== undefined) {
254
- const samples = network.value;
270
+ const samples = Array.isArray(network.value) ? network.value : [];
255
271
  const timestamp = Date.now();
256
272
  const rx = samples.reduce((total, item) => total + (finite(item.rx_bytes) ?? 0), 0);
257
273
  const tx = samples.reduce((total, item) => total + (finite(item.tx_bytes) ?? 0), 0);
@@ -290,6 +306,8 @@ import { existsSync, readdirSync } from "node:fs";
290
306
  import { homedir } from "node:os";
291
307
  import { join as posixJoin, win32 } from "node:path";
292
308
  var NERD_FONT_FILE_PATTERN = /NerdFont[A-Za-z0-9-]*[-_][A-Za-z0-9-]+\.(?:ttf|otf)$/i;
309
+ var MAX_FONT_SCAN_DEPTH = 2;
310
+ var MAX_FONT_SCAN_ENTRIES = 256;
293
311
  function getFontDirectories(platform, home = homedir(), env = process.env) {
294
312
  if (platform === "darwin")
295
313
  return [posixJoin(home, "Library", "Fonts"), "/Library/Fonts", "/System/Library/Fonts"];
@@ -305,19 +323,23 @@ function isNerdFontFile(fileName) {
305
323
  }
306
324
  var isHackNerdFontFile = isNerdFontFile;
307
325
  function hasNerdFont(platform = process.platform, home = homedir(), env = process.env) {
308
- const containsFont = (directory) => {
326
+ if (platform === "win32" && env.OPENCODE_MONITOR_NERD_FONT !== "1")
327
+ return false;
328
+ const containsFont = (directory, depth) => {
329
+ if (depth > MAX_FONT_SCAN_DEPTH)
330
+ return false;
309
331
  if (!existsSync(directory))
310
332
  return false;
311
333
  try {
312
- return readdirSync(directory, { withFileTypes: true }).some((entry) => {
334
+ return readdirSync(directory, { withFileTypes: true }).slice(0, MAX_FONT_SCAN_ENTRIES).some((entry) => {
313
335
  const path = platform === "win32" ? win32.join(directory, entry.name) : posixJoin(directory, entry.name);
314
- return entry.isFile() ? isNerdFontFile(entry.name) : entry.isDirectory() && containsFont(path);
336
+ return entry.isFile() ? isNerdFontFile(entry.name) : entry.isDirectory() && containsFont(path, depth + 1);
315
337
  });
316
338
  } catch {
317
339
  return false;
318
340
  }
319
341
  };
320
- return getFontDirectories(platform, home, env).some(containsFont);
342
+ return getFontDirectories(platform, home, env).some((directory) => containsFont(directory, 0));
321
343
  }
322
344
  var hasHackNerdFont = hasNerdFont;
323
345
  var nerdFontIcons = {
@@ -341,7 +363,8 @@ function shouldUseNerdFont(platform, detected, env = process.env) {
341
363
 
342
364
  // src/tui.tsx
343
365
  var REFRESH_INTERVAL_MS = 2000;
344
- var icons = getMetricIcons(shouldUseNerdFont(process.platform, hasNerdFont()));
366
+ var platform = process.platform;
367
+ var icons = getMetricIcons(shouldUseNerdFont(platform, platform === "win32" ? false : hasNerdFont()));
345
368
  var initialMetrics = {
346
369
  cpuPercent: null,
347
370
  memoryUsedBytes: null,
@@ -356,33 +379,43 @@ var initialMetrics = {
356
379
  downloadBytesPerSecond: null,
357
380
  uploadBytesPerSecond: null
358
381
  };
359
- var [sharedMetrics, setSharedMetrics] = createSignal(initialMetrics);
360
- var metricsTimer;
361
- var metricsConsumers = 0;
362
- var refreshing = false;
382
+ var METRICS_STATE_KEY = Symbol.for("opencode-monitor.system-metrics");
383
+ var globalMetrics = globalThis;
384
+ var metricsState = globalMetrics[METRICS_STATE_KEY] ?? (globalMetrics[METRICS_STATE_KEY] = {
385
+ ...(() => {
386
+ const [sharedMetrics, setSharedMetrics] = createSignal(initialMetrics);
387
+ return {
388
+ sharedMetrics,
389
+ setSharedMetrics
390
+ };
391
+ })(),
392
+ metricsTimer: undefined,
393
+ metricsConsumers: 0,
394
+ refreshing: false
395
+ });
363
396
  var refreshMetrics = async () => {
364
- if (refreshing)
397
+ if (metricsState.refreshing)
365
398
  return;
366
- refreshing = true;
399
+ metricsState.refreshing = true;
367
400
  try {
368
- setSharedMetrics(await readMetrics());
401
+ metricsState.setSharedMetrics(await readMetrics());
369
402
  } finally {
370
- refreshing = false;
403
+ metricsState.refreshing = false;
371
404
  }
372
405
  };
373
406
  function startMetricsPolling() {
374
- metricsConsumers += 1;
375
- if (metricsConsumers !== 1)
407
+ metricsState.metricsConsumers += 1;
408
+ if (metricsState.metricsConsumers !== 1)
376
409
  return;
377
410
  refreshMetrics();
378
- metricsTimer = setInterval(() => void refreshMetrics(), REFRESH_INTERVAL_MS);
411
+ metricsState.metricsTimer = setInterval(() => void refreshMetrics(), REFRESH_INTERVAL_MS);
379
412
  }
380
413
  function stopMetricsPolling() {
381
- metricsConsumers = Math.max(0, metricsConsumers - 1);
382
- if (metricsConsumers !== 0 || metricsTimer === undefined)
414
+ metricsState.metricsConsumers = Math.max(0, metricsState.metricsConsumers - 1);
415
+ if (metricsState.metricsConsumers !== 0 || metricsState.metricsTimer === undefined)
383
416
  return;
384
- clearInterval(metricsTimer);
385
- metricsTimer = undefined;
417
+ clearInterval(metricsState.metricsTimer);
418
+ metricsState.metricsTimer = undefined;
386
419
  }
387
420
  function systemMetricsTitle() {
388
421
  const locale = process.env.LC_ALL ?? process.env.LANGUAGE?.split(":")[0] ?? process.env.LANG ?? Intl.DateTimeFormat().resolvedOptions().locale;
@@ -420,9 +453,9 @@ function MetricsPanel(props) {
420
453
  _$insertNode(_el$6, _$createTextNode(` CPU `));
421
454
  _$insertNode(_el$8, _el$9);
422
455
  _$insertNode(_el$8, _el$0);
423
- _$insert(_el$8, () => formatPercent(sharedMetrics().cpuPercent), _el$9);
456
+ _$insert(_el$8, () => formatPercent(metricsState.sharedMetrics().cpuPercent), _el$9);
424
457
  _$insert(_el$8, () => icons.thermometer, _el$0);
425
- _$insert(_el$8, () => formatTemperature(sharedMetrics().cpuTemperatureCelsius), null);
458
+ _$insert(_el$8, () => formatTemperature(metricsState.sharedMetrics().cpuTemperatureCelsius), null);
426
459
  _$insertNode(_el$1, _el$10);
427
460
  _$insertNode(_el$1, _el$11);
428
461
  _$insertNode(_el$1, _el$13);
@@ -432,9 +465,9 @@ function MetricsPanel(props) {
432
465
  _$insertNode(_el$13, _el$14);
433
466
  _$insertNode(_el$13, _el$15);
434
467
  _$insertNode(_el$13, _el$16);
435
- _$insert(_el$13, () => formatGiB(sharedMetrics().memoryUsedBytes), _el$14);
436
- _$insert(_el$13, () => formatGiB(sharedMetrics().memoryTotalBytes), _el$15);
437
- _$insert(_el$13, () => formatPercent(sharedMetrics().memoryPercent), _el$16);
468
+ _$insert(_el$13, () => formatGiB(metricsState.sharedMetrics().memoryUsedBytes), _el$14);
469
+ _$insert(_el$13, () => formatGiB(metricsState.sharedMetrics().memoryTotalBytes), _el$15);
470
+ _$insert(_el$13, () => formatPercent(metricsState.sharedMetrics().memoryPercent), _el$16);
438
471
  _$insertNode(_el$17, _el$18);
439
472
  _$insertNode(_el$17, _el$19);
440
473
  _$insertNode(_el$17, _el$21);
@@ -443,9 +476,9 @@ function MetricsPanel(props) {
443
476
  _$insertNode(_el$19, _$createTextNode(` GPU `));
444
477
  _$insertNode(_el$21, _el$22);
445
478
  _$insertNode(_el$21, _el$23);
446
- _$insert(_el$21, () => formatPercent(sharedMetrics().gpuPercent), _el$22);
479
+ _$insert(_el$21, () => formatPercent(metricsState.sharedMetrics().gpuPercent), _el$22);
447
480
  _$insert(_el$21, () => icons.thermometer, _el$23);
448
- _$insert(_el$21, () => formatTemperature(sharedMetrics().gpuTemperatureCelsius), null);
481
+ _$insert(_el$21, () => formatTemperature(metricsState.sharedMetrics().gpuTemperatureCelsius), null);
449
482
  _$insertNode(_el$24, _el$25);
450
483
  _$insertNode(_el$24, _el$26);
451
484
  _$insertNode(_el$24, _el$28);
@@ -455,9 +488,9 @@ function MetricsPanel(props) {
455
488
  _$insertNode(_el$28, _el$29);
456
489
  _$insertNode(_el$28, _el$30);
457
490
  _$insertNode(_el$28, _el$31);
458
- _$insert(_el$28, () => formatGiB(sharedMetrics().gpuMemoryUsedBytes), _el$29);
459
- _$insert(_el$28, () => formatGiB(sharedMetrics().gpuMemoryTotalBytes), _el$30);
460
- _$insert(_el$28, () => formatPercent(sharedMetrics().gpuMemoryPercent), _el$31);
491
+ _$insert(_el$28, () => formatGiB(metricsState.sharedMetrics().gpuMemoryUsedBytes), _el$29);
492
+ _$insert(_el$28, () => formatGiB(metricsState.sharedMetrics().gpuMemoryTotalBytes), _el$30);
493
+ _$insert(_el$28, () => formatPercent(metricsState.sharedMetrics().gpuMemoryPercent), _el$31);
461
494
  _$insertNode(_el$32, _el$33);
462
495
  _$insertNode(_el$32, _el$34);
463
496
  _$insertNode(_el$32, _el$36);
@@ -466,8 +499,8 @@ function MetricsPanel(props) {
466
499
  _$insertNode(_el$34, _$createTextNode(` NET `));
467
500
  _$insertNode(_el$36, _el$37);
468
501
  _$insertNode(_el$36, _el$38);
469
- _$insert(_el$36, () => formatRate(sharedMetrics().downloadBytesPerSecond), _el$38);
470
- _$insert(_el$36, () => formatRate(sharedMetrics().uploadBytesPerSecond), null);
502
+ _$insert(_el$36, () => formatRate(metricsState.sharedMetrics().downloadBytesPerSecond), _el$38);
503
+ _$insert(_el$36, () => formatRate(metricsState.sharedMetrics().uploadBytesPerSecond), null);
471
504
  _$effect((_p$) => {
472
505
  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;
473
506
  _v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-system-metrics-tui",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "OpenCode TUI sidebar plugin for live system metrics",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,9 @@
23
23
  "build": "bun scripts/build.ts && tsc --emitDeclarationOnly --declaration --outDir dist",
24
24
  "test": "tsx --test test/*.test.ts",
25
25
  "typecheck": "tsc --noEmit",
26
- "install-font": "node dist/install-hack-font.js"
26
+ "install-font": "node dist/install-hack-font.js",
27
+ "verify-installation": "tsx scripts/plugin-installation.ts",
28
+ "install-plugin": "tsx scripts/install-plugin.ts"
27
29
  },
28
30
  "engines": {
29
31
  "node": ">=18"
@@ -0,0 +1,30 @@
1
+ import { execFile } from "node:child_process"
2
+ import { readFile } from "node:fs/promises"
3
+ import { promisify } from "node:util"
4
+ import { formatVerificationResult, verifyPluginInstallation } from "./plugin-installation.js"
5
+
6
+ const execFileAsync = promisify(execFile)
7
+ const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as {
8
+ name: string
9
+ version: string
10
+ }
11
+ const packageSpec = `${manifest.name}@${manifest.version}`
12
+
13
+ console.log(`Installing ${packageSpec} globally through OpenCode`)
14
+ try {
15
+ const result = await execFileAsync("opencode", ["plugin", "-g", packageSpec, "--force"], { windowsHide: true })
16
+ if (result.stdout.trim()) console.log(result.stdout.trim())
17
+ if (result.stderr.trim()) console.error(result.stderr.trim())
18
+ } catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error)
20
+ console.error(`OpenCode plugin installation failed: ${message}`)
21
+ process.exit(1)
22
+ }
23
+
24
+ const verification = await verifyPluginInstallation({
25
+ packageName: manifest.name,
26
+ expectedVersion: manifest.version,
27
+ cacheDirectory: process.env.OPENCODE_CACHE_DIR,
28
+ })
29
+ console.log(formatVerificationResult(verification))
30
+ if (!verification.ok) process.exitCode = 1
@@ -0,0 +1,107 @@
1
+ import { access, readFile, readdir } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { fileURLToPath } from "node:url"
4
+ import { join, resolve } from "node:path"
5
+
6
+ export type PluginVerificationOptions = {
7
+ cacheDirectory?: string
8
+ packageName: string
9
+ expectedVersion: string
10
+ distPath?: string
11
+ }
12
+
13
+ export type PluginVerification = {
14
+ ok: boolean
15
+ packagePath?: string
16
+ actualVersion?: string
17
+ staleEntries: string[]
18
+ errors: string[]
19
+ }
20
+
21
+ type PackageManifest = { name?: unknown; version?: unknown }
22
+
23
+ export function getDefaultOpenCodeCacheDirectory(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
24
+ const cacheRoot = env.OPENCODE_CACHE_DIR ?? env.XDG_CACHE_HOME ?? join(home, ".cache")
25
+ return join(cacheRoot, "opencode", "packages")
26
+ }
27
+
28
+ async function readManifest(path: string): Promise<PackageManifest | undefined> {
29
+ try {
30
+ return JSON.parse(await readFile(path, "utf8")) as PackageManifest
31
+ } catch {
32
+ return undefined
33
+ }
34
+ }
35
+
36
+ async function exists(path: string): Promise<boolean> {
37
+ try {
38
+ await access(path)
39
+ return true
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ export async function verifyPluginInstallation(options: PluginVerificationOptions): Promise<PluginVerification> {
46
+ const cacheDirectory = resolve(options.cacheDirectory ?? getDefaultOpenCodeCacheDirectory())
47
+ const errors: string[] = []
48
+ const staleEntries: string[] = []
49
+ let packagePath: string | undefined
50
+ let actualVersion: string | undefined
51
+
52
+ let entries: string[]
53
+ try {
54
+ entries = (await readdir(cacheDirectory, { withFileTypes: true }))
55
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(`${options.packageName}@`))
56
+ .map((entry) => entry.name)
57
+ } catch {
58
+ return { ok: false, staleEntries, errors: [`OpenCode package cache not found: ${cacheDirectory}`] }
59
+ }
60
+
61
+ for (const entry of entries) {
62
+ const candidatePath = join(cacheDirectory, entry, "node_modules", options.packageName)
63
+ const manifest = await readManifest(join(candidatePath, "package.json"))
64
+ if (manifest?.name !== options.packageName || typeof manifest.version !== "string") {
65
+ staleEntries.push(join(cacheDirectory, entry))
66
+ continue
67
+ }
68
+ if (manifest.version === options.expectedVersion && packagePath === undefined) {
69
+ packagePath = candidatePath
70
+ actualVersion = manifest.version
71
+ } else if (manifest.version !== options.expectedVersion) {
72
+ staleEntries.push(join(cacheDirectory, entry))
73
+ }
74
+ }
75
+
76
+ if (packagePath === undefined) {
77
+ errors.push(`Installed ${options.packageName}@${options.expectedVersion} was not found in ${cacheDirectory}`)
78
+ } else if (actualVersion !== options.expectedVersion) {
79
+ errors.push(`Installed version is ${actualVersion ?? "unknown"}; expected ${options.expectedVersion}`)
80
+ }
81
+
82
+ if (packagePath !== undefined && !(await exists(join(packagePath, options.distPath ?? "dist", "tui.js")))) {
83
+ errors.push(`Required dist/tui.js is missing from ${packagePath}`)
84
+ }
85
+ if (staleEntries.length > 0) errors.push(`Stale OpenCode cache entries detected: ${staleEntries.join(", ")}`)
86
+
87
+ return { ok: errors.length === 0, packagePath, actualVersion, staleEntries, errors }
88
+ }
89
+
90
+ export function formatVerificationResult(result: PluginVerification): string {
91
+ if (result.ok) return `Verified OpenCode plugin ${result.actualVersion} at ${result.packagePath}`
92
+ return result.errors.join("\n")
93
+ }
94
+
95
+ if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
96
+ const packageManifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as {
97
+ name: string
98
+ version: string
99
+ }
100
+ const result = await verifyPluginInstallation({
101
+ packageName: packageManifest.name,
102
+ expectedVersion: packageManifest.version,
103
+ cacheDirectory: process.env.OPENCODE_CACHE_DIR,
104
+ })
105
+ console.log(formatVerificationResult(result))
106
+ if (!result.ok) process.exitCode = 1
107
+ }