π File detail
ink/termio/osc.ts
π§© .tsπ 494 linesπΎ 16,842 bytesπ text
β Back to All Filesπ― Use case
This file lives under βink/β, which covers Ink terminal UI (layouts, TTY IO, keyboard, renderer components). On the API surface it exposes OSC_PREFIX, ST, osc, wrapForMultiplexer, and ClipboardPath (and more) β mainly functions, hooks, or classes. Dependencies touch buffer. It composes internal code from utils, ansi, and types (relative imports). What the file header says: OSC (Operating System Command) Types and Parser.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
OSC (Operating System Command) Types and Parser
π€ Exports (heuristic)
OSC_PREFIXSToscwrapForMultiplexerClipboardPathgetClipboardPathtmuxLoadBuffersetClipboard_resetLinuxCopyCacheOSCparseOSCparseOscColorlinkLINK_ENDITERM2PROGRESSCLEAR_ITERM2_PROGRESSCLEAR_TERMINAL_TITLECLEAR_TAB_STATUSsupportsTabStatustabStatus
π External import roots
Package roots from from "β¦" (relative paths omitted).
buffer
π₯οΈ Source preview
/**
* 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(';'))
}