@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/sidebar.tsx
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
// OpenShare sidebar chip: rendered into the `sidebar_title` slot
|
|
4
|
+
// (single_winner mode — our render replaces the default title block, so it
|
|
5
|
+
// re-renders the session title and appends the share state). The share
|
|
6
|
+
// state reads the live session store, so the chip updates the moment the
|
|
7
|
+
// server plugin writes the binding.
|
|
8
|
+
//
|
|
9
|
+
// v1-only: the v2 TUI plugin context has no slot registry. The v2 adapter
|
|
10
|
+
// simply skips this module.
|
|
11
|
+
//
|
|
12
|
+
// SLOT CONTRACT (live-caught 2x): (1) @opentui/core SlotRenderer is called
|
|
13
|
+
// as (ctx, props) — CONTEXT FIRST. (2) The payload MUST be box-rooted:
|
|
14
|
+
// a text root with text children is not hostable through the slot
|
|
15
|
+
// machinery ("TextNodeRenderable only accepts strings…" on embed). Every
|
|
16
|
+
// proven consumer returns box-rooted trees (opencode's own default
|
|
17
|
+
// children, magic-context's sidebar, the host's slot self-test); text
|
|
18
|
+
// nodes stay LEAVES here — string content only.
|
|
19
|
+
//
|
|
20
|
+
// TEMPORARY crash-hunt instrumentation (owner-approved 2026-10-19): every
|
|
21
|
+
// render logs its inputs (with TYPES), the computed locals, and a dump of
|
|
22
|
+
// the returned element tree to %TEMP%/openshare-tui-debug.log via
|
|
23
|
+
// synchronous appends, so a crash mid-render still leaves evidence. Any
|
|
24
|
+
// throw inside the renderer is logged with its stack, then rethrown.
|
|
25
|
+
// Remove once the live crash is cornered.
|
|
26
|
+
|
|
27
|
+
import type { JSX } from "@opentui/solid"
|
|
28
|
+
import { bindingsOf, bindingOf, verifyShare } from "./share-flow.js"
|
|
29
|
+
import type { TuiPluginApi, TuiSlotContext } from "@opencode-ai/plugin/tui"
|
|
30
|
+
import type { Session } from "@opencode-ai/sdk/v2"
|
|
31
|
+
import { appendFileSync } from "node:fs"
|
|
32
|
+
import { tmpdir } from "node:os"
|
|
33
|
+
import { join } from "node:path"
|
|
34
|
+
|
|
35
|
+
const DBG_FILE = join(tmpdir(), "openshare-tui-debug.log")
|
|
36
|
+
|
|
37
|
+
/** value -> `kind:preview` so the log shows TYPES (the crash was a type bug) */
|
|
38
|
+
export function dbg(v: unknown, depth = 0): string {
|
|
39
|
+
const t = typeof v
|
|
40
|
+
if (v === null) return "null"
|
|
41
|
+
if (t === "string") return `str(${(v as string).length}):${JSON.stringify((v as string).slice(0, 60))}`
|
|
42
|
+
if (t === "number" || t === "boolean" || t === "bigint") return `${t}:${String(v)}`
|
|
43
|
+
if (t === "undefined") return "undef"
|
|
44
|
+
if (t === "function") return `fn:${(v as { name?: string }).name || "anon"}`
|
|
45
|
+
if (Array.isArray(v)) return `arr[${v.length}]:[${v.slice(0, 6).map((x) => dbg(x, depth + 1)).join(",")}]`
|
|
46
|
+
if (depth >= 3) return "obj:…"
|
|
47
|
+
try {
|
|
48
|
+
const keys = Object.keys(v as object)
|
|
49
|
+
return `obj{${keys.slice(0, 8).join(",")}}` + (depth < 2 ? `{${keys.slice(0, 8).map((k) => `${k}=${dbg((v as Record<string, unknown>)[k], depth + 1)}`).join(",")}}` : "")
|
|
50
|
+
} catch {
|
|
51
|
+
return "obj:?}"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** shallow structural dump of a rendered element tree (type names, children kinds) */
|
|
56
|
+
function dbgEl(el: unknown, depth = 0): string {
|
|
57
|
+
if (el === null || el === undefined || typeof el === "string" || typeof el === "number" || typeof el === "boolean") return dbg(el)
|
|
58
|
+
if (Array.isArray(el)) return `[${el.map((x) => dbgEl(x, depth)).join(",")}]`
|
|
59
|
+
if (typeof el === "function") return "fn-node"
|
|
60
|
+
if (depth >= 4) return "…"
|
|
61
|
+
try {
|
|
62
|
+
const rec = el as Record<string, unknown>
|
|
63
|
+
const t = dbg(rec.t ?? rec.type, 2)
|
|
64
|
+
const kids = rec.children
|
|
65
|
+
const props = Object.keys(rec).filter((k) => !["t", "type", "children"].includes(k))
|
|
66
|
+
return `<${t} ${props.join(" ")}>${kids === undefined ? "" : dbgEl(kids, depth + 1)}</${t}>`
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return `el?-${dbg(e)}`
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function dbgLog(line: string): void {
|
|
73
|
+
try {
|
|
74
|
+
appendFileSync(DBG_FILE, `${new Date().toISOString()} ${line}\n`)
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let renderSeq = 0
|
|
79
|
+
|
|
80
|
+
/** Share liveness cache for the chip: a binding can claim "shared" long
|
|
81
|
+
* after the page died server-side (purge/TTL/delete with no event ever
|
|
82
|
+
* reaching this host). One credential-free probe per (session, server)
|
|
83
|
+
* per minute; the chip picks the verdict up on the next render. */
|
|
84
|
+
type GoneVerdict = { status: "live" | "gone" | "unknown"; at: number }
|
|
85
|
+
const GONE_TTL_MS = 60_000
|
|
86
|
+
const goneCache = new Map<string, GoneVerdict>()
|
|
87
|
+
|
|
88
|
+
function goneVerdict(sid: string, server: string, shareId: string): GoneVerdict | undefined {
|
|
89
|
+
const key = `${sid}|${server}`
|
|
90
|
+
const cached = goneCache.get(key)
|
|
91
|
+
if (cached && Date.now() - cached.at < GONE_TTL_MS) return cached
|
|
92
|
+
void verifyShare(server, shareId).then((status) => {
|
|
93
|
+
goneCache.set(key, { status, at: Date.now() })
|
|
94
|
+
})
|
|
95
|
+
return cached
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type SidebarTitleProps = {
|
|
99
|
+
session_id: string
|
|
100
|
+
title: string
|
|
101
|
+
share_url?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createSidebarSlot(api: TuiPluginApi, opts?: { onShareCommand?: () => void }) {
|
|
105
|
+
dbgLog(`createSidebarSlot called; api.state=${!!api.state} api.theme=${!!api.theme} api.version=${dbg(api.app?.version)}`)
|
|
106
|
+
return {
|
|
107
|
+
slots: {
|
|
108
|
+
sidebar_title: (ctx: TuiSlotContext, props: SidebarTitleProps): JSX.Element => {
|
|
109
|
+
const seq = ++renderSeq
|
|
110
|
+
try {
|
|
111
|
+
dbgLog(`render#${seq} ctx=${dbg(ctx)} props=${dbg(props)}`)
|
|
112
|
+
const sid = typeof props?.session_id === "string" ? props.session_id : ""
|
|
113
|
+
const title = String(props?.title ?? "") || "Session"
|
|
114
|
+
const session = sid ? api.state.session.get(sid) : undefined
|
|
115
|
+
const binding = bindingOf(session as never)
|
|
116
|
+
const allBindings = bindingsOf(session as never)
|
|
117
|
+
const liveCount = allBindings.filter((b) => b.url && b.enabled !== false).length
|
|
118
|
+
const status = session?.time?.archived ? undefined : binding?.url ? (binding.enabled === false ? "attached" : "live") : undefined
|
|
119
|
+
// gone detection: any live-bound server whose page died server-side
|
|
120
|
+
const checked = allBindings.filter((b) => b.url && b.enabled !== false && b.id && b.server)
|
|
121
|
+
const verdicts = checked.map((b) => goneVerdict(sid, b.server, b.id))
|
|
122
|
+
const anyGone = verdicts.some((v) => v?.status === "gone")
|
|
123
|
+
const multi = liveCount > 1 ? `·${liveCount} ` : ""
|
|
124
|
+
const theme = ctx?.theme?.current ?? api.theme.current
|
|
125
|
+
dbgLog(
|
|
126
|
+
`render#${seq} sid=${dbg(sid)} title=${dbg(title)} session=${session ? "found" : "MISSING"} binding=${binding ? `id=${dbg(binding.id)} enabled=${dbg(binding.enabled)}` : "none"} status=${dbg(status)} theme=${dbg(theme)}`,
|
|
127
|
+
)
|
|
128
|
+
// chip on its own line under the title (takes the blank line that
|
|
129
|
+
// separated the title block from the context blocks); clicking it
|
|
130
|
+
// copies the share link — proven pattern: onMouseDown on a box.
|
|
131
|
+
// A verified-gone share renders dim (⊘) and clicking opens the
|
|
132
|
+
// share manager (verify + gone modal) instead of a dead copy.
|
|
133
|
+
const copyLink = () => {
|
|
134
|
+
if (anyGone) {
|
|
135
|
+
opts?.onShareCommand?.()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const url = binding?.url
|
|
139
|
+
if (!url) return
|
|
140
|
+
// navigator first (no-op-resolves in the TUI runtime are
|
|
141
|
+
// possible), then ALWAYS OSC52 — the empirically working path
|
|
142
|
+
let copied = false
|
|
143
|
+
try {
|
|
144
|
+
const clip = (navigator as unknown as { clipboard?: { writeText?: (t: string) => Promise<void> } }).clipboard
|
|
145
|
+
if (typeof clip?.writeText === "function") {
|
|
146
|
+
void clip.writeText(url).then(
|
|
147
|
+
() => {},
|
|
148
|
+
() => {},
|
|
149
|
+
)
|
|
150
|
+
copied = true
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
try {
|
|
154
|
+
// OSC52 escape (most modern terminals, incl. Windows Terminal)
|
|
155
|
+
process.stdout.write(`\x1b]52;c;${Buffer.from(url, "utf8").toString("base64")}\x07`)
|
|
156
|
+
copied = true
|
|
157
|
+
} catch {}
|
|
158
|
+
try {
|
|
159
|
+
api.ui.toast({ variant: copied ? "success" : "info", title: "OpenShare", message: copied ? `Share link copied: ${url}` : `Share link: ${url}`, duration: 6000 })
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
const el = (
|
|
163
|
+
<box flexDirection="column" paddingRight={1}>
|
|
164
|
+
<text fg={theme.text}>{title}</text>
|
|
165
|
+
{status ? (
|
|
166
|
+
<box onMouseDown={copyLink}>
|
|
167
|
+
<text fg={anyGone ? theme.error : binding?.enabled === false ? theme.warning : theme.success}>
|
|
168
|
+
{anyGone
|
|
169
|
+
? `⊘ re:share — gone (removed/expired) — click to fix`
|
|
170
|
+
: status === "live"
|
|
171
|
+
? `◉ re:share ${multi}— click to copy link`
|
|
172
|
+
: "◉ re:share (dormant)"}
|
|
173
|
+
</text>
|
|
174
|
+
</box>
|
|
175
|
+
) : null}
|
|
176
|
+
</box>
|
|
177
|
+
)
|
|
178
|
+
dbgLog(`render#${seq} RETURNS ${dbgEl(el)}`)
|
|
179
|
+
return el
|
|
180
|
+
} catch (error) {
|
|
181
|
+
dbgLog(`render#${seq} THREW ${(error as Error)?.stack ?? dbg(error)} — rethrowing`)
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/tui-host.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Host-agnostic TUI API surface for OpenShare. The exact bounded subset of
|
|
2
|
+
// the OpenCode v1 `TuiPluginApi` the share flows and settings dialog use;
|
|
3
|
+
// a v1 api instance satisfies it structurally, and v2-tui.ts implements it
|
|
4
|
+
// over the OpenCode v2 TUI plugin context. Flows never touch anything
|
|
5
|
+
// outside this interface — one compiled bundle runs on both hosts.
|
|
6
|
+
|
|
7
|
+
import type { ClientApi, SessionInfo, SessionOpts } from "./core.js"
|
|
8
|
+
|
|
9
|
+
export type ShareDialogSelectOption<Value = string> = {
|
|
10
|
+
title: string
|
|
11
|
+
value: Value
|
|
12
|
+
description?: string
|
|
13
|
+
category?: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
/** long-form detail shown in the [i] pane (v1 menus; other hosts ignore).
|
|
16
|
+
* "## Header" lines split paged sections. */
|
|
17
|
+
help?: string
|
|
18
|
+
/** destructive action — renders red (v1 menus) */
|
|
19
|
+
danger?: boolean
|
|
20
|
+
/** pending/edited — renders green (v1 menus) */
|
|
21
|
+
edited?: boolean
|
|
22
|
+
/** non-interactive separator row (v1 menus) */
|
|
23
|
+
divider?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ShareHostKeyContext = {
|
|
27
|
+
event?: {
|
|
28
|
+
preventDefault?: () => void
|
|
29
|
+
stopPropagation?: () => void
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ShareHostLayerCommand = {
|
|
34
|
+
name: string
|
|
35
|
+
title?: string
|
|
36
|
+
desc?: string
|
|
37
|
+
slashName?: string
|
|
38
|
+
slashAliases?: string[]
|
|
39
|
+
suggested?: boolean
|
|
40
|
+
hidden?: boolean
|
|
41
|
+
enabled?: boolean | (() => boolean)
|
|
42
|
+
category?: string
|
|
43
|
+
group?: string
|
|
44
|
+
run: (ctx: ShareHostKeyContext) => void | Promise<void>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ShareHostKeymapLayer = {
|
|
48
|
+
/** v2 adapter only (v2 keymap layers are natively mode-scoped "global").
|
|
49
|
+
* The v1 registration deliberately ignores this: a v1 plugin layer with
|
|
50
|
+
* a declared mode is gated on the mode stack and never reachable. */
|
|
51
|
+
mode?: string
|
|
52
|
+
priority?: number
|
|
53
|
+
commands: readonly ShareHostLayerCommand[]
|
|
54
|
+
bindings: readonly { key: string; cmd: string; desc?: string }[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ShareHostApi = {
|
|
58
|
+
/** Host version marker (v2 adapter sets 2; v1 leaves undefined). */
|
|
59
|
+
hostVersion?: 1 | 2
|
|
60
|
+
app?: { version?: string }
|
|
61
|
+
/** Raw SDK client; metadata PATCH/GET go through it. */
|
|
62
|
+
client?: ClientApi
|
|
63
|
+
kv: {
|
|
64
|
+
get<Value = unknown>(key: string, fallback?: Value): Value
|
|
65
|
+
set(key: string, value: unknown): void
|
|
66
|
+
}
|
|
67
|
+
lifecycle: {
|
|
68
|
+
onDispose(fn: () => void): void
|
|
69
|
+
}
|
|
70
|
+
theme: {
|
|
71
|
+
current: {
|
|
72
|
+
success: unknown
|
|
73
|
+
info: unknown
|
|
74
|
+
error: unknown
|
|
75
|
+
warning: unknown
|
|
76
|
+
textMuted: unknown
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
ui: {
|
|
80
|
+
toast(input: { variant?: "info" | "success" | "warning" | "error"; title?: string; message: string; duration?: number }): void
|
|
81
|
+
dialog: {
|
|
82
|
+
replace(renderer: unknown, onClose?: () => void): void
|
|
83
|
+
clear(): void
|
|
84
|
+
setSize(size: string): void
|
|
85
|
+
}
|
|
86
|
+
/** Promise menus (the family showMenuOnce pattern). Preferred UI on v1. */
|
|
87
|
+
showMenu<Value = string>(props: { title: string; options: ShareDialogSelectOption<Value>[]; current?: Value; hint?: string }): Promise<Value | undefined>
|
|
88
|
+
showConfirm(props: { title: string; message: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>
|
|
89
|
+
showAlert(props: { title: string; message: string }): Promise<void>
|
|
90
|
+
showPrompt(props: { title: string; placeholder?: string; value?: string }): Promise<string | undefined>
|
|
91
|
+
/** Combined menu-size picker (v1 facade only; feature-detected). */
|
|
92
|
+
showSizePicker?(current: { width: string; heightPercent: number }): Promise<{ width: string; heightPercent: number } | undefined>
|
|
93
|
+
/** Non-cancelable busy dialog; returns the done() that clears it. */
|
|
94
|
+
showBusy?(title: string): (() => void) | undefined
|
|
95
|
+
// Callback dialogs (v2 adapter bridge only — host ui.DialogX under
|
|
96
|
+
// plugin dialog.replace receives no keys on opencode 1.19.x).
|
|
97
|
+
DialogSelect<Value = string>(props: {
|
|
98
|
+
title: string
|
|
99
|
+
placeholder?: string
|
|
100
|
+
options: ShareDialogSelectOption<Value>[]
|
|
101
|
+
current?: Value
|
|
102
|
+
flat?: boolean
|
|
103
|
+
onSelect: (option: ShareDialogSelectOption<Value>) => void
|
|
104
|
+
}): unknown
|
|
105
|
+
DialogPrompt(props: {
|
|
106
|
+
title: string
|
|
107
|
+
placeholder?: string
|
|
108
|
+
value?: string
|
|
109
|
+
onConfirm: (value: string) => void
|
|
110
|
+
onCancel?: () => void
|
|
111
|
+
}): unknown
|
|
112
|
+
DialogConfirm(props: {
|
|
113
|
+
title: string
|
|
114
|
+
message: string
|
|
115
|
+
confirmLabel?: string
|
|
116
|
+
onConfirm: () => void
|
|
117
|
+
onCancel?: () => void
|
|
118
|
+
}): unknown
|
|
119
|
+
DialogAlert(props: { title: string; message: string; onConfirm?: () => void }): unknown
|
|
120
|
+
}
|
|
121
|
+
keymap: {
|
|
122
|
+
registerLayer(layer: ShareHostKeymapLayer): () => void
|
|
123
|
+
}
|
|
124
|
+
/** Live session lookup (v1: reactive state store; v2: client GET). */
|
|
125
|
+
sessionInfo(sessionID: string): SessionInfo | undefined
|
|
126
|
+
/** Currently focused/open session id, when the host can tell. */
|
|
127
|
+
currentSessionID?: () => string | undefined
|
|
128
|
+
/** Clipboard write; resolves false when unavailable. */
|
|
129
|
+
clipboard: {
|
|
130
|
+
write(text: string): Promise<boolean>
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type { ClientApi, SessionInfo, SessionOpts }
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
// OpenShare TUI plugin entry (OpenCode v1).
|
|
4
|
+
//
|
|
5
|
+
// Registers the /share (toggle + manager, shadowing the deprecated opncd.ai
|
|
6
|
+
// built-ins via a priority keymap layer) and /openshare commands, plus the
|
|
7
|
+
// sidebar_title slot chip. The flows run against the ShareHostApi facade
|
|
8
|
+
// (tui-host.ts); v2 hosts run the same flows through the v2-tui adapter.
|
|
9
|
+
|
|
10
|
+
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
11
|
+
import type { ClientApi } from "./core.js"
|
|
12
|
+
import { registerCommands, buildShareCommand } from "./commands.js"
|
|
13
|
+
import { createSidebarSlot, dbgLog, dbg } from "./sidebar.js"
|
|
14
|
+
import { showAlertMenu, showConfirmMenu, showMenuOnce, showPromptOnce, showSizePickerOnce, toMenuOptions } from "./dialogs.js"
|
|
15
|
+
import { clampHeightPercent, kvHeightPercentOf, kvWidthOf } from "./ui-prefs.js"
|
|
16
|
+
import type { ShareHostApi } from "./tui-host.js"
|
|
17
|
+
import { createV2TuiSetup } from "./v2-tui.js"
|
|
18
|
+
|
|
19
|
+
/** Builds the host facade over the v1 TuiPluginApi. */
|
|
20
|
+
|
|
21
|
+
/** Combined size-picker flow (config-studio showSizeSlider shape): picker →
|
|
22
|
+
* optional custom-percent prompt → saved result. Undefined = cancelled. */
|
|
23
|
+
async function showSizePickerFlow(api: TuiPluginApi, current: { width: string; heightPercent: number }): Promise<{ width: string; heightPercent: number } | undefined> {
|
|
24
|
+
let heightPercent = clampHeightPercent(current.heightPercent)
|
|
25
|
+
while (true) {
|
|
26
|
+
const choice = await showSizePickerOnce(api, { width: kvWidthOf(api.kv), heightPercent })
|
|
27
|
+
if (!choice) return undefined
|
|
28
|
+
if (choice.heightPercent >= 0) return { width: kvWidthOf(api.kv), heightPercent: clampHeightPercent(choice.heightPercent) }
|
|
29
|
+
const input = await showPromptOnce(api, { title: "Menu height percent", placeholder: "25-100", value: String(heightPercent) })
|
|
30
|
+
if (input === undefined) continue
|
|
31
|
+
const parsed = Number(input)
|
|
32
|
+
if (!Number.isFinite(parsed)) {
|
|
33
|
+
await showAlertMenu(api, { title: "Invalid height", message: "Enter a number from 25 to 100." })
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
heightPercent = clampHeightPercent(parsed)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildV1HostApi(api: TuiPluginApi): ShareHostApi {
|
|
41
|
+
return {
|
|
42
|
+
hostVersion: 1,
|
|
43
|
+
app: { version: api.app?.version },
|
|
44
|
+
// 1.19.x hands plugins the v2 SDK client — a LAZY PROXY (methods
|
|
45
|
+
// materialize on property access; spreading it destroys them, which
|
|
46
|
+
// broke session.update: live-caught "no client.session.update" after
|
|
47
|
+
// the adapter was added). Wrap with passthrough Proxies instead:
|
|
48
|
+
// - path args are translated id -> sessionID (the v2 SDK's URL
|
|
49
|
+
// builder left "{sessionID}" literally in the URL for {path:{id}}
|
|
50
|
+
// calls — server log: "Expected a string starting with ses, got
|
|
51
|
+
// %7BsessionID%7D" — so every session.get/update failed silently),
|
|
52
|
+
// - session.get fills metadata from the state store when the v2
|
|
53
|
+
// response lacks it,
|
|
54
|
+
// - everything else reaches the original proxy untouched.
|
|
55
|
+
client: (() => {
|
|
56
|
+
const raw = api.client as unknown as Record<string, unknown> & { session?: Record<string, unknown> }
|
|
57
|
+
const rawSession = raw?.session
|
|
58
|
+
if (!rawSession) return raw as unknown as ClientApi
|
|
59
|
+
const translateArgs = (args: unknown): unknown => {
|
|
60
|
+
if (!args || typeof args !== "object") return args
|
|
61
|
+
const a = args as { path?: Record<string, unknown>; body?: Record<string, unknown> }
|
|
62
|
+
if (a.path && "id" in a.path && !("sessionID" in a.path)) {
|
|
63
|
+
const { id, ...rest } = a.path
|
|
64
|
+
return { ...a, path: { ...rest, sessionID: id } }
|
|
65
|
+
}
|
|
66
|
+
return args
|
|
67
|
+
}
|
|
68
|
+
// v2 arg-shape probing: hey-api left "{sessionID}" literally in the
|
|
69
|
+
// URL for {path:{id}} calls (server: "Expected a string starting
|
|
70
|
+
// with ses, got %7BsessionID%7D"), and {path:{sessionID}} fared no
|
|
71
|
+
// better — the v2 convention is FLAT args ({sessionID, ...body},
|
|
72
|
+
// matching the beta client's SessionUpdateInput). Failed calls are
|
|
73
|
+
// side-effect-free (URL 500s), so probe: path-shape first, then
|
|
74
|
+
// flat. The winning shape is cached and logged once.
|
|
75
|
+
let argShape: "path" | "flat" | undefined
|
|
76
|
+
const callV2 = async (method: string, args: unknown): Promise<Record<string, unknown> | undefined> => {
|
|
77
|
+
const orig = rawSession[method] as ((a: unknown) => Promise<unknown>) | undefined
|
|
78
|
+
if (!orig) return undefined
|
|
79
|
+
const a = args as { path?: Record<string, unknown>; body?: Record<string, unknown> }
|
|
80
|
+
const flat = { sessionID: a.path?.id ?? a.path?.sessionID, ...(a.body ?? {}) }
|
|
81
|
+
const attempts: Array<["path" | "flat", unknown]> =
|
|
82
|
+
argShape === "flat"
|
|
83
|
+
? [["flat", flat]]
|
|
84
|
+
: argShape === "path"
|
|
85
|
+
? [["path", translateArgs(args)]]
|
|
86
|
+
: [
|
|
87
|
+
["path", translateArgs(args)],
|
|
88
|
+
["flat", flat],
|
|
89
|
+
]
|
|
90
|
+
let last: Record<string, unknown> | undefined
|
|
91
|
+
for (const [shape, callArgs] of attempts) {
|
|
92
|
+
try {
|
|
93
|
+
last = (await orig.call(rawSession, callArgs)) as Record<string, unknown> | undefined
|
|
94
|
+
} catch (e) {
|
|
95
|
+
last = { error: e } as Record<string, unknown>
|
|
96
|
+
}
|
|
97
|
+
const failed = !last || (typeof last === "object" && "error" in last && (last as { error?: unknown }).error)
|
|
98
|
+
if (!failed) {
|
|
99
|
+
if (!argShape) {
|
|
100
|
+
argShape = shape
|
|
101
|
+
dbgLog(`client.${method} arg shape = ${shape}`)
|
|
102
|
+
}
|
|
103
|
+
return last
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return last
|
|
107
|
+
}
|
|
108
|
+
const adaptedGet = async (args: { path: { id: string } }) => {
|
|
109
|
+
const res = (await callV2("get", args)) as Record<string, unknown> | undefined
|
|
110
|
+
const info = (res && typeof res === "object" && "data" in (res as object) ? (res as { data?: Record<string, unknown> }).data : (res as Record<string, unknown> | undefined)) ?? undefined
|
|
111
|
+
if (info && typeof info === "object" && !info.metadata) {
|
|
112
|
+
const stateInfo = api.state.session.get(args.path.id) as { metadata?: Record<string, unknown> } | undefined
|
|
113
|
+
if (stateInfo?.metadata) {
|
|
114
|
+
dbgLog(`client.get metadata fallback: client keys=${Object.keys(info).slice(0, 12).join("|")} -> state metadata used`)
|
|
115
|
+
if (res && typeof res === "object" && "data" in res) return { ...res, data: { ...info, metadata: stateInfo.metadata } }
|
|
116
|
+
return { ...info, metadata: stateInfo.metadata }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return res
|
|
120
|
+
}
|
|
121
|
+
const adaptedSession = new Proxy(rawSession, {
|
|
122
|
+
get(target, key: string) {
|
|
123
|
+
if (key === "get") return adaptedGet
|
|
124
|
+
if (key === "update") return (args: unknown) => callV2("update", args)
|
|
125
|
+
const value = Reflect.get(target, key, target)
|
|
126
|
+
return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(target) : value
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
return new Proxy(raw, {
|
|
130
|
+
get(target, key: string) {
|
|
131
|
+
if (key === "session") return adaptedSession
|
|
132
|
+
const value = Reflect.get(target, key, target)
|
|
133
|
+
return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(target) : value
|
|
134
|
+
},
|
|
135
|
+
}) as unknown as ClientApi
|
|
136
|
+
})(),
|
|
137
|
+
kv: api.kv,
|
|
138
|
+
lifecycle: api.lifecycle,
|
|
139
|
+
theme: api.theme,
|
|
140
|
+
ui: {
|
|
141
|
+
toast: (input) => api.ui.toast(input),
|
|
142
|
+
// dialog.clear() THROWS on an already-empty stack on 1.19.x — and
|
|
143
|
+
// flows legitimately clear after my components already settled+cleared
|
|
144
|
+
// (the throw killed confirmShare before it started, live-caught via
|
|
145
|
+
// the debug log). Idempotent wrapper for every call site.
|
|
146
|
+
dialog: new Proxy({} as ShareHostApi["ui"]["dialog"], {
|
|
147
|
+
get: (_t, key: string) => {
|
|
148
|
+
if (key === "clear")
|
|
149
|
+
return () => {
|
|
150
|
+
try {
|
|
151
|
+
api.ui.dialog.clear()
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
if (key === "replace")
|
|
155
|
+
return (render: () => unknown, onClose?: () => void) => {
|
|
156
|
+
try {
|
|
157
|
+
api.ui.dialog.replace(render as never, onClose)
|
|
158
|
+
} catch (e) {
|
|
159
|
+
dbgLog(`dialog.replace THREW: ${(e as Error)?.message ?? e}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const value = (api.ui.dialog as unknown as Record<string, unknown>)[key]
|
|
163
|
+
return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(api.ui.dialog) : value
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
// Promise menus — the family showMenuOnce pattern, ported verbatim.
|
|
167
|
+
showMenu: (props) => showMenuOnce(api, { ...props, options: toMenuOptions(props.options) }),
|
|
168
|
+
showConfirm: (props) => showConfirmMenu(api, props),
|
|
169
|
+
showAlert: (props) => showAlertMenu(api, props),
|
|
170
|
+
showPrompt: (props) => showPromptOnce(api, props),
|
|
171
|
+
showSizePicker: (current) => showSizePickerFlow(api, current),
|
|
172
|
+
showBusy: (title: string) => {
|
|
173
|
+
const theme = () => api.theme.current
|
|
174
|
+
api.ui.dialog.replace(() => (
|
|
175
|
+
<box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
176
|
+
<text fg={theme().accent}><b>{title}</b></text>
|
|
177
|
+
<text fg={theme().textMuted}>working… (esc does not cancel)</text>
|
|
178
|
+
</box>
|
|
179
|
+
))
|
|
180
|
+
return () => {
|
|
181
|
+
try {
|
|
182
|
+
api.ui.dialog.clear()
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
// CUSTOM interactive dialogs (family MenuDialog pattern: own
|
|
187
|
+
// priority-10000 key layer + mode push + input shield). The host's
|
|
188
|
+
// ui.DialogX components under plugin dialog.replace render but
|
|
189
|
+
// receive no keys on opencode 1.19.x (live-caught + pty-reproduced).
|
|
190
|
+
// These are component functions: flows invoke them inside their own
|
|
191
|
+
// dialog.replace(() => ...) render thunk (the host renders the thunk
|
|
192
|
+
// in a proper Solid root, so the hooks inside run with an owner).
|
|
193
|
+
// DialogX bridge shims (v2 adapter / legacy call sites): route to the
|
|
194
|
+
// ported promise menus. Nothing on v1 renders these through
|
|
195
|
+
// dialog.replace anymore — flows use showMenu/showConfirm directly.
|
|
196
|
+
DialogSelect: (props) => {
|
|
197
|
+
void showMenuOnce(api, { title: props.title, options: toMenuOptions(props.options), current: props.current, hint: props.placeholder }).then((value) => {
|
|
198
|
+
if (value === undefined) (props as { onCancel?: () => void }).onCancel?.()
|
|
199
|
+
else {
|
|
200
|
+
const option = props.options.find((o) => o.value === value)
|
|
201
|
+
if (option) (props as { onSelect: (o: unknown) => void }).onSelect(option)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
return undefined as never
|
|
205
|
+
},
|
|
206
|
+
DialogPrompt: (props) => {
|
|
207
|
+
void showPromptOnce(api, { title: props.title, placeholder: props.placeholder, value: props.value }).then((value) => {
|
|
208
|
+
if (value === undefined) (props as { onCancel?: () => void }).onCancel?.()
|
|
209
|
+
else (props as { onConfirm: (v: string) => void }).onConfirm(value)
|
|
210
|
+
})
|
|
211
|
+
return undefined as never
|
|
212
|
+
},
|
|
213
|
+
DialogConfirm: (props) => {
|
|
214
|
+
void showConfirmMenu(api, { title: props.title, message: props.message, confirmLabel: props.confirmLabel }).then((yes) => (yes ? props.onConfirm() : (props as { onCancel?: () => void }).onCancel?.()))
|
|
215
|
+
return undefined as never
|
|
216
|
+
},
|
|
217
|
+
DialogAlert: (props) => {
|
|
218
|
+
void showAlertMenu(api, { title: props.title, message: props.message }).then(() => (props as { onConfirm?: () => void }).onConfirm?.())
|
|
219
|
+
return undefined as never
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
keymap: {
|
|
223
|
+
registerLayer(layer) {
|
|
224
|
+
// Host layer shape = exactly what the proven family plugins pass:
|
|
225
|
+
// {commands, bindings}. NO mode (a declared mode gates the layer on
|
|
226
|
+
// the mode stack; resting mode is "base" — the layer would never be
|
|
227
|
+
// reachable), no priority, and command objects carry ONLY defined
|
|
228
|
+
// fields (undefined-valued keys are not part of the working shape).
|
|
229
|
+
const commands = layer.commands.map((command) => {
|
|
230
|
+
const host: Record<string, unknown> = {
|
|
231
|
+
namespace: "palette",
|
|
232
|
+
name: command.name,
|
|
233
|
+
title: command.title,
|
|
234
|
+
run: (ctx: unknown) => command.run((ctx ?? {}) as never),
|
|
235
|
+
}
|
|
236
|
+
if (command.desc !== undefined) host.desc = command.desc
|
|
237
|
+
if (command.category !== undefined) host.category = command.category
|
|
238
|
+
if (command.slashName !== undefined) host.slashName = command.slashName
|
|
239
|
+
if (command.slashAliases?.length) host.slashAliases = command.slashAliases
|
|
240
|
+
if (command.suggested !== undefined) host.suggested = command.suggested
|
|
241
|
+
if (command.hidden !== undefined) host.hidden = command.hidden
|
|
242
|
+
if (command.enabled !== undefined) host.enabled = command.enabled
|
|
243
|
+
return host
|
|
244
|
+
})
|
|
245
|
+
return api.keymap.registerLayer({ commands, bindings: layer.bindings as never[] })
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
sessionInfo: (sid) => api.state.session.get(sid) as never,
|
|
249
|
+
currentSessionID: () => {
|
|
250
|
+
const route = api.route.current
|
|
251
|
+
return route.name === "session" ? (route.params?.sessionID as string | undefined) : undefined
|
|
252
|
+
},
|
|
253
|
+
clipboard: {
|
|
254
|
+
// navigator.clipboard.writeText resolves as a silent no-op in the
|
|
255
|
+
// TUI runtime (live-caught: the menu copy said "copied" but nothing
|
|
256
|
+
// landed; the sidebar chip worked because it falls back to OSC52).
|
|
257
|
+
// Belt-and-braces: try navigator, then ALWAYS emit the OSC52 escape
|
|
258
|
+
// — harmless when the terminal ignores it, correct when it honors it.
|
|
259
|
+
async write(text) {
|
|
260
|
+
let ok = false
|
|
261
|
+
try {
|
|
262
|
+
const clip = (navigator as unknown as { clipboard?: { writeText?: (t: string) => Promise<void> } }).clipboard
|
|
263
|
+
if (typeof clip?.writeText === "function") {
|
|
264
|
+
await clip.writeText(text).then(
|
|
265
|
+
() => {
|
|
266
|
+
ok = true
|
|
267
|
+
},
|
|
268
|
+
() => {},
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
} catch {}
|
|
272
|
+
try {
|
|
273
|
+
process.stdout.write(`\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`)
|
|
274
|
+
ok = true
|
|
275
|
+
} catch {}
|
|
276
|
+
return ok
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const tui: TuiPlugin = async (api) => {
|
|
283
|
+
dbgLog(`tui entry running; host v=${dbg(api.app?.version)} slots=${!!api.slots} keymap=${!!api.keymap} state=${!!api.state}`)
|
|
284
|
+
const host = buildV1HostApi(api)
|
|
285
|
+
|
|
286
|
+
const sessionID = () => host.currentSessionID?.() ?? undefined
|
|
287
|
+
const unregister = registerCommands(host, sessionID)
|
|
288
|
+
|
|
289
|
+
// sidebar chip (v1 slots; the v2 context has no slot registry). The
|
|
290
|
+
// slots registry has no unregister API — registration lives for the
|
|
291
|
+
// process lifetime. The chip gets the live /share command instance so a
|
|
292
|
+
// click on a gone/dead share opens the share manager (verify + modal)
|
|
293
|
+
// instead of copying a dead link.
|
|
294
|
+
try {
|
|
295
|
+
const shareCommand = buildShareCommand(host, sessionID)
|
|
296
|
+
const slot = createSidebarSlot(api, { onShareCommand: () => void shareCommand.run({}) })
|
|
297
|
+
dbgLog(`registering sidebar slot: ${Object.keys(slot?.slots ?? {}).join(", ") || "NO SLOTS"}`)
|
|
298
|
+
const reg = api.slots.register(slot as never)
|
|
299
|
+
dbgLog(`sidebar slot registered -> ${dbg(reg)}`)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
dbgLog(`sidebar slot REGISTRATION THREW ${(error as Error)?.stack ?? String(error)}`)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
api.lifecycle.onDispose(() => {
|
|
305
|
+
try {
|
|
306
|
+
unregister()
|
|
307
|
+
} catch {}
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Dual-target TUI entry.
|
|
313
|
+
*
|
|
314
|
+
* - OpenCode v1 (strict loader) requires `default.tui` to be a function;
|
|
315
|
+
* excess keys are ignored.
|
|
316
|
+
* - OpenCode v2 requires `default` to be `{ id, setup }`.
|
|
317
|
+
*/
|
|
318
|
+
export default { id: "openshare", tui, setup: createV2TuiSetup() }
|
package/src/ui-prefs.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Host-agnostic dialog size preferences (owner decision 2026-10-19: host
|
|
2
|
+
// kv, not the config file — UI settings are per-host and synced by
|
|
3
|
+
// opencode itself). dialog width maps to the host's fixed dialog widths;
|
|
4
|
+
// height is a percent of terminal rows. v1 dialogs apply these directly
|
|
5
|
+
// (dialogs.tsx); the settings dialog edits them through this module so the
|
|
6
|
+
// flows stay host-agnostic.
|
|
7
|
+
|
|
8
|
+
export const UI_WIDTH_KV = "openshare.ui-width"
|
|
9
|
+
export const UI_HEIGHT_PERCENT_KV = "openshare.ui-height-percent"
|
|
10
|
+
|
|
11
|
+
export type DialogWidth = "medium" | "large" | "xlarge"
|
|
12
|
+
|
|
13
|
+
export const HEIGHT_PERCENT_MIN = 25
|
|
14
|
+
export const HEIGHT_PERCENT_MAX = 100
|
|
15
|
+
|
|
16
|
+
/** what each width means in columns (opencode's fixed dialog widths) */
|
|
17
|
+
export const DIALOG_WIDTH_COLUMNS: Record<DialogWidth, number> = { medium: 60, large: 88, xlarge: 116 }
|
|
18
|
+
|
|
19
|
+
export function clampHeightPercent(value: number) {
|
|
20
|
+
return Math.max(HEIGHT_PERCENT_MIN, Math.min(HEIGHT_PERCENT_MAX, Math.round(value)))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function nextDialogWidth(width: DialogWidth): DialogWidth {
|
|
24
|
+
return width === "medium" ? "large" : width === "large" ? "xlarge" : "medium"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** kv read with validation; falls back to the default on garbage. */
|
|
28
|
+
export function kvWidthOf(kv: { get<Value = unknown>(key: string, fallback?: Value): Value }): DialogWidth {
|
|
29
|
+
const value = kv.get<DialogWidth>(UI_WIDTH_KV, "large")
|
|
30
|
+
return value === "medium" || value === "large" || value === "xlarge" ? value : "large"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function kvHeightPercentOf(kv: { get<Value = unknown>(key: string, fallback?: Value): Value }): number {
|
|
34
|
+
const value = kv.get<number>(UI_HEIGHT_PERCENT_KV, 50)
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value) ? clampHeightPercent(value) : 50
|
|
36
|
+
}
|