claude-opencode-viewer 2.6.12 → 2.6.13

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 (3) hide show
  1. package/index.html +162 -91
  2. package/package.json +1 -1
  3. package/server.js +76 -19
package/index.html CHANGED
@@ -351,71 +351,94 @@
351
351
  color: #fff;
352
352
  }
353
353
 
354
- /* 选择模式:原位文本层 */
355
- #terminal.select-mode .xterm-screen {
356
- visibility: hidden;
357
- }
358
-
359
- #select-text-layer {
354
+ /* 消息查看器 */
355
+ #message-viewer {
360
356
  display: none;
361
- position: absolute;
357
+ position: fixed;
362
358
  top: 0; left: 0; right: 0; bottom: 0;
363
- overflow-y: auto;
364
- -webkit-overflow-scrolling: touch;
365
359
  background: #0a0a0a;
366
- padding: 4px 8px;
367
- touch-action: auto;
368
- z-index: 10;
360
+ z-index: 1000;
361
+ flex-direction: column;
369
362
  }
370
-
371
- #select-text-layer.visible {
372
- display: block;
363
+ #message-viewer.visible {
364
+ display: flex;
373
365
  }
374
-
375
- #select-hint {
376
- position: sticky;
377
- top: 0;
378
- background: rgba(30,30,30,0.95);
379
- color: #888;
380
- font-size: 11px;
381
- text-align: center;
382
- padding: 6px 0;
383
- border-bottom: 1px solid #333;
384
- z-index: 1;
385
- -webkit-user-select: none;
386
- user-select: none;
366
+ #msg-viewer-header {
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: space-between;
370
+ padding: 10px 14px;
371
+ background: #111;
372
+ border-bottom: 1px solid #222;
373
+ flex-shrink: 0;
387
374
  }
388
-
389
- #select-text-layer pre {
390
- margin: 0;
391
- color: #d4d4d4;
392
- font-family: Menlo, Monaco, "Courier New", monospace;
393
- font-size: 11px;
394
- line-height: 1.4;
395
- white-space: pre-wrap;
396
- word-break: break-all;
397
- -webkit-user-select: text;
398
- user-select: text;
375
+ #msg-viewer-header span {
376
+ color: #ccc;
377
+ font-size: 15px;
378
+ font-weight: 500;
399
379
  }
400
-
401
- #select-mode-close {
402
- position: absolute;
403
- top: 6px;
404
- right: 6px;
405
- z-index: 20;
406
- display: none;
380
+ #msg-viewer-close {
407
381
  background: rgba(50,50,50,0.9);
408
382
  border: 1px solid #555;
409
383
  color: #ccc;
410
- width: 28px;
411
- height: 28px;
384
+ width: 30px;
385
+ height: 30px;
412
386
  border-radius: 50%;
413
- font-size: 14px;
414
- line-height: 26px;
387
+ font-size: 15px;
388
+ line-height: 28px;
415
389
  text-align: center;
416
390
  cursor: pointer;
417
- -webkit-user-select: none;
418
- user-select: none;
391
+ }
392
+ #msg-viewer-content {
393
+ flex: 1;
394
+ overflow-y: auto;
395
+ -webkit-overflow-scrolling: touch;
396
+ padding: 12px;
397
+ }
398
+ .msg-item {
399
+ margin-bottom: 16px;
400
+ padding: 10px 12px;
401
+ border-radius: 8px;
402
+ border: 1px solid #222;
403
+ }
404
+ .msg-user {
405
+ background: #1a2332;
406
+ border-color: #2a4a7c;
407
+ }
408
+ .msg-assistant {
409
+ background: #1a2e1a;
410
+ border-color: #2a5a3a;
411
+ }
412
+ .msg-role {
413
+ font-size: 11px;
414
+ color: #888;
415
+ font-weight: 600;
416
+ text-transform: uppercase;
417
+ margin-bottom: 6px;
418
+ }
419
+ .msg-text {
420
+ color: #ddd;
421
+ font-size: 13px;
422
+ line-height: 1.6;
423
+ white-space: pre-wrap;
424
+ word-break: break-word;
425
+ -webkit-user-select: text;
426
+ user-select: text;
427
+ }
428
+ .msg-tool {
429
+ margin-top: 6px;
430
+ padding: 6px 8px;
431
+ background: #1a1a0a;
432
+ border: 1px solid #333;
433
+ border-radius: 4px;
434
+ font-size: 11px;
435
+ color: #f0ad4e;
436
+ }
437
+ .msg-empty {
438
+ text-align: center;
439
+ padding: 40px 20px;
440
+ color: #666;
441
+ font-size: 13px;
419
442
  }
420
443
 
421
444
  /* 复制成功提示 */
@@ -765,11 +788,6 @@
765
788
  <div id="content">
766
789
  <div id="terminal-container">
767
790
  <div id="terminal">
768
- <div id="select-text-layer">
769
- <div id="select-hint">长按选择文本 · 点右上角 ✕ 返回终端</div>
770
- <pre id="select-text-pre"></pre>
771
- </div>
772
- <button id="select-mode-close">✕</button>
773
791
  </div>
774
792
  <div id="virtual-keybar">
775
793
  <div class="virtual-key" data-key="up">↑</div>
@@ -785,6 +803,16 @@
785
803
  </div>
786
804
  </div>
787
805
 
806
+ <div id="message-viewer">
807
+ <div id="msg-viewer-header">
808
+ <span>会话消息</span>
809
+ <button id="msg-viewer-close">✕</button>
810
+ </div>
811
+ <div id="msg-viewer-content">
812
+ <div class="msg-empty">加载中...</div>
813
+ </div>
814
+ </div>
815
+
788
816
  <div id="copy-toast">已复制</div>
789
817
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
790
818
  <script>
@@ -931,7 +959,7 @@
931
959
  onVVChange();
932
960
  }
933
961
 
934
- // 参考 cc-viewer 的 TerminalPanel.jsx 行 410-447: 移动端固定尺寸计算
962
+ // 移动端固定尺寸计算:基于 #terminal 元素实际高度,确保终端与按钮栏齐平
935
963
  function mobileFixedResize() {
936
964
  if (!term) return;
937
965
  var cellDims = getCellDims();
@@ -941,12 +969,9 @@
941
969
  }
942
970
 
943
971
  var padX = 16;
944
- var padY = 8;
945
- var topBarHeight = 40;
946
- var keybarHeight = 52;
947
-
972
+ var termEl = document.getElementById('terminal');
948
973
  var availW = window.innerWidth - padX;
949
- var availH = window.innerHeight - topBarHeight - keybarHeight - padY;
974
+ var availH = termEl ? termEl.clientHeight : 300;
950
975
 
951
976
  var currentFontSize = term.options.fontSize;
952
977
  var currentCharWidth = cellDims.width;
@@ -1095,7 +1120,10 @@
1095
1120
  // 长按检测
1096
1121
  var longPressTimer = null;
1097
1122
  var longPressTriggered = false;
1098
- var LONG_PRESS_DELAY = 550; // ms
1123
+ var LONG_PRESS_DELAY = 800; // ms
1124
+ var LONG_PRESS_MOVE_THRESHOLD = 10; // px,移动超过此距离取消长按
1125
+ var touchStartX = 0;
1126
+ var touchStartY = 0;
1099
1127
 
1100
1128
  function clearLongPress() {
1101
1129
  if (longPressTimer) {
@@ -1116,6 +1144,8 @@
1116
1144
  lastY = e.touches[0].clientY;
1117
1145
  lastTime = performance.now();
1118
1146
  velocitySamples = [];
1147
+ touchStartX = e.touches[0].clientX;
1148
+ touchStartY = e.touches[0].clientY;
1119
1149
 
1120
1150
  // 启动长按计时器
1121
1151
  // 在长按检测期间阻止 xterm textarea 获取焦点,防止弹出键盘
@@ -1127,14 +1157,18 @@
1127
1157
  longPressTimer = setTimeout(function() {
1128
1158
  longPressTriggered = true;
1129
1159
  longPressTimer = null;
1130
- openSelectMode();
1160
+ openMessageViewer();
1131
1161
  }, LONG_PRESS_DELAY);
1132
1162
  }
1133
1163
 
1134
1164
  function handleTouchMove(e) {
1135
1165
  if (e.touches.length !== 1) return;
1136
- // 有移动则取消长按
1137
- clearLongPress();
1166
+ // 移动超过阈值才取消长按(容忍手指微小抖动)
1167
+ var dx = e.touches[0].clientX - touchStartX;
1168
+ var dy2 = e.touches[0].clientY - touchStartY;
1169
+ if (Math.abs(dx) > LONG_PRESS_MOVE_THRESHOLD || Math.abs(dy2) > LONG_PRESS_MOVE_THRESHOLD) {
1170
+ clearLongPress();
1171
+ }
1138
1172
  var y = e.touches[0].clientY;
1139
1173
  var now = performance.now();
1140
1174
  var dt = now - lastTime;
@@ -1818,47 +1852,84 @@
1818
1852
  }
1819
1853
 
1820
1854
 
1821
- // 方案2: 长按进入选择模式 — 原位显示可选纯文本
1822
- var selectTextLayer = document.getElementById('select-text-layer');
1823
- var selectTextPre = document.getElementById('select-text-pre');
1824
- var selectModeClose = document.getElementById('select-mode-close');
1825
- var inSelectMode = false;
1855
+ // 长按打开消息查看器
1856
+ var messageViewer = document.getElementById('message-viewer');
1857
+ var msgViewerContent = document.getElementById('msg-viewer-content');
1858
+ var msgViewerClose = document.getElementById('msg-viewer-close');
1826
1859
 
1827
- function openSelectMode() {
1828
- if (inSelectMode) return;
1829
- inSelectMode = true;
1860
+ function openMessageViewer() {
1830
1861
  // 收起键盘
1831
1862
  var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1832
1863
  if (xtermTa) xtermTa.blur();
1833
1864
  document.activeElement && document.activeElement.blur();
1834
- var text = getTerminalText();
1835
- selectTextPre.textContent = text || '(终端内容为空)';
1836
- terminalEl.classList.add('select-mode');
1837
- selectTextLayer.classList.add('visible');
1838
- selectModeClose.style.display = 'block';
1865
+
1866
+ messageViewer.classList.add('visible');
1867
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载中...</div>';
1839
1868
  unbindTouchScroll();
1869
+
1870
+ // 获取当前会话 ID
1871
+ fetch(basePath + '/api/current-session')
1872
+ .then(function(r) { return r.json(); })
1873
+ .then(function(data) {
1874
+ if (!data.sessionId) {
1875
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无活跃会话</div>';
1876
+ return;
1877
+ }
1878
+ return fetch(basePath + '/api/session/' + data.sessionId)
1879
+ .then(function(r) { return r.json(); })
1880
+ .then(function(messages) {
1881
+ renderMessages(messages);
1882
+ });
1883
+ })
1884
+ .catch(function(e) {
1885
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
1886
+ });
1887
+ }
1888
+
1889
+ function renderMessages(messages) {
1890
+ if (!messages || messages.length === 0) {
1891
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无消息</div>';
1892
+ return;
1893
+ }
1894
+ var html = '';
1895
+ messages.forEach(function(msg) {
1896
+ var role = msg.role || 'unknown';
1897
+ var roleLabel = role === 'user' ? '用户' : role === 'assistant' ? '助手' : role;
1898
+ var cls = role === 'user' ? 'msg-user' : role === 'assistant' ? 'msg-assistant' : '';
1899
+ html += '<div class="msg-item ' + cls + '">';
1900
+ html += '<div class="msg-role">' + roleLabel + '</div>';
1901
+ if (msg.text) {
1902
+ html += '<div class="msg-text">' + escapeHtml(msg.text) + '</div>';
1903
+ }
1904
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
1905
+ msg.toolCalls.forEach(function(tc) {
1906
+ html += '<div class="msg-tool">🔧 ' + escapeHtml(tc.name || 'tool') + '</div>';
1907
+ });
1908
+ }
1909
+ html += '</div>';
1910
+ });
1911
+ msgViewerContent.innerHTML = html;
1912
+ }
1913
+
1914
+ function escapeHtml(str) {
1915
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1840
1916
  }
1841
1917
 
1842
- function closeSelectMode() {
1843
- if (!inSelectMode) return;
1844
- inSelectMode = false;
1845
- terminalEl.classList.remove('select-mode');
1846
- selectTextLayer.classList.remove('visible');
1847
- selectModeClose.style.display = 'none';
1848
- window.getSelection().removeAllRanges();
1918
+ function closeMessageViewer() {
1919
+ messageViewer.classList.remove('visible');
1849
1920
  rebindTouchScroll();
1850
1921
  }
1851
1922
 
1852
- selectModeClose.addEventListener('click', function(e) {
1923
+ msgViewerClose.addEventListener('click', function(e) {
1853
1924
  e.preventDefault();
1854
1925
  e.stopPropagation();
1855
- closeSelectMode();
1926
+ closeMessageViewer();
1856
1927
  });
1857
1928
 
1858
- selectModeClose.addEventListener('touchend', function(e) {
1929
+ msgViewerClose.addEventListener('touchend', function(e) {
1859
1930
  e.preventDefault();
1860
1931
  e.stopPropagation();
1861
- closeSelectMode();
1932
+ closeMessageViewer();
1862
1933
  });
1863
1934
 
1864
1935
  // ======= Git Diff 功能 =======
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.12",
3
+ "version": "2.6.13",
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
@@ -15,7 +15,7 @@ import Database from 'better-sqlite3';
15
15
  process.title = 'claude-opencode-viewer';
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
- const PORT = parseInt(process.argv[2]) || 7008;
18
+ let PORT = parseInt(process.argv[2]) || 7008;
19
19
  const IS_PC = process.argv.includes('--pc');
20
20
  const USE_HTTPS = process.argv.includes('--https');
21
21
  const JSON_OUTPUT = process.argv.includes('--json');
@@ -72,6 +72,7 @@ let lastPtyCols = 120;
72
72
  let lastPtyRows = 30;
73
73
 
74
74
  let activeWs = null;
75
+ let currentSessionId = null;
75
76
  const clientSizes = new Map();
76
77
  const mobileClients = new Set();
77
78
  let currentMode = 'opencode';
@@ -229,6 +230,30 @@ async function spawnProcess(mode, sessionId = null) {
229
230
  } catch {}
230
231
  }
231
232
  opencodeProcess = proc;
233
+
234
+ // 追踪当前会话 ID
235
+ if (sessionId) {
236
+ currentSessionId = sessionId;
237
+ console.log(`[session] 当前会话 ID: ${currentSessionId}`);
238
+ } else {
239
+ // 新建会话:延迟查数据库获取最新 session ID
240
+ currentSessionId = null;
241
+ setTimeout(() => {
242
+ try {
243
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
244
+ const row = db.prepare(
245
+ `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_created DESC LIMIT 1`
246
+ ).get();
247
+ db.close();
248
+ if (row) {
249
+ currentSessionId = row.id;
250
+ console.log(`[session] 检测到新会话 ID: ${currentSessionId}`);
251
+ }
252
+ } catch (e) {
253
+ console.log('[session] 查询新会话 ID 失败:', e.message);
254
+ }
255
+ }, 3000);
256
+ }
232
257
  }
233
258
 
234
259
  currentProcess = proc;
@@ -479,6 +504,16 @@ const requestHandler = async (req, res) => {
479
504
  return;
480
505
  }
481
506
 
507
+ // API: 获取当前会话 ID
508
+ if (req.url === '/api/current-session') {
509
+ res.writeHead(200, {
510
+ 'Content-Type': 'application/json',
511
+ 'Access-Control-Allow-Origin': '*',
512
+ });
513
+ res.end(JSON.stringify({ sessionId: currentSessionId }));
514
+ return;
515
+ }
516
+
482
517
  // API: 获取 git status
483
518
  if (req.url === '/api/git-status') {
484
519
  res.writeHead(200, {
@@ -795,23 +830,45 @@ wss.on('connection', (ws, req) => {
795
830
  process.on('SIGINT', () => process.exit(0));
796
831
  process.on('SIGTERM', () => process.exit(0));
797
832
 
798
- server.listen(PORT, '0.0.0.0', async () => {
799
- const ip = getLocalIp();
800
- const proto = USE_HTTPS ? 'https' : 'http';
833
+ // PC 模式端口范围,用于端口冲突重试
834
+ const PC_PORT_MIN = 19200;
835
+ const PC_PORT_MAX = 19220;
836
+ const MAX_PORT_RETRIES = 10;
801
837
 
802
- if (JSON_OUTPUT) {
803
- // 输出一行 JSON 供外部程序解析,然后继续运行
804
- console.log(JSON.stringify({ port: PORT, url: `${proto}://127.0.0.1:${PORT}`, ip, proto, pid: process.pid }));
805
- } else {
806
- console.log('\n' + '='.repeat(50));
807
- console.log('✅ Claude OpenCode Viewer 已启动');
808
- console.log('='.repeat(50));
809
- console.log(`🖥️ 本地访问:${proto}://127.0.0.1:${PORT}`);
810
- console.log(`📱 手机访问:${proto}://${ip}:${PORT}`);
811
- if (USE_HTTPS) console.log('🔐 HTTPS 模式(首次访问需信任自签名证书)');
812
- console.log('='.repeat(50));
813
- console.log('\n按 Ctrl+C 停止服务\n');
814
- }
838
+ function startServer(retries = 0) {
839
+ server.listen(PORT, '0.0.0.0', async () => {
840
+ const ip = getLocalIp();
841
+ const proto = USE_HTTPS ? 'https' : 'http';
815
842
 
816
- await spawnProcess('opencode');
817
- });
843
+ if (JSON_OUTPUT) {
844
+ console.log(JSON.stringify({ port: PORT, url: `${proto}://127.0.0.1:${PORT}`, ip, proto, pid: process.pid }));
845
+ } else {
846
+ console.log('\n' + '='.repeat(50));
847
+ console.log('✅ Claude OpenCode Viewer 已启动');
848
+ console.log('='.repeat(50));
849
+ console.log(`🖥️ 本地访问:${proto}://127.0.0.1:${PORT}`);
850
+ console.log(`📱 手机访问:${proto}://${ip}:${PORT}`);
851
+ if (USE_HTTPS) console.log('🔐 HTTPS 模式(首次访问需信任自签名证书)');
852
+ console.log('='.repeat(50));
853
+ console.log('\n按 Ctrl+C 停止服务\n');
854
+ }
855
+
856
+ await spawnProcess('opencode');
857
+ });
858
+
859
+ server.on('error', (err) => {
860
+ if (err.code === 'EADDRINUSE' && IS_PC && retries < MAX_PORT_RETRIES) {
861
+ // PC 模式端口冲突,顺序查找下一个可用端口
862
+ const oldPort = PORT;
863
+ PORT = PORT >= PC_PORT_MAX ? PC_PORT_MIN : PORT + 1;
864
+ console.error(`[port] ${oldPort} 已占用,尝试 ${PORT} (${retries + 1}/${MAX_PORT_RETRIES})`);
865
+ server.removeAllListeners('error');
866
+ startServer(retries + 1);
867
+ } else {
868
+ console.error(`启动失败: ${err.message}`);
869
+ process.exit(1);
870
+ }
871
+ });
872
+ }
873
+
874
+ startServer();