@spunto/design-system 0.9.1 → 0.9.3

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/README.md CHANGED
@@ -54,7 +54,15 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
54
54
  - _Terminal_ — `Terminal` (+`TerminalHandle`, `TerminalOptions`), a transport-agnostic
55
55
  xterm.js surface: mount, shared dark ANSI theme, fit-on-resize. No opinion on where
56
56
  bytes come from (WebSocket, SSE, a static string) — feed it via the `write`/`writeln`
57
- ref handle. Opt-in addons through `options` (currently `webLinks`, clickable URLs).
57
+ ref handle. Opt-in addons through `options` (`webLinks` for clickable URLs, `search`
58
+ for the find API). The handle also reads the buffer back (`getContent`, for copy/
59
+ download) and reports viewport movement (`onViewportChange`, for tail following).
60
+ - _TerminalPanel_ — `TerminalPanel` (+`TerminalPanelStatus`), the **frame** around a
61
+ `Terminal`: a composable bar (title, mono subtitle, status dot, reconnect, free
62
+ `actions` slot) and the states pages used to re-improvise — `placeholder`/`loading`
63
+ instead of an ad-hoc empty box, `status` instead of `[connection closed]` written in
64
+ ANSI into the log. Opt-in bar tools, all off by default: `search`, `followTail`,
65
+ `copy`, `download`. Its ref *is* the wrapped `TerminalHandle`.
58
66
  - _ImagePull_ — `ImagePull` (+`PullLayer`, `PullLayerStatus`, `ImagePullState`), a Docker
59
67
  image pull drawn as a real UI instead of ANSI art redrawn in a terminal: **one** overall
60
68
  bar whose colour follows the phase (flame downloading → amber unpacking over it → solid
@@ -167,7 +175,7 @@ automatically (see `showcase/vite-plugin-llms.ts`).
167
175
  ## Peer requirements
168
176
 
169
177
  - React 19+, and a **Tailwind CSS v4** pipeline in the consuming app.
170
- - `lucide-react` is a **regular dependency** (since `0.9.0`): the worker
178
+ - `lucide-react` is a **regular dependency** (since `0.9.3`): the worker
171
179
  components use ~15 icons, and inlining them as SVG or passing them all as props
172
180
  would have been worse than the dependency. It's tree-shakable and both apps
173
181
  already ship it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@xterm/addon-fit": "^0.11.0",
56
+ "@xterm/addon-search": "^0.16.0",
56
57
  "@xterm/addon-web-links": "^0.12.0",
57
58
  "@xterm/xterm": "^6.0.0",
58
59
  "class-variance-authority": "^0.7.1",
@@ -0,0 +1,695 @@
1
+ "use client"
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react"
13
+
14
+ import { cn } from "../utils"
15
+ import { Spinner } from "./spinner"
16
+ import { Terminal, type TerminalHandle, type TerminalOptions, type TerminalViewportState } from "./terminal"
17
+
18
+ // Same surface as `Terminal` and `ImagePull` (#09090b), on purpose: an ImagePull
19
+ // stacked on a TerminalPanel has to read as *one* object — that's the build view.
20
+ // The bar is not a lighter "chrome" strip; it's the same surface, separated by a
21
+ // single hairline, so the eye sees one panel rather than a widget with a header.
22
+ const panelTheme = {
23
+ background: "#09090b",
24
+ border: "#27272a",
25
+ /** Hairline between the bar and the terminal — dimmer than the outer border. */
26
+ divider: "#1f1f23",
27
+ text: "#e4e4e7",
28
+ dim: "#a1a1aa",
29
+ faint: "#71717a",
30
+ // Status hues, taken from the Terminal's shared ANSI palette so a green dot up
31
+ // here and a green line down there are the same green.
32
+ live: "#4ade80",
33
+ pending: "#facc15",
34
+ error: "#f87171",
35
+ idle: "#52525b",
36
+ accent: "#ea5400",
37
+ } as const
38
+
39
+ /**
40
+ * Connection/lifecycle state of whatever feeds the terminal.
41
+ *
42
+ * The first four are the stream states every log panel already tracked by hand;
43
+ * the last three are build/job states (the worker page draws its own pulsing
44
+ * yellow/green/red dot for exactly this). They share one prop because they share
45
+ * one slot in the bar — a panel is never both connecting *and* building.
46
+ */
47
+ export type TerminalPanelStatus =
48
+ | "idle"
49
+ | "connecting"
50
+ | "live"
51
+ | "closed"
52
+ | "error"
53
+ | "running"
54
+ | "success"
55
+ | "failed"
56
+
57
+ export interface TerminalPanelProps {
58
+ /** Left of the bar. Sans-serif, the human name of what you're looking at. */
59
+ title?: ReactNode
60
+ /** Next to the title, mono and dimmed — `stdout / stderr`, a service name, a session. */
61
+ subtitle?: ReactNode
62
+ status?: TerminalPanelStatus
63
+ /** Overrides the default wording of `status` (which is French, like the rest of the DS). */
64
+ statusLabel?: string
65
+ /**
66
+ * Shows the reconnect button. This is the one action the DS knows about,
67
+ * because every panel already had its own copy of it.
68
+ */
69
+ onReconnect?: () => void
70
+ /** Free slot at the right of the bar — fullscreen, details, whatever the page owns. */
71
+ actions?: ReactNode
72
+ /**
73
+ * Replaces the terminal surface entirely: "aucun conteneur", "Pulling image…".
74
+ * The terminal is *unmounted* while it shows, so anything written meanwhile is
75
+ * dropped — which is the point: there is nothing to write to yet.
76
+ */
77
+ placeholder?: ReactNode
78
+ /** Puts a spinner above `placeholder` — the "…en cours" flavour of empty. */
79
+ loading?: boolean
80
+ /** Red band under the bar. Unlike `placeholder`, it keeps the log visible underneath. */
81
+ error?: string
82
+
83
+ // ── Bar options. All off by default: the bar starts sober, each page opts in. ──
84
+ /** Ctrl/⌘+F search bar over the scrollback (`@xterm/addon-search`). */
85
+ search?: boolean
86
+ /** Sticks to the tail and surfaces a "N nouvelles lignes ↓" pill once the user scrolls up. */
87
+ followTail?: boolean
88
+ /** Copies the whole buffer to the clipboard. */
89
+ copy?: boolean
90
+ /** Downloads the whole buffer as a `.log` file. */
91
+ download?: boolean
92
+ /** File name used by `download`. Defaults to the title/subtitle, else `terminal.log`. */
93
+ downloadFileName?: string
94
+
95
+ // ── Forwarded to the underlying `Terminal`. ──
96
+ fontSize?: number
97
+ scrollback?: number
98
+ onData?: (data: string) => void
99
+ onResize?: (size: { cols: number; rows: number }) => void
100
+ options?: TerminalOptions
101
+ className?: string
102
+ }
103
+
104
+ const STATUS_LABEL: Record<TerminalPanelStatus, string> = {
105
+ idle: "",
106
+ connecting: "connexion…",
107
+ live: "en direct",
108
+ closed: "déconnecté",
109
+ error: "erreur",
110
+ running: "en cours",
111
+ success: "terminé",
112
+ failed: "échec",
113
+ }
114
+
115
+ const STATUS_COLOR: Record<TerminalPanelStatus, string> = {
116
+ idle: panelTheme.idle,
117
+ connecting: panelTheme.pending,
118
+ live: panelTheme.live,
119
+ closed: panelTheme.idle,
120
+ error: panelTheme.error,
121
+ running: panelTheme.pending,
122
+ success: panelTheme.live,
123
+ failed: panelTheme.error,
124
+ }
125
+
126
+ /** The states where something is still happening — the dot pulses for those only. */
127
+ const PULSING: TerminalPanelStatus[] = ["connecting", "running"]
128
+
129
+ /**
130
+ * The frame around a `Terminal`: a bar on top, the terminal surface below, and
131
+ * the states (connecting, empty, loading, error) held by the component instead of
132
+ * re-improvised on every page.
133
+ *
134
+ * It exists because the frame *was* the duplicated part. The primitive is fine —
135
+ * every page kept it and rebuilt the same header around it: a title, a status
136
+ * dot, a reconnect button, an "aucun conteneur" placeholder, each with its own
137
+ * hardcoded hex and padding. Two pages, two looks, one intention.
138
+ *
139
+ * Two design decisions worth knowing before extending it:
140
+ *
141
+ * 1. **The bar is composable, not a fixed list.** Title/subtitle/status are the
142
+ * sober default; everything else (search, follow, copy, download) is opt-in,
143
+ * and `actions` is an open slot so a page can push its own buttons without
144
+ * the DS having to know about them.
145
+ * 2. **Connection status is a prop, not log content.** Panels used to write
146
+ * `[connection closed]` in ANSI straight into the buffer — which pollutes the
147
+ * log, travels with a copy/paste, and stacks up one line per reconnect since
148
+ * nothing ever erases it. `status` puts that in the bar, where it can be
149
+ * *replaced* instead of appended.
150
+ *
151
+ * The ref is the `TerminalHandle` of the terminal it wraps, so migrating a panel
152
+ * is a one-line swap: `<Terminal ref={r} …/>` → `<TerminalPanel ref={r} …/>`.
153
+ */
154
+ const TerminalPanel = forwardRef<TerminalHandle, TerminalPanelProps>(function TerminalPanel(
155
+ {
156
+ title,
157
+ subtitle,
158
+ status = "idle",
159
+ statusLabel,
160
+ onReconnect,
161
+ actions,
162
+ placeholder,
163
+ loading,
164
+ error,
165
+ search,
166
+ followTail,
167
+ copy,
168
+ download,
169
+ downloadFileName,
170
+ fontSize,
171
+ scrollback,
172
+ onData,
173
+ onResize,
174
+ options,
175
+ className,
176
+ },
177
+ ref
178
+ ) {
179
+ const termRef = useRef<TerminalHandle>(null)
180
+ const rootRef = useRef<HTMLDivElement>(null)
181
+
182
+ // Everything the ref exposes goes through the inner terminal — including while
183
+ // it's replaced by a placeholder, where the calls simply no-op.
184
+ useImperativeHandle(ref, () => ({
185
+ write: (data) => termRef.current?.write(data),
186
+ writeln: (data) => termRef.current?.writeln(data),
187
+ clear: () => termRef.current?.clear(),
188
+ focus: () => termRef.current?.focus(),
189
+ getContent: () => termRef.current?.getContent() ?? "",
190
+ scrollToBottom: () => termRef.current?.scrollToBottom(),
191
+ rowsBelow: () => termRef.current?.rowsBelow() ?? 0,
192
+ findNext: (query, opts) => termRef.current?.findNext(query, opts) ?? false,
193
+ findPrevious: (query, opts) => termRef.current?.findPrevious(query, opts) ?? false,
194
+ clearSearch: () => termRef.current?.clearSearch(),
195
+ }))
196
+
197
+ const searchUi = useSearch(termRef, !!search, rootRef)
198
+ const tail = useFollowTail(termRef, !!followTail)
199
+
200
+ const showPlaceholder = placeholder != null || loading
201
+ const hasBar = title != null || subtitle != null || status !== "idle" || !!actions || !!onReconnect || !!search || !!copy || !!download
202
+
203
+ return (
204
+ <div
205
+ ref={rootRef}
206
+ data-slot="terminal-panel"
207
+ className={cn(
208
+ // Square by default like `Terminal`/`ImagePull` — consumers add
209
+ // `rounded-*` (with the `overflow-hidden` already here) when they want it.
210
+ // `@container`: what has to fit is this box, not the viewport — a 380 px
211
+ // panel on a desktop is exactly as cramped as a phone.
212
+ "@container flex h-full w-full min-h-0 min-w-0 flex-col overflow-hidden border",
213
+ className
214
+ )}
215
+ style={{
216
+ backgroundColor: panelTheme.background,
217
+ borderColor: error ? "#7f1d1d" : panelTheme.border,
218
+ // Sets the default for everything inline (icons draw with currentColor) —
219
+ // without it they'd inherit the *page's* foreground, which is near-black
220
+ // in the light theme this dark panel sits in.
221
+ color: panelTheme.text,
222
+ }}
223
+ >
224
+ {hasBar && (
225
+ <div
226
+ className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs"
227
+ style={{ borderColor: panelTheme.divider }}
228
+ >
229
+ {status !== "idle" && <StatusChip status={status} label={statusLabel} />}
230
+ {title != null && (
231
+ <span className="min-w-0 truncate font-medium" style={{ color: panelTheme.text }}>
232
+ {title}
233
+ </span>
234
+ )}
235
+ {subtitle != null && (
236
+ <span className="hidden min-w-0 truncate font-mono @sm:inline" style={{ color: panelTheme.faint }}>
237
+ {subtitle}
238
+ </span>
239
+ )}
240
+
241
+ <div className="ml-auto flex shrink-0 items-center gap-0.5">
242
+ {actions}
243
+ {search && (
244
+ <BarButton label="Rechercher (Ctrl+F)" active={searchUi.open} onClick={searchUi.toggle}>
245
+ <SearchIcon />
246
+ </BarButton>
247
+ )}
248
+ {copy && <CopyButton getContent={() => termRef.current?.getContent() ?? ""} />}
249
+ {download && (
250
+ <BarButton
251
+ label="Télécharger le log"
252
+ onClick={() =>
253
+ downloadText(termRef.current?.getContent() ?? "", downloadFileName ?? defaultFileName(title, subtitle))
254
+ }
255
+ >
256
+ <DownloadIcon />
257
+ </BarButton>
258
+ )}
259
+ {onReconnect && (
260
+ <BarButton label="Reconnecter" onClick={onReconnect}>
261
+ <RefreshIcon />
262
+ </BarButton>
263
+ )}
264
+ </div>
265
+ </div>
266
+ )}
267
+
268
+ {search && searchUi.open && <SearchBar {...searchUi} />}
269
+
270
+ {error && (
271
+ <div
272
+ className="flex shrink-0 items-start gap-2 border-b px-3 py-2 font-mono text-xs leading-relaxed"
273
+ style={{ borderColor: "#7f1d1d", backgroundColor: "rgba(127,29,29,0.15)", color: panelTheme.error }}
274
+ >
275
+ <span className="shrink-0 pt-px">✕</span>
276
+ <span className="min-w-0 break-words">{error}</span>
277
+ </div>
278
+ )}
279
+
280
+ <div className="relative min-h-0 flex-1">
281
+ {showPlaceholder ? (
282
+ <div className="flex h-full flex-col items-center justify-center gap-3 px-4 text-center">
283
+ {loading && <Spinner size="sm" style={{ color: panelTheme.faint }} />}
284
+ <p className="font-mono text-xs" style={{ color: panelTheme.faint }}>
285
+ {placeholder}
286
+ </p>
287
+ </div>
288
+ ) : (
289
+ <>
290
+ <Terminal
291
+ ref={termRef}
292
+ // The panel already draws the border and owns the corners.
293
+ className="border-0"
294
+ fontSize={fontSize}
295
+ scrollback={scrollback}
296
+ onData={onData}
297
+ onResize={onResize}
298
+ options={search ? { ...options, search: true } : options}
299
+ onViewportChange={followTail ? tail.onViewportChange : undefined}
300
+ onSearchResults={search ? searchUi.onResults : undefined}
301
+ />
302
+ {followTail && tail.detached && (
303
+ <button
304
+ type="button"
305
+ onClick={() => {
306
+ termRef.current?.scrollToBottom()
307
+ termRef.current?.focus()
308
+ }}
309
+ // Right-aligned but clear of xterm's scrollbar, and floating over
310
+ // the log rather than reserving a row — it's a transient hint.
311
+ className="absolute bottom-3 right-5 flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px] shadow-lg transition-colors"
312
+ style={{
313
+ backgroundColor: "#18181b",
314
+ borderColor: panelTheme.border,
315
+ color: tail.missed > 0 ? panelTheme.text : panelTheme.dim,
316
+ }}
317
+ >
318
+ {tail.missed > 0 ? `${tail.missed} nouvelle${tail.missed > 1 ? "s" : ""} ligne${tail.missed > 1 ? "s" : ""}` : "Revenir en bas"}
319
+ <ArrowDownIcon />
320
+ </button>
321
+ )}
322
+ </>
323
+ )}
324
+ </div>
325
+ </div>
326
+ )
327
+ })
328
+
329
+ /** Dot + word. The dot alone carries the state when the container is narrow. */
330
+ function StatusChip({ status, label }: { status: TerminalPanelStatus; label?: string }) {
331
+ const color = STATUS_COLOR[status]
332
+ const text = label ?? STATUS_LABEL[status]
333
+ return (
334
+ <span className="flex shrink-0 items-center gap-1.5" title={text}>
335
+ <span
336
+ // `motion-reduce`: the dot keeps its colour, it just stops breathing —
337
+ // the state is still readable without the animation.
338
+ className={cn("size-1.5 shrink-0 rounded-full", PULSING.includes(status) && "animate-pulse motion-reduce:animate-none")}
339
+ style={{ backgroundColor: color }}
340
+ />
341
+ <span className="hidden font-mono @md:inline" style={{ color }}>
342
+ {text}
343
+ </span>
344
+ </span>
345
+ )
346
+ }
347
+
348
+ function BarButton({
349
+ label,
350
+ active,
351
+ onClick,
352
+ children,
353
+ }: {
354
+ label: string
355
+ active?: boolean
356
+ onClick: () => void
357
+ children: ReactNode
358
+ }) {
359
+ return (
360
+ <button
361
+ type="button"
362
+ title={label}
363
+ aria-label={label}
364
+ onClick={onClick}
365
+ className="flex size-6 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/5"
366
+ style={{ color: active ? panelTheme.accent : panelTheme.faint }}
367
+ >
368
+ {children}
369
+ </button>
370
+ )
371
+ }
372
+
373
+ /** Copy with its own "done" beat — a clipboard write is invisible otherwise. */
374
+ function CopyButton({ getContent }: { getContent: () => string }) {
375
+ const [copied, setCopied] = useState(false)
376
+
377
+ useEffect(() => {
378
+ if (!copied) return
379
+ const id = setTimeout(() => setCopied(false), 1500)
380
+ return () => clearTimeout(id)
381
+ }, [copied])
382
+
383
+ return (
384
+ <BarButton
385
+ label={copied ? "Copié" : "Copier tout le log"}
386
+ onClick={async () => {
387
+ if (await copyText(getContent())) setCopied(true)
388
+ }}
389
+ >
390
+ <span style={copied ? { color: panelTheme.live } : undefined}>{copied ? <CheckIcon /> : <CopyIcon />}</span>
391
+ </BarButton>
392
+ )
393
+ }
394
+
395
+ interface SearchUi {
396
+ open: boolean
397
+ toggle: () => void
398
+ close: () => void
399
+ query: string
400
+ setQuery: (q: string) => void
401
+ results: { resultIndex: number; resultCount: number }
402
+ onResults: (r: { resultIndex: number; resultCount: number }) => void
403
+ next: () => void
404
+ previous: () => void
405
+ inputRef: React.RefObject<HTMLInputElement | null>
406
+ }
407
+
408
+ /** Its own row under the bar rather than an overlay — a log is dense, and floating
409
+ * search boxes end up hiding the match they just found. */
410
+ function SearchBar({ query, setQuery, results, next, previous, close, inputRef }: SearchUi) {
411
+ const empty = query.length > 0 && results.resultCount === 0
412
+ return (
413
+ <div
414
+ className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
415
+ style={{ borderColor: panelTheme.divider, backgroundColor: "#0c0c10", color: panelTheme.faint }}
416
+ >
417
+ <SearchIcon />
418
+ <input
419
+ ref={inputRef}
420
+ value={query}
421
+ onChange={(e) => setQuery(e.target.value)}
422
+ onKeyDown={(e) => {
423
+ if (e.key === "Enter") {
424
+ e.preventDefault()
425
+ if (e.shiftKey) previous()
426
+ else next()
427
+ } else if (e.key === "Escape") {
428
+ e.preventDefault()
429
+ close()
430
+ }
431
+ }}
432
+ placeholder="Rechercher dans le log…"
433
+ spellCheck={false}
434
+ className="min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:opacity-60"
435
+ style={{ color: empty ? panelTheme.error : panelTheme.text }}
436
+ />
437
+ <span className="shrink-0 font-mono text-[11px] tabular-nums" style={{ color: empty ? panelTheme.error : panelTheme.faint }}>
438
+ {query.length === 0 ? "" : results.resultCount === 0 ? "aucun résultat" : `${results.resultIndex + 1}/${results.resultCount}`}
439
+ </span>
440
+ <BarButton label="Résultat précédent (Maj+Entrée)" onClick={previous}>
441
+ <ChevronIcon up />
442
+ </BarButton>
443
+ <BarButton label="Résultat suivant (Entrée)" onClick={next}>
444
+ <ChevronIcon />
445
+ </BarButton>
446
+ <BarButton label="Fermer la recherche (Échap)" onClick={close}>
447
+ <CloseIcon />
448
+ </BarButton>
449
+ </div>
450
+ )
451
+ }
452
+
453
+ /**
454
+ * Search state + the Ctrl/⌘+F binding. The shortcut is bound on the panel root in
455
+ * the capture phase: xterm owns the keyboard once focused, so waiting for the
456
+ * event to bubble means never seeing it.
457
+ */
458
+ function useSearch(termRef: React.RefObject<TerminalHandle | null>, enabled: boolean, rootRef: React.RefObject<HTMLDivElement | null>): SearchUi {
459
+ const [open, setOpen] = useState(false)
460
+ const [query, setQueryState] = useState("")
461
+ const [results, setResults] = useState({ resultIndex: -1, resultCount: 0 })
462
+ const inputRef = useRef<HTMLInputElement>(null)
463
+
464
+ const close = useCallback(() => {
465
+ setOpen(false)
466
+ setQueryState("")
467
+ setResults({ resultIndex: -1, resultCount: 0 })
468
+ termRef.current?.clearSearch()
469
+ termRef.current?.focus()
470
+ }, [termRef])
471
+
472
+ const toggle = useCallback(() => {
473
+ setOpen((v) => {
474
+ if (v) {
475
+ termRef.current?.clearSearch()
476
+ return false
477
+ }
478
+ return true
479
+ })
480
+ }, [termRef])
481
+
482
+ useEffect(() => {
483
+ if (open) inputRef.current?.focus()
484
+ }, [open])
485
+
486
+ useEffect(() => {
487
+ const root = rootRef.current
488
+ if (!enabled || !root) return
489
+ function onKeyDown(e: KeyboardEvent) {
490
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") {
491
+ e.preventDefault()
492
+ e.stopPropagation()
493
+ setOpen(true)
494
+ // Already open: re-select so a second Ctrl+F starts a new query.
495
+ inputRef.current?.select()
496
+ }
497
+ }
498
+ root.addEventListener("keydown", onKeyDown, true)
499
+ return () => root.removeEventListener("keydown", onKeyDown, true)
500
+ }, [enabled, rootRef])
501
+
502
+ const setQuery = useCallback(
503
+ (q: string) => {
504
+ setQueryState(q)
505
+ if (q) {
506
+ // `incremental` keeps the match anchored where you are while you type,
507
+ // instead of walking forward one match per keystroke.
508
+ termRef.current?.findNext(q, { incremental: true })
509
+ } else {
510
+ termRef.current?.clearSearch()
511
+ setResults({ resultIndex: -1, resultCount: 0 })
512
+ }
513
+ },
514
+ [termRef]
515
+ )
516
+
517
+ return {
518
+ open,
519
+ toggle,
520
+ close,
521
+ query,
522
+ setQuery,
523
+ results,
524
+ onResults: setResults,
525
+ next: () => query && termRef.current?.findNext(query),
526
+ previous: () => query && termRef.current?.findPrevious(query),
527
+ inputRef,
528
+ }
529
+ }
530
+
531
+ /**
532
+ * "Am I still at the tail, and how much have I missed?"
533
+ *
534
+ * The counter is *lines since the viewport last touched the bottom* — the reason
535
+ * flag from the terminal is what makes that computable: output events increment,
536
+ * and any moment spent at the bottom (scrolled back, or never left) resets. A busy
537
+ * stream fires this per line, so the count accumulates in a ref and only lands in
538
+ * state once per frame.
539
+ */
540
+ function useFollowTail(termRef: React.RefObject<TerminalHandle | null>, enabled: boolean) {
541
+ const [state, setState] = useState({ detached: false, missed: 0 })
542
+ const pending = useRef({ detached: false, missed: 0 })
543
+ const frame = useRef<number | null>(null)
544
+
545
+ useEffect(
546
+ () => () => {
547
+ if (frame.current != null) cancelAnimationFrame(frame.current)
548
+ },
549
+ []
550
+ )
551
+
552
+ const onViewportChange = useCallback((v: TerminalViewportState) => {
553
+ const p = pending.current
554
+ if (v.rowsBelow === 0) p.missed = 0
555
+ else if (v.reason === "output") p.missed += 1
556
+ p.detached = v.rowsBelow > 0
557
+
558
+ if (frame.current != null) return
559
+ frame.current = requestAnimationFrame(() => {
560
+ frame.current = null
561
+ setState((prev) =>
562
+ prev.detached === pending.current.detached && prev.missed === pending.current.missed
563
+ ? prev
564
+ : { ...pending.current }
565
+ )
566
+ })
567
+ }, [])
568
+
569
+ return useMemo(
570
+ () => ({ ...state, onViewportChange: enabled ? onViewportChange : undefined }),
571
+ [state, enabled, onViewportChange]
572
+ )
573
+ }
574
+
575
+ /** Clipboard API, with the `execCommand` fallback for non-secure contexts. */
576
+ async function copyText(text: string): Promise<boolean> {
577
+ try {
578
+ await navigator.clipboard.writeText(text)
579
+ return true
580
+ } catch {
581
+ try {
582
+ const area = document.createElement("textarea")
583
+ area.value = text
584
+ area.style.position = "fixed"
585
+ area.style.opacity = "0"
586
+ document.body.appendChild(area)
587
+ area.select()
588
+ const ok = document.execCommand("copy")
589
+ document.body.removeChild(area)
590
+ return ok
591
+ } catch {
592
+ return false
593
+ }
594
+ }
595
+ }
596
+
597
+ function downloadText(text: string, fileName: string) {
598
+ const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }))
599
+ const link = document.createElement("a")
600
+ link.href = url
601
+ link.download = fileName
602
+ link.click()
603
+ URL.revokeObjectURL(url)
604
+ }
605
+
606
+ /** `service api — stdout / stderr` → `service-api-stdout-stderr.log`. */
607
+ function defaultFileName(title: ReactNode, subtitle: ReactNode): string {
608
+ const parts = [title, subtitle].filter((p) => typeof p === "string") as string[]
609
+ const slug = parts
610
+ .join("-")
611
+ .toLowerCase()
612
+ .normalize("NFD")
613
+ .replace(/[\u0300-\u036f]/g, "")
614
+ .replace(/[^a-z0-9]+/g, "-")
615
+ .replace(/^-|-$/g, "")
616
+ return `${slug || "terminal"}.log`
617
+ }
618
+
619
+ // ── Icons. Inlined like `ImagePull`'s: the design system doesn't take an icon
620
+ // dependency, and these five never leave this file. ──
621
+
622
+ function SearchIcon() {
623
+ return (
624
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
625
+ <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
626
+ <path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
627
+ </svg>
628
+ )
629
+ }
630
+
631
+ function CopyIcon() {
632
+ return (
633
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
634
+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.4" />
635
+ <path d="M10.5 2.5h-8v8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
636
+ </svg>
637
+ )
638
+ }
639
+
640
+ function CheckIcon() {
641
+ return (
642
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
643
+ <path d="m3.5 8.5 3 3 6-7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
644
+ </svg>
645
+ )
646
+ }
647
+
648
+ function DownloadIcon() {
649
+ return (
650
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
651
+ <path d="M8 2.5v7m0 0L5 6.7M8 9.5l3-2.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
652
+ <path d="M2.8 12.5h10.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
653
+ </svg>
654
+ )
655
+ }
656
+
657
+ function RefreshIcon() {
658
+ return (
659
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
660
+ <path
661
+ d="M13 8a5 5 0 1 1-1.6-3.7"
662
+ stroke="currentColor"
663
+ strokeWidth="1.5"
664
+ strokeLinecap="round"
665
+ />
666
+ <path d="M13.2 2v3h-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
667
+ </svg>
668
+ )
669
+ }
670
+
671
+ function ChevronIcon({ up }: { up?: boolean }) {
672
+ return (
673
+ <svg viewBox="0 0 16 16" fill="none" className={cn("size-3.5 shrink-0", up && "rotate-180")} aria-hidden>
674
+ <path d="m4 6.5 4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
675
+ </svg>
676
+ )
677
+ }
678
+
679
+ function ArrowDownIcon() {
680
+ return (
681
+ <svg viewBox="0 0 16 16" fill="none" className="size-3 shrink-0" aria-hidden>
682
+ <path d="M8 3v9m0 0 3.5-3.5M8 12 4.5 8.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
683
+ </svg>
684
+ )
685
+ }
686
+
687
+ function CloseIcon() {
688
+ return (
689
+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0" aria-hidden>
690
+ <path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
691
+ </svg>
692
+ )
693
+ }
694
+
695
+ export { TerminalPanel }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
4
4
  import type { Terminal as XTerm } from "@xterm/xterm"
5
+ import type { SearchAddon } from "@xterm/addon-search"
5
6
  // xterm.js's own stylesheet — hides its input-capture textarea and glyph
6
7
  // measurement helpers. A JS-side import (not a CSS `@import`) so it loads
7
8
  // regardless of the consuming app's stylesheet pipeline/ordering.
@@ -34,17 +35,55 @@ const terminalTheme = {
34
35
  brightWhite: "#f4f4f5",
35
36
  }
36
37
 
38
+ export interface TerminalSearchOptions {
39
+ caseSensitive?: boolean
40
+ wholeWord?: boolean
41
+ regex?: boolean
42
+ /** Re-runs from the current match rather than the next one — for search-as-you-type. */
43
+ incremental?: boolean
44
+ }
45
+
46
+ /** What `onViewportChange` reports. See that prop for why both fields are needed. */
47
+ export interface TerminalViewportState {
48
+ /** Rows between the bottom of the viewport and the end of the buffer. 0 = stuck to the tail. */
49
+ rowsBelow: number
50
+ /** `"output"` = a new line was printed · `"scroll"` = the viewport itself moved. */
51
+ reason: "output" | "scroll"
52
+ }
53
+
37
54
  export interface TerminalHandle {
38
55
  /** Writes raw bytes/text (ANSI sequences included) to the terminal. */
39
56
  write: (data: string | Uint8Array) => void
40
57
  writeln: (data: string) => void
41
58
  clear: () => void
42
59
  focus: () => void
60
+ /**
61
+ * The whole scrollback as plain text — ANSI stripped, rows that xterm split at
62
+ * the viewport width rejoined into the single line they were written as. This
63
+ * is what "copy everything" / "download the log" need, and neither can be done
64
+ * from outside: only xterm holds the buffer.
65
+ */
66
+ getContent: () => string
67
+ /** Jumps the viewport back to the tail of the buffer (the "follow" button). */
68
+ scrollToBottom: () => void
69
+ /** Same number as `TerminalViewportState.rowsBelow`, polled instead of pushed. */
70
+ rowsBelow: () => number
71
+ /**
72
+ * Search, delegated to `@xterm/addon-search`. No-ops (returns false) unless
73
+ * `options.search` was set at mount — the addon isn't loaded otherwise.
74
+ * Match highlighting/colors are handled inside; callers only pass the query.
75
+ */
76
+ findNext: (query: string, options?: TerminalSearchOptions) => boolean
77
+ findPrevious: (query: string, options?: TerminalSearchOptions) => boolean
78
+ /** Drops the highlights — call it when the search UI closes. */
79
+ clearSearch: () => void
43
80
  }
44
81
 
45
82
  export interface TerminalOptions {
46
83
  /** Turns URLs in terminal output into clickable links (`@xterm/addon-web-links`). */
47
84
  webLinks?: boolean
85
+ /** Loads `@xterm/addon-search`, which powers the `findNext`/`findPrevious` handle. */
86
+ search?: boolean
48
87
  }
49
88
 
50
89
  export interface TerminalProps {
@@ -63,6 +102,27 @@ export interface TerminalProps {
63
102
  * props here.
64
103
  */
65
104
  options?: TerminalOptions
105
+ /**
106
+ * Fires whenever the buffer grows or the viewport moves. Exists for one job:
107
+ * letting a wrapper (`TerminalPanel`) answer "is the user still watching the
108
+ * tail, and how much have they missed?" — xterm never scrolls the user down by
109
+ * force, but it also never tells them lines are piling up below.
110
+ *
111
+ * `reason` is what makes the count possible: an `"output"` event means one more
112
+ * missed line, a `"scroll"` event means the user moved on purpose and the count
113
+ * should re-baseline. It fires per line feed on a busy stream, so coalesce
114
+ * (rAF/ref) rather than calling `setState` straight from it.
115
+ */
116
+ onViewportChange?: (state: TerminalViewportState) => void
117
+ /**
118
+ * New grid size after a fit — i.e. after the container resized. An interactive
119
+ * consumer has to forward this to whatever is on the other end (a PTY sizes its
120
+ * output to the client's grid), and `fit()` happens in here, so nowhere else can
121
+ * observe it. Also fires once at mount with the initial size.
122
+ */
123
+ onResize?: (size: { cols: number; rows: number }) => void
124
+ /** Match count/position of the running search — only fires when `options.search` is on. */
125
+ onSearchResults?: (results: { resultIndex: number; resultCount: number }) => void
66
126
  }
67
127
 
68
128
  /**
@@ -72,12 +132,22 @@ export interface TerminalProps {
72
132
  * plug in on top.
73
133
  */
74
134
  const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
75
- { className, fontSize = 13, scrollback = 5000, onData, options },
135
+ { className, fontSize = 13, scrollback = 5000, onData, options, onViewportChange, onResize, onSearchResults },
76
136
  ref
77
137
  ) {
78
138
  const outerRef = useRef<HTMLDivElement>(null)
79
139
  const mountRef = useRef<HTMLDivElement>(null)
80
140
  const termRef = useRef<XTerm | null>(null)
141
+ const searchRef = useRef<SearchAddon | null>(null)
142
+ // xterm's event handlers are wired once at mount, but these two callbacks come
143
+ // from a consumer that re-renders — read them through refs so a new closure
144
+ // doesn't require tearing the terminal down.
145
+ const viewportCbRef = useRef(onViewportChange)
146
+ const resizeCbRef = useRef(onResize)
147
+ const searchCbRef = useRef(onSearchResults)
148
+ viewportCbRef.current = onViewportChange
149
+ resizeCbRef.current = onResize
150
+ searchCbRef.current = onSearchResults
81
151
  // xterm mounts asynchronously (dynamic import); a consumer wiring up a
82
152
  // transport (WebSocket, SSE...) in its own effect can easily win that race
83
153
  // and call write() before termRef exists. Queue those calls instead of
@@ -97,6 +167,12 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
97
167
  },
98
168
  clear: () => termRef.current?.clear(),
99
169
  focus: () => termRef.current?.focus(),
170
+ getContent: () => (termRef.current ? bufferToText(termRef.current) : ""),
171
+ scrollToBottom: () => termRef.current?.scrollToBottom(),
172
+ rowsBelow: () => (termRef.current ? rowsBelowViewport(termRef.current) : 0),
173
+ findNext: (query, opts) => searchRef.current?.findNext(query, searchOptions(opts)) ?? false,
174
+ findPrevious: (query, opts) => searchRef.current?.findPrevious(query, searchOptions(opts)) ?? false,
175
+ clearSearch: () => searchRef.current?.clearDecorations(),
100
176
  }),
101
177
  []
102
178
  )
@@ -133,6 +209,9 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
133
209
  lineHeight: 1.2,
134
210
  scrollback,
135
211
  theme: terminalTheme,
212
+ // Only the search addon needs it (its match highlighting goes through
213
+ // the decoration API), so it stays off for everyone else.
214
+ allowProposedApi: !!options?.search,
136
215
  })
137
216
 
138
217
  const fitAddon = new FitAddon()
@@ -141,6 +220,13 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
141
220
  const { WebLinksAddon } = await import("@xterm/addon-web-links")
142
221
  term.loadAddon(new WebLinksAddon())
143
222
  }
223
+ if (options?.search) {
224
+ const { SearchAddon } = await import("@xterm/addon-search")
225
+ const searchAddon = new SearchAddon()
226
+ term.loadAddon(searchAddon)
227
+ searchAddon.onDidChangeResults((results) => searchCbRef.current?.(results))
228
+ searchRef.current = searchAddon
229
+ }
144
230
  // FitAddon computes available space from *this* element's parent minus
145
231
  // this element's own padding — it has no idea about an outer wrapper's
146
232
  // padding/border. Mounting on a padding-less, border-less inner div (see
@@ -159,6 +245,16 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
159
245
 
160
246
  if (onData) term.onData(onData)
161
247
 
248
+ // Two events rather than one because they mean opposite things to a tail
249
+ // follower: a line feed is output the user may be missing, a scroll is the
250
+ // user deciding where to look.
251
+ term.onLineFeed(() => viewportCbRef.current?.({ rowsBelow: rowsBelowViewport(term), reason: "output" }))
252
+ term.onScroll(() => viewportCbRef.current?.({ rowsBelow: rowsBelowViewport(term), reason: "scroll" }))
253
+ term.onResize((size) => resizeCbRef.current?.(size))
254
+ // xterm only emits onResize on *changes*, and the first fit() above already
255
+ // happened — so announce the initial grid explicitly.
256
+ resizeCbRef.current?.({ cols: term.cols, rows: term.rows })
257
+
162
258
  resizeObserver = new ResizeObserver(() => fitAddon.fit())
163
259
  resizeObserver.observe(mountRef.current)
164
260
  }
@@ -170,6 +266,7 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
170
266
  resizeObserver?.disconnect()
171
267
  termRef.current?.dispose()
172
268
  termRef.current = null
269
+ searchRef.current = null
173
270
  }
174
271
  // eslint-disable-next-line react-hooks/exhaustive-deps
175
272
  }, [])
@@ -201,4 +298,58 @@ const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
201
298
  )
202
299
  })
203
300
 
301
+ /**
302
+ * Rows between the bottom of the viewport and the end of the buffer. `baseY` is
303
+ * where the tail starts, `viewportY` where the user is looking — equal means
304
+ * "stuck to the bottom", which is the state a follow-tail affordance cares about.
305
+ */
306
+ function rowsBelowViewport(term: XTerm): number {
307
+ const buffer = term.buffer.active
308
+ return Math.max(0, buffer.baseY - buffer.viewportY)
309
+ }
310
+
311
+ /**
312
+ * The buffer as plain text. Two things xterm won't do for you:
313
+ *
314
+ * - **Rejoin wrapped rows.** A 300-char line is stored as three rows once the
315
+ * viewport is 100 columns wide; copying it as three lines would make the
316
+ * exported log depend on the window width it was read at.
317
+ * - **Drop the empty tail.** The scrollback is pre-allocated, so the last rows
318
+ * are blank filler, not log content.
319
+ */
320
+ function bufferToText(term: XTerm): string {
321
+ const buffer = term.buffer.active
322
+ const lines: string[] = []
323
+
324
+ for (let i = 0; i < buffer.length; i++) {
325
+ const line = buffer.getLine(i)
326
+ if (!line) continue
327
+ const text = line.translateToString(true)
328
+ if (line.isWrapped && lines.length > 0) lines[lines.length - 1] += text
329
+ else lines.push(text)
330
+ }
331
+
332
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
333
+ return lines.join("\n")
334
+ }
335
+
336
+ /**
337
+ * Match colors live here, not at the call site: search is a design-system
338
+ * affordance and every terminal in the product should highlight the same way —
339
+ * zinc for the other matches, flame for the one you're on.
340
+ */
341
+ function searchOptions(options?: TerminalSearchOptions) {
342
+ return {
343
+ ...options,
344
+ decorations: {
345
+ matchBackground: "#3f3f46",
346
+ matchBorder: "#71717a",
347
+ matchOverviewRuler: "#a1a1aa",
348
+ activeMatchBackground: "#ea5400",
349
+ activeMatchBorder: "#fb923c",
350
+ activeMatchColorOverviewRuler: "#ea5400",
351
+ },
352
+ }
353
+ }
354
+
204
355
  export { Terminal }
@@ -40,7 +40,10 @@ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsPro
40
40
  if (!repos.length) return null
41
41
 
42
42
  return (
43
- <div data-slot="git-branch-chips" className={cn("flex flex-wrap gap-1.5", className)}>
43
+ // `@container` so the branch cap below reads *this row's* width: the same
44
+ // chips sit in a 600 px card on a desktop and in a 290 px one on a phone,
45
+ // and a viewport media query can't tell those apart.
46
+ <div data-slot="git-branch-chips" className={cn("@container flex flex-wrap gap-1.5", className)}>
44
47
  {repos.map((repo) => {
45
48
  const href = repoHref?.(repo)
46
49
  const Tag = href ? "a" : "span"
@@ -49,17 +52,22 @@ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsPro
49
52
  key={repo.path}
50
53
  {...(href ? { href, target: "_blank", rel: "noopener noreferrer" } : {})}
51
54
  title={repo.path}
55
+ // `max-w-full` is what keeps a chip inside the card: without it a
56
+ // long "repo · branch" is a single unbreakable row that runs under
57
+ // the card's rounded edge and gets clipped.
52
58
  className={cn(
53
- "inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground",
59
+ "inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground",
54
60
  href && "transition-colors hover:border-border/80 hover:bg-muted/70"
55
61
  )}
56
62
  >
57
- <span className="font-medium text-foreground/70">{repoName(repo)}</span>
58
- <span className="mx-0.5 text-muted-foreground/40">·</span>
63
+ <span className="shrink-0 font-medium text-foreground/70">{repoName(repo)}</span>
64
+ <span className="mx-0.5 shrink-0 text-muted-foreground/40">·</span>
59
65
  <GitBranchIcon className="h-2.5 w-2.5 shrink-0" />
60
- <span className="max-w-[120px] truncate">{repo.branch || "—"}</span>
66
+ {/* The branch is the only part allowed to give up room — the repo
67
+ name and the counters are short and carry more meaning. */}
68
+ <span className="min-w-0 max-w-[6rem] truncate @min-[20rem]:max-w-[120px]">{repo.branch || "—"}</span>
61
69
  <RepoCounters repo={repo} />
62
- {href && <ExternalLinkIcon className="ml-0.5 h-2.5 w-2.5 opacity-40" />}
70
+ {href && <ExternalLinkIcon className="ml-0.5 h-2.5 w-2.5 shrink-0 opacity-40" />}
63
71
  </Tag>
64
72
  )
65
73
  })}
@@ -71,15 +79,15 @@ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsPro
71
79
  function RepoCounters({ repo }: { repo: GitRepoStatus }) {
72
80
  return (
73
81
  <>
74
- {!!repo.modified && <span className="ml-0.5 text-amber-500 dark:text-amber-400">+{repo.modified}</span>}
82
+ {!!repo.modified && <span className="ml-0.5 shrink-0 text-amber-500 dark:text-amber-400">+{repo.modified}</span>}
75
83
  {!!repo.ahead && (
76
- <span className="ml-0.5 inline-flex items-center text-amber-500 dark:text-amber-400">
84
+ <span className="ml-0.5 inline-flex shrink-0 items-center text-amber-500 dark:text-amber-400">
77
85
  <ArrowUpIcon className="h-2.5 w-2.5" />
78
86
  {repo.ahead}
79
87
  </span>
80
88
  )}
81
89
  {!!repo.behind && (
82
- <span className="ml-0.5 inline-flex items-center text-muted-foreground/70">
90
+ <span className="ml-0.5 inline-flex shrink-0 items-center text-muted-foreground/70">
83
91
  <ArrowDownIcon className="h-2.5 w-2.5" />
84
92
  {repo.behind}
85
93
  </span>
@@ -33,7 +33,11 @@ export function ResourceBars({ stats, compact = false, className }: ResourceBars
33
33
  <div
34
34
  data-slot="resource-bars"
35
35
  className={cn(
36
- "grid grid-cols-2 gap-3",
36
+ // Side by side as soon as there's room for it, stacked below ~240 px —
37
+ // under that the label and its figure end up touching. Container query,
38
+ // not a media query: these bars live in a card, a table cell and a
39
+ // sidebar, and only their own width tells them what to do.
40
+ "@container grid grid-cols-1 gap-3 @min-[15rem]:grid-cols-2",
37
41
  !compact && "rounded-lg border border-border/40 bg-muted/20 px-3 py-2.5",
38
42
  className
39
43
  )}
@@ -82,12 +86,12 @@ function Metric({
82
86
  }) {
83
87
  return (
84
88
  <div>
85
- <div className="mb-1.5 flex items-center justify-between">
86
- <span className="flex items-center gap-1 text-[11px] text-muted-foreground">
89
+ <div className="mb-1.5 flex items-center justify-between gap-2">
90
+ <span className="flex min-w-0 items-center gap-1 truncate text-[11px] text-muted-foreground">
87
91
  {icon}
88
92
  {label}
89
93
  </span>
90
- <span className={cn("font-mono text-[11px] tabular-nums", valueClass)}>{value}</span>
94
+ <span className={cn("shrink-0 font-mono text-[11px] tabular-nums", valueClass)}>{value}</span>
91
95
  </div>
92
96
  <div className="h-1 overflow-hidden rounded-full bg-muted">
93
97
  <div
@@ -136,7 +136,14 @@ export function WorkerCard({
136
136
  return (
137
137
  <div
138
138
  data-slot="worker-card"
139
- className={cn("overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm", className)}
139
+ // `@container`: every responsive rule below is a container query, never a
140
+ // viewport one. The same card is a half-width tile on a desktop grid and a
141
+ // full-width row on a phone — and a 290 px card in a sidebar on a 27"
142
+ // screen is exactly as cramped as a phone.
143
+ className={cn(
144
+ "@container overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm",
145
+ className
146
+ )}
140
147
  >
141
148
  {/* ── Header : état, nom, pills, tags, méta ── */}
142
149
  <div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3">
@@ -145,7 +152,9 @@ export function WorkerCard({
145
152
 
146
153
  <div className="min-w-0 flex-1">
147
154
  <div className="flex flex-wrap items-center gap-2">
148
- <span className="text-sm leading-none font-semibold">{title}</span>
155
+ {/* A worker name is one unbreakable token ("spunto-fix-tunnel-42"):
156
+ without the cap it pushes the card wider than its column. */}
157
+ <span className="max-w-full truncate text-sm leading-none font-semibold">{title}</span>
149
158
  <WorkerStatusPill status={status} />
150
159
  {worker.projectVersion != null && (
151
160
  <span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
@@ -155,46 +164,37 @@ export function WorkerCard({
155
164
  <TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
156
165
  </div>
157
166
 
158
- <div className="mt-1.5 flex flex-wrap items-center gap-2">
167
+ {/* Meta line. Each item carries its own leading separator instead of
168
+ the separators being siblings: this row wraps onto two lines as
169
+ soon as the card is narrow, and a sibling "·" then ends up
170
+ stranded at the end of a line, pointing at nothing. Attached to
171
+ the item, the worst case is a line that *starts* with "·", which
172
+ reads as a continuation. */}
173
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
159
174
  {creator && (
160
- <span className="flex items-center gap-1.5">
175
+ <MetaItem first>
161
176
  <CreatorAvatar creator={creator} />
162
- <span className="text-[11px] text-muted-foreground">{creator.name}</span>
163
- </span>
177
+ <span className="text-muted-foreground">{creator.name}</span>
178
+ </MetaItem>
164
179
  )}
165
180
  {nodeName && (
166
- <>
167
- <Dot />
168
- <span className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
169
- <ServerIcon className="h-3 w-3" />
170
- {nodeName}
171
- </span>
172
- </>
181
+ <MetaItem first={!creator}>
182
+ <ServerIcon className="h-3 w-3 shrink-0" />
183
+ <span className="truncate">{nodeName}</span>
184
+ </MetaItem>
173
185
  )}
174
186
  {worker.createdAt && (
175
- <>
176
- {creator || nodeName ? <Dot /> : null}
177
- <span
178
- className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
179
- title={new Date(worker.createdAt).toLocaleString()}
180
- >
181
- <ClockIcon className="h-3 w-3" />
182
- {formatRelativeTime(worker.createdAt)}
183
- </span>
184
- </>
187
+ <MetaItem first={!creator && !nodeName} title={new Date(worker.createdAt).toLocaleString()}>
188
+ <ClockIcon className="h-3 w-3 shrink-0" />
189
+ {formatRelativeTime(worker.createdAt)}
190
+ </MetaItem>
185
191
  )}
186
192
  {/* Only worth showing once it's over — during setup the live bar says it better. */}
187
193
  {!status.settingUp && startupMs != null && (
188
- <>
189
- <Dot />
190
- <span
191
- className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
192
- title="Worker startup time (clone, features, postCreate…)"
193
- >
194
- <TimerIcon className="h-3 w-3" />
195
- started in {formatDuration(startupMs)}
196
- </span>
197
- </>
194
+ <MetaItem title="Worker startup time (clone, features, postCreate…)">
195
+ <TimerIcon className="h-3 w-3 shrink-0" />
196
+ started in {formatDuration(startupMs)}
197
+ </MetaItem>
198
198
  )}
199
199
  </div>
200
200
  </div>
@@ -223,14 +223,19 @@ export function WorkerCard({
223
223
 
224
224
  {nodeOnline === false && (
225
225
  <Banner tone="offline">
226
- <WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
227
- Node offline
226
+ {/* One span, not two children: the banner spreads its children apart,
227
+ and an icon pinned left with its own label pinned right reads as
228
+ two unrelated things. */}
229
+ <span className="flex min-w-0 items-center gap-2">
230
+ <WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
231
+ Node offline
232
+ </span>
228
233
  </Banner>
229
234
  )}
230
235
 
231
236
  {outdated && (
232
237
  <Banner tone="warn">
233
- <span className="flex min-w-0 items-center gap-1.5">
238
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5">
234
239
  <AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
235
240
  Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> → project at{" "}
236
241
  <span className="font-mono font-semibold">v{currentProjectVersion}</span>
@@ -250,7 +255,9 @@ export function WorkerCard({
250
255
 
251
256
  {error && (
252
257
  <Banner tone="error">
253
- <span className="truncate font-mono">{error}</span>
258
+ {/* Two lines then ellipsis: on a phone a single truncated line of a
259
+ docker error is almost always cut before the useful part. */}
260
+ <span className="line-clamp-2 min-w-0 flex-1 font-mono break-words">{error}</span>
254
261
  {onDismissError && (
255
262
  <button
256
263
  type="button"
@@ -278,9 +285,35 @@ export function WorkerCard({
278
285
  )
279
286
  }
280
287
 
281
- /** The faint separator between two meta items. */
282
- function Dot() {
283
- return <span className="text-[11px] text-muted-foreground/30">·</span>
288
+ /**
289
+ * One item of the meta line: creator, node, age, startup time.
290
+ *
291
+ * The separator is a `::before` on the item rather than a sibling element, so
292
+ * it can never be left behind at a line break. Below ~22 rem it disappears
293
+ * entirely: under ~26 rem the row is two lines deep anyway and the icons already tell
294
+ * the items apart, so the dots are pure noise there.
295
+ */
296
+ function MetaItem({
297
+ first = false,
298
+ title,
299
+ children,
300
+ }: {
301
+ first?: boolean
302
+ title?: string
303
+ children: ReactNode
304
+ }) {
305
+ return (
306
+ <span
307
+ title={title}
308
+ className={cn(
309
+ "flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground/60",
310
+ !first &&
311
+ "before:hidden before:text-muted-foreground/30 before:content-['·'] @min-[26rem]:before:mr-1 @min-[26rem]:before:inline"
312
+ )}
313
+ >
314
+ {children}
315
+ </span>
316
+ )
284
317
  }
285
318
 
286
319
  /**
@@ -299,6 +332,9 @@ function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children:
299
332
  return (
300
333
  <div
301
334
  className={cn(
335
+ // No `flex-wrap`: the action (Rebuild, ✕) stays on the message's line
336
+ // whatever happens — wrapping it underneath reads as a second, unrelated
337
+ // control. The message shrinks and wraps internally instead.
302
338
  "mx-4 mb-3 flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs",
303
339
  BANNER_TONES[tone]
304
340
  )}
package/src/index.ts CHANGED
@@ -28,7 +28,18 @@ export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
28
28
  // xterm.js surface — theme + fit-on-resize, transport-agnostic (feed it via
29
29
  // the write/writeln ref handle).
30
30
  export { Terminal } from "./components/terminal"
31
- export type { TerminalHandle, TerminalProps, TerminalOptions } from "./components/terminal"
31
+ export type {
32
+ TerminalHandle,
33
+ TerminalProps,
34
+ TerminalOptions,
35
+ TerminalSearchOptions,
36
+ TerminalViewportState,
37
+ } from "./components/terminal"
38
+
39
+ // The frame around a Terminal — bar (title/status/actions) + surface + the
40
+ // connection/empty/loading/error states. Its ref *is* a TerminalHandle.
41
+ export { TerminalPanel } from "./components/terminal-panel"
42
+ export type { TerminalPanelProps, TerminalPanelStatus } from "./components/terminal-panel"
32
43
 
33
44
  // Docker image pull as a real UI (one segmented bar, one block per layer) —
34
45
  // presentational only: give it a snapshot, it draws it.