@startsimpli/ui 0.4.43 → 0.4.44

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.
@@ -0,0 +1,369 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import type { Terminal as XTerm } from 'xterm'
5
+ import type { FitAddon } from '@xterm/addon-fit'
6
+ import { AlertCircle, RefreshCw, TerminalSquare, Wifi, WifiOff } from 'lucide-react'
7
+ import { cn } from '../../lib/utils'
8
+ import { TERMINAL_THEMES, type TerminalStatus, type TerminalTheme } from './themes'
9
+
10
+ /**
11
+ * Imperative handle the PARENT uses to drive the terminal. The parent owns the
12
+ * transport (a websocket, an SSE stream, …) and pumps inbound bytes in via
13
+ * `write()` — the terminal never re-renders per byte.
14
+ */
15
+ export interface TerminalHandle {
16
+ /** Write raw output (including ANSI escapes) to the terminal. */
17
+ write(data: string): void
18
+ /** Clear the screen + scrollback. */
19
+ clear(): void
20
+ /** Re-fit the terminal to its container. */
21
+ fit(): void
22
+ /** Move keyboard focus into the terminal. */
23
+ focus(): void
24
+ }
25
+
26
+ export interface TerminalProps {
27
+ /** xterm colour theme. Defaults to `TERMINAL_THEMES.dark`. */
28
+ theme?: TerminalTheme
29
+ fontFamily?: string
30
+ fontSize?: number
31
+ scrollback?: number
32
+ /** Connection status shown in the chrome's indicator (purely cosmetic). */
33
+ status?: TerminalStatus
34
+ /** Fired with keystrokes/data typed into the terminal. Wire to your transport. */
35
+ onData?: (data: string) => void
36
+ /** Fired when the terminal is resized (after a fit). Report to the PTY. */
37
+ onResize?: (cols: number, rows: number) => void
38
+ /** Fired once the terminal is initialised, handing back the imperative handle. */
39
+ onReady?: (handle: TerminalHandle) => void
40
+ /** Fired when the reconnect button is clicked (only shown when provided). */
41
+ onReconnect?: () => void
42
+ /** Show the header chrome (title, status indicator, reconnect). Default true. */
43
+ showChrome?: boolean
44
+ className?: string
45
+ /** Optional text written to the terminal once, right after init. */
46
+ banner?: string
47
+ }
48
+
49
+ type Loggable = unknown
50
+
51
+ function StatusIndicator({ status }: { status: TerminalStatus }) {
52
+ switch (status) {
53
+ case 'connected':
54
+ return (
55
+ <span className="flex items-center gap-1.5 text-emerald-500" role="status">
56
+ <Wifi className="h-4 w-4" aria-hidden="true" />
57
+ <span className="text-xs">Connected</span>
58
+ </span>
59
+ )
60
+ case 'connecting':
61
+ return (
62
+ <span className="flex items-center gap-1.5 text-muted-foreground" role="status">
63
+ <RefreshCw className="h-4 w-4 animate-spin" aria-hidden="true" />
64
+ <span className="text-xs">Connecting…</span>
65
+ </span>
66
+ )
67
+ case 'error':
68
+ return (
69
+ <span className="flex items-center gap-1.5 text-red-500" role="status">
70
+ <AlertCircle className="h-4 w-4" aria-hidden="true" />
71
+ <span className="text-xs">Error</span>
72
+ </span>
73
+ )
74
+ case 'disconnected':
75
+ default:
76
+ return (
77
+ <span className="flex items-center gap-1.5 text-muted-foreground" role="status">
78
+ <WifiOff className="h-4 w-4" aria-hidden="true" />
79
+ <span className="text-xs">Disconnected</span>
80
+ </span>
81
+ )
82
+ }
83
+ }
84
+
85
+ /**
86
+ * A presentational xterm.js terminal. Generic — it knows nothing about the
87
+ * transport, the session model, or any app. The parent feeds output through the
88
+ * `TerminalHandle` (via `onReady`) and receives keystrokes/resizes through
89
+ * `onData`/`onResize`.
90
+ *
91
+ * The defensive init sequence (DOM-ready wait, render-service wait, safe fit, and
92
+ * a React-StrictMode double-init guard) is ported verbatim from dev-hq's
93
+ * TerminalInterface — it exists for real timing bugs, not decoration. xterm is
94
+ * loaded via dynamic import so this module is SSR- and test-safe (importing it
95
+ * never touches `xterm` at module-eval time).
96
+ */
97
+ export function Terminal({
98
+ theme = TERMINAL_THEMES.dark,
99
+ fontFamily = 'Menlo, Monaco, "Courier New", monospace',
100
+ fontSize = 14,
101
+ scrollback = 10000,
102
+ status = 'disconnected',
103
+ onData,
104
+ onResize,
105
+ onReady,
106
+ onReconnect,
107
+ showChrome = true,
108
+ className,
109
+ banner,
110
+ }: TerminalProps) {
111
+ const containerRef = React.useRef<HTMLDivElement>(null)
112
+ const xtermRef = React.useRef<XTerm | null>(null)
113
+ const fitAddonRef = React.useRef<FitAddon | null>(null)
114
+ const isInitializedRef = React.useRef(false) // guard against StrictMode double-init
115
+ const isMountedRef = React.useRef(true)
116
+ const resizeTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
117
+
118
+ // Keep callbacks in refs so the (sessionUuid-less) mount effect stays stable and
119
+ // xterm is never re-created just because a parent re-renders with a new closure.
120
+ const onDataRef = React.useRef(onData)
121
+ const onResizeRef = React.useRef(onResize)
122
+ const onReadyRef = React.useRef(onReady)
123
+ onDataRef.current = onData
124
+ onResizeRef.current = onResize
125
+ onReadyRef.current = onReady
126
+
127
+ const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
128
+
129
+ // ---- defensive init helpers (ported from dev-hq, logging stripped) ----------
130
+
131
+ const waitForDOMReady = React.useCallback((): Promise<void> => {
132
+ return new Promise((resolve, reject) => {
133
+ const container = containerRef.current
134
+ if (!container) {
135
+ reject(new Error('Terminal container ref not available'))
136
+ return
137
+ }
138
+ if (container.offsetWidth > 0 && container.offsetHeight > 0) {
139
+ resolve()
140
+ return
141
+ }
142
+ const ro = new ResizeObserver((entries) => {
143
+ for (const entry of entries) {
144
+ if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
145
+ ro.disconnect()
146
+ resolve()
147
+ return
148
+ }
149
+ }
150
+ })
151
+ ro.observe(container)
152
+ setTimeout(() => {
153
+ ro.disconnect()
154
+ reject(new Error('Container dimensions timeout'))
155
+ }, 5000)
156
+ })
157
+ }, [])
158
+
159
+ // Poll until xterm's render service exists and the terminal is attached. Never
160
+ // throws on timeout — the terminal is usable and fit() retries on resize.
161
+ const waitForRenderService = React.useCallback(async (terminal: XTerm): Promise<void> => {
162
+ const maxAttempts = 10
163
+ const delay = 50
164
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
165
+ if (!isMountedRef.current) throw new Error('Component unmounted during initialization')
166
+ try {
167
+ const term = terminal as unknown as { _core?: { _renderService?: Loggable; element?: Loggable } }
168
+ if (term._core?._renderService && term._core?.element) return
169
+ } catch {
170
+ /* render service not ready — keep polling */
171
+ }
172
+ await new Promise((r) => setTimeout(r, delay))
173
+ }
174
+ }, [])
175
+
176
+ const safeFit = React.useCallback((fitAddon: FitAddon): void => {
177
+ if (!isMountedRef.current || !containerRef.current) return
178
+ try {
179
+ fitAddon.fit()
180
+ } catch {
181
+ /* fit failures are timing-related; retries happen on resize */
182
+ }
183
+ }, [])
184
+
185
+ const handleResize = React.useCallback(() => {
186
+ if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
187
+ resizeTimeoutRef.current = setTimeout(() => {
188
+ if (fitAddonRef.current && xtermRef.current && containerRef.current && isMountedRef.current) {
189
+ try {
190
+ fitAddonRef.current.fit()
191
+ } catch {
192
+ /* ignore — retries on next resize */
193
+ }
194
+ }
195
+ }, 150)
196
+ }, [])
197
+
198
+ // Mount / unmount: create + dispose xterm exactly once per mount.
199
+ React.useEffect(() => {
200
+ isMountedRef.current = true
201
+ let disposed = false
202
+ let cleanupCalled = false
203
+
204
+ const init = async () => {
205
+ if (!containerRef.current || xtermRef.current || isInitializedRef.current) return
206
+ isInitializedRef.current = true
207
+ try {
208
+ // Dynamic import keeps xterm out of the SSR/test module graph.
209
+ const [{ Terminal: XTermCtor }, { FitAddon: FitAddonCtor }] = await Promise.all([
210
+ import('xterm'),
211
+ import('@xterm/addon-fit'),
212
+ ])
213
+ if (!isMountedRef.current || disposed) return
214
+
215
+ await waitForDOMReady()
216
+ if (!isMountedRef.current || disposed) return
217
+
218
+ const terminal = new XTermCtor({
219
+ cursorBlink: true,
220
+ fontSize,
221
+ fontFamily,
222
+ fontWeight: '400',
223
+ fontWeightBold: '700',
224
+ lineHeight: 1.2,
225
+ letterSpacing: 0,
226
+ theme,
227
+ scrollback,
228
+ allowProposedApi: true,
229
+ allowTransparency: true,
230
+ windowsMode: false,
231
+ })
232
+ const fitAddon = new FitAddonCtor()
233
+ terminal.loadAddon(fitAddon)
234
+ terminal.open(containerRef.current)
235
+
236
+ xtermRef.current = terminal
237
+ fitAddonRef.current = fitAddon
238
+
239
+ try {
240
+ await waitForRenderService(terminal)
241
+ } catch {
242
+ /* continue — terminal still usable */
243
+ }
244
+ if (!isMountedRef.current || disposed) {
245
+ terminal.dispose()
246
+ return
247
+ }
248
+
249
+ safeFit(fitAddon)
250
+
251
+ terminal.onData((data: string) => onDataRef.current?.(data))
252
+ terminal.onResize(({ cols, rows }: { cols: number; rows: number }) =>
253
+ onResizeRef.current?.(cols, rows),
254
+ )
255
+
256
+ if (banner) terminal.writeln(banner)
257
+
258
+ const handle: TerminalHandle = {
259
+ write: (d) => terminal.write(d),
260
+ clear: () => terminal.clear(),
261
+ fit: () => safeFit(fitAddon),
262
+ focus: () => terminal.focus(),
263
+ }
264
+ onReadyRef.current?.(handle)
265
+ } catch (error) {
266
+ const err = error instanceof Error ? error : new Error('Failed to initialize terminal')
267
+ setErrorMessage(err.message)
268
+ isInitializedRef.current = false // allow retry
269
+ }
270
+ }
271
+
272
+ // Small delay so refs are set and the container is laid out.
273
+ const initTimer = setTimeout(init, 100)
274
+
275
+ return () => {
276
+ clearTimeout(initTimer)
277
+ if (cleanupCalled) return // StrictMode runs cleanup twice in dev
278
+ cleanupCalled = true
279
+ disposed = true
280
+ isMountedRef.current = false
281
+ if (resizeTimeoutRef.current) {
282
+ clearTimeout(resizeTimeoutRef.current)
283
+ resizeTimeoutRef.current = null
284
+ }
285
+ if (xtermRef.current) {
286
+ xtermRef.current.dispose()
287
+ xtermRef.current = null
288
+ }
289
+ fitAddonRef.current = null
290
+ isInitializedRef.current = false
291
+ }
292
+ // eslint-disable-next-line react-hooks/exhaustive-deps
293
+ }, [])
294
+
295
+ // Live-apply theme changes without re-creating the terminal.
296
+ React.useEffect(() => {
297
+ if (xtermRef.current) xtermRef.current.options.theme = theme
298
+ }, [theme])
299
+
300
+ React.useEffect(() => {
301
+ window.addEventListener('resize', handleResize)
302
+ return () => window.removeEventListener('resize', handleResize)
303
+ }, [handleResize])
304
+
305
+ const showReconnect = !!onReconnect && (status === 'disconnected' || status === 'error')
306
+
307
+ return (
308
+ <div className={cn('flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card', className)}>
309
+ {showChrome && (
310
+ <div className="flex flex-shrink-0 items-center justify-between border-b border-border bg-muted/40 px-4 py-2">
311
+ <div className="flex items-center gap-3">
312
+ <TerminalSquare className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
313
+ <span className="text-sm font-medium text-foreground">Terminal</span>
314
+ <StatusIndicator status={status} />
315
+ </div>
316
+ {showReconnect && (
317
+ <button
318
+ type="button"
319
+ onClick={onReconnect}
320
+ aria-label="Reconnect"
321
+ title="Reconnect"
322
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
323
+ >
324
+ <RefreshCw className="h-4 w-4" aria-hidden="true" />
325
+ </button>
326
+ )}
327
+ </div>
328
+ )}
329
+
330
+ {errorMessage && (
331
+ <div className="flex flex-shrink-0 items-center gap-2 border-b border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
332
+ <AlertCircle className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
333
+ <span>{errorMessage}</span>
334
+ </div>
335
+ )}
336
+
337
+ <div
338
+ ref={containerRef}
339
+ data-testid="terminal-container"
340
+ className="min-h-[300px] w-full flex-1 overflow-hidden"
341
+ style={{ backgroundColor: theme.background }}
342
+ />
343
+ </div>
344
+ )
345
+ }
346
+
347
+ /** Skeleton placeholder shown while a terminal surface is loading. */
348
+ export function TerminalSkeleton({ className }: { className?: string }) {
349
+ return (
350
+ <div className={cn('overflow-hidden rounded-lg border border-border bg-card', className)}>
351
+ <div className="flex items-center justify-between border-b border-border bg-muted/40 px-4 py-2">
352
+ <div className="flex items-center gap-3">
353
+ <div className="h-4 w-4 animate-pulse rounded bg-muted" />
354
+ <div className="h-4 w-20 animate-pulse rounded bg-muted" />
355
+ <div className="h-4 w-24 animate-pulse rounded bg-muted" />
356
+ </div>
357
+ </div>
358
+ <div className="space-y-2 p-4" style={{ minHeight: 300 }}>
359
+ {Array.from({ length: 10 }).map((_, i) => (
360
+ <div
361
+ key={i}
362
+ className="h-4 animate-pulse rounded bg-muted"
363
+ style={{ width: `${((i * 37) % 40) + 55}%` }}
364
+ />
365
+ ))}
366
+ </div>
367
+ </div>
368
+ )
369
+ }
@@ -0,0 +1,4 @@
1
+ export { Terminal, TerminalSkeleton } from './Terminal'
2
+ export type { TerminalProps, TerminalHandle } from './Terminal'
3
+ export { TERMINAL_THEMES } from './themes'
4
+ export type { TerminalTheme, TerminalStatus } from './themes'
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Terminal colour themes (ported from dev-hq `types/terminal.ts`, stripped of the
3
+ * void/plasma/neural "Post-Interface" palette). These are plain xterm theme objects
4
+ * — a consumer can pass any object of the same shape to `<Terminal theme={...} />`.
5
+ */
6
+
7
+ /** An xterm.js-compatible colour theme. Matches xterm's `ITheme` shape. */
8
+ export interface TerminalTheme {
9
+ background: string
10
+ foreground: string
11
+ cursor: string
12
+ cursorAccent: string
13
+ selectionBackground: string
14
+ selectionForeground: string
15
+ // Standard ANSI colours
16
+ black: string
17
+ red: string
18
+ green: string
19
+ yellow: string
20
+ blue: string
21
+ magenta: string
22
+ cyan: string
23
+ white: string
24
+ // Bright ANSI colours
25
+ brightBlack: string
26
+ brightRed: string
27
+ brightGreen: string
28
+ brightYellow: string
29
+ brightBlue: string
30
+ brightMagenta: string
31
+ brightCyan: string
32
+ brightWhite: string
33
+ }
34
+
35
+ /**
36
+ * A small map of neutral, framework-agnostic terminal themes. `dark` is the default
37
+ * (a classic VS Code-style dark palette) — the app-specific dev-hq palette is gone.
38
+ */
39
+ export const TERMINAL_THEMES: { dark: TerminalTheme; light: TerminalTheme } = {
40
+ dark: {
41
+ background: '#1E1E1E',
42
+ foreground: '#D4D4D4',
43
+ cursor: '#FFFFFF',
44
+ cursorAccent: '#1E1E1E',
45
+ selectionBackground: '#264F78',
46
+ selectionForeground: '#FFFFFF',
47
+ black: '#000000',
48
+ red: '#CD3131',
49
+ green: '#0DBC79',
50
+ yellow: '#E5E510',
51
+ blue: '#2472C8',
52
+ magenta: '#BC3FBC',
53
+ cyan: '#11A8CD',
54
+ white: '#E5E5E5',
55
+ brightBlack: '#666666',
56
+ brightRed: '#F14C4C',
57
+ brightGreen: '#23D18B',
58
+ brightYellow: '#F5F543',
59
+ brightBlue: '#3B8EEA',
60
+ brightMagenta: '#D670D6',
61
+ brightCyan: '#29B8DB',
62
+ brightWhite: '#FFFFFF',
63
+ },
64
+ light: {
65
+ background: '#FFFFFF',
66
+ foreground: '#333333',
67
+ cursor: '#000000',
68
+ cursorAccent: '#FFFFFF',
69
+ selectionBackground: '#ADD6FF',
70
+ selectionForeground: '#000000',
71
+ black: '#000000',
72
+ red: '#CD3131',
73
+ green: '#00BC00',
74
+ yellow: '#949800',
75
+ blue: '#0451A5',
76
+ magenta: '#BC05BC',
77
+ cyan: '#0598BC',
78
+ white: '#555555',
79
+ brightBlack: '#666666',
80
+ brightRed: '#CD3131',
81
+ brightGreen: '#14CE14',
82
+ brightYellow: '#B5BA00',
83
+ brightBlue: '#0451A5',
84
+ brightMagenta: '#BC05BC',
85
+ brightCyan: '#0598BC',
86
+ brightWhite: '#A5A5A5',
87
+ },
88
+ }
89
+
90
+ /** Connection state a consumer feeds into the terminal chrome's status indicator. */
91
+ export type TerminalStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Re-export of xterm.js base styles so consumers can pull them from the package
3
+ * (`import '@startsimpli/ui/terminal/styles'`) instead of reaching into
4
+ * node_modules. `xterm` is a direct dependency of @startsimpli/ui, so this bare
5
+ * import resolves in any bundler that resolves CSS @import from node_modules
6
+ * (Next.js, Vite, webpack css-loader).
7
+ */
8
+ @import 'xterm/css/xterm.css';