cc-agent-ui 0.2.5 → 0.2.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-agent-ui",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Live canvas UI for cc-agent jobs — infinite canvas, streaming output, file browser",
5
5
  "type": "module",
6
6
  "repository": {
package/public/index.html CHANGED
@@ -440,38 +440,50 @@ body {
440
440
  .flash-ok { animation: flash-g 0.8s ease-out; }
441
441
  .flash-err { animation: flash-r 0.8s ease-out; }
442
442
 
443
- /* ── File Browser Panel ──────────────────────────────────────────────────── */
444
- #filebrowser {
443
+ /* ── File Browser Modal ──────────────────────────────────────────────────── */
444
+ .fb-backdrop {
445
445
  position: fixed;
446
- top: 40px; right: 0; bottom: 0;
447
- width: 520px;
448
- background: var(--sidebar-bg);
449
- border-left: 1px solid var(--border-hi);
446
+ inset: 0;
447
+ background: rgba(0,0,0,0.7);
448
+ z-index: 1000;
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ }
453
+ .fb-backdrop.hidden { display: none; }
454
+
455
+ #fb-modal {
456
+ background: #0d1117;
457
+ border: 1px solid #2a2d3a;
458
+ border-radius: 8px;
459
+ width: 80vw;
460
+ max-width: 900px;
461
+ height: 80vh;
450
462
  display: flex;
451
463
  flex-direction: column;
452
- z-index: 500;
453
- transform: translateX(100%);
454
- transition: transform 0.22s cubic-bezier(.4,0,.2,1);
464
+ overflow: hidden;
465
+ box-shadow: 0 24px 64px rgba(0,0,0,0.7);
455
466
  }
456
- #filebrowser.open { transform: translateX(0); }
457
467
 
458
- #fb-topbar {
468
+ #fb-modal-header {
459
469
  display: flex;
460
470
  align-items: center;
461
471
  padding: 0 14px;
462
- height: 38px;
472
+ height: 42px;
463
473
  border-bottom: 1px solid var(--border);
464
- gap: 8px;
474
+ gap: 10px;
465
475
  flex-shrink: 0;
476
+ background: rgba(0,0,0,0.3);
466
477
  }
467
478
  #fb-path {
468
479
  flex: 1;
469
- font-size: 10px;
480
+ font-size: 11px;
470
481
  color: var(--cyan);
471
482
  overflow: hidden;
472
483
  text-overflow: ellipsis;
473
484
  white-space: nowrap;
474
485
  cursor: default;
486
+ font-family: var(--font);
475
487
  }
476
488
  #fb-close {
477
489
  background: transparent;
@@ -555,7 +567,6 @@ body {
555
567
  transition: transform 0.22s cubic-bezier(.4,0,.2,1);
556
568
  }
557
569
  #jobpanel.open { transform: translateX(0); }
558
- #jobpanel.open ~ #filebrowser { right: 600px; }
559
570
 
560
571
  #jp-topbar {
561
572
  display: flex; align-items: center;
@@ -635,14 +646,14 @@ body {
635
646
  #jp-task strong { color: var(--text); display: block; margin-bottom: 4px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; }
636
647
 
637
648
  /* Clickable file paths in terminal */
638
- .fp-link {
649
+ .file-link {
639
650
  color: var(--orange);
640
651
  text-decoration: underline;
641
652
  text-decoration-color: rgba(209,154,102,0.4);
642
653
  cursor: pointer;
643
654
  border-radius: 2px;
644
655
  }
645
- .fp-link:hover {
656
+ .file-link:hover {
646
657
  background: rgba(209,154,102,0.1);
647
658
  text-decoration-color: var(--orange);
648
659
  }
@@ -801,14 +812,19 @@ body {
801
812
  </div>
802
813
  </div>
803
814
 
804
- <!-- File Browser Panel -->
805
- <div id="filebrowser">
806
- <div id="fb-topbar">
807
- <button id="fb-back" onclick="fbGoBack()">← back</button>
808
- <span id="fb-path">/</span>
809
- <button id="fb-close" onclick="fbClose()">✕</button>
815
+ <!-- File Browser Modal -->
816
+ <div id="fb-backdrop" class="fb-backdrop hidden">
817
+ <div id="fb-modal">
818
+ <div id="fb-modal-header">
819
+ <span style="color:var(--dim);font-size:12px;flex-shrink:0">📁</span>
820
+ <span id="fb-path">—</span>
821
+ <div style="display:flex;gap:6px;flex-shrink:0">
822
+ <button id="fb-back" onclick="fbGoBack()">← back</button>
823
+ <button id="fb-close" onclick="fbClose()">✕</button>
824
+ </div>
825
+ </div>
826
+ <div id="fb-body"></div>
810
827
  </div>
811
- <div id="fb-body"></div>
812
828
  </div>
813
829
 
814
830
  <script>
@@ -1473,24 +1489,25 @@ async function jobAction(id, action) {
1473
1489
  // Wire card clicks to open job panel
1474
1490
  function wireCardClick(card, id) {
1475
1491
  card.querySelector('.card-hdr').addEventListener('click', (e) => {
1476
- if (e.target.closest('.fp-link')) return; // don't interfere with file links
1492
+ if (e.target.closest('.file-link')) return; // don't interfere with file links
1477
1493
  jpOpen(id);
1478
1494
  });
1479
1495
  }
1480
1496
 
1481
- // ── File Browser ───────────────────────────────────────────────────────────
1482
- const fb = $('filebrowser');
1497
+ // ── File Browser Modal ─────────────────────────────────────────────────────
1498
+ const fbBackdrop = $('fb-backdrop');
1483
1499
  const fbBody = $('fb-body');
1484
1500
  const fbPathEl = $('fb-path');
1485
1501
  let fbHistory = [];
1486
1502
 
1487
1503
  function fbOpen(p) {
1488
- fb.classList.add('open');
1504
+ fbBackdrop.classList.remove('hidden');
1489
1505
  fbNavigate(p);
1490
1506
  }
1491
1507
 
1492
1508
  function fbClose() {
1493
- fb.classList.remove('open');
1509
+ fbBackdrop.classList.add('hidden');
1510
+ fbHistory = [];
1494
1511
  }
1495
1512
 
1496
1513
  function fbGoBack() {
@@ -1500,19 +1517,30 @@ function fbGoBack() {
1500
1517
  }
1501
1518
  }
1502
1519
 
1520
+ function fbParentDir(p) {
1521
+ const trimmed = p.replace(/\/+$/, '');
1522
+ if (!trimmed || trimmed === '/') return null;
1523
+ const parent = trimmed.replace(/\/[^/]+$/, '') || '/';
1524
+ return parent;
1525
+ }
1526
+
1503
1527
  async function fbNavigate(p, fromBack) {
1504
1528
  if (!fromBack) fbHistory.push(p);
1505
1529
  fbPathEl.textContent = p;
1506
1530
  fbBody.innerHTML = '<div style="padding:14px;color:var(--dim)">loading…</div>';
1507
1531
  try {
1508
- const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`);
1509
- if (!res.ok) throw new Error(await res.text());
1510
- const ct = res.headers.get('content-type') || '';
1511
- if (ct.includes('application/json')) {
1512
- const data = await res.json();
1513
- if (data.type === 'dir') renderDir(data);
1532
+ const statRes = await fetch(`/api/fs/stat?path=${encodeURIComponent(p)}`);
1533
+ if (!statRes.ok) throw new Error(await statRes.text());
1534
+ const stat = await statRes.json();
1535
+ if (!stat.exists) throw new Error('not found: ' + p);
1536
+
1537
+ if (stat.type === 'dir') {
1538
+ const lsRes = await fetch(`/api/fs/ls?path=${encodeURIComponent(p)}`);
1539
+ if (!lsRes.ok) throw new Error(await lsRes.text());
1540
+ const data = await lsRes.json();
1541
+ renderDir(p, data.entries);
1514
1542
  } else {
1515
- renderFile(p, ct, res);
1543
+ renderFile(p);
1516
1544
  }
1517
1545
  } catch (e) {
1518
1546
  fbBody.innerHTML = `<div style="padding:14px;color:var(--red)">${escHtml(e.message)}</div>`;
@@ -1529,73 +1557,128 @@ function fmtSize(n) {
1529
1557
  function fileIcon(name, type) {
1530
1558
  if (type === 'dir') return '📁';
1531
1559
  const ext = name.split('.').pop().toLowerCase();
1532
- if (['js','ts','tsx','jsx','py','go','rs','sh'].includes(ext)) return '📄';
1560
+ if (['js','ts','tsx','jsx','py','go','rs','sh','clj','cljs','sql'].includes(ext)) return '📄';
1533
1561
  if (['png','jpg','jpeg','gif','svg','webp'].includes(ext)) return '🖼';
1534
1562
  if (['mp4','webm','mov'].includes(ext)) return '🎬';
1535
1563
  if (['mp3','wav','ogg'].includes(ext)) return '🎵';
1536
1564
  if (ext === 'pdf') return '📋';
1537
1565
  if (['md','txt'].includes(ext)) return '📝';
1538
1566
  if (ext === 'json') return '{}';
1567
+ if (['yaml','yml'].includes(ext)) return '⚙';
1539
1568
  return '📄';
1540
1569
  }
1541
1570
 
1542
- function renderDir(data) {
1571
+ function renderDir(basePath, entries) {
1543
1572
  const frag = document.createDocumentFragment();
1544
- for (const e of data.entries) {
1573
+
1574
+ // Parent dir entry
1575
+ const parent = fbParentDir(basePath);
1576
+ if (parent) {
1577
+ const up = document.createElement('div');
1578
+ up.className = 'fb-entry';
1579
+ up.innerHTML = `<span class="fb-icon">↑</span><span class="fb-name" style="color:var(--dim)">parent directory</span><span class="fb-size"></span>`;
1580
+ up.addEventListener('click', () => fbNavigate(parent));
1581
+ frag.appendChild(up);
1582
+ }
1583
+
1584
+ for (const e of entries) {
1585
+ const entryPath = basePath.replace(/\/$/, '') + '/' + e.name;
1545
1586
  const row = document.createElement('div');
1546
1587
  row.className = `fb-entry${e.type === 'dir' ? ' is-dir' : ''}`;
1547
- row.innerHTML = `<span class="fb-icon">${fileIcon(e.name, e.type)}</span><span class="fb-name">${escHtml(e.name)}</span><span class="fb-size">${fmtSize(e.size)}</span>`;
1548
- row.addEventListener('click', () => fbNavigate(e.path));
1588
+ row.innerHTML = `<span class="fb-icon">${fileIcon(e.name, e.type)}</span><span class="fb-name">${escHtml(e.name + (e.type === 'dir' ? '/' : ''))}</span><span class="fb-size">${fmtSize(e.size)}</span>`;
1589
+ row.addEventListener('click', () => fbNavigate(entryPath));
1549
1590
  frag.appendChild(row);
1550
1591
  }
1592
+
1551
1593
  fbBody.innerHTML = '';
1552
- if (data.entries.length === 0) {
1594
+ if (entries.length === 0 && !parent) {
1553
1595
  fbBody.innerHTML = '<div style="padding:14px;color:var(--dim)">empty directory</div>';
1554
1596
  } else {
1555
1597
  fbBody.appendChild(frag);
1556
1598
  }
1557
1599
  }
1558
1600
 
1559
- async function renderFile(p, ct, res) {
1560
- const isText = ct.startsWith('text/') || ct.includes('json') || ct.includes('javascript') || ct.includes('xml');
1561
- const isImage = ct.startsWith('image/');
1562
- const isVideo = ct.startsWith('video/');
1563
- const isAudio = ct.startsWith('audio/');
1564
- const url = `/api/browse?path=${encodeURIComponent(p)}`;
1601
+ async function renderFile(p) {
1602
+ const ext = p.split('.').pop().toLowerCase();
1603
+ const rawUrl = `/api/fs/raw?path=${encodeURIComponent(p)}`;
1565
1604
  const div = document.createElement('div');
1566
1605
  div.id = 'fb-file-view';
1567
- if (isImage) {
1606
+
1607
+ const imageExts = ['png','jpg','jpeg','gif','svg','webp'];
1608
+ const videoExts = ['mp4','webm','mov'];
1609
+ const audioExts = ['mp3','wav','ogg'];
1610
+ const textExts = ['js','ts','tsx','jsx','py','go','rs','md','json','yaml','yml',
1611
+ 'sh','bash','html','css','txt','log','clj','cljs','sql','toml',
1612
+ 'env','gitignore','dockerfile','makefile','lock'];
1613
+
1614
+ if (imageExts.includes(ext)) {
1568
1615
  div.className = 'media-view';
1569
- div.innerHTML = `<img src="${url}" alt="${escHtml(p)}">`;
1570
- } else if (isVideo) {
1616
+ div.innerHTML = `<img src="${rawUrl}" alt="${escHtml(p)}">`;
1617
+ fbBody.innerHTML = '';
1618
+ fbBody.appendChild(div);
1619
+ } else if (videoExts.includes(ext)) {
1571
1620
  div.className = 'media-view';
1572
- div.innerHTML = `<video controls src="${url}"></video>`;
1573
- } else if (isAudio) {
1621
+ div.innerHTML = `<video controls src="${rawUrl}"></video>`;
1622
+ fbBody.innerHTML = '';
1623
+ fbBody.appendChild(div);
1624
+ } else if (audioExts.includes(ext)) {
1574
1625
  div.className = 'media-view';
1575
- div.innerHTML = `<audio controls src="${url}"></audio>`;
1576
- } else if (isText) {
1577
- const text = await res.text();
1578
- div.textContent = text;
1626
+ div.innerHTML = `<audio controls src="${rawUrl}"></audio>`;
1627
+ fbBody.innerHTML = '';
1628
+ fbBody.appendChild(div);
1629
+ } else if (textExts.includes(ext) || !ext || p.split('/').pop().indexOf('.') === -1) {
1630
+ div.style.cssText = 'padding:12px 14px;font-size:11px;line-height:1.65;white-space:pre-wrap;word-break:break-all;color:var(--text);overflow:auto;flex:1';
1631
+ div.textContent = 'loading…';
1632
+ fbBody.innerHTML = '';
1633
+ fbBody.appendChild(div);
1634
+ try {
1635
+ const r = await fetch(`/api/fs/cat?path=${encodeURIComponent(p)}`);
1636
+ if (!r.ok) { div.style.color = 'var(--red)'; div.textContent = await r.text(); return; }
1637
+ const data = await r.json();
1638
+ div.textContent = data.content;
1639
+ } catch (e) {
1640
+ div.style.color = 'var(--red)';
1641
+ div.textContent = e.message;
1642
+ }
1579
1643
  } else {
1580
- div.innerHTML = `<div style="color:var(--dim);padding:14px">Binary file — <a href="${url}" download style="color:var(--cyan)">download</a></div>`;
1644
+ div.innerHTML = `<div style="color:var(--dim);padding:14px">Binary file — <a href="${rawUrl}" download style="color:var(--cyan)">download</a></div>`;
1645
+ fbBody.innerHTML = '';
1646
+ fbBody.appendChild(div);
1581
1647
  }
1582
- fbBody.innerHTML = '';
1583
- fbBody.appendChild(div);
1584
1648
  }
1585
1649
 
1650
+ // Close modal on backdrop click or ESC
1651
+ document.addEventListener('keydown', e => {
1652
+ if (e.key === 'Escape' && !fbBackdrop.classList.contains('hidden')) fbClose();
1653
+ });
1654
+ $('fb-backdrop').addEventListener('click', e => {
1655
+ if (e.target === $('fb-backdrop')) fbClose();
1656
+ });
1657
+
1586
1658
  // ── File path detection in terminal lines ─────────────────────────────────
1587
- const PATH_RE = /((?:\/[^\s:'"<>()[\]{}\\|]+)+(?:\.[a-zA-Z0-9]+)?|~(?:\/[^\s:'"<>()[\]{}\\|]+)*)/g;
1659
+ function escAttr(s) {
1660
+ return (s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
1661
+ }
1662
+
1663
+ // Delegated click handler for .file-link spans
1664
+ document.addEventListener('click', e => {
1665
+ const link = e.target.closest('.file-link');
1666
+ if (link) {
1667
+ e.stopPropagation();
1668
+ fbOpen(link.dataset.path);
1669
+ }
1670
+ });
1588
1671
 
1589
1672
  function linkifyPaths(text) {
1590
- // Escape then re-insert clickable spans for file paths
1591
- // Split on path patterns, escape non-path segments, wrap paths in spans
1673
+ const PATH_RE = /(\/(?:Users|home|tmp|workspace|var\/folders)[^\s"'`,;()[\]<>]{3,}|~\/[^\s"'`,;()[\]<>]+)/g;
1592
1674
  const parts = [];
1593
1675
  let last = 0;
1594
1676
  let m;
1595
1677
  PATH_RE.lastIndex = 0;
1596
1678
  while ((m = PATH_RE.exec(text)) !== null) {
1597
1679
  if (m.index > last) parts.push(escHtml(text.slice(last, m.index)));
1598
- parts.push(`<span class="fp-link" onclick="fbOpen(${JSON.stringify(m[0])})">${escHtml(m[0])}</span>`);
1680
+ const p = m[0];
1681
+ parts.push(`<span class="file-link" data-path="${escAttr(p)}">${escHtml(p)}</span>`);
1599
1682
  last = m.index + m[0].length;
1600
1683
  }
1601
1684
  if (last < text.length) parts.push(escHtml(text.slice(last)));
package/server.js CHANGED
@@ -154,10 +154,24 @@ function mimeFor(ext) {
154
154
  mp4:'video/mp4', webm:'video/webm', mov:'video/quicktime',
155
155
  mp3:'audio/mpeg', wav:'audio/wav', ogg:'audio/ogg',
156
156
  pdf:'application/pdf',
157
+ clj:'text/x-clojure', cljs:'text/x-clojure', sql:'text/x-sql',
158
+ log:'text/plain', env:'text/plain', toml:'text/x-toml',
157
159
  };
158
160
  return map[ext] || 'application/octet-stream';
159
161
  }
160
162
 
163
+ // Security: only allow paths under approved roots
164
+ const ALLOWED_ROOTS = [os.homedir(), '/tmp', '/workspace'];
165
+
166
+ function isAllowed(p) {
167
+ const resolved = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
168
+ return ALLOWED_ROOTS.some(root => resolved === root || resolved.startsWith(root + '/'));
169
+ }
170
+
171
+ function resolvePath(p) {
172
+ return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
173
+ }
174
+
161
175
  // ── HTTP server ────────────────────────────────────────────────────────────
162
176
  const server = http.createServer((req, res) => {
163
177
  const url = new URL(req.url, `http://localhost`);
@@ -172,8 +186,8 @@ const server = http.createServer((req, res) => {
172
186
  // List directory or read file
173
187
  const p = url.searchParams.get('path');
174
188
  if (!p) { res.writeHead(400); res.end('missing path'); return; }
175
- // Resolve ~ and normalize
176
- const resolved = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
189
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
190
+ const resolved = resolvePath(p);
177
191
  try {
178
192
  const stat = fs.statSync(resolved);
179
193
  if (stat.isDirectory()) {
@@ -195,6 +209,67 @@ const server = http.createServer((req, res) => {
195
209
  res.writeHead(404); res.end(e.message);
196
210
  }
197
211
 
212
+ } else if (url.pathname === '/api/fs/stat') {
213
+ const p = url.searchParams.get('path');
214
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
215
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
216
+ const resolved = resolvePath(p);
217
+ try {
218
+ const stat = fs.statSync(resolved);
219
+ res.writeHead(200, { 'Content-Type': 'application/json' });
220
+ res.end(JSON.stringify({ exists: true, type: stat.isDirectory() ? 'dir' : 'file', size: stat.size }));
221
+ } catch {
222
+ res.writeHead(200, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify({ exists: false }));
224
+ }
225
+
226
+ } else if (url.pathname === '/api/fs/ls') {
227
+ const p = url.searchParams.get('path');
228
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
229
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
230
+ const resolved = resolvePath(p);
231
+ try {
232
+ const entries = fs.readdirSync(resolved, { withFileTypes: true }).map(e => ({
233
+ name: e.name,
234
+ type: e.isDirectory() ? 'dir' : 'file',
235
+ size: e.isFile() ? (() => { try { return fs.statSync(path.join(resolved, e.name)).size; } catch { return 0; } })() : null,
236
+ ext: path.extname(e.name).slice(1).toLowerCase(),
237
+ })).sort((a,b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
238
+ res.writeHead(200, { 'Content-Type': 'application/json' });
239
+ res.end(JSON.stringify({ entries }));
240
+ } catch (e) {
241
+ res.writeHead(404); res.end(e.message);
242
+ }
243
+
244
+ } else if (url.pathname === '/api/fs/cat') {
245
+ const p = url.searchParams.get('path');
246
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
247
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
248
+ const resolved = resolvePath(p);
249
+ try {
250
+ const stat = fs.statSync(resolved);
251
+ if (stat.size > 1048576) { res.writeHead(400); res.end('file too large (>1MB)'); return; }
252
+ const content = fs.readFileSync(resolved, 'utf8');
253
+ res.writeHead(200, { 'Content-Type': 'application/json' });
254
+ res.end(JSON.stringify({ content }));
255
+ } catch (e) {
256
+ res.writeHead(404); res.end(e.message);
257
+ }
258
+
259
+ } else if (url.pathname === '/api/fs/raw') {
260
+ const p = url.searchParams.get('path');
261
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
262
+ if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
263
+ const resolved = resolvePath(p);
264
+ try {
265
+ const ext = path.extname(resolved).slice(1).toLowerCase();
266
+ const mime = mimeFor(ext);
267
+ res.writeHead(200, { 'Content-Type': mime });
268
+ fs.createReadStream(resolved).pipe(res);
269
+ } catch (e) {
270
+ res.writeHead(404); res.end(e.message);
271
+ }
272
+
198
273
  } else if (url.pathname === '/api/job/output') {
199
274
  // Full output for a job
200
275
  const id = url.searchParams.get('id');