| 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). */ |
| 15 | const MAX_PAYLOAD = 32 * 1024; |
| 16 | |
| 17 | const BEL = 0x07; |
| 18 | const ESC = 0x1b; |
| 19 | const CAN = 0x18; |
| 20 | const SUB = 0x1a; |
| 21 | const BACKSLASH = 0x5c; |
| 22 | |
| 23 | export 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. |
| 35 | const State = { |
| 36 | Ground: 0, |
| 37 | Esc: 1, |
| 38 | Osc: 2, |
| 39 | OscEsc: 3, |
| 40 | } as const; |
| 41 | |
| 42 | const decoder = new TextDecoder(); |
| 43 | |
| 44 | type StateValue = (typeof State)[keyof typeof State]; |
| 45 | |
| 46 | export 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 | |
| 140 | function decodePayload(buf: number[]): string { |
| 141 | return decoder.decode(new Uint8Array(buf)); |
| 142 | } |