@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -2
  3. package/ci/README.md +56 -0
  4. package/ci/openshare.config.json +10 -0
  5. package/ci/openshare.js +2270 -0
  6. package/dist/commands.d.ts +7 -0
  7. package/dist/commands.js +440 -0
  8. package/dist/config.d.ts +138 -0
  9. package/dist/config.js +524 -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 +4719 -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 +25 -0
  37. package/src/commands.ts +452 -0
  38. package/src/config.ts +524 -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,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
+ }
@@ -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() }
@@ -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
+ }