@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,66 @@
1
+ import { keyNames } from './key-names.ts';
2
+ import type { KeyInput } from './screen-engine.ts';
3
+
4
+ // Turns text into the key events that type it on a US keyboard. A character that no key types,
5
+ // such as an accented letter, is one event with no key and that text.
6
+ export function buildTextKeys(text: string): KeyInput[] {
7
+ return Array.from(text, (character) => buildCharacterKey(character));
8
+ }
9
+
10
+ const shiftedSymbols: Readonly<Record<string, string>> = {
11
+ '!': '1',
12
+ '@': '2',
13
+ '#': '3',
14
+ $: '4',
15
+ '%': '5',
16
+ '^': '6',
17
+ '&': '7',
18
+ '*': '8',
19
+ '(': '9',
20
+ ')': '0',
21
+ _: '-',
22
+ '+': '=',
23
+ '{': '[',
24
+ '}': ']',
25
+ '|': '\\',
26
+ ':': ';',
27
+ '"': "'",
28
+ '<': ',',
29
+ '>': '.',
30
+ '?': '/',
31
+ '~': '`',
32
+ };
33
+
34
+ const noMods = { shift: false, ctrl: false, alt: false, super: false };
35
+
36
+ function buildCharacterKey(character: string): KeyInput {
37
+ if (character === '\n' || character === '\r') {
38
+ return { key: 'enter', mods: noMods };
39
+ }
40
+
41
+ if (character === '\t') {
42
+ return { key: 'tab', mods: noMods };
43
+ }
44
+
45
+ if (character === ' ') {
46
+ return { key: 'space', mods: noMods, text: ' ' };
47
+ }
48
+
49
+ const lowered = character.toLowerCase();
50
+
51
+ if (lowered !== character && Object.hasOwn(keyNames, lowered)) {
52
+ return { key: lowered, mods: { ...noMods, shift: true }, text: character };
53
+ }
54
+
55
+ const base = shiftedSymbols[character];
56
+
57
+ if (base !== undefined) {
58
+ return { key: base, mods: { ...noMods, shift: true }, text: character };
59
+ }
60
+
61
+ if (Object.hasOwn(keyNames, character)) {
62
+ return { key: character, mods: noMods, text: character };
63
+ }
64
+
65
+ return { key: 'unidentified', mods: noMods, text: character };
66
+ }
@@ -0,0 +1,22 @@
1
+ import type { ServiceCaller } from '@wringer/core';
2
+ import type { z } from 'zod';
3
+ import { sessionServiceContract } from './session-service-contract.ts';
4
+ import type { SessionArgs, SessionMethod, SessionResult } from './session-service-contract.ts';
5
+
6
+ // Indexing this mapped type by a method keeps that method's result type, which indexing the
7
+ // contract's own type loses to a union of every result.
8
+ type SessionResultSchemas = {
9
+ readonly [Method in SessionMethod]: { readonly result: z.ZodType<SessionResult<Method>> };
10
+ };
11
+
12
+ const resultSchemas: SessionResultSchemas = sessionServiceContract;
13
+
14
+ export async function callSessions<Method extends SessionMethod>(
15
+ sessions: ServiceCaller,
16
+ method: Method,
17
+ args: SessionArgs<Method>,
18
+ ): Promise<SessionResult<Method>> {
19
+ const answer = await sessions.call(method, args);
20
+
21
+ return resultSchemas[method].result.parse(answer);
22
+ }
@@ -0,0 +1,104 @@
1
+ import { createWasmBridge } from './create-wasm-bridge.ts';
2
+ import type { WasmBridge } from './create-wasm-bridge.ts';
3
+ import { encodeGhosttyKey } from './encode-ghostty-key.ts';
4
+ import { pickUnwrappedText } from './pick-unwrapped-text.ts';
5
+ import { pickVisibleText } from './pick-visible-text.ts';
6
+ import { readGhosttyText } from './read-ghostty-text.ts';
7
+ import type { CursorState, ScreenEngine } from './screen-engine.ts';
8
+
9
+ export interface GhosttyEngineOptions {
10
+ readonly module: WebAssembly.Module;
11
+ readonly cols: number;
12
+ readonly rows: number;
13
+ }
14
+
15
+ const cellWidthPx = 8;
16
+ const cellHeightPx = 16;
17
+
18
+ // Runs libghostty-vt as WASM with no imports. Each engine owns one instance, so its memory and its
19
+ // terminal die with it. The key encoder reads the terminal's live modes, so a key follows what the
20
+ // program asked for, such as the kitty keyboard protocol.
21
+ export function createGhosttyEngine(options: GhosttyEngineOptions): ScreenEngine {
22
+ const bridge = createWasmBridge(options.module);
23
+
24
+ const terminal = bridge.createHandle(
25
+ (slot) => bridge.call('ghostty_terminal_new', 0, slot, options.cols, options.rows),
26
+ 'create the terminal',
27
+ );
28
+
29
+ const encoder = bridge.createHandle(
30
+ (slot) => bridge.call('ghostty_key_encoder_new', 0, slot),
31
+ 'create the key encoder',
32
+ );
33
+
34
+ const keyEvent = bridge.createHandle(
35
+ (slot) => bridge.call('ghostty_key_event_new', 0, slot),
36
+ 'create the key event',
37
+ );
38
+
39
+ return {
40
+ write: (bytes) => {
41
+ bridge.withScratch(bytes.length, (pointer) => {
42
+ new Uint8Array(bridge.memory.buffer).set(bytes, pointer);
43
+
44
+ bridge.call('ghostty_terminal_vt_write', terminal, pointer, bytes.length);
45
+ });
46
+ },
47
+ resize: (size) => {
48
+ bridge.expectSuccess(
49
+ bridge.call(
50
+ 'ghostty_terminal_resize',
51
+ terminal,
52
+ size.cols,
53
+ size.rows,
54
+ cellWidthPx,
55
+ cellHeightPx,
56
+ ),
57
+ 'resize the terminal',
58
+ );
59
+ },
60
+ text: () =>
61
+ pickVisibleText(
62
+ readGhosttyText(bridge, terminal),
63
+ readTerminalValue(bridge, terminal, 'TOTAL_ROWS', 4).getUint32(0, true),
64
+ readTerminalValue(bridge, terminal, 'ROWS', 2).getUint16(0, true),
65
+ ),
66
+ unwrappedText: () =>
67
+ pickUnwrappedText(
68
+ readGhosttyText(bridge, terminal),
69
+ readGhosttyText(bridge, terminal, true).split('\n'),
70
+ readTerminalValue(bridge, terminal, 'TOTAL_ROWS', 4).getUint32(0, true),
71
+ readTerminalValue(bridge, terminal, 'ROWS', 2).getUint16(0, true),
72
+ ),
73
+ cursor: (): CursorState => ({
74
+ row: readTerminalValue(bridge, terminal, 'CURSOR_Y', 2).getUint16(0, true),
75
+ col: readTerminalValue(bridge, terminal, 'CURSOR_X', 2).getUint16(0, true),
76
+ visible: readTerminalValue(bridge, terminal, 'CURSOR_VISIBLE', 1).getUint8(0) !== 0,
77
+ }),
78
+ encodeKey: (key) => encodeGhosttyKey(bridge, { encoder, keyEvent, terminal }, key),
79
+ dispose: () => {
80
+ bridge.call('ghostty_key_event_free', keyEvent);
81
+ bridge.call('ghostty_key_encoder_free', encoder);
82
+ bridge.call('ghostty_terminal_free', terminal);
83
+ },
84
+ };
85
+ }
86
+
87
+ // Copies the value out, because the library's memory may move when it grows.
88
+ function readTerminalValue(
89
+ bridge: WasmBridge,
90
+ terminal: number,
91
+ member: string,
92
+ size: number,
93
+ ): DataView {
94
+ const data = bridge.layout.enumValue('GhosttyTerminalData', member);
95
+
96
+ return bridge.withScratch(size, (pointer) => {
97
+ bridge.expectSuccess(
98
+ bridge.call('ghostty_terminal_get', terminal, data, pointer),
99
+ `read ${member}`,
100
+ );
101
+
102
+ return new DataView(bridge.memory.buffer.slice(pointer, pointer + size));
103
+ });
104
+ }
@@ -0,0 +1,49 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { createDirectory } from '@wringer/core';
4
+
5
+ export interface RawLog {
6
+ readonly write: (bytes: Uint8Array) => void;
7
+
8
+ // Resolves once every write has landed, so a stop leaves the whole log on disk.
9
+ readonly close: () => Promise<void>;
10
+ }
11
+
12
+ // Appends the bytes a program wrote, in order, to one file. A failed write is reported once, and
13
+ // the log keeps trying later writes, so a full disk never stops the session.
14
+ export async function createRawLog(
15
+ path: string,
16
+ reportError: (message: string) => void,
17
+ ): Promise<RawLog> {
18
+ await createDirectory(dirname(path));
19
+
20
+ const handle = await open(path, 'a');
21
+
22
+ let pending: Promise<void> = Promise.resolve();
23
+ let failed = false;
24
+
25
+ const append = async (previous: Promise<void>, bytes: Uint8Array): Promise<void> => {
26
+ await previous;
27
+
28
+ try {
29
+ await handle.write(bytes);
30
+ } catch (error) {
31
+ if (!failed) {
32
+ failed = true;
33
+
34
+ reportError(`The raw terminal log ${path} stopped: ${String(error)}`);
35
+ }
36
+ }
37
+ };
38
+
39
+ return {
40
+ write: (bytes) => {
41
+ pending = append(pending, Uint8Array.from(bytes));
42
+ },
43
+ close: async () => {
44
+ await pending;
45
+
46
+ await handle.close();
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,231 @@
1
+ import { constants } from 'node:os';
2
+ import { WringerError } from '@wringer/core';
3
+ import { buildTextKeys } from './build-text-keys.ts';
4
+ import { createRawLog } from './create-raw-log.ts';
5
+ import { findScreenMatch } from './find-screen-match.ts';
6
+ import type { ScreenMatch, ScreenPattern } from './find-screen-match.ts';
7
+ import { parseKey } from './parse-key.ts';
8
+ import type { KeyInput, ScreenEngine } from './screen-engine.ts';
9
+
10
+ interface Screen {
11
+ // Each line a program wrapped across rows is one line here, so a scrub sees a value whole.
12
+ readonly text: string;
13
+ readonly cursor: { readonly row: number; readonly col: number; readonly visible: boolean };
14
+ readonly cols: number;
15
+ readonly rows: number;
16
+ }
17
+
18
+ interface WaitResult {
19
+ readonly match: ScreenMatch | null;
20
+ readonly screen: Screen;
21
+ }
22
+
23
+ export interface TerminalSession {
24
+ readonly id: string;
25
+ readonly program: string;
26
+ readonly pid: number;
27
+ readonly isRunning: () => boolean;
28
+ readonly readExitCode: () => number | null;
29
+ readonly readScreen: () => Screen;
30
+ readonly writeText: (text: string) => void;
31
+ readonly writeKeys: (names: readonly string[]) => void;
32
+ readonly waitForScreen: (
33
+ pattern: ScreenPattern,
34
+ timeoutMs: number,
35
+ signal: AbortSignal,
36
+ ) => Promise<WaitResult>;
37
+ readonly stop: () => Promise<number>;
38
+
39
+ // Frees the engine; the session answers no screen after it.
40
+ readonly dispose: () => Promise<void>;
41
+ }
42
+
43
+ export interface TerminalSessionOptions {
44
+ readonly id: string;
45
+ readonly program: string;
46
+ readonly argv: readonly string[];
47
+ readonly cwd: string;
48
+ readonly env: Readonly<Record<string, string | undefined>>;
49
+ readonly cols: number;
50
+ readonly rows: number;
51
+ readonly engine: ScreenEngine;
52
+ readonly rawLogPath: string;
53
+ readonly reportError: (message: string) => void;
54
+ }
55
+
56
+ const termGraceMs = 2000;
57
+
58
+ // How long the end of a program waits for the last bytes of its output.
59
+ const drainMs = 500;
60
+
61
+ // Runs one program in a pseudo-terminal of the given size. Everything the program writes goes to
62
+ // the screen engine and to a raw log, and a waiter wakes after each chunk.
63
+ export async function createTerminalSession(
64
+ options: TerminalSessionOptions,
65
+ ): Promise<TerminalSession> {
66
+ const rawLog = await createRawLog(options.rawLogPath, options.reportError);
67
+
68
+ const listeners = new Set<() => void>();
69
+
70
+ const drained = Promise.withResolvers<void>();
71
+
72
+ const wake = (): void => {
73
+ for (const listener of listeners) {
74
+ listener();
75
+ }
76
+ };
77
+
78
+ let ended = false;
79
+
80
+ const subprocess = Bun.spawn([...options.argv], {
81
+ cwd: options.cwd,
82
+ env: { ...options.env },
83
+ terminal: {
84
+ cols: options.cols,
85
+ rows: options.rows,
86
+ data: (_terminal, data) => {
87
+ options.engine.write(data);
88
+ rawLog.write(data);
89
+
90
+ wake();
91
+ },
92
+ exit: () => {
93
+ drained.resolve();
94
+ },
95
+ },
96
+ });
97
+
98
+ const markEnded = async (): Promise<void> => {
99
+ await subprocess.exited;
100
+
101
+ await Promise.race([drained.promise, Bun.sleep(drainMs)]);
102
+
103
+ ended = true;
104
+
105
+ wake();
106
+ };
107
+
108
+ void markEnded();
109
+
110
+ const readScreen = (): Screen => ({
111
+ text: options.engine.unwrappedText(),
112
+ cursor: options.engine.cursor(),
113
+ cols: options.cols,
114
+ rows: options.rows,
115
+ });
116
+
117
+ const isRunning = (): boolean => !ended;
118
+
119
+ const write = (keys: readonly KeyInput[]): void => {
120
+ if (ended) {
121
+ throw new WringerError('check-failed', {
122
+ message: `Session ${options.id} has exited, so it takes no input.`,
123
+ hint: 'Run `terminal screen` for its last screen, or `terminal start` for a new session.',
124
+ });
125
+ }
126
+
127
+ const chunks = keys.map((key) => options.engine.encodeKey(key));
128
+
129
+ for (const chunk of chunks) {
130
+ subprocess.terminal?.write(chunk);
131
+ }
132
+ };
133
+
134
+ return {
135
+ id: options.id,
136
+ program: options.program,
137
+ pid: subprocess.pid,
138
+ isRunning,
139
+ readExitCode: () => (ended ? readExitCode(subprocess) : null),
140
+ readScreen,
141
+ writeText: (text) => {
142
+ write(buildTextKeys(text));
143
+ },
144
+ writeKeys: (names) => {
145
+ write(names.map((name) => getKey(name)));
146
+ },
147
+ waitForScreen: async (pattern, timeoutMs, signal) => {
148
+ const deadline = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
149
+
150
+ for (;;) {
151
+ const screen = readScreen();
152
+ const match = findScreenMatch(options.engine.text(), pattern);
153
+
154
+ if (match !== null || ended || deadline.aborted) {
155
+ return { match, screen };
156
+ }
157
+
158
+ await waitForChange(listeners, deadline);
159
+ }
160
+ },
161
+ stop: async () => {
162
+ if (!ended) {
163
+ subprocess.kill('SIGTERM');
164
+
165
+ const exited = await Promise.race([
166
+ subprocess.exited.then(() => true),
167
+ Bun.sleep(termGraceMs).then(() => false),
168
+ ]);
169
+
170
+ if (!exited) {
171
+ subprocess.kill('SIGKILL');
172
+ }
173
+
174
+ await subprocess.exited;
175
+ }
176
+
177
+ subprocess.terminal?.close();
178
+ ended = true;
179
+
180
+ return readExitCode(subprocess);
181
+ },
182
+ dispose: async () => {
183
+ options.engine.dispose();
184
+
185
+ await rawLog.close();
186
+ },
187
+ };
188
+ }
189
+
190
+ function readExitCode(subprocess: Bun.Subprocess): number {
191
+ if (subprocess.exitCode !== null) {
192
+ return subprocess.exitCode;
193
+ }
194
+
195
+ const signal = subprocess.signalCode;
196
+
197
+ if (signal === null) {
198
+ return 128;
199
+ }
200
+
201
+ return 128 + (typeof signal === 'number' ? signal : constants.signals[signal]);
202
+ }
203
+
204
+ function getKey(name: string): KeyInput {
205
+ const key = parseKey(name);
206
+
207
+ if (key === null) {
208
+ throw new WringerError('usage', {
209
+ message: `The driver does not know the key ${name}.`,
210
+ hint: 'Name a key such as enter, esc, tab, up, f5, ctrl+c or alt+x. Type a shifted symbol, such as !, with type --text.',
211
+ });
212
+ }
213
+
214
+ return key;
215
+ }
216
+
217
+ async function waitForChange(listeners: Set<() => void>, deadline: AbortSignal): Promise<void> {
218
+ const changed = Promise.withResolvers<void>();
219
+
220
+ const listener = (): void => {
221
+ changed.resolve();
222
+ };
223
+
224
+ listeners.add(listener);
225
+ deadline.addEventListener('abort', listener, { once: true });
226
+
227
+ await changed.promise;
228
+
229
+ listeners.delete(listener);
230
+ deadline.removeEventListener('abort', listener);
231
+ }
@@ -0,0 +1,85 @@
1
+ import { WringerError } from '@wringer/core';
2
+ import { z } from 'zod';
3
+ import { parseGhosttyLayout } from './parse-ghostty-layout.ts';
4
+ import type { GhosttyLayout } from './parse-ghostty-layout.ts';
5
+
6
+ export interface WasmBridge {
7
+ readonly layout: GhosttyLayout;
8
+ readonly memory: WebAssembly.Memory;
9
+ readonly call: (name: string, ...args: number[]) => number;
10
+
11
+ // Runs `use` with a buffer of `size` bytes in the library's memory, and frees it after.
12
+ readonly withScratch: <Result>(size: number, use: (pointer: number) => Result) => Result;
13
+ readonly expectSuccess: (result: number, what: string) => void;
14
+
15
+ // Calls a constructor that writes a handle to a slot, and returns the handle.
16
+ readonly createHandle: (create: (slot: number) => number, what: string) => number;
17
+ }
18
+
19
+ const instanceSchema = z.looseObject({ memory: z.instanceof(WebAssembly.Memory) });
20
+ const resultSchema = z.number().default(0);
21
+
22
+ // Instantiates the library, which takes no imports, and wraps its exports. A call that returns
23
+ // nothing answers 0.
24
+ export function createWasmBridge(module: WebAssembly.Module): WasmBridge {
25
+ const instance = new WebAssembly.Instance(module, {});
26
+
27
+ const memory = instanceSchema.parse(instance.exports).memory;
28
+
29
+ const call = (name: string, ...args: number[]): number => {
30
+ const exported: unknown = instance.exports[name];
31
+
32
+ if (typeof exported !== 'function') {
33
+ throw new WringerError('internal', { message: `The terminal engine has no export ${name}.` });
34
+ }
35
+
36
+ return resultSchema.parse(Reflect.apply(exported, undefined, args));
37
+ };
38
+
39
+ const layout = parseGhosttyLayout(readLayoutJSON(memory, call));
40
+ const success = layout.enumValue('GhosttyResult', 'SUCCESS');
41
+
42
+ const expectSuccess = (result: number, what: string): void => {
43
+ if (result !== success) {
44
+ throw new WringerError('internal', { message: `The terminal engine failed to ${what}.` });
45
+ }
46
+ };
47
+
48
+ return {
49
+ layout,
50
+ memory,
51
+ call,
52
+ expectSuccess,
53
+ withScratch: (size, use) => {
54
+ const pointer = call('ghostty_wasm_alloc', size);
55
+
56
+ try {
57
+ return use(pointer);
58
+ } finally {
59
+ call('ghostty_wasm_free', pointer, size);
60
+ }
61
+ },
62
+ createHandle: (create, what) => {
63
+ const slot = call('ghostty_wasm_alloc_opaque');
64
+
65
+ expectSuccess(create(slot), what);
66
+
67
+ const handle = call('ghostty_wasm_take_opaque', slot);
68
+
69
+ call('ghostty_wasm_free_opaque', slot);
70
+
71
+ return handle;
72
+ },
73
+ };
74
+ }
75
+
76
+ function readLayoutJSON(
77
+ memory: WebAssembly.Memory,
78
+ call: (name: string, ...args: number[]) => number,
79
+ ): string {
80
+ const start = call('ghostty_type_json');
81
+
82
+ const bytes = new Uint8Array(memory.buffer, start);
83
+
84
+ return new TextDecoder().decode(bytes.subarray(0, bytes.indexOf(0)));
85
+ }
@@ -0,0 +1,94 @@
1
+ import { WringerError } from '@wringer/core';
2
+ import type { WasmBridge } from './create-wasm-bridge.ts';
3
+ import { keyNames } from './key-names.ts';
4
+ import type { KeyInput } from './screen-engine.ts';
5
+
6
+ export interface KeyHandles {
7
+ readonly encoder: number;
8
+ readonly keyEvent: number;
9
+ readonly terminal: number;
10
+ }
11
+
12
+ const modBits = { shift: 1, ctrl: 2, alt: 4, super: 8 } as const;
13
+
14
+ // Encodes one key press under the terminal's live modes, and returns the bytes to send.
15
+ export function encodeGhosttyKey(
16
+ bridge: WasmBridge,
17
+ handles: KeyHandles,
18
+ key: KeyInput,
19
+ ): Uint8Array {
20
+ const ghosttyName = keyNames[key.key] ?? 'UNIDENTIFIED';
21
+
22
+ if (ghosttyName === 'UNIDENTIFIED' && key.key !== 'unidentified') {
23
+ throw new WringerError('usage', { message: `The key ${key.key} is not one the driver knows.` });
24
+ }
25
+
26
+ const mods =
27
+ (key.mods.shift ? modBits.shift : 0) |
28
+ (key.mods.ctrl ? modBits.ctrl : 0) |
29
+ (key.mods.alt ? modBits.alt : 0) |
30
+ (key.mods.super ? modBits.super : 0);
31
+
32
+ const unshifted = key.key.length === 1 ? (key.key.codePointAt(0) ?? 0) : 0;
33
+ const keyCode = bridge.layout.enumValue('GhosttyKey', ghosttyName);
34
+ const press = bridge.layout.enumValue('GhosttyKeyAction', 'PRESS');
35
+
36
+ bridge.expectSuccess(
37
+ bridge.call('ghostty_key_encoder_set_from_terminal', handles.encoder, handles.terminal),
38
+ 'sync the key encoder',
39
+ );
40
+
41
+ bridge.call('ghostty_key_event_set_action', handles.keyEvent, press);
42
+ bridge.call('ghostty_key_event_set_key', handles.keyEvent, keyCode);
43
+ bridge.call('ghostty_key_event_set_mods', handles.keyEvent, mods);
44
+ bridge.call('ghostty_key_event_set_consumed_mods', handles.keyEvent, 0);
45
+ bridge.call('ghostty_key_event_set_unshifted_codepoint', handles.keyEvent, unshifted);
46
+
47
+ writeKeyText(bridge, handles.keyEvent, new TextEncoder().encode(key.text ?? ''));
48
+
49
+ return readEncodedKey(bridge, handles);
50
+ }
51
+
52
+ // The library takes its string as a pointer to a pointer and a length.
53
+ function writeKeyText(bridge: WasmBridge, keyEvent: number, text: Uint8Array): void {
54
+ bridge.withScratch(text.length + 8, (pointer) => {
55
+ const textPointer = text.length === 0 ? 0 : pointer + 8;
56
+
57
+ const view = new DataView(bridge.memory.buffer);
58
+
59
+ new Uint8Array(bridge.memory.buffer).set(text, pointer + 8);
60
+
61
+ view.setUint32(pointer, textPointer, true);
62
+ view.setUint32(pointer + 4, text.length, true);
63
+
64
+ bridge.expectSuccess(
65
+ bridge.call('ghostty_key_event_set_utf8', keyEvent, pointer),
66
+ 'set the key text',
67
+ );
68
+ });
69
+ }
70
+
71
+ // Far longer than any key sequence.
72
+ const maxKeyBytes = 64;
73
+
74
+ function readEncodedKey(bridge: WasmBridge, handles: KeyHandles): Uint8Array {
75
+ return bridge.withScratch(maxKeyBytes + bridge.layout.usizeSize, (pointer) => {
76
+ const writtenPointer = pointer + maxKeyBytes;
77
+
78
+ bridge.expectSuccess(
79
+ bridge.call(
80
+ 'ghostty_key_encoder_encode',
81
+ handles.encoder,
82
+ handles.keyEvent,
83
+ pointer,
84
+ maxKeyBytes,
85
+ writtenPointer,
86
+ ),
87
+ 'encode the key',
88
+ );
89
+
90
+ const written = new DataView(bridge.memory.buffer).getUint32(writtenPointer, true);
91
+
92
+ return new Uint8Array(bridge.memory.buffer.slice(pointer, pointer + written));
93
+ });
94
+ }
@@ -0,0 +1,37 @@
1
+ export type ScreenPattern =
2
+ | { readonly kind: 'text'; readonly text: string }
3
+ | { readonly kind: 'regex'; readonly source: string };
4
+
5
+ export interface ScreenMatch {
6
+ readonly row: number;
7
+ readonly col: number;
8
+ }
9
+
10
+ // The first match in reading order, as the row and the column of its first character, both from 0.
11
+ // The pattern's regex is checked when the command is parsed, so a bad one here matches nothing.
12
+ export function findScreenMatch(text: string, pattern: ScreenPattern): ScreenMatch | null {
13
+ const lines = text.split('\n');
14
+
15
+ if (pattern.kind === 'text') {
16
+ return findInLines(lines, (line) => line.indexOf(pattern.text));
17
+ }
18
+
19
+ const regex = new RegExp(pattern.source, 'u');
20
+
21
+ return findInLines(lines, (line) => line.search(regex));
22
+ }
23
+
24
+ function findInLines(
25
+ lines: readonly string[],
26
+ locate: (line: string) => number,
27
+ ): ScreenMatch | null {
28
+ for (const [row, line] of lines.entries()) {
29
+ const col = locate(line);
30
+
31
+ if (col >= 0) {
32
+ return { row, col };
33
+ }
34
+ }
35
+
36
+ return null;
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { terminal } from './terminal.ts';
2
+
3
+ export type {
4
+ TerminalClient,
5
+ TerminalScreen,
6
+ TerminalSessionInfo,
7
+ TerminalStartRequest,
8
+ TerminalWaitRequest,
9
+ } from './terminal.ts';
10
+
11
+ export type { CursorState, KeyInput, KeyMods, ScreenEngine } from './screen-engine.ts';