@mirrowel/opencode-openshare 0.0.0-stage → 0.1.1
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 +181 -2
- package/ci/README.md +56 -0
- package/ci/openshare.config.json +10 -0
- package/ci/openshare.js +2270 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +440 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +524 -0
- package/dist/core.d.ts +117 -0
- package/dist/core.js +1944 -0
- package/dist/dialogs.d.ts +94 -0
- package/dist/dialogs.js +725 -0
- package/dist/server.d.ts +64 -0
- package/dist/server.js +30 -0
- package/dist/settings.d.ts +24 -0
- package/dist/settings.js +1226 -0
- package/dist/share-flow.d.ts +287 -0
- package/dist/share-flow.js +516 -0
- package/dist/sidebar.d.ts +19 -0
- package/dist/sidebar.js +144 -0
- package/dist/tui-host.d.ts +158 -0
- package/dist/tui-host.js +6 -0
- package/dist/tui.d.ts +17 -0
- package/dist/tui.js +4719 -0
- package/dist/ui-prefs.d.ts +16 -0
- package/dist/ui-prefs.js +27 -0
- package/dist/v2-server.d.ts +81 -0
- package/dist/v2-server.js +402 -0
- package/dist/v2-tui.d.ts +24 -0
- package/dist/v2-tui.js +313 -0
- package/dist/v2-types.d.ts +134 -0
- package/dist/v2-types.js +8 -0
- package/openshare.config.json +16 -0
- package/package.json +98 -4
- package/src/banner.txt +25 -0
- package/src/commands.ts +452 -0
- package/src/config.ts +524 -0
- package/src/core.ts +1997 -0
- package/src/dialogs.tsx +971 -0
- package/src/server.ts +47 -0
- package/src/settings.ts +1239 -0
- package/src/share-flow.ts +551 -0
- package/src/sidebar.tsx +187 -0
- package/src/tui-host.ts +134 -0
- package/src/tui.tsx +318 -0
- package/src/ui-prefs.ts +36 -0
- package/src/v2-server.ts +438 -0
- package/src/v2-tui.ts +315 -0
- package/src/v2-types.ts +102 -0
- package/tsconfig.json +18 -0
package/src/dialogs.tsx
ADDED
|
@@ -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
|
+
}
|