opencode-tweaks 0.0.0-stage → 0.4.1

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.
@@ -0,0 +1,327 @@
1
+ import type { Context, SlotMap } from "@opencode/plugin/tui/context"
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
+ import {
7
+ type CostDigits,
8
+ estimateCost,
9
+ footerMessage,
10
+ formatCost,
11
+ formatTokens,
12
+ isCostDigits,
13
+ type ModelPrice,
14
+ messagesCost,
15
+ 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`)
27
+ return found
28
+ }
29
+
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")
41
+
42
+ /**
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.
46
+ */
47
+ function useCounts(props: {
48
+ readonly context: Context
49
+ readonly store: SettingsStore
50
+ readonly sessionID: () => string | undefined
51
+ }) {
52
+ const enabled = () => props.store.read(tokensTweak, ENABLED).value === true
53
+ const perPrompt = () => props.store.read(tokensTweak, PROMPT).value === true
54
+ const sessionTotal = () => props.store.read(tokensTweak, TOTAL).value === true
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
59
+ const costDigits = (): CostDigits => {
60
+ const value = props.store.read(tokensTweak, COST_DIGITS).value
61
+ return isCostDigits(value) ? value : "auto"
62
+ }
63
+ const count = (): TokenCount => {
64
+ const value = props.store.read(tokensTweak, COUNT).value
65
+ return value === "generated" || value === "all" ? value : "new"
66
+ }
67
+ const style = () =>
68
+ props.store.read(tokensTweak, FORMAT).value === "exact" ? "exact" : "compact"
69
+
70
+ const messages = (sessionID: string) => props.context.data.session.message.list(sessionID) ?? []
71
+
72
+ const sessions = props.context.data.session
73
+
74
+ // List prices come from the model catalog the TUI already synced, so an
75
+ // estimate costs nothing but a lookup.
76
+ const prices = createMemo(() => {
77
+ const map = new Map<string, readonly ModelPrice[]>()
78
+ for (const model of props.context.data.location.model.list() ?? []) {
79
+ map.set(`${model.providerID}/${model.modelID}`, model.cost)
80
+ }
81
+ return map
82
+ })
83
+
84
+ /** The list price of a model at a context size, when the catalog knows it. */
85
+ function priceOf(
86
+ ref: { readonly providerID: string; readonly id: string } | undefined,
87
+ context: number,
88
+ ) {
89
+ if (!ref) return undefined
90
+ const list = prices().get(`${ref.providerID}/${ref.id}`)
91
+ return list ? priceFor(list, context) : undefined
92
+ }
93
+
94
+ const family = createMemo(() => {
95
+ const sessionID = props.sessionID()
96
+ if (!sessionID) return []
97
+
98
+ if (!subagents()) return [sessionID]
99
+
100
+ try {
101
+ const root = sessions.root(sessionID)
102
+ return [...new Set([root, ...sessions.family(root)])].filter((id) => id.length > 0)
103
+ } catch {
104
+ // A session the client has not synced yet still shows its own total.
105
+ return [sessionID]
106
+ }
107
+ })
108
+
109
+ // The transcript keeps only a window of messages, so the session's own
110
+ // counters are the only exact total for a long session.
111
+ const total = createMemo(() =>
112
+ family().reduce((sum, sessionID) => {
113
+ const usage = sessions.get(sessionID)?.tokens
114
+ return sum + (usage ? usageTokens(usage, count()) : 0)
115
+ }, 0),
116
+ )
117
+
118
+ // The inline figures describe the turn whose footer is on screen: the host
119
+ // draws one line per finished turn, so a prompt with no finished reply keeps
120
+ // the previous turn's figures on its own line.
121
+ const turn = createMemo(() => {
122
+ const sessionID = props.sessionID()
123
+ return sessionID ? replyTurnMessages(messages(sessionID)) : []
124
+ })
125
+
126
+ const prompt = createMemo(() => sessionTokens(turn(), count()))
127
+
128
+ // The footer the figures belong to, so the injector only writes them on that
129
+ // line and never on the older one while the host catches up.
130
+ const promptKey = createMemo(() => {
131
+ const sessionID = props.sessionID()
132
+ return sessionID ? footerMessage(messages(sessionID))?.id : undefined
133
+ })
134
+
135
+ // A reply that has not produced a counter yet is still empty, so it shows no
136
+ // figures rather than a zero cost.
137
+ const replied = createMemo(() =>
138
+ turn().some((message) => {
139
+ if (message.type !== "assistant") return false
140
+ if ((message.cost ?? 0) > 0) return true
141
+ return message.tokens ? usageTokens(message.tokens, "all") > 0 : false
142
+ }),
143
+ )
144
+
145
+ const sessionCost = createMemo(() =>
146
+ family().reduce((sum, sessionID) => {
147
+ const session = sessions.get(sessionID)
148
+ const reported = session?.cost ?? 0
149
+ if (reported > 0 || !estimate() || !session?.tokens) return sum + reported
150
+
151
+ const price = priceOf(session.model, session.tokens.input + session.tokens.cache.read)
152
+ return sum + (price ? estimateCost(session.tokens, price) : 0)
153
+ }, 0),
154
+ )
155
+
156
+ const cost = createMemo(() => {
157
+ if (!replied()) return undefined
158
+
159
+ const lookup = estimate()
160
+ ? (message: TokenMessage) => {
161
+ const tokens = message.tokens
162
+ if (!tokens) return undefined
163
+ return priceOf(message.model, tokens.input + tokens.cache.read)
164
+ }
165
+ : undefined
166
+
167
+ return messagesCost(turn(), lookup)
168
+ })
169
+
170
+ /** The formatted count, or `undefined` while there is nothing worth showing. */
171
+ const format = (value: number) => (value > 0 ? formatTokens(value, style()) : undefined)
172
+
173
+ const promptCount = () => (enabled() && perPrompt() ? format(prompt()) : undefined)
174
+ const totalCount = () => (enabled() && sessionTotal() ? format(total()) : undefined)
175
+ const promptCostLabel = () => {
176
+ const value = cost()
177
+ return enabled() && promptCost() && value !== undefined
178
+ ? formatCost(value, costDigits())
179
+ : undefined
180
+ }
181
+ const totalCostLabel = () =>
182
+ enabled() && totalCost() ? formatCost(sessionCost(), costDigits()) : undefined
183
+
184
+ return { enabled, promptKey, promptCount, totalCount, promptCostLabel, totalCostLabel }
185
+ }
186
+
187
+ /**
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
190
+ * claimed anyway because it is the published place that gives the plugin a
191
+ * reactive session context above the prompt; this component renders nothing.
192
+ */
193
+ export function TokenInline(props: {
194
+ readonly context: Context
195
+ readonly input: SlotMap["session.composer.top"]
196
+ readonly store: SettingsStore
197
+ readonly injector: TokenInjector
198
+ }) {
199
+ const counts = useCounts({
200
+ context: props.context,
201
+ store: props.store,
202
+ sessionID: () => props.input.sessionID,
203
+ })
204
+
205
+ createEffect(() => {
206
+ if (!counts.enabled()) {
207
+ props.injector.clear()
208
+ return
209
+ }
210
+
211
+ props.injector.setTtft(props.store.read(tokensTweak, TTFT).value !== false)
212
+
213
+ const count = counts.promptCount()
214
+ const cost = counts.promptCostLabel()
215
+ props.injector.setLabels(
216
+ {
217
+ before: count ? ` · ${count} tok` : undefined,
218
+ after: cost ? ` · ${cost}` : undefined,
219
+ },
220
+ counts.promptKey(),
221
+ )
222
+ })
223
+
224
+ return null
225
+ }
226
+
227
+ /** The bottom status line, next to the working directory and the other details. */
228
+ export function TokenStatus(props: {
229
+ readonly context: Context
230
+ readonly input: SlotMap["prompt.footer.status"]
231
+ readonly store: SettingsStore
232
+ readonly injector: TokenInjector
233
+ }) {
234
+ const counts = useCounts({
235
+ context: props.context,
236
+ store: props.store,
237
+ sessionID: () => props.input.sessionID,
238
+ })
239
+
240
+ // The host prints its own shell and subagent indicator before the context
241
+ // usage, so the figures normally ride on that text. The slot is only a
242
+ // fallback for a footer with neither of them.
243
+ const children = createMemo(() => {
244
+ const sessionID = props.input.sessionID
245
+ if (!sessionID) return false
246
+
247
+ try {
248
+ const shells = props.context.data.shell
249
+ .list(props.context.location)
250
+ .filter(
251
+ (shell) =>
252
+ (shell.metadata as { sessionID?: string } | undefined)?.sessionID === sessionID,
253
+ ).length
254
+ const sessions = props.context.data.session
255
+ const subagents = sessions
256
+ .family(sessionID)
257
+ .filter((id) => id !== sessionID && sessions.status(id) === "running").length
258
+ return shells + subagents > 0
259
+ } catch {
260
+ // A session the client has not synced yet simply has no indicator.
261
+ return false
262
+ }
263
+ })
264
+
265
+ const figures = () => {
266
+ const parts: string[] = []
267
+ const tokens = counts.totalCount()
268
+ if (tokens) parts.push(`${tokens} tok total`)
269
+ const cost = counts.totalCostLabel()
270
+ if (cost) parts.push(cost)
271
+ return parts.length > 0 ? parts.join(" · ") : undefined
272
+ }
273
+
274
+ const [placed, setPlaced] = createSignal(false)
275
+
276
+ const repaint = () => props.injector.setFooterFigures(figures(), setPlaced)
277
+
278
+ createEffect(() => {
279
+ // Read the host's detail state so the figures follow the usage line when
280
+ // the host shows or hides it.
281
+ props.input.showDetails
282
+ repaint()
283
+ })
284
+
285
+ // The host adds or removes its shell indicator in a render pass of its own,
286
+ // which rebuilds the usage text and leaves the figures beside a stray
287
+ // separator for a moment. Repainting right away, then again over the next
288
+ // frames, keeps the line from settling visibly.
289
+ let lastPresence: boolean | undefined
290
+ createEffect(() => {
291
+ const present = children()
292
+ if (present === lastPresence) return
293
+ lastPresence = present
294
+ // The token counters must not re-run this effect: it would cancel the
295
+ // follow-up passes every time a figure changes.
296
+ untrack(repaint)
297
+ const passes = [16, 50, 150].map((delay) => setTimeout(() => untrack(repaint), delay))
298
+ onCleanup(() => {
299
+ for (const pass of passes) clearTimeout(pass)
300
+ })
301
+ })
302
+
303
+ const [stale, setStale] = createSignal(false)
304
+
305
+ createEffect(() => {
306
+ // A rejected splice is transient while the host redraws the line, so the
307
+ // slot only takes over after the figures have stayed out for a while.
308
+ setStale(false)
309
+ if (placed()) return
310
+ const timer = setTimeout(() => setStale(true), 500)
311
+ onCleanup(() => clearTimeout(timer))
312
+ })
313
+
314
+ // The injector owns the real line: it puts the figures after the shell
315
+ // indicator and before the context usage. The slot is only a last resort
316
+ // after the splice has stayed unavailable, so nothing flashes to the wrong
317
+ // side of a shell while the host is still drawing it.
318
+ const fallback = () => (placed() || !stale() ? undefined : figures())
319
+
320
+ return (
321
+ <Show when={fallback()}>
322
+ <text fg={props.context.theme.text.muted} wrapMode="none" flexShrink={0}>
323
+ {fallback()}
324
+ </text>
325
+ </Show>
326
+ )
327
+ }
@@ -0,0 +1,213 @@
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
+ }
@@ -0,0 +1,113 @@
1
+ import { defineTweak } from "./types"
2
+
3
+ /**
4
+ * Token usage is a session statistic, so it joins existing status lines instead
5
+ * of taking a panel of its own. Each number has its own toggle: a turn's usage
6
+ * follows the prompt on screen, the session's follows the whole tree.
7
+ */
8
+ export const tokensTweak = defineTweak({
9
+ id: "tokens",
10
+ title: "Tokens",
11
+ description: "Show token and cost usage for a turn and for the whole session.",
12
+ category: "Session",
13
+ settings: [
14
+ {
15
+ id: "enabled",
16
+ title: "Enabled",
17
+ description: "Turn the token and cost counts on.",
18
+ category: "General",
19
+ kind: "boolean",
20
+ default: true,
21
+ },
22
+ {
23
+ id: "prompt",
24
+ title: "Turn tokens",
25
+ description:
26
+ "Show the latest finished turn's tokens inline with its tok/s line. Best effort: that line belongs to OpenCode.",
27
+ category: "Display",
28
+ kind: "boolean",
29
+ default: true,
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
+ {
40
+ id: "total",
41
+ title: "Session tokens",
42
+ description: "Show the session's total token usage on the bottom status line.",
43
+ category: "Display",
44
+ kind: "boolean",
45
+ default: true,
46
+ },
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
+ {
56
+ id: "format",
57
+ title: "Token format",
58
+ description: "Compact counts such as 12.4K, or every digit.",
59
+ category: "Display",
60
+ kind: "select",
61
+ default: "compact",
62
+ values: ["compact", "exact"],
63
+ },
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
+ {
76
+ id: "ttft",
77
+ title: "First token time",
78
+ description:
79
+ "Show the host's ttft figure, the wait before the first token, at the end of the throughput line. Turn it off to keep tok/s alone.",
80
+ category: "Display",
81
+ kind: "boolean",
82
+ default: false,
83
+ },
84
+ {
85
+ id: "count",
86
+ title: "Token count",
87
+ description:
88
+ "New tokens leave out cache reads, generated tokens are only what the model wrote, everything matches /stats.",
89
+ category: "Scope",
90
+ kind: "select",
91
+ default: "generated",
92
+ values: ["new", "generated", "all"],
93
+ labels: ["New tokens", "Generated only", "Everything"],
94
+ },
95
+ {
96
+ id: "subagents",
97
+ 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.",
108
+ category: "Scope",
109
+ kind: "boolean",
110
+ default: true,
111
+ },
112
+ ],
113
+ })