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
13export type MarkSource = 133 | 633;
14
15export 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 */
36export 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). */
65export 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}