@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,57 @@
1
+ import { writeFile } from "node:fs/promises"
2
+
3
+ // ============================================================================
4
+ // Types
5
+ // ============================================================================
6
+
7
+ export interface Screenshotter {
8
+ /** Render HTML to PNG. First call starts Playwright (~3-5s), subsequent calls ~200ms */
9
+ capture(html: string, outputPath?: string): Promise<Buffer>
10
+ /** Close browser */
11
+ close(): Promise<void>
12
+ [Symbol.asyncDispose](): Promise<void>
13
+ }
14
+
15
+ // ============================================================================
16
+ // Factory
17
+ // ============================================================================
18
+
19
+ export function createScreenshotter(): Screenshotter {
20
+ let browser: import("playwright").Browser | null = null
21
+ let page: import("playwright").Page | null = null
22
+
23
+ async function ensureBrowser() {
24
+ if (browser && page) return page
25
+
26
+ const { chromium } = await import("playwright")
27
+ browser = await chromium.launch()
28
+ const context = await browser.newContext()
29
+ page = await context.newPage()
30
+ return page
31
+ }
32
+
33
+ async function capture(html: string, outputPath?: string): Promise<Buffer> {
34
+ const p = await ensureBrowser()
35
+ await p.setContent(html, { waitUntil: "load" })
36
+ await p.waitForTimeout(50)
37
+ const buffer = (await p.screenshot({ fullPage: true })) as Buffer
38
+ if (outputPath) {
39
+ await writeFile(outputPath, buffer)
40
+ }
41
+ return buffer
42
+ }
43
+
44
+ async function close() {
45
+ if (browser) {
46
+ await browser.close()
47
+ browser = null
48
+ page = null
49
+ }
50
+ }
51
+
52
+ return {
53
+ capture,
54
+ close,
55
+ [Symbol.asyncDispose]: close,
56
+ }
57
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Terminal scroll region (DECSTBM) utilities.
3
+ *
4
+ * Scroll regions tell the terminal to natively scroll content within
5
+ * a defined row range, which is faster than re-rendering all cells.
6
+ *
7
+ * DECSTBM (DEC Set Top and Bottom Margins) is supported by most modern
8
+ * terminal emulators: xterm, iTerm2, Kitty, Ghostty, WezTerm, etc.
9
+ */
10
+
11
+ const ESC = "\x1b"
12
+
13
+ /** Set terminal scroll region (1-indexed top and bottom rows). */
14
+ export function setScrollRegion(stdout: NodeJS.WriteStream, top: number, bottom: number): void {
15
+ stdout.write(`${ESC}[${top};${bottom}r`)
16
+ }
17
+
18
+ /** Reset scroll region to full terminal. */
19
+ export function resetScrollRegion(stdout: NodeJS.WriteStream): void {
20
+ stdout.write(`${ESC}[r`)
21
+ }
22
+
23
+ /** Scroll content up by N lines within the current scroll region. */
24
+ export function scrollUp(stdout: NodeJS.WriteStream, lines: number = 1): void {
25
+ stdout.write(`${ESC}[${lines}S`)
26
+ }
27
+
28
+ /** Scroll content down by N lines within the current scroll region. */
29
+ export function scrollDown(stdout: NodeJS.WriteStream, lines: number = 1): void {
30
+ stdout.write(`${ESC}[${lines}T`)
31
+ }
32
+
33
+ /** Move cursor to a specific position (1-indexed row and column). */
34
+ export function moveCursor(stdout: NodeJS.WriteStream, row: number, col: number): void {
35
+ stdout.write(`${ESC}[${row};${col}H`)
36
+ }
37
+
38
+ export interface ScrollRegionConfig {
39
+ /** Top row of the scrollable area (0-indexed). */
40
+ top: number
41
+ /** Bottom row of the scrollable area (0-indexed). */
42
+ bottom: number
43
+ /** Whether scroll region optimization is enabled. */
44
+ enabled: boolean
45
+ }
46
+
47
+ /**
48
+ * Detect if the terminal likely supports DECSTBM scroll regions.
49
+ *
50
+ * Most modern terminals do (xterm, iTerm2, Kitty, Ghostty, WezTerm, etc.)
51
+ * but some (e.g., Linux console) may not handle them correctly.
52
+ */
53
+ export function supportsScrollRegions(): boolean {
54
+ const term = process.env.TERM ?? ""
55
+ const termProgram = process.env.TERM_PROGRAM ?? ""
56
+
57
+ // Known-good terminal programs
58
+ if (termProgram === "iTerm.app" || termProgram === "WezTerm" || termProgram === "ghostty" || termProgram === "vscode")
59
+ return true
60
+
61
+ // Known-good TERM values
62
+ if (term.includes("xterm") || term.includes("screen") || term.includes("tmux") || term.includes("kitty")) return true
63
+
64
+ // Linux console doesn't support DECSTBM
65
+ if (term === "linux") return false
66
+
67
+ // Default: assume support for any term that's not empty
68
+ return term !== ""
69
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Scroll Utilities
3
+ *
4
+ * Shared functions for edge-based scrolling behavior across VirtualList,
5
+ * HorizontalVirtualList, and other scroll-aware components.
6
+ */
7
+
8
+ /**
9
+ * Calculate edge-based scroll offset.
10
+ *
11
+ * Only scrolls when cursor approaches the edge of the visible area.
12
+ * This provides smoother scrolling by starting to scroll before hitting
13
+ * the absolute edge, maintaining context around the selected item.
14
+ *
15
+ * ## Algorithm
16
+ *
17
+ * The viewport is divided into zones:
18
+ * ```
19
+ * |<padding>|<------ safe zone ------>|<padding>|
20
+ * | scroll | no scroll needed | scroll |
21
+ * | if < | | if > |
22
+ * ```
23
+ *
24
+ * When the selected item enters a padding zone, the viewport scrolls
25
+ * to keep the item visible with margin.
26
+ *
27
+ * ## Asymmetry Note
28
+ *
29
+ * The +1 in the "scroll down/right" case is intentional:
30
+ * - Offset points to the TOP/LEFT of the viewport
31
+ * - We want the selected item to be `padding` items from the BOTTOM/RIGHT
32
+ * - Formula: `selectedIndex - visibleCount + padding + 1`
33
+ *
34
+ * Example: visibleCount=10, padding=2, selectedIndex=15
35
+ * offset = 15 - 10 + 2 + 1 = 8
36
+ * viewport shows items 8-17, selected item 15 is at position 7 (2 from bottom)
37
+ *
38
+ * @param selectedIndex - Currently selected item index
39
+ * @param currentOffset - Current scroll offset (topmost/leftmost visible item)
40
+ * @param visibleCount - Number of items visible in viewport
41
+ * @param totalCount - Total number of items
42
+ * @param padding - Items to keep visible before/after cursor (default: 1)
43
+ * @returns New scroll offset
44
+ */
45
+ export function calcEdgeBasedScrollOffset(
46
+ selectedIndex: number,
47
+ currentOffset: number,
48
+ visibleCount: number,
49
+ totalCount: number,
50
+ padding = 1,
51
+ ): number {
52
+ // If everything fits, no scrolling needed
53
+ if (totalCount <= visibleCount) return 0
54
+
55
+ // Reduce padding when viewport is too small to have a non-empty safe zone.
56
+ // With padding=1 and visibleCount=2, paddedStart > paddedEnd (inverted zone),
57
+ // causing every re-render to trigger a scroll in one direction.
58
+ const effectivePadding = padding * 2 >= visibleCount ? 0 : padding
59
+
60
+ // Calculate visible range
61
+ const visibleStart = currentOffset
62
+ const visibleEnd = currentOffset + visibleCount - 1
63
+
64
+ // Define the "safe zone" where cursor doesn't trigger scroll
65
+ const paddedStart = visibleStart + effectivePadding
66
+ const paddedEnd = visibleEnd - effectivePadding
67
+
68
+ let newOffset = currentOffset
69
+
70
+ if (selectedIndex < paddedStart) {
71
+ // Scrolling UP/LEFT: place item `effectivePadding` rows from top
72
+ newOffset = Math.max(0, selectedIndex - effectivePadding)
73
+ } else if (
74
+ effectivePadding === 0 &&
75
+ selectedIndex === paddedStart &&
76
+ currentOffset > 0 &&
77
+ // Only scroll back if the viewport is large enough to show both the
78
+ // context item and the selected item. When visibleCount <= padding,
79
+ // scrolling back pushes the selected item out of view, which triggers
80
+ // a forward scroll on the next render → infinite oscillation.
81
+ visibleCount > padding
82
+ ) {
83
+ // Small viewport (effectivePadding forced to 0): cursor at the very first visible
84
+ // position should still scroll back to provide context. Without this, scrolling
85
+ // right works (cursor past last visible triggers scroll) but scrolling left doesn't
86
+ // (cursor at first visible doesn't trigger), creating asymmetric behavior.
87
+ // Use original padding for the offset formula to show context before the cursor.
88
+ newOffset = Math.max(0, selectedIndex - padding)
89
+ } else if (selectedIndex > paddedEnd) {
90
+ // Scrolling DOWN/RIGHT: place item `effectivePadding` rows from bottom
91
+ // The +1 converts from 0-indexed offset to correct position
92
+ newOffset = Math.min(totalCount - visibleCount, selectedIndex - visibleCount + effectivePadding + 1)
93
+ }
94
+
95
+ // Clamp to valid range
96
+ return Math.max(0, Math.min(newOffset, totalCount - visibleCount))
97
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * TermDef Resolution
3
+ *
4
+ * Converts TermDef (minimal render config) into resolved values for rendering.
5
+ * Handles auto-detection of events from stdin, dimension defaults, etc.
6
+ */
7
+
8
+ import type { ColorLevel, Term } from "./ansi/index"
9
+ import type { Event, TermDef } from "@silvery/tea/types"
10
+
11
+ // ============================================================================
12
+ // Resolved TermDef
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Resolved values from a TermDef, ready for use by the render system.
17
+ */
18
+ export interface ResolvedTermDef {
19
+ /** Output stream (may be mock for static rendering) */
20
+ stdout: NodeJS.WriteStream | null
21
+
22
+ /** Width in columns */
23
+ width: number
24
+
25
+ /** Height in rows */
26
+ height: number
27
+
28
+ /** Color level (null = no colors) */
29
+ colors: ColorLevel | null
30
+
31
+ /** Event source (null = static mode) */
32
+ events: AsyncIterable<Event> | null
33
+
34
+ /** Whether this is static mode (no events = render until stable) */
35
+ isStatic: boolean
36
+ }
37
+
38
+ // ============================================================================
39
+ // Resolution Logic
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Default dimensions when not detectable.
44
+ */
45
+ const DEFAULT_WIDTH = 80
46
+ const DEFAULT_HEIGHT = 24
47
+
48
+ /**
49
+ * Check if a value is a Term instance (duck typing).
50
+ */
51
+ export function isTerm(value: unknown): value is Term {
52
+ // Term can be a callable Proxy (typeof === 'function') or object
53
+ if (!value || (typeof value !== "object" && typeof value !== "function")) {
54
+ return false
55
+ }
56
+ const obj = value as Record<string, unknown>
57
+ return (
58
+ typeof obj.hasCursor === "function" &&
59
+ typeof obj.hasInput === "function" &&
60
+ typeof obj.hasColor === "function" &&
61
+ typeof obj.write === "function"
62
+ )
63
+ }
64
+
65
+ /**
66
+ * Check if a value is a TermDef (not a Term).
67
+ */
68
+ export function isTermDef(value: unknown): value is TermDef {
69
+ if (!value || typeof value !== "object") return false
70
+ // TermDef doesn't have hasCursor method
71
+ const obj = value as Record<string, unknown>
72
+ return typeof obj.hasCursor !== "function"
73
+ }
74
+
75
+ /**
76
+ * Resolve a TermDef into concrete values.
77
+ *
78
+ * @param def - TermDef to resolve
79
+ * @returns Resolved values ready for rendering
80
+ */
81
+ export function resolveTermDef(def: TermDef): ResolvedTermDef {
82
+ // Resolve dimensions
83
+ const width = def.width ?? def.stdout?.columns ?? DEFAULT_WIDTH
84
+ const height = def.height ?? def.stdout?.rows ?? DEFAULT_HEIGHT
85
+
86
+ // Resolve colors
87
+ let colors: ColorLevel | null = null
88
+ if (def.colors === true) {
89
+ // Auto-detect from stdout
90
+ colors = detectColorLevel(def.stdout)
91
+ } else if (def.colors === false || def.colors === null) {
92
+ colors = null
93
+ } else if (def.colors) {
94
+ colors = def.colors
95
+ } else {
96
+ // Default: auto-detect
97
+ colors = detectColorLevel(def.stdout)
98
+ }
99
+
100
+ // Resolve events
101
+ let events: AsyncIterable<Event> | null = null
102
+ if (def.events) {
103
+ // Explicit events provided
104
+ events = def.events
105
+ } else if (def.stdin) {
106
+ // Auto-create events from stdin
107
+ events = createInputEvents(def.stdin)
108
+ }
109
+
110
+ return {
111
+ stdout: def.stdout ?? null,
112
+ width,
113
+ height,
114
+ colors,
115
+ events,
116
+ isStatic: events === null,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolve a Term instance into ResolvedTermDef.
122
+ *
123
+ * @param term - Term instance
124
+ * @returns Resolved values
125
+ */
126
+ export function resolveFromTerm(term: Term): ResolvedTermDef {
127
+ return {
128
+ stdout: term.stdout,
129
+ width: term.cols ?? DEFAULT_WIDTH,
130
+ height: term.rows ?? DEFAULT_HEIGHT,
131
+ colors: term.hasColor(),
132
+ // Term instances always have interactive capabilities
133
+ events: createInputEvents(term.stdin),
134
+ isStatic: false,
135
+ }
136
+ }
137
+
138
+ // ============================================================================
139
+ // Color Detection
140
+ // ============================================================================
141
+
142
+ /**
143
+ * Detect color level from stdout stream.
144
+ */
145
+ function detectColorLevel(stdout?: NodeJS.WriteStream): ColorLevel | null {
146
+ // Check environment variables
147
+ if (process.env.NO_COLOR !== undefined) {
148
+ return null
149
+ }
150
+
151
+ if (process.env.FORCE_COLOR !== undefined) {
152
+ const level = Number.parseInt(process.env.FORCE_COLOR, 10)
153
+ if (level === 0) return null
154
+ if (level === 1) return "basic"
155
+ if (level === 2) return "256"
156
+ if (level >= 3) return "truecolor"
157
+ return "basic"
158
+ }
159
+
160
+ // Check COLORTERM for truecolor
161
+ if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") {
162
+ return "truecolor"
163
+ }
164
+
165
+ // Check if TTY
166
+ if (!stdout?.isTTY) {
167
+ return null
168
+ }
169
+
170
+ // Check TERM for 256 color support
171
+ const term = process.env.TERM ?? ""
172
+ if (term.includes("256color") || term.includes("256")) {
173
+ return "256"
174
+ }
175
+
176
+ // Default to basic if TTY
177
+ return "basic"
178
+ }
179
+
180
+ // ============================================================================
181
+ // Input Events
182
+ // ============================================================================
183
+
184
+ /**
185
+ * Create an async iterable of input events from a stdin stream.
186
+ *
187
+ * This enables interactive mode by providing a source of keyboard events.
188
+ */
189
+ export function createInputEvents(stdin: NodeJS.ReadStream): AsyncIterable<Event> {
190
+ return {
191
+ [Symbol.asyncIterator](): AsyncIterator<Event> {
192
+ const buffer: Event[] = []
193
+ let resolveNext: ((value: IteratorResult<Event>) => void) | null = null
194
+ let done = false
195
+
196
+ // Set up stdin reading
197
+ const handleData = (chunk: Buffer | string) => {
198
+ const data = typeof chunk === "string" ? chunk : chunk.toString("utf8")
199
+
200
+ // Convert raw input to key events
201
+ // This is simplified - real implementation would parse ANSI sequences
202
+ for (const char of data) {
203
+ const event: Event = {
204
+ type: "key",
205
+ key: char,
206
+ ctrl: char.charCodeAt(0) < 32 && char !== "\r" && char !== "\n" && char !== "\t",
207
+ }
208
+
209
+ if (resolveNext) {
210
+ resolveNext({ value: event, done: false })
211
+ resolveNext = null
212
+ } else {
213
+ buffer.push(event)
214
+ }
215
+ }
216
+ }
217
+
218
+ const handleEnd = () => {
219
+ done = true
220
+ if (resolveNext) {
221
+ resolveNext({ value: undefined as unknown as Event, done: true })
222
+ resolveNext = null
223
+ }
224
+ }
225
+
226
+ // Only set up if stdin supports raw mode
227
+ if (stdin.isTTY && typeof stdin.setRawMode === "function") {
228
+ stdin.setEncoding("utf8")
229
+ stdin.on("data", handleData)
230
+ stdin.on("end", handleEnd)
231
+ }
232
+
233
+ return {
234
+ next(): Promise<IteratorResult<Event>> {
235
+ // Return buffered event if available
236
+ const buffered = buffer.shift()
237
+ if (buffered) {
238
+ return Promise.resolve({ value: buffered, done: false })
239
+ }
240
+
241
+ // If done, return done
242
+ if (done) {
243
+ return Promise.resolve({
244
+ value: undefined as unknown as Event,
245
+ done: true,
246
+ })
247
+ }
248
+
249
+ // Wait for next event
250
+ return new Promise((resolve) => {
251
+ resolveNext = resolve
252
+ })
253
+ },
254
+
255
+ return(): Promise<IteratorResult<Event>> {
256
+ done = true
257
+ stdin.off("data", handleData)
258
+ stdin.off("end", handleEnd)
259
+ return Promise.resolve({
260
+ value: undefined as unknown as Event,
261
+ done: true,
262
+ })
263
+ },
264
+ }
265
+ },
266
+ }
267
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Terminal capability detection -- re-exported from the ansi submodule.
3
+ */
4
+
5
+ export { detectTerminalCaps, defaultCaps, type TerminalCaps } from "./ansi/detection"