opencode-tweaks 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +222 -2
- package/package.json +62 -4
- package/src/index.ts +20 -0
- package/src/quota/format.ts +97 -0
- package/src/quota/pace.ts +180 -0
- package/src/quota/parse.ts +101 -0
- package/src/quota/service.ts +224 -0
- package/src/rpc.ts +84 -0
- package/src/tui.tsx +66 -0
- package/src/tweaks/menu-dialog.tsx +424 -0
- package/src/tweaks/menu-model.ts +55 -0
- package/src/tweaks/menu.ts +1 -0
- package/src/tweaks/quota-panel.tsx +514 -0
- package/src/tweaks/quota.ts +163 -0
- package/src/tweaks/registry.ts +10 -0
- package/src/tweaks/settings.ts +73 -0
- package/src/tweaks/token-inject.ts +724 -0
- package/src/tweaks/token-slots.tsx +327 -0
- package/src/tweaks/tokens-model.ts +213 -0
- package/src/tweaks/tokens.ts +113 -0
- package/src/tweaks/types.ts +187 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { usePlugin } from "@opencode/plugin/tui"
|
|
2
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { type BoxRenderable, CliRenderEvents, TextAttributes } from "@opentui/core"
|
|
4
|
+
import { useTerminalDimensions } from "@opentui/solid"
|
|
5
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
6
|
+
import { barParts, errorText, formatPercent, formatReset, levelOf } from "../quota/format"
|
|
7
|
+
import {
|
|
8
|
+
dayKey,
|
|
9
|
+
endOfDay,
|
|
10
|
+
exactRemaining,
|
|
11
|
+
isCurrent,
|
|
12
|
+
isWorkday,
|
|
13
|
+
longestWindow,
|
|
14
|
+
type PaceEntry,
|
|
15
|
+
type PaceProgress,
|
|
16
|
+
paceProgress,
|
|
17
|
+
paceSignature,
|
|
18
|
+
startPace,
|
|
19
|
+
} from "../quota/pace"
|
|
20
|
+
import { Quota, type QuotaSnapshot, type QuotaWindowSnapshot } from "../rpc"
|
|
21
|
+
import { quotaTweak } from "./quota"
|
|
22
|
+
import type { SettingsStore } from "./settings"
|
|
23
|
+
import type { TweakSetting } from "./types"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The sidebar content is narrower than the 42-column sidebar itself. Cap the row
|
|
27
|
+
* here so the reset column always fits, even before the real width is measured.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_ROW_WIDTH = 38
|
|
30
|
+
const LABEL_WIDTH = 3
|
|
31
|
+
const MIN_PERCENT_WIDTH = 3
|
|
32
|
+
/** A failed read retries quickly, because a plugin reload can drop one call. */
|
|
33
|
+
const POLL_MS = 120_000
|
|
34
|
+
const RETRY_MS = 20_000
|
|
35
|
+
|
|
36
|
+
function setting(id: string): TweakSetting {
|
|
37
|
+
const found = quotaTweak.settings.find((item) => item.id === id)
|
|
38
|
+
if (!found) throw new Error(`Quota setting "${id}" is missing`)
|
|
39
|
+
return found
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ENABLED = setting("enabled")
|
|
43
|
+
const BAR_WIDTH = setting("barWidth")
|
|
44
|
+
const PERCENT = setting("percent")
|
|
45
|
+
const PERCENT_DECIMALS = setting("percentDecimals")
|
|
46
|
+
const SHOW_RESET = setting("showReset")
|
|
47
|
+
const PROVIDER_ALIGN = setting("providerAlign")
|
|
48
|
+
const PROVIDER_TAB = setting("providerTab")
|
|
49
|
+
const ONLY_CURRENT = setting("onlyCurrentModel")
|
|
50
|
+
const DAILY_BUDGET = setting("dailyBudget")
|
|
51
|
+
const RESERVE = setting("reserve")
|
|
52
|
+
/** Workday settings ordered like `Date#getDay`, with Sunday first. */
|
|
53
|
+
const WORKDAYS = [
|
|
54
|
+
setting("workSun"),
|
|
55
|
+
setting("workMon"),
|
|
56
|
+
setting("workTue"),
|
|
57
|
+
setting("workWed"),
|
|
58
|
+
setting("workThu"),
|
|
59
|
+
setting("workFri"),
|
|
60
|
+
setting("workSat"),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
/** The stored budget of one provider's pacing window. */
|
|
64
|
+
const PACE_STORE = "opencode-tweaks.quota-pace"
|
|
65
|
+
|
|
66
|
+
const FEEDBACK = {
|
|
67
|
+
good: "success",
|
|
68
|
+
warning: "warning",
|
|
69
|
+
low: "error",
|
|
70
|
+
} as const
|
|
71
|
+
|
|
72
|
+
export function QuotaPanel(props: { readonly context: Context; readonly store: SettingsStore }) {
|
|
73
|
+
const enabled = () => props.store.read(quotaTweak, ENABLED).value === true
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Show when={enabled()}>
|
|
77
|
+
<QuotaBody context={props.context} store={props.store} />
|
|
78
|
+
</Show>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Quotas are provider-specific: each one the server knows about comes back as a
|
|
84
|
+
* snapshot, and a provider it cannot read comes back as nothing. The panel
|
|
85
|
+
* therefore names no provider itself.
|
|
86
|
+
*/
|
|
87
|
+
function QuotaBody(props: { readonly context: Context; readonly store: SettingsStore }) {
|
|
88
|
+
const theme = () => props.context.theme
|
|
89
|
+
const dimensions = useTerminalDimensions()
|
|
90
|
+
const [snapshots, setSnapshots] = createSignal<readonly QuotaSnapshot[]>([])
|
|
91
|
+
const [error, setError] = createSignal<string>()
|
|
92
|
+
const [now, setNow] = createSignal(Date.now())
|
|
93
|
+
const [panelWidth, setPanelWidth] = createSignal(MAX_ROW_WIDTH)
|
|
94
|
+
const [collapsed, setCollapsed] = createSignal<ReadonlySet<string>>(new Set())
|
|
95
|
+
|
|
96
|
+
let root: BoxRenderable | undefined
|
|
97
|
+
let refreshing = false
|
|
98
|
+
let stopped = false
|
|
99
|
+
let poll: ReturnType<typeof setTimeout> | undefined
|
|
100
|
+
let tick: ReturnType<typeof setInterval> | undefined
|
|
101
|
+
|
|
102
|
+
// The slot does not publish its width, so measure the rendered panel after layout.
|
|
103
|
+
createEffect(() => {
|
|
104
|
+
dimensions().width
|
|
105
|
+
|
|
106
|
+
const measure = () => {
|
|
107
|
+
const width = root?.getLayout().width
|
|
108
|
+
if (typeof width === "number" && width > 0) {
|
|
109
|
+
setPanelWidth(Math.min(MAX_ROW_WIDTH, Math.floor(width)))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
props.context.renderer.once(CliRenderEvents.FRAME, measure)
|
|
113
|
+
props.context.renderer.requestRender()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const onlyCurrent = () => props.store.read(quotaTweak, ONLY_CURRENT).value === true
|
|
117
|
+
|
|
118
|
+
const dailyBudget = createMemo(() => props.store.read(quotaTweak, DAILY_BUDGET).value === true)
|
|
119
|
+
const reserve = createMemo(() => {
|
|
120
|
+
const value = props.store.read(quotaTweak, RESERVE).value
|
|
121
|
+
return typeof value === "number" ? value : 0
|
|
122
|
+
})
|
|
123
|
+
const workdays = createMemo(() =>
|
|
124
|
+
WORKDAYS.map((item) => props.store.read(quotaTweak, item).value === true),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
// The day's allowance is frozen at the first reading of the day, so the bar
|
|
128
|
+
// only moves when quota is spent instead of shrinking on every refresh.
|
|
129
|
+
const [pace, updatePace] = props.context.storage.store<Record<string, PaceEntry>>(PACE_STORE, {
|
|
130
|
+
initial: {},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
createEffect(() => {
|
|
134
|
+
const moment = now()
|
|
135
|
+
const mask = workdays()
|
|
136
|
+
const keep = reserve()
|
|
137
|
+
const signature = paceSignature(mask, keep)
|
|
138
|
+
const day = dayKey(moment)
|
|
139
|
+
|
|
140
|
+
for (const snapshot of snapshots()) {
|
|
141
|
+
const window = longestWindow(snapshot.windows)
|
|
142
|
+
if (!window) continue
|
|
143
|
+
|
|
144
|
+
const key = paceKey(snapshot.providerID, window.key)
|
|
145
|
+
if (isCurrent(pace[key], day, window.resetsAt, signature)) continue
|
|
146
|
+
|
|
147
|
+
const entry = startPace(
|
|
148
|
+
exactRemaining(window),
|
|
149
|
+
day,
|
|
150
|
+
window.resetsAt,
|
|
151
|
+
signature,
|
|
152
|
+
mask,
|
|
153
|
+
keep,
|
|
154
|
+
moment,
|
|
155
|
+
)
|
|
156
|
+
void updatePace((draft) => {
|
|
157
|
+
draft[key] = entry
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/** What the daily bar shows for one provider, or nothing when it has none. */
|
|
163
|
+
function budgetOf(snapshot: QuotaSnapshot): DayBudget | undefined {
|
|
164
|
+
if (!dailyBudget()) return undefined
|
|
165
|
+
|
|
166
|
+
const window = longestWindow(snapshot.windows)
|
|
167
|
+
if (!window) return undefined
|
|
168
|
+
|
|
169
|
+
const entry = pace[paceKey(snapshot.providerID, window.key)]
|
|
170
|
+
if (!entry) return undefined
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
progress: paceProgress(entry, exactRemaining(window)),
|
|
174
|
+
rest: !isWorkday(now(), workdays()),
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The provider whose quota the panel follows. `undefined` asks the server for
|
|
180
|
+
* every provider it can read, which is what turning the setting off means.
|
|
181
|
+
*/
|
|
182
|
+
const target = createMemo(() =>
|
|
183
|
+
onlyCurrent() ? props.context.ui.model.current()?.providerID : undefined,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async function refresh() {
|
|
187
|
+
if (refreshing || stopped) return
|
|
188
|
+
|
|
189
|
+
refreshing = true
|
|
190
|
+
try {
|
|
191
|
+
const providerID = target()
|
|
192
|
+
const result = (await props.context.client
|
|
193
|
+
.rpc(Quota)
|
|
194
|
+
.snapshot(providerID ? { providerID } : {})) as { snapshots: QuotaSnapshot[] }
|
|
195
|
+
if (stopped) return
|
|
196
|
+
|
|
197
|
+
setSnapshots(result.snapshots ?? [])
|
|
198
|
+
setError(undefined)
|
|
199
|
+
} catch (problem) {
|
|
200
|
+
// Keep the last good panels; the message only shows when nothing loaded.
|
|
201
|
+
if (!stopped) setError(errorText(problem))
|
|
202
|
+
} finally {
|
|
203
|
+
refreshing = false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function schedule(delay: number) {
|
|
208
|
+
if (poll) clearTimeout(poll)
|
|
209
|
+
poll = setTimeout(() => void cycle(), delay)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function cycle() {
|
|
213
|
+
if (stopped) return
|
|
214
|
+
|
|
215
|
+
await refresh()
|
|
216
|
+
if (stopped) return
|
|
217
|
+
|
|
218
|
+
schedule(error() ? RETRY_MS : POLL_MS)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A different model, or turning the setting, changes which quotas to ask for.
|
|
222
|
+
createEffect(() => {
|
|
223
|
+
target()
|
|
224
|
+
void refresh()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
onMount(() => {
|
|
228
|
+
void cycle()
|
|
229
|
+
tick = setInterval(() => setNow(Date.now()), 30_000)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
onCleanup(() => {
|
|
233
|
+
stopped = true
|
|
234
|
+
if (poll) clearTimeout(poll)
|
|
235
|
+
if (tick) clearInterval(tick)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const showReset = createMemo(() => props.store.read(quotaTweak, SHOW_RESET).value === true)
|
|
239
|
+
const mode = createMemo<"remaining" | "used">(() =>
|
|
240
|
+
props.store.read(quotaTweak, PERCENT).value === "used" ? "used" : "remaining",
|
|
241
|
+
)
|
|
242
|
+
const providerJustify = createMemo<"flex-start" | "center" | "flex-end">(() => {
|
|
243
|
+
const value = props.store.read(quotaTweak, PROVIDER_ALIGN).value
|
|
244
|
+
if (value === "center") return "center"
|
|
245
|
+
if (value === "right") return "flex-end"
|
|
246
|
+
return "flex-start"
|
|
247
|
+
})
|
|
248
|
+
const inlineProvider = createMemo(() => props.store.read(quotaTweak, PROVIDER_TAB).value === true)
|
|
249
|
+
const windows = createMemo(() => snapshots().flatMap((snapshot) => snapshot.windows))
|
|
250
|
+
const decimals = createMemo(() => {
|
|
251
|
+
const value = props.store.read(quotaTweak, PERCENT_DECIMALS).value
|
|
252
|
+
return typeof value === "number" ? value : 1
|
|
253
|
+
})
|
|
254
|
+
const shownPercent = (window: QuotaWindowSnapshot) => {
|
|
255
|
+
const remaining = Math.max(0, Math.min(100, exactRemaining(window)))
|
|
256
|
+
return mode() === "used" ? 100 - remaining : remaining
|
|
257
|
+
}
|
|
258
|
+
const percentWidth = createMemo(() => {
|
|
259
|
+
const widest = windows().reduce(
|
|
260
|
+
(width, window) => Math.max(width, formatPercent(shownPercent(window), decimals()).length),
|
|
261
|
+
0,
|
|
262
|
+
)
|
|
263
|
+
// The daily bar has its own scale, so it is measured on its own rows. It is
|
|
264
|
+
// not reserved at 100%: that would pad every percent by a spare column for
|
|
265
|
+
// the whole day.
|
|
266
|
+
const daily = dailyBudget()
|
|
267
|
+
? snapshots().reduce((width, snapshot) => {
|
|
268
|
+
const budget = budgetOf(snapshot)
|
|
269
|
+
if (!budget || budget.rest) return width
|
|
270
|
+
const shown = mode() === "used" ? 100 - budget.progress.ratio : budget.progress.ratio
|
|
271
|
+
return Math.max(width, formatPercent(shown, decimals()).length)
|
|
272
|
+
}, 0)
|
|
273
|
+
: 0
|
|
274
|
+
|
|
275
|
+
return Math.max(MIN_PERCENT_WIDTH, widest, daily)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// Pad the reset column to its own content so the widest value keeps no leading gap.
|
|
279
|
+
const resetWidth = createMemo(() =>
|
|
280
|
+
windows().reduce(
|
|
281
|
+
(width, window) => Math.max(width, formatReset(window.resetsAt, now()).length),
|
|
282
|
+
0,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
const width = createMemo(() => {
|
|
287
|
+
const fixed = LABEL_WIDTH + percentWidth() + 2 + (showReset() ? resetWidth() + 1 : 0)
|
|
288
|
+
const auto = Math.max(8, Math.min(panelWidth(), MAX_ROW_WIDTH) - fixed)
|
|
289
|
+
const value = props.store.read(quotaTweak, BAR_WIDTH).value
|
|
290
|
+
return typeof value === "number" ? Math.max(1, Math.min(value, auto)) : auto
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
function toggle(providerID: string) {
|
|
294
|
+
setCollapsed((current) => {
|
|
295
|
+
const next = new Set(current)
|
|
296
|
+
if (next.has(providerID)) {
|
|
297
|
+
next.delete(providerID)
|
|
298
|
+
} else {
|
|
299
|
+
next.add(providerID)
|
|
300
|
+
}
|
|
301
|
+
return next
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<box
|
|
307
|
+
ref={(renderable) => {
|
|
308
|
+
root = renderable
|
|
309
|
+
}}
|
|
310
|
+
flexDirection="column"
|
|
311
|
+
gap={1}
|
|
312
|
+
>
|
|
313
|
+
<For each={snapshots()}>
|
|
314
|
+
{(snapshot) => (
|
|
315
|
+
<QuotaSection
|
|
316
|
+
context={props.context}
|
|
317
|
+
snapshot={snapshot}
|
|
318
|
+
budget={budgetOf(snapshot)}
|
|
319
|
+
panelWidth={panelWidth()}
|
|
320
|
+
width={width()}
|
|
321
|
+
percentWidth={percentWidth()}
|
|
322
|
+
decimals={decimals()}
|
|
323
|
+
showReset={showReset()}
|
|
324
|
+
mode={mode()}
|
|
325
|
+
now={now()}
|
|
326
|
+
justify={providerJustify()}
|
|
327
|
+
inlineProvider={inlineProvider()}
|
|
328
|
+
collapsed={collapsed().has(snapshot.providerID)}
|
|
329
|
+
onToggle={() => toggle(snapshot.providerID)}
|
|
330
|
+
onRetry={() => void refresh()}
|
|
331
|
+
/>
|
|
332
|
+
)}
|
|
333
|
+
</For>
|
|
334
|
+
<Show when={snapshots().length === 0 && error()}>
|
|
335
|
+
<text fg={theme().text.muted} wrapMode="word" onMouseUp={() => void refresh()}>
|
|
336
|
+
{error()}
|
|
337
|
+
</text>
|
|
338
|
+
</Show>
|
|
339
|
+
</box>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function QuotaSection(props: {
|
|
344
|
+
readonly context: Context
|
|
345
|
+
readonly snapshot: QuotaSnapshot
|
|
346
|
+
readonly budget?: DayBudget
|
|
347
|
+
readonly panelWidth: number
|
|
348
|
+
readonly width: number
|
|
349
|
+
readonly percentWidth: number
|
|
350
|
+
readonly decimals: number
|
|
351
|
+
readonly showReset: boolean
|
|
352
|
+
readonly mode: "remaining" | "used"
|
|
353
|
+
readonly now: number
|
|
354
|
+
readonly justify: "flex-start" | "center" | "flex-end"
|
|
355
|
+
readonly inlineProvider: boolean
|
|
356
|
+
readonly collapsed: boolean
|
|
357
|
+
readonly onToggle: () => void
|
|
358
|
+
readonly onRetry: () => void
|
|
359
|
+
}) {
|
|
360
|
+
const theme = () => props.context.theme
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<box flexDirection="column">
|
|
364
|
+
<box width={props.panelWidth} flexDirection="row" onMouseUp={props.onToggle}>
|
|
365
|
+
<text attributes={TextAttributes.BOLD} fg={theme().text.base}>
|
|
366
|
+
{props.collapsed ? "▶" : "▼"} Quota
|
|
367
|
+
</text>
|
|
368
|
+
<Show when={props.inlineProvider}>
|
|
369
|
+
<box flexGrow={1} flexDirection="row" justifyContent={props.justify}>
|
|
370
|
+
<text attributes={TextAttributes.BOLD} fg={theme().text.base}>
|
|
371
|
+
{props.justify === "flex-start"
|
|
372
|
+
? ` ${props.snapshot.provider}`
|
|
373
|
+
: props.snapshot.provider}
|
|
374
|
+
</text>
|
|
375
|
+
</box>
|
|
376
|
+
</Show>
|
|
377
|
+
</box>
|
|
378
|
+
<Show when={!props.collapsed}>
|
|
379
|
+
<Show when={!props.inlineProvider}>
|
|
380
|
+
<box width={props.panelWidth} flexDirection="row" justifyContent={props.justify}>
|
|
381
|
+
<text attributes={TextAttributes.BOLD} fg={theme().text.base}>
|
|
382
|
+
{props.snapshot.provider}
|
|
383
|
+
</text>
|
|
384
|
+
</box>
|
|
385
|
+
</Show>
|
|
386
|
+
<Show when={props.snapshot.error && props.snapshot.windows.length === 0}>
|
|
387
|
+
<text fg={theme().text.muted} wrapMode="word" onMouseUp={props.onRetry}>
|
|
388
|
+
{props.snapshot.error}
|
|
389
|
+
</text>
|
|
390
|
+
</Show>
|
|
391
|
+
<For each={props.snapshot.windows}>
|
|
392
|
+
{(window) => (
|
|
393
|
+
<QuotaRow
|
|
394
|
+
window={window}
|
|
395
|
+
width={props.width}
|
|
396
|
+
percentWidth={props.percentWidth}
|
|
397
|
+
decimals={props.decimals}
|
|
398
|
+
showReset={props.showReset}
|
|
399
|
+
mode={props.mode}
|
|
400
|
+
now={props.now}
|
|
401
|
+
/>
|
|
402
|
+
)}
|
|
403
|
+
</For>
|
|
404
|
+
<Show when={props.budget}>
|
|
405
|
+
{(budget: () => DayBudget) => (
|
|
406
|
+
<DayRow
|
|
407
|
+
progress={budget().progress}
|
|
408
|
+
rest={budget().rest}
|
|
409
|
+
width={props.width}
|
|
410
|
+
percentWidth={props.percentWidth}
|
|
411
|
+
decimals={props.decimals}
|
|
412
|
+
showReset={props.showReset}
|
|
413
|
+
mode={props.mode}
|
|
414
|
+
now={props.now}
|
|
415
|
+
/>
|
|
416
|
+
)}
|
|
417
|
+
</Show>
|
|
418
|
+
</Show>
|
|
419
|
+
</box>
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The share of the longest window that today may spend. */
|
|
424
|
+
interface DayBudget {
|
|
425
|
+
readonly progress: PaceProgress
|
|
426
|
+
readonly rest: boolean
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function paceKey(providerID: string, windowKey: string): string {
|
|
430
|
+
return `${providerID}:${windowKey}`
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function DayRow(props: {
|
|
434
|
+
readonly progress: PaceProgress
|
|
435
|
+
readonly rest: boolean
|
|
436
|
+
readonly width: number
|
|
437
|
+
readonly percentWidth: number
|
|
438
|
+
readonly decimals: number
|
|
439
|
+
readonly showReset: boolean
|
|
440
|
+
readonly mode: "remaining" | "used"
|
|
441
|
+
readonly now: number
|
|
442
|
+
}) {
|
|
443
|
+
const context = usePlugin()
|
|
444
|
+
const theme = () => context.theme
|
|
445
|
+
const shown = () => (props.mode === "used" ? 100 - props.progress.ratio : props.progress.ratio)
|
|
446
|
+
const used = () => 100 - props.progress.ratio
|
|
447
|
+
const color = () => {
|
|
448
|
+
if (used() >= 100) return theme().text.feedback.low.base
|
|
449
|
+
if (used() >= 90) return theme().text.feedback.warning.base
|
|
450
|
+
return theme().hue.accent[200]
|
|
451
|
+
}
|
|
452
|
+
const bar = createMemo(() => barParts(props.rest ? 0 : shown(), props.width))
|
|
453
|
+
const reset = createMemo(() =>
|
|
454
|
+
formatReset(new Date(endOfDay(props.now)).toISOString(), props.now),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<box flexDirection="row" gap={1}>
|
|
459
|
+
<text flexShrink={0} fg={theme().text.base}>
|
|
460
|
+
{"day".padEnd(LABEL_WIDTH)}
|
|
461
|
+
</text>
|
|
462
|
+
<text flexShrink={0}>
|
|
463
|
+
<span style={{ fg: props.rest ? theme().text.muted : color() }}>{bar().filled}</span>
|
|
464
|
+
<span style={{ fg: theme().text.muted }}>{bar().empty}</span>
|
|
465
|
+
</text>
|
|
466
|
+
<text flexShrink={0} fg={props.rest ? theme().text.muted : color()}>
|
|
467
|
+
{(props.rest ? "–" : formatPercent(shown(), props.decimals)).padStart(props.percentWidth)}
|
|
468
|
+
</text>
|
|
469
|
+
<Show when={props.showReset || props.rest}>
|
|
470
|
+
<text flexShrink={0} fg={theme().text.muted}>
|
|
471
|
+
{props.rest ? "rest" : reset()}
|
|
472
|
+
</text>
|
|
473
|
+
</Show>
|
|
474
|
+
</box>
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function QuotaRow(props: {
|
|
479
|
+
readonly window: QuotaWindowSnapshot
|
|
480
|
+
readonly width: number
|
|
481
|
+
readonly percentWidth: number
|
|
482
|
+
readonly decimals: number
|
|
483
|
+
readonly showReset: boolean
|
|
484
|
+
readonly mode: "remaining" | "used"
|
|
485
|
+
readonly now: number
|
|
486
|
+
}) {
|
|
487
|
+
const context = usePlugin()
|
|
488
|
+
const theme = () => context.theme
|
|
489
|
+
const remaining = () => Math.max(0, Math.min(100, exactRemaining(props.window)))
|
|
490
|
+
const shown = () => (props.mode === "used" ? 100 - remaining() : remaining())
|
|
491
|
+
const color = () => theme().text.feedback[FEEDBACK[levelOf(remaining())]].base
|
|
492
|
+
const bar = createMemo(() => barParts(shown(), props.width))
|
|
493
|
+
const reset = createMemo(() => formatReset(props.window.resetsAt, props.now))
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<box flexDirection="row" gap={1}>
|
|
497
|
+
<text flexShrink={0} fg={theme().text.base}>
|
|
498
|
+
{props.window.key.padEnd(LABEL_WIDTH)}
|
|
499
|
+
</text>
|
|
500
|
+
<text flexShrink={0}>
|
|
501
|
+
<span style={{ fg: color() }}>{bar().filled}</span>
|
|
502
|
+
<span style={{ fg: theme().text.muted }}>{bar().empty}</span>
|
|
503
|
+
</text>
|
|
504
|
+
<text flexShrink={0} fg={color()}>
|
|
505
|
+
{formatPercent(shown(), props.decimals).padStart(props.percentWidth)}
|
|
506
|
+
</text>
|
|
507
|
+
<Show when={props.showReset}>
|
|
508
|
+
<text flexShrink={0} fg={theme().text.muted}>
|
|
509
|
+
{reset()}
|
|
510
|
+
</text>
|
|
511
|
+
</Show>
|
|
512
|
+
</box>
|
|
513
|
+
)
|
|
514
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { defineTweak } from "./types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The quota sidebar is the first tweak. Its settings land before the panel so
|
|
5
|
+
* `/tweaks` has a real definition to manage from day one.
|
|
6
|
+
*/
|
|
7
|
+
export const quotaTweak = defineTweak({
|
|
8
|
+
id: "quota",
|
|
9
|
+
title: "Quota",
|
|
10
|
+
description: "Show provider quota and reset times in the session sidebar.",
|
|
11
|
+
category: "Quota",
|
|
12
|
+
settings: [
|
|
13
|
+
{
|
|
14
|
+
id: "enabled",
|
|
15
|
+
title: "Enabled",
|
|
16
|
+
description: "Show the quota panel in the session sidebar.",
|
|
17
|
+
category: "General",
|
|
18
|
+
kind: "boolean",
|
|
19
|
+
default: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "barWidth",
|
|
23
|
+
title: "Bar width",
|
|
24
|
+
description: "Width of the usage bars in columns, or auto to use the available width.",
|
|
25
|
+
category: "Display",
|
|
26
|
+
kind: "select",
|
|
27
|
+
default: "auto",
|
|
28
|
+
values: ["auto", 10, 15, 20, 25, 30, 35, 40],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "percent",
|
|
32
|
+
title: "Percentage",
|
|
33
|
+
description: "Show the quota that remains or the quota that was used.",
|
|
34
|
+
category: "Display",
|
|
35
|
+
kind: "select",
|
|
36
|
+
default: "remaining",
|
|
37
|
+
values: ["remaining", "used"],
|
|
38
|
+
labels: ["remaining", "used"],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "percentDecimals",
|
|
42
|
+
title: "Percent decimals",
|
|
43
|
+
description:
|
|
44
|
+
"Decimals after the percent. Providers that report a finer share move the figure between whole points.",
|
|
45
|
+
category: "Display",
|
|
46
|
+
kind: "select",
|
|
47
|
+
default: 1,
|
|
48
|
+
values: [0, 1, 2],
|
|
49
|
+
labels: ["0 decimals", "1 decimal", "2 decimals"],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "showReset",
|
|
53
|
+
title: "Reset times",
|
|
54
|
+
description: "Show when each quota window resets.",
|
|
55
|
+
category: "Display",
|
|
56
|
+
kind: "boolean",
|
|
57
|
+
default: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "providerAlign",
|
|
61
|
+
title: "Provider name",
|
|
62
|
+
description: "Where the provider name sits on its line.",
|
|
63
|
+
category: "Provider",
|
|
64
|
+
kind: "select",
|
|
65
|
+
default: "right",
|
|
66
|
+
values: ["left", "center", "right"],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "providerTab",
|
|
70
|
+
title: "One tab per provider",
|
|
71
|
+
description: "Put the provider name on the Quota line and collapse the rows under it.",
|
|
72
|
+
category: "Provider",
|
|
73
|
+
kind: "boolean",
|
|
74
|
+
default: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "onlyCurrentModel",
|
|
78
|
+
title: "Current provider only",
|
|
79
|
+
description:
|
|
80
|
+
"Follow the selected model and hide quotas from other providers. Turn it off to show every quota the plugin can read.",
|
|
81
|
+
category: "Provider",
|
|
82
|
+
kind: "boolean",
|
|
83
|
+
default: true,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "dailyBudget",
|
|
87
|
+
title: "Daily budget",
|
|
88
|
+
description:
|
|
89
|
+
"Add a bar for the share of the longest window you may spend today, spread over the remaining workdays.",
|
|
90
|
+
category: "Budget",
|
|
91
|
+
kind: "boolean",
|
|
92
|
+
default: true,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "reserve",
|
|
96
|
+
title: "Reserve",
|
|
97
|
+
description:
|
|
98
|
+
"Percent of the longest window kept for the end of the quota, outside the daily budget.",
|
|
99
|
+
category: "Budget",
|
|
100
|
+
kind: "number",
|
|
101
|
+
default: 0,
|
|
102
|
+
min: 0,
|
|
103
|
+
max: 50,
|
|
104
|
+
step: 1,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "workMon",
|
|
108
|
+
title: "Monday",
|
|
109
|
+
description: "Budget a working day on Monday.",
|
|
110
|
+
category: "Workdays",
|
|
111
|
+
kind: "boolean",
|
|
112
|
+
default: true,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "workTue",
|
|
116
|
+
title: "Tuesday",
|
|
117
|
+
description: "Budget a working day on Tuesday.",
|
|
118
|
+
category: "Workdays",
|
|
119
|
+
kind: "boolean",
|
|
120
|
+
default: true,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "workWed",
|
|
124
|
+
title: "Wednesday",
|
|
125
|
+
description: "Budget a working day on Wednesday.",
|
|
126
|
+
category: "Workdays",
|
|
127
|
+
kind: "boolean",
|
|
128
|
+
default: true,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "workThu",
|
|
132
|
+
title: "Thursday",
|
|
133
|
+
description: "Budget a working day on Thursday.",
|
|
134
|
+
category: "Workdays",
|
|
135
|
+
kind: "boolean",
|
|
136
|
+
default: true,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "workFri",
|
|
140
|
+
title: "Friday",
|
|
141
|
+
description: "Budget a working day on Friday.",
|
|
142
|
+
category: "Workdays",
|
|
143
|
+
kind: "boolean",
|
|
144
|
+
default: true,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "workSat",
|
|
148
|
+
title: "Saturday",
|
|
149
|
+
description: "Budget a working day on Saturday.",
|
|
150
|
+
category: "Workdays",
|
|
151
|
+
kind: "boolean",
|
|
152
|
+
default: true,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "workSun",
|
|
156
|
+
title: "Sunday",
|
|
157
|
+
description: "Budget a working day on Sunday.",
|
|
158
|
+
category: "Workdays",
|
|
159
|
+
kind: "boolean",
|
|
160
|
+
default: true,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { quotaTweak } from "./quota"
|
|
2
|
+
import { tokensTweak } from "./tokens"
|
|
3
|
+
import type { Tweak } from "./types"
|
|
4
|
+
|
|
5
|
+
/** Every tweak shown by `/tweaks`. Add new definitions here. */
|
|
6
|
+
export const tweaks: readonly Tweak[] = [quotaTweak, tokensTweak]
|
|
7
|
+
|
|
8
|
+
export function findTweak(id: string): Tweak | undefined {
|
|
9
|
+
return tweaks.find((tweak) => tweak.id === id)
|
|
10
|
+
}
|