claude-opencode-viewer 2.6.1 → 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,7 +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" id="btn-copy">复制</div>
953
979
  </div>
954
980
  </div>
955
981
  </div>
@@ -984,6 +1010,32 @@
984
1010
 
985
1011
  term.open(document.getElementById('terminal'));
986
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
+
987
1039
  var modeSelect = document.getElementById('mode-select');
988
1040
  var terminalEl = document.getElementById('terminal');
989
1041
  var ws = null;
@@ -1018,25 +1070,15 @@
1018
1070
  }
1019
1071
 
1020
1072
  function loadInputCache() {
1021
- // 确保只恢复一次
1022
- if (cacheRestored) {
1023
- console.log('[cache] already restored, skipping');
1024
- return;
1025
- }
1073
+ if (cacheRestored) return;
1074
+ cacheRestored = true;
1026
1075
 
1027
1076
  var cached = localStorage.getItem(CACHE_KEY);
1028
- if (cached && ws && ws.readyState === 1) {
1029
- console.log('[cache] restoring:', cached);
1030
- // 立即清除缓存,防止重复调用
1031
- localStorage.removeItem(CACHE_KEY);
1032
- cacheRestored = true;
1033
-
1034
- // 将缓存的输入发送到终端
1035
- ws.send(JSON.stringify({ type: 'input', data: cached }));
1036
- // 重要:恢复后清空 currentInputBuffer,防止再次保存
1037
- currentInputBuffer = '';
1038
- } else {
1039
- console.log('[cache] no cache to restore');
1077
+ if (cached) {
1078
+ console.log('[cache] restoring buffer:', cached);
1079
+ // 只恢复跟踪变量,不重新发送到 pty
1080
+ // 因为 outputBuffer 回放已经包含了之前的回显
1081
+ currentInputBuffer = cached;
1040
1082
  }
1041
1083
  }
1042
1084
 
@@ -1159,16 +1201,39 @@
1159
1201
  return term.buffer.active.type === 'alternate';
1160
1202
  }
1161
1203
 
1162
- function sendPageKey(direction) {
1163
- if (!ws || ws.readyState !== 1) return;
1164
- var seq = direction === 'up' ? '\x1b[5~' : '\x1b[6~';
1165
- ws.send(JSON.stringify({ type: 'input', data: seq }));
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
+ }
1166
1223
  }
1167
1224
 
1225
+ var altScrollAccum = 0;
1226
+ var ALT_SCROLL_THRESHOLD = 2;
1227
+
1168
1228
  function doScroll(lines) {
1169
1229
  if (lines === 0) return;
1170
1230
  if (isAlternateBuffer()) {
1171
- sendPageKey(lines < 0 ? 'up' : 'down');
1231
+ altScrollAccum += lines;
1232
+ if (Math.abs(altScrollAccum) >= ALT_SCROLL_THRESHOLD) {
1233
+ var scrollLines = Math.trunc(altScrollAccum);
1234
+ emitWheelEvent(scrollLines);
1235
+ altScrollAccum -= scrollLines;
1236
+ }
1172
1237
  } else {
1173
1238
  term.scrollLines(lines);
1174
1239
  }
@@ -1212,18 +1277,21 @@
1212
1277
  // 长按检测
1213
1278
  var longPressTimer = null;
1214
1279
  var longPressTriggered = false;
1215
- var LONG_PRESS_DELAY = 500; // ms
1280
+ var LONG_PRESS_DELAY = 550; // ms
1216
1281
 
1217
1282
  function clearLongPress() {
1218
1283
  if (longPressTimer) {
1219
1284
  clearTimeout(longPressTimer);
1220
1285
  longPressTimer = null;
1221
1286
  }
1287
+ // 始终恢复 xterm textarea,防止 disabled 残留
1288
+ var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1289
+ if (xtermTa) xtermTa.removeAttribute('disabled');
1222
1290
  }
1223
1291
 
1224
1292
  function handleTouchStart(e) {
1225
- console.log('[scroll] touchstart');
1226
1293
  stopMomentum();
1294
+ altScrollAccum = 0;
1227
1295
  longPressTriggered = false;
1228
1296
  clearLongPress();
1229
1297
  if (e.touches.length !== 1) return;
@@ -1232,6 +1300,12 @@
1232
1300
  velocitySamples = [];
1233
1301
 
1234
1302
  // 启动长按计时器
1303
+ // 在长按检测期间阻止 xterm textarea 获取焦点,防止弹出键盘
1304
+ var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1305
+ if (xtermTa) {
1306
+ xtermTa.setAttribute('disabled', 'true');
1307
+ }
1308
+
1235
1309
  longPressTimer = setTimeout(function() {
1236
1310
  longPressTriggered = true;
1237
1311
  longPressTimer = null;
@@ -1423,8 +1497,8 @@
1423
1497
  } else if (d === '\x15') {
1424
1498
  // Ctrl+U:清空整行
1425
1499
  clearInputCache();
1426
- } else if (d.length === 1 && d.charCodeAt(0) >= 32 && d.charCodeAt(0) < 127) {
1427
- // 可打印 ASCII 字符:添加到缓冲区
1500
+ } else if (d.charCodeAt(0) >= 32 && d.charCodeAt(0) !== 127) {
1501
+ // 可打印字符(含中文等多字节):添加到缓冲区
1428
1502
  currentInputBuffer += d;
1429
1503
  saveInputCache();
1430
1504
  }
@@ -1443,6 +1517,7 @@
1443
1517
 
1444
1518
  ws.onopen = function() {
1445
1519
  resize();
1520
+ rebindTouchScroll();
1446
1521
  };
1447
1522
 
1448
1523
  ws.onclose = function() {
@@ -1491,6 +1566,9 @@
1491
1566
  // 恢复失败
1492
1567
  term.write('\x1b[31m✗ 恢复失败: ' + msg.error + '\x1b[0m\r\n');
1493
1568
  }
1569
+ else if (msg.type === 'started') {
1570
+ rebindTouchScroll();
1571
+ }
1494
1572
  else if (msg.type === 'new-session-ok') {
1495
1573
  isCreatingNewSession = false;
1496
1574
  term.clear();
@@ -1507,7 +1585,6 @@
1507
1585
  if (currentMode === 'opencode') {
1508
1586
  loadInputCache();
1509
1587
  } else {
1510
- // 非 opencode 模式,标记为已恢复,避免后续缓存逻辑干扰
1511
1588
  cacheRestored = true;
1512
1589
  }
1513
1590
  }, 800);
@@ -1743,8 +1820,18 @@
1743
1820
  info.appendChild(title);
1744
1821
  info.appendChild(meta);
1745
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
+
1746
1832
  item.appendChild(icon);
1747
1833
  item.appendChild(info);
1834
+ item.appendChild(deleteBtn);
1748
1835
 
1749
1836
  item.addEventListener('click', function() {
1750
1837
  loadSession(session);
@@ -1754,6 +1841,39 @@
1754
1841
  });
1755
1842
  }
1756
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
+
1757
1877
  function showSessionDetail() {
1758
1878
  document.getElementById('session-list-view').classList.add('hidden');
1759
1879
  document.getElementById('session-detail-view').classList.add('visible');
@@ -2001,17 +2121,6 @@
2001
2121
  setTimeout(function() { toast.classList.remove('show'); }, 1200);
2002
2122
  }
2003
2123
 
2004
- // "复制" 按钮
2005
- document.getElementById('btn-copy').addEventListener('touchend', function(e) {
2006
- e.preventDefault();
2007
- var text = getTerminalText();
2008
- if (text) copyToClipboard(text);
2009
- });
2010
- document.getElementById('btn-copy').addEventListener('click', function(e) {
2011
- e.preventDefault();
2012
- var text = getTerminalText();
2013
- if (text) copyToClipboard(text);
2014
- });
2015
2124
 
2016
2125
  // 方案2: 长按进入选择模式 — 原位显示可选纯文本
2017
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.1",
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;