claude-opencode-viewer 2.2.1 → 2.3.1

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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm start:*)",
5
+ "Bash(kill:*)",
6
+ "Bash(pkill:*)",
7
+ "Bash(node:*)",
8
+ "Bash(lsof -ti:7008)"
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="JAVA_MODULE" version="4">
3
+ <component name="NewModuleRootManager" inherit-compiler-output="true">
4
+ <exclude-output />
5
+ <content url="file://$MODULE_DIR$" />
6
+ <orderEntry type="inheritedJdk" />
7
+ <orderEntry type="sourceFolder" forTests="false" />
8
+ </component>
9
+ </module>
@@ -0,0 +1,5 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ </profile>
5
+ </component>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/claude_opencode_viewer.iml" filepath="$PROJECT_DIR$/.idea/claude_opencode_viewer.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="SmartFoxWorkToolsProjectState">
4
+ <option name="branch" value="fix/v2.1.0-keyboard" />
5
+ <option name="gitUrl" value="https://github.com/ChrisJason121238/claude-opencode-viewer.git" />
6
+ <option name="localBranch" value="fix/v2.1.0-keyboard" />
7
+ <option name="revision" value="e81a3afd4423f60c1d4f185dd4b16cf7ad1848cc" />
8
+ </component>
9
+ </project>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="tdkConfig">
4
+ <option name="projectKey" value="0c137ba0-d5df-44aa-97f8-3eee32813208" />
5
+ </component>
6
+ </project>
package/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ </component>
6
+ </project>
package/index.html CHANGED
@@ -2,139 +2,163 @@
2
2
  <html lang="zh-CN">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
6
  <title>Claude OpenCode Viewer</title>
7
7
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
8
8
  <style>
9
9
  * { margin: 0; padding: 0; box-sizing: border-box; }
10
- html, body { height: 100%; background: #0a0a0a; overflow: hidden; }
11
- body {
10
+ html, body { margin: 0; padding: 0; overflow: hidden; }
11
+
12
+ /* 参考 cc-viewer 的 App.jsx 行 1319: 移动端容器使用 100vw/100vh */
13
+ #layout {
14
+ width: 100vw;
15
+ height: 100vh;
16
+ display: flex;
17
+ flex-direction: column;
18
+ background: #000;
19
+ overflow: hidden;
20
+ }
21
+
22
+ /* 参考 cc-viewer 的 App.jsx 行 1320-1335: 顶部工具栏 */
23
+ #header {
24
+ padding: 10px 12px;
25
+ background: #111;
26
+ border-bottom: 1px solid #222;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ flex-shrink: 0;
31
+ height: 40px;
32
+ }
33
+
34
+ #mode-switcher {
35
+ display: flex;
36
+ gap: 4px;
37
+ align-items: center;
38
+ }
39
+
40
+ #mode-label {
41
+ font-size: 12px;
42
+ color: #666;
43
+ }
44
+
45
+ #mode-select {
46
+ background: #1a1a1a;
47
+ color: #d4d4d4;
48
+ border: 1px solid #333;
49
+ border-radius: 4px;
50
+ padding: 4px 24px 4px 8px;
51
+ font-size: 12px;
52
+ cursor: pointer;
53
+ -webkit-appearance: none;
54
+ appearance: none;
55
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 10 10'%3E%3Cpath fill='%23888' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
56
+ background-repeat: no-repeat;
57
+ background-position: right 6px center;
58
+ }
59
+
60
+ /* 参考 cc-viewer 的 App.jsx 行 1425: 内容区使用 flex: 1 + relative + overflow: hidden */
61
+ #content {
62
+ flex: 1;
63
+ position: relative;
64
+ overflow: hidden;
65
+ }
66
+
67
+ /* 参考 cc-viewer 的 TerminalPanel.jsx: 终端容器样式 */
68
+ #terminal-container {
69
+ height: 100%;
12
70
  display: flex;
13
71
  flex-direction: column;
14
72
  background: #0a0a0a;
15
73
  }
74
+
16
75
  #terminal {
17
76
  flex: 1;
18
- min-height: 0;
19
77
  overflow: hidden;
20
78
  padding: 4px 8px;
21
79
  touch-action: none;
22
80
  overscroll-behavior: contain;
23
81
  }
82
+
24
83
  #terminal.transitioning {
25
84
  opacity: 0.3;
26
85
  transition: opacity 0.3s ease;
27
86
  }
87
+
88
+ /* 参考 cc-viewer 的 TerminalPanel.module.css: 虚拟键盘栏 */
28
89
  #virtual-keybar {
29
- display: none;
30
- background: #111;
31
- border-top: 1px solid #222;
32
- flex-shrink: 0;
33
- }
34
- #virtual-keybar-row {
35
90
  display: flex;
36
91
  gap: 6px;
37
92
  padding: 8px 10px;
93
+ background: #111;
94
+ border-top: 1px solid #222;
38
95
  overflow-x: auto;
96
+ flex-shrink: 0;
39
97
  -webkit-overflow-scrolling: touch;
40
- scrollbar-width: none;
41
98
  }
42
- #virtual-keybar-row::-webkit-scrollbar {
43
- display: none;
44
- }
45
- #virtual-keybar-row button {
99
+
100
+ .virtual-key {
46
101
  flex-shrink: 0;
47
- min-width: 56px;
48
- height: 40px;
102
+ padding: 12px 20px;
49
103
  border: 1px solid #333;
50
- border-radius: 6px;
104
+ border-radius: 8px;
51
105
  background: #1a1a1a;
52
106
  color: #ccc;
53
- font-size: 13px;
54
- touch-action: manipulation;
55
- white-space: nowrap;
56
- }
57
- #virtual-keybar-row button:active {
58
- background: #333;
59
- }
60
- #input-bar {
61
- display: none;
62
- background: #111;
63
- border-top: 1px solid #222;
64
- flex-shrink: 0;
65
- padding: 8px 10px;
66
- display: flex;
67
- gap: 8px;
68
- align-items: center;
69
- }
70
- #input-bar input {
71
- flex: 1;
72
- min-width: 0;
73
- padding: 10px 12px;
74
- background: #1a1a1a;
75
- border: 1px solid #333;
76
- border-radius: 6px;
77
- color: #d4d4d4;
78
- font-size: 14px;
107
+ font-size: 15px;
79
108
  font-family: Menlo, Monaco, monospace;
80
- outline: none;
81
- }
82
- #input-bar input:focus {
83
- border-color: #4ade80;
84
- }
85
- #mode-switcher {
86
- display: none;
87
- flex-shrink: 0;
88
- }
89
- #mode-switcher select {
90
- background: #1a1a1a;
91
- color: #d4d4d4;
92
- border: 1px solid #333;
93
- border-radius: 4px;
94
- padding: 10px 28px 10px 12px;
95
- font-size: 13px;
96
109
  cursor: pointer;
97
- -webkit-appearance: none;
98
- -moz-appearance: none;
99
- appearance: none;
100
- 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");
101
- background-repeat: no-repeat;
102
- background-position: right 8px center;
103
- }
104
- #mode-switcher select:focus {
110
+ user-select: none;
111
+ -webkit-tap-highlight-color: transparent;
112
+ touch-action: pan-x;
113
+ min-width: 44px;
114
+ min-height: 44px;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ border: none;
105
119
  outline: none;
106
- border-color: #4ade80;
120
+ -webkit-user-select: none;
121
+ -moz-user-select: none;
122
+ -ms-user-select: none;
107
123
  }
108
- @media (max-width: 768px) {
109
- #mode-switcher { display: block; }
110
- #virtual-keybar { display: block; }
111
- #input-bar { display: flex; }
124
+
125
+ .virtual-key:active {
126
+ background: #333;
127
+ border-color: #555;
128
+ color: #fff;
112
129
  }
113
130
  </style>
114
131
  </head>
115
132
  <body>
116
- <div id="terminal"></div>
117
-
118
- <div id="input-bar">
119
- <input type="text" id="input-field" placeholder="输入命令..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
120
- <div id="mode-switcher">
121
- <select id="mode-select">
122
- <option value="claude">Claude Code</option>
123
- <option value="opencode">OpenCode</option>
124
- </select>
133
+ <!-- 参考 cc-viewer 的 App.jsx 行 1315-1607: 完整的移动端布局结构 -->
134
+ <div id="layout">
135
+ <div id="header">
136
+ <div style="font-size: 12px; color: #aaa;">Claude OpenCode Viewer</div>
137
+ <div id="mode-switcher">
138
+ <span id="mode-label">Mode:</span>
139
+ <select id="mode-select">
140
+ <option value="opencode">OpenCode</option>
141
+ <option value="claude">Claude</option>
142
+ </select>
143
+ </div>
125
144
  </div>
126
- </div>
127
145
 
128
- <div id="virtual-keybar">
129
- <div id="virtual-keybar-row">
130
- <button data-key="up">↑</button>
131
- <button data-key="down">↓</button>
132
- <button data-key="left">←</button>
133
- <button data-key="right">→</button>
134
- <button data-key="enter">Enter</button>
135
- <button data-key="esc">Esc</button>
136
- <button data-key="ctrlc">^C</button>
137
- <button data-key="tab">Tab</button>
146
+ <div id="content">
147
+ <div id="terminal-container">
148
+ <div id="terminal"></div>
149
+ <div id="virtual-keybar">
150
+ <div class="virtual-key" data-key="up">↑</div>
151
+ <div class="virtual-key" data-key="down">↓</div>
152
+ <div class="virtual-key" data-key="left">←</div>
153
+ <div class="virtual-key" data-key="right">→</div>
154
+ <div class="virtual-key" data-key="enter">Enter</div>
155
+ <div class="virtual-key" data-key="tab">Tab</div>
156
+ <div class="virtual-key" data-key="esc">Esc</div>
157
+ <div class="virtual-key" data-key="ctrlc">Ctrl+C</div>
158
+ <div class="virtual-key scroll-key" data-scroll="-5">⇡ 滚动</div>
159
+ <div class="virtual-key scroll-key" data-scroll="5">⇣ 滚动</div>
160
+ </div>
161
+ </div>
138
162
  </div>
139
163
  </div>
140
164
 
@@ -166,7 +190,6 @@
166
190
 
167
191
  term.open(document.getElementById('terminal'));
168
192
 
169
- var keybar = document.getElementById('virtual-keybar');
170
193
  var modeSelect = document.getElementById('mode-select');
171
194
  var terminalEl = document.getElementById('terminal');
172
195
  var ws = null;
@@ -180,25 +203,77 @@
180
203
  var momentumRaf = null;
181
204
  var velocitySamples = [];
182
205
 
206
+ // 未发送消息缓存
207
+ var currentInputBuffer = '';
208
+ var CACHE_KEY = 'claude_opencode_input_cache';
209
+ var cacheRestored = false; // 防止重复恢复
210
+
211
+ function saveInputCache() {
212
+ if (currentInputBuffer) {
213
+ localStorage.setItem(CACHE_KEY, currentInputBuffer);
214
+ console.log('[cache] saved:', currentInputBuffer);
215
+ } else {
216
+ localStorage.removeItem(CACHE_KEY);
217
+ }
218
+ }
219
+
220
+ function loadInputCache() {
221
+ // 确保只恢复一次
222
+ if (cacheRestored) {
223
+ console.log('[cache] already restored, skipping');
224
+ return;
225
+ }
226
+
227
+ var cached = localStorage.getItem(CACHE_KEY);
228
+ if (cached && ws && ws.readyState === 1) {
229
+ console.log('[cache] restoring:', cached);
230
+ // 立即清除缓存,防止重复调用
231
+ localStorage.removeItem(CACHE_KEY);
232
+ cacheRestored = true;
233
+
234
+ // 将缓存的输入发送到终端
235
+ ws.send(JSON.stringify({ type: 'input', data: cached }));
236
+ // 重要:恢复后清空 currentInputBuffer,防止再次保存
237
+ currentInputBuffer = '';
238
+ } else {
239
+ console.log('[cache] no cache to restore');
240
+ }
241
+ }
242
+
243
+ function clearInputCache() {
244
+ currentInputBuffer = '';
245
+ localStorage.removeItem(CACHE_KEY);
246
+ console.log('[cache] cleared');
247
+ }
248
+
249
+ function getCellDims() {
250
+ return term._core && term._core._renderService && term._core._renderService.dimensions && term._core._renderService.dimensions.css && term._core._renderService.dimensions.css.cell;
251
+ }
252
+
253
+ // 参考 cc-viewer 的 App.jsx 行 76-94: iOS visualViewport 处理
254
+ // iOS 虚拟键盘弹出时,Safari 会滚动整个文档将页面上推,
255
+ // 导致导航栏消失在视口之外。通过 visualViewport 的 resize + scroll
256
+ // 事件同步可见区域的高度和偏移,用 fixed 定位将布局锁定在可见区域内。
183
257
  if (isIOS && window.visualViewport) {
184
- var body = document.body;
258
+ var layoutEl = document.getElementById('layout');
185
259
  var onVVChange = function() {
260
+ if (!layoutEl) return;
186
261
  var vv = window.visualViewport;
187
- body.style.height = vv.height + 'px';
188
- body.style.position = 'fixed';
189
- body.style.top = '-' + vv.offsetTop + 'px';
190
- body.style.width = '100%';
262
+ layoutEl.style.position = 'fixed';
263
+ layoutEl.style.top = vv.offsetTop + 'px';
264
+ layoutEl.style.height = vv.height + 'px';
265
+ layoutEl.style.width = '100%';
266
+ layoutEl.style.left = '0';
191
267
  setTimeout(mobileFixedResize, 50);
192
268
  };
193
269
  window.visualViewport.addEventListener('resize', onVVChange);
194
270
  window.visualViewport.addEventListener('scroll', onVVChange);
271
+ onVVChange();
195
272
  }
196
273
 
197
- function getCellDims() {
198
- return term._core && term._core._renderService && term._core._renderService.dimensions && term._core._renderService.dimensions.css && term._core._renderService.dimensions.css.cell;
199
- }
200
-
274
+ // 参考 cc-viewer 的 TerminalPanel.jsx 行 410-447: 移动端固定尺寸计算
201
275
  function mobileFixedResize() {
276
+ if (!term) return;
202
277
  var cellDims = getCellDims();
203
278
  if (!cellDims || !cellDims.width || !cellDims.height) {
204
279
  setTimeout(mobileFixedResize, 50);
@@ -207,17 +282,15 @@
207
282
 
208
283
  var padX = 16;
209
284
  var padY = 8;
210
- var topBarHeight = 32;
211
- var switcherHeight = isMobile ? 48 : 0;
212
- var keybarHeight = isMobile ? 96 : 0;
285
+ var topBarHeight = 40;
286
+ var keybarHeight = 52;
213
287
 
214
288
  var availW = window.innerWidth - padX;
215
- var availH = window.innerHeight - topBarHeight - switcherHeight - keybarHeight - padY;
289
+ var availH = window.innerHeight - topBarHeight - keybarHeight - padY;
216
290
 
217
291
  var currentFontSize = term.options.fontSize;
218
292
  var currentCharWidth = cellDims.width;
219
293
  var targetFontSize = Math.floor(currentFontSize * availW / (MOBILE_COLS * currentCharWidth) * 10) / 10;
220
- targetFontSize = Math.max(8, targetFontSize);
221
294
 
222
295
  term.options.fontSize = targetFontSize;
223
296
 
@@ -278,94 +351,189 @@
278
351
  pixelAccum = 0;
279
352
  }
280
353
 
354
+ // OpenCode 模式触摸滚动实现 - 使用 xterm.js scrollLines API
355
+ var touchScreen = null;
356
+ var touchEventsBound = false;
357
+
358
+ function getLineHeight() {
359
+ var cellDims = getCellDims();
360
+ var height = (cellDims && cellDims.height) || 15;
361
+ console.log('[scroll] lineHeight:', height);
362
+ return height;
363
+ }
364
+
365
+ function stopMomentum() {
366
+ if (momentumRaf) {
367
+ cancelAnimationFrame(momentumRaf);
368
+ momentumRaf = null;
369
+ }
370
+ if (scrollRaf) {
371
+ cancelAnimationFrame(scrollRaf);
372
+ scrollRaf = null;
373
+ }
374
+ pendingDy = 0;
375
+ pixelAccum = 0;
376
+ }
377
+
281
378
  function flushScroll() {
282
379
  scrollRaf = null;
283
380
  if (pendingDy === 0) return;
381
+
284
382
  pixelAccum += pendingDy;
285
383
  pendingDy = 0;
286
- var cellDims = getCellDims();
287
- var lh = (cellDims && cellDims.height) || 16;
384
+
385
+ var lh = getLineHeight();
288
386
  var lines = Math.trunc(pixelAccum / lh);
289
- if (lines !== 0) { term.scrollLines(lines); pixelAccum -= lines * lh; }
387
+
388
+ if (lines !== 0) {
389
+ console.log('[scroll] scrollLines:', lines, 'pixelAccum:', pixelAccum, 'lineHeight:', lh);
390
+ term.scrollLines(lines);
391
+ pixelAccum -= lines * lh;
392
+ }
290
393
  }
291
394
 
292
- if (isMobile) {
293
- var screen = document.querySelector('.xterm-screen');
294
- if (screen) {
295
- screen.addEventListener('touchstart', function(e) {
296
- stopMomentum();
297
- if (e.touches.length !== 1) return;
298
- lastY = e.touches[0].clientY;
299
- lastTime = performance.now();
300
- velocitySamples = [];
301
- }, { passive: true });
395
+ function handleTouchStart(e) {
396
+ console.log('[scroll] touchstart');
397
+ stopMomentum();
398
+ if (e.touches.length !== 1) return;
399
+ lastY = e.touches[0].clientY;
400
+ lastTime = performance.now();
401
+ velocitySamples = [];
402
+ }
302
403
 
303
- screen.addEventListener('touchmove', function(e) {
304
- if (e.touches.length !== 1) return;
305
- var y = e.touches[0].clientY;
306
- var now = performance.now();
307
- var dt = now - lastTime;
308
- var dy = lastY - y;
309
- if (dt > 0) {
310
- var v = dy / dt * 16;
311
- velocitySamples.push({ v: v, t: now });
312
- while (velocitySamples.length > 0 && now - velocitySamples[0].t > 100) velocitySamples.shift();
313
- }
314
- pendingDy += dy;
315
- if (!scrollRaf) scrollRaf = requestAnimationFrame(flushScroll);
316
- lastY = y;
317
- lastTime = now;
318
- }, { passive: true });
404
+ function handleTouchMove(e) {
405
+ if (e.touches.length !== 1) return;
406
+ var y = e.touches[0].clientY;
407
+ var now = performance.now();
408
+ var dt = now - lastTime;
409
+ var dy = lastY - y;
410
+
411
+ if (dt > 0) {
412
+ var v = dy / dt * 16;
413
+ velocitySamples.push({ v: v, t: now });
414
+ while (velocitySamples.length > 0 && now - velocitySamples[0].t > 100) {
415
+ velocitySamples.shift();
416
+ }
417
+ }
319
418
 
320
- screen.addEventListener('touchend', function() {
321
- if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
322
- if (pendingDy !== 0) {
323
- pixelAccum += pendingDy;
324
- pendingDy = 0;
325
- var cellDims = getCellDims();
326
- var lh = (cellDims && cellDims.height) || 16;
327
- var lines = Math.trunc(pixelAccum / lh);
328
- if (lines !== 0) term.scrollLines(lines);
329
- pixelAccum = 0;
330
- }
331
- var velocity = 0;
332
- if (velocitySamples.length >= 2) {
333
- var totalWeight = 0, weightedV = 0;
334
- var latest = velocitySamples[velocitySamples.length - 1].t;
335
- for (var i = 0; i < velocitySamples.length; i++) {
336
- var s = velocitySamples[i];
337
- var w = Math.max(0, 1 - (latest - s.t) / 100);
338
- weightedV += s.v * w;
339
- totalWeight += w;
340
- }
341
- velocity = totalWeight > 0 ? weightedV / totalWeight : 0;
419
+ pendingDy += dy;
420
+ console.log('[scroll] touchmove dy:', dy, 'pendingDy:', pendingDy);
421
+
422
+ if (!scrollRaf) scrollRaf = requestAnimationFrame(flushScroll);
423
+ lastY = y;
424
+ lastTime = now;
425
+ }
426
+
427
+ function handleTouchEnd() {
428
+ console.log('[scroll] touchend');
429
+
430
+ if (scrollRaf) {
431
+ cancelAnimationFrame(scrollRaf);
432
+ scrollRaf = null;
433
+ }
434
+
435
+ if (pendingDy !== 0) {
436
+ pixelAccum += pendingDy;
437
+ pendingDy = 0;
438
+ var lh = getLineHeight();
439
+ var lines = Math.trunc(pixelAccum / lh);
440
+ if (lines !== 0) {
441
+ console.log('[scroll] final scrollLines:', lines);
442
+ term.scrollLines(lines);
443
+ }
444
+ pixelAccum = 0;
445
+ }
446
+
447
+ var velocity = 0;
448
+ if (velocitySamples.length >= 2) {
449
+ var totalWeight = 0;
450
+ var weightedV = 0;
451
+ var latest = velocitySamples[velocitySamples.length - 1].t;
452
+ for (var i = 0; i < velocitySamples.length; i++) {
453
+ var s = velocitySamples[i];
454
+ var w = Math.max(0, 1 - (latest - s.t) / 100);
455
+ weightedV += s.v * w;
456
+ totalWeight += w;
457
+ }
458
+ velocity = totalWeight > 0 ? weightedV / totalWeight : 0;
459
+ }
460
+ velocitySamples = [];
461
+
462
+ console.log('[scroll] velocity:', velocity);
463
+ if (Math.abs(velocity) < 0.5) return;
464
+
465
+ var friction = 0.95;
466
+ var mAccum = 0;
467
+ var tick = function() {
468
+ if (Math.abs(velocity) < 0.3) {
469
+ var lh = getLineHeight();
470
+ var rest = Math.round(mAccum / lh);
471
+ if (rest !== 0) {
472
+ console.log('[scroll] momentum final:', rest);
473
+ term.scrollLines(rest);
342
474
  }
343
- velocitySamples = [];
344
- if (Math.abs(velocity) < 0.5) return;
345
- var friction = 0.95;
346
- var mAccum = 0;
347
- var tick = function() {
348
- if (Math.abs(velocity) < 0.3) {
349
- var cellDims = getCellDims();
350
- var lh = (cellDims && cellDims.height) || 16;
351
- var rest = Math.round(mAccum / lh);
352
- if (rest !== 0) term.scrollLines(rest);
353
- momentumRaf = null;
354
- return;
355
- }
356
- mAccum += velocity;
357
- var cellDims = getCellDims();
358
- var lh = (cellDims && cellDims.height) || 16;
359
- var lines = Math.trunc(mAccum / lh);
360
- if (lines !== 0) { term.scrollLines(lines); mAccum -= lines * lh; }
361
- velocity *= friction;
362
- momentumRaf = requestAnimationFrame(tick);
363
- };
364
- momentumRaf = requestAnimationFrame(tick);
365
- }, { passive: true });
475
+ momentumRaf = null;
476
+ return;
477
+ }
478
+ mAccum += velocity;
479
+ var lh = getLineHeight();
480
+ var lines = Math.trunc(mAccum / lh);
481
+ if (lines !== 0) {
482
+ term.scrollLines(lines);
483
+ mAccum -= lines * lh;
484
+ }
485
+ velocity *= friction;
486
+ momentumRaf = requestAnimationFrame(tick);
487
+ };
488
+ momentumRaf = requestAnimationFrame(tick);
489
+ }
490
+
491
+ function unbindTouchScroll() {
492
+ if (touchScreen && touchEventsBound) {
493
+ console.log('[scroll] unbinding touch events');
494
+ touchScreen.removeEventListener('touchstart', handleTouchStart);
495
+ touchScreen.removeEventListener('touchmove', handleTouchMove);
496
+ touchScreen.removeEventListener('touchend', handleTouchEnd);
497
+ touchEventsBound = false;
498
+ touchScreen = null;
499
+ }
500
+ }
501
+
502
+ function setupMobileTouchScroll() {
503
+ // 先解绑旧的
504
+ unbindTouchScroll();
505
+
506
+ // 只在 opencode 模式下绑定
507
+ if (currentMode !== 'opencode') {
508
+ console.log('[scroll] Not opencode mode, skip binding');
509
+ return;
510
+ }
511
+
512
+ var screen = terminalEl.querySelector('.xterm-screen');
513
+ if (!screen) {
514
+ console.log('[scroll] .xterm-screen not found, retrying...');
515
+ setTimeout(setupMobileTouchScroll, 50);
516
+ return;
517
+ }
518
+
519
+ touchScreen = screen;
520
+ screen.addEventListener('touchstart', handleTouchStart, { passive: true });
521
+ screen.addEventListener('touchmove', handleTouchMove, { passive: true });
522
+ screen.addEventListener('touchend', handleTouchEnd, { passive: true });
523
+ touchEventsBound = true;
524
+ console.log('[scroll] Touch events bound to .xterm-screen (opencode mode)');
525
+ }
526
+
527
+ function rebindTouchScroll() {
528
+ if (isMobile) {
529
+ setTimeout(setupMobileTouchScroll, 100);
366
530
  }
367
531
  }
368
532
 
533
+ if (isMobile) {
534
+ setupMobileTouchScroll();
535
+ }
536
+
369
537
  function startTransition() {
370
538
  isTransitioning = true;
371
539
  terminalEl.classList.add('transitioning');
@@ -411,6 +579,8 @@
411
579
  else if (msg.type === 'exit') throttledWrite('\r\n[进程已退出: ' + msg.exitCode + ']\r\n');
412
580
  else if (msg.type === 'mode') {
413
581
  endTransition(msg.mode);
582
+ // 模式切换完成后,重新绑定触摸事件
583
+ rebindTouchScroll();
414
584
  }
415
585
  else if (msg.type === 'switching') {
416
586
  // 服务端开始切换,前端清屏
@@ -428,67 +598,71 @@
428
598
  };
429
599
 
430
600
  term.onData(function(d) {
431
- if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data: d }));
601
+ if (ws && ws.readyState === 1) {
602
+ ws.send(JSON.stringify({ type: 'input', data: d }));
603
+
604
+ // 缓存管理:跟踪当前行的输入(仅在 opencode 模式且未在恢复中)
605
+ if (currentMode === 'opencode' && cacheRestored) {
606
+ if (d === '\r' || d === '\n' || d === '\r\n') {
607
+ // 回车:命令已发送,清除缓存
608
+ clearInputCache();
609
+ } else if (d === '\x7f' || d === '\b') {
610
+ // 退格:删除最后一个字符
611
+ if (currentInputBuffer.length > 0) {
612
+ currentInputBuffer = currentInputBuffer.slice(0, -1);
613
+ saveInputCache();
614
+ }
615
+ } else if (d === '\x03') {
616
+ // Ctrl+C:中断,清除缓存
617
+ clearInputCache();
618
+ } else if (d === '\x15') {
619
+ // Ctrl+U:清空整行
620
+ clearInputCache();
621
+ } else if (d.length === 1 && d.charCodeAt(0) >= 32 && d.charCodeAt(0) < 127) {
622
+ // 可打印 ASCII 字符:添加到缓冲区
623
+ currentInputBuffer += d;
624
+ saveInputCache();
625
+ }
626
+ }
627
+ }
628
+
432
629
  // 点击终端输入时,滚动到底部
433
630
  setTimeout(function() {
434
631
  window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
435
632
  }, 50);
436
633
  });
437
- }
438
634
 
439
- window.addEventListener('resize', resize);
440
- if (isMobile) {
441
- window.addEventListener('orientationchange', function() { setTimeout(resize, 200); });
442
- }
443
-
444
- // 输入框状态
445
- var inputField = document.getElementById('input-field');
446
- var inputFocused = false;
447
-
448
- if (inputField) {
449
- // 跟踪输入框焦点状态
450
- inputField.addEventListener('focus', function() {
451
- inputFocused = true;
452
- // iOS 上需要临时恢复 body 滚动能力
453
- if (isIOS && document.body.style.position === 'fixed') {
454
- var scrollTop = -parseFloat(document.body.style.top || '0');
455
- document.body.style.position = '';
456
- document.body.style.top = '';
457
- window.scrollTo(0, document.body.scrollHeight);
458
- // 恢复 fixed 定位
459
- setTimeout(function() {
460
- document.body.style.position = 'fixed';
461
- document.body.style.top = '-' + scrollTop + 'px';
462
- }, 300);
635
+ // 页面加载后尝试恢复缓存的输入(仅在 opencode 模式)
636
+ setTimeout(function() {
637
+ if (currentMode === 'opencode') {
638
+ loadInputCache();
463
639
  } else {
464
- window.scrollTo(0, document.body.scrollHeight);
640
+ // 非 opencode 模式,标记为已恢复,避免后续缓存逻辑干扰
641
+ cacheRestored = true;
465
642
  }
466
- });
643
+ }, 800);
644
+ }
467
645
 
468
- inputField.addEventListener('blur', function() {
469
- inputFocused = false;
646
+ window.addEventListener('resize', resize);
647
+ if (isMobile) {
648
+ window.addEventListener('orientationchange', function() {
649
+ setTimeout(resize, 200);
470
650
  });
651
+ }
471
652
 
472
- // 输入框回车处理
473
- inputField.addEventListener('keydown', function(e) {
474
- if (e.key === 'Enter' && ws && ws.readyState === 1 && !isTransitioning) {
475
- var value = inputField.value;
476
- if (value) {
477
- console.log('[input enter] sending:', value);
478
- ws.send(JSON.stringify({ type: 'input', data: value + '\r\n' }));
479
- inputField.value = '';
480
- }
481
- e.preventDefault();
482
- }
483
- });
653
+ // 页面卸载前保存输入缓存
654
+ window.addEventListener('beforeunload', function() {
655
+ if (currentInputBuffer) {
656
+ saveInputCache();
657
+ }
658
+ });
484
659
 
485
- // 移动端触摸输入框时阻止终端获焦
486
- if (isMobile) {
487
- inputField.addEventListener('touchstart', function(e) {
488
- e.stopPropagation();
489
- }, { passive: true });
660
+ // 页面可见性变化时保存缓存
661
+ document.addEventListener('visibilitychange', function() {
662
+ if (document.hidden && currentInputBuffer) {
663
+ saveInputCache();
490
664
  }
491
- }
665
+ });
492
666
 
493
667
  // 虚拟按键映射表
494
668
  var KEY_MAP = {
@@ -498,124 +672,135 @@
498
672
  'right': '\x1b[C',
499
673
  'esc': '\x1b',
500
674
  'ctrlc': '\x03',
501
- 'tab': '\t'
675
+ 'tab': '\t',
676
+ 'enter': '\r'
502
677
  };
503
678
 
504
- // 方向键列表
505
- var ARROW_KEYS = ['up', 'down', 'left', 'right'];
506
-
507
679
  function sendKey(keyName) {
508
- // 重新获取焦点状态(确保使用最新状态)
509
- var isInputFocused = (inputField && document.activeElement === inputField);
510
-
511
- // 方向键特殊处理:根据焦点位置决定行为
512
- if (ARROW_KEYS.indexOf(keyName) !== -1) {
513
- if (isInputFocused) {
514
- // 焦点在输入框:模拟方向键操作输入框光标
515
- var cursorPos = inputField.selectionStart;
516
- var textLen = inputField.value.length;
517
-
518
- if (keyName === 'left' && cursorPos > 0) {
519
- inputField.setSelectionRange(cursorPos - 1, cursorPos - 1);
520
- } else if (keyName === 'right' && cursorPos < textLen) {
521
- inputField.setSelectionRange(cursorPos + 1, cursorPos + 1);
522
- }
523
- // 上下方向键在单行输入框中无操作
524
- return;
525
- } else {
526
- // 焦点在终端:发送方向键到终端,并收起键盘
527
- if (isMobile) {
528
- inputField.blur();
529
- }
530
- }
531
- } else {
532
- // 非方向键:点击时收起键盘
533
- if (isMobile) {
534
- inputField.blur();
535
- }
680
+ var seq = KEY_MAP[keyName];
681
+ if (seq && ws && ws.readyState === 1 && !isTransitioning) {
682
+ ws.send(JSON.stringify({ type: 'input', data: seq }));
536
683
  }
684
+ }
537
685
 
538
- // Enter 键特殊处理:根据焦点位置决定行为
539
- if (keyName === 'enter') {
540
- if (isInputFocused && inputField && inputField.value) {
541
- // 焦点在输入框且有内容:只发送输入框内容,不额外发送 \r
542
- var value = inputField.value;
543
- console.log('[enter] from input:', value);
544
- ws.send(JSON.stringify({ type: 'input', data: value + '\r\n' }));
545
- inputField.value = '';
546
- inputField.blur();
547
- } else if (isInputFocused && inputField) {
548
- // 焦点在输入框但内容为空:不发送任何内容,避免误触
549
- console.log('[enter] input focused but empty, ignored');
550
- } else {
551
- // 焦点在终端:直接发送回车
552
- console.log('[enter] sending \\r to terminal');
553
- ws.send(JSON.stringify({ type: 'input', data: '\r\n' }));
554
- }
555
- return;
686
+ function scrollTerminal(lines) {
687
+ if (term) {
688
+ term.scrollLines(lines);
689
+ console.log('[scroll] scrolled by:', lines);
556
690
  }
691
+ }
557
692
 
558
- // Esc 键:中断终端进程(发送 ESC 字符)
559
- if (keyName === 'esc') {
560
- console.log('[esc] sending escape to terminal');
561
- ws.send(JSON.stringify({ type: 'input', data: '\x1b' }));
562
- return;
563
- }
693
+ // 参考 cc-viewer TerminalPanel.jsx 行 519-546: 虚拟按键触摸处理
694
+ // 每个按键独立绑定事件,不在容器上绑定
695
+ var vkStartX = 0, vkStartY = 0, vkMoved = false, vkTarget = null;
696
+ var scrollInterval = null; // 长按滚动定时器
697
+
698
+ function setupVirtualKeyEvents() {
699
+ var keys = document.querySelectorAll('.virtual-key');
700
+ keys.forEach(function(key) {
701
+ // 防止元素获得焦点
702
+ key.setAttribute('tabindex', '-1');
703
+
704
+ // 移动端触摸事件
705
+ key.addEventListener('touchstart', function(e) {
706
+ var touch = e.touches[0];
707
+ vkStartX = touch.clientX;
708
+ vkStartY = touch.clientY;
709
+ vkMoved = false;
710
+ vkTarget = e.currentTarget;
711
+ vkTarget.style.background = '#333';
712
+
713
+ // 如果是滚动按钮,阻止默认行为(防止键盘弹出)并启动持续滚动
714
+ var scrollLines = e.currentTarget.getAttribute('data-scroll');
715
+ if (scrollLines) {
716
+ e.preventDefault(); // 关键:阻止默认行为,防止键盘弹出
717
+ scrollTerminal(parseInt(scrollLines, 10));
718
+ scrollInterval = setInterval(function() {
719
+ scrollTerminal(parseInt(scrollLines, 10));
720
+ }, 100);
721
+ }
722
+ }, { passive: false }); // 必须是 false 才能调用 preventDefault()
723
+
724
+ key.addEventListener('touchmove', function(e) {
725
+ if (vkMoved) return;
726
+ var touch = e.touches[0];
727
+ var dx = touch.clientX - vkStartX;
728
+ var dy = touch.clientY - vkStartY;
729
+ if (dx * dx + dy * dy > 64) { // 8px 阈值
730
+ vkMoved = true;
731
+ if (vkTarget) {
732
+ vkTarget.style.background = '';
733
+ }
734
+ }
735
+ }, { passive: true });
564
736
 
565
- // 其他按键直接发送到终端
566
- var key = KEY_MAP[keyName];
567
- if (key && ws && ws.readyState === 1 && !isTransitioning) {
568
- console.log('[sendKey] sending:', keyName, '->', key);
569
- ws.send(JSON.stringify({ type: 'input', data: key }));
570
- } else {
571
- console.log('[sendKey] skipped:', keyName, 'ws:', ws?.readyState, 'transitioning:', isTransitioning);
572
- }
573
- }
737
+ key.addEventListener('touchend', function(e) {
738
+ // 清除滚动定时器
739
+ if (scrollInterval) {
740
+ clearInterval(scrollInterval);
741
+ scrollInterval = null;
742
+ }
574
743
 
575
- // 虚拟按键处理:PC 端用 click,移动端用 touch
576
- if (isMobile) {
577
- // 移动端:使用触摸事件,区分点击和拖动(滑动)
578
- var vkStartX = 0, vkStartY = 0, vkMoved = false;
579
-
580
- keybar.addEventListener('touchstart', function(e) {
581
- if (e.target.tagName !== 'BUTTON') return;
582
- var touch = e.touches[0];
583
- vkStartX = touch.clientX;
584
- vkStartY = touch.clientY;
585
- vkMoved = false;
586
- e.target.style.background = '#333';
587
- }, { passive: true });
588
-
589
- keybar.addEventListener('touchmove', function(e) {
590
- if (e.target.tagName !== 'BUTTON') return;
591
- if (vkMoved) return;
592
- var touch = e.touches[0];
593
- var dx = touch.clientX - vkStartX;
594
- var dy = touch.clientY - vkStartY;
595
- if (dx * dx + dy * dy > 100) { // 10px 阈值,允许横向滑动
596
- vkMoved = true;
597
- }
598
- }, { passive: true });
599
-
600
- keybar.addEventListener('touchend', function(e) {
601
- if (e.target.tagName !== 'BUTTON') return;
602
- e.preventDefault();
603
- e.target.style.background = '';
604
- if (!vkMoved) {
605
- var keyName = e.target.getAttribute('data-key');
606
- sendKey(keyName);
607
- }
608
- }, { passive: false });
609
- } else {
610
- // PC 端:直接用 click
611
- keybar.addEventListener('click', function(e) {
612
- if (e.target.tagName === 'BUTTON') {
613
- var keyName = e.target.getAttribute('data-key');
614
- sendKey(keyName);
615
- }
744
+ if (vkTarget) {
745
+ vkTarget.style.background = '';
746
+ vkTarget = null;
747
+ }
748
+
749
+ // 如果没有移动,触发按键功能并阻止默认行为
750
+ if (!vkMoved) {
751
+ e.preventDefault(); // 阻止默认行为
752
+ var scrollLines = e.currentTarget.getAttribute('data-scroll');
753
+ if (!scrollLines) {
754
+ // 非滚动按钮才触发按键
755
+ var keyName = e.currentTarget.getAttribute('data-key');
756
+ sendKey(keyName);
757
+ }
758
+ }
759
+ }, { passive: false });
760
+
761
+ // PC端点击支持
762
+ key.addEventListener('click', function(e) {
763
+ e.preventDefault();
764
+ var scrollLines = e.currentTarget.getAttribute('data-scroll');
765
+ if (scrollLines) {
766
+ scrollTerminal(parseInt(scrollLines, 10));
767
+ } else {
768
+ var keyName = e.currentTarget.getAttribute('data-key');
769
+ sendKey(keyName);
770
+ }
771
+ });
772
+
773
+ // PC端鼠标按下支持(长按滚动)
774
+ key.addEventListener('mousedown', function(e) {
775
+ e.preventDefault();
776
+ var scrollLines = e.currentTarget.getAttribute('data-scroll');
777
+ if (scrollLines) {
778
+ scrollTerminal(parseInt(scrollLines, 10));
779
+ scrollInterval = setInterval(function() {
780
+ scrollTerminal(parseInt(scrollLines, 10));
781
+ }, 100);
782
+ }
783
+ });
784
+
785
+ key.addEventListener('mouseup', function(e) {
786
+ if (scrollInterval) {
787
+ clearInterval(scrollInterval);
788
+ scrollInterval = null;
789
+ }
790
+ });
791
+
792
+ key.addEventListener('mouseleave', function(e) {
793
+ if (scrollInterval) {
794
+ clearInterval(scrollInterval);
795
+ scrollInterval = null;
796
+ }
797
+ });
616
798
  });
617
799
  }
618
800
 
801
+ // 初始化虚拟按键事件
802
+ setupVirtualKeyEvents();
803
+
619
804
  connect();
620
805
  setTimeout(resize, 100);
621
806
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
5
5
  "type": "module",
6
6
  "main": "server.js",