Open History to see the last commit
1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { ProviderInstanceId } from "./providerInstance.ts";
4
5export const DEFAULT_TERMINAL_ID = "term-1";
6export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;
7
8const historyEncoder = new TextEncoder();
9// A BOM at a retained chunk boundary is terminal data, not an encoding marker.
10const historyDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
11
12// libghostty-vt's parser states, merged where the same input takes them back to ground.
13export const TERMINAL_SEQUENCE_GROUND = 0;
14const SEQUENCE_ESCAPE = 1;
15const SEQUENCE_ESCAPE_INTERMEDIATE = 2;
16const SEQUENCE_CSI = 3;
17const SEQUENCE_DCS = 4;
18const SEQUENCE_SOS_PM_APC = 5;
19const SEQUENCE_OSC = 6;
20
21// Bits above the low three hold a high surrogate until its other half arrives.
22function nextTerminalSequenceState(state: number, unit: number): number {
23 if (unit === 0x1b) return SEQUENCE_ESCAPE;
24 if (state === TERMINAL_SEQUENCE_GROUND || unit === 0x18 || unit === 0x1a) {
25 return TERMINAL_SEQUENCE_GROUND;
26 }
27 let mode = state & 7;
28 if (unit < 0x80) {
29 switch (mode) {
30 case SEQUENCE_ESCAPE:
31 if (unit < 0x20 || unit === 0x7f) return mode;
32 if (unit < 0x30) return SEQUENCE_ESCAPE_INTERMEDIATE;
33 if (unit === 0x5b) return SEQUENCE_CSI;
34 if (unit === 0x5d) return SEQUENCE_OSC;
35 if (unit === 0x50) return SEQUENCE_DCS;
36 return unit === 0x58 || unit === 0x5e || unit === 0x5f
37 ? SEQUENCE_SOS_PM_APC
38 : TERMINAL_SEQUENCE_GROUND;
39 case SEQUENCE_ESCAPE_INTERMEDIATE:
40 return unit >= 0x30 && unit !== 0x7f ? TERMINAL_SEQUENCE_GROUND : mode;
41 case SEQUENCE_CSI:
42 return unit >= 0x40 && unit !== 0x7f ? TERMINAL_SEQUENCE_GROUND : mode;
43 case SEQUENCE_OSC:
44 return unit === 0x07 ? TERMINAL_SEQUENCE_GROUND : mode;
45 default:
46 return mode;
47 }
48 }
49 if (mode === SEQUENCE_OSC) return mode;
50 if (unit >= 0xd800 && unit <= 0xdbff) return mode | (unit << 3);
51 let codePoint = unit;
52 if (unit >= 0xdc00 && unit <= 0xdfff) {
53 if (state < 8) return mode;
54 codePoint = 0x10000 + (((state >> 3) - 0xd800) << 10) + (unit - 0xdc00);
55 }
56 // Outside ground and OSC the parser reads UTF-8 bytes one at a time, so a continuation
57 // byte in 0x80-0x9F acts as a C1 control: most end the sequence, five start another.
58 for (let shift = codePoint > 0xffff ? 12 : codePoint > 0x7ff ? 6 : 0; shift >= 0; shift -= 6) {
59 const byte = 0x80 | ((codePoint >> shift) & 0x3f);
60 if (byte >= 0xa0) continue;
61 if (byte === 0x9d) return SEQUENCE_OSC;
62 if (byte === 0x90) mode = SEQUENCE_DCS;
63 else if (byte === 0x9b) mode = SEQUENCE_CSI;
64 else if (byte === 0x98 || byte >= 0x9e) mode = SEQUENCE_SOS_PM_APC;
65 else return TERMINAL_SEQUENCE_GROUND;
66 }
67 return mode;
68}
69
70/** The parser state after `text.slice(start, end)`, for a terminal that entered it in `state`. */
71export function terminalSequenceStateAfter(
72 state: number,
73 text: string,
74 start = 0,
75 end = text.length,
76): number {
77 if (end <= start) return state;
78 // ESC enters the escape state from every state, so only what follows the last one matters.
79 const escape = text.lastIndexOf("\u001b", end - 1);
80 if (escape >= start) {
81 state = SEQUENCE_ESCAPE;
82 start = escape + 1;
83 }
84 for (let index = start; index < end && state !== TERMINAL_SEQUENCE_GROUND; index += 1) {
85 state = nextTerminalSequenceState(state, text.charCodeAt(index));
86 }
87 return state;
88}
89
90/** Where in `text` a terminal that entered it in `state` is first back in ground state. */
91export function terminalSequenceEnd(state: number, text: string): number {
92 let index = 0;
93 while (state !== TERMINAL_SEQUENCE_GROUND && index < text.length) {
94 state = nextTerminalSequenceState(state, text.charCodeAt(index));
95 index += 1;
96 }
97 return index;
98}
99
100/**
101 * The final `maxBytes` of UTF-8 a client retains, starting at a code point boundary and
102 * outside any control sequence the cut went through.
103 */
104export function retainedTerminalHistory(history: string, maxBytes: number): string {
105 if (maxBytes <= 0) {
106 return "";
107 }
108
109 const encoded = historyEncoder.encode(history);
110 if (encoded.byteLength <= maxBytes) {
111 return history;
112 }
113
114 let start = encoded.byteLength - maxBytes;
115 while (start < encoded.length) {
116 const byte = encoded[start];
117 if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) {
118 break;
119 }
120 start += 1;
121 }
122
123 const retained = historyDecoder.decode(encoded.subarray(start));
124 const state = terminalSequenceStateAfter(
125 TERMINAL_SEQUENCE_GROUND,
126 history,
127 0,
128 history.length - retained.length,
129 );
130 return retained.slice(terminalSequenceEnd(state, retained));
131}
132
133const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
134const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(
135 Schema.isLessThanOrEqualTo(1000),
136);
137const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(
138 Schema.isLessThanOrEqualTo(500),
139);
140const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128));
141const TerminalEnvKeySchema = Schema.String.check(
142 Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
143).check(Schema.isMaxLength(128));
144const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192));
145const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check(
146 Schema.isMaxProperties(128),
147);
148
149export const TerminalThreadInput = Schema.Struct({
150 threadId: TrimmedNonEmptyStringSchema,
151});
152export type TerminalThreadInput = typeof TerminalThreadInput.Type;
153
154// Terminal ids are client-chosen; the server never allocates them.
155const TerminalSessionInput = Schema.Struct({
156 ...TerminalThreadInput.fields,
157 terminalId: TerminalIdSchema,
158});
159export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;
160
161export const TerminalOpenInput = Schema.Struct({
162 ...TerminalSessionInput.fields,
163 cwd: TrimmedNonEmptyStringSchema,
164 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
165 cols: Schema.optional(TerminalColsSchema),
166 rows: Schema.optional(TerminalRowsSchema),
167 env: Schema.optional(TerminalEnvSchema),
168 providerInstanceId: Schema.optional(ProviderInstanceId),
169 stopOnSettle: Schema.optional(Schema.Boolean),
170});
171export type TerminalOpenInput = typeof TerminalOpenInput.Type;
172
173export const TerminalAttachInput = Schema.Struct({
174 ...TerminalSessionInput.fields,
175 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
176 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
177 cols: Schema.optional(TerminalColsSchema),
178 rows: Schema.optional(TerminalRowsSchema),
179 env: Schema.optional(TerminalEnvSchema),
180 providerInstanceId: Schema.optional(ProviderInstanceId),
181 stopOnSettle: Schema.optional(Schema.Boolean),
182 restartIfNotRunning: Schema.optional(Schema.Boolean),
183 // Resubscriptions preserve stopped terminals; unknown IDs recover after a server restart.
184 resume: Schema.optional(Schema.Boolean),
185 // Snapshots carry only the history a client this bounded keeps; older servers ignore it.
186 maxHistoryBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
187});
188export type TerminalAttachInput = typeof TerminalAttachInput.Type;
189
190export const TerminalObserveInput = TerminalSessionInput;
191export type TerminalObserveInput = Schema.Codec.Encoded<typeof TerminalObserveInput>;
192
193export const TerminalWriteInput = Schema.Struct({
194 ...TerminalSessionInput.fields,
195 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),
196});
197export type TerminalWriteInput = Schema.Codec.Encoded<typeof TerminalWriteInput>;
198
199export const TerminalResizeInput = Schema.Struct({
200 ...TerminalSessionInput.fields,
201 cols: TerminalColsSchema,
202 rows: TerminalRowsSchema,
203});
204export type TerminalResizeInput = Schema.Codec.Encoded<typeof TerminalResizeInput>;
205
206export const TerminalClearInput = TerminalSessionInput;
207export type TerminalClearInput = Schema.Codec.Encoded<typeof TerminalClearInput>;
208
209export const TerminalCloseInput = Schema.Struct({
210 ...TerminalThreadInput.fields,
211 terminalId: Schema.optional(TerminalIdSchema),
212 deleteHistory: Schema.optional(Schema.Boolean),
213});
214export type TerminalCloseInput = typeof TerminalCloseInput.Type;
215
216export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]);
217export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;
218
219export const TerminalSessionSnapshot = Schema.Struct({
220 threadId: Schema.String.check(Schema.isNonEmpty()),
221 terminalId: Schema.String.check(Schema.isNonEmpty()),
222 cwd: Schema.String.check(Schema.isNonEmpty()),
223 worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema),
224 status: TerminalSessionStatus,
225 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
226 history: Schema.String,
227 exitCode: Schema.NullOr(Schema.Int),
228 exitSignal: Schema.NullOr(Schema.Int),
229 label: Schema.String.check(Schema.isMaxLength(128)),
230 updatedAt: Schema.String,
231 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
232 // The PTY grid every renderer of this terminal should draw; absent from older servers.
233 cols: Schema.optional(TerminalColsSchema),
234 rows: Schema.optional(TerminalRowsSchema),
235});
236export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;
237
238export const TerminalSummary = Schema.Struct({
239 threadId: Schema.String.check(Schema.isNonEmpty()),
240 terminalId: Schema.String.check(Schema.isNonEmpty()),
241 cwd: Schema.String.check(Schema.isNonEmpty()),
242 worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema),
243 status: TerminalSessionStatus,
244 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
245 exitCode: Schema.NullOr(Schema.Int),
246 exitSignal: Schema.NullOr(Schema.Int),
247 hasRunningSubprocess: Schema.Boolean,
248 label: Schema.String.check(Schema.isMaxLength(128)),
249 updatedAt: Schema.String,
250});
251export type TerminalSummary = typeof TerminalSummary.Type;
252
253const TerminalMetadataSnapshotEvent = Schema.Struct({
254 type: Schema.Literal("snapshot"),
255 terminals: Schema.Array(TerminalSummary),
256});
257
258const TerminalMetadataUpsertEvent = Schema.Struct({
259 type: Schema.Literal("upsert"),
260 terminal: TerminalSummary,
261});
262
263const TerminalMetadataRemoveEvent = Schema.Struct({
264 type: Schema.Literal("remove"),
265 threadId: Schema.String.check(Schema.isNonEmpty()),
266 terminalId: Schema.String.check(Schema.isNonEmpty()),
267});
268
269export const TerminalMetadataStreamEvent = Schema.Union([
270 TerminalMetadataSnapshotEvent,
271 TerminalMetadataUpsertEvent,
272 TerminalMetadataRemoveEvent,
273]);
274export type TerminalMetadataStreamEvent = typeof TerminalMetadataStreamEvent.Type;
275
276const TerminalEventBaseSchema = Schema.Struct({
277 threadId: Schema.String.check(Schema.isNonEmpty()),
278 terminalId: Schema.String.check(Schema.isNonEmpty()),
279 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
280});
281
282const TerminalStartedEvent = Schema.Struct({
283 ...TerminalEventBaseSchema.fields,
284 type: Schema.Literal("started"),
285 snapshot: TerminalSessionSnapshot,
286});
287
288const TerminalOutputEvent = Schema.Struct({
289 ...TerminalEventBaseSchema.fields,
290 type: Schema.Literal("output"),
291 data: Schema.String,
292});
293
294const TerminalExitedEvent = Schema.Struct({
295 ...TerminalEventBaseSchema.fields,
296 type: Schema.Literal("exited"),
297 exitCode: Schema.NullOr(Schema.Int),
298 exitSignal: Schema.NullOr(Schema.Int),
299});
300
301const TerminalClosedEvent = Schema.Struct({
302 ...TerminalEventBaseSchema.fields,
303 type: Schema.Literal("closed"),
304});
305
306const TerminalErrorEvent = Schema.Struct({
307 ...TerminalEventBaseSchema.fields,
308 type: Schema.Literal("error"),
309 message: Schema.String.check(Schema.isNonEmpty()),
310});
311
312const TerminalClearedEvent = Schema.Struct({
313 ...TerminalEventBaseSchema.fields,
314 type: Schema.Literal("cleared"),
315});
316
317const TerminalRestartedEvent = Schema.Struct({
318 ...TerminalEventBaseSchema.fields,
319 type: Schema.Literal("restarted"),
320 snapshot: TerminalSessionSnapshot,
321});
322
323const TerminalActivityEvent = Schema.Struct({
324 ...TerminalEventBaseSchema.fields,
325 type: Schema.Literal("activity"),
326 hasRunningSubprocess: Schema.Boolean,
327 label: Schema.String.check(Schema.isMaxLength(128)),
328 // Shared PTY grid changes ride on activity because older clients reject unknown event types.
329 cols: Schema.optional(TerminalColsSchema),
330 rows: Schema.optional(TerminalRowsSchema),
331});
332
333export const TerminalEvent = Schema.Union([
334 TerminalStartedEvent,
335 TerminalOutputEvent,
336 TerminalExitedEvent,
337 TerminalClosedEvent,
338 TerminalErrorEvent,
339 TerminalClearedEvent,
340 TerminalRestartedEvent,
341 TerminalActivityEvent,
342]);
343export type TerminalEvent = typeof TerminalEvent.Type;
344
345const TerminalAttachSnapshotEvent = Schema.Struct({
346 type: Schema.Literal("snapshot"),
347 snapshot: TerminalSessionSnapshot,
348});
349
350export const TerminalAttachStreamEvent = Schema.Union([
351 TerminalAttachSnapshotEvent,
352 TerminalOutputEvent,
353 TerminalExitedEvent,
354 TerminalClosedEvent,
355 TerminalErrorEvent,
356 TerminalClearedEvent,
357 TerminalRestartedEvent,
358 TerminalActivityEvent,
359]);
360export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type;
361
362export class TerminalCwdNotFoundError extends Schema.TaggedError<TerminalCwdNotFoundError>()(
363 "TerminalCwdNotFoundError",
364 {
365 cwd: Schema.String,
366 },
367) {
368 override get message() {
369 return `Terminal folder does not exist: ${this.cwd}. Restore the folder at this path before retrying.`;
370 }
371}
372
373export class TerminalCwdNotDirectoryError extends Schema.TaggedError<TerminalCwdNotDirectoryError>()(
374 "TerminalCwdNotDirectoryError",
375 {
376 cwd: Schema.String,
377 },
378) {
379 override get message() {
380 return `Terminal cwd is not a directory: ${this.cwd}`;
381 }
382}
383
384export class TerminalCwdStatError extends Schema.TaggedError<TerminalCwdStatError>()(
385 "TerminalCwdStatError",
386 {
387 cwd: Schema.String,
388 cause: Schema.Defect(),
389 },
390) {
391 override get message() {
392 return `Failed to access terminal cwd: ${this.cwd}`;
393 }
394}
395
396export const TerminalCwdError = Schema.Union([
397 TerminalCwdNotFoundError,
398 TerminalCwdNotDirectoryError,
399 TerminalCwdStatError,
400]);
401export type TerminalCwdError = typeof TerminalCwdError.Type;
402
403export class TerminalHistoryError extends Schema.TaggedError<TerminalHistoryError>()(
404 "TerminalHistoryError",
405 {
406 operation: Schema.Literals(["read", "truncate", "migrate"]),
407 threadId: Schema.String,
408 terminalId: Schema.String,
409 cause: Schema.optional(Schema.Defect()),
410 },
411) {
412 override get message() {
413 return `Failed to ${this.operation} terminal history for thread: ${this.threadId}, terminal: ${this.terminalId}`;
414 }
415}
416
417export class TerminalSessionLookupError extends Schema.TaggedError<TerminalSessionLookupError>()(
418 "TerminalSessionLookupError",
419 {
420 threadId: Schema.String,
421 terminalId: Schema.String,
422 },
423) {
424 override get message() {
425 return `Unknown terminal thread: ${this.threadId}, terminal: ${this.terminalId}`;
426 }
427}
428
429export class TerminalProviderInstanceNotFoundError extends Schema.TaggedError<TerminalProviderInstanceNotFoundError>()(
430 "TerminalProviderInstanceNotFoundError",
431 {
432 providerInstanceId: ProviderInstanceId,
433 },
434) {
435 override get message() {
436 return `Provider instance is not available: ${this.providerInstanceId}`;
437 }
438}
439
440export class TerminalProviderEnvironmentError extends Schema.TaggedError<TerminalProviderEnvironmentError>()(
441 "TerminalProviderEnvironmentError",
442 {
443 providerInstanceId: ProviderInstanceId,
444 cause: Schema.Defect(),
445 },
446) {
447 override get message() {
448 return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`;
449 }
450}
451
452export class TerminalNotRunningError extends Schema.TaggedError<TerminalNotRunningError>()(
453 "TerminalNotRunningError",
454 {
455 threadId: Schema.String,
456 terminalId: Schema.String,
457 },
458) {
459 override get message() {
460 return `Terminal is not running for thread: ${this.threadId}, terminal: ${this.terminalId}`;
461 }
462}
463
464export class TerminalWriteError extends Schema.TaggedError<TerminalWriteError>()(
465 "TerminalWriteError",
466 {
467 threadId: Schema.String,
468 terminalId: Schema.String,
469 terminalPid: Schema.Number,
470 cause: Schema.Defect(),
471 },
472) {
473 override get message() {
474 return `Failed to write to terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid}`;
475 }
476}
477
478export class TerminalResizeError extends Schema.TaggedError<TerminalResizeError>()(
479 "TerminalResizeError",
480 {
481 threadId: Schema.String,
482 terminalId: Schema.String,
483 terminalPid: Schema.Number,
484 cols: TerminalColsSchema,
485 rows: TerminalRowsSchema,
486 cause: Schema.Defect(),
487 },
488) {
489 override get message() {
490 return `Failed to resize terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid} to ${this.cols}x${this.rows}`;
491 }
492}
493
494export const TerminalError = Schema.Union([
495 TerminalCwdError,
496 TerminalHistoryError,
497 TerminalSessionLookupError,
498 TerminalProviderInstanceNotFoundError,
499 TerminalProviderEnvironmentError,
500 TerminalNotRunningError,
501 TerminalWriteError,
502 TerminalResizeError,
503]);
504export type TerminalError = typeof TerminalError.Type;