| 1 | // OSC payload -> shell-integration mark. |
| 2 | // |
| 3 | // Two dialects, same shape. OSC 133 is the FinalTerm/iTerm2 semantic-prompt |
| 4 | // convention every shell-integration script emits; OSC 633 is VS Code's |
| 5 | // superset, which adds the command line itself (`E`) and properties (`P`) — |
| 6 | // the reason we prefer it when both are present. Everything here is a pure |
| 7 | // string->value mapping; the state machine lives in commands.ts. |
| 8 | // |
| 9 | // Unrecognized payloads return null by design: a scrollback replay can open |
| 10 | // mid-sequence and hand this layer a torn prefix, and shells emit plenty of |
| 11 | // OSC we do not model. |
| 12 | |
| 13 | export type MarkSource = 133 | 633; |
| 14 | |
| 15 | export type ShellMark = |
| 16 | /** `A` — prompt begins. */ |
| 17 | | { kind: "prompt-start"; source: MarkSource } |
| 18 | /** `B` — prompt ends, the user's command line begins. */ |
| 19 | | { kind: "command-start"; source: MarkSource } |
| 20 | /** `C` — the command was submitted; output begins. */ |
| 21 | | { kind: "command-executed"; source: MarkSource } |
| 22 | /** `D[;exit]` — the command finished. */ |
| 23 | | { kind: "command-end"; exitCode: number | null; source: MarkSource } |
| 24 | /** `633;E` — the command line, reported rather than read off the screen. */ |
| 25 | | { kind: "command-line"; text: string; source: MarkSource } |
| 26 | /** `P;Key=Value` — Cwd and friends. */ |
| 27 | | { kind: "property"; key: string; value: string; source: MarkSource } |
| 28 | /** `633;SetMark` — a user bookmark, unrelated to command boundaries. */ |
| 29 | | { kind: "set-mark"; source: MarkSource }; |
| 30 | |
| 31 | /** |
| 32 | * Undo the VS Code shell-integration escaping (`\xAB` hex, `\\` literal). |
| 33 | * Applied to `633;E` command lines and `633;P` values, which would otherwise |
| 34 | * be unable to carry `;` or newlines. |
| 35 | */ |
| 36 | export function unescapeMarkValue(text: string): string { |
| 37 | if (!text.includes("\\")) return text; |
| 38 | let out = ""; |
| 39 | for (let i = 0; i < text.length; i++) { |
| 40 | const ch = text[i]!; |
| 41 | if (ch !== "\\") { |
| 42 | out += ch; |
| 43 | continue; |
| 44 | } |
| 45 | const next = text[i + 1]; |
| 46 | if (next === "\\") { |
| 47 | out += "\\"; |
| 48 | i++; |
| 49 | } else if (next === "x" || next === "X") { |
| 50 | const hex = text.slice(i + 2, i + 4); |
| 51 | if (/^[0-9a-fA-F]{2}$/.test(hex)) { |
| 52 | out += String.fromCharCode(Number.parseInt(hex, 16)); |
| 53 | i += 3; |
| 54 | } else { |
| 55 | out += ch; |
| 56 | } |
| 57 | } else { |
| 58 | out += ch; |
| 59 | } |
| 60 | } |
| 61 | return out; |
| 62 | } |
| 63 | |
| 64 | /** Parse one OSC payload (the bytes between `ESC ]` and the terminator). */ |
| 65 | export function parseShellMark(payload: string): ShellMark | null { |
| 66 | const semi = payload.indexOf(";"); |
| 67 | if (semi <= 0) return null; |
| 68 | const code = payload.slice(0, semi); |
| 69 | if (code !== "133" && code !== "633") return null; |
| 70 | const source: MarkSource = code === "633" ? 633 : 133; |
| 71 | const body = payload.slice(semi + 1); |
| 72 | const parts = body.split(";"); |
| 73 | const command = parts[0] ?? ""; |
| 74 | |
| 75 | switch (command) { |
| 76 | case "A": |
| 77 | return { kind: "prompt-start", source }; |
| 78 | case "B": |
| 79 | return { kind: "command-start", source }; |
| 80 | case "C": |
| 81 | return { kind: "command-executed", source }; |
| 82 | case "D": { |
| 83 | const raw = parts[1]; |
| 84 | // `D` with no argument means "finished, exit status unknown" — a shell |
| 85 | // that lost the status, or a command interrupted before it reported one. |
| 86 | const exitCode = |
| 87 | raw === undefined || raw === "" ? null : Number.isNaN(Number(raw)) ? null : Number(raw); |
| 88 | return { kind: "command-end", exitCode, source }; |
| 89 | } |
| 90 | case "E": { |
| 91 | // `633;E;<commandline>[;nonce]`. The command line may itself contain |
| 92 | // escaped separators, so rejoin everything but a trailing nonce. |
| 93 | if (source !== 633) return null; |
| 94 | const rest = parts.slice(1); |
| 95 | if (rest.length === 0) return null; |
| 96 | // The nonce (when present) is the last field and never contains an |
| 97 | // escape; treat a trailing field as a nonce only if more than one field |
| 98 | // survived the split. |
| 99 | const text = unescapeMarkValue(rest.length > 1 ? rest.slice(0, -1).join(";") : rest[0]!); |
| 100 | return { kind: "command-line", text, source }; |
| 101 | } |
| 102 | case "P": { |
| 103 | const pair = parts.slice(1).join(";"); |
| 104 | const eq = pair.indexOf("="); |
| 105 | if (eq <= 0) return null; |
| 106 | return { |
| 107 | kind: "property", |
| 108 | key: pair.slice(0, eq), |
| 109 | value: unescapeMarkValue(pair.slice(eq + 1)), |
| 110 | source, |
| 111 | }; |
| 112 | } |
| 113 | case "SetMark": |
| 114 | return { kind: "set-mark", source }; |
| 115 | default: |
| 116 | // F/G (continuation), H/I (right prompt) and anything newer: recognized |
| 117 | // as "not ours" rather than misread. |
| 118 | return null; |
| 119 | } |
| 120 | } |