source dump of claude code
at main 493 lines 17 kB view raw
1/** 2 * OSC (Operating System Command) Types and Parser 3 */ 4 5import { Buffer } from 'buffer' 6import { env } from '../../utils/env.js' 7import { execFileNoThrow } from '../../utils/execFileNoThrow.js' 8import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js' 9import type { Action, Color, TabStatusAction } from './types.js' 10 11export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC) 12 13/** String Terminator (ESC \) - alternative to BEL for terminating OSC */ 14export const ST = ESC + '\\' 15 16/** Generate an OSC sequence: ESC ] p1;p2;...;pN <terminator> 17 * Uses ST terminator for Kitty (avoids beeps), BEL for others */ 18export function osc(...parts: (string | number)[]): string { 19 const terminator = env.terminal === 'kitty' ? ST : BEL 20 return `${OSC_PREFIX}${parts.join(SEP)}${terminator}` 21} 22 23/** 24 * Wrap an escape sequence for terminal multiplexer passthrough. 25 * tmux and GNU screen intercept escape sequences; DCS passthrough 26 * tunnels them to the outer terminal unmodified. 27 * 28 * tmux 3.3+ gates this behind `allow-passthrough` (default off). When off, 29 * tmux silently drops the whole DCS — no junk, no worse than unwrapped OSC. 30 * Users who want passthrough set it in their .tmux.conf; we don't mutate it. 31 * 32 * Do NOT wrap BEL: raw \x07 triggers tmux's bell-action (window flag); 33 * wrapped \x07 is opaque DCS payload and tmux never sees the bell. 34 */ 35export function wrapForMultiplexer(sequence: string): string { 36 if (process.env['TMUX']) { 37 const escaped = sequence.replaceAll('\x1b', '\x1b\x1b') 38 return `\x1bPtmux;${escaped}\x1b\\` 39 } 40 if (process.env['STY']) { 41 return `\x1bP${sequence}\x1b\\` 42 } 43 return sequence 44} 45 46/** 47 * Which path setClipboard() will take, based on env state. Synchronous so 48 * callers can show an honest toast without awaiting the copy itself. 49 * 50 * - 'native': pbcopy (or equivalent) will run — high-confidence system 51 * clipboard write. tmux buffer may also be loaded as a bonus. 52 * - 'tmux-buffer': tmux load-buffer will run, but no native tool — paste 53 * with prefix+] works. System clipboard depends on tmux's set-clipboard 54 * option + outer terminal OSC 52 support; can't know from here. 55 * - 'osc52': only the raw OSC 52 sequence will be written to stdout. 56 * Best-effort; iTerm2 disables OSC 52 by default. 57 * 58 * pbcopy gating uses SSH_CONNECTION specifically, not SSH_TTY — tmux panes 59 * inherit SSH_TTY forever even after local reattach, but SSH_CONNECTION is 60 * in tmux's default update-environment set and gets cleared. 61 */ 62export type ClipboardPath = 'native' | 'tmux-buffer' | 'osc52' 63 64export function getClipboardPath(): ClipboardPath { 65 const nativeAvailable = 66 process.platform === 'darwin' && !process.env['SSH_CONNECTION'] 67 if (nativeAvailable) return 'native' 68 if (process.env['TMUX']) return 'tmux-buffer' 69 return 'osc52' 70} 71 72/** 73 * Wrap a payload in tmux's DCS passthrough: ESC P tmux ; <payload> ESC \ 74 * tmux forwards the payload to the outer terminal, bypassing its own parser. 75 * Inner ESCs must be doubled. Requires `set -g allow-passthrough on` in 76 * ~/.tmux.conf; without it, tmux silently drops the whole DCS (no regression). 77 */ 78function tmuxPassthrough(payload: string): string { 79 return `${ESC}Ptmux;${payload.replaceAll(ESC, ESC + ESC)}${ST}` 80} 81 82/** 83 * Load text into tmux's paste buffer via `tmux load-buffer`. 84 * -w (tmux 3.2+) propagates to the outer terminal's clipboard via tmux's 85 * own OSC 52 emission. -w is dropped for iTerm2: tmux's OSC 52 emission 86 * crashes the iTerm2 session over SSH. 87 * 88 * Returns true if the buffer was loaded successfully. 89 */ 90export async function tmuxLoadBuffer(text: string): Promise<boolean> { 91 if (!process.env['TMUX']) return false 92 const args = 93 process.env['LC_TERMINAL'] === 'iTerm2' 94 ? ['load-buffer', '-'] 95 : ['load-buffer', '-w', '-'] 96 const { code } = await execFileNoThrow('tmux', args, { 97 input: text, 98 useCwd: false, 99 timeout: 2000, 100 }) 101 return code === 0 102} 103 104/** 105 * OSC 52 clipboard write: ESC ] 52 ; c ; <base64> BEL/ST 106 * 'c' selects the clipboard (vs 'p' for primary selection on X11). 107 * 108 * When inside tmux ($TMUX set), `tmux load-buffer -w -` is the primary 109 * path. tmux's buffer is always reachable — works over SSH, survives 110 * detach/reattach, immune to stale env vars. The -w flag (tmux 3.2+) tells 111 * tmux to also propagate to the outer terminal via its own OSC 52 path, 112 * which tmux wraps correctly for the attached client. On older tmux, -w is 113 * ignored and the buffer is still loaded. -w is dropped for iTerm2 (#22432) 114 * because tmux's own OSC 52 emission (empty selection param: ESC]52;;b64) 115 * crashes iTerm2 over SSH. 116 * 117 * After load-buffer succeeds, we ALSO return a DCS-passthrough-wrapped 118 * OSC 52 for the caller to write to stdout. Our sequence uses explicit `c` 119 * (not tmux's crashy empty-param variant), so it sidesteps the #22432 path. 120 * With `allow-passthrough on` + an OSC-52-capable outer terminal, selection 121 * reaches the system clipboard; with either off, tmux silently drops the 122 * DCS and prefix+] still works. See Greg Smith's "free pony" in 123 * https://anthropic.slack.com/archives/C07VBSHV7EV/p1773177228548119. 124 * 125 * If load-buffer fails entirely, fall through to raw OSC 52. 126 * 127 * Outside tmux, write raw OSC 52 to stdout (caller handles the write). 128 * 129 * Local (no SSH_CONNECTION): also shell out to a native clipboard utility. 130 * OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables 131 * OSC 52 by default, VS Code shows a permission prompt on first use. Native 132 * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over 133 * SSH these would write to the remote clipboard — OSC 52 is the right path there. 134 * 135 * Returns the sequence for the caller to write to stdout (raw OSC 52 136 * outside tmux, DCS-wrapped inside). 137 */ 138export async function setClipboard(text: string): Promise<string> { 139 const b64 = Buffer.from(text, 'utf8').toString('base64') 140 const raw = osc(OSC.CLIPBOARD, 'c', b64) 141 142 // Native safety net — fire FIRST, before the tmux await, so a quick 143 // focus-switch after selecting doesn't race pbcopy. Previously this ran 144 // AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency 145 // before pbcopy even started — fast cmd+tab → paste would beat it 146 // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829). 147 // Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY 148 // forever but SSH_CONNECTION is in tmux's default update-environment and 149 // clears on local attach. Fire-and-forget. 150 if (!process.env['SSH_CONNECTION']) copyNative(text) 151 152 const tmuxBufferLoaded = await tmuxLoadBuffer(text) 153 154 // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling 155 // too, and BEL works everywhere for OSC 52. 156 if (tmuxBufferLoaded) return tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) 157 return raw 158} 159 160// Linux clipboard tool: undefined = not yet probed, null = none available. 161// Probe order: wl-copy (Wayland) → xclip (X11) → xsel (X11 fallback). 162// Cached after first attempt so repeated mouse-ups skip the probe chain. 163let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined 164 165/** 166 * Shell out to a native clipboard utility as a safety net for OSC 52. 167 * Only called when not in an SSH session (over SSH, these would write to 168 * the remote machine's clipboard — OSC 52 is the right path there). 169 * Fire-and-forget: failures are silent since OSC 52 may have succeeded. 170 */ 171function copyNative(text: string): void { 172 const opts = { input: text, useCwd: false, timeout: 2000 } 173 switch (process.platform) { 174 case 'darwin': 175 void execFileNoThrow('pbcopy', [], opts) 176 return 177 case 'linux': { 178 if (linuxCopy === null) return 179 if (linuxCopy === 'wl-copy') { 180 void execFileNoThrow('wl-copy', [], opts) 181 return 182 } 183 if (linuxCopy === 'xclip') { 184 void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) 185 return 186 } 187 if (linuxCopy === 'xsel') { 188 void execFileNoThrow('xsel', ['--clipboard', '--input'], opts) 189 return 190 } 191 // First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner. 192 void execFileNoThrow('wl-copy', [], opts).then(r => { 193 if (r.code === 0) { 194 linuxCopy = 'wl-copy' 195 return 196 } 197 void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then( 198 r2 => { 199 if (r2.code === 0) { 200 linuxCopy = 'xclip' 201 return 202 } 203 void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then( 204 r3 => { 205 linuxCopy = r3.code === 0 ? 'xsel' : null 206 }, 207 ) 208 }, 209 ) 210 }) 211 return 212 } 213 case 'win32': 214 // clip.exe is always available on Windows. Unicode handling is 215 // imperfect (system locale encoding) but good enough for a fallback. 216 void execFileNoThrow('clip', [], opts) 217 return 218 } 219} 220 221/** @internal test-only */ 222export function _resetLinuxCopyCache(): void { 223 linuxCopy = undefined 224} 225 226/** 227 * OSC command numbers 228 */ 229export const OSC = { 230 SET_TITLE_AND_ICON: 0, 231 SET_ICON: 1, 232 SET_TITLE: 2, 233 SET_COLOR: 4, 234 SET_CWD: 7, 235 HYPERLINK: 8, 236 ITERM2: 9, // iTerm2 proprietary sequences 237 SET_FG_COLOR: 10, 238 SET_BG_COLOR: 11, 239 SET_CURSOR_COLOR: 12, 240 CLIPBOARD: 52, 241 KITTY: 99, // Kitty notification protocol 242 RESET_COLOR: 104, 243 RESET_FG_COLOR: 110, 244 RESET_BG_COLOR: 111, 245 RESET_CURSOR_COLOR: 112, 246 SEMANTIC_PROMPT: 133, 247 GHOSTTY: 777, // Ghostty notification protocol 248 TAB_STATUS: 21337, // Tab status extension 249} as const 250 251/** 252 * Parse an OSC sequence into an action 253 * 254 * @param content - The sequence content (without ESC ] and terminator) 255 */ 256export function parseOSC(content: string): Action | null { 257 const semicolonIdx = content.indexOf(';') 258 const command = semicolonIdx >= 0 ? content.slice(0, semicolonIdx) : content 259 const data = semicolonIdx >= 0 ? content.slice(semicolonIdx + 1) : '' 260 261 const commandNum = parseInt(command, 10) 262 263 // Window/icon title 264 if (commandNum === OSC.SET_TITLE_AND_ICON) { 265 return { type: 'title', action: { type: 'both', title: data } } 266 } 267 if (commandNum === OSC.SET_ICON) { 268 return { type: 'title', action: { type: 'iconName', name: data } } 269 } 270 if (commandNum === OSC.SET_TITLE) { 271 return { type: 'title', action: { type: 'windowTitle', title: data } } 272 } 273 274 // Hyperlinks (OSC 8) 275 if (commandNum === OSC.HYPERLINK) { 276 const parts = data.split(';') 277 const paramsStr = parts[0] ?? '' 278 const url = parts.slice(1).join(';') 279 280 if (url === '') { 281 return { type: 'link', action: { type: 'end' } } 282 } 283 284 const params: Record<string, string> = {} 285 if (paramsStr) { 286 for (const pair of paramsStr.split(':')) { 287 const eqIdx = pair.indexOf('=') 288 if (eqIdx >= 0) { 289 params[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1) 290 } 291 } 292 } 293 294 return { 295 type: 'link', 296 action: { 297 type: 'start', 298 url, 299 params: Object.keys(params).length > 0 ? params : undefined, 300 }, 301 } 302 } 303 304 // Tab status (OSC 21337) 305 if (commandNum === OSC.TAB_STATUS) { 306 return { type: 'tabStatus', action: parseTabStatus(data) } 307 } 308 309 return { type: 'unknown', sequence: `\x1b]${content}` } 310} 311 312/** 313 * Parse an XParseColor-style color spec into an RGB Color. 314 * Accepts `#RRGGBB` and `rgb:R/G/B` (1–4 hex digits per component, scaled 315 * to 8-bit). Returns null on parse failure. 316 */ 317export function parseOscColor(spec: string): Color | null { 318 const hex = spec.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i) 319 if (hex) { 320 return { 321 type: 'rgb', 322 r: parseInt(hex[1]!, 16), 323 g: parseInt(hex[2]!, 16), 324 b: parseInt(hex[3]!, 16), 325 } 326 } 327 const rgb = spec.match( 328 /^rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})$/i, 329 ) 330 if (rgb) { 331 // XParseColor: N hex digits → value / (16^N - 1), scale to 0-255 332 const scale = (s: string) => 333 Math.round((parseInt(s, 16) / (16 ** s.length - 1)) * 255) 334 return { 335 type: 'rgb', 336 r: scale(rgb[1]!), 337 g: scale(rgb[2]!), 338 b: scale(rgb[3]!), 339 } 340 } 341 return null 342} 343 344/** 345 * Parse OSC 21337 payload: `key=value;key=value;...` with `\;` and `\\` 346 * escapes inside values. Bare key or `key=` clears that field; unknown 347 * keys are ignored. 348 */ 349function parseTabStatus(data: string): TabStatusAction { 350 const action: TabStatusAction = {} 351 for (const [key, value] of splitTabStatusPairs(data)) { 352 switch (key) { 353 case 'indicator': 354 action.indicator = value === '' ? null : parseOscColor(value) 355 break 356 case 'status': 357 action.status = value === '' ? null : value 358 break 359 case 'status-color': 360 action.statusColor = value === '' ? null : parseOscColor(value) 361 break 362 } 363 } 364 return action 365} 366 367/** Split `k=v;k=v` honoring `\;` and `\\` escapes. Yields [key, unescapedValue]. */ 368function* splitTabStatusPairs(data: string): Generator<[string, string]> { 369 let key = '' 370 let val = '' 371 let inVal = false 372 let esc = false 373 for (const c of data) { 374 if (esc) { 375 if (inVal) val += c 376 else key += c 377 esc = false 378 } else if (c === '\\') { 379 esc = true 380 } else if (c === ';') { 381 yield [key, val] 382 key = '' 383 val = '' 384 inVal = false 385 } else if (c === '=' && !inVal) { 386 inVal = true 387 } else if (inVal) { 388 val += c 389 } else { 390 key += c 391 } 392 } 393 if (key || inVal) yield [key, val] 394} 395 396// Output generators 397 398/** Start a hyperlink (OSC 8). Auto-assigns an id= param derived from the URL 399 * so terminals group wrapped lines of the same link together (the spec says 400 * cells with matching URI *and* nonempty id are joined; without an id each 401 * wrapped line is a separate link — inconsistent hover, partial tooltips). 402 * Empty url = close sequence (empty params per spec). */ 403export function link(url: string, params?: Record<string, string>): string { 404 if (!url) return LINK_END 405 const p = { id: osc8Id(url), ...params } 406 const paramStr = Object.entries(p) 407 .map(([k, v]) => `${k}=${v}`) 408 .join(':') 409 return osc(OSC.HYPERLINK, paramStr, url) 410} 411 412function osc8Id(url: string): string { 413 let h = 0 414 for (let i = 0; i < url.length; i++) 415 h = ((h << 5) - h + url.charCodeAt(i)) | 0 416 return (h >>> 0).toString(36) 417} 418 419/** End a hyperlink (OSC 8) */ 420export const LINK_END = osc(OSC.HYPERLINK, '', '') 421 422// iTerm2 OSC 9 subcommands 423 424/** iTerm2 OSC 9 subcommand numbers */ 425export const ITERM2 = { 426 NOTIFY: 0, 427 BADGE: 2, 428 PROGRESS: 4, 429} as const 430 431/** Progress operation codes (for use with ITERM2.PROGRESS) */ 432export const PROGRESS = { 433 CLEAR: 0, 434 SET: 1, 435 ERROR: 2, 436 INDETERMINATE: 3, 437} as const 438 439/** 440 * Clear iTerm2 progress bar sequence (OSC 9;4;0;BEL) 441 * Uses BEL terminator since this is for cleanup (not runtime notification) 442 * and we want to ensure it's always sent regardless of terminal type. 443 */ 444export const CLEAR_ITERM2_PROGRESS = `${OSC_PREFIX}${OSC.ITERM2};${ITERM2.PROGRESS};${PROGRESS.CLEAR};${BEL}` 445 446/** 447 * Clear terminal title sequence (OSC 0 with empty string + BEL). 448 * Uses BEL terminator for cleanup — safe on all terminals. 449 */ 450export const CLEAR_TERMINAL_TITLE = `${OSC_PREFIX}${OSC.SET_TITLE_AND_ICON};${BEL}` 451 452/** Clear all three OSC 21337 tab-status fields. Used on exit. */ 453export const CLEAR_TAB_STATUS = osc( 454 OSC.TAB_STATUS, 455 'indicator=;status=;status-color=', 456) 457 458/** 459 * Gate for emitting OSC 21337 (tab-status indicator). Ant-only while the 460 * spec is unstable. Terminals that don't recognize it discard silently, so 461 * emission is safe unconditionally — we don't gate on terminal detection 462 * since support is expected across several terminals. 463 * 464 * Callers must wrap output with wrapForMultiplexer() so tmux/screen 465 * DCS-passthrough carries the sequence to the outer terminal. 466 */ 467export function supportsTabStatus(): boolean { 468 return process.env.USER_TYPE === 'ant' 469} 470 471/** 472 * Emit an OSC 21337 tab-status sequence. Omitted fields are left unchanged 473 * by the receiving terminal; `null` sends an empty value to clear. 474 * `;` and `\` in status text are escaped per the spec. 475 */ 476export function tabStatus(fields: TabStatusAction): string { 477 const parts: string[] = [] 478 const rgb = (c: Color) => 479 c.type === 'rgb' 480 ? `#${[c.r, c.g, c.b].map(n => n.toString(16).padStart(2, '0')).join('')}` 481 : '' 482 if ('indicator' in fields) 483 parts.push(`indicator=${fields.indicator ? rgb(fields.indicator) : ''}`) 484 if ('status' in fields) 485 parts.push( 486 `status=${fields.status?.replaceAll('\\', '\\\\').replaceAll(';', '\\;') ?? ''}`, 487 ) 488 if ('statusColor' in fields) 489 parts.push( 490 `status-color=${fields.statusColor ? rgb(fields.statusColor) : ''}`, 491 ) 492 return osc(OSC.TAB_STATUS, parts.join(';')) 493}