claude-opencode-viewer 2.6.47 → 2.6.49

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/server.js CHANGED
@@ -201,6 +201,7 @@ function killProcessTree(proc) {
201
201
 
202
202
  // 清理孤儿进程(PPID=1 的 opencode/claude)
203
203
  function cleanupOrphanProcesses() {
204
+ const tc = Date.now();
204
205
  try {
205
206
  const myPid = process.pid;
206
207
  const orphans = execSync(
@@ -212,15 +213,23 @@ function cleanupOrphanProcesses() {
212
213
  }
213
214
  if (orphans.length > 0) LOG(`[cleanup] 清理孤儿进程: ${orphans.join(', ')}`);
214
215
  } catch {}
216
+ console.log(`[perf] cleanupOrphanProcesses: ${Date.now() - tc}ms`);
215
217
  }
216
218
 
217
219
  async function spawnProcess(mode, sessionId = null) {
220
+ const t0 = Date.now();
218
221
  const pty = await getPty();
222
+ console.log(`[perf] getPty: ${Date.now() - t0}ms`);
223
+
224
+ const t1 = Date.now();
219
225
  fixSpawnHelperPermissions();
226
+ console.log(`[perf] fixSpawnHelperPermissions: ${Date.now() - t1}ms`);
220
227
 
221
228
  let command, args = [];
222
229
  if (mode === 'claude') {
230
+ const t2 = Date.now();
223
231
  const claudePath = findCommand('claude');
232
+ console.log(`[perf] findCommand(claude): ${Date.now() - t2}ms`);
224
233
  if (claudePath.endsWith('.js')) {
225
234
  command = process.execPath;
226
235
  args = [claudePath];
@@ -233,7 +242,9 @@ async function spawnProcess(mode, sessionId = null) {
233
242
  LOG(`[claude] 恢复会话: ${sessionId}`);
234
243
  }
235
244
  } else {
245
+ const t2 = Date.now();
236
246
  command = findCommand('opencode');
247
+ console.log(`[perf] findCommand(opencode): ${Date.now() - t2}ms`);
237
248
  // 如果提供了 sessionId,添加 --session 参数
238
249
  if (sessionId) {
239
250
  args = ['--session', sessionId];
@@ -244,6 +255,7 @@ async function spawnProcess(mode, sessionId = null) {
244
255
  const spawnEnv = { ...process.env };
245
256
 
246
257
  // 恢复会话时,使用会话记录的工作目录
258
+ const t3 = Date.now();
247
259
  let spawnCwd = process.cwd();
248
260
  if (sessionId && mode === 'opencode') {
249
261
  try {
@@ -286,6 +298,9 @@ async function spawnProcess(mode, sessionId = null) {
286
298
  }
287
299
  }
288
300
 
301
+ console.log(`[perf] cwdLookup: ${Date.now() - t3}ms`);
302
+
303
+ const t4 = Date.now();
289
304
  const proc = pty.spawn(command, args, {
290
305
  name: 'xterm-256color',
291
306
  cols: lastPtyCols,
@@ -294,6 +309,9 @@ async function spawnProcess(mode, sessionId = null) {
294
309
  env: spawnEnv,
295
310
  });
296
311
 
312
+ console.log(`[perf] pty.spawn: ${Date.now() - t4}ms`);
313
+ console.log(`[perf] spawnProcess total: ${Date.now() - t0}ms`);
314
+
297
315
  proc.onData((data) => {
298
316
  // 忽略已被替换的旧进程输出
299
317
  if (currentProcess !== proc) return;
@@ -637,7 +655,7 @@ const requestHandler = async (req, res) => {
637
655
  return;
638
656
  }
639
657
 
640
- // API: 获取 git status
658
+ // API: 获取 git status(含每个文件的 unified_diff,批量获取优化)
641
659
  if (req.url === '/api/git-status') {
642
660
  res.writeHead(200, {
643
661
  'Content-Type': 'application/json',
@@ -645,13 +663,76 @@ const requestHandler = async (req, res) => {
645
663
  });
646
664
  try {
647
665
  const gitCwd = process.env.PROJECT_DIR || process.cwd();
648
- const { stdout } = await execFileAsync('git', ['-c', 'safe.directory=*', 'status', '--porcelain'], {
649
- cwd: gitCwd, encoding: 'utf-8', timeout: 60000,
650
- });
651
- const changes = stdout.split('\n').filter(Boolean).map(line => ({
666
+
667
+ // 并行执行: git status + git diff --numstat + git diff (批量获取)
668
+ const [statusResult, numstatResult, diffResult] = await Promise.all([
669
+ execFileAsync('git', ['-c', 'safe.directory=*', 'status', '--porcelain'], { cwd: gitCwd, encoding: 'utf-8', timeout: 60000 }),
670
+ execFileAsync('git', ['-c', 'safe.directory=*', 'diff', '--numstat', 'HEAD'], { cwd: gitCwd, encoding: 'utf-8', timeout: 60000 }).catch(() => ({ stdout: '' })),
671
+ execFileAsync('git', ['-c', 'safe.directory=*', 'diff', '--no-color', '-U3', 'HEAD'], { cwd: gitCwd, encoding: 'utf-8', timeout: 60000, maxBuffer: 10 * 1024 * 1024 }).catch(e => ({ stdout: e.stdout || '' })),
672
+ ]);
673
+
674
+ const changes = statusResult.stdout.split('\n').filter(Boolean).map(line => ({
652
675
  status: line.substring(0, 2).trim(),
653
676
  file: line.substring(3),
654
677
  })).filter(c => !/^core-/.test(c.file));
678
+
679
+ // 解析 numstat 识别二进制文件
680
+ const binaryFiles = new Set();
681
+ numstatResult.stdout.split('\n').filter(Boolean).forEach(line => {
682
+ if (line.startsWith('-\t-\t')) binaryFiles.add(line.split('\t')[2]);
683
+ });
684
+
685
+ // 将批量 diff 输出按文件拆分
686
+ const diffMap = {};
687
+ const diffParts = diffResult.stdout.split(/^diff --git /m);
688
+ for (let i = 1; i < diffParts.length; i++) {
689
+ const part = diffParts[i];
690
+ // 提取文件名: "a/path b/path\n..."
691
+ const firstLine = part.substring(0, part.indexOf('\n'));
692
+ const bMatch = firstLine.match(/ b\/(.+)$/);
693
+ if (bMatch) diffMap[bMatch[1]] = 'diff --git ' + part;
694
+ }
695
+
696
+ // 填充每个文件的 diff 信息
697
+ const untrackedFiles = [];
698
+ for (const c of changes) {
699
+ if (c.file.includes('..') || c.file.startsWith('/')) continue;
700
+ c.is_new = c.status === 'A' || c.status === '??';
701
+ c.is_deleted = c.status === 'D';
702
+ c.is_binary = binaryFiles.has(c.file);
703
+ if (c.is_binary) continue;
704
+
705
+ // 检查大文件
706
+ if (!c.is_deleted) {
707
+ try {
708
+ const filePath = join(gitCwd, c.file);
709
+ if (existsSync(filePath)) {
710
+ const stat = statSync(filePath);
711
+ if (stat.size > 5 * 1024 * 1024) { c.is_large = true; c.size = stat.size; continue; }
712
+ }
713
+ } catch {}
714
+ }
715
+
716
+ if (c.status === '??') {
717
+ // untracked 文件需要单独处理
718
+ untrackedFiles.push(c);
719
+ } else {
720
+ c.unified_diff = diffMap[c.file] || '';
721
+ }
722
+ }
723
+
724
+ // 对 untracked 文件并行获取 diff
725
+ if (untrackedFiles.length > 0) {
726
+ await Promise.all(untrackedFiles.map(async (c) => {
727
+ try {
728
+ const { stdout: diffOut } = await execFileAsync('git', ['-c', 'safe.directory=*', 'diff', '--no-color', '-U3', '--no-index', '/dev/null', c.file], { cwd: gitCwd, encoding: 'utf-8', timeout: 30000, maxBuffer: 5 * 1024 * 1024 });
729
+ c.unified_diff = diffOut;
730
+ } catch (e) {
731
+ c.unified_diff = e.stdout || '';
732
+ }
733
+ }));
734
+ }
735
+
655
736
  res.end(JSON.stringify({ changes, cwd: gitCwd }));
656
737
  } catch (err) {
657
738
  res.end(JSON.stringify({ changes: [], cwd: process.env.PROJECT_DIR || process.cwd(), error: err.message }));
@@ -786,6 +867,7 @@ const requestHandler = async (req, res) => {
786
867
 
787
868
  // API: 获取最近的 OpenCode 和 Claude 会话(用于启动对话框)
788
869
  if (req.url === '/api/last-sessions') {
870
+ const tls = Date.now();
789
871
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
790
872
  let opencode = null, claude = null;
791
873
  // OpenCode: 从 SQLite 查最近会话
@@ -861,6 +943,7 @@ const requestHandler = async (req, res) => {
861
943
  }
862
944
  }
863
945
  } catch {}
946
+ console.log(`[perf] last-sessions: ${Date.now() - tls}ms`);
864
947
  res.end(JSON.stringify({ opencode, claude }));
865
948
  return;
866
949
  }
@@ -1099,8 +1182,16 @@ wssInst.on('connection', (ws, req) => {
1099
1182
  }
1100
1183
  isSwitching = false;
1101
1184
  }, 200);
1102
- } else if (outputBuffer) {
1103
- ws.send(JSON.stringify({ type: 'data', data: outputBuffer }));
1185
+ } else if (currentProcess) {
1186
+ // TUI 程序使用 alternate screen buffer,直接回放 raw buffer 容易转义序列错乱。
1187
+ // 改为发送 resize 信号让 TUI 重绘当前画面。
1188
+ // 先改为不同尺寸再改回,强制触发 SIGWINCH(相同尺寸不触发)。
1189
+ try {
1190
+ currentProcess.resize(Math.max(2, lastPtyCols - 1), lastPtyRows);
1191
+ setTimeout(() => {
1192
+ try { currentProcess.resize(lastPtyCols, lastPtyRows); } catch {}
1193
+ }, 50);
1194
+ } catch {}
1104
1195
  }
1105
1196
 
1106
1197
 
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "skills": {
4
+ "code-review-expert": {
5
+ "source": "sanyuan0704/sanyuan-skills",
6
+ "sourceType": "github",
7
+ "computedHash": "6c2fe31851a34e63e033257527eab04eea835ca1cf4c4276d1392a323e36e377"
8
+ }
9
+ }
10
+ }
package/test-doc.md ADDED
@@ -0,0 +1,86 @@
1
+ # Claude OpenCode Viewer 使用指南
2
+
3
+ ## 简介
4
+
5
+ Claude OpenCode Viewer(COV)是一个统一的终端查看器,支持在浏览器中远程查看和操作 Claude Code 与 OpenCode 的终端会话。适用于需要在手机或其他设备上查看 AI 编程助手工作进度的场景。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ npm install -g claude-opencode-viewer
11
+ ```
12
+
13
+ 安装完成后,可以通过以下命令启动:
14
+
15
+ ```bash
16
+ cov
17
+ ```
18
+
19
+ 默认监听端口 7008,PC 模式使用 `--pc` 参数启动。
20
+
21
+ ## 功能特性
22
+
23
+ ### 终端查看
24
+
25
+ 支持实时查看终端输出,基于 xterm.js 实现完整的终端模拟,包括:
26
+
27
+ - 颜色渲染
28
+ - Unicode 字符支持
29
+ - WebGL 加速渲染
30
+ - 移动端触摸滚动
31
+
32
+ ### 会话管理
33
+
34
+ 可以管理多个会话,支持以下操作:
35
+
36
+ 1. 查看历史会话列表
37
+ 2. 恢复已有会话
38
+ 3. 创建新会话
39
+ 4. 在 Claude 和 OpenCode 之间切换
40
+
41
+ ### Git 变更查看
42
+
43
+ 集成了 Git 状态查看功能,可以直接在页面上查看:
44
+
45
+ | 状态 | 含义 | 颜色 |
46
+ |------|------|------|
47
+ | M | 已修改 | 橙色 |
48
+ | A | 新增 | 绿色 |
49
+ | D | 已删除 | 红色 |
50
+ | ?? | 未跟踪 | 灰色 |
51
+
52
+ ### 文档浏览
53
+
54
+ 支持浏览项目中的 Markdown 文档,自动扫描项目目录下的 `.md` 文件并以富文本格式展示。
55
+
56
+ ## 配置说明
57
+
58
+ > 注意:以下配置需要在项目根目录下操作,确保 `PROJECT_DIR` 环境变量指向正确的项目路径。
59
+
60
+ 常用环境变量:
61
+
62
+ - `PROJECT_DIR` — 指定项目工作目录
63
+ - `PORT` — 自定义端口号
64
+ - `COV_MODE` — 默认启动模式(claude / opencode)
65
+
66
+ ## 常见问题
67
+
68
+ ### 连接断开怎么办?
69
+
70
+ 页面会自动显示重连提示并尝试重新连接。如果持续无法连接,请检查:
71
+
72
+ 1. 服务进程是否仍在运行
73
+ 2. 网络是否可达
74
+ 3. 端口是否被占用
75
+
76
+ ### 移动端键盘遮挡问题
77
+
78
+ 在 iOS 设备上,系统会自动调整终端高度以适应键盘弹出。如果遇到显示异常,可以尝试旋转屏幕后再旋转回来。
79
+
80
+ ## 更新日志
81
+
82
+ **v2.6.48** — 修复重连内容重复、模式切换黑屏问题
83
+
84
+ **v2.6.47** — 添加 PC 端重连覆盖层
85
+
86
+ **v2.6.46** — 移动端键盘交互优化