@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
package/src/v2-tui.ts ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * OpenCode v2 TUI plugin implementation: builds a ShareHostApi adapter
3
+ * over the v2 TUI plugin context so the unchanged share flows and settings
4
+ * dialog run on both hosts, and registers the same commands (one reactive
5
+ * keymap layer).
6
+ *
7
+ * Dialog bridging (family pattern): the v1 api exposes dialog components
8
+ * that RETURN JSX rendered inside an enclosing `dialog.replace(render)`
9
+ * entry; the v2 api only offers promise-based dialogs that open their own
10
+ * host entry. The bridge reconciles the two — `replace()` invokes the
11
+ * render thunk synchronously; if it returned nothing (a DialogX bridge
12
+ * already opened the host's own dialog), the caller's onClose is parked
13
+ * and invoked when that nested dialog is dismissed without a result.
14
+ */
15
+
16
+ import { createSignal } from "solid-js"
17
+ import { registerCommands } from "./commands.js"
18
+ import type { ClientApi } from "./core.js"
19
+ import type { ShareDialogSelectOption, ShareHostApi, ShareHostKeymapLayer } from "./tui-host.js"
20
+ import type { V2KeymapCommand, V2KeymapLayer, V2TuiContext, V2TuiSetup } from "./v2-types.js"
21
+
22
+ function pickThemeToken(source: unknown, ...path: Array<string | number>): unknown {
23
+ let current: unknown = source
24
+ for (const key of path) {
25
+ if (!current || typeof current !== "object") return undefined
26
+ current = (current as Record<string, unknown>)[key]
27
+ }
28
+ return current
29
+ }
30
+
31
+ function buildV2Theme(context: V2TuiContext) {
32
+ // Read theme/mode live inside the getter so mid-session theme switches
33
+ // reach the flows.
34
+ return {
35
+ get current() {
36
+ const theme = context.theme
37
+ const dark = context.themeMode === "dark"
38
+ const text = pickThemeToken(theme, "text", "default")
39
+ return {
40
+ text,
41
+ textMuted: pickThemeToken(theme, "text", "subdued") ?? text,
42
+ background: pickThemeToken(theme, "background", "default"),
43
+ backgroundPanel: pickThemeToken(theme, "background", "surface", "offset") ?? pickThemeToken(theme, "background", "default"),
44
+ primary: pickThemeToken(theme, "text", "action", "primary", "selected") ?? text,
45
+ secondary: pickThemeToken(theme, "hue", dark ? "interactive" : "neutral", dark ? 300 : 700) ?? text,
46
+ accent: pickThemeToken(theme, "hue", "accent", dark ? 200 : 800) ?? text,
47
+ success: pickThemeToken(theme, "text", "feedback", "success", "default") ?? text,
48
+ warning: pickThemeToken(theme, "text", "feedback", "warning", "default") ?? text,
49
+ error: pickThemeToken(theme, "text", "feedback", "error", "default") ?? text,
50
+ info: pickThemeToken(theme, "text", "feedback", "info", "default") ?? text,
51
+ }
52
+ },
53
+ }
54
+ }
55
+
56
+ function convertKeymapLayer(layer: ShareHostKeymapLayer): V2KeymapLayer {
57
+ const bindingsByName = new Map<string, string[]>()
58
+ for (const binding of layer.bindings ?? []) {
59
+ if (!binding?.cmd) continue
60
+ const keys = bindingsByName.get(binding.cmd) ?? []
61
+ keys.push(binding.key)
62
+ bindingsByName.set(binding.cmd, keys)
63
+ }
64
+ const commands: V2KeymapCommand[] = (layer.commands ?? []).map((command) => ({
65
+ id: command.name,
66
+ title: command.title ?? command.name,
67
+ description: command.desc,
68
+ group: command.group ?? command.category,
69
+ palette: true as const,
70
+ ...(command.slashName ? { slash: { name: command.slashName, ...(command.slashAliases?.length ? { aliases: command.slashAliases } : {}) } } : {}),
71
+ ...(command.suggested !== undefined ? { suggested: command.suggested } : {}),
72
+ ...(command.enabled !== undefined ? { enabled: command.enabled } : {}),
73
+ ...(bindingsByName.get(command.name)?.length ? { bind: bindingsByName.get(command.name)!.join(",") } : {}),
74
+ run: (_input?: string, event?: { preventDefault?: () => void; stopPropagation?: () => void }) =>
75
+ command.run({
76
+ event: event
77
+ ? {
78
+ preventDefault: () => event.preventDefault?.(),
79
+ stopPropagation: () => event.stopPropagation?.(),
80
+ }
81
+ : undefined,
82
+ }),
83
+ }))
84
+ return { mode: layer.mode ?? "global", priority: layer.priority, commands }
85
+ }
86
+
87
+ /** Builds the v1-shaped ShareHostApi facade over a v2 TUI plugin context. */
88
+ export function buildV2HostApi(context: V2TuiContext): ShareHostApi {
89
+ const disposeFns: Array<() => void> = []
90
+
91
+ // --- kv: synchronous read-your-writes mirror over the persistent store ---
92
+ const [kvStore, kvMutate] = context.storage.store("kv", { initial: {} as Record<string, unknown> })
93
+ const kvMirror: Record<string, unknown> = {}
94
+ for (const [key, value] of Object.entries(kvStore as Record<string, unknown>)) kvMirror[key] = value
95
+ const kv = {
96
+ get<Value = unknown>(key: string, fallback?: Value): Value {
97
+ const value = key in kvMirror ? kvMirror[key] : (kvStore as Record<string, unknown>)[key]
98
+ return (value === undefined ? fallback : value) as Value
99
+ },
100
+ set(key: string, value: unknown) {
101
+ kvMirror[key] = value
102
+ void kvMutate((draft) => {
103
+ draft[key] = value
104
+ }).catch(() => undefined)
105
+ },
106
+ }
107
+
108
+ // --- dialog bridging (see module doc comment) ---
109
+ const dialog = context.ui.dialog
110
+ let nestedOnClose: (() => void) | undefined
111
+ const settleNested = (dismissed: boolean) => {
112
+ const onClose = nestedOnClose
113
+ nestedOnClose = undefined
114
+ if (dismissed) onClose?.()
115
+ }
116
+
117
+ return {
118
+ hostVersion: 2,
119
+ app: { version: context.app?.version },
120
+ client: context.client as unknown as ClientApi,
121
+ kv,
122
+ lifecycle: {
123
+ onDispose(fn: () => void) {
124
+ disposeFns.push(fn)
125
+ },
126
+ },
127
+ theme: buildV2Theme(context),
128
+ ui: {
129
+ toast: (input) => {
130
+ try {
131
+ context.ui.toast.show({ title: input.title, message: input.message, variant: input.variant, duration: input.duration })
132
+ } catch {
133
+ /* toasts must never crash the flows */
134
+ }
135
+ },
136
+ dialog: {
137
+ replace(renderer: unknown, onClose?: () => void) {
138
+ nestedOnClose = undefined
139
+ let content: unknown
140
+ try {
141
+ content = typeof renderer === "function" ? (renderer as () => unknown)() : renderer
142
+ } catch {
143
+ content = undefined
144
+ }
145
+ if (content === undefined || content === null) {
146
+ nestedOnClose = onClose
147
+ return
148
+ }
149
+ dialog.show(() => content, onClose)
150
+ },
151
+ clear() {
152
+ nestedOnClose = undefined
153
+ dialog.clear()
154
+ },
155
+ setSize(size: string) {
156
+ try {
157
+ dialog.set({ size })
158
+ } catch {
159
+ /* size hints are best-effort */
160
+ }
161
+ },
162
+ },
163
+ // Promise menus (v2 host dialogs are host-native and fully keyed).
164
+ async showMenu<Value>(props: { title: string; options: ShareDialogSelectOption<Value>[]; current?: Value; hint?: string }): Promise<Value | undefined> {
165
+ const value = await dialog.select({
166
+ title: props.title,
167
+ placeholder: props.hint,
168
+ current: props.current,
169
+ options: props.options.map((option) => ({
170
+ title: option.title,
171
+ value: option.value,
172
+ description: option.description,
173
+ category: option.category,
174
+ disabled: option.disabled,
175
+ })),
176
+ })
177
+ return value as Value | undefined
178
+ },
179
+ async showConfirm(props: { title: string; message: string; confirmLabel?: string }) {
180
+ return (await dialog.confirm({ title: props.title, message: props.message, label: { confirm: props.confirmLabel } })) === true
181
+ },
182
+ async showAlert(props: { title: string; message: string }) {
183
+ await dialog.alert({ title: props.title, message: props.message })
184
+ },
185
+ async showPrompt(props: { title: string; placeholder?: string; value?: string }) {
186
+ return await dialog.prompt({ title: props.title, placeholder: props.placeholder, value: props.value })
187
+ },
188
+ DialogSelect(props) {
189
+ void dialog
190
+ .select({
191
+ title: props.title,
192
+ placeholder: props.placeholder,
193
+ current: props.current,
194
+ options: props.options.map((option) => ({
195
+ title: option.title,
196
+ value: option.value,
197
+ description: option.description,
198
+ category: option.category,
199
+ disabled: option.disabled,
200
+ })),
201
+ })
202
+ .then((value) => {
203
+ if (value === undefined) {
204
+ settleNested(true)
205
+ return
206
+ }
207
+ settleNested(false)
208
+ const option = props.options.find((item) => item.value === value)
209
+ if (option) props.onSelect(option)
210
+ })
211
+ .catch(() => settleNested(true))
212
+ },
213
+ DialogPrompt(props) {
214
+ void dialog
215
+ .prompt({ title: props.title, placeholder: props.placeholder, value: props.value })
216
+ .then((value) => {
217
+ settleNested(value === undefined)
218
+ if (value === undefined) props.onCancel?.()
219
+ else props.onConfirm(value)
220
+ })
221
+ .catch(() => {
222
+ settleNested(true)
223
+ props.onCancel?.()
224
+ })
225
+ },
226
+ DialogConfirm(props) {
227
+ void dialog
228
+ .confirm({ title: props.title, message: props.message, label: { confirm: props.confirmLabel } })
229
+ .then((result) => {
230
+ settleNested(result !== true)
231
+ if (result === true) props.onConfirm()
232
+ else props.onCancel?.()
233
+ })
234
+ .catch(() => {
235
+ settleNested(true)
236
+ props.onCancel?.()
237
+ })
238
+ },
239
+ DialogAlert(props) {
240
+ void dialog
241
+ .alert({ title: props.title, message: props.message })
242
+ .then(() => {
243
+ settleNested(false)
244
+ props.onConfirm?.()
245
+ })
246
+ .catch(() => {
247
+ settleNested(true)
248
+ props.onConfirm?.()
249
+ })
250
+ },
251
+ },
252
+ keymap: {
253
+ registerLayer(layer: ShareHostKeymapLayer) {
254
+ // v2 layers are reactive: the input function re-evaluates when the
255
+ // signal flips, so "unregistering" disables the layer.
256
+ const [active, setActive] = createSignal(true)
257
+ try {
258
+ context.keymap.layer(() =>
259
+ active()
260
+ ? convertKeymapLayer(layer)
261
+ : { mode: "global", enabled: false, commands: [], bindings: [] },
262
+ )
263
+ } catch {
264
+ return () => undefined
265
+ }
266
+ return () => {
267
+ setActive(false)
268
+ }
269
+ },
270
+ },
271
+ sessionInfo: () => undefined,
272
+ currentSessionID: undefined,
273
+ clipboard: {
274
+ // same belt-and-braces as the v1 facade: navigator may resolve as a
275
+ // silent no-op in the TUI runtime; OSC52 is the reliable path
276
+ async write(text) {
277
+ let ok = false
278
+ try {
279
+ const clip = (navigator as unknown as { clipboard?: { writeText?: (t: string) => Promise<void> } }).clipboard
280
+ if (typeof clip?.writeText === "function") {
281
+ await clip.writeText(text).then(
282
+ () => {
283
+ ok = true
284
+ },
285
+ () => {},
286
+ )
287
+ }
288
+ } catch {}
289
+ try {
290
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`)
291
+ ok = true
292
+ } catch {}
293
+ return ok
294
+ },
295
+ },
296
+ }
297
+ }
298
+
299
+ /**
300
+ * v2 TUI plugin setup: registers the share commands (one reactive keymap
301
+ * layer). Session-context wiring (currentSessionID) finalizes in phase 4
302
+ * once the beta's session/route surface is verified.
303
+ */
304
+ export function createV2TuiSetup(): V2TuiSetup {
305
+ return async (context: V2TuiContext) => {
306
+ const host = buildV2HostApi(context)
307
+ const sessionID = () => host.currentSessionID?.() ?? undefined
308
+ const unregister = registerCommands(host, sessionID)
309
+ return () => {
310
+ try {
311
+ unregister()
312
+ } catch {}
313
+ }
314
+ }
315
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Minimal structural types for the OpenCode v2 TUI plugin context subset
3
+ * used by this plugin. Hand-written on purpose (same policy as the server
4
+ * side and the family plugins): the v2 SDK is still beta and a v2 plugin
5
+ * module must not import the plugin SDK at runtime anyway. Keep in sync
6
+ * with the beta repo (packages/plugin/src/tui) when the API settles.
7
+ */
8
+
9
+ export type V2TuiKeyEvent = {
10
+ preventDefault?(): void
11
+ stopPropagation?(): void
12
+ [key: string]: unknown
13
+ }
14
+
15
+ export type V2KeymapCommand = {
16
+ id?: string
17
+ title?: string
18
+ description?: string
19
+ group?: string
20
+ enabled?: boolean | (() => boolean)
21
+ bind?: false | string
22
+ palette?: true
23
+ slash?: { name: string; aliases?: string[]; arguments?: true }
24
+ suggested?: boolean | (() => boolean)
25
+ run: (input?: string, event?: V2TuiKeyEvent) => void | false | Promise<void>
26
+ }
27
+
28
+ export type V2KeymapLayer = {
29
+ mode?: string
30
+ enabled?: boolean | (() => boolean)
31
+ priority?: number
32
+ commands?: readonly V2KeymapCommand[]
33
+ bindings?: readonly string[]
34
+ }
35
+
36
+ export type V2DialogSelectOption<Value> = {
37
+ title: string
38
+ value: Value
39
+ description?: string
40
+ category?: string
41
+ disabled?: boolean
42
+ }
43
+
44
+ export type V2LocationRef = { directory: string; workspaceID?: string }
45
+
46
+ export type V2TuiContext = {
47
+ options: Readonly<Record<string, unknown>>
48
+ location?: unknown
49
+ app: { version?: string; channel?: string }
50
+ renderer: { root?: unknown }
51
+ client: any
52
+ keymap: {
53
+ layer(input: () => V2KeymapLayer): void
54
+ mode: { current(): string; push(mode: string): () => void }
55
+ }
56
+ storage: {
57
+ store<Value extends object>(
58
+ key: string,
59
+ options: { initial: Value },
60
+ ): readonly [Value, (mutation: (draft: Value) => void) => Promise<void>]
61
+ }
62
+ theme: V2ResolvedTheme
63
+ themeMode: "dark" | "light"
64
+ ui: {
65
+ dialog: {
66
+ show(render: () => unknown, onClose?: () => void): void
67
+ set(options: { size?: string; centered?: boolean }): void
68
+ clear(): void
69
+ alert(options: { title: string; message: string }): Promise<void>
70
+ confirm(options: {
71
+ title: string
72
+ message: string
73
+ label?: { confirm?: string; cancel?: string }
74
+ }): Promise<boolean | undefined>
75
+ prompt(options: { title: string; description?: string; placeholder?: string; value?: string }): Promise<string | undefined>
76
+ select<Value>(options: {
77
+ title: string
78
+ placeholder?: string
79
+ current?: Value
80
+ options: readonly V2DialogSelectOption<Value>[]
81
+ }): Promise<Value | undefined>
82
+ }
83
+ toast: { show(options: { title?: string; message: string; variant?: string; duration?: number }): void }
84
+ }
85
+ }
86
+
87
+ export type V2ResolvedTheme = {
88
+ text: {
89
+ default: unknown
90
+ subdued: unknown
91
+ feedback: Record<string, { default: unknown }>
92
+ action: Record<string, Record<string, unknown>>
93
+ }
94
+ background: {
95
+ default: unknown
96
+ surface: { offset: unknown; overlay: unknown }
97
+ }
98
+ hue: Record<string, Record<string | number, unknown>>
99
+ categorical?: ReadonlyArray<unknown>
100
+ }
101
+
102
+ export type V2TuiSetup = (context: V2TuiContext) => Promise<(() => void) | void> | (() => void) | void
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2023", "DOM"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": false,
10
+ "noEmitOnError": true,
11
+ "declaration": true,
12
+ "jsx": "react-jsx",
13
+ "jsxImportSource": "@opentui/solid",
14
+ "skipLibCheck": true,
15
+ "forceConsistentCasingInFileNames": true
16
+ },
17
+ "include": ["src"]
18
+ }