@slkiser/opencode-quota 1.3.0 → 1.4.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # opencode-quota
1
+ # Opencode Quota
2
2
 
3
3
  Quota and token tracking for OpenCode providers via Toasts and Commands with no LLM calls.
4
4
 
@@ -10,7 +10,7 @@ Quota and token tracking for OpenCode providers via Toasts and Commands with no
10
10
 
11
11
  **Token Report Commands** - Track token usage and estimated costs across sessions.
12
12
 
13
- ![Image of /quota and /quota_daily outputs](https://github.com/slkiser/opencode-quota/blob/main/quota.png)
13
+ ![Image of /quota and /tokens_daily outputs](https://github.com/slkiser/opencode-quota/blob/main/quota.png)
14
14
 
15
15
  ## Installation
16
16
 
@@ -22,9 +22,7 @@ Add to your `opencode.json`:
22
22
  }
23
23
  ```
24
24
 
25
- ## Quick Start
26
-
27
- Enable the providers you use:
25
+ Then enable the providers you use:
28
26
 
29
27
  ```jsonc
30
28
  {
@@ -36,54 +34,72 @@ Enable the providers you use:
36
34
  }
37
35
  ```
38
36
 
39
- That's it. Toasts appear automatically after main agent responses.
37
+ That's it. Toasts appear automatically after assistant responses.
38
+
39
+ > **LLM Agents:** Ask your agent to install `https://github.com/slkiser/opencode-quota` with the providers you have enabled. See [LLM Agent Installation Instructions](#llm-agent-installation-instructions) for details.
40
40
 
41
41
  ## Commands
42
42
 
43
- ### Token Reports
43
+ | Command | Description |
44
+ | ------------------------ | ------------------------------------------------ |
45
+ | `/quota` | Show quota toast (verbose) |
46
+ | `/quota_status` | Show diagnostics (config, providers, accounts) |
47
+ | | |
48
+ | `/tokens_today` | Tokens used today (calendar day) |
49
+ | `/tokens_daily` | Tokens used in last 24 hours |
50
+ | `/tokens_weekly` | Tokens used in last 7 days |
51
+ | `/tokens_monthly` | Tokens used in last 30 days |
52
+ | `/tokens_all` | Tokens used all time |
53
+ | `/tokens_session` | Tokens used in current session |
54
+ | `/tokens_between` | Tokens between two dates (YYYY-MM-DD) |
55
+ | | |
56
+ | `/firmware_reset_window` | Resets Firmware 5-hour window (requires confirm) |
44
57
 
45
- | Command | Title |
46
- | ----------------- | -------------------------------------------------------- |
47
- | `/tokens_today` | Tokens used (Today) (/tokens_today) |
48
- | `/tokens_daily` | Tokens used (Last 24 Hours) (/tokens_daily) |
49
- | `/tokens_weekly` | Tokens used (Last 7 Days) (/tokens_weekly) |
50
- | `/tokens_monthly` | Tokens used (Last 30 Days) (/tokens_monthly) |
51
- | `/tokens_all` | Tokens used (All Time) (/tokens_all) |
52
- | `/tokens_session` | Tokens used (Current Session) (/tokens_session) |
53
- | `/tokens_between` | Tokens used (YYYY-MM-DD .. YYYY-MM-DD) (/tokens_between) |
58
+ ## Supported Providers
54
59
 
55
- ### Quota Toast & Diagnostics
60
+ | Provider | Config ID | Auth Source |
61
+ | ------------------ | -------------------- | --------------------------------------------- |
62
+ | GitHub Copilot | `copilot` | OpenCode auth (automatic) |
63
+ | OpenAI (Plus/Pro) | `openai` | OpenCode auth (automatic) |
64
+ | Firmware AI | `firmware` | OpenCode auth or API key |
65
+ | Chutes AI | `chutes` | OpenCode auth or API key |
66
+ | Google Antigravity | `google-antigravity` | Multi-account via `opencode-antigravity-auth` |
56
67
 
57
- | Command | Title |
58
- | --------------- | --------------------------------- |
59
- | `/quota` | Quota Toast (Verbose) (/quota) |
60
- | `/quota_status` | Quota Diagnostics (/quota_status) |
68
+ ### Provider-Specific Setup
61
69
 
62
- ### Legacy Aliases
70
+ <details>
71
+ <summary><strong>GitHub Copilot</strong> (usually no setup needed)</summary>
63
72
 
64
- The following `/quota_*` commands remain as backwards-compatible aliases:
73
+ Copilot works automatically if OpenCode has Copilot configured and logged in.
65
74
 
66
- - `/quota_today` -> `/tokens_today`
67
- - `/quota_daily` -> `/tokens_daily`
68
- - `/quota_weekly` -> `/tokens_weekly`
69
- - `/quota_monthly` -> `/tokens_monthly`
70
- - `/quota_all` -> `/tokens_all`
71
- - `/quota_session` -> `/tokens_session`
72
- - `/quota_between` -> `/tokens_between`
75
+ **Optional:** For more reliable quota reporting, provide a fine-grained PAT:
73
76
 
74
- ## Supported Providers
77
+ 1. Create a fine-grained PAT at GitHub with **Account permissions > Plan > Read**
78
+ 2. Create `~/.config/opencode/copilot-quota-token.json`:
75
79
 
76
- | Provider | Config id | Notes |
77
- | ------------------ | -------------------- | --------------------------------------------- |
78
- | GitHub Copilot | `copilot` | Uses OpenCode auth\* |
79
- | OpenAI (Plus/Pro) | `openai` | Uses OpenCode auth |
80
- | Firmware AI | `firmware` | Uses OpenCode auth or API key |
81
- | Chutes AI | `chutes` | Uses OpenCode auth or API key |
82
- | Google Antigravity | `google-antigravity` | Multi-account via `opencode-antigravity-auth` |
80
+ ```json
81
+ {
82
+ "token": "github_pat_...",
83
+ "username": "your-username",
84
+ "tier": "pro"
85
+ }
86
+ ```
87
+
88
+ Tier options: `free`, `pro`, `pro+`, `business`, `enterprise`
89
+
90
+ </details>
91
+
92
+ <details>
93
+ <summary><strong>OpenAI</strong> (no setup needed)</summary>
94
+
95
+ OpenAI works automatically if OpenCode has OpenAI/ChatGPT configured.
96
+
97
+ </details>
83
98
 
84
- ### Firmware AI Setup
99
+ <details>
100
+ <summary><strong>Firmware AI</strong></summary>
85
101
 
86
- Firmware works automatically if OpenCode has Firmware configured. Alternatively, you can provide an API key in your `opencode.json`:
102
+ Works automatically if OpenCode has Firmware configured. Alternatively, provide an API key:
87
103
 
88
104
  ```jsonc
89
105
  {
@@ -102,11 +118,16 @@ Firmware works automatically if OpenCode has Firmware configured. Alternatively,
102
118
  }
103
119
  ```
104
120
 
105
- The `apiKey` field supports the `{env:VAR_NAME}` syntax to reference environment variables, or you can provide the key directly.
121
+ The `apiKey` field supports `{env:VAR_NAME}` syntax or a direct key.
106
122
 
107
- ### Chutes AI Setup
123
+ **Firmware-specific command:** Use `/firmware_reset_window confirm` to reset your 5-hour spending window (consumes 1 of 2 weekly resets). Running without `confirm` shows a warning first.
108
124
 
109
- Chutes works automatically if OpenCode has Chutes configured. Alternatively, you can provide an API key in your `opencode.json`:
125
+ </details>
126
+
127
+ <details>
128
+ <summary><strong>Chutes AI</strong></summary>
129
+
130
+ Works automatically if OpenCode has Chutes configured. Alternatively, provide an API key:
110
131
 
111
132
  ```jsonc
112
133
  {
@@ -125,64 +146,140 @@ Chutes works automatically if OpenCode has Chutes configured. Alternatively, you
125
146
  }
126
147
  ```
127
148
 
128
- ### GitHub Copilot Setup (optional)
149
+ </details>
129
150
 
130
- Copilot works with no extra setup as long as OpenCode already has Copilot configured and logged in.
151
+ <details>
152
+ <summary><strong>Google Antigravity</strong></summary>
131
153
 
132
- _Optional:_ if Copilot quota does not show up (or you want more reliable quota reporting), you can provide a fine-grained PAT so the plugin can use GitHub's public billing API:
133
-
134
- 1. Create a fine-grained PAT at GitHub with **Account permissions > Plan > Read**
135
- 2. Create `~/.config/opencode/copilot-quota-token.json`:
154
+ Requires the `opencode-antigravity-auth` plugin for multi-account support:
136
155
 
137
156
  ```json
138
157
  {
139
- "token": "github_pat_...",
140
- "username": "your-username",
141
- "tier": "pro"
158
+ "plugin": ["opencode-antigravity-auth", "@slkiser/opencode-quota"]
142
159
  }
143
160
  ```
144
161
 
145
- Tier options: `free`, `pro`, `pro+`, `business`, `enterprise`
162
+ Account credentials are stored in `~/.config/opencode/antigravity-accounts.json`.
146
163
 
147
- \* The plugin reads Copilot auth from OpenCode. The PAT file is only a fallback for reliability.
164
+ </details>
148
165
 
149
166
  ## Configuration Reference
150
167
 
151
168
  All options go under `experimental.quotaToast` in `opencode.json`:
152
169
 
153
- | Option | Default | Description |
154
- | ------------------- | ------------ | ------------------------------------------- |
155
- | `enabled` | `true` | Enable/disable plugin |
156
- | `enableToast` | `true` | Show popup toasts |
157
- | `enabledProviders` | `[]` | Provider ids to query |
158
- | `minIntervalMs` | `300000` | Min ms between fetches (5 min) |
159
- | `toastDurationMs` | `9000` | Toast display time |
160
- | `onlyCurrentModel` | `false` | Only show current model's quota |
161
- | `showSessionTokens` | `true` | Show per-model input/output tokens in toast |
162
- | `googleModels` | `["CLAUDE"]` | Google models: `CLAUDE`, `G3PRO`, `G3FLASH` |
163
- | `debug` | `false` | Show debug info in toasts |
170
+ | Option | Default | Description |
171
+ | ------------------- | ------------ | ----------------------------------------------- |
172
+ | `enabled` | `true` | Enable/disable plugin |
173
+ | `enableToast` | `true` | Show popup toasts |
174
+ | `enabledProviders` | `[]` | Provider IDs to query (see table above) |
175
+ | `minIntervalMs` | `300000` | Minimum ms between API fetches (default: 5 min) |
176
+ | `toastDurationMs` | `9000` | How long toasts display (ms) |
177
+ | `onlyCurrentModel` | `false` | Only show quota for the current model |
178
+ | `showSessionTokens` | `true` | Show per-model input/output tokens in toast |
179
+ | `googleModels` | `["CLAUDE"]` | Google models: `CLAUDE`, `G3PRO`, `G3FLASH` |
180
+ | `debug` | `false` | Show debug info in toasts |
181
+
182
+ ## Troubleshooting
183
+
184
+ Toast not appearing? Run `/quota_status` to check config and provider availability.
164
185
 
165
- ### Example Configuration
186
+ ---
166
187
 
167
- Here's a complete example `opencode.json` with all common options:
188
+ <details id="llm-agent-installation-instructions">
189
+ <summary><strong>LLM Agent Installation Instructions</strong></summary>
190
+
191
+ ### For LLM Agents: How to Install and Configure
192
+
193
+ This section provides instructions for LLM agents to install and configure `opencode-quota` based on the user's current OpenCode setup.
194
+
195
+ #### Step 1: Check Current Configuration
196
+
197
+ First, determine what providers the user has connected. You can:
198
+
199
+ 1. Read the user's `opencode.json` (typically at `~/.config/opencode/opencode.json`)
200
+ 2. Run `/connected` in OpenCode to see active providers
201
+ 3. Ask the user which providers they use
202
+
203
+ #### Step 2: Install the Plugin
204
+
205
+ Add the plugin to the user's `opencode.json`:
206
+
207
+ ```json
208
+ {
209
+ "plugin": ["@slkiser/opencode-quota"]
210
+ }
211
+ ```
212
+
213
+ If the user already has plugins, append to the existing array.
214
+
215
+ #### Step 3: Configure Providers
216
+
217
+ Based on the user's connected providers, add the appropriate `enabledProviders`:
168
218
 
169
219
  ```jsonc
170
220
  {
171
- "plugin": ["@slkiser/opencode-quota"],
172
221
  "experimental": {
173
222
  "quotaToast": {
174
- "enabledProviders": ["copilot", "openai"],
175
- "showSessionTokens": true,
176
- "minIntervalMs": 300000,
177
- "toastDurationMs": 9000,
223
+ "enabledProviders": [
224
+ // Add only the providers the user has configured:
225
+ // "copilot" - if using GitHub Copilot
226
+ // "openai" - if using OpenAI/ChatGPT
227
+ // "firmware" - if using Firmware AI
228
+ // "chutes" - if using Chutes AI
229
+ // "google-antigravity" - if using Google Antigravity (requires opencode-antigravity-auth)
230
+ ],
178
231
  },
179
232
  },
180
233
  }
181
234
  ```
182
235
 
183
- ## Troubleshooting
236
+ #### Provider Detection Guide
184
237
 
185
- Toast not appearing? Run `/quota_status` to check config and provider availability.
238
+ | If user's config has... | Add to enabledProviders |
239
+ | ------------------------------------------- | ----------------------- |
240
+ | `github-copilot` provider or Copilot models | `"copilot"` |
241
+ | `openai` / `chatgpt` provider | `"openai"` |
242
+ | `firmware` / `firmware-ai` provider | `"firmware"` |
243
+ | `chutes` provider | `"chutes"` |
244
+ | `google` provider with antigravity models | `"google-antigravity"` |
245
+ | `opencode-antigravity-auth` in plugins | `"google-antigravity"` |
246
+
247
+ #### Example: Full Configuration
248
+
249
+ For a user with Copilot and Google Antigravity:
250
+
251
+ ```jsonc
252
+ {
253
+ "plugin": [
254
+ "opencode-antigravity-auth", // Required for google-antigravity
255
+ "@slkiser/opencode-quota",
256
+ ],
257
+ "experimental": {
258
+ "quotaToast": {
259
+ "enabledProviders": ["copilot", "google-antigravity"],
260
+ "googleModels": ["CLAUDE"], // Options: CLAUDE, G3PRO, G3FLASH
261
+ "minIntervalMs": 300000, // 5 minutes between fetches
262
+ "toastDurationMs": 9000, // Toast shows for 9 seconds
263
+ },
264
+ },
265
+ }
266
+ ```
267
+
268
+ #### Step 4: Verify Installation
269
+
270
+ After configuration, instruct the user to:
271
+
272
+ 1. Restart OpenCode (or reload the window)
273
+ 2. Run `/quota_status` to verify providers are detected
274
+ 3. Run `/quota` to manually trigger a toast
275
+
276
+ #### Common Issues
277
+
278
+ - **Toast not showing**: Run `/quota_status` to diagnose
279
+ - **Google Antigravity not working**: Ensure `opencode-antigravity-auth` plugin is installed and accounts are configured
280
+ - **Copilot quota unreliable**: Consider setting up a fine-grained PAT (see Provider-Specific Setup above)
281
+
282
+ </details>
186
283
 
187
284
  ## License
188
285
 
@@ -5,12 +5,34 @@
5
5
  * and queries: https://app.firmware.ai/api/v1/quota
6
6
  */
7
7
  import type { QuotaError } from "./types.js";
8
+ /** Single window quota info */
9
+ export interface FirmwareWindowQuota {
10
+ percentRemaining: number;
11
+ resetTimeIso?: string;
12
+ }
8
13
  export type FirmwareResult = {
9
14
  success: true;
15
+ /** Back-compat: worst of 5h window and weekly (for classic toast) */
10
16
  percentRemaining: number;
11
17
  resetTimeIso?: string;
18
+ /** Individual windows for grouped display */
19
+ windows: {
20
+ window: FirmwareWindowQuota;
21
+ weekly: FirmwareWindowQuota;
22
+ };
23
+ /** Manual resets available this week (0-2) */
24
+ windowResetsRemaining: number;
25
+ } | QuotaError | null;
26
+ export type FirmwareResetWindowResult = {
27
+ success: true;
28
+ windowResetsRemaining?: number;
12
29
  } | QuotaError | null;
13
30
  export declare function hasFirmwareApiKeyConfigured(): Promise<boolean>;
14
31
  export { getFirmwareKeyDiagnostics, type FirmwareKeySource } from "./firmware-config.js";
15
32
  export declare function queryFirmwareQuota(): Promise<FirmwareResult>;
33
+ /**
34
+ * Manually reset the 5-hour spending window.
35
+ * Consumes one of the 2 weekly resets available.
36
+ */
37
+ export declare function resetFirmwareQuotaWindow(): Promise<FirmwareResetWindowResult>;
16
38
  //# sourceMappingURL=firmware.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"firmware.d.ts","sourceRoot":"","sources":["../../src/lib/firmware.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AA+B7C,MAAM,MAAM,cAAc,GACtB;IACE,OAAO,EAAE,IAAI,CAAC;IACd,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GACD,UAAU,GACV,IAAI,CAAC;AAIT,wBAAsB,2BAA2B,IAAI,OAAO,CAAC,OAAO,CAAC,CAEpE;AAED,OAAO,EAAE,yBAAyB,EAAE,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAEzF,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,cAAc,CAAC,CAyClE"}
1
+ {"version":3,"file":"firmware.d.ts","sourceRoot":"","sources":["../../src/lib/firmware.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAkB7C,+BAA+B;AAC/B,MAAM,WAAW,mBAAmB;IAClC,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA6BD,MAAM,MAAM,cAAc,GACtB;IACE,OAAO,EAAE,IAAI,CAAC;IACd,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6CAA6C;IAC7C,OAAO,EAAE;QACP,MAAM,EAAE,mBAAmB,CAAC;QAC5B,MAAM,EAAE,mBAAmB,CAAC;KAC7B,CAAC;IACF,8CAA8C;IAC9C,qBAAqB,EAAE,MAAM,CAAC;CAC/B,GACD,UAAU,GACV,IAAI,CAAC;AAET,MAAM,MAAM,yBAAyB,GACjC;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAAE,GACjD,UAAU,GACV,IAAI,CAAC;AAKT,wBAAsB,2BAA2B,IAAI,OAAO,CAAC,OAAO,CAAC,CAEpE;AAED,OAAO,EAAE,yBAAyB,EAAE,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAEzF,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,cAAc,CAAC,CA2ElE;AAED;;;GAGG;AACH,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,yBAAyB,CAAC,CAsDnF"}
@@ -11,6 +11,15 @@ function clampPercent(n) {
11
11
  return 0;
12
12
  return Math.max(0, Math.min(100, Math.round(n)));
13
13
  }
14
+ /**
15
+ * Strip control characters (ANSI escapes, etc.) from error text
16
+ * to prevent terminal injection when displayed in TUI output.
17
+ */
18
+ function sanitizeErrorText(text) {
19
+ // Remove ANSI escape sequences and other control characters (except newline/tab)
20
+ // eslint-disable-next-line no-control-regex
21
+ return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1B\[[0-9;]*[A-Za-z]/g, "");
22
+ }
14
23
  async function readFirmwareAuth() {
15
24
  const result = await resolveFirmwareApiKey();
16
25
  if (!result)
@@ -18,6 +27,7 @@ async function readFirmwareAuth() {
18
27
  return { type: "api", key: result.key, source: result.source };
19
28
  }
20
29
  const FIRMWARE_QUOTA_URL = "https://app.firmware.ai/api/v1/quota";
30
+ const FIRMWARE_RESET_WINDOW_URL = "https://app.firmware.ai/api/v1/quota/reset-window";
21
31
  export async function hasFirmwareApiKeyConfigured() {
22
32
  return await hasFirmwareApiKey();
23
33
  }
@@ -38,18 +48,97 @@ export async function queryFirmwareQuota() {
38
48
  const text = await resp.text();
39
49
  return {
40
50
  success: false,
41
- error: `Firmware API error ${resp.status}: ${text.slice(0, 120)}`,
51
+ error: `Firmware API error ${resp.status}: ${sanitizeErrorText(text.slice(0, 120))}`,
52
+ };
53
+ }
54
+ const data = (await resp.json());
55
+ // Parse 5-hour window
56
+ const windowUsed = typeof data.windowUsed === "number" ? data.windowUsed : NaN;
57
+ const windowPercentRemaining = clampPercent(100 - windowUsed * 100);
58
+ const windowResetIso = typeof data.windowReset === "string" && data.windowReset.length > 0
59
+ ? data.windowReset
60
+ : undefined;
61
+ // Parse weekly
62
+ const weeklyUsed = typeof data.weeklyUsed === "number" ? data.weeklyUsed : NaN;
63
+ const weeklyPercentRemaining = clampPercent(100 - weeklyUsed * 100);
64
+ const weeklyResetIso = typeof data.weeklyReset === "string" && data.weeklyReset.length > 0
65
+ ? data.weeklyReset
66
+ : undefined;
67
+ // Parse resets remaining (use trunc to avoid surprising rounding)
68
+ const windowResetsRemaining = typeof data.windowResetsRemaining === "number"
69
+ ? Math.max(0, Math.min(2, Math.trunc(data.windowResetsRemaining)))
70
+ : 0;
71
+ // Back-compat: use worst window for classic display
72
+ const windowQuota = {
73
+ percentRemaining: windowPercentRemaining,
74
+ resetTimeIso: windowResetIso,
75
+ };
76
+ const weeklyQuota = {
77
+ percentRemaining: weeklyPercentRemaining,
78
+ resetTimeIso: weeklyResetIso,
79
+ };
80
+ // Worst window for classic mode
81
+ const isWindowWorse = windowPercentRemaining <= weeklyPercentRemaining;
82
+ const worst = isWindowWorse ? windowQuota : weeklyQuota;
83
+ return {
84
+ success: true,
85
+ percentRemaining: worst.percentRemaining,
86
+ resetTimeIso: worst.resetTimeIso,
87
+ windows: {
88
+ window: windowQuota,
89
+ weekly: weeklyQuota,
90
+ },
91
+ windowResetsRemaining,
92
+ };
93
+ }
94
+ catch (err) {
95
+ return {
96
+ success: false,
97
+ error: err instanceof Error ? err.message : String(err),
98
+ };
99
+ }
100
+ }
101
+ /**
102
+ * Manually reset the 5-hour spending window.
103
+ * Consumes one of the 2 weekly resets available.
104
+ */
105
+ export async function resetFirmwareQuotaWindow() {
106
+ const auth = await readFirmwareAuth();
107
+ if (!auth)
108
+ return null;
109
+ try {
110
+ const resp = await fetchWithTimeout(FIRMWARE_RESET_WINDOW_URL, {
111
+ method: "POST",
112
+ headers: {
113
+ Authorization: `Bearer ${auth.key}`,
114
+ "User-Agent": "OpenCode-Quota-Toast/1.0",
115
+ },
116
+ });
117
+ if (!resp.ok) {
118
+ const text = await resp.text();
119
+ // Try to parse error JSON for better message
120
+ try {
121
+ const errorData = JSON.parse(text);
122
+ if (errorData.message) {
123
+ return {
124
+ success: false,
125
+ error: sanitizeErrorText(errorData.message),
126
+ };
127
+ }
128
+ }
129
+ catch {
130
+ // Not JSON, use raw text
131
+ }
132
+ return {
133
+ success: false,
134
+ error: `Firmware API error ${resp.status}: ${sanitizeErrorText(text.slice(0, 120))}`,
42
135
  };
43
136
  }
137
+ // Parse success response
44
138
  const data = (await resp.json());
45
- // Firmware returns used ratio [0..1]. We convert to remaining %.
46
- const used = typeof data.used === "number" ? data.used : NaN;
47
- const percentRemaining = clampPercent(100 - used * 100);
48
- const resetIso = typeof data.reset === "string" && data.reset.length > 0 ? data.reset : undefined;
49
139
  return {
50
140
  success: true,
51
- percentRemaining,
52
- resetTimeIso: resetIso,
141
+ windowResetsRemaining: typeof data.windowResetsRemaining === "number" ? data.windowResetsRemaining : undefined,
53
142
  };
54
143
  }
55
144
  catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"firmware.js","sourceRoot":"","sources":["../../src/lib/firmware.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EACL,qBAAqB,EACrB,iBAAiB,GAGlB,MAAM,sBAAsB,CAAC;AAO9B,SAAS,YAAY,CAAC,CAAS;IAC7B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAClC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACnD,CAAC;AAQD,KAAK,UAAU,gBAAgB;IAC7B,MAAM,MAAM,GAAG,MAAM,qBAAqB,EAAE,CAAC;IAC7C,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;AACjE,CAAC;AAWD,MAAM,kBAAkB,GAAG,sCAAsC,CAAC;AAElE,MAAM,CAAC,KAAK,UAAU,2BAA2B;IAC/C,OAAO,MAAM,iBAAiB,EAAE,CAAC;AACnC,CAAC;AAED,OAAO,EAAE,yBAAyB,EAA0B,MAAM,sBAAsB,CAAC;AAEzF,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,MAAM,IAAI,GAAG,MAAM,gBAAgB,EAAE,CAAC;IACtC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,gBAAgB,CAAC,kBAAkB,EAAE;YACtD,MAAM,EAAE,KAAK;YACb,OAAO,EAAE;gBACP,aAAa,EAAE,UAAU,IAAI,CAAC,GAAG,EAAE;gBACnC,YAAY,EAAE,0BAA0B;aACzC;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACb,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;YAC/B,OAAO;gBACL,OAAO,EAAE,KAAK;gBACd,KAAK,EAAE,sBAAsB,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE;aAClE,CAAC;QACJ,CAAC;QAED,MAAM,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,CAA0B,CAAC;QAE1D,iEAAiE;QACjE,MAAM,IAAI,GAAG,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;QAC7D,MAAM,gBAAgB,GAAG,YAAY,CAAC,GAAG,GAAG,IAAI,GAAG,GAAG,CAAC,CAAC;QAExD,MAAM,QAAQ,GACZ,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;QAEnF,OAAO;YACL,OAAO,EAAE,IAAI;YACb,gBAAgB;YAChB,YAAY,EAAE,QAAQ;SACvB,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO;YACL,OAAO,EAAE,KAAK;YACd,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;SACxD,CAAC;IACJ,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"firmware.js","sourceRoot":"","sources":["../../src/lib/firmware.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,15 @@
1
1
  type Align = "left" | "right" | "center";
2
+ /**
3
+ * Width measurement mode:
4
+ * - "raw": Use string length (grapheme count)
5
+ * - "markdown-conceal": Strip markdown syntax for width calculation (for TUI concealment mode)
6
+ */
7
+ export type WidthMode = "raw" | "markdown-conceal";
2
8
  export declare function renderMarkdownTable(params: {
3
9
  headers: string[];
4
10
  rows: string[][];
5
11
  aligns?: Align[];
12
+ widthMode?: WidthMode;
6
13
  }): string;
7
14
  export {};
8
15
  //# sourceMappingURL=markdown-table.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"markdown-table.d.ts","sourceRoot":"","sources":["../../src/lib/markdown-table.ts"],"names":[],"mappings":"AAAA,KAAK,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAqBzC,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAC1C,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC;IACjB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;CAClB,GAAG,MAAM,CAsCT"}
1
+ {"version":3,"file":"markdown-table.d.ts","sourceRoot":"","sources":["../../src/lib/markdown-table.ts"],"names":[],"mappings":"AAAA,KAAK,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEzC;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,kBAAkB,CAAC;AAgGnD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAC1C,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC;IACjB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;IACjB,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,MAAM,CAqCT"}
@@ -1,9 +1,77 @@
1
- function padCell(text, width, align) {
1
+ /**
2
+ * Use Intl.Segmenter for grapheme-aware width measurement when available.
3
+ * Falls back to Array.from for code point count.
4
+ */
5
+ const GRAPHEME_SEGMENTER = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
6
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
7
+ : null;
8
+ /**
9
+ * Measure width using grapheme clusters (preferred) or code points (fallback).
10
+ */
11
+ function measureWidth(text) {
2
12
  const s = text ?? "";
3
- const len = s.length;
4
- if (len >= width)
13
+ if (!s)
14
+ return 0;
15
+ if (GRAPHEME_SEGMENTER) {
16
+ let n = 0;
17
+ for (const _ of GRAPHEME_SEGMENTER.segment(s))
18
+ n++;
19
+ return n;
20
+ }
21
+ return Array.from(s).length;
22
+ }
23
+ /**
24
+ * Convert markdown text to visual representation for width calculation.
25
+ * Strips markdown syntax that is hidden in concealment mode.
26
+ */
27
+ function toVisualTextForWidth(text) {
28
+ // Treat escaped markdown pipes as a single visual char.
29
+ let t = (text ?? "").replace(/\\\|/g, "|");
30
+ // Protect inline code so markdown inside `code` remains literal.
31
+ const codeSpans = [];
32
+ t = t.replace(/`([^`]+?)`/g, (_m, content) => {
33
+ codeSpans.push(content);
34
+ return `\x00CODE${codeSpans.length - 1}\x00`;
35
+ });
36
+ let prev = "";
37
+ while (t !== prev) {
38
+ prev = t;
39
+ t = t
40
+ // ***bold+italic*** -> text
41
+ .replace(/\*\*\*(.+?)\*\*\*/g, "$1")
42
+ // **bold** -> text
43
+ .replace(/\*\*(.+?)\*\*/g, "$1")
44
+ // *italic* -> text
45
+ .replace(/\*(.+?)\*/g, "$1")
46
+ // ~~strikethrough~~ -> text
47
+ .replace(/~~(.+?)~~/g, "$1")
48
+ // ![alt](url) -> alt
49
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1")
50
+ // [text](url) -> text (url)
51
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
52
+ }
53
+ // Restore inline code contents (without backticks)
54
+ t = t.replace(/\x00CODE(\d+)\x00/g, (_m, idx) => {
55
+ const i = Number(idx);
56
+ return Number.isFinite(i) ? (codeSpans[i] ?? "") : "";
57
+ });
58
+ return t;
59
+ }
60
+ /**
61
+ * Calculate cell width based on the specified mode.
62
+ */
63
+ function cellWidth(text, widthMode) {
64
+ if (widthMode === "markdown-conceal") {
65
+ return measureWidth(toVisualTextForWidth(text));
66
+ }
67
+ return measureWidth(text);
68
+ }
69
+ function padCell(text, width, align, widthMode) {
70
+ const s = text ?? "";
71
+ const w = cellWidth(s, widthMode);
72
+ if (w >= width)
5
73
  return s;
6
- const pad = width - len;
74
+ const pad = width - w;
7
75
  if (align === "right")
8
76
  return " ".repeat(pad) + s;
9
77
  if (align === "center") {
@@ -19,6 +87,7 @@ function escapeCell(text) {
19
87
  }
20
88
  export function renderMarkdownTable(params) {
21
89
  const aligns = params.aligns ?? params.headers.map(() => "left");
90
+ const widthMode = params.widthMode ?? "raw";
22
91
  const colCount = params.headers.length;
23
92
  const safeRows = params.rows.map((r) => {
24
93
  const out = [];
@@ -27,15 +96,13 @@ export function renderMarkdownTable(params) {
27
96
  return out;
28
97
  });
29
98
  const headerCells = params.headers.map((h) => escapeCell(h));
30
- const widths = headerCells.map((h) => Math.max(3, h.length));
99
+ const widths = headerCells.map((h) => Math.max(3, cellWidth(h, widthMode)));
31
100
  for (const row of safeRows) {
32
101
  for (let i = 0; i < colCount; i++) {
33
- widths[i] = Math.max(widths[i], (row[i] ?? "").length);
102
+ widths[i] = Math.max(widths[i], cellWidth(row[i] ?? "", widthMode));
34
103
  }
35
104
  }
36
- const fmtRow = (cells) => `| ${cells
37
- .map((c, i) => padCell(c, widths[i], aligns[i] ?? "left"))
38
- .join(" | ")} |`;
105
+ const fmtRow = (cells) => `| ${cells.map((c, i) => padCell(c, widths[i], aligns[i] ?? "left", widthMode)).join(" | ")} |`;
39
106
  const sep = `| ${widths
40
107
  .map((w, i) => {
41
108
  const a = aligns[i] ?? "left";