@wringer/terminal 0.0.0-stage → 0.0.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/dist/build-text-keys.d.ts +2 -0
  4. package/dist/call-sessions.d.ts +3 -0
  5. package/dist/create-ghostty-engine.d.ts +7 -0
  6. package/dist/create-raw-log.d.ts +5 -0
  7. package/dist/create-terminal-session.d.ts +43 -0
  8. package/dist/create-wasm-bridge.d.ts +10 -0
  9. package/dist/encode-ghostty-key.d.ts +8 -0
  10. package/dist/find-screen-match.d.ts +12 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/key-names.d.ts +1 -0
  13. package/dist/parse-ghostty-layout.d.ts +5 -0
  14. package/dist/parse-key.d.ts +2 -0
  15. package/dist/pick-session.d.ts +13 -0
  16. package/dist/pick-unwrapped-text.d.ts +1 -0
  17. package/dist/pick-visible-text.d.ts +1 -0
  18. package/dist/read-ghostty-module.d.ts +12 -0
  19. package/dist/read-ghostty-text.d.ts +2 -0
  20. package/dist/screen-engine.d.ts +30 -0
  21. package/dist/session-service-contract.d.ts +116 -0
  22. package/dist/start-terminal-sessions.d.ts +18 -0
  23. package/dist/terminal.d.ts +99 -0
  24. package/dist/to-session-service-methods.d.ts +6 -0
  25. package/ghostty/ghostty-vt.json +7 -0
  26. package/ghostty/ghostty-vt.wasm +0 -0
  27. package/package.json +45 -4
  28. package/src/build-text-keys.ts +66 -0
  29. package/src/call-sessions.ts +22 -0
  30. package/src/create-ghostty-engine.ts +104 -0
  31. package/src/create-raw-log.ts +49 -0
  32. package/src/create-terminal-session.ts +231 -0
  33. package/src/create-wasm-bridge.ts +85 -0
  34. package/src/encode-ghostty-key.ts +94 -0
  35. package/src/find-screen-match.ts +37 -0
  36. package/src/index.ts +11 -0
  37. package/src/key-names.ts +47 -0
  38. package/src/parse-ghostty-layout.ts +39 -0
  39. package/src/parse-key.ts +83 -0
  40. package/src/pick-session.ts +47 -0
  41. package/src/pick-unwrapped-text.ts +47 -0
  42. package/src/pick-visible-text.ts +8 -0
  43. package/src/read-ghostty-module.ts +26 -0
  44. package/src/read-ghostty-text.ts +56 -0
  45. package/src/screen-engine.ts +41 -0
  46. package/src/session-service-contract.ts +83 -0
  47. package/src/start-terminal-sessions.ts +152 -0
  48. package/src/terminal.ts +323 -0
  49. package/src/to-session-service-methods.ts +34 -0
@@ -0,0 +1,47 @@
1
+ // Each key name an agent may press, mapped to the name of its key in the engine's key table.
2
+ export const keyNames: Readonly<Record<string, string>> = buildKeyNames();
3
+
4
+ function buildKeyNames(): Record<string, string> {
5
+ const names: Record<string, string> = {
6
+ enter: 'ENTER',
7
+ tab: 'TAB',
8
+ space: 'SPACE',
9
+ backspace: 'BACKSPACE',
10
+ escape: 'ESCAPE',
11
+ delete: 'DELETE',
12
+ insert: 'INSERT',
13
+ home: 'HOME',
14
+ end: 'END',
15
+ pageup: 'PAGE_UP',
16
+ pagedown: 'PAGE_DOWN',
17
+ up: 'ARROW_UP',
18
+ down: 'ARROW_DOWN',
19
+ left: 'ARROW_LEFT',
20
+ right: 'ARROW_RIGHT',
21
+ '`': 'BACKQUOTE',
22
+ '\\': 'BACKSLASH',
23
+ '[': 'BRACKET_LEFT',
24
+ ']': 'BRACKET_RIGHT',
25
+ ',': 'COMMA',
26
+ '=': 'EQUAL',
27
+ '-': 'MINUS',
28
+ '.': 'PERIOD',
29
+ "'": 'QUOTE',
30
+ ';': 'SEMICOLON',
31
+ '/': 'SLASH',
32
+ };
33
+
34
+ for (const letter of 'abcdefghijklmnopqrstuvwxyz') {
35
+ names[letter] = letter.toUpperCase();
36
+ }
37
+
38
+ for (const digit of '0123456789') {
39
+ names[digit] = `DIGIT_${digit}`;
40
+ }
41
+
42
+ for (let number = 1; number <= 24; number += 1) {
43
+ names[`f${String(number)}`] = `F${String(number)}`;
44
+ }
45
+
46
+ return names;
47
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+
3
+ const memberSchema = z.record(z.string(), z.number());
4
+ const enumSchema = z.object({ values: memberSchema });
5
+ const usizeSchema = z.union([z.literal(4), z.literal(8)]);
6
+
7
+ const layoutSchema = z.object({
8
+ abi: z.object({ usize_size: usizeSchema }),
9
+ types: z.record(z.string(), z.unknown()),
10
+ });
11
+
12
+ export interface GhosttyLayout {
13
+ readonly usizeSize: 4 | 8;
14
+
15
+ // The value of one member of an enum the library describes, such as `GhosttyKey` and `ENTER`.
16
+ readonly enumValue: (enumName: string, member: string) => number;
17
+ }
18
+
19
+ // The library describes its own ABI as JSON, so the binding reads each enum value and the size of
20
+ // `usize` from it and hard-codes none.
21
+ export function parseGhosttyLayout(json: string): GhosttyLayout {
22
+ const parsed: unknown = JSON.parse(json);
23
+ const layout = layoutSchema.parse(parsed);
24
+
25
+ return {
26
+ usizeSize: layout.abi.usize_size,
27
+ enumValue: (enumName, member) => {
28
+ const described = enumSchema.parse(layout.types[enumName]);
29
+ const members = described.values;
30
+ const value = members[member];
31
+
32
+ if (value === undefined) {
33
+ throw new Error(`The library's ${enumName} has no member ${member}.`);
34
+ }
35
+
36
+ return value;
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,83 @@
1
+ import { keyNames } from './key-names.ts';
2
+ import type { KeyInput, KeyMods } from './screen-engine.ts';
3
+
4
+ const modifierNames: Readonly<Record<string, keyof KeyMods>> = {
5
+ ctrl: 'ctrl',
6
+ control: 'ctrl',
7
+ alt: 'alt',
8
+ option: 'alt',
9
+ shift: 'shift',
10
+ super: 'super',
11
+ cmd: 'super',
12
+ };
13
+
14
+ const keyAliases: Readonly<Record<string, string>> = {
15
+ esc: 'escape',
16
+ return: 'enter',
17
+ del: 'delete',
18
+ ins: 'insert',
19
+ pgup: 'pageup',
20
+ pgdn: 'pagedown',
21
+ ' ': 'space',
22
+ };
23
+
24
+ const modifierPrefix = /^(?<name>[a-z]+)\+(?=.)/u;
25
+
26
+ // Turns `ctrl+c`, `alt+x`, `shift+tab`, `f5` or `up` into a key event, or null for a name the
27
+ // driver does not know. A capital letter is the shifted key.
28
+ export function parseKey(spec: string): KeyInput | null {
29
+ const mods = { shift: false, ctrl: false, alt: false, super: false };
30
+ let rest = spec;
31
+
32
+ for (let match = modifierPrefix.exec(rest); match !== null; match = modifierPrefix.exec(rest)) {
33
+ const modifier = modifierNames[match.groups?.['name']?.toLowerCase() ?? ''];
34
+
35
+ if (modifier === undefined) {
36
+ return null;
37
+ }
38
+
39
+ mods[modifier] = true;
40
+ rest = rest.slice(match[0].length);
41
+ }
42
+
43
+ const isCapital = /^[A-Z]$/u.test(rest);
44
+ const lowered = rest.toLowerCase();
45
+ const key = keyAliases[lowered] ?? lowered;
46
+
47
+ if (!Object.hasOwn(keyNames, key)) {
48
+ return null;
49
+ }
50
+
51
+ const held = { ...mods, shift: mods.shift || isCapital };
52
+
53
+ if (isShiftedSymbol(key, held)) {
54
+ return null;
55
+ }
56
+
57
+ const text = pickKeyText(key, held);
58
+
59
+ return text === undefined ? { key, mods: held } : { key, mods: held, text };
60
+ }
61
+
62
+ // The character that shift gives a digit or a symbol key depends on the keyboard layout, so the
63
+ // driver can't name the text the encoder needs. Without that text, the encoder sends no bytes.
64
+ function isShiftedSymbol(key: string, mods: KeyMods): boolean {
65
+ return mods.shift && !mods.ctrl && !mods.alt && !mods.super && /^[^a-z]$/u.test(key);
66
+ }
67
+
68
+ // A key that no ctrl, alt or super modifies types its character, so the encoder needs that text.
69
+ function pickKeyText(key: string, mods: KeyMods): string | undefined {
70
+ if (mods.ctrl || mods.alt || mods.super) {
71
+ return undefined;
72
+ }
73
+
74
+ if (key === 'space') {
75
+ return ' ';
76
+ }
77
+
78
+ if (key.length !== 1) {
79
+ return undefined;
80
+ }
81
+
82
+ return mods.shift ? key.toUpperCase() : key;
83
+ }
@@ -0,0 +1,47 @@
1
+ export interface SessionSummary {
2
+ readonly id: string;
3
+ readonly running: boolean;
4
+ }
5
+
6
+ export type SessionPick =
7
+ | { readonly kind: 'found'; readonly id: string }
8
+ | { readonly kind: 'refused'; readonly message: string; readonly hint: string };
9
+
10
+ // With no id asked for, the one running session answers. When none runs, the one session that
11
+ // ended answers, so its last screen stays readable.
12
+ export function pickSession(
13
+ sessions: readonly SessionSummary[],
14
+ requested: string | undefined,
15
+ ): SessionPick {
16
+ if (requested !== undefined) {
17
+ return sessions.some((session) => session.id === requested)
18
+ ? { kind: 'found', id: requested }
19
+ : {
20
+ kind: 'refused',
21
+ message: `This run has no terminal session ${requested}.`,
22
+ hint: 'Run `terminal list` for the sessions, or `terminal start <program>` for a new one.',
23
+ };
24
+ }
25
+
26
+ const running = sessions.filter((session) => session.running);
27
+ const candidates = running.length > 0 ? running : sessions;
28
+ const [only] = candidates;
29
+
30
+ if (only === undefined) {
31
+ return {
32
+ kind: 'refused',
33
+ message: 'This run has no terminal session.',
34
+ hint: 'Run `terminal start <program>` first.',
35
+ };
36
+ }
37
+
38
+ if (candidates.length > 1) {
39
+ return {
40
+ kind: 'refused',
41
+ message: `More than one terminal session ${running.length > 0 ? 'runs' : 'exists'}: ${candidates.map((session) => session.id).join(', ')}.`,
42
+ hint: 'Pass --session with one of them.',
43
+ };
44
+ }
45
+
46
+ return { kind: 'found', id: only.id };
47
+ }
@@ -0,0 +1,47 @@
1
+ // Joins each run of soft-wrapped rows into one line. Each logical line takes as many rows as it
2
+ // needs to reach its length. Only the rows from `totalRows - rows` down are the screen, and a line
3
+ // that began above them keeps its visible part.
4
+ export function pickUnwrappedText(
5
+ wrappedText: string,
6
+ logicalLines: readonly string[],
7
+ totalRows: number,
8
+ rows: number,
9
+ ): string {
10
+ const physicalRows = wrappedText.split('\n');
11
+ const top = Math.max(0, totalRows - rows);
12
+ const lines: string[] = [];
13
+ let next = 0;
14
+
15
+ for (const logical of logicalLines) {
16
+ const first = next;
17
+ const lineRows: string[] = [];
18
+ let rest = logical;
19
+
20
+ // The formatter trims the spaces that end a row, so a space at a wrap boundary is missing from
21
+ // the row. The logical line still holds it: the spaces between two rows' text, less the ones
22
+ // that begin the next row, ended this row.
23
+ do {
24
+ const row = physicalRows[next] ?? rest;
25
+ const nextRow = physicalRows[next + 1];
26
+
27
+ rest = rest.slice(row.length);
28
+ next += 1;
29
+
30
+ const between = rest.length - rest.trimStart().length;
31
+ const leading = nextRow === undefined ? 0 : nextRow.length - nextRow.trimStart().length;
32
+ const trailing = rest.trim() === '' ? '' : ' '.repeat(Math.max(0, between - leading));
33
+
34
+ rest = rest.slice(trailing.length);
35
+
36
+ lineRows.push(row + trailing);
37
+ } while (rest.trim() !== '' && next < physicalRows.length);
38
+
39
+ const visible = lineRows.slice(Math.max(first, top) - first);
40
+
41
+ if (visible.length > 0) {
42
+ lines.push(visible.join(''));
43
+ }
44
+ }
45
+
46
+ return lines.join('\n');
47
+ }
@@ -0,0 +1,8 @@
1
+ // The formatter prints every row the terminal holds, scrollback included, and drops the blank rows
2
+ // at the end. The screen is the last `rows` physical rows, so the text starts at row
3
+ // `totalRows - rows`.
4
+ export function pickVisibleText(text: string, totalRows: number, rows: number): string {
5
+ const top = Math.max(0, totalRows - rows);
6
+
7
+ return text.split('\n').slice(top).join('\n');
8
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+
3
+ export const ghosttyWasmURL = new URL('../ghostty/ghostty-vt.wasm', import.meta.url);
4
+
5
+ const ghosttyManifestURL = new URL('../ghostty/ghostty-vt.json', import.meta.url);
6
+
7
+ const manifestSchema = z.strictObject({
8
+ tag: z.string(),
9
+ commit: z.string(),
10
+ zig: z.string(),
11
+ optimize: z.string(),
12
+ sha256: z.string(),
13
+ });
14
+
15
+ export async function readGhosttyManifest(): Promise<z.infer<typeof manifestSchema>> {
16
+ const manifest: unknown = await Bun.file(ghosttyManifestURL).json();
17
+
18
+ return manifestSchema.parse(manifest);
19
+ }
20
+
21
+ // Compiles the library once, so each session only instantiates it.
22
+ export async function readGhosttyModule(): Promise<WebAssembly.Module> {
23
+ const bytes = await Bun.file(ghosttyWasmURL).arrayBuffer();
24
+
25
+ return WebAssembly.compile(bytes);
26
+ }
@@ -0,0 +1,56 @@
1
+ import type { WasmBridge } from './create-wasm-bridge.ts';
2
+
3
+ // Prints the terminal's rows as plain text, scrollback included, with the blank rows at the end
4
+ // dropped.
5
+ export function readGhosttyText(bridge: WasmBridge, terminal: number, unwrap = false): string {
6
+ const formatter = bridge.createHandle(
7
+ (slot) => bridge.call('ghostty_formatter_terminal_new', 0, slot, terminal),
8
+ 'create the formatter',
9
+ );
10
+
11
+ const plain = bridge.layout.enumValue('GhosttyFormatterFormat', 'PLAIN');
12
+ const emitOption = bridge.layout.enumValue('GhosttyFormatterOption', 'EMIT');
13
+
14
+ bridge.withScratch(4, (pointer) => {
15
+ new DataView(bridge.memory.buffer).setInt32(pointer, plain, true);
16
+
17
+ bridge.expectSuccess(
18
+ bridge.call('ghostty_formatter_set', formatter, emitOption, pointer),
19
+ 'set the formatter',
20
+ );
21
+ });
22
+
23
+ if (unwrap) {
24
+ const unwrapOption = bridge.layout.enumValue('GhosttyFormatterOption', 'UNWRAP');
25
+
26
+ bridge.withScratch(1, (pointer) => {
27
+ new DataView(bridge.memory.buffer).setUint8(pointer, 1);
28
+
29
+ bridge.expectSuccess(
30
+ bridge.call('ghostty_formatter_set', formatter, unwrapOption, pointer),
31
+ 'set the formatter to unwrap',
32
+ );
33
+ });
34
+ }
35
+
36
+ const outPointer = bridge.call('ghostty_wasm_alloc_opaque');
37
+ const lengthPointer = bridge.call('ghostty_wasm_alloc', bridge.layout.usizeSize);
38
+
39
+ bridge.expectSuccess(
40
+ bridge.call('ghostty_formatter_format_alloc', formatter, 0, outPointer, lengthPointer),
41
+ 'format the screen',
42
+ );
43
+
44
+ const textPointer = bridge.call('ghostty_wasm_take_opaque', outPointer);
45
+
46
+ const length = new DataView(bridge.memory.buffer).getUint32(lengthPointer, true);
47
+ const bytes = new Uint8Array(bridge.memory.buffer, textPointer, length);
48
+ const text = new TextDecoder().decode(bytes);
49
+
50
+ bridge.call('ghostty_free', 0, textPointer, length);
51
+ bridge.call('ghostty_wasm_free_opaque', outPointer);
52
+ bridge.call('ghostty_wasm_free', lengthPointer, bridge.layout.usizeSize);
53
+ bridge.call('ghostty_formatter_free', formatter);
54
+
55
+ return text;
56
+ }
@@ -0,0 +1,41 @@
1
+ export interface KeyMods {
2
+ readonly shift: boolean;
3
+ readonly ctrl: boolean;
4
+ readonly alt: boolean;
5
+ readonly super: boolean;
6
+ }
7
+
8
+ // One key event. `key` is a name from `keyNames`, and `text` is what the key types when no
9
+ // modifier but shift is held.
10
+ export interface KeyInput {
11
+ readonly key: string;
12
+ readonly mods: KeyMods;
13
+ readonly text?: string | undefined;
14
+ }
15
+
16
+ // Rows and columns count from 0, from the top left of the screen.
17
+ export interface CursorState {
18
+ readonly row: number;
19
+ readonly col: number;
20
+ readonly visible: boolean;
21
+ }
22
+
23
+ interface ScreenSize {
24
+ readonly cols: number;
25
+ readonly rows: number;
26
+ }
27
+
28
+ // The screen a program draws on. An engine takes the bytes the program wrote and answers what a
29
+ // reader sees, and turns a key into the bytes the program expects under its current modes.
30
+ export interface ScreenEngine {
31
+ readonly write: (bytes: Uint8Array) => void;
32
+ readonly resize: (size: ScreenSize) => void;
33
+ readonly text: () => string;
34
+
35
+ // The screen text with each soft-wrapped line joined, so a value that wrapped across rows reads
36
+ // whole. A line can be longer than the columns, and its row index no longer matches the cursor's.
37
+ readonly unwrappedText: () => string;
38
+ readonly cursor: () => CursorState;
39
+ readonly encodeKey: (key: KeyInput) => Uint8Array;
40
+ readonly dispose: () => void;
41
+ }
@@ -0,0 +1,83 @@
1
+ import { z } from 'zod';
2
+
3
+ const sessionIDSchema = z.string().min(1).optional();
4
+
5
+ const matchSchema = z.strictObject({
6
+ row: z.number().int().nonnegative(),
7
+ col: z.number().int().nonnegative(),
8
+ });
9
+
10
+ export const screenSchema = z.strictObject({
11
+ text: z.string(),
12
+ cursor: z.strictObject({
13
+ row: z.number().int().nonnegative(),
14
+ col: z.number().int().nonnegative(),
15
+ visible: z.boolean(),
16
+ }),
17
+ cols: z.number().int().positive(),
18
+ rows: z.number().int().positive(),
19
+ });
20
+
21
+ const sessionOnlySchema = z.strictObject({ session: z.string() });
22
+
23
+ export const sessionInfoSchema = z.strictObject({
24
+ session: z.string(),
25
+ program: z.string(),
26
+ pid: z.number().int(),
27
+ running: z.boolean(),
28
+ exitCode: z.number().int().nullable(),
29
+ });
30
+
31
+ // The sessions service crosses from the CLI to the run's supervisor as untyped JSON, so the client
32
+ // parses each answer and the supervisor parses each request against this one table.
33
+ export const sessionServiceContract = {
34
+ start: {
35
+ args: z.strictObject({ program: z.string().min(1) }),
36
+ result: z.strictObject({ session: z.string(), pid: z.number().int() }),
37
+ },
38
+ type: {
39
+ args: z.strictObject({ session: sessionIDSchema, text: z.string() }),
40
+ result: sessionOnlySchema,
41
+ },
42
+ press: {
43
+ args: z.strictObject({ session: sessionIDSchema, keys: z.array(z.string().min(1)).min(1) }),
44
+ result: sessionOnlySchema,
45
+ },
46
+ wait: {
47
+ args: z.strictObject({
48
+ session: sessionIDSchema,
49
+ text: z.string().min(1).optional(),
50
+ regex: z.string().min(1).optional(),
51
+ timeoutMs: z.number().int().positive(),
52
+ }),
53
+ result: z.strictObject({
54
+ session: z.string(),
55
+ match: matchSchema.nullable(),
56
+ screen: screenSchema,
57
+ }),
58
+ },
59
+ screen: {
60
+ args: z.strictObject({ session: sessionIDSchema }),
61
+ result: z.strictObject({ session: z.string(), screen: screenSchema }),
62
+ },
63
+ stop: {
64
+ args: z.strictObject({ session: sessionIDSchema }),
65
+ result: z.strictObject({ session: z.string(), exitCode: z.number().int() }),
66
+ },
67
+ list: {
68
+ args: z.null(),
69
+ result: z.strictObject({ sessions: z.array(sessionInfoSchema) }),
70
+ },
71
+ };
72
+
73
+ type SessionServiceContract = typeof sessionServiceContract;
74
+
75
+ export type SessionMethod = keyof SessionServiceContract;
76
+
77
+ export type SessionArgs<Method extends SessionMethod> = z.output<
78
+ SessionServiceContract[Method]['args']
79
+ >;
80
+
81
+ export type SessionResult<Method extends SessionMethod> = z.output<
82
+ SessionServiceContract[Method]['result']
83
+ >;
@@ -0,0 +1,152 @@
1
+ import { resolve } from 'node:path';
2
+ import { WringerError } from '@wringer/core';
3
+ import type { ServiceInstance } from '@wringer/core';
4
+ import { createGhosttyEngine } from './create-ghostty-engine.ts';
5
+ import { createTerminalSession } from './create-terminal-session.ts';
6
+ import type { TerminalSession } from './create-terminal-session.ts';
7
+ import { pickSession } from './pick-session.ts';
8
+ import type { SessionSummary } from './pick-session.ts';
9
+ import { toSessionServiceMethods } from './to-session-service-methods.ts';
10
+
11
+ interface ProgramConfig {
12
+ readonly command: readonly string[];
13
+ readonly env?: Readonly<Record<string, string>> | undefined;
14
+ readonly cwd?: string | undefined;
15
+ }
16
+
17
+ export interface TerminalSessionsOptions {
18
+ readonly programs: Readonly<Record<string, ProgramConfig>>;
19
+ readonly cols: number;
20
+ readonly rows: number;
21
+ readonly module: WebAssembly.Module;
22
+
23
+ // A relative program `cwd` starts here.
24
+ readonly root: string;
25
+ readonly env: Readonly<Record<string, string | undefined>>;
26
+ readonly rawLogPath: (sessionID: string) => string;
27
+ readonly reportError: (message: string) => void;
28
+ }
29
+
30
+ // Every program a run starts lives here until the run stops, so a screen keeps its state between
31
+ // commands. The service answers only for the programs the target names.
32
+ export function startTerminalSessions(options: TerminalSessionsOptions): ServiceInstance {
33
+ const sessions = new Map<string, TerminalSession>();
34
+ const started = new Map<string, number>();
35
+
36
+ const summarize = (): SessionSummary[] =>
37
+ [...sessions.values()].map((session) => ({ id: session.id, running: session.isRunning() }));
38
+
39
+ const getSession = (requested: string | undefined): TerminalSession => {
40
+ const pick = pickSession(summarize(), requested);
41
+
42
+ if (pick.kind === 'refused') {
43
+ throw new WringerError('usage', { message: pick.message, hint: pick.hint });
44
+ }
45
+
46
+ const session = sessions.get(pick.id);
47
+
48
+ if (session === undefined) {
49
+ throw new WringerError('internal', { message: `Session ${pick.id} is not kept.` });
50
+ }
51
+
52
+ return session;
53
+ };
54
+
55
+ return {
56
+ methods: toSessionServiceMethods({
57
+ start: async (args) => {
58
+ const program = Object.hasOwn(options.programs, args.program)
59
+ ? options.programs[args.program]
60
+ : undefined;
61
+
62
+ if (program === undefined) {
63
+ throw new WringerError('usage', {
64
+ message: `The target has no terminal program ${args.program}.`,
65
+ hint: `Name one of: ${Object.keys(options.programs).join(', ')}.`,
66
+ });
67
+ }
68
+
69
+ const count = (started.get(args.program) ?? 0) + 1;
70
+
71
+ started.set(args.program, count);
72
+
73
+ const id = `${args.program}-${String(count)}`;
74
+
75
+ const session = await createTerminalSession({
76
+ id,
77
+ program: args.program,
78
+ argv: program.command,
79
+ cwd: resolve(options.root, program.cwd ?? '.'),
80
+ env: { ...options.env, ...program.env },
81
+ cols: options.cols,
82
+ rows: options.rows,
83
+ engine: createGhosttyEngine({
84
+ module: options.module,
85
+ cols: options.cols,
86
+ rows: options.rows,
87
+ }),
88
+ rawLogPath: options.rawLogPath(id),
89
+ reportError: options.reportError,
90
+ });
91
+
92
+ sessions.set(id, session);
93
+
94
+ return { session: id, pid: session.pid };
95
+ },
96
+ type: (args) => {
97
+ const session = getSession(args.session);
98
+
99
+ session.writeText(args.text);
100
+
101
+ return Promise.resolve({ session: session.id });
102
+ },
103
+ press: (args) => {
104
+ const session = getSession(args.session);
105
+
106
+ session.writeKeys(args.keys);
107
+
108
+ return Promise.resolve({ session: session.id });
109
+ },
110
+ wait: async (args, signal) => {
111
+ const session = getSession(args.session);
112
+
113
+ const pattern =
114
+ args.regex === undefined
115
+ ? { kind: 'text' as const, text: args.text ?? '' }
116
+ : { kind: 'regex' as const, source: args.regex };
117
+
118
+ const result = await session.waitForScreen(pattern, args.timeoutMs, signal);
119
+
120
+ return { session: session.id, ...result };
121
+ },
122
+ screen: (args) => {
123
+ const session = getSession(args.session);
124
+
125
+ return Promise.resolve({ session: session.id, screen: session.readScreen() });
126
+ },
127
+ stop: async (args) => {
128
+ const session = getSession(args.session);
129
+
130
+ return { session: session.id, exitCode: await session.stop() };
131
+ },
132
+ list: () =>
133
+ Promise.resolve({
134
+ sessions: [...sessions.values()].map((session) => ({
135
+ session: session.id,
136
+ program: session.program,
137
+ pid: session.pid,
138
+ running: session.isRunning(),
139
+ exitCode: session.readExitCode(),
140
+ })),
141
+ }),
142
+ }),
143
+ stop: async () => {
144
+ await Promise.all(
145
+ [...sessions.values()].map(async (session) => {
146
+ await session.stop();
147
+ await session.dispose();
148
+ }),
149
+ );
150
+ },
151
+ };
152
+ }