@bakapiano/ccsm 0.22.0 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -0
- package/lib/persistedSessions.js +5 -1
- package/package.json +1 -1
- package/public/css/terminals.css +38 -1
- package/public/js/api.js +14 -0
- package/public/js/components/Sidebar.js +3 -4
- package/public/js/components/TerminalInstance.js +266 -0
- package/public/js/components/TerminalView.js +22 -524
- package/public/js/components/XtermTerminal.js +198 -0
- package/public/js/icons.js +8 -0
- package/public/js/pages/SessionsPage.js +106 -8
- package/server.js +62 -0
|
@@ -1,553 +1,58 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// output frames into xterm. Disposes everything on unmount or id change.
|
|
1
|
+
// TerminalView is the Preact shell around a VS Code-style terminal instance:
|
|
2
|
+
// TerminalView -> TerminalInstance -> XtermTerminal -> raw xterm.js.
|
|
4
3
|
|
|
5
4
|
import { html } from '../html.js';
|
|
6
5
|
import { Fragment } from 'preact';
|
|
7
6
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
8
|
-
import {
|
|
9
|
-
import { FitAddon } from '@xterm/addon-fit';
|
|
10
|
-
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
11
|
-
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|
12
|
-
import { WebglAddon } from '@xterm/addon-webgl';
|
|
13
|
-
import { wsBase, getToken, getDeviceId } from '../backend.js';
|
|
14
|
-
import { isDarkTheme, themeMode } from '../state.js';
|
|
7
|
+
import { themeMode } from '../state.js';
|
|
15
8
|
import { TerminalKeyBar } from './TerminalKeyBar.js';
|
|
16
|
-
|
|
17
|
-
// Dark xterm theme — VSCode's Dark+ terminal palette, verbatim (see
|
|
18
|
-
// microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts).
|
|
19
|
-
// #1e1e1e ground, #ccc ink, the standard saturated ANSI set.
|
|
20
|
-
const THEME_DARK = {
|
|
21
|
-
background: '#1e1e1e',
|
|
22
|
-
foreground: '#cccccc',
|
|
23
|
-
cursor: '#aeafad',
|
|
24
|
-
cursorAccent: '#1e1e1e',
|
|
25
|
-
selectionBackground: '#264f78',
|
|
26
|
-
black: '#000000', brightBlack: '#666666',
|
|
27
|
-
red: '#cd3131', brightRed: '#f14c4c',
|
|
28
|
-
green: '#0dbc79', brightGreen: '#23d18b',
|
|
29
|
-
yellow: '#e5e510', brightYellow: '#f5f543',
|
|
30
|
-
blue: '#2472c8', brightBlue: '#3b8eea',
|
|
31
|
-
magenta: '#bc3fbc', brightMagenta: '#d670d6',
|
|
32
|
-
cyan: '#11a8cd', brightCyan: '#29b8db',
|
|
33
|
-
white: '#e5e5e5', brightWhite: '#e5e5e5',
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// Light xterm theme — VSCode's Light+ terminal palette, verbatim (see
|
|
37
|
-
// microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts). Pure
|
|
38
|
-
// white ground, #333 ink, the classic saturated ANSI set tuned for legible
|
|
39
|
-
// contrast on white. The surrounding chrome (terminals.css --term-* light
|
|
40
|
-
// defaults) follows the same neutral light grays so it reads as one panel.
|
|
41
|
-
const THEME_LIGHT = {
|
|
42
|
-
background: '#ffffff',
|
|
43
|
-
foreground: '#333333',
|
|
44
|
-
cursor: '#000000',
|
|
45
|
-
cursorAccent: '#ffffff',
|
|
46
|
-
selectionBackground: '#add6ff',
|
|
47
|
-
black: '#000000', brightBlack: '#666666',
|
|
48
|
-
red: '#cd3131', brightRed: '#cd3131',
|
|
49
|
-
green: '#107c10', brightGreen: '#14ce14',
|
|
50
|
-
yellow: '#949800', brightYellow: '#b5ba00',
|
|
51
|
-
blue: '#0451a5', brightBlue: '#0451a5',
|
|
52
|
-
magenta: '#bc05bc', brightMagenta: '#bc05bc',
|
|
53
|
-
cyan: '#0598bc', brightCyan: '#0598bc',
|
|
54
|
-
white: '#555555', brightWhite: '#a5a5a5',
|
|
55
|
-
};
|
|
56
|
-
const themeFor = (dark) => (dark ? THEME_DARK : THEME_LIGHT);
|
|
9
|
+
import { TerminalInstance } from './TerminalInstance.js';
|
|
57
10
|
|
|
58
11
|
export function TerminalView({ terminalId, cliType }) {
|
|
59
12
|
const hostRef = useRef(null);
|
|
60
|
-
const
|
|
61
|
-
const wsRef = useRef(null);
|
|
62
|
-
// Set when ws.onclose receives our custom "displaced by another
|
|
63
|
-
// client" code (4001) from lib/webTerminal.js's latest-wins policy.
|
|
64
|
-
// Renders a full-pane prompt with a "Take it back" button that bumps
|
|
65
|
-
// reattachNonce → useEffect re-runs → new WS, displacing whoever
|
|
66
|
-
// currently holds the session.
|
|
13
|
+
const instanceRef = useRef(null);
|
|
67
14
|
const [displaced, setDisplaced] = useState(false);
|
|
68
15
|
const [reattachNonce, setReattach] = useState(0);
|
|
69
|
-
// Subscribe to the theme signal so a Settings toggle re-renders us and
|
|
70
|
-
// the theme-sync effect below re-runs. Holds the xterm theme currently
|
|
71
|
-
// applied so the IME handlers can re-issue it with a transparent cursor.
|
|
72
16
|
const mode = themeMode.value;
|
|
73
|
-
const themeRef = useRef(themeFor(isDarkTheme()));
|
|
74
17
|
|
|
75
|
-
// Raw escape-sequence injector for the mobile key bar. Reads wsRef at
|
|
76
|
-
// call time so it stays valid across reattaches without re-binding.
|
|
77
18
|
const sendInput = (data) => {
|
|
78
|
-
|
|
79
|
-
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
|
|
19
|
+
instanceRef.current?.sendInput(data);
|
|
80
20
|
};
|
|
81
21
|
|
|
82
|
-
// Swap the xterm canvas palette when the resolved theme flips — both on
|
|
83
|
-
// an explicit Settings toggle (mode dep) and on an OS change while in
|
|
84
|
-
// 'system' mode (matchMedia listener). No remount: xterm re-rasterizes
|
|
85
|
-
// its glyph atlas from the new options.theme in place.
|
|
86
22
|
useEffect(() => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (!term) return;
|
|
90
|
-
const theme = themeFor(isDarkTheme());
|
|
91
|
-
themeRef.current = theme;
|
|
92
|
-
try { term.options.theme = theme; } catch {}
|
|
93
|
-
};
|
|
94
|
-
apply();
|
|
23
|
+
instanceRef.current?.applyTheme();
|
|
24
|
+
const apply = () => instanceRef.current?.applyTheme();
|
|
95
25
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
96
26
|
mq.addEventListener('change', apply);
|
|
97
27
|
return () => mq.removeEventListener('change', apply);
|
|
98
28
|
}, [mode, reattachNonce]);
|
|
99
29
|
|
|
100
30
|
useEffect(() => {
|
|
101
|
-
if (!terminalId || !hostRef.current) return;
|
|
102
|
-
|
|
103
|
-
// Mobile viewports (≤ 640px) get a smaller default font so claude's
|
|
104
|
-
// UI fits ~50 cols instead of the ~26 that 13px would buy at 390px.
|
|
105
|
-
// Desktop stays at 13. We re-evaluate on every mount, so a viewport
|
|
106
|
-
// rotation that crosses the breakpoint picks up the new size on
|
|
107
|
-
// next mount (rare; users typically don't rotate mid-session).
|
|
108
|
-
const isMobile = window.matchMedia('(max-width: 640px)').matches;
|
|
109
|
-
const baseFontSize = isMobile ? 11 : 13;
|
|
110
|
-
const initialTheme = themeFor(isDarkTheme());
|
|
111
|
-
themeRef.current = initialTheme;
|
|
112
|
-
const term = new Terminal({
|
|
113
|
-
fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
|
|
114
|
-
fontSize: baseFontSize,
|
|
115
|
-
lineHeight: 1.2,
|
|
116
|
-
cursorBlink: true,
|
|
117
|
-
cursorStyle: 'bar',
|
|
118
|
-
scrollback: 5000,
|
|
119
|
-
allowProposedApi: true,
|
|
120
|
-
theme: initialTheme,
|
|
121
|
-
// Modern keyboard protocols. Without these, xterm.js encodes
|
|
122
|
-
// Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
|
|
123
|
-
// unmodified versions (e.g. both Enter and Shift+Enter send \r),
|
|
124
|
-
// so TUIs like claude code can't tell them apart.
|
|
125
|
-
//
|
|
126
|
-
// - kittyKeyboard: opt-in protocol that apps enable per-session;
|
|
127
|
-
// xterm emits CSI u sequences that uniquely encode every modifier
|
|
128
|
-
// combo. Claude / vim / fish recognise it.
|
|
129
|
-
// - win32InputMode: ConPTY-specific protocol that surfaces raw
|
|
130
|
-
// Win32 KEY_EVENT_RECORD to the child process, again preserving
|
|
131
|
-
// modifier info. Required for full key fidelity on Windows.
|
|
132
|
-
// (Same set VSCode enables — see vscode/src/.../xtermTerminal.ts)
|
|
133
|
-
vtExtensions: {
|
|
134
|
-
kittyKeyboard: true,
|
|
135
|
-
win32InputMode: true,
|
|
136
|
-
},
|
|
137
|
-
});
|
|
138
|
-
const fit = new FitAddon();
|
|
139
|
-
term.loadAddon(fit);
|
|
140
|
-
term.loadAddon(new WebLinksAddon());
|
|
141
|
-
// OSC 52 clipboard integration. Lets TUI apps initiate clipboard reads/
|
|
142
|
-
// writes via escape sequences (e.g. `tmux set-buffer` or claude code
|
|
143
|
-
// saying "copied to clipboard"). Does NOT handle the browser-side
|
|
144
|
-
// Ctrl+V — that's still our document-level paste handler below.
|
|
145
|
-
term.loadAddon(new ClipboardAddon());
|
|
146
|
-
// WebGL renderer for performance. The default DOM renderer struggles
|
|
147
|
-
// when claude code produces dense color output (its diff panels,
|
|
148
|
-
// syntax-highlighted code). WebGL paints onto a canvas, much smoother
|
|
149
|
-
// at thousands-of-cells per frame. Falls back to DOM if WebGL is
|
|
150
|
-
// unavailable (e.g. older GPU, hardware accel disabled).
|
|
151
|
-
//
|
|
152
|
-
// Skipped on phones: @xterm/addon-webgl@0.18.0 miscalculates the glyph
|
|
153
|
-
// atlas at the fractional DPRs that modern Android handsets report
|
|
154
|
-
// (Pixel 6/7/8 = 2.625, S24 = 2.625, etc.) — every cell ends up
|
|
155
|
-
// rendered ~3× wider than the layout grid says it should, blowing out
|
|
156
|
-
// the terminal. Integer DPRs (1, 2, 3 — desktops, iPhones) and the
|
|
157
|
-
// common Windows 1.5 are fine, so the gate is on the mobile viewport
|
|
158
|
-
// breakpoint, not the raw DPR.
|
|
159
|
-
if (!isMobile) {
|
|
160
|
-
try {
|
|
161
|
-
const webgl = new WebglAddon();
|
|
162
|
-
webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
|
|
163
|
-
term.loadAddon(webgl);
|
|
164
|
-
} catch (e) {
|
|
165
|
-
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
// Ctrl+C with a selection: by default xterm.js sends \x03 AND the
|
|
169
|
-
// browser's own copy event fires — so the user gets "selection
|
|
170
|
-
// copied to clipboard" AND the running CLI gets SIGINT. Mirror
|
|
171
|
-
// VSCode/Windows Terminal behaviour: when there's a selection,
|
|
172
|
-
// suppress \x03 and let the copy event do its thing. With no
|
|
173
|
-
// selection, Ctrl+C still sends \x03 normally.
|
|
174
|
-
term.attachCustomKeyEventHandler((ev) => {
|
|
175
|
-
if (ev.type === 'keydown'
|
|
176
|
-
&& ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
|
|
177
|
-
&& ev.key.toLowerCase() === 'c'
|
|
178
|
-
&& term.hasSelection()) {
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
return true;
|
|
182
|
-
});
|
|
183
|
-
|
|
184
31
|
const host = hostRef.current;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// Answer OSC 10/11 (default foreground / background colour) queries with
|
|
188
|
-
// the LIVE theme colours. CLIs like claude probe the terminal background
|
|
189
|
-
// (`OSC 11 ; ? ST`) to pick a light- or dark-tuned syntax theme. xterm.js
|
|
190
|
-
// doesn't answer these by default, so claude assumes a dark terminal and
|
|
191
|
-
// paints near-white tokens (comments, f-string interpolations, call
|
|
192
|
-
// names) that vanish on our light background — the "字体颜色和背景重复"
|
|
193
|
-
// bug. VSCode answers them; we match. Reply format is the xterm/X11
|
|
194
|
-
// `rgb:RRRR/GGGG/BBBB` (16-bit-per-channel) the query expects.
|
|
195
|
-
const answerColorOsc = (code, getHex) => (data) => {
|
|
196
|
-
if (data !== '?') return false; // only the query form
|
|
197
|
-
const hex = getHex(); // '#rrggbb'
|
|
198
|
-
const ch = (i) => parseInt(hex.slice(i, i + 2), 16);
|
|
199
|
-
const w = (v) => (v * 257).toString(16).padStart(4, '0'); // 8-bit → 16-bit
|
|
200
|
-
const reply = `\x1b]${code};rgb:${w(ch(1))}/${w(ch(3))}/${w(ch(5))}\x07`;
|
|
201
|
-
const ws = wsRef.current;
|
|
202
|
-
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data: reply }));
|
|
203
|
-
return true;
|
|
204
|
-
};
|
|
205
|
-
try {
|
|
206
|
-
term.parser.registerOscHandler(11, answerColorOsc(11, () => themeRef.current.background));
|
|
207
|
-
term.parser.registerOscHandler(10, answerColorOsc(10, () => themeRef.current.foreground));
|
|
208
|
-
} catch {}
|
|
209
|
-
|
|
210
|
-
// Robust fit scheduler. A single requestAnimationFrame works most
|
|
211
|
-
// of the time but races on tab/session switches: the .tab-panel
|
|
212
|
-
// just flipped from display:none to display:flex and although the
|
|
213
|
-
// browser has laid the element out by the next frame, xterm's
|
|
214
|
-
// canvas measurement occasionally still reports the pre-display
|
|
215
|
-
// size (Chromium quirk — the WebGL renderer caches its viewport
|
|
216
|
-
// before the layout flush propagates through ResizeObserver).
|
|
217
|
-
// Result: visible "wrong cols/rows until I resize the window" bug.
|
|
218
|
-
// Spraying fits at 0 / one rAF / 60ms / 200ms covers every
|
|
219
|
-
// measurement-arrival path without being expensive — fit.fit() is
|
|
220
|
-
// a no-op when cols/rows match the previous call.
|
|
221
|
-
const scheduleFit = () => {
|
|
222
|
-
try { fit.fit(); } catch {}
|
|
223
|
-
requestAnimationFrame(() => {
|
|
224
|
-
try { fit.fit(); } catch {}
|
|
225
|
-
setTimeout(() => { try { fit.fit(); } catch {} }, 60);
|
|
226
|
-
setTimeout(() => { try { fit.fit(); } catch {} }, 200);
|
|
227
|
-
});
|
|
228
|
-
};
|
|
229
|
-
scheduleFit();
|
|
230
|
-
termRef.current = term;
|
|
231
|
-
|
|
232
|
-
// Web fonts settle AFTER the first fit. The terminal's mono stack
|
|
233
|
-
// (`Geist Mono` / `JetBrains Mono`) loads async from Google Fonts with
|
|
234
|
-
// display:swap, so on a machine without a local `Cascadia Mono` the very
|
|
235
|
-
// first term.open()+fit() measures cell metrics against the fallback font.
|
|
236
|
-
// When the real font swaps in its cell height changes, making the row
|
|
237
|
-
// count fit computed wrong — and because the host's box size never changed,
|
|
238
|
-
// the ResizeObserver never fires to correct it. The terminal ends up a row
|
|
239
|
-
// or two short of (or past) the host, "sometimes" — depending purely on
|
|
240
|
-
// whether the font was already cached. Re-fit once fonts settle; it's a
|
|
241
|
-
// no-op when the metrics didn't actually change (e.g. local font hit).
|
|
242
|
-
try {
|
|
243
|
-
document.fonts?.ready?.then(() => { if (termRef.current === term) scheduleFit(); });
|
|
244
|
-
} catch {}
|
|
245
|
-
|
|
246
|
-
// Browser WS API can't set Authorization headers — token + device
|
|
247
|
-
// ride as query string when we have them (Remote-mode access).
|
|
248
|
-
// Server's upgrade handler reads both when Host is non-loopback.
|
|
249
|
-
const tok = getToken();
|
|
250
|
-
const dev = getDeviceId();
|
|
251
|
-
const params = new URLSearchParams();
|
|
252
|
-
if (tok) params.set('token', tok);
|
|
253
|
-
if (dev) params.set('device', dev);
|
|
254
|
-
const qs = params.toString();
|
|
255
|
-
const wsUrl = `${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}${qs ? `?${qs}` : ''}`;
|
|
256
|
-
// Auto-reconnect. Mobile networks drop the WS constantly (radio sleep,
|
|
257
|
-
// cell↔wifi handoff, tab backgrounding) — leaving a dead "[disconnected]"
|
|
258
|
-
// terminal is the #1 mobile annoyance. We retry with capped backoff and
|
|
259
|
-
// re-attach to the same PTY. The server replays its FULL history on every
|
|
260
|
-
// attach (lib/webTerminal.js), so on a reconnect we reset the screen
|
|
261
|
-
// first, otherwise the replay stacks on top of what's already shown.
|
|
262
|
-
let closedByUs = false;
|
|
263
|
-
let reconnectTimer = null;
|
|
264
|
-
let attempts = 0;
|
|
265
|
-
let everOpened = false;
|
|
266
|
-
|
|
267
|
-
const connect = () => {
|
|
268
|
-
const ws = new WebSocket(wsUrl);
|
|
269
|
-
ws.binaryType = 'arraybuffer';
|
|
270
|
-
wsRef.current = ws;
|
|
271
|
-
|
|
272
|
-
ws.onopen = () => {
|
|
273
|
-
if (everOpened) {
|
|
274
|
-
// Reconnect: clear so the replayed history repopulates cleanly.
|
|
275
|
-
try { term.reset(); } catch {}
|
|
276
|
-
}
|
|
277
|
-
everOpened = true;
|
|
278
|
-
attempts = 0;
|
|
279
|
-
// Fit synchronously before sending cols/rows — the handshake often
|
|
280
|
-
// completes before the rAF-scheduled fit, so without this we'd ship
|
|
281
|
-
// the default 80x24 and claude would wrap its prompt at 80 cols.
|
|
282
|
-
scheduleFit();
|
|
283
|
-
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
284
|
-
};
|
|
285
|
-
ws.onmessage = (ev) => {
|
|
286
|
-
let frame;
|
|
287
|
-
try { frame = JSON.parse(ev.data); } catch { return; }
|
|
288
|
-
if (frame.type === 'output') {
|
|
289
|
-
term.write(frame.data);
|
|
290
|
-
} else if (frame.type === 'exit') {
|
|
291
|
-
term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
ws.onclose = (ev) => {
|
|
295
|
-
if (closedByUs) return;
|
|
296
|
-
// Displaced by another client (latest-wins, code 4001) — reconnecting
|
|
297
|
-
// would just ping-pong, so show the "Take it back" pane instead.
|
|
298
|
-
if (ev && ev.code === 4001) { setDisplaced(true); return; }
|
|
299
|
-
// PTY is gone (server restarted / session ended, code 4404) — a
|
|
300
|
-
// reconnect can't revive it; the session needs a full resume.
|
|
301
|
-
if (ev && ev.code === 4404) {
|
|
302
|
-
term.write('\r\n\x1b[2m[session ended]\x1b[0m\r\n');
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
// Network blip — retry with backoff (0.5/1/2/4/8s cap), indefinitely
|
|
306
|
-
// until the effect tears down (cleanup flips closedByUs).
|
|
307
|
-
attempts++;
|
|
308
|
-
const delay = Math.min(8000, 500 * 2 ** Math.min(attempts - 1, 4));
|
|
309
|
-
term.write('\r\n\x1b[2m[disconnected · reconnecting…]\x1b[0m\r\n');
|
|
310
|
-
reconnectTimer = setTimeout(() => { if (!closedByUs) connect(); }, delay);
|
|
311
|
-
};
|
|
312
|
-
};
|
|
313
|
-
connect();
|
|
314
|
-
|
|
315
|
-
// onData/onResize read wsRef.current (not a captured socket) so they keep
|
|
316
|
-
// working across reconnects.
|
|
317
|
-
const onData = (data) => {
|
|
318
|
-
const ws = wsRef.current;
|
|
319
|
-
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
|
|
320
|
-
};
|
|
321
|
-
const onResize = ({ cols, rows }) => {
|
|
322
|
-
const ws = wsRef.current;
|
|
323
|
-
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
324
|
-
};
|
|
325
|
-
term.onData(onData);
|
|
326
|
-
term.onResize(onResize);
|
|
32
|
+
if (!terminalId || !host) return;
|
|
327
33
|
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// height (we use 100vh-derived sizing) so half the terminal sits
|
|
336
|
-
// behind the keyboard with no resize callback fired. Listening
|
|
337
|
-
// here covers it: any visualViewport size change triggers a fit
|
|
338
|
-
// so the cell grid matches the visible area. Cheap; fit.fit() is
|
|
339
|
-
// a no-op when nothing changed.
|
|
340
|
-
const vv = window.visualViewport;
|
|
341
|
-
const onVisualResize = () => scheduleFit();
|
|
342
|
-
vv?.addEventListener?.('resize', onVisualResize);
|
|
343
|
-
vv?.addEventListener?.('scroll', onVisualResize);
|
|
344
|
-
|
|
345
|
-
// Mobile touch scrolling is handled NATIVELY: responsive.css makes the
|
|
346
|
-
// .xterm-screen layer pointer-transparent so finger drags fall through to
|
|
347
|
-
// xterm's own scrollable .xterm-viewport (real momentum, never drops
|
|
348
|
-
// mid-flick the way a JS-intercepted scroll does). The only casualty is
|
|
349
|
-
// tap-to-focus — with the screen ignoring pointer events a tap no longer
|
|
350
|
-
// reaches xterm's focus path, so the soft keyboard wouldn't open. Re-focus
|
|
351
|
-
// on tap explicitly. A drag emits no click, so this fires only on real taps.
|
|
352
|
-
const onHostClick = () => { try { term.focus(); } catch {} };
|
|
353
|
-
if (isMobile) host.addEventListener('click', onHostClick);
|
|
354
|
-
|
|
355
|
-
// Tab-switch refresh. The terminal lives inside a .tab-panel which gets
|
|
356
|
-
// display:none when another tab is active. WebGL renderers keep a glyph
|
|
357
|
-
// texture atlas in GPU memory; when the canvas hides + redisplays at a
|
|
358
|
-
// potentially different devicePixelRatio, the atlas isn't invalidated
|
|
359
|
-
// and old glyphs blend with newly-rasterized ones — visible as scrolling
|
|
360
|
-
// text ghosting / double-strikes. Watching the tab-panel's data-active
|
|
361
|
-
// attribute and clearing the atlas + re-fitting + forcing a full
|
|
362
|
-
// refresh wipes the cache cleanly.
|
|
363
|
-
const panel = host.closest('.tab-panel');
|
|
364
|
-
let panelMo = null;
|
|
365
|
-
if (panel) {
|
|
366
|
-
panelMo = new MutationObserver(() => {
|
|
367
|
-
if (panel.hasAttribute('data-active')) {
|
|
368
|
-
requestAnimationFrame(() => {
|
|
369
|
-
try { term.clearTextureAtlas?.(); } catch {}
|
|
370
|
-
scheduleFit();
|
|
371
|
-
try { term.refresh(0, term.rows - 1); } catch {}
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
panelMo.observe(panel, { attributes: true, attributeFilter: ['data-active'] });
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// give focus to terminal so user can type immediately
|
|
379
|
-
term.focus();
|
|
380
|
-
|
|
381
|
-
// Explicit paste handler. xterm.js relies on the browser routing paste
|
|
382
|
-
// events to its hidden .xterm-helper-textarea, which only works if that
|
|
383
|
-
// textarea has focus at the moment of Ctrl+V. When the user clicks
|
|
384
|
-
// elsewhere then hits Ctrl+V over the terminal, or pastes via the
|
|
385
|
-
// right-click menu on the host div, the event lands on the host and
|
|
386
|
-
// xterm never sees it. Catch it here and route through term.paste()
|
|
387
|
-
// so xterm wraps the text in bracketed-paste markers when the app
|
|
388
|
-
// (claude code) has DECSET 2004 enabled — that's what makes claude
|
|
389
|
-
// show the "[Pasted text]" affordance instead of treating it as
|
|
390
|
-
// typed input.
|
|
391
|
-
const isOurs = () => {
|
|
392
|
-
const ae = document.activeElement;
|
|
393
|
-
return ae && host.contains(ae);
|
|
394
|
-
};
|
|
395
|
-
const doPaste = (text) => {
|
|
396
|
-
if (!text) return;
|
|
397
|
-
// Read the live socket — `ws` is scoped to connect() and reassigned on
|
|
398
|
-
// every reconnect, so referencing it here would be a ReferenceError
|
|
399
|
-
// (which silently killed Ctrl+V via onKey's .catch).
|
|
400
|
-
const ws = wsRef.current;
|
|
401
|
-
if (!ws || ws.readyState !== 1) return;
|
|
402
|
-
// Normalize line endings to \r (CR / Enter). This mirrors VSCode's
|
|
403
|
-
// terminal sendText path (terminalInstance.ts ~L1385):
|
|
404
|
-
// text = text.replace(/\r?\n/g, '\r');
|
|
405
|
-
// Bracketed-paste markers protect each \r from being interpreted
|
|
406
|
-
// as a submit by the host app — claude / pwsh / vim all treat
|
|
407
|
-
// bracketed contents as opaque payload regardless of what's inside.
|
|
408
|
-
// Use \n instead and you trip apps that look for "real" line breaks.
|
|
409
|
-
const normalized = text.replace(/\r?\n/g, '\r');
|
|
410
|
-
// Wrap in bracketed-paste markers. Claude Code enables DECSET 2004
|
|
411
|
-
// on startup, so the markers let it detect a paste and render
|
|
412
|
-
// "[Pasted text]". If the host app doesn't have bracketed paste on,
|
|
413
|
-
// it just sees two ignored escape sequences plus the text.
|
|
414
|
-
const wrapped = `\x1b[200~${normalized}\x1b[201~`;
|
|
415
|
-
ws.send(JSON.stringify({ type: 'input', data: wrapped }));
|
|
416
|
-
};
|
|
417
|
-
const onPaste = async (ev) => {
|
|
418
|
-
if (!isOurs()) return;
|
|
419
|
-
let text = '';
|
|
420
|
-
if (ev.clipboardData) text = ev.clipboardData.getData('text');
|
|
421
|
-
if (!text && navigator.clipboard) {
|
|
422
|
-
try { text = await navigator.clipboard.readText(); } catch {}
|
|
423
|
-
}
|
|
424
|
-
if (!text) return;
|
|
425
|
-
ev.preventDefault();
|
|
426
|
-
ev.stopPropagation();
|
|
427
|
-
doPaste(text);
|
|
428
|
-
};
|
|
429
|
-
document.addEventListener('paste', onPaste, true);
|
|
430
|
-
|
|
431
|
-
// Ctrl/Cmd+V fallback for cases the paste event is suppressed (some
|
|
432
|
-
// extensions, or when our IME workaround moved the helper textarea
|
|
433
|
-
// off-screen and the browser refuses to fire paste on it).
|
|
434
|
-
// IMPORTANT: preventDefault must happen synchronously, BEFORE the
|
|
435
|
-
// await on navigator.clipboard.readText(). If we let the event tick
|
|
436
|
-
// run first, xterm's keystroke handler converts Ctrl+V into the raw
|
|
437
|
-
// ^V (0x16) control byte and ships it before our async paste even
|
|
438
|
-
// resolves.
|
|
439
|
-
const onKey = (ev) => {
|
|
440
|
-
const meta = ev.ctrlKey || ev.metaKey;
|
|
441
|
-
if (!meta || ev.key.toLowerCase() !== 'v') return;
|
|
442
|
-
if (ev.shiftKey || ev.altKey) return;
|
|
443
|
-
if (!isOurs()) return;
|
|
444
|
-
if (!navigator.clipboard?.readText) return;
|
|
445
|
-
ev.preventDefault();
|
|
446
|
-
ev.stopPropagation();
|
|
447
|
-
ev.stopImmediatePropagation();
|
|
448
|
-
navigator.clipboard.readText().then((text) => {
|
|
449
|
-
if (text) doPaste(text);
|
|
450
|
-
}).catch(() => {});
|
|
451
|
-
};
|
|
452
|
-
document.addEventListener('keydown', onKey, true);
|
|
453
|
-
|
|
454
|
-
// Shift+Enter / Ctrl+Enter → insert literal newline, don't submit.
|
|
455
|
-
// Background: xterm.js encodes BOTH plain Enter and Shift+Enter and
|
|
456
|
-
// Ctrl+Enter as \r (0x0D / CR). The kitty keyboard / win32 input
|
|
457
|
-
// protocols WOULD distinguish them, but they're opt-in by the
|
|
458
|
-
// running app and most CLIs don't enable them, so we never get the
|
|
459
|
-
// distinction "for free". Each CLI handles modified-Enter differently:
|
|
460
|
-
//
|
|
461
|
-
// claude · expects a literal LF (0x0A) — its prompt treats \n
|
|
462
|
-
// as "insert newline", \r as "submit". Workaround = '\n'.
|
|
463
|
-
// codex / others · use ratatui or similar TUI libs that decode
|
|
464
|
-
// the kitty keyboard CSI u sequence. We synthesise it
|
|
465
|
-
// explicitly: `CSI 13 ; <mod> u` where mod = 2 for
|
|
466
|
-
// Shift, 5 for Ctrl. That maps to the exact key+mod
|
|
467
|
-
// the user pressed and ratatui inserts a newline.
|
|
468
|
-
//
|
|
469
|
-
// Alt+Enter already works (xterm sends \x1b\r → meta-enter) so we
|
|
470
|
-
// leave that alone.
|
|
471
|
-
const onShiftEnter = (ev) => {
|
|
472
|
-
if (ev.key !== 'Enter') return;
|
|
473
|
-
if (!(ev.shiftKey || ev.ctrlKey)) return;
|
|
474
|
-
if (ev.metaKey || ev.altKey) return;
|
|
475
|
-
if (!isOurs()) return;
|
|
476
|
-
// claude → LF (its prompt parses \n as insert-newline).
|
|
477
|
-
// others → ESC+CR i.e. Alt+Enter. crossterm (codex/copilot
|
|
478
|
-
// TUI libs) decodes ESC-prefixed sequences as Alt-modified
|
|
479
|
-
// without needing the kitty keyboard protocol enabled — and
|
|
480
|
-
// codex's default keymap binds Alt+Enter to insert_newline
|
|
481
|
-
// alongside Shift+Enter (see openai/codex
|
|
482
|
-
// codex-rs/tui/src/keymap.rs L904-909). The kitty CSI u
|
|
483
|
-
// sequence we tried first only works after the app has
|
|
484
|
-
// negotiated kitty mode, which codex doesn't do by default.
|
|
485
|
-
const data = cliType === 'claude' ? '\n' : '\x1b\r';
|
|
486
|
-
ev.preventDefault();
|
|
487
|
-
ev.stopPropagation();
|
|
488
|
-
ev.stopImmediatePropagation();
|
|
489
|
-
const ws = wsRef.current;
|
|
490
|
-
if (ws && ws.readyState === 1) {
|
|
491
|
-
ws.send(JSON.stringify({ type: 'input', data }));
|
|
492
|
-
}
|
|
493
|
-
};
|
|
494
|
-
document.addEventListener('keydown', onShiftEnter, true);
|
|
495
|
-
|
|
496
|
-
// While composing (IME), hide the terminal's own cursor so the blinking
|
|
497
|
-
// bar doesn't sit on top of the composition box (terminals.css paints the
|
|
498
|
-
// box at the cursor, showing the in-progress pinyin). The cursor is drawn
|
|
499
|
-
// on the canvas from theme.cursor, so CSS can't touch it: swap the theme
|
|
500
|
-
// to a transparent cursor AND issue the DECTCEM hide sequence (the theme
|
|
501
|
-
// swap alone doesn't reliably stop the blink frame loop). Restore on end.
|
|
502
|
-
// Use the live theme (themeRef) so the restore matches current light/dark.
|
|
503
|
-
const onCompStart = () => {
|
|
504
|
-
try { term.options.theme = { ...themeRef.current, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
|
|
505
|
-
try { term.write('\x1b[?25l'); } catch {}
|
|
506
|
-
};
|
|
507
|
-
const onCompEnd = () => {
|
|
508
|
-
try { term.options.theme = themeRef.current; } catch {}
|
|
509
|
-
try { term.write('\x1b[?25h'); } catch {}
|
|
510
|
-
};
|
|
511
|
-
const helper = host?.querySelector('.xterm-helper-textarea');
|
|
512
|
-
if (helper) {
|
|
513
|
-
helper.addEventListener('compositionstart', onCompStart);
|
|
514
|
-
helper.addEventListener('compositionend', onCompEnd);
|
|
515
|
-
}
|
|
34
|
+
const instance = new TerminalInstance({
|
|
35
|
+
terminalId,
|
|
36
|
+
cliType,
|
|
37
|
+
onDisplaced: () => setDisplaced(true),
|
|
38
|
+
});
|
|
39
|
+
instanceRef.current = instance;
|
|
40
|
+
instance.attachToElement(host);
|
|
516
41
|
|
|
517
42
|
return () => {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
document.removeEventListener('keydown', onShiftEnter, true);
|
|
521
|
-
if (helper) {
|
|
522
|
-
helper.removeEventListener('compositionstart', onCompStart);
|
|
523
|
-
helper.removeEventListener('compositionend', onCompEnd);
|
|
524
|
-
}
|
|
525
|
-
ro.disconnect();
|
|
526
|
-
if (panelMo) panelMo.disconnect();
|
|
527
|
-
vv?.removeEventListener?.('resize', onVisualResize);
|
|
528
|
-
vv?.removeEventListener?.('scroll', onVisualResize);
|
|
529
|
-
if (isMobile) host.removeEventListener('click', onHostClick);
|
|
530
|
-
closedByUs = true;
|
|
531
|
-
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
532
|
-
try { wsRef.current?.close(); } catch {}
|
|
533
|
-
try { term.dispose(); } catch {}
|
|
534
|
-
termRef.current = null;
|
|
535
|
-
wsRef.current = null;
|
|
43
|
+
if (instanceRef.current === instance) instanceRef.current = null;
|
|
44
|
+
instance.dispose();
|
|
536
45
|
};
|
|
537
46
|
}, [terminalId, reattachNonce]);
|
|
538
47
|
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
instanceRef.current?.setCliType(cliType);
|
|
50
|
+
}, [cliType, terminalId, reattachNonce]);
|
|
51
|
+
|
|
539
52
|
if (!terminalId) {
|
|
540
53
|
return html`<div class="terminal-empty">Select a terminal on the left, or launch a new one.</div>`;
|
|
541
54
|
}
|
|
542
55
|
if (displaced) {
|
|
543
|
-
// Distinct key (and a non-div tag) forces Preact's reconciler to
|
|
544
|
-
// UNMOUNT the host <div> and mount a fresh element. Without this,
|
|
545
|
-
// Preact reuses the same DOM node, only flipping its className —
|
|
546
|
-
// and the xterm canvases stay parented inside, visible behind our
|
|
547
|
-
// overlay text. Re-mount on reattach: bumping reattachNonce reruns
|
|
548
|
-
// the effect with a fresh Terminal + WebSocket pair, which the
|
|
549
|
-
// server's latest-wins gate handles by displacing the current
|
|
550
|
-
// holder.
|
|
551
56
|
return html`
|
|
552
57
|
<section key="displaced" class="terminal-displaced">
|
|
553
58
|
<div class="terminal-displaced-card">
|
|
@@ -560,13 +65,6 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
560
65
|
<div class="terminal-displaced-actions">
|
|
561
66
|
<button class="action primary"
|
|
562
67
|
onClick=${() => {
|
|
563
|
-
// Clear displaced FIRST so the next render swaps the
|
|
564
|
-
// overlay out for the host div — that's the only
|
|
565
|
-
// way hostRef.current populates. Then bump the nonce
|
|
566
|
-
// so the effect re-runs with the freshly-mounted
|
|
567
|
-
// host and opens a new WS. Doing both in one tick
|
|
568
|
-
// batches the state updates; React renders, mounts
|
|
569
|
-
// the host, then flushes effects.
|
|
570
68
|
setDisplaced(false);
|
|
571
69
|
setReattach((n) => n + 1);
|
|
572
70
|
}}>
|