@mirrowel/opencode-openshare 0.0.0-stage → 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -2
  3. package/ci/README.md +56 -0
  4. package/ci/openshare.config.json +5 -0
  5. package/ci/openshare.js +2265 -0
  6. package/dist/commands.d.ts +7 -0
  7. package/dist/commands.js +440 -0
  8. package/dist/config.d.ts +134 -0
  9. package/dist/config.js +517 -0
  10. package/dist/core.d.ts +117 -0
  11. package/dist/core.js +1944 -0
  12. package/dist/dialogs.d.ts +94 -0
  13. package/dist/dialogs.js +725 -0
  14. package/dist/server.d.ts +64 -0
  15. package/dist/server.js +30 -0
  16. package/dist/settings.d.ts +24 -0
  17. package/dist/settings.js +1226 -0
  18. package/dist/share-flow.d.ts +287 -0
  19. package/dist/share-flow.js +516 -0
  20. package/dist/sidebar.d.ts +19 -0
  21. package/dist/sidebar.js +144 -0
  22. package/dist/tui-host.d.ts +158 -0
  23. package/dist/tui-host.js +6 -0
  24. package/dist/tui.d.ts +17 -0
  25. package/dist/tui.js +4762 -0
  26. package/dist/ui-prefs.d.ts +16 -0
  27. package/dist/ui-prefs.js +27 -0
  28. package/dist/v2-server.d.ts +81 -0
  29. package/dist/v2-server.js +402 -0
  30. package/dist/v2-tui.d.ts +24 -0
  31. package/dist/v2-tui.js +313 -0
  32. package/dist/v2-types.d.ts +134 -0
  33. package/dist/v2-types.js +8 -0
  34. package/openshare.config.json +16 -0
  35. package/package.json +98 -4
  36. package/src/banner.txt +22 -0
  37. package/src/commands.ts +452 -0
  38. package/src/config.ts +518 -0
  39. package/src/core.ts +1997 -0
  40. package/src/dialogs.tsx +971 -0
  41. package/src/server.ts +47 -0
  42. package/src/settings.ts +1239 -0
  43. package/src/share-flow.ts +551 -0
  44. package/src/sidebar.tsx +187 -0
  45. package/src/tui-host.ts +134 -0
  46. package/src/tui.tsx +318 -0
  47. package/src/ui-prefs.ts +36 -0
  48. package/src/v2-server.ts +438 -0
  49. package/src/v2-tui.ts +315 -0
  50. package/src/v2-types.ts +102 -0
  51. package/tsconfig.json +18 -0
@@ -0,0 +1,971 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ // OpenShare TUI dialogs — PORTED WHOLESALE from agent-variants' wizard
4
+ // MenuDialog (the family pattern, owner-verified working): one menu
5
+ // component driven by a temporary priority-10000 keymap layer + pushed
6
+ // mode + printable-key shield, settled through a single done() that
7
+ // clears the host dialog stack exactly once.
8
+ //
9
+ // Every choice UI (select / confirm / alert) is a MenuDialog instance —
10
+ // no bespoke focus handling, no host ui.DialogX components (their input
11
+ // is dead under plugin dialog.replace on opencode 1.19.x). The only
12
+ // custom component is PromptDialog (single-line text entry; needed for
13
+ // settings fields, no family precedent on 1.19.x) built on the same
14
+ // discipline.
15
+
16
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
17
+ import { useTerminalDimensions } from "@opentui/solid"
18
+ import type { JSX } from "@opentui/solid"
19
+ import type { ScrollBoxRenderable } from "@opentui/core"
20
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
21
+ import type { ShareDialogSelectOption } from "./tui-host.js"
22
+ import { dbgLog } from "./sidebar.js"
23
+ import { UI_WIDTH_KV, UI_HEIGHT_PERCENT_KV, HEIGHT_PERCENT_MIN, HEIGHT_PERCENT_MAX, clampHeightPercent, kvWidthOf, kvHeightPercentOf, type DialogWidth } from "./ui-prefs.js"
24
+
25
+ type KeyContext = { event?: { preventDefault?: () => void; stopPropagation?: () => void } }
26
+
27
+ const blockKey = (ctx: KeyContext | undefined) => {
28
+ ctx?.event?.preventDefault?.()
29
+ ctx?.event?.stopPropagation?.()
30
+ }
31
+
32
+ // verbatim from agent-variants (plus their punctuation coverage)
33
+ const SHIELDED_KEYS = [
34
+ ..."abcdefghijklmnopqrstuvwxyz".split(""),
35
+ ..."0123456789".split(""),
36
+ "space",
37
+ "tab",
38
+ "backspace",
39
+ "delete",
40
+ "home",
41
+ "end",
42
+ "left",
43
+ "right",
44
+ "/",
45
+ "?",
46
+ ":",
47
+ ";",
48
+ "'",
49
+ '"',
50
+ ",",
51
+ ".",
52
+ "-",
53
+ "=",
54
+ "[",
55
+ "]",
56
+ "\\",
57
+ "`",
58
+ ] as const
59
+
60
+ function shieldBindings(command: string, except: readonly string[] = []) {
61
+ const allowed = new Set(except)
62
+ return SHIELDED_KEYS.filter((key) => !allowed.has(key)).map((key) => ({ key, cmd: command, desc: "Keep input inside OpenShare" }))
63
+ }
64
+
65
+ function collectEditors(root: { getChildren?: () => unknown[] }): Array<{ showCursor: boolean; isDestroyed?: boolean; getChildren?: () => unknown[] }> {
66
+ const children = root.getChildren?.() ?? []
67
+ return [
68
+ ...((root as { showCursor?: boolean }).showCursor !== undefined ? [root as { showCursor: boolean; isDestroyed?: boolean; getChildren?: () => unknown[] }] : []),
69
+ ...children.flatMap((child) => collectEditors(child as { getChildren?: () => unknown[] })),
70
+ ]
71
+ }
72
+
73
+ /** verbatim from agent-variants: hide the prompt editor's cursor while a dialog is up */
74
+ function useHidePromptCursor(api: TuiPluginApi) {
75
+ const editors = collectEditors(api.renderer.root)
76
+ const previous = editors.map((editor) => ({ editor, showCursor: editor.showCursor }))
77
+ for (const editor of editors) {
78
+ editor.showCursor = false
79
+ }
80
+ onCleanup(() => {
81
+ for (const item of previous) {
82
+ if (item.editor.isDestroyed) continue
83
+ item.editor.showCursor = item.showCursor
84
+ }
85
+ })
86
+ }
87
+
88
+ // --- dialog sizing (agent-variants/config-studio machinery, kv-backed) ---------
89
+ // Keys/clamps live in ui-prefs.ts (host-agnostic — the settings dialog edits
90
+ // them too). Width maps to the host's fixed dialog widths; height is a
91
+ // percent of terminal rows clamped 25-100.
92
+
93
+ const HEIGHT_PRESETS: Array<{ label: string; value: number; key: string }> = [
94
+ { label: "compact", value: 35, key: "1" },
95
+ { label: "normal", value: 50, key: "2" },
96
+ { label: "tall", value: 70, key: "3" },
97
+ { label: "max", value: 100, key: "4" },
98
+ ]
99
+
100
+ export function dialogWidthOf(api: TuiPluginApi): DialogWidth {
101
+ return kvWidthOf(api.kv)
102
+ }
103
+
104
+ export function setDialogWidth(api: TuiPluginApi, width: DialogWidth) {
105
+ api.kv.set(UI_WIDTH_KV, width)
106
+ try {
107
+ api.ui.dialog.setSize(width)
108
+ } catch {}
109
+ }
110
+
111
+ export function dialogHeightPercentOf(api: TuiPluginApi): number {
112
+ return kvHeightPercentOf(api.kv)
113
+ }
114
+
115
+ export function setDialogHeightPercent(api: TuiPluginApi, value: number) {
116
+ api.kv.set(UI_HEIGHT_PERCENT_KV, clampHeightPercent(value))
117
+ }
118
+
119
+ /** agent-variants' wizardMaxRows superseded (parity check 2026-10-19):
120
+ * config-studio's computeDialogRows is the correct math — the requested
121
+ * percent is capped by a 75%-of-terminal BACKDROP BUDGET (the host dialog
122
+ * sits at terminalHeight/4 from the top, so a dialog taller than ~75%
123
+ * overflows the screen no matter what the percent says). */
124
+ function dialogMetrics(api: TuiPluginApi, terminalHeight: number, chromeRows: number, minRows: number) {
125
+ const percent = dialogHeightPercentOf(api)
126
+ const backdropBudget = Math.max(minRows + chromeRows, Math.floor(terminalHeight * 0.75) - 1)
127
+ const available = Math.max(minRows, backdropBudget - chromeRows)
128
+ const requested = Math.max(minRows, Math.floor((terminalHeight * percent) / 100) - chromeRows)
129
+ return { availableRows: available, targetRows: Math.min(requested, available) }
130
+ }
131
+
132
+ function maxRows(api: TuiPluginApi, terminalHeight: number, chromeRows: number, minRows: number) {
133
+ return dialogMetrics(api, terminalHeight, chromeRows, minRows).targetRows
134
+ }
135
+
136
+ /** usable text columns inside a dialog at the current width (frame padding
137
+ * accounted); the estimator and the pre-wrapper both use this so heights
138
+ * match what actually renders. */
139
+ export function dialogContentWidth(api: TuiPluginApi) {
140
+ const width = dialogWidthOf(api)
141
+ return (width === "xlarge" ? 116 : width === "large" ? 88 : 60) - 8
142
+ }
143
+
144
+ /** reactive (config-studio's form): width changes apply to the LIVE dialog
145
+ * (the size picker's `w` key resizes as you watch). No medium reset on
146
+ * cleanup — the reset fought other dialogs and the next dialog sets its
147
+ * own size anyway. */
148
+ function useDialogSize(api: TuiPluginApi) {
149
+ createEffect(() => {
150
+ try {
151
+ api.ui.dialog.setSize(dialogWidthOf(api))
152
+ } catch {}
153
+ })
154
+ }
155
+
156
+ function cappedHeight(count: number, max: number, min = 1) {
157
+ if (count <= 0) return min
158
+ return Math.max(min, Math.min(count, max))
159
+ }
160
+
161
+ /** rows a wrapped message occupies at a given content width */
162
+ function estimatedVisualRows(message: string, width: number) {
163
+ return message.split(/\r?\n/).reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / Math.max(1, width))), 0)
164
+ }
165
+
166
+ /** Deterministic character-level pre-wrap (parity lesson: opentui's
167
+ * word wrap cannot break unbreakable tokens like a JSON policy blob, so
168
+ * "wrapped" text still escaped the dialog border — live-caught). Both the
169
+ * [i] pane and InfoDialog render THESE lines; the row estimate uses the
170
+ * same output, so heights match what renders. */
171
+ export function preWrap(text: string, width: number): string[] {
172
+ const out: string[] = []
173
+ for (const raw of text.split(/\r?\n/)) {
174
+ if (raw.length === 0) {
175
+ out.push("")
176
+ continue
177
+ }
178
+ let line = ""
179
+ for (const word of raw.split(" ")) {
180
+ const candidate = line.length === 0 ? word : `${line} ${word}`
181
+ if (candidate.length <= width) {
182
+ line = candidate
183
+ continue
184
+ }
185
+ if (line.length > 0) out.push(line)
186
+ line = ""
187
+ // unbreakable token longer than a line: hard-split it
188
+ let token = word
189
+ while (token.length > width) {
190
+ out.push(token.slice(0, width))
191
+ token = token.slice(width)
192
+ }
193
+ line = token
194
+ }
195
+ if (line.length > 0) out.push(line)
196
+ }
197
+ return out
198
+ }
199
+
200
+ /** config-studio's contentLineAttrs, adapted: headings accent/bold,
201
+ * warnings (error/failed/refused/delete/danger...) red, positives
202
+ * (saved/enrolled/connected...) green, body plain. */
203
+ function infoLineAttrs(line: string): { fg: "heading" | "warning" | "positive" | "muted"; bold: boolean } {
204
+ if (line.length === 0) return { fg: "muted", bold: false }
205
+ const heading = !line.startsWith(" ") && (line.endsWith(":") || /^[A-Z][A-Za-z0-9 ._-]+$/.test(line))
206
+ if (/error|failed|refus|invalid|gone|danger|purge|delete/i.test(line)) return { fg: "warning", bold: false }
207
+ if (/saved|enrolled|connected|active|live|success|token saved/i.test(line)) return { fg: "positive", bold: false }
208
+ if (heading) return { fg: "heading", bold: true }
209
+ return { fg: "muted", bold: false }
210
+ }
211
+
212
+ function InfoLines(props: { api: TuiPluginApi; lines: string[] }): JSX.Element {
213
+ const theme = () => props.api.theme.current
214
+ const colorOf = (kind: ReturnType<typeof infoLineAttrs>["fg"]) =>
215
+ kind === "warning" ? theme().error : kind === "positive" ? theme().success : kind === "heading" ? theme().accent : theme().text
216
+ return (
217
+ <box flexDirection="column" gap={0}>
218
+ <For each={props.lines}>
219
+ {(line) => {
220
+ const attrs = infoLineAttrs(line)
221
+ return line.length === 0 ? (
222
+ <text> </text>
223
+ ) : (
224
+ <text fg={colorOf(attrs.fg)} wrapMode="none">{attrs.bold ? <b>{line}</b> : line}</text>
225
+ )
226
+ }}
227
+ </For>
228
+ </box>
229
+ )
230
+ }
231
+
232
+ /** title column cap (family menuTitleWidth, live-caught need: a server URL
233
+ * as a row title stretched the column toward half the dialog — the owner
234
+ * capped it further: full URLs belong in the [i] pane, not the row).
235
+ * Capped + ellipsized. */
236
+ function menuTitleWidth(width: DialogWidth, options: readonly { title: string }[]) {
237
+ const longest = Math.max(0, ...options.map((option) => option.title.length)) + 2
238
+ if (width === "xlarge") return Math.min(Math.max(20, longest), 28)
239
+ if (width === "large") return Math.min(Math.max(18, longest), 24)
240
+ return Math.min(Math.max(16, longest), 20)
241
+ }
242
+
243
+ function ellipsize(text: string, width: number) {
244
+ return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
245
+ }
246
+
247
+ /** OpenShare visual frame: rounded accent border + accent title row. The
248
+ * logic inside the menus stays the ported family pattern; this wrapper is
249
+ * purely presentation (owner-preferred styling). Natural width — the host
250
+ * dialog panel provides the container; the border hugs the content. */
251
+ // Frame note (live-caught): the HOST dialog panel renders plugin content
252
+ // with paddingTop=1 (opencode dialog.tsx), so a BORDERED frame always
253
+ // showed a dead panel strip above its top border. Borderless family
254
+ // shape + a solid accent rule under the title: zero dead space, accent
255
+ // identity kept.
256
+ function DialogFrame(props: { api: TuiPluginApi; title: string; children: JSX.Element }): JSX.Element {
257
+ const theme = () => props.api.theme.current
258
+ return (
259
+ <box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2}>
260
+ <box width="100%" marginBottom={1}>
261
+ <text fg={theme().accent}><b>{props.title}</b></text>
262
+ </box>
263
+ <box width="100%" height={1} backgroundColor={theme().accent} marginBottom={1} />
264
+ {props.children}
265
+ </box>
266
+ )
267
+ }
268
+
269
+ /** Stylized key legend row: key glyphs in accent (boxed feel via inverse
270
+ * panel background), verbs muted, separated by dim dots. The esc token is
271
+ * clickable. */
272
+ function KeyLegend(props: { api: TuiPluginApi; keys: Array<[string, string]>; onBack?: () => void }): JSX.Element {
273
+ const theme = () => props.api.theme.current
274
+ return (
275
+ <box flexDirection="row" gap={1} paddingTop={1} paddingLeft={1} alignItems="center">
276
+ <For each={props.keys}>
277
+ {(pair, index) => (
278
+ <>
279
+ <box backgroundColor={theme().backgroundPanel} paddingLeft={1} paddingRight={1} onMouseUp={() => { if (pair[0] === "esc") props.onBack?.() }}>
280
+ <text fg={theme().accent}><b>{pair[0]}</b></text>
281
+ </box>
282
+ <text fg={theme().textMuted}>{pair[1]}</text>
283
+ <Show when={index() < props.keys.length - 1}>
284
+ <text fg={theme().textMuted}>·</text>
285
+ </Show>
286
+ </>
287
+ )}
288
+ </For>
289
+ </box>
290
+ )
291
+ }
292
+
293
+ // --- the menu (ported from agent-variants' MenuDialog) -----------------------
294
+
295
+ export type MenuOption<Value = string> = {
296
+ title: string
297
+ description?: string
298
+ value: Value
299
+ disabled?: boolean
300
+ /** long-form detail for the [i] pane: what it is, current value + source,
301
+ * why it matters, full data the row truncates (URLs, ids, statuses).
302
+ * Lines starting with "## " become section headers (paged [i] pane). */
303
+ help?: string
304
+ /** destructive action — title renders red (config-studio port) */
305
+ danger?: boolean
306
+ /** pending/edited state — title renders green (config-studio port) */
307
+ edited?: boolean
308
+ /** non-interactive section separator row (never focusable) */
309
+ divider?: boolean
310
+ }
311
+
312
+ export function MenuDialog<Value>(props: {
313
+ api: TuiPluginApi
314
+ title: string
315
+ options: MenuOption<Value>[]
316
+ current?: Value
317
+ hint?: string
318
+ onDone: (value: Value | undefined) => void
319
+ }): JSX.Element {
320
+ const theme = () => props.api.theme.current
321
+ useDialogSize(props.api)
322
+ useHidePromptCursor(props.api)
323
+ const dimensions = useTerminalDimensions()
324
+ const listHeight = createMemo(() => cappedHeight(props.options.length, maxRows(props.api, dimensions().height, 12, 4), 4))
325
+ // fixed title column shared by every row (family menuTitleWidth): flex
326
+ // columns stagger per-row because flex splits by content — a computed
327
+ // width is what makes the value/description column align (live-caught).
328
+ const width = dialogWidthOf(props.api)
329
+ const titleWidth = createMemo(() => menuTitleWidth(width, props.options))
330
+ const hasHelp = createMemo(() => props.options.some((option) => option.help))
331
+ const hintLines = createMemo(() => (props.hint ? preWrap(props.hint, dialogContentWidth(props.api)) : []))
332
+ const [inspecting, setInspecting] = createSignal(false)
333
+ let listScroll: ScrollBoxRenderable | undefined
334
+ let infoScroll: ScrollBoxRenderable | undefined
335
+ const popMode = props.api.mode.push("openshare.dialog")
336
+ const [selected, setSelected] = createSignal(
337
+ (() => {
338
+ const initial = Math.max(0, props.options.findIndex((option) => option.value === props.current))
339
+ let index = initial
340
+ while (index < props.options.length && props.options[index]?.divider) index++
341
+ return index < props.options.length ? index : initial
342
+ })(),
343
+ )
344
+ const current = createMemo(() => props.options[selected()] ?? props.options[0])
345
+ const move = (delta: number) =>
346
+ setSelected((value) => {
347
+ let next = Math.max(0, Math.min(props.options.length - 1, value + delta))
348
+ while (next >= 0 && next < props.options.length && props.options[next]?.divider) next += delta >= 0 ? 1 : -1
349
+ if (next < 0 || next >= props.options.length || props.options[next]?.divider) return value
350
+ listScroll?.scrollTo(Math.max(0, next - 2))
351
+ return next
352
+ })
353
+ // [i] pane sections: "## Header" lines in help split sections; the pane
354
+ // height comes from the CONTENT (pre-wrapped rows), capped by the same
355
+ // dialog metrics — no longer bound to the spawning menu's list height
356
+ // (live-caught: a 3-row menu gave a 3-row info pane).
357
+ const helpSections = createMemo(() => {
358
+ const raw = current()?.help ?? ""
359
+ const sections: Array<{ title: string; lines: string[] }> = [{ title: "", lines: [] }]
360
+ for (const line of raw.split(/\r?\n/)) {
361
+ const match = /^##\s+(.*)$/.exec(line)
362
+ if (match) sections.push({ title: match[1], lines: [] })
363
+ else sections.at(-1)?.lines.push(line)
364
+ }
365
+ // trim trailing empties of the first (untitled) block; drop empty blocks
366
+ for (const section of sections) {
367
+ while (section.lines.length && section.lines.at(-1) === "") section.lines.pop()
368
+ while (section.lines.length && section.lines[0] === "") section.lines.shift()
369
+ }
370
+ const contentWidth = dialogContentWidth(props.api) - 2
371
+ return sections
372
+ .filter((section, index) => (index === 0 ? section.lines.length > 0 : true))
373
+ .map((section) => ({ title: section.title, lines: preWrap(section.lines.join("\n"), contentWidth) }))
374
+ })
375
+ const infoHeight = createMemo(() =>
376
+ cappedHeight(
377
+ helpSections().reduce((rows, section) => rows + section.lines.length + (section.title ? 2 : 0), 0) + 1,
378
+ maxRows(props.api, dimensions().height, 8, 4),
379
+ 4,
380
+ ),
381
+ )
382
+ const [section, setSection] = createSignal(0)
383
+ const jumpSection = (delta: number) => {
384
+ const count = helpSections().length
385
+ if (count <= 1) return
386
+ const next = (section() + delta + count) % count
387
+ setSection(next)
388
+ infoScroll?.scrollTo(0)
389
+ }
390
+ const vertical = (delta: number) => {
391
+ if (inspecting()) infoScroll?.scrollBy(delta)
392
+ else move(delta)
393
+ }
394
+ const page = (delta: number) => {
395
+ if (inspecting()) infoScroll?.scrollBy(delta * Math.max(1, (infoScroll?.height ?? infoHeight()) - 1))
396
+ }
397
+ const choose = () => {
398
+ const option = current()
399
+ if (!option || option.disabled || option.divider) return
400
+ props.onDone(option.value)
401
+ }
402
+ const inspect = () => {
403
+ const option = current()
404
+ if (!option || !option.help) return
405
+ setInspecting(true)
406
+ setSection(0)
407
+ infoScroll?.scrollTo(0)
408
+ }
409
+ const commandPrefix = `openshare.menu.${Math.random().toString(36).slice(2)}`
410
+ const sectionJumpBindings = () =>
411
+ helpSections().length > 1
412
+ ? [
413
+ { key: "p", cmd: `${commandPrefix}.prevSection`, desc: "Previous section" },
414
+ { key: "n", cmd: `${commandPrefix}.nextSection`, desc: "Next section" },
415
+ ...helpSections()
416
+ .slice(0, 9)
417
+ .map((_, index) => ({ key: String(index + 1), cmd: `${commandPrefix}.jump${index + 1}`, desc: `Section ${index + 1}` })),
418
+ ]
419
+ : []
420
+ const unregister = props.api.keymap.registerLayer({
421
+ priority: 10000,
422
+ commands: [
423
+ { name: `${commandPrefix}.up`, title: "Previous item", run: (ctx: KeyContext) => { blockKey(ctx); vertical(-1) } },
424
+ { name: `${commandPrefix}.down`, title: "Next item", run: (ctx: KeyContext) => { blockKey(ctx); vertical(1) } },
425
+ { name: `${commandPrefix}.pageUp`, title: "Page up", run: (ctx: KeyContext) => { blockKey(ctx); page(-1) } },
426
+ { name: `${commandPrefix}.pageDown`, title: "Page down", run: (ctx: KeyContext) => { blockKey(ctx); page(1) } },
427
+ { name: `${commandPrefix}.home`, title: "Top", run: (ctx: KeyContext) => { blockKey(ctx); if (inspecting()) infoScroll?.scrollTo(0) } },
428
+ { name: `${commandPrefix}.end`, title: "Bottom", run: (ctx: KeyContext) => { blockKey(ctx); if (inspecting()) infoScroll?.scrollTo(infoScroll.scrollHeight) } },
429
+ { name: `${commandPrefix}.select`, title: "Select item", run: (ctx: KeyContext) => { blockKey(ctx); if (inspecting()) setInspecting(false); else choose() } },
430
+ { name: `${commandPrefix}.inspect`, title: "Option info", run: (ctx: KeyContext) => { blockKey(ctx); if (!inspecting()) inspect() } },
431
+ { name: `${commandPrefix}.back`, title: "Back", run: (ctx: KeyContext) => { blockKey(ctx); if (inspecting()) setInspecting(false); else props.onDone(undefined) } },
432
+ { name: `${commandPrefix}.prevSection`, title: "Previous section", run: (ctx: KeyContext) => { blockKey(ctx); jumpSection(-1) } },
433
+ { name: `${commandPrefix}.nextSection`, title: "Next section", run: (ctx: KeyContext) => { blockKey(ctx); jumpSection(1) } },
434
+ ...helpSections()
435
+ .slice(0, 9)
436
+ .map((_, index) => ({
437
+ name: `${commandPrefix}.jump${index + 1}`,
438
+ title: `Section ${index + 1}`,
439
+ run: (ctx: KeyContext) => {
440
+ blockKey(ctx)
441
+ setSection(index)
442
+ infoScroll?.scrollTo(0)
443
+ },
444
+ })),
445
+ { name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
446
+ ],
447
+ bindings: [
448
+ { key: "up", cmd: `${commandPrefix}.up`, desc: "Previous item" },
449
+ { key: "ctrl+p", cmd: `${commandPrefix}.up`, desc: "Previous item" },
450
+ { key: "down", cmd: `${commandPrefix}.down`, desc: "Next item" },
451
+ { key: "ctrl+n", cmd: `${commandPrefix}.down`, desc: "Next item" },
452
+ { key: "pageup", cmd: `${commandPrefix}.pageUp`, desc: "Page up" },
453
+ { key: "ctrl+b", cmd: `${commandPrefix}.pageUp`, desc: "Page up" },
454
+ { key: "pagedown", cmd: `${commandPrefix}.pageDown`, desc: "Page down" },
455
+ { key: "ctrl+f", cmd: `${commandPrefix}.pageDown`, desc: "Page down" },
456
+ { key: "home", cmd: `${commandPrefix}.home`, desc: "Top" },
457
+ { key: "end", cmd: `${commandPrefix}.end`, desc: "Bottom" },
458
+ { key: "enter", cmd: `${commandPrefix}.select`, desc: "Select item" },
459
+ { key: "i", cmd: `${commandPrefix}.inspect`, desc: "Option info" },
460
+ { key: "escape", cmd: `${commandPrefix}.back`, desc: "Back" },
461
+ ...sectionJumpBindings(),
462
+ ...shieldBindings(`${commandPrefix}.shield`, ["i", ...sectionJumpBindings().map((binding) => binding.key)]),
463
+ ],
464
+ })
465
+ onCleanup(() => {
466
+ unregister()
467
+ popMode()
468
+ })
469
+
470
+ return (
471
+ <DialogFrame api={props.api} title={props.title}>
472
+ <Show when={hintLines().length > 0}>
473
+ <box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
474
+ <InfoLines api={props.api} lines={hintLines()} />
475
+ </box>
476
+ </Show>
477
+ <Show
478
+ when={inspecting() && current()?.help}
479
+ fallback={
480
+ <scrollbox maxHeight={listHeight()} ref={(element: ScrollBoxRenderable) => (listScroll = element)}>
481
+ <box flexDirection="column" gap={0}>
482
+ <For each={props.options}>
483
+ {(option, index) => {
484
+ const active = createMemo(() => selected() === index())
485
+ const fg = createMemo(() =>
486
+ active()
487
+ ? theme().background
488
+ : option.divider
489
+ ? theme().textMuted
490
+ : option.disabled
491
+ ? theme().textMuted
492
+ : option.danger
493
+ ? theme().error
494
+ : option.edited
495
+ ? theme().success
496
+ : theme().text,
497
+ )
498
+ const descFg = createMemo(() => (active() ? theme().background : theme().textMuted))
499
+ if (option.divider) {
500
+ return (
501
+ <box paddingLeft={1} paddingRight={1} marginTop={1} marginBottom={1}>
502
+ <text fg={theme().textMuted}>{"─".repeat(Math.max(8, dialogContentWidth(props.api) - 4))}</text>
503
+ </box>
504
+ )
505
+ }
506
+ return (
507
+ <box
508
+ flexDirection="row"
509
+ width="100%"
510
+ gap={1}
511
+ paddingLeft={1}
512
+ paddingRight={1}
513
+ backgroundColor={active() ? theme().primary : theme().backgroundPanel}
514
+ onMouseOver={() => {
515
+ if (!option.divider) setSelected(index())
516
+ }}
517
+ onMouseUp={() => {
518
+ if (!option.disabled && !option.divider) props.onDone(option.value)
519
+ }}
520
+ >
521
+ <text width={2} flexShrink={0} fg={fg()}>{active() ? "❯" : " "}</text>
522
+ <text width={titleWidth()} flexShrink={0} fg={fg()} wrapMode="none" overflow="hidden"><b>{ellipsize(option.title, titleWidth())}</b></text>
523
+ <text flexGrow={1} fg={descFg()} wrapMode="none" overflow="hidden">{option.help ? "ⓘ " : ""}{option.description ?? ""}</text>
524
+ </box>
525
+ )
526
+ }}
527
+ </For>
528
+ </box>
529
+ </scrollbox>
530
+ }
531
+ >
532
+ <box flexDirection="column">
533
+ <box marginBottom={1} paddingLeft={1}>
534
+ <text fg={theme().text} wrapMode="none" overflow="hidden"><b>{current()?.title ?? ""}</b></text>
535
+ </box>
536
+ <scrollbox maxHeight={infoHeight()} ref={(element: ScrollBoxRenderable) => (infoScroll = element)}>
537
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
538
+ <For each={helpSections()}>
539
+ {(sectionData, index) => (
540
+ <>
541
+ <Show when={sectionData.title}>
542
+ <box marginTop={index() > 0 ? 1 : 0} marginBottom={1}>
543
+ <text fg={theme().accent}><b>{`── ${sectionData.title} `}{"─".repeat(Math.max(0, Math.min(40, 40 - sectionData.title.length)))}</b></text>
544
+ </box>
545
+ </Show>
546
+ <InfoLines api={props.api} lines={sectionData.lines} />
547
+ </>
548
+ )}
549
+ </For>
550
+ </box>
551
+ </scrollbox>
552
+ <Show when={helpSections().length > 1}>
553
+ <box paddingLeft={1} marginTop={1}>
554
+ <text fg={theme().textMuted}>{`section ${section() + 1}/${helpSections().length} · p/n move · 1-${Math.min(9, helpSections().length)} jump`}</text>
555
+ </box>
556
+ </Show>
557
+ </box>
558
+ </Show>
559
+ <KeyLegend
560
+ api={props.api}
561
+ keys={
562
+ inspecting()
563
+ ? helpSections().length > 1
564
+ ? [["↑↓", "scroll"], ["p/n", "section"], ["⏎", "back"], ["esc", "back"]]
565
+ : [["↑↓", "scroll"], ["⏎", "back"], ["esc", "back"]]
566
+ : hasHelp()
567
+ ? [["↑↓", "move"], ["⏎", "select"], ["i", "info"], ["esc", "back"]]
568
+ : [["↑↓", "move"], ["⏎", "select"], ["esc", "back"]]
569
+ }
570
+ onBack={() => {
571
+ if (inspecting()) setInspecting(false)
572
+ else props.onDone(undefined)
573
+ }}
574
+ />
575
+ </DialogFrame>
576
+ )
577
+ }
578
+
579
+ /** showMenuOnce, family discipline with the flicker fix (2026-10-19):
580
+ * done() does NOT clear the host stack — the next dialog's replace() is an
581
+ * atomic swap, so clearing first paints one empty frame (the esc-to-parent
582
+ * flicker, live-caught). Chain ends clear explicitly (command exits,
583
+ * settings exit). */
584
+ export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; options: MenuOption<Value>[]; current?: Value; hint?: string }): Promise<Value | undefined> {
585
+ return new Promise((resolve) => {
586
+ let settled = false
587
+ const done = (value: Value | undefined, _clear = false) => {
588
+ if (settled) return
589
+ settled = true
590
+ dbgLog(`menu(${props.title}) -> ${value === undefined ? "cancel" : String(value)}`)
591
+ resolve(value)
592
+ }
593
+ dbgLog(`menu(${props.title}) OPEN (${props.options.length} options)`)
594
+ api.ui.dialog.replace(
595
+ () => <MenuDialog api={api} title={props.title} options={props.options} current={props.current} hint={props.hint} onDone={done} />,
596
+ () => done(undefined),
597
+ )
598
+ })
599
+ }
600
+
601
+ // --- InfoDialog (config-studio port): a real message body ----------------------
602
+ // Content-derived height, pre-wrapped (char-level — word wrap cannot break
603
+ // JSON blobs), semantically styled lines, page/home/end keys, and an action
604
+ // footer with buttons + key shortcuts. Replaces the hint-as-message menus
605
+ // for confirm/alert (whose single overflow-hidden line clipped destructive
606
+ // prompts mid-URL — live-caught).
607
+
608
+ export type DialogAction = { title: string; value: string; key?: string; danger?: boolean }
609
+
610
+ export function showInfoDialog(api: TuiPluginApi, props: { title: string; message: string; actions?: DialogAction[] }): Promise<string | undefined> {
611
+ return new Promise((resolve) => {
612
+ let settled = false
613
+ const done = (value: string | undefined) => {
614
+ if (settled) return
615
+ settled = true
616
+ resolve(value)
617
+ }
618
+ api.ui.dialog.replace(
619
+ () => <InfoDialog api={api} title={props.title} message={props.message} actions={props.actions} onDone={done} />,
620
+ () => done(undefined),
621
+ )
622
+ })
623
+ }
624
+
625
+ function InfoDialog(props: { api: TuiPluginApi; title: string; message: string; actions?: DialogAction[]; onDone: (value: string | undefined) => void }): JSX.Element {
626
+ const theme = () => props.api.theme.current
627
+ useDialogSize(props.api)
628
+ useHidePromptCursor(props.api)
629
+ const dimensions = useTerminalDimensions()
630
+ const lines = createMemo(() => preWrap(props.message, dialogContentWidth(props.api)))
631
+ const bodyHeight = createMemo(() => cappedHeight(lines().length + 1, maxRows(props.api, dimensions().height, 8, 4), 4))
632
+ let scroll: ScrollBoxRenderable | undefined
633
+ const page = () => Math.max(1, (scroll?.height ?? bodyHeight()) - 1)
634
+ const popMode = props.api.mode.push("openshare.dialog")
635
+ const commandPrefix = `openshare.info.${Math.random().toString(36).slice(2)}`
636
+ const okValue = "__ok"
637
+ const actions = createMemo(() => {
638
+ const list = props.actions?.length ? props.actions : [{ title: "ok", value: okValue, danger: false }]
639
+ return list.map((action, index) => ({ ...action, key: action.key ?? (index === 0 ? "enter" : undefined) }))
640
+ })
641
+ const unregister = props.api.keymap.registerLayer({
642
+ priority: 10000,
643
+ commands: [
644
+ { name: `${commandPrefix}.close`, title: "Close", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone(actions().find((a) => a.key === "enter")?.value ?? okValue) } },
645
+ { name: `${commandPrefix}.back`, title: "Back", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone(undefined) } },
646
+ { name: `${commandPrefix}.up`, title: "Scroll up", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollBy(-1) } },
647
+ { name: `${commandPrefix}.down`, title: "Scroll down", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollBy(1) } },
648
+ { name: `${commandPrefix}.pageUp`, title: "Page up", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollBy(-page()) } },
649
+ { name: `${commandPrefix}.pageDown`, title: "Page down", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollBy(page()) } },
650
+ { name: `${commandPrefix}.home`, title: "Scroll top", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollTo(0) } },
651
+ { name: `${commandPrefix}.end`, title: "Scroll bottom", run: (ctx: KeyContext) => { blockKey(ctx); scroll?.scrollTo(scroll.scrollHeight) } },
652
+ { name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
653
+ ...actions()
654
+ .filter((action) => action.key && action.key !== "enter")
655
+ .map((action) => ({
656
+ name: `${commandPrefix}.act_${action.value}`,
657
+ title: action.title,
658
+ run: (ctx: KeyContext) => {
659
+ blockKey(ctx)
660
+ props.onDone(action.value)
661
+ },
662
+ })),
663
+ ],
664
+ bindings: [
665
+ { key: "enter", cmd: `${commandPrefix}.close`, desc: "Close" },
666
+ { key: "escape", cmd: `${commandPrefix}.back`, desc: "Back" },
667
+ { key: "up", cmd: `${commandPrefix}.up`, desc: "Scroll up" },
668
+ { key: "ctrl+p", cmd: `${commandPrefix}.up`, desc: "Scroll up" },
669
+ { key: "down", cmd: `${commandPrefix}.down`, desc: "Scroll down" },
670
+ { key: "ctrl+n", cmd: `${commandPrefix}.down`, desc: "Scroll down" },
671
+ { key: "pageup", cmd: `${commandPrefix}.pageUp`, desc: "Page up" },
672
+ { key: "ctrl+b", cmd: `${commandPrefix}.pageUp`, desc: "Page up" },
673
+ { key: "pagedown", cmd: `${commandPrefix}.pageDown`, desc: "Page down" },
674
+ { key: "ctrl+f", cmd: `${commandPrefix}.pageDown`, desc: "Page down" },
675
+ { key: "home", cmd: `${commandPrefix}.home`, desc: "Scroll top" },
676
+ { key: "end", cmd: `${commandPrefix}.end`, desc: "Scroll bottom" },
677
+ ...actions()
678
+ .filter((action) => action.key && action.key !== "enter")
679
+ .map((action) => ({ key: action.key as string, cmd: `${commandPrefix}.act_${action.value}`, desc: action.title })),
680
+ ...shieldBindings(`${commandPrefix}.shield`, ["home", "end", ...actions().filter((a) => a.key && a.key !== "enter").map((a) => a.key as string)]),
681
+ ],
682
+ })
683
+ onCleanup(() => {
684
+ unregister()
685
+ popMode()
686
+ })
687
+
688
+ return (
689
+ <DialogFrame api={props.api} title={props.title}>
690
+ <scrollbox maxHeight={bodyHeight()} ref={(element: ScrollBoxRenderable) => (scroll = element)}>
691
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
692
+ <InfoLines api={props.api} lines={lines()} />
693
+ </box>
694
+ </scrollbox>
695
+ <box flexDirection="row" justifyContent="space-between" width="100%" paddingTop={1}>
696
+ <box flexDirection="row" gap={1}>
697
+ <For each={actions().filter((action) => action.key && action.key !== "enter")}>
698
+ {(action) => (
699
+ <text fg={action.danger ? theme().error : theme().textMuted}>{`${action.key}: ${action.title}`}</text>
700
+ )}
701
+ </For>
702
+ </box>
703
+ <box flexDirection="row" gap={1}>
704
+ <For each={actions()}>
705
+ {(action) => (
706
+ <box paddingLeft={2} paddingRight={2} backgroundColor={action.danger ? theme().error : theme().primary} onMouseUp={() => props.onDone(action.value)}>
707
+ <text fg={theme().background}><b>{action.title}</b></text>
708
+ </box>
709
+ )}
710
+ </For>
711
+ </box>
712
+ </box>
713
+ </DialogFrame>
714
+ )
715
+ }
716
+
717
+ // --- confirm / alert on the InfoDialog body -------------------------------------
718
+
719
+ export function showConfirmMenu(api: TuiPluginApi, props: { title: string; message: string; confirmLabel?: string; danger?: boolean }): Promise<boolean> {
720
+ return showInfoDialog(api, {
721
+ title: props.title,
722
+ message: props.message,
723
+ actions: [
724
+ { title: props.confirmLabel ?? "Confirm", value: "yes", danger: props.danger },
725
+ { title: "Cancel", value: "no" },
726
+ ],
727
+ }).then((value) => value === "yes")
728
+ }
729
+
730
+ export function showAlertMenu(api: TuiPluginApi, props: { title: string; message: string }): Promise<void> {
731
+ return showInfoDialog(api, { title: props.title, message: props.message }).then(() => undefined)
732
+ }
733
+
734
+ // --- prompt (text entry — the one custom piece) -------------------------------
735
+
736
+ // Full printable ASCII (parity fix, live-caught class: the lowercase-only
737
+ // set made uppercase CREATE_TOKENs and codes UNTYPEABLE). Space is bound
738
+ // separately below; escape is the back binding, so it's excluded.
739
+ const PROMPT_CHARS = [
740
+ ..."abcdefghijklmnopqrstuvwxyz",
741
+ ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ",
742
+ ..."0123456789",
743
+ ..."!@#$%^&*()-_=+[]{};:'\",.<>/?\\|`~",
744
+ ..."://.-_?&=%~+#@",
745
+ ]
746
+ .join("")
747
+ .split("")
748
+ .filter((ch, index, all) => ch !== " " && all.indexOf(ch) === index)
749
+ .join("")
750
+
751
+ export function PromptDialog(props: {
752
+ api: TuiPluginApi
753
+ title: string
754
+ placeholder?: string
755
+ value?: string
756
+ onDone: (value: string | undefined) => void
757
+ }): JSX.Element {
758
+ const theme = () => props.api.theme.current
759
+ useDialogSize(props.api)
760
+ useHidePromptCursor(props.api)
761
+ const [text, setText] = createSignal(props.value ?? "")
762
+ const popMode = props.api.mode.push("openshare.dialog")
763
+ const commandPrefix = `openshare.prompt.${Math.random().toString(36).slice(2)}`
764
+ const commands: Array<{ name: string; title: string; run: (ctx: KeyContext) => void }> = [
765
+ { name: `${commandPrefix}.ok`, title: "Confirm", run: (ctx) => { blockKey(ctx); props.onDone(text()) } },
766
+ { name: `${commandPrefix}.back`, title: "Cancel", run: (ctx) => { blockKey(ctx); props.onDone(undefined) } },
767
+ { name: `${commandPrefix}.del`, title: "Backspace", run: (ctx) => { blockKey(ctx); setText((v) => v.slice(0, -1)) } },
768
+ { name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
769
+ ]
770
+ const bindings: Array<{ key: string; cmd: string; desc?: string }> = [
771
+ { key: "enter", cmd: `${commandPrefix}.ok`, desc: "Confirm" },
772
+ { key: "escape", cmd: `${commandPrefix}.back`, desc: "Cancel" },
773
+ { key: "backspace", cmd: `${commandPrefix}.del`, desc: "Backspace" },
774
+ { key: "space", cmd: `${commandPrefix}.type_space`, desc: "Space" },
775
+ ]
776
+ commands.push({ name: `${commandPrefix}.type_space`, title: "Type space", run: (ctx) => { blockKey(ctx); setText((v) => v + " ") } })
777
+ for (const ch of PROMPT_CHARS) {
778
+ if (ch === " ") continue
779
+ const name = `type_${ch.codePointAt(0)}`
780
+ commands.push({ name: `${commandPrefix}.${name}`, title: `Type ${ch}`, run: (ctx) => { blockKey(ctx); setText((v) => v + ch) } })
781
+ bindings.push({ key: ch, cmd: `${commandPrefix}.${name}` })
782
+ }
783
+ const unregister = props.api.keymap.registerLayer({ priority: 10000, commands, bindings })
784
+ onCleanup(() => {
785
+ unregister()
786
+ popMode()
787
+ })
788
+ return (
789
+ <DialogFrame api={props.api} title={props.title}>
790
+ <box backgroundColor={theme().backgroundPanel} paddingLeft={1} paddingRight={1} marginBottom={1}>
791
+ <text fg={text() ? theme().text : theme().textMuted}>{text() || props.placeholder || ""}</text>
792
+ <text fg={theme().accent}>{"▌"}</text>
793
+ </box>
794
+ <KeyLegend api={props.api} keys={[["a-z0-9", "type"], ["⌫", "delete"], ["⏎", "confirm"], ["esc", "cancel"]]} onBack={() => props.onDone(undefined)} />
795
+ </DialogFrame>
796
+ )
797
+ }
798
+
799
+ export function showPromptOnce(api: TuiPluginApi, props: { title: string; placeholder?: string; value?: string }): Promise<string | undefined> {
800
+ return new Promise((resolve) => {
801
+ let settled = false
802
+ const done = (value: string | undefined, _clear = false) => {
803
+ if (settled) return
804
+ settled = true
805
+ dbgLog(`prompt(${props.title}) -> ${value === undefined ? "cancel" : `len=${value.length}`}`)
806
+ resolve(value)
807
+ }
808
+ dbgLog(`prompt(${props.title}) OPEN`)
809
+ api.ui.dialog.replace(
810
+ () => <PromptDialog api={api} title={props.title} placeholder={props.placeholder} value={props.value} onDone={done} />,
811
+ () => done(undefined),
812
+ )
813
+ })
814
+ }
815
+
816
+ /** host-facade adapter: ShareDialogSelectOption (flows) -> MenuOption */
817
+ export function toMenuOptions<Value>(options: ShareDialogSelectOption<Value>[]): MenuOption<Value>[] {
818
+ return options.map((o) => ({ title: o.title, description: o.description, value: o.value, disabled: o.disabled, help: o.help, danger: o.danger, edited: o.edited, divider: o.divider }))
819
+ }
820
+
821
+ // --- combined size picker (config-studio SizeSliderDialog port) ----------------
822
+ // One dialog for BOTH dimensions: `w` cycles the width LIVE (reactive
823
+ // setSize resizes this very dialog), the slider sets height percent,
824
+ // presets 1-4, `c` custom, and a live mini preview shows the real capped
825
+ // height the menus will get — not the raw percent math.
826
+
827
+ export type SizePickerResult = { width: DialogWidth; heightPercent: number }
828
+
829
+ export function showSizePickerOnce(api: TuiPluginApi, current: SizePickerResult): Promise<SizePickerResult | undefined> {
830
+ return new Promise((resolve) => {
831
+ let settled = false
832
+ const done = (value: SizePickerResult | undefined) => {
833
+ if (settled) return
834
+ settled = true
835
+ resolve(value)
836
+ }
837
+ api.ui.dialog.replace(
838
+ () => <SizePickerDialog api={api} current={current} onDone={done} />,
839
+ () => done(undefined),
840
+ )
841
+ })
842
+ }
843
+
844
+ function SizePickerDialog(props: { api: TuiPluginApi; current: SizePickerResult; onDone: (value: SizePickerResult | undefined) => void }): JSX.Element {
845
+ const theme = () => props.api.theme.current
846
+ useDialogSize(props.api)
847
+ useHidePromptCursor(props.api)
848
+ const dimensions = useTerminalDimensions()
849
+ const [height, setHeight] = createSignal(clampHeightPercent(props.current.heightPercent))
850
+ const popMode = props.api.mode.push("openshare.dialog")
851
+ const commandPrefix = `openshare.size.${Math.random().toString(36).slice(2)}`
852
+ const cycleWidth = () => {
853
+ const next = kvWidthOf(props.api.kv) === "medium" ? "large" : kvWidthOf(props.api.kv) === "large" ? "xlarge" : "medium"
854
+ props.api.kv.set(UI_WIDTH_KV, next)
855
+ }
856
+ const setPreset = (label: string) => {
857
+ const preset = HEIGHT_PRESETS.find((p) => p.label === label)
858
+ if (preset) setHeight(preset.value)
859
+ }
860
+ const move = (delta: number) => setHeight((current) => clampHeightPercent(current + delta))
861
+ const save = () => {
862
+ props.api.kv.set(UI_HEIGHT_PERCENT_KV, height())
863
+ props.onDone({ width: kvWidthOf(props.api.kv), heightPercent: height() })
864
+ }
865
+ const sliderWidth = () => (kvWidthOf(props.api.kv) === "xlarge" ? 64 : kvWidthOf(props.api.kv) === "large" ? 48 : 34)
866
+ const sliderCells = createMemo(() => {
867
+ const width = sliderWidth()
868
+ const selected = Math.round(((height() - HEIGHT_PERCENT_MIN) / (HEIGHT_PERCENT_MAX - HEIGHT_PERCENT_MIN)) * (width - 1))
869
+ const presetPositions = new Map(HEIGHT_PRESETS.map((preset) => [Math.round(((preset.value - HEIGHT_PERCENT_MIN) / (HEIGHT_PERCENT_MAX - HEIGHT_PERCENT_MIN)) * (width - 1)), preset.label]))
870
+ return Array.from({ length: width }, (_, index) => {
871
+ const isCurrent = index === selected
872
+ const preset = presetPositions.get(index)
873
+ return {
874
+ char: isCurrent ? "●" : preset ? "│" : index < selected ? "━" : "─",
875
+ color: isCurrent ? theme().primary : preset ? theme().accent : index < selected ? theme().success : theme().textMuted,
876
+ }
877
+ })
878
+ })
879
+ /** live mini preview (config-studio): a mock dialog box scaled to the
880
+ * current settings, using the SAME metrics as real dialogs so it shows
881
+ * the true capped height and flags when the request is capped. */
882
+ const preview = createMemo(() => {
883
+ const widthColumns = kvWidthOf(props.api.kv) === "xlarge" ? 116 : kvWidthOf(props.api.kv) === "large" ? 88 : 60
884
+ const previewWidth = Math.max(10, Math.min(sliderWidth() + 4, 72))
885
+ const scale = previewWidth / widthColumns
886
+ const metrics = dialogMetrics(props.api, dimensions().height, 6, 4)
887
+ const effective = metrics.targetRows
888
+ const requested = Math.floor((dimensions().height * Math.min(100, Math.max(25, height()))) / 100)
889
+ const rows = Math.max(4, Math.round(effective * scale))
890
+ const fill = (text: string, width: number) => {
891
+ const inner = width - 2
892
+ const slice = text.length > inner ? text.slice(0, inner - 1) + "…" : text
893
+ return `│${slice}${" ".repeat(Math.max(0, inner - slice.length))}│`
894
+ }
895
+ const lines: string[] = []
896
+ lines.push(`┌${"─".repeat(previewWidth - 2)}┐`)
897
+ lines.push(fill(`OpenShare (preview)${effective < requested ? " [capped]" : ""}`, previewWidth))
898
+ for (let index = 0; index < rows - 3; index++) lines.push(fill("", previewWidth))
899
+ lines.push(`└${"─".repeat(previewWidth - 2)}┘`)
900
+ return lines
901
+ })
902
+ const unregister = props.api.keymap.registerLayer({
903
+ priority: 10000,
904
+ commands: [
905
+ { name: `${commandPrefix}.width`, title: "Cycle width", run: (ctx: KeyContext) => { blockKey(ctx); cycleWidth() } },
906
+ { name: `${commandPrefix}.left`, title: "Lower height", run: (ctx: KeyContext) => { blockKey(ctx); move(-1) } },
907
+ { name: `${commandPrefix}.right`, title: "Raise height", run: (ctx: KeyContext) => { blockKey(ctx); move(1) } },
908
+ { name: `${commandPrefix}.down`, title: "Lower height faster", run: (ctx: KeyContext) => { blockKey(ctx); move(-5) } },
909
+ { name: `${commandPrefix}.up`, title: "Raise height faster", run: (ctx: KeyContext) => { blockKey(ctx); move(5) } },
910
+ ...HEIGHT_PRESETS.map((preset) => ({
911
+ name: `${commandPrefix}.preset_${preset.label}`,
912
+ title: `${preset.label} preset`,
913
+ run: (ctx: KeyContext) => {
914
+ blockKey(ctx)
915
+ setPreset(preset.label)
916
+ },
917
+ })),
918
+ { name: `${commandPrefix}.custom`, title: "Custom percent", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone({ width: kvWidthOf(props.api.kv), heightPercent: -1 }) } },
919
+ { name: `${commandPrefix}.save`, title: "Save", run: (ctx: KeyContext) => { blockKey(ctx); save() } },
920
+ { name: `${commandPrefix}.back`, title: "Back", run: (ctx: KeyContext) => { blockKey(ctx); props.onDone(undefined) } },
921
+ { name: `${commandPrefix}.shield`, title: "Block background input", run: blockKey },
922
+ ],
923
+ bindings: [
924
+ { key: "w", cmd: `${commandPrefix}.width`, desc: "Cycle width" },
925
+ { key: "left", cmd: `${commandPrefix}.left`, desc: "Lower height" },
926
+ { key: "right", cmd: `${commandPrefix}.right`, desc: "Raise height" },
927
+ { key: "down", cmd: `${commandPrefix}.down`, desc: "Lower height faster" },
928
+ { key: "up", cmd: `${commandPrefix}.up`, desc: "Raise height faster" },
929
+ ...HEIGHT_PRESETS.map((preset) => ({ key: preset.key, cmd: `${commandPrefix}.preset_${preset.label}`, desc: `${preset.label} preset` })),
930
+ { key: "c", cmd: `${commandPrefix}.custom`, desc: "Custom percent" },
931
+ { key: "enter", cmd: `${commandPrefix}.save`, desc: "Save" },
932
+ { key: "escape", cmd: `${commandPrefix}.back`, desc: "Back" },
933
+ ...shieldBindings(`${commandPrefix}.shield`, ["w", "c", ...HEIGHT_PRESETS.map((preset) => preset.key)]),
934
+ ],
935
+ })
936
+ onCleanup(() => {
937
+ unregister()
938
+ popMode()
939
+ })
940
+
941
+ return (
942
+ <DialogFrame api={props.api} title="Menu size">
943
+ <box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1} paddingLeft={1} paddingRight={1}>
944
+ <text fg={theme().textMuted}>width (w): {kvWidthOf(props.api.kv)} ({kvWidthOf(props.api.kv) === "xlarge" ? 116 : kvWidthOf(props.api.kv) === "large" ? 88 : 60} cols)</text>
945
+ <text fg={theme().accent}><b>{height()}%</b></text>
946
+ </box>
947
+ <box flexDirection="row" width="100%" marginBottom={1} paddingLeft={1} paddingRight={1}>
948
+ <text fg={theme().textMuted}>{HEIGHT_PERCENT_MIN}% </text>
949
+ <For each={sliderCells()}>{(cell) => <text fg={cell.color}>{cell.char}</text>}</For>
950
+ <text fg={theme().textMuted}> {HEIGHT_PERCENT_MAX}%</text>
951
+ </box>
952
+ <box flexDirection="column" gap={0} marginBottom={1} paddingLeft={1} paddingRight={1}>
953
+ <For each={HEIGHT_PRESETS}>
954
+ {(preset) => (
955
+ <text fg={height() === preset.value ? theme().accent : theme().textMuted}>
956
+ {preset.key} {preset.label}: {preset.value}%{height() === preset.value ? " ● current" : ""}
957
+ </text>
958
+ )}
959
+ </For>
960
+ </box>
961
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} marginBottom={1}>
962
+ <For each={preview()}>{(line) => <text fg={theme().textMuted}>{line}</text>}</For>
963
+ </box>
964
+ <KeyLegend
965
+ api={props.api}
966
+ keys={[["w", "width"], ["←→", "±1%"], ["↑↓", "±5%"], ["c", "custom"], ["⏎", "save"], ["esc", "back"]]}
967
+ onBack={() => props.onDone(undefined)}
968
+ />
969
+ </DialogFrame>
970
+ )
971
+ }