@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
@@ -1,18 +1,39 @@
1
- import { useState } from 'react'
1
+ import { useEffect, useState } from 'react'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
- import { ChevronDown, Clock, Wrench } from 'lucide-react'
3
+ import { ChevronDown, Clock } from 'lucide-react'
4
4
  import { Badge } from '../ui/Badge.tsx'
5
5
  import { CodeBlock } from '../ui/CodeBlock.tsx'
6
6
  import { Spinner } from '../ui/Spinner.tsx'
7
7
  import { cn } from '../../lib/utils.ts'
8
8
  import { toolInputPreview } from '../../lib/format.ts'
9
+ import { toolIcon } from '../../lib/tool-icon.ts'
9
10
 
10
11
  export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
11
12
 
12
13
  const RESULT_PREVIEW_CHARS = 2000
13
14
 
15
+ /** Codex's built-in image tools name a host path rather than sending bytes —
16
+ * an event log carries references, never base64. Rendering the picture means
17
+ * reading that path back through the gateway's host-file route. */
18
+ const IMAGE_TOOLS = new Set(['CodexImageGeneration', 'CodexImageView'])
19
+
20
+ const imagePathOf = (item: ToolCallItem): string | undefined => {
21
+ if (!IMAGE_TOOLS.has(item.name)) return undefined
22
+ const input = item.input as { savedPath?: unknown; path?: unknown } | null
23
+ const path = input?.savedPath ?? input?.path
24
+ return typeof path === 'string' ? path : undefined
25
+ }
26
+
14
27
  export interface ToolCallCardProps {
15
28
  item: ToolCallItem
29
+ /**
30
+ * Reads a host file as a data URL, for tools whose output is a picture on the
31
+ * host. Resolves `undefined` when the gateway won't serve that path — a
32
+ * generated image saved outside the allowed roots (codex's default
33
+ * `$CODEX_HOME/generated_images/`) is one, and the card then names the path
34
+ * instead of showing it.
35
+ */
36
+ hostImage?: (path: string) => Promise<string | undefined>
16
37
  className?: string
17
38
  }
18
39
 
@@ -27,12 +48,14 @@ const STATE_BADGE = {
27
48
  failed: { label: 'Error', variant: 'danger', busy: false },
28
49
  } as const
29
50
 
30
- export function ToolCallCard({ item, className }: ToolCallCardProps) {
51
+ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps) {
31
52
  const [open, setOpen] = useState(false)
32
53
  const [fullResult, setFullResult] = useState(false)
54
+ const imagePath = imagePathOf(item)
33
55
  const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
34
56
  const badge = STATE_BADGE[status]
35
57
  const isError = status === 'failed' || item.result?.isError === true
58
+ const Icon = toolIcon(item.name)
36
59
 
37
60
  const resultText = item.result?.text ?? ''
38
61
  const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
@@ -50,8 +73,13 @@ export function ToolCallCard({ item, className }: ToolCallCardProps) {
50
73
  'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors outline-none',
51
74
  'hover:bg-surface-hover focus-visible:bg-surface-hover',
52
75
  )}>
53
- <Wrench className='size-3.5 shrink-0 text-fg-3' />
76
+ <Icon className='size-3.5 shrink-0 text-fg-3' />
54
77
  <span className='shrink-0 font-mono text-body-sm font-medium text-fg-1'>{item.name}</span>
78
+ {item.backend && item.backend !== 'server' ? (
79
+ <Badge variant='neutral' className='shrink-0'>
80
+ {item.backend}
81
+ </Badge>
82
+ ) : null}
55
83
  <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-4'>
56
84
  {toolInputPreview(item.input)}
57
85
  </span>
@@ -64,6 +92,11 @@ export function ToolCallCard({ item, className }: ToolCallCardProps) {
64
92
  className={cn('size-3.5 shrink-0 text-fg-4 transition-transform', open && 'rotate-180')}
65
93
  />
66
94
  </button>
95
+ {/* The picture is the point of the call — shown without expanding, the
96
+ way the tool's own output would be if the engine had sent bytes. */}
97
+ {imagePath && hostImage ? (
98
+ <HostImage path={imagePath} load={hostImage} />
99
+ ) : null}
67
100
  {open ? (
68
101
  <div className='flex flex-col gap-2 border-t border-border p-2.5'>
69
102
  <CodeBlock code={JSON.stringify(item.input, null, 2)} label='Parameters' />
@@ -92,3 +125,46 @@ export function ToolCallCard({ item, className }: ToolCallCardProps) {
92
125
  </div>
93
126
  )
94
127
  }
128
+
129
+ /**
130
+ * A picture that lives on the host, fetched through the gateway's host-file
131
+ * route and shown inline.
132
+ *
133
+ * Silent on failure by design: a path outside the server's allowed roots is the
134
+ * *expected* case for codex's default save location, and the card's result text
135
+ * already names where the file went. An error banner over that would be noise
136
+ * about a thing the operator can fix in one line of config.
137
+ */
138
+ function HostImage({
139
+ path,
140
+ load,
141
+ }: {
142
+ path: string
143
+ load: (path: string) => Promise<string | undefined>
144
+ }) {
145
+ const [src, setSrc] = useState<string | undefined>()
146
+ useEffect(() => {
147
+ let cancelled = false
148
+ setSrc(undefined)
149
+ load(path)
150
+ .then((url) => {
151
+ if (!cancelled) setSrc(url)
152
+ })
153
+ .catch(() => {
154
+ // Not readable from here — the path in the result is the answer.
155
+ })
156
+ return () => {
157
+ cancelled = true
158
+ }
159
+ }, [path, load])
160
+ if (!src) return null
161
+ return (
162
+ <div className='border-t border-border p-2.5'>
163
+ <img
164
+ src={src}
165
+ alt={path.split('/').pop() ?? 'Generated image'}
166
+ className='max-h-96 w-auto max-w-full rounded-md border border-border'
167
+ />
168
+ </div>
169
+ )
170
+ }
@@ -1,3 +1,4 @@
1
+ import type { MessageAttachment } from '@workerdeck/protocol'
1
2
  import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
2
3
  import { cn } from '../../lib/utils.ts'
3
4
  import { formatCost, formatDuration } from '../../lib/format.ts'
@@ -5,19 +6,34 @@ import { Conversation, ConversationContent, ConversationScrollButton } from './C
5
6
  import { FileCard } from './FileCard.tsx'
6
7
  import { Loader } from './Loader.tsx'
7
8
  import { Message, MessageContent } from './Message.tsx'
9
+ import { PromptTokenText } from './PromptTokenText.tsx'
8
10
  import { Reasoning } from './Reasoning.tsx'
9
11
  import { Response } from './Response.tsx'
12
+ import { SessionEmptyState } from './SessionEmptyState.tsx'
10
13
  import { ToolCallCard } from './ToolCallCard.tsx'
11
14
 
12
15
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
13
16
  return (
14
- <div data-slot='turn-result' className='flex items-center gap-2 py-1'>
15
- <div className='h-px flex-1 bg-border' />
16
- <span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
17
- {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
18
- {formatCost(item.totalCostUsd)}
19
- </span>
20
- <div className='h-px flex-1 bg-border' />
17
+ <div data-slot='turn-result' className='py-1'>
18
+ <div className='flex items-center gap-2'>
19
+ <div className='h-px flex-1 bg-border' />
20
+ <span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
21
+ {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
22
+ {formatCost(item.totalCostUsd)}
23
+ </span>
24
+ <div className='h-px flex-1 bg-border' />
25
+ </div>
26
+ {/* A failed turn's reasons are the whole point of the row — dropping them
27
+ leaves "error_during_execution" and nothing to act on. */}
28
+ {item.errors?.length ? (
29
+ <ul className='mt-1 flex flex-col gap-0.5 text-center'>
30
+ {item.errors.map((message, index) => (
31
+ <li key={index} className='text-label break-words text-danger'>
32
+ {message}
33
+ </li>
34
+ ))}
35
+ </ul>
36
+ ) : null}
21
37
  </div>
22
38
  )
23
39
  }
@@ -40,15 +56,27 @@ function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }>
40
56
  function TranscriptItemView({
41
57
  item,
42
58
  fileUrl,
59
+ attachmentUrl,
60
+ hostImage,
43
61
  }: {
44
62
  item: TranscriptItem
45
63
  fileUrl?: (path: string) => string
64
+ attachmentUrl?: (attachmentId: string) => string
65
+ hostImage?: (path: string) => Promise<string | undefined>
46
66
  }) {
47
67
  switch (item.kind) {
48
68
  case 'user':
49
69
  return (
50
70
  <Message from='user'>
51
- <MessageContent>{item.text}</MessageContent>
71
+ {item.attachments?.length ? (
72
+ <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} />
73
+ ) : null}
74
+ {/* A photo can be the whole message — an empty bubble under it says nothing. */}
75
+ {item.text ? (
76
+ <MessageContent>
77
+ <PromptTokenText text={item.text} />
78
+ </MessageContent>
79
+ ) : null}
52
80
  </Message>
53
81
  )
54
82
  case 'assistant_text':
@@ -62,7 +90,7 @@ function TranscriptItemView({
62
90
  case 'thinking':
63
91
  return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
64
92
  case 'tool_call':
65
- return <ToolCallCard item={item} />
93
+ return <ToolCallCard item={item} hostImage={hostImage} />
66
94
  case 'turn_result':
67
95
  return <TurnResultRow item={item} />
68
96
  case 'notice':
@@ -85,23 +113,92 @@ function showLoader(state: TranscriptState): boolean {
85
113
  return last.kind !== 'turn_result' || state.status === 'running'
86
114
  }
87
115
 
116
+ /** Files sent with a message: thumbnails for images, named chips for the rest.
117
+ * References only — the bytes are fetched from the gateway. */
118
+ function SentAttachments({
119
+ attachments,
120
+ attachmentUrl,
121
+ }: {
122
+ attachments: MessageAttachment[]
123
+ attachmentUrl?: (attachmentId: string) => string
124
+ }) {
125
+ return (
126
+ <div className='mb-1 flex flex-wrap justify-end gap-1.5'>
127
+ {attachments.map((attachment) => {
128
+ const href = attachmentUrl?.(attachment.id)
129
+ return attachment.mediaType.startsWith('image/') && href ? (
130
+ <img
131
+ key={attachment.id}
132
+ src={href}
133
+ alt={attachment.name}
134
+ className='size-20 rounded-md border border-border object-cover'
135
+ />
136
+ ) : (
137
+ <span
138
+ key={attachment.id}
139
+ className='rounded-full border border-border bg-surface px-2.5 py-1 text-body-xs text-fg-3'>
140
+ {attachment.name}
141
+ </span>
142
+ )
143
+ })}
144
+ </div>
145
+ )
146
+ }
147
+
148
+ /** Rows produced inside a subagent (`parentToolUseId != null`) are stepped in
149
+ * behind a rule, so a Task's own output reads as belonging to the tool call
150
+ * above it rather than as the main thread carrying on. */
151
+ function nestedClass(item: TranscriptItem): string | undefined {
152
+ const nested = 'parentToolUseId' in item && item.parentToolUseId != null
153
+ return nested ? 'border-l-2 border-border pl-3' : undefined
154
+ }
155
+
88
156
  export interface TranscriptProps {
89
157
  state: TranscriptState
90
158
  /** Builds the download URL for a delivered file (see FileCard). Typically
91
159
  * `(path) => client.sessionFileUrl(sessionId, path)`. */
92
160
  fileUrl?: (path: string) => string
161
+ /** Builds the URL for an uploaded attachment. Typically
162
+ * `(id) => client.attachmentUrl(sessionId, id)`. Same-origin and
163
+ * cookie-authenticated, which is what lets an `<img src>` render one. */
164
+ attachmentUrl?: (attachmentId: string) => string
165
+ /** Whether this gateway serves `@file` search here — the empty state must not
166
+ * advertise an affordance the composer doesn't have. */
167
+ canBrowseFiles?: boolean
168
+ /** Reads a host file as a data URL, for tool calls whose output is a picture
169
+ * on the host (codex's `image_gen`). Omit and those cards name the path. */
170
+ hostImage?: (path: string) => Promise<string | undefined>
93
171
  className?: string
94
172
  }
95
173
 
96
- export function Transcript({ state, fileUrl, className }: TranscriptProps) {
174
+ export function Transcript({
175
+ state,
176
+ fileUrl,
177
+ attachmentUrl,
178
+ canBrowseFiles,
179
+ hostImage,
180
+ className,
181
+ }: TranscriptProps) {
97
182
  return (
98
183
  <Conversation className={className}>
99
184
  <ConversationContent>
100
185
  {state.items.length === 0 && state.status !== 'starting' ? (
101
- <div className='py-12 text-center text-body-sm text-fg-4'>No messages yet.</div>
186
+ <SessionEmptyState
187
+ cwd={state.cwd}
188
+ hasCommands={!!state.commands?.length}
189
+ hasSkills={!!state.skills?.some((s) => s.enabled)}
190
+ canBrowseFiles={canBrowseFiles}
191
+ />
102
192
  ) : (
103
193
  state.items.map((item) => (
104
- <TranscriptItemView key={`${item.kind}:${item.id}`} item={item} fileUrl={fileUrl} />
194
+ <div key={`${item.kind}:${item.id}`} className={nestedClass(item)}>
195
+ <TranscriptItemView
196
+ item={item}
197
+ fileUrl={fileUrl}
198
+ attachmentUrl={attachmentUrl}
199
+ hostImage={hostImage}
200
+ />
201
+ </div>
105
202
  ))
106
203
  )}
107
204
  {showLoader(state) ? (
@@ -0,0 +1,168 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { ProfileEngine, RateLimitInfo } from '@workerdeck/protocol'
3
+ import { RotateCcw } from 'lucide-react'
4
+ import { Badge } from '../ui/Badge.tsx'
5
+ import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
6
+ import { cn } from '../../lib/utils.ts'
7
+ import {
8
+ formatAgoPrecise,
9
+ formatCost,
10
+ formatCountdown,
11
+ formatRateLimitWindowLong,
12
+ rateLimitWindowSeconds,
13
+ } from '../../lib/format.ts'
14
+
15
+ export interface UsageDialogProps {
16
+ /** Windows in reading order — session, weekly, then per-model weeklies. */
17
+ rateLimits: Array<{ key: string; info: RateLimitInfo }>
18
+ /** claude.ai plan behind the windows ('max', 'pro', …), when there is one. */
19
+ subscriptionType?: string
20
+ engine: ProfileEngine
21
+ totalCostUsd: number
22
+ /** Local receipt time of the last window update. `rate_limit` events are one
23
+ * per turn at best, so a stale reading is normal and worth saying out loud. */
24
+ updatedAt?: number
25
+ open: boolean
26
+ onOpenChange: (open: boolean) => void
27
+ }
28
+
29
+ /** Ticking clock — the countdowns and the pace markers both move with it, and a
30
+ * minute is the finest resolution either of them prints. */
31
+ function useMinuteClock(open: boolean): number {
32
+ const [now, setNow] = useState(() => Date.now())
33
+ useEffect(() => {
34
+ if (!open) return
35
+ setNow(Date.now())
36
+ const timer = setInterval(() => setNow(Date.now()), 60_000)
37
+ return () => clearInterval(timer)
38
+ }, [open])
39
+ return now
40
+ }
41
+
42
+ const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
43
+
44
+ /**
45
+ * The plan's rate-limit windows, spelled out: how much of each is used, how that
46
+ * compares to the pace that would spend the window exactly, and when it resets.
47
+ *
48
+ * The pace marker is the point. A bar alone says "17% used", which is only
49
+ * alarming or reassuring once you know how far into the week you are — so every
50
+ * window draws a tick at the elapsed share of its duration. Left of the tick is
51
+ * under budget, right of it is ahead of it. The duration comes from the window
52
+ * key (5h, 7d) because the CLI reports a reset time and a percentage and never a
53
+ * duration; a window whose key doesn't say gets no marker rather than a guessed one.
54
+ */
55
+ export function UsageDialog({
56
+ rateLimits,
57
+ subscriptionType,
58
+ engine,
59
+ totalCostUsd,
60
+ updatedAt,
61
+ open,
62
+ onOpenChange,
63
+ }: UsageDialogProps) {
64
+ const now = useMinuteClock(open)
65
+ return (
66
+ <Dialog open={open} onOpenChange={onOpenChange}>
67
+ <DialogContent>
68
+ <DialogHeader
69
+ title='Usage'
70
+ description={engine === 'claude' ? 'Claude Code' : engine}
71
+ actions={
72
+ // The CLI reports a tier ('max'), never the multiplier a
73
+ // subscription page shows — so this says "Max" and stops there.
74
+ subscriptionType ? (
75
+ <Badge variant='accent' className='mt-0.5 shrink-0 capitalize'>
76
+ {subscriptionType}
77
+ </Badge>
78
+ ) : null
79
+ }
80
+ />
81
+ <DialogBody>
82
+ {rateLimits.length === 0 ? (
83
+ <p className='py-6 text-center text-body-sm text-fg-4'>
84
+ {engine === 'claude'
85
+ ? 'This session reports no plan windows — API-key sessions have none, and a subscription session reports them once a turn has run.'
86
+ : `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
87
+ </p>
88
+ ) : (
89
+ <div className='flex flex-col gap-5'>
90
+ {rateLimits.map(({ key, info }) => (
91
+ <UsageWindow key={key} windowKey={key} info={info} now={now} />
92
+ ))}
93
+ </div>
94
+ )}
95
+ <div className='mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3'>
96
+ <span className='text-label text-fg-3'>This session has cost</span>
97
+ <span className='font-mono text-body-sm text-fg-1'>{formatCost(totalCostUsd)}</span>
98
+ </div>
99
+ {updatedAt ? (
100
+ <p className='mt-2 text-label text-fg-4'>Updated {formatAgoPrecise(updatedAt, now)}</p>
101
+ ) : null}
102
+ </DialogBody>
103
+ </DialogContent>
104
+ </Dialog>
105
+ )
106
+ }
107
+
108
+ function UsageWindow({
109
+ windowKey,
110
+ info,
111
+ now,
112
+ }: {
113
+ windowKey: string
114
+ info: RateLimitInfo
115
+ now: number
116
+ }) {
117
+ const utilization = info.utilization ?? 0
118
+ const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
119
+ // Share of the window already elapsed — where usage *would* be if it were
120
+ // spent evenly. Needs both a duration (from the key) and a reset time.
121
+ const duration = rateLimitWindowSeconds(windowKey)
122
+ const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
123
+ const pace =
124
+ duration !== undefined && remaining !== undefined && remaining > 0 && remaining < duration
125
+ ? (duration - remaining) / duration
126
+ : undefined
127
+ return (
128
+ <div>
129
+ <div className='flex items-baseline justify-between gap-3'>
130
+ <span className='truncate text-body-sm text-fg-1'>
131
+ {formatRateLimitWindowLong(windowKey)}
132
+ </span>
133
+ <span
134
+ className={cn(
135
+ 'shrink-0 font-mono text-body-sm font-medium',
136
+ info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
137
+ )}>
138
+ {utilization.toFixed(0)}% used
139
+ </span>
140
+ </div>
141
+ <div className='relative mt-2 h-2 rounded-full bg-border'>
142
+ <div
143
+ className={cn('h-full rounded-full', usageTint(utilization))}
144
+ // A floor, so a barely-touched window still shows a mark instead of
145
+ // reading as missing data.
146
+ style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
147
+ />
148
+ {pace !== undefined ? (
149
+ <span
150
+ aria-hidden
151
+ title='Spent evenly, usage would be here by now'
152
+ className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
153
+ style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
154
+ />
155
+ ) : null}
156
+ </div>
157
+ <div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
158
+ {resetsAtMs !== undefined ? (
159
+ <span className='inline-flex items-center gap-1'>
160
+ <RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
161
+ </span>
162
+ ) : null}
163
+ {info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
164
+ {info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
165
+ </div>
166
+ </div>
167
+ )
168
+ }
@@ -288,6 +288,59 @@ export function resolveChip(
288
288
  return { segments: merged, cursorOffset }
289
289
  }
290
290
 
291
+ /**
292
+ * Replaces the active trigger's range with **plain text** instead of a chip.
293
+ *
294
+ * The sibling of {@link resolveChip}, for suggestions that are a typing aid
295
+ * rather than a token: what lands in the document is ordinary editable text the
296
+ * user is expected to finish and change, and it must not look or behave like a
297
+ * resolved chip — no immutability, no trigger character, nothing for a consumer
298
+ * to parse back out. The caret is left at the end of the inserted text so typing
299
+ * continues from there.
300
+ */
301
+ export function resolveText(
302
+ segments: Segment[],
303
+ activeTrigger: ActiveTrigger,
304
+ text: string,
305
+ ): { segments: Segment[]; cursorOffset: number } {
306
+ const triggerStart = activeTrigger.startOffset
307
+ const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
308
+
309
+ const newSegments: Segment[] = []
310
+ let offset = 0
311
+ let cursorOffset = triggerStart + text.length
312
+
313
+ for (const seg of segments) {
314
+ if (seg.type === 'chip') {
315
+ const chipEnd = offset + `${seg.trigger}${seg.displayText}`.length
316
+ // A trigger range can never overlap a chip — chips are atomic — so a chip
317
+ // is either wholly before or wholly after, and is kept either way.
318
+ if (chipEnd <= triggerStart || offset >= triggerEnd) newSegments.push(seg)
319
+ offset = chipEnd
320
+ continue
321
+ }
322
+ const textStart = offset
323
+ const textEnd = offset + seg.text.length
324
+ if (textEnd <= triggerStart || textStart >= triggerEnd) {
325
+ newSegments.push(seg)
326
+ } else {
327
+ const before = seg.text.slice(0, Math.max(0, triggerStart - textStart))
328
+ const after = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart))
329
+ if (before) newSegments.push({ type: 'text', text: before })
330
+ newSegments.push({ type: 'text', text })
331
+ if (after) newSegments.push({ type: 'text', text: after })
332
+ }
333
+ offset = textEnd
334
+ }
335
+
336
+ const merged = mergeAdjacentTextSegments(newSegments)
337
+ // Clamp: a trigger detected against a stale document could otherwise leave the
338
+ // caret past the end, which renders as no caret at all.
339
+ const total = segmentsToPlainText(merged).length
340
+ if (cursorOffset > total) cursorOffset = total
341
+ return { segments: merged, cursorOffset }
342
+ }
343
+
291
344
  // ---------------------------------------------------------------------------
292
345
  // Chip removal
293
346
  // ---------------------------------------------------------------------------
@@ -103,6 +103,21 @@ export type TriggerConfig = {
103
103
  * Return the display text for the chip, or void to use `suggestion.label`.
104
104
  */
105
105
  onSelect?: (suggestion: TriggerSuggestion) => string | void
106
+ /**
107
+ * For 'dropdown' mode: opt a suggestion out of becoming a chip.
108
+ *
109
+ * Return a string and the trigger's range is replaced with that **plain,
110
+ * editable text** — the trigger character included — with the caret left at
111
+ * its end. Return undefined and the suggestion resolves to a chip as usual,
112
+ * so one dropdown can mix both kinds.
113
+ *
114
+ * For suggestions that are a typing aid rather than a token: something the
115
+ * user is meant to finish and edit, where a chip would falsely promise the
116
+ * host parses it back out. Takes precedence over `onSelect`, which is not
117
+ * called for a text-resolved suggestion (there is no chip to label), and
118
+ * `onChipAdd` does not fire either.
119
+ */
120
+ insertAsText?: (suggestion: TriggerSuggestion) => string | undefined
106
121
  /**
107
122
  * For 'callback' and 'launch' modes: called when the trigger is activated.
108
123
  * Receives the full input text and cursor position. For 'launch' it fires on
@@ -17,6 +17,7 @@ import {
17
17
  plainTextToSegments,
18
18
  segmentsEqual,
19
19
  resolveChip,
20
+ resolveText,
20
21
  removeChipAtIndex,
21
22
  revertChipAtIndex,
22
23
  replaceTextRange,
@@ -929,6 +930,25 @@ export function usePromptArea({
929
930
  if (!activeTrigger) return
930
931
 
931
932
  const segments = readSegmentsFromDOM()
933
+
934
+ // Text-resolved suggestions leave before any chip machinery runs: there
935
+ // is no chip, so there is nothing for the chip-click editing path or the
936
+ // onChipAdd callback below to be about.
937
+ const asText = activeTrigger.config.insertAsText?.(suggestion)
938
+ if (asText !== undefined) {
939
+ const inserted = resolveText(segments, activeTrigger, asText)
940
+ events.pushUndo(segments)
941
+ onChange(inserted.segments)
942
+ renderSegmentsToDOM(inserted.segments)
943
+ const editor = editorRef.current
944
+ if (editor) setCursorAtOffset(editor, inserted.cursorOffset)
945
+ dismissTrigger()
946
+ setTimeout(() => {
947
+ editorRef.current?.focus()
948
+ }, 0)
949
+ return
950
+ }
951
+
932
952
  const displayText = activeTrigger.config.onSelect?.(suggestion) ?? suggestion.label
933
953
 
934
954
  const chipData = {
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react'
2
2
  import { Check, Copy } from 'lucide-react'
3
3
  import { Button, type ButtonProps } from './Button.tsx'
4
+ import { copyText } from '../../lib/clipboard.ts'
4
5
  import { cn } from '../../lib/utils.ts'
5
6
 
6
7
  export interface CopyButtonProps extends Omit<ButtonProps, 'onClick' | 'children'> {
@@ -16,7 +17,13 @@ export function CopyButton({ value, className, variant = 'ghost', size = 'icon-s
16
17
  aria-label='Copy'
17
18
  className={cn('text-fg-3', className)}
18
19
  onClick={() => {
19
- void navigator.clipboard.writeText(value).then(() => {
20
+ // Through `copyText`, which falls back for insecure origins — the
21
+ // dashboard on a LAN address has no `navigator.clipboard` at all, and
22
+ // reaching straight for `.writeText` there throws.
23
+ void copyText(value).then((ok) => {
24
+ // Only tick when it really copied: a check mark over an empty
25
+ // clipboard is worse than no feedback.
26
+ if (!ok) return
20
27
  setCopied(true)
21
28
  setTimeout(() => setCopied(false), 1500)
22
29
  })