claude-opencode-viewer 2.6.25 → 2.6.27

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.
@@ -11,7 +11,8 @@
11
11
  "Bash(git add:*)",
12
12
  "Bash(git commit -m ':*)",
13
13
  "Bash(git push:*)",
14
- "Bash(npm publish:*)"
14
+ "Bash(npm publish:*)",
15
+ "Bash(git commit:*)"
15
16
  ]
16
17
  }
17
18
  }
package/index-pc.html CHANGED
@@ -544,6 +544,96 @@
544
544
  text-align: center;
545
545
  }
546
546
 
547
+ /* 文档面板 */
548
+ #docs-bar {
549
+ display: none;
550
+ position: absolute;
551
+ top: 0; left: 0; right: 0; bottom: 0;
552
+ background: #0a0a0a;
553
+ z-index: 1000;
554
+ flex-direction: column;
555
+ }
556
+ #docs-bar.visible { display: flex; }
557
+ #docs-header {
558
+ display: flex;
559
+ align-items: center;
560
+ justify-content: space-between;
561
+ padding: 12px 16px;
562
+ background: #111;
563
+ border-bottom: 1px solid #222;
564
+ flex-shrink: 0;
565
+ }
566
+ #docs-title {
567
+ font-size: 14px;
568
+ color: #ddd;
569
+ font-weight: 600;
570
+ }
571
+ .docs-file-list {
572
+ height: 250px;
573
+ flex-shrink: 0;
574
+ overflow-y: auto;
575
+ border-bottom: 1px solid #2a2a2a;
576
+ }
577
+ .docs-file-item {
578
+ display: flex;
579
+ align-items: center;
580
+ padding: 8px 12px;
581
+ cursor: pointer;
582
+ color: #ccc;
583
+ font-size: 13px;
584
+ gap: 8px;
585
+ }
586
+ .docs-file-item:hover { background: #1a1a1a; }
587
+ .docs-file-item.active {
588
+ background: rgba(197, 134, 192, 0.12);
589
+ color: #fff;
590
+ }
591
+ .docs-file-name {
592
+ overflow: hidden;
593
+ text-overflow: ellipsis;
594
+ flex: 1;
595
+ font-family: Menlo, Monaco, monospace;
596
+ font-size: 12px;
597
+ }
598
+ .docs-file-time {
599
+ color: #666;
600
+ font-size: 11px;
601
+ flex-shrink: 0;
602
+ }
603
+ .docs-content-area {
604
+ flex: 1;
605
+ display: flex;
606
+ flex-direction: column;
607
+ min-height: 0;
608
+ overflow: auto;
609
+ padding: 16px;
610
+ }
611
+ .docs-content-area pre {
612
+ margin: 0;
613
+ white-space: pre-wrap;
614
+ word-break: break-word;
615
+ font-family: Menlo, Monaco, monospace;
616
+ font-size: 13px;
617
+ line-height: 1.6;
618
+ color: #d4d4d4;
619
+ }
620
+ .docs-placeholder {
621
+ flex: 1;
622
+ display: flex;
623
+ flex-direction: column;
624
+ align-items: center;
625
+ justify-content: center;
626
+ gap: 12px;
627
+ color: #333;
628
+ font-size: 13px;
629
+ }
630
+ .docs-loading {
631
+ text-align: center;
632
+ padding: 16px;
633
+ color: #888;
634
+ font-size: 12px;
635
+ }
636
+
547
637
  /* Unified diff 行 */
548
638
  .diff-table {
549
639
  width: 100%;
@@ -638,6 +728,15 @@
638
728
  </svg>
639
729
  <span>Diff</span>
640
730
  </button>
731
+ <button class="history-toggle-btn" id="docs-toggle" style="color:#c586c0; border-color:#4a2a5a;">
732
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
733
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
734
+ <polyline points="14 2 14 8 20 8"></polyline>
735
+ <line x1="16" y1="13" x2="8" y2="13"></line>
736
+ <line x1="16" y1="17" x2="8" y2="17"></line>
737
+ </svg>
738
+ <span>文档</span>
739
+ </button>
641
740
  </div>
642
741
  <div id="mode-switcher">
643
742
  <span id="mode-label"></span>
@@ -720,6 +819,43 @@
720
819
  </div>
721
820
  </div>
722
821
 
822
+ <!-- 文档栏 -->
823
+ <div id="docs-bar">
824
+ <div id="docs-header">
825
+ <div style="display: flex; align-items: center; gap: 8px;">
826
+ <span id="docs-title">文档</span>
827
+ <span class="diff-file-count" id="docs-count">0</span>
828
+ </div>
829
+ <div style="display: flex; gap: 8px;">
830
+ <button class="history-toggle-btn" id="refresh-docs" style="padding: 4px 8px;">
831
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
832
+ <polyline points="23 4 23 10 17 10"></polyline>
833
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
834
+ </svg>
835
+ <span>刷新</span>
836
+ </button>
837
+ <button class="history-toggle-btn" id="close-docs" style="padding: 4px 8px;">
838
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
839
+ <polyline points="15 18 9 12 15 6"></polyline>
840
+ </svg>
841
+ <span>返回</span>
842
+ </button>
843
+ </div>
844
+ </div>
845
+ <div class="docs-file-list" id="docs-file-list">
846
+ <div class="docs-loading">加载中...</div>
847
+ </div>
848
+ <div class="docs-content-area" id="docs-content-area">
849
+ <div class="docs-placeholder">
850
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">
851
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
852
+ <polyline points="14 2 14 8 20 8"></polyline>
853
+ </svg>
854
+ <span>点击文件查看内容</span>
855
+ </div>
856
+ </div>
857
+ </div>
858
+
723
859
  <div id="content">
724
860
  <div id="terminal-container">
725
861
  <div id="terminal">
@@ -1239,8 +1375,8 @@
1239
1375
  }
1240
1376
  else if (msg.type === 'exit') {
1241
1377
  if (!isCreatingNewSession && !isTransitioning) {
1242
- throttledWrite('\r\n\x1b[33m[进程已退出: ' + msg.exitCode + ']\x1b[0m\r\n');
1243
- throttledWrite('\x1b[90m按 Enter 键重新启动 ' + currentMode + '...\x1b[0m\r\n');
1378
+ throttledWrite('\r\n[进程已退出: ' + msg.exitCode + ']\r\n');
1379
+ throttledWrite('按 Enter 键重新启动 ' + currentMode + '...\r\n');
1244
1380
  }
1245
1381
  }
1246
1382
  else if (msg.type === 'mode') {
@@ -1261,25 +1397,23 @@
1261
1397
  }
1262
1398
  }
1263
1399
  else if (msg.type === 'restored') {
1264
- // 会话恢复成功
1265
- term.write('\x1b[32m✓ 会话已恢复: ' + msg.sessionId + '\x1b[0m\r\n');
1266
- term.write('\x1b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n');
1267
- term.write('\r\n');
1400
+ // 会话恢复成功,重置终端清除残留ANSI状态
1401
+ term.reset();
1268
1402
  }
1269
1403
  else if (msg.type === 'restore-error') {
1270
1404
  // 恢复失败
1271
- term.write('\x1b[31m✗ 恢复失败: ' + msg.error + '\x1b[0m\r\n');
1405
+ term.write('恢复失败: ' + msg.error + '\r\n');
1272
1406
  }
1273
1407
  else if (msg.type === 'started') {
1274
1408
  rebindTouchScroll();
1275
1409
  }
1276
1410
  else if (msg.type === 'new-session-ok') {
1277
1411
  isCreatingNewSession = false;
1278
- term.clear();
1412
+ term.reset();
1279
1413
  }
1280
1414
  else if (msg.type === 'new-session-error') {
1281
1415
  isCreatingNewSession = false;
1282
- term.write('\x1b[31m✗ 新会话启动失败: ' + msg.error + '\x1b[0m\r\n');
1416
+ term.write('新会话启动失败: ' + msg.error + '\r\n');
1283
1417
  }
1284
1418
  } catch(err) {}
1285
1419
  };
@@ -1480,25 +1614,17 @@
1480
1614
  // 关闭历史栏
1481
1615
  toggleHistoryBar();
1482
1616
 
1483
- // 清空终端
1484
- term.clear();
1485
-
1486
- // 显示正在恢复的提示
1487
- term.write('\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m\r\n');
1488
- term.write('\x1b[1;36m║ \x1b[1;37m正在恢复会话... \x1b[1;36m║\x1b[0m\r\n');
1489
- term.write('\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m\r\n');
1490
- term.write('\r\n');
1617
+ // 重置终端(清除残留ANSI解析状态)
1618
+ term.reset();
1491
1619
 
1492
1620
  if (ws && ws.readyState === 1) {
1493
1621
  if (currentMode !== 'opencode') {
1494
- term.write('\x1b[31m错误: 请先切换到 OpenCode 模式\x1b[0m\r\n');
1622
+ term.write('错误: 请先切换到 OpenCode 模式\r\n');
1495
1623
  return;
1496
1624
  }
1497
- term.write('\x1b[33m正在重启 OpenCode 并恢复会话: ' + session.id + '\x1b[0m\r\n');
1498
- term.write('\r\n');
1499
1625
  ws.send(JSON.stringify({ type: 'restore', sessionId: session.id }));
1500
1626
  } else {
1501
- term.write('\x1b[31m错误: WebSocket 未连接\x1b[0m\r\n');
1627
+ term.write('错误: WebSocket 未连接\r\n');
1502
1628
  }
1503
1629
  }
1504
1630
 
@@ -1818,6 +1944,92 @@
1818
1944
  '</svg><span>点击文件查看 diff</span></div>';
1819
1945
  });
1820
1946
 
1947
+ // === 文档面板 ===
1948
+ var docsBarVisible = false;
1949
+ var docsSelectedFile = null;
1950
+
1951
+ function toggleDocsBar() {
1952
+ docsBarVisible = !docsBarVisible;
1953
+ var bar = document.getElementById('docs-bar');
1954
+ if (docsBarVisible) {
1955
+ bar.classList.add('visible');
1956
+ loadDocs();
1957
+ } else {
1958
+ bar.classList.remove('visible');
1959
+ docsSelectedFile = null;
1960
+ }
1961
+ }
1962
+
1963
+ function loadDocs() {
1964
+ var fileList = document.getElementById('docs-file-list');
1965
+ fileList.innerHTML = '<div class="docs-loading">加载中...</div>';
1966
+ document.getElementById('docs-count').textContent = '0';
1967
+
1968
+ fetch(basePath + '/api/docs')
1969
+ .then(function(r) { return r.json(); })
1970
+ .then(function(data) {
1971
+ var docs = data.docs || [];
1972
+ document.getElementById('docs-count').textContent = docs.length;
1973
+ if (!docs.length) {
1974
+ fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
1975
+ return;
1976
+ }
1977
+ var html = '';
1978
+ docs.forEach(function(doc) {
1979
+ var activeClass = docsSelectedFile === doc.name ? ' active' : '';
1980
+ var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
1981
+ html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
1982
+ html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
1983
+ html += '<span class="docs-file-time">' + time + '</span>';
1984
+ html += '</div>';
1985
+ });
1986
+ fileList.innerHTML = html;
1987
+ fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
1988
+ item.addEventListener('click', function() {
1989
+ var file = this.getAttribute('data-file');
1990
+ docsSelectedFile = file;
1991
+ fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
1992
+ this.classList.add('active');
1993
+ loadDocContent(file);
1994
+ });
1995
+ });
1996
+ })
1997
+ .catch(function() {
1998
+ fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
1999
+ });
2000
+ }
2001
+
2002
+ function loadDocContent(file) {
2003
+ var area = document.getElementById('docs-content-area');
2004
+ area.innerHTML = '<div class="docs-loading">加载中...</div>';
2005
+ fetch(basePath + '/api/doc-content?file=' + encodeURIComponent(file))
2006
+ .then(function(r) { return r.json(); })
2007
+ .then(function(data) {
2008
+ if (data.error) {
2009
+ area.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">' + escapeHtml(data.error) + '</div>';
2010
+ return;
2011
+ }
2012
+ area.innerHTML = '<pre>' + escapeHtml(data.content) + '</pre>';
2013
+ })
2014
+ .catch(function(err) {
2015
+ area.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2016
+ });
2017
+ }
2018
+
2019
+ document.getElementById('docs-toggle').addEventListener('click', toggleDocsBar);
2020
+ document.getElementById('close-docs').addEventListener('click', toggleDocsBar);
2021
+ document.getElementById('refresh-docs').addEventListener('click', function(e) {
2022
+ e.stopPropagation();
2023
+ loadDocs();
2024
+ docsSelectedFile = null;
2025
+ document.getElementById('docs-content-area').innerHTML =
2026
+ '<div class="docs-placeholder">' +
2027
+ '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">' +
2028
+ '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>' +
2029
+ '<polyline points="14 2 14 8 20 8"></polyline></svg>' +
2030
+ '<span>点击文件查看内容</span></div>';
2031
+ });
2032
+
1821
2033
  connect();
1822
2034
  setTimeout(resize, 100);
1823
2035
  })();
package/index.html CHANGED
@@ -612,6 +612,97 @@
612
612
  text-align: center;
613
613
  }
614
614
 
615
+ /* 文档面板 */
616
+ #docs-bar {
617
+ display: none;
618
+ position: absolute;
619
+ top: 0; left: 0; right: 0; bottom: 0;
620
+ background: #0a0a0a;
621
+ z-index: 1000;
622
+ flex-direction: column;
623
+ }
624
+ #docs-bar.visible { display: flex; }
625
+ #docs-header {
626
+ display: flex;
627
+ align-items: center;
628
+ justify-content: space-between;
629
+ padding: 12px 16px;
630
+ background: #111;
631
+ border-bottom: 1px solid #222;
632
+ flex-shrink: 0;
633
+ }
634
+ #docs-title {
635
+ font-size: 14px;
636
+ color: #ddd;
637
+ font-weight: 600;
638
+ }
639
+ .docs-file-list {
640
+ height: 250px;
641
+ flex-shrink: 0;
642
+ overflow-y: auto;
643
+ border-bottom: 1px solid #2a2a2a;
644
+ }
645
+ .docs-file-item {
646
+ display: flex;
647
+ align-items: center;
648
+ padding: 8px 12px;
649
+ cursor: pointer;
650
+ color: #ccc;
651
+ font-size: 13px;
652
+ gap: 8px;
653
+ }
654
+ .docs-file-item:hover { background: #1a1a1a; }
655
+ .docs-file-item.active {
656
+ background: rgba(197, 134, 192, 0.12);
657
+ color: #fff;
658
+ }
659
+ .docs-file-name {
660
+ overflow: hidden;
661
+ text-overflow: ellipsis;
662
+ flex: 1;
663
+ font-family: Menlo, Monaco, monospace;
664
+ font-size: 12px;
665
+ }
666
+ .docs-file-time {
667
+ color: #666;
668
+ font-size: 11px;
669
+ flex-shrink: 0;
670
+ }
671
+ .docs-content-area {
672
+ flex: 1;
673
+ display: flex;
674
+ flex-direction: column;
675
+ min-height: 0;
676
+ overflow: auto;
677
+ padding: 16px;
678
+ -webkit-overflow-scrolling: touch;
679
+ }
680
+ .docs-content-area pre {
681
+ margin: 0;
682
+ white-space: pre-wrap;
683
+ word-break: break-word;
684
+ font-family: Menlo, Monaco, monospace;
685
+ font-size: 13px;
686
+ line-height: 1.6;
687
+ color: #d4d4d4;
688
+ }
689
+ .docs-placeholder {
690
+ flex: 1;
691
+ display: flex;
692
+ flex-direction: column;
693
+ align-items: center;
694
+ justify-content: center;
695
+ gap: 12px;
696
+ color: #333;
697
+ font-size: 13px;
698
+ }
699
+ .docs-loading {
700
+ text-align: center;
701
+ padding: 16px;
702
+ color: #888;
703
+ font-size: 12px;
704
+ }
705
+
615
706
  /* Unified diff 行 */
616
707
  .diff-table {
617
708
  width: 100%;
@@ -706,6 +797,15 @@
706
797
  </svg>
707
798
  <span>Diff</span>
708
799
  </button>
800
+ <button class="history-toggle-btn" id="docs-toggle" style="color:#c586c0; border-color:#4a2a5a;">
801
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
802
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
803
+ <polyline points="14 2 14 8 20 8"></polyline>
804
+ <line x1="16" y1="13" x2="8" y2="13"></line>
805
+ <line x1="16" y1="17" x2="8" y2="17"></line>
806
+ </svg>
807
+ <span>文档</span>
808
+ </button>
709
809
  <button class="history-toggle-btn" id="msg-toggle" style="color:#c9a0dc; border-color:#5a3a6a;">
710
810
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
711
811
  <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
@@ -794,6 +894,43 @@
794
894
  </div>
795
895
  </div>
796
896
 
897
+ <!-- 文档栏 -->
898
+ <div id="docs-bar">
899
+ <div id="docs-header">
900
+ <div style="display: flex; align-items: center; gap: 8px;">
901
+ <span id="docs-title">文档</span>
902
+ <span class="diff-file-count" id="docs-count">0</span>
903
+ </div>
904
+ <div style="display: flex; gap: 8px;">
905
+ <button class="history-toggle-btn" id="refresh-docs" style="padding: 4px 8px;">
906
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
907
+ <polyline points="23 4 23 10 17 10"></polyline>
908
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
909
+ </svg>
910
+ <span>刷新</span>
911
+ </button>
912
+ <button class="history-toggle-btn" id="close-docs" style="padding: 4px 8px;">
913
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
914
+ <polyline points="15 18 9 12 15 6"></polyline>
915
+ </svg>
916
+ <span>返回</span>
917
+ </button>
918
+ </div>
919
+ </div>
920
+ <div class="docs-file-list" id="docs-file-list">
921
+ <div class="docs-loading">加载中...</div>
922
+ </div>
923
+ <div class="docs-content-area" id="docs-content-area">
924
+ <div class="docs-placeholder">
925
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">
926
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
927
+ <polyline points="14 2 14 8 20 8"></polyline>
928
+ </svg>
929
+ <span>点击文件查看内容</span>
930
+ </div>
931
+ </div>
932
+ </div>
933
+
797
934
  <div id="content">
798
935
  <div id="terminal-container">
799
936
  <div id="terminal">
@@ -1348,8 +1485,8 @@
1348
1485
  }
1349
1486
  else if (msg.type === 'exit') {
1350
1487
  if (!isCreatingNewSession && !isTransitioning) {
1351
- throttledWrite('\r\n\x1b[33m[进程已退出: ' + msg.exitCode + ']\x1b[0m\r\n');
1352
- throttledWrite('\x1b[90m按 Enter 键重新启动 ' + currentMode + '...\x1b[0m\r\n');
1488
+ throttledWrite('\r\n[进程已退出: ' + msg.exitCode + ']\r\n');
1489
+ throttledWrite('按 Enter 键重新启动 ' + currentMode + '...\r\n');
1353
1490
  }
1354
1491
  }
1355
1492
  else if (msg.type === 'mode') {
@@ -1370,25 +1507,23 @@
1370
1507
  }
1371
1508
  }
1372
1509
  else if (msg.type === 'restored') {
1373
- // 会话恢复成功
1374
- term.write('\x1b[32m✓ 会话已恢复: ' + msg.sessionId + '\x1b[0m\r\n');
1375
- term.write('\x1b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n');
1376
- term.write('\r\n');
1510
+ // 会话恢复成功,重置终端清除残留ANSI状态
1511
+ term.reset();
1377
1512
  }
1378
1513
  else if (msg.type === 'restore-error') {
1379
1514
  // 恢复失败
1380
- term.write('\x1b[31m✗ 恢复失败: ' + msg.error + '\x1b[0m\r\n');
1515
+ term.write('恢复失败: ' + msg.error + '\r\n');
1381
1516
  }
1382
1517
  else if (msg.type === 'started') {
1383
1518
  rebindTouchScroll();
1384
1519
  }
1385
1520
  else if (msg.type === 'new-session-ok') {
1386
1521
  isCreatingNewSession = false;
1387
- term.clear();
1522
+ term.reset();
1388
1523
  }
1389
1524
  else if (msg.type === 'new-session-error') {
1390
1525
  isCreatingNewSession = false;
1391
- term.write('\x1b[31m✗ 新会话启动失败: ' + msg.error + '\x1b[0m\r\n');
1526
+ term.write('新会话启动失败: ' + msg.error + '\r\n');
1392
1527
  }
1393
1528
  } catch(err) {}
1394
1529
  };
@@ -1706,25 +1841,18 @@
1706
1841
  // 关闭历史栏
1707
1842
  toggleHistoryBar();
1708
1843
 
1709
- // 清空终端
1710
- term.clear();
1711
-
1712
- // 显示正在恢复的提示
1713
- term.write('\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m\r\n');
1714
- term.write('\x1b[1;36m║ \x1b[1;37m正在恢复会话... \x1b[1;36m║\x1b[0m\r\n');
1715
- term.write('\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m\r\n');
1716
- term.write('\r\n');
1844
+ // 重置终端(清除残留ANSI解析状态)
1845
+ term.reset();
1717
1846
 
1718
1847
  if (ws && ws.readyState === 1) {
1719
1848
  if (currentMode !== 'opencode') {
1720
- term.write('\x1b[31m错误: 请先切换到 OpenCode 模式\x1b[0m\r\n');
1849
+ term.write('错误: 请先切换到 OpenCode 模式\r\n');
1721
1850
  return;
1722
1851
  }
1723
- term.write('\x1b[33m正在重启 OpenCode 并恢复会话: ' + session.id + '\x1b[0m\r\n');
1724
- term.write('\r\n');
1852
+ // 静默恢复
1725
1853
  ws.send(JSON.stringify({ type: 'restore', sessionId: session.id }));
1726
1854
  } else {
1727
- term.write('\x1b[31m错误: WebSocket 未连接\x1b[0m\r\n');
1855
+ term.write('错误: WebSocket 未连接\r\n');
1728
1856
  }
1729
1857
  }
1730
1858
 
@@ -2086,6 +2214,92 @@
2086
2214
  '</svg><span>点击文件查看 diff</span></div>';
2087
2215
  });
2088
2216
 
2217
+ // === 文档面板 ===
2218
+ var docsBarVisible = false;
2219
+ var docsSelectedFile = null;
2220
+
2221
+ function toggleDocsBar() {
2222
+ docsBarVisible = !docsBarVisible;
2223
+ var bar = document.getElementById('docs-bar');
2224
+ if (docsBarVisible) {
2225
+ bar.classList.add('visible');
2226
+ loadDocs();
2227
+ } else {
2228
+ bar.classList.remove('visible');
2229
+ docsSelectedFile = null;
2230
+ }
2231
+ }
2232
+
2233
+ function loadDocs() {
2234
+ var fileList = document.getElementById('docs-file-list');
2235
+ fileList.innerHTML = '<div class="docs-loading">加载中...</div>';
2236
+ document.getElementById('docs-count').textContent = '0';
2237
+
2238
+ fetch(basePath + '/api/docs')
2239
+ .then(function(r) { return r.json(); })
2240
+ .then(function(data) {
2241
+ var docs = data.docs || [];
2242
+ document.getElementById('docs-count').textContent = docs.length;
2243
+ if (!docs.length) {
2244
+ fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2245
+ return;
2246
+ }
2247
+ var html = '';
2248
+ docs.forEach(function(doc) {
2249
+ var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2250
+ var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2251
+ html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2252
+ html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2253
+ html += '<span class="docs-file-time">' + time + '</span>';
2254
+ html += '</div>';
2255
+ });
2256
+ fileList.innerHTML = html;
2257
+ fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2258
+ item.addEventListener('click', function() {
2259
+ var file = this.getAttribute('data-file');
2260
+ docsSelectedFile = file;
2261
+ fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2262
+ this.classList.add('active');
2263
+ loadDocContent(file);
2264
+ });
2265
+ });
2266
+ })
2267
+ .catch(function() {
2268
+ fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2269
+ });
2270
+ }
2271
+
2272
+ function loadDocContent(file) {
2273
+ var area = document.getElementById('docs-content-area');
2274
+ area.innerHTML = '<div class="docs-loading">加载中...</div>';
2275
+ fetch(basePath + '/api/doc-content?file=' + encodeURIComponent(file))
2276
+ .then(function(r) { return r.json(); })
2277
+ .then(function(data) {
2278
+ if (data.error) {
2279
+ area.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">' + escapeHtml(data.error) + '</div>';
2280
+ return;
2281
+ }
2282
+ area.innerHTML = '<pre>' + escapeHtml(data.content) + '</pre>';
2283
+ })
2284
+ .catch(function() {
2285
+ area.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2286
+ });
2287
+ }
2288
+
2289
+ document.getElementById('docs-toggle').addEventListener('click', toggleDocsBar);
2290
+ document.getElementById('close-docs').addEventListener('click', toggleDocsBar);
2291
+ document.getElementById('refresh-docs').addEventListener('click', function(e) {
2292
+ e.stopPropagation();
2293
+ loadDocs();
2294
+ docsSelectedFile = null;
2295
+ document.getElementById('docs-content-area').innerHTML =
2296
+ '<div class="docs-placeholder">' +
2297
+ '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">' +
2298
+ '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>' +
2299
+ '<polyline points="14 2 14 8 20 8"></polyline></svg>' +
2300
+ '<span>点击文件查看内容</span></div>';
2301
+ });
2302
+
2089
2303
  // 初始化虚拟按键事件
2090
2304
  setupVirtualKeyEvents();
2091
2305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.25",
3
+ "version": "2.6.27",
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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createServer } from 'node:http';
3
3
  import { createServer as createHttpsServer } from 'node:https';
4
- import { existsSync, createReadStream, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { existsSync, createReadStream, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { networkInterfaces, platform, arch, homedir } from 'node:os';
@@ -95,6 +95,7 @@ let lastPtyRows = 30;
95
95
 
96
96
  let activeWs = null;
97
97
  let currentSessionId = null;
98
+ let sessionStartTime = 0; // 新会话启动时间,用于限制查询范围
98
99
  let previousSessionId = null; // 用于判断新会话是否已写入 DB
99
100
  const clientSizes = new Map();
100
101
  const mobileClients = new Set();
@@ -163,23 +164,38 @@ function findCommand(cmd) {
163
164
  return cmd;
164
165
  }
165
166
 
167
+ function getChildPids(pid) {
168
+ // 递归获取所有子进程 PID
169
+ try {
170
+ const out = execSync(`ps --ppid ${pid} -o pid= 2>/dev/null || pgrep -P ${pid} 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 });
171
+ const children = out.trim().split(/\s+/).filter(Boolean).map(Number);
172
+ let all = [...children];
173
+ for (const child of children) {
174
+ all = all.concat(getChildPids(child));
175
+ }
176
+ return all;
177
+ } catch { return []; }
178
+ }
179
+
166
180
  function killProcessTree(proc) {
167
181
  if (!proc || !proc.pid) return;
168
182
  const pid = proc.pid;
169
- // 1. 尝试杀整个进程组(负 PID)
183
+ // 1. 收集所有子进程(在杀父进程之前,否则子进程变孤儿就找不到了)
184
+ const children = getChildPids(pid);
185
+ // 2. 尝试杀进程组
170
186
  try { process.kill(-pid, 'SIGTERM'); } catch {}
171
- // 2. 杀 pty 进程本身
187
+ // 3. 杀 pty 进程本身
172
188
  try { proc.kill(); } catch {}
173
- // 3. 1秒后检查,如果还活着就 SIGKILL 强杀
189
+ // 4. SIGTERM 所有子进程
190
+ for (const cpid of children) {
191
+ try { process.kill(cpid, 'SIGTERM'); } catch {}
192
+ }
193
+ // 5. 1秒后 SIGKILL 兜底
174
194
  setTimeout(() => {
175
- try {
176
- process.kill(-pid, 0); // 检查进程组是否还在
177
- process.kill(-pid, 'SIGKILL');
178
- } catch {}
179
- try {
180
- process.kill(pid, 0);
181
- process.kill(pid, 'SIGKILL');
182
- } catch {}
195
+ for (const cpid of children) {
196
+ try { process.kill(cpid, 'SIGKILL'); } catch {}
197
+ }
198
+ try { process.kill(pid, 'SIGKILL'); } catch {}
183
199
  }, 1000);
184
200
  }
185
201
 
@@ -284,8 +300,8 @@ async function spawnProcess(mode, sessionId = null) {
284
300
  currentSessionId = sessionId;
285
301
  LOG(`[session] 当前会话 ID: ${currentSessionId}`);
286
302
  } else {
287
- // 新建会话:保持 null,前端点击复制时不显示内容
288
303
  currentSessionId = null;
304
+ sessionStartTime = Date.now();
289
305
  }
290
306
  }
291
307
 
@@ -550,7 +566,23 @@ const requestHandler = async (req, res) => {
550
566
  'Content-Type': 'application/json',
551
567
  'Access-Control-Allow-Origin': '*',
552
568
  });
553
- res.end(JSON.stringify({ sessionId: currentSessionId }));
569
+ // 实时从数据库查当前有效的会话
570
+ let resolvedSessionId = currentSessionId;
571
+ if (!resolvedSessionId && sessionStartTime && existsSync(OPENCODE_DB_PATH)) {
572
+ try {
573
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
574
+ const row = db.prepare(
575
+ `SELECT s.id FROM session s
576
+ WHERE s.parent_id IS NULL AND s.time_archived IS NULL
577
+ AND s.time_created >= ?
578
+ AND EXISTS (SELECT 1 FROM message m WHERE m.session_id = s.id)
579
+ ORDER BY s.time_created DESC LIMIT 1`
580
+ ).get(sessionStartTime);
581
+ db.close();
582
+ if (row) resolvedSessionId = row.id;
583
+ } catch {}
584
+ }
585
+ res.end(JSON.stringify({ sessionId: resolvedSessionId }));
554
586
  return;
555
587
  }
556
588
 
@@ -561,8 +593,9 @@ const requestHandler = async (req, res) => {
561
593
  'Access-Control-Allow-Origin': '*',
562
594
  });
563
595
  try {
596
+ const gitCwd = existsSync('/halo/code') ? '/halo/code' : process.cwd();
564
597
  const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
565
- cwd: process.cwd(), encoding: 'utf-8', timeout: 5000,
598
+ cwd: gitCwd, encoding: 'utf-8', timeout: 5000,
566
599
  });
567
600
  const changes = stdout.split('\n').filter(Boolean).map(line => ({
568
601
  status: line.substring(0, 2).trim(),
@@ -589,7 +622,7 @@ const requestHandler = async (req, res) => {
589
622
  }
590
623
  const fileList = files.split(',').filter(Boolean);
591
624
  const diffs = [];
592
- const cwd = process.cwd();
625
+ const cwd = existsSync('/halo/code') ? '/halo/code' : process.cwd();
593
626
  for (const file of fileList) {
594
627
  if (file.includes('..') || file.startsWith('/')) continue;
595
628
  try {
@@ -644,6 +677,53 @@ const requestHandler = async (req, res) => {
644
677
  return;
645
678
  }
646
679
 
680
+ // API: 获取文档列表
681
+ if (req.url === '/api/docs') {
682
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
683
+ try {
684
+ const docsCwd = existsSync('/halo/code') ? '/halo/code' : process.cwd();
685
+ const EXCLUDE = new Set(['readme.md', 'changelog.md', 'license.md', 'claude.md', 'agents.md', 'contributing.md', 'security.md', 'context.md']);
686
+ const files = readdirSync(docsCwd)
687
+ .filter(f => f.endsWith('.md') && !EXCLUDE.has(f.toLowerCase()))
688
+ .map(f => {
689
+ try {
690
+ const st = statSync(join(docsCwd, f));
691
+ return { name: f, mtime: st.mtimeMs };
692
+ } catch { return null; }
693
+ })
694
+ .filter(Boolean)
695
+ .sort((a, b) => b.mtime - a.mtime);
696
+ res.end(JSON.stringify({ docs: files }));
697
+ } catch (err) {
698
+ res.end(JSON.stringify({ docs: [], error: err.message }));
699
+ }
700
+ return;
701
+ }
702
+
703
+ // API: 获取文档内容
704
+ if (req.url?.startsWith('/api/doc-content')) {
705
+ const url = new URL(req.url, `http://${req.headers.host}`);
706
+ const file = url.searchParams.get('file');
707
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
708
+ if (!file || file.includes('/') || file.includes('..') || !file.endsWith('.md')) {
709
+ res.end(JSON.stringify({ error: '无效文件名' }));
710
+ return;
711
+ }
712
+ try {
713
+ const docsCwd = existsSync('/halo/code') ? '/halo/code' : process.cwd();
714
+ const filePath = join(docsCwd, file);
715
+ if (!existsSync(filePath)) {
716
+ res.end(JSON.stringify({ error: '文件不存在' }));
717
+ return;
718
+ }
719
+ const content = readFileSync(filePath, 'utf-8');
720
+ res.end(JSON.stringify({ name: file, content }));
721
+ } catch (err) {
722
+ res.end(JSON.stringify({ error: err.message }));
723
+ }
724
+ return;
725
+ }
726
+
647
727
  // API: 软删除会话(设置 time_archived)
648
728
  if (req.method === 'DELETE' && req.url?.startsWith('/api/session/')) {
649
729
  const sessionId = req.url.split('/').pop();
@@ -687,6 +767,29 @@ const requestHandler = async (req, res) => {
687
767
  const httpsOpts = USE_HTTPS ? await getOrCreateCert() : null;
688
768
  let server, wss;
689
769
 
770
+ // 无客户端连接后自动退出,防止进程堆积
771
+ // 首次启动等 3 分钟(给用户时间打开浏览器),连接过之后断开等 30 秒
772
+ let noClientTimer = null;
773
+ let hasEverConnected = false;
774
+ function startNoClientTimer() {
775
+ if (noClientTimer) return;
776
+ if (wss && wss.clients.size > 0) return;
777
+ const timeout = hasEverConnected ? 30000 : 180000;
778
+ noClientTimer = setTimeout(() => {
779
+ if (!wss || wss.clients.size === 0) {
780
+ LOG(`[auto-exit] ${timeout / 1000}秒无客户端连接,自动退出`);
781
+ cleanupAndExit();
782
+ }
783
+ noClientTimer = null;
784
+ }, timeout);
785
+ }
786
+ function cancelNoClientTimer() {
787
+ if (noClientTimer) {
788
+ clearTimeout(noClientTimer);
789
+ noClientTimer = null;
790
+ }
791
+ }
792
+
690
793
  function createServerAndWss() {
691
794
  server = USE_HTTPS
692
795
  ? createHttpsServer(httpsOpts, requestHandler)
@@ -708,6 +811,8 @@ function createServerAndWss() {
708
811
  function setupWss(wssInst) {
709
812
  wssInst.on('connection', (ws, req) => {
710
813
  LOG('[WS] 客户端连接 from', req.socket.remoteAddress);
814
+ hasEverConnected = true;
815
+ cancelNoClientTimer();
711
816
  ws.isAlive = true;
712
817
  ws.on('pong', () => { ws.isAlive = true; });
713
818
 
@@ -864,6 +969,7 @@ wssInst.on('connection', (ws, req) => {
864
969
  isSwitching = true;
865
970
  previousSessionId = currentSessionId; // 记住旧会话,用于判断新会话是否已写入 DB
866
971
  currentSessionId = null; // 立即清除旧会话 ID
972
+ sessionStartTime = Date.now(); // 重置启动时间,防止查到旧会话
867
973
 
868
974
  if (mode === 'opencode' && opencodeProcess) {
869
975
  try { killProcessTree(opencodeProcess); } catch {}
@@ -888,18 +994,22 @@ wssInst.on('connection', (ws, req) => {
888
994
  }
889
995
  isSwitching = false;
890
996
 
891
- // 延迟检测新会话 ID(等 opencode 写入 DB
892
- const prevSid = previousSessionId;
997
+ // 延迟检测新会话 ID(等 opencode 写入 DB,必须在 sessionStartTime 之后且有消息)
998
+ const startTs = sessionStartTime;
893
999
  const checkNewSession = (attempt) => {
894
- if (currentSessionId || attempt > 10) return;
1000
+ if (currentSessionId || attempt > 15) return;
895
1001
  try {
896
1002
  if (existsSync(OPENCODE_DB_PATH)) {
897
1003
  const db = new Database(OPENCODE_DB_PATH, { readonly: true });
898
1004
  const row = db.prepare(
899
- `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_created DESC LIMIT 1`
900
- ).get();
1005
+ `SELECT s.id FROM session s
1006
+ WHERE s.parent_id IS NULL AND s.time_archived IS NULL
1007
+ AND s.time_created >= ?
1008
+ AND EXISTS (SELECT 1 FROM message m WHERE m.session_id = s.id)
1009
+ ORDER BY s.time_created DESC LIMIT 1`
1010
+ ).get(startTs);
901
1011
  db.close();
902
- if (row && row.id !== prevSid) {
1012
+ if (row) {
903
1013
  currentSessionId = row.id;
904
1014
  LOG(`[new-session] 检测到新会话 ID: ${currentSessionId}`);
905
1015
  return;
@@ -937,6 +1047,8 @@ wssInst.on('connection', (ws, req) => {
937
1047
  }
938
1048
  }
939
1049
  }
1050
+ // 无客户端连接时,30秒后自动退出
1051
+ startNoClientTimer();
940
1052
  });
941
1053
  });
942
1054
 
@@ -998,6 +1110,9 @@ function startServer() {
998
1110
  } else {
999
1111
  await spawnProcess('opencode');
1000
1112
  }
1113
+
1114
+ // 启动首次连接超时检测(3分钟无人连接则退出)
1115
+ startNoClientTimer();
1001
1116
  });
1002
1117
 
1003
1118
  server.on('error', (err) => {