@rmrdeveloper/sideroom-pi 8.12.0 → 8.14.0

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 (32) hide show
  1. package/README.md +16 -0
  2. package/assets/visual-context/browser/INSTALL.md +69 -0
  3. package/assets/visual-context/browser/background.js +578 -0
  4. package/assets/visual-context/browser/content.js +198 -0
  5. package/assets/visual-context/browser/icons/README.md +9 -0
  6. package/assets/visual-context/browser/icons/icon-128.png +0 -0
  7. package/assets/visual-context/browser/icons/icon-16.png +0 -0
  8. package/assets/visual-context/browser/icons/icon-32.png +0 -0
  9. package/assets/visual-context/browser/icons/icon-48.png +0 -0
  10. package/assets/visual-context/browser/manifest.json +20 -0
  11. package/assets/visual-context/browser/popup.css +19 -0
  12. package/assets/visual-context/browser/popup.html +27 -0
  13. package/assets/visual-context/browser/popup.js +141 -0
  14. package/extensions/visual-context/bridge.ts +240 -0
  15. package/extensions/visual-context/broker.ts +292 -0
  16. package/extensions/visual-context/envelope.ts +34 -0
  17. package/extensions/visual-context/index.ts +328 -0
  18. package/extensions/visual-context/literal.ts +34 -0
  19. package/extensions/visual-context/markup.ts +104 -0
  20. package/extensions/visual-context/match.ts +222 -0
  21. package/extensions/visual-context/model.ts +180 -0
  22. package/extensions/visual-context/pairing.ts +114 -0
  23. package/extensions/visual-context/parse-astro.ts +166 -0
  24. package/extensions/visual-context/parse-html.ts +72 -0
  25. package/extensions/visual-context/parse-jsx.ts +157 -0
  26. package/extensions/visual-context/parse-svelte.ts +128 -0
  27. package/extensions/visual-context/parse-vue.ts +133 -0
  28. package/extensions/visual-context/session.ts +192 -0
  29. package/extensions/visual-context/source.ts +197 -0
  30. package/extensions/visual-context/ui.ts +57 -0
  31. package/package.json +16 -7
  32. package/skills/sideroom-codebase-design/SKILL.md +28 -1
@@ -0,0 +1,292 @@
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { Check } from 'typebox/value';
4
+ import { type WebSocket, WebSocketServer } from 'ws';
5
+ import { parseEnvelope, send } from './envelope.ts';
6
+ import {
7
+ BRIDGE_PATH,
8
+ MAX_MESSAGE_BYTES,
9
+ MESSAGE_TYPE,
10
+ PEER_ROLE,
11
+ PI_ORIGIN,
12
+ PROTOCOL_VERSION,
13
+ parseSelection,
14
+ type SessionDescriptor,
15
+ SessionDescriptorSchema,
16
+ } from './model.ts';
17
+ import type { PairingIdentity } from './pairing.ts';
18
+
19
+ const MAX_PEERS = 20;
20
+ const REQUEST_TIMEOUT_MS = 10_000;
21
+ const MIN_SELECTION_INTERVAL_MS = 300;
22
+ const EXTENSION_ORIGIN = /^chrome-extension:\/\/[a-p]{32}$/u;
23
+ interface Peer {
24
+ role?: string;
25
+ session?: SessionDescriptor;
26
+ lastSelectionAt: number;
27
+ }
28
+ interface Delivery {
29
+ browser: WebSocket;
30
+ pi: WebSocket;
31
+ id: string;
32
+ revision: string;
33
+ timer: NodeJS.Timeout;
34
+ }
35
+ export interface LocalBroker {
36
+ close(): Promise<void>;
37
+ }
38
+
39
+ export async function startBroker({
40
+ identity,
41
+ onError,
42
+ }: {
43
+ readonly identity: PairingIdentity;
44
+ readonly onError: (error: Error) => void;
45
+ }): Promise<LocalBroker> {
46
+ const server = createServer((_request, response) => {
47
+ response.writeHead(404, { 'Cache-Control': 'no-store' });
48
+ response.end();
49
+ });
50
+ const sockets = new WebSocketServer({
51
+ noServer: true,
52
+ maxPayload: MAX_MESSAGE_BYTES,
53
+ });
54
+ const peers = new Map<WebSocket, Peer>();
55
+ const deliveries = new Map<string, Delivery>();
56
+ const sessions = () =>
57
+ [...peers.values()].flatMap((peer) => (peer.session ? [peer.session] : []));
58
+ const publish = () => {
59
+ for (const [socket, peer] of peers) {
60
+ if (peer.role === PEER_ROLE.browser) {
61
+ send(socket, { type: MESSAGE_TYPE.sessions, sessions: sessions() });
62
+ }
63
+ }
64
+ };
65
+ server.on('upgrade', (request, socket, head) => {
66
+ const origin = request.headers.origin;
67
+ if (
68
+ request.url !== BRIDGE_PATH ||
69
+ typeof origin !== 'string' ||
70
+ (origin !== PI_ORIGIN && !EXTENSION_ORIGIN.test(origin)) ||
71
+ peers.size >= MAX_PEERS
72
+ ) {
73
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
74
+ return;
75
+ }
76
+ sockets.handleUpgrade(request, socket, head, (connection) =>
77
+ sockets.emit('connection', connection, origin),
78
+ );
79
+ });
80
+ sockets.on('connection', (socket: WebSocket, origin: string) => {
81
+ const peer: Peer = { lastSelectionAt: 0 };
82
+ peers.set(socket, peer);
83
+ const authenticationTimeout = setTimeout(
84
+ () => socket.terminate(),
85
+ REQUEST_TIMEOUT_MS,
86
+ );
87
+ authenticationTimeout.unref();
88
+ socket.on('error', onError);
89
+ socket.on('close', () => {
90
+ clearTimeout(authenticationTimeout);
91
+ peers.delete(socket);
92
+ for (const [requestId, delivery] of deliveries) {
93
+ if (delivery.pi === socket || delivery.browser === socket) {
94
+ clearTimeout(delivery.timer);
95
+ deliveries.delete(requestId);
96
+ if (delivery.browser !== socket) {
97
+ send(delivery.browser, {
98
+ type: MESSAGE_TYPE.error,
99
+ id: delivery.id,
100
+ message:
101
+ 'The destination session disconnected. Select the element again.',
102
+ });
103
+ }
104
+ }
105
+ }
106
+ publish();
107
+ });
108
+ socket.on('message', (bytes, binary) => {
109
+ try {
110
+ const message = parseEnvelope(bytes.toString(), binary);
111
+ if (!peer.role) {
112
+ const expectedRole =
113
+ origin === PI_ORIGIN ? PEER_ROLE.pi : PEER_ROLE.browser;
114
+ if (
115
+ message.type !== MESSAGE_TYPE.authenticate ||
116
+ message.version !== PROTOCOL_VERSION ||
117
+ message.role !== expectedRole ||
118
+ !matchesSecret(message.token, identity.token)
119
+ ) {
120
+ socket.close(1008, 'Invalid pairing');
121
+ return;
122
+ }
123
+ peer.role = expectedRole;
124
+ clearTimeout(authenticationTimeout);
125
+ send(socket, {
126
+ type: MESSAGE_TYPE.ready,
127
+ version: PROTOCOL_VERSION,
128
+ sessions: sessions(),
129
+ });
130
+ return;
131
+ }
132
+ if (message.type === MESSAGE_TYPE.ping) {
133
+ send(socket, { type: MESSAGE_TYPE.pong });
134
+ return;
135
+ }
136
+ if (peer.role === PEER_ROLE.pi) {
137
+ if (
138
+ message.type === MESSAGE_TYPE.register &&
139
+ Check(SessionDescriptorSchema, message.session)
140
+ ) {
141
+ const registeredSession = message.session;
142
+ if (peer.session && peer.session.id !== registeredSession.id) {
143
+ throw new Error(
144
+ 'A Pi connection cannot replace another instance.',
145
+ );
146
+ }
147
+ if (
148
+ [...peers.values()].some(
149
+ (other) =>
150
+ other !== peer && other.session?.id === registeredSession.id,
151
+ )
152
+ ) {
153
+ throw new Error('The Pi instance is already registered.');
154
+ }
155
+ peer.session = registeredSession;
156
+ send(socket, {
157
+ type: MESSAGE_TYPE.register,
158
+ revision: peer.session.revision,
159
+ });
160
+ publish();
161
+ return;
162
+ }
163
+ if (
164
+ message.type === MESSAGE_TYPE.result &&
165
+ typeof message.requestId === 'string'
166
+ ) {
167
+ const delivery = deliveries.get(message.requestId);
168
+ if (!delivery || delivery.pi !== socket) {
169
+ throw new Error('Unknown selection receipt.');
170
+ }
171
+ clearTimeout(delivery.timer);
172
+ deliveries.delete(message.requestId);
173
+ const stale = peer.session?.revision !== delivery.revision;
174
+ send(
175
+ delivery.browser,
176
+ typeof message.error === 'string' || stale
177
+ ? {
178
+ type: MESSAGE_TYPE.error,
179
+ id: delivery.id,
180
+ message: stale
181
+ ? 'The session changed. Select the element again.'
182
+ : String(message.error).slice(0, 500),
183
+ }
184
+ : {
185
+ type: MESSAGE_TYPE.received,
186
+ id: delivery.id,
187
+ candidates: message.candidates,
188
+ },
189
+ );
190
+ return;
191
+ }
192
+ throw new Error('Invalid Pi message.');
193
+ }
194
+ if (
195
+ message.type !== MESSAGE_TYPE.selection ||
196
+ !Check(SessionDescriptorSchema, message.target)
197
+ ) {
198
+ throw new Error(
199
+ 'Choose a destination session in the browser companion.',
200
+ );
201
+ }
202
+ const target = message.target;
203
+ const destination = [...peers.entries()].find(
204
+ ([, entry]) =>
205
+ entry.session?.id === target.id &&
206
+ entry.session.revision === target.revision,
207
+ );
208
+ if (!destination) {
209
+ throw new Error(
210
+ 'The destination session changed. Select the element again.',
211
+ );
212
+ }
213
+ if (
214
+ Date.now() - peer.lastSelectionAt < MIN_SELECTION_INTERVAL_MS ||
215
+ deliveries.size >= 128
216
+ ) {
217
+ throw new Error(
218
+ 'Pi is still processing selections. Try again shortly.',
219
+ );
220
+ }
221
+ const selection = parseSelection(message.selection);
222
+ peer.lastSelectionAt = Date.now();
223
+ const requestId = randomUUID();
224
+ const timer = setTimeout(() => {
225
+ deliveries.delete(requestId);
226
+ send(socket, {
227
+ type: MESSAGE_TYPE.error,
228
+ id: selection.id,
229
+ message:
230
+ 'The destination session did not respond. Select the element again.',
231
+ });
232
+ }, REQUEST_TIMEOUT_MS);
233
+ timer.unref();
234
+ deliveries.set(requestId, {
235
+ browser: socket,
236
+ pi: destination[0],
237
+ id: selection.id,
238
+ revision: target.revision,
239
+ timer,
240
+ });
241
+ send(destination[0], {
242
+ type: MESSAGE_TYPE.deliver,
243
+ requestId,
244
+ revision: target.revision,
245
+ selection,
246
+ });
247
+ } catch (cause) {
248
+ const error =
249
+ cause instanceof Error
250
+ ? cause
251
+ : new Error('Cannot route browser selection.', { cause });
252
+ send(socket, { type: MESSAGE_TYPE.error, message: error.message });
253
+ if (!peer.role) {
254
+ socket.close(1008, 'Invalid pairing');
255
+ }
256
+ }
257
+ });
258
+ });
259
+ await new Promise<void>((resolve, reject) => {
260
+ server.once('error', reject);
261
+ server.listen(identity.port, '127.0.0.1', () => {
262
+ server.removeListener('error', reject);
263
+ resolve();
264
+ });
265
+ });
266
+ server.on('error', onError);
267
+ let closing: Promise<void> | undefined;
268
+ return {
269
+ close() {
270
+ closing ??= new Promise<void>((resolve, reject) => {
271
+ for (const delivery of deliveries.values()) {
272
+ clearTimeout(delivery.timer);
273
+ }
274
+ deliveries.clear();
275
+ for (const socket of sockets.clients) {
276
+ socket.terminate();
277
+ }
278
+ sockets.close();
279
+ server.close((error) => (error ? reject(error) : resolve()));
280
+ });
281
+ return closing;
282
+ },
283
+ };
284
+ }
285
+
286
+ function matchesSecret(token: unknown, secret: string): boolean {
287
+ return (
288
+ typeof token === 'string' &&
289
+ /^[a-f0-9]{64}$/u.test(token) &&
290
+ timingSafeEqual(Buffer.from(token), Buffer.from(secret))
291
+ );
292
+ }
@@ -0,0 +1,34 @@
1
+ import { WebSocket } from 'ws';
2
+
3
+ const INVALID_MESSAGE = 'Invalid inspector message.';
4
+
5
+ export function send(
6
+ socket: WebSocket,
7
+ message: Readonly<Record<string, unknown>>,
8
+ ): void {
9
+ if (socket.readyState === WebSocket.OPEN) {
10
+ socket.send(JSON.stringify(message));
11
+ }
12
+ }
13
+
14
+ export function parseEnvelope(
15
+ source: string,
16
+ binary = false,
17
+ ): Record<string, unknown> {
18
+ let message: unknown;
19
+ try {
20
+ message = JSON.parse(source);
21
+ } catch (cause) {
22
+ throw new Error(INVALID_MESSAGE, { cause });
23
+ }
24
+ if (
25
+ binary ||
26
+ !message ||
27
+ typeof message !== 'object' ||
28
+ Array.isArray(message) ||
29
+ !('type' in message)
30
+ ) {
31
+ throw new Error(INVALID_MESSAGE);
32
+ }
33
+ return message as Record<string, unknown>;
34
+ }
@@ -0,0 +1,328 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ getAgentDir,
6
+ } from '@earendil-works/pi-coding-agent';
7
+ import { connectSession, type VisualBridge } from './bridge.ts';
8
+ import {
9
+ candidateLabel,
10
+ cleanLabel,
11
+ elementLabel,
12
+ type PendingSelection,
13
+ type SessionDescriptor,
14
+ VISUAL_SHORTCUT,
15
+ VISUAL_WIDGET,
16
+ } from './model.ts';
17
+ import { ensurePairing, pairingAddress, readPairing } from './pairing.ts';
18
+ import {
19
+ commitPending,
20
+ createVisualStore,
21
+ prepareContext,
22
+ receiveSelection,
23
+ restorePending,
24
+ } from './session.ts';
25
+ import { candidateIsCurrent } from './source.ts';
26
+ import { BROWSER_EXTENSION_DIRECTORY, refreshVisualWidget } from './ui.ts';
27
+
28
+ const CONTROL = {
29
+ pair: 'Pair browser',
30
+ stop: 'Disconnect browser',
31
+ clear: 'Remove all pending elements',
32
+ } as const;
33
+
34
+ type VisualDialogOption =
35
+ | { readonly label: string; readonly action: 'pair' }
36
+ | { readonly label: string; readonly action: 'stop' }
37
+ | { readonly label: string; readonly action: 'clear' }
38
+ | {
39
+ readonly label: string;
40
+ readonly action: 'review';
41
+ readonly entry: PendingSelection;
42
+ };
43
+
44
+ function notifyRestoreFailure(ctx: ExtensionContext, cause: unknown): void {
45
+ ctx.ui.notify(`Cannot restore browser context: ${String(cause)}`, 'error');
46
+ }
47
+
48
+ export default function registerVisualContext(
49
+ pi: ExtensionAPI,
50
+ agentDirectory = getAgentDir(),
51
+ ): void {
52
+ const store = createVisualStore();
53
+ let bridge: VisualBridge | undefined;
54
+ let activeContext: ExtensionContext | undefined;
55
+ let opening = false;
56
+ let generation = 0;
57
+ let preparedContext: ReturnType<typeof prepareContext>;
58
+ const instanceId = randomUUID();
59
+ let destination: SessionDescriptor | undefined;
60
+
61
+ async function stopBridge(): Promise<void> {
62
+ generation += 1;
63
+ preparedContext = undefined;
64
+ const previous = bridge;
65
+ bridge = undefined;
66
+ await previous?.close();
67
+ }
68
+
69
+ const refresh = (ctx: ExtensionContext): void =>
70
+ refreshVisualWidget(store.pending, ctx);
71
+ const savePending = (
72
+ ctx: ExtensionContext,
73
+ pending: readonly PendingSelection[],
74
+ ): void => {
75
+ commitPending({ store, pi, pending });
76
+ refresh(ctx);
77
+ };
78
+ // An async dialog may only act while it still belongs to the active session.
79
+ const dialogIsCurrent = (ctx: ExtensionContext, epoch: number): boolean =>
80
+ epoch === generation && activeContext === ctx;
81
+ async function activate(ctx: ExtensionContext): Promise<void> {
82
+ generation += 1;
83
+ preparedContext = undefined;
84
+ activeContext = ctx;
85
+ if (ctx.mode !== 'tui') {
86
+ await stopBridge();
87
+ return;
88
+ }
89
+ destination = {
90
+ id: instanceId,
91
+ revision: randomUUID(),
92
+ name: cleanLabel(
93
+ ctx.sessionManager.getSessionName() ??
94
+ ctx.sessionManager.getSessionId(),
95
+ 160,
96
+ ),
97
+ project: cleanLabel(ctx.cwd, 512),
98
+ environment:
99
+ process.platform === 'win32'
100
+ ? 'Windows'
101
+ : process.env.WSL_DISTRO_NAME
102
+ ? `WSL (${cleanLabel(process.env.WSL_DISTRO_NAME, 140)})`
103
+ : process.platform,
104
+ };
105
+ restorePending({ store, ctx });
106
+ refresh(ctx);
107
+ if (!ctx.isProjectTrusted()) {
108
+ await stopBridge();
109
+ return;
110
+ }
111
+ bridge?.update(destination);
112
+ if (!bridge) {
113
+ const identity = readPairing(agentDirectory);
114
+ if (identity) {
115
+ await openBridge(identity);
116
+ }
117
+ }
118
+ }
119
+ async function openBridge(
120
+ identity: Awaited<ReturnType<typeof ensurePairing>>,
121
+ ): Promise<void> {
122
+ if (!destination) {
123
+ throw new Error('The Pi conversation is not available.');
124
+ }
125
+ const captured = generation;
126
+ const created = await connectSession({
127
+ identity,
128
+ session: destination,
129
+ onSelection: (selection, revision) => {
130
+ const current = activeContext;
131
+ if (
132
+ !current?.isProjectTrusted() ||
133
+ destination?.revision !== revision
134
+ ) {
135
+ throw new Error('The destination Pi session is no longer trusted.');
136
+ }
137
+ const receipt = receiveSelection({
138
+ store,
139
+ pi,
140
+ cwd: current.cwd,
141
+ selection,
142
+ });
143
+ refresh(current);
144
+ return receipt;
145
+ },
146
+ onError: (error) => activeContext?.ui.notify(error.message, 'error'),
147
+ });
148
+ const current = activeContext;
149
+ if (!current || !dialogIsCurrent(current, captured)) {
150
+ await created.close();
151
+ return;
152
+ }
153
+ bridge = created;
154
+ }
155
+ const activateSafely = async (ctx: ExtensionContext): Promise<void> => {
156
+ try {
157
+ await activate(ctx);
158
+ } catch (cause) {
159
+ notifyRestoreFailure(ctx, cause);
160
+ }
161
+ };
162
+ pi.on('session_start', (_event, ctx) => activateSafely(ctx));
163
+ pi.on('session_tree', (_event, ctx) => activateSafely(ctx));
164
+ pi.on('session_compact', (_event, ctx) => {
165
+ activeContext = ctx;
166
+ try {
167
+ restorePending({ store, ctx });
168
+ } catch (cause) {
169
+ notifyRestoreFailure(ctx, cause);
170
+ }
171
+ refresh(ctx);
172
+ });
173
+ pi.on('session_shutdown', async (_event, ctx) => {
174
+ activeContext = undefined;
175
+ await stopBridge();
176
+ ctx.ui.setWidget(VISUAL_WIDGET, undefined);
177
+ });
178
+ pi.on('input', (event, ctx) => {
179
+ if (
180
+ event.source !== 'interactive' ||
181
+ !ctx.isIdle() ||
182
+ event.streamingBehavior !== undefined ||
183
+ /^[!/]/u.test(event.text.trimStart())
184
+ ) {
185
+ return;
186
+ }
187
+ const context = prepareContext({ store, cwd: ctx.cwd });
188
+ if (context === undefined) {
189
+ return;
190
+ }
191
+ preparedContext = context;
192
+ return {
193
+ action: 'transform',
194
+ text: `${event.text}\n\n${context.text}`,
195
+ images: event.images,
196
+ };
197
+ });
198
+ // Input precedes model/auth validation in Pi. Keep the pending snapshot until
199
+ // the run actually starts so a failed submission cannot lose the attachment.
200
+ pi.on('before_agent_start', (event, ctx) => {
201
+ const attachment = preparedContext;
202
+ preparedContext = undefined;
203
+ if (!attachment || !event.prompt.includes(attachment.text)) {
204
+ return;
205
+ }
206
+ savePending(
207
+ ctx,
208
+ store.pending.filter(
209
+ (entry) => !attachment.selectionIds.includes(entry.selection.id),
210
+ ),
211
+ );
212
+ });
213
+ pi.registerShortcut(VISUAL_SHORTCUT, {
214
+ description: 'Pair the browser or review selected elements',
215
+ handler: async (ctx) => {
216
+ if (opening || ctx.mode !== 'tui') {
217
+ return;
218
+ }
219
+ if (!ctx.isProjectTrusted()) {
220
+ ctx.ui.notify(
221
+ 'Trust this project in Pi before connecting the browser.',
222
+ 'warning',
223
+ );
224
+ return;
225
+ }
226
+ opening = true;
227
+ try {
228
+ if (!destination || activeContext !== ctx) {
229
+ await activate(ctx);
230
+ }
231
+ const dialogGeneration = generation;
232
+ const stopOptions: readonly VisualDialogOption[] = bridge
233
+ ? [{ label: CONTROL.stop, action: 'stop' }]
234
+ : [];
235
+ const options: readonly VisualDialogOption[] = [
236
+ { label: CONTROL.pair, action: 'pair' },
237
+ ...store.pending.map(
238
+ (entry): VisualDialogOption => ({
239
+ label: `Review ${elementLabel(entry.selection)} [${entry.selection.id}]`,
240
+ action: 'review',
241
+ entry,
242
+ }),
243
+ ),
244
+ { label: CONTROL.clear, action: 'clear' },
245
+ ...stopOptions,
246
+ ];
247
+ const choice = await ctx.ui.select(
248
+ 'Browser context',
249
+ options.map((option) => option.label),
250
+ );
251
+ if (!dialogIsCurrent(ctx, dialogGeneration)) {
252
+ return;
253
+ }
254
+ const picked = options.find((option) => option.label === choice);
255
+ if (!picked) {
256
+ return;
257
+ }
258
+ if (picked.action === 'pair') {
259
+ const identity = await ensurePairing(agentDirectory);
260
+ if (!bridge) {
261
+ await openBridge(identity);
262
+ }
263
+ if (!dialogIsCurrent(ctx, dialogGeneration)) {
264
+ return;
265
+ }
266
+ ctx.ui.notify(
267
+ `Load the browser extension from ${BROWSER_EXTENSION_DIRECTORY}. Pair once with this address: ${pairingAddress(identity)}. The browser reconnects to future sessions automatically.`,
268
+ 'info',
269
+ );
270
+ await ctx.ui.editor(
271
+ 'Pairing address — copy into the Sideroom browser extension',
272
+ pairingAddress(identity),
273
+ );
274
+ return;
275
+ }
276
+ if (picked.action === 'stop') {
277
+ await stopBridge();
278
+ ctx.ui.notify(
279
+ 'This session stopped receiving browser elements. Other Pi instances remain available. Pending elements are preserved.',
280
+ 'info',
281
+ );
282
+ return;
283
+ }
284
+ if (picked.action === 'clear') {
285
+ savePending(ctx, []);
286
+ return;
287
+ }
288
+ const entry = picked.entry;
289
+ const labels = entry.resolution.candidates.map(
290
+ (candidate) =>
291
+ `${candidateLabel(candidate)} · ${candidate.reasons.join(', ')}`,
292
+ );
293
+ const remove = 'Remove this element';
294
+ const selected = await ctx.ui.select(
295
+ 'Choose a source candidate — origin is inferred',
296
+ [...labels, remove],
297
+ );
298
+ if (!dialogIsCurrent(ctx, dialogGeneration)) {
299
+ return;
300
+ }
301
+ if (selected === undefined) {
302
+ return;
303
+ }
304
+ const candidate = entry.resolution.candidates[labels.indexOf(selected)];
305
+ if (candidate && !candidateIsCurrent(ctx.cwd, candidate)) {
306
+ ctx.ui.notify(
307
+ 'The source changed. Select the element again in your browser.',
308
+ 'warning',
309
+ );
310
+ return;
311
+ }
312
+ const pending =
313
+ selected === remove
314
+ ? store.pending.filter(
315
+ (item) => item.selection.id !== entry.selection.id,
316
+ )
317
+ : store.pending.map((item) =>
318
+ item.selection.id === entry.selection.id
319
+ ? { ...item, chosen: candidate }
320
+ : item,
321
+ );
322
+ savePending(ctx, pending);
323
+ } finally {
324
+ opening = false;
325
+ }
326
+ },
327
+ });
328
+ }
@@ -0,0 +1,34 @@
1
+ import { parseExpression } from '@babel/parser';
2
+
3
+ const SYNTAX_ERROR_CODES = new Set([
4
+ 'BABEL_PARSER_SYNTAX_ERROR',
5
+ 'BABEL_PARSER_SOURCETYPE_MODULE_REQUIRED',
6
+ ]);
7
+
8
+ export function isBabelSyntaxError(cause: unknown): boolean {
9
+ return (
10
+ cause instanceof Error &&
11
+ 'code' in cause &&
12
+ SYNTAX_ERROR_CODES.has(String(cause.code))
13
+ );
14
+ }
15
+
16
+ // Only syntax literals are read. No inspected source is imported or executed.
17
+ export function literalText(expression: string): string | undefined {
18
+ let node: ReturnType<typeof parseExpression>;
19
+ try {
20
+ node = parseExpression(expression, { plugins: ['typescript'] });
21
+ } catch (cause) {
22
+ if (isBabelSyntaxError(cause)) {
23
+ return undefined;
24
+ }
25
+ throw new Error('Cannot parse a source expression', { cause });
26
+ }
27
+ if (node.type === 'StringLiteral') {
28
+ return node.value;
29
+ }
30
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
31
+ return node.quasis[0]?.value.cooked ?? undefined;
32
+ }
33
+ return undefined;
34
+ }