claude-opencode-viewer 2.6.12 → 2.6.14

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 +165 -135
  2. package/package.json +1 -1
  3. package/server.js +92 -19
package/index.html CHANGED
@@ -174,13 +174,13 @@
174
174
  background: none;
175
175
  border: 1px solid #333;
176
176
  color: #aaa;
177
- padding: 4px 10px;
178
- font-size: 12px;
177
+ padding: 3px 6px;
178
+ font-size: 11px;
179
179
  cursor: pointer;
180
180
  border-radius: 4px;
181
181
  display: flex;
182
182
  align-items: center;
183
- gap: 4px;
183
+ gap: 3px;
184
184
  flex-shrink: 0;
185
185
  }
186
186
 
@@ -191,8 +191,8 @@
191
191
  }
192
192
 
193
193
  .history-toggle-btn svg {
194
- width: 14px;
195
- height: 14px;
194
+ width: 12px;
195
+ height: 12px;
196
196
  }
197
197
 
198
198
  .session-loading {
@@ -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
  /* 复制成功提示 */
@@ -680,6 +703,12 @@
680
703
  </svg>
681
704
  <span>Diff</span>
682
705
  </button>
706
+ <button class="history-toggle-btn" id="msg-toggle" style="color:#c9a0dc; border-color:#5a3a6a;">
707
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
708
+ <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>
709
+ </svg>
710
+ <span>消息</span>
711
+ </button>
683
712
  </div>
684
713
  <div id="mode-switcher">
685
714
  <span id="mode-label"></span>
@@ -765,11 +794,6 @@
765
794
  <div id="content">
766
795
  <div id="terminal-container">
767
796
  <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
797
  </div>
774
798
  <div id="virtual-keybar">
775
799
  <div class="virtual-key" data-key="up">↑</div>
@@ -785,6 +809,16 @@
785
809
  </div>
786
810
  </div>
787
811
 
812
+ <div id="message-viewer">
813
+ <div id="msg-viewer-header">
814
+ <span>会话消息</span>
815
+ <button id="msg-viewer-close">✕</button>
816
+ </div>
817
+ <div id="msg-viewer-content">
818
+ <div class="msg-empty">加载中...</div>
819
+ </div>
820
+ </div>
821
+
788
822
  <div id="copy-toast">已复制</div>
789
823
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
790
824
  <script>
@@ -931,7 +965,7 @@
931
965
  onVVChange();
932
966
  }
933
967
 
934
- // 参考 cc-viewer 的 TerminalPanel.jsx 行 410-447: 移动端固定尺寸计算
968
+ // 移动端固定尺寸计算:基于 #terminal 元素实际高度,确保终端与按钮栏齐平
935
969
  function mobileFixedResize() {
936
970
  if (!term) return;
937
971
  var cellDims = getCellDims();
@@ -941,12 +975,9 @@
941
975
  }
942
976
 
943
977
  var padX = 16;
944
- var padY = 8;
945
- var topBarHeight = 40;
946
- var keybarHeight = 52;
947
-
978
+ var termEl = document.getElementById('terminal');
948
979
  var availW = window.innerWidth - padX;
949
- var availH = window.innerHeight - topBarHeight - keybarHeight - padY;
980
+ var availH = termEl ? termEl.clientHeight : 300;
950
981
 
951
982
  var currentFontSize = term.options.fontSize;
952
983
  var currentCharWidth = cellDims.width;
@@ -1092,49 +1123,17 @@
1092
1123
  }
1093
1124
  }
1094
1125
 
1095
- // 长按检测
1096
- var longPressTimer = null;
1097
- var longPressTriggered = false;
1098
- var LONG_PRESS_DELAY = 550; // ms
1099
-
1100
- function clearLongPress() {
1101
- if (longPressTimer) {
1102
- clearTimeout(longPressTimer);
1103
- longPressTimer = null;
1104
- }
1105
- // 始终恢复 xterm textarea,防止 disabled 残留
1106
- var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1107
- if (xtermTa) xtermTa.removeAttribute('disabled');
1108
- }
1109
-
1110
1126
  function handleTouchStart(e) {
1111
1127
  stopMomentum();
1112
1128
  altScrollAccum = 0;
1113
- longPressTriggered = false;
1114
- clearLongPress();
1115
1129
  if (e.touches.length !== 1) return;
1116
1130
  lastY = e.touches[0].clientY;
1117
1131
  lastTime = performance.now();
1118
1132
  velocitySamples = [];
1119
-
1120
- // 启动长按计时器
1121
- // 在长按检测期间阻止 xterm textarea 获取焦点,防止弹出键盘
1122
- var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1123
- if (xtermTa) {
1124
- xtermTa.setAttribute('disabled', 'true');
1125
- }
1126
-
1127
- longPressTimer = setTimeout(function() {
1128
- longPressTriggered = true;
1129
- longPressTimer = null;
1130
- openSelectMode();
1131
- }, LONG_PRESS_DELAY);
1132
1133
  }
1133
1134
 
1134
1135
  function handleTouchMove(e) {
1135
1136
  if (e.touches.length !== 1) return;
1136
- // 有移动则取消长按
1137
- clearLongPress();
1138
1137
  var y = e.touches[0].clientY;
1139
1138
  var now = performance.now();
1140
1139
  var dt = now - lastTime;
@@ -1157,17 +1156,6 @@
1157
1156
  }
1158
1157
 
1159
1158
  function handleTouchEnd() {
1160
- console.log('[scroll] touchend');
1161
- clearLongPress();
1162
-
1163
- // 长按已触发,不执行滚动惯性
1164
- if (longPressTriggered) {
1165
- longPressTriggered = false;
1166
- pendingDy = 0;
1167
- pixelAccum = 0;
1168
- velocitySamples = [];
1169
- return;
1170
- }
1171
1159
 
1172
1160
  if (scrollRaf) {
1173
1161
  cancelAnimationFrame(scrollRaf);
@@ -1818,47 +1806,89 @@
1818
1806
  }
1819
1807
 
1820
1808
 
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;
1809
+ // 消息查看器
1810
+ var messageViewer = document.getElementById('message-viewer');
1811
+ var msgViewerContent = document.getElementById('msg-viewer-content');
1812
+ var msgViewerClose = document.getElementById('msg-viewer-close');
1826
1813
 
1827
- function openSelectMode() {
1828
- if (inSelectMode) return;
1829
- inSelectMode = true;
1814
+ function openMessageViewer() {
1830
1815
  // 收起键盘
1831
1816
  var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1832
1817
  if (xtermTa) xtermTa.blur();
1833
1818
  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';
1819
+
1820
+ messageViewer.classList.add('visible');
1821
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载中...</div>';
1839
1822
  unbindTouchScroll();
1823
+
1824
+ // 获取当前会话 ID
1825
+ fetch(basePath + '/api/current-session')
1826
+ .then(function(r) { return r.json(); })
1827
+ .then(function(data) {
1828
+ if (!data.sessionId) {
1829
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无活跃会话</div>';
1830
+ return;
1831
+ }
1832
+ return fetch(basePath + '/api/session/' + data.sessionId)
1833
+ .then(function(r) { return r.json(); })
1834
+ .then(function(messages) {
1835
+ renderMessages(messages);
1836
+ });
1837
+ })
1838
+ .catch(function(e) {
1839
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
1840
+ });
1840
1841
  }
1841
1842
 
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();
1843
+ function renderMessages(messages) {
1844
+ if (!messages || messages.length === 0) {
1845
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无消息</div>';
1846
+ return;
1847
+ }
1848
+ var html = '';
1849
+ messages.forEach(function(msg) {
1850
+ var role = msg.role || 'unknown';
1851
+ var roleLabel = role === 'user' ? '用户' : role === 'assistant' ? '助手' : role;
1852
+ var cls = role === 'user' ? 'msg-user' : role === 'assistant' ? 'msg-assistant' : '';
1853
+ html += '<div class="msg-item ' + cls + '">';
1854
+ html += '<div class="msg-role">' + roleLabel + '</div>';
1855
+ if (msg.text) {
1856
+ html += '<div class="msg-text">' + escapeHtml(msg.text) + '</div>';
1857
+ }
1858
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
1859
+ msg.toolCalls.forEach(function(tc) {
1860
+ html += '<div class="msg-tool">🔧 ' + escapeHtml(tc.name || 'tool') + '</div>';
1861
+ });
1862
+ }
1863
+ html += '</div>';
1864
+ });
1865
+ msgViewerContent.innerHTML = html;
1866
+ }
1867
+
1868
+ function escapeHtml(str) {
1869
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1870
+ }
1871
+
1872
+ function closeMessageViewer() {
1873
+ messageViewer.classList.remove('visible');
1849
1874
  rebindTouchScroll();
1850
1875
  }
1851
1876
 
1852
- selectModeClose.addEventListener('click', function(e) {
1877
+ msgViewerClose.addEventListener('click', function(e) {
1853
1878
  e.preventDefault();
1854
1879
  e.stopPropagation();
1855
- closeSelectMode();
1880
+ closeMessageViewer();
1856
1881
  });
1857
1882
 
1858
- selectModeClose.addEventListener('touchend', function(e) {
1883
+ msgViewerClose.addEventListener('touchend', function(e) {
1859
1884
  e.preventDefault();
1860
1885
  e.stopPropagation();
1861
- closeSelectMode();
1886
+ closeMessageViewer();
1887
+ });
1888
+
1889
+ // 顶部消息按钮
1890
+ document.getElementById('msg-toggle').addEventListener('click', function() {
1891
+ openMessageViewer();
1862
1892
  });
1863
1893
 
1864
1894
  // ======= 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.14",
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,61 @@ 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
+ // 尝试恢复最近的会话,如果没有则新建
857
+ let lastSessionId = null;
858
+ try {
859
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
860
+ const row = db.prepare(
861
+ `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_updated DESC LIMIT 1`
862
+ ).get();
863
+ db.close();
864
+ if (row) lastSessionId = row.id;
865
+ } catch (e) {}
866
+
867
+ if (lastSessionId) {
868
+ console.log(`[startup] 恢复最近会话: ${lastSessionId}`);
869
+ await spawnProcess('opencode', lastSessionId);
870
+ } else {
871
+ await spawnProcess('opencode');
872
+ }
873
+ });
874
+
875
+ server.on('error', (err) => {
876
+ if (err.code === 'EADDRINUSE' && IS_PC && retries < MAX_PORT_RETRIES) {
877
+ // PC 模式端口冲突,顺序查找下一个可用端口
878
+ const oldPort = PORT;
879
+ PORT = PORT >= PC_PORT_MAX ? PC_PORT_MIN : PORT + 1;
880
+ console.error(`[port] ${oldPort} 已占用,尝试 ${PORT} (${retries + 1}/${MAX_PORT_RETRIES})`);
881
+ server.removeAllListeners('error');
882
+ startServer(retries + 1);
883
+ } else {
884
+ console.error(`启动失败: ${err.message}`);
885
+ process.exit(1);
886
+ }
887
+ });
888
+ }
889
+
890
+ startServer();