claude-opencode-viewer 2.6.37 → 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.
Files changed (3) hide show
  1. package/index-pc.html +157 -1
  2. package/package.json +1 -1
  3. package/server.js +98 -48
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') {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.37",
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
@@ -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;
@@ -1298,54 +1393,9 @@ function startServer() {
1298
1393
  cleanupOrphanProcesses();
1299
1394
 
1300
1395
 
1301
- // 尝试恢复最近的会话,如果没有则新建
1302
- if (currentMode === 'opencode') {
1303
- let lastSessionId = null;
1304
- try {
1305
- const db = new Database(OPENCODE_DB_PATH, { readonly: true });
1306
- const row = db.prepare(
1307
- `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_updated DESC LIMIT 1`
1308
- ).get();
1309
- db.close();
1310
- if (row) lastSessionId = row.id;
1311
- } catch (e) {}
1312
-
1313
- if (lastSessionId) {
1314
- LOG(`[startup] 恢复最近会话: ${lastSessionId}`);
1315
- await spawnProcess('opencode', lastSessionId);
1316
- } else {
1317
- await spawnProcess('opencode');
1318
- }
1319
- } else {
1320
- // Claude 模式:找最近的会话恢复
1321
- let lastClaudeSession = null;
1322
- try {
1323
- const projectsDir = join(CLAUDE_HOME, 'projects');
1324
- if (existsSync(projectsDir)) {
1325
- let newest = 0;
1326
- for (const projDir of readdirSync(projectsDir, { withFileTypes: true })) {
1327
- if (!projDir.isDirectory()) continue;
1328
- const projPath = join(projectsDir, projDir.name);
1329
- for (const f of readdirSync(projPath)) {
1330
- if (!f.endsWith('.jsonl')) continue;
1331
- try {
1332
- const st = statSync(join(projPath, f));
1333
- if (st.mtimeMs > newest) {
1334
- newest = st.mtimeMs;
1335
- lastClaudeSession = f.replace('.jsonl', '');
1336
- }
1337
- } catch {}
1338
- }
1339
- }
1340
- }
1341
- } catch {}
1342
- if (lastClaudeSession) {
1343
- LOG(`[startup] 恢复最近Claude会话: ${lastClaudeSession}`);
1344
- await spawnProcess('claude', lastClaudeSession);
1345
- } else {
1346
- await spawnProcess('claude');
1347
- }
1348
- }
1396
+ // 延迟启动:等待 PC 客户端发送 init 消息后再 spawn 进程
1397
+ // 移动端客户端连接时自动启动 claude(见 WS 连接处理)
1398
+ LOG('[startup] 等待客户端连接并选择会话...');
1349
1399
 
1350
1400
  // 启动首次连接超时检测(3分钟无人连接则退出)
1351
1401
  startNoClientTimer();