@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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const UI_WIDTH_KV = "openshare.ui-width";
|
|
2
|
+
export declare const UI_HEIGHT_PERCENT_KV = "openshare.ui-height-percent";
|
|
3
|
+
export type DialogWidth = "medium" | "large" | "xlarge";
|
|
4
|
+
export declare const HEIGHT_PERCENT_MIN = 25;
|
|
5
|
+
export declare const HEIGHT_PERCENT_MAX = 100;
|
|
6
|
+
/** what each width means in columns (opencode's fixed dialog widths) */
|
|
7
|
+
export declare const DIALOG_WIDTH_COLUMNS: Record<DialogWidth, number>;
|
|
8
|
+
export declare function clampHeightPercent(value: number): number;
|
|
9
|
+
export declare function nextDialogWidth(width: DialogWidth): DialogWidth;
|
|
10
|
+
/** kv read with validation; falls back to the default on garbage. */
|
|
11
|
+
export declare function kvWidthOf(kv: {
|
|
12
|
+
get<Value = unknown>(key: string, fallback?: Value): Value;
|
|
13
|
+
}): DialogWidth;
|
|
14
|
+
export declare function kvHeightPercentOf(kv: {
|
|
15
|
+
get<Value = unknown>(key: string, fallback?: Value): Value;
|
|
16
|
+
}): number;
|
package/dist/ui-prefs.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
export const UI_WIDTH_KV = "openshare.ui-width";
|
|
8
|
+
export const UI_HEIGHT_PERCENT_KV = "openshare.ui-height-percent";
|
|
9
|
+
export const HEIGHT_PERCENT_MIN = 25;
|
|
10
|
+
export const HEIGHT_PERCENT_MAX = 100;
|
|
11
|
+
/** what each width means in columns (opencode's fixed dialog widths) */
|
|
12
|
+
export const DIALOG_WIDTH_COLUMNS = { medium: 60, large: 88, xlarge: 116 };
|
|
13
|
+
export function clampHeightPercent(value) {
|
|
14
|
+
return Math.max(HEIGHT_PERCENT_MIN, Math.min(HEIGHT_PERCENT_MAX, Math.round(value)));
|
|
15
|
+
}
|
|
16
|
+
export function nextDialogWidth(width) {
|
|
17
|
+
return width === "medium" ? "large" : width === "large" ? "xlarge" : "medium";
|
|
18
|
+
}
|
|
19
|
+
/** kv read with validation; falls back to the default on garbage. */
|
|
20
|
+
export function kvWidthOf(kv) {
|
|
21
|
+
const value = kv.get(UI_WIDTH_KV, "large");
|
|
22
|
+
return value === "medium" || value === "large" || value === "xlarge" ? value : "large";
|
|
23
|
+
}
|
|
24
|
+
export function kvHeightPercentOf(kv) {
|
|
25
|
+
const value = kv.get(UI_HEIGHT_PERCENT_KV, 50);
|
|
26
|
+
return typeof value === "number" && Number.isFinite(value) ? clampHeightPercent(value) : 50;
|
|
27
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type MessageInfo, type PartInfo } from "./core.js";
|
|
2
|
+
type V2Event = {
|
|
3
|
+
type: string;
|
|
4
|
+
data?: Record<string, unknown>;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
type V2Message = {
|
|
8
|
+
id: string;
|
|
9
|
+
type: string;
|
|
10
|
+
time?: {
|
|
11
|
+
created?: number;
|
|
12
|
+
completed?: number;
|
|
13
|
+
streamed?: number;
|
|
14
|
+
};
|
|
15
|
+
text?: string;
|
|
16
|
+
agent?: string;
|
|
17
|
+
model?: {
|
|
18
|
+
providerID: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
modelID?: string;
|
|
21
|
+
};
|
|
22
|
+
content?: Array<Record<string, unknown>>;
|
|
23
|
+
error?: {
|
|
24
|
+
message?: string;
|
|
25
|
+
} | null;
|
|
26
|
+
metadata?: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
type V2SessionClient = {
|
|
29
|
+
get(input: {
|
|
30
|
+
sessionID: string;
|
|
31
|
+
}): Promise<unknown>;
|
|
32
|
+
update(input: {
|
|
33
|
+
sessionID: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
36
|
+
}): Promise<unknown>;
|
|
37
|
+
list(input?: {
|
|
38
|
+
directory?: string;
|
|
39
|
+
parentID?: string;
|
|
40
|
+
cursor?: string;
|
|
41
|
+
limit?: number;
|
|
42
|
+
}): Promise<unknown>;
|
|
43
|
+
diff(input: {
|
|
44
|
+
sessionID: string;
|
|
45
|
+
}): Promise<unknown>;
|
|
46
|
+
remove?(input: {
|
|
47
|
+
sessionID: string;
|
|
48
|
+
}): Promise<unknown>;
|
|
49
|
+
};
|
|
50
|
+
type V2MessageClient = {
|
|
51
|
+
list(input: {
|
|
52
|
+
sessionID: string;
|
|
53
|
+
limit?: number;
|
|
54
|
+
cursor?: string;
|
|
55
|
+
}): Promise<unknown>;
|
|
56
|
+
};
|
|
57
|
+
type V2Context = {
|
|
58
|
+
event?: {
|
|
59
|
+
subscribe(options?: {
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
}): AsyncIterable<V2Event>;
|
|
62
|
+
};
|
|
63
|
+
client?: {
|
|
64
|
+
session?: V2SessionClient;
|
|
65
|
+
message?: V2MessageClient;
|
|
66
|
+
};
|
|
67
|
+
options?: Record<string, unknown>;
|
|
68
|
+
app?: {
|
|
69
|
+
name?: string;
|
|
70
|
+
version?: string;
|
|
71
|
+
channel?: string;
|
|
72
|
+
};
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
};
|
|
75
|
+
/** v2 flat message -> { info, parts } in the v1 shapes the viewer speaks. */
|
|
76
|
+
export declare function convertV2Message(message: V2Message, sessionID: string): {
|
|
77
|
+
info: MessageInfo;
|
|
78
|
+
parts: PartInfo[];
|
|
79
|
+
} | undefined;
|
|
80
|
+
export declare function createV2ServerSetup(): (context: V2Context) => Promise<(() => void) | void>;
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// Open(code)Share — OpenCode v2 server setup.
|
|
2
|
+
//
|
|
3
|
+
// The v2 host validates `default` as `{ id, setup }` and calls setup with
|
|
4
|
+
// the v2 plugin context (packages/plugin/src/promise). The v2 world is
|
|
5
|
+
// shaped differently from v1 in three places this adapter bridges:
|
|
6
|
+
//
|
|
7
|
+
// 1. Events: v2 publishes fine-grained domain events
|
|
8
|
+
// (session.metadata.updated, session.text.started, session.tool.*,
|
|
9
|
+
// session.step.*) — not v1's coarse session/message/part updates.
|
|
10
|
+
// Mapping strategy: structural events (created/deleted/metadata/
|
|
11
|
+
// status) pass into the engine directly; content events trigger a
|
|
12
|
+
// debounced re-sync that pulls the fresh message list through the
|
|
13
|
+
// client. The engine's keyed queue makes re-syncs idempotent, and
|
|
14
|
+
// correctness never depends on decoding every fine-grained shape.
|
|
15
|
+
//
|
|
16
|
+
// 2. Client: the v2 client speaks /api/session with {sessionID} inputs,
|
|
17
|
+
// unwrapped outputs and cursor pagination. The adapter below presents
|
|
18
|
+
// the v1-shaped ClientApi the engine already speaks, including the
|
|
19
|
+
// v2-message -> v1 message+parts conversion (the worker/viewer
|
|
20
|
+
// contract is the v1 Data[] format).
|
|
21
|
+
//
|
|
22
|
+
// 3. Share mode: v2 auto-discovers the package's ./tui entrypoint, so
|
|
23
|
+
// presence detection cannot scan tui.json. The adapter applies the
|
|
24
|
+
// v2-native rule instead: when the plugin's own package declares the
|
|
25
|
+
// tui entrypoint (oc-plugin), a human drives sharing -> manual; the
|
|
26
|
+
// single-file build (no package) stays all-mode. Explicit shareMode
|
|
27
|
+
// config still wins.
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { METADATA_KEY, moduleDir, packageRoot } from "./config.js";
|
|
31
|
+
import { createEngine } from "./core.js";
|
|
32
|
+
// --- v2 message -> v1 items ---------------------------------------------------
|
|
33
|
+
function toolContentText(content) {
|
|
34
|
+
if (typeof content === "string")
|
|
35
|
+
return content;
|
|
36
|
+
if (Array.isArray(content)) {
|
|
37
|
+
return content
|
|
38
|
+
.map((item) => {
|
|
39
|
+
if (typeof item === "string")
|
|
40
|
+
return item;
|
|
41
|
+
if (item && typeof item === "object") {
|
|
42
|
+
const record = item;
|
|
43
|
+
if (typeof record.text === "string")
|
|
44
|
+
return record.text;
|
|
45
|
+
}
|
|
46
|
+
return "";
|
|
47
|
+
})
|
|
48
|
+
.join("");
|
|
49
|
+
}
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
function convertToolState(state) {
|
|
53
|
+
if (!state || typeof state !== "object")
|
|
54
|
+
return { status: "pending" };
|
|
55
|
+
const record = state;
|
|
56
|
+
const status = String(record.status ?? "pending");
|
|
57
|
+
const mapped = {};
|
|
58
|
+
if (status === "streaming" || status === "running")
|
|
59
|
+
mapped.status = "running";
|
|
60
|
+
else if (status === "completed")
|
|
61
|
+
mapped.status = "completed";
|
|
62
|
+
else if (status === "failed" || status === "error")
|
|
63
|
+
mapped.status = "error";
|
|
64
|
+
else
|
|
65
|
+
mapped.status = "pending";
|
|
66
|
+
if (record.input !== undefined)
|
|
67
|
+
mapped.input = record.input;
|
|
68
|
+
if (record.output !== undefined)
|
|
69
|
+
mapped.output = toolContentText(record.output);
|
|
70
|
+
if (record.error !== undefined)
|
|
71
|
+
mapped.error = record.error;
|
|
72
|
+
if (record.time !== undefined)
|
|
73
|
+
mapped.time = record.time;
|
|
74
|
+
return mapped;
|
|
75
|
+
}
|
|
76
|
+
/** v2 flat message -> { info, parts } in the v1 shapes the viewer speaks. */
|
|
77
|
+
export function convertV2Message(message, sessionID) {
|
|
78
|
+
if (!message || typeof message.id !== "string")
|
|
79
|
+
return undefined;
|
|
80
|
+
const base = {
|
|
81
|
+
id: message.id,
|
|
82
|
+
sessionID,
|
|
83
|
+
};
|
|
84
|
+
if (message.type === "user") {
|
|
85
|
+
return {
|
|
86
|
+
info: {
|
|
87
|
+
...base,
|
|
88
|
+
role: "user",
|
|
89
|
+
time: { created: message.time?.created ?? 0 },
|
|
90
|
+
text: message.text ?? "",
|
|
91
|
+
},
|
|
92
|
+
parts: [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (message.type === "assistant") {
|
|
96
|
+
const parts = [];
|
|
97
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
98
|
+
content.forEach((block, index) => {
|
|
99
|
+
const record = block;
|
|
100
|
+
const kind = String(record.type ?? "");
|
|
101
|
+
if (kind === "text") {
|
|
102
|
+
parts.push({
|
|
103
|
+
id: `${message.id}_t${index}`,
|
|
104
|
+
messageID: message.id,
|
|
105
|
+
sessionID,
|
|
106
|
+
type: "text",
|
|
107
|
+
text: String(record.text ?? ""),
|
|
108
|
+
synthetic: false,
|
|
109
|
+
time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else if (kind === "reasoning") {
|
|
113
|
+
parts.push({
|
|
114
|
+
id: `${message.id}_r${index}`,
|
|
115
|
+
messageID: message.id,
|
|
116
|
+
sessionID,
|
|
117
|
+
type: "reasoning",
|
|
118
|
+
text: String(record.text ?? ""),
|
|
119
|
+
synthetic: false,
|
|
120
|
+
time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
else if (kind === "tool") {
|
|
124
|
+
parts.push({
|
|
125
|
+
id: String(record.id ?? `${message.id}_tool${index}`),
|
|
126
|
+
messageID: message.id,
|
|
127
|
+
sessionID,
|
|
128
|
+
type: "tool",
|
|
129
|
+
tool: String(record.name ?? "unknown"),
|
|
130
|
+
state: convertToolState(record.state),
|
|
131
|
+
synthetic: false,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
info: {
|
|
137
|
+
...base,
|
|
138
|
+
role: "assistant",
|
|
139
|
+
agent: message.agent,
|
|
140
|
+
model: message.model
|
|
141
|
+
? { providerID: message.model.providerID, modelID: message.model.modelID ?? message.model.id }
|
|
142
|
+
: undefined,
|
|
143
|
+
time: { created: message.time?.created ?? 0, completed: message.time?.completed ?? 0 },
|
|
144
|
+
},
|
|
145
|
+
parts,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
// --- v2 client -> engine ClientApi ---------------------------------------------
|
|
151
|
+
function adaptSessionClient(session, message, directory) {
|
|
152
|
+
if (!session)
|
|
153
|
+
return undefined;
|
|
154
|
+
const loc = () => (directory ? { directory } : undefined);
|
|
155
|
+
const listAll = async () => {
|
|
156
|
+
const out = [];
|
|
157
|
+
let cursor;
|
|
158
|
+
for (let page = 0; page < 50; page++) {
|
|
159
|
+
const res = (await session.list({ ...loc(), cursor, limit: 100 }));
|
|
160
|
+
const data = res?.data;
|
|
161
|
+
if (Array.isArray(data))
|
|
162
|
+
out.push(...data);
|
|
163
|
+
const next = res?.cursor?.next ?? undefined;
|
|
164
|
+
if (!next || !Array.isArray(data) || data.length === 0)
|
|
165
|
+
break;
|
|
166
|
+
cursor = next;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
};
|
|
170
|
+
const listMessages = async (sessionID) => {
|
|
171
|
+
if (!message)
|
|
172
|
+
return [];
|
|
173
|
+
const out = [];
|
|
174
|
+
let cursor;
|
|
175
|
+
for (let page = 0; page < 50; page++) {
|
|
176
|
+
const res = (await message.list({ sessionID, limit: 100, cursor }));
|
|
177
|
+
const data = res?.data;
|
|
178
|
+
if (!Array.isArray(data))
|
|
179
|
+
break;
|
|
180
|
+
for (const raw of data) {
|
|
181
|
+
const converted = convertV2Message(raw, sessionID);
|
|
182
|
+
if (converted)
|
|
183
|
+
out.push(converted);
|
|
184
|
+
}
|
|
185
|
+
const next = res?.cursor?.next ?? undefined;
|
|
186
|
+
if (!next || data.length === 0)
|
|
187
|
+
break;
|
|
188
|
+
cursor = next;
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
session: {
|
|
194
|
+
async get(input) {
|
|
195
|
+
const res = await session.get({ sessionID: input.path.id });
|
|
196
|
+
return { data: res };
|
|
197
|
+
},
|
|
198
|
+
async update(input) {
|
|
199
|
+
const body = input.body;
|
|
200
|
+
const res = await session.update({ sessionID: input.path.id, title: body.title, metadata: body.metadata });
|
|
201
|
+
return { data: res };
|
|
202
|
+
},
|
|
203
|
+
async list() {
|
|
204
|
+
return { data: await listAll() };
|
|
205
|
+
},
|
|
206
|
+
async messages(input) {
|
|
207
|
+
return { data: await listMessages(input.path.id) };
|
|
208
|
+
},
|
|
209
|
+
async diff(input) {
|
|
210
|
+
const res = await session.diff({ sessionID: input.path.id });
|
|
211
|
+
return { data: res };
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// --- event classification --------------------------------------------------------
|
|
217
|
+
const SESSION_ITEM_EVENTS = new Set([
|
|
218
|
+
"session.created",
|
|
219
|
+
"session.renamed",
|
|
220
|
+
"session.metadata.updated",
|
|
221
|
+
"session.agent.selected",
|
|
222
|
+
"session.model.selected",
|
|
223
|
+
"session.moved",
|
|
224
|
+
"session.permissions",
|
|
225
|
+
"session.compaction.started",
|
|
226
|
+
"session.compaction.ended",
|
|
227
|
+
]);
|
|
228
|
+
const CONTENT_EVENTS_PREFIXES = [
|
|
229
|
+
"session.text.",
|
|
230
|
+
"session.reasoning.",
|
|
231
|
+
"session.tool.",
|
|
232
|
+
"session.message.",
|
|
233
|
+
"session.step.streamed",
|
|
234
|
+
];
|
|
235
|
+
const BUSY_EVENTS = new Set(["session.step.started", "session.execution.started"]);
|
|
236
|
+
const IDLE_EVENTS = new Set(["session.step.ended", "session.execution.succeeded", "session.execution.interrupted"]);
|
|
237
|
+
const ERROR_EVENTS = new Set(["session.step.failed", "session.tool.failed", "session.retry.scheduled"]);
|
|
238
|
+
function eventSessionID(event) {
|
|
239
|
+
const data = event.data;
|
|
240
|
+
if (data && typeof data === "object" && typeof data.sessionID === "string") {
|
|
241
|
+
return data.sessionID;
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
// --- the setup ---------------------------------------------------------------------
|
|
246
|
+
function resolveV2ShareMode(options) {
|
|
247
|
+
const explicit = options?.["shareMode"] ?? process.env.OPENSHARE_SHARE_MODE;
|
|
248
|
+
if (explicit === "all" || explicit === "manual")
|
|
249
|
+
return explicit;
|
|
250
|
+
// v2-native presence: the v2 host auto-loads ./tui for packages that
|
|
251
|
+
// declare it. The single-file build ships no package.json next to it.
|
|
252
|
+
try {
|
|
253
|
+
const root = packageRoot(moduleDir());
|
|
254
|
+
const pkgPath = join(root, "package.json");
|
|
255
|
+
if (existsSync(pkgPath)) {
|
|
256
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
257
|
+
const declared = pkg["oc-plugin"];
|
|
258
|
+
if (Array.isArray(declared) && declared.includes("tui"))
|
|
259
|
+
return "manual";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch { }
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
export function createV2ServerSetup() {
|
|
266
|
+
return async (context) => {
|
|
267
|
+
if (!context?.event?.subscribe || !context.client?.session) {
|
|
268
|
+
// v1 directory loads call BOTH `server` and `setup` on the default
|
|
269
|
+
// export; the v1 input arrives here and trips this guard. That is
|
|
270
|
+
// expected on v1 — stay inert and quiet (debug file only).
|
|
271
|
+
if (process.env.OPENSHARE_DEBUG_FILE) {
|
|
272
|
+
try {
|
|
273
|
+
console.error(`[${METADATA_KEY}] v2 setup not applicable on this host — sharing handled by the server entry`);
|
|
274
|
+
}
|
|
275
|
+
catch { }
|
|
276
|
+
}
|
|
277
|
+
return () => { };
|
|
278
|
+
}
|
|
279
|
+
const directory = context.directory;
|
|
280
|
+
const client = adaptSessionClient(context.client.session, context.client.message, directory);
|
|
281
|
+
const modeOverride = resolveV2ShareMode(context.options);
|
|
282
|
+
const engine = createEngine({
|
|
283
|
+
client,
|
|
284
|
+
pluginDir: moduleDir(),
|
|
285
|
+
directory,
|
|
286
|
+
options: modeOverride ? { ...context.options, shareMode: modeOverride } : context.options,
|
|
287
|
+
});
|
|
288
|
+
if (!engine.hooks.event) {
|
|
289
|
+
return () => { };
|
|
290
|
+
}
|
|
291
|
+
// debounced content re-sync per session: fetch info + messages through
|
|
292
|
+
// the client adapter and feed them into the engine as the v1 events it
|
|
293
|
+
// understands (session.updated / message.updated / message.part.updated).
|
|
294
|
+
// The engine's keyed queue makes repeated re-syncs idempotent.
|
|
295
|
+
const pendingRefresh = new Map();
|
|
296
|
+
const refresh = (sessionID) => {
|
|
297
|
+
let timer = pendingRefresh.get(sessionID);
|
|
298
|
+
if (timer)
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
timer = setTimeout(() => {
|
|
301
|
+
pendingRefresh.delete(sessionID);
|
|
302
|
+
void (async () => {
|
|
303
|
+
try {
|
|
304
|
+
const info = await client.session.get({ path: { id: sessionID } });
|
|
305
|
+
await engine.hooks.event({
|
|
306
|
+
event: { type: "session.updated", properties: { sessionID, info: info.data ?? info } },
|
|
307
|
+
});
|
|
308
|
+
const messages = await client.session.messages?.({ path: { id: sessionID } });
|
|
309
|
+
const list = (messages?.data ?? []);
|
|
310
|
+
for (const message of list) {
|
|
311
|
+
await engine.hooks.event({
|
|
312
|
+
event: { type: "message.updated", properties: { sessionID, info: message.info } },
|
|
313
|
+
});
|
|
314
|
+
for (const part of message.parts ?? []) {
|
|
315
|
+
await engine.hooks.event({
|
|
316
|
+
event: { type: "message.part.updated", properties: { sessionID, part } },
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
/* refresh is best-effort; the next event retries */
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
}, 400);
|
|
326
|
+
pendingRefresh.set(sessionID, timer);
|
|
327
|
+
};
|
|
328
|
+
const unsubscribe = (() => {
|
|
329
|
+
let stopped = false;
|
|
330
|
+
const run = async () => {
|
|
331
|
+
try {
|
|
332
|
+
const stream = context.event.subscribe({ signal: context.signal });
|
|
333
|
+
for await (const event of stream) {
|
|
334
|
+
if (stopped)
|
|
335
|
+
break;
|
|
336
|
+
if (!event || typeof event.type !== "string")
|
|
337
|
+
continue;
|
|
338
|
+
const sid = eventSessionID(event);
|
|
339
|
+
if (!sid)
|
|
340
|
+
continue;
|
|
341
|
+
const type = event.type;
|
|
342
|
+
if (type === "session.deleted") {
|
|
343
|
+
await engine.hooks.event({ event: { type, properties: { sessionID: sid } } });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (type === "message.removed" || type === "message.part.removed") {
|
|
347
|
+
// undo cleanup: forwarded verbatim — the engine emits the
|
|
348
|
+
// tombstones that purge the reverted items from the share
|
|
349
|
+
const data = (event.data ?? {});
|
|
350
|
+
await engine.hooks.event({
|
|
351
|
+
event: { type, properties: { sessionID: sid, messageID: data.messageID, partID: data.partID } },
|
|
352
|
+
});
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (SESSION_ITEM_EVENTS.has(type)) {
|
|
356
|
+
refresh(sid);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (BUSY_EVENTS.has(type)) {
|
|
360
|
+
await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "busy" } } } });
|
|
361
|
+
refresh(sid);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (IDLE_EVENTS.has(type)) {
|
|
365
|
+
await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "idle" } } } });
|
|
366
|
+
refresh(sid);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (type === "session.error" || type === "session.execution.failed") {
|
|
370
|
+
// terminal failure: the share says error at the next idle
|
|
371
|
+
// (recovered retries never set it)
|
|
372
|
+
await engine.hooks.event({ event: { type: "session.error", properties: { sessionID: sid } } });
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (ERROR_EVENTS.has(type)) {
|
|
376
|
+
await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "retry" } } } });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (CONTENT_EVENTS_PREFIXES.some((prefix) => type.startsWith(prefix))) {
|
|
380
|
+
refresh(sid);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
/* stream ended or aborted */
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
void run();
|
|
390
|
+
return () => {
|
|
391
|
+
stopped = true;
|
|
392
|
+
};
|
|
393
|
+
})();
|
|
394
|
+
return () => {
|
|
395
|
+
unsubscribe();
|
|
396
|
+
for (const timer of pendingRefresh.values())
|
|
397
|
+
clearTimeout(timer);
|
|
398
|
+
pendingRefresh.clear();
|
|
399
|
+
void engine.hooks.dispose();
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
}
|
package/dist/v2-tui.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
import type { ShareHostApi } from "./tui-host.js";
|
|
16
|
+
import type { V2TuiContext, V2TuiSetup } from "./v2-types.js";
|
|
17
|
+
/** Builds the v1-shaped ShareHostApi facade over a v2 TUI plugin context. */
|
|
18
|
+
export declare function buildV2HostApi(context: V2TuiContext): ShareHostApi;
|
|
19
|
+
/**
|
|
20
|
+
* v2 TUI plugin setup: registers the share commands (one reactive keymap
|
|
21
|
+
* layer). Session-context wiring (currentSessionID) finalizes in phase 4
|
|
22
|
+
* once the beta's session/route surface is verified.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createV2TuiSetup(): V2TuiSetup;
|