@workerdeck/ui 0.7.0 → 0.10.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 (38) hide show
  1. package/README.md +44 -3
  2. package/build/index.d.mts +767 -21
  3. package/build/index.mjs +3268 -348
  4. package/build/index.mjs.map +1 -1
  5. package/package.json +5 -4
  6. package/src/components/agent/CodeEditor.tsx +300 -0
  7. package/src/components/agent/Composer.tsx +379 -56
  8. package/src/components/agent/ContextDialog.tsx +99 -0
  9. package/src/components/agent/EditorTabs.tsx +165 -0
  10. package/src/components/agent/FileTree.tsx +287 -0
  11. package/src/components/agent/FileViewer.tsx +148 -0
  12. package/src/components/agent/HostFilesDialog.tsx +218 -0
  13. package/src/components/agent/McpDialog.tsx +363 -0
  14. package/src/components/agent/ModelSelect.tsx +34 -6
  15. package/src/components/agent/PermissionModeSelect.tsx +99 -22
  16. package/src/components/agent/PermissionPrompt.tsx +72 -6
  17. package/src/components/agent/PromptTokenText.tsx +39 -0
  18. package/src/components/agent/SessionEmptyState.tsx +65 -0
  19. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  20. package/src/components/agent/SessionPanel.tsx +380 -40
  21. package/src/components/agent/SessionWorkspace.tsx +282 -0
  22. package/src/components/agent/SkillsDialog.tsx +195 -0
  23. package/src/components/agent/StatusBar.tsx +85 -18
  24. package/src/components/agent/ToolCallCard.tsx +80 -4
  25. package/src/components/agent/Transcript.tsx +109 -12
  26. package/src/components/agent/UsageDialog.tsx +168 -0
  27. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  28. package/src/components/prompt-area/types.ts +15 -0
  29. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  30. package/src/components/ui/CopyButton.tsx +8 -1
  31. package/src/components/ui/Dialog.tsx +92 -0
  32. package/src/components/ui/Menu.tsx +55 -0
  33. package/src/components/ui/Splitter.tsx +133 -0
  34. package/src/components/ui/Tooltip.tsx +22 -5
  35. package/src/index.ts +53 -1
  36. package/src/lib/clipboard.ts +56 -0
  37. package/src/lib/format.ts +48 -0
  38. package/src/lib/tool-icon.ts +74 -0
@@ -0,0 +1,92 @@
1
+ import { type FunctionComponent } from 'react'
2
+ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
3
+ import { X } from 'lucide-react'
4
+ import { cn } from '../../lib/utils.ts'
5
+
6
+ export const Dialog = DialogPrimitive.Root
7
+ export const DialogTrigger = DialogPrimitive.Trigger
8
+ export const DialogClose = DialogPrimitive.Close
9
+
10
+ /**
11
+ * A dismissible panel, sized for reading rather than confirming — the web
12
+ * counterpart of the iOS app's detail sheets (context, usage, session info, MCP).
13
+ *
14
+ * Taller than {@link AlertDialogContent} and scrollable inside, because these
15
+ * carry lists whose length is the engine's business, not the layout's.
16
+ */
17
+ export const DialogContent: FunctionComponent<
18
+ DialogPrimitive.Popup.Props & { size?: 'sm' | 'md' | 'lg' }
19
+ > = ({ className, children, size = 'md', ...props }) => (
20
+ <DialogPrimitive.Portal>
21
+ <DialogPrimitive.Backdrop
22
+ className={cn(
23
+ 'fixed inset-0 z-70 bg-black/40 backdrop-blur-[1px]',
24
+ 'transition-opacity duration-(--motion-base)',
25
+ 'data-starting-style:opacity-0 data-ending-style:opacity-0',
26
+ )}
27
+ />
28
+ <DialogPrimitive.Popup
29
+ data-slot='dialog-content'
30
+ className={cn(
31
+ 'fixed top-1/2 left-1/2 z-70 flex max-h-[min(42rem,calc(100dvh-3rem))] -translate-x-1/2 -translate-y-1/2 flex-col',
32
+ size === 'sm' && 'w-[min(24rem,calc(100vw-2rem))]',
33
+ size === 'md' && 'w-[min(32rem,calc(100vw-2rem))]',
34
+ size === 'lg' && 'w-[min(46rem,calc(100vw-2rem))]',
35
+ 'rounded-lg border border-border bg-surface shadow-(--shadow-lg) outline-none',
36
+ 'transition-[opacity,transform] duration-(--motion-base)',
37
+ 'data-starting-style:scale-95 data-starting-style:opacity-0',
38
+ 'data-ending-style:scale-95 data-ending-style:opacity-0',
39
+ className,
40
+ )}
41
+ {...props}>
42
+ {children}
43
+ </DialogPrimitive.Popup>
44
+ </DialogPrimitive.Portal>
45
+ )
46
+
47
+ /** Title row with the close button, pinned above the scrolling body. */
48
+ export const DialogHeader: FunctionComponent<{
49
+ title: string
50
+ description?: string
51
+ /** Rendered between the title and the close button. */
52
+ actions?: React.ReactNode
53
+ }> = ({ title, description, actions }) => (
54
+ <div className='flex items-start gap-2 border-b border-border px-4 py-3'>
55
+ <div className='min-w-0 flex-1'>
56
+ <DialogPrimitive.Title className='truncate text-body-sm font-semibold text-text'>
57
+ {title}
58
+ </DialogPrimitive.Title>
59
+ {description ? (
60
+ <DialogPrimitive.Description className='mt-0.5 text-label text-fg-4'>
61
+ {description}
62
+ </DialogPrimitive.Description>
63
+ ) : null}
64
+ </div>
65
+ {actions}
66
+ <DialogPrimitive.Close
67
+ aria-label='Close'
68
+ className='-mr-1 flex size-6 shrink-0 items-center justify-center rounded-md text-fg-3 transition-colors outline-none hover:bg-surface-hover hover:text-fg-1'>
69
+ <X className='size-3.5' />
70
+ </DialogPrimitive.Close>
71
+ </div>
72
+ )
73
+
74
+ /** The scrolling region under the header. */
75
+ export const DialogBody: FunctionComponent<React.HTMLAttributes<HTMLDivElement>> = ({
76
+ className,
77
+ ...props
78
+ }) => <div className={cn('min-h-0 flex-1 overflow-y-auto p-4', className)} {...props} />
79
+
80
+ /** A label/value row — the shape every one of these panels is mostly made of. */
81
+ export const DialogRow: FunctionComponent<{
82
+ label: string
83
+ children: React.ReactNode
84
+ mono?: boolean
85
+ }> = ({ label, children, mono }) => (
86
+ <div className='flex items-baseline justify-between gap-4 py-1.5'>
87
+ <span className='shrink-0 text-label text-fg-3'>{label}</span>
88
+ <span className={cn('min-w-0 truncate text-right text-body-sm text-fg-1', mono && 'font-mono text-label')}>
89
+ {children}
90
+ </span>
91
+ </div>
92
+ )
@@ -0,0 +1,55 @@
1
+ import { type FunctionComponent } from 'react'
2
+ import { Menu as MenuPrimitive } from '@base-ui/react/menu'
3
+ import { cn } from '../../lib/utils.ts'
4
+
5
+ export const Menu = MenuPrimitive.Root
6
+ export const MenuTrigger = MenuPrimitive.Trigger
7
+
8
+ export const MenuContent: FunctionComponent<
9
+ MenuPrimitive.Popup.Props &
10
+ Pick<MenuPrimitive.Positioner.Props, 'align' | 'side' | 'sideOffset'>
11
+ > = ({ className, align = 'end', side = 'bottom', sideOffset = 6, ...props }) => (
12
+ <MenuPrimitive.Portal>
13
+ <MenuPrimitive.Positioner
14
+ align={align}
15
+ side={side}
16
+ sideOffset={sideOffset}
17
+ className='isolate z-60 outline-none'>
18
+ <MenuPrimitive.Popup
19
+ data-slot='menu-content'
20
+ className={cn(
21
+ 'min-w-48 rounded-md border border-border bg-surface p-1 text-fg-1 shadow-(--shadow-lg) outline-none',
22
+ 'transition-[opacity,transform] duration-(--motion-base)',
23
+ 'data-starting-style:scale-95 data-starting-style:opacity-0',
24
+ 'data-ending-style:scale-95 data-ending-style:opacity-0',
25
+ className,
26
+ )}
27
+ {...props}
28
+ />
29
+ </MenuPrimitive.Positioner>
30
+ </MenuPrimitive.Portal>
31
+ )
32
+
33
+ export const MenuItem: FunctionComponent<MenuPrimitive.Item.Props & { destructive?: boolean }> = ({
34
+ className,
35
+ destructive,
36
+ ...props
37
+ }) => (
38
+ <MenuPrimitive.Item
39
+ data-slot='menu-item'
40
+ className={cn(
41
+ 'flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-body-sm outline-none select-none',
42
+ 'data-highlighted:bg-surface-hover',
43
+ destructive ? 'text-danger' : 'text-text',
44
+ className,
45
+ )}
46
+ {...props}
47
+ />
48
+ )
49
+
50
+ export const MenuSeparator: FunctionComponent<MenuPrimitive.Separator.Props> = ({
51
+ className,
52
+ ...props
53
+ }) => (
54
+ <MenuPrimitive.Separator className={cn('my-1 h-px bg-border', className)} {...props} />
55
+ )
@@ -0,0 +1,133 @@
1
+ import {
2
+ useCallback,
3
+ useRef,
4
+ type KeyboardEvent as ReactKeyboardEvent,
5
+ type PointerEvent as ReactPointerEvent,
6
+ } from 'react'
7
+ import { cn } from '../../lib/utils.ts'
8
+
9
+ export interface SplitterProps {
10
+ /**
11
+ * ARIA's sense of the word: a `vertical` splitter is a vertical bar between
12
+ * two side-by-side panes, and it resizes a **width**. A `horizontal` one sits
13
+ * between stacked panes and resizes a **height**.
14
+ */
15
+ orientation: 'vertical' | 'horizontal'
16
+ /** Current size of the pane this splitter controls, in pixels. */
17
+ value: number
18
+ onValueChange: (value: number) => void
19
+ min: number
20
+ max: number
21
+ /** Keyboard step. */
22
+ step?: number
23
+ /** Set when dragging the splitter *away* from the origin should shrink the
24
+ * controlled pane — i.e. the pane is on the right or the bottom. */
25
+ inverted?: boolean
26
+ /** Required: "Resize" alone does not say which of two splitters this is. */
27
+ 'aria-label': string
28
+ className?: string
29
+ }
30
+
31
+ /**
32
+ * A draggable pane divider.
33
+ *
34
+ * Hand-rolled rather than depended on: `@base-ui/react` ships no splitter, the
35
+ * behaviour is a hundred lines of pointer events, and this repo's instinct at
36
+ * this layer is to own it (the composer is vendored for the same reason).
37
+ *
38
+ * Pointer capture is what makes it survive a fast drag — without it the pointer
39
+ * leaves the 5px bar within a frame and the moves go to whatever is underneath,
40
+ * which for this layout is an iframe-free but still selection-happy code pane.
41
+ * The drag origin is captured on pointerdown and every move is measured against
42
+ * it, so the pane cannot drift relative to the cursor over a long drag the way
43
+ * per-move deltas do once clamping is involved.
44
+ *
45
+ * Keyboard-operable and announced as a separator, because a pane you can only
46
+ * size by dragging is a pane some people cannot size.
47
+ */
48
+ export function Splitter({
49
+ orientation,
50
+ value,
51
+ onValueChange,
52
+ min,
53
+ max,
54
+ step = 16,
55
+ inverted,
56
+ 'aria-label': label,
57
+ className,
58
+ }: SplitterProps) {
59
+ const drag = useRef<{ origin: number; start: number } | null>(null)
60
+ const vertical = orientation === 'vertical'
61
+
62
+ const clamp = useCallback((next: number) => Math.min(max, Math.max(min, next)), [min, max])
63
+
64
+ const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
65
+ // Secondary buttons open context menus; they are not drags.
66
+ if (event.button !== 0) return
67
+ event.preventDefault()
68
+ event.currentTarget.setPointerCapture(event.pointerId)
69
+ drag.current = { origin: vertical ? event.clientX : event.clientY, start: value }
70
+ }
71
+
72
+ const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
73
+ const state = drag.current
74
+ if (!state) return
75
+ const delta = (vertical ? event.clientX : event.clientY) - state.origin
76
+ onValueChange(clamp(state.start + (inverted ? -delta : delta)))
77
+ }
78
+
79
+ const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
80
+ if (!drag.current) return
81
+ drag.current = null
82
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
83
+ event.currentTarget.releasePointerCapture(event.pointerId)
84
+ }
85
+ }
86
+
87
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
88
+ // The arrows that move *along* this splitter's axis of travel; the other
89
+ // pair is left to the page, which is what a separator should do with them.
90
+ const grow = vertical ? 'ArrowRight' : 'ArrowDown'
91
+ const shrink = vertical ? 'ArrowLeft' : 'ArrowUp'
92
+ const direction = inverted ? -1 : 1
93
+ if (event.key === grow) onValueChange(clamp(value + step * direction))
94
+ else if (event.key === shrink) onValueChange(clamp(value - step * direction))
95
+ else if (event.key === 'Home') onValueChange(min)
96
+ else if (event.key === 'End') onValueChange(max)
97
+ else return
98
+ event.preventDefault()
99
+ }
100
+
101
+ return (
102
+ <div
103
+ data-slot='splitter'
104
+ role='separator'
105
+ tabIndex={0}
106
+ aria-label={label}
107
+ aria-orientation={orientation}
108
+ aria-valuenow={Math.round(value)}
109
+ aria-valuemin={min}
110
+ aria-valuemax={max}
111
+ onPointerDown={onPointerDown}
112
+ onPointerMove={onPointerMove}
113
+ onPointerUp={endDrag}
114
+ onPointerCancel={endDrag}
115
+ onKeyDown={onKeyDown}
116
+ className={cn(
117
+ // A 1px line that reads as a border, with a larger invisible grab area
118
+ // around it — a hairline is an honest divider and a cruel target.
119
+ 'group relative shrink-0 touch-none bg-border transition-colors',
120
+ 'hover:bg-border-strong focus-visible:bg-accent focus-visible:outline-none',
121
+ vertical ? 'w-px cursor-col-resize' : 'h-px cursor-row-resize',
122
+ className,
123
+ )}>
124
+ <span
125
+ aria-hidden
126
+ className={cn(
127
+ 'absolute',
128
+ vertical ? '-inset-x-[3px] inset-y-0' : '-inset-y-[3px] inset-x-0',
129
+ )}
130
+ />
131
+ </div>
132
+ )
133
+ }
@@ -1,4 +1,4 @@
1
- import { type FunctionComponent, type ReactNode } from 'react'
1
+ import { type FunctionComponent, type ReactElement, type ReactNode } from 'react'
2
2
  import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
3
3
  import { cn } from '../../lib/utils.ts'
4
4
 
@@ -21,14 +21,31 @@ export const TooltipContent: FunctionComponent<
21
21
  </TooltipPrimitive.Portal>
22
22
  )
23
23
 
24
- /** Convenience wrapper: <Tip content="..."><Button/></Tip> */
25
- export function Tip({ content, children }: { content: ReactNode; children: ReactNode }) {
24
+ /**
25
+ * Convenience wrapper: `<Tip content="..."><Button/></Tip>`.
26
+ *
27
+ * Pass `render` when the trigger must *be* an element you already have — a tab, a
28
+ * row — rather than something wrapped in a span. The default span is fine beside
29
+ * a button but would break any layout that styles its own children (a flex tab
30
+ * strip gets an extra box between the container and its items).
31
+ */
32
+ export function Tip({
33
+ content,
34
+ render,
35
+ side,
36
+ children,
37
+ }: {
38
+ content: ReactNode
39
+ render?: ReactElement
40
+ side?: 'top' | 'right' | 'bottom' | 'left'
41
+ children?: ReactNode
42
+ }) {
26
43
  return (
27
44
  <TooltipPrimitive.Root>
28
- <TooltipPrimitive.Trigger render={<span className='inline-flex' />}>
45
+ <TooltipPrimitive.Trigger render={render ?? <span className='inline-flex' />}>
29
46
  {children}
30
47
  </TooltipPrimitive.Trigger>
31
- <TooltipContent>{content}</TooltipContent>
48
+ <TooltipContent side={side}>{content}</TooltipContent>
32
49
  </TooltipPrimitive.Root>
33
50
  )
34
51
  }
package/src/index.ts CHANGED
@@ -20,11 +20,28 @@ export {
20
20
  AlertDialogTitle,
21
21
  AlertDialogTrigger,
22
22
  } from './components/ui/AlertDialog.tsx'
23
+ export {
24
+ Menu,
25
+ MenuContent,
26
+ MenuItem,
27
+ MenuSeparator,
28
+ MenuTrigger,
29
+ } from './components/ui/Menu.tsx'
30
+ export {
31
+ Dialog,
32
+ DialogBody,
33
+ DialogClose,
34
+ DialogContent,
35
+ DialogHeader,
36
+ DialogRow,
37
+ DialogTrigger,
38
+ } from './components/ui/Dialog.tsx'
23
39
  export { Tip, TooltipContent, TooltipProvider } from './components/ui/Tooltip.tsx'
24
40
  export { Toaster, toast } from './components/ui/Sonner.tsx'
25
41
  export { CopyButton, type CopyButtonProps } from './components/ui/CopyButton.tsx'
26
42
  export { Spinner } from './components/ui/Spinner.tsx'
27
43
  export { CodeBlock, type CodeBlockProps } from './components/ui/CodeBlock.tsx'
44
+ export { Splitter, type SplitterProps } from './components/ui/Splitter.tsx'
28
45
  export { ProgressRing, type ProgressRingProps } from './components/ui/ProgressRing.tsx'
29
46
  // Prompt input (vendored just-marketing/prompt-area, themed to these tokens)
30
47
  export {
@@ -48,6 +65,14 @@ export {
48
65
 
49
66
  // Agent-control components
50
67
  export { SessionPanel, type SessionPanelProps } from './components/agent/SessionPanel.tsx'
68
+ export {
69
+ SessionWorkspace,
70
+ type SessionWorkspaceProps,
71
+ } from './components/agent/SessionWorkspace.tsx'
72
+ export { FileTree, type FileTreeProps } from './components/agent/FileTree.tsx'
73
+ export { EditorTabs, type EditorTabsProps } from './components/agent/EditorTabs.tsx'
74
+ export { FileViewer, type FileViewerProps } from './components/agent/FileViewer.tsx'
75
+ export { CodeEditor, type CodeEditorProps } from './components/agent/CodeEditor.tsx'
51
76
  export { Transcript, type TranscriptProps } from './components/agent/Transcript.tsx'
52
77
  export {
53
78
  Conversation,
@@ -69,31 +94,58 @@ export {
69
94
  type QuestionPromptProps,
70
95
  type QuestionBehaviorMeta,
71
96
  } from './components/agent/QuestionPrompt.tsx'
72
- export { Composer, type ComposerProps } from './components/agent/Composer.tsx'
97
+ export {
98
+ Composer,
99
+ skillPrompt,
100
+ type ComposerFileMatch,
101
+ type ComposerHandle,
102
+ type ComposerProps,
103
+ } from './components/agent/Composer.tsx'
73
104
  export { ModelSelect, type ModelSelectProps } from './components/agent/ModelSelect.tsx'
74
105
  export {
75
106
  PERMISSION_MODES,
76
107
  PermissionModeSelect,
108
+ permissionModeMeta,
77
109
  type PermissionModeMeta,
78
110
  type PermissionModeSelectProps,
79
111
  } from './components/agent/PermissionModeSelect.tsx'
80
112
  export { StatusBar, type StatusBarProps } from './components/agent/StatusBar.tsx'
113
+ export { ContextDialog, type ContextDialogProps } from './components/agent/ContextDialog.tsx'
114
+ export { UsageDialog, type UsageDialogProps } from './components/agent/UsageDialog.tsx'
115
+ export {
116
+ SessionInfoDialog,
117
+ type SessionInfoDialogProps,
118
+ } from './components/agent/SessionInfoDialog.tsx'
119
+ export { McpDialog, type McpDialogProps } from './components/agent/McpDialog.tsx'
120
+ export { SkillsDialog, type SkillsDialogProps } from './components/agent/SkillsDialog.tsx'
121
+ export { HostFilesDialog, type HostFilesDialogProps } from './components/agent/HostFilesDialog.tsx'
81
122
  export {
82
123
  SessionList,
83
124
  SessionListItem,
84
125
  type SessionListItemProps,
85
126
  type SessionListProps,
86
127
  } from './components/agent/SessionList.tsx'
128
+ export {
129
+ SessionEmptyState,
130
+ type SessionEmptyStateProps,
131
+ } from './components/agent/SessionEmptyState.tsx'
132
+ export { PromptTokenText } from './components/agent/PromptTokenText.tsx'
87
133
  export { STATUS_META } from './components/agent/status.ts'
88
134
 
89
135
  // Utilities
90
136
  export { cn } from './lib/utils.ts'
137
+ export { copyText } from './lib/clipboard.ts'
138
+ export { toolIcon } from './lib/tool-icon.ts'
91
139
  export {
140
+ formatAgoPrecise,
92
141
  formatBytes,
93
142
  formatCost,
94
143
  formatCountdown,
95
144
  formatDuration,
145
+ formatRateLimitWindow,
146
+ formatRateLimitWindowLong,
96
147
  formatRelativeTime,
97
148
  formatTokens,
149
+ rateLimitWindowSeconds,
98
150
  toolInputPreview,
99
151
  } from './lib/format.ts'
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Copy text to the clipboard, on origins where the modern API does not exist.
3
+ *
4
+ * `navigator.clipboard` is gated on a **secure context**: HTTPS, or localhost.
5
+ * A WorkerDeck dashboard reached the way it is meant to be reached — plain HTTP
6
+ * on a LAN address, from a laptop or a phone — is neither, so `navigator.clipboard`
7
+ * is `undefined` there and touching `.writeText` throws outright. That is the
8
+ * normal deployment, not an edge case, which is why this falls back rather than
9
+ * feature-detecting into a disabled button.
10
+ *
11
+ * The fallback is `document.execCommand('copy')` over an off-screen textarea.
12
+ * It is deprecated and it is also the only thing that works here; every browser
13
+ * still implements it. Returns whether the text actually landed, so a caller can
14
+ * avoid claiming success it did not have.
15
+ */
16
+ export async function copyText(value: string): Promise<boolean> {
17
+ // Optional-chained, not `in`-checked: on an insecure origin the property is
18
+ // absent entirely, and some embedded webviews expose a `clipboard` object
19
+ // whose `writeText` rejects. Both end up in the fallback.
20
+ try {
21
+ if (navigator.clipboard?.writeText) {
22
+ await navigator.clipboard.writeText(value)
23
+ return true
24
+ }
25
+ } catch {
26
+ // Permission denied, or a webview that lied about having the API.
27
+ }
28
+ return legacyCopy(value)
29
+ }
30
+
31
+ function legacyCopy(value: string): boolean {
32
+ if (typeof document === 'undefined') return false
33
+ const textarea = document.createElement('textarea')
34
+ textarea.value = value
35
+ // Off-screen rather than hidden: `display: none` and `visibility: hidden`
36
+ // elements cannot hold a selection, so the copy would silently do nothing.
37
+ // `readOnly` keeps the mobile keyboard from appearing for the instant it exists.
38
+ textarea.setAttribute('readonly', '')
39
+ textarea.style.position = 'fixed'
40
+ textarea.style.top = '-9999px'
41
+ textarea.style.opacity = '0'
42
+ document.body.appendChild(textarea)
43
+ // Preserve where the user was: selecting steals the current selection, and on
44
+ // a text field mid-edit that is visible and annoying.
45
+ const previous = document.activeElement
46
+ try {
47
+ textarea.select()
48
+ textarea.setSelectionRange(0, value.length)
49
+ return document.execCommand('copy')
50
+ } catch {
51
+ return false
52
+ } finally {
53
+ document.body.removeChild(textarea)
54
+ if (previous instanceof HTMLElement) previous.focus()
55
+ }
56
+ }
package/src/lib/format.ts CHANGED
@@ -51,6 +51,54 @@ export function formatRelativeTime(epochMs: number | undefined, now = Date.now()
51
51
  return `${d}d ago`
52
52
  }
53
53
 
54
+ /**
55
+ * Human label for a rate-limit window key, compact: 'five_hour' → "5h",
56
+ * 'seven_day_opus' → "7d opus". The per-model suffix is an open set — the CLI
57
+ * adds buckets as plans gain them — so it is rewritten rather than enumerated.
58
+ */
59
+ export function formatRateLimitWindow(key: string): string {
60
+ if (key === 'five_hour') return '5h'
61
+ if (key === 'seven_day') return '7d'
62
+ const spaced = key.replaceAll('_', ' ')
63
+ return key.startsWith('seven_day_') ? `7d ${spaced.slice('seven day '.length)}` : spaced
64
+ }
65
+
66
+ /** The same key spelled out, where there is room: 'five_hour' → "5-hour
67
+ * session", 'seven_day_fable' → "Weekly · Fable". */
68
+ export function formatRateLimitWindowLong(key: string): string {
69
+ if (key === 'five_hour') return '5-hour session'
70
+ if (key === 'seven_day') return 'Weekly'
71
+ if (key === 'seven_day_oauth_apps') return 'Weekly · apps'
72
+ const capitalize = (s: string) => s.replace(/\b\w/g, (c) => c.toUpperCase())
73
+ if (!key.startsWith('seven_day_')) return capitalize(key.replaceAll('_', ' '))
74
+ return `Weekly · ${capitalize(key.slice('seven_day_'.length).replaceAll('_', ' '))}`
75
+ }
76
+
77
+ /**
78
+ * How long a rate-limit window is, in seconds — the denominator behind the pace
79
+ * marker. Derived from the key rather than reported: the CLI sends a reset time
80
+ * and a percentage, never a duration. `undefined` for a window whose key doesn't
81
+ * say, and the marker is then simply not drawn rather than guessed.
82
+ */
83
+ export function rateLimitWindowSeconds(key: string): number | undefined {
84
+ if (key === 'five_hour') return 5 * 3600
85
+ if (key.startsWith('seven_day')) return 7 * 86_400
86
+ return undefined
87
+ }
88
+
89
+ /** "8 secs ago" / "3 mins ago" — a freshness line finer-grained than
90
+ * {@link formatRelativeTime}, because a poll that just landed should say so. */
91
+ export function formatAgoPrecise(epochMs: number, now = Date.now()): string {
92
+ const seconds = Math.max(0, Math.floor((now - epochMs) / 1000))
93
+ if (seconds < 60) return `${seconds} sec${seconds === 1 ? '' : 's'} ago`
94
+ if (seconds < 3600) {
95
+ const minutes = Math.floor(seconds / 60)
96
+ return `${minutes} min${minutes === 1 ? '' : 's'} ago`
97
+ }
98
+ const hours = Math.floor(seconds / 3600)
99
+ return `${hours} hour${hours === 1 ? '' : 's'} ago`
100
+ }
101
+
54
102
  /** Compact one-line preview of a tool input for card headers. */
55
103
  export function toolInputPreview(input: unknown, max = 80): string {
56
104
  if (input === null || input === undefined) return ''
@@ -0,0 +1,74 @@
1
+ import {
2
+ ArrowDownCircle,
3
+ CheckSquare,
4
+ FileDiff,
5
+ FileText,
6
+ FolderSearch,
7
+ Globe,
8
+ Image,
9
+ type LucideIcon,
10
+ MessageCircleQuestion,
11
+ PencilLine,
12
+ Puzzle,
13
+ Search,
14
+ Sparkles,
15
+ SquarePen,
16
+ Terminal,
17
+ UsersRound,
18
+ Wrench,
19
+ } from 'lucide-react'
20
+
21
+ /**
22
+ * An icon per tool, so a transcript can be skimmed by shape rather than read.
23
+ *
24
+ * The same mapping the iOS app makes, in lucide's vocabulary rather than SF
25
+ * Symbols — the two clients should be recognisably showing the same thing. An
26
+ * unknown tool falls back to a wrench, and an MCP tool (`mcp__server__name`) to
27
+ * the puzzle piece the MCP screens use, because "which server is this from" is
28
+ * the useful thing to see at a glance.
29
+ */
30
+ export function toolIcon(toolName: string): LucideIcon {
31
+ switch (toolName) {
32
+ case 'Bash':
33
+ case 'BashOutput':
34
+ case 'KillShell':
35
+ return Terminal
36
+ case 'Read':
37
+ return FileText
38
+ case 'Write':
39
+ return SquarePen
40
+ case 'Edit':
41
+ case 'MultiEdit':
42
+ case 'NotebookEdit':
43
+ return PencilLine
44
+ case 'Glob':
45
+ return FolderSearch
46
+ case 'Grep':
47
+ return Search
48
+ case 'WebFetch':
49
+ return ArrowDownCircle
50
+ case 'WebSearch':
51
+ return Globe
52
+ case 'Task':
53
+ case 'Agent':
54
+ return UsersRound
55
+ case 'TodoWrite':
56
+ return CheckSquare
57
+ case 'Skill':
58
+ return Sparkles
59
+ case 'AskUserQuestion':
60
+ return MessageCircleQuestion
61
+ // The codex engine's own tool names (see its runner's item mapping).
62
+ case 'CodexCommand':
63
+ return Terminal
64
+ case 'CodexFileChange':
65
+ return FileDiff
66
+ case 'CodexWebSearch':
67
+ return Globe
68
+ case 'CodexImageGeneration':
69
+ case 'CodexImageView':
70
+ return Image
71
+ default:
72
+ return toolName.startsWith('mcp__') ? Puzzle : Wrench
73
+ }
74
+ }