claude-opencode-viewer 2.6.31 → 2.6.33

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 +272 -5
  2. package/index.html +266 -5
  3. package/package.json +1 -1
  4. package/server.js +245 -40
package/index-pc.html CHANGED
@@ -254,6 +254,69 @@
254
254
  border-radius: 3px;
255
255
  }
256
256
 
257
+ /* Claude 会话详情视图 */
258
+ #claude-detail-view {
259
+ display: flex;
260
+ flex-direction: column;
261
+ flex: 1;
262
+ min-height: 0;
263
+ }
264
+ #claude-detail-view.hidden { display: none; }
265
+
266
+ #claude-detail-header {
267
+ display: flex;
268
+ align-items: center;
269
+ padding: 10px 16px;
270
+ background: #111;
271
+ border-bottom: 1px solid #222;
272
+ flex-shrink: 0;
273
+ gap: 8px;
274
+ }
275
+
276
+ #claude-messages-container {
277
+ flex: 1;
278
+ overflow-y: auto;
279
+ padding: 12px 16px;
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 12px;
283
+ }
284
+
285
+ .claude-msg {
286
+ padding: 10px 14px;
287
+ border-radius: 6px;
288
+ font-size: 13px;
289
+ line-height: 1.6;
290
+ white-space: pre-wrap;
291
+ word-break: break-word;
292
+ max-width: 100%;
293
+ }
294
+ .claude-msg-user {
295
+ background: #1a2a3a;
296
+ border-left: 3px solid #4a9eff;
297
+ }
298
+ .claude-msg-assistant {
299
+ background: #1a1a2a;
300
+ border-left: 3px solid #c586c0;
301
+ }
302
+ .claude-msg-role {
303
+ font-size: 11px;
304
+ color: #888;
305
+ margin-bottom: 4px;
306
+ font-weight: 600;
307
+ }
308
+ .claude-msg-user .claude-msg-role { color: #4a9eff; }
309
+ .claude-msg-assistant .claude-msg-role { color: #c586c0; }
310
+ .claude-msg-text {
311
+ color: #d4d4d4;
312
+ }
313
+ .claude-msg-time {
314
+ font-size: 10px;
315
+ color: #666;
316
+ margin-top: 4px;
317
+ text-align: right;
318
+ }
319
+
257
320
  #mode-switcher {
258
321
  display: flex;
259
322
  gap: 4px;
@@ -777,6 +840,27 @@
777
840
  </div>
778
841
  </div>
779
842
 
843
+ <!-- Claude 会话详情视图 -->
844
+ <div id="claude-detail-view" class="hidden">
845
+ <div id="claude-detail-header">
846
+ <button class="history-toggle-btn" id="claude-detail-back">
847
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
848
+ <polyline points="15 18 9 12 15 6"></polyline>
849
+ </svg>
850
+ <span>返回列表</span>
851
+ </button>
852
+ <div id="claude-detail-title" style="flex:1; text-align:center; color:#ddd; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></div>
853
+ <button class="history-toggle-btn" id="claude-detail-close">
854
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
855
+ <line x1="18" y1="6" x2="6" y2="18"></line>
856
+ <line x1="6" y1="6" x2="18" y2="18"></line>
857
+ </svg>
858
+ <span>关闭</span>
859
+ </button>
860
+ </div>
861
+ <div id="claude-messages-container"></div>
862
+ </div>
863
+
780
864
  </div>
781
865
 
782
866
  <!-- Git Diff 面板 -->
@@ -877,7 +961,7 @@
877
961
  var isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
878
962
  var MOBILE_COLS = 60;
879
963
  var fontSize = isMobile ? 11 : 13;
880
- var currentMode = 'opencode';
964
+ var currentMode = 'claude';
881
965
  var isTransitioning = false;
882
966
  var isBufferReplay = true; // 初始缓冲区回放中,不弹 toast
883
967
 
@@ -1640,18 +1724,197 @@
1640
1724
 
1641
1725
  if (historyBarVisible) {
1642
1726
  historyBar.classList.add('visible');
1727
+ // 重置视图:显示列表,隐藏详情
1728
+ document.getElementById('session-list-view').classList.remove('hidden');
1729
+ document.getElementById('claude-detail-view').classList.add('hidden');
1643
1730
  if (currentMode === 'opencode') {
1731
+ document.getElementById('session-history-title').textContent = '历史会话';
1644
1732
  loadSessions();
1645
1733
  } else {
1646
- // claude 模式暂无历史会话功能
1647
- var sessionList = document.getElementById('session-list');
1648
- sessionList.innerHTML = '<div class="session-empty">Claude Code 暂不支持历史会话</div>';
1734
+ document.getElementById('session-history-title').textContent = 'Claude 历史会话';
1735
+ loadClaudeSessions();
1649
1736
  }
1650
1737
  } else {
1651
1738
  historyBar.classList.remove('visible');
1652
1739
  }
1653
1740
  }
1654
1741
 
1742
+ // Claude 会话功能
1743
+ function loadClaudeSessions() {
1744
+ var sessionList = document.getElementById('session-list');
1745
+ sessionList.innerHTML = '<div class="session-loading">加载 Claude 历史会话...</div>';
1746
+
1747
+ fetch(basePath + '/api/claude-sessions')
1748
+ .then(function(r) { return r.json(); })
1749
+ .then(function(data) {
1750
+ var sessions = data.sessions || [];
1751
+ if (sessions.length === 0) {
1752
+ sessionList.innerHTML = '<div class="session-empty">暂无 Claude 历史会话</div>';
1753
+ return;
1754
+ }
1755
+ sessionList.innerHTML = '';
1756
+ sessions.forEach(function(s) {
1757
+ var item = document.createElement('div');
1758
+ item.className = 'session-item';
1759
+
1760
+ var icon = document.createElement('div');
1761
+ icon.className = 'session-icon';
1762
+ icon.style.background = '#c586c0';
1763
+
1764
+ var info = document.createElement('div');
1765
+ info.className = 'session-info';
1766
+
1767
+ var title = document.createElement('div');
1768
+ title.className = 'session-title';
1769
+ title.textContent = s.preview || '(空会话)';
1770
+
1771
+ var meta = document.createElement('div');
1772
+ meta.className = 'session-meta';
1773
+ var time = document.createElement('span');
1774
+ time.className = 'session-time';
1775
+ time.textContent = formatTime(s.mtime);
1776
+ meta.appendChild(time);
1777
+ if (s.directory) {
1778
+ var dir = document.createElement('span');
1779
+ dir.className = 'session-dir';
1780
+ dir.textContent = s.directory.replace(/^\/Users\/[^\/]+/, '~');
1781
+ meta.appendChild(dir);
1782
+ }
1783
+
1784
+ info.appendChild(title);
1785
+ info.appendChild(meta);
1786
+ item.appendChild(icon);
1787
+ item.appendChild(info);
1788
+
1789
+ var deleteBtn = document.createElement('button');
1790
+ deleteBtn.className = 'session-delete-btn';
1791
+ deleteBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
1792
+ deleteBtn.title = '删除会话';
1793
+ deleteBtn.addEventListener('click', function(e) {
1794
+ e.stopPropagation();
1795
+ deleteClaudeSession(s.id, s.project, item);
1796
+ });
1797
+ item.appendChild(deleteBtn);
1798
+
1799
+ item.addEventListener('click', function() {
1800
+ restoreClaudeSession(s.id);
1801
+ });
1802
+
1803
+ sessionList.appendChild(item);
1804
+ });
1805
+ })
1806
+ .catch(function(err) {
1807
+ sessionList.innerHTML = '<div class="session-empty">加载失败: ' + err.message + '</div>';
1808
+ });
1809
+ }
1810
+
1811
+ function loadClaudeSessionDetail(sessionId, preview) {
1812
+ // 切换到详情视图
1813
+ document.getElementById('session-list-view').classList.add('hidden');
1814
+ var detailView = document.getElementById('claude-detail-view');
1815
+ detailView.classList.remove('hidden');
1816
+ document.getElementById('claude-detail-title').textContent = preview || sessionId;
1817
+
1818
+ var container = document.getElementById('claude-messages-container');
1819
+ container.innerHTML = '<div class="session-loading">加载对话内容...</div>';
1820
+
1821
+ fetch(basePath + '/api/claude-session-messages?id=' + encodeURIComponent(sessionId))
1822
+ .then(function(r) { return r.json(); })
1823
+ .then(function(data) {
1824
+ if (data.error) {
1825
+ container.innerHTML = '<div class="session-empty">' + escapeHtml(data.error) + '</div>';
1826
+ return;
1827
+ }
1828
+ var messages = data.messages || [];
1829
+ if (messages.length === 0) {
1830
+ container.innerHTML = '<div class="session-empty">无对话内容</div>';
1831
+ return;
1832
+ }
1833
+ container.innerHTML = '';
1834
+ messages.forEach(function(msg) {
1835
+ var div = document.createElement('div');
1836
+ div.className = 'claude-msg ' + (msg.role === 'user' ? 'claude-msg-user' : 'claude-msg-assistant');
1837
+
1838
+ var role = document.createElement('div');
1839
+ role.className = 'claude-msg-role';
1840
+ role.textContent = msg.role === 'user' ? 'User' : 'Assistant';
1841
+
1842
+ var text = document.createElement('div');
1843
+ text.className = 'claude-msg-text';
1844
+ text.textContent = msg.text;
1845
+
1846
+ div.appendChild(role);
1847
+ div.appendChild(text);
1848
+
1849
+ if (msg.timestamp) {
1850
+ var time = document.createElement('div');
1851
+ time.className = 'claude-msg-time';
1852
+ time.textContent = new Date(msg.timestamp).toLocaleString('zh-CN');
1853
+ div.appendChild(time);
1854
+ }
1855
+
1856
+ container.appendChild(div);
1857
+ });
1858
+ })
1859
+ .catch(function(err) {
1860
+ container.innerHTML = '<div class="session-empty">加载失败: ' + err.message + '</div>';
1861
+ });
1862
+ }
1863
+
1864
+ function deleteClaudeSession(sessionId, project, itemEl) {
1865
+ if (!confirm('确定要删除这个会话吗?')) return;
1866
+ itemEl.style.opacity = '0.4';
1867
+ itemEl.style.pointerEvents = 'none';
1868
+ fetch(basePath + '/api/claude-session/' + encodeURIComponent(sessionId) + '?project=' + encodeURIComponent(project), { method: 'DELETE' })
1869
+ .then(function(r) { return r.json(); })
1870
+ .then(function(data) {
1871
+ if (data.ok) {
1872
+ itemEl.style.transition = 'all 0.2s';
1873
+ itemEl.style.maxHeight = '0';
1874
+ itemEl.style.overflow = 'hidden';
1875
+ itemEl.style.padding = '0 12px';
1876
+ itemEl.style.margin = '0';
1877
+ itemEl.style.opacity = '0';
1878
+ setTimeout(function() { itemEl.remove(); }, 200);
1879
+ } else {
1880
+ itemEl.style.opacity = '';
1881
+ itemEl.style.pointerEvents = '';
1882
+ alert('删除失败: ' + (data.error || '未知错误'));
1883
+ }
1884
+ })
1885
+ .catch(function(err) {
1886
+ itemEl.style.opacity = '';
1887
+ itemEl.style.pointerEvents = '';
1888
+ alert('删除失败: ' + err.message);
1889
+ });
1890
+ }
1891
+
1892
+ function restoreClaudeSession(sessionId) {
1893
+ console.log('[restore] 恢复 Claude 会话:', sessionId);
1894
+ toggleHistoryBar();
1895
+ term.reset();
1896
+ if (ws && ws.readyState === 1) {
1897
+ if (currentMode !== 'claude') {
1898
+ term.write('错误: 请先切换到 Claude 模式\r\n');
1899
+ return;
1900
+ }
1901
+ ws.send(JSON.stringify({ type: 'restore', sessionId: sessionId }));
1902
+ } else {
1903
+ term.write('错误: WebSocket 未连接\r\n');
1904
+ }
1905
+ }
1906
+
1907
+ // Claude 详情返回按钮
1908
+ document.getElementById('claude-detail-back').addEventListener('click', function() {
1909
+ document.getElementById('claude-detail-view').classList.add('hidden');
1910
+ document.getElementById('session-list-view').classList.remove('hidden');
1911
+ });
1912
+
1913
+ // Claude 详情关闭按钮
1914
+ document.getElementById('claude-detail-close').addEventListener('click', function() {
1915
+ toggleHistoryBar();
1916
+ });
1917
+
1655
1918
  // 新会话按钮
1656
1919
  var isCreatingNewSession = false;
1657
1920
  document.getElementById('new-session-btn').addEventListener('click', function() {
@@ -1669,7 +1932,11 @@
1669
1932
  // 刷新会话列表
1670
1933
  document.getElementById('refresh-sessions').addEventListener('click', function(e) {
1671
1934
  e.stopPropagation();
1672
- loadSessions();
1935
+ if (currentMode === 'claude') {
1936
+ loadClaudeSessions();
1937
+ } else {
1938
+ loadSessions();
1939
+ }
1673
1940
  });
1674
1941
 
1675
1942
  // 关闭历史栏
package/index.html CHANGED
@@ -257,6 +257,67 @@
257
257
  border-radius: 3px;
258
258
  }
259
259
 
260
+ /* Claude 会话详情视图 */
261
+ #claude-detail-view {
262
+ display: flex;
263
+ flex-direction: column;
264
+ flex: 1;
265
+ min-height: 0;
266
+ }
267
+ #claude-detail-view.hidden { display: none; }
268
+
269
+ #claude-detail-header {
270
+ display: flex;
271
+ align-items: center;
272
+ padding: 10px 12px;
273
+ background: #111;
274
+ border-bottom: 1px solid #222;
275
+ flex-shrink: 0;
276
+ gap: 8px;
277
+ }
278
+
279
+ #claude-messages-container {
280
+ flex: 1;
281
+ overflow-y: auto;
282
+ padding: 10px 12px;
283
+ display: flex;
284
+ flex-direction: column;
285
+ gap: 10px;
286
+ -webkit-overflow-scrolling: touch;
287
+ }
288
+
289
+ .claude-msg {
290
+ padding: 8px 12px;
291
+ border-radius: 6px;
292
+ font-size: 13px;
293
+ line-height: 1.6;
294
+ white-space: pre-wrap;
295
+ word-break: break-word;
296
+ }
297
+ .claude-msg-user {
298
+ background: #1a2a3a;
299
+ border-left: 3px solid #4a9eff;
300
+ }
301
+ .claude-msg-assistant {
302
+ background: #1a1a2a;
303
+ border-left: 3px solid #c586c0;
304
+ }
305
+ .claude-msg-role {
306
+ font-size: 11px;
307
+ color: #888;
308
+ margin-bottom: 4px;
309
+ font-weight: 600;
310
+ }
311
+ .claude-msg-user .claude-msg-role { color: #4a9eff; }
312
+ .claude-msg-assistant .claude-msg-role { color: #c586c0; }
313
+ .claude-msg-text { color: #d4d4d4; }
314
+ .claude-msg-time {
315
+ font-size: 10px;
316
+ color: #666;
317
+ margin-top: 4px;
318
+ text-align: right;
319
+ }
320
+
260
321
  #mode-switcher {
261
322
  display: flex;
262
323
  gap: 4px;
@@ -852,6 +913,27 @@
852
913
  </div>
853
914
  </div>
854
915
 
916
+ <!-- Claude 会话详情视图 -->
917
+ <div id="claude-detail-view" class="hidden">
918
+ <div id="claude-detail-header">
919
+ <button class="history-toggle-btn" id="claude-detail-back">
920
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
921
+ <polyline points="15 18 9 12 15 6"></polyline>
922
+ </svg>
923
+ <span>返回</span>
924
+ </button>
925
+ <div id="claude-detail-title" style="flex:1; text-align:center; color:#ddd; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></div>
926
+ <button class="history-toggle-btn" id="claude-detail-close">
927
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
928
+ <line x1="18" y1="6" x2="6" y2="18"></line>
929
+ <line x1="6" y1="6" x2="18" y2="18"></line>
930
+ </svg>
931
+ <span>关闭</span>
932
+ </button>
933
+ </div>
934
+ <div id="claude-messages-container"></div>
935
+ </div>
936
+
855
937
  </div>
856
938
 
857
939
  <!-- Git Diff 面板 -->
@@ -968,7 +1050,7 @@
968
1050
  var isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
969
1051
  var MOBILE_COLS = 60;
970
1052
  var fontSize = isMobile ? 11 : 13;
971
- var currentMode = 'opencode';
1053
+ var currentMode = 'claude';
972
1054
  var isTransitioning = false;
973
1055
 
974
1056
  var term = new Terminal({
@@ -1868,18 +1950,193 @@
1868
1950
 
1869
1951
  if (historyBarVisible) {
1870
1952
  historyBar.classList.add('visible');
1953
+ document.getElementById('session-list-view').classList.remove('hidden');
1954
+ document.getElementById('claude-detail-view').classList.add('hidden');
1871
1955
  if (currentMode === 'opencode') {
1956
+ document.getElementById('session-history-title').textContent = '历史会话';
1872
1957
  loadSessions();
1873
1958
  } else {
1874
- // claude 模式暂无历史会话功能
1875
- var sessionList = document.getElementById('session-list');
1876
- sessionList.innerHTML = '<div class="session-empty">Claude Code 暂不支持历史会话</div>';
1959
+ document.getElementById('session-history-title').textContent = 'Claude 历史会话';
1960
+ loadClaudeSessions();
1877
1961
  }
1878
1962
  } else {
1879
1963
  historyBar.classList.remove('visible');
1880
1964
  }
1881
1965
  }
1882
1966
 
1967
+ // Claude 会话功能
1968
+ function loadClaudeSessions() {
1969
+ var sessionList = document.getElementById('session-list');
1970
+ sessionList.innerHTML = '<div class="session-loading">加载 Claude 历史会话...</div>';
1971
+
1972
+ fetch(basePath + '/api/claude-sessions')
1973
+ .then(function(r) { return r.json(); })
1974
+ .then(function(data) {
1975
+ var sessions = data.sessions || [];
1976
+ if (sessions.length === 0) {
1977
+ sessionList.innerHTML = '<div class="session-empty">暂无 Claude 历史会话</div>';
1978
+ return;
1979
+ }
1980
+ sessionList.innerHTML = '';
1981
+ sessions.forEach(function(s) {
1982
+ var item = document.createElement('div');
1983
+ item.className = 'session-item';
1984
+
1985
+ var icon = document.createElement('div');
1986
+ icon.className = 'session-icon';
1987
+ icon.style.background = '#c586c0';
1988
+
1989
+ var info = document.createElement('div');
1990
+ info.className = 'session-info';
1991
+
1992
+ var title = document.createElement('div');
1993
+ title.className = 'session-title';
1994
+ title.textContent = s.preview || '(空会话)';
1995
+
1996
+ var meta = document.createElement('div');
1997
+ meta.className = 'session-meta';
1998
+ var time = document.createElement('span');
1999
+ time.className = 'session-time';
2000
+ time.textContent = formatTime(s.mtime);
2001
+ meta.appendChild(time);
2002
+ if (s.directory) {
2003
+ var dir = document.createElement('span');
2004
+ dir.className = 'session-dir';
2005
+ dir.textContent = s.directory.replace(/^\/Users\/[^\/]+/, '~');
2006
+ meta.appendChild(dir);
2007
+ }
2008
+
2009
+ info.appendChild(title);
2010
+ info.appendChild(meta);
2011
+ item.appendChild(icon);
2012
+ item.appendChild(info);
2013
+
2014
+ var deleteBtn = document.createElement('button');
2015
+ deleteBtn.className = 'session-delete-btn';
2016
+ deleteBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
2017
+ deleteBtn.title = '删除会话';
2018
+ deleteBtn.addEventListener('click', function(e) {
2019
+ e.stopPropagation();
2020
+ deleteClaudeSession(s.id, s.project, item);
2021
+ });
2022
+ item.appendChild(deleteBtn);
2023
+
2024
+ item.addEventListener('click', function() {
2025
+ restoreClaudeSession(s.id);
2026
+ });
2027
+
2028
+ sessionList.appendChild(item);
2029
+ });
2030
+ })
2031
+ .catch(function(err) {
2032
+ sessionList.innerHTML = '<div class="session-empty">加载失败: ' + err.message + '</div>';
2033
+ });
2034
+ }
2035
+
2036
+ function loadClaudeSessionDetail(sessionId, preview) {
2037
+ document.getElementById('session-list-view').classList.add('hidden');
2038
+ var detailView = document.getElementById('claude-detail-view');
2039
+ detailView.classList.remove('hidden');
2040
+ document.getElementById('claude-detail-title').textContent = preview || sessionId;
2041
+
2042
+ var container = document.getElementById('claude-messages-container');
2043
+ container.innerHTML = '<div class="session-loading">加载对话内容...</div>';
2044
+
2045
+ fetch(basePath + '/api/claude-session-messages?id=' + encodeURIComponent(sessionId))
2046
+ .then(function(r) { return r.json(); })
2047
+ .then(function(data) {
2048
+ if (data.error) {
2049
+ container.innerHTML = '<div class="session-empty">' + escapeHtml(data.error) + '</div>';
2050
+ return;
2051
+ }
2052
+ var messages = data.messages || [];
2053
+ if (messages.length === 0) {
2054
+ container.innerHTML = '<div class="session-empty">无对话内容</div>';
2055
+ return;
2056
+ }
2057
+ container.innerHTML = '';
2058
+ messages.forEach(function(msg) {
2059
+ var div = document.createElement('div');
2060
+ div.className = 'claude-msg ' + (msg.role === 'user' ? 'claude-msg-user' : 'claude-msg-assistant');
2061
+
2062
+ var role = document.createElement('div');
2063
+ role.className = 'claude-msg-role';
2064
+ role.textContent = msg.role === 'user' ? 'User' : 'Assistant';
2065
+
2066
+ var text = document.createElement('div');
2067
+ text.className = 'claude-msg-text';
2068
+ text.textContent = msg.text;
2069
+
2070
+ div.appendChild(role);
2071
+ div.appendChild(text);
2072
+
2073
+ if (msg.timestamp) {
2074
+ var time = document.createElement('div');
2075
+ time.className = 'claude-msg-time';
2076
+ time.textContent = new Date(msg.timestamp).toLocaleString('zh-CN');
2077
+ div.appendChild(time);
2078
+ }
2079
+
2080
+ container.appendChild(div);
2081
+ });
2082
+ })
2083
+ .catch(function(err) {
2084
+ container.innerHTML = '<div class="session-empty">加载失败: ' + err.message + '</div>';
2085
+ });
2086
+ }
2087
+
2088
+ function deleteClaudeSession(sessionId, project, itemEl) {
2089
+ if (!confirm('确定要删除这个会话吗?')) return;
2090
+ itemEl.style.opacity = '0.4';
2091
+ itemEl.style.pointerEvents = 'none';
2092
+ fetch(basePath + '/api/claude-session/' + encodeURIComponent(sessionId) + '?project=' + encodeURIComponent(project), { method: 'DELETE' })
2093
+ .then(function(r) { return r.json(); })
2094
+ .then(function(data) {
2095
+ if (data.ok) {
2096
+ itemEl.style.transition = 'all 0.2s';
2097
+ itemEl.style.maxHeight = '0';
2098
+ itemEl.style.overflow = 'hidden';
2099
+ itemEl.style.padding = '0 12px';
2100
+ itemEl.style.margin = '0';
2101
+ itemEl.style.opacity = '0';
2102
+ setTimeout(function() { itemEl.remove(); }, 200);
2103
+ } else {
2104
+ itemEl.style.opacity = '';
2105
+ itemEl.style.pointerEvents = '';
2106
+ alert('删除失败: ' + (data.error || '未知错误'));
2107
+ }
2108
+ })
2109
+ .catch(function(err) {
2110
+ itemEl.style.opacity = '';
2111
+ itemEl.style.pointerEvents = '';
2112
+ alert('删除失败: ' + err.message);
2113
+ });
2114
+ }
2115
+
2116
+ function restoreClaudeSession(sessionId) {
2117
+ console.log('[restore] 恢复 Claude 会话:', sessionId);
2118
+ toggleHistoryBar();
2119
+ term.reset();
2120
+ if (ws && ws.readyState === 1) {
2121
+ if (currentMode !== 'claude') {
2122
+ term.write('错误: 请先切换到 Claude 模式\r\n');
2123
+ return;
2124
+ }
2125
+ ws.send(JSON.stringify({ type: 'restore', sessionId: sessionId }));
2126
+ } else {
2127
+ term.write('错误: WebSocket 未连接\r\n');
2128
+ }
2129
+ }
2130
+
2131
+ document.getElementById('claude-detail-back').addEventListener('click', function() {
2132
+ document.getElementById('claude-detail-view').classList.add('hidden');
2133
+ document.getElementById('session-list-view').classList.remove('hidden');
2134
+ });
2135
+
2136
+ document.getElementById('claude-detail-close').addEventListener('click', function() {
2137
+ toggleHistoryBar();
2138
+ });
2139
+
1883
2140
  // 新会话按钮
1884
2141
  var isCreatingNewSession = false;
1885
2142
  document.getElementById('new-session-btn').addEventListener('click', function() {
@@ -1897,7 +2154,11 @@
1897
2154
  // 刷新会话列表
1898
2155
  document.getElementById('refresh-sessions').addEventListener('click', function(e) {
1899
2156
  e.stopPropagation();
1900
- loadSessions();
2157
+ if (currentMode === 'claude') {
2158
+ loadClaudeSessions();
2159
+ } else {
2160
+ loadSessions();
2161
+ }
1901
2162
  });
1902
2163
 
1903
2164
  // 关闭历史栏
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.31",
3
+ "version": "2.6.33",
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
@@ -53,6 +53,9 @@ async function getOrCreateCert() {
53
53
  return { key: pems.private, cert: pems.cert };
54
54
  }
55
55
 
56
+ // Claude Code 数据目录(Sandbox 下优先读 NAS 上的 /halo/.claude,本地回退 ~/.claude)
57
+ const CLAUDE_HOME = existsSync('/halo/.claude') ? '/halo/.claude' : join(homedir(), '.claude');
58
+
56
59
  // OpenCode 数据库路径(优先读 OPENCODE_DB 环境变量,兼容本地盘 DB 方案)
57
60
  const OPENCODE_DB_PATH = process.env.OPENCODE_DB || process.env.OPENCODE_DB_PATH || join(
58
61
  process.env.XDG_DATA_HOME || join(homedir(), '.local/share'),
@@ -99,7 +102,7 @@ let sessionStartTime = 0; // 新会话启动时间,用于限制查询范围
99
102
  let previousSessionId = null; // 用于判断新会话是否已写入 DB
100
103
  const clientSizes = new Map();
101
104
  const mobileClients = new Set();
102
- let currentMode = 'opencode';
105
+ let currentMode = 'claude';
103
106
  let isSwitching = false; // 模式切换/恢复会话中,忽略旧进程退出事件
104
107
 
105
108
  function getMobileSize() {
@@ -142,24 +145,21 @@ function getLocalIp() {
142
145
 
143
146
  function findCommand(cmd) {
144
147
  const paths = [
145
- cmd,
146
148
  '/opt/homebrew/bin/' + cmd,
147
149
  '/usr/local/bin/' + cmd,
148
150
  '/usr/bin/' + cmd,
149
151
  join(process.env.HOME, '.local/bin/' + cmd),
150
- join(process.env.HOME, '.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js'),
151
152
  ];
153
+ if (cmd === 'claude') {
154
+ paths.push(join(process.env.HOME, '.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js'));
155
+ }
152
156
  for (const p of paths) {
153
- if (p === 'opencode' && existsSync(p)) return p;
154
- if (p === 'claude' && existsSync(p)) return p;
155
- if (p === 'claude' && p.includes('@anthropic-ai')) {
156
- try {
157
- return execSync(`which claude`, { encoding: 'utf8' }).trim();
158
- } catch {}
159
- }
157
+ if (existsSync(p)) return p;
160
158
  }
159
+ // fallback: which (non-interactive shell, no aliases)
161
160
  try {
162
- return execSync(`which ${cmd}`, { encoding: 'utf8' }).trim();
161
+ const result = execSync(`command -v ${cmd} 2>/dev/null || which ${cmd} 2>/dev/null`, { encoding: 'utf8', shell: '/bin/sh' }).trim();
162
+ if (result && !result.includes('alias') && existsSync(result)) return result;
163
163
  } catch {}
164
164
  return cmd;
165
165
  }
@@ -202,10 +202,11 @@ function killProcessTree(proc) {
202
202
  // 清理孤儿进程(PPID=1 的 opencode/claude)
203
203
  function cleanupOrphanProcesses() {
204
204
  try {
205
+ const myPid = process.pid;
205
206
  const orphans = execSync(
206
207
  "ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && (/opencode/||/claude/) {print $1}'",
207
208
  { encoding: 'utf-8', timeout: 5000 }
208
- ).trim().split(/\s+/).filter(Boolean).map(Number);
209
+ ).trim().split(/\s+/).filter(Boolean).map(Number).filter(pid => pid !== myPid);
209
210
  for (const pid of orphans) {
210
211
  try { process.kill(pid, 'SIGKILL'); } catch {}
211
212
  }
@@ -226,6 +227,11 @@ async function spawnProcess(mode, sessionId = null) {
226
227
  } else {
227
228
  command = claudePath;
228
229
  }
230
+ // 如果提供了 sessionId,添加 --resume 参数
231
+ if (sessionId) {
232
+ args.push('--resume', sessionId);
233
+ LOG(`[claude] 恢复会话: ${sessionId}`);
234
+ }
229
235
  } else {
230
236
  command = findCommand('opencode');
231
237
  // 如果提供了 sessionId,添加 --session 参数
@@ -239,7 +245,7 @@ async function spawnProcess(mode, sessionId = null) {
239
245
 
240
246
  // 恢复会话时,使用会话记录的工作目录
241
247
  let spawnCwd = process.cwd();
242
- if (sessionId) {
248
+ if (sessionId && mode === 'opencode') {
243
249
  try {
244
250
  const db = new Database(OPENCODE_DB_PATH, { readonly: true });
245
251
  const session = db.prepare('SELECT directory FROM session WHERE id = ?').get(sessionId);
@@ -251,6 +257,33 @@ async function spawnProcess(mode, sessionId = null) {
251
257
  } catch (e) {
252
258
  LOG('[opencode] 查询会话目录失败:', e.message);
253
259
  }
260
+ } else if (sessionId && mode === 'claude') {
261
+ // 从 JSONL 读取 cwd
262
+ try {
263
+ const projectsDir = join(CLAUDE_HOME, 'projects');
264
+ // 在所有项目目录中查找该会话文件
265
+ for (const projDir of readdirSync(projectsDir, { withFileTypes: true })) {
266
+ if (!projDir.isDirectory()) continue;
267
+ const filePath = join(projectsDir, projDir.name, sessionId + '.jsonl');
268
+ if (existsSync(filePath)) {
269
+ const lines = readFileSync(filePath, 'utf-8').split('\n').slice(0, 30);
270
+ for (const line of lines) {
271
+ if (!line.trim()) continue;
272
+ try {
273
+ const d = JSON.parse(line);
274
+ if (d.cwd && existsSync(d.cwd)) {
275
+ spawnCwd = d.cwd;
276
+ LOG(`[claude] 使用会话目录: ${spawnCwd}`);
277
+ break;
278
+ }
279
+ } catch {}
280
+ }
281
+ break;
282
+ }
283
+ }
284
+ } catch (e) {
285
+ LOG('[claude] 查询会话目录失败:', e.message);
286
+ }
254
287
  }
255
288
 
256
289
  const proc = pty.spawn(command, args, {
@@ -609,7 +642,7 @@ const requestHandler = async (req, res) => {
609
642
  'Access-Control-Allow-Origin': '*',
610
643
  });
611
644
  try {
612
- const gitCwd = existsSync('/halo/code') ? '/halo/code' : process.cwd();
645
+ const gitCwd = process.env.PROJECT_DIR || process.cwd();
613
646
  const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
614
647
  cwd: gitCwd, encoding: 'utf-8', timeout: 5000,
615
648
  });
@@ -618,8 +651,8 @@ const requestHandler = async (req, res) => {
618
651
  file: line.substring(3),
619
652
  })).filter(c => !/^core-/.test(c.file));
620
653
  res.end(JSON.stringify({ changes }));
621
- } catch (err) {
622
- res.end(JSON.stringify({ changes: [], error: err.message }));
654
+ } catch {
655
+ res.end(JSON.stringify({ changes: [] }));
623
656
  }
624
657
  return;
625
658
  }
@@ -638,7 +671,7 @@ const requestHandler = async (req, res) => {
638
671
  }
639
672
  const fileList = files.split(',').filter(Boolean);
640
673
  const diffs = [];
641
- const cwd = existsSync('/halo/code') ? '/halo/code' : process.cwd();
674
+ const cwd = process.env.PROJECT_DIR || process.cwd();
642
675
  for (const file of fileList) {
643
676
  if (file.includes('..') || file.startsWith('/')) continue;
644
677
  try {
@@ -749,6 +782,117 @@ const requestHandler = async (req, res) => {
749
782
  return;
750
783
  }
751
784
 
785
+ // API: Claude Code 会话列表(扫描所有项目)
786
+ if (req.url === '/api/claude-sessions') {
787
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
788
+ try {
789
+ const projectsDir = join(CLAUDE_HOME, 'projects');
790
+ if (!existsSync(projectsDir)) {
791
+ res.end(JSON.stringify({ sessions: [] }));
792
+ return;
793
+ }
794
+ const allSessions = [];
795
+ // 遍历所有项目目录
796
+ for (const projDir of readdirSync(projectsDir, { withFileTypes: true })) {
797
+ if (!projDir.isDirectory()) continue;
798
+ const projPath = join(projectsDir, projDir.name);
799
+ // 解码项目路径: -Users-jason-xxx → /Users/jason/xxx
800
+ const decodedPath = projDir.name.replace(/^-/, '/').replace(/-/g, '/');
801
+ for (const f of readdirSync(projPath)) {
802
+ if (!f.endsWith('.jsonl')) continue;
803
+ try {
804
+ const fullPath = join(projPath, f);
805
+ const st = statSync(fullPath);
806
+ const id = f.replace('.jsonl', '');
807
+ let preview = '', cwd = '';
808
+ // 读取前 30 行提取第一条用户消息和 cwd
809
+ const content = readFileSync(fullPath, 'utf-8');
810
+ const lines = content.split('\n').slice(0, 30);
811
+ for (const line of lines) {
812
+ if (!line.trim()) continue;
813
+ try {
814
+ const d = JSON.parse(line);
815
+ if (d.type === 'user' && d.message) {
816
+ if (!cwd && d.cwd) cwd = d.cwd;
817
+ if (!preview) {
818
+ const c = d.message.content;
819
+ if (typeof c === 'string') {
820
+ preview = c.slice(0, 200);
821
+ } else if (Array.isArray(c)) {
822
+ for (const item of c) {
823
+ if (item.type === 'text' && item.text) {
824
+ preview = item.text.slice(0, 200);
825
+ break;
826
+ }
827
+ }
828
+ }
829
+ }
830
+ break;
831
+ }
832
+ } catch {}
833
+ }
834
+ allSessions.push({ id, preview, mtime: st.mtimeMs, project: projDir.name, directory: cwd || decodedPath });
835
+ } catch {}
836
+ }
837
+ }
838
+ allSessions.sort((a, b) => b.mtime - a.mtime);
839
+ res.end(JSON.stringify({ sessions: allSessions.slice(0, 50) }));
840
+ } catch (err) {
841
+ res.end(JSON.stringify({ sessions: [], error: err.message }));
842
+ }
843
+ return;
844
+ }
845
+
846
+ // API: Claude Code 会话消息详情
847
+ if (req.url?.startsWith('/api/claude-session-messages')) {
848
+ const url = new URL(req.url, `http://${req.headers.host}`);
849
+ const id = url.searchParams.get('id');
850
+ const project = url.searchParams.get('project');
851
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
852
+ if (!id || /[\/\\]|\.\./.test(id) || !project || /[\/\\]|\.\./.test(project)) {
853
+ res.end(JSON.stringify({ error: '无效参数' }));
854
+ return;
855
+ }
856
+ try {
857
+ const filePath = join(CLAUDE_HOME, 'projects', project, id + '.jsonl');
858
+ if (!existsSync(filePath)) {
859
+ res.end(JSON.stringify({ error: '会话不存在' }));
860
+ return;
861
+ }
862
+ const content = readFileSync(filePath, 'utf-8');
863
+ const messages = [];
864
+ for (const line of content.split('\n')) {
865
+ if (!line.trim()) continue;
866
+ try {
867
+ const d = JSON.parse(line);
868
+ if (d.type === 'user' && d.message) {
869
+ let text = '';
870
+ const c = d.message.content;
871
+ if (typeof c === 'string') {
872
+ text = c;
873
+ } else if (Array.isArray(c)) {
874
+ text = c.filter(i => i.type === 'text').map(i => i.text).join('\n');
875
+ }
876
+ if (text) messages.push({ role: 'user', text, timestamp: d.timestamp });
877
+ } else if ((d.type === 'assistant' || d.message?.role === 'assistant') && d.message?.content) {
878
+ const c = d.message.content;
879
+ let text = '';
880
+ if (typeof c === 'string') {
881
+ text = c;
882
+ } else if (Array.isArray(c)) {
883
+ text = c.filter(i => i.type === 'text').map(i => i.text).join('\n');
884
+ }
885
+ if (text) messages.push({ role: 'assistant', text, timestamp: d.timestamp });
886
+ }
887
+ } catch {}
888
+ }
889
+ res.end(JSON.stringify({ messages }));
890
+ } catch (err) {
891
+ res.end(JSON.stringify({ error: err.message }));
892
+ }
893
+ return;
894
+ }
895
+
752
896
  // API: 软删除会话(设置 time_archived)
753
897
  if (req.method === 'DELETE' && req.url?.startsWith('/api/session/')) {
754
898
  const sessionId = req.url.split('/').pop();
@@ -773,6 +917,31 @@ const requestHandler = async (req, res) => {
773
917
  return;
774
918
  }
775
919
 
920
+ // API: 删除 Claude 会话(删除 JSONL 文件)
921
+ if (req.method === 'DELETE' && req.url?.startsWith('/api/claude-session/')) {
922
+ const url = new URL(req.url, `http://${req.headers.host}`);
923
+ const id = url.pathname.split('/').pop();
924
+ const project = url.searchParams.get('project');
925
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
926
+ if (!id || /[\/\\]|\.\./.test(id) || !project || /[\/\\]|\.\./.test(project)) {
927
+ res.end(JSON.stringify({ ok: false, error: '无效参数' }));
928
+ return;
929
+ }
930
+ try {
931
+ const { unlinkSync } = await import('node:fs');
932
+ const filePath = join(CLAUDE_HOME, 'projects', project, id + '.jsonl');
933
+ if (!existsSync(filePath)) {
934
+ res.end(JSON.stringify({ ok: false, error: '文件不存在' }));
935
+ return;
936
+ }
937
+ unlinkSync(filePath);
938
+ res.end(JSON.stringify({ ok: true }));
939
+ } catch (err) {
940
+ res.end(JSON.stringify({ ok: false, error: err.message }));
941
+ }
942
+ return;
943
+ }
944
+
776
945
  // API: 获取会话消息
777
946
  if (req.url?.startsWith('/api/session/')) {
778
947
  const sessionId = req.url.split('/').pop();
@@ -940,21 +1109,26 @@ wssInst.on('connection', (ws, req) => {
940
1109
  }, 100);
941
1110
  }
942
1111
  } else if (msg.type === 'restore') {
943
- // 恢复会话
944
- if (msg.sessionId && currentMode === 'opencode') {
945
- LOG(`[restore] 恢复会话: ${msg.sessionId}`);
1112
+ // 恢复会话(支持 opencode 和 claude)
1113
+ if (msg.sessionId) {
1114
+ LOG(`[restore] 恢复 ${currentMode} 会话: ${msg.sessionId}`);
946
1115
 
947
1116
  isSwitching = true;
948
1117
 
949
- // 杀死当前 opencode 进程
950
- if (opencodeProcess) {
1118
+ // 杀死当前进程
1119
+ const proc = currentMode === 'opencode' ? opencodeProcess : claudeProcess;
1120
+ if (proc) {
951
1121
  try {
952
- LOG(`[restore] 杀死当前进程 PID: ${opencodeProcess.pid}`);
953
- killProcessTree(opencodeProcess);
1122
+ LOG(`[restore] 杀死当前进程 PID: ${proc.pid}`);
1123
+ killProcessTree(proc);
954
1124
  } catch (e) {
955
1125
  LOG('[restore] 杀死进程失败:', e.message);
956
1126
  }
957
- opencodeProcess = null;
1127
+ if (currentMode === 'opencode') {
1128
+ opencodeProcess = null;
1129
+ } else {
1130
+ claudeProcess = null;
1131
+ }
958
1132
  currentProcess = null;
959
1133
  }
960
1134
 
@@ -965,9 +1139,9 @@ wssInst.on('connection', (ws, req) => {
965
1139
  await new Promise(resolve => setTimeout(resolve, 500));
966
1140
  cleanupOrphanProcesses();
967
1141
 
968
- // 启动新的 opencode 进程,传入 session ID
1142
+ // 启动进程,传入 session ID
969
1143
  try {
970
- await spawnProcess('opencode', msg.sessionId);
1144
+ await spawnProcess(currentMode, msg.sessionId);
971
1145
  ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId }));
972
1146
  } catch (e) {
973
1147
  LOG('[restore] 启动进程失败:', e.message);
@@ -1124,21 +1298,52 @@ function startServer() {
1124
1298
  cleanupOrphanProcesses();
1125
1299
 
1126
1300
  // 尝试恢复最近的会话,如果没有则新建
1127
- let lastSessionId = null;
1128
- try {
1129
- const db = new Database(OPENCODE_DB_PATH, { readonly: true });
1130
- const row = db.prepare(
1131
- `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_updated DESC LIMIT 1`
1132
- ).get();
1133
- db.close();
1134
- if (row) lastSessionId = row.id;
1135
- } catch (e) {}
1301
+ if (currentMode === 'opencode') {
1302
+ let lastSessionId = null;
1303
+ try {
1304
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
1305
+ const row = db.prepare(
1306
+ `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_updated DESC LIMIT 1`
1307
+ ).get();
1308
+ db.close();
1309
+ if (row) lastSessionId = row.id;
1310
+ } catch (e) {}
1136
1311
 
1137
- if (lastSessionId) {
1138
- LOG(`[startup] 恢复最近会话: ${lastSessionId}`);
1139
- await spawnProcess('opencode', lastSessionId);
1312
+ if (lastSessionId) {
1313
+ LOG(`[startup] 恢复最近会话: ${lastSessionId}`);
1314
+ await spawnProcess('opencode', lastSessionId);
1315
+ } else {
1316
+ await spawnProcess('opencode');
1317
+ }
1140
1318
  } else {
1141
- await spawnProcess('opencode');
1319
+ // Claude 模式:找最近的会话恢复
1320
+ let lastClaudeSession = null;
1321
+ try {
1322
+ const projectsDir = join(CLAUDE_HOME, 'projects');
1323
+ if (existsSync(projectsDir)) {
1324
+ let newest = 0;
1325
+ for (const projDir of readdirSync(projectsDir, { withFileTypes: true })) {
1326
+ if (!projDir.isDirectory()) continue;
1327
+ const projPath = join(projectsDir, projDir.name);
1328
+ for (const f of readdirSync(projPath)) {
1329
+ if (!f.endsWith('.jsonl')) continue;
1330
+ try {
1331
+ const st = statSync(join(projPath, f));
1332
+ if (st.mtimeMs > newest) {
1333
+ newest = st.mtimeMs;
1334
+ lastClaudeSession = f.replace('.jsonl', '');
1335
+ }
1336
+ } catch {}
1337
+ }
1338
+ }
1339
+ }
1340
+ } catch {}
1341
+ if (lastClaudeSession) {
1342
+ LOG(`[startup] 恢复最近Claude会话: ${lastClaudeSession}`);
1343
+ await spawnProcess('claude', lastClaudeSession);
1344
+ } else {
1345
+ await spawnProcess('claude');
1346
+ }
1142
1347
  }
1143
1348
 
1144
1349
  // 启动首次连接超时检测(3分钟无人连接则退出)