silvery 0.0.1 → 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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -11
  3. package/bin/silvery.ts +258 -0
  4. package/examples/CLAUDE.md +75 -0
  5. package/examples/_banner.tsx +60 -0
  6. package/examples/cli.ts +228 -0
  7. package/examples/index.md +101 -0
  8. package/examples/inline/inline-nontty.tsx +98 -0
  9. package/examples/inline/inline-progress.tsx +79 -0
  10. package/examples/inline/inline-simple.tsx +63 -0
  11. package/examples/inline/scrollback.tsx +185 -0
  12. package/examples/interactive/_input-debug.tsx +110 -0
  13. package/examples/interactive/_stdin-test.ts +71 -0
  14. package/examples/interactive/_textarea-bare.tsx +45 -0
  15. package/examples/interactive/aichat/components.tsx +468 -0
  16. package/examples/interactive/aichat/index.tsx +207 -0
  17. package/examples/interactive/aichat/script.ts +460 -0
  18. package/examples/interactive/aichat/state.ts +326 -0
  19. package/examples/interactive/aichat/types.ts +19 -0
  20. package/examples/interactive/app-todo.tsx +198 -0
  21. package/examples/interactive/async-data.tsx +208 -0
  22. package/examples/interactive/cli-wizard.tsx +332 -0
  23. package/examples/interactive/clipboard.tsx +183 -0
  24. package/examples/interactive/components.tsx +463 -0
  25. package/examples/interactive/data-explorer.tsx +506 -0
  26. package/examples/interactive/dev-tools.tsx +379 -0
  27. package/examples/interactive/explorer.tsx +747 -0
  28. package/examples/interactive/gallery.tsx +652 -0
  29. package/examples/interactive/inline-bench.tsx +136 -0
  30. package/examples/interactive/kanban.tsx +267 -0
  31. package/examples/interactive/layout-ref.tsx +185 -0
  32. package/examples/interactive/outline.tsx +171 -0
  33. package/examples/interactive/paste-demo.tsx +198 -0
  34. package/examples/interactive/scroll.tsx +77 -0
  35. package/examples/interactive/search-filter.tsx +240 -0
  36. package/examples/interactive/task-list.tsx +279 -0
  37. package/examples/interactive/terminal.tsx +798 -0
  38. package/examples/interactive/textarea.tsx +103 -0
  39. package/examples/interactive/theme.tsx +336 -0
  40. package/examples/interactive/transform.tsx +256 -0
  41. package/examples/interactive/virtual-10k.tsx +413 -0
  42. package/examples/kitty/canvas.tsx +519 -0
  43. package/examples/kitty/generate-samples.ts +236 -0
  44. package/examples/kitty/image-component.tsx +273 -0
  45. package/examples/kitty/images.tsx +604 -0
  46. package/examples/kitty/input.tsx +371 -0
  47. package/examples/kitty/keys.tsx +378 -0
  48. package/examples/kitty/paint.tsx +1017 -0
  49. package/examples/layout/dashboard.tsx +551 -0
  50. package/examples/layout/live-resize.tsx +290 -0
  51. package/examples/layout/overflow.tsx +51 -0
  52. package/examples/playground/README.md +69 -0
  53. package/examples/playground/build.ts +61 -0
  54. package/examples/playground/index.html +420 -0
  55. package/examples/playground/playground-app.tsx +416 -0
  56. package/examples/runtime/elm-counter.tsx +206 -0
  57. package/examples/runtime/hello-runtime.tsx +73 -0
  58. package/examples/runtime/pipe-composition.tsx +184 -0
  59. package/examples/runtime/run-counter.tsx +78 -0
  60. package/examples/runtime/runtime-counter.tsx +197 -0
  61. package/examples/screenshots/generate.tsx +563 -0
  62. package/examples/scrollback-perf.tsx +230 -0
  63. package/examples/viewer.tsx +654 -0
  64. package/examples/web/build.ts +365 -0
  65. package/examples/web/canvas-app.tsx +80 -0
  66. package/examples/web/canvas.html +89 -0
  67. package/examples/web/dom-app.tsx +81 -0
  68. package/examples/web/dom.html +113 -0
  69. package/examples/web/showcase-app.tsx +107 -0
  70. package/examples/web/showcase.html +34 -0
  71. package/examples/web/showcases/index.tsx +56 -0
  72. package/examples/web/viewer-app.tsx +555 -0
  73. package/examples/web/viewer.html +30 -0
  74. package/examples/web/xterm-app.tsx +105 -0
  75. package/examples/web/xterm.html +118 -0
  76. package/package.json +106 -5
  77. package/src/index.ts +5 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Generate sample PNG images for the Kitty protocol examples.
3
+ *
4
+ * Creates 3 sample images in examples/kitty/samples/:
5
+ * - gradient.png — Rainbow gradient (256x192)
6
+ * - checker.png — Colorful checkerboard pattern (256x192)
7
+ * - circles.png — Overlapping colored circles on dark background (256x192)
8
+ *
9
+ * Uses raw PNG encoding with Node's zlib — no external dependencies.
10
+ *
11
+ * Run: bun vendor/silvery/examples/kitty/generate-samples.ts
12
+ */
13
+
14
+ import { deflateSync } from "node:zlib"
15
+ import { writeFileSync, mkdirSync } from "node:fs"
16
+ import { resolve, dirname } from "node:path"
17
+ import { fileURLToPath } from "node:url"
18
+
19
+ const WIDTH = 256
20
+ const HEIGHT = 192
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // HSV to RGB
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
27
+ const c = v * s
28
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
29
+ const m = v - c
30
+
31
+ let r = 0,
32
+ g = 0,
33
+ b = 0
34
+ if (h < 60) {
35
+ r = c
36
+ g = x
37
+ } else if (h < 120) {
38
+ r = x
39
+ g = c
40
+ } else if (h < 180) {
41
+ g = c
42
+ b = x
43
+ } else if (h < 240) {
44
+ g = x
45
+ b = c
46
+ } else if (h < 300) {
47
+ r = x
48
+ b = c
49
+ } else {
50
+ r = c
51
+ b = x
52
+ }
53
+
54
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Raw PNG encoder (no dependencies)
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function crc32(buf: Buffer): number {
62
+ // CRC-32 lookup table
63
+ const table: number[] = []
64
+ for (let n = 0; n < 256; n++) {
65
+ let c = n
66
+ for (let k = 0; k < 8; k++) {
67
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
68
+ }
69
+ table[n] = c
70
+ }
71
+
72
+ let crc = 0xffffffff
73
+ for (let i = 0; i < buf.length; i++) {
74
+ crc = table[(crc ^ buf[i]!) & 0xff]! ^ (crc >>> 8)
75
+ }
76
+ return (crc ^ 0xffffffff) >>> 0
77
+ }
78
+
79
+ function makePngChunk(type: string, data: Buffer): Buffer {
80
+ const length = Buffer.alloc(4)
81
+ length.writeUInt32BE(data.length, 0)
82
+
83
+ const typeAndData = Buffer.concat([Buffer.from(type, "ascii"), data])
84
+ const crc = Buffer.alloc(4)
85
+ crc.writeUInt32BE(crc32(typeAndData), 0)
86
+
87
+ return Buffer.concat([length, typeAndData, crc])
88
+ }
89
+
90
+ function rgbaToPng(rgba: Buffer, width: number, height: number): Buffer {
91
+ // PNG signature
92
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
93
+
94
+ // IHDR chunk
95
+ const ihdr = Buffer.alloc(13)
96
+ ihdr.writeUInt32BE(width, 0)
97
+ ihdr.writeUInt32BE(height, 4)
98
+ ihdr[8] = 8 // bit depth
99
+ ihdr[9] = 6 // color type: RGBA
100
+ ihdr[10] = 0 // compression
101
+ ihdr[11] = 0 // filter
102
+ ihdr[12] = 0 // interlace
103
+ const ihdrChunk = makePngChunk("IHDR", ihdr)
104
+
105
+ // IDAT chunk: filter each row with filter type 0 (None), then deflate
106
+ const rawRows = Buffer.alloc(height * (1 + width * 4))
107
+ for (let y = 0; y < height; y++) {
108
+ const rowOffset = y * (1 + width * 4)
109
+ rawRows[rowOffset] = 0 // filter type: None
110
+ rgba.copy(rawRows, rowOffset + 1, y * width * 4, (y + 1) * width * 4)
111
+ }
112
+ const compressed = deflateSync(rawRows)
113
+ const idatChunk = makePngChunk("IDAT", compressed)
114
+
115
+ // IEND chunk
116
+ const iendChunk = makePngChunk("IEND", Buffer.alloc(0))
117
+
118
+ return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk])
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Image generators
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function generateGradient(): Buffer {
126
+ const rgba = Buffer.alloc(WIDTH * HEIGHT * 4)
127
+
128
+ for (let y = 0; y < HEIGHT; y++) {
129
+ for (let x = 0; x < WIDTH; x++) {
130
+ const offset = (y * WIDTH + x) * 4
131
+ const hue = (x / WIDTH) * 360
132
+ const brightness = 0.3 + 0.7 * (1 - y / HEIGHT)
133
+ const [r, g, b] = hsvToRgb(hue, 1.0, brightness)
134
+ rgba[offset] = r
135
+ rgba[offset + 1] = g
136
+ rgba[offset + 2] = b
137
+ rgba[offset + 3] = 255
138
+ }
139
+ }
140
+
141
+ return rgbaToPng(rgba, WIDTH, HEIGHT)
142
+ }
143
+
144
+ function generateChecker(): Buffer {
145
+ const rgba = Buffer.alloc(WIDTH * HEIGHT * 4)
146
+ const checkerSize = 24
147
+
148
+ for (let y = 0; y < HEIGHT; y++) {
149
+ for (let x = 0; x < WIDTH; x++) {
150
+ const offset = (y * WIDTH + x) * 4
151
+ const isLight = (Math.floor(x / checkerSize) + Math.floor(y / checkerSize)) % 2 === 0
152
+ const hue = (x / WIDTH) * 360
153
+ const [hr, hg, hb] = hsvToRgb(hue, 0.6, 1.0)
154
+
155
+ if (isLight) {
156
+ rgba[offset] = Math.min(255, hr + 60)
157
+ rgba[offset + 1] = Math.min(255, hg + 60)
158
+ rgba[offset + 2] = Math.min(255, hb + 60)
159
+ } else {
160
+ rgba[offset] = Math.max(0, hr - 100)
161
+ rgba[offset + 1] = Math.max(0, hg - 100)
162
+ rgba[offset + 2] = Math.max(0, hb - 100)
163
+ }
164
+ rgba[offset + 3] = 255
165
+ }
166
+ }
167
+
168
+ return rgbaToPng(rgba, WIDTH, HEIGHT)
169
+ }
170
+
171
+ function generateCircles(): Buffer {
172
+ const rgba = Buffer.alloc(WIDTH * HEIGHT * 4)
173
+
174
+ // Dark background
175
+ for (let i = 0; i < WIDTH * HEIGHT * 4; i += 4) {
176
+ rgba[i] = 20
177
+ rgba[i + 1] = 20
178
+ rgba[i + 2] = 30
179
+ rgba[i + 3] = 255
180
+ }
181
+
182
+ // Draw overlapping circles with additive blending
183
+ const circles: { cx: number; cy: number; r: number; color: [number, number, number] }[] = [
184
+ { cx: 90, cy: 80, r: 70, color: [200, 40, 40] },
185
+ { cx: 166, cy: 80, r: 70, color: [40, 180, 40] },
186
+ { cx: 128, cy: 140, r: 70, color: [40, 80, 220] },
187
+ { cx: 50, cy: 140, r: 50, color: [220, 180, 30] },
188
+ { cx: 206, cy: 140, r: 50, color: [180, 40, 220] },
189
+ { cx: 128, cy: 60, r: 45, color: [40, 200, 200] },
190
+ ]
191
+
192
+ for (const circle of circles) {
193
+ for (let y = 0; y < HEIGHT; y++) {
194
+ for (let x = 0; x < WIDTH; x++) {
195
+ const dx = x - circle.cx
196
+ const dy = y - circle.cy
197
+ const dist = Math.sqrt(dx * dx + dy * dy)
198
+
199
+ if (dist < circle.r) {
200
+ const offset = (y * WIDTH + x) * 4
201
+ // Soft edge with smooth falloff
202
+ const alpha = dist > circle.r - 8 ? (circle.r - dist) / 8 : 1.0
203
+
204
+ // Additive blending
205
+ rgba[offset] = Math.min(255, rgba[offset]! + Math.round(circle.color[0] * alpha))
206
+ rgba[offset + 1] = Math.min(255, rgba[offset + 1]! + Math.round(circle.color[1] * alpha))
207
+ rgba[offset + 2] = Math.min(255, rgba[offset + 2]! + Math.round(circle.color[2] * alpha))
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ return rgbaToPng(rgba, WIDTH, HEIGHT)
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Main
218
+ // ---------------------------------------------------------------------------
219
+
220
+ const samplesDir = resolve(dirname(fileURLToPath(import.meta.url)), "samples")
221
+ mkdirSync(samplesDir, { recursive: true })
222
+
223
+ const samples = [
224
+ { name: "gradient.png", generate: generateGradient },
225
+ { name: "checker.png", generate: generateChecker },
226
+ { name: "circles.png", generate: generateCircles },
227
+ ]
228
+
229
+ for (const sample of samples) {
230
+ const path = resolve(samplesDir, sample.name)
231
+ const png = sample.generate()
232
+ writeFileSync(path, png)
233
+ console.log(` ${sample.name} (${png.length} bytes)`)
234
+ }
235
+
236
+ console.log(`\nGenerated ${samples.length} samples in ${samplesDir}`)
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Image Component Demo
3
+ *
4
+ * Demonstrates the `<Image>` component — a high-level React component for
5
+ * displaying images in the terminal. Unlike the raw Kitty graphics protocol
6
+ * example (images.tsx), this uses the declarative component API with automatic
7
+ * protocol detection and fallback support.
8
+ *
9
+ * Features:
10
+ * - Uses the <Image> component from silvery
11
+ * - Auto-generated rainbow test pattern (no external files needed)
12
+ * - Fallback text when graphics protocol is not supported
13
+ * - Protocol auto-detection status display
14
+ * - Adjustable image dimensions with +/- keys
15
+ *
16
+ * Run: bun vendor/silvery/examples/kitty/image-component.tsx
17
+ */
18
+
19
+ import { deflateSync } from "node:zlib"
20
+ import React, { useState, useMemo } from "react"
21
+ import { render, Box, Text, Image, useInput, useApp, createTerm, type Key } from "../../src/index.js"
22
+ import { isKittyGraphicsSupported } from "../../src/image/kitty-graphics.js"
23
+ import { isSixelSupported } from "../../src/image/sixel-encoder.js"
24
+ import { ExampleBanner, type ExampleMeta } from "../_banner.js"
25
+
26
+ export const meta: ExampleMeta = {
27
+ name: "Image Component",
28
+ description: "Declarative <Image> component with protocol auto-detection",
29
+ features: ["Image", "Kitty graphics", "Sixel", "fallback text", "protocol detection"],
30
+ }
31
+
32
+ // ============================================================================
33
+ // PNG Generation
34
+ // ============================================================================
35
+
36
+ /** Convert HSV (h: 0-360, s/v: 0-1) to RGB bytes */
37
+ function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
38
+ const c = v * s
39
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
40
+ const m = v - c
41
+
42
+ let r = 0,
43
+ g = 0,
44
+ b = 0
45
+ if (h < 60) {
46
+ r = c
47
+ g = x
48
+ } else if (h < 120) {
49
+ r = x
50
+ g = c
51
+ } else if (h < 180) {
52
+ g = c
53
+ b = x
54
+ } else if (h < 240) {
55
+ g = x
56
+ b = c
57
+ } else if (h < 300) {
58
+ r = x
59
+ b = c
60
+ } else {
61
+ r = c
62
+ b = x
63
+ }
64
+
65
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]
66
+ }
67
+
68
+ /** Generate a rainbow gradient PNG buffer (no external dependencies). */
69
+ function generateTestPatternPng(width: number, height: number): Buffer {
70
+ // Build raw RGBA pixel data with filter bytes
71
+ const rawData = Buffer.alloc(height * (1 + width * 4))
72
+
73
+ for (let y = 0; y < height; y++) {
74
+ const rowOffset = y * (1 + width * 4)
75
+ rawData[rowOffset] = 0 // PNG filter: None
76
+
77
+ for (let x = 0; x < width; x++) {
78
+ const hue = (x / width) * 360
79
+ const saturation = 0.7 + 0.3 * Math.sin((y / height) * Math.PI)
80
+ const value = 0.5 + 0.5 * Math.cos((y / height) * Math.PI * 2)
81
+ const [r, g, b] = hsvToRgb(hue, saturation, value)
82
+
83
+ const pixelOffset = rowOffset + 1 + x * 4
84
+ rawData[pixelOffset] = r!
85
+ rawData[pixelOffset + 1] = g!
86
+ rawData[pixelOffset + 2] = b!
87
+ rawData[pixelOffset + 3] = 255
88
+ }
89
+ }
90
+
91
+ // Encode as minimal PNG
92
+ const compressed = deflateSync(rawData)
93
+
94
+ const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
95
+
96
+ function makeChunk(type: string, data: Buffer): Buffer {
97
+ const len = Buffer.alloc(4)
98
+ len.writeUInt32BE(data.length)
99
+ const typeBytes = Buffer.from(type, "ascii")
100
+ const payload = Buffer.concat([typeBytes, data])
101
+ const crc = crc32(payload)
102
+ const crcBuf = Buffer.alloc(4)
103
+ crcBuf.writeUInt32BE(crc >>> 0)
104
+ return Buffer.concat([len, payload, crcBuf])
105
+ }
106
+
107
+ // IHDR
108
+ const ihdr = Buffer.alloc(13)
109
+ ihdr.writeUInt32BE(width, 0)
110
+ ihdr.writeUInt32BE(height, 4)
111
+ ihdr[8] = 8 // bit depth
112
+ ihdr[9] = 6 // color type: RGBA
113
+ ihdr[10] = 0 // compression
114
+ ihdr[11] = 0 // filter
115
+ ihdr[12] = 0 // interlace
116
+
117
+ return Buffer.concat([
118
+ signature,
119
+ makeChunk("IHDR", ihdr),
120
+ makeChunk("IDAT", compressed),
121
+ makeChunk("IEND", Buffer.alloc(0)),
122
+ ])
123
+ }
124
+
125
+ /** CRC-32 for PNG chunks */
126
+ function crc32(data: Buffer): number {
127
+ let crc = 0xffffffff
128
+ for (let i = 0; i < data.length; i++) {
129
+ crc ^= data[i]!
130
+ for (let j = 0; j < 8; j++) {
131
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
132
+ }
133
+ }
134
+ return (crc ^ 0xffffffff) >>> 0
135
+ }
136
+
137
+ // ============================================================================
138
+ // Components
139
+ // ============================================================================
140
+
141
+ function ProtocolStatus(): JSX.Element {
142
+ const kitty = isKittyGraphicsSupported()
143
+ const sixel = isSixelSupported()
144
+
145
+ const detected = kitty ? "Kitty" : sixel ? "Sixel" : "None"
146
+ const color = kitty || sixel ? "green" : "yellow"
147
+
148
+ return (
149
+ <Box gap={1} paddingX={1}>
150
+ <Text dim>Protocol:</Text>
151
+ <Text color={color} bold>
152
+ {detected}
153
+ </Text>
154
+ <Text dim>
155
+ (Kitty: {kitty ? "yes" : "no"}, Sixel: {sixel ? "yes" : "no"})
156
+ </Text>
157
+ </Box>
158
+ )
159
+ }
160
+
161
+ export function ImageComponentDemo(): JSX.Element {
162
+ const { exit } = useApp()
163
+ const [imageWidth, setImageWidth] = useState(40)
164
+ const [imageHeight, setImageHeight] = useState(15)
165
+
166
+ // Generate a test pattern PNG
167
+ const pngBuffer = useMemo(() => generateTestPatternPng(256, 192), [])
168
+
169
+ useInput((input: string, key: Key) => {
170
+ if (input === "q" || key.escape) {
171
+ exit()
172
+ return
173
+ }
174
+
175
+ // Adjust width
176
+ if (key.rightArrow || input === "l") {
177
+ setImageWidth((prev) => Math.min(80, prev + 5))
178
+ }
179
+ if (key.leftArrow || input === "h") {
180
+ setImageWidth((prev) => Math.max(10, prev - 5))
181
+ }
182
+
183
+ // Adjust height
184
+ if (input === "+" || input === "=") {
185
+ setImageHeight((prev) => Math.min(30, prev + 2))
186
+ }
187
+ if (input === "-") {
188
+ setImageHeight((prev) => Math.max(5, prev - 2))
189
+ }
190
+ })
191
+
192
+ return (
193
+ <Box flexDirection="column" padding={1} gap={1}>
194
+ <ProtocolStatus />
195
+
196
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
197
+ <Box marginBottom={1} gap={1}>
198
+ <Text bold color="cyan">
199
+ {"<Image>"}
200
+ </Text>
201
+ <Text dim>
202
+ {imageWidth}x{imageHeight} cols/rows
203
+ </Text>
204
+ </Box>
205
+
206
+ <Image
207
+ src={pngBuffer}
208
+ width={imageWidth}
209
+ height={imageHeight}
210
+ fallback="[Rainbow gradient — graphics protocol not available]"
211
+ />
212
+ </Box>
213
+
214
+ <Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
215
+ <Box marginBottom={1}>
216
+ <Text bold>Component Props</Text>
217
+ </Box>
218
+ <Box gap={2}>
219
+ <Text>
220
+ width={"{"}
221
+ <Text color="green">{imageWidth}</Text>
222
+ {"}"}
223
+ </Text>
224
+ <Text>
225
+ height={"{"}
226
+ <Text color="green">{imageHeight}</Text>
227
+ {"}"}
228
+ </Text>
229
+ <Text>
230
+ protocol=
231
+ <Text color="yellow">"auto"</Text>
232
+ </Text>
233
+ </Box>
234
+ <Text dim>fallback="[Rainbow gradient — graphics protocol not available]"</Text>
235
+ </Box>
236
+
237
+ <Text dim>
238
+ {" "}
239
+ <Text bold dim>
240
+ h/l
241
+ </Text>{" "}
242
+ width{" "}
243
+ <Text bold dim>
244
+ +/-
245
+ </Text>{" "}
246
+ height{" "}
247
+ <Text bold dim>
248
+ Esc/q
249
+ </Text>{" "}
250
+ quit
251
+ </Text>
252
+ </Box>
253
+ )
254
+ }
255
+
256
+ // ============================================================================
257
+ // Main
258
+ // ============================================================================
259
+
260
+ async function main() {
261
+ using term = createTerm()
262
+ const { waitUntilExit } = await render(
263
+ <ExampleBanner meta={meta} controls="h/l width +/- height Esc/q quit">
264
+ <ImageComponentDemo />
265
+ </ExampleBanner>,
266
+ term,
267
+ )
268
+ await waitUntilExit()
269
+ }
270
+
271
+ if (import.meta.main) {
272
+ main().catch(console.error)
273
+ }