@deckbuilderkit/terminal 0.0.0-stage → 0.1.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.
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@deckbuilderkit/terminal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/adrianogpena/deckbuilder.git",
8
+ "directory": "packages/terminal"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.ts",
20
+ "./protocol": "./src/protocol.ts",
21
+ "./session": "./src/client.ts",
22
+ "./options": "./src/terminalOptions.ts",
23
+ "./server": "./src/server/index.ts"
24
+ },
25
+ "dependencies": {
26
+ "@deckbuilderkit/platform": "0.1.1",
27
+ "@deckbuilderkit/server-core": "0.1.1",
28
+ "@deckbuilderkit/ui": "0.1.1",
29
+ "@xterm/addon-clipboard": "^0.2.0",
30
+ "@xterm/addon-fit": "^0.11.0",
31
+ "@xterm/addon-search": "^0.16.0",
32
+ "@xterm/addon-serialize": "^0.14.0",
33
+ "@xterm/addon-unicode11": "^0.9.0",
34
+ "@xterm/addon-web-links": "^0.12.0",
35
+ "@xterm/addon-webgl": "0.19.0",
36
+ "@xterm/headless": "^6.0.0",
37
+ "@xterm/xterm": "^6.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "react": "^18.3.1",
41
+ "react-dom": "^18.3.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "^18.3.12",
45
+ "react": "^18.3.1",
46
+ "react-dom": "^18.3.1",
47
+ "@types/react-dom": "^18.3.1"
48
+ }
49
+ }
@@ -0,0 +1,590 @@
1
+ import { copyText, Icon, effectiveKeys, errorMessage, errorOf, paletteCommand, t, useDialogs, useKeybindings, type CommandDef } from '@deckbuilderkit/ui';
2
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent } from 'react';
3
+ import { attachSession, closeSession, isSessionGone, openSession, socketUrl, type TerminalCwd, type TerminalLaunch } from './client.ts';
4
+ import { withAppearance, type ProfileAppearance } from './appearance.ts';
5
+ import { PANE_COMMANDS, terminalKeyAction, type PaneCommand } from './keys.ts';
6
+ import { pasteNeedsWarning, pasteSummary, sanitizePaste } from './paste.ts';
7
+ import { decodeServerMessage, encodeControl } from './protocol.ts';
8
+ import { useTerminalOptions } from './terminalOptions.ts';
9
+ import { createXtermView, type TerminalOptions, type XtermView } from './xterm-view.ts';
10
+ import './terminal.css';
11
+
12
+ /** Server close codes (see `terminalRoutes`). */
13
+ const BACKPRESSURE_CLOSE = 4001;
14
+ const NO_SESSION_CLOSE = 1008;
15
+ const NORMAL_CLOSE = 1000;
16
+ const MAX_BACKOFF = 30_000;
17
+ /** The command that opens the find bar (registered by the app); the key is the fallback without a keymap. */
18
+ const FIND_COMMAND = 'terminal.find';
19
+ const DEFAULT_FIND_KEY = 'Ctrl+Shift+F';
20
+ /** Scroll to the previous / next prompt (shell integration); same fallback rule as find. */
21
+ const PREV_COMMAND = 'terminal.prevCommand';
22
+ const NEXT_COMMAND = 'terminal.nextCommand';
23
+ const DEFAULT_PREV_KEY = 'Ctrl+ArrowUp';
24
+ const DEFAULT_NEXT_KEY = 'Ctrl+ArrowDown';
25
+ const MIN_FONT = 6;
26
+ const MAX_FONT = 72;
27
+ const DEFAULT_FONT = 13;
28
+
29
+ /** What the app can make a pane do (tab menus): the pane commands. */
30
+ export interface TerminalPaneHandle {
31
+ run(command: PaneCommand): void;
32
+ }
33
+
34
+ let audio: AudioContext | null = null;
35
+ let lastBeep = 0;
36
+ /** A short beep, at most one per 200 ms. */
37
+ function beep() {
38
+ const now = Date.now();
39
+ if (now - lastBeep < 200) return;
40
+ lastBeep = now;
41
+ try {
42
+ audio ??= new AudioContext();
43
+ const osc = audio.createOscillator();
44
+ const gain = audio.createGain();
45
+ osc.frequency.value = 880;
46
+ gain.gain.setValueAtTime(0.08, audio.currentTime);
47
+ gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.12);
48
+ osc.connect(gain).connect(audio.destination);
49
+ osc.start();
50
+ osc.stop(audio.currentTime + 0.12);
51
+ } catch {
52
+ // no audio here
53
+ }
54
+ }
55
+
56
+ export interface TerminalPaneProps {
57
+ /** The session to attach to; none opens a new one and reports it through `onSession`. */
58
+ session?: string;
59
+ /** Where a new session starts; the user's home when absent. */
60
+ cwd?: TerminalCwd;
61
+ /** A launcher the app registered on the server, instead of the configured shell. */
62
+ launch?: TerminalLaunch;
63
+ /** Profile id for a new session; none: the default one. */
64
+ profile?: string;
65
+ /** Absolute folder a new session starts in (wins over `cwd`); the server falls back when it is gone. */
66
+ dir?: string;
67
+ /** Opens a new shell when the session exits, instead of the exit overlay. */
68
+ restartOnExit?: boolean;
69
+ /** Read-only, at the session's own size, scaled to the box. */
70
+ preview?: boolean;
71
+ /** App commands whose keys still work while the terminal has focus; default: the command palette. */
72
+ appKeys?: CommandDef[];
73
+ fontSize?: number;
74
+ /** Look and behavior preferences; without it the app-wide ones (TerminalOptionsContext), else the built-in defaults. */
75
+ options?: TerminalOptions;
76
+ onSession?: (id: string) => void;
77
+ onExit?: (code: number | null) => void;
78
+ onTitle?: (title: string) => void;
79
+ /** The shell's folder changed (OSC 7). */
80
+ onCwd?: (cwd: string) => void;
81
+ onBell?: () => void;
82
+ /** Shell integration: a command started (true) or finished (false). */
83
+ onRunning?: (running: boolean) => void;
84
+ /** The profile's look, over `options`. */
85
+ appearance?: ProfileAppearance;
86
+ /** Shell id of the session (`SessionInfo.shell`): how a sanitized paste continues lines. */
87
+ shell?: string;
88
+ /** False dims the pane by `options.dimUnfocused` (a split pane without the focus); true focuses it. */
89
+ focused?: boolean;
90
+ /** Gets the pane's handle once mounted, null when it goes. */
91
+ handle?: (h: TerminalPaneHandle | null) => void;
92
+ }
93
+
94
+ type Status =
95
+ | { kind: 'connecting' }
96
+ | { kind: 'live' }
97
+ | { kind: 'reconnecting' }
98
+ | { kind: 'exited'; code: number | null }
99
+ | { kind: 'ended' }
100
+ | { kind: 'detached' }
101
+ | { kind: 'error'; message: string };
102
+
103
+ interface Controller {
104
+ /** The session the pane is connected (or connecting) to. */
105
+ session(): string | undefined;
106
+ attach(id: string): void;
107
+ restart(): void;
108
+ reattach(): void;
109
+ }
110
+
111
+ /**
112
+ * A terminal session in a box. Unmounting only closes the socket: the shell keeps running until
113
+ * `closeSession`, its own exit, or app quit. While focus is inside, keys go to the program except
114
+ * the `appKeys` commands (the container is `data-keys="own"`).
115
+ */
116
+ export function TerminalPane(props: TerminalPaneProps) {
117
+ const { preview = false } = props;
118
+ const hostRef = useRef<HTMLDivElement>(null);
119
+ const boxRef = useRef<HTMLDivElement>(null);
120
+ const latest = useRef(props);
121
+ latest.current = props;
122
+ const [status, setStatus] = useState<Status>({ kind: 'connecting' });
123
+ const controller = useRef<Controller | null>(null);
124
+ const viewRef = useRef<XtermView | null>(null);
125
+ const contextOptions = useTerminalOptions();
126
+ const baseOptions = withAppearance(props.options ?? contextOptions, props.appearance);
127
+ const baseKey = baseOptions === undefined ? undefined : JSON.stringify(baseOptions);
128
+ const [zoom, setZoom] = useState(0);
129
+ const options = useMemo((): TerminalOptions | undefined => {
130
+ if (!zoom) return baseOptions;
131
+ const size = (baseOptions?.fontSize ?? props.fontSize ?? DEFAULT_FONT) + zoom;
132
+ return { ...baseOptions, fontSize: Math.max(MIN_FONT, Math.min(MAX_FONT, size)) };
133
+ }, [baseKey, zoom, props.fontSize]);
134
+ const optionsRef = useRef(options);
135
+ optionsRef.current = options;
136
+
137
+ const [finding, setFinding] = useState(false);
138
+ const [findQuery, setFindQuery] = useState('');
139
+ const [matchCase, setMatchCase] = useState(false);
140
+ const [regex, setRegex] = useState(false);
141
+ const [found, setFound] = useState({ index: -1, count: 0 });
142
+ const findInput = useRef<HTMLInputElement>(null);
143
+
144
+ const kb = useKeybindings();
145
+ const appKeys = useMemo(() => props.appKeys ?? [paletteCommand()], [props.appKeys]);
146
+ const appChords = useRef(new Set<string>());
147
+ appChords.current = useMemo(() => {
148
+ const keys = kb ? effectiveKeys(kb.defs, kb.keymap) : new Map<string, string[]>();
149
+ return new Set(appKeys.flatMap((d) => keys.get(d.id) ?? d.keys).map((c) => c.split(' ')[0]));
150
+ }, [kb, appKeys]);
151
+
152
+ const findChords = useRef(new Set<string>());
153
+ findChords.current = useMemo(() => {
154
+ const keys = kb ? effectiveKeys(kb.defs, kb.keymap).get(FIND_COMMAND) : undefined;
155
+ return new Set((keys ?? [DEFAULT_FIND_KEY]).map((c) => c.split(' ')[0]));
156
+ }, [kb]);
157
+
158
+ const paneChords = useRef(new Map<string, PaneCommand>());
159
+ paneChords.current = useMemo(() => {
160
+ const keys = kb ? effectiveKeys(kb.defs, kb.keymap) : undefined;
161
+ const out = new Map<string, PaneCommand>();
162
+ for (const [id, fallback] of Object.entries(PANE_COMMANDS) as [PaneCommand, readonly string[]][]) {
163
+ for (const c of keys?.get(id) ?? fallback) out.set(c.split(' ')[0], id);
164
+ }
165
+ return out;
166
+ }, [kb]);
167
+
168
+ const dialogs = useDialogs();
169
+ const dialogsRef = useRef(dialogs);
170
+ dialogsRef.current = dialogs;
171
+
172
+ const navChords = useRef({ prev: new Set<string>(), next: new Set<string>() });
173
+ navChords.current = useMemo(() => {
174
+ const keys = kb ? effectiveKeys(kb.defs, kb.keymap) : undefined;
175
+ const set = (id: string, fallback: string) => new Set((keys?.get(id) ?? [fallback]).map((c) => c.split(' ')[0]));
176
+ return { prev: set(PREV_COMMAND, DEFAULT_PREV_KEY), next: set(NEXT_COMMAND, DEFAULT_NEXT_KEY) };
177
+ }, [kb]);
178
+
179
+ useEffect(() => {
180
+ const host = hostRef.current!;
181
+ let disposed = false;
182
+ let ws: WebSocket | null = null;
183
+ let sessionId = latest.current.session;
184
+ let decoder = new TextDecoder();
185
+ let retries = 0;
186
+ let timer: ReturnType<typeof setTimeout> | undefined;
187
+ let ended = false;
188
+ let sessionChecked = false;
189
+ const encoder = new TextEncoder();
190
+
191
+ const view: XtermView = createXtermView(host, {
192
+ readOnly: preview,
193
+ fontSize: latest.current.fontSize,
194
+ options: optionsRef.current,
195
+ onCwd: (cwd) => latest.current.onCwd?.(cwd),
196
+ onBell: () => {
197
+ const style = optionsRef.current?.bellStyle ?? 'tab';
198
+ if (style === 'sound' || style === 'both') beep();
199
+ latest.current.onBell?.();
200
+ },
201
+ onCommand: (running) => latest.current.onRunning?.(running),
202
+ onPaste: (text) => {
203
+ const o = optionsRef.current;
204
+ if (preview || !pasteNeedsWarning(text, o?.pasteWarning ?? 'multiLine', o?.pasteLargeSize ?? 5120)) return false;
205
+ void askPaste(text);
206
+ return true;
207
+ },
208
+ onKey: (e) => {
209
+ if (preview || e.type !== 'keydown') return true;
210
+ const action = terminalKeyAction(e, view.hasSelection(), appChords.current, findChords.current, navChords.current, paneChords.current);
211
+ if (action.startsWith('terminal.')) {
212
+ e.preventDefault();
213
+ runPane(action as PaneCommand);
214
+ return false;
215
+ }
216
+ switch (action) {
217
+ case 'prevCommand':
218
+ e.preventDefault();
219
+ view.scrollToPrompt(-1);
220
+ return false;
221
+ case 'nextCommand':
222
+ e.preventDefault();
223
+ view.scrollToPrompt(1);
224
+ return false;
225
+ case 'find':
226
+ e.preventDefault();
227
+ setFinding(true);
228
+ requestAnimationFrame(() => findInput.current?.select());
229
+ return false;
230
+ case 'app':
231
+ case 'paste': // the browser's paste event reaches xterm
232
+ return false;
233
+ case 'copy':
234
+ e.preventDefault();
235
+ void copyText(view.getSelection());
236
+ return false;
237
+ default:
238
+ return true;
239
+ }
240
+ },
241
+ });
242
+
243
+ async function askPaste(text: string) {
244
+ const { lines, chars, preview: sample } = pasteSummary(text);
245
+ const choice = await dialogsRef.current.choose({
246
+ title: lines > 1 ? t('terminal.paste.title', { lines }) : t('terminal.paste.titleLarge', { chars }),
247
+ body: (
248
+ <>
249
+ <p>{t('terminal.paste.body', { lines, chars })}</p>
250
+ <pre className="terminal-paste-preview">{sample}</pre>
251
+ <p className="muted">{t('terminal.paste.sanitizeHint')}</p>
252
+ </>
253
+ ),
254
+ choices: [
255
+ { id: 'sanitize', label: t('terminal.paste.sanitize') },
256
+ { id: 'paste', label: t('terminal.paste.paste') },
257
+ ],
258
+ });
259
+ if (disposed || !choice) return;
260
+ view.paste(choice === 'paste' ? text : sanitizePaste(text, { bracketed: view.bracketedPaste(), shell: latest.current.shell }));
261
+ view.focus();
262
+ }
263
+
264
+ function runPane(command: PaneCommand) {
265
+ switch (command) {
266
+ case 'terminal.zoomIn':
267
+ return setZoom((z) => z + 1);
268
+ case 'terminal.zoomOut':
269
+ return setZoom((z) => z - 1);
270
+ case 'terminal.zoomReset':
271
+ return setZoom(0);
272
+ case 'terminal.clear':
273
+ view.clear();
274
+ return send(encodeControl({ type: 'clear' }));
275
+ case 'terminal.scrollLineUp':
276
+ return view.scroll('lineUp');
277
+ case 'terminal.scrollLineDown':
278
+ return view.scroll('lineDown');
279
+ case 'terminal.scrollPageUp':
280
+ return view.scroll('pageUp');
281
+ case 'terminal.scrollPageDown':
282
+ return view.scroll('pageDown');
283
+ case 'terminal.scrollTop':
284
+ return view.scroll('top');
285
+ case 'terminal.scrollBottom':
286
+ return view.scroll('bottom');
287
+ }
288
+ }
289
+ if (!preview) latest.current.handle?.({ run: runPane });
290
+
291
+ viewRef.current = view;
292
+ view.onFindResults((index, count) => setFound({ index, count }));
293
+ const send = (data: string | Uint8Array) => {
294
+ if (ws?.readyState === WebSocket.OPEN) ws.send(data);
295
+ };
296
+ view.onInput((data) => send(encoder.encode(data)));
297
+ view.onResize((cols, rows) => {
298
+ if (!preview) send(encodeControl({ type: 'resize', cols, rows }));
299
+ });
300
+
301
+ // A preview renders at the session's size, scaled down to the box.
302
+ const scale = () => {
303
+ const box = boxRef.current;
304
+ if (!preview || !box) return;
305
+ const el = view.element();
306
+ const s = Math.min(1, box.clientWidth / (el.offsetWidth || 1), box.clientHeight / (el.offsetHeight || 1));
307
+ host.style.transform = `scale(${s})`;
308
+ };
309
+ const boxObserver = new ResizeObserver(scale);
310
+ if (preview && boxRef.current) boxObserver.observe(boxRef.current);
311
+
312
+ const stop = (next: Status) => {
313
+ ended = true;
314
+ setStatus(next);
315
+ };
316
+
317
+ const retryLater = () => {
318
+ setStatus({ kind: 'reconnecting' });
319
+ const delay = Math.min(MAX_BACKOFF, 500 * 2 ** retries++);
320
+ timer = setTimeout(() => void connect(false), delay);
321
+ };
322
+
323
+ function openSocket(ticket: string) {
324
+ const socket = new WebSocket(socketUrl(ticket));
325
+ const previous = ws;
326
+ ws = socket;
327
+ previous?.close();
328
+ socket.binaryType = 'arraybuffer';
329
+ socket.onmessage = (ev) => {
330
+ if (ws !== socket) return;
331
+ const m = decodeServerMessage(ev.data as string | ArrayBuffer);
332
+ if (!m) return;
333
+ if (m.kind === 'output') {
334
+ view.write(decoder.decode(m.data, { stream: true }));
335
+ return;
336
+ }
337
+ const msg = m.msg;
338
+ if (msg.type === 'hello') {
339
+ // The replay that follows redraws the screen from scratch.
340
+ retries = 0;
341
+ view.reset();
342
+ decoder = new TextDecoder();
343
+ if (preview) {
344
+ view.resize(msg.cols, msg.rows);
345
+ requestAnimationFrame(scale);
346
+ } else {
347
+ view.fit();
348
+ const { cols, rows } = view.size();
349
+ if (cols !== msg.cols || rows !== msg.rows) send(encodeControl({ type: 'resize', cols, rows }));
350
+ view.focus();
351
+ }
352
+ setStatus({ kind: 'live' });
353
+ } else if (msg.type === 'exit') {
354
+ latest.current.onRunning?.(false);
355
+ if (latest.current.restartOnExit && !preview) {
356
+ ended = true;
357
+ latest.current.onExit?.(msg.code);
358
+ void connect(true);
359
+ return;
360
+ }
361
+ stop({ kind: 'exited', code: msg.code });
362
+ latest.current.onExit?.(msg.code);
363
+ } else {
364
+ latest.current.onTitle?.(msg.title);
365
+ }
366
+ };
367
+ socket.onclose = (ev) => {
368
+ if (ws !== socket) return;
369
+ ws = null;
370
+ if (disposed || ended) return;
371
+ if (ev.code === BACKPRESSURE_CLOSE) void connect(false);
372
+ else if (ev.code === NO_SESSION_CLOSE) stop({ kind: 'ended' });
373
+ // A plain close without an exit: another interactive view took the session.
374
+ else if (ev.code === NORMAL_CLOSE) stop({ kind: 'detached' });
375
+ else retryLater();
376
+ };
377
+ }
378
+
379
+ async function connect(fresh: boolean) {
380
+ clearTimeout(timer);
381
+ ended = false;
382
+ // A session id from a saved layout may belong to an earlier app run: then open a new shell instead.
383
+ const restored = !fresh && !!sessionId && !sessionChecked;
384
+ sessionChecked = true;
385
+ try {
386
+ let ticketed;
387
+ if (fresh || !sessionId) {
388
+ view.fit();
389
+ const { cols, rows } = view.size();
390
+ const { cwd, launch, profile, dir } = latest.current;
391
+ ticketed = await openSession({ cols, rows, cwd, launch, profile, dir });
392
+ if (disposed) {
393
+ // Unmounted while opening (React strict mode remounts): nobody holds this session.
394
+ void closeSession(ticketed.session).catch(() => {});
395
+ return;
396
+ }
397
+ sessionId = ticketed.session;
398
+ latest.current.onSession?.(ticketed.session);
399
+ } else {
400
+ ticketed = await attachSession(sessionId, preview ? 'preview' : 'interactive');
401
+ if (disposed) return;
402
+ }
403
+ openSocket(ticketed.ticket);
404
+ } catch (err) {
405
+ if (disposed) return;
406
+ if (isSessionGone(err)) {
407
+ if (restored && !preview) void connect(true);
408
+ else stop({ kind: 'ended' });
409
+ }
410
+ else if (errorOf(err).kind === 'offline') retryLater();
411
+ else stop({ kind: 'error', message: errorMessage(errorOf(err)) });
412
+ }
413
+ }
414
+
415
+ controller.current = {
416
+ session: () => sessionId,
417
+ attach: (id) => {
418
+ sessionId = id;
419
+ ws?.close();
420
+ ws = null;
421
+ setStatus({ kind: 'connecting' });
422
+ void connect(false);
423
+ },
424
+ restart: () => {
425
+ setStatus({ kind: 'connecting' });
426
+ void connect(true);
427
+ },
428
+ reattach: () => {
429
+ setStatus({ kind: 'connecting' });
430
+ void connect(false);
431
+ },
432
+ };
433
+ void connect(false);
434
+
435
+ return () => {
436
+ disposed = true;
437
+ clearTimeout(timer);
438
+ boxObserver.disconnect();
439
+ ws?.close();
440
+ ws = null;
441
+ controller.current = null;
442
+ viewRef.current = null;
443
+ if (!preview) latest.current.handle?.(null);
444
+ view.dispose();
445
+ };
446
+ }, [preview]);
447
+
448
+ useEffect(() => {
449
+ if (options) viewRef.current?.setOptions(options);
450
+ }, [options]);
451
+
452
+ // Focus moved to this pane (another split pane had it): the keys come here.
453
+ useEffect(() => {
454
+ if (props.focused && !preview && !boxRef.current?.contains(document.activeElement)) viewRef.current?.focus();
455
+ }, [props.focused]);
456
+
457
+ const dim = Math.max(0, Math.min(80, options?.dimUnfocused ?? 0));
458
+ const dimmed = !preview && props.focused === false && dim > 0;
459
+
460
+ const step = (dir: 1 | -1) => {
461
+ const view = viewRef.current;
462
+ if (!view || !findQuery) return;
463
+ const o = { caseSensitive: matchCase, regex };
464
+ if (dir === 1) view.findNext(findQuery, o);
465
+ else view.findPrevious(findQuery, o);
466
+ };
467
+ const closeFind = () => {
468
+ setFinding(false);
469
+ setFound({ index: -1, count: 0 });
470
+ viewRef.current?.clearFind();
471
+ viewRef.current?.focus();
472
+ };
473
+ // Typing or toggling an option searches again; an empty query clears the highlights.
474
+ useEffect(() => {
475
+ const view = viewRef.current;
476
+ if (!finding || !view) return;
477
+ if (findQuery) view.findNext(findQuery, { caseSensitive: matchCase, regex });
478
+ else {
479
+ view.clearFind();
480
+ setFound({ index: -1, count: 0 });
481
+ }
482
+ }, [finding, findQuery, matchCase, regex]);
483
+ const onFindKey = (e: ReactKeyboardEvent) => {
484
+ e.stopPropagation();
485
+ if (e.key === 'Escape') {
486
+ e.preventDefault();
487
+ closeFind();
488
+ } else if (e.key === 'Enter') {
489
+ e.preventDefault();
490
+ step(e.shiftKey ? -1 : 1);
491
+ }
492
+ };
493
+
494
+ // A new `session` prop (not the one this pane just opened and reported) switches sessions.
495
+ useEffect(() => {
496
+ const c = controller.current;
497
+ if (c && props.session && props.session !== c.session()) c.attach(props.session);
498
+ }, [props.session]);
499
+
500
+ const overlay = overlayOf(status, preview, controller);
501
+ return (
502
+ <div
503
+ ref={boxRef}
504
+ className={`terminal-pane${preview ? ' preview' : ''}${dimmed ? ' dimmed' : ''}`}
505
+ style={dimmed ? ({ '--terminal-dim': dim / 100 } as CSSProperties) : undefined}
506
+ {...(preview ? {} : { 'data-keys': 'own', 'data-keys-allow': appKeys.map((d) => d.id).join(' ') })}
507
+ >
508
+ <div ref={hostRef} className="terminal-host" />
509
+ {finding && !preview && (
510
+ <div className="terminal-find">
511
+ <input
512
+ ref={findInput}
513
+ autoFocus
514
+ type="search"
515
+ value={findQuery}
516
+ placeholder={t('terminal.find.placeholder')}
517
+ aria-label={t('terminal.find.placeholder')}
518
+ onChange={(e) => setFindQuery(e.target.value)}
519
+ onKeyDown={onFindKey}
520
+ />
521
+ <span className="terminal-find-count muted">
522
+ {findQuery ? (found.count ? `${found.index >= 0 ? found.index + 1 : 0}/${found.count}` : t('terminal.find.none')) : ''}
523
+ </span>
524
+ <button type="button" className={`chip${matchCase ? ' chip-on' : ''}`} onClick={() => setMatchCase(!matchCase)} aria-pressed={matchCase} title={t('terminal.find.matchCase')}>
525
+ Aa
526
+ </button>
527
+ <button type="button" className={`chip${regex ? ' chip-on' : ''}`} onClick={() => setRegex(!regex)} aria-pressed={regex} title={t('terminal.find.regex')}>
528
+ .*
529
+ </button>
530
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={() => step(-1)} title={t('terminal.find.prev')} aria-label={t('terminal.find.prev')}>
531
+ <Icon name="chevron-up" />
532
+ </button>
533
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={() => step(1)} title={t('terminal.find.next')} aria-label={t('terminal.find.next')}>
534
+ <Icon name="chevron-down" />
535
+ </button>
536
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={closeFind} title={t('terminal.find.close')} aria-label={t('terminal.find.close')}>
537
+ <Icon name="close" />
538
+ </button>
539
+ </div>
540
+ )}
541
+ {overlay && <div className="terminal-overlay">{overlay}</div>}
542
+ {dialogs.element}
543
+ </div>
544
+ );
545
+ }
546
+
547
+ function overlayOf(status: Status, preview: boolean, controller: { current: Controller | null }) {
548
+ const action = (label: string, run: (c: Controller) => void) =>
549
+ preview ? null : (
550
+ <button type="button" className="btn btn-sm" onClick={() => controller.current && run(controller.current)}>
551
+ {label}
552
+ </button>
553
+ );
554
+ switch (status.kind) {
555
+ case 'live':
556
+ return null;
557
+ case 'connecting':
558
+ return <span>{t('terminal.connecting')}</span>;
559
+ case 'reconnecting':
560
+ return <span>{t('terminal.reconnecting')}</span>;
561
+ case 'exited':
562
+ return (
563
+ <>
564
+ <span>{status.code === null ? t('terminal.exitedNoCode') : t('terminal.exited', { code: status.code })}</span>
565
+ {action(t('terminal.restart'), (c) => c.restart())}
566
+ </>
567
+ );
568
+ case 'ended':
569
+ return (
570
+ <>
571
+ <span>{t('terminal.ended')}</span>
572
+ {action(t('terminal.restart'), (c) => c.restart())}
573
+ </>
574
+ );
575
+ case 'detached':
576
+ return (
577
+ <>
578
+ <span>{t('terminal.detached')}</span>
579
+ {action(t('terminal.reattach'), (c) => c.reattach())}
580
+ </>
581
+ );
582
+ case 'error':
583
+ return (
584
+ <>
585
+ <span className="terminal-error">{t('terminal.error', { message: status.message })}</span>
586
+ {action(t('common.retry'), (c) => c.reattach())}
587
+ </>
588
+ );
589
+ }
590
+ }
@@ -0,0 +1,10 @@
1
+ import type { CommandDef } from '@deckbuilderkit/ui';
2
+
3
+ /**
4
+ * Of `all` (every command of the app), those whose keys reach the app while a terminal in view `scope`
5
+ * has focus; the rest goes to the shell. `extra` adds command ids of the view.
6
+ */
7
+ export function terminalAppKeys(scope: string, all: readonly CommandDef[], extra: readonly string[] = []): CommandDef[] {
8
+ const ids = new Set(['palette.toggle', 'app.keybindings', `${scope}.tab.saveAll`, `${scope}.pane.maximize`, ...extra]);
9
+ return all.filter((d) => ids.has(d.id));
10
+ }