@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.
@@ -1,553 +1,58 @@
1
- // xterm.js wrapper. Mounts a terminal into a ref'd div, opens a WebSocket
2
- // to /ws/terminal/<id>, forwards keystrokes/resize as JSON frames, renders
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 { Terminal } from '@xterm/xterm';
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 termRef = useRef(null);
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
- const ws = wsRef.current;
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
- const apply = () => {
88
- const term = termRef.current;
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
- term.open(host);
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 ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
329
- ro.observe(hostRef.current);
330
-
331
- // Mobile soft-keyboard resize. When the IME slides up on iOS /
332
- // Android, the layout viewport doesn't change but `visualViewport`
333
- // does — the page now has less vertical room before the keyboard
334
- // covers the bottom. xterm's host element keeps its old layout
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
- document.removeEventListener('paste', onPaste, true);
519
- document.removeEventListener('keydown', onKey, true);
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
  }}>