@deckbuilderkit/terminal 0.0.0-stage → 0.1.0
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 +47 -4
- package/src/TerminalPane.tsx +590 -0
- package/src/app-keys.ts +10 -0
- package/src/appearance.ts +56 -0
- package/src/client.ts +63 -0
- package/src/index.ts +13 -0
- package/src/keys.ts +48 -0
- package/src/paste.ts +64 -0
- package/src/protocol.ts +146 -0
- package/src/schemes.ts +145 -0
- package/src/server/index.ts +8 -0
- package/src/server/integration/bash.sh +62 -0
- package/src/server/integration/fish.fish +21 -0
- package/src/server/integration/pwsh.ps1 +40 -0
- package/src/server/integration/scripts.d.ts +16 -0
- package/src/server/integration/zsh-forward.zsh +7 -0
- package/src/server/integration/zshrc.zsh +22 -0
- package/src/server/integration.ts +107 -0
- package/src/server/launchers.ts +70 -0
- package/src/server/profiles.ts +40 -0
- package/src/server/pty.ts +75 -0
- package/src/server/routes.ts +232 -0
- package/src/server/sessions.ts +318 -0
- package/src/server/shells.ts +94 -0
- package/src/shell-integration.ts +46 -0
- package/src/terminal.css +76 -0
- package/src/terminalOptions.ts +13 -0
- package/src/theme.ts +44 -0
- package/src/themes.ts +49 -0
- package/src/view.ts +12 -0
- package/src/xterm-view.ts +424 -0
- package/README.md +0 -3
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { invalid, notFound } from '@deckbuilderkit/server-core';
|
|
2
|
+
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
3
|
+
import { Terminal } from '@xterm/headless';
|
|
4
|
+
import type { SessionInfo } from '../protocol.ts';
|
|
5
|
+
import { spawnPty, type Pty } from './pty.ts';
|
|
6
|
+
|
|
7
|
+
export type { SessionInfo };
|
|
8
|
+
|
|
9
|
+
export type ViewKind = 'interactive' | 'preview';
|
|
10
|
+
|
|
11
|
+
export interface Sink {
|
|
12
|
+
send(frame: string | Uint8Array): void;
|
|
13
|
+
close(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CreateOptions {
|
|
17
|
+
cmd: string[];
|
|
18
|
+
cwd: string;
|
|
19
|
+
cols: number;
|
|
20
|
+
rows: number;
|
|
21
|
+
env?: Record<string, string>;
|
|
22
|
+
label: string;
|
|
23
|
+
launcher?: string;
|
|
24
|
+
meta?: Record<string, unknown>;
|
|
25
|
+
profile?: string;
|
|
26
|
+
shell?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_CWD = 4096;
|
|
30
|
+
|
|
31
|
+
/** The local path of an OSC 7 `file://host/path` payload (Windows drive paths get backslashes), or null. */
|
|
32
|
+
export function cwdFromOsc7(data: string): string | null {
|
|
33
|
+
if (!data.startsWith('file://') || data.length > MAX_CWD) return null;
|
|
34
|
+
const rest = data.slice('file://'.length);
|
|
35
|
+
const slash = rest.search(/[/\\]/);
|
|
36
|
+
if (slash < 0) return null;
|
|
37
|
+
let path = rest.slice(slash);
|
|
38
|
+
try {
|
|
39
|
+
path = decodeURIComponent(path);
|
|
40
|
+
} catch {}
|
|
41
|
+
if (/^[/\\][A-Za-z]:/.test(path)) path = path.slice(1).replace(/\//g, '\\');
|
|
42
|
+
if (/^[A-Za-z]:$/.test(path)) path += '\\';
|
|
43
|
+
return path && !path.includes('\0') ? path : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Session {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly cwd: string;
|
|
49
|
+
readonly createdAt: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TerminalSessions {
|
|
53
|
+
create(opts: CreateOptions): Session;
|
|
54
|
+
get(id: string): Session | undefined;
|
|
55
|
+
list(): SessionInfo[];
|
|
56
|
+
attach(id: string, sink: Sink, view: ViewKind): void;
|
|
57
|
+
detach(id: string, sink: Sink): void;
|
|
58
|
+
write(id: string, sink: Sink, data: string | Uint8Array): void;
|
|
59
|
+
resize(id: string, sink: Sink, cols: number, rows: number): void;
|
|
60
|
+
/** Drops the replay buffer's scrollback (the interactive view cleared its screen). */
|
|
61
|
+
clear(id: string, sink: Sink): void;
|
|
62
|
+
close(id: string, sink?: Sink): void;
|
|
63
|
+
closeAll(): void;
|
|
64
|
+
setLabel(id: string, label: string): void;
|
|
65
|
+
setMeta(id: string, meta: Record<string, unknown> | undefined): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TerminalSessionsOptions {
|
|
69
|
+
spawn?: typeof spawnPty;
|
|
70
|
+
max?: number;
|
|
71
|
+
now?: () => number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SCROLLBACK = 5000;
|
|
75
|
+
const MAX_LABEL = 80;
|
|
76
|
+
const MAX_META_BYTES = 4096;
|
|
77
|
+
|
|
78
|
+
interface Viewer {
|
|
79
|
+
sink: Sink;
|
|
80
|
+
view: ViewKind;
|
|
81
|
+
ready: boolean;
|
|
82
|
+
exitSent: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Entry extends Session {
|
|
86
|
+
cwd: string;
|
|
87
|
+
profile?: string;
|
|
88
|
+
shell?: string;
|
|
89
|
+
running?: boolean;
|
|
90
|
+
pty: Pty;
|
|
91
|
+
term: Terminal;
|
|
92
|
+
serializer: SerializeAddon;
|
|
93
|
+
label: string;
|
|
94
|
+
launcher?: string;
|
|
95
|
+
meta?: Record<string, unknown>;
|
|
96
|
+
cols: number;
|
|
97
|
+
rows: number;
|
|
98
|
+
exited: number | null | undefined;
|
|
99
|
+
title: string;
|
|
100
|
+
bells: number;
|
|
101
|
+
lastOutputAt: number;
|
|
102
|
+
viewers: Viewer[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const randomId = () => Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
106
|
+
|
|
107
|
+
const control = (msg: Record<string, unknown>) => JSON.stringify(msg);
|
|
108
|
+
|
|
109
|
+
function checkMeta(meta: Record<string, unknown> | undefined): void {
|
|
110
|
+
if (meta !== undefined && JSON.stringify(meta).length > MAX_META_BYTES) {
|
|
111
|
+
throw invalid('terminalMeta', 'Session metadata is too large');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function terminalSessions({ spawn = spawnPty, max = 20, now = Date.now }: TerminalSessionsOptions = {}): TerminalSessions {
|
|
116
|
+
const entries = new Map<string, Entry>();
|
|
117
|
+
|
|
118
|
+
const must = (id: string): Entry => {
|
|
119
|
+
const e = entries.get(id);
|
|
120
|
+
if (!e) throw notFound('terminalSession', 'No such terminal session');
|
|
121
|
+
return e;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const interactive = (e: Entry) => e.viewers.find((v) => v.view === 'interactive');
|
|
125
|
+
|
|
126
|
+
function broadcast(e: Entry, frame: string | Uint8Array): void {
|
|
127
|
+
for (const v of e.viewers) if (v.ready) v.sink.send(frame);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function create(opts: CreateOptions): Session {
|
|
131
|
+
if (entries.size >= max) reapExited();
|
|
132
|
+
if (entries.size >= max) throw invalid('terminalLimit', 'Too many terminal sessions', { max });
|
|
133
|
+
checkMeta(opts.meta);
|
|
134
|
+
const pty = spawn(opts.cmd, opts.cwd, opts.cols, opts.rows, opts.env);
|
|
135
|
+
const term = new Terminal({ cols: opts.cols, rows: opts.rows, scrollback: SCROLLBACK, allowProposedApi: true });
|
|
136
|
+
const serializer = new SerializeAddon();
|
|
137
|
+
term.loadAddon(serializer);
|
|
138
|
+
const createdAt = now();
|
|
139
|
+
const e: Entry = {
|
|
140
|
+
id: randomId(),
|
|
141
|
+
cwd: opts.cwd,
|
|
142
|
+
createdAt,
|
|
143
|
+
pty,
|
|
144
|
+
term,
|
|
145
|
+
serializer,
|
|
146
|
+
label: opts.label.slice(0, MAX_LABEL),
|
|
147
|
+
launcher: opts.launcher,
|
|
148
|
+
meta: opts.meta,
|
|
149
|
+
profile: opts.profile,
|
|
150
|
+
shell: opts.shell,
|
|
151
|
+
cols: opts.cols,
|
|
152
|
+
rows: opts.rows,
|
|
153
|
+
exited: undefined,
|
|
154
|
+
title: '',
|
|
155
|
+
bells: 0,
|
|
156
|
+
lastOutputAt: createdAt,
|
|
157
|
+
viewers: [],
|
|
158
|
+
};
|
|
159
|
+
entries.set(e.id, e);
|
|
160
|
+
|
|
161
|
+
// The interactive client answers terminal queries itself; headless answers only when none is attached.
|
|
162
|
+
term.onData((reply) => {
|
|
163
|
+
if (!interactive(e) && e.exited === undefined) pty.write(reply);
|
|
164
|
+
});
|
|
165
|
+
term.onTitleChange((title) => {
|
|
166
|
+
e.title = title;
|
|
167
|
+
broadcast(e, control({ type: 'title', title }));
|
|
168
|
+
});
|
|
169
|
+
term.onBell(() => {
|
|
170
|
+
e.bells++;
|
|
171
|
+
});
|
|
172
|
+
// Shell integration: OSC 7 reports the cwd, OSC 133 C/D bracket a running command.
|
|
173
|
+
term.parser.registerOscHandler(7, (data) => {
|
|
174
|
+
const cwd = cwdFromOsc7(data);
|
|
175
|
+
if (cwd) e.cwd = cwd;
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
term.parser.registerOscHandler(133, (data) => {
|
|
179
|
+
const mark = data.split(';', 1)[0];
|
|
180
|
+
if (mark === 'C') e.running = true;
|
|
181
|
+
else if (mark === 'D' || mark === 'A') e.running = false;
|
|
182
|
+
return true;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
pty.onData((data) => {
|
|
186
|
+
const chunk = data.slice();
|
|
187
|
+
e.lastOutputAt = now();
|
|
188
|
+
// Viewers get the bytes after the replay state has parsed them, so an attach never sees a gap.
|
|
189
|
+
try {
|
|
190
|
+
term.write(chunk, () => broadcast(e, chunk));
|
|
191
|
+
} catch {
|
|
192
|
+
close(e.id);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
pty.onExit((code) => {
|
|
196
|
+
if (e.exited !== undefined) return;
|
|
197
|
+
e.exited = code;
|
|
198
|
+
term.write('', () => {
|
|
199
|
+
const frame = control({ type: 'exit', code });
|
|
200
|
+
for (const v of e.viewers) {
|
|
201
|
+
if (v.ready && !v.exitSent) v.sink.send(frame);
|
|
202
|
+
v.exitSent = true;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
return e;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function attach(id: string, sink: Sink, view: ViewKind): void {
|
|
210
|
+
const e = must(id);
|
|
211
|
+
if (view === 'interactive') {
|
|
212
|
+
const prev = interactive(e);
|
|
213
|
+
if (prev) {
|
|
214
|
+
e.viewers = e.viewers.filter((v) => v !== prev);
|
|
215
|
+
prev.sink.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
sink.send(control({ type: 'hello', session: e.id, cols: e.cols, rows: e.rows }));
|
|
219
|
+
// Live output starts once every earlier chunk has been parsed: the replay then ends exactly where it begins.
|
|
220
|
+
const viewer: Viewer = { sink, view, ready: false, exitSent: false };
|
|
221
|
+
e.viewers.push(viewer);
|
|
222
|
+
e.term.write('', () => {
|
|
223
|
+
if (!e.viewers.includes(viewer)) return;
|
|
224
|
+
sink.send(new TextEncoder().encode(e.serializer.serialize()));
|
|
225
|
+
if (e.title) sink.send(control({ type: 'title', title: e.title }));
|
|
226
|
+
if (e.exited !== undefined) {
|
|
227
|
+
sink.send(control({ type: 'exit', code: e.exited }));
|
|
228
|
+
viewer.exitSent = true;
|
|
229
|
+
}
|
|
230
|
+
viewer.ready = true;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function detach(id: string, sink: Sink): void {
|
|
235
|
+
const e = entries.get(id);
|
|
236
|
+
if (!e) return;
|
|
237
|
+
e.viewers = e.viewers.filter((v) => v.sink !== sink);
|
|
238
|
+
if (e.exited !== undefined && e.viewers.length === 0) close(id);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function reapExited(): void {
|
|
242
|
+
for (const e of [...entries.values()]) if (e.exited !== undefined && e.viewers.length === 0) close(e.id);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const isInteractive = (e: Entry, sink: Sink) => interactive(e)?.sink === sink;
|
|
246
|
+
|
|
247
|
+
function write(id: string, sink: Sink, data: string | Uint8Array): void {
|
|
248
|
+
const e = entries.get(id);
|
|
249
|
+
if (e && e.exited === undefined && isInteractive(e, sink)) e.pty.write(data);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function resize(id: string, sink: Sink, cols: number, rows: number): void {
|
|
253
|
+
const e = entries.get(id);
|
|
254
|
+
if (!e || e.exited !== undefined || !isInteractive(e, sink)) return;
|
|
255
|
+
e.cols = cols;
|
|
256
|
+
e.rows = rows;
|
|
257
|
+
e.pty.resize(cols, rows);
|
|
258
|
+
e.term.resize(cols, rows);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function clear(id: string, sink: Sink): void {
|
|
262
|
+
const e = entries.get(id);
|
|
263
|
+
if (!e || !isInteractive(e, sink)) return;
|
|
264
|
+
// After the output already queued, like the view's own clear.
|
|
265
|
+
e.term.write('', () => e.term.clear());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function close(id: string, sink?: Sink): void {
|
|
269
|
+
const e = entries.get(id);
|
|
270
|
+
if (!e) return;
|
|
271
|
+
if (sink && !isInteractive(e, sink)) return;
|
|
272
|
+
entries.delete(id);
|
|
273
|
+
if (e.exited === undefined) e.pty.kill();
|
|
274
|
+
for (const v of e.viewers) v.sink.close();
|
|
275
|
+
e.viewers = [];
|
|
276
|
+
e.term.dispose();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
create,
|
|
281
|
+
get: (id) => entries.get(id),
|
|
282
|
+
list: () =>
|
|
283
|
+
[...entries.values()].map((e) => ({
|
|
284
|
+
id: e.id,
|
|
285
|
+
label: e.label,
|
|
286
|
+
launcher: e.launcher,
|
|
287
|
+
meta: e.meta,
|
|
288
|
+
cwd: e.cwd,
|
|
289
|
+
profile: e.profile,
|
|
290
|
+
shell: e.shell,
|
|
291
|
+
running: e.running,
|
|
292
|
+
cols: e.cols,
|
|
293
|
+
rows: e.rows,
|
|
294
|
+
createdAt: e.createdAt,
|
|
295
|
+
exited: e.exited,
|
|
296
|
+
title: e.title,
|
|
297
|
+
bells: e.bells,
|
|
298
|
+
lastOutputAt: e.lastOutputAt,
|
|
299
|
+
viewers: { interactive: !!interactive(e), previews: e.viewers.filter((v) => v.view === 'preview').length },
|
|
300
|
+
})),
|
|
301
|
+
attach,
|
|
302
|
+
detach,
|
|
303
|
+
write,
|
|
304
|
+
resize,
|
|
305
|
+
clear,
|
|
306
|
+
close,
|
|
307
|
+
closeAll: () => {
|
|
308
|
+
for (const id of [...entries.keys()]) close(id);
|
|
309
|
+
},
|
|
310
|
+
setLabel(id, label) {
|
|
311
|
+
must(id).label = label.slice(0, MAX_LABEL);
|
|
312
|
+
},
|
|
313
|
+
setMeta(id, meta) {
|
|
314
|
+
checkMeta(meta);
|
|
315
|
+
must(id).meta = meta;
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { isShellId, SHELL_IDS, type ShellId } from '../protocol.ts';
|
|
2
|
+
|
|
3
|
+
export { isShellId, SHELL_IDS, type ShellId };
|
|
4
|
+
|
|
5
|
+
export interface ShellLookup {
|
|
6
|
+
platform: NodeJS.Platform;
|
|
7
|
+
env: Record<string, string | undefined>;
|
|
8
|
+
exists(path: string): boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const POSIX_DIRS = ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin'];
|
|
12
|
+
|
|
13
|
+
const firstExisting = (paths: string[], exists: (p: string) => boolean): string | undefined => paths.find(exists);
|
|
14
|
+
|
|
15
|
+
const posixArgs = (name: string): string[] => {
|
|
16
|
+
if (name === 'zsh') return ['-l', '-o', 'nopromptsp'];
|
|
17
|
+
if (name === 'bash' || name === 'fish' || name === 'sh' || name === 'dash') return ['-l'];
|
|
18
|
+
return [];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const posixShell = (name: string, { exists }: ShellLookup): string[] | null => {
|
|
22
|
+
const path = firstExisting(
|
|
23
|
+
POSIX_DIRS.map((d) => `${d}/${name}`),
|
|
24
|
+
exists,
|
|
25
|
+
);
|
|
26
|
+
return path ? [path, ...posixArgs(name)] : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function windowsShell(id: ShellId, { env, exists }: ShellLookup): string[] | null {
|
|
30
|
+
const root = env.SystemRoot ?? env.SYSTEMROOT ?? 'C:\\Windows';
|
|
31
|
+
const programFiles = env.ProgramFiles ?? 'C:\\Program Files';
|
|
32
|
+
const programFilesX86 = env['ProgramFiles(x86)'];
|
|
33
|
+
const localAppData = env.LOCALAPPDATA;
|
|
34
|
+
const pick = (paths: (string | undefined)[], args: string[] = []): string[] | null => {
|
|
35
|
+
const found = firstExisting(paths.filter((p): p is string => !!p), exists);
|
|
36
|
+
return found ? [found, ...args] : null;
|
|
37
|
+
};
|
|
38
|
+
switch (id) {
|
|
39
|
+
case 'powershell':
|
|
40
|
+
return pick([`${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`], ['-NoLogo']);
|
|
41
|
+
case 'pwsh':
|
|
42
|
+
return pick(
|
|
43
|
+
[`${programFiles}\\PowerShell\\7\\pwsh.exe`, localAppData && `${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`],
|
|
44
|
+
['-NoLogo'],
|
|
45
|
+
);
|
|
46
|
+
case 'cmd':
|
|
47
|
+
return pick([`${root}\\System32\\cmd.exe`]);
|
|
48
|
+
case 'wsl':
|
|
49
|
+
return pick([`${root}\\System32\\wsl.exe`]);
|
|
50
|
+
case 'git-bash':
|
|
51
|
+
return pick(
|
|
52
|
+
[
|
|
53
|
+
`${programFiles}\\Git\\bin\\bash.exe`,
|
|
54
|
+
programFilesX86 && `${programFilesX86}\\Git\\bin\\bash.exe`,
|
|
55
|
+
localAppData && `${localAppData}\\Programs\\Git\\bin\\bash.exe`,
|
|
56
|
+
],
|
|
57
|
+
['--login', '-i'],
|
|
58
|
+
);
|
|
59
|
+
default:
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function posixAuto(lookup: ShellLookup): string[] | null {
|
|
65
|
+
const login = lookup.env.SHELL;
|
|
66
|
+
if (login && login.startsWith('/') && lookup.exists(login)) return [login, ...posixArgs(login.slice(login.lastIndexOf('/') + 1))];
|
|
67
|
+
return posixShell('bash', lookup) ?? posixShell('sh', lookup);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Argv for a known shell id on this machine; null when the id doesn't apply to the platform or isn't installed. */
|
|
71
|
+
export function resolveShell(id: ShellId, lookup: ShellLookup): string[] | null {
|
|
72
|
+
if (lookup.platform === 'win32') {
|
|
73
|
+
return id === 'auto' ? windowsShell('powershell', lookup) : windowsShell(id, lookup);
|
|
74
|
+
}
|
|
75
|
+
switch (id) {
|
|
76
|
+
case 'auto':
|
|
77
|
+
return posixAuto(lookup);
|
|
78
|
+
case 'pwsh': {
|
|
79
|
+
const path = firstExisting(['/usr/local/bin/pwsh', '/usr/bin/pwsh', '/opt/homebrew/bin/pwsh', '/opt/microsoft/powershell/7/pwsh'], lookup.exists);
|
|
80
|
+
return path ? [path, '-NoLogo'] : null;
|
|
81
|
+
}
|
|
82
|
+
case 'bash':
|
|
83
|
+
case 'zsh':
|
|
84
|
+
case 'fish':
|
|
85
|
+
case 'sh':
|
|
86
|
+
return posixShell(id, lookup);
|
|
87
|
+
default:
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function availableShells(lookup: ShellLookup): ShellId[] {
|
|
93
|
+
return SHELL_IDS.filter((id) => resolveShell(id, lookup) !== null);
|
|
94
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** An OSC 133 mark: A prompt start, B prompt end (input starts), C command output starts, D command done with its exit code. */
|
|
2
|
+
export type PromptMark = { kind: 'A' } | { kind: 'B' } | { kind: 'C' } | { kind: 'D'; code: number | null };
|
|
3
|
+
|
|
4
|
+
/** Parses the data of an OSC 133 sequence (`A`, `B`, `C`, `D;0`, `D`); null for anything else. */
|
|
5
|
+
export function parseOsc133(data: string): PromptMark | null {
|
|
6
|
+
const [kind, arg] = data.split(';');
|
|
7
|
+
if (kind === 'A' || kind === 'B' || kind === 'C') return { kind };
|
|
8
|
+
if (kind !== 'D') return null;
|
|
9
|
+
const code = arg !== undefined && /^-?\d+$/.test(arg.trim()) ? Number(arg.trim()) : null;
|
|
10
|
+
return { kind: 'D', code };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The folder of an OSC 7 report (`file://host/path`, percent-encoded); a Windows drive path loses its
|
|
15
|
+
* leading slash (`/C:/x` → `C:/x`). Null when it is not a file URL.
|
|
16
|
+
*/
|
|
17
|
+
export function parseOsc7(data: string): string | null {
|
|
18
|
+
const m = /^file:\/\/[^/]*(\/.*)$/.exec(data.trim());
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
let path: string;
|
|
21
|
+
try {
|
|
22
|
+
path = decodeURIComponent(m[1]);
|
|
23
|
+
} catch {
|
|
24
|
+
path = m[1];
|
|
25
|
+
}
|
|
26
|
+
return /^\/[A-Za-z]:([\\/]|$)/.test(path) ? path.slice(1) : path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Values of the `{name}` placeholders in a tab title template. */
|
|
30
|
+
export interface TitleVars {
|
|
31
|
+
name?: string;
|
|
32
|
+
shell?: string;
|
|
33
|
+
folder?: string;
|
|
34
|
+
cwd?: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Fills a tab title template; unknown placeholders stay as typed, empty ones vanish; the result is trimmed. */
|
|
39
|
+
export function formatTabTitle(template: string, vars: TitleVars): string {
|
|
40
|
+
return template
|
|
41
|
+
.replace(/\{(\w+)\}/g, (all, key: string) => (key in TITLE_KEYS ? (vars[key as keyof TitleVars] ?? '') : all))
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TITLE_KEYS = { name: 1, shell: 1, folder: 1, cwd: 1, title: 1 };
|
package/src/terminal.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
.terminal-pane {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: flex;
|
|
4
|
+
flex: 1;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
min-height: 0;
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
background: var(--bg-sunken);
|
|
9
|
+
}
|
|
10
|
+
.terminal-host {
|
|
11
|
+
position: relative;
|
|
12
|
+
isolation: isolate;
|
|
13
|
+
flex: 1;
|
|
14
|
+
min-width: 0;
|
|
15
|
+
min-height: 0;
|
|
16
|
+
/* Top half the padding and no bottom one: the screen edges already leave room there. */
|
|
17
|
+
padding: calc(var(--terminal-padding, 8px) / 2) var(--terminal-padding, 8px) 0 var(--terminal-padding, 8px);
|
|
18
|
+
}
|
|
19
|
+
.terminal-pane.preview .terminal-host { flex: none; padding: 0; transform-origin: 0 0; pointer-events: none; }
|
|
20
|
+
.terminal-host .xterm { height: 100%; }
|
|
21
|
+
.terminal-host .xterm-viewport { background: var(--terminal-bg, var(--bg-sunken)) !important; }
|
|
22
|
+
.terminal-host { background: var(--terminal-bg, transparent); }
|
|
23
|
+
.terminal-overlay {
|
|
24
|
+
position: absolute;
|
|
25
|
+
left: 50%;
|
|
26
|
+
bottom: 16px;
|
|
27
|
+
transform: translateX(-50%);
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: var(--space-10);
|
|
31
|
+
max-width: calc(100% - 32px);
|
|
32
|
+
padding: var(--space-8) var(--space-12);
|
|
33
|
+
border: 1px solid var(--border);
|
|
34
|
+
border-radius: var(--radius-md);
|
|
35
|
+
background: var(--panel);
|
|
36
|
+
color: var(--text-2);
|
|
37
|
+
font-size: var(--fs-sm);
|
|
38
|
+
}
|
|
39
|
+
.terminal-overlay .terminal-error { color: var(--err); }
|
|
40
|
+
|
|
41
|
+
.terminal-find {
|
|
42
|
+
position: absolute;
|
|
43
|
+
top: 6px;
|
|
44
|
+
right: 14px;
|
|
45
|
+
z-index: 2;
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: var(--space-6);
|
|
49
|
+
padding: var(--space-4) var(--space-8);
|
|
50
|
+
border: 1px solid var(--border);
|
|
51
|
+
border-radius: var(--radius-md);
|
|
52
|
+
background: var(--panel);
|
|
53
|
+
}
|
|
54
|
+
.terminal-find input { width: 220px; min-width: 0; }
|
|
55
|
+
.terminal-find-count { font-size: var(--fs-xs-plus); white-space: nowrap; }
|
|
56
|
+
.terminal-host.ligatures .xterm-rows { font-variant-ligatures: normal !important; font-feature-settings: "liga" 1, "calt" 1; }
|
|
57
|
+
.terminal-host .xterm-decoration.terminal-mark { left: -7px !important; width: 5px !important; display: flex; align-items: center; pointer-events: auto; cursor: default; }
|
|
58
|
+
.terminal-mark::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--text-3); }
|
|
59
|
+
.terminal-mark.running::before { background: var(--text-2); }
|
|
60
|
+
.terminal-mark.ok::before { background: var(--ok); }
|
|
61
|
+
.terminal-mark.err::before { background: var(--err); }
|
|
62
|
+
.terminal-host.scrollbar-hidden .xterm-scrollable-element > .scrollbar { display: none !important; }
|
|
63
|
+
.terminal-host:not(.scrollbar-hover):not(.scrollbar-hidden) .xterm-scrollable-element > .scrollbar.invisible { opacity: 1; pointer-events: auto; }
|
|
64
|
+
.terminal-host.scrollbar-hover .xterm-scrollable-element > .scrollbar.invisible { opacity: 0; }
|
|
65
|
+
.terminal-host.scrollbar-hover:hover .xterm-scrollable-element > .scrollbar { opacity: 1; pointer-events: auto; }
|
|
66
|
+
/* A pane without the focus in a split: veiled in its own background by --terminal-dim (0 to 0.8). */
|
|
67
|
+
.terminal-pane.dimmed .terminal-host::after {
|
|
68
|
+
content: '';
|
|
69
|
+
position: absolute;
|
|
70
|
+
inset: 0;
|
|
71
|
+
z-index: 20;
|
|
72
|
+
background: var(--terminal-bg, var(--bg-sunken));
|
|
73
|
+
opacity: var(--terminal-dim, 0);
|
|
74
|
+
pointer-events: none;
|
|
75
|
+
}
|
|
76
|
+
.terminal-paste-preview { max-height: 160px; overflow: auto; margin: var(--space-8) 0; padding: var(--space-6) var(--space-8); border-radius: var(--radius-md); background: var(--bg-sunken); font-family: var(--font-mono); font-size: var(--fs-xs-plus); white-space: pre; }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import type { TerminalOptions } from './xterm-view.ts';
|
|
3
|
+
|
|
4
|
+
export { resolveThemeId, TERMINAL_THEMES, themeOf, type TerminalThemeId } from './themes.ts';
|
|
5
|
+
export { MAX_SCHEMES, parseWindowsTerminalSchemes, SCHEME_COLORS, type ColorScheme } from './schemes.ts';
|
|
6
|
+
|
|
7
|
+
/** xterm's default `wordSeparator`. */
|
|
8
|
+
export const DEFAULT_WORD_SEPARATORS = ' ()[]{}\',"`';
|
|
9
|
+
|
|
10
|
+
/** App-wide terminal preferences; a pane's own `options` prop wins. */
|
|
11
|
+
export const TerminalOptionsContext = createContext<TerminalOptions | undefined>(undefined);
|
|
12
|
+
|
|
13
|
+
export const useTerminalOptions = (): TerminalOptions | undefined => useContext(TerminalOptionsContext);
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ITheme } from '@xterm/xterm';
|
|
2
|
+
|
|
3
|
+
/** xterm theme slot → app CSS token. Brights reuse the base tokens; the app palette has one shade per hue. */
|
|
4
|
+
const SLOTS: [keyof ITheme, string][] = [
|
|
5
|
+
['background', '--bg-sunken'],
|
|
6
|
+
['foreground', '--text'],
|
|
7
|
+
['cursor', '--accent'],
|
|
8
|
+
['cursorAccent', '--bg-sunken'],
|
|
9
|
+
['selectionBackground', '--selected'],
|
|
10
|
+
['black', '--bg-elev'],
|
|
11
|
+
['red', '--err'],
|
|
12
|
+
['green', '--ok'],
|
|
13
|
+
['yellow', '--warn'],
|
|
14
|
+
['blue', '--info'],
|
|
15
|
+
['magenta', '--debug'],
|
|
16
|
+
['cyan', '--y-str'],
|
|
17
|
+
['white', '--text-2'],
|
|
18
|
+
['brightBlack', '--text-3'],
|
|
19
|
+
['brightRed', '--err'],
|
|
20
|
+
['brightGreen', '--ok'],
|
|
21
|
+
['brightYellow', '--warn'],
|
|
22
|
+
['brightBlue', '--info'],
|
|
23
|
+
['brightMagenta', '--debug'],
|
|
24
|
+
['brightCyan', '--y-str'],
|
|
25
|
+
['brightWhite', '--text'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Builds an xterm theme from the app's CSS tokens; `read` returns a token's value, empty when unset. */
|
|
29
|
+
export function xtermTheme(read: (token: string) => string): ITheme {
|
|
30
|
+
const theme: Record<string, string> = {};
|
|
31
|
+
// A layout can set --terminal-surface to put the terminal on another surface than --bg-sunken.
|
|
32
|
+
const surface = read('--terminal-surface').trim();
|
|
33
|
+
for (const [slot, token] of SLOTS) {
|
|
34
|
+
const value = token === '--bg-sunken' && surface ? surface : read(token).trim();
|
|
35
|
+
if (value) theme[slot] = value;
|
|
36
|
+
}
|
|
37
|
+
return theme as ITheme;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reads tokens from the document root's computed style. */
|
|
41
|
+
export const readCssToken =
|
|
42
|
+
(el: Element = document.documentElement) =>
|
|
43
|
+
(token: string): string =>
|
|
44
|
+
getComputedStyle(el).getPropertyValue(token);
|
package/src/themes.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ITheme } from '@xterm/xterm';
|
|
2
|
+
import { schemeTheme, type ColorScheme } from './schemes.ts';
|
|
3
|
+
import { xtermTheme } from './theme.ts';
|
|
4
|
+
|
|
5
|
+
export const TERMINAL_THEMES = ['app', 'dark-plus', 'light-plus', 'solarized-dark', 'solarized-light', 'dracula', 'one-dark', 'nord', 'gruvbox-dark', 'monokai'] as const;
|
|
6
|
+
export type TerminalThemeId = (typeof TERMINAL_THEMES)[number];
|
|
7
|
+
|
|
8
|
+
/** Background, foreground, cursor, selection, then the 16 ANSI colors (black..white, bright black..bright white). */
|
|
9
|
+
type Palette = [string, string, string, string, ...string[]];
|
|
10
|
+
|
|
11
|
+
const ANSI: (keyof ITheme)[] = [
|
|
12
|
+
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
|
|
13
|
+
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const PALETTES: Record<Exclude<TerminalThemeId, 'app'>, Palette> = {
|
|
17
|
+
'dark-plus': ['#1e1e1e', '#cccccc', '#aeafad', '#264f78', '#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5', '#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#e5e5e5'],
|
|
18
|
+
'light-plus': ['#ffffff', '#333333', '#000000', '#add6ff', '#000000', '#cd3131', '#00bc00', '#949800', '#0451a5', '#bc05bc', '#0598bc', '#555555', '#666666', '#cd3131', '#14ce14', '#b5ba00', '#0451a5', '#bc05bc', '#0598bc', '#a5a5a5'],
|
|
19
|
+
'solarized-dark': ['#002b36', '#839496', '#93a1a1', '#073642', '#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#586e75', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3'],
|
|
20
|
+
'solarized-light': ['#fdf6e3', '#657b83', '#586e75', '#eee8d5', '#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', '#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3'],
|
|
21
|
+
dracula: ['#282a36', '#f8f8f2', '#f8f8f2', '#44475a', '#21222c', '#ff5555', '#50fa7b', '#f1fa8c', '#bd93f9', '#ff79c6', '#8be9fd', '#f8f8f2', '#6272a4', '#ff6e6e', '#69ff94', '#ffffa5', '#d6acff', '#ff92df', '#a4ffff', '#ffffff'],
|
|
22
|
+
'one-dark': ['#282c34', '#abb2bf', '#528bff', '#3e4451', '#282c34', '#e06c75', '#98c379', '#e5c07b', '#61afef', '#c678dd', '#56b6c2', '#abb2bf', '#5c6370', '#e06c75', '#98c379', '#e5c07b', '#61afef', '#c678dd', '#56b6c2', '#ffffff'],
|
|
23
|
+
nord: ['#2e3440', '#d8dee9', '#d8dee9', '#434c5e', '#3b4252', '#bf616a', '#a3be8c', '#ebcb8b', '#81a1c1', '#b48ead', '#88c0d0', '#e5e9f0', '#4c566a', '#bf616a', '#a3be8c', '#ebcb8b', '#81a1c1', '#b48ead', '#8fbcbb', '#eceff4'],
|
|
24
|
+
'gruvbox-dark': ['#282828', '#ebdbb2', '#ebdbb2', '#504945', '#282828', '#cc241d', '#98971a', '#d79921', '#458588', '#b16286', '#689d6a', '#a89984', '#928374', '#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#ebdbb2'],
|
|
25
|
+
monokai: ['#272822', '#f8f8f2', '#f8f8f0', '#49483e', '#272822', '#f92672', '#a6e22e', '#f4bf75', '#66d9ef', '#ae81ff', '#a1efe4', '#f8f8f2', '#75715e', '#f92672', '#a6e22e', '#f4bf75', '#66d9ef', '#ae81ff', '#a1efe4', '#f9f8f5'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** `id` when it names a preset or one of `schemes`, else 'app'. */
|
|
29
|
+
export function resolveThemeId(id: string | undefined, schemes: readonly ColorScheme[] = []): string {
|
|
30
|
+
if (!id) return 'app';
|
|
31
|
+
if ((TERMINAL_THEMES as readonly string[]).includes(id) || schemes.some((s) => s.id === id)) return id;
|
|
32
|
+
return 'app';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The xterm theme of a preset or a custom scheme (presets win on a shared id); 'app' (or an unknown
|
|
37
|
+
* id) builds it from the app's CSS tokens through `read`.
|
|
38
|
+
*/
|
|
39
|
+
export function themeOf(id: string | undefined, read: (token: string) => string, schemes: readonly ColorScheme[] = []): ITheme {
|
|
40
|
+
const palette = id && id !== 'app' && Object.hasOwn(PALETTES, id) ? PALETTES[id as Exclude<TerminalThemeId, 'app'>] : undefined;
|
|
41
|
+
if (!palette) {
|
|
42
|
+
const custom = id && id !== 'app' ? schemes.find((s) => s.id === id) : undefined;
|
|
43
|
+
return custom ? schemeTheme(custom) : xtermTheme(read);
|
|
44
|
+
}
|
|
45
|
+
const [background, foreground, cursor, selectionBackground, ...colors] = palette;
|
|
46
|
+
const theme: Record<string, string> = { background, foreground, cursor, cursorAccent: background, selectionBackground };
|
|
47
|
+
ANSI.forEach((slot, i) => (theme[slot] = colors[i]));
|
|
48
|
+
return theme as ITheme;
|
|
49
|
+
}
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** What `TerminalPane` needs from an emulator; xterm.js implements it (`xterm-view.ts`). */
|
|
2
|
+
export interface TerminalView {
|
|
3
|
+
write(data: string): void;
|
|
4
|
+
resize(cols: number, rows: number): void;
|
|
5
|
+
onInput(cb: (data: string) => void): void;
|
|
6
|
+
onResize(cb: (cols: number, rows: number) => void): void;
|
|
7
|
+
dispose(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// TODO(ghostty-web): not a drop-in as of 0.4.0. It needs 'wasm-unsafe-eval' in the CSP
|
|
11
|
+
// (WebAssembly.compile on an inlined data: URL, also fetched, so connect-src data:), an awaited
|
|
12
|
+
// init() before `new Terminal`, and ignores `options.theme =` after open() (no live re-theme).
|