opencode-tweaks 0.4.0 → 0.5.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
@@ -95,10 +95,11 @@ Resetting a setting drops the local value, so the option or default applies agai
95
95
 
96
96
  ## Tweaks
97
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 |
98
+ | Tweak | What it does | Default |
99
+ | -------- | --------------------------------------------------------- | ------- |
100
+ | `quota` | Provider quota, reset times, and a daily spending budget. | On |
101
+ | `tokens` | Token usage per turn, plus session totals at the bottom. | On |
102
+ | `cost` | Cost per turn, plus session totals at the bottom. | On |
102
103
 
103
104
  ### Quota
104
105
 
@@ -134,7 +135,7 @@ the row. The budget resets at local midnight, and a changed provider reset or wo
134
135
 
135
136
  ### Tokens
136
137
 
137
- Tokens shows two token counts and, optionally, their cost. Each has its own toggle.
138
+ Tokens shows two token counts, each with its own toggle.
138
139
 
139
140
  `Turn tokens` adds what the latest finished turn spent to its `tok/s` line: `12.3K tok · 62.2 tok/s`. OpenCode draws
140
141
  that line itself and publishes no slot inside it, so the tweak splices the value into the line's text node and repairs
@@ -148,23 +149,32 @@ receives its figures and the older lines keep theirs.
148
149
  `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
150
  span is removed and `tok/s` stays alone. Turning it on restores the host's figure.
150
151
 
151
- `Turn cost` adds that turn's cost after the throughput figure: `62.2 tok/s · $0.0042`.
152
-
153
152
  `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.
153
+ `1 shell · 790.6K tok total · 115K (29%)`. When the cost tweak is on, its figure sits between the total and the
154
+ context. It reads the counters the session itself keeps, so the number stays exact even when the transcript has only
155
+ loaded its latest messages. The figures follow the shell indicator when one is running and otherwise open the context
156
+ text, so the order stays shell, total, session cost, context. OpenCode draws those texts itself and publishes no slot
157
+ inside them, so the figures are spliced in and repaired while the line changes; when the splice is not available the
158
+ figures stay out of the line rather than moving elsewhere.
159
+
160
+ `Token count` picks what the numbers add up. `Generated only` is the default: it keeps just what the model wrote.
162
161
  `New tokens` adds prompt, completion, reasoning, and cache writes, without cache reads, because a turn re-reads the
163
162
  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
163
+ reports, cache reads included. `Include subagents` is on by default, so both session totals cover the whole session tree
164
+ instead of only the session on screen; the per-turn figures always stay on the session on screen, because a prompt
166
165
  belongs to it. `Token format` switches between compact counts such as `12.4K` and every digit such as `12,431`.
167
166
 
167
+ ### Cost
168
+
169
+ Cost shows what a turn and the session spent, each with its own toggle. It reads the same counters as Tokens, and
170
+ either tweak can be off without hiding the other.
171
+
172
+ `Turn cost` adds that turn's cost after the throughput figure: `62.2 tok/s · $0.0042`.
173
+
174
+ `Session cost` adds the session's total cost to the bottom status line, before the host's context figure:
175
+ `1 shell · 790.6K tok total · $0.91 · 115K (29%)`. It follows the same session tree as the token total, including
176
+ `Include subagents`.
177
+
168
178
  Costs come from what the provider reports, so a free plan shows `$0.00`. `Cost decimals` controls the decimals:
169
179
  `Auto` keeps two decimals and grows until the first significant digit shows (`$0.004`), while a fixed choice always
170
180
  keeps that many (`$0.0042`).
@@ -175,6 +185,8 @@ provider that reports a price always wins, and a session that changed model is e
175
185
  treat the figure as an estimate. This is what makes the OpenCode Go monthly credit readable: the plan reports `$0.00`
176
186
  per turn, and the estimate follows the API rates instead. Costs use the raw counters whatever `Token count` says.
177
187
 
188
+ The cost settings used to live in the Tokens entry. A choice saved there before the split keeps applying.
189
+
178
190
  ## Development
179
191
 
180
192
  ```sh
@@ -194,8 +206,13 @@ src/
194
206
  quota/
195
207
  service.ts credential lookup and quota fetch
196
208
  parse.ts provider response parsing
209
+ pace.ts daily budget pacing
197
210
  format.ts percents, reset times, and bars
198
- tui.tsx terminal entry: command and sidebar slot
211
+ usage/
212
+ messages.ts message shape and turn selection
213
+ tokens.ts token sums and formatting
214
+ cost.ts list prices, estimates, and cost formatting
215
+ tui.tsx terminal entry: commands and slots
199
216
  tweaks/
200
217
  types.ts settings schema and validation
201
218
  registry.ts list of tweaks
@@ -205,10 +222,10 @@ src/
205
222
  menu-model.ts filtering and selection helpers
206
223
  quota.ts quota tweak definition
207
224
  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
225
+ tokens.ts token tweak definition
226
+ cost.ts cost tweak definition
227
+ usage-slots.tsx inline and status line figures
228
+ usage-inject.ts splice into the host's lines
212
229
  ```
213
230
 
214
231
  ## Contributing
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-tweaks",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "description": "Opt-in settings and small interface improvements for OpenCode.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -23,7 +23,8 @@
23
23
  "tui",
24
24
  "settings",
25
25
  "quota",
26
- "tokens"
26
+ "tokens",
27
+ "cost"
27
28
  ],
28
29
  "exports": {
29
30
  ".": "./src/index.ts",
package/src/tui.tsx CHANGED
@@ -2,14 +2,14 @@ import { Plugin } from "@opencode/plugin/tui"
2
2
  import { openTweaksMenu } from "./tweaks/menu"
3
3
  import { QuotaPanel } from "./tweaks/quota-panel"
4
4
  import { createSettingsStore } from "./tweaks/settings"
5
- import { createTokenInjector } from "./tweaks/token-inject"
6
- import { TokenInline, TokenStatus } from "./tweaks/token-slots"
5
+ import { createUsageInjector } from "./tweaks/usage-inject"
6
+ import { UsageInline, UsageStatus } from "./tweaks/usage-slots"
7
7
 
8
8
  export default Plugin.define({
9
9
  id: "opencode-tweaks.tui",
10
10
  setup(context) {
11
11
  const store = createSettingsStore(context)
12
- const tokens = createTokenInjector(context)
12
+ const usage = createUsageInjector(context)
13
13
  let menuOpen = false
14
14
 
15
15
  context.ui.slot({
@@ -20,14 +20,14 @@ export default Plugin.define({
20
20
  context.ui.slot({
21
21
  append: "session.composer.top",
22
22
  render: (input) => (
23
- <TokenInline context={context} input={input} store={store} injector={tokens} />
23
+ <UsageInline context={context} input={input} store={store} injector={usage} />
24
24
  ),
25
25
  })
26
26
 
27
27
  context.ui.slot({
28
28
  after: "prompt.footer.status",
29
29
  render: (input) => (
30
- <TokenStatus context={context} input={input} store={store} injector={tokens} />
30
+ <UsageStatus context={context} input={input} store={store} injector={usage} />
31
31
  ),
32
32
  })
33
33
 
@@ -61,6 +61,6 @@ export default Plugin.define({
61
61
  ],
62
62
  }))
63
63
 
64
- return () => tokens.dispose()
64
+ return () => usage.dispose()
65
65
  },
66
66
  })
@@ -0,0 +1,70 @@
1
+ import { defineTweak } from "./types"
2
+
3
+ /**
4
+ * Cost reads the same counters as token usage but stays its own tweak: a
5
+ * provider that reports nothing still benefits from an estimate, while a user
6
+ * who only wants the counts can leave the prices off. The figures share the
7
+ * turn's throughput line and the session status line with the token counts.
8
+ */
9
+ export const costTweak = defineTweak({
10
+ id: "cost",
11
+ title: "Cost",
12
+ description: "Show what a turn and the whole session cost.",
13
+ category: "Session",
14
+ /** Cost used to live in the token tweak; keep those choices readable. */
15
+ legacy: {
16
+ id: "tokens",
17
+ settings: {
18
+ prompt: "promptCost",
19
+ total: "totalCost",
20
+ digits: "costDigits",
21
+ estimate: "estimateCost",
22
+ },
23
+ },
24
+ settings: [
25
+ {
26
+ id: "enabled",
27
+ title: "Enabled",
28
+ description: "Turn the cost figures on.",
29
+ category: "General",
30
+ kind: "boolean",
31
+ default: true,
32
+ },
33
+ {
34
+ id: "prompt",
35
+ title: "Turn cost",
36
+ description: "Show that turn's cost after the tok/s figure.",
37
+ category: "Display",
38
+ kind: "boolean",
39
+ default: true,
40
+ },
41
+ {
42
+ id: "total",
43
+ title: "Session cost",
44
+ description: "Show the session's total cost on the bottom status line.",
45
+ category: "Display",
46
+ kind: "boolean",
47
+ default: true,
48
+ },
49
+ {
50
+ id: "digits",
51
+ title: "Cost decimals",
52
+ description:
53
+ "Decimals shown for costs, or auto to keep going until the first significant digit.",
54
+ category: "Display",
55
+ kind: "select",
56
+ default: "auto",
57
+ values: ["auto", 2, 3, 4, 5, 6],
58
+ labels: ["Auto", "2 digits", "3 digits", "4 digits", "5 digits", "6 digits"],
59
+ },
60
+ {
61
+ id: "estimate",
62
+ title: "Estimate cost",
63
+ description:
64
+ "Guess the cost from the model's list price when the provider reports none. A session that changed model is estimated with the model it ended on.",
65
+ category: "Scope",
66
+ kind: "boolean",
67
+ default: true,
68
+ },
69
+ ],
70
+ })
@@ -1,9 +1,10 @@
1
+ import { costTweak } from "./cost"
1
2
  import { quotaTweak } from "./quota"
2
3
  import { tokensTweak } from "./tokens"
3
4
  import type { Tweak } from "./types"
4
5
 
5
6
  /** Every tweak shown by `/tweaks`. Add new definitions here. */
6
- export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak]
7
+ export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak, costTweak]
7
8
 
8
9
  export function findTweak(id: string): Tweak | undefined {
9
10
  return tweaks.find((tweak) => tweak.id === id)
@@ -18,10 +18,28 @@ interface OptionsTree {
18
18
  readonly [key: string]: unknown
19
19
  }
20
20
 
21
+ /** One setting from a group in `opencode.json`, if the group holds one. */
22
+ function configValue(group: unknown, settingID: string): unknown {
23
+ if (!group || typeof group !== "object") return undefined
24
+ return (group as OptionsTree)[settingID]
25
+ }
26
+
27
+ /** Drops one stored value, and the empty group it leaves behind. */
28
+ function forget(stored: StoredSettings, tweakID: string, settingID: string) {
29
+ const group = stored[tweakID]
30
+ if (!group) return
31
+
32
+ delete group[settingID]
33
+ if (Object.keys(group).length === 0) delete stored[tweakID]
34
+ }
35
+
21
36
  /**
22
37
  * Reads settings in priority order: value chosen in `/tweaks`, then the plugin
23
38
  * options in `opencode.json`, then the definition default. Writes only touch
24
39
  * the local layer, so the config file stays the user's to edit.
40
+ *
41
+ * A split tweak also checks the group its settings came from, so a value chosen
42
+ * before the split keeps applying until the user changes or resets it.
25
43
  */
26
44
  export function createSettingsStore(context: Context): SettingsStore {
27
45
  const [stored, update] = context.storage.store<StoredSettings>("settings", { initial: {} })
@@ -33,14 +51,22 @@ export function createSettingsStore(context: Context): SettingsStore {
33
51
  return { value: userValue, source: "user" }
34
52
  }
35
53
 
36
- const tweakOptions = options[tweak.id]
37
- const configValue =
38
- tweakOptions && typeof tweakOptions === "object"
39
- ? (tweakOptions as OptionsTree)[setting.id]
40
- : undefined
54
+ const tweakConfig = configValue(options[tweak.id], setting.id)
55
+ if (isSettingValue(setting, tweakConfig)) {
56
+ return { value: tweakConfig, source: "config" }
57
+ }
58
+
59
+ const legacyID = tweak.legacy?.settings[setting.id]
60
+ if (tweak.legacy && legacyID) {
61
+ const legacyUser = stored[tweak.legacy.id]?.[legacyID]
62
+ if (isSettingValue(setting, legacyUser)) {
63
+ return { value: legacyUser, source: "user" }
64
+ }
41
65
 
42
- if (isSettingValue(setting, configValue)) {
43
- return { value: configValue, source: "config" }
66
+ const legacyConfig = configValue(options[tweak.legacy.id], legacyID)
67
+ if (isSettingValue(setting, legacyConfig)) {
68
+ return { value: legacyConfig, source: "config" }
69
+ }
44
70
  }
45
71
 
46
72
  return { value: setting.default, source: "default" }
@@ -60,12 +86,11 @@ export function createSettingsStore(context: Context): SettingsStore {
60
86
  }
61
87
 
62
88
  async function reset(tweak: Tweak, setting: TweakSetting): Promise<void> {
63
- await update((draft) => {
64
- const group = draft[tweak.id]
65
- if (!group) return
89
+ const legacyID = tweak.legacy?.settings[setting.id]
66
90
 
67
- delete group[setting.id]
68
- if (Object.keys(group).length === 0) delete draft[tweak.id]
91
+ await update((draft) => {
92
+ forget(draft, tweak.id, setting.id)
93
+ if (tweak.legacy && legacyID) forget(draft, tweak.legacy.id, legacyID)
69
94
  })
70
95
  }
71
96
 
@@ -8,13 +8,13 @@ import { defineTweak } from "./types"
8
8
  export const tokensTweak = defineTweak({
9
9
  id: "tokens",
10
10
  title: "Tokens",
11
- description: "Show token and cost usage for a turn and for the whole session.",
11
+ description: "Show token usage for a turn and for the whole session.",
12
12
  category: "Session",
13
13
  settings: [
14
14
  {
15
15
  id: "enabled",
16
16
  title: "Enabled",
17
- description: "Turn the token and cost counts on.",
17
+ description: "Turn the token counts on.",
18
18
  category: "General",
19
19
  kind: "boolean",
20
20
  default: true,
@@ -28,14 +28,6 @@ export const tokensTweak = defineTweak({
28
28
  kind: "boolean",
29
29
  default: true,
30
30
  },
31
- {
32
- id: "promptCost",
33
- title: "Turn cost",
34
- description: "Show that turn's cost after the tok/s figure.",
35
- category: "Display",
36
- kind: "boolean",
37
- default: true,
38
- },
39
31
  {
40
32
  id: "total",
41
33
  title: "Session tokens",
@@ -44,14 +36,6 @@ export const tokensTweak = defineTweak({
44
36
  kind: "boolean",
45
37
  default: true,
46
38
  },
47
- {
48
- id: "totalCost",
49
- title: "Session cost",
50
- description: "Show the session's total cost on the bottom status line.",
51
- category: "Display",
52
- kind: "boolean",
53
- default: true,
54
- },
55
39
  {
56
40
  id: "format",
57
41
  title: "Token format",
@@ -61,17 +45,6 @@ export const tokensTweak = defineTweak({
61
45
  default: "compact",
62
46
  values: ["compact", "exact"],
63
47
  },
64
- {
65
- id: "costDigits",
66
- title: "Cost decimals",
67
- description:
68
- "Decimals shown for costs, or auto to keep going until the first significant digit.",
69
- category: "Display",
70
- kind: "select",
71
- default: "auto",
72
- values: ["auto", 2, 3, 4, 5, 6],
73
- labels: ["Auto", "2 digits", "3 digits", "4 digits", "5 digits", "6 digits"],
74
- },
75
48
  {
76
49
  id: "ttft",
77
50
  title: "First token time",
@@ -95,16 +68,7 @@ export const tokensTweak = defineTweak({
95
68
  {
96
69
  id: "subagents",
97
70
  title: "Include subagents",
98
- description: "Add the tokens used by subagents to the session totals.",
99
- category: "Scope",
100
- kind: "boolean",
101
- default: true,
102
- },
103
- {
104
- id: "estimateCost",
105
- title: "Estimate cost",
106
- description:
107
- "Guess the cost from the model's list price when the provider reports none. A session that changed model is estimated with the model it ended on.",
71
+ description: "Add the usage of subagents to the session totals. Costs follow the same scope.",
108
72
  category: "Scope",
109
73
  kind: "boolean",
110
74
  default: true,
@@ -30,6 +30,18 @@ export interface TweakSetting {
30
30
  readonly format?: (value: SettingValue) => string
31
31
  }
32
32
 
33
+ /**
34
+ * Where a tweak's settings lived before it was split out. The store reads the
35
+ * old group when the new one holds no value, so a rename keeps the choice the
36
+ * user already made instead of silently resetting it.
37
+ */
38
+ export interface TweakLegacy {
39
+ /** The tweak id the settings used to be stored under. */
40
+ readonly id: string
41
+ /** Current setting id to the id it used to have. */
42
+ readonly settings: Readonly<Record<string, string>>
43
+ }
44
+
33
45
  export interface Tweak {
34
46
  readonly id: string
35
47
  readonly title: string
@@ -37,6 +49,7 @@ export interface Tweak {
37
49
  /** Group shown in the `/tweaks` list. */
38
50
  readonly category: string
39
51
  readonly settings: readonly TweakSetting[]
52
+ readonly legacy?: TweakLegacy
40
53
  }
41
54
 
42
55
  /** Settings changed from the menu, keyed by tweak and setting id. */
@@ -105,6 +118,12 @@ export function defineTweak(tweak: Tweak): Tweak {
105
118
  }
106
119
  }
107
120
 
121
+ for (const id of Object.keys(tweak.legacy?.settings ?? {})) {
122
+ if (!ids.has(id)) {
123
+ throw new Error(`Legacy setting "${tweak.id}.${id}" is not defined`)
124
+ }
125
+ }
126
+
108
127
  return tweak
109
128
  }
110
129
 
@@ -52,9 +52,9 @@ const THROUGHPUT = / \d[\d.]* tok\/s/
52
52
  const FOOTER_USAGE = /^(?: · )?\d[\d.,]*\s?[KMG]?\s?\(\d+%\)(?:\s·\s\$[\d.,]+){0,2}$/
53
53
  /** The line is rebuilt on every token, so a slow repair would lag behind it. */
54
54
  const REPAIR_MS = 400
55
- const BEFORE_ID = "opencode-tweaks.tokens.before"
56
- const AFTER_ID = "opencode-tweaks.tokens.after"
57
- const FOOTER_ID = "opencode-tweaks.tokens.footer"
55
+ const BEFORE_ID = "opencode-tweaks.usage.before"
56
+ const AFTER_ID = "opencode-tweaks.usage.after"
57
+ const FOOTER_ID = "opencode-tweaks.usage.footer"
58
58
 
59
59
  /**
60
60
  * Hot reload evaluates this module again while the old repair timers keep
@@ -62,10 +62,10 @@ const FOOTER_ID = "opencode-tweaks.tokens.footer"
62
62
  * never reached its own dispose.
63
63
  */
64
64
  const timers = new Set<ReturnType<typeof setInterval>>()
65
- const previousTimers = (globalThis as { tokenInjectorTimers?: Set<ReturnType<typeof setInterval>> })
66
- .tokenInjectorTimers
65
+ const previousTimers = (globalThis as { usageInjectorTimers?: Set<ReturnType<typeof setInterval>> })
66
+ .usageInjectorTimers
67
67
  for (const timer of previousTimers ?? []) clearInterval(timer)
68
- ;(globalThis as { tokenInjectorTimers?: Set<ReturnType<typeof setInterval>> }).tokenInjectorTimers =
68
+ ;(globalThis as { usageInjectorTimers?: Set<ReturnType<typeof setInterval>> }).usageInjectorTimers =
69
69
  timers
70
70
 
71
71
  /**
@@ -229,7 +229,7 @@ export interface InjectorLabels {
229
229
  readonly after?: string
230
230
  }
231
231
 
232
- export interface TokenInjector {
232
+ export interface UsageInjector {
233
233
  /**
234
234
  * The figures for the newest footer, tagged with the turn they belong to.
235
235
  * Empty fields hide them there.
@@ -257,7 +257,7 @@ interface Placed {
257
257
  afterNode?: HostNode
258
258
  }
259
259
 
260
- export function createTokenInjector(context: Context): TokenInjector {
260
+ export function createUsageInjector(context: Context): UsageInjector {
261
261
  let labels: InjectorLabels = {}
262
262
  let key: string | undefined
263
263
  let active: HostText | undefined
@@ -1,63 +1,64 @@
1
1
  import type { Context, SlotMap } from "@opencode/plugin/tui/context"
2
2
  import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js"
3
- import type { SettingsStore } from "./settings"
4
- import type { TokenInjector } from "./token-inject"
5
- import { tokensTweak } from "./tokens"
6
3
  import {
7
4
  type CostDigits,
8
5
  estimateCost,
9
- footerMessage,
10
6
  formatCost,
11
- formatTokens,
12
7
  isCostDigits,
13
8
  type ModelPrice,
14
9
  messagesCost,
15
10
  priceFor,
16
- replyTurnMessages,
17
- sessionTokens,
18
- type TokenCount,
19
- type TokenMessage,
20
- usageTokens,
21
- } from "./tokens-model"
22
- import type { TweakSetting } from "./types"
23
-
24
- function setting(id: string): TweakSetting {
25
- const found = tokensTweak.settings.find((item) => item.id === id)
26
- if (!found) throw new Error(`Tokens setting "${id}" is missing`)
11
+ } from "../usage/cost"
12
+ import { footerMessage, replyTurnMessages, type UsageMessage } from "../usage/messages"
13
+ import { formatTokens, sessionTokens, type TokenCount, usageTokens } from "../usage/tokens"
14
+ import { costTweak } from "./cost"
15
+ import type { SettingsStore } from "./settings"
16
+ import { tokensTweak } from "./tokens"
17
+ import type { Tweak, TweakSetting } from "./types"
18
+ import type { UsageInjector } from "./usage-inject"
19
+
20
+ function setting(tweak: Tweak, id: string): TweakSetting {
21
+ const found = tweak.settings.find((item) => item.id === id)
22
+ if (!found) throw new Error(`${tweak.title} setting "${id}" is missing`)
27
23
  return found
28
24
  }
29
25
 
30
- const ENABLED = setting("enabled")
31
- const PROMPT = setting("prompt")
32
- const TOTAL = setting("total")
33
- const SUBAGENTS = setting("subagents")
34
- const COUNT = setting("count")
35
- const PROMPT_COST = setting("promptCost")
36
- const TOTAL_COST = setting("totalCost")
37
- const COST_DIGITS = setting("costDigits")
38
- const ESTIMATE = setting("estimateCost")
39
- const FORMAT = setting("format")
40
- const TTFT = setting("ttft")
26
+ const TOKENS_ON = setting(tokensTweak, "enabled")
27
+ const PROMPT = setting(tokensTweak, "prompt")
28
+ const TOTAL = setting(tokensTweak, "total")
29
+ const SUBAGENTS = setting(tokensTweak, "subagents")
30
+ const COUNT = setting(tokensTweak, "count")
31
+ const FORMAT = setting(tokensTweak, "format")
32
+ const TTFT = setting(tokensTweak, "ttft")
33
+
34
+ const COST_ON = setting(costTweak, "enabled")
35
+ const PROMPT_COST = setting(costTweak, "prompt")
36
+ const TOTAL_COST = setting(costTweak, "total")
37
+ const COST_DIGITS = setting(costTweak, "digits")
38
+ const ESTIMATE = setting(costTweak, "estimate")
41
39
 
42
40
  /**
43
- * The two counts, shared by both placements. The prompt count follows the open
44
- * session because a prompt belongs to it. The total optionally follows the
45
- * session tree, since a subagent's tokens belong to the turn its parent started.
41
+ * The figures, shared by both placements. The prompt figures follow the open
42
+ * session because a prompt belongs to it. The totals optionally follow the
43
+ * session tree, since a subagent's usage belongs to the turn its parent
44
+ * started. Tokens and cost can be switched off independently.
46
45
  */
47
- function useCounts(props: {
46
+ function useUsage(props: {
48
47
  readonly context: Context
49
48
  readonly store: SettingsStore
50
49
  readonly sessionID: () => string | undefined
51
50
  }) {
52
- const enabled = () => props.store.read(tokensTweak, ENABLED).value === true
51
+ const tokensOn = () => props.store.read(tokensTweak, TOKENS_ON).value === true
52
+ const costOn = () => props.store.read(costTweak, COST_ON).value === true
53
53
  const perPrompt = () => props.store.read(tokensTweak, PROMPT).value === true
54
54
  const sessionTotal = () => props.store.read(tokensTweak, TOTAL).value === true
55
55
  const subagents = () => props.store.read(tokensTweak, SUBAGENTS).value === true
56
- const promptCost = () => props.store.read(tokensTweak, PROMPT_COST).value === true
57
- const totalCost = () => props.store.read(tokensTweak, TOTAL_COST).value === true
58
- const estimate = () => props.store.read(tokensTweak, ESTIMATE).value === true
56
+ const promptCost = () => props.store.read(costTweak, PROMPT_COST).value === true
57
+ const totalCost = () => props.store.read(costTweak, TOTAL_COST).value === true
58
+ const estimate = () => props.store.read(costTweak, ESTIMATE).value === true
59
+ const ttft = () => props.store.read(tokensTweak, TTFT).value !== false
59
60
  const costDigits = (): CostDigits => {
60
- const value = props.store.read(tokensTweak, COST_DIGITS).value
61
+ const value = props.store.read(costTweak, COST_DIGITS).value
61
62
  return isCostDigits(value) ? value : "auto"
62
63
  }
63
64
  const count = (): TokenCount => {
@@ -157,7 +158,7 @@ function useCounts(props: {
157
158
  if (!replied()) return undefined
158
159
 
159
160
  const lookup = estimate()
160
- ? (message: TokenMessage) => {
161
+ ? (message: UsageMessage) => {
161
162
  const tokens = message.tokens
162
163
  if (!tokens) return undefined
163
164
  return priceOf(message.model, tokens.input + tokens.cache.read)
@@ -170,54 +171,63 @@ function useCounts(props: {
170
171
  /** The formatted count, or `undefined` while there is nothing worth showing. */
171
172
  const format = (value: number) => (value > 0 ? formatTokens(value, style()) : undefined)
172
173
 
173
- const promptCount = () => (enabled() && perPrompt() ? format(prompt()) : undefined)
174
- const totalCount = () => (enabled() && sessionTotal() ? format(total()) : undefined)
174
+ const promptCount = () => (tokensOn() && perPrompt() ? format(prompt()) : undefined)
175
+ const totalCount = () => (tokensOn() && sessionTotal() ? format(total()) : undefined)
175
176
  const promptCostLabel = () => {
176
177
  const value = cost()
177
- return enabled() && promptCost() && value !== undefined
178
+ return costOn() && promptCost() && value !== undefined
178
179
  ? formatCost(value, costDigits())
179
180
  : undefined
180
181
  }
181
182
  const totalCostLabel = () =>
182
- enabled() && totalCost() ? formatCost(sessionCost(), costDigits()) : undefined
183
-
184
- return { enabled, promptKey, promptCount, totalCount, promptCostLabel, totalCostLabel }
183
+ costOn() && totalCost() ? formatCost(sessionCost(), costDigits()) : undefined
184
+
185
+ return {
186
+ enabled: () => tokensOn() || costOn(),
187
+ // The host keeps its own ttft figure whenever the token tweak is off.
188
+ ttft: () => !tokensOn() || ttft(),
189
+ promptKey,
190
+ promptCount,
191
+ totalCount,
192
+ promptCostLabel,
193
+ totalCostLabel,
194
+ }
185
195
  }
186
196
 
187
197
  /**
188
- * Inline placement has no row of its own: it hands the prompt count to the
189
- * injector, which splices it into the host's tok/s line. The composer slot is
198
+ * Inline placement has no row of its own: it hands the prompt figures to the
199
+ * injector, which splices them into the host's tok/s line. The composer slot is
190
200
  * claimed anyway because it is the published place that gives the plugin a
191
201
  * reactive session context above the prompt; this component renders nothing.
192
202
  */
193
- export function TokenInline(props: {
203
+ export function UsageInline(props: {
194
204
  readonly context: Context
195
205
  readonly input: SlotMap["session.composer.top"]
196
206
  readonly store: SettingsStore
197
- readonly injector: TokenInjector
207
+ readonly injector: UsageInjector
198
208
  }) {
199
- const counts = useCounts({
209
+ const usage = useUsage({
200
210
  context: props.context,
201
211
  store: props.store,
202
212
  sessionID: () => props.input.sessionID,
203
213
  })
204
214
 
205
215
  createEffect(() => {
206
- if (!counts.enabled()) {
216
+ if (!usage.enabled()) {
207
217
  props.injector.clear()
208
218
  return
209
219
  }
210
220
 
211
- props.injector.setTtft(props.store.read(tokensTweak, TTFT).value !== false)
221
+ props.injector.setTtft(usage.ttft())
212
222
 
213
- const count = counts.promptCount()
214
- const cost = counts.promptCostLabel()
223
+ const count = usage.promptCount()
224
+ const cost = usage.promptCostLabel()
215
225
  props.injector.setLabels(
216
226
  {
217
227
  before: count ? ` · ${count} tok` : undefined,
218
228
  after: cost ? ` · ${cost}` : undefined,
219
229
  },
220
- counts.promptKey(),
230
+ usage.promptKey(),
221
231
  )
222
232
  })
223
233
 
@@ -225,13 +235,13 @@ export function TokenInline(props: {
225
235
  }
226
236
 
227
237
  /** The bottom status line, next to the working directory and the other details. */
228
- export function TokenStatus(props: {
238
+ export function UsageStatus(props: {
229
239
  readonly context: Context
230
240
  readonly input: SlotMap["prompt.footer.status"]
231
241
  readonly store: SettingsStore
232
- readonly injector: TokenInjector
242
+ readonly injector: UsageInjector
233
243
  }) {
234
- const counts = useCounts({
244
+ const usage = useUsage({
235
245
  context: props.context,
236
246
  store: props.store,
237
247
  sessionID: () => props.input.sessionID,
@@ -264,9 +274,9 @@ export function TokenStatus(props: {
264
274
 
265
275
  const figures = () => {
266
276
  const parts: string[] = []
267
- const tokens = counts.totalCount()
277
+ const tokens = usage.totalCount()
268
278
  if (tokens) parts.push(`${tokens} tok total`)
269
- const cost = counts.totalCostLabel()
279
+ const cost = usage.totalCostLabel()
270
280
  if (cost) parts.push(cost)
271
281
  return parts.length > 0 ? parts.join(" · ") : undefined
272
282
  }
@@ -291,7 +301,7 @@ export function TokenStatus(props: {
291
301
  const present = children()
292
302
  if (present === lastPresence) return
293
303
  lastPresence = present
294
- // The token counters must not re-run this effect: it would cancel the
304
+ // The usage counters must not re-run this effect: it would cancel the
295
305
  // follow-up passes every time a figure changes.
296
306
  untrack(repaint)
297
307
  const passes = [16, 50, 150].map((delay) => setTimeout(() => untrack(repaint), delay))
@@ -0,0 +1,92 @@
1
+ import type { MessageTokens, UsageMessage } from "./messages"
2
+
3
+ /** A model's list price, in dollars per million tokens. */
4
+ export interface ModelPrice {
5
+ readonly input: number
6
+ readonly output: number
7
+ readonly cache: { readonly read: number; readonly write: number }
8
+ readonly tier?: { readonly size: number }
9
+ }
10
+
11
+ /**
12
+ * The price that applies at a context size: the base price, raised by a tier
13
+ * once the request is large enough. Long-context pricing works this way.
14
+ */
15
+ export function priceFor(prices: readonly ModelPrice[], context: number): ModelPrice | undefined {
16
+ let base: ModelPrice | undefined
17
+ let tiered: ModelPrice | undefined
18
+
19
+ for (const price of prices) {
20
+ const tier = price.tier
21
+ if (!tier) {
22
+ base ??= price
23
+ continue
24
+ }
25
+ if (context < tier.size) continue
26
+ if (!tiered?.tier || tier.size > tiered.tier.size) tiered = price
27
+ }
28
+
29
+ return tiered ?? base ?? prices[0]
30
+ }
31
+
32
+ /**
33
+ * The list-price cost of one message's counters. Reasoning is billed at the
34
+ * output price, as providers do.
35
+ */
36
+ export function estimateCost(tokens: MessageTokens, price: ModelPrice): number {
37
+ const { input, output, reasoning, cache } = tokens
38
+ const total =
39
+ input * price.input +
40
+ (output + reasoning) * price.output +
41
+ cache.read * price.cache.read +
42
+ cache.write * price.cache.write
43
+
44
+ return total / 1_000_000
45
+ }
46
+
47
+ /** Resolves the list price of one message, if the model is known. */
48
+ export type PriceLookup = (message: UsageMessage) => ModelPrice | undefined
49
+
50
+ /**
51
+ * The cost of a list of messages: what the provider reported, or the list-price
52
+ * estimate when it reported nothing and a price is available.
53
+ */
54
+ export function messagesCost(messages: readonly UsageMessage[], price?: PriceLookup): number {
55
+ return messages.reduce((total, message) => {
56
+ const reported = message.cost ?? 0
57
+ if (reported > 0) return total + reported
58
+ if (!price || !message.tokens) return total + reported
59
+
60
+ const value = price(message)
61
+ return total + (value ? estimateCost(message.tokens, value) : reported)
62
+ }, 0)
63
+ }
64
+
65
+ export type CostDigits = "auto" | 2 | 3 | 4 | 5 | 6
66
+
67
+ /**
68
+ * Dollars with two decimals, or more under a cent so the first significant
69
+ * digit shows. `auto` picks the decimals; a number fixes them.
70
+ */
71
+ export function formatCost(value: number, digits: CostDigits = "auto"): string {
72
+ const cost = Math.max(0, value)
73
+ const places = digits === "auto" ? autoDigits(cost) : digits
74
+
75
+ return new Intl.NumberFormat("en-US", {
76
+ style: "currency",
77
+ currency: "USD",
78
+ minimumFractionDigits: places,
79
+ maximumFractionDigits: places,
80
+ }).format(cost)
81
+ }
82
+
83
+ /** Whether a stored or configured value is a cost digit choice. */
84
+ export function isCostDigits(value: unknown): value is CostDigits {
85
+ return value === "auto" || value === 2 || value === 3 || value === 4 || value === 5 || value === 6
86
+ }
87
+
88
+ /** Normal amounts keep two decimals; smaller ones grow until a digit shows. */
89
+ function autoDigits(cost: number): number {
90
+ if (cost === 0 || cost >= 0.01) return 2
91
+ return Math.min(6, Math.ceil(-Math.log10(cost)))
92
+ }
@@ -0,0 +1,67 @@
1
+ /** The token counters OpenCode stores on an assistant message. */
2
+ export interface MessageTokens {
3
+ readonly input: number
4
+ readonly output: number
5
+ readonly reasoning: number
6
+ readonly cache: { readonly read: number; readonly write: number }
7
+ }
8
+
9
+ /** Only what usage sums need, so any session message list fits. */
10
+ export interface UsageMessage {
11
+ readonly id?: string
12
+ readonly type: string
13
+ readonly tokens?: MessageTokens
14
+ readonly cost?: number
15
+ readonly model?: { readonly providerID: string; readonly id: string }
16
+ /** How the step ended; the host only draws a footer for a finished one. */
17
+ readonly finish?: string | null
18
+ readonly error?: unknown
19
+ readonly retry?: unknown
20
+ }
21
+
22
+ /** The index just after the last user prompt before `end`. */
23
+ function turnStart(messages: readonly UsageMessage[], end: number): number {
24
+ for (let index = end - 1; index >= 0; index--) {
25
+ if (messages[index]?.type === "user") return index + 1
26
+ }
27
+ return 0
28
+ }
29
+
30
+ /** Where the turn that starts at `start` ends: at the next prompt, or the end. */
31
+ function turnEnd(messages: readonly UsageMessage[], start: number): number {
32
+ for (let index = start; index < messages.length; index++) {
33
+ if (messages[index]?.type === "user") return index
34
+ }
35
+ return messages.length
36
+ }
37
+
38
+ /**
39
+ * The newest message the host draws a footer under. A footer belongs to the
40
+ * last step of a turn: one that stopped for any reason other than another tool
41
+ * call, or one that failed or is being retried.
42
+ */
43
+ export function footerMessage(messages: readonly UsageMessage[]): UsageMessage | undefined {
44
+ for (let index = messages.length - 1; index >= 0; index--) {
45
+ const message = messages[index]
46
+ if (message?.type !== "assistant") continue
47
+ if (message.error || message.retry) return message
48
+ if (message.finish && message.finish !== "tool-calls" && message.finish !== "unknown") {
49
+ return message
50
+ }
51
+ }
52
+ return undefined
53
+ }
54
+
55
+ /**
56
+ * The messages of the turn whose footer is on screen. A prompt with no finished
57
+ * reply yet leaves the previous turn's line there, so that line keeps showing
58
+ * the previous turn's numbers instead of the running one's.
59
+ */
60
+ export function replyTurnMessages(messages: readonly UsageMessage[]): readonly UsageMessage[] {
61
+ const footer = footerMessage(messages)
62
+ if (!footer) return []
63
+
64
+ const last = messages.lastIndexOf(footer)
65
+ const start = turnStart(messages, last + 1)
66
+ return messages.slice(start, turnEnd(messages, start))
67
+ }
@@ -0,0 +1,56 @@
1
+ import { type MessageTokens, replyTurnMessages, type UsageMessage } from "./messages"
2
+
3
+ /**
4
+ * Which counters a count adds up.
5
+ *
6
+ * - `new` is what the model actually processed: prompt, completion, reasoning,
7
+ * and cache writes, without cache reads. A turn re-reads the whole context at
8
+ * every step, so cache reads would dwarf every other counter.
9
+ * - `generated` is only what the model wrote.
10
+ * - `all` matches the counters `/stats` reports, cache reads included.
11
+ */
12
+ export type TokenCount = "new" | "generated" | "all"
13
+
14
+ /** The tokens in one assistant message's counters under the chosen definition. */
15
+ export function usageTokens(tokens: MessageTokens, mode: TokenCount): number {
16
+ const { input, output, reasoning, cache } = tokens
17
+
18
+ switch (mode) {
19
+ case "generated":
20
+ return output + reasoning
21
+
22
+ case "new":
23
+ return input + output + reasoning + cache.write
24
+
25
+ case "all":
26
+ return input + output + reasoning + cache.read + cache.write
27
+ }
28
+ }
29
+
30
+ /** The tokens one assistant message spent under the chosen definition. */
31
+ export function messageTokens(message: UsageMessage, mode: TokenCount): number {
32
+ if (message.type !== "assistant" || !message.tokens) return 0
33
+ return usageTokens(message.tokens, mode)
34
+ }
35
+
36
+ /** Every token a session spent under the chosen definition. */
37
+ export function sessionTokens(messages: readonly UsageMessage[], mode: TokenCount): number {
38
+ return messages.reduce((total, message) => total + messageTokens(message, mode), 0)
39
+ }
40
+
41
+ /** The tokens that turn spent under the chosen definition. */
42
+ export function replyTurnTokens(messages: readonly UsageMessage[], mode: TokenCount): number {
43
+ return sessionTokens(replyTurnMessages(messages), mode)
44
+ }
45
+
46
+ const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 })
47
+ const exact = new Intl.NumberFormat("en-US")
48
+
49
+ /**
50
+ * `12.4K` in compact style, matching the numbers in `/stats`, or `12,431` for
51
+ * every digit. Both stay short enough for the prompt footer.
52
+ */
53
+ export function formatTokens(value: number, style: "compact" | "exact"): string {
54
+ const total = Math.max(0, Math.round(value))
55
+ return style === "exact" ? exact.format(total) : compact.format(total)
56
+ }
@@ -1,213 +0,0 @@
1
- /** The token counters OpenCode stores on an assistant message. */
2
- export interface MessageTokens {
3
- readonly input: number
4
- readonly output: number
5
- readonly reasoning: number
6
- readonly cache: { readonly read: number; readonly write: number }
7
- }
8
-
9
- /** Only what the sum needs, so any session message list fits. */
10
- export interface TokenMessage {
11
- readonly id?: string
12
- readonly type: string
13
- readonly tokens?: MessageTokens
14
- readonly cost?: number
15
- readonly model?: { readonly providerID: string; readonly id: string }
16
- /** How the step ended; the host only draws a footer for a finished one. */
17
- readonly finish?: string | null
18
- readonly error?: unknown
19
- readonly retry?: unknown
20
- }
21
-
22
- /**
23
- * Which counters a count adds up.
24
- *
25
- * - `new` is what the model actually processed: prompt, completion, reasoning,
26
- * and cache writes, without cache reads. A turn re-reads the whole context at
27
- * every step, so cache reads would dwarf every other counter.
28
- * - `generated` is only what the model wrote.
29
- * - `all` matches the counters `/stats` reports, cache reads included.
30
- */
31
- export type TokenCount = "new" | "generated" | "all"
32
-
33
- /** The tokens in one assistant message's counters under the chosen definition. */
34
- export function usageTokens(tokens: MessageTokens, mode: TokenCount): number {
35
- const { input, output, reasoning, cache } = tokens
36
-
37
- switch (mode) {
38
- case "generated":
39
- return output + reasoning
40
-
41
- case "new":
42
- return input + output + reasoning + cache.write
43
-
44
- case "all":
45
- return input + output + reasoning + cache.read + cache.write
46
- }
47
- }
48
-
49
- /** The tokens one assistant message spent under the chosen definition. */
50
- export function messageTokens(message: TokenMessage, mode: TokenCount): number {
51
- if (message.type !== "assistant" || !message.tokens) return 0
52
- return usageTokens(message.tokens, mode)
53
- }
54
-
55
- /** Every token a session spent under the chosen definition. */
56
- export function sessionTokens(messages: readonly TokenMessage[], mode: TokenCount): number {
57
- return messages.reduce((total, message) => total + messageTokens(message, mode), 0)
58
- }
59
-
60
- /** The index just after the last user prompt before `end`. */
61
- function turnStart(messages: readonly TokenMessage[], end: number): number {
62
- for (let index = end - 1; index >= 0; index--) {
63
- if (messages[index]?.type === "user") return index + 1
64
- }
65
- return 0
66
- }
67
-
68
- /** Where the turn that starts at `start` ends: at the next prompt, or the end. */
69
- function turnEnd(messages: readonly TokenMessage[], start: number): number {
70
- for (let index = start; index < messages.length; index++) {
71
- if (messages[index]?.type === "user") return index
72
- }
73
- return messages.length
74
- }
75
-
76
- /**
77
- * The newest message the host draws a footer under. A footer belongs to the
78
- * last step of a turn: one that stopped for any reason other than another tool
79
- * call, or one that failed or is being retried.
80
- */
81
- export function footerMessage(messages: readonly TokenMessage[]): TokenMessage | undefined {
82
- for (let index = messages.length - 1; index >= 0; index--) {
83
- const message = messages[index]
84
- if (message?.type !== "assistant") continue
85
- if (message.error || message.retry) return message
86
- if (message.finish && message.finish !== "tool-calls" && message.finish !== "unknown") {
87
- return message
88
- }
89
- }
90
- return undefined
91
- }
92
-
93
- /**
94
- * The messages of the turn whose footer is on screen. A prompt with no finished
95
- * reply yet leaves the previous turn's line there, so that line keeps showing
96
- * the previous turn's numbers instead of the running one's.
97
- */
98
- export function replyTurnMessages(messages: readonly TokenMessage[]): readonly TokenMessage[] {
99
- const footer = footerMessage(messages)
100
- if (!footer) return []
101
-
102
- const last = messages.lastIndexOf(footer)
103
- const start = turnStart(messages, last + 1)
104
- return messages.slice(start, turnEnd(messages, start))
105
- }
106
-
107
- /** The tokens that turn spent under the chosen definition. */
108
- export function replyTurnTokens(messages: readonly TokenMessage[], mode: TokenCount): number {
109
- return sessionTokens(replyTurnMessages(messages), mode)
110
- }
111
-
112
- /** A model's list price, in dollars per million tokens. */
113
- export interface ModelPrice {
114
- readonly input: number
115
- readonly output: number
116
- readonly cache: { readonly read: number; readonly write: number }
117
- readonly tier?: { readonly size: number }
118
- }
119
-
120
- /**
121
- * The price that applies at a context size: the base price, raised by a tier
122
- * once the request is large enough. Long-context pricing works this way.
123
- */
124
- export function priceFor(prices: readonly ModelPrice[], context: number): ModelPrice | undefined {
125
- let base: ModelPrice | undefined
126
- let tiered: ModelPrice | undefined
127
-
128
- for (const price of prices) {
129
- const tier = price.tier
130
- if (!tier) {
131
- base ??= price
132
- continue
133
- }
134
- if (context < tier.size) continue
135
- if (!tiered?.tier || tier.size > tiered.tier.size) tiered = price
136
- }
137
-
138
- return tiered ?? base ?? prices[0]
139
- }
140
-
141
- /**
142
- * The list-price cost of one message's counters. Reasoning is billed at the
143
- * output price, as providers do.
144
- */
145
- export function estimateCost(tokens: MessageTokens, price: ModelPrice): number {
146
- const { input, output, reasoning, cache } = tokens
147
- const total =
148
- input * price.input +
149
- (output + reasoning) * price.output +
150
- cache.read * price.cache.read +
151
- cache.write * price.cache.write
152
-
153
- return total / 1_000_000
154
- }
155
-
156
- /** Resolves the list price of one message, if the model is known. */
157
- export type PriceLookup = (message: TokenMessage) => ModelPrice | undefined
158
-
159
- /**
160
- * The cost of a list of messages: what the provider reported, or the list-price
161
- * estimate when it reported nothing and a price is available.
162
- */
163
- export function messagesCost(messages: readonly TokenMessage[], price?: PriceLookup): number {
164
- return messages.reduce((total, message) => {
165
- const reported = message.cost ?? 0
166
- if (reported > 0) return total + reported
167
- if (!price || !message.tokens) return total + reported
168
-
169
- const value = price(message)
170
- return total + (value ? estimateCost(message.tokens, value) : reported)
171
- }, 0)
172
- }
173
-
174
- const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 })
175
- const exact = new Intl.NumberFormat("en-US")
176
-
177
- /**
178
- * `12.4K` in compact style, matching the numbers in `/stats`, or `12,431` for
179
- * every digit. Both stay short enough for the prompt footer.
180
- */
181
- export function formatTokens(value: number, style: "compact" | "exact"): string {
182
- const total = Math.max(0, Math.round(value))
183
- return style === "exact" ? exact.format(total) : compact.format(total)
184
- }
185
-
186
- /**
187
- * Dollars with two decimals, or more under a cent so the first significant
188
- * digit shows. `auto` picks the decimals; a number fixes them.
189
- */
190
- export function formatCost(value: number, digits: CostDigits = "auto"): string {
191
- const cost = Math.max(0, value)
192
- const places = digits === "auto" ? autoDigits(cost) : digits
193
-
194
- return new Intl.NumberFormat("en-US", {
195
- style: "currency",
196
- currency: "USD",
197
- minimumFractionDigits: places,
198
- maximumFractionDigits: places,
199
- }).format(cost)
200
- }
201
-
202
- export type CostDigits = "auto" | 2 | 3 | 4 | 5 | 6
203
-
204
- /** Whether a stored or configured value is a cost digit choice. */
205
- export function isCostDigits(value: unknown): value is CostDigits {
206
- return value === "auto" || value === 2 || value === 3 || value === 4 || value === 5 || value === 6
207
- }
208
-
209
- /** Normal amounts keep two decimals; smaller ones grow until a digit shows. */
210
- function autoDigits(cost: number): number {
211
- if (cost === 0 || cost >= 0.01) return 2
212
- return Math.min(6, Math.ceil(-Math.log10(cost)))
213
- }