claude-opencode-viewer 2.0.0
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/README.md +101 -0
- package/bin/cov.js +79 -0
- package/index.html +459 -0
- package/install.sh +52 -0
- package/package.json +42 -0
- package/server.js +309 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Claude OpenCode Viewer
|
|
2
|
+
|
|
3
|
+
一个统一的终端查看器,支持 **Claude Code** 和 **OpenCode** 之间的无缝切换。
|
|
4
|
+
|
|
5
|
+
## ✨ 特性
|
|
6
|
+
|
|
7
|
+
- 🔄 **无缝切换** - 在 Claude Code 和 OpenCode 之间丝滑切换
|
|
8
|
+
- 📱 **移动优先** - 支持移动端访问,配备虚拟键盘
|
|
9
|
+
- 🚀 **快速启动** - 通过简单的 `cov` 命令即可启动
|
|
10
|
+
- 🌐 **局域网访问** - 支持手机等移动设备访问
|
|
11
|
+
|
|
12
|
+
## 📦 安装
|
|
13
|
+
|
|
14
|
+
### 方式一:npm 全局安装(推荐)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g claude-opencode-viewer
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 方式二:使用 npx 直接运行
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx claude-opencode-viewer
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 方式三:从源码安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone <repository-url>
|
|
30
|
+
cd claude_opencode_viewer
|
|
31
|
+
npm install
|
|
32
|
+
npm link
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 🚀 使用方法
|
|
36
|
+
|
|
37
|
+
### 启动服务
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 在当前目录启动
|
|
41
|
+
cov
|
|
42
|
+
|
|
43
|
+
# 在指定目录启动
|
|
44
|
+
cov /path/to/project
|
|
45
|
+
|
|
46
|
+
# 查看帮助
|
|
47
|
+
cov --help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 访问界面
|
|
51
|
+
|
|
52
|
+
启动后,在浏览器中访问:
|
|
53
|
+
|
|
54
|
+
- **本地访问**: http://127.0.0.1:7008
|
|
55
|
+
- **手机访问**: http://<本机 IP>:7008
|
|
56
|
+
|
|
57
|
+
### 切换模式
|
|
58
|
+
|
|
59
|
+
在界面底部可以选择切换到:
|
|
60
|
+
- **Claude Code** - Anthropic 的 AI 编程助手
|
|
61
|
+
- **OpenCode** - 开源 AI 编程工具
|
|
62
|
+
|
|
63
|
+
## 📋 系统要求
|
|
64
|
+
|
|
65
|
+
- Node.js >= 16.0.0
|
|
66
|
+
- macOS / Linux / Windows
|
|
67
|
+
- 需要预先安装 `claude` 或 `opencode` 命令
|
|
68
|
+
|
|
69
|
+
## 🔧 开发
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# 安装依赖
|
|
73
|
+
npm install
|
|
74
|
+
|
|
75
|
+
# 启动服务
|
|
76
|
+
npm start
|
|
77
|
+
|
|
78
|
+
# 本地测试全局命令
|
|
79
|
+
npm link
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 📝 命令说明
|
|
83
|
+
|
|
84
|
+
| 命令 | 说明 |
|
|
85
|
+
|------|------|
|
|
86
|
+
| `cov` | 启动服务 |
|
|
87
|
+
| `cov [路径]` | 在指定目录启动服务 |
|
|
88
|
+
| `cov --help` | 显示帮助信息 |
|
|
89
|
+
| `cov --version` | 显示版本号 |
|
|
90
|
+
|
|
91
|
+
## 🎮 移动端使用
|
|
92
|
+
|
|
93
|
+
在移动设备上访问时,界面会自动适配:
|
|
94
|
+
- 虚拟方向键
|
|
95
|
+
- 常用控制键(Esc、Tab、Ctrl+C 等)
|
|
96
|
+
- 模式切换选择器
|
|
97
|
+
- 惯性滚动支持
|
|
98
|
+
|
|
99
|
+
## 📄 License
|
|
100
|
+
|
|
101
|
+
MIT
|
package/bin/cov.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cov - Claude OpenCode Viewer 启动命令
|
|
4
|
+
*
|
|
5
|
+
* 使用方法:
|
|
6
|
+
* cov # 在当前目录启动
|
|
7
|
+
* cov [path] # 在指定目录启动
|
|
8
|
+
* cov --help # 显示帮助信息
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const SERVER_PATH = join(__dirname, '..', 'server.js');
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
|
|
21
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
22
|
+
console.log(`
|
|
23
|
+
╔══════════════════════════════════════════════════════════╗
|
|
24
|
+
║ Claude OpenCode Viewer (cov) ║
|
|
25
|
+
╠══════════════════════════════════════════════════════════╣
|
|
26
|
+
║ 用法: ║
|
|
27
|
+
║ cov 在当前目录启动服务 ║
|
|
28
|
+
║ cov [路径] 在指定目录启动服务 ║
|
|
29
|
+
║ cov --help, -h 显示此帮助信息 ║
|
|
30
|
+
║ cov --version, -v 显示版本号 ║
|
|
31
|
+
╠══════════════════════════════════════════════════════════╣
|
|
32
|
+
║ 功能: ║
|
|
33
|
+
║ - 同时支持 Claude Code 和 OpenCode ║
|
|
34
|
+
║ - 丝滑切换两个 AI 终端 ║
|
|
35
|
+
║ - 支持移动端访问 ║
|
|
36
|
+
╠══════════════════════════════════════════════════════════╣
|
|
37
|
+
║ 访问地址: ║
|
|
38
|
+
║ 本地:http://127.0.0.1:7008 ║
|
|
39
|
+
║ 手机:http://<本机 IP>:7008 ║
|
|
40
|
+
╚══════════════════════════════════════════════════════════╝
|
|
41
|
+
`);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
46
|
+
const pkgPath = join(__dirname, '..', 'package.json');
|
|
47
|
+
if (existsSync(pkgPath)) {
|
|
48
|
+
const pkg = JSON.parse(await import('node:fs').then(m => m.readFileSync(pkgPath, 'utf-8')));
|
|
49
|
+
console.log(`v${pkg.version}`);
|
|
50
|
+
}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 如果指定了路径,切换到该目录
|
|
55
|
+
if (args.length > 0 && !args[0].startsWith('-')) {
|
|
56
|
+
const targetPath = resolve(args[0]);
|
|
57
|
+
if (existsSync(targetPath)) {
|
|
58
|
+
process.chdir(targetPath);
|
|
59
|
+
console.log(`📁 工作目录:${process.cwd()}`);
|
|
60
|
+
} else {
|
|
61
|
+
console.error(`❌ 目录不存在:${targetPath}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 启动服务器
|
|
67
|
+
const server = spawn(process.execPath, [SERVER_PATH], {
|
|
68
|
+
stdio: 'inherit',
|
|
69
|
+
cwd: process.cwd(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
server.on('error', (err) => {
|
|
73
|
+
console.error('❌ 启动失败:', err.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
server.on('exit', (code) => {
|
|
78
|
+
process.exit(code || 0);
|
|
79
|
+
});
|
package/index.html
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
|
|
6
|
+
<title>Combined Viewer V2</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
8
|
+
<style>
|
|
9
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
10
|
+
html, body { height: 100%; background: #0a0a0a; overflow: hidden; }
|
|
11
|
+
body {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
background: #0a0a0a;
|
|
15
|
+
}
|
|
16
|
+
#terminal {
|
|
17
|
+
flex: 1;
|
|
18
|
+
min-height: 0;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
padding: 4px 8px;
|
|
21
|
+
touch-action: none;
|
|
22
|
+
overscroll-behavior: contain;
|
|
23
|
+
}
|
|
24
|
+
#terminal.transitioning {
|
|
25
|
+
opacity: 0.3;
|
|
26
|
+
transition: opacity 0.3s ease;
|
|
27
|
+
}
|
|
28
|
+
#status {
|
|
29
|
+
padding: 4px;
|
|
30
|
+
background: #111;
|
|
31
|
+
font-size: 12px;
|
|
32
|
+
color: #888;
|
|
33
|
+
text-align: center;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
height: 32px;
|
|
36
|
+
line-height: 24px;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
gap: 8px;
|
|
41
|
+
}
|
|
42
|
+
#status.connected { color: #4ade80; }
|
|
43
|
+
#status.disconnected { color: #f87171; }
|
|
44
|
+
#mode-indicator {
|
|
45
|
+
font-size: 10px;
|
|
46
|
+
padding: 2px 6px;
|
|
47
|
+
background: #222;
|
|
48
|
+
border-radius: 3px;
|
|
49
|
+
color: #888;
|
|
50
|
+
}
|
|
51
|
+
#mode-switcher {
|
|
52
|
+
display: none;
|
|
53
|
+
background: #111;
|
|
54
|
+
border-top: 1px solid #222;
|
|
55
|
+
flex-shrink: 0;
|
|
56
|
+
padding: 6px 8px;
|
|
57
|
+
}
|
|
58
|
+
#mode-switcher select {
|
|
59
|
+
background: #1a1a1a;
|
|
60
|
+
color: #d4d4d4;
|
|
61
|
+
border: 1px solid #333;
|
|
62
|
+
border-radius: 4px;
|
|
63
|
+
padding: 6px 28px 6px 10px;
|
|
64
|
+
font-size: 13px;
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
-webkit-appearance: none;
|
|
67
|
+
-moz-appearance: none;
|
|
68
|
+
appearance: none;
|
|
69
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23888' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
|
|
70
|
+
background-repeat: no-repeat;
|
|
71
|
+
background-position: right 8px center;
|
|
72
|
+
}
|
|
73
|
+
#mode-switcher select:focus {
|
|
74
|
+
outline: none;
|
|
75
|
+
border-color: #4ade80;
|
|
76
|
+
}
|
|
77
|
+
#virtual-keybar {
|
|
78
|
+
display: none;
|
|
79
|
+
background: #111;
|
|
80
|
+
border-top: 1px solid #222;
|
|
81
|
+
flex-shrink: 0;
|
|
82
|
+
}
|
|
83
|
+
#virtual-keybar-row1, #virtual-keybar-row2 {
|
|
84
|
+
display: flex;
|
|
85
|
+
gap: 6px;
|
|
86
|
+
padding: 6px 8px;
|
|
87
|
+
justify-content: center;
|
|
88
|
+
flex-wrap: wrap;
|
|
89
|
+
}
|
|
90
|
+
#virtual-keybar-row1 button, #virtual-keybar-row2 button {
|
|
91
|
+
flex: 1;
|
|
92
|
+
min-width: 48px;
|
|
93
|
+
max-width: 80px;
|
|
94
|
+
height: 40px;
|
|
95
|
+
border: 1px solid #333;
|
|
96
|
+
border-radius: 6px;
|
|
97
|
+
background: #1a1a1a;
|
|
98
|
+
color: #ccc;
|
|
99
|
+
font-size: 13px;
|
|
100
|
+
touch-action: manipulation;
|
|
101
|
+
}
|
|
102
|
+
#virtual-keybar-row1 button:active, #virtual-keybar-row2 button:active { background: #333; }
|
|
103
|
+
@media (max-width: 768px) {
|
|
104
|
+
#mode-switcher { display: block; }
|
|
105
|
+
#virtual-keybar { display: block; }
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
108
|
+
</head>
|
|
109
|
+
<body>
|
|
110
|
+
<div id="terminal"></div>
|
|
111
|
+
<div id="status" class="disconnected">
|
|
112
|
+
<span id="status-text">● 未连接</span>
|
|
113
|
+
<span id="mode-indicator">-</span>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div id="mode-switcher">
|
|
117
|
+
<select id="mode-select">
|
|
118
|
+
<option value="claude">Claude Code</option>
|
|
119
|
+
<option value="opencode">OpenCode</option>
|
|
120
|
+
</select>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div id="virtual-keybar">
|
|
124
|
+
<div id="virtual-keybar-row1">
|
|
125
|
+
<button data-key="[A">↑</button>
|
|
126
|
+
<button data-key="[B">↓</button>
|
|
127
|
+
<button data-key="[D">←</button>
|
|
128
|
+
<button data-key="[C">→</button>
|
|
129
|
+
<button data-key="
|
|
130
|
+
">Enter</button>
|
|
131
|
+
<button data-key="">Esc</button>
|
|
132
|
+
</div>
|
|
133
|
+
<div id="virtual-keybar-row2">
|
|
134
|
+
<button data-key="">⌫</button>
|
|
135
|
+
<button data-key="">^C</button>
|
|
136
|
+
<button data-key="">^D</button>
|
|
137
|
+
<button data-key=" ">Space</button>
|
|
138
|
+
<button data-key=" ">Tab</button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
143
|
+
<script>
|
|
144
|
+
(function() {
|
|
145
|
+
var isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
|
146
|
+
var isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
|
147
|
+
var MOBILE_COLS = 60;
|
|
148
|
+
var fontSize = isMobile ? 11 : 13;
|
|
149
|
+
var currentMode = 'claude';
|
|
150
|
+
var isTransitioning = false;
|
|
151
|
+
|
|
152
|
+
var term = new Terminal({
|
|
153
|
+
cursorBlink: !isMobile,
|
|
154
|
+
fontSize: fontSize,
|
|
155
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
156
|
+
theme: {
|
|
157
|
+
background: '#0a0a0a',
|
|
158
|
+
foreground: '#d4d4d4',
|
|
159
|
+
cursor: '#d4d4d4',
|
|
160
|
+
selectionBackground: '#264f78',
|
|
161
|
+
},
|
|
162
|
+
allowProposedApi: true,
|
|
163
|
+
scrollback: isIOS ? 200 : isMobile ? 1000 : 3000,
|
|
164
|
+
smoothScrollDuration: 0,
|
|
165
|
+
scrollOnUserInput: true,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
term.open(document.getElementById('terminal'));
|
|
169
|
+
|
|
170
|
+
var statusText = document.getElementById('status-text');
|
|
171
|
+
var modeIndicator = document.getElementById('mode-indicator');
|
|
172
|
+
var statusEl = document.getElementById('status');
|
|
173
|
+
var keybar = document.getElementById('virtual-keybar');
|
|
174
|
+
var modeSelect = document.getElementById('mode-select');
|
|
175
|
+
var terminalEl = document.getElementById('terminal');
|
|
176
|
+
var ws = null;
|
|
177
|
+
var writeBuffer = '';
|
|
178
|
+
var writeTimer = null;
|
|
179
|
+
var lastY = 0;
|
|
180
|
+
var lastTime = 0;
|
|
181
|
+
var pixelAccum = 0;
|
|
182
|
+
var pendingDy = 0;
|
|
183
|
+
var scrollRaf = null;
|
|
184
|
+
var momentumRaf = null;
|
|
185
|
+
var velocitySamples = [];
|
|
186
|
+
|
|
187
|
+
if (isIOS && window.visualViewport) {
|
|
188
|
+
var body = document.body;
|
|
189
|
+
var onVVChange = function() {
|
|
190
|
+
var vv = window.visualViewport;
|
|
191
|
+
body.style.height = vv.height + 'px';
|
|
192
|
+
body.style.position = 'fixed';
|
|
193
|
+
body.style.top = '-' + vv.offsetTop + 'px';
|
|
194
|
+
body.style.width = '100%';
|
|
195
|
+
setTimeout(mobileFixedResize, 50);
|
|
196
|
+
};
|
|
197
|
+
window.visualViewport.addEventListener('resize', onVVChange);
|
|
198
|
+
window.visualViewport.addEventListener('scroll', onVVChange);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getCellDims() {
|
|
202
|
+
return term._core && term._core._renderService && term._core._renderService.dimensions && term._core._renderService.dimensions.css && term._core._renderService.dimensions.css.cell;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mobileFixedResize() {
|
|
206
|
+
var cellDims = getCellDims();
|
|
207
|
+
if (!cellDims || !cellDims.width || !cellDims.height) {
|
|
208
|
+
setTimeout(mobileFixedResize, 50);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
var padX = 16;
|
|
213
|
+
var padY = 8;
|
|
214
|
+
var topBarHeight = 32;
|
|
215
|
+
var switcherHeight = isMobile ? 48 : 0;
|
|
216
|
+
var keybarHeight = isMobile ? 96 : 0;
|
|
217
|
+
|
|
218
|
+
var availW = window.innerWidth - padX;
|
|
219
|
+
var availH = window.innerHeight - topBarHeight - switcherHeight - keybarHeight - padY;
|
|
220
|
+
|
|
221
|
+
var currentFontSize = term.options.fontSize;
|
|
222
|
+
var currentCharWidth = cellDims.width;
|
|
223
|
+
var targetFontSize = Math.floor(currentFontSize * availW / (MOBILE_COLS * currentCharWidth) * 10) / 10;
|
|
224
|
+
targetFontSize = Math.max(8, targetFontSize);
|
|
225
|
+
|
|
226
|
+
term.options.fontSize = targetFontSize;
|
|
227
|
+
|
|
228
|
+
requestAnimationFrame(function() {
|
|
229
|
+
var newCellDims = getCellDims();
|
|
230
|
+
var lineHeight = (newCellDims && newCellDims.height) || cellDims.height;
|
|
231
|
+
var rows = Math.max(5, Math.min(Math.floor(availH / lineHeight), 100));
|
|
232
|
+
term.resize(MOBILE_COLS, rows);
|
|
233
|
+
if (ws && ws.readyState === 1 && !isTransitioning) {
|
|
234
|
+
ws.send(JSON.stringify({ type: 'resize', cols: MOBILE_COLS, rows: rows, mobile: true }));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function pcResize() {
|
|
240
|
+
var container = document.getElementById('terminal');
|
|
241
|
+
var cellDims = getCellDims();
|
|
242
|
+
if (!cellDims) return;
|
|
243
|
+
var charW = cellDims.width;
|
|
244
|
+
var charH = cellDims.height;
|
|
245
|
+
var availW = container.clientWidth - 16;
|
|
246
|
+
var availH = container.clientHeight - 8;
|
|
247
|
+
var cols = Math.max(20, Math.floor(availW / charW));
|
|
248
|
+
var rows = Math.max(10, Math.floor(availH / charH));
|
|
249
|
+
term.resize(cols, rows);
|
|
250
|
+
if (ws && ws.readyState === 1 && !isTransitioning) {
|
|
251
|
+
ws.send(JSON.stringify({ type: 'resize', cols: cols, rows: rows }));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resize() {
|
|
256
|
+
if (isMobile) {
|
|
257
|
+
mobileFixedResize();
|
|
258
|
+
} else {
|
|
259
|
+
pcResize();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function flushWrite() {
|
|
264
|
+
if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
|
|
265
|
+
if (!writeBuffer || !term) return;
|
|
266
|
+
var buf = writeBuffer;
|
|
267
|
+
writeBuffer = '';
|
|
268
|
+
term.write(buf);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function throttledWrite(data) {
|
|
272
|
+
writeBuffer += data;
|
|
273
|
+
if (!writeTimer) {
|
|
274
|
+
writeTimer = requestAnimationFrame(flushWrite);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stopMomentum() {
|
|
279
|
+
if (momentumRaf) { cancelAnimationFrame(momentumRaf); momentumRaf = null; }
|
|
280
|
+
if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
|
|
281
|
+
pendingDy = 0;
|
|
282
|
+
pixelAccum = 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function flushScroll() {
|
|
286
|
+
scrollRaf = null;
|
|
287
|
+
if (pendingDy === 0) return;
|
|
288
|
+
pixelAccum += pendingDy;
|
|
289
|
+
pendingDy = 0;
|
|
290
|
+
var cellDims = getCellDims();
|
|
291
|
+
var lh = (cellDims && cellDims.height) || 16;
|
|
292
|
+
var lines = Math.trunc(pixelAccum / lh);
|
|
293
|
+
if (lines !== 0) { term.scrollLines(lines); pixelAccum -= lines * lh; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (isMobile) {
|
|
297
|
+
var screen = document.querySelector('.xterm-screen');
|
|
298
|
+
if (screen) {
|
|
299
|
+
screen.addEventListener('touchstart', function(e) {
|
|
300
|
+
stopMomentum();
|
|
301
|
+
if (e.touches.length !== 1) return;
|
|
302
|
+
lastY = e.touches[0].clientY;
|
|
303
|
+
lastTime = performance.now();
|
|
304
|
+
velocitySamples = [];
|
|
305
|
+
}, { passive: true });
|
|
306
|
+
|
|
307
|
+
screen.addEventListener('touchmove', function(e) {
|
|
308
|
+
if (e.touches.length !== 1) return;
|
|
309
|
+
var y = e.touches[0].clientY;
|
|
310
|
+
var now = performance.now();
|
|
311
|
+
var dt = now - lastTime;
|
|
312
|
+
var dy = lastY - y;
|
|
313
|
+
if (dt > 0) {
|
|
314
|
+
var v = dy / dt * 16;
|
|
315
|
+
velocitySamples.push({ v: v, t: now });
|
|
316
|
+
while (velocitySamples.length > 0 && now - velocitySamples[0].t > 100) velocitySamples.shift();
|
|
317
|
+
}
|
|
318
|
+
pendingDy += dy;
|
|
319
|
+
if (!scrollRaf) scrollRaf = requestAnimationFrame(flushScroll);
|
|
320
|
+
lastY = y;
|
|
321
|
+
lastTime = now;
|
|
322
|
+
}, { passive: true });
|
|
323
|
+
|
|
324
|
+
screen.addEventListener('touchend', function() {
|
|
325
|
+
if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
|
|
326
|
+
if (pendingDy !== 0) {
|
|
327
|
+
pixelAccum += pendingDy;
|
|
328
|
+
pendingDy = 0;
|
|
329
|
+
var cellDims = getCellDims();
|
|
330
|
+
var lh = (cellDims && cellDims.height) || 16;
|
|
331
|
+
var lines = Math.trunc(pixelAccum / lh);
|
|
332
|
+
if (lines !== 0) term.scrollLines(lines);
|
|
333
|
+
pixelAccum = 0;
|
|
334
|
+
}
|
|
335
|
+
var velocity = 0;
|
|
336
|
+
if (velocitySamples.length >= 2) {
|
|
337
|
+
var totalWeight = 0, weightedV = 0;
|
|
338
|
+
var latest = velocitySamples[velocitySamples.length - 1].t;
|
|
339
|
+
for (var i = 0; i < velocitySamples.length; i++) {
|
|
340
|
+
var s = velocitySamples[i];
|
|
341
|
+
var w = Math.max(0, 1 - (latest - s.t) / 100);
|
|
342
|
+
weightedV += s.v * w;
|
|
343
|
+
totalWeight += w;
|
|
344
|
+
}
|
|
345
|
+
velocity = totalWeight > 0 ? weightedV / totalWeight : 0;
|
|
346
|
+
}
|
|
347
|
+
velocitySamples = [];
|
|
348
|
+
if (Math.abs(velocity) < 0.5) return;
|
|
349
|
+
var friction = 0.95;
|
|
350
|
+
var mAccum = 0;
|
|
351
|
+
var tick = function() {
|
|
352
|
+
if (Math.abs(velocity) < 0.3) {
|
|
353
|
+
var cellDims = getCellDims();
|
|
354
|
+
var lh = (cellDims && cellDims.height) || 16;
|
|
355
|
+
var rest = Math.round(mAccum / lh);
|
|
356
|
+
if (rest !== 0) term.scrollLines(rest);
|
|
357
|
+
momentumRaf = null;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
mAccum += velocity;
|
|
361
|
+
var cellDims = getCellDims();
|
|
362
|
+
var lh = (cellDims && cellDims.height) || 16;
|
|
363
|
+
var lines = Math.trunc(mAccum / lh);
|
|
364
|
+
if (lines !== 0) { term.scrollLines(lines); mAccum -= lines * lh; }
|
|
365
|
+
velocity *= friction;
|
|
366
|
+
momentumRaf = requestAnimationFrame(tick);
|
|
367
|
+
};
|
|
368
|
+
momentumRaf = requestAnimationFrame(tick);
|
|
369
|
+
}, { passive: true });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function startTransition() {
|
|
374
|
+
isTransitioning = true;
|
|
375
|
+
terminalEl.classList.add('transitioning');
|
|
376
|
+
modeIndicator.textContent = '切换中...';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function endTransition(mode) {
|
|
380
|
+
currentMode = mode;
|
|
381
|
+
modeSelect.value = mode;
|
|
382
|
+
modeIndicator.textContent = mode === 'claude' ? 'Claude' : 'OpenCode';
|
|
383
|
+
terminalEl.classList.remove('transitioning');
|
|
384
|
+
isTransitioning = false;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function switchMode(mode) {
|
|
388
|
+
if (mode === currentMode || !ws || ws.readyState !== 1 || isTransitioning) return;
|
|
389
|
+
startTransition();
|
|
390
|
+
ws.send(JSON.stringify({ type: 'switch', mode: mode }));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
modeSelect.addEventListener('change', function() {
|
|
394
|
+
switchMode(modeSelect.value);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
function connect() {
|
|
398
|
+
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
399
|
+
ws = new WebSocket(proto + '//' + location.host + '/ws');
|
|
400
|
+
|
|
401
|
+
ws.onopen = function() {
|
|
402
|
+
statusText.textContent = '● 已连接';
|
|
403
|
+
statusEl.className = 'connected';
|
|
404
|
+
resize();
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
ws.onclose = function() {
|
|
408
|
+
statusText.textContent = '● 未连接';
|
|
409
|
+
statusEl.className = 'disconnected';
|
|
410
|
+
modeIndicator.textContent = '-';
|
|
411
|
+
ws = null;
|
|
412
|
+
setTimeout(connect, 2000);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
ws.onmessage = function(e) {
|
|
416
|
+
try {
|
|
417
|
+
var msg = JSON.parse(e.data);
|
|
418
|
+
if (msg.type === 'data') throttledWrite(msg.data);
|
|
419
|
+
else if (msg.type === 'exit') throttledWrite('\r\n[进程已退出: ' + msg.exitCode + ']\r\n');
|
|
420
|
+
else if (msg.type === 'mode') {
|
|
421
|
+
endTransition(msg.mode);
|
|
422
|
+
}
|
|
423
|
+
else if (msg.type === 'switching') {
|
|
424
|
+
// 服务端开始切换,前端清屏
|
|
425
|
+
term.clear();
|
|
426
|
+
writeBuffer = '';
|
|
427
|
+
}
|
|
428
|
+
else if (msg.type === 'state') {
|
|
429
|
+
if (msg.mode) {
|
|
430
|
+
currentMode = msg.mode;
|
|
431
|
+
modeSelect.value = msg.mode;
|
|
432
|
+
modeIndicator.textContent = msg.mode === 'claude' ? 'Claude' : 'OpenCode';
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} catch(err) {}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
term.onData(function(d) {
|
|
439
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data: d }));
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
window.addEventListener('resize', resize);
|
|
444
|
+
if (isMobile) {
|
|
445
|
+
window.addEventListener('orientationchange', function() { setTimeout(resize, 200); });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
keybar.addEventListener('click', function(e) {
|
|
449
|
+
if (e.target.tagName === 'BUTTON' && ws && ws.readyState === 1 && !isTransitioning) {
|
|
450
|
+
ws.send(JSON.stringify({ type: 'input', data: e.target.getAttribute('data-key') }));
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
connect();
|
|
455
|
+
setTimeout(resize, 100);
|
|
456
|
+
})();
|
|
457
|
+
</script>
|
|
458
|
+
</body>
|
|
459
|
+
</html>
|
package/install.sh
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Claude OpenCode Viewer 安装脚本
|
|
3
|
+
# 使用方法:curl -fsSL https://... | bash
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
echo "🚀 开始安装 Claude OpenCode Viewer..."
|
|
8
|
+
|
|
9
|
+
# 检查 Node.js
|
|
10
|
+
if ! command -v node &> /dev/null; then
|
|
11
|
+
echo "❌ 错误:未找到 Node.js,请先安装 Node.js"
|
|
12
|
+
echo " 访问:https://nodejs.org/"
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
|
17
|
+
if [ "$NODE_VERSION" -lt 16 ]; then
|
|
18
|
+
echo "❌ 错误:Node.js 版本过低,需要 >= 16.0.0"
|
|
19
|
+
echo " 当前版本:$(node -v)"
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
echo "✅ Node.js 版本检查通过:$(node -v)"
|
|
24
|
+
|
|
25
|
+
# 检查 npm
|
|
26
|
+
if ! command -v npm &> /dev/null; then
|
|
27
|
+
echo "❌ 错误:未找到 npm"
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
echo "✅ npm 版本:$(npm -v)"
|
|
32
|
+
|
|
33
|
+
# 全局安装
|
|
34
|
+
echo "📦 正在全局安装 claude-opencode-viewer..."
|
|
35
|
+
npm install -g claude-opencode-viewer
|
|
36
|
+
|
|
37
|
+
echo ""
|
|
38
|
+
echo "✅ 安装完成!"
|
|
39
|
+
echo ""
|
|
40
|
+
echo "╔══════════════════════════════════════════════════════════╗"
|
|
41
|
+
║ 使用指南 ║
|
|
42
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
43
|
+
║ 启动命令: ║
|
|
44
|
+
║ cov # 在当前目录启动 ║
|
|
45
|
+
║ cov /path/to/project # 在指定目录启动 ║
|
|
46
|
+
║ cov --help # 查看帮助 ║
|
|
47
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
48
|
+
║ 访问地址: ║
|
|
49
|
+
║ http://127.0.0.1:7008 ║
|
|
50
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
51
|
+
echo ""
|
|
52
|
+
echo "💡 提示:请确保已安装 claude 或 opencode 命令"
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-opencode-viewer",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cov": "bin/cov.js",
|
|
9
|
+
"claude-opencode-viewer": "bin/cov.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "node server.js",
|
|
13
|
+
"postinstall": "node -e \"console.log('\\n✅ claude-opencode-viewer 安装成功!\\n\\n使用方法:\\n - 全局启动:cov\\n - 或通过 npm 启动:npx cov\\n')\""
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude",
|
|
17
|
+
"opencode",
|
|
18
|
+
"terminal",
|
|
19
|
+
"viewer",
|
|
20
|
+
"cli",
|
|
21
|
+
"ai",
|
|
22
|
+
"claude-code",
|
|
23
|
+
"opencode-cli"
|
|
24
|
+
],
|
|
25
|
+
"author": "chrisjason12138",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/ChrisJason121238/claude-opencode-viewer.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/ChrisJason121238/claude-opencode-viewer/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/ChrisJason121238/claude-opencode-viewer#readme",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"node-pty": "^1.1.0",
|
|
37
|
+
"ws": "^8.19.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=16.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { existsSync, createReadStream } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { networkInterfaces, platform, arch } from 'node:os';
|
|
7
|
+
import { chmodSync, statSync } from 'node:fs';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
import { WebSocketServer } from 'ws';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const PORT = 7008;
|
|
13
|
+
|
|
14
|
+
const MAX_BUFFER = 200000;
|
|
15
|
+
|
|
16
|
+
let ptyModule = null;
|
|
17
|
+
let claudeProcess = null;
|
|
18
|
+
let opencodeProcess = null;
|
|
19
|
+
let currentProcess = null;
|
|
20
|
+
let currentMode = 'claude';
|
|
21
|
+
let outputBuffer = '';
|
|
22
|
+
const dataListeners = [];
|
|
23
|
+
const exitListeners = [];
|
|
24
|
+
let lastPtyCols = 120;
|
|
25
|
+
let lastPtyRows = 30;
|
|
26
|
+
|
|
27
|
+
let activeWs = null;
|
|
28
|
+
const clientSizes = new Map();
|
|
29
|
+
const mobileClients = new Set();
|
|
30
|
+
|
|
31
|
+
function getMobileSize() {
|
|
32
|
+
for (const mws of mobileClients) {
|
|
33
|
+
if (mws.readyState === 1) {
|
|
34
|
+
const size = clientSizes.get(mws);
|
|
35
|
+
if (size) return size;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getPty() {
|
|
42
|
+
if (!ptyModule) {
|
|
43
|
+
ptyModule = await import('node-pty');
|
|
44
|
+
ptyModule = ptyModule.default || ptyModule;
|
|
45
|
+
}
|
|
46
|
+
return ptyModule;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fixSpawnHelperPermissions() {
|
|
50
|
+
try {
|
|
51
|
+
const helperPath = join(__dirname, 'node_modules', 'node-pty', 'prebuilds', `${platform()}-${arch()}`, 'spawn-helper');
|
|
52
|
+
const stat = statSync(helperPath);
|
|
53
|
+
if (!(stat.mode & 0o111)) {
|
|
54
|
+
chmodSync(helperPath, stat.mode | 0o755);
|
|
55
|
+
}
|
|
56
|
+
} catch { }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getLocalIp() {
|
|
60
|
+
const nets = networkInterfaces();
|
|
61
|
+
for (const name of Object.keys(nets)) {
|
|
62
|
+
for (const net of nets[name]) {
|
|
63
|
+
if (net.family === 'IPv4' && !net.internal) return net.address;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return '127.0.0.1';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findCommand(cmd) {
|
|
70
|
+
const paths = [
|
|
71
|
+
cmd,
|
|
72
|
+
'/opt/homebrew/bin/' + cmd,
|
|
73
|
+
'/usr/local/bin/' + cmd,
|
|
74
|
+
'/usr/bin/' + cmd,
|
|
75
|
+
join(process.env.HOME, '.local/bin/' + cmd),
|
|
76
|
+
join(process.env.HOME, '.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js'),
|
|
77
|
+
];
|
|
78
|
+
for (const p of paths) {
|
|
79
|
+
if (p === 'opencode' && existsSync(p)) return p;
|
|
80
|
+
if (p === 'claude' && existsSync(p)) return p;
|
|
81
|
+
if (p === 'claude' && p.includes('@anthropic-ai')) {
|
|
82
|
+
try {
|
|
83
|
+
return execSync(`which claude`, { encoding: 'utf8' }).trim();
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return execSync(`which ${cmd}`, { encoding: 'utf8' }).trim();
|
|
89
|
+
} catch {}
|
|
90
|
+
return cmd;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function spawnProcess(mode) {
|
|
94
|
+
const pty = await getPty();
|
|
95
|
+
fixSpawnHelperPermissions();
|
|
96
|
+
|
|
97
|
+
let command, args = [];
|
|
98
|
+
if (mode === 'claude') {
|
|
99
|
+
const claudePath = findCommand('claude');
|
|
100
|
+
if (claudePath.endsWith('.js')) {
|
|
101
|
+
command = process.execPath;
|
|
102
|
+
args = [claudePath];
|
|
103
|
+
} else {
|
|
104
|
+
command = claudePath;
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
command = findCommand('opencode');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const proc = pty.spawn(command, args, {
|
|
111
|
+
name: 'xterm-256color',
|
|
112
|
+
cols: lastPtyCols,
|
|
113
|
+
rows: lastPtyRows,
|
|
114
|
+
cwd: process.cwd(),
|
|
115
|
+
env: { ...process.env },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
proc.onData((data) => {
|
|
119
|
+
outputBuffer += data;
|
|
120
|
+
if (outputBuffer.length > MAX_BUFFER) {
|
|
121
|
+
outputBuffer = outputBuffer.slice(-MAX_BUFFER);
|
|
122
|
+
}
|
|
123
|
+
dataListeners.forEach(cb => cb(data));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
proc.onExit(() => {
|
|
127
|
+
if (currentProcess === proc) {
|
|
128
|
+
currentProcess = null;
|
|
129
|
+
exitListeners.forEach(cb => cb(0));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (mode === 'claude') {
|
|
134
|
+
if (claudeProcess?.pid) {
|
|
135
|
+
try { claudeProcess.kill(); } catch {}
|
|
136
|
+
}
|
|
137
|
+
claudeProcess = proc;
|
|
138
|
+
} else {
|
|
139
|
+
if (opencodeProcess?.pid) {
|
|
140
|
+
try { opencodeProcess.kill(); } catch {}
|
|
141
|
+
}
|
|
142
|
+
opencodeProcess = proc;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
currentProcess = proc;
|
|
146
|
+
console.log(`[CombinedV2] ${mode === 'claude' ? 'Claude Code' : 'OpenCode'} 已启动 (PID: ${proc.pid})`);
|
|
147
|
+
return proc;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function switchMode(newMode) {
|
|
151
|
+
if (newMode === currentMode) return;
|
|
152
|
+
|
|
153
|
+
// 清空所有历史输出
|
|
154
|
+
outputBuffer = '';
|
|
155
|
+
|
|
156
|
+
// 杀死旧进程
|
|
157
|
+
if (currentMode === 'claude' && claudeProcess) {
|
|
158
|
+
try { claudeProcess.kill(); } catch {}
|
|
159
|
+
claudeProcess = null;
|
|
160
|
+
} else if (currentMode === 'opencode' && opencodeProcess) {
|
|
161
|
+
try { opencodeProcess.kill(); } catch {}
|
|
162
|
+
opencodeProcess = null;
|
|
163
|
+
}
|
|
164
|
+
currentProcess = null;
|
|
165
|
+
|
|
166
|
+
// 切换到新模式
|
|
167
|
+
currentMode = newMode;
|
|
168
|
+
await spawnProcess(newMode);
|
|
169
|
+
console.log(`[CombinedV2] 切换到 ${newMode}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function writeToPty(data) {
|
|
173
|
+
if (currentProcess) {
|
|
174
|
+
currentProcess.write(data);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resizePty(cols, rows) {
|
|
179
|
+
lastPtyCols = cols;
|
|
180
|
+
lastPtyRows = rows;
|
|
181
|
+
[claudeProcess, opencodeProcess].forEach(proc => {
|
|
182
|
+
if (proc) {
|
|
183
|
+
try { proc.resize(cols, rows); } catch {}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const server = createServer((req, res) => {
|
|
189
|
+
if (req.url === '/' || req.url === '/index.html') {
|
|
190
|
+
res.writeHead(200, {
|
|
191
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
192
|
+
'Access-Control-Allow-Origin': '*',
|
|
193
|
+
});
|
|
194
|
+
createReadStream(join(__dirname, 'index.html')).pipe(res);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
198
|
+
res.end('Not Found');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
202
|
+
|
|
203
|
+
wss.on('connection', (ws, req) => {
|
|
204
|
+
console.log('[WS] 客户端连接 from', req.socket.remoteAddress);
|
|
205
|
+
|
|
206
|
+
ws.send(JSON.stringify({
|
|
207
|
+
type: 'state',
|
|
208
|
+
running: !!currentProcess,
|
|
209
|
+
pid: currentProcess?.pid,
|
|
210
|
+
mode: currentMode,
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
if (outputBuffer) {
|
|
214
|
+
ws.send(JSON.stringify({ type: 'data', data: outputBuffer }));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const listener = (data) => {
|
|
218
|
+
if (ws.readyState === 1) {
|
|
219
|
+
ws.send(JSON.stringify({ type: 'data', data }));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
dataListeners.push(listener);
|
|
223
|
+
|
|
224
|
+
const exitListener = (code) => {
|
|
225
|
+
if (ws.readyState === 1) {
|
|
226
|
+
ws.send(JSON.stringify({ type: 'exit', exitCode: code }));
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
exitListeners.push(exitListener);
|
|
230
|
+
|
|
231
|
+
ws.on('message', async (raw) => {
|
|
232
|
+
try {
|
|
233
|
+
const msg = JSON.parse(raw);
|
|
234
|
+
|
|
235
|
+
if (msg.type === 'input') {
|
|
236
|
+
if (activeWs !== ws) {
|
|
237
|
+
activeWs = ws;
|
|
238
|
+
const mSize = getMobileSize();
|
|
239
|
+
if (mSize) {
|
|
240
|
+
resizePty(mSize.cols, mSize.rows);
|
|
241
|
+
} else {
|
|
242
|
+
const size = clientSizes.get(ws);
|
|
243
|
+
if (size) resizePty(size.cols, size.rows);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
writeToPty(msg.data);
|
|
247
|
+
} else if (msg.type === 'resize') {
|
|
248
|
+
clientSizes.set(ws, { cols: msg.cols, rows: msg.rows });
|
|
249
|
+
if (msg.mobile) mobileClients.add(ws);
|
|
250
|
+
if (msg.mobile) {
|
|
251
|
+
resizePty(msg.cols, msg.rows);
|
|
252
|
+
} else if (mobileClients.size === 0 && (activeWs === ws || activeWs === null)) {
|
|
253
|
+
activeWs = ws;
|
|
254
|
+
resizePty(msg.cols, msg.rows);
|
|
255
|
+
}
|
|
256
|
+
} else if (msg.type === 'switch') {
|
|
257
|
+
if (msg.mode !== currentMode) {
|
|
258
|
+
ws.send(JSON.stringify({ type: 'switching', mode: msg.mode }));
|
|
259
|
+
await switchMode(msg.mode);
|
|
260
|
+
ws.send(JSON.stringify({ type: 'mode', mode: currentMode }));
|
|
261
|
+
setTimeout(() => {
|
|
262
|
+
if (outputBuffer) {
|
|
263
|
+
ws.send(JSON.stringify({ type: 'data', data: outputBuffer }));
|
|
264
|
+
}
|
|
265
|
+
}, 100);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error('[WS] Error:', err.message);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
ws.on('close', () => {
|
|
274
|
+
mobileClients.delete(ws);
|
|
275
|
+
const idx = dataListeners.indexOf(listener);
|
|
276
|
+
if (idx >= 0) dataListeners.splice(idx, 1);
|
|
277
|
+
const eIdx = exitListeners.indexOf(exitListener);
|
|
278
|
+
if (eIdx >= 0) exitListeners.splice(eIdx, 1);
|
|
279
|
+
clientSizes.delete(ws);
|
|
280
|
+
if (activeWs === ws) {
|
|
281
|
+
activeWs = null;
|
|
282
|
+
const mSize = getMobileSize();
|
|
283
|
+
if (mSize) {
|
|
284
|
+
resizePty(mSize.cols, mSize.rows);
|
|
285
|
+
} else {
|
|
286
|
+
for (const [remainWs, size] of clientSizes) {
|
|
287
|
+
if (remainWs.readyState === 1) {
|
|
288
|
+
activeWs = remainWs;
|
|
289
|
+
resizePty(size.cols, size.rows);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
server.listen(PORT, '0.0.0.0', async () => {
|
|
299
|
+
const ip = getLocalIp();
|
|
300
|
+
console.log('\n' + '='.repeat(50));
|
|
301
|
+
console.log('✅ Combined Viewer V2 已启动 (丝滑切换版)');
|
|
302
|
+
console.log('='.repeat(50));
|
|
303
|
+
console.log(`🖥️ 本地访问:http://127.0.0.1:${PORT}`);
|
|
304
|
+
console.log(`📱 手机访问:http://${ip}:${PORT}`);
|
|
305
|
+
console.log('='.repeat(50));
|
|
306
|
+
console.log('\n按 Ctrl+C 停止服务\n');
|
|
307
|
+
|
|
308
|
+
await spawnProcess('claude');
|
|
309
|
+
});
|