@silvery/term 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSC 10/11/12 Terminal Color Queries
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to query and set the terminal's foreground, background,
|
|
5
|
+
* and cursor colors. Also provides theme detection via background luminance.
|
|
6
|
+
*
|
|
7
|
+
* Protocol: OSC N
|
|
8
|
+
* - Query: ESC ] N ; ? BEL
|
|
9
|
+
* - Set: ESC ] N ; <color> BEL
|
|
10
|
+
* - Reset: ESC ] N BEL (some terminals) or ESC ] 1N BEL (110/111/112)
|
|
11
|
+
* - Response: ESC ] N ; rgb:RRRR/GGGG/BBBB ST
|
|
12
|
+
*
|
|
13
|
+
* Where N is:
|
|
14
|
+
* 10 = foreground (text) color
|
|
15
|
+
* 11 = background color
|
|
16
|
+
* 12 = cursor color
|
|
17
|
+
*
|
|
18
|
+
* Response format uses the same rgb: notation as OSC 4 palette colors.
|
|
19
|
+
* Terminators: BEL (\x07) or ST (ESC \) — both are accepted.
|
|
20
|
+
*
|
|
21
|
+
* Supported by: xterm, Ghostty, Kitty, WezTerm, iTerm2, foot, Alacritty
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const ESC = "\x1b"
|
|
25
|
+
const BEL = "\x07"
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Response Parsing (shared)
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Regex for an OSC color response body: rgb:R/G/B (1-4 hex digits per channel)
|
|
33
|
+
*/
|
|
34
|
+
const RGB_BODY_RE = /rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a hex color channel to 2-digit hex.
|
|
38
|
+
* - 1-digit: repeat (e.g., "f" -> "ff")
|
|
39
|
+
* - 2-digit: as-is
|
|
40
|
+
* - 3-digit: take first 2
|
|
41
|
+
* - 4-digit: take first 2
|
|
42
|
+
*/
|
|
43
|
+
function normalizeHexChannel(hex: string): string {
|
|
44
|
+
switch (hex.length) {
|
|
45
|
+
case 1:
|
|
46
|
+
return hex + hex
|
|
47
|
+
case 2:
|
|
48
|
+
return hex
|
|
49
|
+
default:
|
|
50
|
+
return hex.slice(0, 2)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse an OSC color response (10/11/12) into a #RRGGBB hex string.
|
|
56
|
+
*
|
|
57
|
+
* @param input Raw terminal input
|
|
58
|
+
* @param oscCode The OSC code to look for (10, 11, or 12)
|
|
59
|
+
* @returns Normalized #RRGGBB hex string, or null if not a valid response
|
|
60
|
+
*/
|
|
61
|
+
function parseOscColorResponse(input: string, oscCode: number): string | null {
|
|
62
|
+
const prefix = `${ESC}]${oscCode};`
|
|
63
|
+
const prefixIdx = input.indexOf(prefix)
|
|
64
|
+
if (prefixIdx === -1) return null
|
|
65
|
+
|
|
66
|
+
const bodyStart = prefixIdx + prefix.length
|
|
67
|
+
|
|
68
|
+
// Find terminator: BEL (\x07) or ST (ESC \)
|
|
69
|
+
let bodyEnd = input.indexOf(BEL, bodyStart)
|
|
70
|
+
if (bodyEnd === -1) {
|
|
71
|
+
bodyEnd = input.indexOf(`${ESC}\\`, bodyStart)
|
|
72
|
+
}
|
|
73
|
+
if (bodyEnd === -1) return null
|
|
74
|
+
|
|
75
|
+
const body = input.slice(bodyStart, bodyEnd)
|
|
76
|
+
const match = RGB_BODY_RE.exec(body)
|
|
77
|
+
if (!match) return null
|
|
78
|
+
|
|
79
|
+
const r = normalizeHexChannel(match[1]!)
|
|
80
|
+
const g = normalizeHexChannel(match[2]!)
|
|
81
|
+
const b = normalizeHexChannel(match[3]!)
|
|
82
|
+
|
|
83
|
+
return `#${r}${g}${b}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Query Functions
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Query a terminal color via OSC code.
|
|
92
|
+
*
|
|
93
|
+
* @param write Function to write to stdout
|
|
94
|
+
* @param read Function to read a chunk from stdin
|
|
95
|
+
* @param oscCode The OSC code (10, 11, or 12)
|
|
96
|
+
* @param timeoutMs How long to wait for response
|
|
97
|
+
*/
|
|
98
|
+
async function queryOscColor(
|
|
99
|
+
write: (data: string) => void,
|
|
100
|
+
read: (timeoutMs: number) => Promise<string | null>,
|
|
101
|
+
oscCode: number,
|
|
102
|
+
timeoutMs: number,
|
|
103
|
+
): Promise<string | null> {
|
|
104
|
+
write(`${ESC}]${oscCode};?${BEL}`)
|
|
105
|
+
|
|
106
|
+
const data = await read(timeoutMs)
|
|
107
|
+
if (data == null) return null
|
|
108
|
+
|
|
109
|
+
return parseOscColorResponse(data, oscCode)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Query the terminal foreground (text) color.
|
|
114
|
+
* @returns "#RRGGBB" hex string, or null on timeout/unsupported
|
|
115
|
+
*/
|
|
116
|
+
export async function queryForegroundColor(
|
|
117
|
+
write: (data: string) => void,
|
|
118
|
+
read: (timeoutMs: number) => Promise<string | null>,
|
|
119
|
+
timeoutMs = 200,
|
|
120
|
+
): Promise<string | null> {
|
|
121
|
+
return queryOscColor(write, read, 10, timeoutMs)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Query the terminal background color.
|
|
126
|
+
* @returns "#RRGGBB" hex string, or null on timeout/unsupported
|
|
127
|
+
*/
|
|
128
|
+
export async function queryBackgroundColor(
|
|
129
|
+
write: (data: string) => void,
|
|
130
|
+
read: (timeoutMs: number) => Promise<string | null>,
|
|
131
|
+
timeoutMs = 200,
|
|
132
|
+
): Promise<string | null> {
|
|
133
|
+
return queryOscColor(write, read, 11, timeoutMs)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Query the terminal cursor color.
|
|
138
|
+
* @returns "#RRGGBB" hex string, or null on timeout/unsupported
|
|
139
|
+
*/
|
|
140
|
+
export async function queryCursorColor(
|
|
141
|
+
write: (data: string) => void,
|
|
142
|
+
read: (timeoutMs: number) => Promise<string | null>,
|
|
143
|
+
timeoutMs = 200,
|
|
144
|
+
): Promise<string | null> {
|
|
145
|
+
return queryOscColor(write, read, 12, timeoutMs)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Set Functions
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
/** Set the terminal foreground (text) color. */
|
|
153
|
+
export function setForegroundColor(write: (data: string) => void, color: string): void {
|
|
154
|
+
write(`${ESC}]10;${color}${BEL}`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Set the terminal background color. */
|
|
158
|
+
export function setBackgroundColor(write: (data: string) => void, color: string): void {
|
|
159
|
+
write(`${ESC}]11;${color}${BEL}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Set the terminal cursor color. */
|
|
163
|
+
export function setCursorColor(write: (data: string) => void, color: string): void {
|
|
164
|
+
write(`${ESC}]12;${color}${BEL}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ============================================================================
|
|
168
|
+
// Reset Functions
|
|
169
|
+
// ============================================================================
|
|
170
|
+
|
|
171
|
+
/** Reset the terminal foreground color to default. */
|
|
172
|
+
export function resetForegroundColor(write: (data: string) => void): void {
|
|
173
|
+
write(`${ESC}]110${BEL}`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Reset the terminal background color to default. */
|
|
177
|
+
export function resetBackgroundColor(write: (data: string) => void): void {
|
|
178
|
+
write(`${ESC}]111${BEL}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Reset the terminal cursor color to default. */
|
|
182
|
+
export function resetCursorColor(write: (data: string) => void): void {
|
|
183
|
+
write(`${ESC}]112${BEL}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// Theme Detection
|
|
188
|
+
// ============================================================================
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Detect the terminal color scheme (light or dark) by querying the
|
|
192
|
+
* background color and computing its relative luminance.
|
|
193
|
+
*
|
|
194
|
+
* Uses the standard sRGB luminance formula:
|
|
195
|
+
* L = 0.2126*R + 0.7152*G + 0.0722*B
|
|
196
|
+
*
|
|
197
|
+
* L > 0.5 → light theme, L <= 0.5 → dark theme
|
|
198
|
+
*
|
|
199
|
+
* @returns "light" or "dark", or null if the background color could not be queried
|
|
200
|
+
*/
|
|
201
|
+
export async function detectColorScheme(
|
|
202
|
+
write: (data: string) => void,
|
|
203
|
+
read: (timeoutMs: number) => Promise<string | null>,
|
|
204
|
+
timeoutMs = 200,
|
|
205
|
+
): Promise<"light" | "dark" | null> {
|
|
206
|
+
const bg = await queryBackgroundColor(write, read, timeoutMs)
|
|
207
|
+
if (bg == null) return null
|
|
208
|
+
|
|
209
|
+
// Parse #RRGGBB
|
|
210
|
+
const r = parseInt(bg.slice(1, 3), 16) / 255
|
|
211
|
+
const g = parseInt(bg.slice(3, 5), 16) / 255
|
|
212
|
+
const b = parseInt(bg.slice(5, 7), 16) / 255
|
|
213
|
+
|
|
214
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
215
|
+
return luminance > 0.5 ? "light" : "dark"
|
|
216
|
+
}
|
package/src/termtest.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Capability Test
|
|
3
|
+
*
|
|
4
|
+
* Renders labeled test patterns for each terminal feature.
|
|
5
|
+
* Run in any terminal to visually verify what it supports.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { runTermtest } from "@silvery/react"
|
|
9
|
+
* runTermtest() // all sections
|
|
10
|
+
* runTermtest({ sections: ["emoji", "colors"] }) // specific sections
|
|
11
|
+
*
|
|
12
|
+
* Compare output across terminals to build/verify profiles.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { detectTerminalCaps } from "./terminal-caps"
|
|
16
|
+
|
|
17
|
+
const ESC = "\x1b"
|
|
18
|
+
const CSI = `${ESC}[`
|
|
19
|
+
const RESET = `${CSI}0m`
|
|
20
|
+
|
|
21
|
+
function sgr(...codes: (string | number)[]): string {
|
|
22
|
+
return `${CSI}${codes.join(";")}m`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sectionHeader(title: string): string {
|
|
26
|
+
return `\n${sgr(1)}═══ ${title} ═══${RESET}\n`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function row(label: string, content: string): string {
|
|
30
|
+
return ` ${label.padEnd(24)} ${content}${RESET}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Available test sections */
|
|
34
|
+
export const TERMTEST_SECTIONS = [
|
|
35
|
+
"sgr",
|
|
36
|
+
"underline",
|
|
37
|
+
"colors",
|
|
38
|
+
"256",
|
|
39
|
+
"truecolor",
|
|
40
|
+
"unicode",
|
|
41
|
+
"emoji",
|
|
42
|
+
"borders",
|
|
43
|
+
"inverse",
|
|
44
|
+
"profile",
|
|
45
|
+
] as const
|
|
46
|
+
|
|
47
|
+
export type TermtestSection = (typeof TERMTEST_SECTIONS)[number]
|
|
48
|
+
|
|
49
|
+
export interface TermtestOptions {
|
|
50
|
+
/** Writable stream (defaults to process.stdout) */
|
|
51
|
+
output?: { write(s: string): boolean }
|
|
52
|
+
/** Show only these sections. Omit or empty = all sections. */
|
|
53
|
+
sections?: TermtestSection[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run the terminal capability test.
|
|
58
|
+
* Pass section names to filter: `runTermtest({ sections: ["emoji"] })`
|
|
59
|
+
*/
|
|
60
|
+
export function runTermtest(options?: TermtestOptions): void {
|
|
61
|
+
const w = options?.output ?? process.stdout
|
|
62
|
+
const filter = options?.sections
|
|
63
|
+
const show = (s: TermtestSection) => !filter || filter.length === 0 || filter.includes(s)
|
|
64
|
+
|
|
65
|
+
const caps = detectTerminalCaps()
|
|
66
|
+
|
|
67
|
+
w.write(`\n${sgr(1)}Terminal Capability Test${RESET}\n`)
|
|
68
|
+
w.write(` Program: ${caps.program || "(unknown)"}\n`)
|
|
69
|
+
w.write(` TERM: ${caps.term || "(unknown)"}\n`)
|
|
70
|
+
w.write(` COLORTERM: ${process.env.COLORTERM || "(unset)"}\n`)
|
|
71
|
+
w.write(` Detected: color=${caps.colorLevel} dark=${caps.darkBackground} nerdfont=${caps.nerdfont}\n`)
|
|
72
|
+
w.write(` Underline: styles=${caps.underlineStyles} color=${caps.underlineColor}\n`)
|
|
73
|
+
w.write(` Emoji wide: ${caps.textEmojiWide}\n`)
|
|
74
|
+
|
|
75
|
+
if (show("sgr")) {
|
|
76
|
+
w.write(sectionHeader("SGR Text Attributes"))
|
|
77
|
+
w.write(row("Bold", `${sgr(1)}The quick brown fox${RESET}`) + "\n")
|
|
78
|
+
w.write(row("Dim", `${sgr(2)}The quick brown fox${RESET}`) + "\n")
|
|
79
|
+
w.write(row("Italic", `${sgr(3)}The quick brown fox${RESET}`) + "\n")
|
|
80
|
+
w.write(row("Underline", `${sgr(4)}The quick brown fox${RESET}`) + "\n")
|
|
81
|
+
w.write(row("Strikethrough", `${sgr(9)}The quick brown fox${RESET}`) + "\n")
|
|
82
|
+
w.write(row("Inverse", `${sgr(7)}The quick brown fox${RESET}`) + "\n")
|
|
83
|
+
w.write(row("Blink", `${sgr(5)}The quick brown fox${RESET}`) + "\n")
|
|
84
|
+
w.write(row("Bold+Italic", `${sgr(1, 3)}The quick brown fox${RESET}`) + "\n")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (show("underline")) {
|
|
88
|
+
w.write(sectionHeader("SGR 4:x Underline Styles (Terminal.app BREAKS here)"))
|
|
89
|
+
w.write(row("4:1 Single", `${CSI}4:1mThe quick brown fox${RESET}`) + "\n")
|
|
90
|
+
w.write(row("4:2 Double", `${CSI}4:2mThe quick brown fox${RESET}`) + "\n")
|
|
91
|
+
w.write(row("4:3 Curly", `${CSI}4:3mThe quick brown fox${RESET}`) + "\n")
|
|
92
|
+
w.write(row("4:4 Dotted", `${CSI}4:4mThe quick brown fox${RESET}`) + "\n")
|
|
93
|
+
w.write(row("4:5 Dashed", `${CSI}4:5mThe quick brown fox${RESET}`) + "\n")
|
|
94
|
+
w.write(
|
|
95
|
+
row("After 4:x (clean?)", `${CSI}4:3m${RESET}This text should be normal — if garbled, 4:x corrupted SGR state`) +
|
|
96
|
+
"\n",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
w.write(sectionHeader("SGR 58 Underline Color (Terminal.app BREAKS here)"))
|
|
100
|
+
w.write(row("58;5;1 Red UL", `${sgr(4)}${CSI}58;5;1mThe quick brown fox${RESET}`) + "\n")
|
|
101
|
+
w.write(row("58;5;2 Green UL", `${sgr(4)}${CSI}58;5;2mThe quick brown fox${RESET}`) + "\n")
|
|
102
|
+
w.write(row("58;5;4 Blue UL", `${sgr(4)}${CSI}58;5;4mThe quick brown fox${RESET}`) + "\n")
|
|
103
|
+
w.write(row("58;2;R;G;B TC UL", `${sgr(4)}${CSI}58;2;255;128;0mThe quick brown fox${RESET}`) + "\n")
|
|
104
|
+
w.write(
|
|
105
|
+
row(
|
|
106
|
+
"After SGR 58 (clean?)",
|
|
107
|
+
`${sgr(4)}${CSI}58;5;1m${RESET}This text should be normal — if garbled, 58 corrupted SGR state`,
|
|
108
|
+
) + "\n",
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (show("colors")) {
|
|
113
|
+
w.write(sectionHeader("ANSI 16 Colors"))
|
|
114
|
+
const colorNames = ["Black", "Red", "Green", "Yellow", "Blue", "Magenta", "Cyan", "White"]
|
|
115
|
+
let fgLine = " FG: "
|
|
116
|
+
for (let i = 0; i < 8; i++) fgLine += `${sgr(30 + i)} ${colorNames[i]}${RESET}`
|
|
117
|
+
w.write(fgLine + "\n")
|
|
118
|
+
let fgBrLine = " Br: "
|
|
119
|
+
for (let i = 0; i < 8; i++) fgBrLine += `${sgr(90 + i)} ${colorNames[i]}${RESET}`
|
|
120
|
+
w.write(fgBrLine + "\n")
|
|
121
|
+
let bgLine = " BG: "
|
|
122
|
+
for (let i = 0; i < 8; i++) bgLine += `${sgr(40 + i)} ${colorNames[i]} ${RESET}`
|
|
123
|
+
w.write(bgLine + "\n")
|
|
124
|
+
let bgBrLine = " BrBG:"
|
|
125
|
+
for (let i = 0; i < 8; i++) bgBrLine += `${sgr(100 + i)} ${colorNames[i]} ${RESET}`
|
|
126
|
+
w.write(bgBrLine + "\n")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (show("256")) {
|
|
130
|
+
w.write(sectionHeader("256 Colors (indices 0-15, 16-231, 232-255)"))
|
|
131
|
+
let stdLine = " 0-15: "
|
|
132
|
+
for (let i = 0; i < 16; i++) stdLine += `${CSI}48;5;${i}m ${RESET}`
|
|
133
|
+
w.write(stdLine + "\n")
|
|
134
|
+
let cubeLine = " Cube: "
|
|
135
|
+
for (let i = 16; i < 52; i++) cubeLine += `${CSI}48;5;${i}m ${RESET}`
|
|
136
|
+
w.write(cubeLine + "\n")
|
|
137
|
+
let grayLine = " Gray: "
|
|
138
|
+
for (let i = 232; i < 256; i++) grayLine += `${CSI}48;5;${i}m ${RESET}`
|
|
139
|
+
w.write(grayLine + "\n")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (show("truecolor")) {
|
|
143
|
+
w.write(sectionHeader("Truecolor (38;2;R;G;B / 48;2;R;G;B)"))
|
|
144
|
+
let tcLine = " Gradient: "
|
|
145
|
+
for (let i = 0; i < 40; i++) {
|
|
146
|
+
const r = Math.round((i / 39) * 255)
|
|
147
|
+
const g = Math.round(((39 - i) / 39) * 128)
|
|
148
|
+
tcLine += `${CSI}48;2;${r};${g};80m ${RESET}`
|
|
149
|
+
}
|
|
150
|
+
w.write(tcLine + "\n")
|
|
151
|
+
w.write(row("If solid blocks →", "Truecolor NOT supported (256-color fallback)") + "\n")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (show("unicode")) {
|
|
155
|
+
w.write(sectionHeader("Unicode, Emoji, PUA (Nerd Fonts)"))
|
|
156
|
+
w.write(row("ASCII", "Hello World! 0123456789") + "\n")
|
|
157
|
+
w.write(row("Latin Extended", "àéîõü ñ ß ø å") + "\n")
|
|
158
|
+
w.write(row("CJK", "你好世界 日本語 한국어") + "\n")
|
|
159
|
+
w.write(row("Box Drawing", "┌─┬─┐ ╔═╦═╗ ╭─╮") + "\n")
|
|
160
|
+
w.write(row("Block Elements", "▀▄█▌▐░▒▓") + "\n")
|
|
161
|
+
w.write(row("Symbols", "● ○ ◉ ▶ ◀ ⚠ ✓ ✗ ⋮ §") + "\n")
|
|
162
|
+
w.write(row("Emoji", "🎉 🚀 📁 📄 ⭐ 🔥 👍") + "\n")
|
|
163
|
+
w.write(row("Nerd Font PUA", "\uF114 folder \uF0F6 file \uE0B0 arrow \uF013 gear") + "\n")
|
|
164
|
+
w.write(row("If PUA = boxes →", "Nerd Fonts not installed") + "\n")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (show("emoji")) {
|
|
168
|
+
// Each test line places a character then fills to exactly 10 visible columns.
|
|
169
|
+
// If the _'s don't align with the ruler, the terminal's character width
|
|
170
|
+
// disagrees with the expected width (shown in parentheses).
|
|
171
|
+
w.write(sectionHeader("Emoji Width Alignment (_'s should align with ruler)"))
|
|
172
|
+
w.write(" Ruler: |1234567890|\n")
|
|
173
|
+
w.write(" ASCII 'A': |A_________| (w=1)\n")
|
|
174
|
+
w.write(" CJK '你': |你________| (w=2)\n")
|
|
175
|
+
w.write(" Flag 🇨🇦: |🇨🇦________| (w=2)\n")
|
|
176
|
+
w.write(" Flag 🇺🇸: |🇺🇸________| (w=2)\n")
|
|
177
|
+
w.write(" Emoji 📁: |📁________| (w=2)\n")
|
|
178
|
+
w.write(" Emoji 📄: |📄________| (w=2)\n")
|
|
179
|
+
w.write(" Emoji 📋: |📋________| (w=2)\n")
|
|
180
|
+
w.write(" Emoji ⚠️: |⚠️________| (w=2)\n")
|
|
181
|
+
w.write(" Text ⚠: |⚠_________| (w=1 text, 2 emoji)\n")
|
|
182
|
+
w.write(" Text ⭐: |⭐________| (w=1 text, 2 emoji)\n")
|
|
183
|
+
w.write(" Text ☑: |☑_________| (w=1 text, 2 emoji)\n")
|
|
184
|
+
w.write(" Emoji 🏠: |🏠________| (w=2)\n")
|
|
185
|
+
w.write(" Emoji 👓: |👓________| (w=2)\n")
|
|
186
|
+
w.write(" ZWJ 👨🏻💻: |👨🏻💻________| (w=2)\n")
|
|
187
|
+
w.write(" Arrow →: |→_________| (w=1)\n")
|
|
188
|
+
w.write(" Arrow ▸: |▸_________| (w=1)\n")
|
|
189
|
+
w.write(" Circle ○: |○_________| (w=1)\n")
|
|
190
|
+
w.write(" Square □: |□_________| (w=1)\n")
|
|
191
|
+
w.write(" Check ✓: |✓_________| (w=1)\n")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (show("borders")) {
|
|
195
|
+
w.write(sectionHeader("Box Drawing Borders"))
|
|
196
|
+
w.write(" ┌──────────┐ ╔══════════╗ ╭──────────╮\n")
|
|
197
|
+
w.write(" │ single │ ║ double ║ │ round │\n")
|
|
198
|
+
w.write(" └──────────┘ ╚══════════╝ ╰──────────╯\n")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (show("inverse")) {
|
|
202
|
+
w.write(sectionHeader("Inverse + Background (potential artifact source)"))
|
|
203
|
+
w.write(row("Red FG + Inverse", `${sgr(31, 7)}This should have red background${RESET}`) + "\n")
|
|
204
|
+
w.write(row("Cyan BG + White FG", `${sgr(46, 37)}Cyan background, white text${RESET}`) + "\n")
|
|
205
|
+
w.write(row("Black BG + White FG", `${sgr(40, 97)}Black bg, bright white text${RESET}`) + "\n")
|
|
206
|
+
w.write(row("White BG + Black FG", `${sgr(107, 30)}White bg, black text${RESET}`) + "\n")
|
|
207
|
+
|
|
208
|
+
w.write(sectionHeader("Reset Sanity Check"))
|
|
209
|
+
w.write(" This line should be completely normal with no formatting artifacts.\n")
|
|
210
|
+
w.write(" If you see colors, underlines, or other styling above, the terminal\n")
|
|
211
|
+
w.write(" failed to process an SGR reset (\\x1b[0m) correctly.\n")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (show("profile")) {
|
|
215
|
+
w.write(sectionHeader("Detected Terminal Profile"))
|
|
216
|
+
const entries = Object.entries(caps) as [string, unknown][]
|
|
217
|
+
for (const [key, value] of entries) {
|
|
218
|
+
const indicator = value === true ? "✓" : value === false ? "✗" : String(value)
|
|
219
|
+
w.write(` ${key.padEnd(20)} ${indicator}\n`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
w.write("\n")
|
|
224
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Sizing Protocol (OSC 66) -- Kitty v0.40+
|
|
3
|
+
*
|
|
4
|
+
* Lets the app specify how many cells a character should occupy.
|
|
5
|
+
* This solves the measurement/rendering mismatch for Private Use Area (PUA)
|
|
6
|
+
* characters (nerdfont icons, powerline symbols) that `string-width` reports
|
|
7
|
+
* as 1-cell but terminals render as 2-cell.
|
|
8
|
+
*
|
|
9
|
+
* When OSC 66 is used with w=2, both the app's layout engine and the terminal
|
|
10
|
+
* agree on the character width, eliminating truncation and misalignment.
|
|
11
|
+
*
|
|
12
|
+
* Protocol format:
|
|
13
|
+
* ESC ] 66 ; w=<width> ; <text> BEL
|
|
14
|
+
*
|
|
15
|
+
* @see https://sw.kovidgoyal.net/kitty/text-sizing-protocol/
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const OSC = "\x1b]"
|
|
19
|
+
const ST = "\x07" // BEL terminator (more compatible than ESC \)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wrap text in an OSC 66 sequence that tells the terminal to render it
|
|
23
|
+
* in exactly `width` cells.
|
|
24
|
+
*/
|
|
25
|
+
export function textSized(text: string, width: number): string {
|
|
26
|
+
return `${OSC}66;w=${width};${text}${ST}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a code point is in the Private Use Area (PUA).
|
|
31
|
+
* Covers BMP PUA (U+E000-U+F8FF) and Supplementary PUA-A/B.
|
|
32
|
+
*/
|
|
33
|
+
export function isPrivateUseArea(cp: number): boolean {
|
|
34
|
+
return (
|
|
35
|
+
(cp >= 0xe000 && cp <= 0xf8ff) || // BMP PUA
|
|
36
|
+
(cp >= 0xf0000 && cp <= 0xffffd) || // Supplementary PUA-A
|
|
37
|
+
(cp >= 0x100000 && cp <= 0x10fffd) // Supplementary PUA-B
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if text sizing is likely supported based on environment variables.
|
|
43
|
+
* This is a fast synchronous check -- use detectTextSizingSupport() for
|
|
44
|
+
* definitive detection via cursor position reports.
|
|
45
|
+
*/
|
|
46
|
+
export function isTextSizingLikelySupported(): boolean {
|
|
47
|
+
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? ""
|
|
48
|
+
const termVersion = process.env.TERM_PROGRAM_VERSION ?? ""
|
|
49
|
+
|
|
50
|
+
// Kitty v0.40+ supports OSC 66
|
|
51
|
+
if (termProgram === "kitty") {
|
|
52
|
+
const parts = termVersion.split(".")
|
|
53
|
+
const major = Number(parts[0]) || 0
|
|
54
|
+
const minor = Number(parts[1]) || 0
|
|
55
|
+
if (major > 0 || (major === 0 && minor >= 40)) return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Ghostty parses OSC 66 but does NOT render it (as of v1.3.0, March 2026).
|
|
59
|
+
// Wrapping text in OSC 66 causes Ghostty to swallow the content silently.
|
|
60
|
+
// Re-enable when Ghostty ships actual text sizing GUI support.
|
|
61
|
+
// if (termProgram === "ghostty") return true
|
|
62
|
+
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Detect terminal support for the text sizing protocol.
|
|
68
|
+
* Uses cursor position reports (CPR) to check if OSC 66 advances the cursor
|
|
69
|
+
* by the specified width.
|
|
70
|
+
*
|
|
71
|
+
* @returns Object with `supported` and `widthOnly` flags:
|
|
72
|
+
* - supported=true, widthOnly=false: full support (scale + width)
|
|
73
|
+
* - supported=true, widthOnly=true: width mode only
|
|
74
|
+
* - supported=false: no support
|
|
75
|
+
*/
|
|
76
|
+
export async function detectTextSizingSupport(
|
|
77
|
+
write: (data: string) => void,
|
|
78
|
+
read: () => Promise<string>,
|
|
79
|
+
timeout = 1000,
|
|
80
|
+
): Promise<{ supported: boolean; widthOnly: boolean }> {
|
|
81
|
+
// Detection sequence:
|
|
82
|
+
// 1. CR to column 0
|
|
83
|
+
// 2. OSC 66 w=2 with a space character
|
|
84
|
+
// 3. Request CPR (cursor position report)
|
|
85
|
+
// If cursor is at column 3 (1-indexed), w=2 worked
|
|
86
|
+
const testSequence = "\r" + textSized(" ", 2) + "\x1b[6n" + "\r\x1b[K"
|
|
87
|
+
write(testSequence)
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const response = await Promise.race([
|
|
91
|
+
read(),
|
|
92
|
+
new Promise<string>((_resolve, reject) => setTimeout(() => reject(new Error("timeout")), timeout)),
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
// Parse CPR response: ESC [ row ; col R
|
|
96
|
+
const match = response.match(/\x1b\[(\d+);(\d+)R/)
|
|
97
|
+
if (match) {
|
|
98
|
+
const col = Number(match[2])
|
|
99
|
+
// Column 3 means the space occupied 2 cells (col is 1-indexed, started at 1)
|
|
100
|
+
if (col === 3) {
|
|
101
|
+
return { supported: true, widthOnly: false }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { supported: false, widthOnly: false }
|
|
106
|
+
} catch {
|
|
107
|
+
return { supported: false, widthOnly: false }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silvery/toolbelt - Diagnostic and helper tools for debugging TUI rendering
|
|
3
|
+
*
|
|
4
|
+
* Central import for all diagnostic utilities. Use this when you need to:
|
|
5
|
+
* - Debug incremental rendering issues
|
|
6
|
+
* - Verify ANSI replay correctness
|
|
7
|
+
* - Check buffer content stability
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import {
|
|
12
|
+
* withDiagnostics,
|
|
13
|
+
* VirtualTerminal,
|
|
14
|
+
* IncrementalRenderMismatchError,
|
|
15
|
+
* compareBuffers,
|
|
16
|
+
* formatMismatch,
|
|
17
|
+
* } from '@silvery/term/toolbelt';
|
|
18
|
+
*
|
|
19
|
+
* // All checks enabled by default when plugin is used
|
|
20
|
+
* const driver = withDiagnostics(createBoardDriver(repo, rootId));
|
|
21
|
+
*
|
|
22
|
+
* // Or disable specific checks
|
|
23
|
+
* const driver = withDiagnostics(createBoardDriver(repo, rootId), {
|
|
24
|
+
* checkReplay: false // skip ANSI replay check
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Diagnostic Plugin
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
withDiagnostics,
|
|
37
|
+
checkLayoutInvariants,
|
|
38
|
+
VirtualTerminal,
|
|
39
|
+
type DiagnosticOptions,
|
|
40
|
+
} from "@silvery/tea/with-diagnostics"
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// Error Types
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
export { IncrementalRenderMismatchError } from "../scheduler"
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Buffer Comparison
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
export { compareBuffers, formatMismatch, type BufferMismatch } from "@silvery/test/compare-buffers"
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Pipeline Internals (for manual ANSI replay verification)
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
export { outputPhase } from "../pipeline"
|
|
59
|
+
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// Mismatch Debug Utilities
|
|
62
|
+
// =============================================================================
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
findNodeAtPosition,
|
|
66
|
+
findAllContainingNodes,
|
|
67
|
+
getNodeDebugInfo,
|
|
68
|
+
buildMismatchContext,
|
|
69
|
+
formatMismatchContext,
|
|
70
|
+
type NodeDebugInfo,
|
|
71
|
+
type MismatchDebugContext,
|
|
72
|
+
} from "@silvery/test/debug-mismatch"
|