init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
/**
* ANSI Control Characters and Escape Sequence Introducers
*
* Based on ECMA-48 / ANSI X3.64 standards.
*/
/**
* C0 (7-bit) control characters
*/
export const C0 = {
NUL: 0x00,
SOH: 0x01,
STX: 0x02,
ETX: 0x03,
EOT: 0x04,
ENQ: 0x05,
ACK: 0x06,
BEL: 0x07,
BS: 0x08,
HT: 0x09,
LF: 0x0a,
VT: 0x0b,
FF: 0x0c,
CR: 0x0d,
SO: 0x0e,
SI: 0x0f,
DLE: 0x10,
DC1: 0x11,
DC2: 0x12,
DC3: 0x13,
DC4: 0x14,
NAK: 0x15,
SYN: 0x16,
ETB: 0x17,
CAN: 0x18,
EM: 0x19,
SUB: 0x1a,
ESC: 0x1b,
FS: 0x1c,
GS: 0x1d,
RS: 0x1e,
US: 0x1f,
DEL: 0x7f,
} as const
// String constants for output generation
export const ESC = '\x1b'
export const BEL = '\x07'
export const SEP = ';'
/**
* Escape sequence type introducers (byte after ESC)
*/
export const ESC_TYPE = {
CSI: 0x5b, // [ - Control Sequence Introducer
OSC: 0x5d, // ] - Operating System Command
DCS: 0x50, // P - Device Control String
APC: 0x5f, // _ - Application Program Command
PM: 0x5e, // ^ - Privacy Message
SOS: 0x58, // X - Start of String
ST: 0x5c, // \ - String Terminator
} as const
/** Check if a byte is a C0 control character */
export function isC0(byte: number): boolean {
return byte < 0x20 || byte === 0x7f
}
/**
* Check if a byte is an ESC sequence final byte (0-9, :, ;, <, =, >, ?, @ through ~)
* ESC sequences have a wider final byte range than CSI
*/
export function isEscFinal(byte: number): boolean {
return byte >= 0x30 && byte <= 0x7e
}
+319
View File
@@ -0,0 +1,319 @@
/**
* CSI (Control Sequence Introducer) Types
*
* Enums and types for CSI command parameters.
*/
import { ESC, ESC_TYPE, SEP } from './ansi.js'
export const CSI_PREFIX = ESC + String.fromCharCode(ESC_TYPE.CSI)
/**
* CSI parameter byte ranges
*/
export const CSI_RANGE = {
PARAM_START: 0x30,
PARAM_END: 0x3f,
INTERMEDIATE_START: 0x20,
INTERMEDIATE_END: 0x2f,
FINAL_START: 0x40,
FINAL_END: 0x7e,
} as const
/** Check if a byte is a CSI parameter byte */
export function isCSIParam(byte: number): boolean {
return byte >= CSI_RANGE.PARAM_START && byte <= CSI_RANGE.PARAM_END
}
/** Check if a byte is a CSI intermediate byte */
export function isCSIIntermediate(byte: number): boolean {
return (
byte >= CSI_RANGE.INTERMEDIATE_START && byte <= CSI_RANGE.INTERMEDIATE_END
)
}
/** Check if a byte is a CSI final byte (@ through ~) */
export function isCSIFinal(byte: number): boolean {
return byte >= CSI_RANGE.FINAL_START && byte <= CSI_RANGE.FINAL_END
}
/**
* Generate a CSI sequence: ESC [ p1;p2;...;pN final
* Single arg: treated as raw body
* Multiple args: last is final byte, rest are params joined by ;
*/
export function csi(...args: (string | number)[]): string {
if (args.length === 0) return CSI_PREFIX
if (args.length === 1) return `${CSI_PREFIX}${args[0]}`
const params = args.slice(0, -1)
const final = args[args.length - 1]
return `${CSI_PREFIX}${params.join(SEP)}${final}`
}
/**
* CSI final bytes - the command identifier
*/
export const CSI = {
// Cursor movement
CUU: 0x41, // A - Cursor Up
CUD: 0x42, // B - Cursor Down
CUF: 0x43, // C - Cursor Forward
CUB: 0x44, // D - Cursor Back
CNL: 0x45, // E - Cursor Next Line
CPL: 0x46, // F - Cursor Previous Line
CHA: 0x47, // G - Cursor Horizontal Absolute
CUP: 0x48, // H - Cursor Position
CHT: 0x49, // I - Cursor Horizontal Tab
VPA: 0x64, // d - Vertical Position Absolute
HVP: 0x66, // f - Horizontal Vertical Position
// Erase
ED: 0x4a, // J - Erase in Display
EL: 0x4b, // K - Erase in Line
ECH: 0x58, // X - Erase Character
// Insert/Delete
IL: 0x4c, // L - Insert Lines
DL: 0x4d, // M - Delete Lines
ICH: 0x40, // @ - Insert Characters
DCH: 0x50, // P - Delete Characters
// Scroll
SU: 0x53, // S - Scroll Up
SD: 0x54, // T - Scroll Down
// Modes
SM: 0x68, // h - Set Mode
RM: 0x6c, // l - Reset Mode
// SGR
SGR: 0x6d, // m - Select Graphic Rendition
// Other
DSR: 0x6e, // n - Device Status Report
DECSCUSR: 0x71, // q - Set Cursor Style (with space intermediate)
DECSTBM: 0x72, // r - Set Top and Bottom Margins
SCOSC: 0x73, // s - Save Cursor Position
SCORC: 0x75, // u - Restore Cursor Position
CBT: 0x5a, // Z - Cursor Backward Tabulation
} as const
/**
* Erase in Display regions (ED command parameter)
*/
export const ERASE_DISPLAY = ['toEnd', 'toStart', 'all', 'scrollback'] as const
/**
* Erase in Line regions (EL command parameter)
*/
export const ERASE_LINE_REGION = ['toEnd', 'toStart', 'all'] as const
/**
* Cursor styles (DECSCUSR)
*/
export type CursorStyle = 'block' | 'underline' | 'bar'
export const CURSOR_STYLES: Array<{ style: CursorStyle; blinking: boolean }> = [
{ style: 'block', blinking: true }, // 0 - default
{ style: 'block', blinking: true }, // 1
{ style: 'block', blinking: false }, // 2
{ style: 'underline', blinking: true }, // 3
{ style: 'underline', blinking: false }, // 4
{ style: 'bar', blinking: true }, // 5
{ style: 'bar', blinking: false }, // 6
]
// Cursor movement generators
/** Move cursor up n lines (CSI n A) */
export function cursorUp(n = 1): string {
return n === 0 ? '' : csi(n, 'A')
}
/** Move cursor down n lines (CSI n B) */
export function cursorDown(n = 1): string {
return n === 0 ? '' : csi(n, 'B')
}
/** Move cursor forward n columns (CSI n C) */
export function cursorForward(n = 1): string {
return n === 0 ? '' : csi(n, 'C')
}
/** Move cursor back n columns (CSI n D) */
export function cursorBack(n = 1): string {
return n === 0 ? '' : csi(n, 'D')
}
/** Move cursor to column n (1-indexed) (CSI n G) */
export function cursorTo(col: number): string {
return csi(col, 'G')
}
/** Move cursor to column 1 (CSI G) */
export const CURSOR_LEFT = csi('G')
/** Move cursor to row, col (1-indexed) (CSI row ; col H) */
export function cursorPosition(row: number, col: number): string {
return csi(row, col, 'H')
}
/** Move cursor to home position (CSI H) */
export const CURSOR_HOME = csi('H')
/**
* Move cursor relative to current position
* Positive x = right, negative x = left
* Positive y = down, negative y = up
*/
export function cursorMove(x: number, y: number): string {
let result = ''
// Horizontal first (matches ansi-escapes behavior)
if (x < 0) {
result += cursorBack(-x)
} else if (x > 0) {
result += cursorForward(x)
}
// Then vertical
if (y < 0) {
result += cursorUp(-y)
} else if (y > 0) {
result += cursorDown(y)
}
return result
}
// Save/restore cursor position
/** Save cursor position (CSI s) */
export const CURSOR_SAVE = csi('s')
/** Restore cursor position (CSI u) */
export const CURSOR_RESTORE = csi('u')
// Erase generators
/** Erase from cursor to end of line (CSI K) */
export function eraseToEndOfLine(): string {
return csi('K')
}
/** Erase from cursor to start of line (CSI 1 K) */
export function eraseToStartOfLine(): string {
return csi(1, 'K')
}
/** Erase entire line (CSI 2 K) */
export function eraseLine(): string {
return csi(2, 'K')
}
/** Erase entire line - constant form */
export const ERASE_LINE = csi(2, 'K')
/** Erase from cursor to end of screen (CSI J) */
export function eraseToEndOfScreen(): string {
return csi('J')
}
/** Erase from cursor to start of screen (CSI 1 J) */
export function eraseToStartOfScreen(): string {
return csi(1, 'J')
}
/** Erase entire screen (CSI 2 J) */
export function eraseScreen(): string {
return csi(2, 'J')
}
/** Erase entire screen - constant form */
export const ERASE_SCREEN = csi(2, 'J')
/** Erase scrollback buffer (CSI 3 J) */
export const ERASE_SCROLLBACK = csi(3, 'J')
/**
* Erase n lines starting from cursor line, moving cursor up
* This erases each line and moves up, ending at column 1
*/
export function eraseLines(n: number): string {
if (n <= 0) return ''
let result = ''
for (let i = 0; i < n; i++) {
result += ERASE_LINE
if (i < n - 1) {
result += cursorUp(1)
}
}
result += CURSOR_LEFT
return result
}
// Scroll
/** Scroll up n lines (CSI n S) */
export function scrollUp(n = 1): string {
return n === 0 ? '' : csi(n, 'S')
}
/** Scroll down n lines (CSI n T) */
export function scrollDown(n = 1): string {
return n === 0 ? '' : csi(n, 'T')
}
/** Set scroll region (DECSTBM, CSI top;bottom r). 1-indexed, inclusive. */
export function setScrollRegion(top: number, bottom: number): string {
return csi(top, bottom, 'r')
}
/** Reset scroll region to full screen (DECSTBM, CSI r). Homes the cursor. */
export const RESET_SCROLL_REGION = csi('r')
// Bracketed paste markers (input from terminal, not output)
// These are sent by the terminal to delimit pasted content when
// bracketed paste mode is enabled (via DEC mode 2004)
/** Sent by terminal before pasted content (CSI 200 ~) */
export const PASTE_START = csi('200~')
/** Sent by terminal after pasted content (CSI 201 ~) */
export const PASTE_END = csi('201~')
// Focus event markers (input from terminal, not output)
// These are sent by the terminal when focus changes while
// focus events mode is enabled (via DEC mode 1004)
/** Sent by terminal when it gains focus (CSI I) */
export const FOCUS_IN = csi('I')
/** Sent by terminal when it loses focus (CSI O) */
export const FOCUS_OUT = csi('O')
// Kitty keyboard protocol (CSI u)
// Enables enhanced key reporting with modifier information
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
/**
* Enable Kitty keyboard protocol with basic modifier reporting
* CSI > 1 u - pushes mode with flags=1 (disambiguate escape codes)
* This makes Shift+Enter send CSI 13;2 u instead of just CR
*/
export const ENABLE_KITTY_KEYBOARD = csi('>1u')
/**
* Disable Kitty keyboard protocol
* CSI < u - pops the keyboard mode stack
*/
export const DISABLE_KITTY_KEYBOARD = csi('<u')
/**
* Enable xterm modifyOtherKeys level 2.
* tmux accepts this (not the kitty stack) to enable extended keys — when
* extended-keys-format is csi-u, tmux then emits keys in kitty format.
*/
export const ENABLE_MODIFY_OTHER_KEYS = csi('>4;2m')
/**
* Disable xterm modifyOtherKeys (reset to default).
*/
export const DISABLE_MODIFY_OTHER_KEYS = csi('>4m')
+60
View File
@@ -0,0 +1,60 @@
/**
* DEC (Digital Equipment Corporation) Private Mode Sequences
*
* DEC private modes use CSI ? N h (set) and CSI ? N l (reset) format.
* These are terminal-specific extensions to the ANSI standard.
*/
import { csi } from './csi.js'
/**
* DEC private mode numbers
*/
export const DEC = {
CURSOR_VISIBLE: 25,
ALT_SCREEN: 47,
ALT_SCREEN_CLEAR: 1049,
MOUSE_NORMAL: 1000,
MOUSE_BUTTON: 1002,
MOUSE_ANY: 1003,
MOUSE_SGR: 1006,
FOCUS_EVENTS: 1004,
BRACKETED_PASTE: 2004,
SYNCHRONIZED_UPDATE: 2026,
} as const
/** Generate CSI ? N h sequence (set mode) */
export function decset(mode: number): string {
return csi(`?${mode}h`)
}
/** Generate CSI ? N l sequence (reset mode) */
export function decreset(mode: number): string {
return csi(`?${mode}l`)
}
// Pre-generated sequences for common modes
export const BSU = decset(DEC.SYNCHRONIZED_UPDATE)
export const ESU = decreset(DEC.SYNCHRONIZED_UPDATE)
export const EBP = decset(DEC.BRACKETED_PASTE)
export const DBP = decreset(DEC.BRACKETED_PASTE)
export const EFE = decset(DEC.FOCUS_EVENTS)
export const DFE = decreset(DEC.FOCUS_EVENTS)
export const SHOW_CURSOR = decset(DEC.CURSOR_VISIBLE)
export const HIDE_CURSOR = decreset(DEC.CURSOR_VISIBLE)
export const ENTER_ALT_SCREEN = decset(DEC.ALT_SCREEN_CLEAR)
export const EXIT_ALT_SCREEN = decreset(DEC.ALT_SCREEN_CLEAR)
// Mouse tracking: 1000 reports button press/release/wheel, 1002 adds drag
// events (button-motion), 1003 adds all-motion (no button held — for
// hover), 1006 uses SGR format (CSI < btn;col;row M/m) instead of legacy
// X10 bytes. Combined: wheel + click/drag for selection + hover.
export const ENABLE_MOUSE_TRACKING =
decset(DEC.MOUSE_NORMAL) +
decset(DEC.MOUSE_BUTTON) +
decset(DEC.MOUSE_ANY) +
decset(DEC.MOUSE_SGR)
export const DISABLE_MOUSE_TRACKING =
decreset(DEC.MOUSE_SGR) +
decreset(DEC.MOUSE_ANY) +
decreset(DEC.MOUSE_BUTTON) +
decreset(DEC.MOUSE_NORMAL)
+67
View File
@@ -0,0 +1,67 @@
/**
* ESC Sequence Parser
*
* Handles simple escape sequences: ESC + one or two characters
*/
import type { Action } from './types.js'
/**
* Parse a simple ESC sequence
*
* @param chars - Characters after ESC (not including ESC itself)
*/
export function parseEsc(chars: string): Action | null {
if (chars.length === 0) return null
const first = chars[0]!
// Full reset (RIS)
if (first === 'c') {
return { type: 'reset' }
}
// Cursor save (DECSC)
if (first === '7') {
return { type: 'cursor', action: { type: 'save' } }
}
// Cursor restore (DECRC)
if (first === '8') {
return { type: 'cursor', action: { type: 'restore' } }
}
// Index - move cursor down (IND)
if (first === 'D') {
return {
type: 'cursor',
action: { type: 'move', direction: 'down', count: 1 },
}
}
// Reverse index - move cursor up (RI)
if (first === 'M') {
return {
type: 'cursor',
action: { type: 'move', direction: 'up', count: 1 },
}
}
// Next line (NEL)
if (first === 'E') {
return { type: 'cursor', action: { type: 'nextLine', count: 1 } }
}
// Horizontal tab set (HTS)
if (first === 'H') {
return null // Tab stop, not commonly needed
}
// Charset selection (ESC ( X, ESC ) X, etc.) - silently ignore
if ('()'.includes(first) && chars.length >= 2) {
return null
}
// Unknown
return { type: 'unknown', sequence: `\x1b${chars}` }
}
+493
View File
@@ -0,0 +1,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` (14 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(';'))
}
+394
View File
@@ -0,0 +1,394 @@
/**
* ANSI Parser - Semantic Action Generator
*
* A streaming parser for ANSI escape sequences that produces semantic actions.
* Uses the tokenizer for escape sequence boundary detection, then interprets
* each sequence to produce structured actions.
*
* Key design decisions:
* - Streaming: can process input incrementally
* - Semantic output: produces structured actions, not string tokens
* - Style tracking: maintains current text style state
*/
import { getGraphemeSegmenter } from '../../utils/intl.js'
import { C0 } from './ansi.js'
import { CSI, CURSOR_STYLES, ERASE_DISPLAY, ERASE_LINE_REGION } from './csi.js'
import { DEC } from './dec.js'
import { parseEsc } from './esc.js'
import { parseOSC } from './osc.js'
import { applySGR } from './sgr.js'
import { createTokenizer, type Token, type Tokenizer } from './tokenize.js'
import type { Action, Grapheme, TextStyle } from './types.js'
import { defaultStyle } from './types.js'
// =============================================================================
// Grapheme Utilities
// =============================================================================
function isEmoji(codePoint: number): boolean {
return (
(codePoint >= 0x2600 && codePoint <= 0x26ff) ||
(codePoint >= 0x2700 && codePoint <= 0x27bf) ||
(codePoint >= 0x1f300 && codePoint <= 0x1f9ff) ||
(codePoint >= 0x1fa00 && codePoint <= 0x1faff) ||
(codePoint >= 0x1f1e0 && codePoint <= 0x1f1ff)
)
}
function isEastAsianWide(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0x9fff) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe1f) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x20000 && codePoint <= 0x2fffd) ||
(codePoint >= 0x30000 && codePoint <= 0x3fffd)
)
}
function hasMultipleCodepoints(str: string): boolean {
let count = 0
for (const _ of str) {
count++
if (count > 1) return true
}
return false
}
function graphemeWidth(grapheme: string): 1 | 2 {
if (hasMultipleCodepoints(grapheme)) return 2
const codePoint = grapheme.codePointAt(0)
if (codePoint === undefined) return 1
if (isEmoji(codePoint) || isEastAsianWide(codePoint)) return 2
return 1
}
function* segmentGraphemes(str: string): Generator<Grapheme> {
for (const { segment } of getGraphemeSegmenter().segment(str)) {
yield { value: segment, width: graphemeWidth(segment) }
}
}
// =============================================================================
// Sequence Parsing
// =============================================================================
function parseCSIParams(paramStr: string): number[] {
if (paramStr === '') return []
return paramStr.split(/[;:]/).map(s => (s === '' ? 0 : parseInt(s, 10)))
}
/** Parse a raw CSI sequence (e.g., "\x1b[31m") into an action */
function parseCSI(rawSequence: string): Action | null {
const inner = rawSequence.slice(2)
if (inner.length === 0) return null
const finalByte = inner.charCodeAt(inner.length - 1)
const beforeFinal = inner.slice(0, -1)
let privateMode = ''
let paramStr = beforeFinal
let intermediate = ''
if (beforeFinal.length > 0 && '?>='.includes(beforeFinal[0]!)) {
privateMode = beforeFinal[0]!
paramStr = beforeFinal.slice(1)
}
const intermediateMatch = paramStr.match(/([^0-9;:]+)$/)
if (intermediateMatch) {
intermediate = intermediateMatch[1]!
paramStr = paramStr.slice(0, -intermediate.length)
}
const params = parseCSIParams(paramStr)
const p0 = params[0] ?? 1
const p1 = params[1] ?? 1
// SGR (Select Graphic Rendition)
if (finalByte === CSI.SGR && privateMode === '') {
return { type: 'sgr', params: paramStr }
}
// Cursor movement
if (finalByte === CSI.CUU) {
return {
type: 'cursor',
action: { type: 'move', direction: 'up', count: p0 },
}
}
if (finalByte === CSI.CUD) {
return {
type: 'cursor',
action: { type: 'move', direction: 'down', count: p0 },
}
}
if (finalByte === CSI.CUF) {
return {
type: 'cursor',
action: { type: 'move', direction: 'forward', count: p0 },
}
}
if (finalByte === CSI.CUB) {
return {
type: 'cursor',
action: { type: 'move', direction: 'back', count: p0 },
}
}
if (finalByte === CSI.CNL) {
return { type: 'cursor', action: { type: 'nextLine', count: p0 } }
}
if (finalByte === CSI.CPL) {
return { type: 'cursor', action: { type: 'prevLine', count: p0 } }
}
if (finalByte === CSI.CHA) {
return { type: 'cursor', action: { type: 'column', col: p0 } }
}
if (finalByte === CSI.CUP || finalByte === CSI.HVP) {
return { type: 'cursor', action: { type: 'position', row: p0, col: p1 } }
}
if (finalByte === CSI.VPA) {
return { type: 'cursor', action: { type: 'row', row: p0 } }
}
// Erase
if (finalByte === CSI.ED) {
const region = ERASE_DISPLAY[params[0] ?? 0] ?? 'toEnd'
return { type: 'erase', action: { type: 'display', region } }
}
if (finalByte === CSI.EL) {
const region = ERASE_LINE_REGION[params[0] ?? 0] ?? 'toEnd'
return { type: 'erase', action: { type: 'line', region } }
}
if (finalByte === CSI.ECH) {
return { type: 'erase', action: { type: 'chars', count: p0 } }
}
// Scroll
if (finalByte === CSI.SU) {
return { type: 'scroll', action: { type: 'up', count: p0 } }
}
if (finalByte === CSI.SD) {
return { type: 'scroll', action: { type: 'down', count: p0 } }
}
if (finalByte === CSI.DECSTBM) {
return {
type: 'scroll',
action: { type: 'setRegion', top: p0, bottom: p1 },
}
}
// Cursor save/restore
if (finalByte === CSI.SCOSC) {
return { type: 'cursor', action: { type: 'save' } }
}
if (finalByte === CSI.SCORC) {
return { type: 'cursor', action: { type: 'restore' } }
}
// Cursor style
if (finalByte === CSI.DECSCUSR && intermediate === ' ') {
const styleInfo = CURSOR_STYLES[p0] ?? CURSOR_STYLES[0]!
return { type: 'cursor', action: { type: 'style', ...styleInfo } }
}
// Private modes
if (privateMode === '?' && (finalByte === CSI.SM || finalByte === CSI.RM)) {
const enabled = finalByte === CSI.SM
if (p0 === DEC.CURSOR_VISIBLE) {
return {
type: 'cursor',
action: enabled ? { type: 'show' } : { type: 'hide' },
}
}
if (p0 === DEC.ALT_SCREEN_CLEAR || p0 === DEC.ALT_SCREEN) {
return { type: 'mode', action: { type: 'alternateScreen', enabled } }
}
if (p0 === DEC.BRACKETED_PASTE) {
return { type: 'mode', action: { type: 'bracketedPaste', enabled } }
}
if (p0 === DEC.MOUSE_NORMAL) {
return {
type: 'mode',
action: { type: 'mouseTracking', mode: enabled ? 'normal' : 'off' },
}
}
if (p0 === DEC.MOUSE_BUTTON) {
return {
type: 'mode',
action: { type: 'mouseTracking', mode: enabled ? 'button' : 'off' },
}
}
if (p0 === DEC.MOUSE_ANY) {
return {
type: 'mode',
action: { type: 'mouseTracking', mode: enabled ? 'any' : 'off' },
}
}
if (p0 === DEC.FOCUS_EVENTS) {
return { type: 'mode', action: { type: 'focusEvents', enabled } }
}
}
return { type: 'unknown', sequence: rawSequence }
}
/**
* Identify the type of escape sequence from its raw form.
*/
function identifySequence(
seq: string,
): 'csi' | 'osc' | 'esc' | 'ss3' | 'unknown' {
if (seq.length < 2) return 'unknown'
if (seq.charCodeAt(0) !== C0.ESC) return 'unknown'
const second = seq.charCodeAt(1)
if (second === 0x5b) return 'csi' // [
if (second === 0x5d) return 'osc' // ]
if (second === 0x4f) return 'ss3' // O
return 'esc'
}
// =============================================================================
// Main Parser
// =============================================================================
/**
* Parser class - maintains state for streaming/incremental parsing
*
* Usage:
* ```typescript
* const parser = new Parser()
* const actions1 = parser.feed('partial\x1b[')
* const actions2 = parser.feed('31mred') // state maintained internally
* ```
*/
export class Parser {
private tokenizer: Tokenizer = createTokenizer()
style: TextStyle = defaultStyle()
inLink = false
linkUrl: string | undefined
reset(): void {
this.tokenizer.reset()
this.style = defaultStyle()
this.inLink = false
this.linkUrl = undefined
}
/** Feed input and get resulting actions */
feed(input: string): Action[] {
const tokens = this.tokenizer.feed(input)
const actions: Action[] = []
for (const token of tokens) {
const tokenActions = this.processToken(token)
actions.push(...tokenActions)
}
return actions
}
private processToken(token: Token): Action[] {
switch (token.type) {
case 'text':
return this.processText(token.value)
case 'sequence':
return this.processSequence(token.value)
}
}
private processText(text: string): Action[] {
// Handle BEL characters embedded in text
const actions: Action[] = []
let current = ''
for (const char of text) {
if (char.charCodeAt(0) === C0.BEL) {
if (current) {
const graphemes = [...segmentGraphemes(current)]
if (graphemes.length > 0) {
actions.push({ type: 'text', graphemes, style: { ...this.style } })
}
current = ''
}
actions.push({ type: 'bell' })
} else {
current += char
}
}
if (current) {
const graphemes = [...segmentGraphemes(current)]
if (graphemes.length > 0) {
actions.push({ type: 'text', graphemes, style: { ...this.style } })
}
}
return actions
}
private processSequence(seq: string): Action[] {
const seqType = identifySequence(seq)
switch (seqType) {
case 'csi': {
const action = parseCSI(seq)
if (!action) return []
if (action.type === 'sgr') {
this.style = applySGR(action.params, this.style)
return []
}
return [action]
}
case 'osc': {
// Extract OSC content (between ESC ] and terminator)
let content = seq.slice(2)
// Remove terminator (BEL or ESC \)
if (content.endsWith('\x07')) {
content = content.slice(0, -1)
} else if (content.endsWith('\x1b\\')) {
content = content.slice(0, -2)
}
const action = parseOSC(content)
if (action) {
if (action.type === 'link') {
if (action.action.type === 'start') {
this.inLink = true
this.linkUrl = action.action.url
} else {
this.inLink = false
this.linkUrl = undefined
}
}
return [action]
}
return []
}
case 'esc': {
const escContent = seq.slice(1)
const action = parseEsc(escContent)
return action ? [action] : []
}
case 'ss3':
// SS3 sequences are typically cursor keys in application mode
// For output parsing, treat as unknown
return [{ type: 'unknown', sequence: seq }]
default:
return [{ type: 'unknown', sequence: seq }]
}
}
}
+308
View File
@@ -0,0 +1,308 @@
/**
* SGR (Select Graphic Rendition) Parser
*
* Parses SGR parameters and applies them to a TextStyle.
* Handles both semicolon (;) and colon (:) separated parameters.
*/
import type { NamedColor, TextStyle, UnderlineStyle } from './types.js'
import { defaultStyle } from './types.js'
const NAMED_COLORS: NamedColor[] = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'brightBlack',
'brightRed',
'brightGreen',
'brightYellow',
'brightBlue',
'brightMagenta',
'brightCyan',
'brightWhite',
]
const UNDERLINE_STYLES: UnderlineStyle[] = [
'none',
'single',
'double',
'curly',
'dotted',
'dashed',
]
type Param = { value: number | null; subparams: number[]; colon: boolean }
function parseParams(str: string): Param[] {
if (str === '') return [{ value: 0, subparams: [], colon: false }]
const result: Param[] = []
let current: Param = { value: null, subparams: [], colon: false }
let num = ''
let inSub = false
for (let i = 0; i <= str.length; i++) {
const c = str[i]
if (c === ';' || c === undefined) {
const n = num === '' ? null : parseInt(num, 10)
if (inSub) {
if (n !== null) current.subparams.push(n)
} else {
current.value = n
}
result.push(current)
current = { value: null, subparams: [], colon: false }
num = ''
inSub = false
} else if (c === ':') {
const n = num === '' ? null : parseInt(num, 10)
if (!inSub) {
current.value = n
current.colon = true
inSub = true
} else {
if (n !== null) current.subparams.push(n)
}
num = ''
} else if (c >= '0' && c <= '9') {
num += c
}
}
return result
}
function parseExtendedColor(
params: Param[],
idx: number,
): { r: number; g: number; b: number } | { index: number } | null {
const p = params[idx]
if (!p) return null
if (p.colon && p.subparams.length >= 1) {
if (p.subparams[0] === 5 && p.subparams.length >= 2) {
return { index: p.subparams[1]! }
}
if (p.subparams[0] === 2 && p.subparams.length >= 4) {
const off = p.subparams.length >= 5 ? 1 : 0
return {
r: p.subparams[1 + off]!,
g: p.subparams[2 + off]!,
b: p.subparams[3 + off]!,
}
}
}
const next = params[idx + 1]
if (!next) return null
if (
next.value === 5 &&
params[idx + 2]?.value !== null &&
params[idx + 2]?.value !== undefined
) {
return { index: params[idx + 2]!.value! }
}
if (next.value === 2) {
const r = params[idx + 2]?.value
const g = params[idx + 3]?.value
const b = params[idx + 4]?.value
if (
r !== null &&
r !== undefined &&
g !== null &&
g !== undefined &&
b !== null &&
b !== undefined
) {
return { r, g, b }
}
}
return null
}
export function applySGR(paramStr: string, style: TextStyle): TextStyle {
const params = parseParams(paramStr)
let s = { ...style }
let i = 0
while (i < params.length) {
const p = params[i]!
const code = p.value ?? 0
if (code === 0) {
s = defaultStyle()
i++
continue
}
if (code === 1) {
s.bold = true
i++
continue
}
if (code === 2) {
s.dim = true
i++
continue
}
if (code === 3) {
s.italic = true
i++
continue
}
if (code === 4) {
s.underline = p.colon
? (UNDERLINE_STYLES[p.subparams[0]!] ?? 'single')
: 'single'
i++
continue
}
if (code === 5 || code === 6) {
s.blink = true
i++
continue
}
if (code === 7) {
s.inverse = true
i++
continue
}
if (code === 8) {
s.hidden = true
i++
continue
}
if (code === 9) {
s.strikethrough = true
i++
continue
}
if (code === 21) {
s.underline = 'double'
i++
continue
}
if (code === 22) {
s.bold = false
s.dim = false
i++
continue
}
if (code === 23) {
s.italic = false
i++
continue
}
if (code === 24) {
s.underline = 'none'
i++
continue
}
if (code === 25) {
s.blink = false
i++
continue
}
if (code === 27) {
s.inverse = false
i++
continue
}
if (code === 28) {
s.hidden = false
i++
continue
}
if (code === 29) {
s.strikethrough = false
i++
continue
}
if (code === 53) {
s.overline = true
i++
continue
}
if (code === 55) {
s.overline = false
i++
continue
}
if (code >= 30 && code <= 37) {
s.fg = { type: 'named', name: NAMED_COLORS[code - 30]! }
i++
continue
}
if (code === 39) {
s.fg = { type: 'default' }
i++
continue
}
if (code >= 40 && code <= 47) {
s.bg = { type: 'named', name: NAMED_COLORS[code - 40]! }
i++
continue
}
if (code === 49) {
s.bg = { type: 'default' }
i++
continue
}
if (code >= 90 && code <= 97) {
s.fg = { type: 'named', name: NAMED_COLORS[code - 90 + 8]! }
i++
continue
}
if (code >= 100 && code <= 107) {
s.bg = { type: 'named', name: NAMED_COLORS[code - 100 + 8]! }
i++
continue
}
if (code === 38) {
const c = parseExtendedColor(params, i)
if (c) {
s.fg =
'index' in c
? { type: 'indexed', index: c.index }
: { type: 'rgb', ...c }
i += p.colon ? 1 : 'index' in c ? 3 : 5
continue
}
}
if (code === 48) {
const c = parseExtendedColor(params, i)
if (c) {
s.bg =
'index' in c
? { type: 'indexed', index: c.index }
: { type: 'rgb', ...c }
i += p.colon ? 1 : 'index' in c ? 3 : 5
continue
}
}
if (code === 58) {
const c = parseExtendedColor(params, i)
if (c) {
s.underlineColor =
'index' in c
? { type: 'indexed', index: c.index }
: { type: 'rgb', ...c }
i += p.colon ? 1 : 'index' in c ? 3 : 5
continue
}
}
if (code === 59) {
s.underlineColor = { type: 'default' }
i++
continue
}
i++
}
return s
}
+319
View File
@@ -0,0 +1,319 @@
/**
* Input Tokenizer - Escape sequence boundary detection
*
* Splits terminal input into tokens: text chunks and raw escape sequences.
* Unlike the Parser which interprets sequences semantically, this just
* identifies boundaries for use by keyboard input parsing.
*/
import { C0, ESC_TYPE, isEscFinal } from './ansi.js'
import { isCSIFinal, isCSIIntermediate, isCSIParam } from './csi.js'
export type Token =
| { type: 'text'; value: string }
| { type: 'sequence'; value: string }
type State =
| 'ground'
| 'escape'
| 'escapeIntermediate'
| 'csi'
| 'ss3'
| 'osc'
| 'dcs'
| 'apc'
export type Tokenizer = {
/** Feed input and get resulting tokens */
feed(input: string): Token[]
/** Flush any buffered incomplete sequences */
flush(): Token[]
/** Reset tokenizer state */
reset(): void
/** Get any buffered incomplete sequence */
buffer(): string
}
type TokenizerOptions = {
/**
* Treat `CSI M` as an X10 mouse event prefix and consume 3 payload bytes.
* Only enable for stdin input — `\x1b[M` is also CSI DL (Delete Lines) in
* output streams, and enabling this there swallows display text. Default false.
*/
x10Mouse?: boolean
}
/**
* Create a streaming tokenizer for terminal input.
*
* Usage:
* ```typescript
* const tokenizer = createTokenizer()
* const tokens1 = tokenizer.feed('hello\x1b[')
* const tokens2 = tokenizer.feed('A') // completes the escape sequence
* const remaining = tokenizer.flush() // force output incomplete sequences
* ```
*/
export function createTokenizer(options?: TokenizerOptions): Tokenizer {
let currentState: State = 'ground'
let currentBuffer = ''
const x10Mouse = options?.x10Mouse ?? false
return {
feed(input: string): Token[] {
const result = tokenize(
input,
currentState,
currentBuffer,
false,
x10Mouse,
)
currentState = result.state.state
currentBuffer = result.state.buffer
return result.tokens
},
flush(): Token[] {
const result = tokenize('', currentState, currentBuffer, true, x10Mouse)
currentState = result.state.state
currentBuffer = result.state.buffer
return result.tokens
},
reset(): void {
currentState = 'ground'
currentBuffer = ''
},
buffer(): string {
return currentBuffer
},
}
}
type InternalState = {
state: State
buffer: string
}
function tokenize(
input: string,
initialState: State,
initialBuffer: string,
flush: boolean,
x10Mouse: boolean,
): { tokens: Token[]; state: InternalState } {
const tokens: Token[] = []
const result: InternalState = {
state: initialState,
buffer: '',
}
const data = initialBuffer + input
let i = 0
let textStart = 0
let seqStart = 0
const flushText = (): void => {
if (i > textStart) {
const text = data.slice(textStart, i)
if (text) {
tokens.push({ type: 'text', value: text })
}
}
textStart = i
}
const emitSequence = (seq: string): void => {
if (seq) {
tokens.push({ type: 'sequence', value: seq })
}
result.state = 'ground'
textStart = i
}
while (i < data.length) {
const code = data.charCodeAt(i)
switch (result.state) {
case 'ground':
if (code === C0.ESC) {
flushText()
seqStart = i
result.state = 'escape'
i++
} else {
i++
}
break
case 'escape':
if (code === ESC_TYPE.CSI) {
result.state = 'csi'
i++
} else if (code === ESC_TYPE.OSC) {
result.state = 'osc'
i++
} else if (code === ESC_TYPE.DCS) {
result.state = 'dcs'
i++
} else if (code === ESC_TYPE.APC) {
result.state = 'apc'
i++
} else if (code === 0x4f) {
// 'O' - SS3
result.state = 'ss3'
i++
} else if (isCSIIntermediate(code)) {
// Intermediate byte (e.g., ESC ( for charset) - continue buffering
result.state = 'escapeIntermediate'
i++
} else if (isEscFinal(code)) {
// Two-character escape sequence
i++
emitSequence(data.slice(seqStart, i))
} else if (code === C0.ESC) {
// Double escape - emit first, start new
emitSequence(data.slice(seqStart, i))
seqStart = i
result.state = 'escape'
i++
} else {
// Invalid - treat ESC as text
result.state = 'ground'
textStart = seqStart
}
break
case 'escapeIntermediate':
// After intermediate byte(s), wait for final byte
if (isCSIIntermediate(code)) {
// More intermediate bytes
i++
} else if (isEscFinal(code)) {
// Final byte - complete the sequence
i++
emitSequence(data.slice(seqStart, i))
} else {
// Invalid - treat as text
result.state = 'ground'
textStart = seqStart
}
break
case 'csi':
// X10 mouse: CSI M + 3 raw payload bytes (Cb+32, Cx+32, Cy+32).
// M immediately after [ (offset 2) means no params — SGR mouse
// (CSI < … M) has a `<` param byte first and reaches M at offset > 2.
// Terminals that ignore DECSET 1006 but honor 1000/1002 emit this
// legacy encoding; without this branch the 3 payload bytes leak
// through as text (`` `rK `` / `arK` garbage in the prompt).
//
// Gated on x10Mouse — `\x1b[M` is also CSI DL (Delete Lines) and
// blindly consuming 3 chars corrupts output rendering (Parser/Ansi)
// and fragments bracketed-paste PASTE_END. Only stdin enables this.
// The ≥0x20 check on each payload slot is belt-and-suspenders: X10
// guarantees Cb≥32, Cx≥33, Cy≥33, so a control byte (ESC=0x1B) in
// any slot means this is CSI DL adjacent to another sequence, not a
// mouse event. Checking all three slots prevents PASTE_END's ESC
// from being consumed when paste content ends in `\x1b[M`+0-2 chars.
//
// Known limitation: this counts JS string chars, but X10 is byte-
// oriented and stdin uses utf8 encoding (App.tsx). At col 162-191 ×
// row 96-159 the two coord bytes (0xC2-0xDF, 0x80-0xBF) form a valid
// UTF-8 2-byte sequence and collapse to one char — the length check
// fails and the event buffers until the next keypress absorbs it.
// Fixing this requires latin1 stdin; X10's 223-coord cap is exactly
// why SGR was invented, and no-SGR terminals at 162+ cols are rare.
if (
x10Mouse &&
code === 0x4d /* M */ &&
i - seqStart === 2 &&
(i + 1 >= data.length || data.charCodeAt(i + 1) >= 0x20) &&
(i + 2 >= data.length || data.charCodeAt(i + 2) >= 0x20) &&
(i + 3 >= data.length || data.charCodeAt(i + 3) >= 0x20)
) {
if (i + 4 <= data.length) {
i += 4
emitSequence(data.slice(seqStart, i))
} else {
// Incomplete — exit loop; end-of-input buffers from seqStart.
// Re-entry re-tokenizes from ground via the invalid-CSI fallthrough.
i = data.length
}
break
}
if (isCSIFinal(code)) {
i++
emitSequence(data.slice(seqStart, i))
} else if (isCSIParam(code) || isCSIIntermediate(code)) {
i++
} else {
// Invalid CSI - abort, treat as text
result.state = 'ground'
textStart = seqStart
}
break
case 'ss3':
// SS3 sequences: ESC O followed by a single final byte
if (code >= 0x40 && code <= 0x7e) {
i++
emitSequence(data.slice(seqStart, i))
} else {
// Invalid - treat as text
result.state = 'ground'
textStart = seqStart
}
break
case 'osc':
if (code === C0.BEL) {
i++
emitSequence(data.slice(seqStart, i))
} else if (
code === C0.ESC &&
i + 1 < data.length &&
data.charCodeAt(i + 1) === ESC_TYPE.ST
) {
i += 2
emitSequence(data.slice(seqStart, i))
} else {
i++
}
break
case 'dcs':
case 'apc':
if (code === C0.BEL) {
i++
emitSequence(data.slice(seqStart, i))
} else if (
code === C0.ESC &&
i + 1 < data.length &&
data.charCodeAt(i + 1) === ESC_TYPE.ST
) {
i += 2
emitSequence(data.slice(seqStart, i))
} else {
i++
}
break
}
}
// Handle end of input
if (result.state === 'ground') {
flushText()
} else if (flush) {
// Force output incomplete sequence
const remaining = data.slice(seqStart)
if (remaining) tokens.push({ type: 'sequence', value: remaining })
result.state = 'ground'
} else {
// Buffer incomplete sequence for next call
result.buffer = data.slice(seqStart)
}
return { tokens, state: result }
}
+236
View File
@@ -0,0 +1,236 @@
/**
* ANSI Parser - Semantic Types
*
* These types represent the semantic meaning of ANSI escape sequences,
* not their string representation. Inspired by ghostty's action-based design.
*/
// =============================================================================
// Colors
// =============================================================================
/** Named colors from the 16-color palette */
export type NamedColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'brightBlack'
| 'brightRed'
| 'brightGreen'
| 'brightYellow'
| 'brightBlue'
| 'brightMagenta'
| 'brightCyan'
| 'brightWhite'
/** Color specification - can be named, indexed (256), or RGB */
export type Color =
| { type: 'named'; name: NamedColor }
| { type: 'indexed'; index: number } // 0-255
| { type: 'rgb'; r: number; g: number; b: number }
| { type: 'default' }
// =============================================================================
// Text Styles
// =============================================================================
/** Underline style variants */
export type UnderlineStyle =
| 'none'
| 'single'
| 'double'
| 'curly'
| 'dotted'
| 'dashed'
/** Text style attributes - represents current styling state */
export type TextStyle = {
bold: boolean
dim: boolean
italic: boolean
underline: UnderlineStyle
blink: boolean
inverse: boolean
hidden: boolean
strikethrough: boolean
overline: boolean
fg: Color
bg: Color
underlineColor: Color
}
/** Create a default (reset) text style */
export function defaultStyle(): TextStyle {
return {
bold: false,
dim: false,
italic: false,
underline: 'none',
blink: false,
inverse: false,
hidden: false,
strikethrough: false,
overline: false,
fg: { type: 'default' },
bg: { type: 'default' },
underlineColor: { type: 'default' },
}
}
/** Check if two styles are equal */
export function stylesEqual(a: TextStyle, b: TextStyle): boolean {
return (
a.bold === b.bold &&
a.dim === b.dim &&
a.italic === b.italic &&
a.underline === b.underline &&
a.blink === b.blink &&
a.inverse === b.inverse &&
a.hidden === b.hidden &&
a.strikethrough === b.strikethrough &&
a.overline === b.overline &&
colorsEqual(a.fg, b.fg) &&
colorsEqual(a.bg, b.bg) &&
colorsEqual(a.underlineColor, b.underlineColor)
)
}
/** Check if two colors are equal */
export function colorsEqual(a: Color, b: Color): boolean {
if (a.type !== b.type) return false
switch (a.type) {
case 'named':
return a.name === (b as typeof a).name
case 'indexed':
return a.index === (b as typeof a).index
case 'rgb':
return (
a.r === (b as typeof a).r &&
a.g === (b as typeof a).g &&
a.b === (b as typeof a).b
)
case 'default':
return true
}
}
// =============================================================================
// Cursor Actions
// =============================================================================
export type CursorDirection = 'up' | 'down' | 'forward' | 'back'
export type CursorAction =
| { type: 'move'; direction: CursorDirection; count: number }
| { type: 'position'; row: number; col: number }
| { type: 'column'; col: number }
| { type: 'row'; row: number }
| { type: 'save' }
| { type: 'restore' }
| { type: 'show' }
| { type: 'hide' }
| {
type: 'style'
style: 'block' | 'underline' | 'bar'
blinking: boolean
}
| { type: 'nextLine'; count: number }
| { type: 'prevLine'; count: number }
// =============================================================================
// Erase Actions
// =============================================================================
export type EraseAction =
| { type: 'display'; region: 'toEnd' | 'toStart' | 'all' | 'scrollback' }
| { type: 'line'; region: 'toEnd' | 'toStart' | 'all' }
| { type: 'chars'; count: number }
// =============================================================================
// Scroll Actions
// =============================================================================
export type ScrollAction =
| { type: 'up'; count: number }
| { type: 'down'; count: number }
| { type: 'setRegion'; top: number; bottom: number }
// =============================================================================
// Mode Actions
// =============================================================================
export type ModeAction =
| { type: 'alternateScreen'; enabled: boolean }
| { type: 'bracketedPaste'; enabled: boolean }
| { type: 'mouseTracking'; mode: 'off' | 'normal' | 'button' | 'any' }
| { type: 'focusEvents'; enabled: boolean }
// =============================================================================
// Link Actions (OSC 8)
// =============================================================================
export type LinkAction =
| { type: 'start'; url: string; params?: Record<string, string> }
| { type: 'end' }
// =============================================================================
// Title Actions (OSC 0/1/2)
// =============================================================================
export type TitleAction =
| { type: 'windowTitle'; title: string }
| { type: 'iconName'; name: string }
| { type: 'both'; title: string }
// =============================================================================
// Tab Status Action (OSC 21337)
// =============================================================================
/**
* Per-tab chrome metadata. Tristate for each field:
* - property absent → not mentioned in sequence, no change
* - null → explicitly cleared (bare key or key= with empty value)
* - value → set to this
*/
export type TabStatusAction = {
indicator?: Color | null
status?: string | null
statusColor?: Color | null
}
// =============================================================================
// Parsed Segments - The output of the parser
// =============================================================================
/** A segment of styled text */
export type TextSegment = {
type: 'text'
text: string
style: TextStyle
}
/** A grapheme (visual character unit) with width info */
export type Grapheme = {
value: string
width: 1 | 2 // Display width in columns
}
/** All possible parsed actions */
export type Action =
| { type: 'text'; graphemes: Grapheme[]; style: TextStyle }
| { type: 'cursor'; action: CursorAction }
| { type: 'erase'; action: EraseAction }
| { type: 'scroll'; action: ScrollAction }
| { type: 'mode'; action: ModeAction }
| { type: 'link'; action: LinkAction }
| { type: 'title'; action: TitleAction }
| { type: 'tabStatus'; action: TabStatusAction }
| { type: 'sgr'; params: string } // Select Graphic Rendition (style change)
| { type: 'bell' }
| { type: 'reset' } // Full terminal reset (ESC c)
| { type: 'unknown'; sequence: string } // Unrecognized sequence