@workerdeck/ui 0.6.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@workerdeck/ui",
3
+ "version": "0.6.0",
4
+ "type": "module",
5
+ "description": "Styled agent-control component library for WorkerDeck: session panel, transcript with streaming, tool-call cards, permission prompts, session list, composer. Tailwind v4 + Base UI + cva. Pairs with the headless @workerdeck/react layer.",
6
+ "license": "MIT",
7
+ "main": "./build/index.mjs",
8
+ "types": "./build/index.d.mts",
9
+ "files": [
10
+ "build",
11
+ "src"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "@workerdeck/source": "./src/index.ts",
16
+ "types": "./build/index.d.mts",
17
+ "default": "./build/index.mjs"
18
+ },
19
+ "./theme.css": "./src/styles/theme.css"
20
+ },
21
+ "dependencies": {
22
+ "@base-ui/react": "^1.6.0",
23
+ "class-variance-authority": "^0.7.1",
24
+ "clsx": "^2.1.1",
25
+ "lucide-react": "^1.21.0",
26
+ "sonner": "^2.0.7",
27
+ "streamdown": "^2.5.0",
28
+ "tailwind-merge": "^3.6.0",
29
+ "use-stick-to-bottom": "^1.1.6",
30
+ "@workerdeck/protocol": "0.6.0",
31
+ "@workerdeck/client": "0.6.0",
32
+ "@workerdeck/react": "0.6.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^18.0.0 || ^19.0.0",
36
+ "react-dom": "^18.0.0 || ^19.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.10.0",
40
+ "@types/react": "^19.2.0",
41
+ "@types/react-dom": "^19.2.0",
42
+ "react": "^19.2.0",
43
+ "react-dom": "^19.2.0",
44
+ "rimraf": "^6.1.3",
45
+ "tsdown": "^0.21.10"
46
+ },
47
+ "author": "Tobias Strebitzer",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/workerdeck/workerdeck.git",
51
+ "directory": "packages/ui"
52
+ },
53
+ "homepage": "https://workerdeck.github.io/workerdeck/",
54
+ "bugs": "https://github.com/workerdeck/workerdeck/issues",
55
+ "keywords": [
56
+ "claude",
57
+ "claude-code",
58
+ "anthropic",
59
+ "agent",
60
+ "react",
61
+ "components",
62
+ "tailwind",
63
+ "ui"
64
+ ],
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "scripts": {
69
+ "clean": "rimraf build",
70
+ "build": "tsdown",
71
+ "typecheck": "tsgo -p tsconfig.json"
72
+ }
73
+ }
@@ -0,0 +1,139 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import type { SlashCommandInfo } from '@workerdeck/protocol'
3
+ import { ArrowUp, Square } from 'lucide-react'
4
+ import { Button } from '../ui/Button.tsx'
5
+ import { PromptArea } from '../prompt-area/prompt-area.tsx'
6
+ import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
7
+ import { commandTrigger } from '../prompt-area/trigger-presets.ts'
8
+ import type { TriggerSuggestion } from '../prompt-area/types.ts'
9
+ import { cn } from '../../lib/utils.ts'
10
+
11
+ export interface ComposerProps {
12
+ onSend: (text: string) => void
13
+ onInterrupt: () => void
14
+ busy: boolean
15
+ /** Disable input entirely (session failed/closed). */
16
+ disabled?: boolean
17
+ placeholder?: string
18
+ /** Slash commands offered as autocomplete; picked ones render as chips. */
19
+ commands?: SlashCommandInfo[]
20
+ /** Left side of the toolbar row (mode selects, attachments, …). */
21
+ toolbar?: ReactNode
22
+ className?: string
23
+ }
24
+
25
+ /** CLI names may carry display annotations (e.g. "foo (MCP)") the parser rejects. */
26
+ const cleanName = (name: string) => name.replace(/\s*\(MCP\)$/i, '')
27
+
28
+ /** Framed prompt input built on prompt-area's contentEditable: typing "/" — at the
29
+ * start or after whitespace — opens a suggestion dropdown fed by `commands`, and a
30
+ * picked command becomes an inline chip. Submit button flips to stop while a turn
31
+ * is running (messages still queue while busy). */
32
+ export function Composer({
33
+ onSend,
34
+ onInterrupt,
35
+ busy,
36
+ disabled,
37
+ placeholder = 'Message Claude…',
38
+ commands,
39
+ toolbar,
40
+ className,
41
+ }: ComposerProps) {
42
+ const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState()
43
+
44
+ const triggers = useMemo(() => {
45
+ if (!commands || commands.length === 0) return undefined
46
+ // The CLI list can contain the same skill name from several sources — first wins.
47
+ const seen = new Set<string>()
48
+ const unique = commands.flatMap((c) => {
49
+ const name = cleanName(c.name)
50
+ if (seen.has(name)) return []
51
+ seen.add(name)
52
+ return [{ ...c, name }]
53
+ })
54
+ return [
55
+ commandTrigger({
56
+ onSearch: (query: string): TriggerSuggestion[] => {
57
+ const needle = query.toLowerCase()
58
+ const scored = unique.flatMap((c) => {
59
+ const haystacks = [c.name, ...(c.aliases ?? [])].map((s) => s.toLowerCase())
60
+ const score = haystacks.some((h) => h.startsWith(needle))
61
+ ? 2
62
+ : haystacks.some((h) => h.includes(needle))
63
+ ? 1
64
+ : 0
65
+ return score === 0 ? [] : [{ c, score }]
66
+ })
67
+ scored.sort((a, b) => b.score - a.score)
68
+ return scored.map(({ c }) => ({
69
+ value: c.name,
70
+ label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ''}`,
71
+ description: c.description,
72
+ }))
73
+ },
74
+ // Chip text renders as trigger + displayText — return the bare name so
75
+ // the chip reads "/name" (label carries the argument hint for the menu).
76
+ onSelect: (suggestion) => suggestion.value,
77
+ chipClassName: 'font-mono',
78
+ }),
79
+ ]
80
+ }, [commands])
81
+
82
+ const submit = () => {
83
+ const trimmed = plainText.trim()
84
+ if (!trimmed || disabled) return
85
+ onSend(trimmed)
86
+ clear()
87
+ focus()
88
+ }
89
+
90
+ const canSend = !disabled && !isEmpty
91
+
92
+ return (
93
+ <div data-slot='composer' className={cn('px-3 pb-3', className)}>
94
+ <div
95
+ className={cn(
96
+ 'mx-auto w-full max-w-3xl overflow-hidden rounded-lg border border-border bg-bg shadow-(--shadow-xs)',
97
+ 'transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
98
+ disabled && 'opacity-60',
99
+ )}>
100
+ <PromptArea
101
+ {...bind}
102
+ triggers={triggers}
103
+ onSubmit={submit}
104
+ disabled={disabled}
105
+ placeholder={disabled ? 'Session ended' : placeholder}
106
+ minHeight={28}
107
+ maxHeight={192}
108
+ aria-label='Message Claude'
109
+ className='px-3 pt-2.5 pb-1 text-body-sm text-text'
110
+ />
111
+ <div className='flex items-center justify-between gap-2 px-2 pb-2'>
112
+ <div className='flex min-w-0 items-center gap-1'>{toolbar}</div>
113
+ {busy ? (
114
+ <Button
115
+ variant='outline'
116
+ size='icon-sm'
117
+ aria-label='Interrupt'
118
+ className='rounded-full'
119
+ onClick={onInterrupt}>
120
+ <Square className='size-3' />
121
+ </Button>
122
+ ) : (
123
+ <Button
124
+ size='icon-sm'
125
+ aria-label='Send'
126
+ className='rounded-full'
127
+ disabled={!canSend}
128
+ onClick={submit}>
129
+ <ArrowUp className='size-4' />
130
+ </Button>
131
+ )}
132
+ </div>
133
+ </div>
134
+ <div className='mx-auto mt-1 w-full max-w-3xl text-center text-label text-fg-4'>
135
+ Enter to send · Shift+Enter for a new line
136
+ </div>
137
+ </div>
138
+ )
139
+ }
@@ -0,0 +1,59 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
3
+ import { ArrowDown } from 'lucide-react'
4
+ import { Button } from '../ui/Button.tsx'
5
+ import { cn } from '../../lib/utils.ts'
6
+
7
+ export interface ConversationProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
8
+ children: ReactNode
9
+ }
10
+
11
+ /** Scroll container that stays pinned to the bottom while streaming, unless the user
12
+ * scrolls up — plus a floating scroll-to-bottom button. */
13
+ export function Conversation({ className, children, ...props }: ConversationProps) {
14
+ return (
15
+ <StickToBottom
16
+ data-slot='conversation'
17
+ className={cn('relative flex-1 overflow-y-auto', className)}
18
+ initial='instant'
19
+ resize='smooth'
20
+ role='log'
21
+ {...props}>
22
+ {children}
23
+ </StickToBottom>
24
+ )
25
+ }
26
+
27
+ export function ConversationContent({
28
+ className,
29
+ children,
30
+ ...props
31
+ }: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
32
+ return (
33
+ <StickToBottom.Content
34
+ data-slot='conversation-content'
35
+ className={cn('mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4', className)}
36
+ {...props}>
37
+ {children}
38
+ </StickToBottom.Content>
39
+ )
40
+ }
41
+
42
+ export function ConversationScrollButton({ className }: { className?: string }) {
43
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext()
44
+ if (isAtBottom) return null
45
+ return (
46
+ <Button
47
+ variant='outline'
48
+ size='icon'
49
+ aria-label='Scroll to bottom'
50
+ data-slot='conversation-scroll-button'
51
+ className={cn(
52
+ 'absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full shadow-(--shadow-md)',
53
+ className,
54
+ )}
55
+ onClick={() => void scrollToBottom()}>
56
+ <ArrowDown className='size-4' />
57
+ </Button>
58
+ )
59
+ }
@@ -0,0 +1,50 @@
1
+ import type { TranscriptItem } from '@workerdeck/react'
2
+ import { Download, FileText } from 'lucide-react'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { formatBytes } from '../../lib/format.ts'
5
+
6
+ export type FileDeliveredItem = Extract<TranscriptItem, { kind: 'file_delivered' }>
7
+
8
+ export interface FileCardProps {
9
+ item: FileDeliveredItem
10
+ /** Download URL for the delivered path (the server's session file route).
11
+ * Without it the card renders informational only. */
12
+ href?: string
13
+ className?: string
14
+ }
15
+
16
+ /** A file the agent handed over (`file_delivered`): name, size, download link.
17
+ * The file lives in the session's in-memory VFS — the link works while the
18
+ * session lives. */
19
+ export function FileCard({ item, href, className }: FileCardProps) {
20
+ const name = item.path.split('/').pop() || item.path
21
+ return (
22
+ <div
23
+ data-slot='file-delivered'
24
+ className={cn(
25
+ 'flex w-full items-center gap-3 rounded-lg border border-border bg-surface px-3 py-2.5',
26
+ className,
27
+ )}>
28
+ <FileText className='size-4 shrink-0 text-fg-3' />
29
+ <div className='min-w-0 flex-1'>
30
+ <div className='truncate font-mono text-body-sm font-medium text-fg-1'>{name}</div>
31
+ <div className='truncate text-label text-fg-4'>
32
+ {formatBytes(item.bytes)}
33
+ {item.description ? ` · ${item.description}` : ''}
34
+ </div>
35
+ </div>
36
+ {href ? (
37
+ <a
38
+ href={href}
39
+ download={name}
40
+ className={cn(
41
+ 'flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5',
42
+ 'text-body-sm text-fg-2 transition-colors hover:bg-surface-hover',
43
+ )}>
44
+ <Download className='size-3.5' />
45
+ Download
46
+ </a>
47
+ ) : null}
48
+ </div>
49
+ )
50
+ }
@@ -0,0 +1,21 @@
1
+ import { cn } from '../../lib/utils.ts'
2
+
3
+ /** Three-dot pulse shown while the assistant hasn't produced output yet. */
4
+ export function Loader({ label, className }: { label?: string; className?: string }) {
5
+ return (
6
+ <div
7
+ data-slot='loader'
8
+ className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
9
+ <span className='flex items-center gap-1'>
10
+ {[0, 1, 2].map((i) => (
11
+ <span
12
+ key={i}
13
+ className='size-1.5 animate-pulse rounded-full bg-fg-4'
14
+ style={{ animationDelay: `${i * 160}ms` }}
15
+ />
16
+ ))}
17
+ </span>
18
+ {label ? <span>{label}</span> : null}
19
+ </div>
20
+ )
21
+ }
@@ -0,0 +1,44 @@
1
+ import type { HTMLAttributes } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ export interface MessageProps extends HTMLAttributes<HTMLDivElement> {
5
+ from: 'user' | 'assistant'
6
+ }
7
+
8
+ /** One chat turn row. User messages sit right in a bubble; assistant content is flat,
9
+ * full-width (the AI-chat convention — assistant output is the page, user input is quoted). */
10
+ export function Message({ from, className, ...props }: MessageProps) {
11
+ return (
12
+ <div
13
+ data-slot='message'
14
+ data-from={from}
15
+ className={cn(
16
+ 'flex w-full flex-col gap-1',
17
+ from === 'user' ? 'items-end' : 'items-start',
18
+ className,
19
+ )}
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ export function MessageContent({
26
+ className,
27
+ ...props
28
+ }: HTMLAttributes<HTMLDivElement>) {
29
+ return (
30
+ <div
31
+ data-slot='message-content'
32
+ className={cn(
33
+ 'min-w-0 text-body-sm leading-6 text-fg-1',
34
+ // Bubble treatment only within a user message row.
35
+ 'in-data-[from=user]:max-w-[85%] in-data-[from=user]:rounded-lg in-data-[from=user]:rounded-br-sm',
36
+ 'in-data-[from=user]:bg-accent-bg in-data-[from=user]:px-3 in-data-[from=user]:py-2',
37
+ 'in-data-[from=user]:whitespace-pre-wrap',
38
+ 'in-data-[from=assistant]:w-full',
39
+ className,
40
+ )}
41
+ {...props}
42
+ />
43
+ )
44
+ }
@@ -0,0 +1,87 @@
1
+ import type { ModelOption } from '@workerdeck/protocol'
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectItemText,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from '../ui/Select.tsx'
10
+ import { cn } from '../../lib/utils.ts'
11
+
12
+ export interface ModelSelectProps {
13
+ /** Models the session can switch to (TranscriptState.models). */
14
+ models: ModelOption[]
15
+ /** The session's current model id (TranscriptState.model), possibly decorated
16
+ * (e.g. "claude-fable-5[1m]") — matched leniently against the options.
17
+ * `undefined` selects the list's default row, if it has one. */
18
+ model?: string
19
+ /** `undefined` = back to the CLI's default model. */
20
+ onModelChange: (model?: string) => void
21
+ /** 'toolbar' (default) is the composer's compact borderless trigger;
22
+ * 'form' is a standard field-sized Select for create/settings forms. */
23
+ variant?: 'toolbar' | 'form'
24
+ disabled?: boolean
25
+ className?: string
26
+ }
27
+
28
+ /** The CLI's supportedModels list leads with a "Default (recommended)" row whose value
29
+ * is a sentinel, not a model id — selecting it means "clear the override". */
30
+ const isDefaultOption = (value: string) => value === 'default'
31
+
32
+ /** Find the option matching a (possibly decorated/aliased) session model id. */
33
+ function matchModel(models: ModelOption[], model?: string): ModelOption | undefined {
34
+ if (!model) return undefined
35
+ const normalized = model.replace(/\[.*\]$/, '')
36
+ const concrete = models.filter((m) => !isDefaultOption(m.value))
37
+ return (
38
+ concrete.find((m) => m.value === normalized) ??
39
+ concrete.find((m) => normalized.includes(m.value) || m.value.includes(normalized))
40
+ )
41
+ }
42
+
43
+ /** Compact model switcher for the composer toolbar; fed by the `capabilities` event.
44
+ * Rows render CLI-style: bold display name with the model's description beneath. */
45
+ export function ModelSelect({
46
+ models,
47
+ model,
48
+ onModelChange,
49
+ variant = 'toolbar',
50
+ disabled,
51
+ className,
52
+ }: ModelSelectProps) {
53
+ const selected =
54
+ matchModel(models, model) ?? (model ? undefined : models.find((m) => isDefaultOption(m.value)))
55
+ return (
56
+ <Select
57
+ items={models.map((m) => ({ value: m.value, label: m.displayName }))}
58
+ value={selected?.value ?? null}
59
+ onValueChange={(value) => {
60
+ if (typeof value !== 'string' || value === selected?.value) return
61
+ onModelChange(isDefaultOption(value) ? undefined : value)
62
+ }}
63
+ disabled={disabled}>
64
+ <SelectTrigger
65
+ aria-label='Model'
66
+ className={cn(
67
+ variant === 'toolbar' &&
68
+ 'h-6 max-w-56 border-transparent bg-transparent text-fg-3 hover:bg-surface-hover',
69
+ className,
70
+ )}>
71
+ <span className={cn('truncate', variant === 'toolbar' && 'font-mono text-label')}>
72
+ <SelectValue placeholder={model ?? 'model'} />
73
+ </span>
74
+ </SelectTrigger>
75
+ <SelectContent className='min-w-72'>
76
+ {models.map((m) => (
77
+ <SelectItem key={m.value} value={m.value}>
78
+ <SelectItemText>
79
+ <span className='font-medium'>{m.displayName}</span>
80
+ </SelectItemText>
81
+ {m.description ? <span className='text-label text-fg-4'>{m.description}</span> : null}
82
+ </SelectItem>
83
+ ))}
84
+ </SelectContent>
85
+ </Select>
86
+ )
87
+ }
@@ -0,0 +1,94 @@
1
+ import type { PermissionMode } from '@workerdeck/protocol'
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectItemText,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from '../ui/Select.tsx'
10
+ import { cn } from '../../lib/utils.ts'
11
+
12
+ export type PermissionModeMeta = {
13
+ value: PermissionMode
14
+ label: string
15
+ description: string
16
+ dangerous?: boolean
17
+ }
18
+
19
+ /** The modes surfaced across UI surfaces (session creation, in-session switcher). */
20
+ export const PERMISSION_MODES: PermissionModeMeta[] = [
21
+ { value: 'default', label: 'default', description: 'ask for approval' },
22
+ { value: 'acceptEdits', label: 'acceptEdits', description: 'auto-approve file edits' },
23
+ { value: 'plan', label: 'plan', description: 'read-only planning' },
24
+ { value: 'auto', label: 'auto', description: 'model decides when to ask' },
25
+ { value: 'dontAsk', label: 'dontAsk', description: 'never ask — deny unapproved' },
26
+ {
27
+ value: 'bypassPermissions',
28
+ label: 'bypassPermissions',
29
+ description: 'no prompts (danger)',
30
+ dangerous: true,
31
+ },
32
+ ]
33
+
34
+ export interface PermissionModeSelectProps {
35
+ /** The session's current mode (TranscriptState.permissionMode). */
36
+ mode?: PermissionMode
37
+ onModeChange: (mode: PermissionMode) => void
38
+ /** Restrict what is offered — most of {@link PERMISSION_MODES} is Claude Code
39
+ * vocabulary the provider engine has no meaning for. Defaults to all of them;
40
+ * pass `PROVIDER_PERMISSION_MODES` for a provider session. */
41
+ modes?: readonly PermissionMode[]
42
+ /** 'toolbar' (default) is the composer's compact borderless trigger;
43
+ * 'form' is a standard field-sized Select for create/settings forms. */
44
+ variant?: 'toolbar' | 'form'
45
+ disabled?: boolean
46
+ className?: string
47
+ }
48
+
49
+ /** Permission-mode switcher: compact in the composer toolbar, field-sized in forms. */
50
+ export function PermissionModeSelect({
51
+ mode,
52
+ onModeChange,
53
+ modes,
54
+ variant = 'toolbar',
55
+ disabled,
56
+ className,
57
+ }: PermissionModeSelectProps) {
58
+ const dangerous = mode === 'bypassPermissions'
59
+ const offered = modes ? PERMISSION_MODES.filter((m) => modes.includes(m.value)) : PERMISSION_MODES
60
+ return (
61
+ <Select
62
+ items={offered.map((m) => ({ value: m.value, label: m.label }))}
63
+ value={mode ?? null}
64
+ onValueChange={(value) => {
65
+ if (typeof value === 'string' && value !== mode) onModeChange(value as PermissionMode)
66
+ }}
67
+ disabled={disabled}>
68
+ <SelectTrigger
69
+ aria-label='Permission mode'
70
+ className={cn(
71
+ variant === 'toolbar' &&
72
+ 'h-6 max-w-44 border-transparent bg-transparent hover:bg-surface-hover',
73
+ dangerous ? 'text-danger' : variant === 'toolbar' ? 'text-fg-3' : undefined,
74
+ className,
75
+ )}>
76
+ <span className={cn('truncate', variant === 'toolbar' && 'font-mono text-label')}>
77
+ <SelectValue placeholder='permissions' />
78
+ </span>
79
+ </SelectTrigger>
80
+ <SelectContent className='min-w-56'>
81
+ {offered.map((m) => (
82
+ <SelectItem key={m.value} value={m.value}>
83
+ <SelectItemText>
84
+ <span className={cn('font-medium', m.dangerous && 'text-danger')}>{m.label}</span>
85
+ </SelectItemText>
86
+ <span className={cn('text-label', m.dangerous ? 'text-danger/80' : 'text-fg-4')}>
87
+ {m.description}
88
+ </span>
89
+ </SelectItem>
90
+ ))}
91
+ </SelectContent>
92
+ </Select>
93
+ )
94
+ }
@@ -0,0 +1,52 @@
1
+ import { useState } from 'react'
2
+ import type { PermissionRequest } from '@workerdeck/protocol'
3
+ import { ShieldAlert } from 'lucide-react'
4
+ import { Button } from '../ui/Button.tsx'
5
+ import { cn } from '../../lib/utils.ts'
6
+
7
+ export interface PermissionPromptProps {
8
+ request: PermissionRequest
9
+ onApprove: (requestId: string) => void
10
+ onDeny: (requestId: string, message?: string) => void
11
+ className?: string
12
+ }
13
+
14
+ export function PermissionPrompt({ request, onApprove, onDeny, className }: PermissionPromptProps) {
15
+ const [showInput, setShowInput] = useState(false)
16
+ return (
17
+ <div
18
+ data-slot='permission-prompt'
19
+ className={cn('rounded-lg border border-warning/40 bg-warning-bg p-3', className)}>
20
+ <div className='flex items-start gap-2.5'>
21
+ <ShieldAlert className='mt-0.5 size-4 shrink-0 text-warning' />
22
+ <div className='min-w-0 flex-1'>
23
+ <div className='text-body-sm font-medium text-fg-1'>
24
+ {request.title ?? `Claude wants to use ${request.toolName}`}
25
+ </div>
26
+ {request.description ? (
27
+ <div className='mt-0.5 text-label text-fg-3'>{request.description}</div>
28
+ ) : null}
29
+ <button
30
+ type='button'
31
+ className='mt-1 font-mono text-label text-fg-3 underline-offset-2 hover:underline'
32
+ onClick={() => setShowInput((v) => !v)}>
33
+ {showInput ? 'Hide' : 'Show'} {request.toolName} input
34
+ </button>
35
+ {showInput ? (
36
+ <pre className='mt-1.5 max-h-48 overflow-auto rounded-md bg-code-bg px-2.5 py-1.5 font-mono text-label whitespace-pre-wrap text-fg-2'>
37
+ {JSON.stringify(request.input, null, 2)}
38
+ </pre>
39
+ ) : null}
40
+ </div>
41
+ <div className='flex shrink-0 gap-1.5'>
42
+ <Button size='sm' onClick={() => onApprove(request.id)}>
43
+ Allow
44
+ </Button>
45
+ <Button size='sm' variant='outline' onClick={() => onDeny(request.id, 'Denied by user')}>
46
+ Deny
47
+ </Button>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ )
52
+ }