@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,56 @@
|
|
|
1
|
+
export const FONT_WEIGHTS = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'] as const;
|
|
2
|
+
export const CURSOR_STYLES = ['block', 'bar', 'underline'] as const;
|
|
3
|
+
|
|
4
|
+
/** The look of one profile; a set field wins over the global terminal setting of the same name. */
|
|
5
|
+
export interface ProfileAppearance {
|
|
6
|
+
fontFamily?: string;
|
|
7
|
+
/** Pixels, 8 to 32. */
|
|
8
|
+
fontSize?: number;
|
|
9
|
+
fontWeight?: (typeof FONT_WEIGHTS)[number];
|
|
10
|
+
/** 1 to 2. */
|
|
11
|
+
lineHeight?: number;
|
|
12
|
+
/** A preset id or a custom scheme id; an unknown one follows the app. */
|
|
13
|
+
theme?: string;
|
|
14
|
+
cursorStyle?: (typeof CURSOR_STYLES)[number];
|
|
15
|
+
cursorBlink?: boolean;
|
|
16
|
+
/** Pixels, 0 to 48. */
|
|
17
|
+
padding?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Check = (v: unknown) => boolean;
|
|
21
|
+
const range = (min: number, max: number): Check => (v) => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
|
22
|
+
const oneOf = (values: readonly string[]): Check => (v) => typeof v === 'string' && values.includes(v);
|
|
23
|
+
const text = (max: number): Check => (v) => typeof v === 'string' && v.length <= max;
|
|
24
|
+
|
|
25
|
+
/** Same ranges as the flat `terminal.*` settings. */
|
|
26
|
+
const APPEARANCE_SPEC: Record<keyof ProfileAppearance, Check> = {
|
|
27
|
+
fontFamily: text(300),
|
|
28
|
+
fontSize: range(8, 32),
|
|
29
|
+
fontWeight: oneOf(FONT_WEIGHTS),
|
|
30
|
+
lineHeight: range(1, 2),
|
|
31
|
+
theme: (v) => typeof v === 'string' && /^[\w.-]{1,64}$/.test(v),
|
|
32
|
+
cursorStyle: oneOf(CURSOR_STYLES),
|
|
33
|
+
cursorBlink: (v) => typeof v === 'boolean',
|
|
34
|
+
padding: range(0, 48),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A profile's `appearance` with only known keys and values in range; else null. */
|
|
38
|
+
export function validAppearance(v: unknown): ProfileAppearance | null {
|
|
39
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) return null;
|
|
40
|
+
for (const [k, x] of Object.entries(v)) {
|
|
41
|
+
const check = APPEARANCE_SPEC[k as keyof ProfileAppearance];
|
|
42
|
+
if (!Object.hasOwn(APPEARANCE_SPEC, k) || !check(x)) return null;
|
|
43
|
+
}
|
|
44
|
+
return v as ProfileAppearance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `base` with the set fields of `appearance` over it (an empty font family keeps the base one). */
|
|
48
|
+
export function withAppearance<T extends object>(base: T | undefined, appearance: ProfileAppearance | undefined): T | undefined {
|
|
49
|
+
if (!appearance) return base;
|
|
50
|
+
const out: Record<string, unknown> = { ...base };
|
|
51
|
+
for (const [k, v] of Object.entries(appearance)) {
|
|
52
|
+
if (v === undefined || (k === 'fontFamily' && typeof v === 'string' && !v.trim())) continue;
|
|
53
|
+
out[k] = v;
|
|
54
|
+
}
|
|
55
|
+
return out as T;
|
|
56
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { errorOf, request } from '@deckbuilderkit/ui';
|
|
2
|
+
import { TERMINAL_SOCKET_PATH } from './protocol.ts';
|
|
3
|
+
import type { SessionInfo, TerminalProfile, TerminalProfileInfo } from './protocol.ts';
|
|
4
|
+
|
|
5
|
+
export type { SessionInfo, TerminalProfile, TerminalProfileInfo };
|
|
6
|
+
|
|
7
|
+
/** A project directory: a root id and a path relative to it. */
|
|
8
|
+
export interface TerminalCwd {
|
|
9
|
+
root: string;
|
|
10
|
+
path: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A program the app registered on the server, with the args its launcher validates. */
|
|
14
|
+
export interface TerminalLaunch {
|
|
15
|
+
id: string;
|
|
16
|
+
args?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Ticketed {
|
|
20
|
+
session: string;
|
|
21
|
+
ticket: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Opens a session. `profile` is a profile id (default: the `defaultProfile` setting); `dir` is an
|
|
26
|
+
* absolute directory to start in (a missing one falls back to the profile/default cwd); `cwd` wins over both.
|
|
27
|
+
*/
|
|
28
|
+
export function openSession(opts: { cols: number; rows: number; cwd?: TerminalCwd; launch?: TerminalLaunch; profile?: string; dir?: string }): Promise<Ticketed> {
|
|
29
|
+
const { cols, rows, cwd, launch, profile, dir } = opts;
|
|
30
|
+
return request<Ticketed>('POST', '/api/terminal/open', {}, { cols, rows, root: cwd?.root, path: cwd?.path, launch, profile, dir });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Shell ids installed on the server (for the default shell select). */
|
|
34
|
+
export function listShells(): Promise<string[]> {
|
|
35
|
+
return request<string[]>('GET', '/api/terminal/shells');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Built-in profiles (installed shells, overrides applied), then custom ones. */
|
|
39
|
+
export function listProfiles(): Promise<TerminalProfileInfo[]> {
|
|
40
|
+
return request<TerminalProfileInfo[]>('GET', '/api/terminal/profiles');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function attachSession(session: string, view: 'interactive' | 'preview' = 'interactive'): Promise<Ticketed> {
|
|
44
|
+
return request<Ticketed>('POST', '/api/terminal/attach', {}, { session, view });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Kills a session (a tab close). Unknown ids are a no-op. */
|
|
48
|
+
export async function closeSession(session: string): Promise<void> {
|
|
49
|
+
await request('POST', '/api/terminal/close', {}, { session });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function listSessions(): Promise<SessionInfo[]> {
|
|
53
|
+
return request<SessionInfo[]>('GET', '/api/terminal/sessions');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True for the error `attach` throws when the session is gone. */
|
|
57
|
+
export const isSessionGone = (err: unknown): boolean => errorOf(err).code === 'terminalSession';
|
|
58
|
+
|
|
59
|
+
/** The socket URL for a ticket, on the page's own origin. */
|
|
60
|
+
export function socketUrl(ticket: string, loc: { protocol: string; host: string } = location): string {
|
|
61
|
+
const scheme = loc.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
62
|
+
return `${scheme}//${loc.host}${TERMINAL_SOCKET_PATH}?t=${encodeURIComponent(ticket)}`;
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { attachSession, closeSession, isSessionGone, listProfiles, listSessions, openSession, socketUrl, type SessionInfo, type TerminalCwd, type TerminalLaunch, type TerminalProfile, type TerminalProfileInfo, type Ticketed } from './client.ts';
|
|
2
|
+
export { PANE_COMMANDS, terminalKeyAction, type PaneCommand, type TerminalKeyAction } from './keys.ts';
|
|
3
|
+
export { TerminalPane, type TerminalPaneHandle, type TerminalPaneProps } from './TerminalPane.tsx';
|
|
4
|
+
export { DEFAULT_WORD_SEPARATORS, TerminalOptionsContext } from './terminalOptions.ts';
|
|
5
|
+
export { readCssToken, xtermTheme } from './theme.ts';
|
|
6
|
+
export { resolveThemeId, TERMINAL_THEMES, themeOf, type TerminalThemeId } from './themes.ts';
|
|
7
|
+
export { validAppearance, withAppearance, type ProfileAppearance } from './appearance.ts';
|
|
8
|
+
export { parseWindowsTerminalSchemes, schemeTheme, SCHEME_COLORS, validSchemes, type ColorScheme } from './schemes.ts';
|
|
9
|
+
export { continuationOf, pasteNeedsWarning, pasteSummary, sanitizePaste, trimCopied, type PasteWarning } from './paste.ts';
|
|
10
|
+
export { formatTabTitle, parseOsc133, parseOsc7, type PromptMark, type TitleVars } from './shell-integration.ts';
|
|
11
|
+
export type { TerminalView } from './view.ts';
|
|
12
|
+
export { createXtermView, type TerminalFontWeight, type TerminalOptions, type XtermView, type XtermViewOptions } from './xterm-view.ts';
|
|
13
|
+
export { terminalAppKeys } from './app-keys';
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { chordOf, type KeyLike } from '@deckbuilderkit/ui/keybindings';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Commands a terminal pane runs itself, with their default keys (the fallback without a keymap;
|
|
5
|
+
* the app lists the same ids and keys so they show in its shortcuts).
|
|
6
|
+
*/
|
|
7
|
+
export const PANE_COMMANDS = {
|
|
8
|
+
'terminal.zoomIn': ['Ctrl+=', 'Ctrl++', 'Ctrl+Shift++'],
|
|
9
|
+
'terminal.zoomOut': ['Ctrl+-'],
|
|
10
|
+
'terminal.zoomReset': ['Ctrl+0'],
|
|
11
|
+
'terminal.scrollLineUp': ['Ctrl+Shift+ArrowUp'],
|
|
12
|
+
'terminal.scrollLineDown': ['Ctrl+Shift+ArrowDown'],
|
|
13
|
+
'terminal.scrollPageUp': ['Ctrl+Shift+PageUp'],
|
|
14
|
+
'terminal.scrollPageDown': ['Ctrl+Shift+PageDown'],
|
|
15
|
+
'terminal.scrollTop': ['Ctrl+Shift+Home'],
|
|
16
|
+
'terminal.scrollBottom': ['Ctrl+Shift+End'],
|
|
17
|
+
'terminal.clear': ['Ctrl+Shift+K'],
|
|
18
|
+
} as const satisfies Record<string, readonly string[]>;
|
|
19
|
+
export type PaneCommand = keyof typeof PANE_COMMANDS;
|
|
20
|
+
|
|
21
|
+
/** What a key pressed inside the terminal does. */
|
|
22
|
+
export type TerminalKeyAction = 'app' | 'find' | 'prevCommand' | 'nextCommand' | 'copy' | 'paste' | 'terminal' | PaneCommand;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Keys go to the program, except the find chord (opens the find bar), the previous/next command chords, the pane command
|
|
26
|
+
* chords (`pane`: chord to command), chords of the allowed app commands, Ctrl+C / Ctrl+Shift+C with text selected (copy),
|
|
27
|
+
* and Ctrl+V / Ctrl+Shift+V (paste).
|
|
28
|
+
*/
|
|
29
|
+
export function terminalKeyAction(
|
|
30
|
+
e: KeyLike,
|
|
31
|
+
hasSelection: boolean,
|
|
32
|
+
appChords: ReadonlySet<string>,
|
|
33
|
+
findChords: ReadonlySet<string> = new Set(),
|
|
34
|
+
navChords: { prev: ReadonlySet<string>; next: ReadonlySet<string> } = { prev: new Set(), next: new Set() },
|
|
35
|
+
pane: ReadonlyMap<string, PaneCommand> = new Map(),
|
|
36
|
+
): TerminalKeyAction {
|
|
37
|
+
const chord = chordOf(e);
|
|
38
|
+
if (!chord) return 'terminal';
|
|
39
|
+
if (findChords.has(chord)) return 'find';
|
|
40
|
+
if (navChords.prev.has(chord)) return 'prevCommand';
|
|
41
|
+
if (navChords.next.has(chord)) return 'nextCommand';
|
|
42
|
+
const own = pane.get(chord);
|
|
43
|
+
if (own) return own;
|
|
44
|
+
if (appChords.has(chord)) return 'app';
|
|
45
|
+
if ((chord === 'Ctrl+C' || chord === 'Ctrl+Shift+C') && hasSelection) return 'copy';
|
|
46
|
+
if (chord === 'Ctrl+V' || chord === 'Ctrl+Shift+V') return 'paste';
|
|
47
|
+
return 'terminal';
|
|
48
|
+
}
|
package/src/paste.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** When a paste asks first: 'multiLine' on a line break or a large paste, 'large' only on a large one. */
|
|
2
|
+
export type PasteWarning = 'multiLine' | 'large' | 'never';
|
|
3
|
+
|
|
4
|
+
/** Whether pasting `text` asks first. */
|
|
5
|
+
export function pasteNeedsWarning(text: string, mode: PasteWarning, largeSize: number): boolean {
|
|
6
|
+
if (mode === 'never') return false;
|
|
7
|
+
if (text.length > largeSize) return true;
|
|
8
|
+
return mode === 'multiLine' && /[\r\n]/.test(text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Line count, size and the first lines (each cut short) of a paste, for the warning. */
|
|
12
|
+
export function pasteSummary(text: string, maxLines = 5, maxWidth = 120): { lines: number; chars: number; preview: string } {
|
|
13
|
+
const all = text.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n');
|
|
14
|
+
const preview = all
|
|
15
|
+
.slice(0, maxLines)
|
|
16
|
+
.map((l) => (l.length > maxWidth ? `${l.slice(0, maxWidth)}…` : l))
|
|
17
|
+
.join('\n');
|
|
18
|
+
return { lines: all.length, chars: text.length, preview: all.length > maxLines ? `${preview}\n…` : preview };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Line continuation of a shell: what ends a line so the next one joins the same pending command. */
|
|
22
|
+
export function continuationOf(shell: string | undefined): string {
|
|
23
|
+
switch (shell) {
|
|
24
|
+
case 'bash':
|
|
25
|
+
case 'zsh':
|
|
26
|
+
case 'sh':
|
|
27
|
+
case 'fish':
|
|
28
|
+
case 'git-bash':
|
|
29
|
+
case 'wsl':
|
|
30
|
+
return ' \\\n';
|
|
31
|
+
case 'pwsh':
|
|
32
|
+
case 'powershell':
|
|
33
|
+
return ' `\n';
|
|
34
|
+
case 'cmd':
|
|
35
|
+
return ' ^\n';
|
|
36
|
+
default:
|
|
37
|
+
return ' ';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line no-control-regex
|
|
42
|
+
const ESCAPES = /\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)?|\x9d[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)?|\x1b[PX^_][^\x1b\x9c]*(?:\x1b\\|\x9c)?|\x1b[ -/]*[0-~]?/g;
|
|
43
|
+
// eslint-disable-next-line no-control-regex
|
|
44
|
+
const CONTROLS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A paste that cannot run anything before the user presses Enter: escape sequences (injected
|
|
48
|
+
* bracketed-paste markers too) and control characters other than tab and newline removed, trailing
|
|
49
|
+
* line breaks dropped. With `bracketed` (the program turned bracketed paste on) the lines stay as
|
|
50
|
+
* they are, for the terminal to bracket; otherwise they are joined with the shell's line
|
|
51
|
+
* continuation (`continuationOf`), so they stay one pending command.
|
|
52
|
+
*/
|
|
53
|
+
export function sanitizePaste(text: string, opts: { bracketed: boolean; shell?: string }): string {
|
|
54
|
+
const lines = text
|
|
55
|
+
.replace(/\r\n/g, '\n')
|
|
56
|
+
.replace(ESCAPES, '')
|
|
57
|
+
.replace(CONTROLS, '')
|
|
58
|
+
.replace(/\s+$/, '')
|
|
59
|
+
.split('\n');
|
|
60
|
+
return opts.bracketed ? lines.join('\n') : lines.join(continuationOf(opts.shell));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Copied text without the trailing spaces of each line. */
|
|
64
|
+
export const trimCopied = (text: string): string => text.replace(/ +$/gm, '');
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ProfileAppearance } from './appearance.ts';
|
|
2
|
+
|
|
3
|
+
export const TERMINAL_SOCKET_PATH = '/api/terminal/socket';
|
|
4
|
+
export const MAX_COLS = 1000;
|
|
5
|
+
export const MAX_ROWS = 1000;
|
|
6
|
+
export const MAX_CONTROL_BYTES = 64 * 1024;
|
|
7
|
+
export const MAX_TITLE = 256;
|
|
8
|
+
|
|
9
|
+
export const SHELL_IDS = ['auto', 'powershell', 'pwsh', 'cmd', 'git-bash', 'wsl', 'bash', 'zsh', 'fish', 'sh'] as const;
|
|
10
|
+
export type ShellId = (typeof SHELL_IDS)[number];
|
|
11
|
+
|
|
12
|
+
export const isShellId = (x: unknown): x is ShellId => typeof x === 'string' && (SHELL_IDS as readonly string[]).includes(x);
|
|
13
|
+
|
|
14
|
+
/** Display names of the built-in profiles (one per installed shell). */
|
|
15
|
+
export const SHELL_NAMES: Record<Exclude<ShellId, 'auto'>, string> = {
|
|
16
|
+
powershell: 'PowerShell',
|
|
17
|
+
pwsh: 'PowerShell 7',
|
|
18
|
+
cmd: 'Command Prompt',
|
|
19
|
+
'git-bash': 'Git Bash',
|
|
20
|
+
wsl: 'WSL',
|
|
21
|
+
bash: 'bash',
|
|
22
|
+
zsh: 'zsh',
|
|
23
|
+
fish: 'fish',
|
|
24
|
+
sh: 'sh',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Default icon names of the built-in profiles. */
|
|
28
|
+
export const SHELL_ICONS: Record<Exclude<ShellId, 'auto'>, string> = {
|
|
29
|
+
powershell: 'terminal-powershell',
|
|
30
|
+
pwsh: 'terminal-pwsh',
|
|
31
|
+
cmd: 'terminal-cmd',
|
|
32
|
+
'git-bash': 'terminal-git-bash',
|
|
33
|
+
wsl: 'terminal-wsl',
|
|
34
|
+
bash: 'shell-bash',
|
|
35
|
+
zsh: 'shell-zsh',
|
|
36
|
+
fish: 'shell-fish',
|
|
37
|
+
sh: 'shell-bash',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A terminal profile. Built-ins have a shell id as `id`; a saved entry with that id overrides
|
|
42
|
+
* name, icon, args (appended to the shell's own), env, cwd, hidden, shellIntegration and appearance.
|
|
43
|
+
* Other ids are custom: `shell: 'custom'` runs `command` (absolute path) with `args`; a known
|
|
44
|
+
* `shell` runs that shell with `args` appended.
|
|
45
|
+
*/
|
|
46
|
+
export interface TerminalProfile {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
shell: ShellId | 'custom';
|
|
50
|
+
command?: string;
|
|
51
|
+
args?: string[];
|
|
52
|
+
env?: Record<string, string>;
|
|
53
|
+
cwd?: string;
|
|
54
|
+
icon?: string;
|
|
55
|
+
hidden?: boolean;
|
|
56
|
+
shellIntegration?: boolean;
|
|
57
|
+
/** Look of this profile's panes over the global terminal settings. */
|
|
58
|
+
appearance?: ProfileAppearance;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A profile as `GET /api/terminal/profiles` lists it: built-ins first, then custom ones. */
|
|
62
|
+
export interface TerminalProfileInfo extends TerminalProfile {
|
|
63
|
+
/** False when the shell or command is not on this machine. */
|
|
64
|
+
available: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `clear` drops the session's scrollback on the server too, so a reattach does not bring it back. */
|
|
68
|
+
export type ClientControl = { type: 'resize'; cols: number; rows: number } | { type: 'close' } | { type: 'clear' };
|
|
69
|
+
|
|
70
|
+
export type ServerControl =
|
|
71
|
+
| { type: 'hello'; session: string; cols: number; rows: number }
|
|
72
|
+
| { type: 'exit'; code: number | null }
|
|
73
|
+
| { type: 'title'; title: string };
|
|
74
|
+
|
|
75
|
+
export type ClientMessage = { kind: 'input'; data: Uint8Array } | { kind: 'control'; msg: ClientControl };
|
|
76
|
+
export type ServerMessage = { kind: 'output'; data: Uint8Array } | { kind: 'control'; msg: ServerControl };
|
|
77
|
+
|
|
78
|
+
/** A session as `GET /api/terminal/sessions` lists it. */
|
|
79
|
+
export interface SessionInfo {
|
|
80
|
+
id: string;
|
|
81
|
+
label: string;
|
|
82
|
+
launcher?: string;
|
|
83
|
+
meta?: Record<string, unknown>;
|
|
84
|
+
/** Start directory, then the last one shell integration reported (OSC 7). */
|
|
85
|
+
cwd: string;
|
|
86
|
+
/** Profile id the session was opened with, if any. */
|
|
87
|
+
profile?: string;
|
|
88
|
+
/** Shell id used (`custom` for a custom command); absent for launchers. */
|
|
89
|
+
shell?: string;
|
|
90
|
+
/** Shell integration (OSC 133): true while a command runs; absent without integration. */
|
|
91
|
+
running?: boolean;
|
|
92
|
+
cols: number;
|
|
93
|
+
rows: number;
|
|
94
|
+
createdAt: number;
|
|
95
|
+
exited: number | null | undefined;
|
|
96
|
+
title: string;
|
|
97
|
+
bells: number;
|
|
98
|
+
lastOutputAt: number;
|
|
99
|
+
viewers: { interactive: boolean; previews: number };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type Frame = string | ArrayBuffer | Uint8Array;
|
|
103
|
+
|
|
104
|
+
export const encodeControl = (msg: ClientControl | ServerControl): string => JSON.stringify(msg);
|
|
105
|
+
|
|
106
|
+
const isSize = (n: unknown, max: number): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= max;
|
|
107
|
+
|
|
108
|
+
const parseControl = (text: string): Record<string, unknown> | null => {
|
|
109
|
+
if (text.length > MAX_CONTROL_BYTES) return null;
|
|
110
|
+
try {
|
|
111
|
+
const v: unknown = JSON.parse(text);
|
|
112
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const bytes = (frame: ArrayBuffer | Uint8Array): Uint8Array => (frame instanceof Uint8Array ? frame : new Uint8Array(frame));
|
|
119
|
+
|
|
120
|
+
export function decodeClientMessage(frame: Frame): ClientMessage | null {
|
|
121
|
+
if (typeof frame !== 'string') return { kind: 'input', data: bytes(frame) };
|
|
122
|
+
const m = parseControl(frame);
|
|
123
|
+
if (!m) return null;
|
|
124
|
+
if (m.type === 'close') return { kind: 'control', msg: { type: 'close' } };
|
|
125
|
+
if (m.type === 'clear') return { kind: 'control', msg: { type: 'clear' } };
|
|
126
|
+
if (m.type === 'resize' && isSize(m.cols, MAX_COLS) && isSize(m.rows, MAX_ROWS)) {
|
|
127
|
+
return { kind: 'control', msg: { type: 'resize', cols: m.cols, rows: m.rows } };
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function decodeServerMessage(frame: Frame): ServerMessage | null {
|
|
133
|
+
if (typeof frame !== 'string') return { kind: 'output', data: bytes(frame) };
|
|
134
|
+
const m = parseControl(frame);
|
|
135
|
+
if (!m) return null;
|
|
136
|
+
if (m.type === 'hello' && typeof m.session === 'string' && isSize(m.cols, MAX_COLS) && isSize(m.rows, MAX_ROWS)) {
|
|
137
|
+
return { kind: 'control', msg: { type: 'hello', session: m.session, cols: m.cols, rows: m.rows } };
|
|
138
|
+
}
|
|
139
|
+
if (m.type === 'exit' && (m.code === null || (typeof m.code === 'number' && Number.isFinite(m.code)))) {
|
|
140
|
+
return { kind: 'control', msg: { type: 'exit', code: m.code } };
|
|
141
|
+
}
|
|
142
|
+
if (m.type === 'title' && typeof m.title === 'string') {
|
|
143
|
+
return { kind: 'control', msg: { type: 'title', title: m.title.slice(0, MAX_TITLE) } };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
package/src/schemes.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ITheme } from '@xterm/xterm';
|
|
2
|
+
|
|
3
|
+
/** The 16 ANSI colors of a scheme, in Windows Terminal's key names (`purple` is xterm's `magenta`). */
|
|
4
|
+
export const SCHEME_COLORS = [
|
|
5
|
+
'black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white',
|
|
6
|
+
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightPurple', 'brightCyan', 'brightWhite',
|
|
7
|
+
] as const;
|
|
8
|
+
export type SchemeColor = (typeof SCHEME_COLORS)[number];
|
|
9
|
+
|
|
10
|
+
/** A custom color scheme (`terminal.schemes`); every color is `#RRGGBB`. */
|
|
11
|
+
export type ColorScheme = {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
foreground: string;
|
|
15
|
+
background: string;
|
|
16
|
+
cursor?: string;
|
|
17
|
+
cursorAccent?: string;
|
|
18
|
+
selectionBackground?: string;
|
|
19
|
+
} & Record<SchemeColor, string>;
|
|
20
|
+
|
|
21
|
+
export const MAX_SCHEMES = 100;
|
|
22
|
+
const HEX = /^#[0-9a-fA-F]{6}$/;
|
|
23
|
+
const SCHEME_ID = /^[\w.-]{1,64}$/;
|
|
24
|
+
const OPTIONAL_COLORS = ['cursor', 'cursorAccent', 'selectionBackground'] as const;
|
|
25
|
+
const SCHEME_KEYS = new Set<string>(['id', 'name', 'foreground', 'background', ...OPTIONAL_COLORS, ...SCHEME_COLORS]);
|
|
26
|
+
|
|
27
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
28
|
+
const isHex = (v: unknown): v is string => typeof v === 'string' && HEX.test(v);
|
|
29
|
+
|
|
30
|
+
/** A stored scheme with only known keys, a short id and name and `#RRGGBB` colors; else null. */
|
|
31
|
+
export function validScheme(v: unknown): ColorScheme | null {
|
|
32
|
+
if (!isRecord(v) || !Object.keys(v).every((k) => SCHEME_KEYS.has(k))) return null;
|
|
33
|
+
if (typeof v.id !== 'string' || !SCHEME_ID.test(v.id)) return null;
|
|
34
|
+
if (typeof v.name !== 'string' || !v.name || v.name.length > 80) return null;
|
|
35
|
+
if (!isHex(v.foreground) || !isHex(v.background) || !SCHEME_COLORS.every((k) => isHex(v[k]))) return null;
|
|
36
|
+
if (!OPTIONAL_COLORS.every((k) => v[k] === undefined || isHex(v[k]))) return null;
|
|
37
|
+
return v as ColorScheme;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Up to MAX_SCHEMES valid schemes with distinct ids, else null. */
|
|
41
|
+
export function validSchemes(raw: unknown): ColorScheme[] | null {
|
|
42
|
+
if (!Array.isArray(raw) || raw.length > MAX_SCHEMES) return null;
|
|
43
|
+
const out = raw.map(validScheme);
|
|
44
|
+
if (out.some((s) => !s)) return null;
|
|
45
|
+
return new Set(out.map((s) => s!.id)).size === out.length ? (out as ColorScheme[]) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The xterm theme of a custom scheme. */
|
|
49
|
+
export function schemeTheme(s: ColorScheme): ITheme {
|
|
50
|
+
const theme: Record<string, string> = {
|
|
51
|
+
background: s.background,
|
|
52
|
+
foreground: s.foreground,
|
|
53
|
+
cursor: s.cursor ?? s.foreground,
|
|
54
|
+
cursorAccent: s.cursorAccent ?? s.background,
|
|
55
|
+
};
|
|
56
|
+
if (s.selectionBackground) theme.selectionBackground = s.selectionBackground;
|
|
57
|
+
for (const k of SCHEME_COLORS) theme[k.replace(/purple/i, (m) => (m[0] === 'P' ? 'Magenta' : 'magenta'))] = s[k];
|
|
58
|
+
return theme as ITheme;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** JSON text without `//` and `/* *\/` comments and trailing commas (strings left alone). */
|
|
62
|
+
function looseJson(text: string): string {
|
|
63
|
+
let out = '';
|
|
64
|
+
for (let i = 0; i < text.length; i++) {
|
|
65
|
+
const c = text[i];
|
|
66
|
+
if (c === '"') {
|
|
67
|
+
let j = i + 1;
|
|
68
|
+
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
|
69
|
+
out += text.slice(i, j + 1);
|
|
70
|
+
i = j;
|
|
71
|
+
} else if (c === '/' && text[i + 1] === '/') {
|
|
72
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
73
|
+
out += '\n';
|
|
74
|
+
} else if (c === '/' && text[i + 1] === '*') {
|
|
75
|
+
const end = text.indexOf('*/', i + 2);
|
|
76
|
+
i = end < 0 ? text.length : end + 1;
|
|
77
|
+
} else out += c;
|
|
78
|
+
}
|
|
79
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `#RGB` / `#RRGGBB` (any case) as lowercase `#rrggbb`; null otherwise. */
|
|
83
|
+
function hexOf(v: unknown): string | null {
|
|
84
|
+
if (typeof v !== 'string') return null;
|
|
85
|
+
const s = v.trim();
|
|
86
|
+
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
|
|
87
|
+
if (/^#[0-9a-fA-F]{3}$/.test(s)) return `#${[...s.slice(1)].map((x) => x + x).join('')}`.toLowerCase();
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const slug = (name: string) =>
|
|
92
|
+
name
|
|
93
|
+
.toLowerCase()
|
|
94
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
95
|
+
.replace(/^-+|-+$/g, '')
|
|
96
|
+
.slice(0, 40) || 'scheme';
|
|
97
|
+
|
|
98
|
+
/** One Windows Terminal scheme object as a ColorScheme without an id; null when a required color is missing. */
|
|
99
|
+
function fromWindowsTerminal(v: unknown): Omit<ColorScheme, 'id'> | null {
|
|
100
|
+
if (!isRecord(v) || typeof v.name !== 'string' || !v.name.trim()) return null;
|
|
101
|
+
const foreground = hexOf(v.foreground);
|
|
102
|
+
const background = hexOf(v.background);
|
|
103
|
+
if (!foreground || !background) return null;
|
|
104
|
+
const out: Record<string, string> = { name: v.name.trim().slice(0, 80), foreground, background };
|
|
105
|
+
for (const k of SCHEME_COLORS) {
|
|
106
|
+
const c = hexOf(v[k]);
|
|
107
|
+
if (!c) return null;
|
|
108
|
+
out[k] = c;
|
|
109
|
+
}
|
|
110
|
+
const cursor = hexOf(v.cursorColor ?? v.cursor);
|
|
111
|
+
if (cursor) out.cursor = cursor;
|
|
112
|
+
const accent = hexOf(v.cursorAccent);
|
|
113
|
+
if (accent) out.cursorAccent = accent;
|
|
114
|
+
const selection = hexOf(v.selectionBackground);
|
|
115
|
+
if (selection) out.selectionBackground = selection;
|
|
116
|
+
return out as Omit<ColorScheme, 'id'>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Schemes from Windows Terminal JSON: a `schemes` array, one scheme object, or a whole settings.json
|
|
121
|
+
* (comments and trailing commas allowed). Each gets an id `custom-<name>` unique against `taken` and
|
|
122
|
+
* the others; invalid entries are skipped, unparsable text gives [].
|
|
123
|
+
*/
|
|
124
|
+
export function parseWindowsTerminalSchemes(json: string, taken: Iterable<string> = []): ColorScheme[] {
|
|
125
|
+
let data: unknown;
|
|
126
|
+
try {
|
|
127
|
+
data = JSON.parse(looseJson(json));
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const list = Array.isArray(data) ? data : isRecord(data) && Array.isArray(data.schemes) ? data.schemes : [data];
|
|
132
|
+
const used = new Set(taken);
|
|
133
|
+
const out: ColorScheme[] = [];
|
|
134
|
+
for (const raw of list) {
|
|
135
|
+
const s = fromWindowsTerminal(raw);
|
|
136
|
+
if (!s) continue;
|
|
137
|
+
const base = `custom-${slug(s.name)}`;
|
|
138
|
+
let id = base;
|
|
139
|
+
for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
|
|
140
|
+
used.add(id);
|
|
141
|
+
out.push({ id, ...s } as ColorScheme);
|
|
142
|
+
if (out.length >= MAX_SCHEMES) break;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { isUuid, launchers, type Launcher } from './launchers.ts';
|
|
2
|
+
export { terminalRoutes, type TerminalRoutesOptions } from './routes.ts';
|
|
3
|
+
export { isShellId, SHELL_IDS, type ShellId } from './shells.ts';
|
|
4
|
+
export type { TerminalProfile, TerminalProfileInfo } from '../protocol.ts';
|
|
5
|
+
export { mergeProfiles, profileArgv } from './profiles.ts';
|
|
6
|
+
export { cwdFromOsc7, terminalSessions, type SessionInfo, type TerminalSessions } from './sessions.ts';
|
|
7
|
+
export { validAppearance, withAppearance, type ProfileAppearance } from '../appearance.ts';
|
|
8
|
+
export { parseWindowsTerminalSchemes, validSchemes, type ColorScheme } from '../schemes.ts';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# DeckBuilder shell integration for bash (and Git Bash), loaded with --rcfile.
|
|
2
|
+
# Sources the startup files a login shell would, then wraps the prompt with OSC 133 marks and OSC 7.
|
|
3
|
+
if [ -z "${__db_si:-}" ]; then
|
|
4
|
+
__db_si=1
|
|
5
|
+
if [ -r /etc/profile ]; then . /etc/profile; fi
|
|
6
|
+
if [ -r ~/.bash_profile ]; then . ~/.bash_profile
|
|
7
|
+
elif [ -r ~/.bash_login ]; then . ~/.bash_login
|
|
8
|
+
elif [ -r ~/.profile ]; then . ~/.profile
|
|
9
|
+
elif [ -r ~/.bashrc ]; then . ~/.bashrc
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
__db_first=1
|
|
13
|
+
__db_ec=0
|
|
14
|
+
__db_precmd() {
|
|
15
|
+
__db_ec=$?
|
|
16
|
+
# A prompt that already emits its own marks (e.g. Windows Terminal's snippet) sends D itself.
|
|
17
|
+
if [ -z "$__db_first" ] && [ -z "${__db_own_marks:-}" ]; then printf '\033]133;D;%s\007' "$__db_ec"; fi
|
|
18
|
+
__db_first=
|
|
19
|
+
local p="$PWD" w
|
|
20
|
+
if [ -n "${MSYSTEM:-}" ] && w=$(pwd -W 2>/dev/null); then p="/$w"; fi
|
|
21
|
+
printf '\033]7;file://%s%s\007' "${HOSTNAME:-localhost}" "${p//%/%25}"
|
|
22
|
+
return $__db_ec
|
|
23
|
+
}
|
|
24
|
+
__db_ps1() {
|
|
25
|
+
case "$PS1" in
|
|
26
|
+
*'133;D'*) __db_own_marks=1 ;;
|
|
27
|
+
*) __db_own_marks= ;;
|
|
28
|
+
esac
|
|
29
|
+
case "$PS1" in
|
|
30
|
+
*'133;B'*) ;;
|
|
31
|
+
*) PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]' ;;
|
|
32
|
+
esac
|
|
33
|
+
# Checked every prompt: a PROMPT_COMMAND may reset PS0.
|
|
34
|
+
if [ -n "${__db_ps0:-}" ]; then
|
|
35
|
+
case "${PS0:-}" in
|
|
36
|
+
*'133;C'*) ;;
|
|
37
|
+
*) PS0="${PS0:-}"$'\e]133;C\a' ;;
|
|
38
|
+
esac
|
|
39
|
+
fi
|
|
40
|
+
return $__db_ec
|
|
41
|
+
}
|
|
42
|
+
if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
|
|
43
|
+
PROMPT_COMMAND=(__db_precmd "${PROMPT_COMMAND[@]}" __db_ps1)
|
|
44
|
+
else
|
|
45
|
+
PROMPT_COMMAND=$'__db_precmd\n'"${PROMPT_COMMAND:-}"$'\n__db_ps1'
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
|
|
49
|
+
__db_ps0=1
|
|
50
|
+
elif [ -z "$(trap -p DEBUG)" ]; then
|
|
51
|
+
__db_at_prompt=
|
|
52
|
+
__db_preexec() {
|
|
53
|
+
[ -n "${COMP_LINE:-}" ] && return
|
|
54
|
+
[ -z "$__db_at_prompt" ] && return
|
|
55
|
+
__db_at_prompt=
|
|
56
|
+
[ "$BASH_COMMAND" = __db_precmd ] && return
|
|
57
|
+
printf '\033]133;C\007'
|
|
58
|
+
}
|
|
59
|
+
trap '__db_preexec' DEBUG
|
|
60
|
+
PROMPT_COMMAND="$PROMPT_COMMAND"$'\n__db_at_prompt=1'
|
|
61
|
+
fi
|
|
62
|
+
fi
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# DeckBuilder shell integration for fish, run with --init-command after the user's config.
|
|
2
|
+
if not set -q __db_si
|
|
3
|
+
set -g __db_si 1
|
|
4
|
+
function __db_prompt --on-event fish_prompt
|
|
5
|
+
printf '\e]7;file://%s%s\a' $hostname (string replace -a % %25 -- $PWD)
|
|
6
|
+
end
|
|
7
|
+
function __db_preexec --on-event fish_preexec
|
|
8
|
+
printf '\e]133;C\a'
|
|
9
|
+
end
|
|
10
|
+
function __db_postexec --on-event fish_postexec
|
|
11
|
+
printf '\e]133;D;%s\a' $status
|
|
12
|
+
end
|
|
13
|
+
if functions -q fish_prompt
|
|
14
|
+
functions -c fish_prompt __db_user_prompt
|
|
15
|
+
function fish_prompt
|
|
16
|
+
printf '\e]133;A\a'
|
|
17
|
+
__db_user_prompt
|
|
18
|
+
printf '\e]133;B\a'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|