1// Streaming OSC scanner over the raw pty byte stream.
2//
3// Why a second parser at all: libghostty-vt consumes OSC internally and only
4// surfaces the handful it models (title, pwd, clipboard). The shell-integration
5// command model needs OSC 133 and VS Code's OSC 633, which the core does not
6// hand back. This scanner watches the same bytes on the way in.
7//
8// It is deliberately *not* a VT parser. It recognizes exactly one construct —
9// `ESC ]` … terminator — which is enough to lift OSC payloads out, and is
10// resilient to the wire's realities: sequences split across writes (state
11// carries), a replayed scrollback head that opens mid-sequence (garbage
12// payloads fail to parse one layer up), and unbounded OSC floods (capped).
13
14/** Payload cap: longer OSC bodies are dropped, not buffered (ghostty-web #186). */
15const MAX_PAYLOAD = 32 * 1024;
16
17const BEL = 0x07;
18const ESC = 0x1b;
19const CAN = 0x18;
20const SUB = 0x1a;
21const BACKSLASH = 0x5c;
22
23export interface OscSpan {
24 /**
25 * Byte offset in the current chunk one past the sequence terminator. Writes
26 * split here so a mark's anchor sees the cursor exactly where the shell put it.
27 */
28 end: number;
29 /** Payload between `ESC ]` and the terminator, decoded as UTF-8. */
30 payload: string;
31}
32
33// A plain const rather than `const enum`: erasable-syntax-only toolchains
34// (node --experimental-strip-types among them) reject const enums outright.
35const State = {
36 Ground: 0,
37 Esc: 1,
38 Osc: 2,
39 OscEsc: 3,
40} as const;
41
42const decoder = new TextDecoder();
43
44type StateValue = (typeof State)[keyof typeof State];
45
46export class OscScanner {
47 private state: StateValue = State.Ground;
48 private buf: number[] = [];
49 /** Payload blew the cap: consume to the terminator, then emit nothing. */
50 private overflowed = false;
51
52 /**
53 * Feed a chunk; returns the OSC sequences that *ended* in it, in order.
54 * State carries across calls, so a sequence opened in an earlier chunk is
55 * reported against the chunk that closed it.
56 */
57 scan(bytes: Uint8Array): OscSpan[] {
58 const spans: OscSpan[] = [];
59 for (let i = 0; i < bytes.length; i++) {
60 const byte = bytes[i]!;
61 switch (this.state) {
62 case State.Ground: {
63 // Ordinary output is the overwhelming majority of the stream and
64 // contains no ESC. Skip to the next one natively rather than
65 // switch-dispatching per byte.
66 if (byte === ESC) {
67 this.state = State.Esc;
68 break;
69 }
70 const next = bytes.indexOf(ESC, i + 1);
71 if (next === -1) return spans;
72 i = next - 1;
73 break;
74 }
75
76 case State.Esc:
77 // `ESC ]` opens an OSC; anything else is some other escape we ignore
78 // (but a second ESC keeps us in Esc — ESC ESC ] is still an opener).
79 if (byte === 0x5d) {
80 this.state = State.Osc;
81 this.buf = [];
82 this.overflowed = false;
83 } else if (byte !== ESC) {
84 this.state = State.Ground;
85 }
86 break;
87
88 case State.Osc:
89 if (byte === BEL) {
90 this.emit(spans, i + 1);
91 } else if (byte === ESC) {
92 this.state = State.OscEsc;
93 } else if (byte === CAN || byte === SUB) {
94 // xterm's abort characters: the sequence is cancelled, not emitted.
95 this.reset();
96 } else if (!this.overflowed) {
97 this.buf.push(byte);
98 if (this.buf.length > MAX_PAYLOAD) {
99 this.buf = [];
100 this.overflowed = true;
101 }
102 }
103 break;
104
105 case State.OscEsc:
106 if (byte === BACKSLASH) {
107 // ST terminator.
108 this.emit(spans, i + 1);
109 } else {
110 // A bare ESC inside the payload: the sequence is malformed. Drop it
111 // and re-dispatch this byte from ground so a following `ESC ]` is
112 // not swallowed.
113 this.reset();
114 i--;
115 }
116 break;
117 }
118 }
119 return spans;
120 }
121
122 /** True when a sequence is mid-flight and its bytes are still buffered. */
123 get pending(): boolean {
124 return this.state !== State.Ground;
125 }
126
127 /** Drop carried state — e.g. before replaying a scrollback snapshot. */
128 reset(): void {
129 this.state = State.Ground;
130 this.buf = [];
131 this.overflowed = false;
132 }
133
134 private emit(spans: OscSpan[], end: number): void {
135 if (!this.overflowed) spans.push({ end, payload: decodePayload(this.buf) });
136 this.reset();
137 }
138}
139
140function decodePayload(buf: number[]): string {
141 return decoder.decode(new Uint8Array(buf));
142}