opencode-tweaks 0.7.2 → 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
@@ -197,17 +197,26 @@ The cost settings used to live in the Tokens entry. A choice saved there before
197
197
 
198
198
  ### Model
199
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.
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. 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
- 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`).
206
+ draws them as a price list with the labels on the left and the values in the theme accent. `Price alignment` picks
207
+ whether the prices start on one shared column (`left`) or sit against the right edge (`right`). It uses the catalog
208
+ price that applies to the session's context, so a long-context tier shows the higher rate with its threshold
209
+ (`>512K`).
207
210
 
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
+ 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
+
215
+ `Peak state` puts the price multiplier on the right of the card header for schedules the plugin knows, such as
216
+ DeepSeek and OpenCode Go: `×2` in the warning colour during the higher window and `×1` in the normal text colour off
217
+ it. A model without a schedule shows nothing. The marker stays visible when the card is collapsed. `Peak only` hides
218
+ `×1`, so the header stays empty at the base price. The badge on the turn cost still records the window each round
219
+ started in.
211
220
 
212
221
  ## Development
213
222
 
@@ -234,6 +243,7 @@ src/
234
243
  messages.ts message shape and turn selection
235
244
  tokens.ts token sums and formatting
236
245
  cost.ts list prices, estimates, and cost formatting
246
+ catalog.ts catalog entries and paid sibling lookup
237
247
  price-window.ts provider peak hours and holidays
238
248
  tui.tsx terminal entry: commands and slots
239
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.7.2",
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"
@@ -18,19 +19,21 @@ const ENABLED = setting("enabled")
18
19
  const INPUT = setting("input")
19
20
  const OUTPUT = setting("output")
20
21
  const CACHE = setting("cache")
21
- const VARIANT = setting("variant")
22
22
  const PEAK = setting("peak")
23
+ const PEAK_ONLY = setting("peakOnly")
24
+ const ALIGN = setting("align")
25
+ const FREE = setting("free")
23
26
 
24
27
  /** The ref stored on a session or returned by the composer. */
25
28
  interface ModelRef {
26
29
  readonly providerID: string
27
30
  readonly id: string
28
- readonly variant?: string
29
31
  }
30
32
 
31
33
  /**
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
+ * The model card sits under the quota and carries what the bottom bar does
35
+ * not: the current model's list price and its provider's peak state. The
36
+ * model name and variant already live on that bar, so the card stays compact.
34
37
  */
35
38
  export function ModelPanel(props: {
36
39
  readonly context: Context
@@ -58,40 +61,35 @@ function ModelBody(props: {
58
61
  const inputOn = () => props.store.read(modelTweak, INPUT).value === true
59
62
  const outputOn = () => props.store.read(modelTweak, OUTPUT).value === true
60
63
  const cacheOn = () => props.store.read(modelTweak, CACHE).value === true
61
- const variantOn = () => props.store.read(modelTweak, VARIANT).value === true
62
64
  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 peakOnly = () => props.store.read(modelTweak, PEAK_ONLY).value === true
66
+ const alignRight = () => props.store.read(modelTweak, ALIGN).value === "right"
67
+ const freeMode = () => String(props.store.read(modelTweak, FREE).value)
68
+
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
+ */
65
74
  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
75
  const current = props.context.ui.model.current()
70
- return current
71
- ? { providerID: current.providerID, id: current.modelID, variant: current.variant }
72
- : 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 entry for that model, with what the panel needs to show it. */
76
- const entry = 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
86
  if (!ref) return undefined
79
87
 
80
- const found = (props.context.data.location.model.list() ?? []).find(
88
+ return catalog().find(
81
89
  (candidate) =>
82
90
  candidate.providerID === ref.providerID &&
83
91
  (candidate.modelID === ref.id || candidate.id === ref.id),
84
92
  )
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
93
  })
96
94
 
97
95
  /** The context the session has read so far, which picks a long-context tier. */
@@ -101,52 +99,80 @@ function ModelBody(props: {
101
99
  })
102
100
 
103
101
  const price = createMemo(() => {
104
- const costs = entry()?.costs
105
- return costs?.length ? priceFor(costs, contextSize()) : undefined
102
+ const list = entry()?.cost
103
+ return list && list.length > 0 ? priceFor(list, contextSize()) : undefined
106
104
  })
107
105
 
108
- const rates = createMemo(() => {
106
+ /** A free model bills zero on every rate the card can show. */
107
+ const freeModel = createMemo(() => {
109
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()
110
127
  if (!value) return []
111
128
 
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) })
129
+ const rows: { label: string; value: string; muted?: boolean }[] = []
130
+ if (inputOn()) rows.push({ label: "Input", value: formatRate(value.input) })
131
+ if (outputOn()) rows.push({ label: "Output", value: formatRate(value.output) })
132
+ if (cacheOn()) rows.push({ label: "Cache", value: formatRate(value.cache.read) })
116
133
  if (rows.length > 0 && value.tier) {
117
- rows.push({ label: "from", value: `>${formatTokens(value.tier.size, "compact")}` })
134
+ rows.push({
135
+ label: "From",
136
+ value: `>${formatTokens(value.tier.size, "compact")}`,
137
+ muted: true,
138
+ })
118
139
  }
119
140
 
120
141
  return rows
121
142
  })
122
143
 
123
- const variant = () => (variantOn() ? entry()?.ref.variant : undefined)
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
+ })
124
149
 
125
- const title = () => {
126
- const name = entry()?.name
127
- const selected = variant()
128
- return selected ? `${name} · ${selected}` : name
129
- }
150
+ /** `Hide card` removes the panel entirely while a free model is selected. */
151
+ const hidden = () => freeModel() && freeMode() === "hidden"
152
+
153
+ /** The widest label, so the prices start on one column. */
154
+ const labelWidth = createMemo(() =>
155
+ rates().reduce((width, row) => Math.max(width, row.label.length), 0),
156
+ )
130
157
 
131
158
  const scheduled = () => {
132
- const ref = entry()?.ref
159
+ const ref = model()
133
160
  return ref ? hasPriceSchedule(ref) : false
134
161
  }
135
162
 
136
163
  const peak = () => {
137
- const ref = entry()?.ref
164
+ const ref = model()
138
165
  if (!ref || !peakOn() || !scheduled()) return undefined
139
166
  return priceWindowFor(ref, new Date(now()))
140
167
  }
141
168
 
142
- const stateValue = () => {
143
- if (!peakOn() || !scheduled()) return undefined
144
- const value = peak()
145
- return value ? `${value.label} ×${value.multiplier} now` : "off-peak"
146
- }
169
+ /** The price multiplier now, `1` when the catalog price is the one charged. */
170
+ const multiplier = () => peak()?.multiplier ?? 1
171
+
172
+ const showMultiplier = () => peakOn() && scheduled() && (peak() !== undefined || !peakOnly())
147
173
 
148
174
  // 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.
175
+ // show one. Models without a schedule and a hidden peak state need no timer.
150
176
  createEffect(() => {
151
177
  if (!peakOn() || !scheduled()) return
152
178
 
@@ -156,48 +182,58 @@ function ModelBody(props: {
156
182
  })
157
183
 
158
184
  return (
159
- <box flexDirection="column">
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 attributes={TextAttributes.BOLD} fg={theme().text.base} 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()}
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
178
194
  </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)}
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
184
211
  </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>
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>
198
234
  </Show>
199
235
  </Show>
200
- </Show>
201
- </box>
236
+ </box>
237
+ </Show>
202
238
  )
203
239
  }
@@ -44,20 +44,40 @@ export const modelTweak = defineTweak({
44
44
  default: true,
45
45
  },
46
46
  {
47
- id: "variant",
48
- title: "Variant",
49
- description: "Show the reasoning variant selected for the model.",
47
+ id: "peak",
48
+ title: "Peak state",
49
+ description: "Show the model's current price multiplier in the card header.",
50
50
  category: "Display",
51
51
  kind: "boolean",
52
52
  default: true,
53
53
  },
54
54
  {
55
- id: "peak",
56
- title: "Peak state",
57
- description: "Show whether the model is billed above its catalog price right now.",
55
+ id: "align",
56
+ title: "Price alignment",
57
+ description: "Start the prices on one column, or push them against the right edge.",
58
+ category: "Display",
59
+ kind: "select",
60
+ default: "right",
61
+ values: ["left", "right"],
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
+ },
74
+ {
75
+ id: "peakOnly",
76
+ title: "Peak only",
77
+ description: "Show the multiplier only during a peak window.",
58
78
  category: "Display",
59
79
  kind: "boolean",
60
- default: true,
80
+ default: false,
61
81
  },
62
82
  ],
63
83
  })
@@ -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
+ }