init claude-code
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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}` }
|
||||
}
|
||||
@@ -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` (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(';'))
|
||||
}
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user