opencode-tweaks 0.6.0 → 0.7.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
@@ -100,6 +100,7 @@ Resetting a setting drops the local value, so the option or default applies agai
100
100
  | `quota` | Provider quota, reset times, and a daily spending budget. | On |
101
101
  | `tokens` | Token usage per turn, plus session totals at the bottom. | On |
102
102
  | `cost` | Cost per turn, plus session totals at the bottom. | On |
103
+ | `model` | Current model list price and peak state in the sidebar. | On |
103
104
 
104
105
  ### Quota
105
106
 
@@ -179,11 +180,12 @@ Costs come from what the provider reports, so a free plan shows `$0.00`. `Cost d
179
180
  `Auto` keeps two decimals and grows until the first significant digit shows (`$0.004`), while a fixed choice always
180
181
  keeps that many (`$0.0042`).
181
182
 
182
- `Price windows` marks the provider's higher rate beside the cost. DeepSeek doubles its listed prices from 01:00 to
183
+ `Price windows` marks rounds billed above the catalog price. DeepSeek doubles its listed prices from 01:00 to
183
184
  04:00 and from 06:00 to 10:00 UTC, Monday to Friday, and bills Chinese public holidays at the off-peak rate; OpenCode
184
- Go publishes the same DeepSeek rates. The badge reads `peak ×2` while that rate applies. The catalog already holds the
185
- off-peak price, so the figures themselves stay the listed rate. The 2026 and 2027 holidays are built in; update
186
- `src/usage/price-window.ts` when the next calendar is published.
185
+ Go publishes the same DeepSeek rates. The badge reads `peak ×2` beside the turn cost when that round started in a peak
186
+ window, so it stays on screen after the window closes. The catalog already holds the off-peak price, so the figures
187
+ themselves stay the listed rate. The 2026 and 2027 holidays are built in; update `src/usage/price-window.ts` when the
188
+ next calendar is published.
187
189
 
188
190
  `Estimate cost` fills in a price the provider did not report, using the model's list price from the same catalog
189
191
  OpenCode already synced: prompt, completion, reasoning billed as output, and cache traffic, each at its own rate. A
@@ -193,6 +195,19 @@ per turn, and the estimate follows the API rates instead. Costs use the raw coun
193
195
 
194
196
  The cost settings used to live in the Tokens entry. A choice saved there before the split keeps applying.
195
197
 
198
+ ### Model
199
+
200
+ Model shows the model the session is using and its list price in the sidebar, under the quota. The name, provider,
201
+ and reasoning variant come from the session, so the card follows `/models`.
202
+
203
+ `Input price`, `Output price`, and `Cache read price` choose the rates, all in dollars per million tokens. The card
204
+ uses the catalog price that applies to the session's context, so a long-context tier shows the higher rate with its
205
+ threshold (`>512K`).
206
+
207
+ `Peak state` adds the provider's live peak marker for schedules the plugin knows, such as DeepSeek and OpenCode Go:
208
+ `peak ×2 now` during the higher window and `off-peak` otherwise. The badge on the turn cost still records the window
209
+ each round started in.
210
+
196
211
  ## Development
197
212
 
198
213
  ```sh
@@ -231,6 +246,8 @@ src/
231
246
  quota-panel.tsx sidebar quota panel
232
247
  tokens.ts token tweak definition
233
248
  cost.ts cost tweak definition
249
+ model.ts model tweak definition
250
+ model-panel.tsx sidebar model price card
234
251
  usage-slots.tsx inline and status line figures
235
252
  usage-inject.ts splice into the host's lines
236
253
  ```
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.6.0",
4
+ "version": "0.7.0",
5
5
  "description": "Opt-in settings and small interface improvements for OpenCode.",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/src/tui.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Plugin } from "@opencode/plugin/tui"
2
2
  import { openTweaksMenu } from "./tweaks/menu"
3
+ import { ModelPanel } from "./tweaks/model-panel"
3
4
  import { QuotaPanel } from "./tweaks/quota-panel"
4
5
  import { createSettingsStore } from "./tweaks/settings"
5
6
  import { createUsageInjector } from "./tweaks/usage-inject"
@@ -17,6 +18,11 @@ export default Plugin.define({
17
18
  render: () => <QuotaPanel context={context} store={store} />,
18
19
  })
19
20
 
21
+ context.ui.slot({
22
+ append: "sidebar.content",
23
+ render: (input) => <ModelPanel context={context} input={input} store={store} />,
24
+ })
25
+
20
26
  context.ui.slot({
21
27
  append: "session.composer.top",
22
28
  render: (input) => (
@@ -60,8 +60,7 @@ export const costTweak = defineTweak({
60
60
  {
61
61
  id: "windows",
62
62
  title: "Price windows",
63
- description:
64
- "Mark when the provider charges more than the catalog price, such as DeepSeek peak hours.",
63
+ description: "Mark rounds billed above the catalog price, such as DeepSeek peak hours.",
65
64
  category: "Display",
66
65
  kind: "boolean",
67
66
  default: true,
@@ -0,0 +1,185 @@
1
+ import type { Context, SlotMap } from "@opencode/plugin/tui/context"
2
+ import { TextAttributes } from "@opentui/core"
3
+ import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
4
+ import { formatRate, priceFor } from "../usage/cost"
5
+ import { hasPriceSchedule, priceWindowFor } from "../usage/price-window"
6
+ import { formatTokens } from "../usage/tokens"
7
+ import { modelTweak } from "./model"
8
+ import type { SettingsStore } from "./settings"
9
+ import type { TweakSetting } from "./types"
10
+
11
+ function setting(id: string): TweakSetting {
12
+ const found = modelTweak.settings.find((item) => item.id === id)
13
+ if (!found) throw new Error(`Model setting "${id}" is missing`)
14
+ return found
15
+ }
16
+
17
+ const ENABLED = setting("enabled")
18
+ const INPUT = setting("input")
19
+ const OUTPUT = setting("output")
20
+ const CACHE = setting("cache")
21
+ const VARIANT = setting("variant")
22
+ const PEAK = setting("peak")
23
+
24
+ /** The ref stored on a session or returned by the composer. */
25
+ interface ModelRef {
26
+ readonly providerID: string
27
+ readonly id: string
28
+ readonly variant?: string
29
+ }
30
+
31
+ /**
32
+ * The model card is a companion to the cost figures: it names the model and
33
+ * lists its catalog rates, while the cost tweak counts what the session spent.
34
+ */
35
+ export function ModelPanel(props: {
36
+ readonly context: Context
37
+ readonly input: SlotMap["sidebar.content"]
38
+ readonly store: SettingsStore
39
+ }) {
40
+ const enabled = () => props.store.read(modelTweak, ENABLED).value === true
41
+
42
+ return (
43
+ <Show when={enabled()}>
44
+ <ModelBody context={props.context} input={props.input} store={props.store} />
45
+ </Show>
46
+ )
47
+ }
48
+
49
+ function ModelBody(props: {
50
+ readonly context: Context
51
+ readonly input: SlotMap["sidebar.content"]
52
+ readonly store: SettingsStore
53
+ }) {
54
+ const theme = () => props.context.theme
55
+ const [now, setNow] = createSignal(Date.now())
56
+
57
+ const inputOn = () => props.store.read(modelTweak, INPUT).value === true
58
+ const outputOn = () => props.store.read(modelTweak, OUTPUT).value === true
59
+ const cacheOn = () => props.store.read(modelTweak, CACHE).value === true
60
+ const variantOn = () => props.store.read(modelTweak, VARIANT).value === true
61
+ const peakOn = () => props.store.read(modelTweak, PEAK).value === true
62
+
63
+ /** The session's model, or the composer's while the session has none yet. */
64
+ const model = createMemo<ModelRef | undefined>(() => {
65
+ const session = props.context.data.session.get(props.input.sessionID)
66
+ if (session?.model) return session.model
67
+
68
+ const current = props.context.ui.model.current()
69
+ return current
70
+ ? { providerID: current.providerID, id: current.modelID, variant: current.variant }
71
+ : undefined
72
+ })
73
+
74
+ /** The catalog entry for that model, with what the panel needs to show it. */
75
+ const entry = createMemo(() => {
76
+ const ref = model()
77
+ if (!ref) return undefined
78
+
79
+ const found = (props.context.data.location.model.list() ?? []).find(
80
+ (candidate) =>
81
+ candidate.providerID === ref.providerID &&
82
+ (candidate.modelID === ref.id || candidate.id === ref.id),
83
+ )
84
+ const provider = (props.context.data.location.provider.list() ?? []).find(
85
+ (candidate) => candidate.id === ref.providerID,
86
+ )
87
+
88
+ return {
89
+ ref,
90
+ name: found?.name ?? ref.id,
91
+ provider: provider?.name,
92
+ costs: found?.cost ?? [],
93
+ }
94
+ })
95
+
96
+ /** The context the session has read so far, which picks a long-context tier. */
97
+ const contextSize = createMemo(() => {
98
+ const tokens = props.context.data.session.get(props.input.sessionID)?.tokens
99
+ return tokens ? tokens.input + tokens.cache.read : 0
100
+ })
101
+
102
+ const price = createMemo(() => {
103
+ const costs = entry()?.costs
104
+ return costs?.length ? priceFor(costs, contextSize()) : undefined
105
+ })
106
+
107
+ const rates = createMemo(() => {
108
+ const value = price()
109
+ if (!value) return undefined
110
+
111
+ const parts: string[] = []
112
+ if (inputOn()) parts.push(`in ${formatRate(value.input)}`)
113
+ if (outputOn()) parts.push(`out ${formatRate(value.output)}`)
114
+ if (cacheOn()) parts.push(`cache ${formatRate(value.cache.read)}`)
115
+ if (parts.length > 0 && value.tier) {
116
+ parts.push(`>${formatTokens(value.tier.size, "compact")}`)
117
+ }
118
+
119
+ return parts.length > 0 ? parts.join(" · ") : undefined
120
+ })
121
+
122
+ const variant = () => (variantOn() ? entry()?.ref.variant : undefined)
123
+
124
+ const subtitle = () => {
125
+ const parts = [entry()?.provider, variant()].filter(
126
+ (part): part is string => typeof part === "string" && part.length > 0,
127
+ )
128
+ return parts.length > 0 ? parts.join(" · ") : undefined
129
+ }
130
+
131
+ const scheduled = () => {
132
+ const ref = entry()?.ref
133
+ return ref ? hasPriceSchedule(ref) : false
134
+ }
135
+
136
+ const peak = () => {
137
+ const ref = entry()?.ref
138
+ if (!ref || !peakOn() || !scheduled()) return undefined
139
+ return priceWindowFor(ref, new Date(now()))
140
+ }
141
+
142
+ const peakLabel = () => {
143
+ if (!peakOn() || !scheduled()) return undefined
144
+ const window = peak()
145
+ return window ? `${window.label} ×${window.multiplier} now` : "off-peak"
146
+ }
147
+
148
+ // The live peak marker follows the clock, so refresh it while the card can
149
+ // show one. Models without a schedule and a hidden peak row need no timer.
150
+ createEffect(() => {
151
+ if (!peakOn() || !scheduled()) return
152
+
153
+ setNow(Date.now())
154
+ const timer = setInterval(() => setNow(Date.now()), 30_000)
155
+ onCleanup(() => clearInterval(timer))
156
+ })
157
+
158
+ return (
159
+ <box flexDirection="column" marginTop={1}>
160
+ <text attributes={TextAttributes.BOLD} fg={theme().text.base}>
161
+ Model
162
+ </text>
163
+ <Show when={entry()} fallback={<text fg={theme().text.muted}>No model selected</text>}>
164
+ <text fg={theme().text.base} wrapMode="word">
165
+ {entry()?.name}
166
+ </text>
167
+ <Show when={subtitle()}>
168
+ <text fg={theme().text.muted} wrapMode="word">
169
+ {subtitle()}
170
+ </text>
171
+ </Show>
172
+ <Show when={rates()}>
173
+ <text fg={theme().text.muted} wrapMode="word">
174
+ {rates()}
175
+ </text>
176
+ </Show>
177
+ <Show when={peakLabel()}>
178
+ <text fg={peak() ? theme().text.feedback.warning.base : theme().text.muted}>
179
+ {peakLabel()}
180
+ </text>
181
+ </Show>
182
+ </Show>
183
+ </box>
184
+ )
185
+ }
@@ -0,0 +1,63 @@
1
+ import { defineTweak } from "./types"
2
+
3
+ /**
4
+ * The sidebar card reads the same catalog as the cost figures, but stays its
5
+ * own tweak: it describes the model, while the cost tweak counts what it spent.
6
+ * Every rate is optional so the card fits a narrow sidebar.
7
+ */
8
+ export const modelTweak = defineTweak({
9
+ id: "model",
10
+ title: "Model",
11
+ description: "Show the current model's list price and peak state in the sidebar.",
12
+ category: "Session",
13
+ settings: [
14
+ {
15
+ id: "enabled",
16
+ title: "Enabled",
17
+ description: "Show the model card in the sidebar.",
18
+ category: "General",
19
+ kind: "boolean",
20
+ default: true,
21
+ },
22
+ {
23
+ id: "input",
24
+ title: "Input price",
25
+ description: "Show the input rate per million tokens.",
26
+ category: "Rates",
27
+ kind: "boolean",
28
+ default: true,
29
+ },
30
+ {
31
+ id: "output",
32
+ title: "Output price",
33
+ description: "Show the output rate per million tokens.",
34
+ category: "Rates",
35
+ kind: "boolean",
36
+ default: true,
37
+ },
38
+ {
39
+ id: "cache",
40
+ title: "Cache read price",
41
+ description: "Show the cache read rate per million tokens.",
42
+ category: "Rates",
43
+ kind: "boolean",
44
+ default: true,
45
+ },
46
+ {
47
+ id: "variant",
48
+ title: "Variant",
49
+ description: "Show the reasoning variant selected for the model.",
50
+ category: "Display",
51
+ kind: "boolean",
52
+ default: true,
53
+ },
54
+ {
55
+ id: "peak",
56
+ title: "Peak state",
57
+ description: "Show whether the model is billed above its catalog price right now.",
58
+ category: "Display",
59
+ kind: "boolean",
60
+ default: true,
61
+ },
62
+ ],
63
+ })
@@ -1,10 +1,11 @@
1
1
  import { costTweak } from "./cost"
2
+ import { modelTweak } from "./model"
2
3
  import { quotaTweak } from "./quota"
3
4
  import { tokensTweak } from "./tokens"
4
5
  import type { Tweak } from "./types"
5
6
 
6
7
  /** Every tweak shown by `/tweaks`. Add new definitions here. */
7
- export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak, costTweak]
8
+ export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak, costTweak, modelTweak]
8
9
 
9
10
  export function findTweak(id: string): Tweak | undefined {
10
11
  return tweaks.find((tweak) => tweak.id === id)
@@ -10,7 +10,7 @@ import {
10
10
  priceFor,
11
11
  } from "../usage/cost"
12
12
  import { footerMessage, replyTurnMessages, type UsageMessage } from "../usage/messages"
13
- import { formatPriceWindow, type PriceModel, priceWindowFor } from "../usage/price-window"
13
+ import { formatPriceWindow, type PriceModel, turnPriceWindow } from "../usage/price-window"
14
14
  import { formatTokens, sessionTokens, type TokenCount, usageTokens } from "../usage/tokens"
15
15
  import { costTweak } from "./cost"
16
16
  import type { SettingsStore } from "./settings"
@@ -84,19 +84,6 @@ function useUsage(props: {
84
84
 
85
85
  const sessions = props.context.data.session
86
86
 
87
- const [now, setNow] = createSignal(Date.now())
88
-
89
- // The badge follows the clock, not the transcript: a line that stays on
90
- // screen across a rate change has to update on its own. The timer exists
91
- // only while the cost tweak and its price windows are on.
92
- createEffect(() => {
93
- if (!costOn() || !windowsOn() || (!promptCost() && !totalCost())) return
94
-
95
- setNow(Date.now())
96
- const timer = setInterval(() => setNow(Date.now()), 30_000)
97
- onCleanup(() => clearInterval(timer))
98
- })
99
-
100
87
  // List prices come from the model catalog the TUI already synced, so an
101
88
  // estimate costs nothing but a lookup.
102
89
  const prices = createMemo(() => {
@@ -205,27 +192,17 @@ function useUsage(props: {
205
192
  return sessions.get(sessionID)?.model ?? lastModel(messages(sessionID))
206
193
  }
207
194
 
208
- /** The higher price that applies right now, when the cost figures are on. */
209
- const windowFor = (model: PriceModel | undefined) =>
210
- costOn() && windowsOn() ? priceWindowFor(model, new Date(now())) : undefined
211
-
212
195
  const promptCostLabel = () => {
213
196
  const value = cost()
214
197
  if (!costOn() || !promptCost() || value === undefined) return undefined
215
198
 
216
199
  const label = formatCost(value, costDigits())
217
- const window = windowFor(lastModel(turn()) ?? sessionModel())
200
+ const window = windowsOn() ? turnPriceWindow(turn(), sessionModel()) : undefined
218
201
  return window ? `${label} · ${formatPriceWindow(window)}` : label
219
202
  }
220
203
 
221
- const totalCostLabel = () => {
222
- if (!costOn() || !totalCost()) return undefined
223
-
224
- const value = sessionCost()
225
- const label = formatCost(value, costDigits())
226
- const window = value > 0 ? windowFor(sessionModel()) : undefined
227
- return window ? `${label} · ${formatPriceWindow(window)}` : label
228
- }
204
+ const totalCostLabel = () =>
205
+ costOn() && totalCost() ? formatCost(sessionCost(), costDigits()) : undefined
229
206
 
230
207
  return {
231
208
  enabled: () => tokensOn() || costOn(),
package/src/usage/cost.ts CHANGED
@@ -85,6 +85,15 @@ export function isCostDigits(value: unknown): value is CostDigits {
85
85
  return value === "auto" || value === 2 || value === 3 || value === 4 || value === 5 || value === 6
86
86
  }
87
87
 
88
+ /**
89
+ * A list rate per million tokens. Normal rates keep two decimals; sub-cent
90
+ * rates show up to six and drop trailing zeros, so `$0.003` stays readable.
91
+ */
92
+ export function formatRate(value: number): string {
93
+ if (value >= 0.01) return `$${value.toFixed(2)}`
94
+ return `$${value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`
95
+ }
96
+
88
97
  /** Normal amounts keep two decimals; smaller ones grow until a digit shows. */
89
98
  function autoDigits(cost: number): number {
90
99
  if (cost === 0 || cost >= 0.01) return 2
@@ -13,6 +13,8 @@ export interface UsageMessage {
13
13
  readonly tokens?: MessageTokens
14
14
  readonly cost?: number
15
15
  readonly model?: { readonly providerID: string; readonly id: string }
16
+ /** When the step was created, which is when the provider started billing it. */
17
+ readonly time?: { readonly created?: number }
16
18
  /** How the step ended; the host only draws a footer for a finished one. */
17
19
  readonly finish?: string | null
18
20
  readonly error?: unknown
@@ -1,3 +1,5 @@
1
+ import type { UsageMessage } from "./messages"
2
+
1
3
  /** The provider and model a message belongs to. */
2
4
  export interface PriceModel {
3
5
  readonly providerID: string
@@ -105,6 +107,19 @@ function utcMinutes(date: Date): number {
105
107
  return date.getUTCHours() * 60 + date.getUTCMinutes()
106
108
  }
107
109
 
110
+ /** The schedule a model follows, when its provider publishes one. */
111
+ function scheduleFor(model: PriceModel): PriceSchedule | undefined {
112
+ return schedules.find(
113
+ (candidate) =>
114
+ candidate.providers.includes(model.providerID) && model.id.startsWith(candidate.modelPrefix),
115
+ )
116
+ }
117
+
118
+ /** Whether the model follows a published peak schedule, even off-peak. */
119
+ export function hasPriceSchedule(model: PriceModel | undefined): boolean {
120
+ return model ? scheduleFor(model) !== undefined : false
121
+ }
122
+
108
123
  /**
109
124
  * The higher price that applies to a model at `at`, or `undefined` when the
110
125
  * catalog price is the one being charged. The check is deliberately narrow:
@@ -113,10 +128,7 @@ function utcMinutes(date: Date): number {
113
128
  export function priceWindowFor(model: PriceModel | undefined, at: Date): PriceWindow | undefined {
114
129
  if (!model) return undefined
115
130
 
116
- const schedule = schedules.find(
117
- (candidate) =>
118
- candidate.providers.includes(model.providerID) && model.id.startsWith(candidate.modelPrefix),
119
- )
131
+ const schedule = scheduleFor(model)
120
132
  if (!schedule) return undefined
121
133
 
122
134
  if (!schedule.peakDays.includes(weekday(at))) return undefined
@@ -134,3 +146,22 @@ export function priceWindowFor(model: PriceModel | undefined, at: Date): PriceWi
134
146
  export function formatPriceWindow(window: PriceWindow): string {
135
147
  return `${window.label} ×${window.multiplier}`
136
148
  }
149
+
150
+ /**
151
+ * The window that applied to a round, from the step that started it. Each
152
+ * request is billed when it starts, so a round that ran during peak keeps its
153
+ * marker after the window closes. Only assistant steps carry a start time.
154
+ */
155
+ export function turnPriceWindow(
156
+ messages: readonly UsageMessage[],
157
+ fallback?: PriceModel,
158
+ ): PriceWindow | undefined {
159
+ const step = messages.find(
160
+ (message) => message.type === "assistant" && message.time?.created !== undefined,
161
+ )
162
+ const created = step?.time?.created
163
+ const model = step?.model ?? fallback
164
+ if (!model || created === undefined) return undefined
165
+
166
+ return priceWindowFor(model, new Date(created))
167
+ }