@silvery/ui 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 +71 -0
- package/src/animation/easing.ts +38 -0
- package/src/animation/index.ts +18 -0
- package/src/animation/useAnimation.ts +143 -0
- package/src/animation/useInterval.ts +39 -0
- package/src/animation/useLatest.ts +35 -0
- package/src/animation/useTimeout.ts +65 -0
- package/src/animation/useTransition.ts +110 -0
- package/src/animation.ts +24 -0
- package/src/ansi/index.ts +43 -0
- package/src/canvas/index.ts +169 -0
- package/src/cli/ansi.ts +85 -0
- package/src/cli/index.ts +39 -0
- package/src/cli/multi-progress.ts +340 -0
- package/src/cli/progress-bar.ts +222 -0
- package/src/cli/spinner.ts +275 -0
- package/src/components/Badge.tsx +54 -0
- package/src/components/Breadcrumb.tsx +72 -0
- package/src/components/Button.tsx +73 -0
- package/src/components/CommandPalette.tsx +186 -0
- package/src/components/Console.tsx +79 -0
- package/src/components/CursorLine.tsx +71 -0
- package/src/components/Divider.tsx +67 -0
- package/src/components/EditContextDisplay.tsx +164 -0
- package/src/components/ErrorBoundary.tsx +179 -0
- package/src/components/Form.tsx +86 -0
- package/src/components/GridCell.tsx +42 -0
- package/src/components/HorizontalVirtualList.tsx +375 -0
- package/src/components/ModalDialog.tsx +179 -0
- package/src/components/PickerDialog.tsx +208 -0
- package/src/components/PickerList.tsx +93 -0
- package/src/components/ProgressBar.tsx +126 -0
- package/src/components/Screen.tsx +78 -0
- package/src/components/ScrollbackList.tsx +92 -0
- package/src/components/ScrollbackView.tsx +390 -0
- package/src/components/SelectList.tsx +176 -0
- package/src/components/Skeleton.tsx +87 -0
- package/src/components/Spinner.tsx +64 -0
- package/src/components/SplitView.tsx +199 -0
- package/src/components/Table.tsx +139 -0
- package/src/components/Tabs.tsx +203 -0
- package/src/components/TextArea.tsx +264 -0
- package/src/components/TextInput.tsx +240 -0
- package/src/components/Toast.tsx +216 -0
- package/src/components/Toggle.tsx +73 -0
- package/src/components/Tooltip.tsx +60 -0
- package/src/components/TreeView.tsx +212 -0
- package/src/components/Typography.tsx +233 -0
- package/src/components/VirtualList.tsx +318 -0
- package/src/components/VirtualView.tsx +221 -0
- package/src/components/useReadline.ts +213 -0
- package/src/components/useTextArea.ts +648 -0
- package/src/components.ts +133 -0
- package/src/display/Table.tsx +179 -0
- package/src/display/index.ts +13 -0
- package/src/hooks/useTea.ts +133 -0
- package/src/image/Image.tsx +187 -0
- package/src/image/index.ts +15 -0
- package/src/image/kitty-graphics.ts +161 -0
- package/src/image/sixel-encoder.ts +194 -0
- package/src/images.ts +22 -0
- package/src/index.ts +34 -0
- package/src/input/Select.tsx +155 -0
- package/src/input/TextInput.tsx +227 -0
- package/src/input/index.ts +25 -0
- package/src/progress/als-context.ts +160 -0
- package/src/progress/declarative.ts +519 -0
- package/src/progress/index.ts +54 -0
- package/src/progress/step-node.ts +152 -0
- package/src/progress/steps.ts +425 -0
- package/src/progress/task.ts +138 -0
- package/src/progress/tasks.ts +216 -0
- package/src/react/ProgressBar.tsx +146 -0
- package/src/react/Spinner.tsx +74 -0
- package/src/react/Tasks.tsx +144 -0
- package/src/react/context.tsx +145 -0
- package/src/react/index.ts +30 -0
- package/src/types.ts +252 -0
- package/src/utils/eta.ts +155 -0
- package/src/utils/index.ts +13 -0
- package/src/wrappers/index.ts +36 -0
- package/src/wrappers/with-progress.ts +250 -0
- package/src/wrappers/with-select.ts +194 -0
- package/src/wrappers/with-spinner.ts +108 -0
- package/src/wrappers/with-text-input.ts +388 -0
- package/src/wrappers/wrap-emitter.ts +158 -0
- package/src/wrappers/wrap-generator.ts +143 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kitty Graphics Protocol
|
|
3
|
+
*
|
|
4
|
+
* Encodes and manages images using the Kitty terminal graphics protocol.
|
|
5
|
+
* Images are transmitted as base64-encoded PNG data via APC (Application
|
|
6
|
+
* Program Command) escape sequences.
|
|
7
|
+
*
|
|
8
|
+
* Protocol reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
|
9
|
+
*
|
|
10
|
+
* Key concepts:
|
|
11
|
+
* - `a=T` — transmit and display the image
|
|
12
|
+
* - `f=100` — format is PNG (raw PNG data, terminal decodes it)
|
|
13
|
+
* - `m=0|1` — 0 = last/only chunk, 1 = more chunks follow
|
|
14
|
+
* - Chunks should be <= 4096 bytes of base64 to avoid overwhelming the terminal
|
|
15
|
+
* - Images can be assigned an `i=<id>` for later deletion
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const APC_START = "\x1b_G"
|
|
19
|
+
const ST = "\x1b\\"
|
|
20
|
+
|
|
21
|
+
/** Maximum base64 bytes per chunk (Kitty recommendation) */
|
|
22
|
+
const MAX_CHUNK_SIZE = 4096
|
|
23
|
+
|
|
24
|
+
export interface KittyImageOptions {
|
|
25
|
+
/** Image width in terminal columns */
|
|
26
|
+
width?: number
|
|
27
|
+
/** Image height in terminal rows */
|
|
28
|
+
height?: number
|
|
29
|
+
/** Image ID for later reference/deletion (positive integer) */
|
|
30
|
+
id?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Encode a PNG image into Kitty graphics protocol escape sequences.
|
|
35
|
+
*
|
|
36
|
+
* The image data is base64-encoded and split into chunks of <= 4096 bytes.
|
|
37
|
+
* Each chunk is wrapped in an APC escape sequence. The first chunk carries
|
|
38
|
+
* the image metadata (action, format, dimensions, ID). Subsequent chunks
|
|
39
|
+
* only carry `m=1` or `m=0` to indicate continuation.
|
|
40
|
+
*
|
|
41
|
+
* @param pngData - Raw PNG image data
|
|
42
|
+
* @param opts - Optional dimensions and ID
|
|
43
|
+
* @returns A string containing the complete escape sequence(s)
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { readFileSync } from "fs"
|
|
48
|
+
* import { encodeKittyImage } from "@silvery/react"
|
|
49
|
+
*
|
|
50
|
+
* const png = readFileSync("photo.png")
|
|
51
|
+
* const seq = encodeKittyImage(png, { width: 40, height: 20 })
|
|
52
|
+
* process.stdout.write(seq)
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function encodeKittyImage(pngData: Buffer, opts?: KittyImageOptions): string {
|
|
56
|
+
const b64 = pngData.toString("base64")
|
|
57
|
+
const chunks = splitIntoChunks(b64, MAX_CHUNK_SIZE)
|
|
58
|
+
|
|
59
|
+
if (chunks.length === 0) {
|
|
60
|
+
// Empty image — send a single empty payload
|
|
61
|
+
return `${APC_START}${buildParams(opts, 0)};${ST}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (chunks.length === 1) {
|
|
65
|
+
// Single chunk — m=0 (last/only)
|
|
66
|
+
return `${APC_START}${buildParams(opts, 0)};${chunks[0]}${ST}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Multiple chunks
|
|
70
|
+
const parts: string[] = []
|
|
71
|
+
|
|
72
|
+
// First chunk carries full metadata, m=1 (more follows)
|
|
73
|
+
parts.push(`${APC_START}${buildParams(opts, 1)};${chunks[0]}${ST}`)
|
|
74
|
+
|
|
75
|
+
// Middle chunks — only m=1
|
|
76
|
+
for (let i = 1; i < chunks.length - 1; i++) {
|
|
77
|
+
parts.push(`${APC_START}m=1;${chunks[i]}${ST}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Last chunk — m=0
|
|
81
|
+
parts.push(`${APC_START}m=0;${chunks[chunks.length - 1]}${ST}`)
|
|
82
|
+
|
|
83
|
+
return parts.join("")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Generate an escape sequence to delete a Kitty image by ID.
|
|
88
|
+
*
|
|
89
|
+
* Uses `a=d` (delete) with `d=i` (delete by image ID).
|
|
90
|
+
*
|
|
91
|
+
* @param id - The image ID to delete
|
|
92
|
+
* @returns The delete escape sequence
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* process.stdout.write(deleteKittyImage(42))
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export function deleteKittyImage(id: number): string {
|
|
100
|
+
return `${APC_START}a=d,d=i,i=${id}${ST}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if the current terminal likely supports the Kitty graphics protocol.
|
|
105
|
+
*
|
|
106
|
+
* This is a heuristic based on `TERM` and `TERM_PROGRAM` environment variables.
|
|
107
|
+
* For definitive detection, use a terminal query (send the graphics protocol
|
|
108
|
+
* query and check for a response), but that requires async I/O.
|
|
109
|
+
*
|
|
110
|
+
* Known supporting terminals: Kitty, WezTerm, Ghostty (partial), Konsole (partial).
|
|
111
|
+
*
|
|
112
|
+
* @returns `true` if the terminal likely supports Kitty graphics
|
|
113
|
+
*/
|
|
114
|
+
export function isKittyGraphicsSupported(): boolean {
|
|
115
|
+
const term = process.env.TERM ?? ""
|
|
116
|
+
const termProgram = process.env.TERM_PROGRAM ?? ""
|
|
117
|
+
|
|
118
|
+
// Kitty terminal
|
|
119
|
+
if (term === "xterm-kitty" || termProgram === "kitty") return true
|
|
120
|
+
|
|
121
|
+
// WezTerm supports Kitty graphics protocol
|
|
122
|
+
if (termProgram === "WezTerm") return true
|
|
123
|
+
|
|
124
|
+
// Ghostty supports Kitty graphics
|
|
125
|
+
if (termProgram === "ghostty") return true
|
|
126
|
+
|
|
127
|
+
// Konsole 22.04+ supports Kitty graphics
|
|
128
|
+
if (termProgram === "konsole") return true
|
|
129
|
+
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Internal helpers
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the Kitty graphics protocol parameter string for the first chunk.
|
|
139
|
+
*/
|
|
140
|
+
function buildParams(opts: KittyImageOptions | undefined, more: 0 | 1): string {
|
|
141
|
+
const parts = [`a=T`, `f=100`, `m=${more}`]
|
|
142
|
+
|
|
143
|
+
if (opts?.width != null) parts.push(`s=${opts.width}`)
|
|
144
|
+
if (opts?.height != null) parts.push(`v=${opts.height}`)
|
|
145
|
+
if (opts?.id != null) parts.push(`i=${opts.id}`)
|
|
146
|
+
|
|
147
|
+
return parts.join(",")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Split a string into chunks of at most `size` characters.
|
|
152
|
+
*/
|
|
153
|
+
function splitIntoChunks(str: string, size: number): string[] {
|
|
154
|
+
if (str.length === 0) return []
|
|
155
|
+
|
|
156
|
+
const chunks: string[] = []
|
|
157
|
+
for (let i = 0; i < str.length; i += size) {
|
|
158
|
+
chunks.push(str.slice(i, i + size))
|
|
159
|
+
}
|
|
160
|
+
return chunks
|
|
161
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sixel Encoder (Minimal Implementation)
|
|
3
|
+
*
|
|
4
|
+
* Sixel is an older image protocol supported by terminals like xterm, mlterm,
|
|
5
|
+
* foot, and some others. Images are encoded as DCS (Device Control String)
|
|
6
|
+
* sequences where each character encodes 6 vertical pixels.
|
|
7
|
+
*
|
|
8
|
+
* DCS format: `ESC P <params> q <sixel-data> ESC \`
|
|
9
|
+
*
|
|
10
|
+
* This is a minimal implementation that produces valid Sixel output for
|
|
11
|
+
* simple images. For production use with complex images, consider using
|
|
12
|
+
* a dedicated Sixel library that handles color quantization and dithering.
|
|
13
|
+
*
|
|
14
|
+
* Protocol reference: https://en.wikipedia.org/wiki/Sixel
|
|
15
|
+
*
|
|
16
|
+
* TODO: Full Sixel encoding with proper color quantization, dithering,
|
|
17
|
+
* and compression. The current implementation handles basic RGBA image data
|
|
18
|
+
* with a simple nearest-color palette approach.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DCS_START = "\x1bP"
|
|
22
|
+
const ST = "\x1b\\"
|
|
23
|
+
|
|
24
|
+
/** Sixel introduces a color with `#<index>;2;<r>;<g>;<b>` (RGB percentages 0-100) */
|
|
25
|
+
const SIXEL_NEWLINE = "-"
|
|
26
|
+
|
|
27
|
+
export interface SixelImageData {
|
|
28
|
+
/** Image width in pixels */
|
|
29
|
+
width: number
|
|
30
|
+
/** Image height in pixels */
|
|
31
|
+
height: number
|
|
32
|
+
/** RGBA pixel data (4 bytes per pixel: R, G, B, A), row-major order */
|
|
33
|
+
data: Uint8Array
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Encode RGBA image data as a Sixel escape sequence.
|
|
38
|
+
*
|
|
39
|
+
* This is a basic implementation that:
|
|
40
|
+
* 1. Quantizes colors to a small palette (up to 256 colors)
|
|
41
|
+
* 2. Encodes 6-row bands as Sixel characters
|
|
42
|
+
* 3. Wraps in a DCS escape sequence
|
|
43
|
+
*
|
|
44
|
+
* For transparent pixels (alpha < 128), the background shows through.
|
|
45
|
+
*
|
|
46
|
+
* @param imageData - Image dimensions and RGBA pixel data
|
|
47
|
+
* @returns A DCS escape sequence containing the Sixel-encoded image
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* const img = { width: 10, height: 12, data: new Uint8Array(10 * 12 * 4) }
|
|
52
|
+
* const seq = encodeSixel(img)
|
|
53
|
+
* process.stdout.write(seq)
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function encodeSixel(imageData: SixelImageData): string {
|
|
57
|
+
const { width, height, data } = imageData
|
|
58
|
+
|
|
59
|
+
if (width === 0 || height === 0 || data.length === 0) {
|
|
60
|
+
return `${DCS_START}q${ST}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build a simple palette by collecting unique (quantized) colors
|
|
64
|
+
const palette = new Map<string, number>()
|
|
65
|
+
const pixelColors = new Uint16Array(width * height) // palette index per pixel (0 = transparent)
|
|
66
|
+
let nextColorIndex = 1 // 0 reserved for transparent/background
|
|
67
|
+
|
|
68
|
+
for (let y = 0; y < height; y++) {
|
|
69
|
+
for (let x = 0; x < width; x++) {
|
|
70
|
+
const offset = (y * width + x) * 4
|
|
71
|
+
const r = data[offset]!
|
|
72
|
+
const g = data[offset + 1]!
|
|
73
|
+
const b = data[offset + 2]!
|
|
74
|
+
const a = data[offset + 3]!
|
|
75
|
+
|
|
76
|
+
if (a < 128) {
|
|
77
|
+
// Transparent — leave as 0
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Quantize to 6-bit per channel (64 levels) to keep palette small
|
|
82
|
+
const qr = (r >> 2) & 0x3f
|
|
83
|
+
const qg = (g >> 2) & 0x3f
|
|
84
|
+
const qb = (b >> 2) & 0x3f
|
|
85
|
+
const key = `${qr},${qg},${qb}`
|
|
86
|
+
|
|
87
|
+
let idx = palette.get(key)
|
|
88
|
+
if (idx == null) {
|
|
89
|
+
if (nextColorIndex >= 256) {
|
|
90
|
+
// Palette full — find closest existing color (simple fallback)
|
|
91
|
+
idx = 1
|
|
92
|
+
} else {
|
|
93
|
+
idx = nextColorIndex++
|
|
94
|
+
palette.set(key, idx)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pixelColors[y * width + x] = idx
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build Sixel data
|
|
103
|
+
const parts: string[] = []
|
|
104
|
+
|
|
105
|
+
// Raster attributes: Pan;Pad;Ph;Pv (aspect ratio 1:1, width, height)
|
|
106
|
+
parts.push(`"1;1;${width};${height}`)
|
|
107
|
+
|
|
108
|
+
// Define palette colors
|
|
109
|
+
for (const [key, idx] of palette) {
|
|
110
|
+
const [qr, qg, qb] = key.split(",").map(Number)
|
|
111
|
+
// Convert from 6-bit (0-63) to percentage (0-100)
|
|
112
|
+
const rPct = Math.round((qr! / 63) * 100)
|
|
113
|
+
const gPct = Math.round((qg! / 63) * 100)
|
|
114
|
+
const bPct = Math.round((qb! / 63) * 100)
|
|
115
|
+
parts.push(`#${idx};2;${rPct};${gPct};${bPct}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Encode pixel data in 6-row bands
|
|
119
|
+
for (let bandY = 0; bandY < height; bandY += 6) {
|
|
120
|
+
if (bandY > 0) {
|
|
121
|
+
parts.push(SIXEL_NEWLINE) // Move to next sixel row
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// For each color in the palette, emit the sixel row
|
|
125
|
+
// (Only emit colors that appear in this band)
|
|
126
|
+
const bandColors = new Set<number>()
|
|
127
|
+
for (let dy = 0; dy < 6 && bandY + dy < height; dy++) {
|
|
128
|
+
for (let x = 0; x < width; x++) {
|
|
129
|
+
const ci = pixelColors[(bandY + dy) * width + x]!
|
|
130
|
+
if (ci > 0) bandColors.add(ci)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let first = true
|
|
135
|
+
for (const colorIdx of bandColors) {
|
|
136
|
+
if (!first) {
|
|
137
|
+
parts.push("$") // Carriage return within sixel line (reposition to start)
|
|
138
|
+
}
|
|
139
|
+
first = false
|
|
140
|
+
|
|
141
|
+
parts.push(`#${colorIdx}`)
|
|
142
|
+
|
|
143
|
+
// Build the sixel characters for this color in this band
|
|
144
|
+
for (let x = 0; x < width; x++) {
|
|
145
|
+
let sixelBits = 0
|
|
146
|
+
for (let dy = 0; dy < 6; dy++) {
|
|
147
|
+
const y = bandY + dy
|
|
148
|
+
if (y < height && pixelColors[y * width + x] === colorIdx) {
|
|
149
|
+
sixelBits |= 1 << dy
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Sixel character = bits + 63 (0x3F)
|
|
153
|
+
parts.push(String.fromCharCode(sixelBits + 63))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return `${DCS_START}q${parts.join("")}${ST}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check if the current terminal likely supports the Sixel protocol.
|
|
163
|
+
*
|
|
164
|
+
* This is a heuristic based on environment variables. For definitive
|
|
165
|
+
* detection, send a DA1 (Device Attributes) query and check for "4"
|
|
166
|
+
* in the response, but that requires async I/O.
|
|
167
|
+
*
|
|
168
|
+
* Known supporting terminals: xterm (with +sixel), mlterm, foot, mintty,
|
|
169
|
+
* WezTerm, Contour, Sixel-enabled builds of various terminals.
|
|
170
|
+
*
|
|
171
|
+
* @returns `true` if the terminal likely supports Sixel
|
|
172
|
+
*/
|
|
173
|
+
export function isSixelSupported(): boolean {
|
|
174
|
+
const term = process.env.TERM ?? ""
|
|
175
|
+
const termProgram = process.env.TERM_PROGRAM ?? ""
|
|
176
|
+
|
|
177
|
+
// mlterm supports Sixel natively
|
|
178
|
+
if (termProgram === "mlterm" || term.startsWith("mlterm")) return true
|
|
179
|
+
|
|
180
|
+
// foot supports Sixel
|
|
181
|
+
if (termProgram === "foot" || term === "foot" || term === "foot-extra") return true
|
|
182
|
+
|
|
183
|
+
// WezTerm supports Sixel
|
|
184
|
+
if (termProgram === "WezTerm") return true
|
|
185
|
+
|
|
186
|
+
// mintty supports Sixel
|
|
187
|
+
if (termProgram === "mintty") return true
|
|
188
|
+
|
|
189
|
+
// xterm might support Sixel if compiled with +sixel
|
|
190
|
+
// We can't know for sure from env alone, so we don't claim support
|
|
191
|
+
// (the user can set protocol='sixel' explicitly)
|
|
192
|
+
|
|
193
|
+
return false
|
|
194
|
+
}
|
package/src/images.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silvery/images -- Image rendering via Kitty graphics and Sixel protocol.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* import { Image } from '@silvery/ui/images'
|
|
6
|
+
*
|
|
7
|
+
* <Image src={pngBuffer} width={40} height={15} fallback="[image]" />
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Auto-detects the best available protocol (Kitty > Sixel > text fallback).
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { Image } from "./image/Image"
|
|
16
|
+
export type { ImageProps } from "./image/Image"
|
|
17
|
+
|
|
18
|
+
export { encodeKittyImage, deleteKittyImage, isKittyGraphicsSupported } from "./image/kitty-graphics"
|
|
19
|
+
export type { KittyImageOptions } from "./image/kitty-graphics"
|
|
20
|
+
|
|
21
|
+
export { encodeSixel, isSixelSupported } from "./image/sixel-encoder"
|
|
22
|
+
export type { SixelImageData } from "./image/sixel-encoder"
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silvery-ui - UI components for Ink/silvery TUI apps
|
|
3
|
+
*
|
|
4
|
+
* Progress indicators, spinners, and task wrappers for CLI applications.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* // Fluent task API (recommended)
|
|
9
|
+
* import { task, tasks } from "@silvery/ui/progress";
|
|
10
|
+
*
|
|
11
|
+
* const data = await task("Loading").wrap(fetchData());
|
|
12
|
+
*
|
|
13
|
+
* const results = await tasks()
|
|
14
|
+
* .add("Loading", () => fetchData())
|
|
15
|
+
* .add("Processing", () => process())
|
|
16
|
+
* .run({ clear: true });
|
|
17
|
+
*
|
|
18
|
+
* // Low-level CLI components
|
|
19
|
+
* import { Spinner, ProgressBar } from "@silvery/ui/cli";
|
|
20
|
+
*
|
|
21
|
+
* // React/TUI components
|
|
22
|
+
* import { Spinner, ProgressBar } from "@silvery/ui/react";
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @packageDocumentation
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Re-export everything for convenience
|
|
29
|
+
export * from "./types.js"
|
|
30
|
+
export * from "./cli/index.js"
|
|
31
|
+
export * from "./wrappers/index.js"
|
|
32
|
+
|
|
33
|
+
// Note: React components should be imported from "@silvery/ui/react"
|
|
34
|
+
// to avoid requiring React as a dependency for CLI-only usage
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Select component for silvery/Ink TUI apps
|
|
3
|
+
*
|
|
4
|
+
* Single-choice selection list with keyboard navigation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useState, useEffect, useCallback } from "react"
|
|
8
|
+
import type { SelectProps, SelectOption } from "../types.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scrollable single-choice selection list
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* import { Select } from "@silvery/ui/input";
|
|
16
|
+
*
|
|
17
|
+
* function SettingsView() {
|
|
18
|
+
* const [theme, setTheme] = useState("light");
|
|
19
|
+
*
|
|
20
|
+
* return (
|
|
21
|
+
* <Select
|
|
22
|
+
* options={[
|
|
23
|
+
* { label: "Light", value: "light" },
|
|
24
|
+
* { label: "Dark", value: "dark" },
|
|
25
|
+
* { label: "System", value: "system" },
|
|
26
|
+
* ]}
|
|
27
|
+
* value={theme}
|
|
28
|
+
* onChange={setTheme}
|
|
29
|
+
* />
|
|
30
|
+
* );
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function Select<T>({
|
|
35
|
+
options,
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
maxVisible = 10,
|
|
39
|
+
highlightIndex: controlledHighlightIndex,
|
|
40
|
+
onHighlightChange,
|
|
41
|
+
}: SelectProps<T>): React.ReactElement {
|
|
42
|
+
// Find the index of the currently selected value
|
|
43
|
+
const selectedIndex = options.findIndex((opt) => opt.value === value)
|
|
44
|
+
|
|
45
|
+
// Internal highlight state (for uncontrolled mode)
|
|
46
|
+
const [internalHighlightIndex, setInternalHighlightIndex] = useState(selectedIndex >= 0 ? selectedIndex : 0)
|
|
47
|
+
|
|
48
|
+
// Use controlled or internal highlight index
|
|
49
|
+
const highlightIndex = controlledHighlightIndex ?? internalHighlightIndex
|
|
50
|
+
|
|
51
|
+
// Calculate scroll window
|
|
52
|
+
const scrollOffset = Math.max(0, Math.min(highlightIndex - Math.floor(maxVisible / 2), options.length - maxVisible))
|
|
53
|
+
const visibleOptions = options.slice(scrollOffset, scrollOffset + maxVisible)
|
|
54
|
+
const hasMoreAbove = scrollOffset > 0
|
|
55
|
+
const hasMoreBelow = scrollOffset + maxVisible < options.length
|
|
56
|
+
|
|
57
|
+
// Sync internal highlight when value changes externally
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (controlledHighlightIndex === undefined && selectedIndex >= 0) {
|
|
60
|
+
setInternalHighlightIndex(selectedIndex)
|
|
61
|
+
}
|
|
62
|
+
}, [selectedIndex, controlledHighlightIndex])
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div data-silvery-select>
|
|
66
|
+
{hasMoreAbove && <div data-silvery-select-scroll-indicator="up">...</div>}
|
|
67
|
+
{visibleOptions.map((option, visibleIdx) => {
|
|
68
|
+
const actualIndex = scrollOffset + visibleIdx
|
|
69
|
+
const isSelected = option.value === value
|
|
70
|
+
const isHighlighted = actualIndex === highlightIndex
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div key={actualIndex} data-silvery-select-option data-selected={isSelected} data-highlighted={isHighlighted}>
|
|
74
|
+
<span data-silvery-select-indicator>{isSelected ? ">" : " "}</span>
|
|
75
|
+
<span data-silvery-select-label>{option.label}</span>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
})}
|
|
79
|
+
{hasMoreBelow && <div data-silvery-select-scroll-indicator="down">...</div>}
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Hook for managing select state with keyboard navigation
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```tsx
|
|
89
|
+
* function MySelect() {
|
|
90
|
+
* const options = [
|
|
91
|
+
* { label: "Option A", value: "a" },
|
|
92
|
+
* { label: "Option B", value: "b" },
|
|
93
|
+
* ];
|
|
94
|
+
*
|
|
95
|
+
* const { highlightIndex, moveUp, moveDown, select, value } = useSelect({
|
|
96
|
+
* options,
|
|
97
|
+
* initialValue: "a",
|
|
98
|
+
* });
|
|
99
|
+
*
|
|
100
|
+
* useInput((input, key) => {
|
|
101
|
+
* if (key.upArrow) moveUp();
|
|
102
|
+
* if (key.downArrow) moveDown();
|
|
103
|
+
* if (key.return) select();
|
|
104
|
+
* });
|
|
105
|
+
*
|
|
106
|
+
* return <Select options={options} value={value} highlightIndex={highlightIndex} />;
|
|
107
|
+
* }
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export function useSelect<T>({
|
|
111
|
+
options,
|
|
112
|
+
initialValue,
|
|
113
|
+
onChange,
|
|
114
|
+
}: {
|
|
115
|
+
options: SelectOption<T>[]
|
|
116
|
+
initialValue?: T
|
|
117
|
+
onChange?: (value: T) => void
|
|
118
|
+
}): {
|
|
119
|
+
value: T | undefined
|
|
120
|
+
highlightIndex: number
|
|
121
|
+
moveUp: () => void
|
|
122
|
+
moveDown: () => void
|
|
123
|
+
select: () => void
|
|
124
|
+
setHighlightIndex: (index: number) => void
|
|
125
|
+
} {
|
|
126
|
+
const initialIndex = initialValue !== undefined ? options.findIndex((opt) => opt.value === initialValue) : 0
|
|
127
|
+
|
|
128
|
+
const [highlightIndex, setHighlightIndex] = useState(Math.max(0, initialIndex))
|
|
129
|
+
const [value, setValue] = useState<T | undefined>(initialValue)
|
|
130
|
+
|
|
131
|
+
const moveUp = useCallback(() => {
|
|
132
|
+
setHighlightIndex((i) => Math.max(0, i - 1))
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
const moveDown = useCallback(() => {
|
|
136
|
+
setHighlightIndex((i) => Math.min(options.length - 1, i + 1))
|
|
137
|
+
}, [options.length])
|
|
138
|
+
|
|
139
|
+
const select = useCallback(() => {
|
|
140
|
+
const option = options[highlightIndex]
|
|
141
|
+
if (option) {
|
|
142
|
+
setValue(option.value)
|
|
143
|
+
onChange?.(option.value)
|
|
144
|
+
}
|
|
145
|
+
}, [highlightIndex, options, onChange])
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
value,
|
|
149
|
+
highlightIndex,
|
|
150
|
+
moveUp,
|
|
151
|
+
moveDown,
|
|
152
|
+
select,
|
|
153
|
+
setHighlightIndex,
|
|
154
|
+
}
|
|
155
|
+
}
|