@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/build/index.d.mts +927 -0
- package/build/index.mjs +5236 -0
- package/build/index.mjs.map +1 -0
- package/package.json +73 -0
- package/src/components/agent/Composer.tsx +139 -0
- package/src/components/agent/Conversation.tsx +59 -0
- package/src/components/agent/FileCard.tsx +50 -0
- package/src/components/agent/Loader.tsx +21 -0
- package/src/components/agent/Message.tsx +44 -0
- package/src/components/agent/ModelSelect.tsx +87 -0
- package/src/components/agent/PermissionModeSelect.tsx +94 -0
- package/src/components/agent/PermissionPrompt.tsx +52 -0
- package/src/components/agent/QuestionPrompt.tsx +193 -0
- package/src/components/agent/Reasoning.tsx +58 -0
- package/src/components/agent/Response.tsx +31 -0
- package/src/components/agent/SessionList.tsx +93 -0
- package/src/components/agent/SessionPanel.tsx +149 -0
- package/src/components/agent/StatusBar.tsx +140 -0
- package/src/components/agent/ToolCallCard.tsx +94 -0
- package/src/components/agent/Transcript.tsx +114 -0
- package/src/components/agent/status.ts +16 -0
- package/src/components/prompt-area/animated-placeholder.tsx +42 -0
- package/src/components/prompt-area/clipboard-helpers.ts +206 -0
- package/src/components/prompt-area/cursor-helpers.ts +244 -0
- package/src/components/prompt-area/dom-helpers.ts +721 -0
- package/src/components/prompt-area/file-strip.tsx +250 -0
- package/src/components/prompt-area/html-to-markdown.ts +278 -0
- package/src/components/prompt-area/image-strip.tsx +49 -0
- package/src/components/prompt-area/index.ts +23 -0
- package/src/components/prompt-area/prompt-area-engine.ts +705 -0
- package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
- package/src/components/prompt-area/prompt-area.tsx +375 -0
- package/src/components/prompt-area/remove-button.tsx +37 -0
- package/src/components/prompt-area/segment-helpers.ts +62 -0
- package/src/components/prompt-area/trigger-popover.tsx +139 -0
- package/src/components/prompt-area/trigger-presets.ts +143 -0
- package/src/components/prompt-area/types.ts +360 -0
- package/src/components/prompt-area/use-markdown-mode.ts +113 -0
- package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
- package/src/components/prompt-area/use-prompt-area.ts +1507 -0
- package/src/components/prompt-area/use-trigger-search.ts +115 -0
- package/src/components/ui/AlertDialog.tsx +56 -0
- package/src/components/ui/Badge.tsx +42 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Card.tsx +29 -0
- package/src/components/ui/CodeBlock.tsx +31 -0
- package/src/components/ui/CopyButton.tsx +28 -0
- package/src/components/ui/Input.tsx +20 -0
- package/src/components/ui/ProgressRing.tsx +49 -0
- package/src/components/ui/Select.tsx +80 -0
- package/src/components/ui/Sonner.tsx +22 -0
- package/src/components/ui/Spinner.tsx +6 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +34 -0
- package/src/index.ts +99 -0
- package/src/lib/format.ts +67 -0
- package/src/lib/utils.ts +33 -0
- 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
|
+
}
|