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