tab-agent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +62 -0
  2. package/bin/tab-agent.js +40 -0
  3. package/cli/detect-extension.js +131 -0
  4. package/cli/setup.js +133 -0
  5. package/cli/start.js +19 -0
  6. package/cli/status.js +70 -0
  7. package/extension/content-script.js +510 -0
  8. package/extension/icons/icon128.png +0 -0
  9. package/extension/icons/icon16.png +0 -0
  10. package/extension/icons/icon48.png +0 -0
  11. package/extension/manifest.json +40 -0
  12. package/extension/popup/popup.html +142 -0
  13. package/extension/popup/popup.js +104 -0
  14. package/extension/service-worker.js +471 -0
  15. package/extension/snapshot.js +194 -0
  16. package/package.json +25 -0
  17. package/relay/install-native-host.sh +57 -0
  18. package/relay/native-host-wrapper.cmd +3 -0
  19. package/relay/native-host-wrapper.sh +29 -0
  20. package/relay/native-host.js +128 -0
  21. package/relay/node_modules/.package-lock.json +29 -0
  22. package/relay/node_modules/ws/LICENSE +20 -0
  23. package/relay/node_modules/ws/README.md +548 -0
  24. package/relay/node_modules/ws/browser.js +8 -0
  25. package/relay/node_modules/ws/index.js +13 -0
  26. package/relay/node_modules/ws/lib/buffer-util.js +131 -0
  27. package/relay/node_modules/ws/lib/constants.js +19 -0
  28. package/relay/node_modules/ws/lib/event-target.js +292 -0
  29. package/relay/node_modules/ws/lib/extension.js +203 -0
  30. package/relay/node_modules/ws/lib/limiter.js +55 -0
  31. package/relay/node_modules/ws/lib/permessage-deflate.js +528 -0
  32. package/relay/node_modules/ws/lib/receiver.js +706 -0
  33. package/relay/node_modules/ws/lib/sender.js +602 -0
  34. package/relay/node_modules/ws/lib/stream.js +161 -0
  35. package/relay/node_modules/ws/lib/subprotocol.js +62 -0
  36. package/relay/node_modules/ws/lib/validation.js +152 -0
  37. package/relay/node_modules/ws/lib/websocket-server.js +554 -0
  38. package/relay/node_modules/ws/lib/websocket.js +1393 -0
  39. package/relay/node_modules/ws/package.json +69 -0
  40. package/relay/node_modules/ws/wrapper.mjs +8 -0
  41. package/relay/package-lock.json +36 -0
  42. package/relay/package.json +12 -0
  43. package/relay/server.js +114 -0
  44. package/skills/claude-code/tab-agent.md +53 -0
  45. package/skills/codex/tab-agent.md +40 -0
@@ -0,0 +1,510 @@
1
+ // content-script.js
2
+ // Handles commands from service worker and executes DOM actions
3
+
4
+ if (window.__tabAgent_contentScriptLoaded) {
5
+ console.log('Tab Agent content script already loaded');
6
+ } else {
7
+ window.__tabAgent_contentScriptLoaded = true;
8
+
9
+ const snapshotState = (() => {
10
+ let refCounter = 0;
11
+ const refMap = new Map();
12
+
13
+ function resetRefs() {
14
+ refCounter = 0;
15
+ refMap.clear();
16
+ }
17
+
18
+ function nextRef() {
19
+ return `e${++refCounter}`;
20
+ }
21
+
22
+ function storeRef(ref, element) {
23
+ refMap.set(ref, element);
24
+ }
25
+
26
+ function getElementByRef(ref) {
27
+ return refMap.get(ref);
28
+ }
29
+
30
+ function getRole(element) {
31
+ const explicitRole = element.getAttribute('role');
32
+ if (explicitRole) return explicitRole;
33
+
34
+ const tag = element.tagName.toLowerCase();
35
+ const type = element.getAttribute('type');
36
+
37
+ const roleMap = {
38
+ 'a': 'link',
39
+ 'button': 'button',
40
+ 'input': type === 'submit' ? 'button' :
41
+ type === 'checkbox' ? 'checkbox' :
42
+ type === 'radio' ? 'radio' :
43
+ type === 'text' || type === 'email' || type === 'password' || type === 'search' ? 'textbox' :
44
+ 'input',
45
+ 'textarea': 'textbox',
46
+ 'select': 'combobox',
47
+ 'img': 'img',
48
+ 'h1': 'heading',
49
+ 'h2': 'heading',
50
+ 'h3': 'heading',
51
+ 'h4': 'heading',
52
+ 'h5': 'heading',
53
+ 'h6': 'heading',
54
+ 'nav': 'navigation',
55
+ 'main': 'main',
56
+ 'footer': 'contentinfo',
57
+ 'header': 'banner',
58
+ 'form': 'form',
59
+ 'table': 'table',
60
+ 'ul': 'list',
61
+ 'ol': 'list',
62
+ 'li': 'listitem',
63
+ };
64
+
65
+ return roleMap[tag] || 'generic';
66
+ }
67
+
68
+ function getName(element) {
69
+ const ariaLabel = element.getAttribute('aria-label');
70
+ if (ariaLabel) return ariaLabel;
71
+
72
+ const ariaLabelledBy = element.getAttribute('aria-labelledby');
73
+ if (ariaLabelledBy) {
74
+ const labelEl = document.getElementById(ariaLabelledBy);
75
+ if (labelEl) return labelEl.textContent.trim();
76
+ }
77
+
78
+ if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
79
+ const id = element.id;
80
+ if (id) {
81
+ const label = document.querySelector(`label[for="${id}"]`);
82
+ if (label) return label.textContent.trim();
83
+ }
84
+ if (element.placeholder) return element.placeholder;
85
+ }
86
+
87
+ if (element.tagName === 'IMG') {
88
+ return element.alt || '';
89
+ }
90
+
91
+ if (element.tagName === 'BUTTON' || element.tagName === 'A') {
92
+ return element.textContent.trim().substring(0, 100);
93
+ }
94
+
95
+ if (/^H[1-6]$/.test(element.tagName)) {
96
+ return element.textContent.trim().substring(0, 100);
97
+ }
98
+
99
+ if (element.title) {
100
+ return element.title;
101
+ }
102
+
103
+ return '';
104
+ }
105
+
106
+ function isInteractive(element) {
107
+ const tag = element.tagName.toLowerCase();
108
+ const interactiveTags = ['a', 'button', 'input', 'textarea', 'select', 'details', 'summary'];
109
+
110
+ if (interactiveTags.includes(tag)) return true;
111
+ if (element.getAttribute('onclick')) return true;
112
+ if (element.getAttribute('role') === 'button') return true;
113
+ if (element.getAttribute('tabindex') !== null) return true;
114
+ if (element.contentEditable === 'true') return true;
115
+
116
+ return false;
117
+ }
118
+
119
+ function isVisible(element) {
120
+ const style = window.getComputedStyle(element);
121
+ if (style.display === 'none') return false;
122
+ if (style.visibility === 'hidden') return false;
123
+ if (style.opacity === '0') return false;
124
+
125
+ const rect = element.getBoundingClientRect();
126
+ if (rect.width === 0 && rect.height === 0) return false;
127
+
128
+ return true;
129
+ }
130
+
131
+ function getDirectText(element) {
132
+ let text = '';
133
+ for (const node of element.childNodes) {
134
+ if (node.nodeType === Node.TEXT_NODE) {
135
+ text += node.textContent;
136
+ }
137
+ }
138
+ return text.trim();
139
+ }
140
+
141
+ function buildSnapshot(element, depth = 0, maxDepth = 15) {
142
+ if (depth > maxDepth) return [];
143
+ if (!isVisible(element)) return [];
144
+
145
+ const lines = [];
146
+ const role = getRole(element);
147
+ const name = getName(element);
148
+ const interactive = isInteractive(element);
149
+
150
+ const includedRoles = [
151
+ 'link', 'button', 'textbox', 'checkbox', 'radio', 'combobox',
152
+ 'heading', 'img', 'navigation', 'main', 'form', 'listitem',
153
+ 'tab', 'tabpanel', 'menu', 'menuitem', 'dialog', 'alert',
154
+ 'article', 'paragraph'
155
+ ];
156
+
157
+ // Check for data-testid (useful for Twitter/X)
158
+ const testId = element.getAttribute('data-testid');
159
+ const isTweet = testId && (testId.includes('tweet') || testId.includes('tweetText'));
160
+
161
+ // Get direct text content for text-heavy elements
162
+ const directText = getDirectText(element);
163
+ const hasSignificantText = directText.length > 20;
164
+
165
+ const shouldInclude = includedRoles.includes(role) || interactive || isTweet || hasSignificantText;
166
+
167
+ if (shouldInclude && (name || interactive || directText || isTweet)) {
168
+ const ref = nextRef();
169
+ storeRef(ref, element);
170
+
171
+ let line = `[${ref}] ${role}`;
172
+ if (isTweet) {
173
+ line = `[${ref}] tweet`;
174
+ }
175
+
176
+ const displayText = name || directText;
177
+ if (displayText) {
178
+ line += ` "${displayText.substring(0, 200).replace(/\n/g, ' ')}"`;
179
+ }
180
+
181
+ if (element.tagName === 'INPUT') {
182
+ const type = element.type;
183
+ if (type === 'checkbox' || type === 'radio') {
184
+ line += element.checked ? ' (checked)' : ' (unchecked)';
185
+ }
186
+ if (element.value && type !== 'password') {
187
+ line += ` value="${element.value.substring(0, 30)}"`;
188
+ }
189
+ }
190
+
191
+ if (element.tagName === 'SELECT') {
192
+ const selected = element.options[element.selectedIndex];
193
+ if (selected) {
194
+ line += ` selected="${selected.text}"`;
195
+ }
196
+ }
197
+
198
+ lines.push(line);
199
+ }
200
+
201
+ for (const child of element.children) {
202
+ lines.push(...buildSnapshot(child, depth + 1, maxDepth));
203
+ }
204
+
205
+ return lines;
206
+ }
207
+
208
+ function snapshot() {
209
+ resetRefs();
210
+
211
+ const lines = ['== Page Snapshot ==', `URL: ${window.location.href}`, `Title: ${document.title}`, ''];
212
+ const root = document.body || document.documentElement;
213
+ if (root) {
214
+ lines.push(...buildSnapshot(root));
215
+ }
216
+
217
+ // Special handling for Twitter/X - find tweets by data-testid
218
+ const tweetElements = document.querySelectorAll('[data-testid="tweet"], [data-testid="tweetText"], article[role="article"]');
219
+ if (tweetElements.length > 0) {
220
+ lines.push('');
221
+ lines.push('== Tweets ==');
222
+ tweetElements.forEach((el, i) => {
223
+ const ref = nextRef();
224
+ storeRef(ref, el);
225
+
226
+ // Get tweet text content
227
+ const tweetText = el.querySelector('[data-testid="tweetText"]');
228
+ const text = tweetText ? tweetText.textContent.trim() : el.textContent.trim();
229
+
230
+ // Get author if available
231
+ const authorLink = el.querySelector('a[href^="/"][role="link"]');
232
+ const author = authorLink ? authorLink.textContent : '';
233
+
234
+ if (text && text.length > 10) {
235
+ const cleanText = text.substring(0, 300).replace(/\n+/g, ' ').replace(/\s+/g, ' ');
236
+ lines.push(`[${ref}] tweet ${author ? 'by ' + author + ': ' : ''}"${cleanText}"`);
237
+ }
238
+ });
239
+ }
240
+
241
+ return {
242
+ url: window.location.href,
243
+ title: document.title,
244
+ snapshot: lines.join('\n'),
245
+ refCount: refCounter,
246
+ };
247
+ }
248
+
249
+ return { getElementByRef, snapshot };
250
+ })();
251
+
252
+ function getElementByRef(ref) {
253
+ return snapshotState.getElementByRef(ref);
254
+ }
255
+
256
+ async function executeClick(params) {
257
+ const { ref, doubleClick = false } = params;
258
+ const element = getElementByRef(ref);
259
+
260
+ if (!element) {
261
+ return { ok: false, error: `Element ${ref} not found` };
262
+ }
263
+
264
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
265
+ await new Promise(r => setTimeout(r, 100));
266
+
267
+ if (doubleClick) {
268
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
269
+ } else {
270
+ element.click();
271
+ }
272
+
273
+ return { ok: true, ref };
274
+ }
275
+
276
+ async function executeType(params) {
277
+ const { ref, text, submit = false } = params;
278
+ const element = getElementByRef(ref);
279
+
280
+ if (!element) {
281
+ return { ok: false, error: `Element ${ref} not found` };
282
+ }
283
+
284
+ element.focus();
285
+
286
+ for (const char of text) {
287
+ element.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true }));
288
+ element.dispatchEvent(new KeyboardEvent('keypress', { key: char, bubbles: true }));
289
+
290
+ if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
291
+ element.value += char;
292
+ element.dispatchEvent(new Event('input', { bubbles: true }));
293
+ }
294
+
295
+ element.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true }));
296
+ await new Promise(r => setTimeout(r, 10));
297
+ }
298
+
299
+ // Handle submit if requested
300
+ if (submit) {
301
+ element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
302
+ element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
303
+
304
+ // Also try form submit
305
+ const form = element.closest('form');
306
+ if (form) form.requestSubmit();
307
+ }
308
+
309
+ return { ok: true, ref, typed: text, submitted: submit };
310
+ }
311
+
312
+ async function executeFill(params) {
313
+ const { ref, value } = params;
314
+ const element = getElementByRef(ref);
315
+
316
+ if (!element) {
317
+ return { ok: false, error: `Element ${ref} not found` };
318
+ }
319
+
320
+ element.focus();
321
+ element.value = '';
322
+ element.dispatchEvent(new Event('input', { bubbles: true }));
323
+
324
+ element.value = value;
325
+ element.dispatchEvent(new Event('input', { bubbles: true }));
326
+ element.dispatchEvent(new Event('change', { bubbles: true }));
327
+
328
+ return { ok: true, ref, filled: value };
329
+ }
330
+
331
+ async function executePress(params) {
332
+ const { key } = params;
333
+
334
+ const keyMap = {
335
+ 'Enter': { key: 'Enter', code: 'Enter', keyCode: 13 },
336
+ 'Escape': { key: 'Escape', code: 'Escape', keyCode: 27 },
337
+ 'Tab': { key: 'Tab', code: 'Tab', keyCode: 9 },
338
+ 'Backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 },
339
+ 'ArrowUp': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
340
+ 'ArrowDown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
341
+ 'ArrowLeft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
342
+ 'ArrowRight': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
343
+ };
344
+
345
+ const keyInfo = keyMap[key] || { key, code: key, keyCode: 0 };
346
+ const target = document.activeElement || document.body;
347
+
348
+ if (!target) {
349
+ return { ok: false, error: 'No active element to receive keypress' };
350
+ }
351
+
352
+ target.dispatchEvent(new KeyboardEvent('keydown', { ...keyInfo, bubbles: true }));
353
+ target.dispatchEvent(new KeyboardEvent('keyup', { ...keyInfo, bubbles: true }));
354
+
355
+ return { ok: true, key };
356
+ }
357
+
358
+ async function executeSelect(params) {
359
+ const { ref, value } = params;
360
+ const element = getElementByRef(ref);
361
+
362
+ if (!element || element.tagName !== 'SELECT') {
363
+ return { ok: false, error: `Select element ${ref} not found` };
364
+ }
365
+
366
+ element.value = value;
367
+ element.dispatchEvent(new Event('change', { bubbles: true }));
368
+
369
+ return { ok: true, ref, selected: value };
370
+ }
371
+
372
+ async function executeHover(params) {
373
+ const { ref } = params;
374
+ const element = getElementByRef(ref);
375
+
376
+ if (!element) {
377
+ return { ok: false, error: `Element ${ref} not found` };
378
+ }
379
+
380
+ element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
381
+ element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
382
+
383
+ return { ok: true, ref };
384
+ }
385
+
386
+ async function executeScroll(params) {
387
+ const { direction = 'down', amount = 300 } = params;
388
+
389
+ const scrollAmount = direction === 'up' ? -amount : amount;
390
+ window.scrollBy({ top: scrollAmount, behavior: 'smooth' });
391
+
392
+ await new Promise(r => setTimeout(r, 300));
393
+
394
+ return { ok: true, direction, scrollY: window.scrollY };
395
+ }
396
+
397
+ // Wait for condition (text, selector, or timeout)
398
+ async function executeWait(params) {
399
+ const { text, selector, timeout = 30000 } = params;
400
+ const start = Date.now();
401
+
402
+ while (Date.now() - start < timeout) {
403
+ if (text && document.body.innerText.includes(text)) {
404
+ return { ok: true, found: 'text' };
405
+ }
406
+ if (selector && document.querySelector(selector)) {
407
+ return { ok: true, found: 'selector' };
408
+ }
409
+ await new Promise(r => setTimeout(r, 100));
410
+ }
411
+
412
+ return { ok: false, error: 'Timeout waiting for condition' };
413
+ }
414
+
415
+ // Scroll element into view
416
+ async function executeScrollIntoView(params) {
417
+ const { ref } = params;
418
+ const element = getElementByRef(ref);
419
+
420
+ if (!element) {
421
+ return { ok: false, error: `Element ${ref} not found` };
422
+ }
423
+
424
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
425
+ await new Promise(r => setTimeout(r, 300));
426
+
427
+ return { ok: true, ref };
428
+ }
429
+
430
+ // Batch fill multiple fields
431
+ async function executeBatchFill(params) {
432
+ const { fields } = params;
433
+ const results = [];
434
+
435
+ for (const field of fields) {
436
+ const result = await executeFill({ ref: field.ref, value: field.value });
437
+ results.push({ ref: field.ref, ...result });
438
+ }
439
+
440
+ return { ok: results.every(r => r.ok), results };
441
+ }
442
+
443
+ async function executeNavigate(params) {
444
+ const { url } = params;
445
+ window.location.href = url;
446
+ return { ok: true, url };
447
+ }
448
+
449
+ async function getSnapshot() {
450
+ return { ok: true, ...snapshotState.snapshot() };
451
+ }
452
+
453
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
454
+ const { action, ...params } = message;
455
+
456
+ (async () => {
457
+ let result;
458
+
459
+ switch (action) {
460
+ case '__ping':
461
+ result = { ok: true };
462
+ break;
463
+ case 'snapshot':
464
+ result = await getSnapshot();
465
+ break;
466
+ case 'click':
467
+ result = await executeClick(params);
468
+ break;
469
+ case 'type':
470
+ result = await executeType(params);
471
+ break;
472
+ case 'fill':
473
+ result = await executeFill(params);
474
+ break;
475
+ case 'press':
476
+ result = await executePress(params);
477
+ break;
478
+ case 'select':
479
+ result = await executeSelect(params);
480
+ break;
481
+ case 'hover':
482
+ result = await executeHover(params);
483
+ break;
484
+ case 'scroll':
485
+ result = await executeScroll(params);
486
+ break;
487
+ case 'wait':
488
+ result = await executeWait(params);
489
+ break;
490
+ case 'scrollintoview':
491
+ result = await executeScrollIntoView(params);
492
+ break;
493
+ case 'batchfill':
494
+ result = await executeBatchFill(params);
495
+ break;
496
+ case 'navigate':
497
+ result = await executeNavigate(params);
498
+ break;
499
+ default:
500
+ result = { ok: false, error: `Unknown action: ${action}` };
501
+ }
502
+
503
+ sendResponse(result);
504
+ })();
505
+
506
+ return true;
507
+ });
508
+
509
+ console.log('Tab Agent content script loaded');
510
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1,40 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Tab Agent",
4
+ "version": "0.1.0",
5
+ "description": "Browser control for Claude Code and Codex via WebSocket",
6
+ "permissions": [
7
+ "activeTab",
8
+ "scripting",
9
+ "storage",
10
+ "tabs",
11
+ "nativeMessaging",
12
+ "debugger"
13
+ ],
14
+ "host_permissions": [
15
+ "<all_urls>"
16
+ ],
17
+ "background": {
18
+ "service_worker": "service-worker.js",
19
+ "type": "module"
20
+ },
21
+ "action": {
22
+ "default_title": "Tab Agent - Click to toggle",
23
+ "default_icon": {
24
+ "16": "icons/icon16.png",
25
+ "48": "icons/icon48.png",
26
+ "128": "icons/icon128.png"
27
+ }
28
+ },
29
+ "icons": {
30
+ "16": "icons/icon16.png",
31
+ "48": "icons/icon48.png",
32
+ "128": "icons/icon128.png"
33
+ },
34
+ "web_accessible_resources": [
35
+ {
36
+ "resources": ["snapshot.js"],
37
+ "matches": ["<all_urls>"]
38
+ }
39
+ ]
40
+ }
@@ -0,0 +1,142 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <style>
6
+ * { box-sizing: border-box; margin: 0; padding: 0; }
7
+
8
+ body {
9
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
10
+ width: 300px;
11
+ padding: 16px;
12
+ background: #1a1a2e;
13
+ color: #e0e0e0;
14
+ }
15
+
16
+ h1 {
17
+ font-size: 16px;
18
+ margin-bottom: 12px;
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 8px;
22
+ }
23
+
24
+ .status {
25
+ font-size: 12px;
26
+ padding: 8px;
27
+ border-radius: 6px;
28
+ margin-bottom: 12px;
29
+ }
30
+
31
+ .status.connected { background: #1a3a1a; color: #4ade80; }
32
+ .status.disconnected { background: #3a1a1a; color: #f87171; }
33
+
34
+ .current-tab {
35
+ background: #2a2a3e;
36
+ border-radius: 6px;
37
+ padding: 12px;
38
+ margin-bottom: 12px;
39
+ }
40
+
41
+ .current-tab .label {
42
+ font-size: 11px;
43
+ color: #888;
44
+ text-transform: uppercase;
45
+ margin-bottom: 4px;
46
+ }
47
+
48
+ .current-tab .url {
49
+ font-size: 12px;
50
+ color: #aaa;
51
+ word-break: break-all;
52
+ margin-bottom: 8px;
53
+ }
54
+
55
+ .btn {
56
+ width: 100%;
57
+ padding: 10px 16px;
58
+ border: none;
59
+ border-radius: 6px;
60
+ font-size: 13px;
61
+ cursor: pointer;
62
+ transition: background 0.2s;
63
+ }
64
+
65
+ .btn-primary { background: #4a9eff; color: white; }
66
+ .btn-primary:hover { background: #3a8eef; }
67
+ .btn-danger { background: #ef4444; color: white; }
68
+ .btn-danger:hover { background: #dc2626; }
69
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
70
+
71
+ .activated-tabs { margin-top: 16px; }
72
+ .activated-tabs h2 {
73
+ font-size: 12px;
74
+ color: #888;
75
+ text-transform: uppercase;
76
+ margin-bottom: 8px;
77
+ }
78
+
79
+ .tab-list { max-height: 150px; overflow-y: auto; }
80
+
81
+ .tab-item {
82
+ display: flex;
83
+ justify-content: space-between;
84
+ align-items: center;
85
+ padding: 8px;
86
+ background: #2a2a3e;
87
+ border-radius: 4px;
88
+ margin-bottom: 4px;
89
+ font-size: 12px;
90
+ }
91
+
92
+ .tab-item .title {
93
+ flex: 1;
94
+ overflow: hidden;
95
+ text-overflow: ellipsis;
96
+ white-space: nowrap;
97
+ margin-right: 8px;
98
+ }
99
+
100
+ .tab-item .deactivate {
101
+ background: none;
102
+ border: none;
103
+ color: #888;
104
+ cursor: pointer;
105
+ padding: 4px;
106
+ }
107
+
108
+ .tab-item .deactivate:hover { color: #ef4444; }
109
+
110
+ .footer {
111
+ margin-top: 16px;
112
+ padding-top: 12px;
113
+ border-top: 1px solid #2a2a3e;
114
+ font-size: 11px;
115
+ color: #666;
116
+ text-align: center;
117
+ }
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <h1>Tab Agent</h1>
122
+
123
+ <div id="status" class="status disconnected">Checking connection...</div>
124
+
125
+ <div class="current-tab">
126
+ <div class="label">Current Tab</div>
127
+ <div id="currentUrl" class="url">Loading...</div>
128
+ <button id="activateBtn" class="btn btn-primary" disabled>Activate Control</button>
129
+ </div>
130
+
131
+ <div class="activated-tabs">
132
+ <h2>Activated Tabs</h2>
133
+ <div id="tabList" class="tab-list">
134
+ <div style="color: #666; font-size: 12px;">No tabs activated</div>
135
+ </div>
136
+ </div>
137
+
138
+ <div class="footer">ws://localhost:9876 | v0.1.0</div>
139
+
140
+ <script src="popup.js"></script>
141
+ </body>
142
+ </html>