opencode-tweaks 0.6.1 → 0.7.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.
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
 
@@ -194,6 +195,20 @@ per turn, and the estimate follows the API rates instead. Costs use the raw coun
194
195
 
195
196
  The cost settings used to live in the Tokens entry. A choice saved there before the split keeps applying.
196
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`. Click the `Model` header to collapse or
202
+ expand the card.
203
+
204
+ `Input price`, `Output price`, and `Cache read price` choose the rates, all in dollars per million tokens. The card
205
+ uses the catalog price that applies to the session's context, so a long-context tier shows the higher rate with its
206
+ threshold (`>512K`).
207
+
208
+ `Peak state` adds the provider's live peak marker for schedules the plugin knows, such as DeepSeek and OpenCode Go:
209
+ `peak ×2 now` during the higher window and `off-peak` otherwise. The badge on the turn cost still records the window
210
+ each round started in.
211
+
197
212
  ## Development
198
213
 
199
214
  ```sh
@@ -232,6 +247,8 @@ src/
232
247
  quota-panel.tsx sidebar quota panel
233
248
  tokens.ts token tweak definition
234
249
  cost.ts cost tweak definition
250
+ model.ts model tweak definition
251
+ model-panel.tsx sidebar model price card
235
252
  usage-slots.tsx inline and status line figures
236
253
  usage-inject.ts splice into the host's lines
237
254
  ```
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.1",
4
+ "version": "0.7.1",
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) => (
@@ -0,0 +1,203 @@
1
+ import type { Context, SlotMap } from "@opencode/plugin/tui/context"
2
+ import { TextAttributes } from "@opentui/core"
3
+ import { createEffect, createMemo, createSignal, For, 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
+ const [collapsed, setCollapsed] = createSignal(false)
57
+
58
+ const inputOn = () => props.store.read(modelTweak, INPUT).value === true
59
+ const outputOn = () => props.store.read(modelTweak, OUTPUT).value === true
60
+ const cacheOn = () => props.store.read(modelTweak, CACHE).value === true
61
+ const variantOn = () => props.store.read(modelTweak, VARIANT).value === true
62
+ const peakOn = () => props.store.read(modelTweak, PEAK).value === true
63
+
64
+ /** The session's model, or the composer's while the session has none yet. */
65
+ const model = createMemo<ModelRef | undefined>(() => {
66
+ const session = props.context.data.session.get(props.input.sessionID)
67
+ if (session?.model) return session.model
68
+
69
+ const current = props.context.ui.model.current()
70
+ return current
71
+ ? { providerID: current.providerID, id: current.modelID, variant: current.variant }
72
+ : undefined
73
+ })
74
+
75
+ /** The catalog entry for that model, with what the panel needs to show it. */
76
+ const entry = createMemo(() => {
77
+ const ref = model()
78
+ if (!ref) return undefined
79
+
80
+ const found = (props.context.data.location.model.list() ?? []).find(
81
+ (candidate) =>
82
+ candidate.providerID === ref.providerID &&
83
+ (candidate.modelID === ref.id || candidate.id === ref.id),
84
+ )
85
+ const provider = (props.context.data.location.provider.list() ?? []).find(
86
+ (candidate) => candidate.id === ref.providerID,
87
+ )
88
+
89
+ return {
90
+ ref,
91
+ name: found?.name ?? ref.id,
92
+ provider: provider?.name,
93
+ costs: found?.cost ?? [],
94
+ }
95
+ })
96
+
97
+ /** The context the session has read so far, which picks a long-context tier. */
98
+ const contextSize = createMemo(() => {
99
+ const tokens = props.context.data.session.get(props.input.sessionID)?.tokens
100
+ return tokens ? tokens.input + tokens.cache.read : 0
101
+ })
102
+
103
+ const price = createMemo(() => {
104
+ const costs = entry()?.costs
105
+ return costs?.length ? priceFor(costs, contextSize()) : undefined
106
+ })
107
+
108
+ const rates = createMemo(() => {
109
+ const value = price()
110
+ if (!value) return []
111
+
112
+ const rows: { label: string; value: string }[] = []
113
+ if (inputOn()) rows.push({ label: "in", value: formatRate(value.input) })
114
+ if (outputOn()) rows.push({ label: "out", value: formatRate(value.output) })
115
+ if (cacheOn()) rows.push({ label: "cache", value: formatRate(value.cache.read) })
116
+ if (rows.length > 0 && value.tier) {
117
+ rows.push({ label: "from", value: `>${formatTokens(value.tier.size, "compact")}` })
118
+ }
119
+
120
+ return rows
121
+ })
122
+
123
+ const variant = () => (variantOn() ? entry()?.ref.variant : undefined)
124
+
125
+ const title = () => {
126
+ const name = entry()?.name
127
+ const selected = variant()
128
+ return selected ? `${name} · ${selected}` : name
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 stateValue = () => {
143
+ if (!peakOn() || !scheduled()) return undefined
144
+ const value = peak()
145
+ return value ? `${value.label} ×${value.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
+ <box
161
+ flexDirection="row"
162
+ justifyContent="space-between"
163
+ onMouseUp={() => setCollapsed((value) => !value)}
164
+ >
165
+ <text attributes={TextAttributes.BOLD} fg={theme().text.base}>
166
+ {collapsed() ? "▶" : "▼"} Model
167
+ </text>
168
+ <Show when={entry()?.provider}>
169
+ <text fg={theme().text.muted} wrapMode="none">
170
+ {entry()?.provider}
171
+ </text>
172
+ </Show>
173
+ </box>
174
+ <Show when={!collapsed()}>
175
+ <Show when={entry()} fallback={<text fg={theme().text.muted}>No model selected</text>}>
176
+ <text fg={theme().text.base} wrapMode="word">
177
+ {title()}
178
+ </text>
179
+ <For each={rates()}>
180
+ {(row) => (
181
+ <box flexDirection="row" gap={1}>
182
+ <text flexShrink={0} fg={theme().text.muted}>
183
+ {row.label.padEnd(5)}
184
+ </text>
185
+ <text fg={theme().text.base}>{row.value}</text>
186
+ </box>
187
+ )}
188
+ </For>
189
+ <Show when={stateValue()}>
190
+ <box flexDirection="row" gap={1}>
191
+ <text flexShrink={0} fg={theme().text.muted}>
192
+ {"state".padEnd(5)}
193
+ </text>
194
+ <text fg={peak() ? theme().text.feedback.warning.base : theme().text.muted}>
195
+ {stateValue()}
196
+ </text>
197
+ </box>
198
+ </Show>
199
+ </Show>
200
+ </Show>
201
+ </box>
202
+ )
203
+ }
@@ -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)
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
@@ -107,6 +107,19 @@ function utcMinutes(date: Date): number {
107
107
  return date.getUTCHours() * 60 + date.getUTCMinutes()
108
108
  }
109
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
+
110
123
  /**
111
124
  * The higher price that applies to a model at `at`, or `undefined` when the
112
125
  * catalog price is the one being charged. The check is deliberately narrow:
@@ -115,10 +128,7 @@ function utcMinutes(date: Date): number {
115
128
  export function priceWindowFor(model: PriceModel | undefined, at: Date): PriceWindow | undefined {
116
129
  if (!model) return undefined
117
130
 
118
- const schedule = schedules.find(
119
- (candidate) =>
120
- candidate.providers.includes(model.providerID) && model.id.startsWith(candidate.modelPrefix),
121
- )
131
+ const schedule = scheduleFor(model)
122
132
  if (!schedule) return undefined
123
133
 
124
134
  if (!schedule.peakDays.includes(weekday(at))) return undefined