claude-opencode-viewer 2.6.0 → 2.6.2

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.
@@ -6,7 +6,8 @@
6
6
  "Bash(pkill:*)",
7
7
  "Bash(node:*)",
8
8
  "Bash(lsof -ti:7008)",
9
- "Bash(sqlite3:*)"
9
+ "Bash(sqlite3:*)",
10
+ "Bash(ps:*)"
10
11
  ]
11
12
  }
12
13
  }
package/index.html CHANGED
@@ -373,6 +373,33 @@
373
373
  font-size: 12px;
374
374
  }
375
375
 
376
+ .session-delete-btn {
377
+ flex-shrink: 0;
378
+ width: 28px;
379
+ height: 28px;
380
+ border: none;
381
+ background: none;
382
+ color: #f85149;
383
+ font-size: 14px;
384
+ cursor: pointer;
385
+ border-radius: 50%;
386
+ display: flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ transition: all 0.15s;
390
+ -webkit-tap-highlight-color: transparent;
391
+ }
392
+
393
+ .session-delete-btn:hover {
394
+ background: rgba(248, 81, 73, 0.15);
395
+ color: #f85149;
396
+ }
397
+
398
+ .session-delete-btn:active {
399
+ background: rgba(248, 81, 73, 0.3);
400
+ color: #f85149;
401
+ }
402
+
376
403
  .session-restore-hint {
377
404
  margin-top: 8px;
378
405
  padding: 8px 12px;
@@ -949,9 +976,6 @@
949
976
  <div class="virtual-key" data-key="tab">Tab</div>
950
977
  <div class="virtual-key" data-key="esc">Esc</div>
951
978
  <div class="virtual-key" data-key="ctrlc">Ctrl+C</div>
952
- <div class="virtual-key scroll-key" data-scroll="-5">⇡ 滚动</div>
953
- <div class="virtual-key scroll-key" data-scroll="5">⇣ 滚动</div>
954
- <div class="virtual-key" id="btn-copy">复制</div>
955
979
  </div>
956
980
  </div>
957
981
  </div>
@@ -986,6 +1010,32 @@
986
1010
 
987
1011
  term.open(document.getElementById('terminal'));
988
1012
 
1013
+ // OSC 52 剪贴板支持:拦截应用发送的剪贴板设置请求
1014
+ term.parser.registerOscHandler(52, function(data) {
1015
+ var idx = data.indexOf(';');
1016
+ if (idx === -1) return false;
1017
+ var b64 = data.substring(idx + 1);
1018
+ if (!b64 || b64 === '?') return false;
1019
+ try {
1020
+ var text = atob(b64);
1021
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1022
+ navigator.clipboard.writeText(text).then(function() {
1023
+ showCopyToast();
1024
+ });
1025
+ } else {
1026
+ var ta = document.createElement('textarea');
1027
+ ta.value = text;
1028
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
1029
+ document.body.appendChild(ta);
1030
+ ta.select();
1031
+ document.execCommand('copy');
1032
+ document.body.removeChild(ta);
1033
+ showCopyToast();
1034
+ }
1035
+ } catch (e) {}
1036
+ return true;
1037
+ });
1038
+
989
1039
  var modeSelect = document.getElementById('mode-select');
990
1040
  var terminalEl = document.getElementById('terminal');
991
1041
  var ws = null;
@@ -1020,25 +1070,15 @@
1020
1070
  }
1021
1071
 
1022
1072
  function loadInputCache() {
1023
- // 确保只恢复一次
1024
- if (cacheRestored) {
1025
- console.log('[cache] already restored, skipping');
1026
- return;
1027
- }
1073
+ if (cacheRestored) return;
1074
+ cacheRestored = true;
1028
1075
 
1029
1076
  var cached = localStorage.getItem(CACHE_KEY);
1030
- if (cached && ws && ws.readyState === 1) {
1031
- console.log('[cache] restoring:', cached);
1032
- // 立即清除缓存,防止重复调用
1033
- localStorage.removeItem(CACHE_KEY);
1034
- cacheRestored = true;
1035
-
1036
- // 将缓存的输入发送到终端
1037
- ws.send(JSON.stringify({ type: 'input', data: cached }));
1038
- // 重要:恢复后清空 currentInputBuffer,防止再次保存
1039
- currentInputBuffer = '';
1040
- } else {
1041
- console.log('[cache] no cache to restore');
1077
+ if (cached) {
1078
+ console.log('[cache] restoring buffer:', cached);
1079
+ // 只恢复跟踪变量,不重新发送到 pty
1080
+ // 因为 outputBuffer 回放已经包含了之前的回显
1081
+ currentInputBuffer = cached;
1042
1082
  }
1043
1083
  }
1044
1084
 
@@ -1153,14 +1193,55 @@
1153
1193
  pixelAccum = 0;
1154
1194
  }
1155
1195
 
1156
- // OpenCode 模式触摸滚动实现 - 使用 xterm.js scrollLines API
1196
+ // 触摸滚动实现 - 混合模式:alternate buffer 发鼠标滚轮,normal buffer 用 scrollLines
1157
1197
  var touchScreen = null;
1158
1198
  var touchEventsBound = false;
1159
1199
 
1200
+ function isAlternateBuffer() {
1201
+ return term.buffer.active.type === 'alternate';
1202
+ }
1203
+
1204
+ function emitWheelEvent(lines) {
1205
+ var screen = terminalEl.querySelector('.xterm-screen');
1206
+ if (!screen) return;
1207
+ var lh = getLineHeight();
1208
+ var rect = screen.getBoundingClientRect();
1209
+ var cx = rect.left + rect.width / 2;
1210
+ var cy = rect.top + rect.height / 2;
1211
+ var count = Math.abs(lines);
1212
+ var dy = lines < 0 ? -lh : lh;
1213
+ for (var i = 0; i < count; i++) {
1214
+ screen.dispatchEvent(new WheelEvent('wheel', {
1215
+ deltaY: dy,
1216
+ deltaMode: 0,
1217
+ clientX: cx,
1218
+ clientY: cy,
1219
+ bubbles: true,
1220
+ cancelable: true,
1221
+ }));
1222
+ }
1223
+ }
1224
+
1225
+ var altScrollAccum = 0;
1226
+ var ALT_SCROLL_THRESHOLD = 2;
1227
+
1228
+ function doScroll(lines) {
1229
+ if (lines === 0) return;
1230
+ if (isAlternateBuffer()) {
1231
+ altScrollAccum += lines;
1232
+ if (Math.abs(altScrollAccum) >= ALT_SCROLL_THRESHOLD) {
1233
+ var scrollLines = Math.trunc(altScrollAccum);
1234
+ emitWheelEvent(scrollLines);
1235
+ altScrollAccum -= scrollLines;
1236
+ }
1237
+ } else {
1238
+ term.scrollLines(lines);
1239
+ }
1240
+ }
1241
+
1160
1242
  function getLineHeight() {
1161
1243
  var cellDims = getCellDims();
1162
1244
  var height = (cellDims && cellDims.height) || 15;
1163
- console.log('[scroll] lineHeight:', height);
1164
1245
  return height;
1165
1246
  }
1166
1247
 
@@ -1188,8 +1269,7 @@
1188
1269
  var lines = Math.trunc(pixelAccum / lh);
1189
1270
 
1190
1271
  if (lines !== 0) {
1191
- console.log('[scroll] scrollLines:', lines, 'pixelAccum:', pixelAccum, 'lineHeight:', lh);
1192
- term.scrollLines(lines);
1272
+ doScroll(lines);
1193
1273
  pixelAccum -= lines * lh;
1194
1274
  }
1195
1275
  }
@@ -1197,18 +1277,21 @@
1197
1277
  // 长按检测
1198
1278
  var longPressTimer = null;
1199
1279
  var longPressTriggered = false;
1200
- var LONG_PRESS_DELAY = 500; // ms
1280
+ var LONG_PRESS_DELAY = 550; // ms
1201
1281
 
1202
1282
  function clearLongPress() {
1203
1283
  if (longPressTimer) {
1204
1284
  clearTimeout(longPressTimer);
1205
1285
  longPressTimer = null;
1206
1286
  }
1287
+ // 始终恢复 xterm textarea,防止 disabled 残留
1288
+ var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1289
+ if (xtermTa) xtermTa.removeAttribute('disabled');
1207
1290
  }
1208
1291
 
1209
1292
  function handleTouchStart(e) {
1210
- console.log('[scroll] touchstart');
1211
1293
  stopMomentum();
1294
+ altScrollAccum = 0;
1212
1295
  longPressTriggered = false;
1213
1296
  clearLongPress();
1214
1297
  if (e.touches.length !== 1) return;
@@ -1217,6 +1300,12 @@
1217
1300
  velocitySamples = [];
1218
1301
 
1219
1302
  // 启动长按计时器
1303
+ // 在长按检测期间阻止 xterm textarea 获取焦点,防止弹出键盘
1304
+ var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1305
+ if (xtermTa) {
1306
+ xtermTa.setAttribute('disabled', 'true');
1307
+ }
1308
+
1220
1309
  longPressTimer = setTimeout(function() {
1221
1310
  longPressTriggered = true;
1222
1311
  longPressTimer = null;
@@ -1273,8 +1362,7 @@
1273
1362
  var lh = getLineHeight();
1274
1363
  var lines = Math.trunc(pixelAccum / lh);
1275
1364
  if (lines !== 0) {
1276
- console.log('[scroll] final scrollLines:', lines);
1277
- term.scrollLines(lines);
1365
+ doScroll(lines);
1278
1366
  }
1279
1367
  pixelAccum = 0;
1280
1368
  }
@@ -1304,8 +1392,7 @@
1304
1392
  var lh = getLineHeight();
1305
1393
  var rest = Math.round(mAccum / lh);
1306
1394
  if (rest !== 0) {
1307
- console.log('[scroll] momentum final:', rest);
1308
- term.scrollLines(rest);
1395
+ doScroll(rest);
1309
1396
  }
1310
1397
  momentumRaf = null;
1311
1398
  return;
@@ -1314,7 +1401,7 @@
1314
1401
  var lh = getLineHeight();
1315
1402
  var lines = Math.trunc(mAccum / lh);
1316
1403
  if (lines !== 0) {
1317
- term.scrollLines(lines);
1404
+ doScroll(lines);
1318
1405
  mAccum -= lines * lh;
1319
1406
  }
1320
1407
  velocity *= friction;
@@ -1338,12 +1425,6 @@
1338
1425
  // 先解绑旧的
1339
1426
  unbindTouchScroll();
1340
1427
 
1341
- // 只在 opencode 模式下绑定
1342
- if (currentMode !== 'opencode') {
1343
- console.log('[scroll] Not opencode mode, skip binding');
1344
- return;
1345
- }
1346
-
1347
1428
  var screen = terminalEl.querySelector('.xterm-screen');
1348
1429
  if (!screen) {
1349
1430
  console.log('[scroll] .xterm-screen not found, retrying...');
@@ -1356,7 +1437,7 @@
1356
1437
  screen.addEventListener('touchmove', handleTouchMove, { passive: true });
1357
1438
  screen.addEventListener('touchend', handleTouchEnd, { passive: true });
1358
1439
  touchEventsBound = true;
1359
- console.log('[scroll] Touch events bound to .xterm-screen (opencode mode)');
1440
+ console.log('[scroll] Touch events bound to .xterm-screen');
1360
1441
  }
1361
1442
 
1362
1443
  function rebindTouchScroll() {
@@ -1416,8 +1497,8 @@
1416
1497
  } else if (d === '\x15') {
1417
1498
  // Ctrl+U:清空整行
1418
1499
  clearInputCache();
1419
- } else if (d.length === 1 && d.charCodeAt(0) >= 32 && d.charCodeAt(0) < 127) {
1420
- // 可打印 ASCII 字符:添加到缓冲区
1500
+ } else if (d.charCodeAt(0) >= 32 && d.charCodeAt(0) !== 127) {
1501
+ // 可打印字符(含中文等多字节):添加到缓冲区
1421
1502
  currentInputBuffer += d;
1422
1503
  saveInputCache();
1423
1504
  }
@@ -1436,6 +1517,7 @@
1436
1517
 
1437
1518
  ws.onopen = function() {
1438
1519
  resize();
1520
+ rebindTouchScroll();
1439
1521
  };
1440
1522
 
1441
1523
  ws.onclose = function() {
@@ -1484,6 +1566,9 @@
1484
1566
  // 恢复失败
1485
1567
  term.write('\x1b[31m✗ 恢复失败: ' + msg.error + '\x1b[0m\r\n');
1486
1568
  }
1569
+ else if (msg.type === 'started') {
1570
+ rebindTouchScroll();
1571
+ }
1487
1572
  else if (msg.type === 'new-session-ok') {
1488
1573
  isCreatingNewSession = false;
1489
1574
  term.clear();
@@ -1500,7 +1585,6 @@
1500
1585
  if (currentMode === 'opencode') {
1501
1586
  loadInputCache();
1502
1587
  } else {
1503
- // 非 opencode 模式,标记为已恢复,避免后续缓存逻辑干扰
1504
1588
  cacheRestored = true;
1505
1589
  }
1506
1590
  }, 800);
@@ -1547,10 +1631,8 @@
1547
1631
  }
1548
1632
 
1549
1633
  function scrollTerminal(lines) {
1550
- if (term) {
1551
- term.scrollLines(lines);
1552
- console.log('[scroll] scrolled by:', lines);
1553
- }
1634
+ if (!term) return;
1635
+ doScroll(lines);
1554
1636
  }
1555
1637
 
1556
1638
  // 参考 cc-viewer 的 TerminalPanel.jsx 行 519-546: 虚拟按键触摸处理
@@ -1738,8 +1820,18 @@
1738
1820
  info.appendChild(title);
1739
1821
  info.appendChild(meta);
1740
1822
 
1823
+ var deleteBtn = document.createElement('button');
1824
+ deleteBtn.className = 'session-delete-btn';
1825
+ 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>';
1826
+ deleteBtn.title = '删除会话';
1827
+ deleteBtn.addEventListener('click', function(e) {
1828
+ e.stopPropagation();
1829
+ deleteSession(session.id, item);
1830
+ });
1831
+
1741
1832
  item.appendChild(icon);
1742
1833
  item.appendChild(info);
1834
+ item.appendChild(deleteBtn);
1743
1835
 
1744
1836
  item.addEventListener('click', function() {
1745
1837
  loadSession(session);
@@ -1749,6 +1841,39 @@
1749
1841
  });
1750
1842
  }
1751
1843
 
1844
+ function deleteSession(sessionId, itemEl) {
1845
+ if (!confirm('确定要删除这个会话吗?')) return;
1846
+
1847
+ itemEl.style.opacity = '0.4';
1848
+ itemEl.style.pointerEvents = 'none';
1849
+
1850
+ fetch('/api/session/' + sessionId, { method: 'DELETE' })
1851
+ .then(function(r) { return r.json(); })
1852
+ .then(function(data) {
1853
+ if (data.ok) {
1854
+ itemEl.style.transition = 'all 0.2s';
1855
+ itemEl.style.maxHeight = '0';
1856
+ itemEl.style.overflow = 'hidden';
1857
+ itemEl.style.padding = '0 12px';
1858
+ itemEl.style.margin = '0';
1859
+ itemEl.style.opacity = '0';
1860
+ setTimeout(function() {
1861
+ sessions = sessions.filter(function(s) { return s.id !== sessionId; });
1862
+ renderSessions();
1863
+ }, 200);
1864
+ } else {
1865
+ itemEl.style.opacity = '';
1866
+ itemEl.style.pointerEvents = '';
1867
+ alert('删除失败: ' + (data.error || '未知错误'));
1868
+ }
1869
+ })
1870
+ .catch(function(err) {
1871
+ itemEl.style.opacity = '';
1872
+ itemEl.style.pointerEvents = '';
1873
+ alert('删除失败: ' + err.message);
1874
+ });
1875
+ }
1876
+
1752
1877
  function showSessionDetail() {
1753
1878
  document.getElementById('session-list-view').classList.add('hidden');
1754
1879
  document.getElementById('session-detail-view').classList.add('visible');
@@ -1996,17 +2121,6 @@
1996
2121
  setTimeout(function() { toast.classList.remove('show'); }, 1200);
1997
2122
  }
1998
2123
 
1999
- // "复制" 按钮
2000
- document.getElementById('btn-copy').addEventListener('touchend', function(e) {
2001
- e.preventDefault();
2002
- var text = getTerminalText();
2003
- if (text) copyToClipboard(text);
2004
- });
2005
- document.getElementById('btn-copy').addEventListener('click', function(e) {
2006
- e.preventDefault();
2007
- var text = getTerminalText();
2008
- if (text) copyToClipboard(text);
2009
- });
2010
2124
 
2011
2125
  // 方案2: 长按进入选择模式 — 原位显示可选纯文本
2012
2126
  var selectTextLayer = document.getElementById('select-text-layer');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
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
@@ -501,6 +501,30 @@ const server = createServer(async (req, res) => {
501
501
  return;
502
502
  }
503
503
 
504
+ // API: 软删除会话(设置 time_archived)
505
+ if (req.method === 'DELETE' && req.url?.startsWith('/api/session/')) {
506
+ const sessionId = req.url.split('/').pop();
507
+ res.writeHead(200, {
508
+ 'Content-Type': 'application/json',
509
+ 'Access-Control-Allow-Origin': '*',
510
+ });
511
+ try {
512
+ if (!existsSync(OPENCODE_DB_PATH)) {
513
+ res.end(JSON.stringify({ ok: false, error: '数据库不存在' }));
514
+ return;
515
+ }
516
+ const db = new Database(OPENCODE_DB_PATH);
517
+ const now = Date.now();
518
+ const result = db.prepare('UPDATE session SET time_archived = ? WHERE id = ? AND time_archived IS NULL').run(now, sessionId);
519
+ db.close();
520
+ res.end(JSON.stringify({ ok: true, changes: result.changes }));
521
+ } catch (err) {
522
+ console.error('[DB] 软删除会话失败:', err.message);
523
+ res.end(JSON.stringify({ ok: false, error: err.message }));
524
+ }
525
+ return;
526
+ }
527
+
504
528
  // API: 获取会话消息
505
529
  if (req.url?.startsWith('/api/session/')) {
506
530
  const sessionId = req.url.split('/').pop();
@@ -627,6 +651,18 @@ wss.on('connection', (ws, req) => {
627
651
  ws.send(JSON.stringify({ type: 'restore-error', error: e.message }));
628
652
  }
629
653
  }
654
+ } else if (msg.type === 'start') {
655
+ // 前端启动指令:可选带 sessionId 恢复会话
656
+ const mode = currentMode;
657
+ console.log(`[start] 启动 ${mode}, sessionId=${msg.sessionId || '(新会话)'}`);
658
+ outputBuffer = '';
659
+ try {
660
+ await spawnProcess(mode, msg.sessionId || null);
661
+ ws.send(JSON.stringify({ type: 'started', sessionId: msg.sessionId || null }));
662
+ } catch (e) {
663
+ console.error('[start] 启动失败:', e.message);
664
+ ws.send(JSON.stringify({ type: 'start-error', error: e.message }));
665
+ }
630
666
  } else if (msg.type === 'new-session') {
631
667
  // 开启新会话:杀掉当前进程,重新启动不带 session 参数的 opencode
632
668
  const mode = currentMode;