opencode-tweaks 0.8.0 → 0.9.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
@@ -198,8 +198,9 @@ The cost settings used to live in the Tokens entry. A choice saved there before
198
198
  ### Model
199
199
 
200
200
  Model shows the current model's list price in the sidebar, under the quota. The model name, provider, and variant
201
- already sit on the bottom bar, so the card only carries the rates and the peak state. Click the `Model` header to
202
- collapse or expand it.
201
+ already sit on the bottom bar, so the card only carries the rates and the peak state. The rates follow the prompt's
202
+ selected model, so picking another one in `/models` updates them at once, before the next turn. Click the `Model`
203
+ header to collapse or expand it.
203
204
 
204
205
  `Input price`, `Output price`, and `Cache read price` choose the rates, all in dollars per million tokens. The card
205
206
  draws them as a price list with the labels on the left and the values in the theme accent. `Price alignment` picks
@@ -207,6 +208,10 @@ whether the prices start on one shared column (`left`) or sit against the right
207
208
  price that applies to the session's context, so a long-context tier shows the higher rate with its threshold
208
209
  (`>512K`).
209
210
 
211
+ A model that bills nothing, such as a free promo, follows `Free models`. `List price` shows the rates of the paid
212
+ catalog entry it borrows. `Free label` shows a single `Free` line instead. `Hide card` removes the card until a paid
213
+ model is selected.
214
+
210
215
  `Peak state` puts the price multiplier on the right of the card header for schedules the plugin knows, such as
211
216
  DeepSeek and OpenCode Go: `×2` in the warning colour during the higher window and `×1` in the normal text colour off
212
217
  it. A model without a schedule shows nothing. The marker stays visible when the card is collapsed. `Peak only` hides
@@ -238,6 +243,7 @@ src/
238
243
  messages.ts message shape and turn selection
239
244
  tokens.ts token sums and formatting
240
245
  cost.ts list prices, estimates, and cost formatting
246
+ catalog.ts catalog entries and paid sibling lookup
241
247
  price-window.ts provider peak hours and holidays
242
248
  tui.tsx terminal entry: commands and slots
243
249
  tweaks/
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.8.0",
4
+ "version": "0.9.0",
5
5
  "description": "Opt-in settings and small interface improvements for OpenCode.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -1,6 +1,7 @@
1
1
  import type { Context, SlotMap } from "@opencode/plugin/tui/context"
2
2
  import { TextAttributes } from "@opentui/core"
3
3
  import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
4
+ import { type CatalogModel, paidSibling } from "../usage/catalog"
4
5
  import { formatRate, priceFor } from "../usage/cost"
5
6
  import { hasPriceSchedule, priceWindowFor } from "../usage/price-window"
6
7
  import { formatTokens } from "../usage/tokens"
@@ -21,6 +22,7 @@ const CACHE = setting("cache")
21
22
  const PEAK = setting("peak")
22
23
  const PEAK_ONLY = setting("peakOnly")
23
24
  const ALIGN = setting("align")
25
+ const FREE = setting("free")
24
26
 
25
27
  /** The ref stored on a session or returned by the composer. */
26
28
  interface ModelRef {
@@ -62,27 +64,31 @@ function ModelBody(props: {
62
64
  const peakOn = () => props.store.read(modelTweak, PEAK).value === true
63
65
  const peakOnly = () => props.store.read(modelTweak, PEAK_ONLY).value === true
64
66
  const alignRight = () => props.store.read(modelTweak, ALIGN).value === "right"
67
+ const freeMode = () => String(props.store.read(modelTweak, FREE).value)
65
68
 
66
- /** The session's model, or the composer's while the session has none yet. */
69
+ /**
70
+ * The model the next prompt would use, read from the prompt selection so a
71
+ * switch in `/models` shows its rates at once. The session only supplies a
72
+ * model for a transcript opened without a prompt of its own.
73
+ */
67
74
  const model = createMemo<ModelRef | undefined>(() => {
68
- const session = props.context.data.session.get(props.input.sessionID)
69
- if (session?.model) return session.model
70
-
71
75
  const current = props.context.ui.model.current()
72
- return current ? { providerID: current.providerID, id: current.modelID } : undefined
76
+ if (current) return { providerID: current.providerID, id: current.modelID }
77
+
78
+ return props.context.data.session.get(props.input.sessionID)?.model
73
79
  })
74
80
 
75
- /** The catalog rates for that model, when the catalog knows it. */
76
- const costs = createMemo(() => {
81
+ const catalog = () => props.context.data.location.model.list() ?? []
82
+
83
+ /** The catalog entry for that model, when the catalog knows it. */
84
+ const entry = createMemo<CatalogModel | undefined>(() => {
77
85
  const ref = model()
78
- if (!ref) return []
79
-
80
- return (
81
- (props.context.data.location.model.list() ?? []).find(
82
- (candidate) =>
83
- candidate.providerID === ref.providerID &&
84
- (candidate.modelID === ref.id || candidate.id === ref.id),
85
- )?.cost ?? []
86
+ if (!ref) return undefined
87
+
88
+ return catalog().find(
89
+ (candidate) =>
90
+ candidate.providerID === ref.providerID &&
91
+ (candidate.modelID === ref.id || candidate.id === ref.id),
86
92
  )
87
93
  })
88
94
 
@@ -93,12 +99,31 @@ function ModelBody(props: {
93
99
  })
94
100
 
95
101
  const price = createMemo(() => {
96
- const list = costs()
97
- return list.length > 0 ? priceFor(list, contextSize()) : undefined
102
+ const list = entry()?.cost
103
+ return list && list.length > 0 ? priceFor(list, contextSize()) : undefined
98
104
  })
99
105
 
100
- const rates = createMemo(() => {
106
+ /** A free model bills zero on every rate the card can show. */
107
+ const freeModel = createMemo(() => {
101
108
  const value = price()
109
+ return value ? value.input === 0 && value.output === 0 && value.cache.read === 0 : false
110
+ })
111
+
112
+ /** The paid list price of a free promo, when the catalog has its sibling. */
113
+ const reference = createMemo(() => {
114
+ if (!freeModel() || freeMode() !== "reference") return undefined
115
+ const selected = entry()
116
+ if (!selected) return undefined
117
+
118
+ const sibling = paidSibling(catalog(), selected)
119
+ return sibling ? priceFor(sibling.cost, contextSize()) : undefined
120
+ })
121
+
122
+ /** The rates on screen: the selected model's, or the reference it borrows. */
123
+ const shown = createMemo(() => reference() ?? (freeModel() ? undefined : price()))
124
+
125
+ const rates = createMemo(() => {
126
+ const value = shown()
102
127
  if (!value) return []
103
128
 
104
129
  const rows: { label: string; value: string; muted?: boolean }[] = []
@@ -116,6 +141,15 @@ function ModelBody(props: {
116
141
  return rows
117
142
  })
118
143
 
144
+ /** A free model with no reference price says so instead of drawing zeroes. */
145
+ const freeLabel = createMemo(() => {
146
+ if (!freeModel() || reference()) return false
147
+ return inputOn() || outputOn() || cacheOn()
148
+ })
149
+
150
+ /** `Hide card` removes the panel entirely while a free model is selected. */
151
+ const hidden = () => freeModel() && freeMode() === "hidden"
152
+
119
153
  /** The widest label, so the prices start on one column. */
120
154
  const labelWidth = createMemo(() =>
121
155
  rates().reduce((width, row) => Math.max(width, row.label.length), 0),
@@ -148,44 +182,58 @@ function ModelBody(props: {
148
182
  })
149
183
 
150
184
  return (
151
- <box flexDirection="column">
152
- <box
153
- flexDirection="row"
154
- justifyContent="space-between"
155
- onMouseUp={() => setCollapsed((value) => !value)}
156
- >
157
- <text attributes={TextAttributes.BOLD} fg={theme().text.base}>
158
- {collapsed() ? "▶" : "▼"} Model
159
- </text>
160
- <Show when={showMultiplier()}>
161
- <text
162
- fg={peak() ? theme().text.feedback.warning.base : theme().text.base}
163
- wrapMode="none"
164
- >
165
- {`×${multiplier()}`}
185
+ <Show when={!hidden()}>
186
+ <box flexDirection="column">
187
+ <box
188
+ flexDirection="row"
189
+ justifyContent="space-between"
190
+ onMouseUp={() => setCollapsed((value) => !value)}
191
+ >
192
+ <text attributes={TextAttributes.BOLD} fg={theme().text.base}>
193
+ {collapsed() ? "▶" : "▼"} Model
166
194
  </text>
167
- </Show>
168
- </box>
169
- <Show when={!collapsed()}>
170
- <Show when={model()} fallback={<text fg={theme().text.muted}>No model selected</text>}>
171
- <For each={rates()}>
172
- {(row) => (
173
- <box
174
- flexDirection="row"
175
- justifyContent={alignRight() ? "space-between" : "flex-start"}
176
- gap={1}
177
- >
178
- <text flexShrink={0} fg={theme().text.base}>
179
- {alignRight() ? row.label : row.label.padEnd(labelWidth())}
180
- </text>
181
- <text flexShrink={0} fg={row.muted ? theme().text.muted : theme().hue.accent[200]}>
182
- {row.value}
195
+ <Show when={showMultiplier()}>
196
+ <text
197
+ fg={peak() ? theme().text.feedback.warning.base : theme().text.base}
198
+ wrapMode="none"
199
+ >
200
+ {`×${multiplier()}`}
201
+ </text>
202
+ </Show>
203
+ </box>
204
+ <Show when={!collapsed()}>
205
+ <Show when={model()} fallback={<text fg={theme().text.muted}>No model selected</text>}>
206
+ <Show
207
+ when={!freeLabel()}
208
+ fallback={
209
+ <text fg={theme().hue.accent[200]} attributes={TextAttributes.BOLD}>
210
+ Free
183
211
  </text>
184
- </box>
185
- )}
186
- </For>
212
+ }
213
+ >
214
+ <For each={rates()}>
215
+ {(row) => (
216
+ <box
217
+ flexDirection="row"
218
+ justifyContent={alignRight() ? "space-between" : "flex-start"}
219
+ gap={1}
220
+ >
221
+ <text flexShrink={0} fg={theme().text.base}>
222
+ {alignRight() ? row.label : row.label.padEnd(labelWidth())}
223
+ </text>
224
+ <text
225
+ flexShrink={0}
226
+ fg={row.muted ? theme().text.muted : theme().hue.accent[200]}
227
+ >
228
+ {row.value}
229
+ </text>
230
+ </box>
231
+ )}
232
+ </For>
233
+ </Show>
234
+ </Show>
187
235
  </Show>
188
- </Show>
189
- </box>
236
+ </box>
237
+ </Show>
190
238
  )
191
239
  }
@@ -60,6 +60,17 @@ export const modelTweak = defineTweak({
60
60
  default: "right",
61
61
  values: ["left", "right"],
62
62
  },
63
+ {
64
+ id: "free",
65
+ title: "Free models",
66
+ description:
67
+ "Show the paid list price, a Free label, or no card at all when the selected model is free.",
68
+ category: "Display",
69
+ kind: "select",
70
+ default: "reference",
71
+ values: ["reference", "label", "hidden"],
72
+ labels: ["List price", "Free label", "Hide card"],
73
+ },
63
74
  {
64
75
  id: "peakOnly",
65
76
  title: "Peak only",
@@ -0,0 +1,41 @@
1
+ import type { ModelPrice } from "./cost"
2
+
3
+ /** A catalog entry, narrowed to the fields the model card reads. */
4
+ export interface CatalogModel {
5
+ readonly id: string
6
+ readonly modelID: string
7
+ readonly providerID: string
8
+ readonly name: string
9
+ readonly cost: readonly ModelPrice[]
10
+ }
11
+
12
+ /** Whether a price list charges anything at any of its rates. */
13
+ export function charged(prices: readonly ModelPrice[]): boolean {
14
+ return prices.some((price) => price.input > 0 || price.output > 0 || price.cache.read > 0)
15
+ }
16
+
17
+ /**
18
+ * The paid entry behind a free promo. Providers name the promo after the model
19
+ * it borrows, so dropping a trailing `Free` and matching the same provider
20
+ * finds the price the model lists at when it is not on the house.
21
+ */
22
+ export function paidSibling(
23
+ models: readonly CatalogModel[],
24
+ selected: CatalogModel,
25
+ ): CatalogModel | undefined {
26
+ const base = selected.name.replace(/\s*\(?free\)?$/i, "").trim()
27
+ if (base.length === 0 || base === selected.name) return undefined
28
+
29
+ const candidates = models.filter(
30
+ (candidate) =>
31
+ candidate.providerID === selected.providerID &&
32
+ candidate.id !== selected.id &&
33
+ charged(candidate.cost) &&
34
+ (candidate.name === base || candidate.name.startsWith(`${base} `)),
35
+ )
36
+
37
+ candidates.sort(
38
+ (a, b) => Number(b.name === base) - Number(a.name === base) || a.name.length - b.name.length,
39
+ )
40
+ return candidates[0]
41
+ }