claude-opencode-viewer 2.6.15 → 2.6.17

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.
@@ -7,7 +7,11 @@
7
7
  "Bash(node:*)",
8
8
  "Bash(lsof -ti:7008)",
9
9
  "Bash(sqlite3:*)",
10
- "Bash(ps:*)"
10
+ "Bash(ps:*)",
11
+ "Bash(git add:*)",
12
+ "Bash(git commit -m ':*)",
13
+ "Bash(git push:*)",
14
+ "Bash(npm publish:*)"
11
15
  ]
12
16
  }
13
17
  }
package/index-pc.html CHANGED
@@ -1217,6 +1217,7 @@
1217
1217
 
1218
1218
  ws.onclose = function() {
1219
1219
  ws = null;
1220
+ term.reset(); // 清除终端状态,避免 buffer 回放叠加导致状态混乱
1220
1221
  setTimeout(connect, 2000);
1221
1222
  };
1222
1223
 
@@ -1229,7 +1230,7 @@
1229
1230
  }
1230
1231
  }
1231
1232
  else if (msg.type === 'exit') {
1232
- if (!isCreatingNewSession) {
1233
+ if (!isCreatingNewSession && !isTransitioning) {
1233
1234
  throttledWrite('\r\n\x1b[33m[进程已退出: ' + msg.exitCode + ']\x1b[0m\r\n');
1234
1235
  throttledWrite('\x1b[90m按 Enter 键重新启动 ' + currentMode + '...\x1b[0m\r\n');
1235
1236
  }
@@ -1240,8 +1241,8 @@
1240
1241
  rebindTouchScroll();
1241
1242
  }
1242
1243
  else if (msg.type === 'switching') {
1243
- // 服务端开始切换,前端清屏
1244
- term.clear();
1244
+ // 服务端开始切换,完全重置终端(清除残留输出和状态)
1245
+ term.reset();
1245
1246
  writeBuffer = '';
1246
1247
  }
1247
1248
  else if (msg.type === 'state') {
package/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  /* 参考 cc-viewer 的 App.jsx 行 1320-1335: 顶部工具栏 */
23
23
  #header {
24
- padding: 10px 12px;
24
+ padding: 10px 8px;
25
25
  background: #111;
26
26
  border-bottom: 1px solid #222;
27
27
  display: flex;
@@ -29,7 +29,7 @@
29
29
  justify-content: space-between;
30
30
  flex-shrink: 0;
31
31
  height: 40px;
32
- gap: 12px;
32
+ gap: 6px;
33
33
  }
34
34
 
35
35
  #session-history-bar {
@@ -679,7 +679,7 @@
679
679
  <!-- 参考 cc-viewer 的 App.jsx 行 1315-1607: 完整的移动端布局结构 -->
680
680
  <div id="layout">
681
681
  <div id="header">
682
- <div style="display: flex; gap: 8px; align-items: center;">
682
+ <div style="display: flex; gap: 4px; align-items: center; overflow-x: auto; flex: 1; min-width: 0;">
683
683
  <button class="history-toggle-btn" id="new-session-btn" style="color:#73c991; border-color:#2a5a3a;">
684
684
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
685
685
  <line x1="12" y1="5" x2="12" y2="19"></line>
@@ -801,10 +801,10 @@
801
801
  <div class="virtual-key" data-key="left">←</div>
802
802
  <div class="virtual-key" data-key="right">→</div>
803
803
  <div class="virtual-key" data-key="enter">Enter</div>
804
+ <div class="virtual-key" data-key="paste">Ctrl+V</div>
804
805
  <div class="virtual-key" data-key="tab">Tab</div>
805
806
  <div class="virtual-key" data-key="esc">Esc</div>
806
807
  <div class="virtual-key" data-key="ctrlc">Ctrl+C</div>
807
- <div class="virtual-key" data-key="paste">Ctrl+V</div>
808
808
  </div>
809
809
  </div>
810
810
  </div>
@@ -1044,6 +1044,7 @@
1044
1044
  }
1045
1045
 
1046
1046
  // 触摸滚动实现 - 混合模式:alternate buffer 发鼠标滚轮,normal buffer 用 scrollLines
1047
+ var SCROLL_SENSITIVITY = 0.7; // 滚动灵敏度,越小每次滑动行数越少
1047
1048
  var touchScreen = null;
1048
1049
  var touchEventsBound = false;
1049
1050
 
@@ -1112,7 +1113,7 @@
1112
1113
  scrollRaf = null;
1113
1114
  if (pendingDy === 0) return;
1114
1115
 
1115
- pixelAccum += pendingDy;
1116
+ pixelAccum += pendingDy * SCROLL_SENSITIVITY;
1116
1117
  pendingDy = 0;
1117
1118
 
1118
1119
  var lh = getLineHeight();
@@ -1164,7 +1165,7 @@
1164
1165
  }
1165
1166
 
1166
1167
  if (pendingDy !== 0) {
1167
- pixelAccum += pendingDy;
1168
+ pixelAccum += pendingDy * SCROLL_SENSITIVITY;
1168
1169
  pendingDy = 0;
1169
1170
  var lh = getLineHeight();
1170
1171
  var lines = Math.trunc(pixelAccum / lh);
@@ -1189,8 +1190,9 @@
1189
1190
  }
1190
1191
  velocitySamples = [];
1191
1192
 
1193
+ velocity *= SCROLL_SENSITIVITY;
1192
1194
  console.log('[scroll] velocity:', velocity);
1193
- if (Math.abs(velocity) < 0.5) return;
1195
+ if (Math.abs(velocity) < 0.3) return;
1194
1196
 
1195
1197
  var friction = 0.95;
1196
1198
  var mAccum = 0;
@@ -1329,6 +1331,7 @@
1329
1331
 
1330
1332
  ws.onclose = function() {
1331
1333
  ws = null;
1334
+ term.reset(); // 清除终端状态,避免 buffer 回放叠加导致状态混乱
1332
1335
  setTimeout(connect, 2000);
1333
1336
  };
1334
1337
 
@@ -1341,7 +1344,7 @@
1341
1344
  }
1342
1345
  }
1343
1346
  else if (msg.type === 'exit') {
1344
- if (!isCreatingNewSession) {
1347
+ if (!isCreatingNewSession && !isTransitioning) {
1345
1348
  throttledWrite('\r\n\x1b[33m[进程已退出: ' + msg.exitCode + ']\x1b[0m\r\n');
1346
1349
  throttledWrite('\x1b[90m按 Enter 键重新启动 ' + currentMode + '...\x1b[0m\r\n');
1347
1350
  }
@@ -1352,8 +1355,8 @@
1352
1355
  rebindTouchScroll();
1353
1356
  }
1354
1357
  else if (msg.type === 'switching') {
1355
- // 服务端开始切换,前端清屏
1356
- term.clear();
1358
+ // 服务端开始切换,完全重置终端(清除残留输出和状态)
1359
+ term.reset();
1357
1360
  writeBuffer = '';
1358
1361
  }
1359
1362
  else if (msg.type === 'state') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.15",
3
+ "version": "2.6.17",
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
@@ -59,6 +59,27 @@ const OPENCODE_DB_PATH = process.env.OPENCODE_DB_PATH || join(
59
59
  );
60
60
 
61
61
  const MAX_BUFFER = 1024 * 1024; // 1MB
62
+
63
+ // 安全截断:避免从 ANSI 转义序列中间开始导致终端状态紊乱
64
+ function findSafeSliceStart(buf, rawStart) {
65
+ const scanLimit = Math.min(rawStart + 64, buf.length);
66
+ let i = rawStart;
67
+ while (i < scanLimit) {
68
+ const ch = buf.charCodeAt(i);
69
+ if (ch === 0x1b) {
70
+ let j = i + 1;
71
+ while (j < scanLimit && !((buf.charCodeAt(j) >= 0x40 && buf.charCodeAt(j) <= 0x7e) && j > i + 1)) {
72
+ j++;
73
+ }
74
+ if (j < scanLimit) return j + 1;
75
+ i = j;
76
+ continue;
77
+ }
78
+ if (ch >= 0x20 && ch <= 0x3f) { i++; continue; }
79
+ break;
80
+ }
81
+ return i < buf.length ? i : rawStart;
82
+ }
62
83
  const execFileAsync = promisify(execFile);
63
84
 
64
85
  let ptyModule = null;
@@ -73,9 +94,11 @@ let lastPtyRows = 30;
73
94
 
74
95
  let activeWs = null;
75
96
  let currentSessionId = null;
97
+ let previousSessionId = null; // 用于判断新会话是否已写入 DB
76
98
  const clientSizes = new Map();
77
99
  const mobileClients = new Set();
78
100
  let currentMode = 'opencode';
101
+ let isSwitching = false; // 模式切换/恢复会话中,忽略旧进程退出事件
79
102
 
80
103
  function getMobileSize() {
81
104
  for (const mws of mobileClients) {
@@ -194,7 +217,9 @@ async function spawnProcess(mode, sessionId = null) {
194
217
  proc.onData((data) => {
195
218
  outputBuffer += data;
196
219
  if (outputBuffer.length > MAX_BUFFER) {
197
- outputBuffer = outputBuffer.slice(-MAX_BUFFER);
220
+ const rawStart = outputBuffer.length - MAX_BUFFER;
221
+ const safeStart = findSafeSliceStart(outputBuffer, rawStart);
222
+ outputBuffer = outputBuffer.slice(safeStart);
198
223
  }
199
224
  dataListeners.forEach(cb => cb(data));
200
225
  });
@@ -210,6 +235,8 @@ async function spawnProcess(mode, sessionId = null) {
210
235
  if (opencodeProcess === proc) {
211
236
  opencodeProcess = null;
212
237
  }
238
+ // 已被替换的旧进程或切换中的进程,不通知前端
239
+ if (isSwitching || currentProcess !== null) return;
213
240
  exitListeners.forEach(cb => cb(exitCode || 0));
214
241
  });
215
242
 
@@ -236,23 +263,8 @@ async function spawnProcess(mode, sessionId = null) {
236
263
  currentSessionId = sessionId;
237
264
  console.log(`[session] 当前会话 ID: ${currentSessionId}`);
238
265
  } else {
239
- // 新建会话:延迟查数据库获取最新 session ID
266
+ // 新建会话:保持 null,前端点击复制时不显示内容
240
267
  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
268
  }
257
269
  }
258
270
 
@@ -266,6 +278,8 @@ async function switchMode(newMode) {
266
278
 
267
279
  console.log(`[switchMode] 从 ${currentMode} 切换到 ${newMode}`);
268
280
 
281
+ isSwitching = true;
282
+
269
283
  // 清空所有历史输出
270
284
  outputBuffer = '';
271
285
 
@@ -296,10 +310,15 @@ async function switchMode(newMode) {
296
310
  currentMode = newMode;
297
311
  try {
298
312
  await spawnProcess(newMode);
313
+ // 强制 resize 确保新进程使用正确的终端尺寸
314
+ if (currentProcess && lastPtyCols && lastPtyRows) {
315
+ try { currentProcess.resize(lastPtyCols, lastPtyRows); } catch {}
316
+ }
299
317
  console.log(`[switchMode] 切换到 ${newMode} 成功`);
300
318
  } catch (e) {
301
319
  console.error('[switchMode] 启动新进程失败:', e.message);
302
320
  }
321
+ isSwitching = false;
303
322
  }
304
323
 
305
324
  function writeToPty(data) {
@@ -644,13 +663,29 @@ const requestHandler = async (req, res) => {
644
663
  res.end('Not Found');
645
664
  };
646
665
 
647
- const server = USE_HTTPS
648
- ? createHttpsServer(await getOrCreateCert(), requestHandler)
649
- : createServer(requestHandler);
666
+ const httpsOpts = USE_HTTPS ? await getOrCreateCert() : null;
667
+ let server, wss;
668
+
669
+ function createServerAndWss() {
670
+ server = USE_HTTPS
671
+ ? createHttpsServer(httpsOpts, requestHandler)
672
+ : createServer(requestHandler);
673
+ wss = new WebSocketServer({ noServer: true });
674
+ setupWss(wss);
650
675
 
651
- const wss = new WebSocketServer({ server, path: '/ws' });
676
+ server.on('upgrade', (req, socket, head) => {
677
+ if (req.url === '/ws') {
678
+ wss.handleUpgrade(req, socket, head, (ws) => {
679
+ wss.emit('connection', ws, req);
680
+ });
681
+ } else {
682
+ socket.destroy();
683
+ }
684
+ });
685
+ }
652
686
 
653
- wss.on('connection', (ws, req) => {
687
+ function setupWss(wssInst) {
688
+ wssInst.on('connection', (ws, req) => {
654
689
  console.log('[WS] 客户端连接 from', req.socket.remoteAddress);
655
690
 
656
691
  ws.send(JSON.stringify({
@@ -660,12 +695,34 @@ wss.on('connection', (ws, req) => {
660
695
  mode: currentMode,
661
696
  }));
662
697
 
663
- if (outputBuffer) {
698
+ // 重连时:如果有当前 opencode 会话且不在切换中,重启加载完整会话
699
+ if (!isSwitching && currentMode === 'opencode' && currentProcess && currentSessionId) {
700
+ isSwitching = true;
701
+ const sid = currentSessionId;
702
+ try {
703
+ if (opencodeProcess) {
704
+ opencodeProcess.kill();
705
+ opencodeProcess = null;
706
+ currentProcess = null;
707
+ }
708
+ } catch {}
709
+ outputBuffer = '';
710
+ setTimeout(async () => {
711
+ try {
712
+ await spawnProcess('opencode', sid);
713
+ ws.send(JSON.stringify({ type: 'started', sessionId: sid }));
714
+ } catch (e) {
715
+ console.error('[reconnect] 重启失败:', e.message);
716
+ }
717
+ isSwitching = false;
718
+ }, 200);
719
+ } else if (outputBuffer) {
664
720
  ws.send(JSON.stringify({ type: 'data', data: outputBuffer }));
665
721
  }
666
722
 
723
+
667
724
  const listener = (data) => {
668
- if (ws.readyState === 1) {
725
+ if (ws.readyState === 1 && !isSwitching) {
669
726
  ws.send(JSON.stringify({ type: 'data', data }));
670
727
  }
671
728
  };
@@ -685,7 +742,8 @@ wss.on('connection', (ws, req) => {
685
742
 
686
743
  if (msg.type === 'input') {
687
744
  // 进程已退出时,自动重新启动(参考 cc-viewer 逻辑)
688
- if (!currentProcess) {
745
+ // 模式切换/新建会话期间不触发 respawn
746
+ if (!currentProcess && !isSwitching) {
689
747
  try {
690
748
  console.log(`[respawn] 进程已退出,自动重新启动 ${currentMode}`);
691
749
  outputBuffer = '';
@@ -719,6 +777,8 @@ wss.on('connection', (ws, req) => {
719
777
  if (msg.mode !== currentMode) {
720
778
  ws.send(JSON.stringify({ type: 'switching', mode: msg.mode }));
721
779
  await switchMode(msg.mode);
780
+ // 切换完成后:先发 reset 清除残留,再发新进程的 buffer
781
+ ws.send(JSON.stringify({ type: 'data', data: '\x1bc' }));
722
782
  ws.send(JSON.stringify({ type: 'mode', mode: currentMode }));
723
783
  setTimeout(() => {
724
784
  if (outputBuffer) {
@@ -731,6 +791,8 @@ wss.on('connection', (ws, req) => {
731
791
  if (msg.sessionId && currentMode === 'opencode') {
732
792
  console.log(`[restore] 恢复会话: ${msg.sessionId}`);
733
793
 
794
+ isSwitching = true;
795
+
734
796
  // 杀死当前 opencode 进程
735
797
  if (opencodeProcess) {
736
798
  try {
@@ -757,6 +819,7 @@ wss.on('connection', (ws, req) => {
757
819
  console.error('[restore] 启动进程失败:', e.message);
758
820
  ws.send(JSON.stringify({ type: 'restore-error', error: e.message }));
759
821
  }
822
+ isSwitching = false;
760
823
  }
761
824
  } else if (msg.type === 'start') {
762
825
  // 前端启动指令:可选带 sessionId 恢复会话
@@ -775,6 +838,10 @@ wss.on('connection', (ws, req) => {
775
838
  const mode = currentMode;
776
839
  console.log(`[new-session] 开启新会话, mode=${mode}`);
777
840
 
841
+ isSwitching = true;
842
+ previousSessionId = currentSessionId; // 记住旧会话,用于判断新会话是否已写入 DB
843
+ currentSessionId = null; // 立即清除旧会话 ID
844
+
778
845
  if (mode === 'opencode' && opencodeProcess) {
779
846
  try { opencodeProcess.kill(); } catch {}
780
847
  opencodeProcess = null;
@@ -796,6 +863,29 @@ wss.on('connection', (ws, req) => {
796
863
  console.error('[new-session] 启动失败:', e.message);
797
864
  ws.send(JSON.stringify({ type: 'new-session-error', error: e.message }));
798
865
  }
866
+ isSwitching = false;
867
+
868
+ // 延迟检测新会话 ID(等 opencode 写入 DB)
869
+ const prevSid = previousSessionId;
870
+ const checkNewSession = (attempt) => {
871
+ if (currentSessionId || attempt > 10) return;
872
+ try {
873
+ if (existsSync(OPENCODE_DB_PATH)) {
874
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
875
+ const row = db.prepare(
876
+ `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_created DESC LIMIT 1`
877
+ ).get();
878
+ db.close();
879
+ if (row && row.id !== prevSid) {
880
+ currentSessionId = row.id;
881
+ console.log(`[new-session] 检测到新会话 ID: ${currentSessionId}`);
882
+ return;
883
+ }
884
+ }
885
+ } catch {}
886
+ setTimeout(() => checkNewSession(attempt + 1), 2000);
887
+ };
888
+ setTimeout(() => checkNewSession(0), 3000);
799
889
  }
800
890
  } catch (err) {
801
891
  console.error('[WS] Error:', err.message);
@@ -826,14 +916,17 @@ wss.on('connection', (ws, req) => {
826
916
  }
827
917
  });
828
918
  });
919
+ } // end setupWss
920
+
921
+ createServerAndWss();
829
922
 
830
923
  process.on('SIGINT', () => process.exit(0));
831
924
  process.on('SIGTERM', () => process.exit(0));
832
925
 
833
- // PC 模式端口范围,用于端口冲突重试
834
- const PC_PORT_MIN = 19200;
835
- const PC_PORT_MAX = 19220;
836
- const MAX_PORT_RETRIES = 10;
926
+ // 端口范围,用于端口冲突重试
927
+ const PORT_MIN = IS_PC ? 19200 : 7008;
928
+ const PORT_MAX = IS_PC ? 19220 : 7028;
929
+ const MAX_PORT_RETRIES = 20;
837
930
 
838
931
  function startServer(retries = 0) {
839
932
  server.listen(PORT, '0.0.0.0', async () => {
@@ -873,12 +966,11 @@ function startServer(retries = 0) {
873
966
  });
874
967
 
875
968
  server.on('error', (err) => {
876
- if (err.code === 'EADDRINUSE' && IS_PC && retries < MAX_PORT_RETRIES) {
877
- // PC 模式端口冲突,顺序查找下一个可用端口
969
+ if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
878
970
  const oldPort = PORT;
879
- PORT = PORT >= PC_PORT_MAX ? PC_PORT_MIN : PORT + 1;
971
+ PORT = PORT >= PORT_MAX ? PORT_MIN : PORT + 1;
880
972
  console.error(`[port] ${oldPort} 已占用,尝试 ${PORT} (${retries + 1}/${MAX_PORT_RETRIES})`);
881
- server.removeAllListeners('error');
973
+ createServerAndWss();
882
974
  startServer(retries + 1);
883
975
  } else {
884
976
  console.error(`启动失败: ${err.message}`);