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.
@@ -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
+ }