opencode-tweaks 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +222 -2
- package/package.json +62 -4
- package/src/index.ts +20 -0
- package/src/quota/format.ts +97 -0
- package/src/quota/pace.ts +180 -0
- package/src/quota/parse.ts +101 -0
- package/src/quota/service.ts +224 -0
- package/src/rpc.ts +84 -0
- package/src/tui.tsx +66 -0
- package/src/tweaks/menu-dialog.tsx +424 -0
- package/src/tweaks/menu-model.ts +55 -0
- package/src/tweaks/menu.ts +1 -0
- package/src/tweaks/quota-panel.tsx +514 -0
- package/src/tweaks/quota.ts +163 -0
- package/src/tweaks/registry.ts +10 -0
- package/src/tweaks/settings.ts +73 -0
- package/src/tweaks/token-inject.ts +724 -0
- package/src/tweaks/token-slots.tsx +327 -0
- package/src/tweaks/tokens-model.ts +213 -0
- package/src/tweaks/tokens.ts +113 -0
- package/src/tweaks/types.ts +187 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FURYMOB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,223 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-tweaks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/furymob-git/opencode-tweaks/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Settings and small interface improvements for [OpenCode](https://opencode.ai), collected in one place.
|
|
7
|
+
|
|
8
|
+
OpenCode is already comfortable out of the box. This plugin is for the details you may still want to tune: a command
|
|
9
|
+
that is missing, a keybinding that does not fit your workflow, a behavior you would rather switch off. Every tweak is
|
|
10
|
+
optional, and the defaults stay exactly where they are.
|
|
11
|
+
|
|
12
|
+
## Status
|
|
13
|
+
|
|
14
|
+
Early development. The `/tweaks` menu and the OpenCode Go quota sidebar are in place. Both the tweak list and the
|
|
15
|
+
configuration format may change before the first stable release.
|
|
16
|
+
|
|
17
|
+
## Principles
|
|
18
|
+
|
|
19
|
+
- **Sensible defaults.** Tweaks are on out of the box, tuned to the setup that works best day to day. Every one has a
|
|
20
|
+
switch in `/tweaks` when you want OpenCode untouched.
|
|
21
|
+
- **Native first.** If OpenCode already handles something well, this plugin does not replace it.
|
|
22
|
+
- **Public API only.** No forks, patches, or private imports. Everything goes through `@opencode/plugin`.
|
|
23
|
+
- **Small surface.** Each tweak is isolated and documented, so you can see exactly what it changes.
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- OpenCode 2.x
|
|
28
|
+
- Node.js 24 or newer for local development
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
opencode plugin add opencode-tweaks
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or let OpenCode install and verify it for you. Paste this prompt into a session:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
Install the opencode-tweaks plugin on this machine.
|
|
40
|
+
|
|
41
|
+
- Run `opencode --version` first and stop with a clear message if it is not 2.x.
|
|
42
|
+
- Run `opencode plugin add opencode-tweaks`.
|
|
43
|
+
- Run `opencode plugin list` and confirm opencode-tweaks is registered.
|
|
44
|
+
- Tell me the plugin version, the config file that changed, and anything I need to restart.
|
|
45
|
+
- Do not edit my existing settings or plugin options.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
To add it by hand, put it in the `plugins` list in `opencode.json`:
|
|
49
|
+
|
|
50
|
+
```jsonc
|
|
51
|
+
{
|
|
52
|
+
"$schema": "https://opencode.ai/config.json",
|
|
53
|
+
"plugins": ["opencode-tweaks"]
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
To follow the source instead, point the same list at a local checkout, such as `"../opencode-tweaks"`. Relative paths
|
|
58
|
+
resolve from the config file that contains the entry and must point at a directory.
|
|
59
|
+
|
|
60
|
+
## Settings
|
|
61
|
+
|
|
62
|
+
Run `/tweaks` to open the settings menu. It mirrors OpenCode's own settings dialog: the same theme, grouped rows, search
|
|
63
|
+
field, and footer hints. The tweak list opens first; selecting one shows its settings. Boolean settings toggle in place;
|
|
64
|
+
other settings cycle through their values with `enter` or `←`/`→`. Press `ctrl+r` to reset the selected setting.
|
|
65
|
+
|
|
66
|
+
The command also appears in the palette under `Tweaks`. Its id is `opencode-tweaks.tweaks` if you want to bind a key in
|
|
67
|
+
`cli.json`.
|
|
68
|
+
|
|
69
|
+
Values are resolved in this order:
|
|
70
|
+
|
|
71
|
+
1. a value changed from `/tweaks` and stored locally by the plugin
|
|
72
|
+
2. an option in `opencode.json`
|
|
73
|
+
3. the default in the tweak definition
|
|
74
|
+
|
|
75
|
+
Options are useful when you want the same configuration on several machines:
|
|
76
|
+
|
|
77
|
+
```jsonc
|
|
78
|
+
{
|
|
79
|
+
"$schema": "https://opencode.ai/config.json",
|
|
80
|
+
"plugins": [
|
|
81
|
+
{
|
|
82
|
+
"package": "opencode-tweaks",
|
|
83
|
+
"options": {
|
|
84
|
+
"quota": {
|
|
85
|
+
"enabled": true,
|
|
86
|
+
"barWidth": 30
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Resetting a setting drops the local value, so the option or default applies again.
|
|
95
|
+
|
|
96
|
+
## Tweaks
|
|
97
|
+
|
|
98
|
+
| Tweak | What it does | Default |
|
|
99
|
+
| -------- | ----------------------------------------------------------------- | ------- |
|
|
100
|
+
| `quota` | Provider quota, reset times, and a daily spending budget. | On |
|
|
101
|
+
| `tokens` | Token and cost usage per turn, plus session totals at the bottom. | On |
|
|
102
|
+
|
|
103
|
+
### Quota
|
|
104
|
+
|
|
105
|
+
Quota shows the OpenCode Go limits for the 5h, 7d, and 30d windows. It uses the active OpenCode Console sign-in when
|
|
106
|
+
there is one, then an `opencode-go` API key. Sign in with `opencode auth login opencode` or
|
|
107
|
+
`opencode auth login opencode-go` before enabling the tweak.
|
|
108
|
+
|
|
109
|
+
The panel refreshes every two minutes, and a failed read retries after twenty seconds and keeps the last rows. Click the
|
|
110
|
+
`Quota` header to collapse or expand the rows. Bar width defaults to `auto`, which uses the space the sidebar leaves
|
|
111
|
+
after the percent and reset columns. A fixed width is available from `/tweaks`.
|
|
112
|
+
|
|
113
|
+
`Percent decimals` chooses 0, 1, or 2 decimals for every percentage, the `day` row included. The Console endpoints
|
|
114
|
+
publish a finer share than a whole percent, so the figures move between points instead of jumping a whole one; a source
|
|
115
|
+
that only reports whole percents shows the same value either way.
|
|
116
|
+
|
|
117
|
+
The provider name sits on the `Quota` line by default and can be aligned left, center, or right. Turning
|
|
118
|
+
`One tab per provider` off gives it a line of its own, above the rows that collapse underneath.
|
|
119
|
+
|
|
120
|
+
`Current provider only` is on by default. Each quota source is registered on the server under the provider ID the model
|
|
121
|
+
catalog uses, so the panel names no provider itself and follows the model in use: it appears for a provider whose quota
|
|
122
|
+
the plugin can read, and stays hidden for one that publishes none. Turn the setting off to show every quota the plugin
|
|
123
|
+
knows at once. OpenCode Go is the only source today; adding another is a reader and one entry in the registry.
|
|
124
|
+
|
|
125
|
+
`Daily budget` adds a `day` row under the longest window: the share of that quota set aside for today, spread over the
|
|
126
|
+
workdays left before it resets. The bar starts full each morning and empties as the provider reports less quota, so you
|
|
127
|
+
see how much of today's share is left. It takes the theme accent color and turns orange near the end, then red once the
|
|
128
|
+
share is spent. The figures are frozen at the first reading of the day, so the bar only moves when quota is used, not
|
|
129
|
+
on every refresh.
|
|
130
|
+
|
|
131
|
+
`Reserve` keeps a percentage of the window out of the daily budget, so the month does not end at zero. The `Workdays`
|
|
132
|
+
settings choose which weekdays count; every day counts by default, and a day left off gets no budget and shows `rest` on
|
|
133
|
+
the row. The budget resets at local midnight, and a changed provider reset or workday pattern starts a fresh one.
|
|
134
|
+
|
|
135
|
+
### Tokens
|
|
136
|
+
|
|
137
|
+
Tokens shows two token counts and, optionally, their cost. Each has its own toggle.
|
|
138
|
+
|
|
139
|
+
`Turn tokens` adds what the latest finished turn spent to its `tok/s` line: `12.3K tok · 62.2 tok/s`. OpenCode draws
|
|
140
|
+
that line itself and publishes no slot inside it, so the tweak splices the value into the line's text node and repairs
|
|
141
|
+
the splice while a turn runs. That placement is best effort and may lag or break when OpenCode changes the line. When the
|
|
142
|
+
splice is not available the count stays out of the line rather than moving elsewhere.
|
|
143
|
+
|
|
144
|
+
The line belongs to the turn that finished above it, so each line keeps its own figures: while the next turn runs, the
|
|
145
|
+
previous line still shows the previous turn's numbers rather than the running one's. When a turn finishes, its line
|
|
146
|
+
receives its figures and the older lines keep theirs.
|
|
147
|
+
|
|
148
|
+
`First token time` controls the host's own `1.7s ttft` figure at the end of that line: it is off by default, so the
|
|
149
|
+
span is removed and `tok/s` stays alone. Turning it on restores the host's figure.
|
|
150
|
+
|
|
151
|
+
`Turn cost` adds that turn's cost after the throughput figure: `62.2 tok/s · $0.0042`.
|
|
152
|
+
|
|
153
|
+
`Session tokens` adds the session's total usage to the bottom status line, before the host's context figure:
|
|
154
|
+
`1 shell · 790.6K tok total · $0.91 · 115K (29%)`. It reads the counters the session itself keeps, so
|
|
155
|
+
the number stays exact even when the transcript has only loaded its latest messages. The figures follow the shell
|
|
156
|
+
indicator when one is running and otherwise open the context text, so the order stays shell, total, session cost,
|
|
157
|
+
context. OpenCode draws those texts itself and publishes no slot inside them, so the figures are spliced in and
|
|
158
|
+
repaired while the line changes; when the splice is not available the figures stay out of the line rather than
|
|
159
|
+
moving elsewhere.
|
|
160
|
+
|
|
161
|
+
`Token count` picks what the two numbers add up. `Generated only` is the default: it keeps just what the model wrote.
|
|
162
|
+
`New tokens` adds prompt, completion, reasoning, and cache writes, without cache reads, because a turn re-reads the
|
|
163
|
+
whole context at every step and those reads would dwarf everything else. `Everything` matches the counters `/stats`
|
|
164
|
+
reports, cache reads included. `Include subagents` is on by default, so the session totals cover the whole session tree
|
|
165
|
+
instead of only the session on screen; the per-turn count always stays on the session on screen, because a prompt
|
|
166
|
+
belongs to it. `Token format` switches between compact counts such as `12.4K` and every digit such as `12,431`.
|
|
167
|
+
|
|
168
|
+
Costs come from what the provider reports, so a free plan shows `$0.00`. `Cost decimals` controls the decimals:
|
|
169
|
+
`Auto` keeps two decimals and grows until the first significant digit shows (`$0.004`), while a fixed choice always
|
|
170
|
+
keeps that many (`$0.0042`).
|
|
171
|
+
|
|
172
|
+
`Estimate cost` fills in a price the provider did not report, using the model's list price from the same catalog
|
|
173
|
+
OpenCode already synced: prompt, completion, reasoning billed as output, and cache traffic, each at its own rate. A
|
|
174
|
+
provider that reports a price always wins, and a session that changed model is estimated with the model it ended on, so
|
|
175
|
+
treat the figure as an estimate. This is what makes the OpenCode Go monthly credit readable: the plan reports `$0.00`
|
|
176
|
+
per turn, and the estimate follows the API rates instead. Costs use the raw counters whatever `Token count` says.
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
npm install
|
|
182
|
+
npm run check
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`npm run check` runs the type checker, the linter, and the tests. `npm run format` applies the formatter.
|
|
186
|
+
|
|
187
|
+
OpenCode loads the package from `src`, so there is no build step. To test changes, configure the checkout as a local
|
|
188
|
+
plugin (see Install) and let the config watcher reload it. If a change is not picked up, run `opencode service restart`.
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
src/
|
|
192
|
+
index.ts server entry: quota RPC registration
|
|
193
|
+
rpc.ts typed RPC contract
|
|
194
|
+
quota/
|
|
195
|
+
service.ts credential lookup and quota fetch
|
|
196
|
+
parse.ts provider response parsing
|
|
197
|
+
format.ts percents, reset times, and bars
|
|
198
|
+
tui.tsx terminal entry: command and sidebar slot
|
|
199
|
+
tweaks/
|
|
200
|
+
types.ts settings schema and validation
|
|
201
|
+
registry.ts list of tweaks
|
|
202
|
+
settings.ts stored, config, and default resolution
|
|
203
|
+
menu.ts /tweaks entry
|
|
204
|
+
menu-dialog.tsx reactive settings dialog
|
|
205
|
+
menu-model.ts filtering and selection helpers
|
|
206
|
+
quota.ts quota tweak definition
|
|
207
|
+
quota-panel.tsx sidebar quota panel
|
|
208
|
+
tokens.ts tokens tweak definition
|
|
209
|
+
token-slots.tsx inline splice driver and status line
|
|
210
|
+
token-inject.ts splice into the host's tok/s line
|
|
211
|
+
tokens-model.ts token sum and formatting
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Contributing
|
|
215
|
+
|
|
216
|
+
Tweaks should stay small, opt-in, and built on the public plugin API. If something can be done with normal OpenCode
|
|
217
|
+
configuration, it does not belong here. Open an issue before starting on anything substantial.
|
|
218
|
+
|
|
219
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the branch model, commit format, code rules, and release process.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
2
3
|
"name": "opencode-tweaks",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"description": "Opt-in settings and small interface improvements for OpenCode.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=24"
|
|
10
|
+
},
|
|
11
|
+
"author": "FURYMOB",
|
|
12
|
+
"homepage": "https://github.com/furymob-git/opencode-tweaks#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/furymob-git/opencode-tweaks.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/furymob-git/opencode-tweaks/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"opencode",
|
|
22
|
+
"opencode-plugin",
|
|
23
|
+
"tui",
|
|
24
|
+
"settings",
|
|
25
|
+
"quota",
|
|
26
|
+
"tokens"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./src/index.ts",
|
|
30
|
+
"./rpc": "./src/rpc.ts",
|
|
31
|
+
"./tui": "./src/tui.tsx"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"!src/**/*.test.ts"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"lint": "biome check .",
|
|
43
|
+
"format": "biome format --write .",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"check": "npm run typecheck && npm run lint && npm test"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@opencode/plugin": "^2.2.7"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@opentui/core": ">=0.8.1",
|
|
52
|
+
"@opentui/solid": ">=0.8.1",
|
|
53
|
+
"solid-js": ">=1.9.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@biomejs/biome": "^2.6.7",
|
|
57
|
+
"@opentui/core": "^0.8.4",
|
|
58
|
+
"@opentui/solid": "^0.8.4",
|
|
59
|
+
"@types/node": "^24",
|
|
60
|
+
"solid-js": "^1.9.19",
|
|
61
|
+
"typescript": "^7.0.2",
|
|
62
|
+
"vitest": "^5.1.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { readQuotas } from "./quota/service"
|
|
3
|
+
import { Quota } from "./rpc"
|
|
4
|
+
|
|
5
|
+
export default Plugin.define({
|
|
6
|
+
id: "opencode-tweaks",
|
|
7
|
+
async setup(context) {
|
|
8
|
+
const registration = await context.rpc.register(Quota, {
|
|
9
|
+
snapshot: async (input, call) => {
|
|
10
|
+
// JSON Schema methods do not infer their input, so the shape is cast here.
|
|
11
|
+
const { providerID } = input as { providerID?: string }
|
|
12
|
+
return { snapshots: await readQuotas(context, providerID, call.signal) }
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
return async () => {
|
|
17
|
+
await registration.dispose()
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export type QuotaLevel = "good" | "warning" | "low"
|
|
2
|
+
|
|
3
|
+
const MINUTE = 60_000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One decimal only when the percent has one: "98%", "99.2%". Passing
|
|
7
|
+
* `decimals` keeps that many instead, so the column can show the finer share a
|
|
8
|
+
* provider publishes.
|
|
9
|
+
*/
|
|
10
|
+
export function formatPercent(value: number, decimals?: number): string {
|
|
11
|
+
const clamped = Math.max(0, Math.min(100, value))
|
|
12
|
+
|
|
13
|
+
if (decimals !== undefined) {
|
|
14
|
+
const fixed = Math.max(0, Math.min(2, Math.round(decimals)))
|
|
15
|
+
if (fixed === 0) return `${Math.round(clamped)}%`
|
|
16
|
+
const factor = 10 ** fixed
|
|
17
|
+
return `${(Math.round(clamped * factor) / factor).toFixed(fixed)}%`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rounded = Math.round(clamped * 10) / 10
|
|
21
|
+
return Number.isInteger(rounded) ? `${rounded}%` : `${rounded.toFixed(1)}%`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The compact reset style from the sidebar: "42m", "4h 9m", "2d 23h", "29d 14h".
|
|
26
|
+
* Minutes stay visible under a day, and days always carry their hours so the
|
|
27
|
+
* column keeps the same shape.
|
|
28
|
+
*/
|
|
29
|
+
export function formatReset(iso: string, now: number): string {
|
|
30
|
+
const time = Date.parse(iso)
|
|
31
|
+
if (!Number.isFinite(time) || time <= now) return "now"
|
|
32
|
+
|
|
33
|
+
const minutes = Math.ceil((time - now) / MINUTE)
|
|
34
|
+
if (minutes < 60) return `${minutes}m`
|
|
35
|
+
|
|
36
|
+
const totalHours = Math.floor(minutes / 60)
|
|
37
|
+
const days = Math.floor(totalHours / 24)
|
|
38
|
+
|
|
39
|
+
if (days === 0) {
|
|
40
|
+
const rest = minutes % 60
|
|
41
|
+
return rest === 0 ? `${totalHours}h` : `${totalHours}h ${rest}m`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hours = totalHours % 24
|
|
45
|
+
return hours === 0 ? `${days}d` : `${days}d ${hours}h`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Filled and empty cells for a line bar. The caller colors each part. */
|
|
49
|
+
export function barParts(percent: number, width: number): { filled: string; empty: string } {
|
|
50
|
+
const cells = Math.max(0, Math.floor(width))
|
|
51
|
+
const clamped = Math.max(0, Math.min(100, percent))
|
|
52
|
+
const filled = Math.round((clamped / 100) * cells)
|
|
53
|
+
return {
|
|
54
|
+
filled: "━".repeat(filled),
|
|
55
|
+
empty: "─".repeat(Math.max(0, cells - filled)),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Plenty left, getting low, or almost out. */
|
|
60
|
+
export function levelOf(percentRemaining: number): QuotaLevel {
|
|
61
|
+
const left = Math.max(0, Math.min(100, percentRemaining))
|
|
62
|
+
if (left < 20) return "low"
|
|
63
|
+
return left < 50 ? "warning" : "good"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function messageOf(value: unknown): string | undefined {
|
|
67
|
+
if (typeof value !== "object" || value === null || !("message" in value)) return undefined
|
|
68
|
+
const message = (value as { message: unknown }).message
|
|
69
|
+
return typeof message === "string" ? message : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Readable text for anything thrown. The RPC client rejects with plain objects
|
|
74
|
+
* such as `{ type, message }` rather than `Error` instances, so `String(error)`
|
|
75
|
+
* would show "[object Object]" in the panel.
|
|
76
|
+
*/
|
|
77
|
+
export function errorText(error: unknown): string {
|
|
78
|
+
const message =
|
|
79
|
+
error instanceof Error
|
|
80
|
+
? error.message
|
|
81
|
+
: (messageOf(error) ?? (typeof error === "string" ? error : describe(error)))
|
|
82
|
+
|
|
83
|
+
return message.trim().slice(0, 160)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function describe(value: unknown): string {
|
|
87
|
+
if (value === undefined || value === null) return "Unknown error"
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const json = JSON.stringify(value)
|
|
91
|
+
if (typeof json === "string" && json.length > 2) return json
|
|
92
|
+
} catch {
|
|
93
|
+
// Fall back to the default string conversion below.
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return String(value)
|
|
97
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { QuotaWindowKey } from "../rpc"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The longest window is the one a daily budget is planned on. Its nominal
|
|
5
|
+
* length is enough to choose it, because the reset date alone cannot tell a
|
|
6
|
+
* 5h window that just started from a 30d one that is about to reset.
|
|
7
|
+
*/
|
|
8
|
+
const WINDOW_DAYS: Readonly<Record<QuotaWindowKey, number>> = {
|
|
9
|
+
"5h": 5 / 24,
|
|
10
|
+
"7d": 7,
|
|
11
|
+
"30d": 30,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PaceWindow {
|
|
15
|
+
readonly key: QuotaWindowKey
|
|
16
|
+
readonly percentRemaining: number
|
|
17
|
+
readonly percentRemainingExact?: number
|
|
18
|
+
readonly resetsAt: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The share to pace with: the provider's exact figure when it publishes one, so
|
|
23
|
+
* the daily bar moves with real spending instead of a whole percent at a time.
|
|
24
|
+
*/
|
|
25
|
+
export function exactRemaining(window: PaceWindow): number {
|
|
26
|
+
return window.percentRemainingExact ?? window.percentRemaining
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The window whose quota is spread over the days: the 30d one when the provider
|
|
31
|
+
* publishes it, the 7d one otherwise, and so on.
|
|
32
|
+
*/
|
|
33
|
+
export function longestWindow<Window extends PaceWindow>(
|
|
34
|
+
windows: readonly Window[],
|
|
35
|
+
): Window | undefined {
|
|
36
|
+
let best: Window | undefined
|
|
37
|
+
for (const window of windows) {
|
|
38
|
+
if (!best || WINDOW_DAYS[window.key] > WINDOW_DAYS[best.key]) best = window
|
|
39
|
+
}
|
|
40
|
+
return best
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Local calendar day as `2026-11-13`: the budget resets with it. */
|
|
44
|
+
export function dayKey(time: number): string {
|
|
45
|
+
const date = new Date(time)
|
|
46
|
+
const month = String(date.getMonth() + 1).padStart(2, "0")
|
|
47
|
+
const day = String(date.getDate()).padStart(2, "0")
|
|
48
|
+
return `${date.getFullYear()}-${month}-${day}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Local midnight that follows `time`, when today's budget is replaced. */
|
|
52
|
+
export function endOfDay(time: number): number {
|
|
53
|
+
const date = new Date(time)
|
|
54
|
+
date.setHours(24, 0, 0, 0)
|
|
55
|
+
return date.getTime()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The weekday a timestamp falls on, with Sunday at index 0 like `Date`. */
|
|
59
|
+
export function weekdayOf(time: number): number {
|
|
60
|
+
return new Date(time).getDay()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How many selected weekdays are left before the window resets, today
|
|
65
|
+
* included when it is a workday. The reset day counts even when the reset
|
|
66
|
+
* happens early, because that morning still had a budget.
|
|
67
|
+
*/
|
|
68
|
+
export function workdaysUntil(
|
|
69
|
+
time: number,
|
|
70
|
+
resetsAt: string,
|
|
71
|
+
workdays: readonly boolean[],
|
|
72
|
+
): number {
|
|
73
|
+
const reset = Date.parse(resetsAt)
|
|
74
|
+
if (!Number.isFinite(reset) || reset <= time) return 0
|
|
75
|
+
|
|
76
|
+
const cursor = new Date(time)
|
|
77
|
+
cursor.setHours(0, 0, 0, 0)
|
|
78
|
+
const end = new Date(reset)
|
|
79
|
+
end.setHours(0, 0, 0, 0)
|
|
80
|
+
|
|
81
|
+
let count = 0
|
|
82
|
+
let guard = 0
|
|
83
|
+
// A window can never span more than a year, so the guard only stops a
|
|
84
|
+
// malformed reset date from looping.
|
|
85
|
+
while (cursor.getTime() <= end.getTime() && guard < 400) {
|
|
86
|
+
if (workdays[cursor.getDay()] === true) count += 1
|
|
87
|
+
cursor.setDate(cursor.getDate() + 1)
|
|
88
|
+
guard += 1
|
|
89
|
+
}
|
|
90
|
+
return count
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Percent of the window that can be spent on each remaining workday. */
|
|
94
|
+
export function dailyAllowance(
|
|
95
|
+
percentRemaining: number,
|
|
96
|
+
reserve: number,
|
|
97
|
+
workdays: number,
|
|
98
|
+
): number {
|
|
99
|
+
const spendable = Math.max(0, clamp(percentRemaining) - Math.max(0, reserve))
|
|
100
|
+
if (workdays <= 0) return 0
|
|
101
|
+
return spendable / workdays
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What identifies a budget day, so a changed setting starts a new baseline. */
|
|
105
|
+
export function paceSignature(workdays: readonly boolean[], reserve: number): string {
|
|
106
|
+
return `${workdays.map((day) => (day ? "1" : "0")).join("")}:${reserve}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The baseline frozen at the first reading of a budget day. Keeping the start
|
|
111
|
+
* and the allowance still means the bar only moves when quota is spent, instead
|
|
112
|
+
* of shrinking every time the panel reads a lower percentage.
|
|
113
|
+
*/
|
|
114
|
+
export interface PaceEntry {
|
|
115
|
+
readonly day: string
|
|
116
|
+
readonly resetsAt: string
|
|
117
|
+
readonly signature: string
|
|
118
|
+
readonly start: number
|
|
119
|
+
readonly allowance: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isCurrent(
|
|
123
|
+
entry: PaceEntry | undefined,
|
|
124
|
+
day: string,
|
|
125
|
+
resetsAt: string,
|
|
126
|
+
signature: string,
|
|
127
|
+
): boolean {
|
|
128
|
+
return (
|
|
129
|
+
entry !== undefined &&
|
|
130
|
+
entry.day === day &&
|
|
131
|
+
entry.resetsAt === resetsAt &&
|
|
132
|
+
entry.signature === signature
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function startPace(
|
|
137
|
+
percentRemaining: number,
|
|
138
|
+
day: string,
|
|
139
|
+
resetsAt: string,
|
|
140
|
+
signature: string,
|
|
141
|
+
workdays: readonly boolean[],
|
|
142
|
+
reserve: number,
|
|
143
|
+
now: number,
|
|
144
|
+
): PaceEntry {
|
|
145
|
+
const restDay = workdays[weekdayOf(now)] !== true
|
|
146
|
+
const allowance = restDay
|
|
147
|
+
? 0
|
|
148
|
+
: dailyAllowance(percentRemaining, reserve, workdaysUntil(now, resetsAt, workdays))
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
day,
|
|
152
|
+
resetsAt,
|
|
153
|
+
signature,
|
|
154
|
+
start: clamp(percentRemaining),
|
|
155
|
+
allowance,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface PaceProgress {
|
|
160
|
+
readonly allowance: number
|
|
161
|
+
readonly spent: number
|
|
162
|
+
readonly left: number
|
|
163
|
+
/** Share of the day's allowance that is left, from 0 to 100. */
|
|
164
|
+
readonly ratio: number
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function paceProgress(entry: PaceEntry, percentRemaining: number): PaceProgress {
|
|
168
|
+
const spent = Math.max(0, entry.start - clamp(percentRemaining))
|
|
169
|
+
const left = Math.max(0, entry.allowance - spent)
|
|
170
|
+
const ratio = entry.allowance > 0 ? clamp((left / entry.allowance) * 100) : 0
|
|
171
|
+
return { allowance: entry.allowance, spent, left, ratio }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isWorkday(time: number, workdays: readonly boolean[]): boolean {
|
|
175
|
+
return workdays[weekdayOf(time)] === true
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function clamp(value: number): number {
|
|
179
|
+
return Math.max(0, Math.min(100, value))
|
|
180
|
+
}
|