claude-opencode-viewer 2.6.48 → 2.6.50

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/index-pc.html CHANGED
@@ -740,6 +740,39 @@
740
740
  line-height: 1.6;
741
741
  color: #d4d4d4;
742
742
  }
743
+ .docs-md-content {
744
+ font-size: 13px; line-height: 1.7; color: #d4d4d4; word-break: break-word;
745
+ }
746
+ .docs-md-content h1, .docs-md-content h2, .docs-md-content h3 {
747
+ margin: 16px 0 8px; color: #e0e0e0; border-bottom: 1px solid #333; padding-bottom: 4px;
748
+ }
749
+ .docs-md-content h1 { font-size: 1.4em; }
750
+ .docs-md-content h2 { font-size: 1.2em; }
751
+ .docs-md-content h3 { font-size: 1.05em; }
752
+ .docs-md-content p { margin: 8px 0; }
753
+ .docs-md-content pre {
754
+ background: #1a1a1a; border: 1px solid #333; border-radius: 4px;
755
+ padding: 10px; overflow-x: auto; margin: 8px 0; white-space: pre-wrap; word-break: break-word;
756
+ }
757
+ .docs-md-content code {
758
+ background: #1a1a1a; padding: 1px 4px; border-radius: 3px; font-size: 12px;
759
+ font-family: Menlo, Monaco, monospace;
760
+ }
761
+ .docs-md-content pre code { background: none; padding: 0; }
762
+ .docs-md-content ul, .docs-md-content ol { margin: 8px 0; padding-left: 20px; }
763
+ .docs-md-content li { margin: 4px 0; }
764
+ .docs-md-content blockquote {
765
+ border-left: 3px solid #444; margin: 8px 0; padding: 4px 12px; color: #999;
766
+ }
767
+ .docs-md-content table { border-collapse: collapse; margin: 8px 0; width: 100%; }
768
+ .docs-md-content th, .docs-md-content td {
769
+ border: 1px solid #333; padding: 6px 10px; text-align: left;
770
+ }
771
+ .docs-md-content th { background: #1a1a1a; }
772
+ .docs-md-content a { color: #58a6ff; text-decoration: none; }
773
+ .docs-md-content a:hover { text-decoration: underline; }
774
+ .docs-md-content img { max-width: 100%; }
775
+ .docs-md-content hr { border: none; border-top: 1px solid #333; margin: 12px 0; }
743
776
  .docs-placeholder {
744
777
  flex: 1;
745
778
  display: flex;
@@ -805,6 +838,13 @@
805
838
  color: #ffa198;
806
839
  }
807
840
 
841
+ .diff-sign {
842
+ display: inline-block;
843
+ width: 12px;
844
+ font-weight: bold;
845
+ user-select: none;
846
+ }
847
+
808
848
  .diff-line-hunk {
809
849
  background: rgba(56, 139, 253, 0.1);
810
850
  }
@@ -1071,6 +1111,8 @@
1071
1111
  <div id="copy-toast">已复制</div>
1072
1112
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
1073
1113
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
1114
+ <script src="https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js"></script>
1115
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
1074
1116
  <script>
1075
1117
  (function() {
1076
1118
  var isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
@@ -1724,6 +1766,11 @@
1724
1766
  term.clear();
1725
1767
  }
1726
1768
  else if (msg.type === 'state') {
1769
+ // 重连时清掉旧终端内容,防止重复叠加
1770
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1771
+ writeBuffer = '';
1772
+ term.reset();
1773
+ term.clear();
1727
1774
  if (msg.mode) {
1728
1775
  currentMode = msg.mode;
1729
1776
  modeSelect.value = msg.mode;
@@ -2432,38 +2479,29 @@
2432
2479
 
2433
2480
  function loadDiffContent(file) {
2434
2481
  var area = document.getElementById('git-diff-content-area');
2435
- area.innerHTML = '<div class="git-diff-loading">加载 diff...</div>';
2436
-
2437
- fetch(basePath + '/api/git-diff?files=' + encodeURIComponent(file))
2438
- .then(function(r) { return r.json(); })
2439
- .then(function(data) {
2440
- if (!data.diffs || !data.diffs[0]) {
2441
- area.innerHTML = '<div class="git-diff-error">无 diff 数据</div>';
2442
- return;
2443
- }
2444
- var d = data.diffs[0];
2445
- var html = '<div class="git-diff-content-header">';
2446
- html += '<span class="git-diff-content-path">' + escapeHtml(d.file) + '</span>';
2447
- html += '<span class="git-diff-badge">' + (d.is_new ? 'NEW' : d.is_deleted ? 'DEL' : 'DIFF') + '</span>';
2448
- html += '</div>';
2449
- html += '<div class="git-diff-content-scroll">';
2450
-
2451
- if (d.is_binary) {
2452
- html += '<div class="git-diff-loading" style="font-style:italic;">二进制文件</div>';
2453
- } else if (d.is_large) {
2454
- html += '<div class="git-diff-loading" style="color:#e2c08d;">文件过大: ' + (d.size / (1024 * 1024)).toFixed(2) + ' MB</div>';
2455
- } else if (d.unified_diff) {
2456
- html += renderUnifiedDiff(d.unified_diff);
2457
- } else {
2458
- html += '<div class="git-diff-loading" style="color:#666;">无变更内容</div>';
2459
- }
2482
+ var d = diffChanges.find(function(c) { return c.file === file; });
2483
+ if (!d) {
2484
+ area.innerHTML = '<div class="git-diff-error">无 diff 数据</div>';
2485
+ return;
2486
+ }
2487
+ var html = '<div class="git-diff-content-header">';
2488
+ html += '<span class="git-diff-content-path">' + escapeHtml(d.file) + '</span>';
2489
+ html += '<span class="git-diff-badge">' + (d.is_new ? 'NEW' : d.is_deleted ? 'DEL' : 'DIFF') + '</span>';
2490
+ html += '</div>';
2491
+ html += '<div class="git-diff-content-scroll">';
2492
+
2493
+ if (d.is_binary) {
2494
+ html += '<div class="git-diff-loading" style="font-style:italic;">二进制文件</div>';
2495
+ } else if (d.is_large) {
2496
+ html += '<div class="git-diff-loading" style="color:#e2c08d;">文件过大: ' + (d.size / (1024 * 1024)).toFixed(2) + ' MB</div>';
2497
+ } else if (d.unified_diff) {
2498
+ html += renderUnifiedDiff(d.unified_diff);
2499
+ } else {
2500
+ html += '<div class="git-diff-loading" style="color:#666;">无变更内容</div>';
2501
+ }
2460
2502
 
2461
- html += '</div>';
2462
- area.innerHTML = html;
2463
- })
2464
- .catch(function(err) {
2465
- area.innerHTML = '<div class="git-diff-error">加载失败: ' + escapeHtml(err.message) + '</div>';
2466
- });
2503
+ html += '</div>';
2504
+ area.innerHTML = html;
2467
2505
  }
2468
2506
 
2469
2507
  function renderUnifiedDiff(diffText) {
@@ -2496,13 +2534,13 @@
2496
2534
  html += '<tr class="diff-line diff-line-add">';
2497
2535
  html += '<td class="diff-line-num"></td>';
2498
2536
  html += '<td class="diff-line-num">' + newLine + '</td>';
2499
- html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2537
+ html += '<td class="diff-line-content"><span class="diff-sign">+</span>' + escapeHtml(line.substring(1)) + '</td></tr>';
2500
2538
  newLine++;
2501
2539
  } else if (line.startsWith('-')) {
2502
2540
  html += '<tr class="diff-line diff-line-del">';
2503
2541
  html += '<td class="diff-line-num">' + oldLine + '</td>';
2504
2542
  html += '<td class="diff-line-num"></td>';
2505
- html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2543
+ html += '<td class="diff-line-content"><span class="diff-sign">-</span>' + escapeHtml(line.substring(1)) + '</td></tr>';
2506
2544
  oldLine++;
2507
2545
  } else if (line.startsWith(' ') || (line === '' && i < lines.length - 1)) {
2508
2546
  html += '<tr class="diff-line">';
@@ -2608,6 +2646,15 @@
2608
2646
  });
2609
2647
  }
2610
2648
 
2649
+ function formatDocContent(text) {
2650
+ if (!text) return '';
2651
+ try {
2652
+ return DOMPurify.sanitize(marked.parse(text, { breaks: true }));
2653
+ } catch (e) {
2654
+ return '<pre>' + escapeHtml(text) + '</pre>';
2655
+ }
2656
+ }
2657
+
2611
2658
  function loadDocContent(file) {
2612
2659
  var area = document.getElementById('docs-content-area');
2613
2660
  area.innerHTML = '<div class="docs-loading">加载中...</div>';
@@ -2618,7 +2665,7 @@
2618
2665
  area.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">' + escapeHtml(data.error) + '</div>';
2619
2666
  return;
2620
2667
  }
2621
- area.innerHTML = '<pre>' + escapeHtml(data.content) + '</pre>';
2668
+ area.innerHTML = '<div class="docs-md-content">' + formatDocContent(data.content) + '</div>';
2622
2669
  })
2623
2670
  .catch(function(err) {
2624
2671
  area.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
package/index.html CHANGED
@@ -11,24 +11,62 @@
11
11
  50% { content: '..'; }
12
12
  75% { content: '...'; }
13
13
  }
14
- #loading-overlay {
15
- position: fixed;
14
+ #init-overlay {
15
+ display: none;
16
+ position: absolute;
16
17
  top: 0; left: 0; right: 0; bottom: 0;
17
18
  background: #0a0a0a;
18
19
  color: #ccc;
19
- display: flex;
20
20
  align-items: center;
21
21
  justify-content: center;
22
22
  font-size: 16px;
23
23
  font-family: -apple-system, BlinkMacSystemFont, sans-serif;
24
24
  letter-spacing: 1px;
25
- z-index: 99999;
25
+ z-index: 100;
26
+ }
27
+ #init-overlay::after {
28
+ content: '';
29
+ animation: loading-dots 1.2s steps(4, end) infinite;
30
+ }
31
+ #init-overlay.visible { display: flex; }
32
+ #reconnect-overlay {
33
+ display: none;
34
+ position: absolute;
35
+ top: 0; left: 0; right: 0; bottom: 0;
36
+ background: rgba(0,0,0,0.85);
37
+ color: #ccc;
38
+ align-items: center;
39
+ justify-content: center;
40
+ font-size: 15px;
41
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
42
+ letter-spacing: 1px;
43
+ z-index: 100;
44
+ }
45
+ #reconnect-overlay.visible { display: flex; }
46
+ #reconnect-overlay::after {
47
+ content: '';
48
+ animation: loading-dots 1.2s steps(4, end) infinite;
49
+ }
50
+ #restore-overlay {
51
+ position: absolute;
52
+ top: 0; left: 0; right: 0; bottom: 0;
53
+ background: rgba(0,0,0,0.85);
54
+ color: #ccc;
55
+ display: none;
56
+ align-items: center;
57
+ justify-content: center;
58
+ font-size: 15px;
59
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
60
+ letter-spacing: 1px;
61
+ z-index: 100;
62
+ flex-direction: column;
63
+ gap: 12px;
26
64
  }
27
- #loading-overlay::after {
65
+ #restore-overlay.visible { display: flex; }
66
+ #restore-overlay::after {
28
67
  content: '';
29
68
  animation: loading-dots 1.2s steps(4, end) infinite;
30
69
  }
31
- #loading-overlay.hidden { display: none !important; }
32
70
  </style>
33
71
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.min.css" media="print" onload="this.media='all'">
34
72
  <style>
@@ -393,6 +431,8 @@
393
431
  padding: 4px 8px;
394
432
  touch-action: none;
395
433
  overscroll-behavior: contain;
434
+ background: #000;
435
+ background: #000;
396
436
  }
397
437
 
398
438
  #terminal.transitioning .xterm { visibility: hidden; }
@@ -809,6 +849,39 @@
809
849
  line-height: 1.6;
810
850
  color: #d4d4d4;
811
851
  }
852
+ .docs-md-content {
853
+ font-size: 13px; line-height: 1.7; color: #d4d4d4; word-break: break-word;
854
+ }
855
+ .docs-md-content h1, .docs-md-content h2, .docs-md-content h3 {
856
+ margin: 16px 0 8px; color: #e0e0e0; border-bottom: 1px solid #333; padding-bottom: 4px;
857
+ }
858
+ .docs-md-content h1 { font-size: 1.4em; }
859
+ .docs-md-content h2 { font-size: 1.2em; }
860
+ .docs-md-content h3 { font-size: 1.05em; }
861
+ .docs-md-content p { margin: 8px 0; }
862
+ .docs-md-content pre {
863
+ background: #1a1a1a; border: 1px solid #333; border-radius: 4px;
864
+ padding: 10px; overflow-x: auto; margin: 8px 0; white-space: pre-wrap; word-break: break-word;
865
+ }
866
+ .docs-md-content code {
867
+ background: #1a1a1a; padding: 1px 4px; border-radius: 3px; font-size: 12px;
868
+ font-family: Menlo, Monaco, monospace;
869
+ }
870
+ .docs-md-content pre code { background: none; padding: 0; }
871
+ .docs-md-content ul, .docs-md-content ol { margin: 8px 0; padding-left: 20px; }
872
+ .docs-md-content li { margin: 4px 0; }
873
+ .docs-md-content blockquote {
874
+ border-left: 3px solid #444; margin: 8px 0; padding: 4px 12px; color: #999;
875
+ }
876
+ .docs-md-content table { border-collapse: collapse; margin: 8px 0; width: 100%; }
877
+ .docs-md-content th, .docs-md-content td {
878
+ border: 1px solid #333; padding: 6px 10px; text-align: left;
879
+ }
880
+ .docs-md-content th { background: #1a1a1a; }
881
+ .docs-md-content a { color: #58a6ff; text-decoration: none; }
882
+ .docs-md-content a:hover { text-decoration: underline; }
883
+ .docs-md-content img { max-width: 100%; }
884
+ .docs-md-content hr { border: none; border-top: 1px solid #333; margin: 12px 0; }
812
885
  .docs-placeholder {
813
886
  flex: 1;
814
887
  display: flex;
@@ -874,6 +947,13 @@
874
947
  color: #ffa198;
875
948
  }
876
949
 
950
+ .diff-sign {
951
+ display: inline-block;
952
+ width: 12px;
953
+ font-weight: bold;
954
+ user-select: none;
955
+ }
956
+
877
957
  .diff-line-hunk {
878
958
  background: rgba(56, 139, 253, 0.1);
879
959
  }
@@ -894,7 +974,6 @@
894
974
  </head>
895
975
  <body>
896
976
  <!-- 参考 cc-viewer 的 App.jsx 行 1315-1607: 完整的移动端布局结构 -->
897
- <div id="loading-overlay">正在初始化</div>
898
977
  <div id="layout">
899
978
  <div id="header">
900
979
  <div style="display: flex; gap: 4px; align-items: center; overflow-x: auto; flex: 1; min-width: 0;">
@@ -1080,6 +1159,9 @@
1080
1159
  <div id="terminal-container">
1081
1160
  <div id="terminal" style="position:relative;">
1082
1161
  <div id="switch-overlay">正在切换</div>
1162
+ <div id="restore-overlay">正在恢复会话</div>
1163
+ <div id="init-overlay">正在初始化</div>
1164
+ <div id="reconnect-overlay">连接断开,正在重连</div>
1083
1165
  </div>
1084
1166
  <div id="virtual-keybar">
1085
1167
  <div class="virtual-key" data-key="up">↑</div>
@@ -1269,24 +1351,31 @@
1269
1351
  // iOS 虚拟键盘弹出时,Safari 会滚动整个文档将页面上推,
1270
1352
  // 导致导航栏消失在视口之外。通过 visualViewport 的 resize + scroll
1271
1353
  // 事件同步可见区域的高度和偏移,用 fixed 定位将布局锁定在可见区域内。
1272
- if (isIOS && window.visualViewport) {
1354
+ var mobileKbOpen = false;
1355
+ if (isMobile && window.visualViewport) {
1273
1356
  var layoutEl = document.getElementById('layout');
1357
+ var initVVH = window.visualViewport.height;
1274
1358
  var onVVChange = function() {
1275
1359
  if (!layoutEl) return;
1276
1360
  var vv = window.visualViewport;
1277
- layoutEl.style.position = 'fixed';
1278
- layoutEl.style.top = vv.offsetTop + 'px';
1279
- layoutEl.style.height = vv.height + 'px';
1280
- layoutEl.style.width = '100%';
1281
- layoutEl.style.left = '0';
1282
- setTimeout(mobileFixedResize, 50);
1361
+ if ((initVVH - vv.height) > 50) {
1362
+ mobileKbOpen = true;
1363
+ layoutEl.style.height = vv.height + 'px';
1364
+ layoutEl.style.top = vv.offsetTop + 'px';
1365
+ layoutEl.style.bottom = 'auto';
1366
+ } else {
1367
+ mobileKbOpen = false;
1368
+ layoutEl.style.height = '';
1369
+ layoutEl.style.top = '0';
1370
+ layoutEl.style.bottom = '0';
1371
+ }
1283
1372
  };
1284
1373
  window.visualViewport.addEventListener('resize', onVVChange);
1285
1374
  window.visualViewport.addEventListener('scroll', onVVChange);
1286
- onVVChange();
1287
1375
  }
1288
1376
 
1289
- // 移动端固定尺寸计算:基于 #terminal 元素实际高度,确保终端与按钮栏齐平
1377
+ // 移动端固定尺寸计算:用屏幕全高减去固定区域,保证全屏时也填满
1378
+ var lastMobileCols = 0, lastMobileRows = 0;
1290
1379
  function mobileFixedResize() {
1291
1380
  if (!term) return;
1292
1381
  var cellDims = getCellDims();
@@ -1310,9 +1399,12 @@
1310
1399
  var newCellDims = getCellDims();
1311
1400
  var lineHeight = (newCellDims && newCellDims.height) || cellDims.height;
1312
1401
  var rows = Math.max(5, Math.min(Math.floor(availH / lineHeight), 100));
1402
+ // 尺寸未变时只 resize xterm 本地,不通知服务端(避免重复 SIGWINCH)
1403
+ var sizeChanged = !(MOBILE_COLS === lastMobileCols && rows === lastMobileRows);
1404
+ lastMobileCols = MOBILE_COLS;
1405
+ lastMobileRows = rows;
1313
1406
  term.resize(MOBILE_COLS, rows);
1314
- term.scrollToBottom();
1315
- if (ws && ws.readyState === 1 && !isTransitioning) {
1407
+ if (sizeChanged && ws && ws.readyState === 1) {
1316
1408
  ws.send(JSON.stringify({ type: 'resize', cols: MOBILE_COLS, rows: rows, mobile: true }));
1317
1409
  }
1318
1410
  });
@@ -1638,57 +1730,67 @@
1638
1730
  }, 50);
1639
1731
  });
1640
1732
 
1733
+ var restoreOverlay = document.getElementById('restore-overlay');
1734
+ function showRestoreOverlay() {
1735
+ if (restoreOverlay) restoreOverlay.classList.add('visible');
1736
+ }
1737
+ function hideRestoreOverlay() {
1738
+ if (restoreOverlay) restoreOverlay.classList.remove('visible');
1739
+ }
1740
+
1741
+ var waitingInitData = false;
1742
+ var initDataTimer = null;
1743
+
1744
+ function showInitOverlay(text) {
1745
+ var ov = document.getElementById('init-overlay');
1746
+ if (ov) {
1747
+ ov.textContent = text || '正在初始化';
1748
+ ov.classList.add('visible');
1749
+ }
1750
+ }
1751
+ function hideInitOverlay() {
1752
+ var ov = document.getElementById('init-overlay');
1753
+ if (ov) ov.classList.remove('visible');
1754
+ }
1755
+
1641
1756
  function connect() {
1757
+ if (ws && ws.readyState <= 1) return;
1642
1758
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1643
1759
  ws = new WebSocket(proto + '//' + location.host + basePath + '/ws');
1644
1760
 
1645
1761
  ws.onopen = function() {
1646
- setLoadingText('正在连接服务');
1762
+ document.getElementById('reconnect-overlay').classList.remove('visible');
1763
+ lastMobileCols = 0;
1764
+ lastMobileRows = 0;
1647
1765
  resize();
1648
1766
  rebindTouchScroll();
1649
1767
  };
1650
1768
 
1651
1769
  ws.onclose = function() {
1652
1770
  ws = null;
1653
- term.reset();
1654
- term.write('\r\n \x1b[33m连接断开,正在重连...\x1b[0m\r\n');
1771
+ document.getElementById('reconnect-overlay').classList.add('visible');
1655
1772
  setTimeout(connect, 2000);
1656
1773
  };
1657
1774
 
1658
- var loadingOverlay = document.getElementById('loading-overlay');
1659
- var loadingShowTime = Date.now();
1660
- var loadingMinMs = 600;
1661
- var loadingHideTimer = null;
1662
- function setLoadingText(text) {
1663
- if (loadingOverlay && !loadingOverlay.classList.contains('hidden')) {
1664
- loadingOverlay.textContent = text;
1665
- }
1666
- }
1667
- function hideLoading() {
1668
- if (!loadingOverlay || loadingOverlay.classList.contains('hidden')) return;
1669
- if (loadingHideTimer) return; // 已在等待中
1670
- var elapsed = Date.now() - loadingShowTime;
1671
- if (elapsed >= loadingMinMs) {
1672
- loadingOverlay.classList.add('hidden');
1673
- } else {
1674
- loadingHideTimer = setTimeout(function() {
1675
- loadingOverlay.classList.add('hidden');
1676
- }, loadingMinMs - elapsed);
1677
- }
1678
- }
1679
-
1680
1775
  ws.onmessage = function(e) {
1681
1776
  try {
1682
1777
  var msg = JSON.parse(e.data);
1683
1778
  if (msg.type === 'data') {
1684
- hideLoading();
1685
1779
  if (isTransitioning) {
1686
1780
  term.write(msg.data);
1687
1781
  clearTimeout(transitionEndTimer);
1688
1782
  transitionEndTimer = setTimeout(function() {
1689
1783
  terminalEl.classList.remove('transitioning');
1690
1784
  isTransitioning = false;
1691
- }, 2000);
1785
+ }, 500);
1786
+ } else if (waitingInitData) {
1787
+ throttledWrite(msg.data);
1788
+ clearTimeout(initDataTimer);
1789
+ initDataTimer = setTimeout(function() {
1790
+ hideInitOverlay();
1791
+ hideRestoreOverlay();
1792
+ waitingInitData = false;
1793
+ }, 500);
1692
1794
  } else if (!isCreatingNewSession) {
1693
1795
  throttledWrite(msg.data);
1694
1796
  }
@@ -1700,31 +1802,25 @@
1700
1802
  }
1701
1803
  }
1702
1804
  else if (msg.type === 'state') {
1703
- // 重连时清掉旧终端内容(如"连接断开"提示)
1704
1805
  if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1705
1806
  writeBuffer = '';
1706
1807
  term.reset();
1707
1808
  term.clear();
1708
- // 同步模式 UI
1709
1809
  if (msg.mode) {
1710
1810
  currentMode = msg.mode;
1711
1811
  modeSelect.value = msg.mode;
1712
1812
  }
1713
1813
  if (msg.running) {
1714
- setLoadingText('正在连接');
1715
- hideLoading();
1716
1814
  preloadData();
1717
1815
  }
1718
- // 服务端还没启动进程时,查最近会话并自动恢复
1719
1816
  if (!msg.running && !mobileInitSent) {
1720
1817
  mobileInitSent = true;
1721
- setLoadingText('正在查询会话');
1818
+ showInitOverlay('正在查询会话');
1722
1819
  fetch('/api/last-sessions')
1723
1820
  .then(function(r) { return r.json(); })
1724
1821
  .then(function(data) {
1725
1822
  var oc = data.opencode;
1726
1823
  var cl = data.claude;
1727
- // 取 mtime 最新的那个
1728
1824
  var useOc = oc && (!cl || oc.mtime > cl.mtime);
1729
1825
  var mode = useOc ? 'opencode' : 'claude';
1730
1826
  var sessionId = useOc ? (oc && oc.id) : (cl && cl.id);
@@ -1733,7 +1829,7 @@
1733
1829
  claudeProject = cl.project;
1734
1830
  }
1735
1831
  currentMode = mode;
1736
- setLoadingText('正在启动 ' + (mode === 'claude' ? 'Claude' : 'OpenCode'));
1832
+ showInitOverlay('正在启动 ' + (mode === 'claude' ? 'Claude' : 'OpenCode'));
1737
1833
  ws.send(JSON.stringify({ type: 'init', mode: mode, sessionId: sessionId || null }));
1738
1834
  })
1739
1835
  .catch(function() {
@@ -1758,7 +1854,7 @@
1758
1854
  term.clear();
1759
1855
  }
1760
1856
  else if (msg.type === 'restored') {
1761
- // 会话恢复成功,清除所有残留
1857
+ waitingInitData = true;
1762
1858
  if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1763
1859
  writeBuffer = '';
1764
1860
  term.reset();
@@ -1769,14 +1865,17 @@
1769
1865
  }
1770
1866
  else if (msg.type === 'restore-error') {
1771
1867
  isTransitioning = false;
1868
+ hideInitOverlay();
1869
+ hideRestoreOverlay();
1772
1870
  term.write('恢复失败: ' + msg.error + '\r\n');
1773
1871
  }
1774
1872
  else if (msg.type === 'started') {
1775
- hideLoading();
1873
+ waitingInitData = true;
1776
1874
  rebindTouchScroll();
1777
1875
  preloadData();
1778
1876
  }
1779
1877
  else if (msg.type === 'new-session-ok') {
1878
+ waitingInitData = true;
1780
1879
  if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1781
1880
  writeBuffer = '';
1782
1881
  term.reset();
@@ -1784,11 +1883,9 @@
1784
1883
  term.write(msg.buffer);
1785
1884
  }
1786
1885
  isCreatingNewSession = false;
1787
- isTransitioning = false;
1788
1886
  }
1789
1887
  else if (msg.type === 'new-session-error') {
1790
1888
  isCreatingNewSession = false;
1791
- isTransitioning = false;
1792
1889
  term.write('新会话启动失败: ' + msg.error + '\r\n');
1793
1890
  }
1794
1891
  } catch(err) {}
@@ -1804,24 +1901,56 @@
1804
1901
  }, 2000);
1805
1902
  }
1806
1903
 
1807
- window.addEventListener('resize', resize);
1808
1904
  if (isMobile) {
1905
+ // 移动端:不监听 window resize(键盘弹出/收起会触发导致重复内容)
1906
+ // 仅监听屏幕旋转
1809
1907
  window.addEventListener('orientationchange', function() {
1810
1908
  setTimeout(resize, 200);
1811
1909
  });
1910
+ } else {
1911
+ window.addEventListener('resize', resize);
1912
+ }
1913
+
1914
+ // 监听终端容器大小变化(全屏切换、地址栏收起等),键盘弹出时跳过
1915
+ // 只做本地 term.resize 填充空间,不发服务端,避免 SIGWINCH 导致重复内容
1916
+ if (isMobile && typeof ResizeObserver !== 'undefined') {
1917
+ var termResizeTimer = null;
1918
+ new ResizeObserver(function() {
1919
+ if (mobileKbOpen || !term) return;
1920
+ if (termResizeTimer) clearTimeout(termResizeTimer);
1921
+ termResizeTimer = setTimeout(function() {
1922
+ var cellDims = getCellDims();
1923
+ if (!cellDims || !cellDims.height) return;
1924
+ var termEl = document.getElementById('terminal');
1925
+ if (!termEl) return;
1926
+ var rows = Math.max(5, Math.min(Math.floor(termEl.clientHeight / cellDims.height), 100));
1927
+ if (rows !== lastMobileRows) {
1928
+ lastMobileRows = rows;
1929
+ lastMobileCols = MOBILE_COLS;
1930
+ term.resize(MOBILE_COLS, rows);
1931
+ }
1932
+ }, 150);
1933
+ }).observe(document.getElementById('terminal'));
1812
1934
  }
1813
1935
 
1814
- // 页面卸载前保存输入缓存
1936
+ // 页面卸载前保存输入缓存,并通知服务端退出
1815
1937
  window.addEventListener('beforeunload', function() {
1816
1938
  if (currentInputBuffer) {
1817
1939
  saveInputCache();
1818
1940
  }
1941
+ if (ws && ws.readyState === WebSocket.OPEN) {
1942
+ ws.send(JSON.stringify({ type: 'quit' }));
1943
+ }
1819
1944
  });
1820
1945
 
1821
- // 页面可见性变化时保存缓存
1946
+ // 页面可见性变化时保存缓存 + 尝试重连
1822
1947
  document.addEventListener('visibilitychange', function() {
1823
- if (document.hidden && currentInputBuffer) {
1824
- saveInputCache();
1948
+ if (document.hidden) {
1949
+ if (currentInputBuffer) saveInputCache();
1950
+ } else {
1951
+ if (!ws || ws.readyState > 1) {
1952
+ connect();
1953
+ }
1825
1954
  }
1826
1955
  });
1827
1956
 
@@ -2057,27 +2186,23 @@
2057
2186
 
2058
2187
 
2059
2188
  function loadSession(session) {
2060
- console.log('[restore] 直接恢复会话:', session.title);
2061
2189
  currentSessionData = session;
2062
-
2063
- // 关闭历史栏
2064
2190
  toggleHistoryBar();
2065
-
2066
- // 阻止旧数据写入
2191
+ showRestoreOverlay();
2067
2192
  isTransitioning = true;
2068
2193
  if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
2069
2194
  writeBuffer = '';
2070
2195
  term.reset();
2071
-
2072
2196
  if (ws && ws.readyState === 1) {
2073
2197
  if (currentMode !== 'opencode') {
2074
2198
  isTransitioning = false;
2199
+ hideRestoreOverlay();
2075
2200
  term.write('错误: 请先切换到 OpenCode 模式\r\n');
2076
2201
  return;
2077
2202
  }
2078
- // 静默恢复
2079
2203
  ws.send(JSON.stringify({ type: 'restore', sessionId: session.id }));
2080
2204
  } else {
2205
+ hideRestoreOverlay();
2081
2206
  term.write('错误: WebSocket 未连接\r\n');
2082
2207
  }
2083
2208
  }
@@ -2258,10 +2383,10 @@
2258
2383
  }
2259
2384
 
2260
2385
  function restoreClaudeSession(sessionId, project) {
2261
- console.log('[restore] 恢复 Claude 会话:', sessionId);
2262
2386
  claudeSessionId = sessionId;
2263
2387
  if (project) claudeProject = project;
2264
2388
  toggleHistoryBar();
2389
+ showRestoreOverlay();
2265
2390
  isTransitioning = true;
2266
2391
  if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
2267
2392
  writeBuffer = '';
@@ -2269,11 +2394,13 @@
2269
2394
  if (ws && ws.readyState === 1) {
2270
2395
  if (currentMode !== 'claude') {
2271
2396
  isTransitioning = false;
2397
+ hideRestoreOverlay();
2272
2398
  term.write('错误: 请先切换到 Claude 模式\r\n');
2273
2399
  return;
2274
2400
  }
2275
2401
  ws.send(JSON.stringify({ type: 'restore', sessionId: sessionId }));
2276
2402
  } else {
2403
+ hideRestoreOverlay();
2277
2404
  term.write('错误: WebSocket 未连接\r\n');
2278
2405
  }
2279
2406
  }
@@ -2633,38 +2760,29 @@
2633
2760
 
2634
2761
  function loadDiffContent(file) {
2635
2762
  var area = document.getElementById('git-diff-content-area');
2636
- area.innerHTML = '<div class="git-diff-loading">加载 diff...</div>';
2637
-
2638
- fetch(basePath + '/api/git-diff?files=' + encodeURIComponent(file))
2639
- .then(function(r) { return r.json(); })
2640
- .then(function(data) {
2641
- if (!data.diffs || !data.diffs[0]) {
2642
- area.innerHTML = '<div class="git-diff-error">无 diff 数据</div>';
2643
- return;
2644
- }
2645
- var d = data.diffs[0];
2646
- var html = '<div class="git-diff-content-header">';
2647
- html += '<span class="git-diff-content-path">' + escapeHtml(d.file) + '</span>';
2648
- html += '<span class="git-diff-badge">' + (d.is_new ? 'NEW' : d.is_deleted ? 'DEL' : 'DIFF') + '</span>';
2649
- html += '</div>';
2650
- html += '<div class="git-diff-content-scroll">';
2651
-
2652
- if (d.is_binary) {
2653
- html += '<div class="git-diff-loading" style="font-style:italic;">二进制文件</div>';
2654
- } else if (d.is_large) {
2655
- html += '<div class="git-diff-loading" style="color:#e2c08d;">文件过大: ' + (d.size / (1024 * 1024)).toFixed(2) + ' MB</div>';
2656
- } else if (d.unified_diff) {
2657
- html += renderUnifiedDiff(d.unified_diff);
2658
- } else {
2659
- html += '<div class="git-diff-loading" style="color:#666;">无变更内容</div>';
2660
- }
2763
+ var d = diffChanges.find(function(c) { return c.file === file; });
2764
+ if (!d) {
2765
+ area.innerHTML = '<div class="git-diff-error">无 diff 数据</div>';
2766
+ return;
2767
+ }
2768
+ var html = '<div class="git-diff-content-header">';
2769
+ html += '<span class="git-diff-content-path">' + escapeHtml(d.file) + '</span>';
2770
+ html += '<span class="git-diff-badge">' + (d.is_new ? 'NEW' : d.is_deleted ? 'DEL' : 'DIFF') + '</span>';
2771
+ html += '</div>';
2772
+ html += '<div class="git-diff-content-scroll">';
2773
+
2774
+ if (d.is_binary) {
2775
+ html += '<div class="git-diff-loading" style="font-style:italic;">二进制文件</div>';
2776
+ } else if (d.is_large) {
2777
+ html += '<div class="git-diff-loading" style="color:#e2c08d;">文件过大: ' + (d.size / (1024 * 1024)).toFixed(2) + ' MB</div>';
2778
+ } else if (d.unified_diff) {
2779
+ html += renderUnifiedDiff(d.unified_diff);
2780
+ } else {
2781
+ html += '<div class="git-diff-loading" style="color:#666;">无变更内容</div>';
2782
+ }
2661
2783
 
2662
- html += '</div>';
2663
- area.innerHTML = html;
2664
- })
2665
- .catch(function(err) {
2666
- area.innerHTML = '<div class="git-diff-error">加载失败: ' + escapeHtml(err.message) + '</div>';
2667
- });
2784
+ html += '</div>';
2785
+ area.innerHTML = html;
2668
2786
  }
2669
2787
 
2670
2788
  function renderUnifiedDiff(diffText) {
@@ -2697,13 +2815,13 @@
2697
2815
  html += '<tr class="diff-line diff-line-add">';
2698
2816
  html += '<td class="diff-line-num"></td>';
2699
2817
  html += '<td class="diff-line-num">' + newLine + '</td>';
2700
- html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2818
+ html += '<td class="diff-line-content"><span class="diff-sign">+</span>' + escapeHtml(line.substring(1)) + '</td></tr>';
2701
2819
  newLine++;
2702
2820
  } else if (line.startsWith('-')) {
2703
2821
  html += '<tr class="diff-line diff-line-del">';
2704
2822
  html += '<td class="diff-line-num">' + oldLine + '</td>';
2705
2823
  html += '<td class="diff-line-num"></td>';
2706
- html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2824
+ html += '<td class="diff-line-content"><span class="diff-sign">-</span>' + escapeHtml(line.substring(1)) + '</td></tr>';
2707
2825
  oldLine++;
2708
2826
  } else if (line.startsWith(' ') || (line === '' && i < lines.length - 1)) {
2709
2827
  html += '<tr class="diff-line">';
@@ -2811,6 +2929,15 @@
2811
2929
  });
2812
2930
  }
2813
2931
 
2932
+ function formatDocContent(text) {
2933
+ if (!text) return '';
2934
+ try {
2935
+ return DOMPurify.sanitize(marked.parse(text, { breaks: true }));
2936
+ } catch (e) {
2937
+ return '<pre>' + escapeHtml(text) + '</pre>';
2938
+ }
2939
+ }
2940
+
2814
2941
  function loadDocContent(file) {
2815
2942
  var area = document.getElementById('docs-content-area');
2816
2943
  area.innerHTML = '<div class="docs-loading">加载中...</div>';
@@ -2821,7 +2948,7 @@
2821
2948
  area.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">' + escapeHtml(data.error) + '</div>';
2822
2949
  return;
2823
2950
  }
2824
- area.innerHTML = '<pre>' + escapeHtml(data.content) + '</pre>';
2951
+ area.innerHTML = '<div class="docs-md-content">' + formatDocContent(data.content) + '</div>';
2825
2952
  })
2826
2953
  .catch(function() {
2827
2954
  area.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.48",
3
+ "version": "2.6.50",
4
4
  "description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -434,11 +434,17 @@ function writeToPty(data) {
434
434
  }
435
435
 
436
436
  function resizePty(cols, rows) {
437
+ const sameSize = (cols === lastPtyCols && rows === lastPtyRows);
437
438
  lastPtyCols = cols;
438
439
  lastPtyRows = rows;
440
+ if (sameSize) return;
439
441
  [claudeProcess, opencodeProcess].forEach(proc => {
440
442
  if (proc) {
441
- try { proc.resize(cols, rows); } catch {}
443
+ try {
444
+ // 先设不同尺寸再设目标尺寸,保证即使进程已是该尺寸也能触发 SIGWINCH
445
+ proc.resize(Math.max(2, cols - 1), rows);
446
+ proc.resize(cols, rows);
447
+ } catch {}
442
448
  }
443
449
  });
444
450
  }
@@ -603,6 +609,7 @@ const requestHandler = async (req, res) => {
603
609
  return;
604
610
  }
605
611
 
612
+
606
613
  // API: 获取服务端口信息
607
614
  if (req.url === '/api/port') {
608
615
  res.writeHead(200, {
@@ -655,7 +662,7 @@ const requestHandler = async (req, res) => {
655
662
  return;
656
663
  }
657
664
 
658
- // API: 获取 git status
665
+ // API: 获取 git status(含每个文件的 unified_diff,批量获取优化)
659
666
  if (req.url === '/api/git-status') {
660
667
  res.writeHead(200, {
661
668
  'Content-Type': 'application/json',
@@ -663,13 +670,76 @@ const requestHandler = async (req, res) => {
663
670
  });
664
671
  try {
665
672
  const gitCwd = process.env.PROJECT_DIR || process.cwd();
666
- const { stdout } = await execFileAsync('git', ['-c', 'safe.directory=*', 'status', '--porcelain'], {
667
- cwd: gitCwd, encoding: 'utf-8', timeout: 60000,
668
- });
669
- const changes = stdout.split('\n').filter(Boolean).map(line => ({
673
+
674
+ // 并行执行: git status + git diff --numstat + git diff (批量获取)
675
+ const [statusResult, numstatResult, diffResult] = await Promise.all([
676
+ execFileAsync('git', ['-c', 'safe.directory=*', 'status', '--porcelain'], { cwd: gitCwd, encoding: 'utf-8', timeout: 60000 }),
677
+ execFileAsync('git', ['-c', 'safe.directory=*', 'diff', '--numstat', 'HEAD'], { cwd: gitCwd, encoding: 'utf-8', timeout: 60000 }).catch(() => ({ stdout: '' })),
678
+ execFileAsync('git', ['-c', 'safe.directory=*', 'diff', '--no-color', '-U3', 'HEAD'], { cwd: gitCwd, encoding: 'utf-8', timeout: 60000, maxBuffer: 10 * 1024 * 1024 }).catch(e => ({ stdout: e.stdout || '' })),
679
+ ]);
680
+
681
+ const changes = statusResult.stdout.split('\n').filter(Boolean).map(line => ({
670
682
  status: line.substring(0, 2).trim(),
671
683
  file: line.substring(3),
672
684
  })).filter(c => !/^core-/.test(c.file));
685
+
686
+ // 解析 numstat 识别二进制文件
687
+ const binaryFiles = new Set();
688
+ numstatResult.stdout.split('\n').filter(Boolean).forEach(line => {
689
+ if (line.startsWith('-\t-\t')) binaryFiles.add(line.split('\t')[2]);
690
+ });
691
+
692
+ // 将批量 diff 输出按文件拆分
693
+ const diffMap = {};
694
+ const diffParts = diffResult.stdout.split(/^diff --git /m);
695
+ for (let i = 1; i < diffParts.length; i++) {
696
+ const part = diffParts[i];
697
+ // 提取文件名: "a/path b/path\n..."
698
+ const firstLine = part.substring(0, part.indexOf('\n'));
699
+ const bMatch = firstLine.match(/ b\/(.+)$/);
700
+ if (bMatch) diffMap[bMatch[1]] = 'diff --git ' + part;
701
+ }
702
+
703
+ // 填充每个文件的 diff 信息
704
+ const untrackedFiles = [];
705
+ for (const c of changes) {
706
+ if (c.file.includes('..') || c.file.startsWith('/')) continue;
707
+ c.is_new = c.status === 'A' || c.status === '??';
708
+ c.is_deleted = c.status === 'D';
709
+ c.is_binary = binaryFiles.has(c.file);
710
+ if (c.is_binary) continue;
711
+
712
+ // 检查大文件
713
+ if (!c.is_deleted) {
714
+ try {
715
+ const filePath = join(gitCwd, c.file);
716
+ if (existsSync(filePath)) {
717
+ const stat = statSync(filePath);
718
+ if (stat.size > 5 * 1024 * 1024) { c.is_large = true; c.size = stat.size; continue; }
719
+ }
720
+ } catch {}
721
+ }
722
+
723
+ if (c.status === '??') {
724
+ // untracked 文件需要单独处理
725
+ untrackedFiles.push(c);
726
+ } else {
727
+ c.unified_diff = diffMap[c.file] || '';
728
+ }
729
+ }
730
+
731
+ // 对 untracked 文件并行获取 diff
732
+ if (untrackedFiles.length > 0) {
733
+ await Promise.all(untrackedFiles.map(async (c) => {
734
+ try {
735
+ const { stdout: diffOut } = await execFileAsync('git', ['-c', 'safe.directory=*', 'diff', '--no-color', '-U3', '--no-index', '/dev/null', c.file], { cwd: gitCwd, encoding: 'utf-8', timeout: 30000, maxBuffer: 5 * 1024 * 1024 });
736
+ c.unified_diff = diffOut;
737
+ } catch (e) {
738
+ c.unified_diff = e.stdout || '';
739
+ }
740
+ }));
741
+ }
742
+
673
743
  res.end(JSON.stringify({ changes, cwd: gitCwd }));
674
744
  } catch (err) {
675
745
  res.end(JSON.stringify({ changes: [], cwd: process.env.PROJECT_DIR || process.cwd(), error: err.message }));
@@ -1118,15 +1188,32 @@ wssInst.on('connection', (ws, req) => {
1118
1188
  LOG('[reconnect] 重启失败:', e.message);
1119
1189
  }
1120
1190
  isSwitching = false;
1121
- }, 200);
1122
- } else if (outputBuffer) {
1123
- ws.send(JSON.stringify({ type: 'data', data: outputBuffer }));
1191
+ }, 100);
1192
+ } else if (currentProcess) {
1193
+ // 连接已运行的进程:发送缓冲区内容 + 重置尺寸触发 SIGWINCH 重绘
1194
+ if (outputBuffer) {
1195
+ ws.send(JSON.stringify({ type: 'data', data: outputBuffer }));
1196
+ }
1197
+ lastPtyCols = 0;
1198
+ lastPtyRows = 0;
1124
1199
  }
1125
1200
 
1126
1201
 
1202
+ let wsBuf = '';
1203
+ let wsFlushTimer = null;
1204
+ const flushWs = () => {
1205
+ wsFlushTimer = null;
1206
+ if (wsBuf && ws.readyState === 1) {
1207
+ ws.send(JSON.stringify({ type: 'data', data: wsBuf }));
1208
+ wsBuf = '';
1209
+ }
1210
+ };
1127
1211
  const listener = (data) => {
1128
1212
  if (ws.readyState === 1 && !isSwitching) {
1129
- ws.send(JSON.stringify({ type: 'data', data }));
1213
+ wsBuf += data;
1214
+ if (!wsFlushTimer) {
1215
+ wsFlushTimer = setTimeout(flushWs, 16);
1216
+ }
1130
1217
  }
1131
1218
  };
1132
1219
  dataListeners.push(listener);
@@ -1141,7 +1228,6 @@ wssInst.on('connection', (ws, req) => {
1141
1228
  ws.on('message', async (raw) => {
1142
1229
  try {
1143
1230
  const msg = JSON.parse(raw);
1144
- LOG(`[WS msg] type=${msg.type}, currentProcess=${!!currentProcess}, currentMode=${currentMode}`);
1145
1231
 
1146
1232
  if (msg.type === 'input') {
1147
1233
  // 进程已退出时,自动重新启动(参考 cc-viewer 逻辑)
@@ -1189,6 +1275,7 @@ wssInst.on('connection', (ws, req) => {
1189
1275
  }
1190
1276
  } else if (msg.type === 'restore') {
1191
1277
  // 恢复会话(支持 opencode 和 claude)
1278
+ LOG(`[restore] received: sessionId=${msg.sessionId}, mode=${currentMode}, isSwitching=${isSwitching}`);
1192
1279
  if (msg.sessionId) {
1193
1280
  LOG(`[restore] 恢复 ${currentMode} 会话: ${msg.sessionId}`);
1194
1281
 
@@ -1214,8 +1301,8 @@ wssInst.on('connection', (ws, req) => {
1214
1301
  // 清空输出缓冲
1215
1302
  outputBuffer = '';
1216
1303
 
1217
- // 等待进程完全退出
1218
- await new Promise(resolve => setTimeout(resolve, 500));
1304
+ // 等待进程退出
1305
+ await new Promise(resolve => setTimeout(resolve, 100));
1219
1306
  cleanupOrphanProcesses();
1220
1307
 
1221
1308
  // 启动进程,传入 session ID
@@ -1328,7 +1415,7 @@ wssInst.on('connection', (ws, req) => {
1328
1415
  }, 5000);
1329
1416
  }
1330
1417
  } catch (err) {
1331
- LOG('[WS] Error:', err.message);
1418
+ console.log('[WS] Error:', err.message, err.stack);
1332
1419
  }
1333
1420
  });
1334
1421
 
package/test-doc.md ADDED
@@ -0,0 +1,86 @@
1
+ # Claude OpenCode Viewer 使用指南
2
+
3
+ ## 简介
4
+
5
+ Claude OpenCode Viewer(COV)是一个统一的终端查看器,支持在浏览器中远程查看和操作 Claude Code 与 OpenCode 的终端会话。适用于需要在手机或其他设备上查看 AI 编程助手工作进度的场景。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ npm install -g claude-opencode-viewer
11
+ ```
12
+
13
+ 安装完成后,可以通过以下命令启动:
14
+
15
+ ```bash
16
+ cov
17
+ ```
18
+
19
+ 默认监听端口 7008,PC 模式使用 `--pc` 参数启动。
20
+
21
+ ## 功能特性
22
+
23
+ ### 终端查看
24
+
25
+ 支持实时查看终端输出,基于 xterm.js 实现完整的终端模拟,包括:
26
+
27
+ - 颜色渲染
28
+ - Unicode 字符支持
29
+ - WebGL 加速渲染
30
+ - 移动端触摸滚动
31
+
32
+ ### 会话管理
33
+
34
+ 可以管理多个会话,支持以下操作:
35
+
36
+ 1. 查看历史会话列表
37
+ 2. 恢复已有会话
38
+ 3. 创建新会话
39
+ 4. 在 Claude 和 OpenCode 之间切换
40
+
41
+ ### Git 变更查看
42
+
43
+ 集成了 Git 状态查看功能,可以直接在页面上查看:
44
+
45
+ | 状态 | 含义 | 颜色 |
46
+ |------|------|------|
47
+ | M | 已修改 | 橙色 |
48
+ | A | 新增 | 绿色 |
49
+ | D | 已删除 | 红色 |
50
+ | ?? | 未跟踪 | 灰色 |
51
+
52
+ ### 文档浏览
53
+
54
+ 支持浏览项目中的 Markdown 文档,自动扫描项目目录下的 `.md` 文件并以富文本格式展示。
55
+
56
+ ## 配置说明
57
+
58
+ > 注意:以下配置需要在项目根目录下操作,确保 `PROJECT_DIR` 环境变量指向正确的项目路径。
59
+
60
+ 常用环境变量:
61
+
62
+ - `PROJECT_DIR` — 指定项目工作目录
63
+ - `PORT` — 自定义端口号
64
+ - `COV_MODE` — 默认启动模式(claude / opencode)
65
+
66
+ ## 常见问题
67
+
68
+ ### 连接断开怎么办?
69
+
70
+ 页面会自动显示重连提示并尝试重新连接。如果持续无法连接,请检查:
71
+
72
+ 1. 服务进程是否仍在运行
73
+ 2. 网络是否可达
74
+ 3. 端口是否被占用
75
+
76
+ ### 移动端键盘遮挡问题
77
+
78
+ 在 iOS 设备上,系统会自动调整终端高度以适应键盘弹出。如果遇到显示异常,可以尝试旋转屏幕后再旋转回来。
79
+
80
+ ## 更新日志
81
+
82
+ **v2.6.48** — 修复重连内容重复、模式切换黑屏问题
83
+
84
+ **v2.6.47** — 添加 PC 端重连覆盖层
85
+
86
+ **v2.6.46** — 移动端键盘交互优化