Path: blob/main/src/vs/platform/agentHost/node/osc633Parser.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45/**6* Lightweight parser for OSC 633 (VS Code shell integration) sequences in raw7* PTY output. Designed for the agent host where we don't have a full xterm.js8* instance - it scans data chunks for the sequences, extracts events, and9* removes the sequences from the data stream.10*11* Handles partial sequences that span across data chunk boundaries.12*/1314/** OSC 633 event types we care about. */15export const enum Osc633EventType {16/** 633;A - Prompt start. Used to detect shell integration is active. */17PromptStart,18/** 633;B - Command start (where user inputs command). */19CommandStart,20/** 633;C - Command executed (output begins). */21CommandExecuted,22/** 633;D[;exitCode] - Command finished. */23CommandFinished,24/** 633;E;commandLine[;nonce] - Explicit command line. */25CommandLine,26/** 633;P;Key=Value - Property (e.g. Cwd). */27Property,28}2930export interface IOsc633PromptStartEvent {31type: Osc633EventType.PromptStart;32}3334export interface IOsc633CommandStartEvent {35type: Osc633EventType.CommandStart;36}3738export interface IOsc633CommandExecutedEvent {39type: Osc633EventType.CommandExecuted;40}4142export interface IOsc633CommandFinishedEvent {43type: Osc633EventType.CommandFinished;44exitCode: number | undefined;45}4647export interface IOsc633CommandLineEvent {48type: Osc633EventType.CommandLine;49commandLine: string;50nonce: string | undefined;51}5253export interface IOsc633PropertyEvent {54type: Osc633EventType.Property;55key: string;56value: string;57}5859export type Osc633Event =60| IOsc633PromptStartEvent61| IOsc633CommandStartEvent62| IOsc633CommandExecutedEvent63| IOsc633CommandFinishedEvent64| IOsc633CommandLineEvent65| IOsc633PropertyEvent;6667export interface IOsc633ParseResult {68/** Data with all OSC 633 sequences stripped. */69cleanedData: string;70/** Parsed events in order of appearance. */71events: Osc633Event[];72}7374/**75* Decode escaped values in OSC 633 messages.76* Handles `\\` -> `\` and `\xAB` -> character with code 0xAB.77*/78function deserializeOscMessage(message: string): string {79if (message.indexOf('\\') === -1) {80return message;81}82return message.replaceAll(83/\\(\\|x([0-9a-f]{2}))/gi,84(_match: string, op: string, hex?: string) => hex ? String.fromCharCode(parseInt(hex, 16)) : op,85);86}8788function parseOsc633Payload(payload: string): Osc633Event | undefined {89const semiIdx = payload.indexOf(';');90if ((semiIdx === -1 ? payload.length : semiIdx) !== 1) {91return undefined;92}9394const command = payload[0];95const argsRaw = semiIdx === -1 ? '' : payload.substring(semiIdx + 1);9697switch (command) {98case 'A':99return { type: Osc633EventType.PromptStart };100case 'B':101return { type: Osc633EventType.CommandStart };102case 'C':103return { type: Osc633EventType.CommandExecuted };104case 'D': {105const exitCode = argsRaw.length > 0 ? parseInt(argsRaw, 10) : undefined;106return {107type: Osc633EventType.CommandFinished,108exitCode: exitCode !== undefined && !isNaN(exitCode) ? exitCode : undefined,109};110}111case 'E': {112const nonceIdx = argsRaw.indexOf(';');113const commandLine = deserializeOscMessage(nonceIdx === -1 ? argsRaw : argsRaw.substring(0, nonceIdx));114const nonce = nonceIdx === -1 ? undefined : argsRaw.substring(nonceIdx + 1);115return { type: Osc633EventType.CommandLine, commandLine, nonce };116}117case 'P': {118const deserialized = deserializeOscMessage(argsRaw);119const eqIdx = deserialized.indexOf('=');120if (eqIdx === -1) {121return undefined;122}123return {124type: Osc633EventType.Property,125key: deserialized.substring(0, eqIdx),126value: deserialized.substring(eqIdx + 1),127};128}129default:130return undefined;131}132}133134// OSC introducer is ESC ] (0x1b 0x5d)135const ESC = '\x1b';136const OSC_START = ESC + ']';137// Terminators: BEL (0x07) or ST (ESC \)138const BEL = '\x07';139const ST = ESC + '\\';140141/**142* Stateful parser that handles data chunks, correctly dealing with143* partial sequences that span multiple chunks.144*/145export class Osc633Parser {146/** Buffer for an incomplete OSC sequence (from ESC] up to but not including the terminator). */147private _pendingOsc = '';148/** Whether we are currently accumulating an OSC sequence. */149private _inOsc = false;150/** Set when the previous chunk ended with ESC inside an OSC body (potential ST start). */151private _pendingEscInOsc = false;152153/**154* Parse a chunk of PTY data.155* Returns cleaned data (all OSC 633 sequences removed) and extracted events.156*/157parse(data: string): IOsc633ParseResult {158const events: Osc633Event[] = [];159if (!this._inOsc && data.indexOf(OSC_START) === -1) {160return { cleanedData: data, events };161}162163let cleaned = '';164let i = 0;165166while (i < data.length) {167if (this._inOsc) {168// Handle ESC that was pending from the previous chunk.169if (this._pendingEscInOsc) {170this._pendingEscInOsc = false;171if (data[i] === '\\') {172// ESC \ = ST terminator, sequence is complete.173i++;174this._inOsc = false;175const payload = this._pendingOsc;176this._pendingOsc = '';177this._handleOscPayload(payload, events, { value: cleaned, append(s: string) { cleaned = s; } }, ST);178continue;179}180// ESC was not followed by \, malformed: complete the OSC anyway.181this._inOsc = false;182const payload = this._pendingOsc;183this._pendingOsc = '';184this._handleOscPayload(payload, events, { value: cleaned, append(s: string) { cleaned = s; } });185continue;186}187188// We're inside an OSC sequence, look for the terminator.189const result = this._consumeOscBody(data, i);190i = result.nextIndex;191if (result.complete) {192this._inOsc = false;193const payload = this._pendingOsc;194this._pendingOsc = '';195this._handleOscPayload(payload, events, { value: cleaned, append(s: string) { cleaned = s; } }, result.terminator);196} else if (result.pendingEsc) {197this._pendingEscInOsc = true;198}199// If not complete, _pendingOsc has been extended, and we're at end of data.200continue;201}202203// Look for the next ESC ] which starts an OSC sequence204const escIdx = data.indexOf(OSC_START, i);205if (escIdx === -1) {206cleaned += data.substring(i);207i = data.length;208continue;209}210211// Copy everything before the OSC start to cleaned output.212cleaned += data.substring(i, escIdx);213214// Start of OSC: check if it's 633.215i = escIdx + 2; // skip past ESC ]216this._pendingOsc = '';217this._inOsc = true;218219// Try to consume the OSC body in this same chunk.220const result = this._consumeOscBody(data, i);221i = result.nextIndex;222if (result.complete) {223this._inOsc = false;224const payload = this._pendingOsc;225this._pendingOsc = '';226// If it's a 633 sequence, extract event; otherwise put it back in cleaned.227this._handleOscPayload(payload, events, { value: cleaned, append(s: string) { cleaned = s; } }, result.terminator);228} else if (result.pendingEsc) {229this._pendingEscInOsc = true;230}231// If not complete, we're at end of data and _pendingOsc is buffered.232}233234return { cleanedData: cleaned, events };235}236237/**238* Consume characters from the OSC body, appending to _pendingOsc until a239* terminator (BEL or ST) is found.240*/241private _consumeOscBody(data: string, startIdx: number): { nextIndex: number; complete: boolean; pendingEsc?: boolean; terminator?: string } {242const belIdx = data.indexOf(BEL, startIdx);243const escIdx = data.indexOf(ESC, startIdx);244245if (belIdx !== -1 && (escIdx === -1 || belIdx < escIdx)) {246this._pendingOsc += data.substring(startIdx, belIdx);247return { nextIndex: belIdx + 1, complete: true, terminator: BEL };248}249250if (escIdx !== -1) {251if (escIdx + 1 >= data.length) {252this._pendingOsc += data.substring(startIdx, escIdx);253return { nextIndex: data.length, complete: false, pendingEsc: true };254}255256this._pendingOsc += data.substring(startIdx, escIdx);257if (data[escIdx + 1] === '\\') {258return { nextIndex: escIdx + 2, complete: true, terminator: ST };259}260261return { nextIndex: escIdx, complete: true };262}263264this._pendingOsc += data.substring(startIdx);265return { nextIndex: data.length, complete: false };266}267268/**269* Process a complete OSC payload. If it's a 633; sequence, extract the270* event. Otherwise, reconstruct the original bytes and pass them through271* to cleaned output.272*/273private _handleOscPayload(274payload: string,275events: Osc633Event[],276cleanedRef: { value: string; append(s: string): void } | undefined,277terminator = BEL,278): void {279if (payload.startsWith('633;')) {280const oscContent = payload.substring(4); // strip "633;"281const event = parseOsc633Payload(oscContent);282if (event) {283events.push(event);284}285// 633 sequences are always stripped from output286} else {287// Non-633 OSC: put back the original bytes.288if (cleanedRef) {289cleanedRef.append(cleanedRef.value + OSC_START + payload + terminator);290}291}292}293}294295296