@bakapiano/ccsm 0.14.0 → 0.15.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.
Files changed (53) hide show
  1. package/CLAUDE.md +474 -475
  2. package/README.md +189 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliActivity.js +118 -0
  5. package/lib/codexSeed.js +147 -0
  6. package/lib/config.js +205 -188
  7. package/lib/folders.js +105 -105
  8. package/lib/localCliSessions.js +489 -489
  9. package/lib/persistedSessions.js +144 -142
  10. package/lib/webTerminal.js +224 -224
  11. package/lib/workspace.js +230 -230
  12. package/package.json +57 -57
  13. package/public/css/base.css +99 -99
  14. package/public/css/cards.css +183 -183
  15. package/public/css/feedback.css +303 -303
  16. package/public/css/forms.css +405 -405
  17. package/public/css/layout.css +160 -160
  18. package/public/css/modal.css +190 -190
  19. package/public/css/responsive.css +10 -10
  20. package/public/css/sidebar.css +613 -608
  21. package/public/css/terminals.css +294 -294
  22. package/public/css/tokens.css +81 -81
  23. package/public/css/wco.css +98 -98
  24. package/public/css/widgets.css +1628 -1628
  25. package/public/index.html +111 -105
  26. package/public/js/api.js +296 -280
  27. package/public/js/components/AdoptModal.js +343 -343
  28. package/public/js/components/App.js +35 -35
  29. package/public/js/components/DirectoryPicker.js +203 -203
  30. package/public/js/components/EntityFormModal.js +141 -141
  31. package/public/js/components/Modal.js +51 -51
  32. package/public/js/components/OfflineBanner.js +93 -93
  33. package/public/js/components/PageTitleBar.js +13 -13
  34. package/public/js/components/Picker.js +179 -179
  35. package/public/js/components/Popover.js +55 -55
  36. package/public/js/components/Sidebar.js +299 -299
  37. package/public/js/components/TerminalView.js +314 -314
  38. package/public/js/components/useDragSort.js +67 -67
  39. package/public/js/dialog.js +67 -67
  40. package/public/js/icons.js +177 -177
  41. package/public/js/main.js +132 -132
  42. package/public/js/pages/AboutPage.js +173 -165
  43. package/public/js/pages/ConfigurePage.js +513 -475
  44. package/public/js/pages/LaunchPage.js +369 -369
  45. package/public/js/pages/SessionsPage.js +101 -97
  46. package/public/js/state.js +231 -231
  47. package/scripts/dev.js +44 -11
  48. package/scripts/install.js +158 -158
  49. package/scripts/restart-helper.js +96 -0
  50. package/scripts/upgrade-helper.js +6 -1
  51. package/server.js +1282 -1254
  52. package/lib/cliSessionWatcher.js +0 -275
  53. package/public/manifest.webmanifest +0 -15
@@ -1,314 +1,314 @@
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.
4
-
5
- import { html } from '../html.js';
6
- import { useEffect, useRef } from 'preact/hooks';
7
- import { Terminal } from '@xterm/xterm';
8
- import { FitAddon } from '@xterm/addon-fit';
9
- import { WebLinksAddon } from '@xterm/addon-web-links';
10
- import { ClipboardAddon } from '@xterm/addon-clipboard';
11
- import { WebglAddon } from '@xterm/addon-webgl';
12
- import { wsBase } from '../backend.js';
13
-
14
- // Dark xterm theme. We give the terminal a near-black ink background to
15
- // match what claude code's TUI assumes (it paints its own input box +
16
- // prompt with hardcoded dark backgrounds — a light terminal makes those
17
- // regions look like black blocks). Cursor uses the favorite-star gold so
18
- // it pops against the ink without dragging brand orange back in.
19
- const THEME = {
20
- background: '#1a1815',
21
- foreground: '#e8e3d5',
22
- cursor: '#e3b341',
23
- cursorAccent: '#1a1815',
24
- selectionBackground: '#3a3530',
25
- black: '#1a1815', brightBlack: '#534e44',
26
- red: '#e07b6e', brightRed: '#f0a098',
27
- green: '#7fb670', brightGreen: '#a0d28f',
28
- yellow: '#e3b341', brightYellow: '#f0c860',
29
- blue: '#7d9fc4', brightBlue: '#9bb8d8',
30
- magenta: '#c08fd0', brightMagenta: '#d8aae2',
31
- cyan: '#6fb0b0', brightCyan: '#90c8c8',
32
- white: '#e8e3d5', brightWhite: '#faf9f5',
33
- };
34
-
35
- export function TerminalView({ terminalId }) {
36
- const hostRef = useRef(null);
37
- const termRef = useRef(null);
38
- const wsRef = useRef(null);
39
-
40
- useEffect(() => {
41
- if (!terminalId || !hostRef.current) return;
42
-
43
- const term = new Terminal({
44
- fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
45
- fontSize: 13,
46
- lineHeight: 1.2,
47
- cursorBlink: true,
48
- cursorStyle: 'bar',
49
- scrollback: 5000,
50
- allowProposedApi: true,
51
- theme: THEME,
52
- // Modern keyboard protocols. Without these, xterm.js encodes
53
- // Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
54
- // unmodified versions (e.g. both Enter and Shift+Enter send \r),
55
- // so TUIs like claude code can't tell them apart.
56
- //
57
- // - kittyKeyboard: opt-in protocol that apps enable per-session;
58
- // xterm emits CSI u sequences that uniquely encode every modifier
59
- // combo. Claude / vim / fish recognise it.
60
- // - win32InputMode: ConPTY-specific protocol that surfaces raw
61
- // Win32 KEY_EVENT_RECORD to the child process, again preserving
62
- // modifier info. Required for full key fidelity on Windows.
63
- // (Same set VSCode enables — see vscode/src/.../xtermTerminal.ts)
64
- vtExtensions: {
65
- kittyKeyboard: true,
66
- win32InputMode: true,
67
- },
68
- });
69
- const fit = new FitAddon();
70
- term.loadAddon(fit);
71
- term.loadAddon(new WebLinksAddon());
72
- // OSC 52 clipboard integration. Lets TUI apps initiate clipboard reads/
73
- // writes via escape sequences (e.g. `tmux set-buffer` or claude code
74
- // saying "copied to clipboard"). Does NOT handle the browser-side
75
- // Ctrl+V — that's still our document-level paste handler below.
76
- term.loadAddon(new ClipboardAddon());
77
- // WebGL renderer for performance. The default DOM renderer struggles
78
- // when claude code produces dense color output (its diff panels,
79
- // syntax-highlighted code). WebGL paints onto a canvas, much smoother
80
- // at thousands-of-cells per frame. Falls back to DOM if WebGL is
81
- // unavailable (e.g. older GPU, hardware accel disabled).
82
- try {
83
- const webgl = new WebglAddon();
84
- webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
85
- term.loadAddon(webgl);
86
- } catch (e) {
87
- console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
88
- }
89
- // Ctrl+C with a selection: by default xterm.js sends \x03 AND the
90
- // browser's own copy event fires — so the user gets "selection
91
- // copied to clipboard" AND the running CLI gets SIGINT. Mirror
92
- // VSCode/Windows Terminal behaviour: when there's a selection,
93
- // suppress \x03 and let the copy event do its thing. With no
94
- // selection, Ctrl+C still sends \x03 normally.
95
- term.attachCustomKeyEventHandler((ev) => {
96
- if (ev.type === 'keydown'
97
- && ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
98
- && ev.key.toLowerCase() === 'c'
99
- && term.hasSelection()) {
100
- return false;
101
- }
102
- return true;
103
- });
104
-
105
- const host = hostRef.current;
106
- term.open(host);
107
- // Defer fit one tick so the container has measured layout
108
- requestAnimationFrame(() => { try { fit.fit(); } catch {} });
109
- termRef.current = term;
110
-
111
- const ws = new WebSocket(`${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}`);
112
- ws.binaryType = 'arraybuffer';
113
- wsRef.current = ws;
114
-
115
- ws.onopen = () => {
116
- // tell server the initial size (cols/rows after fit)
117
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
118
- };
119
- ws.onmessage = (ev) => {
120
- let frame;
121
- try { frame = JSON.parse(ev.data); } catch { return; }
122
- if (frame.type === 'output') {
123
- term.write(frame.data);
124
- } else if (frame.type === 'exit') {
125
- term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
126
- }
127
- };
128
- ws.onclose = () => {
129
- term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
130
- };
131
-
132
- const onData = (data) => {
133
- if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
134
- };
135
- const onResize = ({ cols, rows }) => {
136
- if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
137
- };
138
- term.onData(onData);
139
- term.onResize(onResize);
140
-
141
- const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
142
- ro.observe(hostRef.current);
143
-
144
- // Tab-switch refresh. The terminal lives inside a .tab-panel which gets
145
- // display:none when another tab is active. WebGL renderers keep a glyph
146
- // texture atlas in GPU memory; when the canvas hides + redisplays at a
147
- // potentially different devicePixelRatio, the atlas isn't invalidated
148
- // and old glyphs blend with newly-rasterized ones — visible as scrolling
149
- // text ghosting / double-strikes. Watching the tab-panel's data-active
150
- // attribute and clearing the atlas + re-fitting + forcing a full
151
- // refresh wipes the cache cleanly.
152
- const panel = host.closest('.tab-panel');
153
- let panelMo = null;
154
- if (panel) {
155
- panelMo = new MutationObserver(() => {
156
- if (panel.hasAttribute('data-active')) {
157
- requestAnimationFrame(() => {
158
- try { term.clearTextureAtlas?.(); } catch {}
159
- try { fit.fit(); } catch {}
160
- try { term.refresh(0, term.rows - 1); } catch {}
161
- });
162
- }
163
- });
164
- panelMo.observe(panel, { attributes: true, attributeFilter: ['data-active'] });
165
- }
166
-
167
- // give focus to terminal so user can type immediately
168
- term.focus();
169
-
170
- // Explicit paste handler. xterm.js relies on the browser routing paste
171
- // events to its hidden .xterm-helper-textarea, which only works if that
172
- // textarea has focus at the moment of Ctrl+V. When the user clicks
173
- // elsewhere then hits Ctrl+V over the terminal, or pastes via the
174
- // right-click menu on the host div, the event lands on the host and
175
- // xterm never sees it. Catch it here and route through term.paste()
176
- // so xterm wraps the text in bracketed-paste markers when the app
177
- // (claude code) has DECSET 2004 enabled — that's what makes claude
178
- // show the "[Pasted text]" affordance instead of treating it as
179
- // typed input.
180
- const isOurs = () => {
181
- const ae = document.activeElement;
182
- return ae && host.contains(ae);
183
- };
184
- const doPaste = (text) => {
185
- if (!text) return;
186
- if (ws.readyState !== 1) return;
187
- // Normalize line endings to \r (CR / Enter). This mirrors VSCode's
188
- // terminal sendText path (terminalInstance.ts ~L1385):
189
- // text = text.replace(/\r?\n/g, '\r');
190
- // Bracketed-paste markers protect each \r from being interpreted
191
- // as a submit by the host app — claude / pwsh / vim all treat
192
- // bracketed contents as opaque payload regardless of what's inside.
193
- // Use \n instead and you trip apps that look for "real" line breaks.
194
- const normalized = text.replace(/\r?\n/g, '\r');
195
- // Wrap in bracketed-paste markers. Claude Code enables DECSET 2004
196
- // on startup, so the markers let it detect a paste and render
197
- // "[Pasted text]". If the host app doesn't have bracketed paste on,
198
- // it just sees two ignored escape sequences plus the text.
199
- const wrapped = `\x1b[200~${normalized}\x1b[201~`;
200
- ws.send(JSON.stringify({ type: 'input', data: wrapped }));
201
- };
202
- const onPaste = async (ev) => {
203
- if (!isOurs()) return;
204
- let text = '';
205
- if (ev.clipboardData) text = ev.clipboardData.getData('text');
206
- if (!text && navigator.clipboard) {
207
- try { text = await navigator.clipboard.readText(); } catch {}
208
- }
209
- if (!text) return;
210
- ev.preventDefault();
211
- ev.stopPropagation();
212
- doPaste(text);
213
- };
214
- document.addEventListener('paste', onPaste, true);
215
-
216
- // Ctrl/Cmd+V fallback for cases the paste event is suppressed (some
217
- // extensions, or when our IME workaround moved the helper textarea
218
- // off-screen and the browser refuses to fire paste on it).
219
- // IMPORTANT: preventDefault must happen synchronously, BEFORE the
220
- // await on navigator.clipboard.readText(). If we let the event tick
221
- // run first, xterm's keystroke handler converts Ctrl+V into the raw
222
- // ^V (0x16) control byte and ships it before our async paste even
223
- // resolves.
224
- const onKey = (ev) => {
225
- const meta = ev.ctrlKey || ev.metaKey;
226
- if (!meta || ev.key.toLowerCase() !== 'v') return;
227
- if (ev.shiftKey || ev.altKey) return;
228
- if (!isOurs()) return;
229
- if (!navigator.clipboard?.readText) return;
230
- ev.preventDefault();
231
- ev.stopPropagation();
232
- ev.stopImmediatePropagation();
233
- navigator.clipboard.readText().then((text) => {
234
- if (text) doPaste(text);
235
- }).catch(() => {});
236
- };
237
- document.addEventListener('keydown', onKey, true);
238
-
239
- // Shift+Enter / Ctrl+Enter → insert literal newline, don't submit.
240
- // Background: xterm.js encodes BOTH plain Enter and Shift+Enter and
241
- // Ctrl+Enter as \r (0x0D / CR). The kitty keyboard / win32 input
242
- // protocols (enabled in vtExtensions above) WOULD distinguish them,
243
- // but they're opt-in by the running app — claude code doesn't enable
244
- // either, so we never get the distinction "for free".
245
- //
246
- // Send the LF (0x0A) explicitly. Claude code (and most modern TUIs)
247
- // treat \n inside a prompt as a literal newline insert, \r as submit.
248
- // Alt+Enter already works (xterm sends \x1b\r → meta-enter) so we
249
- // leave that alone.
250
- const onShiftEnter = (ev) => {
251
- if (ev.key !== 'Enter') return;
252
- if (!(ev.shiftKey || ev.ctrlKey)) return;
253
- if (ev.metaKey || ev.altKey) return;
254
- if (!isOurs()) return;
255
- ev.preventDefault();
256
- ev.stopPropagation();
257
- ev.stopImmediatePropagation();
258
- if (ws.readyState === 1) {
259
- ws.send(JSON.stringify({ type: 'input', data: '\n' }));
260
- }
261
- };
262
- document.addEventListener('keydown', onShiftEnter, true);
263
-
264
- // IME fix: xterm positions .xterm-helper-textarea via `left: <col-px>`
265
- // following the cursor. When the cursor is near the right edge and the
266
- // user starts composing (e.g. Chinese pinyin), the textarea + native
267
- // composition popup grow with the composed string and overflow the
268
- // terminal host — which visually pushes the layout right. We can't cap
269
- // width / change wrapping (that breaks Chromium's IME event flow), but
270
- // we CAN re-anchor the textarea to the right edge while composing so
271
- // it grows leftward instead. Toggling a class on the host is enough;
272
- // the CSS in terminals.css does the rest.
273
- const onCompStart = () => {
274
- if (host) host.classList.add('is-composing');
275
- // The terminal cursor is rendered on canvas (THEME.cursor), so CSS
276
- // can't hide it. Theme swap alone doesn't reliably stop the blink
277
- // frame loop, so also issue the DECTCEM hide sequence which the
278
- // renderer honours immediately.
279
- try { term.options.theme = { ...THEME, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
280
- try { term.write('\x1b[?25l'); } catch {}
281
- };
282
- const onCompEnd = () => {
283
- if (host) host.classList.remove('is-composing');
284
- try { term.options.theme = THEME; } catch {}
285
- try { term.write('\x1b[?25h'); } catch {}
286
- };
287
- const helper = host?.querySelector('.xterm-helper-textarea');
288
- if (helper) {
289
- helper.addEventListener('compositionstart', onCompStart);
290
- helper.addEventListener('compositionend', onCompEnd);
291
- }
292
-
293
- return () => {
294
- document.removeEventListener('paste', onPaste, true);
295
- document.removeEventListener('keydown', onKey, true);
296
- document.removeEventListener('keydown', onShiftEnter, true);
297
- if (helper) {
298
- helper.removeEventListener('compositionstart', onCompStart);
299
- helper.removeEventListener('compositionend', onCompEnd);
300
- }
301
- ro.disconnect();
302
- if (panelMo) panelMo.disconnect();
303
- try { ws.close(); } catch {}
304
- try { term.dispose(); } catch {}
305
- termRef.current = null;
306
- wsRef.current = null;
307
- };
308
- }, [terminalId]);
309
-
310
- if (!terminalId) {
311
- return html`<div class="terminal-empty">Select a terminal on the left, or launch a new one.</div>`;
312
- }
313
- return html`<div ref=${hostRef} class="terminal-host"></div>`;
314
- }
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.
4
+
5
+ import { html } from '../html.js';
6
+ import { useEffect, useRef } from 'preact/hooks';
7
+ import { Terminal } from '@xterm/xterm';
8
+ import { FitAddon } from '@xterm/addon-fit';
9
+ import { WebLinksAddon } from '@xterm/addon-web-links';
10
+ import { ClipboardAddon } from '@xterm/addon-clipboard';
11
+ import { WebglAddon } from '@xterm/addon-webgl';
12
+ import { wsBase } from '../backend.js';
13
+
14
+ // Dark xterm theme. We give the terminal a near-black ink background to
15
+ // match what claude code's TUI assumes (it paints its own input box +
16
+ // prompt with hardcoded dark backgrounds — a light terminal makes those
17
+ // regions look like black blocks). Cursor uses the favorite-star gold so
18
+ // it pops against the ink without dragging brand orange back in.
19
+ const THEME = {
20
+ background: '#1a1815',
21
+ foreground: '#e8e3d5',
22
+ cursor: '#e3b341',
23
+ cursorAccent: '#1a1815',
24
+ selectionBackground: '#3a3530',
25
+ black: '#1a1815', brightBlack: '#534e44',
26
+ red: '#e07b6e', brightRed: '#f0a098',
27
+ green: '#7fb670', brightGreen: '#a0d28f',
28
+ yellow: '#e3b341', brightYellow: '#f0c860',
29
+ blue: '#7d9fc4', brightBlue: '#9bb8d8',
30
+ magenta: '#c08fd0', brightMagenta: '#d8aae2',
31
+ cyan: '#6fb0b0', brightCyan: '#90c8c8',
32
+ white: '#e8e3d5', brightWhite: '#faf9f5',
33
+ };
34
+
35
+ export function TerminalView({ terminalId }) {
36
+ const hostRef = useRef(null);
37
+ const termRef = useRef(null);
38
+ const wsRef = useRef(null);
39
+
40
+ useEffect(() => {
41
+ if (!terminalId || !hostRef.current) return;
42
+
43
+ const term = new Terminal({
44
+ fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
45
+ fontSize: 13,
46
+ lineHeight: 1.2,
47
+ cursorBlink: true,
48
+ cursorStyle: 'bar',
49
+ scrollback: 5000,
50
+ allowProposedApi: true,
51
+ theme: THEME,
52
+ // Modern keyboard protocols. Without these, xterm.js encodes
53
+ // Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
54
+ // unmodified versions (e.g. both Enter and Shift+Enter send \r),
55
+ // so TUIs like claude code can't tell them apart.
56
+ //
57
+ // - kittyKeyboard: opt-in protocol that apps enable per-session;
58
+ // xterm emits CSI u sequences that uniquely encode every modifier
59
+ // combo. Claude / vim / fish recognise it.
60
+ // - win32InputMode: ConPTY-specific protocol that surfaces raw
61
+ // Win32 KEY_EVENT_RECORD to the child process, again preserving
62
+ // modifier info. Required for full key fidelity on Windows.
63
+ // (Same set VSCode enables — see vscode/src/.../xtermTerminal.ts)
64
+ vtExtensions: {
65
+ kittyKeyboard: true,
66
+ win32InputMode: true,
67
+ },
68
+ });
69
+ const fit = new FitAddon();
70
+ term.loadAddon(fit);
71
+ term.loadAddon(new WebLinksAddon());
72
+ // OSC 52 clipboard integration. Lets TUI apps initiate clipboard reads/
73
+ // writes via escape sequences (e.g. `tmux set-buffer` or claude code
74
+ // saying "copied to clipboard"). Does NOT handle the browser-side
75
+ // Ctrl+V — that's still our document-level paste handler below.
76
+ term.loadAddon(new ClipboardAddon());
77
+ // WebGL renderer for performance. The default DOM renderer struggles
78
+ // when claude code produces dense color output (its diff panels,
79
+ // syntax-highlighted code). WebGL paints onto a canvas, much smoother
80
+ // at thousands-of-cells per frame. Falls back to DOM if WebGL is
81
+ // unavailable (e.g. older GPU, hardware accel disabled).
82
+ try {
83
+ const webgl = new WebglAddon();
84
+ webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
85
+ term.loadAddon(webgl);
86
+ } catch (e) {
87
+ console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
88
+ }
89
+ // Ctrl+C with a selection: by default xterm.js sends \x03 AND the
90
+ // browser's own copy event fires — so the user gets "selection
91
+ // copied to clipboard" AND the running CLI gets SIGINT. Mirror
92
+ // VSCode/Windows Terminal behaviour: when there's a selection,
93
+ // suppress \x03 and let the copy event do its thing. With no
94
+ // selection, Ctrl+C still sends \x03 normally.
95
+ term.attachCustomKeyEventHandler((ev) => {
96
+ if (ev.type === 'keydown'
97
+ && ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
98
+ && ev.key.toLowerCase() === 'c'
99
+ && term.hasSelection()) {
100
+ return false;
101
+ }
102
+ return true;
103
+ });
104
+
105
+ const host = hostRef.current;
106
+ term.open(host);
107
+ // Defer fit one tick so the container has measured layout
108
+ requestAnimationFrame(() => { try { fit.fit(); } catch {} });
109
+ termRef.current = term;
110
+
111
+ const ws = new WebSocket(`${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}`);
112
+ ws.binaryType = 'arraybuffer';
113
+ wsRef.current = ws;
114
+
115
+ ws.onopen = () => {
116
+ // tell server the initial size (cols/rows after fit)
117
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
118
+ };
119
+ ws.onmessage = (ev) => {
120
+ let frame;
121
+ try { frame = JSON.parse(ev.data); } catch { return; }
122
+ if (frame.type === 'output') {
123
+ term.write(frame.data);
124
+ } else if (frame.type === 'exit') {
125
+ term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
126
+ }
127
+ };
128
+ ws.onclose = () => {
129
+ term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
130
+ };
131
+
132
+ const onData = (data) => {
133
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
134
+ };
135
+ const onResize = ({ cols, rows }) => {
136
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
137
+ };
138
+ term.onData(onData);
139
+ term.onResize(onResize);
140
+
141
+ const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
142
+ ro.observe(hostRef.current);
143
+
144
+ // Tab-switch refresh. The terminal lives inside a .tab-panel which gets
145
+ // display:none when another tab is active. WebGL renderers keep a glyph
146
+ // texture atlas in GPU memory; when the canvas hides + redisplays at a
147
+ // potentially different devicePixelRatio, the atlas isn't invalidated
148
+ // and old glyphs blend with newly-rasterized ones — visible as scrolling
149
+ // text ghosting / double-strikes. Watching the tab-panel's data-active
150
+ // attribute and clearing the atlas + re-fitting + forcing a full
151
+ // refresh wipes the cache cleanly.
152
+ const panel = host.closest('.tab-panel');
153
+ let panelMo = null;
154
+ if (panel) {
155
+ panelMo = new MutationObserver(() => {
156
+ if (panel.hasAttribute('data-active')) {
157
+ requestAnimationFrame(() => {
158
+ try { term.clearTextureAtlas?.(); } catch {}
159
+ try { fit.fit(); } catch {}
160
+ try { term.refresh(0, term.rows - 1); } catch {}
161
+ });
162
+ }
163
+ });
164
+ panelMo.observe(panel, { attributes: true, attributeFilter: ['data-active'] });
165
+ }
166
+
167
+ // give focus to terminal so user can type immediately
168
+ term.focus();
169
+
170
+ // Explicit paste handler. xterm.js relies on the browser routing paste
171
+ // events to its hidden .xterm-helper-textarea, which only works if that
172
+ // textarea has focus at the moment of Ctrl+V. When the user clicks
173
+ // elsewhere then hits Ctrl+V over the terminal, or pastes via the
174
+ // right-click menu on the host div, the event lands on the host and
175
+ // xterm never sees it. Catch it here and route through term.paste()
176
+ // so xterm wraps the text in bracketed-paste markers when the app
177
+ // (claude code) has DECSET 2004 enabled — that's what makes claude
178
+ // show the "[Pasted text]" affordance instead of treating it as
179
+ // typed input.
180
+ const isOurs = () => {
181
+ const ae = document.activeElement;
182
+ return ae && host.contains(ae);
183
+ };
184
+ const doPaste = (text) => {
185
+ if (!text) return;
186
+ if (ws.readyState !== 1) return;
187
+ // Normalize line endings to \r (CR / Enter). This mirrors VSCode's
188
+ // terminal sendText path (terminalInstance.ts ~L1385):
189
+ // text = text.replace(/\r?\n/g, '\r');
190
+ // Bracketed-paste markers protect each \r from being interpreted
191
+ // as a submit by the host app — claude / pwsh / vim all treat
192
+ // bracketed contents as opaque payload regardless of what's inside.
193
+ // Use \n instead and you trip apps that look for "real" line breaks.
194
+ const normalized = text.replace(/\r?\n/g, '\r');
195
+ // Wrap in bracketed-paste markers. Claude Code enables DECSET 2004
196
+ // on startup, so the markers let it detect a paste and render
197
+ // "[Pasted text]". If the host app doesn't have bracketed paste on,
198
+ // it just sees two ignored escape sequences plus the text.
199
+ const wrapped = `\x1b[200~${normalized}\x1b[201~`;
200
+ ws.send(JSON.stringify({ type: 'input', data: wrapped }));
201
+ };
202
+ const onPaste = async (ev) => {
203
+ if (!isOurs()) return;
204
+ let text = '';
205
+ if (ev.clipboardData) text = ev.clipboardData.getData('text');
206
+ if (!text && navigator.clipboard) {
207
+ try { text = await navigator.clipboard.readText(); } catch {}
208
+ }
209
+ if (!text) return;
210
+ ev.preventDefault();
211
+ ev.stopPropagation();
212
+ doPaste(text);
213
+ };
214
+ document.addEventListener('paste', onPaste, true);
215
+
216
+ // Ctrl/Cmd+V fallback for cases the paste event is suppressed (some
217
+ // extensions, or when our IME workaround moved the helper textarea
218
+ // off-screen and the browser refuses to fire paste on it).
219
+ // IMPORTANT: preventDefault must happen synchronously, BEFORE the
220
+ // await on navigator.clipboard.readText(). If we let the event tick
221
+ // run first, xterm's keystroke handler converts Ctrl+V into the raw
222
+ // ^V (0x16) control byte and ships it before our async paste even
223
+ // resolves.
224
+ const onKey = (ev) => {
225
+ const meta = ev.ctrlKey || ev.metaKey;
226
+ if (!meta || ev.key.toLowerCase() !== 'v') return;
227
+ if (ev.shiftKey || ev.altKey) return;
228
+ if (!isOurs()) return;
229
+ if (!navigator.clipboard?.readText) return;
230
+ ev.preventDefault();
231
+ ev.stopPropagation();
232
+ ev.stopImmediatePropagation();
233
+ navigator.clipboard.readText().then((text) => {
234
+ if (text) doPaste(text);
235
+ }).catch(() => {});
236
+ };
237
+ document.addEventListener('keydown', onKey, true);
238
+
239
+ // Shift+Enter / Ctrl+Enter → insert literal newline, don't submit.
240
+ // Background: xterm.js encodes BOTH plain Enter and Shift+Enter and
241
+ // Ctrl+Enter as \r (0x0D / CR). The kitty keyboard / win32 input
242
+ // protocols (enabled in vtExtensions above) WOULD distinguish them,
243
+ // but they're opt-in by the running app — claude code doesn't enable
244
+ // either, so we never get the distinction "for free".
245
+ //
246
+ // Send the LF (0x0A) explicitly. Claude code (and most modern TUIs)
247
+ // treat \n inside a prompt as a literal newline insert, \r as submit.
248
+ // Alt+Enter already works (xterm sends \x1b\r → meta-enter) so we
249
+ // leave that alone.
250
+ const onShiftEnter = (ev) => {
251
+ if (ev.key !== 'Enter') return;
252
+ if (!(ev.shiftKey || ev.ctrlKey)) return;
253
+ if (ev.metaKey || ev.altKey) return;
254
+ if (!isOurs()) return;
255
+ ev.preventDefault();
256
+ ev.stopPropagation();
257
+ ev.stopImmediatePropagation();
258
+ if (ws.readyState === 1) {
259
+ ws.send(JSON.stringify({ type: 'input', data: '\n' }));
260
+ }
261
+ };
262
+ document.addEventListener('keydown', onShiftEnter, true);
263
+
264
+ // IME fix: xterm positions .xterm-helper-textarea via `left: <col-px>`
265
+ // following the cursor. When the cursor is near the right edge and the
266
+ // user starts composing (e.g. Chinese pinyin), the textarea + native
267
+ // composition popup grow with the composed string and overflow the
268
+ // terminal host — which visually pushes the layout right. We can't cap
269
+ // width / change wrapping (that breaks Chromium's IME event flow), but
270
+ // we CAN re-anchor the textarea to the right edge while composing so
271
+ // it grows leftward instead. Toggling a class on the host is enough;
272
+ // the CSS in terminals.css does the rest.
273
+ const onCompStart = () => {
274
+ if (host) host.classList.add('is-composing');
275
+ // The terminal cursor is rendered on canvas (THEME.cursor), so CSS
276
+ // can't hide it. Theme swap alone doesn't reliably stop the blink
277
+ // frame loop, so also issue the DECTCEM hide sequence which the
278
+ // renderer honours immediately.
279
+ try { term.options.theme = { ...THEME, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
280
+ try { term.write('\x1b[?25l'); } catch {}
281
+ };
282
+ const onCompEnd = () => {
283
+ if (host) host.classList.remove('is-composing');
284
+ try { term.options.theme = THEME; } catch {}
285
+ try { term.write('\x1b[?25h'); } catch {}
286
+ };
287
+ const helper = host?.querySelector('.xterm-helper-textarea');
288
+ if (helper) {
289
+ helper.addEventListener('compositionstart', onCompStart);
290
+ helper.addEventListener('compositionend', onCompEnd);
291
+ }
292
+
293
+ return () => {
294
+ document.removeEventListener('paste', onPaste, true);
295
+ document.removeEventListener('keydown', onKey, true);
296
+ document.removeEventListener('keydown', onShiftEnter, true);
297
+ if (helper) {
298
+ helper.removeEventListener('compositionstart', onCompStart);
299
+ helper.removeEventListener('compositionend', onCompEnd);
300
+ }
301
+ ro.disconnect();
302
+ if (panelMo) panelMo.disconnect();
303
+ try { ws.close(); } catch {}
304
+ try { term.dispose(); } catch {}
305
+ termRef.current = null;
306
+ wsRef.current = null;
307
+ };
308
+ }, [terminalId]);
309
+
310
+ if (!terminalId) {
311
+ return html`<div class="terminal-empty">Select a terminal on the left, or launch a new one.</div>`;
312
+ }
313
+ return html`<div ref=${hostRef} class="terminal-host"></div>`;
314
+ }