claude-opencode-viewer 2.6.37 → 2.6.39

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 (4) hide show
  1. package/index-pc.html +290 -48
  2. package/index.html +287 -84
  3. package/package.json +1 -1
  4. package/server.js +128 -86
package/index-pc.html CHANGED
@@ -761,6 +761,58 @@
761
761
  padding: 1px 6px;
762
762
  border-radius: 8px;
763
763
  }
764
+ /* 启动对话框 */
765
+ #startup-overlay {
766
+ display: none;
767
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
768
+ background: rgba(0, 0, 0, 0.75);
769
+ z-index: 99999;
770
+ align-items: center; justify-content: center;
771
+ }
772
+ #startup-overlay.visible { display: flex; }
773
+ #startup-card {
774
+ background: #1e1e2e; border-radius: 12px; padding: 28px;
775
+ max-width: 500px; width: 90%; color: #ccc;
776
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
777
+ }
778
+ #startup-card h3 {
779
+ margin: 0 0 16px 0; color: #e0e0e0; font-size: 16px; font-weight: 600;
780
+ }
781
+ .startup-session-info {
782
+ background: #252535; border-radius: 8px; padding: 14px; margin-bottom: 16px;
783
+ border-left: 3px solid #4a9eff;
784
+ }
785
+ .startup-session-info.claude-session { border-left-color: #4a9eff; }
786
+ .startup-session-mode {
787
+ font-size: 12px; font-weight: 600; margin-bottom: 6px; color: #aaa;
788
+ }
789
+ .startup-session-mode .mode-tag {
790
+ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px;
791
+ }
792
+ .startup-session-mode .mode-tag.opencode { background: #1a3a2a; color: #4ec9b0; }
793
+ .startup-session-mode .mode-tag.claude { background: #1a2a3a; color: #4a9eff; }
794
+ .startup-session-preview {
795
+ font-size: 13px; color: #ddd; margin-bottom: 6px;
796
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
797
+ }
798
+ .startup-session-meta {
799
+ font-size: 11px; color: #888;
800
+ }
801
+ .startup-buttons {
802
+ display: flex; gap: 10px; margin-top: 8px;
803
+ }
804
+ .startup-buttons button {
805
+ flex: 1; padding: 10px 16px; border: none; border-radius: 8px;
806
+ font-size: 14px; cursor: pointer; font-weight: 500; transition: background 0.2s;
807
+ }
808
+ .startup-btn-restore {
809
+ background: #4a9eff; color: #fff;
810
+ }
811
+ .startup-btn-restore:hover { background: #3a8eef; }
812
+ .startup-btn-new {
813
+ background: #333; color: #ccc;
814
+ }
815
+ .startup-btn-new:hover { background: #444; }
764
816
  </style>
765
817
  </head>
766
818
  <body>
@@ -955,6 +1007,7 @@
955
1007
 
956
1008
  <div id="copy-toast">已复制</div>
957
1009
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
1010
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
958
1011
  <script>
959
1012
  (function() {
960
1013
  var isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
@@ -964,6 +1017,7 @@
964
1017
  var currentMode = 'claude';
965
1018
  var isTransitioning = false;
966
1019
  var isBufferReplay = true; // 初始缓冲区回放中,不弹 toast
1020
+ var startupDialogShown = false;
967
1021
 
968
1022
  var term = new Terminal({
969
1023
  cursorBlink: !isMobile,
@@ -983,6 +1037,17 @@
983
1037
 
984
1038
  term.open(document.getElementById('terminal'));
985
1039
 
1040
+ // WebGL 渲染器:GPU 加速绘制
1041
+ if (window.WebglAddon) {
1042
+ try {
1043
+ var webglAddon = new WebglAddon.WebglAddon();
1044
+ webglAddon.onContextLoss(function() {
1045
+ webglAddon.dispose();
1046
+ });
1047
+ term.loadAddon(webglAddon);
1048
+ } catch(e) {}
1049
+ }
1050
+
986
1051
  // PC端复制:用 xterm.js selection API 获取纯文本,避免复制出乱码
987
1052
  document.getElementById('terminal').addEventListener('copy', function(e) {
988
1053
  var sel = term.getSelection();
@@ -1432,6 +1497,92 @@
1432
1497
  }, 50);
1433
1498
  });
1434
1499
 
1500
+ // === 启动对话框 ===
1501
+ function showStartupDialog() {
1502
+ fetch(basePath + '/api/last-sessions')
1503
+ .then(function(r) { return r.json(); })
1504
+ .then(function(data) {
1505
+ var oc = data.opencode;
1506
+ var cl = data.claude;
1507
+ // 无任何历史 → 直接新建 claude
1508
+ if (!oc && !cl) {
1509
+ sendInit('claude', null);
1510
+ return;
1511
+ }
1512
+ // 确定最近的会话
1513
+ var latest = null, latestMode = 'claude';
1514
+ if (oc && cl) {
1515
+ if (oc.mtime >= cl.mtime) { latest = oc; latestMode = 'opencode'; }
1516
+ else { latest = cl; latestMode = 'claude'; }
1517
+ } else if (oc) { latest = oc; latestMode = 'opencode'; }
1518
+ else { latest = cl; latestMode = 'claude'; }
1519
+
1520
+ // 渲染会话信息
1521
+ var infoDiv = document.getElementById('startup-session-info');
1522
+ var modeLabel = latestMode === 'claude' ? 'Claude Code' : 'OpenCode';
1523
+ var modeClass = latestMode === 'claude' ? 'claude' : 'opencode';
1524
+ var timeAgo = getTimeAgo(latest.mtime);
1525
+ var dir = latest.directory || '';
1526
+ if (dir.startsWith('/Users/')) dir = '~' + dir.substring(dir.indexOf('/', 1));
1527
+ var preview = latest.preview || '(无预览)';
1528
+ if (preview.length > 100) preview = preview.substring(0, 100) + '...';
1529
+
1530
+ infoDiv.innerHTML =
1531
+ '<div class="startup-session-info ' + (latestMode === 'claude' ? 'claude-session' : '') + '">' +
1532
+ '<div class="startup-session-mode"><span class="mode-tag ' + modeClass + '">' + modeLabel + '</span> · ' + timeAgo + '</div>' +
1533
+ '<div class="startup-session-preview">' + escapeHtml(preview) + '</div>' +
1534
+ (dir ? '<div class="startup-session-meta">' + escapeHtml(dir) + '</div>' : '') +
1535
+ '</div>';
1536
+
1537
+ // 恢复按钮样式
1538
+ var restoreBtn = document.getElementById('startup-btn-restore');
1539
+ restoreBtn.className = 'startup-btn-restore';
1540
+ restoreBtn.textContent = '恢复会话';
1541
+
1542
+ // 绑定事件
1543
+ restoreBtn.onclick = function() {
1544
+ hideStartupDialog();
1545
+ sendInit(latestMode, latest.id);
1546
+ };
1547
+ document.getElementById('startup-btn-new').onclick = function() {
1548
+ hideStartupDialog();
1549
+ sendInit('claude', null);
1550
+ };
1551
+
1552
+ // 显示对话框
1553
+ document.getElementById('startup-overlay').classList.add('visible');
1554
+ })
1555
+ .catch(function() {
1556
+ // API 失败,直接新建
1557
+ sendInit('claude', null);
1558
+ });
1559
+ }
1560
+
1561
+ function hideStartupDialog() {
1562
+ document.getElementById('startup-overlay').classList.remove('visible');
1563
+ }
1564
+
1565
+ function sendInit(mode, sessionId) {
1566
+ if (!ws || ws.readyState !== 1) return;
1567
+ currentMode = mode;
1568
+ modeSelect.value = mode;
1569
+ document.getElementById('mode-label').textContent = '';
1570
+ var msg = { type: 'init', mode: mode };
1571
+ if (sessionId) msg.sessionId = sessionId;
1572
+ ws.send(JSON.stringify(msg));
1573
+ }
1574
+
1575
+ function getTimeAgo(ts) {
1576
+ var diff = Date.now() - ts;
1577
+ var min = Math.floor(diff / 60000);
1578
+ if (min < 1) return '刚刚';
1579
+ if (min < 60) return min + ' 分钟前';
1580
+ var hr = Math.floor(min / 60);
1581
+ if (hr < 24) return hr + ' 小时前';
1582
+ var day = Math.floor(hr / 24);
1583
+ return day + ' 天前';
1584
+ }
1585
+
1435
1586
  function connect() {
1436
1587
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1437
1588
  ws = new WebSocket(proto + '//' + location.host + basePath + '/ws');
@@ -1441,11 +1592,13 @@
1441
1592
  resize();
1442
1593
  rebindTouchScroll();
1443
1594
  setTimeout(function() { isBufferReplay = false; }, 500);
1595
+ // 不在这里初始化,等 state 消息判断是否需要弹对话框
1444
1596
  };
1445
1597
 
1446
1598
  ws.onclose = function() {
1447
1599
  ws = null;
1448
- term.reset(); // 清除终端状态,避免 buffer 回放叠加导致状态混乱
1600
+ term.reset();
1601
+ term.write('\r\n \x1b[33m连接断开,正在重连...\x1b[0m\r\n');
1449
1602
  setTimeout(connect, 2000);
1450
1603
  };
1451
1604
 
@@ -1453,7 +1606,7 @@
1453
1606
  try {
1454
1607
  var msg = JSON.parse(e.data);
1455
1608
  if (msg.type === 'data') {
1456
- if (!isCreatingNewSession) {
1609
+ if (!isCreatingNewSession && !isTransitioning) {
1457
1610
  throttledWrite(msg.data);
1458
1611
  }
1459
1612
  }
@@ -1464,36 +1617,55 @@
1464
1617
  }
1465
1618
  }
1466
1619
  else if (msg.type === 'mode') {
1620
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1621
+ writeBuffer = '';
1622
+ term.reset();
1467
1623
  endTransition(msg.mode);
1468
- // 模式切换完成后,重新绑定触摸事件
1624
+ if (msg.buffer) {
1625
+ term.write(msg.buffer);
1626
+ }
1469
1627
  rebindTouchScroll();
1470
1628
  }
1471
1629
  else if (msg.type === 'switching') {
1472
- // 服务端开始切换,完全重置终端(清除残留输出和状态)
1473
- term.reset();
1630
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1474
1631
  writeBuffer = '';
1632
+ term.clear();
1475
1633
  }
1476
1634
  else if (msg.type === 'state') {
1477
1635
  if (msg.mode) {
1478
1636
  currentMode = msg.mode;
1479
1637
  modeSelect.value = msg.mode;
1480
- modeIndicator.textContent = msg.mode === 'claude' ? 'Claude' : 'OpenCode';
1638
+ document.getElementById('mode-label').textContent = '';
1639
+ }
1640
+ // 服务端无运行进程,显示启动对话框
1641
+ if (!msg.running && !startupDialogShown) {
1642
+ startupDialogShown = true;
1643
+ showStartupDialog();
1481
1644
  }
1482
1645
  }
1483
1646
  else if (msg.type === 'restored') {
1484
- // 会话恢复成功,重置终端清除残留ANSI状态
1647
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1648
+ writeBuffer = '';
1485
1649
  term.reset();
1650
+ if (msg.buffer) {
1651
+ term.write(msg.buffer);
1652
+ }
1486
1653
  }
1487
1654
  else if (msg.type === 'restore-error') {
1488
- // 恢复失败
1489
1655
  term.write('恢复失败: ' + msg.error + '\r\n');
1490
1656
  }
1491
1657
  else if (msg.type === 'started') {
1492
1658
  rebindTouchScroll();
1659
+ preloadData();
1493
1660
  }
1494
1661
  else if (msg.type === 'new-session-ok') {
1495
- isCreatingNewSession = false;
1662
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1663
+ writeBuffer = '';
1496
1664
  term.reset();
1665
+ if (msg.buffer) {
1666
+ term.write(msg.buffer);
1667
+ }
1668
+ isCreatingNewSession = false;
1497
1669
  }
1498
1670
  else if (msg.type === 'new-session-error') {
1499
1671
  isCreatingNewSession = false;
@@ -1519,11 +1691,14 @@
1519
1691
  });
1520
1692
  }
1521
1693
 
1522
- // 页面卸载前保存输入缓存
1694
+ // 页面卸载前保存输入缓存,并通知服务端退出
1523
1695
  window.addEventListener('beforeunload', function() {
1524
1696
  if (currentInputBuffer) {
1525
1697
  saveInputCache();
1526
1698
  }
1699
+ if (ws && ws.readyState === WebSocket.OPEN) {
1700
+ ws.send(JSON.stringify({ type: 'quit' }));
1701
+ }
1527
1702
  });
1528
1703
 
1529
1704
  // 页面可见性变化时保存缓存
@@ -2038,6 +2213,29 @@
2038
2213
  var diffChanges = [];
2039
2214
  var diffSelectedFile = null;
2040
2215
 
2216
+ // 预加载缓存
2217
+ var cachedGitStatus = null;
2218
+ var cachedDocs = null;
2219
+ var gitStatusLoading = false;
2220
+ var docsLoading = false;
2221
+
2222
+ function preloadData() {
2223
+ if (!gitStatusLoading) {
2224
+ gitStatusLoading = true;
2225
+ fetch(basePath + '/api/git-status')
2226
+ .then(function(r) { return r.json(); })
2227
+ .then(function(data) { cachedGitStatus = data; gitStatusLoading = false; })
2228
+ .catch(function() { gitStatusLoading = false; });
2229
+ }
2230
+ if (!docsLoading) {
2231
+ docsLoading = true;
2232
+ fetch(basePath + '/api/docs')
2233
+ .then(function(r) { return r.json(); })
2234
+ .then(function(data) { cachedDocs = data; docsLoading = false; })
2235
+ .catch(function() { docsLoading = false; });
2236
+ }
2237
+ }
2238
+
2041
2239
  var STATUS_COLORS = {
2042
2240
  'M': '#e2c08d', 'A': '#73c991', 'D': '#f14c4c',
2043
2241
  'R': '#73c991', 'C': '#73c991', 'U': '#e2c08d',
@@ -2049,27 +2247,42 @@
2049
2247
  var bar = document.getElementById('git-diff-bar');
2050
2248
  if (diffBarVisible) {
2051
2249
  bar.classList.add('visible');
2052
- loadGitStatus();
2250
+ loadGitStatus(false);
2053
2251
  } else {
2054
2252
  bar.classList.remove('visible');
2055
2253
  diffSelectedFile = null;
2056
2254
  }
2057
2255
  }
2058
2256
 
2059
- function loadGitStatus() {
2257
+ function loadGitStatus(forceRefresh) {
2060
2258
  var fileList = document.getElementById('git-diff-file-list');
2061
- fileList.innerHTML = '<div class="git-diff-loading">加载中...</div>';
2062
- document.getElementById('git-diff-count').textContent = '0';
2259
+
2260
+ if (cachedGitStatus && !forceRefresh) {
2261
+ diffChanges = cachedGitStatus.changes || [];
2262
+ document.getElementById('git-diff-count').textContent = diffChanges.length;
2263
+ renderDiffFileList();
2264
+ return;
2265
+ }
2266
+
2267
+ fileList.innerHTML = '<div class="git-diff-loading">' + (forceRefresh ? '正在刷新...' : '正在查询 git status...') + '</div>';
2268
+ document.getElementById('git-diff-count').textContent = '...';
2269
+
2270
+ if (gitStatusLoading && !forceRefresh) return;
2271
+ gitStatusLoading = true;
2272
+ if (forceRefresh) cachedGitStatus = null;
2063
2273
 
2064
2274
  fetch(basePath + '/api/git-status')
2065
2275
  .then(function(r) { return r.json(); })
2066
2276
  .then(function(data) {
2277
+ cachedGitStatus = data;
2278
+ gitStatusLoading = false;
2067
2279
  diffChanges = data.changes || [];
2068
2280
  document.getElementById('git-diff-count').textContent = diffChanges.length;
2069
- renderDiffFileList();
2281
+ if (diffBarVisible) renderDiffFileList();
2070
2282
  })
2071
2283
  .catch(function() {
2072
- fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2284
+ gitStatusLoading = false;
2285
+ if (diffBarVisible) fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2073
2286
  });
2074
2287
  }
2075
2288
 
@@ -2198,7 +2411,7 @@
2198
2411
  document.getElementById('close-diff').addEventListener('click', toggleDiffBar);
2199
2412
  document.getElementById('refresh-diff').addEventListener('click', function(e) {
2200
2413
  e.stopPropagation();
2201
- loadGitStatus();
2414
+ loadGitStatus(true);
2202
2415
  // 重置 diff 内容区
2203
2416
  diffSelectedFile = null;
2204
2417
  document.getElementById('git-diff-content-area').innerHTML =
@@ -2220,49 +2433,67 @@
2220
2433
  var bar = document.getElementById('docs-bar');
2221
2434
  if (docsBarVisible) {
2222
2435
  bar.classList.add('visible');
2223
- loadDocs();
2436
+ loadDocs(false);
2224
2437
  } else {
2225
2438
  bar.classList.remove('visible');
2226
2439
  docsSelectedFile = null;
2227
2440
  }
2228
2441
  }
2229
2442
 
2230
- function loadDocs() {
2443
+ function renderDocsList(data) {
2444
+ var fileList = document.getElementById('docs-file-list');
2445
+ var docs = data.docs || [];
2446
+ document.getElementById('docs-count').textContent = docs.length;
2447
+ if (!docs.length) {
2448
+ fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2449
+ return;
2450
+ }
2451
+ var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2452
+ docs.forEach(function(doc) {
2453
+ var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2454
+ var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2455
+ html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2456
+ html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2457
+ html += '<span class="docs-file-time">' + time + '</span>';
2458
+ html += '</div>';
2459
+ });
2460
+ fileList.innerHTML = html;
2461
+ fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2462
+ item.addEventListener('click', function() {
2463
+ var file = this.getAttribute('data-file');
2464
+ docsSelectedFile = file;
2465
+ fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2466
+ this.classList.add('active');
2467
+ loadDocContent(file);
2468
+ });
2469
+ });
2470
+ }
2471
+
2472
+ function loadDocs(forceRefresh) {
2231
2473
  var fileList = document.getElementById('docs-file-list');
2232
- fileList.innerHTML = '<div class="docs-loading">加载中...</div>';
2233
- document.getElementById('docs-count').textContent = '0';
2474
+
2475
+ if (cachedDocs && !forceRefresh) {
2476
+ renderDocsList(cachedDocs);
2477
+ return;
2478
+ }
2479
+
2480
+ fileList.innerHTML = '<div class="docs-loading">' + (forceRefresh ? '正在刷新...' : '正在查询文档...') + '</div>';
2481
+ document.getElementById('docs-count').textContent = '...';
2482
+
2483
+ if (docsLoading && !forceRefresh) return;
2484
+ docsLoading = true;
2485
+ if (forceRefresh) cachedDocs = null;
2234
2486
 
2235
2487
  fetch(basePath + '/api/docs')
2236
2488
  .then(function(r) { return r.json(); })
2237
2489
  .then(function(data) {
2238
- var docs = data.docs || [];
2239
- document.getElementById('docs-count').textContent = docs.length;
2240
- if (!docs.length) {
2241
- fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2242
- return;
2243
- }
2244
- var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2245
- docs.forEach(function(doc) {
2246
- var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2247
- var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2248
- html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2249
- html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2250
- html += '<span class="docs-file-time">' + time + '</span>';
2251
- html += '</div>';
2252
- });
2253
- fileList.innerHTML = html;
2254
- fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2255
- item.addEventListener('click', function() {
2256
- var file = this.getAttribute('data-file');
2257
- docsSelectedFile = file;
2258
- fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2259
- this.classList.add('active');
2260
- loadDocContent(file);
2261
- });
2262
- });
2490
+ cachedDocs = data;
2491
+ docsLoading = false;
2492
+ if (docsBarVisible) renderDocsList(data);
2263
2493
  })
2264
2494
  .catch(function() {
2265
- fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2495
+ docsLoading = false;
2496
+ if (docsBarVisible) fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2266
2497
  });
2267
2498
  }
2268
2499
 
@@ -2287,7 +2518,7 @@
2287
2518
  document.getElementById('close-docs').addEventListener('click', toggleDocsBar);
2288
2519
  document.getElementById('refresh-docs').addEventListener('click', function(e) {
2289
2520
  e.stopPropagation();
2290
- loadDocs();
2521
+ loadDocs(true);
2291
2522
  docsSelectedFile = null;
2292
2523
  document.getElementById('docs-content-area').innerHTML =
2293
2524
  '<div class="docs-placeholder">' +
@@ -2301,5 +2532,16 @@
2301
2532
  setTimeout(resize, 100);
2302
2533
  })();
2303
2534
  </script>
2535
+ <!-- 启动对话框 -->
2536
+ <div id="startup-overlay">
2537
+ <div id="startup-card">
2538
+ <h3>选择会话</h3>
2539
+ <div id="startup-session-info"></div>
2540
+ <div class="startup-buttons">
2541
+ <button class="startup-btn-restore" id="startup-btn-restore">恢复会话</button>
2542
+ <button class="startup-btn-new" id="startup-btn-new">新建会话</button>
2543
+ </div>
2544
+ </div>
2545
+ </div>
2304
2546
  </body>
2305
2547
  </html>