@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.
Files changed (87) hide show
  1. package/package.json +71 -0
  2. package/src/animation/easing.ts +38 -0
  3. package/src/animation/index.ts +18 -0
  4. package/src/animation/useAnimation.ts +143 -0
  5. package/src/animation/useInterval.ts +39 -0
  6. package/src/animation/useLatest.ts +35 -0
  7. package/src/animation/useTimeout.ts +65 -0
  8. package/src/animation/useTransition.ts +110 -0
  9. package/src/animation.ts +24 -0
  10. package/src/ansi/index.ts +43 -0
  11. package/src/canvas/index.ts +169 -0
  12. package/src/cli/ansi.ts +85 -0
  13. package/src/cli/index.ts +39 -0
  14. package/src/cli/multi-progress.ts +340 -0
  15. package/src/cli/progress-bar.ts +222 -0
  16. package/src/cli/spinner.ts +275 -0
  17. package/src/components/Badge.tsx +54 -0
  18. package/src/components/Breadcrumb.tsx +72 -0
  19. package/src/components/Button.tsx +73 -0
  20. package/src/components/CommandPalette.tsx +186 -0
  21. package/src/components/Console.tsx +79 -0
  22. package/src/components/CursorLine.tsx +71 -0
  23. package/src/components/Divider.tsx +67 -0
  24. package/src/components/EditContextDisplay.tsx +164 -0
  25. package/src/components/ErrorBoundary.tsx +179 -0
  26. package/src/components/Form.tsx +86 -0
  27. package/src/components/GridCell.tsx +42 -0
  28. package/src/components/HorizontalVirtualList.tsx +375 -0
  29. package/src/components/ModalDialog.tsx +179 -0
  30. package/src/components/PickerDialog.tsx +208 -0
  31. package/src/components/PickerList.tsx +93 -0
  32. package/src/components/ProgressBar.tsx +126 -0
  33. package/src/components/Screen.tsx +78 -0
  34. package/src/components/ScrollbackList.tsx +92 -0
  35. package/src/components/ScrollbackView.tsx +390 -0
  36. package/src/components/SelectList.tsx +176 -0
  37. package/src/components/Skeleton.tsx +87 -0
  38. package/src/components/Spinner.tsx +64 -0
  39. package/src/components/SplitView.tsx +199 -0
  40. package/src/components/Table.tsx +139 -0
  41. package/src/components/Tabs.tsx +203 -0
  42. package/src/components/TextArea.tsx +264 -0
  43. package/src/components/TextInput.tsx +240 -0
  44. package/src/components/Toast.tsx +216 -0
  45. package/src/components/Toggle.tsx +73 -0
  46. package/src/components/Tooltip.tsx +60 -0
  47. package/src/components/TreeView.tsx +212 -0
  48. package/src/components/Typography.tsx +233 -0
  49. package/src/components/VirtualList.tsx +318 -0
  50. package/src/components/VirtualView.tsx +221 -0
  51. package/src/components/useReadline.ts +213 -0
  52. package/src/components/useTextArea.ts +648 -0
  53. package/src/components.ts +133 -0
  54. package/src/display/Table.tsx +179 -0
  55. package/src/display/index.ts +13 -0
  56. package/src/hooks/useTea.ts +133 -0
  57. package/src/image/Image.tsx +187 -0
  58. package/src/image/index.ts +15 -0
  59. package/src/image/kitty-graphics.ts +161 -0
  60. package/src/image/sixel-encoder.ts +194 -0
  61. package/src/images.ts +22 -0
  62. package/src/index.ts +34 -0
  63. package/src/input/Select.tsx +155 -0
  64. package/src/input/TextInput.tsx +227 -0
  65. package/src/input/index.ts +25 -0
  66. package/src/progress/als-context.ts +160 -0
  67. package/src/progress/declarative.ts +519 -0
  68. package/src/progress/index.ts +54 -0
  69. package/src/progress/step-node.ts +152 -0
  70. package/src/progress/steps.ts +425 -0
  71. package/src/progress/task.ts +138 -0
  72. package/src/progress/tasks.ts +216 -0
  73. package/src/react/ProgressBar.tsx +146 -0
  74. package/src/react/Spinner.tsx +74 -0
  75. package/src/react/Tasks.tsx +144 -0
  76. package/src/react/context.tsx +145 -0
  77. package/src/react/index.ts +30 -0
  78. package/src/types.ts +252 -0
  79. package/src/utils/eta.ts +155 -0
  80. package/src/utils/index.ts +13 -0
  81. package/src/wrappers/index.ts +36 -0
  82. package/src/wrappers/with-progress.ts +250 -0
  83. package/src/wrappers/with-select.ts +194 -0
  84. package/src/wrappers/with-spinner.ts +108 -0
  85. package/src/wrappers/with-text-input.ts +388 -0
  86. package/src/wrappers/wrap-emitter.ts +158 -0
  87. package/src/wrappers/wrap-generator.ts +143 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * silvery/components -- Rich UI components beyond Ink's built-in set.
3
+ *
4
+ * ```tsx
5
+ * import { VirtualList, Table, SelectList, TextInput, Spinner } from '@silvery/ui/components'
6
+ * ```
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ // =============================================================================
12
+ // Layout Components
13
+ // =============================================================================
14
+
15
+ export { VirtualList } from "./components/VirtualList"
16
+ export type { VirtualListProps, VirtualListHandle } from "./components/VirtualList"
17
+
18
+ export { HorizontalVirtualList } from "./components/HorizontalVirtualList"
19
+ export type { HorizontalVirtualListProps, HorizontalVirtualListHandle } from "./components/HorizontalVirtualList"
20
+
21
+ export { SplitView } from "./components/SplitView"
22
+ export type { SplitViewProps } from "./components/SplitView"
23
+ export type { LayoutNode as SplitLayoutNode } from "@silvery/term/pane-manager"
24
+ export {
25
+ createLeaf,
26
+ splitPane,
27
+ removePane,
28
+ getPaneIds,
29
+ findAdjacentPane,
30
+ resizeSplit,
31
+ swapPanes,
32
+ getTabOrder as getSplitTabOrder,
33
+ } from "@silvery/term/pane-manager"
34
+
35
+ export { Fill } from "@silvery/react/components/Fill"
36
+ export type { FillProps } from "@silvery/react/components/Fill"
37
+
38
+ export { Link } from "@silvery/react/components/Link"
39
+ export type { LinkProps } from "@silvery/react/components/Link"
40
+
41
+ export { ErrorBoundary } from "./components/ErrorBoundary"
42
+ export type { ErrorBoundaryProps } from "./components/ErrorBoundary"
43
+
44
+ export { Console } from "./components/Console"
45
+
46
+ // =============================================================================
47
+ // Input Components
48
+ // =============================================================================
49
+
50
+ export { TextInput } from "./components/TextInput"
51
+ export type { TextInputProps, TextInputHandle } from "./components/TextInput"
52
+
53
+ export { TextArea } from "./components/TextArea"
54
+ export type { TextAreaProps, TextAreaHandle, TextAreaSelection } from "./components/TextArea"
55
+
56
+ export { useTextArea, clampScroll } from "./components/useTextArea"
57
+ export type { UseTextAreaOptions, UseTextAreaResult } from "./components/useTextArea"
58
+
59
+ export { EditContextDisplay } from "./components/EditContextDisplay"
60
+ export type { EditContextDisplayProps } from "./components/EditContextDisplay"
61
+
62
+ // Display Components
63
+ export { CursorLine } from "./components/CursorLine"
64
+ export type { CursorLineProps } from "./components/CursorLine"
65
+
66
+ // Dialog Components
67
+ export { ModalDialog, formatTitleWithHotkey } from "./components/ModalDialog"
68
+ export type { ModalDialogProps } from "./components/ModalDialog"
69
+
70
+ export { PickerDialog } from "./components/PickerDialog"
71
+ export type { PickerDialogProps } from "./components/PickerDialog"
72
+
73
+ // Focusable Controls
74
+ export { Toggle } from "./components/Toggle"
75
+ export type { ToggleProps } from "./components/Toggle"
76
+
77
+ export { Button } from "./components/Button"
78
+ export type { ButtonProps } from "./components/Button"
79
+
80
+ export { useReadline } from "./components/useReadline"
81
+ export type { ReadlineState, UseReadlineOptions, UseReadlineResult } from "./components/useReadline"
82
+
83
+ // =============================================================================
84
+ // Widget Components
85
+ // =============================================================================
86
+
87
+ export { Spinner } from "./components/Spinner"
88
+ export type { SpinnerProps } from "./components/Spinner"
89
+
90
+ export { ProgressBar } from "./components/ProgressBar"
91
+ export type { ProgressBarProps } from "./components/ProgressBar"
92
+
93
+ export { SelectList } from "./components/SelectList"
94
+ export type { SelectListProps, SelectOption } from "./components/SelectList"
95
+
96
+ export { Table } from "./components/Table"
97
+ export type { TableProps, TableColumn } from "./components/Table"
98
+
99
+ export { Badge } from "./components/Badge"
100
+ export type { BadgeProps } from "./components/Badge"
101
+
102
+ export { Divider } from "./components/Divider"
103
+ export type { DividerProps } from "./components/Divider"
104
+
105
+ // =============================================================================
106
+ // Position Registry (2D Grid Virtualization)
107
+ // =============================================================================
108
+
109
+ export {
110
+ PositionRegistryProvider,
111
+ usePositionRegistry,
112
+ createPositionRegistry,
113
+ } from "@silvery/react/hooks/usePositionRegistry"
114
+ export type { PositionRegistry, ScreenRect } from "@silvery/react/hooks/usePositionRegistry"
115
+ export { useGridPosition } from "@silvery/react/hooks/useGridPosition"
116
+ export { GridCell } from "./components/GridCell"
117
+ export type { GridCellProps } from "./components/GridCell"
118
+
119
+ // =============================================================================
120
+ // Scroll Utilities
121
+ // =============================================================================
122
+
123
+ export { calcEdgeBasedScrollOffset } from "@silvery/term/scroll-utils"
124
+
125
+ export {
126
+ setScrollRegion,
127
+ resetScrollRegion,
128
+ scrollUp,
129
+ scrollDown,
130
+ moveCursor,
131
+ supportsScrollRegions,
132
+ } from "@silvery/term/scroll-region"
133
+ export type { ScrollRegionConfig } from "@silvery/term/scroll-region"
@@ -0,0 +1,179 @@
1
+ /**
2
+ * React Table component for silvery/Ink TUI apps
3
+ */
4
+
5
+ import React from "react"
6
+ import type { TableProps, TableColumn } from "../types.js"
7
+
8
+ /**
9
+ * Unicode box drawing characters for borders
10
+ */
11
+ const BOX = {
12
+ topLeft: "┌",
13
+ topRight: "┐",
14
+ bottomLeft: "└",
15
+ bottomRight: "┘",
16
+ horizontal: "─",
17
+ vertical: "│",
18
+ leftT: "├",
19
+ rightT: "┤",
20
+ topT: "┬",
21
+ bottomT: "┴",
22
+ cross: "┼",
23
+ } as const
24
+
25
+ /**
26
+ * Data grid display component for React TUI apps
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * import { Table } from "@silvery/ui/display";
31
+ *
32
+ * const columns = [
33
+ * { key: "name", header: "Name", width: 20 },
34
+ * { key: "status", header: "Status", width: 10, align: "center" },
35
+ * { key: "count", header: "Count", width: 8, align: "right" },
36
+ * ];
37
+ *
38
+ * const data = [
39
+ * { name: "Item 1", status: "active", count: 42 },
40
+ * { name: "Item 2", status: "pending", count: 7 },
41
+ * ];
42
+ *
43
+ * function DataView() {
44
+ * return <Table columns={columns} data={data} border />;
45
+ * }
46
+ * ```
47
+ */
48
+ export function Table({ columns, data, border = false }: TableProps): React.ReactElement {
49
+ // Calculate effective column widths
50
+ const effectiveColumns = calculateColumnWidths(columns, data)
51
+
52
+ const lines: string[] = []
53
+
54
+ if (border) {
55
+ // Top border
56
+ lines.push(buildBorderLine(effectiveColumns, "top"))
57
+ }
58
+
59
+ // Header row
60
+ lines.push(buildDataRow(effectiveColumns, getHeaderRow(effectiveColumns), border))
61
+
62
+ if (border) {
63
+ // Separator after header
64
+ lines.push(buildBorderLine(effectiveColumns, "middle"))
65
+ }
66
+
67
+ // Data rows
68
+ for (const row of data) {
69
+ lines.push(buildDataRow(effectiveColumns, row, border))
70
+ }
71
+
72
+ if (border) {
73
+ // Bottom border
74
+ lines.push(buildBorderLine(effectiveColumns, "bottom"))
75
+ }
76
+
77
+ return (
78
+ <span data-table data-border={border}>
79
+ {lines.join("\n")}
80
+ </span>
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Calculate effective column widths based on content if not specified
86
+ */
87
+ function calculateColumnWidths(
88
+ columns: TableColumn[],
89
+ data: Array<Record<string, unknown>>,
90
+ ): Array<TableColumn & { effectiveWidth: number }> {
91
+ return columns.map((col) => {
92
+ if (col.width !== undefined) {
93
+ return { ...col, effectiveWidth: col.width }
94
+ }
95
+
96
+ // Calculate width from content
97
+ let maxWidth = col.header.length
98
+
99
+ for (const row of data) {
100
+ const value = String(row[col.key] ?? "")
101
+ maxWidth = Math.max(maxWidth, value.length)
102
+ }
103
+
104
+ return { ...col, effectiveWidth: maxWidth }
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Create header row object from columns
110
+ */
111
+ function getHeaderRow(columns: Array<TableColumn & { effectiveWidth: number }>): Record<string, unknown> {
112
+ const row: Record<string, unknown> = {}
113
+ for (const col of columns) {
114
+ row[col.key] = col.header
115
+ }
116
+ return row
117
+ }
118
+
119
+ /**
120
+ * Build a border line (top, middle, or bottom)
121
+ */
122
+ function buildBorderLine(
123
+ columns: Array<TableColumn & { effectiveWidth: number }>,
124
+ position: "top" | "middle" | "bottom",
125
+ ): string {
126
+ const left = position === "top" ? BOX.topLeft : position === "bottom" ? BOX.bottomLeft : BOX.leftT
127
+ const right = position === "top" ? BOX.topRight : position === "bottom" ? BOX.bottomRight : BOX.rightT
128
+ const join = position === "top" ? BOX.topT : position === "bottom" ? BOX.bottomT : BOX.cross
129
+
130
+ const segments = columns.map((col) => BOX.horizontal.repeat(col.effectiveWidth + 2))
131
+
132
+ return left + segments.join(join) + right
133
+ }
134
+
135
+ /**
136
+ * Build a data row (header or content)
137
+ */
138
+ function buildDataRow(
139
+ columns: Array<TableColumn & { effectiveWidth: number }>,
140
+ row: Record<string, unknown>,
141
+ border: boolean,
142
+ ): string {
143
+ const cells = columns.map((col) => {
144
+ const value = String(row[col.key] ?? "")
145
+ return formatCell(value, col.effectiveWidth, col.align ?? "left")
146
+ })
147
+
148
+ if (border) {
149
+ return BOX.vertical + " " + cells.join(" " + BOX.vertical + " ") + " " + BOX.vertical
150
+ }
151
+
152
+ return cells.join(" ")
153
+ }
154
+
155
+ /**
156
+ * Format a cell value with alignment and truncation
157
+ */
158
+ function formatCell(value: string, width: number, align: "left" | "center" | "right"): string {
159
+ // Truncate if too long
160
+ if (value.length > width) {
161
+ return value.slice(0, width - 1) + "…"
162
+ }
163
+
164
+ // Pad according to alignment
165
+ const padding = width - value.length
166
+
167
+ switch (align) {
168
+ case "right":
169
+ return " ".repeat(padding) + value
170
+ case "center": {
171
+ const leftPad = Math.floor(padding / 2)
172
+ const rightPad = padding - leftPad
173
+ return " ".repeat(leftPad) + value + " ".repeat(rightPad)
174
+ }
175
+ case "left":
176
+ default:
177
+ return value + " ".repeat(padding)
178
+ }
179
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Display components for silvery/Ink TUI apps
3
+ *
4
+ * These are read-only display components (no input handling).
5
+ *
6
+ * @example
7
+ * ```tsx
8
+ * import { Table } from "@silvery/ui/display";
9
+ * ```
10
+ */
11
+
12
+ export { Table } from "./Table"
13
+ export type { TableColumn, TableProps } from "../types.js"
@@ -0,0 +1,133 @@
1
+ /**
2
+ * useTea — React hook for TEA (The Elm Architecture) state machines.
3
+ *
4
+ * Like useReducer, but the reducer can return [state, effects].
5
+ * Effects are plain data objects executed by runners. Built-in timer
6
+ * runners handle delay/interval/cancel. All timers auto-cleanup on unmount.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { useTea } from "silvery"
11
+ * import { fx } from "@silvery/tea/effects"
12
+ *
13
+ * type State = { count: number; running: boolean }
14
+ * type Msg = { type: "start" } | { type: "tick" } | { type: "stop" }
15
+ *
16
+ * function update(state: State, msg: Msg) {
17
+ * switch (msg.type) {
18
+ * case "start":
19
+ * return [{ ...state, running: true }, [fx.interval(100, { type: "tick" }, "counter")]]
20
+ * case "tick":
21
+ * return { ...state, count: state.count + 1 }
22
+ * case "stop":
23
+ * return [{ ...state, running: false }, [fx.cancel("counter")]]
24
+ * }
25
+ * }
26
+ *
27
+ * function Counter() {
28
+ * const [state, send] = useTea({ count: 0, running: false }, update)
29
+ * return <Text>Count: {state.count}</Text>
30
+ * }
31
+ * ```
32
+ */
33
+
34
+ import { useCallback, useEffect, useRef, useReducer } from "react"
35
+ import type { EffectLike, EffectRunners, TeaResult } from "@silvery/tea/tea"
36
+ import { collect } from "@silvery/tea/tea"
37
+ import { createTimerRunners, type TimerEffect } from "@silvery/tea/effects"
38
+
39
+ // =============================================================================
40
+ // Hook
41
+ // =============================================================================
42
+
43
+ /**
44
+ * TEA state machine hook with automatic timer management.
45
+ *
46
+ * The update function can return plain state (no effects) or `[state, effects]`.
47
+ * Timer effects (delay, interval, cancel) are handled automatically.
48
+ * Additional effect runners can be provided for custom effects.
49
+ *
50
+ * All timers are cleaned up automatically on unmount.
51
+ *
52
+ * @param initialState - Initial state value
53
+ * @param update - Pure update function: `(state, msg) => state | [state, effects]`
54
+ * @param customRunners - Optional additional effect runners for non-timer effects
55
+ * @returns `[state, send]` tuple — send dispatches a message through the update function
56
+ */
57
+ export function useTea<S, Msg, E extends EffectLike = TimerEffect<Msg>>(
58
+ initialState: S | (() => S),
59
+ update: (state: S, msg: Msg) => TeaResult<S, E>,
60
+ customRunners?: EffectRunners<E, Msg>,
61
+ ): [S, (msg: Msg) => void] {
62
+ // Create timer runners once (stable across renders)
63
+ const timerRef = useRef<ReturnType<typeof createTimerRunners<Msg>> | null>(null)
64
+ if (timerRef.current === null) {
65
+ timerRef.current = createTimerRunners<Msg>()
66
+ }
67
+ const { runners: timerRunners, cleanup } = timerRef.current
68
+
69
+ // Keep custom runners ref-stable
70
+ const customRunnersRef = useRef(customRunners)
71
+ customRunnersRef.current = customRunners
72
+
73
+ // Pending effects queue — effects from the reducer can't be executed
74
+ // during render, so we queue them and execute in a useEffect.
75
+ const pendingEffectsRef = useRef<E[]>([])
76
+
77
+ // Use React's useReducer for state — it integrates with React's scheduler
78
+ const [state, reactDispatch] = useReducer(
79
+ (prevState: S, msg: Msg): S => {
80
+ const result = update(prevState, msg)
81
+ const [newState, effects] = collect(result)
82
+ if (effects.length > 0) {
83
+ pendingEffectsRef.current.push(...effects)
84
+ }
85
+ return newState
86
+ },
87
+ undefined,
88
+ () => (typeof initialState === "function" ? (initialState as () => S)() : initialState),
89
+ )
90
+
91
+ // Execute effects outside of render (React rules)
92
+ const sendRef = useRef<(msg: Msg) => void>(() => {})
93
+
94
+ const executeEffects = useCallback(() => {
95
+ if (pendingEffectsRef.current.length === 0) return
96
+ const effects = pendingEffectsRef.current.splice(0)
97
+ for (const effect of effects) {
98
+ // Try timer runners first
99
+ const timerRunner = timerRunners[effect.type as keyof typeof timerRunners]
100
+ if (timerRunner) {
101
+ ;(timerRunner as (e: any, d: (msg: Msg) => void) => void)(effect, sendRef.current)
102
+ continue
103
+ }
104
+ // Try custom runners
105
+ const customRunner = customRunnersRef.current?.[effect.type as E["type"]]
106
+ if (customRunner) {
107
+ ;(customRunner as (e: any, d: (msg: Msg) => void) => void)(effect, sendRef.current)
108
+ }
109
+ }
110
+ }, [timerRunners])
111
+
112
+ // The send function: dispatch to React, then execute effects
113
+ const send = useCallback(
114
+ (msg: Msg) => {
115
+ reactDispatch(msg)
116
+ // Effects are queued by the reducer — execute them after React processes the update.
117
+ // We use queueMicrotask to ensure effects run after the reducer but before the next paint.
118
+ queueMicrotask(executeEffects)
119
+ },
120
+ [reactDispatch, executeEffects],
121
+ )
122
+ sendRef.current = send
123
+
124
+ // Execute any effects from the initial render
125
+ useEffect(() => {
126
+ executeEffects()
127
+ }, [executeEffects])
128
+
129
+ // Cleanup all timers on unmount
130
+ useEffect(() => cleanup, [cleanup])
131
+
132
+ return [state, send]
133
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Image Component
3
+ *
4
+ * Renders bitmap images in supported terminals using the Kitty graphics
5
+ * protocol (primary) or Sixel (fallback). When neither is supported,
6
+ * displays a text placeholder.
7
+ *
8
+ * Since terminal images are escape-sequence-based and don't fit the cell
9
+ * buffer model, the component reserves visual space with a Box of the
10
+ * requested dimensions and uses `useEffect` to write image data directly
11
+ * to stdout after render.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * import { readFileSync } from "fs"
16
+ * import { Image } from "@silvery/react"
17
+ *
18
+ * const png = readFileSync("photo.png")
19
+ * <Image src={png} width={40} height={20} />
20
+ *
21
+ * // With file path
22
+ * <Image src="/path/to/image.png" width={40} height={20} />
23
+ *
24
+ * // Auto-detect protocol, fall back to text
25
+ * <Image src={png} width={40} height={20} fallback="[photo]" />
26
+ * ```
27
+ */
28
+
29
+ import { readFileSync } from "node:fs"
30
+ import { type JSX, useContext, useEffect, useMemo, useRef } from "react"
31
+ import { StdoutContext } from "@silvery/react/context"
32
+ import { useContentRect } from "@silvery/react/hooks/useLayout"
33
+ import { encodeKittyImage, isKittyGraphicsSupported, deleteKittyImage } from "./kitty-graphics"
34
+ import { isSixelSupported } from "./sixel-encoder"
35
+
36
+ // ============================================================================
37
+ // Types
38
+ // ============================================================================
39
+
40
+ export type ImageProtocol = "kitty" | "sixel" | "auto"
41
+
42
+ export interface ImageProps {
43
+ /** PNG image data (Buffer) or file path (string) to a PNG file */
44
+ src: Buffer | string
45
+ /** Width in terminal columns. If omitted, uses available width from layout. */
46
+ width?: number
47
+ /** Height in terminal rows. If omitted, defaults to half the width (rough aspect ratio). */
48
+ height?: number
49
+ /** Text to display when image rendering is not supported. Default: "[image]" */
50
+ fallback?: string
51
+ /** Which protocol to use. Default: "auto" (tries Kitty, then Sixel, then fallback) */
52
+ protocol?: ImageProtocol
53
+ }
54
+
55
+ // ============================================================================
56
+ // Protocol Detection
57
+ // ============================================================================
58
+
59
+ /**
60
+ * Determine the best available image protocol.
61
+ * Returns null if no image protocol is available.
62
+ */
63
+ function detectProtocol(preferred: ImageProtocol): "kitty" | "sixel" | null {
64
+ if (preferred === "kitty") {
65
+ return isKittyGraphicsSupported() ? "kitty" : null
66
+ }
67
+ if (preferred === "sixel") {
68
+ return isSixelSupported() ? "sixel" : null
69
+ }
70
+
71
+ // Auto-detect: prefer Kitty, fall back to Sixel
72
+ if (isKittyGraphicsSupported()) return "kitty"
73
+ if (isSixelSupported()) return "sixel"
74
+ return null
75
+ }
76
+
77
+ // ============================================================================
78
+ // Component
79
+ // ============================================================================
80
+
81
+ /** Incrementing image ID counter for Kitty protocol */
82
+ let nextImageId = 1
83
+
84
+ /**
85
+ * Renders a bitmap image in the terminal.
86
+ *
87
+ * The component operates in two phases:
88
+ * 1. **Layout phase**: Renders a Box that reserves the visual space
89
+ * (filled with spaces so the cell buffer has the right dimensions).
90
+ * 2. **Effect phase**: After render, writes the image escape sequence
91
+ * directly to stdout, positioned over the reserved space.
92
+ *
93
+ * When image protocols are not available, the fallback text is shown instead.
94
+ */
95
+ export function Image({
96
+ src,
97
+ width: requestedWidth,
98
+ height: requestedHeight,
99
+ fallback = "[image]",
100
+ protocol: preferredProtocol = "auto",
101
+ }: ImageProps): JSX.Element {
102
+ const contentRect = useContentRect()
103
+ const stdoutCtx = useContext(StdoutContext)
104
+ const imageIdRef = useRef<number | null>(null)
105
+
106
+ // Resolve image data
107
+ const pngData = useMemo(() => {
108
+ if (Buffer.isBuffer(src)) return src
109
+ // String path — read file synchronously (during render is fine for a path)
110
+ try {
111
+ return readFileSync(src)
112
+ } catch {
113
+ return null
114
+ }
115
+ }, [src])
116
+
117
+ // Determine effective dimensions
118
+ const effectiveWidth = requestedWidth ?? contentRect.width
119
+ const effectiveHeight = requestedHeight ?? Math.max(1, Math.floor(effectiveWidth / 2))
120
+
121
+ // Detect protocol support
122
+ const activeProtocol = useMemo(() => detectProtocol(preferredProtocol), [preferredProtocol])
123
+
124
+ // Assign a stable image ID for Kitty (for cleanup on unmount)
125
+ if (activeProtocol === "kitty" && imageIdRef.current == null) {
126
+ imageIdRef.current = nextImageId++
127
+ }
128
+
129
+ // Write image escape sequences after render
130
+ useEffect(() => {
131
+ if (!pngData || !stdoutCtx || !activeProtocol) return
132
+ if (effectiveWidth <= 0 || effectiveHeight <= 0) return
133
+
134
+ const { write } = stdoutCtx
135
+
136
+ if (activeProtocol === "kitty") {
137
+ const seq = encodeKittyImage(pngData, {
138
+ width: effectiveWidth,
139
+ height: effectiveHeight,
140
+ id: imageIdRef.current ?? undefined,
141
+ })
142
+ write(seq)
143
+ } else if (activeProtocol === "sixel") {
144
+ // For Sixel, we would need the decoded pixel data.
145
+ // Since we receive PNG, and decoding PNG requires a library,
146
+ // Sixel rendering from raw PNG is deferred. The Kitty protocol
147
+ // can transmit PNG directly (f=100), but Sixel cannot.
148
+ // For now, Sixel only works if src is already decoded pixel data.
149
+ // This is a known limitation noted in the module docs.
150
+ //
151
+ // If someone passes a Buffer that's already RGBA pixel data
152
+ // (not PNG), this would need a flag. For now, Sixel falls through
153
+ // to fallback when src is PNG.
154
+ }
155
+ }, [pngData, stdoutCtx, activeProtocol, effectiveWidth, effectiveHeight])
156
+
157
+ // Cleanup: delete Kitty image on unmount
158
+ useEffect(() => {
159
+ const id = imageIdRef.current
160
+ if (activeProtocol !== "kitty" || id == null || !stdoutCtx) return
161
+
162
+ return () => {
163
+ stdoutCtx.write(deleteKittyImage(id))
164
+ }
165
+ }, [activeProtocol, stdoutCtx])
166
+
167
+ // If no protocol or no image data, render fallback text
168
+ if (!activeProtocol || !pngData) {
169
+ return (
170
+ <silvery-box width={effectiveWidth} height={effectiveHeight}>
171
+ <silvery-text>{fallback}</silvery-text>
172
+ </silvery-box>
173
+ )
174
+ }
175
+
176
+ // Reserve visual space with an empty box.
177
+ // The image is drawn over this space via stdout escape sequences.
178
+ // Fill with spaces so the cell buffer allocates the right area.
179
+ const spaceLine = " ".repeat(Math.max(0, effectiveWidth))
180
+ const spaceContent = Array.from({ length: Math.max(0, effectiveHeight) }, () => spaceLine).join("\n")
181
+
182
+ return (
183
+ <silvery-box width={effectiveWidth} height={effectiveHeight}>
184
+ <silvery-text>{spaceContent}</silvery-text>
185
+ </silvery-box>
186
+ )
187
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Image rendering support for silvery.
3
+ *
4
+ * Provides encoders for the Kitty graphics protocol and Sixel protocol,
5
+ * plus a React component for rendering images in terminal UIs.
6
+ */
7
+
8
+ export { encodeKittyImage, deleteKittyImage, isKittyGraphicsSupported } from "./kitty-graphics"
9
+ export type { KittyImageOptions } from "./kitty-graphics"
10
+
11
+ export { encodeSixel, isSixelSupported } from "./sixel-encoder"
12
+ export type { SixelImageData } from "./sixel-encoder"
13
+
14
+ export { Image } from "./Image"
15
+ export type { ImageProps } from "./Image"