claude-opencode-viewer 2.6.36 → 2.6.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index-pc.html CHANGED
@@ -761,6 +761,58 @@
761
761
  padding: 1px 6px;
762
762
  border-radius: 8px;
763
763
  }
764
+ /* 启动对话框 */
765
+ #startup-overlay {
766
+ display: none;
767
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
768
+ background: rgba(0, 0, 0, 0.75);
769
+ z-index: 99999;
770
+ align-items: center; justify-content: center;
771
+ }
772
+ #startup-overlay.visible { display: flex; }
773
+ #startup-card {
774
+ background: #1e1e2e; border-radius: 12px; padding: 28px;
775
+ max-width: 500px; width: 90%; color: #ccc;
776
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
777
+ }
778
+ #startup-card h3 {
779
+ margin: 0 0 16px 0; color: #e0e0e0; font-size: 16px; font-weight: 600;
780
+ }
781
+ .startup-session-info {
782
+ background: #252535; border-radius: 8px; padding: 14px; margin-bottom: 16px;
783
+ border-left: 3px solid #4a9eff;
784
+ }
785
+ .startup-session-info.claude-session { border-left-color: #4a9eff; }
786
+ .startup-session-mode {
787
+ font-size: 12px; font-weight: 600; margin-bottom: 6px; color: #aaa;
788
+ }
789
+ .startup-session-mode .mode-tag {
790
+ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px;
791
+ }
792
+ .startup-session-mode .mode-tag.opencode { background: #1a3a2a; color: #4ec9b0; }
793
+ .startup-session-mode .mode-tag.claude { background: #1a2a3a; color: #4a9eff; }
794
+ .startup-session-preview {
795
+ font-size: 13px; color: #ddd; margin-bottom: 6px;
796
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
797
+ }
798
+ .startup-session-meta {
799
+ font-size: 11px; color: #888;
800
+ }
801
+ .startup-buttons {
802
+ display: flex; gap: 10px; margin-top: 8px;
803
+ }
804
+ .startup-buttons button {
805
+ flex: 1; padding: 10px 16px; border: none; border-radius: 8px;
806
+ font-size: 14px; cursor: pointer; font-weight: 500; transition: background 0.2s;
807
+ }
808
+ .startup-btn-restore {
809
+ background: #4a9eff; color: #fff;
810
+ }
811
+ .startup-btn-restore:hover { background: #3a8eef; }
812
+ .startup-btn-new {
813
+ background: #333; color: #ccc;
814
+ }
815
+ .startup-btn-new:hover { background: #444; }
764
816
  </style>
765
817
  </head>
766
818
  <body>
@@ -964,6 +1016,7 @@
964
1016
  var currentMode = 'claude';
965
1017
  var isTransitioning = false;
966
1018
  var isBufferReplay = true; // 初始缓冲区回放中,不弹 toast
1019
+ var startupDialogShown = false;
967
1020
 
968
1021
  var term = new Terminal({
969
1022
  cursorBlink: !isMobile,
@@ -1432,6 +1485,92 @@
1432
1485
  }, 50);
1433
1486
  });
1434
1487
 
1488
+ // === 启动对话框 ===
1489
+ function showStartupDialog() {
1490
+ fetch(basePath + '/api/last-sessions')
1491
+ .then(function(r) { return r.json(); })
1492
+ .then(function(data) {
1493
+ var oc = data.opencode;
1494
+ var cl = data.claude;
1495
+ // 无任何历史 → 直接新建 claude
1496
+ if (!oc && !cl) {
1497
+ sendInit('claude', null);
1498
+ return;
1499
+ }
1500
+ // 确定最近的会话
1501
+ var latest = null, latestMode = 'claude';
1502
+ if (oc && cl) {
1503
+ if (oc.mtime >= cl.mtime) { latest = oc; latestMode = 'opencode'; }
1504
+ else { latest = cl; latestMode = 'claude'; }
1505
+ } else if (oc) { latest = oc; latestMode = 'opencode'; }
1506
+ else { latest = cl; latestMode = 'claude'; }
1507
+
1508
+ // 渲染会话信息
1509
+ var infoDiv = document.getElementById('startup-session-info');
1510
+ var modeLabel = latestMode === 'claude' ? 'Claude Code' : 'OpenCode';
1511
+ var modeClass = latestMode === 'claude' ? 'claude' : 'opencode';
1512
+ var timeAgo = getTimeAgo(latest.mtime);
1513
+ var dir = latest.directory || '';
1514
+ if (dir.startsWith('/Users/')) dir = '~' + dir.substring(dir.indexOf('/', 1));
1515
+ var preview = latest.preview || '(无预览)';
1516
+ if (preview.length > 100) preview = preview.substring(0, 100) + '...';
1517
+
1518
+ infoDiv.innerHTML =
1519
+ '<div class="startup-session-info ' + (latestMode === 'claude' ? 'claude-session' : '') + '">' +
1520
+ '<div class="startup-session-mode"><span class="mode-tag ' + modeClass + '">' + modeLabel + '</span> · ' + timeAgo + '</div>' +
1521
+ '<div class="startup-session-preview">' + escapeHtml(preview) + '</div>' +
1522
+ (dir ? '<div class="startup-session-meta">' + escapeHtml(dir) + '</div>' : '') +
1523
+ '</div>';
1524
+
1525
+ // 恢复按钮样式
1526
+ var restoreBtn = document.getElementById('startup-btn-restore');
1527
+ restoreBtn.className = 'startup-btn-restore';
1528
+ restoreBtn.textContent = '恢复会话';
1529
+
1530
+ // 绑定事件
1531
+ restoreBtn.onclick = function() {
1532
+ hideStartupDialog();
1533
+ sendInit(latestMode, latest.id);
1534
+ };
1535
+ document.getElementById('startup-btn-new').onclick = function() {
1536
+ hideStartupDialog();
1537
+ sendInit('claude', null);
1538
+ };
1539
+
1540
+ // 显示对话框
1541
+ document.getElementById('startup-overlay').classList.add('visible');
1542
+ })
1543
+ .catch(function() {
1544
+ // API 失败,直接新建
1545
+ sendInit('claude', null);
1546
+ });
1547
+ }
1548
+
1549
+ function hideStartupDialog() {
1550
+ document.getElementById('startup-overlay').classList.remove('visible');
1551
+ }
1552
+
1553
+ function sendInit(mode, sessionId) {
1554
+ if (!ws || ws.readyState !== 1) return;
1555
+ currentMode = mode;
1556
+ modeSelect.value = mode;
1557
+ document.getElementById('mode-label').textContent = '';
1558
+ var msg = { type: 'init', mode: mode };
1559
+ if (sessionId) msg.sessionId = sessionId;
1560
+ ws.send(JSON.stringify(msg));
1561
+ }
1562
+
1563
+ function getTimeAgo(ts) {
1564
+ var diff = Date.now() - ts;
1565
+ var min = Math.floor(diff / 60000);
1566
+ if (min < 1) return '刚刚';
1567
+ if (min < 60) return min + ' 分钟前';
1568
+ var hr = Math.floor(min / 60);
1569
+ if (hr < 24) return hr + ' 小时前';
1570
+ var day = Math.floor(hr / 24);
1571
+ return day + ' 天前';
1572
+ }
1573
+
1435
1574
  function connect() {
1436
1575
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1437
1576
  ws = new WebSocket(proto + '//' + location.host + basePath + '/ws');
@@ -1441,6 +1580,7 @@
1441
1580
  resize();
1442
1581
  rebindTouchScroll();
1443
1582
  setTimeout(function() { isBufferReplay = false; }, 500);
1583
+ // 不在这里初始化,等 state 消息判断是否需要弹对话框
1444
1584
  };
1445
1585
 
1446
1586
  ws.onclose = function() {
@@ -1477,7 +1617,12 @@
1477
1617
  if (msg.mode) {
1478
1618
  currentMode = msg.mode;
1479
1619
  modeSelect.value = msg.mode;
1480
- modeIndicator.textContent = msg.mode === 'claude' ? 'Claude' : 'OpenCode';
1620
+ document.getElementById('mode-label').textContent = '';
1621
+ }
1622
+ // 服务端无运行进程,显示启动对话框
1623
+ if (!msg.running && !startupDialogShown) {
1624
+ startupDialogShown = true;
1625
+ showStartupDialog();
1481
1626
  }
1482
1627
  }
1483
1628
  else if (msg.type === 'restored') {
@@ -2241,7 +2386,7 @@
2241
2386
  fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2242
2387
  return;
2243
2388
  }
2244
- var html = '';
2389
+ var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2245
2390
  docs.forEach(function(doc) {
2246
2391
  var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2247
2392
  var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
@@ -2301,5 +2446,16 @@
2301
2446
  setTimeout(resize, 100);
2302
2447
  })();
2303
2448
  </script>
2449
+ <!-- 启动对话框 -->
2450
+ <div id="startup-overlay">
2451
+ <div id="startup-card">
2452
+ <h3>选择会话</h3>
2453
+ <div id="startup-session-info"></div>
2454
+ <div class="startup-buttons">
2455
+ <button class="startup-btn-restore" id="startup-btn-restore">恢复会话</button>
2456
+ <button class="startup-btn-new" id="startup-btn-new">新建会话</button>
2457
+ </div>
2458
+ </div>
2459
+ </div>
2304
2460
  </body>
2305
2461
  </html>
package/index.html CHANGED
@@ -2505,7 +2505,7 @@
2505
2505
  fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2506
2506
  return;
2507
2507
  }
2508
- var html = '';
2508
+ var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2509
2509
  docs.forEach(function(doc) {
2510
2510
  var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2511
2511
  var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.36",
3
+ "version": "2.6.38",
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
@@ -644,15 +644,15 @@ const requestHandler = async (req, res) => {
644
644
  try {
645
645
  const gitCwd = process.env.PROJECT_DIR || process.cwd();
646
646
  const { stdout } = await execFileAsync('git', ['-c', 'safe.directory=*', 'status', '--porcelain'], {
647
- cwd: gitCwd, encoding: 'utf-8', timeout: 5000,
647
+ cwd: gitCwd, encoding: 'utf-8', timeout: 60000,
648
648
  });
649
649
  const changes = stdout.split('\n').filter(Boolean).map(line => ({
650
650
  status: line.substring(0, 2).trim(),
651
651
  file: line.substring(3),
652
652
  })).filter(c => !/^core-/.test(c.file));
653
653
  res.end(JSON.stringify({ changes, cwd: gitCwd }));
654
- } catch {
655
- res.end(JSON.stringify({ changes: [], cwd: process.env.PROJECT_DIR || process.cwd() }));
654
+ } catch (err) {
655
+ res.end(JSON.stringify({ changes: [], cwd: process.env.PROJECT_DIR || process.cwd(), error: err.message }));
656
656
  }
657
657
  return;
658
658
  }
@@ -675,7 +675,7 @@ const requestHandler = async (req, res) => {
675
675
  for (const file of fileList) {
676
676
  if (file.includes('..') || file.startsWith('/')) continue;
677
677
  try {
678
- const { stdout: statusOut } = await execFileAsync('git', ['-c', 'safe.directory=*', 'status', '--porcelain', '--', file], { cwd, encoding: 'utf-8', timeout: 3000 });
678
+ const { stdout: statusOut } = await execFileAsync('git', ['-c', 'safe.directory=*', 'status', '--porcelain', '--', file], { cwd, encoding: 'utf-8', timeout: 30000 });
679
679
  if (!statusOut.trim()) continue;
680
680
  const status = statusOut.substring(0, 2).trim();
681
681
  const is_new = status === 'A' || status === '??';
@@ -683,7 +683,7 @@ const requestHandler = async (req, res) => {
683
683
  let is_binary = false;
684
684
  if (!is_deleted) {
685
685
  try {
686
- const { stdout: dc } = await execFileAsync('git', ['-c', 'safe.directory=*', 'diff', '--numstat', 'HEAD', '--', file], { cwd, encoding: 'utf-8', timeout: 3000 });
686
+ const { stdout: dc } = await execFileAsync('git', ['-c', 'safe.directory=*', 'diff', '--numstat', 'HEAD', '--', file], { cwd, encoding: 'utf-8', timeout: 30000 });
687
687
  if (dc.includes('-\t-\t')) is_binary = true;
688
688
  } catch {}
689
689
  }
@@ -691,7 +691,7 @@ const requestHandler = async (req, res) => {
691
691
  if (!is_binary) {
692
692
  if (!is_new) {
693
693
  try {
694
- const { stdout } = await execFileAsync('git', ['-c', 'safe.directory=*', 'show', `HEAD:${file}`], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
694
+ const { stdout } = await execFileAsync('git', ['-c', 'safe.directory=*', 'show', `HEAD:${file}`], { cwd, encoding: 'utf-8', timeout: 60000, maxBuffer: 5 * 1024 * 1024 });
695
695
  old_content = stdout;
696
696
  } catch {}
697
697
  }
@@ -713,7 +713,7 @@ const requestHandler = async (req, res) => {
713
713
  const diffArgs = is_new
714
714
  ? ['-c', 'safe.directory=*', 'diff', '--no-color', '-U3', '--no-index', '/dev/null', file]
715
715
  : ['-c', 'safe.directory=*', 'diff', '--no-color', '-U3', 'HEAD', '--', file];
716
- const { stdout } = await execFileAsync('git', diffArgs, { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
716
+ const { stdout } = await execFileAsync('git', diffArgs, { cwd, encoding: 'utf-8', timeout: 60000, maxBuffer: 5 * 1024 * 1024 });
717
717
  unified_diff = stdout;
718
718
  } catch (e) {
719
719
  if (e.stdout) unified_diff = e.stdout;
@@ -752,7 +752,7 @@ const requestHandler = async (req, res) => {
752
752
  };
753
753
  scanDir(docsRoot, '', 0);
754
754
  files.sort((a, b) => b.mtime - a.mtime);
755
- res.end(JSON.stringify({ docs: files }));
755
+ res.end(JSON.stringify({ docs: files, cwd: docsRoot }));
756
756
  } catch (err) {
757
757
  res.end(JSON.stringify({ docs: [], error: err.message }));
758
758
  }
@@ -782,6 +782,87 @@ const requestHandler = async (req, res) => {
782
782
  return;
783
783
  }
784
784
 
785
+ // API: 获取最近的 OpenCode 和 Claude 会话(用于启动对话框)
786
+ if (req.url === '/api/last-sessions') {
787
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
788
+ let opencode = null, claude = null;
789
+ // OpenCode: 从 SQLite 查最近会话
790
+ try {
791
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
792
+ const row = db.prepare(
793
+ `SELECT id, directory, time_updated FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_updated DESC LIMIT 1`
794
+ ).get();
795
+ db.close();
796
+ if (row) {
797
+ opencode = { id: row.id, mtime: new Date(row.time_updated).getTime(), directory: row.directory || '', preview: '' };
798
+ // 尝试读最近用户消息作为预览(文本在 part 表中)
799
+ try {
800
+ const db2 = new Database(OPENCODE_DB_PATH, { readonly: true });
801
+ const partRow = db2.prepare(
802
+ `SELECT p.data FROM part p JOIN message m ON p.message_id = m.id
803
+ WHERE m.session_id = ? AND json_extract(m.data, '$.role') = 'user'
804
+ ORDER BY m.time_created DESC LIMIT 1`
805
+ ).get(row.id);
806
+ db2.close();
807
+ if (partRow) {
808
+ try {
809
+ const pd = JSON.parse(partRow.data);
810
+ if (pd.text) opencode.preview = pd.text.slice(0, 200);
811
+ } catch {}
812
+ }
813
+ } catch {}
814
+ }
815
+ } catch {}
816
+ // Claude: 扫描 JSONL 找最近文件
817
+ try {
818
+ const projectsDir = join(CLAUDE_HOME, 'projects');
819
+ if (existsSync(projectsDir)) {
820
+ let newest = 0, newestFile = null, newestProj = null;
821
+ for (const projDir of readdirSync(projectsDir, { withFileTypes: true })) {
822
+ if (!projDir.isDirectory()) continue;
823
+ const projPath = join(projectsDir, projDir.name);
824
+ for (const f of readdirSync(projPath)) {
825
+ if (!f.endsWith('.jsonl')) continue;
826
+ try {
827
+ const st = statSync(join(projPath, f));
828
+ if (st.mtimeMs > newest) {
829
+ newest = st.mtimeMs;
830
+ newestFile = f.replace('.jsonl', '');
831
+ newestProj = projDir.name;
832
+ }
833
+ } catch {}
834
+ }
835
+ }
836
+ if (newestFile && newestProj) {
837
+ claude = { id: newestFile, mtime: newest, project: newestProj, directory: '', preview: '' };
838
+ try {
839
+ const content = readFileSync(join(projectsDir, newestProj, newestFile + '.jsonl'), 'utf-8');
840
+ const lines = content.split('\n').slice(0, 30);
841
+ for (const line of lines) {
842
+ if (!line.trim()) continue;
843
+ try {
844
+ const d = JSON.parse(line);
845
+ if (d.cwd && !claude.directory) claude.directory = d.cwd;
846
+ if (d.type === 'user' && d.message && !claude.preview) {
847
+ const c = d.message.content;
848
+ if (typeof c === 'string') claude.preview = c.slice(0, 200);
849
+ else if (Array.isArray(c)) {
850
+ for (const item of c) {
851
+ if (item.type === 'text' && item.text) { claude.preview = item.text.slice(0, 200); break; }
852
+ }
853
+ }
854
+ break;
855
+ }
856
+ } catch {}
857
+ }
858
+ } catch {}
859
+ }
860
+ }
861
+ } catch {}
862
+ res.end(JSON.stringify({ opencode, claude }));
863
+ return;
864
+ }
865
+
785
866
  // API: Claude Code 会话列表(扫描所有项目)
786
867
  if (req.url === '/api/claude-sessions') {
787
868
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
@@ -1149,6 +1230,20 @@ wssInst.on('connection', (ws, req) => {
1149
1230
  }
1150
1231
  isSwitching = false;
1151
1232
  }
1233
+ } else if (msg.type === 'init') {
1234
+ // 首次启动:客户端选择模式和会话后发送
1235
+ const mode = msg.mode || 'claude';
1236
+ currentMode = mode;
1237
+ LOG(`[init] 启动 ${mode}, sessionId=${msg.sessionId || '(新会话)'}`);
1238
+ outputBuffer = '';
1239
+ try {
1240
+ await spawnProcess(mode, msg.sessionId || null);
1241
+ ws.send(JSON.stringify({ type: 'mode', mode: currentMode }));
1242
+ ws.send(JSON.stringify({ type: 'started', sessionId: msg.sessionId || null }));
1243
+ } catch (e) {
1244
+ LOG('[init] 启动失败:', e.message);
1245
+ ws.send(JSON.stringify({ type: 'start-error', error: e.message }));
1246
+ }
1152
1247
  } else if (msg.type === 'start') {
1153
1248
  // 前端启动指令:可选带 sessionId 恢复会话
1154
1249
  const mode = currentMode;
@@ -1297,54 +1392,10 @@ function startServer() {
1297
1392
  // 清理上次 cov 崩溃残留的孤儿进程
1298
1393
  cleanupOrphanProcesses();
1299
1394
 
1300
- // 尝试恢复最近的会话,如果没有则新建
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) {}
1311
1395
 
1312
- if (lastSessionId) {
1313
- LOG(`[startup] 恢复最近会话: ${lastSessionId}`);
1314
- await spawnProcess('opencode', lastSessionId);
1315
- } else {
1316
- await spawnProcess('opencode');
1317
- }
1318
- } else {
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
- }
1347
- }
1396
+ // 延迟启动:等待 PC 客户端发送 init 消息后再 spawn 进程
1397
+ // 移动端客户端连接时自动启动 claude(见 WS 连接处理)
1398
+ LOG('[startup] 等待客户端连接并选择会话...');
1348
1399
 
1349
1400
  // 启动首次连接超时检测(3分钟无人连接则退出)
1350
1401
  startNoClientTimer();