@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.
Files changed (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. 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
+ }
@@ -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"