kiro-mobile-bridge 1.0.7 → 1.0.10

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.
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Click service - handles UI element clicks via CDP
3
+ */
4
+ import { MODEL_NAMES } from '../utils/constants.js';
5
+
6
+ /**
7
+ * Build the click script for CDP evaluation
8
+ * This is separated for maintainability and testing
9
+ * @param {object} clickInfo - Element identification info (already sanitized)
10
+ * @returns {string} - JavaScript expression
11
+ */
12
+ function buildClickScript(clickInfo) {
13
+ // Escape the clickInfo for safe inclusion in the script
14
+ const safeClickInfo = JSON.stringify(clickInfo);
15
+ const modelNamesJson = JSON.stringify(MODEL_NAMES);
16
+
17
+ return `(function() {
18
+ let targetDoc = document;
19
+ const activeFrame = document.getElementById('active-frame');
20
+ if (activeFrame && activeFrame.contentDocument) targetDoc = activeFrame.contentDocument;
21
+
22
+ const info = ${safeClickInfo};
23
+ const modelNames = ${modelNamesJson};
24
+ let element = null;
25
+ let matchMethod = '';
26
+
27
+ // Helper functions
28
+ const isVisible = (el) => el && el.offsetParent !== null;
29
+ const findModelName = (text) => {
30
+ const lowerText = text.toLowerCase();
31
+ for (const m of modelNames) {
32
+ if (lowerText.includes(m)) return m;
33
+ }
34
+ return null;
35
+ };
36
+
37
+ // Determine element type
38
+ const isTabClick = info.isTab || info.role === 'tab';
39
+ const isCloseButton = info.isCloseButton || (info.ariaLabel && info.ariaLabel.toLowerCase() === 'close');
40
+ const isToggle = info.isToggle || info.role === 'switch';
41
+ const isModelSelector = info.isModelSelector;
42
+ const isModelOption = info.isModelOption;
43
+
44
+ // =========================================================================
45
+ // Model Selector Button Click
46
+ // =========================================================================
47
+ if (isModelSelector && !element) {
48
+ // Strategy 1: Find Kiro's specific dropdown trigger
49
+ const kiroDropdownTrigger = targetDoc.querySelector('button.kiro-dropdown-trigger[aria-haspopup="true"]');
50
+ if (kiroDropdownTrigger && isVisible(kiroDropdownTrigger)) {
51
+ const triggerText = (kiroDropdownTrigger.textContent || '').toLowerCase();
52
+ if (modelNames.some(m => triggerText.includes(m))) {
53
+ element = kiroDropdownTrigger;
54
+ matchMethod = 'kiro-dropdown-trigger';
55
+ }
56
+ }
57
+
58
+ // Strategy 2: Find by aria-haspopup with model text
59
+ if (!element) {
60
+ const hasPopupButtons = targetDoc.querySelectorAll('button[aria-haspopup="true"], button[aria-haspopup="listbox"], button[aria-haspopup="menu"]');
61
+ for (const btn of hasPopupButtons) {
62
+ if (!isVisible(btn)) continue;
63
+ const btnText = (btn.textContent || '').toLowerCase();
64
+ if (modelNames.some(m => btnText.includes(m))) {
65
+ element = btn;
66
+ matchMethod = 'aria-haspopup-model';
67
+ break;
68
+ }
69
+ }
70
+ }
71
+
72
+ // Strategy 3: Find by class patterns
73
+ if (!element) {
74
+ const modelSelectors = [
75
+ '.kiro-dropdown-trigger',
76
+ '[class*="model-selector"]', '[class*="modelSelector"]',
77
+ '[class*="model-dropdown"]', '[class*="modelDropdown"]',
78
+ 'button[class*="dropdown-trigger"]'
79
+ ];
80
+
81
+ for (const sel of modelSelectors) {
82
+ try {
83
+ const candidates = targetDoc.querySelectorAll(sel);
84
+ for (const c of candidates) {
85
+ if (isVisible(c)) {
86
+ element = c;
87
+ matchMethod = 'model-selector-class';
88
+ break;
89
+ }
90
+ }
91
+ if (element) break;
92
+ } catch(e) {}
93
+ }
94
+ }
95
+ }
96
+
97
+ // =========================================================================
98
+ // Model Option Selection (from dropdown menu)
99
+ // =========================================================================
100
+ if (isModelOption && !element) {
101
+ const searchText = (info.text || '').trim().toLowerCase();
102
+ const searchModelName = findModelName(searchText);
103
+
104
+ // Check if dropdown is currently open
105
+ const openDropdown = targetDoc.querySelector('.kiro-dropdown-menu, [class*="dropdown-menu"][class*="open"], [role="listbox"], [role="menu"]');
106
+ const isDropdownOpen = openDropdown && isVisible(openDropdown);
107
+
108
+ // If dropdown is NOT open, we need to open it first
109
+ if (!isDropdownOpen) {
110
+ const dropdownTrigger = targetDoc.querySelector('.kiro-dropdown-trigger[aria-haspopup="true"], button[aria-haspopup="true"], button[aria-haspopup="listbox"]');
111
+ if (dropdownTrigger && isVisible(dropdownTrigger)) {
112
+ const triggerText = (dropdownTrigger.textContent || '').toLowerCase();
113
+ if (modelNames.some(m => triggerText.includes(m))) {
114
+ dropdownTrigger.click();
115
+ return { found: true, clicked: true, needsRetry: true, matchMethod: 'dropdown-opened-for-option' };
116
+ }
117
+ }
118
+ }
119
+
120
+ // Strategy 1: Find Kiro's specific dropdown items
121
+ const kiroDropdownItems = targetDoc.querySelectorAll('.kiro-dropdown-item, .kiro-dropdown-menu > div');
122
+ for (const item of kiroDropdownItems) {
123
+ if (!isVisible(item)) continue;
124
+ const itemText = (item.textContent || '').trim().toLowerCase();
125
+ const itemModelName = findModelName(itemText);
126
+
127
+ if (searchModelName && itemModelName && searchModelName === itemModelName) {
128
+ const searchVersion = searchText.match(/\\d+\\.?\\d*/)?.[0];
129
+ const itemVersion = itemText.match(/\\d+\\.?\\d*/)?.[0];
130
+
131
+ if (!searchVersion || !itemVersion || searchVersion === itemVersion) {
132
+ element = item;
133
+ matchMethod = 'kiro-dropdown-item';
134
+ break;
135
+ }
136
+ }
137
+ }
138
+
139
+ // Strategy 2: Find by role selectors
140
+ if (!element) {
141
+ const optionSelectors = ['[role="option"]', '[role="menuitem"]', '[class*="dropdown-item"]', '[class*="menu-item"]'];
142
+
143
+ for (const sel of optionSelectors) {
144
+ try {
145
+ const options = targetDoc.querySelectorAll(sel);
146
+ for (const opt of options) {
147
+ if (!isVisible(opt)) continue;
148
+ const optText = (opt.textContent || '').trim().toLowerCase();
149
+ if (searchText && (optText.includes(searchText) || searchText.includes(optText.substring(0, 20)))) {
150
+ element = opt;
151
+ matchMethod = 'model-option-role';
152
+ break;
153
+ }
154
+ }
155
+ if (element) break;
156
+ } catch(e) {}
157
+ }
158
+ }
159
+
160
+ // Strategy 3: Find any clickable element in dropdown with matching model name
161
+ if (!element && searchText && searchModelName) {
162
+ const dropdownMenu = targetDoc.querySelector('.kiro-dropdown-menu, [class*="dropdown-menu"], [role="listbox"], [role="menu"]');
163
+ if (dropdownMenu) {
164
+ const allItems = dropdownMenu.querySelectorAll('*');
165
+ for (const item of allItems) {
166
+ if (item.children.length > 5) continue;
167
+ if (!isVisible(item)) continue;
168
+ const itemText = (item.textContent || '').trim().toLowerCase();
169
+ const itemModelName = findModelName(itemText);
170
+
171
+ if (itemModelName && searchModelName === itemModelName) {
172
+ element = item;
173
+ matchMethod = 'dropdown-menu-item';
174
+ break;
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ // =========================================================================
182
+ // Send Button
183
+ // =========================================================================
184
+ if (info.isSendButton && !element) {
185
+ const sendSelectors = ['button[data-variant="submit"]', 'svg.lucide-arrow-right', 'button[type="submit"]', 'button[aria-label*="send" i]'];
186
+ for (const sel of sendSelectors) {
187
+ try {
188
+ const el = targetDoc.querySelector(sel);
189
+ if (el) {
190
+ element = el.closest('button') || el;
191
+ if (element && !element.disabled) { matchMethod = 'send-button'; break; }
192
+ }
193
+ } catch(e) {}
194
+ }
195
+ }
196
+
197
+ // =========================================================================
198
+ // Toggle/Switch
199
+ // =========================================================================
200
+ if (isToggle && !element) {
201
+ if (info.toggleId) {
202
+ element = targetDoc.getElementById(info.toggleId);
203
+ if (element) matchMethod = 'toggle-id';
204
+ }
205
+ if (!element && info.text) {
206
+ const toggles = targetDoc.querySelectorAll('.kiro-toggle-switch, [role="switch"]');
207
+ for (const t of toggles) {
208
+ const label = t.querySelector('label');
209
+ if (label && label.textContent.trim().toLowerCase().includes(info.text.toLowerCase())) {
210
+ element = t.querySelector('input') || t;
211
+ matchMethod = 'toggle-label';
212
+ break;
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // =========================================================================
219
+ // Notification Banner Buttons
220
+ // =========================================================================
221
+ if (info.isNotificationButton && !element) {
222
+ const searchText = (info.text || '').trim().toLowerCase();
223
+ const searchAriaLabel = (info.ariaLabel || '').trim().toLowerCase();
224
+
225
+ const snackbarButtons = targetDoc.querySelectorAll('.kiro-snackbar button, .kiro-snackbar-actions button, .kiro-snackbar-header button');
226
+
227
+ // Strategy 1: Find by text
228
+ for (const btn of snackbarButtons) {
229
+ if (!isVisible(btn)) continue;
230
+ const btnText = (btn.textContent || '').trim().toLowerCase();
231
+ if (searchText && btnText && (btnText.includes(searchText) || searchText.includes(btnText))) {
232
+ element = btn;
233
+ matchMethod = 'snackbar-button-text';
234
+ break;
235
+ }
236
+ }
237
+
238
+ // Strategy 2: Find X/close/dismiss icon button
239
+ if (!element && (searchText === 'close' || searchText === 'dismiss' || searchText === 'x' ||
240
+ searchText === 'icon-button' || searchText === '' || !searchText ||
241
+ searchAriaLabel.includes('close') || searchAriaLabel.includes('dismiss'))) {
242
+ for (const btn of snackbarButtons) {
243
+ if (!isVisible(btn)) continue;
244
+ const btnAriaLabel = (btn.getAttribute('aria-label') || '').toLowerCase();
245
+ const isExpandBtn = btnAriaLabel.includes('expand') || btn.classList.contains('kiro-snackbar-expand');
246
+
247
+ if (isExpandBtn) continue;
248
+
249
+ if (btnAriaLabel.includes('close') || btnAriaLabel.includes('dismiss') ||
250
+ btn.querySelector('.codicon-close, .codicon-x, [class*="close"]')) {
251
+ element = btn;
252
+ matchMethod = 'snackbar-dismiss-button';
253
+ break;
254
+ }
255
+
256
+ const isIconBtn = btn.classList.contains('kiro-icon-button');
257
+ const btnText = (btn.textContent || '').trim();
258
+ if (isIconBtn && !btnText && !isExpandBtn) {
259
+ element = btn;
260
+ matchMethod = 'snackbar-icon-dismiss';
261
+ break;
262
+ }
263
+ }
264
+ }
265
+
266
+ // Strategy 3: Find by aria-label
267
+ if (!element && searchAriaLabel && !searchAriaLabel.includes('expand')) {
268
+ for (const btn of snackbarButtons) {
269
+ if (!isVisible(btn)) continue;
270
+ const btnAriaLabel = (btn.getAttribute('aria-label') || '').toLowerCase();
271
+ if (btnAriaLabel.includes('expand')) continue;
272
+ if (btnAriaLabel && (btnAriaLabel.includes(searchAriaLabel) || searchAriaLabel.includes(btnAriaLabel))) {
273
+ element = btn;
274
+ matchMethod = 'snackbar-button-aria';
275
+ break;
276
+ }
277
+ }
278
+ }
279
+
280
+ // Strategy 4: Find in agent-outcome notifications
281
+ if (!element) {
282
+ const outcomeButtons = targetDoc.querySelectorAll('.agent-outcome button, .agent-outcome-notification button, [class*="outcome"] button');
283
+ for (const btn of outcomeButtons) {
284
+ if (!isVisible(btn)) continue;
285
+ const btnText = (btn.textContent || '').trim().toLowerCase();
286
+ if (searchText && (btnText.includes(searchText) || searchText.includes(btnText))) {
287
+ element = btn;
288
+ matchMethod = 'outcome-button';
289
+ break;
290
+ }
291
+ }
292
+ }
293
+
294
+ // Strategy 5: Find expand/collapse arrow (only if explicitly requested)
295
+ if (!element && (searchText.includes('expand') || searchAriaLabel.includes('expand'))) {
296
+ const expandArrow = targetDoc.querySelector('.kiro-snackbar [class*="expand"], .kiro-snackbar [class*="arrow"], .kiro-snackbar [class*="chevron"], .kiro-snackbar button[aria-label*="expand" i]');
297
+ if (expandArrow && isVisible(expandArrow)) {
298
+ element = expandArrow;
299
+ matchMethod = 'snackbar-expand';
300
+ }
301
+ }
302
+ }
303
+
304
+ // =========================================================================
305
+ // Close Button
306
+ // =========================================================================
307
+ if (isCloseButton && !element) {
308
+ const closeButtons = targetDoc.querySelectorAll('[aria-label="close"], .kiro-tabs-item-close, [class*="close"]');
309
+ if (info.parentTabLabel) {
310
+ const searchLabel = info.parentTabLabel.trim().toLowerCase();
311
+ for (const btn of closeButtons) {
312
+ const parentTab = btn.closest('[role="tab"]');
313
+ if (parentTab) {
314
+ const labelEl = parentTab.querySelector('.kiro-tabs-item-label, [class*="label"]');
315
+ const tabLabel = labelEl ? labelEl.textContent.trim().toLowerCase() : '';
316
+ if (tabLabel.includes(searchLabel) || searchLabel.includes(tabLabel)) {
317
+ element = btn;
318
+ matchMethod = 'close-button-by-tab';
319
+ break;
320
+ }
321
+ }
322
+ }
323
+ }
324
+ if (!element && closeButtons.length > 0) {
325
+ for (const btn of closeButtons) {
326
+ const parentTab = btn.closest('[role="tab"]');
327
+ if (parentTab && parentTab.getAttribute('aria-selected') === 'true') {
328
+ element = btn;
329
+ matchMethod = 'close-button-selected';
330
+ break;
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ // =========================================================================
337
+ // Tab Click
338
+ // =========================================================================
339
+ if (isTabClick && !element) {
340
+ const allTabs = targetDoc.querySelectorAll('[role="tab"]');
341
+ const searchText = (info.tabLabel || info.text || '').trim().toLowerCase();
342
+ for (const tab of allTabs) {
343
+ const labelEl = tab.querySelector('.kiro-tabs-item-label, [class*="label"]');
344
+ const tabText = labelEl ? labelEl.textContent.trim().toLowerCase() : tab.textContent.trim().toLowerCase();
345
+ if (searchText && (tabText.includes(searchText) || searchText.includes(tabText))) {
346
+ element = tab;
347
+ matchMethod = 'tab-label';
348
+ break;
349
+ }
350
+ }
351
+ }
352
+
353
+ // =========================================================================
354
+ // File Link
355
+ // =========================================================================
356
+ if (info.isFileLink && info.filePath && !element) {
357
+ const fileName = info.filePath.split('/').pop().split('\\\\').pop();
358
+ const fileSelectors = ['a[href*="' + fileName + '"]', '[data-path*="' + fileName + '"]', 'code', 'span', '[class*="file"]'];
359
+ for (const selector of fileSelectors) {
360
+ try {
361
+ const candidates = targetDoc.querySelectorAll(selector);
362
+ for (const el of candidates) {
363
+ const text = (el.textContent || '').trim();
364
+ if (text.includes(info.filePath) || text.includes(fileName)) {
365
+ element = el;
366
+ matchMethod = 'file-link';
367
+ break;
368
+ }
369
+ }
370
+ if (element) break;
371
+ } catch(e) {}
372
+ }
373
+ }
374
+
375
+ // =========================================================================
376
+ // Aria Label Match
377
+ // =========================================================================
378
+ if (info.ariaLabel && !element && !isCloseButton) {
379
+ try {
380
+ const escapedLabel = info.ariaLabel.replace(/"/g, '\\\\"');
381
+ const candidates = targetDoc.querySelectorAll('[aria-label="' + escapedLabel + '"]');
382
+ for (const c of candidates) {
383
+ const label = (c.getAttribute('aria-label') || '').toLowerCase();
384
+ if (!label.includes('close')) { element = c; matchMethod = 'aria-label'; break; }
385
+ }
386
+ } catch(e) {}
387
+ }
388
+
389
+ // =========================================================================
390
+ // History/Session List Items
391
+ // =========================================================================
392
+ if (info.isHistoryItem && !element) {
393
+ const searchText = (info.text || '').trim().toLowerCase();
394
+ const datePattern = /\\d{1,2}\\/\\d{1,2}\\/\\d{4}|\\d{1,2}:\\d{2}:\\d{2}/;
395
+ const allDivs = targetDoc.querySelectorAll('div, li, article');
396
+ const historyItems = [];
397
+
398
+ for (const item of allDivs) {
399
+ if (item.children.length > 15) continue;
400
+ const text = item.textContent || '';
401
+ if (datePattern.test(text) && text.length > 20 && text.length < 500) {
402
+ historyItems.push(item);
403
+ }
404
+ }
405
+
406
+ for (const item of historyItems) {
407
+ const itemText = (item.textContent || '').trim().toLowerCase();
408
+ if (searchText && (itemText.includes(searchText) || searchText.includes(itemText.substring(0, 50)))) {
409
+ element = item;
410
+ matchMethod = 'history-item-date';
411
+ break;
412
+ }
413
+ }
414
+
415
+ if (!element) {
416
+ const historySelectors = ['[role="listitem"]', '[role="option"]', '[class*="history"] > *', '[class*="session"] > *'];
417
+ for (const selector of historySelectors) {
418
+ try {
419
+ const items = targetDoc.querySelectorAll(selector);
420
+ for (const item of items) {
421
+ const itemText = (item.textContent || '').trim().toLowerCase();
422
+ if (searchText && (itemText.includes(searchText) || searchText.includes(itemText.substring(0, 50)))) {
423
+ element = item;
424
+ matchMethod = 'history-item-selector';
425
+ break;
426
+ }
427
+ }
428
+ if (element) break;
429
+ } catch(e) {}
430
+ }
431
+ }
432
+ }
433
+
434
+ // =========================================================================
435
+ // Text Content Match (fallback)
436
+ // =========================================================================
437
+ if (info.text && info.text.trim() && !element) {
438
+ const searchText = info.text.trim();
439
+ const allElements = targetDoc.querySelectorAll('button, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="listitem"], a, [tabindex="0"], [class*="cursor-pointer"]');
440
+ for (const el of allElements) {
441
+ if (!isCloseButton) {
442
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
443
+ if (ariaLabel.includes('close')) continue;
444
+ }
445
+ const elText = (el.textContent || '').trim();
446
+ if (elText === searchText || elText.includes(searchText) || (elText.length >= 10 && searchText.includes(elText))) {
447
+ element = el;
448
+ matchMethod = 'text-content';
449
+ break;
450
+ }
451
+ }
452
+ }
453
+
454
+ // =========================================================================
455
+ // Execute Click
456
+ // =========================================================================
457
+ if (!element) return { found: false, error: 'Element not found' };
458
+
459
+ const elementInfo = {
460
+ tag: element.tagName,
461
+ className: element.className?.substring?.(0, 100) || '',
462
+ role: element.getAttribute('role'),
463
+ ariaHaspopup: element.getAttribute('aria-haspopup'),
464
+ ariaExpanded: element.getAttribute('aria-expanded'),
465
+ dataState: element.getAttribute('data-state'),
466
+ textContent: (element.textContent || '').substring(0, 50)
467
+ };
468
+
469
+ try {
470
+ element.click();
471
+ return { found: true, clicked: true, matchMethod, elementInfo };
472
+ } catch (e) {
473
+ try {
474
+ const rect = element.getBoundingClientRect();
475
+ const centerX = rect.left + rect.width / 2;
476
+ const centerY = rect.top + rect.height / 2;
477
+
478
+ const mouseOpts = {
479
+ bubbles: true,
480
+ cancelable: true,
481
+ view: window,
482
+ clientX: centerX,
483
+ clientY: centerY
484
+ };
485
+
486
+ element.dispatchEvent(new MouseEvent('mousedown', mouseOpts));
487
+ element.dispatchEvent(new MouseEvent('mouseup', mouseOpts));
488
+ element.dispatchEvent(new MouseEvent('click', mouseOpts));
489
+
490
+ return { found: true, clicked: true, matchMethod: matchMethod + '-dispatch', elementInfo };
491
+ } catch (e2) {
492
+ return { found: true, clicked: false, error: 'Click failed: ' + e2.message };
493
+ }
494
+ }
495
+ })()`;
496
+ }
497
+
498
+ /**
499
+ * Click an element in the Kiro UI via CDP
500
+ * @param {CDPConnection} cdp - CDP connection
501
+ * @param {object} clickInfo - Element identification info (should be sanitized)
502
+ * @returns {Promise<{success: boolean, matchMethod?: string, error?: string}>}
503
+ */
504
+ export async function clickElement(cdp, clickInfo) {
505
+ const script = buildClickScript(clickInfo);
506
+
507
+ try {
508
+ const result = await cdp.call('Runtime.evaluate', {
509
+ expression: script,
510
+ contextId: cdp.rootContextId,
511
+ returnByValue: true
512
+ });
513
+
514
+ const elementInfo = result.result?.value;
515
+
516
+ if (!elementInfo?.found) {
517
+ console.log('[Click] Element not found:', clickInfo.ariaLabel || clickInfo.text || 'unknown');
518
+ return { success: false, error: 'Element not found' };
519
+ }
520
+
521
+ if (elementInfo.clicked) {
522
+ return {
523
+ success: true,
524
+ matchMethod: elementInfo.matchMethod,
525
+ needsRetry: elementInfo.needsRetry
526
+ };
527
+ }
528
+ return { success: false, error: elementInfo.error || 'Click failed' };
529
+ } catch (err) {
530
+ console.error('[Click] CDP error:', err.message);
531
+ return { success: false, error: err.message };
532
+ }
533
+ }