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,424 @@
1
+ import { usePlugin } from "@opencode/plugin/tui"
2
+ import type { Context } from "@opencode/plugin/tui/context"
3
+ import { CliRenderEvents, type ScrollBoxRenderable, TextAttributes } from "@opentui/core"
4
+ import { useTerminalDimensions } from "@opentui/solid"
5
+ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
6
+ import { filterRows, groupRows, type MenuRow, moveRow, orderedRows } from "./menu-model"
7
+ import { tweaks } from "./registry"
8
+ import type { SettingsStore } from "./settings"
9
+ import { displayValue, nextValue, type Tweak, type TweakSetting } from "./types"
10
+
11
+ interface Row extends MenuRow {
12
+ readonly footer: () => string
13
+ readonly activate: () => void | Promise<void>
14
+ readonly cycle?: (direction: 1 | -1) => Promise<void>
15
+ readonly reset?: () => Promise<void>
16
+ /** Rows that cycle through values show arrows around the value while selected. */
17
+ readonly cycleable?: boolean
18
+ }
19
+
20
+ type View = { readonly kind: "tweaks" } | { readonly kind: "tweak"; readonly tweak: Tweak }
21
+
22
+ type DisplayRow =
23
+ | { readonly kind: "category"; readonly title: string }
24
+ | { readonly kind: "row"; readonly row: Row }
25
+
26
+ interface DialogProps {
27
+ readonly context: Context
28
+ readonly store: SettingsStore
29
+ readonly close: () => void
30
+ }
31
+
32
+ const tweakRowID = (tweak: Tweak) => `tweak:${tweak.id}`
33
+ const settingRowID = (tweak: Tweak, setting: TweakSetting) => `setting:${tweak.id}.${setting.id}`
34
+
35
+ /**
36
+ * Opens the tweak list in one dialog that never closes while values change.
37
+ * The layout follows OpenCode's own settings screen row for row, and values
38
+ * update in place instead of reopening the menu after every choice.
39
+ */
40
+ export async function openTweaksMenu(context: Context, store: SettingsStore): Promise<void> {
41
+ await new Promise<void>((resolve) => {
42
+ let settled = false
43
+ const settle = () => {
44
+ if (settled) return
45
+ settled = true
46
+ resolve()
47
+ }
48
+
49
+ context.ui.dialog.show(
50
+ () => (
51
+ <TweaksDialog context={context} store={store} close={() => context.ui.dialog.clear()} />
52
+ ),
53
+ settle,
54
+ )
55
+ })
56
+ }
57
+
58
+ function TweaksDialog(props: DialogProps) {
59
+ const theme = () => props.context.theme.surface("dialog")
60
+ const dimensions = useTerminalDimensions()
61
+ const [view, setView] = createSignal<View>({ kind: "tweaks" })
62
+ const [filter, setFilter] = createSignal("")
63
+ const firstTweak = tweaks[0]
64
+ const [selectedID, setSelectedID] = createSignal<string | undefined>(
65
+ firstTweak ? tweakRowID(firstTweak) : undefined,
66
+ )
67
+ const [saving, setSaving] = createSignal(false)
68
+
69
+ let scroll: ScrollBoxRenderable | undefined
70
+ let pendingScroll: (() => void) | undefined
71
+
72
+ async function cycleSetting(tweak: Tweak, setting: TweakSetting, direction: 1 | -1) {
73
+ const current = props.store.read(tweak, setting).value
74
+ const next = nextValue(setting, current, direction)
75
+ if (next !== undefined) await props.store.set(tweak, setting, next)
76
+ }
77
+
78
+ async function run(action: () => void | Promise<void>) {
79
+ if (saving()) return
80
+
81
+ setSaving(true)
82
+ try {
83
+ await action()
84
+ } catch (error) {
85
+ props.context.ui.toast.show({
86
+ variant: "error",
87
+ title: "Tweaks",
88
+ message: error instanceof Error ? error.message : String(error),
89
+ })
90
+ } finally {
91
+ setSaving(false)
92
+ }
93
+ }
94
+
95
+ function summarize(tweak: Tweak): string {
96
+ const enabled = tweak.settings.find((setting) => setting.id === "enabled")
97
+ if (enabled) return displayValue(enabled, props.store.read(tweak, enabled).value)
98
+
99
+ return tweak.settings.length === 1 ? "1 setting" : `${tweak.settings.length} settings`
100
+ }
101
+
102
+ function openTweak(tweak: Tweak) {
103
+ const first = tweak.settings[0]
104
+ setFilter("")
105
+ setSelectedID(first ? settingRowID(tweak, first) : undefined)
106
+ setView({ kind: "tweak", tweak })
107
+ }
108
+
109
+ function goBack() {
110
+ const current = view()
111
+ if (current.kind === "tweak") {
112
+ setFilter("")
113
+ setSelectedID(tweakRowID(current.tweak))
114
+ setView({ kind: "tweaks" })
115
+ return
116
+ }
117
+
118
+ props.close()
119
+ }
120
+
121
+ function tweakRows(): Row[] {
122
+ return tweaks.map((tweak) => ({
123
+ id: tweakRowID(tweak),
124
+ title: tweak.title,
125
+ category: tweak.category,
126
+ // The chevron marks a row that opens another page. Settings live one level down.
127
+ footer: () => `${summarize(tweak)} ›`,
128
+ activate: () => openTweak(tweak),
129
+ }))
130
+ }
131
+
132
+ function settingRows(tweak: Tweak): Row[] {
133
+ return tweak.settings.map((setting) => ({
134
+ id: settingRowID(tweak, setting),
135
+ title: setting.title,
136
+ category: setting.category,
137
+ footer: () => displayValue(setting, props.store.read(tweak, setting).value),
138
+ activate: () => cycleSetting(tweak, setting, 1),
139
+ cycle: (direction) => cycleSetting(tweak, setting, direction),
140
+ reset: () => props.store.reset(tweak, setting),
141
+ cycleable: true,
142
+ }))
143
+ }
144
+
145
+ const rows = createMemo<Row[]>(() => {
146
+ const current = view()
147
+ return current.kind === "tweaks" ? tweakRows() : settingRows(current.tweak)
148
+ })
149
+
150
+ const visible = createMemo(() => filterRows(rows(), filter()))
151
+
152
+ const display = createMemo<DisplayRow[]>(() => {
153
+ const query = filter().trim()
154
+ const result: DisplayRow[] = []
155
+
156
+ for (const [category, group] of groupRows(visible())) {
157
+ if (!query && category) result.push({ kind: "category", title: category })
158
+ for (const row of group) result.push({ kind: "row", row })
159
+ }
160
+
161
+ return result
162
+ })
163
+
164
+ const ordered = createMemo(() => orderedRows(visible()))
165
+ const selected = createMemo(() => ordered().find((row) => row.id === selectedID()))
166
+ const title = createMemo(() => {
167
+ const current = view()
168
+ return current.kind === "tweaks" ? "Tweaks" : current.tweak.title
169
+ })
170
+ const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 6))
171
+
172
+ function move(delta: number) {
173
+ const next = moveRow(ordered(), selectedID(), delta)
174
+ if (next) setSelectedID(next.id)
175
+ }
176
+
177
+ function activate(row = selected()) {
178
+ if (row) void run(row.activate)
179
+ }
180
+
181
+ function cycle(direction: 1 | -1) {
182
+ const runCycle = selected()?.cycle
183
+ if (runCycle) void run(() => runCycle(direction))
184
+ }
185
+
186
+ function reset() {
187
+ const runReset = selected()?.reset
188
+ if (runReset) void run(runReset)
189
+ }
190
+
191
+ function updateFilter(value: string) {
192
+ setFilter(value)
193
+ setSelectedID(orderedRows(filterRows(rows(), value))[0]?.id)
194
+ }
195
+
196
+ // Keep a valid selection when the filter changes the list.
197
+ createEffect(() => {
198
+ const list = ordered()
199
+ if (list.length === 0) {
200
+ if (selectedID() !== undefined) setSelectedID(undefined)
201
+ return
202
+ }
203
+
204
+ if (!list.some((row) => row.id === selectedID())) setSelectedID(list[0]?.id)
205
+ })
206
+
207
+ // Keep the selected row inside the scroll viewport.
208
+ createEffect(() => {
209
+ const id = selectedID()
210
+ if (!id) return
211
+
212
+ const index = display().findIndex((item) => item.kind === "row" && item.row.id === id)
213
+ if (index < 0) return
214
+
215
+ if (pendingScroll) props.context.renderer.off(CliRenderEvents.FRAME, pendingScroll)
216
+ pendingScroll = () => {
217
+ pendingScroll = undefined
218
+ if (!scroll) return
219
+
220
+ const target = scroll.getChildren()[index]
221
+ if (!target) return
222
+
223
+ const y = target.y - scroll.y
224
+ if (y >= scroll.height) scroll.scrollBy(y - scroll.height + 1)
225
+ if (y < 0) scroll.scrollBy(y)
226
+ }
227
+ props.context.renderer.once(CliRenderEvents.FRAME, pendingScroll)
228
+ props.context.renderer.requestRender()
229
+ })
230
+
231
+ props.context.keymap.layer(() => ({
232
+ mode: "modal",
233
+ priority: 10,
234
+ commands: [
235
+ {
236
+ id: "opencode-tweaks.dialog.up",
237
+ bind: "up",
238
+ run: () => move(-1),
239
+ },
240
+ {
241
+ id: "opencode-tweaks.dialog.down",
242
+ bind: "down",
243
+ run: () => move(1),
244
+ },
245
+ {
246
+ id: "opencode-tweaks.dialog.enter",
247
+ bind: "return",
248
+ run: () => activate(),
249
+ },
250
+ {
251
+ id: "opencode-tweaks.dialog.left",
252
+ bind: "left",
253
+ enabled: () => view().kind === "tweak",
254
+ run: () => cycle(-1),
255
+ },
256
+ {
257
+ id: "opencode-tweaks.dialog.right",
258
+ bind: "right",
259
+ enabled: () => view().kind === "tweak",
260
+ run: () => cycle(1),
261
+ },
262
+ {
263
+ id: "opencode-tweaks.dialog.reset",
264
+ bind: "ctrl+r",
265
+ enabled: () => view().kind === "tweak",
266
+ run: () => reset(),
267
+ },
268
+ {
269
+ id: "opencode-tweaks.dialog.back",
270
+ bind: "escape",
271
+ run: () => goBack(),
272
+ },
273
+ ],
274
+ }))
275
+
276
+ return (
277
+ <box flexDirection="column" gap={1} paddingLeft={3} paddingRight={3} paddingBottom={1}>
278
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
279
+ <box flexDirection="row" justifyContent="space-between" gap={2}>
280
+ <text attributes={TextAttributes.BOLD} fg={theme().text.base}>
281
+ {title()}
282
+ </text>
283
+ <text fg={theme().text.muted} onMouseUp={goBack}>
284
+ esc
285
+ </text>
286
+ </box>
287
+ <box paddingTop={1}>
288
+ <input
289
+ flexGrow={1}
290
+ value={filter()}
291
+ onInput={updateFilter}
292
+ focusedBackgroundColor={theme().background.formfield.focused}
293
+ cursorColor={theme().text.formfield.focused}
294
+ focusedTextColor={theme().text.formfield.focused}
295
+ placeholder="Search"
296
+ placeholderColor={theme().text.muted}
297
+ ref={(renderable) => {
298
+ setTimeout(() => {
299
+ if (!renderable.isDestroyed) renderable.focus()
300
+ }, 1)
301
+ }}
302
+ />
303
+ </box>
304
+ </box>
305
+
306
+ <box marginLeft={-3} marginRight={-3}>
307
+ <scrollbox
308
+ paddingLeft={1}
309
+ paddingRight={1}
310
+ maxHeight={height()}
311
+ scrollbarOptions={{ visible: false }}
312
+ ref={(renderable) => {
313
+ scroll = renderable
314
+ }}
315
+ >
316
+ <Show
317
+ when={display().length > 0}
318
+ fallback={
319
+ <box paddingLeft={3}>
320
+ <text fg={theme().text.muted}>No results found</text>
321
+ </box>
322
+ }
323
+ >
324
+ <For each={display()}>
325
+ {(item, index) =>
326
+ item.kind === "category" ? (
327
+ <box paddingTop={index() === 0 ? 0 : 1} paddingLeft={3}>
328
+ <text fg={theme().hue.accent[200]} attributes={TextAttributes.BOLD}>
329
+ {item.title}
330
+ </text>
331
+ </box>
332
+ ) : (
333
+ <RowView
334
+ row={item.row}
335
+ active={item.row.id === selectedID()}
336
+ onHover={() => setSelectedID(item.row.id)}
337
+ onActivate={() => activate(item.row)}
338
+ />
339
+ )
340
+ }
341
+ </For>
342
+ </Show>
343
+ </scrollbox>
344
+ </box>
345
+
346
+ <box paddingLeft={1} paddingRight={1}>
347
+ <Footer editing={view().kind === "tweak"} />
348
+ </box>
349
+ </box>
350
+ )
351
+ }
352
+
353
+ function RowView(props: {
354
+ readonly row: Row
355
+ readonly active: boolean
356
+ readonly onHover: () => void
357
+ readonly onActivate: () => void
358
+ }) {
359
+ const theme = useDialogTheme()
360
+ const active = () => props.active
361
+ const value = () => {
362
+ const text = props.row.footer()
363
+ if (!props.row.cycleable) return text
364
+
365
+ // Keep the column width stable: arrows appear only on the selected row.
366
+ return active() ? `‹ ${text} ›` : ` ${text} `
367
+ }
368
+
369
+ return (
370
+ <box
371
+ flexDirection="row"
372
+ gap={1}
373
+ paddingLeft={active() ? 1 : 3}
374
+ paddingRight={3}
375
+ backgroundColor={active() ? theme().background.action.primary.focused : undefined}
376
+ onMouseDown={props.onHover}
377
+ onMouseMove={props.onHover}
378
+ onMouseUp={props.onActivate}
379
+ >
380
+ <Show when={active()}>
381
+ <text width={1} flexShrink={0} fg={theme().text.action.primary.focused}>
382
+ ●
383
+ </text>
384
+ </Show>
385
+ <text
386
+ flexGrow={1}
387
+ paddingLeft={3}
388
+ overflow="hidden"
389
+ wrapMode="none"
390
+ fg={active() ? theme().text.action.primary.focused : theme().text.base}
391
+ attributes={active() ? TextAttributes.BOLD : undefined}
392
+ >
393
+ {props.row.title}
394
+ </text>
395
+ <text flexShrink={0} fg={active() ? theme().text.action.primary.focused : theme().text.muted}>
396
+ {value()}
397
+ </text>
398
+ </box>
399
+ )
400
+ }
401
+
402
+ function Footer(props: { readonly editing: boolean }) {
403
+ return (
404
+ <box flexDirection="row" gap={2} flexShrink={0}>
405
+ <Hint title={props.editing ? "change" : "open"} key={props.editing ? "←/→" : "enter"} />
406
+ </box>
407
+ )
408
+ }
409
+
410
+ function Hint(props: { readonly title: string; readonly key: string }) {
411
+ const theme = useDialogTheme()
412
+
413
+ return (
414
+ <text>
415
+ {props.title}
416
+ <span style={{ fg: theme().text.muted }}>{` ${props.key}`}</span>
417
+ </text>
418
+ )
419
+ }
420
+
421
+ function useDialogTheme() {
422
+ const context = usePlugin()
423
+ return () => context.theme.surface("dialog")
424
+ }
@@ -0,0 +1,55 @@
1
+ export interface MenuRow {
2
+ readonly id: string
3
+ readonly title: string
4
+ readonly category: string
5
+ }
6
+
7
+ /** Keeps rows whose title or category matches the query. */
8
+ export function filterRows<Row extends MenuRow>(rows: readonly Row[], query: string): Row[] {
9
+ const needle = query.trim().toLowerCase()
10
+ if (!needle) return [...rows]
11
+
12
+ return rows.filter(
13
+ (row) =>
14
+ row.title.toLowerCase().includes(needle) || row.category.toLowerCase().includes(needle),
15
+ )
16
+ }
17
+
18
+ /** Groups rows by category while preserving the order they were defined in. */
19
+ export function groupRows<Row extends MenuRow>(rows: readonly Row[]): Array<[string, Row[]]> {
20
+ const groups = new Map<string, Row[]>()
21
+
22
+ for (const row of rows) {
23
+ const group = groups.get(row.category)
24
+ if (group) {
25
+ group.push(row)
26
+ } else {
27
+ groups.set(row.category, [row])
28
+ }
29
+ }
30
+
31
+ return [...groups.entries()]
32
+ }
33
+
34
+ /** Moves through a wrapping list, starting at the first or last row when the current id is gone. */
35
+ export function moveRow<Row extends MenuRow>(
36
+ rows: readonly Row[],
37
+ current: string | undefined,
38
+ delta: number,
39
+ ): Row | undefined {
40
+ if (rows.length === 0) return undefined
41
+
42
+ const index = rows.findIndex((row) => row.id === current)
43
+ if (index < 0) return delta > 0 ? rows[0] : rows[rows.length - 1]
44
+
45
+ return rows[(index + delta + rows.length) % rows.length]
46
+ }
47
+
48
+ /**
49
+ * The order the menu draws rows in: grouped by category, headers left out.
50
+ * Navigation must follow this order, not the order the settings were defined
51
+ * in, or the selection appears to skip rows.
52
+ */
53
+ export function orderedRows<Row extends MenuRow>(rows: readonly Row[]): Row[] {
54
+ return groupRows(rows).flatMap(([, group]) => group)
55
+ }
@@ -0,0 +1 @@
1
+ export { openTweaksMenu } from "./menu-dialog"