kiro-mobile-bridge 1.0.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.
- package/README.md +221 -0
- package/package.json +40 -0
- package/src/public/index.html +1940 -0
- package/src/server.js +2672 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,2672 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Kiro Mobile Bridge Server
|
|
4
|
+
*
|
|
5
|
+
* A simple mobile web interface for monitoring Kiro IDE agent sessions from your phone over LAN.
|
|
6
|
+
* Captures snapshots of the chat interface via CDP and lets you send messages remotely.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import { createServer } from 'http';
|
|
11
|
+
import http from 'http';
|
|
12
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
13
|
+
import { networkInterfaces } from 'os';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { dirname, join } from 'path';
|
|
16
|
+
import crypto from 'crypto';
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = dirname(__filename);
|
|
20
|
+
|
|
21
|
+
// Configuration
|
|
22
|
+
const PORT = process.env.PORT || 3000;
|
|
23
|
+
const CDP_PORTS = [9000, 9001, 9002, 9003, 9222, 9229];
|
|
24
|
+
|
|
25
|
+
// State management
|
|
26
|
+
const cascades = new Map(); // cascadeId -> { id, cdp, metadata, snapshot, css, snapshotHash, terminal, sidebar, editor }
|
|
27
|
+
const mainWindowCDP = { connection: null, id: null }; // Separate CDP connection for main VS Code window
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// CDP Connection Helpers (Task 2)
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fetch JSON from a CDP endpoint
|
|
35
|
+
* @param {number} port - The port to fetch from
|
|
36
|
+
* @param {string} path - The path to fetch (default: /json/list)
|
|
37
|
+
* @returns {Promise<any>} - Parsed JSON response
|
|
38
|
+
*/
|
|
39
|
+
function fetchCDPTargets(port, path = '/json/list') {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const url = `http://127.0.0.1:${port}${path}`;
|
|
42
|
+
|
|
43
|
+
const req = http.get(url, { timeout: 2000 }, (res) => {
|
|
44
|
+
let data = '';
|
|
45
|
+
res.on('data', chunk => data += chunk);
|
|
46
|
+
res.on('end', () => {
|
|
47
|
+
try {
|
|
48
|
+
resolve(JSON.parse(data));
|
|
49
|
+
} catch (e) {
|
|
50
|
+
reject(new Error(`Failed to parse JSON from ${url}: ${e.message}`));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
req.on('error', (err) => {
|
|
56
|
+
reject(new Error(`Failed to fetch ${url}: ${err.message}`));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
req.on('timeout', () => {
|
|
60
|
+
req.destroy();
|
|
61
|
+
reject(new Error(`Timeout fetching ${url}`));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a CDP connection to a target
|
|
68
|
+
* @param {string} wsUrl - WebSocket debugger URL
|
|
69
|
+
* @returns {Promise<CDPConnection>} - CDP connection object
|
|
70
|
+
*
|
|
71
|
+
* @typedef {Object} CDPConnection
|
|
72
|
+
* @property {WebSocket} ws - The WebSocket connection
|
|
73
|
+
* @property {function(string, object): Promise<any>} call - Send CDP command
|
|
74
|
+
* @property {Array<{id: number, name: string, origin: string}>} contexts - Runtime execution contexts
|
|
75
|
+
* @property {number|null} rootContextId - Main context ID for evaluation
|
|
76
|
+
* @property {function(): void} close - Close the connection
|
|
77
|
+
*/
|
|
78
|
+
function connectToCDP(wsUrl) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const ws = new WebSocket(wsUrl);
|
|
81
|
+
let idCounter = 1;
|
|
82
|
+
const pendingCalls = new Map(); // id -> { resolve, reject }
|
|
83
|
+
const contexts = [];
|
|
84
|
+
let rootContextId = null;
|
|
85
|
+
let isConnected = false;
|
|
86
|
+
|
|
87
|
+
// Handle incoming messages
|
|
88
|
+
ws.on('message', (rawMsg) => {
|
|
89
|
+
try {
|
|
90
|
+
const msg = JSON.parse(rawMsg.toString());
|
|
91
|
+
|
|
92
|
+
// Handle CDP events
|
|
93
|
+
if (msg.method === 'Runtime.executionContextCreated') {
|
|
94
|
+
const ctx = msg.params.context;
|
|
95
|
+
contexts.push(ctx);
|
|
96
|
+
|
|
97
|
+
// Track the main/root context (usually the first one or one with specific origin)
|
|
98
|
+
// The root context typically has origin matching the page or is the first created
|
|
99
|
+
if (rootContextId === null || ctx.auxData?.isDefault) {
|
|
100
|
+
rootContextId = ctx.id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(`[CDP] Context created: id=${ctx.id}, name="${ctx.name}", origin="${ctx.origin}"`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (msg.method === 'Runtime.executionContextDestroyed') {
|
|
107
|
+
const ctxId = msg.params.executionContextId;
|
|
108
|
+
const idx = contexts.findIndex(c => c.id === ctxId);
|
|
109
|
+
if (idx !== -1) {
|
|
110
|
+
contexts.splice(idx, 1);
|
|
111
|
+
console.log(`[CDP] Context destroyed: id=${ctxId}`);
|
|
112
|
+
}
|
|
113
|
+
if (rootContextId === ctxId) {
|
|
114
|
+
rootContextId = contexts.length > 0 ? contexts[0].id : null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (msg.method === 'Runtime.executionContextsCleared') {
|
|
119
|
+
contexts.length = 0;
|
|
120
|
+
rootContextId = null;
|
|
121
|
+
console.log('[CDP] All contexts cleared');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Handle responses to our calls
|
|
125
|
+
if (msg.id !== undefined && pendingCalls.has(msg.id)) {
|
|
126
|
+
const { resolve: res, reject: rej } = pendingCalls.get(msg.id);
|
|
127
|
+
pendingCalls.delete(msg.id);
|
|
128
|
+
|
|
129
|
+
if (msg.error) {
|
|
130
|
+
rej(new Error(`CDP Error: ${msg.error.message} (code: ${msg.error.code})`));
|
|
131
|
+
} else {
|
|
132
|
+
res(msg.result);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.error('[CDP] Failed to parse message:', e.message);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
ws.on('open', async () => {
|
|
141
|
+
isConnected = true;
|
|
142
|
+
console.log(`[CDP] Connected to ${wsUrl}`);
|
|
143
|
+
|
|
144
|
+
// Create the CDP connection object
|
|
145
|
+
const cdp = {
|
|
146
|
+
ws,
|
|
147
|
+
contexts,
|
|
148
|
+
get rootContextId() { return rootContextId; },
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Send a CDP command and wait for response
|
|
152
|
+
* @param {string} method - CDP method name
|
|
153
|
+
* @param {object} params - Method parameters
|
|
154
|
+
* @returns {Promise<any>} - CDP response result
|
|
155
|
+
*/
|
|
156
|
+
call(method, params = {}) {
|
|
157
|
+
return new Promise((res, rej) => {
|
|
158
|
+
if (!isConnected) {
|
|
159
|
+
rej(new Error('CDP connection is closed'));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const id = idCounter++;
|
|
164
|
+
pendingCalls.set(id, { resolve: res, reject: rej });
|
|
165
|
+
|
|
166
|
+
const message = JSON.stringify({ id, method, params });
|
|
167
|
+
ws.send(message);
|
|
168
|
+
|
|
169
|
+
// Timeout for calls (10 seconds)
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
if (pendingCalls.has(id)) {
|
|
172
|
+
pendingCalls.delete(id);
|
|
173
|
+
rej(new Error(`CDP call timeout: ${method}`));
|
|
174
|
+
}
|
|
175
|
+
}, 10000);
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Close the CDP connection
|
|
181
|
+
*/
|
|
182
|
+
close() {
|
|
183
|
+
isConnected = false;
|
|
184
|
+
// Reject all pending calls
|
|
185
|
+
for (const [id, { reject }] of pendingCalls) {
|
|
186
|
+
reject(new Error('CDP connection closed'));
|
|
187
|
+
}
|
|
188
|
+
pendingCalls.clear();
|
|
189
|
+
ws.terminate();
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// Enable Runtime to receive execution context events
|
|
195
|
+
await cdp.call('Runtime.enable', {});
|
|
196
|
+
|
|
197
|
+
// Wait a bit for contexts to be discovered
|
|
198
|
+
await new Promise(r => setTimeout(r, 300));
|
|
199
|
+
|
|
200
|
+
console.log(`[CDP] Runtime enabled, found ${contexts.length} context(s)`);
|
|
201
|
+
resolve(cdp);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
cdp.close();
|
|
204
|
+
reject(err);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
ws.on('error', (err) => {
|
|
209
|
+
console.error(`[CDP] WebSocket error: ${err.message}`);
|
|
210
|
+
isConnected = false;
|
|
211
|
+
reject(err);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
ws.on('close', () => {
|
|
215
|
+
console.log('[CDP] Connection closed');
|
|
216
|
+
isConnected = false;
|
|
217
|
+
// Reject all pending calls
|
|
218
|
+
for (const [id, { reject }] of pendingCalls) {
|
|
219
|
+
reject(new Error('CDP connection closed'));
|
|
220
|
+
}
|
|
221
|
+
pendingCalls.clear();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Generate a unique ID for a cascade based on WebSocket URL
|
|
228
|
+
* @param {string} wsUrl - WebSocket debugger URL
|
|
229
|
+
* @returns {string} - Hash ID
|
|
230
|
+
*/
|
|
231
|
+
function generateCascadeId(wsUrl) {
|
|
232
|
+
return crypto.createHash('md5').update(wsUrl).digest('hex').substring(0, 8);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// =============================================================================
|
|
236
|
+
// Snapshot Capture (Task 4)
|
|
237
|
+
// =============================================================================
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Compute a simple hash of content for change detection
|
|
241
|
+
* @param {string} content - Content to hash
|
|
242
|
+
* @returns {string} - Hash string
|
|
243
|
+
*/
|
|
244
|
+
function computeHash(content) {
|
|
245
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Extract chat metadata (title, active state) from the page via CDP
|
|
250
|
+
* @param {CDPConnection} cdp - CDP connection
|
|
251
|
+
* @returns {Promise<{chatTitle: string, isActive: boolean}>}
|
|
252
|
+
*/
|
|
253
|
+
async function captureMetadata(cdp) {
|
|
254
|
+
if (!cdp.rootContextId) {
|
|
255
|
+
return { chatTitle: '', isActive: false };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const script = `
|
|
259
|
+
(function() {
|
|
260
|
+
// Try to find chat title from various possible elements
|
|
261
|
+
let chatTitle = '';
|
|
262
|
+
let isActive = false;
|
|
263
|
+
|
|
264
|
+
// Look for chat title in common locations
|
|
265
|
+
// Kiro might have title in header, tab, or specific element
|
|
266
|
+
const titleSelectors = [
|
|
267
|
+
'.chat-title',
|
|
268
|
+
'.conversation-title',
|
|
269
|
+
'[data-testid="chat-title"]',
|
|
270
|
+
'.chat-header h1',
|
|
271
|
+
'.chat-header h2',
|
|
272
|
+
'.chat-header .title'
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
for (const selector of titleSelectors) {
|
|
276
|
+
const el = document.querySelector(selector);
|
|
277
|
+
if (el && el.textContent) {
|
|
278
|
+
chatTitle = el.textContent.trim();
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Check if chat is active (has recent activity or is focused)
|
|
284
|
+
// Look for typing indicators, loading states, or recent messages
|
|
285
|
+
const activeIndicators = [
|
|
286
|
+
'.typing-indicator',
|
|
287
|
+
'.loading-indicator',
|
|
288
|
+
'[data-loading="true"]',
|
|
289
|
+
'.chat-loading'
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
for (const selector of activeIndicators) {
|
|
293
|
+
if (document.querySelector(selector)) {
|
|
294
|
+
isActive = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Also check if document is focused
|
|
300
|
+
isActive = isActive || document.hasFocus();
|
|
301
|
+
|
|
302
|
+
return { chatTitle, isActive };
|
|
303
|
+
})()
|
|
304
|
+
`;
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
308
|
+
expression: script,
|
|
309
|
+
contextId: cdp.rootContextId,
|
|
310
|
+
returnByValue: true
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (result.result && result.result.value) {
|
|
314
|
+
return result.result.value;
|
|
315
|
+
}
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.error('[Snapshot] Failed to capture metadata:', err.message);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { chatTitle: '', isActive: false };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Capture CSS styles from the page (run once per connection)
|
|
325
|
+
* Gathers all stylesheets and CSS variables, returns CSS string
|
|
326
|
+
* @param {CDPConnection} cdp - CDP connection
|
|
327
|
+
* @returns {Promise<string>} - Combined CSS string
|
|
328
|
+
*/
|
|
329
|
+
async function captureCSS(cdp) {
|
|
330
|
+
if (!cdp.rootContextId) {
|
|
331
|
+
return '';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const script = `
|
|
335
|
+
(function() {
|
|
336
|
+
let css = '';
|
|
337
|
+
|
|
338
|
+
// VS Code webviews use nested iframes - look for #active-frame
|
|
339
|
+
let targetDoc = document;
|
|
340
|
+
const activeFrame = document.getElementById('active-frame');
|
|
341
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
342
|
+
targetDoc = activeFrame.contentDocument;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// First, capture all CSS custom properties (variables) from :root/html/body
|
|
346
|
+
// These are needed because VS Code styles use var(--vscode-*) extensively
|
|
347
|
+
const rootEl = targetDoc.documentElement;
|
|
348
|
+
const bodyEl = targetDoc.body;
|
|
349
|
+
const rootStyles = window.getComputedStyle(rootEl);
|
|
350
|
+
const bodyStyles = window.getComputedStyle(bodyEl);
|
|
351
|
+
|
|
352
|
+
let cssVars = ':root {\\n';
|
|
353
|
+
|
|
354
|
+
// Get all CSS properties and filter for custom properties (start with --)
|
|
355
|
+
const allProps = [];
|
|
356
|
+
for (let i = 0; i < rootStyles.length; i++) {
|
|
357
|
+
allProps.push(rootStyles[i]);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Also check for VS Code specific variables by iterating stylesheets
|
|
361
|
+
for (const sheet of targetDoc.styleSheets) {
|
|
362
|
+
try {
|
|
363
|
+
if (sheet.cssRules) {
|
|
364
|
+
for (const rule of sheet.cssRules) {
|
|
365
|
+
if (rule.style) {
|
|
366
|
+
for (let i = 0; i < rule.style.length; i++) {
|
|
367
|
+
const prop = rule.style[i];
|
|
368
|
+
if (prop.startsWith('--') && !allProps.includes(prop)) {
|
|
369
|
+
allProps.push(prop);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} catch (e) {}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Get computed values for all custom properties
|
|
379
|
+
for (const prop of allProps) {
|
|
380
|
+
if (prop.startsWith('--')) {
|
|
381
|
+
const value = rootStyles.getPropertyValue(prop).trim();
|
|
382
|
+
if (value) {
|
|
383
|
+
cssVars += ' ' + prop + ': ' + value + ';\\n';
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
cssVars += '}\\n\\n';
|
|
388
|
+
|
|
389
|
+
css += cssVars;
|
|
390
|
+
|
|
391
|
+
// Gather all stylesheets from target document
|
|
392
|
+
for (const sheet of targetDoc.styleSheets) {
|
|
393
|
+
try {
|
|
394
|
+
if (sheet.cssRules) {
|
|
395
|
+
for (const rule of sheet.cssRules) {
|
|
396
|
+
css += rule.cssText + '\\n';
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} catch (e) {
|
|
400
|
+
// Cross-origin stylesheets will throw
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Also gather inline styles from <style> tags
|
|
405
|
+
const styleTags = targetDoc.querySelectorAll('style');
|
|
406
|
+
for (const tag of styleTags) {
|
|
407
|
+
css += tag.textContent + '\\n';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return css;
|
|
411
|
+
})()
|
|
412
|
+
`;
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
416
|
+
expression: script,
|
|
417
|
+
contextId: cdp.rootContextId,
|
|
418
|
+
returnByValue: true
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
if (result.result && result.result.value) {
|
|
422
|
+
return result.result.value;
|
|
423
|
+
}
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.error('[Snapshot] Failed to capture CSS:', err.message);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return '';
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Capture HTML snapshot of the chat interface
|
|
433
|
+
* @param {CDPConnection} cdp - CDP connection
|
|
434
|
+
* @returns {Promise<{html: string, bodyBg: string, bodyColor: string} | null>}
|
|
435
|
+
*/
|
|
436
|
+
async function captureSnapshot(cdp) {
|
|
437
|
+
if (!cdp.rootContextId) {
|
|
438
|
+
console.log('[Snapshot] No rootContextId available');
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const script = `
|
|
443
|
+
(function() {
|
|
444
|
+
const debug = {
|
|
445
|
+
hasActiveFrame: false,
|
|
446
|
+
activeFrameAccessible: false,
|
|
447
|
+
bodyExists: false,
|
|
448
|
+
selectorsChecked: [],
|
|
449
|
+
foundElement: null,
|
|
450
|
+
htmlLength: 0
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// VS Code webviews use nested iframes - look for #active-frame
|
|
454
|
+
let targetDoc = document;
|
|
455
|
+
let targetBody = document.body;
|
|
456
|
+
|
|
457
|
+
debug.bodyExists = !!targetBody;
|
|
458
|
+
|
|
459
|
+
const activeFrame = document.getElementById('active-frame');
|
|
460
|
+
debug.hasActiveFrame = !!activeFrame;
|
|
461
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
462
|
+
debug.activeFrameAccessible = true;
|
|
463
|
+
targetDoc = activeFrame.contentDocument;
|
|
464
|
+
targetBody = targetDoc.body;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!targetBody) {
|
|
468
|
+
return { html: '<div style="padding:20px;color:#888;">No content found</div>', bodyBg: '', bodyColor: '', debug };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Get body styles
|
|
472
|
+
const bodyStyles = window.getComputedStyle(targetBody);
|
|
473
|
+
const bodyBg = bodyStyles.backgroundColor || '';
|
|
474
|
+
const bodyColor = bodyStyles.color || '';
|
|
475
|
+
|
|
476
|
+
// Look for the main content container
|
|
477
|
+
const chatSelectors = [
|
|
478
|
+
'#root',
|
|
479
|
+
'#app',
|
|
480
|
+
'.app',
|
|
481
|
+
'main',
|
|
482
|
+
'[class*="chat"]',
|
|
483
|
+
'[class*="message"]',
|
|
484
|
+
'body > div'
|
|
485
|
+
];
|
|
486
|
+
|
|
487
|
+
let chatElement = null;
|
|
488
|
+
for (const selector of chatSelectors) {
|
|
489
|
+
const el = targetDoc.querySelector(selector);
|
|
490
|
+
const len = el ? el.innerHTML.length : 0;
|
|
491
|
+
debug.selectorsChecked.push({ selector, found: !!el, htmlLength: len });
|
|
492
|
+
if (el && len > 50) {
|
|
493
|
+
chatElement = el;
|
|
494
|
+
debug.foundElement = selector;
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!chatElement) {
|
|
500
|
+
chatElement = targetBody;
|
|
501
|
+
debug.foundElement = 'body (fallback)';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
debug.htmlLength = chatElement.innerHTML.length;
|
|
505
|
+
|
|
506
|
+
// Scroll chat container to bottom to show latest messages
|
|
507
|
+
const scrollContainers = targetDoc.querySelectorAll('[class*="scroll"], [style*="overflow"]');
|
|
508
|
+
for (const container of scrollContainers) {
|
|
509
|
+
if (container.scrollHeight > container.clientHeight) {
|
|
510
|
+
container.scrollTop = container.scrollHeight;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Remove tooltips, popovers, and other overlay elements before capture
|
|
515
|
+
// IMPORTANT: Don't remove dropdown buttons (model selector), only dropdown menus/panels
|
|
516
|
+
const elementsToRemove = [
|
|
517
|
+
'[role="tooltip"]',
|
|
518
|
+
'[data-tooltip]',
|
|
519
|
+
'[class*="tooltip"]:not(button):not([role="button"])',
|
|
520
|
+
'[class*="Tooltip"]:not(button):not([role="button"])',
|
|
521
|
+
'[class*="popover"]:not(button):not([role="button"])',
|
|
522
|
+
'[class*="Popover"]:not(button):not([role="button"])',
|
|
523
|
+
'[class*="dropdown-menu"]',
|
|
524
|
+
'[class*="dropdownMenu"]',
|
|
525
|
+
'[class*="DropdownMenu"]',
|
|
526
|
+
'[class*="dropdown-content"]',
|
|
527
|
+
'[class*="dropdownContent"]',
|
|
528
|
+
'[class*="menu"]:not([role="menubar"]):not([class*="menubar"]):not(button):not([role="button"])',
|
|
529
|
+
'[class*="overlay"]:not(button):not([role="button"])',
|
|
530
|
+
'[class*="Overlay"]:not(button):not([role="button"])',
|
|
531
|
+
'[class*="modal"]',
|
|
532
|
+
'[class*="Modal"]',
|
|
533
|
+
'[style*="position: fixed"]:not(button):not([role="button"]):not([class*="input"]):not([class*="chat"])',
|
|
534
|
+
'[style*="position:fixed"]:not(button):not([role="button"]):not([class*="input"]):not([class*="chat"])'
|
|
535
|
+
];
|
|
536
|
+
|
|
537
|
+
elementsToRemove.forEach(selector => {
|
|
538
|
+
try {
|
|
539
|
+
chatElement.querySelectorAll(selector).forEach(el => {
|
|
540
|
+
// Don't remove if it's a main content element or important UI component
|
|
541
|
+
const isMainContent = el.closest('#root > div:first-child');
|
|
542
|
+
const isTooltip = el.matches('[role="tooltip"], [class*="tooltip"], [class*="Tooltip"]');
|
|
543
|
+
const isImportantUI = el.matches('[class*="model"], [class*="Model"], [class*="context"], [class*="Context"], [class*="input"], [class*="Input"], [class*="selector"], [class*="Selector"], button, [role="button"]');
|
|
544
|
+
|
|
545
|
+
if (isTooltip || (!isMainContent && !isImportantUI)) {
|
|
546
|
+
el.remove();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
} catch(e) {}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Before cloning, inline computed styles for SVGs (currentColor fix)
|
|
553
|
+
const originalSvgs = chatElement.querySelectorAll('svg');
|
|
554
|
+
for (const svg of originalSvgs) {
|
|
555
|
+
try {
|
|
556
|
+
const computedColor = window.getComputedStyle(svg).color || window.getComputedStyle(svg.parentElement).color;
|
|
557
|
+
if (computedColor && computedColor !== 'rgba(0, 0, 0, 0)') {
|
|
558
|
+
svg.querySelectorAll('[fill="currentColor"]').forEach(el => el.setAttribute('fill', computedColor));
|
|
559
|
+
svg.querySelectorAll('[stroke="currentColor"]').forEach(el => el.setAttribute('stroke', computedColor));
|
|
560
|
+
if (svg.getAttribute('fill') === 'currentColor') svg.setAttribute('fill', computedColor);
|
|
561
|
+
if (svg.getAttribute('stroke') === 'currentColor') svg.setAttribute('stroke', computedColor);
|
|
562
|
+
if (!svg.getAttribute('fill') && !svg.getAttribute('stroke')) svg.style.color = computedColor;
|
|
563
|
+
}
|
|
564
|
+
} catch(e) {}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Clone and return
|
|
568
|
+
const clone = chatElement.cloneNode(true);
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
html: clone.outerHTML,
|
|
572
|
+
bodyBg,
|
|
573
|
+
bodyColor,
|
|
574
|
+
debug
|
|
575
|
+
};
|
|
576
|
+
})()
|
|
577
|
+
`;
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
581
|
+
expression: script,
|
|
582
|
+
contextId: cdp.rootContextId,
|
|
583
|
+
returnByValue: true
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
if (result.result && result.result.value) {
|
|
587
|
+
return result.result.value;
|
|
588
|
+
}
|
|
589
|
+
} catch (err) {
|
|
590
|
+
console.error('[Snapshot] Failed to capture HTML:', err.message);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Capture Terminal panel HTML snapshot
|
|
598
|
+
* @param {CDPConnection} cdp - CDP connection
|
|
599
|
+
* @returns {Promise<{html: string, hasContent: boolean} | null>}
|
|
600
|
+
*/
|
|
601
|
+
async function captureTerminal(cdp) {
|
|
602
|
+
if (!cdp.rootContextId) return null;
|
|
603
|
+
|
|
604
|
+
// VS Code terminal uses xterm.js which renders to canvas
|
|
605
|
+
// We need to access the accessibility buffer or use xterm's serialize addon
|
|
606
|
+
const script = `
|
|
607
|
+
(function() {
|
|
608
|
+
let targetDoc = document;
|
|
609
|
+
let textContent = '';
|
|
610
|
+
let terminalTabs = [];
|
|
611
|
+
|
|
612
|
+
// Find the terminal panel area
|
|
613
|
+
const terminalPanel = targetDoc.querySelector('.terminal-outer-container, .integrated-terminal, [class*="terminal-wrapper"]');
|
|
614
|
+
|
|
615
|
+
// Try to get terminal tabs/instances
|
|
616
|
+
const tabElements = targetDoc.querySelectorAll('.terminal-tab, .single-terminal-tab, [class*="terminal-tabs"] .tab');
|
|
617
|
+
tabElements.forEach((tab, i) => {
|
|
618
|
+
const label = tab.textContent?.trim() || tab.getAttribute('aria-label') || '';
|
|
619
|
+
const isActive = tab.classList.contains('active') || tab.getAttribute('aria-selected') === 'true';
|
|
620
|
+
if (label) {
|
|
621
|
+
terminalTabs.push({ label, isActive, index: i });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// Method 1: xterm accessibility tree (most reliable)
|
|
626
|
+
const xtermAccessibility = targetDoc.querySelector('.xterm-accessibility-tree, .xterm-accessibility');
|
|
627
|
+
if (xtermAccessibility) {
|
|
628
|
+
// Get all rows from accessibility tree
|
|
629
|
+
const rows = xtermAccessibility.querySelectorAll('[role="listitem"], div[style*="position"]');
|
|
630
|
+
const lines = [];
|
|
631
|
+
rows.forEach(row => {
|
|
632
|
+
const rowText = row.textContent || '';
|
|
633
|
+
// Filter out screen reader toggle text
|
|
634
|
+
if (rowText && !rowText.includes('Toggle Screen Reader') && rowText.trim()) {
|
|
635
|
+
lines.push(rowText);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
if (lines.length > 0) {
|
|
639
|
+
textContent = lines.join('\\n');
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Method 2: xterm screen buffer via textarea (fallback)
|
|
644
|
+
if (!textContent.trim()) {
|
|
645
|
+
const xtermTextarea = targetDoc.querySelector('.xterm-helper-textarea');
|
|
646
|
+
if (xtermTextarea && xtermTextarea.value) {
|
|
647
|
+
textContent = xtermTextarea.value;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Method 3: Look for terminal rows with actual content
|
|
652
|
+
if (!textContent.trim()) {
|
|
653
|
+
const xtermRows = targetDoc.querySelectorAll('.xterm-rows > div, .xterm-screen .xterm-rows span');
|
|
654
|
+
const lines = [];
|
|
655
|
+
xtermRows.forEach(row => {
|
|
656
|
+
const spans = row.querySelectorAll('span');
|
|
657
|
+
let lineText = '';
|
|
658
|
+
spans.forEach(span => {
|
|
659
|
+
lineText += span.textContent || '';
|
|
660
|
+
});
|
|
661
|
+
if (!lineText) lineText = row.textContent || '';
|
|
662
|
+
if (lineText.trim()) {
|
|
663
|
+
lines.push(lineText);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
if (lines.length > 0) {
|
|
667
|
+
textContent = lines.join('\\n');
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Method 4: Try OUTPUT panel (HTML-based, not canvas)
|
|
672
|
+
if (!textContent.trim()) {
|
|
673
|
+
const outputPanel = targetDoc.querySelector('.output-view, .repl-input-wrapper, [class*="output"]');
|
|
674
|
+
if (outputPanel) {
|
|
675
|
+
const outputText = outputPanel.textContent || '';
|
|
676
|
+
if (outputText.trim()) {
|
|
677
|
+
textContent = outputText;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Method 5: Problems panel
|
|
683
|
+
if (!textContent.trim()) {
|
|
684
|
+
const problemsPanel = targetDoc.querySelector('.markers-panel, [class*="problems"]');
|
|
685
|
+
if (problemsPanel) {
|
|
686
|
+
textContent = problemsPanel.textContent || '';
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Method 6: Debug Console
|
|
691
|
+
if (!textContent.trim()) {
|
|
692
|
+
const debugConsole = targetDoc.querySelector('.debug-console, .repl, [class*="debug-console"]');
|
|
693
|
+
if (debugConsole) {
|
|
694
|
+
textContent = debugConsole.textContent || '';
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Method 7: Any panel content in bottom area
|
|
699
|
+
if (!textContent.trim()) {
|
|
700
|
+
const panelContent = targetDoc.querySelector('.panel .content, .panel-body, .pane-body');
|
|
701
|
+
if (panelContent) {
|
|
702
|
+
const panelText = panelContent.textContent || '';
|
|
703
|
+
if (panelText.trim() && panelText.length > 20) {
|
|
704
|
+
textContent = panelText;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Clean up the text
|
|
710
|
+
textContent = textContent.trim();
|
|
711
|
+
|
|
712
|
+
// Build HTML representation
|
|
713
|
+
let html = '';
|
|
714
|
+
if (terminalTabs.length > 0) {
|
|
715
|
+
html += '<div class="terminal-tabs">';
|
|
716
|
+
terminalTabs.forEach(tab => {
|
|
717
|
+
html += '<span class="terminal-tab-item' + (tab.isActive ? ' active' : '') + '">' + tab.label + '</span>';
|
|
718
|
+
});
|
|
719
|
+
html += '</div>';
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (textContent) {
|
|
723
|
+
html += '<pre class="terminal-output">' + textContent.replace(/</g, '<').replace(/>/g, '>') + '</pre>';
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
html: html,
|
|
728
|
+
textContent: textContent,
|
|
729
|
+
hasContent: textContent.length > 0,
|
|
730
|
+
tabs: terminalTabs
|
|
731
|
+
};
|
|
732
|
+
})()
|
|
733
|
+
`;
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
737
|
+
expression: script,
|
|
738
|
+
contextId: cdp.rootContextId,
|
|
739
|
+
returnByValue: true
|
|
740
|
+
});
|
|
741
|
+
if (result.result && result.result.value) {
|
|
742
|
+
const data = result.result.value;
|
|
743
|
+
if (data.hasContent) {
|
|
744
|
+
console.log(`[Terminal] Captured ${data.textContent.length} chars of output`);
|
|
745
|
+
}
|
|
746
|
+
return data;
|
|
747
|
+
}
|
|
748
|
+
} catch (err) {
|
|
749
|
+
console.error('[Terminal] Failed to capture:', err.message);
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Capture Sidebar panel HTML snapshot (File Explorer + Kiro panels)
|
|
756
|
+
* @param {CDPConnection} cdp - CDP connection
|
|
757
|
+
* @returns {Promise<{html: string, files: Array, kiroPanels: Array} | null>}
|
|
758
|
+
*/
|
|
759
|
+
async function captureSidebar(cdp) {
|
|
760
|
+
if (!cdp.rootContextId) return null;
|
|
761
|
+
|
|
762
|
+
const script = `
|
|
763
|
+
(function() {
|
|
764
|
+
let targetDoc = document;
|
|
765
|
+
const activeFrame = document.getElementById('active-frame');
|
|
766
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
767
|
+
targetDoc = activeFrame.contentDocument;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const result = {
|
|
771
|
+
html: '',
|
|
772
|
+
files: [],
|
|
773
|
+
kiroPanels: [],
|
|
774
|
+
hasContent: false
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
// Look for file explorer / sidebar
|
|
778
|
+
const sidebarSelectors = [
|
|
779
|
+
'.sidebar',
|
|
780
|
+
'.explorer-viewlet',
|
|
781
|
+
'[class*="sidebar"]',
|
|
782
|
+
'.activitybar + .part',
|
|
783
|
+
'.monaco-workbench .part.sidebar',
|
|
784
|
+
'[data-testid="sidebar"]',
|
|
785
|
+
'.composite.viewlet'
|
|
786
|
+
];
|
|
787
|
+
|
|
788
|
+
let sidebarElement = null;
|
|
789
|
+
for (const selector of sidebarSelectors) {
|
|
790
|
+
const el = targetDoc.querySelector(selector);
|
|
791
|
+
if (el && el.innerHTML.length > 50) {
|
|
792
|
+
sidebarElement = el;
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Extract file tree structure
|
|
798
|
+
const fileTreeSelectors = [
|
|
799
|
+
'.monaco-list-row',
|
|
800
|
+
'.explorer-item',
|
|
801
|
+
'[class*="tree-row"]',
|
|
802
|
+
'.file-icon-themable-tree .monaco-list-row'
|
|
803
|
+
];
|
|
804
|
+
|
|
805
|
+
for (const selector of fileTreeSelectors) {
|
|
806
|
+
const items = targetDoc.querySelectorAll(selector);
|
|
807
|
+
if (items.length > 0) {
|
|
808
|
+
items.forEach(item => {
|
|
809
|
+
const label = item.querySelector('.label-name, .monaco-icon-label-container, [class*="label"]');
|
|
810
|
+
const icon = item.querySelector('.file-icon, .folder-icon, [class*="icon"]');
|
|
811
|
+
const isFolder = item.classList.contains('folder') ||
|
|
812
|
+
item.querySelector('.folder-icon') !== null ||
|
|
813
|
+
item.getAttribute('aria-expanded') !== null;
|
|
814
|
+
const isExpanded = item.getAttribute('aria-expanded') === 'true';
|
|
815
|
+
const depth = parseInt(item.style.paddingLeft || item.style.textIndent || '0') / 8 || 0;
|
|
816
|
+
|
|
817
|
+
if (label && label.textContent) {
|
|
818
|
+
result.files.push({
|
|
819
|
+
name: label.textContent.trim(),
|
|
820
|
+
isFolder,
|
|
821
|
+
isExpanded,
|
|
822
|
+
depth: Math.floor(depth)
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Look for Kiro-specific panels (specs, hooks, steering)
|
|
831
|
+
const kiroPanelSelectors = [
|
|
832
|
+
'[class*="kiro"]',
|
|
833
|
+
'[data-testid*="kiro"]',
|
|
834
|
+
'.specs-panel',
|
|
835
|
+
'.hooks-panel',
|
|
836
|
+
'.steering-panel'
|
|
837
|
+
];
|
|
838
|
+
|
|
839
|
+
for (const selector of kiroPanelSelectors) {
|
|
840
|
+
const panels = targetDoc.querySelectorAll(selector);
|
|
841
|
+
panels.forEach(panel => {
|
|
842
|
+
const title = panel.querySelector('h2, h3, .title, [class*="title"]');
|
|
843
|
+
if (title && title.textContent) {
|
|
844
|
+
result.kiroPanels.push({
|
|
845
|
+
title: title.textContent.trim(),
|
|
846
|
+
html: panel.outerHTML.substring(0, 5000) // Limit size
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (sidebarElement) {
|
|
853
|
+
result.html = sidebarElement.outerHTML;
|
|
854
|
+
result.hasContent = true;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
result.hasContent = result.hasContent || result.files.length > 0 || result.kiroPanels.length > 0;
|
|
858
|
+
|
|
859
|
+
return result;
|
|
860
|
+
})()
|
|
861
|
+
`;
|
|
862
|
+
|
|
863
|
+
try {
|
|
864
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
865
|
+
expression: script,
|
|
866
|
+
contextId: cdp.rootContextId,
|
|
867
|
+
returnByValue: true
|
|
868
|
+
});
|
|
869
|
+
if (result.result && result.result.value) return result.result.value;
|
|
870
|
+
} catch (err) {
|
|
871
|
+
console.error('[Sidebar] Failed to capture:', err.message);
|
|
872
|
+
}
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Capture Editor panel HTML snapshot (currently open file)
|
|
878
|
+
* @param {CDPConnection} cdp - CDP connection
|
|
879
|
+
* @returns {Promise<{html: string, fileName: string, language: string, content: string} | null>}
|
|
880
|
+
*/
|
|
881
|
+
async function captureEditor(cdp) {
|
|
882
|
+
if (!cdp.rootContextId) return null;
|
|
883
|
+
|
|
884
|
+
const script = `
|
|
885
|
+
(function() {
|
|
886
|
+
let targetDoc = document;
|
|
887
|
+
const activeFrame = document.getElementById('active-frame');
|
|
888
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
889
|
+
targetDoc = activeFrame.contentDocument;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const result = {
|
|
893
|
+
html: '',
|
|
894
|
+
fileName: '',
|
|
895
|
+
language: '',
|
|
896
|
+
content: '',
|
|
897
|
+
lineCount: 0,
|
|
898
|
+
hasContent: false
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
// Get active tab / file name - try multiple selectors
|
|
902
|
+
const tabSelectors = [
|
|
903
|
+
'.tab.active .label-name',
|
|
904
|
+
'.tab.active .monaco-icon-label-container .label-name',
|
|
905
|
+
'.tab.selected .monaco-icon-label',
|
|
906
|
+
'[class*="tab"][class*="active"] .label-name',
|
|
907
|
+
'.editor-group-container .tab.active',
|
|
908
|
+
'.tabs-container .tab.active',
|
|
909
|
+
'.tab.active',
|
|
910
|
+
'[role="tab"][aria-selected="true"]'
|
|
911
|
+
];
|
|
912
|
+
|
|
913
|
+
for (const selector of tabSelectors) {
|
|
914
|
+
try {
|
|
915
|
+
const tab = targetDoc.querySelector(selector);
|
|
916
|
+
if (tab && tab.textContent) {
|
|
917
|
+
result.fileName = tab.textContent.trim().split('\\n')[0].trim();
|
|
918
|
+
if (result.fileName) break;
|
|
919
|
+
}
|
|
920
|
+
} catch(e) {}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Try to get content from Monaco editor's internal model (best approach)
|
|
924
|
+
try {
|
|
925
|
+
// Look for Monaco editor instance
|
|
926
|
+
const monacoEditors = targetDoc.querySelectorAll('.monaco-editor');
|
|
927
|
+
for (const editorEl of monacoEditors) {
|
|
928
|
+
// Try to access the editor instance through VS Code's API
|
|
929
|
+
const editorInstance = editorEl.__vscode_editor__ ||
|
|
930
|
+
editorEl._editor ||
|
|
931
|
+
(window.monaco && window.monaco.editor.getEditors && window.monaco.editor.getEditors()[0]);
|
|
932
|
+
|
|
933
|
+
if (editorInstance && editorInstance.getModel) {
|
|
934
|
+
const model = editorInstance.getModel();
|
|
935
|
+
if (model) {
|
|
936
|
+
result.content = model.getValue();
|
|
937
|
+
result.lineCount = model.getLineCount();
|
|
938
|
+
result.language = model.getLanguageId ? model.getLanguageId() : (model.getModeId ? model.getModeId() : '');
|
|
939
|
+
result.hasContent = true;
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
} catch(e) {
|
|
945
|
+
console.log('Monaco API access failed:', e);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Fallback: Try to get content from textarea (some editors use this)
|
|
949
|
+
if (!result.content) {
|
|
950
|
+
try {
|
|
951
|
+
const textareas = targetDoc.querySelectorAll('textarea.inputarea, textarea[class*="input"]');
|
|
952
|
+
for (const ta of textareas) {
|
|
953
|
+
if (ta.value && ta.value.length > 10) {
|
|
954
|
+
result.content = ta.value;
|
|
955
|
+
result.hasContent = true;
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
} catch(e) {}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Fallback: Extract from visible view-lines (limited to what's rendered)
|
|
963
|
+
if (!result.content) {
|
|
964
|
+
const editorSelectors = [
|
|
965
|
+
'.monaco-editor .view-lines',
|
|
966
|
+
'.monaco-editor',
|
|
967
|
+
'.lines-content'
|
|
968
|
+
];
|
|
969
|
+
|
|
970
|
+
let viewLinesElement = null;
|
|
971
|
+
let editorElement = null;
|
|
972
|
+
for (const selector of editorSelectors) {
|
|
973
|
+
try {
|
|
974
|
+
const el = targetDoc.querySelector(selector);
|
|
975
|
+
if (el) {
|
|
976
|
+
viewLinesElement = el.querySelector('.view-lines') || el;
|
|
977
|
+
editorElement = el.closest('.monaco-editor') || el;
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
} catch(e) {}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (viewLinesElement) {
|
|
984
|
+
const lines = viewLinesElement.querySelectorAll('.view-line');
|
|
985
|
+
|
|
986
|
+
// Try to get line numbers from the line number gutter
|
|
987
|
+
const lineNumberElements = editorElement ?
|
|
988
|
+
editorElement.querySelectorAll('.line-numbers, .margin-view-overlays .line-numbers') : [];
|
|
989
|
+
|
|
990
|
+
if (lines.length > 0) {
|
|
991
|
+
// Create a map of line number to content
|
|
992
|
+
const lineMap = new Map();
|
|
993
|
+
let minLineNum = Infinity;
|
|
994
|
+
let maxLineNum = 0;
|
|
995
|
+
|
|
996
|
+
// Try to match lines with their line numbers from the gutter
|
|
997
|
+
const lineNumMap = new Map();
|
|
998
|
+
lineNumberElements.forEach(ln => {
|
|
999
|
+
const top = parseFloat(ln.style.top) || 0;
|
|
1000
|
+
const num = parseInt(ln.textContent, 10);
|
|
1001
|
+
if (!isNaN(num)) {
|
|
1002
|
+
lineNumMap.set(Math.round(top), num);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
lines.forEach(line => {
|
|
1007
|
+
const top = parseFloat(line.style.top) || 0;
|
|
1008
|
+
const roundedTop = Math.round(top);
|
|
1009
|
+
|
|
1010
|
+
// Try to get line number from gutter, or calculate from position
|
|
1011
|
+
let lineNum = lineNumMap.get(roundedTop);
|
|
1012
|
+
if (!lineNum) {
|
|
1013
|
+
// Fallback: calculate from top position (19px line height)
|
|
1014
|
+
const lineHeight = 19;
|
|
1015
|
+
lineNum = Math.round(top / lineHeight) + 1;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const text = line.textContent || '';
|
|
1019
|
+
lineMap.set(lineNum, text);
|
|
1020
|
+
minLineNum = Math.min(minLineNum, lineNum);
|
|
1021
|
+
maxLineNum = Math.max(maxLineNum, lineNum);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
// Build content from line map, starting from minLineNum
|
|
1025
|
+
let codeContent = '';
|
|
1026
|
+
const startLine = Math.max(1, minLineNum);
|
|
1027
|
+
for (let i = startLine; i <= Math.min(maxLineNum, startLine + 500); i++) {
|
|
1028
|
+
codeContent += (lineMap.get(i) || '') + '\\n';
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
result.content = codeContent;
|
|
1032
|
+
result.lineCount = maxLineNum;
|
|
1033
|
+
result.startLine = startLine;
|
|
1034
|
+
result.hasContent = codeContent.trim().length > 0;
|
|
1035
|
+
|
|
1036
|
+
// Mark as partial if we don't start from line 1
|
|
1037
|
+
if (startLine > 1) {
|
|
1038
|
+
result.isPartial = true;
|
|
1039
|
+
result.note = 'Showing lines ' + startLine + '-' + maxLineNum + '. Scroll in Kiro to see other parts.';
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Get language from editor element if not already set
|
|
1046
|
+
if (!result.language) {
|
|
1047
|
+
try {
|
|
1048
|
+
const monacoEditor = targetDoc.querySelector('.monaco-editor');
|
|
1049
|
+
if (monacoEditor) {
|
|
1050
|
+
const modeId = monacoEditor.getAttribute('data-mode-id');
|
|
1051
|
+
if (modeId) result.language = modeId;
|
|
1052
|
+
|
|
1053
|
+
const langMatch = monacoEditor.className.match(/\\b(typescript|javascript|python|java|html|css|json|markdown|yaml|xml|sql|go|rust|c|cpp|csharp)\\b/i);
|
|
1054
|
+
if (langMatch) result.language = langMatch[1].toLowerCase();
|
|
1055
|
+
}
|
|
1056
|
+
} catch(e) {}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Fallback: detect language from filename
|
|
1060
|
+
if (!result.language && result.fileName) {
|
|
1061
|
+
const ext = result.fileName.split('.').pop()?.toLowerCase();
|
|
1062
|
+
const extMap = {
|
|
1063
|
+
'ts': 'typescript', 'tsx': 'typescript',
|
|
1064
|
+
'js': 'javascript', 'jsx': 'javascript',
|
|
1065
|
+
'py': 'python', 'java': 'java',
|
|
1066
|
+
'html': 'html', 'css': 'css',
|
|
1067
|
+
'json': 'json', 'md': 'markdown',
|
|
1068
|
+
'yaml': 'yaml', 'yml': 'yaml',
|
|
1069
|
+
'xml': 'xml', 'sql': 'sql',
|
|
1070
|
+
'go': 'go', 'rs': 'rust',
|
|
1071
|
+
'c': 'c', 'cpp': 'cpp', 'h': 'c',
|
|
1072
|
+
'cs': 'csharp', 'rb': 'ruby',
|
|
1073
|
+
'php': 'php', 'sh': 'bash'
|
|
1074
|
+
};
|
|
1075
|
+
result.language = extMap[ext] || ext || '';
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Add note about partial content
|
|
1079
|
+
if (result.hasContent && result.lineCount < 50) {
|
|
1080
|
+
result.isPartial = true;
|
|
1081
|
+
result.note = 'Showing visible lines only. Scroll in Kiro to see more.';
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return result;
|
|
1085
|
+
})()
|
|
1086
|
+
`;
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
1090
|
+
expression: script,
|
|
1091
|
+
contextId: cdp.rootContextId,
|
|
1092
|
+
returnByValue: true
|
|
1093
|
+
});
|
|
1094
|
+
if (result.result && result.result.value) return result.result.value;
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
console.error('[Editor] Failed to capture:', err.message);
|
|
1097
|
+
}
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Alternative: Read file content directly from filesystem
|
|
1103
|
+
* This is more reliable than trying to scrape Monaco editor
|
|
1104
|
+
*/
|
|
1105
|
+
async function readFileContent(filePath, workspaceRoot) {
|
|
1106
|
+
const fs = await import('fs/promises');
|
|
1107
|
+
const path = await import('path');
|
|
1108
|
+
|
|
1109
|
+
try {
|
|
1110
|
+
// Try to resolve the file path
|
|
1111
|
+
let fullPath = filePath;
|
|
1112
|
+
if (!path.isAbsolute(filePath) && workspaceRoot) {
|
|
1113
|
+
fullPath = path.join(workspaceRoot, filePath);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
1117
|
+
return content;
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
console.error('[ReadFile] Failed to read:', err.message);
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Poll all cascades for snapshot changes
|
|
1126
|
+
* Captures snapshots, compares hashes, and broadcasts updates on change
|
|
1127
|
+
*/
|
|
1128
|
+
async function pollSnapshots() {
|
|
1129
|
+
for (const [cascadeId, cascade] of cascades) {
|
|
1130
|
+
try {
|
|
1131
|
+
const cdp = cascade.cdp;
|
|
1132
|
+
|
|
1133
|
+
// Capture CSS once if not already captured
|
|
1134
|
+
if (cascade.css === null) {
|
|
1135
|
+
console.log(`[Snapshot] Capturing CSS for cascade ${cascadeId}...`);
|
|
1136
|
+
cascade.css = await captureCSS(cdp);
|
|
1137
|
+
console.log(`[Snapshot] CSS captured: ${cascade.css.length} chars`);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Capture metadata
|
|
1141
|
+
const metadata = await captureMetadata(cdp);
|
|
1142
|
+
cascade.metadata.chatTitle = metadata.chatTitle || cascade.metadata.chatTitle;
|
|
1143
|
+
cascade.metadata.isActive = metadata.isActive;
|
|
1144
|
+
|
|
1145
|
+
// Capture HTML snapshot (chat) from Kiro Agent webview
|
|
1146
|
+
const snapshot = await captureSnapshot(cdp);
|
|
1147
|
+
|
|
1148
|
+
if (snapshot) {
|
|
1149
|
+
// Log debug info for troubleshooting
|
|
1150
|
+
if (snapshot.debug) {
|
|
1151
|
+
console.log(`[Snapshot] Debug for ${cascadeId}:`, JSON.stringify(snapshot.debug, null, 2));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const newHash = computeHash(snapshot.html);
|
|
1155
|
+
if (newHash !== cascade.snapshotHash) {
|
|
1156
|
+
console.log(`[Snapshot] Chat content changed for cascade ${cascadeId} (${snapshot.html.length} chars)`);
|
|
1157
|
+
cascade.snapshot = snapshot;
|
|
1158
|
+
cascade.snapshotHash = newHash;
|
|
1159
|
+
broadcastSnapshotUpdate(cascadeId, 'chat');
|
|
1160
|
+
}
|
|
1161
|
+
} else {
|
|
1162
|
+
console.log(`[Snapshot] captureSnapshot returned null for cascade ${cascadeId}`);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Use main window CDP for terminal/sidebar/editor
|
|
1166
|
+
const mainCDP = mainWindowCDP.connection;
|
|
1167
|
+
if (mainCDP && mainCDP.rootContextId) {
|
|
1168
|
+
// Capture Terminal snapshot from main window
|
|
1169
|
+
const terminal = await captureTerminal(mainCDP);
|
|
1170
|
+
if (terminal && terminal.hasContent) {
|
|
1171
|
+
const termHash = computeHash(terminal.html || terminal.textContent || '');
|
|
1172
|
+
if (termHash !== cascade.terminalHash) {
|
|
1173
|
+
console.log(`[Snapshot] Terminal content changed for cascade ${cascadeId}`);
|
|
1174
|
+
cascade.terminal = terminal;
|
|
1175
|
+
cascade.terminalHash = termHash;
|
|
1176
|
+
broadcastSnapshotUpdate(cascadeId, 'terminal');
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Capture Sidebar snapshot from main window
|
|
1181
|
+
const sidebar = await captureSidebar(mainCDP);
|
|
1182
|
+
if (sidebar && sidebar.hasContent) {
|
|
1183
|
+
const sideHash = computeHash(JSON.stringify(sidebar.files) + sidebar.html);
|
|
1184
|
+
if (sideHash !== cascade.sidebarHash) {
|
|
1185
|
+
console.log(`[Snapshot] Sidebar content changed for cascade ${cascadeId}`);
|
|
1186
|
+
cascade.sidebar = sidebar;
|
|
1187
|
+
cascade.sidebarHash = sideHash;
|
|
1188
|
+
broadcastSnapshotUpdate(cascadeId, 'sidebar');
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Capture Editor snapshot from main window
|
|
1193
|
+
const editor = await captureEditor(mainCDP);
|
|
1194
|
+
if (editor && editor.hasContent) {
|
|
1195
|
+
const editorHash = computeHash(editor.content + editor.fileName);
|
|
1196
|
+
if (editorHash !== cascade.editorHash) {
|
|
1197
|
+
console.log(`[Snapshot] Editor content changed for cascade ${cascadeId}`);
|
|
1198
|
+
cascade.editor = editor;
|
|
1199
|
+
cascade.editorHash = editorHash;
|
|
1200
|
+
broadcastSnapshotUpdate(cascadeId, 'editor');
|
|
1201
|
+
}
|
|
1202
|
+
} else if (cascade.editor && cascade.editor.hasContent) {
|
|
1203
|
+
// Clear stale editor data when no file is open
|
|
1204
|
+
console.log(`[Snapshot] Editor closed/no file open for cascade ${cascadeId}`);
|
|
1205
|
+
cascade.editor = { hasContent: false, fileName: '', content: '' };
|
|
1206
|
+
cascade.editorHash = '';
|
|
1207
|
+
broadcastSnapshotUpdate(cascadeId, 'editor');
|
|
1208
|
+
}
|
|
1209
|
+
} else if (!mainCDP) {
|
|
1210
|
+
// Main window not connected yet - this is normal during startup
|
|
1211
|
+
}
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
console.error(`[Snapshot] Error polling cascade ${cascadeId}:`, err.message);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Broadcast a snapshot update notification to all connected WebSocket clients
|
|
1220
|
+
* @param {string} cascadeId - ID of the cascade that was updated
|
|
1221
|
+
* @param {string} panel - Panel type that was updated (chat, terminal, sidebar, editor)
|
|
1222
|
+
*/
|
|
1223
|
+
function broadcastSnapshotUpdate(cascadeId, panel = 'chat') {
|
|
1224
|
+
const message = JSON.stringify({
|
|
1225
|
+
type: 'snapshot_update',
|
|
1226
|
+
cascadeId,
|
|
1227
|
+
panel
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
for (const client of wss.clients) {
|
|
1231
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1232
|
+
client.send(message);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// =============================================================================
|
|
1238
|
+
// Message Injection (Task 6)
|
|
1239
|
+
// =============================================================================
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* CDP script to inject a message into the chat input and send it.
|
|
1243
|
+
*
|
|
1244
|
+
* This script:
|
|
1245
|
+
* 1. Finds the chat input element (contenteditable div or textarea)
|
|
1246
|
+
* 2. Inserts the message text into the input
|
|
1247
|
+
* 3. Triggers the send button click or dispatches Enter key event
|
|
1248
|
+
*
|
|
1249
|
+
* @param {string} messageText - The message to inject (will be escaped)
|
|
1250
|
+
* @returns {string} - JavaScript expression to evaluate in the page context
|
|
1251
|
+
*/
|
|
1252
|
+
function createInjectMessageScript(messageText) {
|
|
1253
|
+
// Escape the message for safe inclusion in JavaScript string
|
|
1254
|
+
const escapedMessage = messageText
|
|
1255
|
+
.replace(/\\/g, '\\\\')
|
|
1256
|
+
.replace(/'/g, "\\'")
|
|
1257
|
+
.replace(/\n/g, '\\n')
|
|
1258
|
+
.replace(/\r/g, '\\r');
|
|
1259
|
+
|
|
1260
|
+
return `(async () => {
|
|
1261
|
+
const text = '${escapedMessage}';
|
|
1262
|
+
|
|
1263
|
+
// VS Code webviews use nested iframes - look for #active-frame
|
|
1264
|
+
let targetDoc = document;
|
|
1265
|
+
const activeFrame = document.getElementById('active-frame');
|
|
1266
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
1267
|
+
targetDoc = activeFrame.contentDocument;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// 6.1 Find input element (contenteditable or textarea)
|
|
1271
|
+
// Try Kiro's Lexical editor first (contenteditable div)
|
|
1272
|
+
let editors = [...targetDoc.querySelectorAll('[data-lexical-editor="true"][contenteditable="true"][role="textbox"]')]
|
|
1273
|
+
.filter(el => el.offsetParent !== null);
|
|
1274
|
+
let editor = editors.at(-1);
|
|
1275
|
+
|
|
1276
|
+
// Fallback: try any contenteditable in the cascade area
|
|
1277
|
+
if (!editor) {
|
|
1278
|
+
editors = [...targetDoc.querySelectorAll('#cascade [contenteditable="true"]')]
|
|
1279
|
+
.filter(el => el.offsetParent !== null);
|
|
1280
|
+
editor = editors.at(-1);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Fallback: try any contenteditable
|
|
1284
|
+
if (!editor) {
|
|
1285
|
+
editors = [...targetDoc.querySelectorAll('[contenteditable="true"]')]
|
|
1286
|
+
.filter(el => el.offsetParent !== null);
|
|
1287
|
+
editor = editors.at(-1);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Fallback: try textarea
|
|
1291
|
+
if (!editor) {
|
|
1292
|
+
const textareas = [...targetDoc.querySelectorAll('textarea')]
|
|
1293
|
+
.filter(el => el.offsetParent !== null);
|
|
1294
|
+
editor = textareas.at(-1);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (!editor) {
|
|
1298
|
+
return { ok: false, error: 'editor_not_found', message: 'Could not find chat input element' };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const isTextarea = editor.tagName.toLowerCase() === 'textarea';
|
|
1302
|
+
|
|
1303
|
+
// 6.2 Insert text into input element
|
|
1304
|
+
editor.focus();
|
|
1305
|
+
|
|
1306
|
+
if (isTextarea) {
|
|
1307
|
+
// For textarea, set value directly and dispatch input event
|
|
1308
|
+
editor.value = text;
|
|
1309
|
+
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1310
|
+
editor.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1311
|
+
} else {
|
|
1312
|
+
// For contenteditable, use execCommand or fallback to direct manipulation
|
|
1313
|
+
// First, select all and delete existing content
|
|
1314
|
+
targetDoc.execCommand?.('selectAll', false, null);
|
|
1315
|
+
targetDoc.execCommand?.('delete', false, null);
|
|
1316
|
+
|
|
1317
|
+
// Try insertText command
|
|
1318
|
+
let inserted = false;
|
|
1319
|
+
try {
|
|
1320
|
+
inserted = !!targetDoc.execCommand?.('insertText', false, text);
|
|
1321
|
+
} catch (e) {
|
|
1322
|
+
inserted = false;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Fallback: set textContent and dispatch events
|
|
1326
|
+
if (!inserted) {
|
|
1327
|
+
editor.textContent = text;
|
|
1328
|
+
editor.dispatchEvent(new InputEvent('beforeinput', {
|
|
1329
|
+
bubbles: true,
|
|
1330
|
+
inputType: 'insertText',
|
|
1331
|
+
data: text
|
|
1332
|
+
}));
|
|
1333
|
+
editor.dispatchEvent(new InputEvent('input', {
|
|
1334
|
+
bubbles: true,
|
|
1335
|
+
inputType: 'insertText',
|
|
1336
|
+
data: text
|
|
1337
|
+
}));
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Wait for React/framework to process the input
|
|
1342
|
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
1343
|
+
|
|
1344
|
+
// 6.3 Trigger send button click or Enter key
|
|
1345
|
+
// Try to find the send button (arrow-right icon button)
|
|
1346
|
+
const submitButton = targetDoc.querySelector('svg.lucide-arrow-right')?.closest('button');
|
|
1347
|
+
|
|
1348
|
+
if (submitButton && !submitButton.disabled) {
|
|
1349
|
+
submitButton.click();
|
|
1350
|
+
return { ok: true, method: 'click_submit', inputType: isTextarea ? 'textarea' : 'contenteditable' };
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Fallback: try other common send button patterns
|
|
1354
|
+
const altSubmitButtons = [
|
|
1355
|
+
targetDoc.querySelector('[data-tooltip-id*="send"]')?.closest('button'),
|
|
1356
|
+
targetDoc.querySelector('button[type="submit"]'),
|
|
1357
|
+
targetDoc.querySelector('button[aria-label*="send" i]'),
|
|
1358
|
+
targetDoc.querySelector('button[aria-label*="submit" i]')
|
|
1359
|
+
].filter(btn => btn && !btn.disabled && btn.offsetParent !== null);
|
|
1360
|
+
|
|
1361
|
+
if (altSubmitButtons.length > 0) {
|
|
1362
|
+
altSubmitButtons[0].click();
|
|
1363
|
+
return { ok: true, method: 'click_alt_submit', inputType: isTextarea ? 'textarea' : 'contenteditable' };
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Last resort: dispatch Enter key event
|
|
1367
|
+
editor.dispatchEvent(new KeyboardEvent('keydown', {
|
|
1368
|
+
bubbles: true,
|
|
1369
|
+
key: 'Enter',
|
|
1370
|
+
code: 'Enter',
|
|
1371
|
+
keyCode: 13,
|
|
1372
|
+
which: 13
|
|
1373
|
+
}));
|
|
1374
|
+
editor.dispatchEvent(new KeyboardEvent('keypress', {
|
|
1375
|
+
bubbles: true,
|
|
1376
|
+
key: 'Enter',
|
|
1377
|
+
code: 'Enter',
|
|
1378
|
+
keyCode: 13,
|
|
1379
|
+
which: 13
|
|
1380
|
+
}));
|
|
1381
|
+
editor.dispatchEvent(new KeyboardEvent('keyup', {
|
|
1382
|
+
bubbles: true,
|
|
1383
|
+
key: 'Enter',
|
|
1384
|
+
code: 'Enter',
|
|
1385
|
+
keyCode: 13,
|
|
1386
|
+
which: 13
|
|
1387
|
+
}));
|
|
1388
|
+
|
|
1389
|
+
return {
|
|
1390
|
+
ok: true,
|
|
1391
|
+
method: 'enter_key',
|
|
1392
|
+
inputType: isTextarea ? 'textarea' : 'contenteditable',
|
|
1393
|
+
submitButtonFound: !!submitButton,
|
|
1394
|
+
submitButtonDisabled: submitButton?.disabled ?? null
|
|
1395
|
+
};
|
|
1396
|
+
})()`;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Inject a message into the chat via CDP
|
|
1401
|
+
*
|
|
1402
|
+
* @param {CDPConnection} cdp - CDP connection object
|
|
1403
|
+
* @param {string} message - Message text to inject
|
|
1404
|
+
* @returns {Promise<{success: boolean, method?: string, error?: string}>}
|
|
1405
|
+
*/
|
|
1406
|
+
async function injectMessage(cdp, message) {
|
|
1407
|
+
if (!cdp.rootContextId) {
|
|
1408
|
+
return { success: false, error: 'No execution context available' };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const script = createInjectMessageScript(message);
|
|
1412
|
+
|
|
1413
|
+
try {
|
|
1414
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
1415
|
+
expression: script,
|
|
1416
|
+
contextId: cdp.rootContextId,
|
|
1417
|
+
returnByValue: true,
|
|
1418
|
+
awaitPromise: true
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
if (result.exceptionDetails) {
|
|
1422
|
+
const errorMsg = result.exceptionDetails.exception?.description ||
|
|
1423
|
+
result.exceptionDetails.text ||
|
|
1424
|
+
'Unknown error';
|
|
1425
|
+
console.error('[Inject] Script exception:', errorMsg);
|
|
1426
|
+
return { success: false, error: errorMsg };
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const value = result.result?.value;
|
|
1430
|
+
if (!value) {
|
|
1431
|
+
return { success: false, error: 'No result from injection script' };
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
if (value.ok) {
|
|
1435
|
+
console.log(`[Inject] Message sent via ${value.method} (${value.inputType})`);
|
|
1436
|
+
return {
|
|
1437
|
+
success: true,
|
|
1438
|
+
method: value.method,
|
|
1439
|
+
inputType: value.inputType
|
|
1440
|
+
};
|
|
1441
|
+
} else {
|
|
1442
|
+
console.error('[Inject] Failed:', value.error, value.message);
|
|
1443
|
+
return { success: false, error: value.message || value.error };
|
|
1444
|
+
}
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
console.error('[Inject] CDP call failed:', err.message);
|
|
1447
|
+
return { success: false, error: err.message };
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// =============================================================================
|
|
1452
|
+
// Discovery Loop (Task 3)
|
|
1453
|
+
// =============================================================================
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Discover CDP targets across all configured ports
|
|
1457
|
+
* Scans ports 9000-9003, connects to:
|
|
1458
|
+
* 1. Kiro Agent webview (for chat)
|
|
1459
|
+
* 2. Main VS Code window (for terminal, sidebar, editor)
|
|
1460
|
+
*/
|
|
1461
|
+
async function discoverTargets() {
|
|
1462
|
+
console.log('[Discovery] Scanning for CDP targets...');
|
|
1463
|
+
|
|
1464
|
+
// Track which cascade IDs we find in this scan
|
|
1465
|
+
const foundCascadeIds = new Set();
|
|
1466
|
+
let foundMainWindow = false;
|
|
1467
|
+
|
|
1468
|
+
// 3.1 Scan all CDP ports for targets
|
|
1469
|
+
for (const port of CDP_PORTS) {
|
|
1470
|
+
try {
|
|
1471
|
+
const targets = await fetchCDPTargets(port);
|
|
1472
|
+
|
|
1473
|
+
// Debug: log all targets found on this port
|
|
1474
|
+
console.log(`[Discovery] Port ${port}: Found ${targets.length} target(s)`);
|
|
1475
|
+
targets.forEach((t, i) => {
|
|
1476
|
+
console.log(` [${i}] type="${t.type}" title="${t.title?.substring(0, 40)}" url="${t.url?.substring(0, 50)}..."`);
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
// Find the main VS Code window (type: page, url starts with vscode-file://)
|
|
1480
|
+
const mainWindowTarget = targets.find(target => {
|
|
1481
|
+
const url = (target.url || '').toLowerCase();
|
|
1482
|
+
return target.type === 'page' &&
|
|
1483
|
+
(url.startsWith('vscode-file://') || url.includes('workbench')) &&
|
|
1484
|
+
target.webSocketDebuggerUrl;
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
// Connect to main window for terminal/sidebar/editor
|
|
1488
|
+
if (mainWindowTarget && !mainWindowCDP.connection) {
|
|
1489
|
+
console.log(`[Discovery] Found main VS Code window: ${mainWindowTarget.title}`);
|
|
1490
|
+
try {
|
|
1491
|
+
const cdp = await connectToCDP(mainWindowTarget.webSocketDebuggerUrl);
|
|
1492
|
+
mainWindowCDP.connection = cdp;
|
|
1493
|
+
mainWindowCDP.id = generateCascadeId(mainWindowTarget.webSocketDebuggerUrl);
|
|
1494
|
+
foundMainWindow = true;
|
|
1495
|
+
console.log(`[Discovery] Connected to main window: ${mainWindowCDP.id}`);
|
|
1496
|
+
|
|
1497
|
+
// Set up disconnect handler
|
|
1498
|
+
cdp.ws.on('close', () => {
|
|
1499
|
+
console.log(`[Discovery] Main window disconnected`);
|
|
1500
|
+
mainWindowCDP.connection = null;
|
|
1501
|
+
mainWindowCDP.id = null;
|
|
1502
|
+
});
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
console.error(`[Discovery] Failed to connect to main window: ${err.message}`);
|
|
1505
|
+
}
|
|
1506
|
+
} else if (mainWindowTarget) {
|
|
1507
|
+
foundMainWindow = true;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Find Kiro Agent webview (for chat)
|
|
1511
|
+
const kiroAgentTargets = targets.filter(target => {
|
|
1512
|
+
const url = (target.url || '').toLowerCase();
|
|
1513
|
+
return (url.includes('kiroagent') || url.includes('vscode-webview')) &&
|
|
1514
|
+
target.webSocketDebuggerUrl &&
|
|
1515
|
+
target.type !== 'page';
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
for (const target of kiroAgentTargets) {
|
|
1519
|
+
const wsUrl = target.webSocketDebuggerUrl;
|
|
1520
|
+
const cascadeId = generateCascadeId(wsUrl);
|
|
1521
|
+
foundCascadeIds.add(cascadeId);
|
|
1522
|
+
|
|
1523
|
+
// 3.3 Connect to new targets, reuse existing connections
|
|
1524
|
+
if (!cascades.has(cascadeId)) {
|
|
1525
|
+
console.log(`[Discovery] Found new Kiro Agent: ${target.title} (${cascadeId})`);
|
|
1526
|
+
|
|
1527
|
+
try {
|
|
1528
|
+
const cdp = await connectToCDP(wsUrl);
|
|
1529
|
+
|
|
1530
|
+
// Create cascade object
|
|
1531
|
+
const cascade = {
|
|
1532
|
+
id: cascadeId,
|
|
1533
|
+
cdp,
|
|
1534
|
+
metadata: {
|
|
1535
|
+
windowTitle: target.title || 'Unknown',
|
|
1536
|
+
chatTitle: '',
|
|
1537
|
+
isActive: true
|
|
1538
|
+
},
|
|
1539
|
+
snapshot: null,
|
|
1540
|
+
css: null,
|
|
1541
|
+
snapshotHash: null,
|
|
1542
|
+
// Panel snapshots (populated from main window)
|
|
1543
|
+
terminal: null,
|
|
1544
|
+
terminalHash: null,
|
|
1545
|
+
sidebar: null,
|
|
1546
|
+
sidebarHash: null,
|
|
1547
|
+
editor: null,
|
|
1548
|
+
editorHash: null
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
cascades.set(cascadeId, cascade);
|
|
1552
|
+
console.log(`[Discovery] Connected to cascade: ${cascadeId}`);
|
|
1553
|
+
|
|
1554
|
+
// Set up disconnect handler
|
|
1555
|
+
cdp.ws.on('close', () => {
|
|
1556
|
+
console.log(`[Discovery] Cascade disconnected: ${cascadeId}`);
|
|
1557
|
+
cascades.delete(cascadeId);
|
|
1558
|
+
broadcastCascadeList();
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// Broadcast updated cascade list to all clients
|
|
1562
|
+
broadcastCascadeList();
|
|
1563
|
+
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
console.error(`[Discovery] Failed to connect to ${cascadeId}: ${err.message}`);
|
|
1566
|
+
}
|
|
1567
|
+
} else {
|
|
1568
|
+
// Update metadata for existing connection
|
|
1569
|
+
const cascade = cascades.get(cascadeId);
|
|
1570
|
+
cascade.metadata.windowTitle = target.title || cascade.metadata.windowTitle;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
// Port not available or no CDP server
|
|
1575
|
+
console.log(`[Discovery] Port ${port}: ${err.message}`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// 3.4 Clean up disconnected targets
|
|
1580
|
+
for (const [cascadeId, cascade] of cascades) {
|
|
1581
|
+
if (!foundCascadeIds.has(cascadeId)) {
|
|
1582
|
+
console.log(`[Discovery] Target no longer available: ${cascadeId}`);
|
|
1583
|
+
try {
|
|
1584
|
+
cascade.cdp.close();
|
|
1585
|
+
} catch (e) {}
|
|
1586
|
+
cascades.delete(cascadeId);
|
|
1587
|
+
broadcastCascadeList();
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
console.log(`[Discovery] Active cascades: ${cascades.size}`);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Broadcast the current cascade list to all connected WebSocket clients
|
|
1596
|
+
*/
|
|
1597
|
+
function broadcastCascadeList() {
|
|
1598
|
+
const cascadeList = Array.from(cascades.values()).map(c => ({
|
|
1599
|
+
id: c.id,
|
|
1600
|
+
title: c.metadata.chatTitle || c.metadata.windowTitle,
|
|
1601
|
+
window: c.metadata.windowTitle,
|
|
1602
|
+
active: c.metadata.isActive
|
|
1603
|
+
}));
|
|
1604
|
+
|
|
1605
|
+
const message = JSON.stringify({
|
|
1606
|
+
type: 'cascade_list',
|
|
1607
|
+
cascades: cascadeList
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
for (const client of wss.clients) {
|
|
1611
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1612
|
+
client.send(message);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// Get local IP address for display
|
|
1618
|
+
function getLocalIP() {
|
|
1619
|
+
const interfaces = networkInterfaces();
|
|
1620
|
+
for (const name of Object.keys(interfaces)) {
|
|
1621
|
+
for (const iface of interfaces[name]) {
|
|
1622
|
+
// Skip internal and non-IPv4 addresses
|
|
1623
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
1624
|
+
return iface.address;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return 'localhost';
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Express app setup
|
|
1632
|
+
const app = express();
|
|
1633
|
+
app.use(express.json());
|
|
1634
|
+
|
|
1635
|
+
// Serve static files from public directory
|
|
1636
|
+
app.use(express.static(join(__dirname, 'public')));
|
|
1637
|
+
|
|
1638
|
+
// =============================================================================
|
|
1639
|
+
// REST API Endpoints (Task 5)
|
|
1640
|
+
// =============================================================================
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* GET /cascades - List active chat sessions
|
|
1644
|
+
* Returns array of { id, title, window, active }
|
|
1645
|
+
*/
|
|
1646
|
+
app.get('/cascades', (req, res) => {
|
|
1647
|
+
const cascadeList = Array.from(cascades.values()).map(c => ({
|
|
1648
|
+
id: c.id,
|
|
1649
|
+
title: c.metadata?.chatTitle || c.metadata?.windowTitle || 'Unknown',
|
|
1650
|
+
window: c.metadata?.windowTitle || 'Unknown',
|
|
1651
|
+
active: c.metadata?.isActive || false
|
|
1652
|
+
}));
|
|
1653
|
+
res.json(cascadeList);
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* GET /snapshot/:id - Get HTML snapshot for a specific cascade
|
|
1658
|
+
* Returns snapshot object { html, bodyBg, bodyColor } or 404
|
|
1659
|
+
*/
|
|
1660
|
+
app.get('/snapshot/:id', (req, res) => {
|
|
1661
|
+
const cascade = cascades.get(req.params.id);
|
|
1662
|
+
if (!cascade) {
|
|
1663
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
1664
|
+
}
|
|
1665
|
+
if (!cascade.snapshot) {
|
|
1666
|
+
return res.status(404).json({ error: 'No snapshot available' });
|
|
1667
|
+
}
|
|
1668
|
+
res.json(cascade.snapshot);
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* GET /snapshot - Get snapshot of first active cascade (convenience endpoint)
|
|
1673
|
+
* Returns snapshot object or 404 if no cascades available
|
|
1674
|
+
*/
|
|
1675
|
+
app.get('/snapshot', (req, res) => {
|
|
1676
|
+
const firstCascade = cascades.values().next().value;
|
|
1677
|
+
if (!firstCascade) {
|
|
1678
|
+
return res.status(404).json({ error: 'No cascades available' });
|
|
1679
|
+
}
|
|
1680
|
+
if (!firstCascade.snapshot) {
|
|
1681
|
+
return res.status(404).json({ error: 'No snapshot available' });
|
|
1682
|
+
}
|
|
1683
|
+
res.json(firstCascade.snapshot);
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* GET /debug/:id - Debug endpoint to discover DOM structure
|
|
1688
|
+
* Returns list of potential chat elements
|
|
1689
|
+
*/
|
|
1690
|
+
app.get('/debug/:id', async (req, res) => {
|
|
1691
|
+
const cascade = cascades.get(req.params.id);
|
|
1692
|
+
if (!cascade) {
|
|
1693
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Instead of looking at DOM, let's list ALL CDP targets
|
|
1697
|
+
const results = [];
|
|
1698
|
+
|
|
1699
|
+
for (const port of CDP_PORTS) {
|
|
1700
|
+
try {
|
|
1701
|
+
const targets = await fetchCDPTargets(port);
|
|
1702
|
+
targets.forEach(t => {
|
|
1703
|
+
results.push({
|
|
1704
|
+
port,
|
|
1705
|
+
type: t.type,
|
|
1706
|
+
title: t.title,
|
|
1707
|
+
url: t.url?.substring(0, 100),
|
|
1708
|
+
hasWsUrl: !!t.webSocketDebuggerUrl
|
|
1709
|
+
});
|
|
1710
|
+
});
|
|
1711
|
+
} catch (e) {
|
|
1712
|
+
// ignore
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
res.json(results);
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* GET /dom/:id - Debug endpoint to see actual DOM content
|
|
1721
|
+
*/
|
|
1722
|
+
app.get('/dom/:id', async (req, res) => {
|
|
1723
|
+
const cascade = cascades.get(req.params.id);
|
|
1724
|
+
if (!cascade) {
|
|
1725
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const script = `
|
|
1729
|
+
(function() {
|
|
1730
|
+
// Check for nested iframe (VS Code webview pattern)
|
|
1731
|
+
const activeFrame = document.getElementById('active-frame');
|
|
1732
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
1733
|
+
const innerBody = activeFrame.contentDocument.body;
|
|
1734
|
+
return {
|
|
1735
|
+
type: 'nested-iframe',
|
|
1736
|
+
url: window.location.href,
|
|
1737
|
+
innerURL: activeFrame.src,
|
|
1738
|
+
innerBodyHTML: innerBody ? innerBody.innerHTML.substring(0, 5000) : 'no inner body',
|
|
1739
|
+
innerBodyChildCount: innerBody ? innerBody.children.length : 0,
|
|
1740
|
+
innerDivs: innerBody ? Array.from(innerBody.querySelectorAll('div')).slice(0, 30).map(d => ({
|
|
1741
|
+
id: d.id,
|
|
1742
|
+
className: d.className?.substring?.(0, 100) || '',
|
|
1743
|
+
childCount: d.children.length
|
|
1744
|
+
})) : []
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
return {
|
|
1749
|
+
type: 'direct',
|
|
1750
|
+
url: window.location.href,
|
|
1751
|
+
title: document.title,
|
|
1752
|
+
bodyHTML: document.body ? document.body.innerHTML.substring(0, 5000) : 'no body',
|
|
1753
|
+
bodyChildCount: document.body ? document.body.children.length : 0,
|
|
1754
|
+
hasActiveFrame: !!activeFrame,
|
|
1755
|
+
activeFrameSrc: activeFrame?.src
|
|
1756
|
+
};
|
|
1757
|
+
})()
|
|
1758
|
+
`;
|
|
1759
|
+
|
|
1760
|
+
try {
|
|
1761
|
+
const result = await cascade.cdp.call('Runtime.evaluate', {
|
|
1762
|
+
expression: script,
|
|
1763
|
+
contextId: cascade.cdp.rootContextId,
|
|
1764
|
+
returnByValue: true
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
res.json(result.result?.value || { error: 'no result' });
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
res.status(500).json({ error: err.message });
|
|
1770
|
+
}
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* GET /styles/:id - Get CSS for a specific cascade
|
|
1775
|
+
* Returns CSS string or 404
|
|
1776
|
+
*/
|
|
1777
|
+
app.get('/styles/:id', (req, res) => {
|
|
1778
|
+
const cascade = cascades.get(req.params.id);
|
|
1779
|
+
if (!cascade) {
|
|
1780
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
1781
|
+
}
|
|
1782
|
+
if (!cascade.css) {
|
|
1783
|
+
return res.status(404).json({ error: 'No styles available' });
|
|
1784
|
+
}
|
|
1785
|
+
res.type('text/css').send(cascade.css);
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* GET /terminal/:id - Get Terminal snapshot for a specific cascade
|
|
1790
|
+
* Returns terminal object { html, textContent, hasContent } or 404
|
|
1791
|
+
*/
|
|
1792
|
+
app.get('/terminal/:id', (req, res) => {
|
|
1793
|
+
const cascade = cascades.get(req.params.id);
|
|
1794
|
+
if (!cascade) {
|
|
1795
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
1796
|
+
}
|
|
1797
|
+
if (!cascade.terminal || !cascade.terminal.hasContent) {
|
|
1798
|
+
return res.status(404).json({ error: 'No terminal content available' });
|
|
1799
|
+
}
|
|
1800
|
+
res.json(cascade.terminal);
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* GET /sidebar/:id - Get Sidebar snapshot for a specific cascade
|
|
1805
|
+
* Returns sidebar object { html, files, kiroPanels, hasContent } or 404
|
|
1806
|
+
*/
|
|
1807
|
+
app.get('/sidebar/:id', (req, res) => {
|
|
1808
|
+
const cascade = cascades.get(req.params.id);
|
|
1809
|
+
if (!cascade) {
|
|
1810
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
1811
|
+
}
|
|
1812
|
+
if (!cascade.sidebar || !cascade.sidebar.hasContent) {
|
|
1813
|
+
return res.status(404).json({ error: 'No sidebar content available' });
|
|
1814
|
+
}
|
|
1815
|
+
res.json(cascade.sidebar);
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* GET /editor/:id - Get Editor snapshot for a specific cascade
|
|
1820
|
+
* Returns editor object { html, fileName, language, content, lineCount, hasContent } or 404
|
|
1821
|
+
*/
|
|
1822
|
+
app.get('/editor/:id', (req, res) => {
|
|
1823
|
+
const cascade = cascades.get(req.params.id);
|
|
1824
|
+
if (!cascade) {
|
|
1825
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
1826
|
+
}
|
|
1827
|
+
if (!cascade.editor || !cascade.editor.hasContent) {
|
|
1828
|
+
return res.status(404).json({ error: 'No editor content available' });
|
|
1829
|
+
}
|
|
1830
|
+
res.json(cascade.editor);
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* POST /readFile/:id - Read a file directly from the filesystem
|
|
1835
|
+
* Body: { filePath: string }
|
|
1836
|
+
* Returns { content, fileName, language, lineCount, hasContent: true }
|
|
1837
|
+
* This bypasses Monaco's virtual scrolling limitation by reading the file directly
|
|
1838
|
+
*/
|
|
1839
|
+
app.post('/readFile/:id', async (req, res) => {
|
|
1840
|
+
const cascade = cascades.get(req.params.id);
|
|
1841
|
+
if (!cascade) {
|
|
1842
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const { filePath } = req.body;
|
|
1846
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
1847
|
+
return res.status(400).json({ error: 'filePath is required' });
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
console.log(`[ReadFile] Reading file: ${filePath}`);
|
|
1851
|
+
|
|
1852
|
+
try {
|
|
1853
|
+
const fs = await import('fs/promises');
|
|
1854
|
+
const path = await import('path');
|
|
1855
|
+
|
|
1856
|
+
// Helper function to recursively find a file by name
|
|
1857
|
+
async function findFileRecursive(dir, fileName, maxDepth = 4, currentDepth = 0) {
|
|
1858
|
+
if (currentDepth > maxDepth) return null;
|
|
1859
|
+
|
|
1860
|
+
try {
|
|
1861
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1862
|
+
|
|
1863
|
+
// First check if file exists directly in this directory
|
|
1864
|
+
for (const entry of entries) {
|
|
1865
|
+
if (entry.isFile() && entry.name === fileName) {
|
|
1866
|
+
return path.join(dir, entry.name);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Then search subdirectories (skip node_modules, .git, etc.)
|
|
1871
|
+
for (const entry of entries) {
|
|
1872
|
+
if (entry.isDirectory() &&
|
|
1873
|
+
!entry.name.startsWith('.') &&
|
|
1874
|
+
entry.name !== 'node_modules' &&
|
|
1875
|
+
entry.name !== 'dist' &&
|
|
1876
|
+
entry.name !== 'build' &&
|
|
1877
|
+
entry.name !== '.next') {
|
|
1878
|
+
const found = await findFileRecursive(
|
|
1879
|
+
path.join(dir, entry.name),
|
|
1880
|
+
fileName,
|
|
1881
|
+
maxDepth,
|
|
1882
|
+
currentDepth + 1
|
|
1883
|
+
);
|
|
1884
|
+
if (found) return found;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
} catch (e) {
|
|
1888
|
+
// Directory not accessible
|
|
1889
|
+
}
|
|
1890
|
+
return null;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Try to get workspace root from VS Code via CDP
|
|
1894
|
+
let workspaceRoot = null;
|
|
1895
|
+
if (mainWindowCDP.connection && mainWindowCDP.connection.rootContextId) {
|
|
1896
|
+
try {
|
|
1897
|
+
const wsScript = `
|
|
1898
|
+
(function() {
|
|
1899
|
+
// Try to get workspace folder from VS Code API
|
|
1900
|
+
if (typeof acquireVsCodeApi !== 'undefined') {
|
|
1901
|
+
return null; // Can't access workspace from webview
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// Try to find workspace path from window title or breadcrumbs
|
|
1905
|
+
let targetDoc = document;
|
|
1906
|
+
const activeFrame = document.getElementById('active-frame');
|
|
1907
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
1908
|
+
targetDoc = activeFrame.contentDocument;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// Look for breadcrumb path
|
|
1912
|
+
const breadcrumb = targetDoc.querySelector('.monaco-breadcrumbs, .breadcrumbs-control');
|
|
1913
|
+
if (breadcrumb) {
|
|
1914
|
+
const parts = breadcrumb.textContent.split(/[/\\\\]/);
|
|
1915
|
+
if (parts.length > 1) {
|
|
1916
|
+
return parts.slice(0, -1).join('/');
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// Try to get from window title
|
|
1921
|
+
const title = document.title || '';
|
|
1922
|
+
const match = title.match(/- ([A-Za-z]:[^-]+|\/[^-]+)/);
|
|
1923
|
+
if (match) {
|
|
1924
|
+
return match[1].trim();
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
return null;
|
|
1928
|
+
})()
|
|
1929
|
+
`;
|
|
1930
|
+
|
|
1931
|
+
const wsResult = await mainWindowCDP.connection.call('Runtime.evaluate', {
|
|
1932
|
+
expression: wsScript,
|
|
1933
|
+
contextId: mainWindowCDP.connection.rootContextId,
|
|
1934
|
+
returnByValue: true
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
if (wsResult.result && wsResult.result.value) {
|
|
1938
|
+
workspaceRoot = wsResult.result.value;
|
|
1939
|
+
}
|
|
1940
|
+
} catch (e) {
|
|
1941
|
+
console.log('[ReadFile] Could not get workspace root from CDP:', e.message);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// Try multiple possible paths
|
|
1946
|
+
const possiblePaths = [];
|
|
1947
|
+
const fileName = path.basename(filePath);
|
|
1948
|
+
|
|
1949
|
+
// If path is absolute, use it directly
|
|
1950
|
+
if (path.isAbsolute(filePath)) {
|
|
1951
|
+
possiblePaths.push(filePath);
|
|
1952
|
+
} else {
|
|
1953
|
+
// Try relative to workspace root if we have it
|
|
1954
|
+
if (workspaceRoot) {
|
|
1955
|
+
possiblePaths.push(path.join(workspaceRoot, filePath));
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Try relative to common workspace locations
|
|
1959
|
+
const commonRoots = [
|
|
1960
|
+
process.cwd(),
|
|
1961
|
+
path.dirname(__dirname), // Parent of kiro-mobile-bridge
|
|
1962
|
+
path.join(path.dirname(__dirname), '..'), // Two levels up
|
|
1963
|
+
];
|
|
1964
|
+
|
|
1965
|
+
for (const root of commonRoots) {
|
|
1966
|
+
possiblePaths.push(path.join(root, filePath));
|
|
1967
|
+
// Also try in public subdirectory (common for web projects)
|
|
1968
|
+
possiblePaths.push(path.join(root, 'public', filePath));
|
|
1969
|
+
possiblePaths.push(path.join(root, 'src', filePath));
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// Try each path until we find the file
|
|
1974
|
+
let content = null;
|
|
1975
|
+
let foundPath = null;
|
|
1976
|
+
|
|
1977
|
+
for (const tryPath of possiblePaths) {
|
|
1978
|
+
try {
|
|
1979
|
+
content = await fs.readFile(tryPath, 'utf-8');
|
|
1980
|
+
foundPath = tryPath;
|
|
1981
|
+
console.log(`[ReadFile] Found file at: ${tryPath}`);
|
|
1982
|
+
break;
|
|
1983
|
+
} catch (e) {
|
|
1984
|
+
// File not found at this path, try next
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// If still not found, do a recursive search from workspace roots
|
|
1989
|
+
if (!content) {
|
|
1990
|
+
console.log(`[ReadFile] Direct paths failed, searching recursively for: ${fileName}`);
|
|
1991
|
+
const searchRoots = [
|
|
1992
|
+
process.cwd(),
|
|
1993
|
+
path.dirname(__dirname),
|
|
1994
|
+
];
|
|
1995
|
+
|
|
1996
|
+
for (const root of searchRoots) {
|
|
1997
|
+
foundPath = await findFileRecursive(root, fileName);
|
|
1998
|
+
if (foundPath) {
|
|
1999
|
+
try {
|
|
2000
|
+
content = await fs.readFile(foundPath, 'utf-8');
|
|
2001
|
+
console.log(`[ReadFile] Found file via recursive search: ${foundPath}`);
|
|
2002
|
+
break;
|
|
2003
|
+
} catch (e) {
|
|
2004
|
+
foundPath = null;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
if (!content) {
|
|
2011
|
+
console.log(`[ReadFile] File not found. Tried paths:`, possiblePaths);
|
|
2012
|
+
return res.status(404).json({ error: 'File not found', triedPaths: possiblePaths, searchedFor: fileName });
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// Detect language from file extension
|
|
2016
|
+
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
2017
|
+
const extMap = {
|
|
2018
|
+
'ts': 'typescript', 'tsx': 'typescript',
|
|
2019
|
+
'js': 'javascript', 'jsx': 'javascript',
|
|
2020
|
+
'py': 'python', 'java': 'java',
|
|
2021
|
+
'html': 'html', 'css': 'css',
|
|
2022
|
+
'json': 'json', 'md': 'markdown',
|
|
2023
|
+
'yaml': 'yaml', 'yml': 'yaml',
|
|
2024
|
+
'xml': 'xml', 'sql': 'sql',
|
|
2025
|
+
'go': 'go', 'rs': 'rust',
|
|
2026
|
+
'c': 'c', 'cpp': 'cpp', 'h': 'c',
|
|
2027
|
+
'cs': 'csharp', 'rb': 'ruby',
|
|
2028
|
+
'php': 'php', 'sh': 'bash',
|
|
2029
|
+
'vue': 'vue', 'svelte': 'svelte',
|
|
2030
|
+
'cob': 'cobol', 'cbl': 'cobol'
|
|
2031
|
+
};
|
|
2032
|
+
|
|
2033
|
+
const language = extMap[ext] || ext || '';
|
|
2034
|
+
const lines = content.split('\n');
|
|
2035
|
+
|
|
2036
|
+
res.json({
|
|
2037
|
+
content,
|
|
2038
|
+
fileName: path.basename(filePath),
|
|
2039
|
+
fullPath: foundPath,
|
|
2040
|
+
language,
|
|
2041
|
+
lineCount: lines.length,
|
|
2042
|
+
hasContent: true,
|
|
2043
|
+
startLine: 1,
|
|
2044
|
+
isPartial: false
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
} catch (err) {
|
|
2048
|
+
console.error(`[ReadFile] Error:`, err.message);
|
|
2049
|
+
res.status(500).json({ error: err.message });
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
/**
|
|
2054
|
+
* GET /files/:id - List all code files in the workspace
|
|
2055
|
+
* Returns { files: [{ name, path, language }] }
|
|
2056
|
+
* Scans workspace directory for code files (filtered by extension)
|
|
2057
|
+
*/
|
|
2058
|
+
app.get('/files/:id', async (req, res) => {
|
|
2059
|
+
const cascade = cascades.get(req.params.id);
|
|
2060
|
+
if (!cascade) {
|
|
2061
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
console.log(`[Files] Listing workspace files`);
|
|
2065
|
+
|
|
2066
|
+
try {
|
|
2067
|
+
const fs = await import('fs/promises');
|
|
2068
|
+
const path = await import('path');
|
|
2069
|
+
|
|
2070
|
+
// Code file extensions to include
|
|
2071
|
+
const codeExtensions = new Set([
|
|
2072
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
2073
|
+
'.py', '.java', '.go', '.rs', '.rb', '.php',
|
|
2074
|
+
'.html', '.css', '.scss', '.sass', '.less',
|
|
2075
|
+
'.json', '.yaml', '.yml', '.xml', '.toml',
|
|
2076
|
+
'.md', '.mdx', '.txt',
|
|
2077
|
+
'.sql', '.graphql', '.gql',
|
|
2078
|
+
'.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd',
|
|
2079
|
+
'.c', '.cpp', '.h', '.hpp', '.cs',
|
|
2080
|
+
'.vue', '.svelte', '.astro',
|
|
2081
|
+
'.env', '.gitignore', '.dockerignore',
|
|
2082
|
+
'.cob', '.cbl'
|
|
2083
|
+
]);
|
|
2084
|
+
|
|
2085
|
+
// Extension to language mapping
|
|
2086
|
+
const extToLang = {
|
|
2087
|
+
'.ts': 'typescript', '.tsx': 'typescript',
|
|
2088
|
+
'.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
|
|
2089
|
+
'.py': 'python', '.java': 'java', '.go': 'go', '.rs': 'rust',
|
|
2090
|
+
'.rb': 'ruby', '.php': 'php',
|
|
2091
|
+
'.html': 'html', '.css': 'css', '.scss': 'scss', '.sass': 'sass', '.less': 'less',
|
|
2092
|
+
'.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.xml': 'xml', '.toml': 'toml',
|
|
2093
|
+
'.md': 'markdown', '.mdx': 'markdown',
|
|
2094
|
+
'.sql': 'sql', '.graphql': 'graphql', '.gql': 'graphql',
|
|
2095
|
+
'.sh': 'bash', '.bash': 'bash', '.zsh': 'zsh', '.ps1': 'powershell', '.bat': 'batch', '.cmd': 'batch',
|
|
2096
|
+
'.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp', '.cs': 'csharp',
|
|
2097
|
+
'.vue': 'vue', '.svelte': 'svelte', '.astro': 'astro',
|
|
2098
|
+
'.cob': 'cobol', '.cbl': 'cobol'
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
const files = [];
|
|
2102
|
+
// Use parent directory as workspace root (kiro-mobile-bridge is inside the workspace)
|
|
2103
|
+
const workspaceRoot = path.dirname(__dirname);
|
|
2104
|
+
|
|
2105
|
+
// Recursive function to collect files
|
|
2106
|
+
async function collectFiles(dir, relativePath = '', depth = 0) {
|
|
2107
|
+
if (depth > 5) return; // Max depth
|
|
2108
|
+
|
|
2109
|
+
try {
|
|
2110
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
2111
|
+
|
|
2112
|
+
for (const entry of entries) {
|
|
2113
|
+
// Skip hidden files/folders and common non-code directories
|
|
2114
|
+
if (entry.name.startsWith('.') ||
|
|
2115
|
+
entry.name === 'node_modules' ||
|
|
2116
|
+
entry.name === 'dist' ||
|
|
2117
|
+
entry.name === 'build' ||
|
|
2118
|
+
entry.name === '.next' ||
|
|
2119
|
+
entry.name === '__pycache__' ||
|
|
2120
|
+
entry.name === 'venv' ||
|
|
2121
|
+
entry.name === 'coverage') {
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
const entryPath = path.join(dir, entry.name);
|
|
2126
|
+
const entryRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
2127
|
+
|
|
2128
|
+
if (entry.isFile()) {
|
|
2129
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
2130
|
+
if (codeExtensions.has(ext) || entry.name === 'Dockerfile' || entry.name === 'Makefile') {
|
|
2131
|
+
files.push({
|
|
2132
|
+
name: entry.name,
|
|
2133
|
+
path: entryRelative,
|
|
2134
|
+
language: extToLang[ext] || ext.slice(1) || 'text'
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
} else if (entry.isDirectory()) {
|
|
2138
|
+
await collectFiles(entryPath, entryRelative, depth + 1);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
} catch (e) {
|
|
2142
|
+
// Directory not accessible
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
await collectFiles(workspaceRoot);
|
|
2147
|
+
|
|
2148
|
+
// Sort files: by path for easier browsing
|
|
2149
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
2150
|
+
|
|
2151
|
+
console.log(`[Files] Found ${files.length} code files`);
|
|
2152
|
+
res.json({ files, workspaceRoot });
|
|
2153
|
+
|
|
2154
|
+
} catch (err) {
|
|
2155
|
+
console.error(`[Files] Error:`, err.message);
|
|
2156
|
+
res.status(500).json({ error: err.message });
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* POST /openFile/:id - Open a file in the Kiro editor
|
|
2162
|
+
* Body: { filePath: string }
|
|
2163
|
+
* Uses VS Code command to open the file
|
|
2164
|
+
*/
|
|
2165
|
+
app.post('/openFile/:id', async (req, res) => {
|
|
2166
|
+
const cascade = cascades.get(req.params.id);
|
|
2167
|
+
if (!cascade) {
|
|
2168
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const { filePath } = req.body;
|
|
2172
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
2173
|
+
return res.status(400).json({ error: 'filePath is required' });
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// Use main window CDP to execute VS Code command
|
|
2177
|
+
if (!mainWindowCDP.connection || !mainWindowCDP.connection.rootContextId) {
|
|
2178
|
+
return res.status(503).json({ error: 'Main window CDP connection not available' });
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
console.log(`[OpenFile] Opening file: ${filePath}`);
|
|
2182
|
+
|
|
2183
|
+
try {
|
|
2184
|
+
const cdp = mainWindowCDP.connection;
|
|
2185
|
+
|
|
2186
|
+
// Execute VS Code command to open file via the command palette API
|
|
2187
|
+
const script = `
|
|
2188
|
+
(function() {
|
|
2189
|
+
const filePath = ${JSON.stringify(filePath)};
|
|
2190
|
+
|
|
2191
|
+
// Try to find and use VS Code API
|
|
2192
|
+
if (typeof acquireVsCodeApi !== 'undefined') {
|
|
2193
|
+
const vscode = acquireVsCodeApi();
|
|
2194
|
+
vscode.postMessage({ command: 'openFile', path: filePath });
|
|
2195
|
+
return { success: true, method: 'vscodeApi' };
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// Try clicking on file link in the chat if it exists
|
|
2199
|
+
let targetDoc = document;
|
|
2200
|
+
const activeFrame = document.getElementById('active-frame');
|
|
2201
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
2202
|
+
targetDoc = activeFrame.contentDocument;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Look for file links that match the path
|
|
2206
|
+
const fileLinks = targetDoc.querySelectorAll('a[href], [data-path], [class*="file"], [class*="link"]');
|
|
2207
|
+
for (const link of fileLinks) {
|
|
2208
|
+
const text = link.textContent || '';
|
|
2209
|
+
const href = link.getAttribute('href') || '';
|
|
2210
|
+
const dataPath = link.getAttribute('data-path') || '';
|
|
2211
|
+
|
|
2212
|
+
if (text.includes(filePath) || href.includes(filePath) || dataPath.includes(filePath)) {
|
|
2213
|
+
link.click();
|
|
2214
|
+
return { success: true, method: 'linkClick', element: text.substring(0, 50) };
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// Try keyboard shortcut Ctrl+P to open quick open, then type filename
|
|
2219
|
+
// This is a fallback that simulates user behavior
|
|
2220
|
+
return { success: false, error: 'Could not find file link' };
|
|
2221
|
+
})()
|
|
2222
|
+
`;
|
|
2223
|
+
|
|
2224
|
+
const evalResult = await cdp.call('Runtime.evaluate', {
|
|
2225
|
+
expression: script,
|
|
2226
|
+
contextId: cdp.rootContextId,
|
|
2227
|
+
returnByValue: true,
|
|
2228
|
+
awaitPromise: false
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
if (evalResult.result && evalResult.result.value) {
|
|
2232
|
+
res.json(evalResult.result.value);
|
|
2233
|
+
} else {
|
|
2234
|
+
res.json({ success: false, error: 'Script execution returned no result' });
|
|
2235
|
+
}
|
|
2236
|
+
} catch (err) {
|
|
2237
|
+
console.error(`[OpenFile] Error:`, err.message);
|
|
2238
|
+
res.status(500).json({ success: false, error: err.message });
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* POST /send/:id - Send message to a cascade
|
|
2244
|
+
* Body: { message: string }
|
|
2245
|
+
* Injects the message into the chat via CDP (Task 6)
|
|
2246
|
+
*/
|
|
2247
|
+
app.post('/send/:id', async (req, res) => {
|
|
2248
|
+
const cascade = cascades.get(req.params.id);
|
|
2249
|
+
if (!cascade) {
|
|
2250
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
const { message } = req.body;
|
|
2254
|
+
if (!message || typeof message !== 'string') {
|
|
2255
|
+
return res.status(400).json({ error: 'Message is required' });
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
if (!cascade.cdp) {
|
|
2259
|
+
return res.status(503).json({ error: 'CDP connection not available' });
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
console.log(`[Send] Injecting message to cascade ${req.params.id}: ${message.substring(0, 50)}${message.length > 50 ? '...' : ''}`);
|
|
2263
|
+
|
|
2264
|
+
try {
|
|
2265
|
+
const result = await injectMessage(cascade.cdp, message);
|
|
2266
|
+
|
|
2267
|
+
if (result.success) {
|
|
2268
|
+
res.json({
|
|
2269
|
+
success: true,
|
|
2270
|
+
method: result.method,
|
|
2271
|
+
inputType: result.inputType
|
|
2272
|
+
});
|
|
2273
|
+
} else {
|
|
2274
|
+
res.status(500).json({
|
|
2275
|
+
success: false,
|
|
2276
|
+
error: result.error || 'Message injection failed'
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
console.error(`[Send] Error injecting message:`, err.message);
|
|
2281
|
+
res.status(500).json({
|
|
2282
|
+
success: false,
|
|
2283
|
+
error: err.message
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* POST /click/:id - Click an element in the Kiro UI
|
|
2290
|
+
* Body: { tag, text, ariaLabel, title, role, className, id, relativeX, relativeY }
|
|
2291
|
+
* Finds and clicks the matching element via CDP
|
|
2292
|
+
*/
|
|
2293
|
+
app.post('/click/:id', async (req, res) => {
|
|
2294
|
+
const cascade = cascades.get(req.params.id);
|
|
2295
|
+
if (!cascade) {
|
|
2296
|
+
return res.status(404).json({ error: 'Cascade not found' });
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
if (!cascade.cdp || !cascade.cdp.rootContextId) {
|
|
2300
|
+
return res.status(503).json({ error: 'CDP connection not available' });
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
const clickInfo = req.body;
|
|
2304
|
+
console.log(`[Click] Attempting click:`, clickInfo.text?.substring(0, 30) || clickInfo.ariaLabel || clickInfo.tag);
|
|
2305
|
+
|
|
2306
|
+
try {
|
|
2307
|
+
const result = await clickElement(cascade.cdp, clickInfo);
|
|
2308
|
+
res.json(result);
|
|
2309
|
+
} catch (err) {
|
|
2310
|
+
console.error(`[Click] Error:`, err.message);
|
|
2311
|
+
res.status(500).json({ success: false, error: err.message });
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
/**
|
|
2316
|
+
* Click an element in the Kiro UI via CDP using native mouse events
|
|
2317
|
+
*/
|
|
2318
|
+
async function clickElement(cdp, clickInfo) {
|
|
2319
|
+
// First, find the element and get its coordinates
|
|
2320
|
+
const findScript = `
|
|
2321
|
+
(function() {
|
|
2322
|
+
let targetDoc = document;
|
|
2323
|
+
const activeFrame = document.getElementById('active-frame');
|
|
2324
|
+
if (activeFrame && activeFrame.contentDocument) {
|
|
2325
|
+
targetDoc = activeFrame.contentDocument;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
const info = ${JSON.stringify(clickInfo)};
|
|
2329
|
+
let element = null;
|
|
2330
|
+
let matchMethod = '';
|
|
2331
|
+
let isTabClick = info.isTab || info.role === 'tab';
|
|
2332
|
+
let isCloseButton = info.isCloseButton || (info.ariaLabel && info.ariaLabel.toLowerCase() === 'close');
|
|
2333
|
+
let isToggle = info.isToggle || info.role === 'switch';
|
|
2334
|
+
let isDropdown = info.isDropdown || info.ariaHaspopup;
|
|
2335
|
+
|
|
2336
|
+
// Handle toggle/switch clicks
|
|
2337
|
+
if (isToggle && !element) {
|
|
2338
|
+
// Find by toggle ID first
|
|
2339
|
+
if (info.toggleId) {
|
|
2340
|
+
element = targetDoc.getElementById(info.toggleId);
|
|
2341
|
+
if (element) matchMethod = 'toggle-id';
|
|
2342
|
+
}
|
|
2343
|
+
// Find by label text
|
|
2344
|
+
if (!element && info.text) {
|
|
2345
|
+
const toggles = targetDoc.querySelectorAll('.kiro-toggle-switch, [role="switch"]');
|
|
2346
|
+
for (const t of toggles) {
|
|
2347
|
+
const label = t.querySelector('label') || t.closest('.kiro-toggle-switch')?.querySelector('label');
|
|
2348
|
+
if (label && label.textContent.trim().toLowerCase().includes(info.text.toLowerCase())) {
|
|
2349
|
+
element = t.querySelector('input') || t;
|
|
2350
|
+
matchMethod = 'toggle-label';
|
|
2351
|
+
break;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
// Fallback: find any toggle switch
|
|
2356
|
+
if (!element) {
|
|
2357
|
+
element = targetDoc.querySelector('#autonomy-mode-toggle-switch, .kiro-toggle-switch input, [role="switch"]');
|
|
2358
|
+
if (element) matchMethod = 'toggle-fallback';
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// Handle dropdown clicks
|
|
2363
|
+
if (isDropdown && !element) {
|
|
2364
|
+
// Find dropdown by text content
|
|
2365
|
+
if (info.text) {
|
|
2366
|
+
const dropdowns = targetDoc.querySelectorAll('.kiro-dropdown-trigger, [aria-haspopup="true"], [aria-haspopup="listbox"]');
|
|
2367
|
+
for (const d of dropdowns) {
|
|
2368
|
+
if (d.textContent.trim().toLowerCase().includes(info.text.toLowerCase())) {
|
|
2369
|
+
element = d;
|
|
2370
|
+
matchMethod = 'dropdown-text';
|
|
2371
|
+
break;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
// Fallback: find any dropdown trigger
|
|
2376
|
+
if (!element) {
|
|
2377
|
+
element = targetDoc.querySelector('.kiro-dropdown-trigger, [aria-haspopup="true"]');
|
|
2378
|
+
if (element) matchMethod = 'dropdown-fallback';
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// Handle close button clicks explicitly
|
|
2383
|
+
if (isCloseButton) {
|
|
2384
|
+
const closeButtons = targetDoc.querySelectorAll('[aria-label="close"], .kiro-tabs-item-close, [class*="close"]');
|
|
2385
|
+
for (const btn of closeButtons) {
|
|
2386
|
+
// Find the close button in the currently selected tab or matching context
|
|
2387
|
+
const parentTab = btn.closest('[role="tab"]');
|
|
2388
|
+
if (parentTab && parentTab.getAttribute('aria-selected') === 'true') {
|
|
2389
|
+
element = btn;
|
|
2390
|
+
matchMethod = 'close-button-selected-tab';
|
|
2391
|
+
break;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
// If no selected tab close button, find any close button
|
|
2395
|
+
if (!element && closeButtons.length > 0) {
|
|
2396
|
+
element = closeButtons[0];
|
|
2397
|
+
matchMethod = 'close-button-first';
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// Handle file link clicks - find and click file references in chat
|
|
2402
|
+
if (info.isFileLink && info.filePath && !element) {
|
|
2403
|
+
const filePath = info.filePath;
|
|
2404
|
+
const fileName = filePath.split('/').pop().split('\\\\').pop();
|
|
2405
|
+
|
|
2406
|
+
// Look for file links in the chat
|
|
2407
|
+
const fileSelectors = [
|
|
2408
|
+
'a[href*="' + fileName + '"]',
|
|
2409
|
+
'[data-path*="' + fileName + '"]',
|
|
2410
|
+
'code',
|
|
2411
|
+
'span',
|
|
2412
|
+
'[class*="file"]',
|
|
2413
|
+
'[class*="link"]',
|
|
2414
|
+
'[class*="path"]'
|
|
2415
|
+
];
|
|
2416
|
+
|
|
2417
|
+
for (const selector of fileSelectors) {
|
|
2418
|
+
const candidates = targetDoc.querySelectorAll(selector);
|
|
2419
|
+
for (const el of candidates) {
|
|
2420
|
+
const text = (el.textContent || '').trim();
|
|
2421
|
+
const dataPath = el.getAttribute('data-path') || '';
|
|
2422
|
+
const href = el.getAttribute('href') || '';
|
|
2423
|
+
|
|
2424
|
+
if (text.includes(filePath) || text.includes(fileName) ||
|
|
2425
|
+
dataPath.includes(filePath) || dataPath.includes(fileName) ||
|
|
2426
|
+
href.includes(filePath) || href.includes(fileName)) {
|
|
2427
|
+
element = el;
|
|
2428
|
+
matchMethod = 'file-link-' + selector.split('[')[0];
|
|
2429
|
+
break;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
if (element) break;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// For tabs, find by label text and click the tab itself (not close button)
|
|
2437
|
+
if (isTabClick && !element) {
|
|
2438
|
+
const allTabs = targetDoc.querySelectorAll('[role="tab"]');
|
|
2439
|
+
const searchText = (info.tabLabel || info.text || '').trim().toLowerCase();
|
|
2440
|
+
|
|
2441
|
+
for (const tab of allTabs) {
|
|
2442
|
+
const labelEl = tab.querySelector('.kiro-tabs-item-label, [class*="label"]');
|
|
2443
|
+
const tabText = labelEl ? labelEl.textContent.trim().toLowerCase() : tab.textContent.trim().toLowerCase();
|
|
2444
|
+
|
|
2445
|
+
// Match by label text
|
|
2446
|
+
if (searchText && (tabText.includes(searchText) || searchText.includes(tabText))) {
|
|
2447
|
+
element = tab;
|
|
2448
|
+
matchMethod = 'tab-label-match';
|
|
2449
|
+
break;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// 1. Try by aria-label (skip for tabs and close buttons, already handled)
|
|
2455
|
+
if (!isTabClick && !isCloseButton && info.ariaLabel && !element) {
|
|
2456
|
+
try {
|
|
2457
|
+
// Exclude close buttons
|
|
2458
|
+
const candidates = targetDoc.querySelectorAll('[aria-label="' + info.ariaLabel.replace(/"/g, '\\\\"') + '"]');
|
|
2459
|
+
for (const c of candidates) {
|
|
2460
|
+
const label = (c.getAttribute('aria-label') || '').toLowerCase();
|
|
2461
|
+
if (!label.includes('close') && !label.includes('delete') && !label.includes('remove')) {
|
|
2462
|
+
element = c;
|
|
2463
|
+
matchMethod = 'aria-label';
|
|
2464
|
+
break;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
} catch(e) {}
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// 2. Try by title
|
|
2471
|
+
if (info.title && !element) {
|
|
2472
|
+
try {
|
|
2473
|
+
element = targetDoc.querySelector('[title="' + info.title.replace(/"/g, '\\\\"') + '"]');
|
|
2474
|
+
if (element) matchMethod = 'title';
|
|
2475
|
+
} catch(e) {}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// 3. Try by text content - search all clickable elements
|
|
2479
|
+
if (info.text && info.text.trim() && !element) {
|
|
2480
|
+
const searchText = info.text.trim();
|
|
2481
|
+
const allElements = targetDoc.querySelectorAll('button, [role="button"], [role="tab"], [role="menuitem"], [role="switch"], a, [tabindex="0"], [class*="button"], [class*="btn"]');
|
|
2482
|
+
for (const el of allElements) {
|
|
2483
|
+
// Skip close buttons unless explicitly looking for one
|
|
2484
|
+
if (!isCloseButton) {
|
|
2485
|
+
const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
|
|
2486
|
+
if (ariaLabel.includes('close') || ariaLabel.includes('delete')) continue;
|
|
2487
|
+
if (el.classList.contains('kiro-tabs-item-close')) continue;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
const elText = (el.textContent || '').trim();
|
|
2491
|
+
if (elText === searchText || (elText.length > 0 && searchText.includes(elText)) || (searchText.length > 0 && elText.includes(searchText))) {
|
|
2492
|
+
element = el;
|
|
2493
|
+
matchMethod = 'text-content';
|
|
2494
|
+
break;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// 4. Try by partial aria-label match
|
|
2500
|
+
if (info.ariaLabel && !element && !isCloseButton) {
|
|
2501
|
+
const allWithAria = targetDoc.querySelectorAll('[aria-label]');
|
|
2502
|
+
for (const el of allWithAria) {
|
|
2503
|
+
const label = el.getAttribute('aria-label') || '';
|
|
2504
|
+
// Skip close buttons
|
|
2505
|
+
if (label.toLowerCase().includes('close') || label.toLowerCase().includes('delete')) continue;
|
|
2506
|
+
|
|
2507
|
+
if (label.toLowerCase().includes(info.ariaLabel.toLowerCase()) || info.ariaLabel.toLowerCase().includes(label.toLowerCase())) {
|
|
2508
|
+
element = el;
|
|
2509
|
+
matchMethod = 'aria-label-partial';
|
|
2510
|
+
break;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// 5. Try by role
|
|
2516
|
+
if (info.role && !element) {
|
|
2517
|
+
const candidates = targetDoc.querySelectorAll('[role="' + info.role + '"]');
|
|
2518
|
+
if (info.text) {
|
|
2519
|
+
for (const c of candidates) {
|
|
2520
|
+
if ((c.textContent || '').includes(info.text.substring(0, 15))) {
|
|
2521
|
+
element = c;
|
|
2522
|
+
matchMethod = 'role+text';
|
|
2523
|
+
break;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
if (!element) {
|
|
2530
|
+
return { found: false, error: 'Element not found' };
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// Scroll element into view first
|
|
2534
|
+
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
2535
|
+
|
|
2536
|
+
// Get element's bounding rect for coordinate-based clicking
|
|
2537
|
+
const rect = element.getBoundingClientRect();
|
|
2538
|
+
|
|
2539
|
+
// For tabs, click on the LEFT side (on the label area) to avoid close button
|
|
2540
|
+
let x, y;
|
|
2541
|
+
if (isTabClick && !isCloseButton) {
|
|
2542
|
+
// Find the label element and click on it
|
|
2543
|
+
const labelEl = element.querySelector('.kiro-tabs-item-label, [class*="label"]');
|
|
2544
|
+
if (labelEl) {
|
|
2545
|
+
const labelRect = labelEl.getBoundingClientRect();
|
|
2546
|
+
x = labelRect.left + labelRect.width / 2;
|
|
2547
|
+
y = labelRect.top + labelRect.height / 2;
|
|
2548
|
+
} else {
|
|
2549
|
+
// Fallback: click 30% from left edge
|
|
2550
|
+
x = rect.left + rect.width * 0.3;
|
|
2551
|
+
y = rect.top + rect.height / 2;
|
|
2552
|
+
}
|
|
2553
|
+
} else {
|
|
2554
|
+
x = rect.left + rect.width / 2;
|
|
2555
|
+
y = rect.top + rect.height / 2;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
return {
|
|
2559
|
+
found: true,
|
|
2560
|
+
matchMethod,
|
|
2561
|
+
x: Math.round(x),
|
|
2562
|
+
y: Math.round(y),
|
|
2563
|
+
tag: element.tagName,
|
|
2564
|
+
isTab: isTabClick,
|
|
2565
|
+
isCloseButton: isCloseButton
|
|
2566
|
+
};
|
|
2567
|
+
})()
|
|
2568
|
+
`;
|
|
2569
|
+
|
|
2570
|
+
try {
|
|
2571
|
+
// Step 1: Find element and get coordinates
|
|
2572
|
+
const findResult = await cdp.call('Runtime.evaluate', {
|
|
2573
|
+
expression: findScript,
|
|
2574
|
+
contextId: cdp.rootContextId,
|
|
2575
|
+
returnByValue: true
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
const elementInfo = findResult.result?.value;
|
|
2579
|
+
if (!elementInfo || !elementInfo.found) {
|
|
2580
|
+
console.log('[Click] Element not found:', clickInfo.ariaLabel || clickInfo.text);
|
|
2581
|
+
return { success: false, error: 'Element not found' };
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
console.log('[Click] Found element at', elementInfo.x, elementInfo.y, 'via', elementInfo.matchMethod);
|
|
2585
|
+
|
|
2586
|
+
// Step 2: Use CDP Input.dispatchMouseEvent for native click
|
|
2587
|
+
// This works better with React/VS Code components
|
|
2588
|
+
await cdp.call('Input.dispatchMouseEvent', {
|
|
2589
|
+
type: 'mousePressed',
|
|
2590
|
+
x: elementInfo.x,
|
|
2591
|
+
y: elementInfo.y,
|
|
2592
|
+
button: 'left',
|
|
2593
|
+
clickCount: 1
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
await cdp.call('Input.dispatchMouseEvent', {
|
|
2597
|
+
type: 'mouseReleased',
|
|
2598
|
+
x: elementInfo.x,
|
|
2599
|
+
y: elementInfo.y,
|
|
2600
|
+
button: 'left',
|
|
2601
|
+
clickCount: 1
|
|
2602
|
+
});
|
|
2603
|
+
|
|
2604
|
+
return { success: true, matchMethod: elementInfo.matchMethod, x: elementInfo.x, y: elementInfo.y };
|
|
2605
|
+
|
|
2606
|
+
} catch (err) {
|
|
2607
|
+
console.error('[Click] CDP error:', err.message);
|
|
2608
|
+
return { success: false, error: err.message };
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// Create HTTP server
|
|
2613
|
+
const httpServer = createServer(app);
|
|
2614
|
+
|
|
2615
|
+
// =============================================================================
|
|
2616
|
+
// WebSocket Server (Task 7)
|
|
2617
|
+
// =============================================================================
|
|
2618
|
+
|
|
2619
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
2620
|
+
|
|
2621
|
+
// 7.1 WebSocketServer attached to HTTP server (done above)
|
|
2622
|
+
// 7.2 Send cascade list on client connect
|
|
2623
|
+
// 7.3 Broadcast snapshot updates when content changes (handled by broadcastSnapshotUpdate)
|
|
2624
|
+
|
|
2625
|
+
wss.on('connection', (ws, req) => {
|
|
2626
|
+
const clientIP = req.socket.remoteAddress || 'unknown';
|
|
2627
|
+
console.log(`[WebSocket] Client connected from ${clientIP}`);
|
|
2628
|
+
|
|
2629
|
+
// 7.2 Send current cascade list immediately on connect
|
|
2630
|
+
const cascadeList = Array.from(cascades.values()).map(c => ({
|
|
2631
|
+
id: c.id,
|
|
2632
|
+
title: c.metadata?.chatTitle || c.metadata?.windowTitle || 'Unknown',
|
|
2633
|
+
window: c.metadata?.windowTitle || 'Unknown',
|
|
2634
|
+
active: c.metadata?.isActive || false
|
|
2635
|
+
}));
|
|
2636
|
+
|
|
2637
|
+
ws.send(JSON.stringify({
|
|
2638
|
+
type: 'cascade_list',
|
|
2639
|
+
cascades: cascadeList
|
|
2640
|
+
}));
|
|
2641
|
+
|
|
2642
|
+
// Handle client disconnect
|
|
2643
|
+
ws.on('close', () => {
|
|
2644
|
+
console.log(`[WebSocket] Client disconnected from ${clientIP}`);
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
// Handle errors
|
|
2648
|
+
ws.on('error', (err) => {
|
|
2649
|
+
console.error(`[WebSocket] Error from ${clientIP}:`, err.message);
|
|
2650
|
+
});
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
// Start server
|
|
2654
|
+
httpServer.listen(PORT, () => {
|
|
2655
|
+
const localIP = getLocalIP();
|
|
2656
|
+
console.log('');
|
|
2657
|
+
console.log('🌉 Kiro Mobile Bridge');
|
|
2658
|
+
console.log('─────────────────────');
|
|
2659
|
+
console.log(`Local: http://localhost:${PORT}`);
|
|
2660
|
+
console.log(`Network: http://${localIP}:${PORT}`);
|
|
2661
|
+
console.log('');
|
|
2662
|
+
console.log('Open the Network URL on your phone to monitor Kiro.');
|
|
2663
|
+
console.log('');
|
|
2664
|
+
console.log('');
|
|
2665
|
+
|
|
2666
|
+
// 3.5 Run discovery on startup and every 10 seconds
|
|
2667
|
+
discoverTargets();
|
|
2668
|
+
setInterval(discoverTargets, 10000);
|
|
2669
|
+
|
|
2670
|
+
// 4.5 Run snapshot polling every 1 second for faster updates
|
|
2671
|
+
setInterval(pollSnapshots, 1000);
|
|
2672
|
+
});
|