@mirrowel/opencode-openshare 0.1.0 → 0.2.0-dev.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/README.md +36 -30
- package/ci/openshare.config.json +7 -2
- package/ci/openshare.js +625 -197
- package/dist/config.d.ts +5 -1
- package/dist/config.js +9 -2
- package/dist/contract.d.ts +156 -0
- package/dist/contract.js +263 -0
- package/dist/core.js +411 -55
- package/dist/dialogs.js +38 -0
- package/dist/share-flow.js +2 -2
- package/dist/sidebar.d.ts +3 -1
- package/dist/sidebar.js +3 -17
- package/dist/tui.js +73 -121
- package/dist/v2-server.d.ts +0 -27
- package/dist/v2-server.js +6 -121
- package/openshare.config.json +1 -1
- package/package.json +4 -2
- package/src/banner.txt +7 -4
- package/src/config.ts +8 -2
- package/src/contract.ts +320 -0
- package/src/core.ts +403 -56
- package/src/dialogs.tsx +34 -0
- package/src/share-flow.ts +2 -2
- package/src/sidebar.tsx +4 -21
- package/src/tui.tsx +1 -1
- package/src/v2-server.ts +7 -110
package/src/dialogs.tsx
CHANGED
|
@@ -576,6 +576,32 @@ export function MenuDialog<Value>(props: {
|
|
|
576
576
|
)
|
|
577
577
|
}
|
|
578
578
|
|
|
579
|
+
/** Guard against stacked plugin dialogs. `dialog.replace` is documented as
|
|
580
|
+
* an atomic swap, but on some host builds a second replace while a dialog is
|
|
581
|
+
* still pending leaves BOTH renderers mounted - the reader sees two menus
|
|
582
|
+
* with two highlighted rows and keys go to a stale layer ("/share does
|
|
583
|
+
* nothing"). Cancelling the pending dialog and clearing before the next
|
|
584
|
+
* replace keeps exactly one dialog alive. The normal single-dialog path is
|
|
585
|
+
* untouched, so the esc-to-parent flicker fix stays intact. */
|
|
586
|
+
let pendingDialog: (() => void) | undefined
|
|
587
|
+
function claimDialog(api: TuiPluginApi): () => void {
|
|
588
|
+
if (pendingDialog) {
|
|
589
|
+
try {
|
|
590
|
+
pendingDialog()
|
|
591
|
+
} catch {}
|
|
592
|
+
try {
|
|
593
|
+
api.ui.dialog.clear()
|
|
594
|
+
} catch {}
|
|
595
|
+
}
|
|
596
|
+
let released = false
|
|
597
|
+
const release = () => {
|
|
598
|
+
if (released) return
|
|
599
|
+
released = true
|
|
600
|
+
if (pendingDialog === release) pendingDialog = undefined
|
|
601
|
+
}
|
|
602
|
+
pendingDialog = release
|
|
603
|
+
return release
|
|
604
|
+
}
|
|
579
605
|
/** showMenuOnce, family discipline with the flicker fix (2026-10-19):
|
|
580
606
|
* done() does NOT clear the host stack — the next dialog's replace() is an
|
|
581
607
|
* atomic swap, so clearing first paints one empty frame (the esc-to-parent
|
|
@@ -584,9 +610,11 @@ export function MenuDialog<Value>(props: {
|
|
|
584
610
|
export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; options: MenuOption<Value>[]; current?: Value; hint?: string }): Promise<Value | undefined> {
|
|
585
611
|
return new Promise((resolve) => {
|
|
586
612
|
let settled = false
|
|
613
|
+
const release = claimDialog(api)
|
|
587
614
|
const done = (value: Value | undefined, _clear = false) => {
|
|
588
615
|
if (settled) return
|
|
589
616
|
settled = true
|
|
617
|
+
release()
|
|
590
618
|
dbgLog(`menu(${props.title}) -> ${value === undefined ? "cancel" : String(value)}`)
|
|
591
619
|
resolve(value)
|
|
592
620
|
}
|
|
@@ -610,9 +638,11 @@ export type DialogAction = { title: string; value: string; key?: string; danger?
|
|
|
610
638
|
export function showInfoDialog(api: TuiPluginApi, props: { title: string; message: string; actions?: DialogAction[] }): Promise<string | undefined> {
|
|
611
639
|
return new Promise((resolve) => {
|
|
612
640
|
let settled = false
|
|
641
|
+
const release = claimDialog(api)
|
|
613
642
|
const done = (value: string | undefined) => {
|
|
614
643
|
if (settled) return
|
|
615
644
|
settled = true
|
|
645
|
+
release()
|
|
616
646
|
resolve(value)
|
|
617
647
|
}
|
|
618
648
|
api.ui.dialog.replace(
|
|
@@ -799,9 +829,11 @@ export function PromptDialog(props: {
|
|
|
799
829
|
export function showPromptOnce(api: TuiPluginApi, props: { title: string; placeholder?: string; value?: string }): Promise<string | undefined> {
|
|
800
830
|
return new Promise((resolve) => {
|
|
801
831
|
let settled = false
|
|
832
|
+
const release = claimDialog(api)
|
|
802
833
|
const done = (value: string | undefined, _clear = false) => {
|
|
803
834
|
if (settled) return
|
|
804
835
|
settled = true
|
|
836
|
+
release()
|
|
805
837
|
dbgLog(`prompt(${props.title}) -> ${value === undefined ? "cancel" : `len=${value.length}`}`)
|
|
806
838
|
resolve(value)
|
|
807
839
|
}
|
|
@@ -829,9 +861,11 @@ export type SizePickerResult = { width: DialogWidth; heightPercent: number }
|
|
|
829
861
|
export function showSizePickerOnce(api: TuiPluginApi, current: SizePickerResult): Promise<SizePickerResult | undefined> {
|
|
830
862
|
return new Promise((resolve) => {
|
|
831
863
|
let settled = false
|
|
864
|
+
const release = claimDialog(api)
|
|
832
865
|
const done = (value: SizePickerResult | undefined) => {
|
|
833
866
|
if (settled) return
|
|
834
867
|
settled = true
|
|
868
|
+
release()
|
|
835
869
|
resolve(value)
|
|
836
870
|
}
|
|
837
871
|
api.ui.dialog.replace(
|
package/src/share-flow.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// OpenShare share flows — host-agnostic logic behind /share
|
|
1
|
+
// OpenShare share flows — host-agnostic logic behind /share and
|
|
2
2
|
// the settings dialog's "this session" actions. Runs against the
|
|
3
3
|
// ShareHostApi facade (tui-host.ts) and the shared config module, so the
|
|
4
4
|
// same compiled code serves the v1 TUI and the v2 adapter.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (with per-session opts when present) -> wait for the server
|
|
10
10
|
// plugin to publish the binding URL into session metadata
|
|
11
11
|
// (~seconds; worker-down surfaces as a timeout toast) -> copy.
|
|
12
|
-
//
|
|
12
|
+
// stop-sharing resolve root -> confirm -> read binding -> clear flag ->
|
|
13
13
|
// DELETE the share directly via the worker (belt and braces:
|
|
14
14
|
// works even if the server plugin never saw the flag event) ->
|
|
15
15
|
// the server-side flag-removal path cleans up on its own.
|
package/src/sidebar.tsx
CHANGED
|
@@ -28,12 +28,6 @@ import type { JSX } from "@opentui/solid"
|
|
|
28
28
|
import { bindingsOf, bindingOf, verifyShare } from "./share-flow.js"
|
|
29
29
|
import type { TuiPluginApi, TuiSlotContext } from "@opencode-ai/plugin/tui"
|
|
30
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
31
|
/** value -> `kind:preview` so the log shows TYPES (the crash was a type bug) */
|
|
38
32
|
export function dbg(v: unknown, depth = 0): string {
|
|
39
33
|
const t = typeof v
|
|
@@ -69,13 +63,9 @@ function dbgEl(el: unknown, depth = 0): string {
|
|
|
69
63
|
}
|
|
70
64
|
}
|
|
71
65
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
} catch {}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let renderSeq = 0
|
|
66
|
+
/** no-op: the TUI debug log file was a crash-hunt aid and must not write
|
|
67
|
+
* to temp any more (owner request 2026-10-21) */
|
|
68
|
+
export function dbgLog(_line: string): void {}
|
|
79
69
|
|
|
80
70
|
/** Share liveness cache for the chip: a binding can claim "shared" long
|
|
81
71
|
* after the page died server-side (purge/TTL/delete with no event ever
|
|
@@ -102,13 +92,10 @@ type SidebarTitleProps = {
|
|
|
102
92
|
}
|
|
103
93
|
|
|
104
94
|
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
95
|
return {
|
|
107
96
|
slots: {
|
|
108
97
|
sidebar_title: (ctx: TuiSlotContext, props: SidebarTitleProps): JSX.Element => {
|
|
109
|
-
const seq = ++renderSeq
|
|
110
98
|
try {
|
|
111
|
-
dbgLog(`render#${seq} ctx=${dbg(ctx)} props=${dbg(props)}`)
|
|
112
99
|
const sid = typeof props?.session_id === "string" ? props.session_id : ""
|
|
113
100
|
const title = String(props?.title ?? "") || "Session"
|
|
114
101
|
const session = sid ? api.state.session.get(sid) : undefined
|
|
@@ -122,9 +109,7 @@ export function createSidebarSlot(api: TuiPluginApi, opts?: { onShareCommand?: (
|
|
|
122
109
|
const anyGone = verdicts.some((v) => v?.status === "gone")
|
|
123
110
|
const multi = liveCount > 1 ? `·${liveCount} ` : ""
|
|
124
111
|
const theme = ctx?.theme?.current ?? api.theme.current
|
|
125
|
-
|
|
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
|
-
)
|
|
112
|
+
|
|
128
113
|
// chip on its own line under the title (takes the blank line that
|
|
129
114
|
// separated the title block from the context blocks); clicking it
|
|
130
115
|
// copies the share link — proven pattern: onMouseDown on a box.
|
|
@@ -175,10 +160,8 @@ export function createSidebarSlot(api: TuiPluginApi, opts?: { onShareCommand?: (
|
|
|
175
160
|
) : null}
|
|
176
161
|
</box>
|
|
177
162
|
)
|
|
178
|
-
dbgLog(`render#${seq} RETURNS ${dbgEl(el)}`)
|
|
179
163
|
return el
|
|
180
164
|
} catch (error) {
|
|
181
|
-
dbgLog(`render#${seq} THREW ${(error as Error)?.stack ?? dbg(error)} — rethrowing`)
|
|
182
165
|
throw error
|
|
183
166
|
}
|
|
184
167
|
},
|
package/src/tui.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// OpenShare TUI plugin entry (OpenCode v1).
|
|
4
4
|
//
|
|
5
|
-
// Registers the /share,
|
|
5
|
+
// Registers the /share (toggle + manager, shadowing the deprecated opncd.ai
|
|
6
6
|
// built-ins via a priority keymap layer) and /openshare commands, plus the
|
|
7
7
|
// sidebar_title slot chip. The flows run against the ShareHostApi facade
|
|
8
8
|
// (tui-host.ts); v2 hosts run the same flows through the v2-tui adapter.
|
package/src/v2-server.ts
CHANGED
|
@@ -74,114 +74,10 @@ type V2Context = {
|
|
|
74
74
|
signal?: AbortSignal
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// --- v2 message -> v1 items ---------------------------------------------------
|
|
78
|
-
|
|
79
|
-
function toolContentText(content: unknown): string {
|
|
80
|
-
if (typeof content === "string") return content
|
|
81
|
-
if (Array.isArray(content)) {
|
|
82
|
-
return content
|
|
83
|
-
.map((item) => {
|
|
84
|
-
if (typeof item === "string") return item
|
|
85
|
-
if (item && typeof item === "object") {
|
|
86
|
-
const record = item as Record<string, unknown>
|
|
87
|
-
if (typeof record.text === "string") return record.text
|
|
88
|
-
}
|
|
89
|
-
return ""
|
|
90
|
-
})
|
|
91
|
-
.join("")
|
|
92
|
-
}
|
|
93
|
-
return ""
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function convertToolState(state: unknown): Record<string, unknown> {
|
|
97
|
-
if (!state || typeof state !== "object") return { status: "pending" }
|
|
98
|
-
const record = state as Record<string, unknown>
|
|
99
|
-
const status = String(record.status ?? "pending")
|
|
100
|
-
const mapped: Record<string, unknown> = {}
|
|
101
|
-
if (status === "streaming" || status === "running") mapped.status = "running"
|
|
102
|
-
else if (status === "completed") mapped.status = "completed"
|
|
103
|
-
else if (status === "failed" || status === "error") mapped.status = "error"
|
|
104
|
-
else mapped.status = "pending"
|
|
105
|
-
if (record.input !== undefined) mapped.input = record.input
|
|
106
|
-
if (record.output !== undefined) mapped.output = toolContentText(record.output)
|
|
107
|
-
if (record.error !== undefined) mapped.error = record.error
|
|
108
|
-
if (record.time !== undefined) mapped.time = record.time
|
|
109
|
-
return mapped
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** v2 flat message -> { info, parts } in the v1 shapes the viewer speaks. */
|
|
113
|
-
export function convertV2Message(message: V2Message, sessionID: string): { info: MessageInfo; parts: PartInfo[] } | undefined {
|
|
114
|
-
if (!message || typeof message.id !== "string") return undefined
|
|
115
|
-
const base = {
|
|
116
|
-
id: message.id,
|
|
117
|
-
sessionID,
|
|
118
|
-
}
|
|
119
|
-
if (message.type === "user") {
|
|
120
|
-
return {
|
|
121
|
-
info: {
|
|
122
|
-
...base,
|
|
123
|
-
role: "user",
|
|
124
|
-
time: { created: message.time?.created ?? 0 },
|
|
125
|
-
text: message.text ?? "",
|
|
126
|
-
},
|
|
127
|
-
parts: [],
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if (message.type === "assistant") {
|
|
131
|
-
const parts: PartInfo[] = []
|
|
132
|
-
const content = Array.isArray(message.content) ? message.content : []
|
|
133
|
-
content.forEach((block, index) => {
|
|
134
|
-
const record = block as Record<string, unknown>
|
|
135
|
-
const kind = String(record.type ?? "")
|
|
136
|
-
if (kind === "text") {
|
|
137
|
-
parts.push({
|
|
138
|
-
id: `${message.id}_t${index}`,
|
|
139
|
-
messageID: message.id,
|
|
140
|
-
sessionID,
|
|
141
|
-
type: "text",
|
|
142
|
-
text: String(record.text ?? ""),
|
|
143
|
-
synthetic: false,
|
|
144
|
-
time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
|
|
145
|
-
})
|
|
146
|
-
} else if (kind === "reasoning") {
|
|
147
|
-
parts.push({
|
|
148
|
-
id: `${message.id}_r${index}`,
|
|
149
|
-
messageID: message.id,
|
|
150
|
-
sessionID,
|
|
151
|
-
type: "reasoning",
|
|
152
|
-
text: String(record.text ?? ""),
|
|
153
|
-
synthetic: false,
|
|
154
|
-
time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
|
|
155
|
-
})
|
|
156
|
-
} else if (kind === "tool") {
|
|
157
|
-
parts.push({
|
|
158
|
-
id: String(record.id ?? `${message.id}_tool${index}`),
|
|
159
|
-
messageID: message.id,
|
|
160
|
-
sessionID,
|
|
161
|
-
type: "tool",
|
|
162
|
-
tool: String(record.name ?? "unknown"),
|
|
163
|
-
state: convertToolState(record.state),
|
|
164
|
-
synthetic: false,
|
|
165
|
-
})
|
|
166
|
-
}
|
|
167
|
-
})
|
|
168
|
-
return {
|
|
169
|
-
info: {
|
|
170
|
-
...base,
|
|
171
|
-
role: "assistant",
|
|
172
|
-
agent: message.agent,
|
|
173
|
-
model: message.model
|
|
174
|
-
? { providerID: message.model.providerID, modelID: message.model.modelID ?? message.model.id }
|
|
175
|
-
: undefined,
|
|
176
|
-
time: { created: message.time?.created ?? 0, completed: message.time?.completed ?? 0 },
|
|
177
|
-
},
|
|
178
|
-
parts,
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return undefined
|
|
182
|
-
}
|
|
183
|
-
|
|
184
77
|
// --- v2 client -> engine ClientApi ---------------------------------------------
|
|
78
|
+
// Contract v2: messages pass through VERBATIM as v2 session messages (the
|
|
79
|
+
// engine's ingestion detects v2-native shapes and enqueues them directly —
|
|
80
|
+
// no v1 conversion, no dropped compaction, no dropped user text).
|
|
185
81
|
|
|
186
82
|
function adaptSessionClient(session: V2SessionClient | undefined, message: V2MessageClient | undefined, directory: string | undefined): ClientApi | undefined {
|
|
187
83
|
if (!session) return undefined
|
|
@@ -203,7 +99,7 @@ function adaptSessionClient(session: V2SessionClient | undefined, message: V2Mes
|
|
|
203
99
|
|
|
204
100
|
const listMessages = async (sessionID: string) => {
|
|
205
101
|
if (!message) return []
|
|
206
|
-
const out: Array<{ info:
|
|
102
|
+
const out: Array<{ info: unknown; parts: unknown[] }> = []
|
|
207
103
|
let cursor: string | undefined
|
|
208
104
|
for (let page = 0; page < 50; page++) {
|
|
209
105
|
const res = (await message.list({ sessionID, limit: 100, cursor })) as
|
|
@@ -212,8 +108,9 @@ function adaptSessionClient(session: V2SessionClient | undefined, message: V2Mes
|
|
|
212
108
|
const data = res?.data
|
|
213
109
|
if (!Array.isArray(data)) break
|
|
214
110
|
for (const raw of data) {
|
|
215
|
-
|
|
216
|
-
|
|
111
|
+
if (raw && typeof raw === "object" && typeof (raw as { id?: unknown }).id === "string") {
|
|
112
|
+
out.push({ info: raw, parts: [] })
|
|
113
|
+
}
|
|
217
114
|
}
|
|
218
115
|
const next = res?.cursor?.next ?? undefined
|
|
219
116
|
if (!next || data.length === 0) break
|