@weft-js/warp 0.0.0-stage → 0.1.1

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 (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,31 @@
1
+ import { contextLevel, formatTokens } from '@weft-js/warp-core'
2
+ import { derived } from '@weft-js/core'
3
+ import { Button } from '../Button'
4
+ import { Tooltip } from '../Tooltip'
5
+ import '../styles'
6
+ import '@weft-js/warp-core/styles/app/context-meter.css'
7
+
8
+ /** How full the context window is, and optionally what with. */
9
+ export type ContextUse = { used: number; limit: number; parts?: { label: string; tokens: number }[] | undefined }
10
+
11
+ /**
12
+ * ContextMeter (07 §1.2): a ring and a number, caution at 80%, critical at 95%, where `onCompact` offers /compact. The
13
+ * tooltip lists what fills it. `showTokens` adds the count beside the percent.
14
+ */
15
+ export function ContextMeter({ used, limit, parts, showTokens, onCompact }: ContextUse & { showTokens?: boolean; onCompact?: (() => void) | undefined }) {
16
+ const ratio = derived(() => Math.min(1, limit > 0 ? used / limit : 0))
17
+ const level = derived(() => contextLevel(ratio.value))
18
+ const pct = derived(() => Math.round(ratio.value * 100))
19
+ const tip = derived(() => [`${formatTokens(used)} of ${formatTokens(limit)} tokens`, ...(parts ?? []).map(p => `${p.label} ${formatTokens(p.tokens)}`)].join(' · '))
20
+ return (
21
+ <>
22
+ <Tooltip content={tip.value}>
23
+ <span data-scope="context-meter" data-level={level.value} role="meter" tabindex="0" aria-label="Context used" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct.value} aria-valuetext={`${pct.value}%, ${formatTokens(used)} of ${formatTokens(limit)} tokens`} style={`--_v:${ratio.value}`}>
24
+ <svg viewBox="0 0 36 36" aria-hidden="true"><circle cx="18" cy="18" r="15.9" stroke="var(--warp-color-surface-active)" pathLength="100" /><circle cx="18" cy="18" r="15.9" pathLength="100" /></svg>
25
+ <span>{showTokens ? `${pct.value}% · ${formatTokens(used)}` : `${pct.value}%`}</span>
26
+ </span>
27
+ </Tooltip>
28
+ {level.value && onCompact && <Button emphasis="link" tone="neutral" size="sm" onClick={onCompact}>/compact</Button>}
29
+ </>
30
+ )
31
+ }
@@ -0,0 +1,34 @@
1
+ import { DownIcon } from '@weft-js/warp-icons'
2
+ import { stick } from '@weft-js/warp-core'
3
+ import { derived, state } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import '../styles'
7
+ import '@weft-js/warp-core/styles/app/tokens.css'
8
+ import '@weft-js/warp-core/styles/app/base.css'
9
+ import '@weft-js/warp-core/styles/app/thread.css'
10
+
11
+ /**
12
+ * Conversation (07 §1.1, §1.3, §1.6): role=log with aria-live off (a turn announces its own end), keyed turns as
13
+ * `children`, an optional app bar and the Prompt below. It sticks to the bottom only within 48px of it; otherwise Jump
14
+ * to latest appears, and pressing it goes back to the end and focuses the log.
15
+ */
16
+ export function Conversation({ label = 'Assistant conversation', title, bar, prompt, children }: { label?: string | undefined; title?: unknown; bar?: unknown; prompt?: unknown; children?: unknown }) {
17
+ const away = state(false)
18
+ const head = derived(() => title !== undefined || bar !== undefined)
19
+ let log: HTMLElement | undefined
20
+ use(stick, () => log, () => ({ onAway: (a: boolean) => (away.value = a) }))
21
+ const jump = () => {
22
+ log!.scrollTop = log!.scrollHeight
23
+ log!.focus({ preventScroll: true })
24
+ away.value = false
25
+ }
26
+ return (
27
+ <section data-scope="thread" data-part="root" aria-label={label}>
28
+ {head.value && <div data-part="app-bar"><h2 data-part="app-title">{title}</h2><span data-part="spacer"></span>{bar}</div>}
29
+ <div ref={n => (log = n)} data-part="log" role="log" aria-live="off" aria-label="Messages" tabindex="0">{children}</div>
30
+ {away.value && <Button emphasis="soft" tone="neutral" size="sm" data-part="jump" onClick={jump}><DownIcon />Jump to latest</Button>}
31
+ <div style="grid-row:3">{prompt}</div>
32
+ </section>
33
+ )
34
+ }
@@ -0,0 +1,53 @@
1
+ import { CopyIcon, EditIcon } from '@weft-js/warp-icons'
2
+ import { copyConfirm, installSweep } from '@weft-js/warp-core'
3
+ import { onMount, state, untrack } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { Textarea } from '../Field'
6
+ import { IconButton, Sweep } from './bits'
7
+ import '../styles'
8
+ import '@weft-js/warp-core/styles/app/message.css'
9
+
10
+ /**
11
+ * The user's turn (07 §1.1): a prompt bubble with edit and copy. `arrive` is a message just sent: it enters and
12
+ * confirms one sweep. Edit opens the bubble as a Textarea in place; saving is `onEdit` (a new branch).
13
+ */
14
+ export function Message({ text, arrive, onEdit }: { text: string; arrive?: boolean; onEdit?: ((text: string) => void) | undefined }) {
15
+ const editing = state(false)
16
+ const draft = state('')
17
+ let bubble: HTMLElement | undefined
18
+ let root: HTMLElement | undefined
19
+ onMount(() => { const s = installSweep(); if (untrack(() => arrive)) requestAnimationFrame(() => s.confirm(bubble)) })
20
+ const open = () => {
21
+ draft.value = text
22
+ editing.value = true
23
+ queueMicrotask(() => root?.querySelector('textarea')?.focus())
24
+ }
25
+ const close = () => {
26
+ editing.value = false
27
+ queueMicrotask(() => root?.querySelector<HTMLElement>('[aria-label="Edit message"]')?.focus())
28
+ }
29
+ const save = () => { const t = draft.value.trim(); close(); if (t && t !== text) onEdit?.(t) }
30
+ return (
31
+ <div ref={n => (root = n)} data-scope="message" data-mine="" data-arrive={arrive ? '' : undefined}>
32
+ <div data-part="body">
33
+ {editing.value ? (
34
+ <>
35
+ <Textarea label="Edit message" value={draft.value} onInput={e => (draft.value = e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); close() } }} />
36
+ <div data-part="status">
37
+ <Button size="sm" onClick={save}>Save</Button>
38
+ <Button emphasis="ghost" tone="neutral" size="sm" onClick={close}>Cancel</Button>
39
+ </div>
40
+ </>
41
+ ) : (
42
+ <>
43
+ <div ref={n => (bubble = n)} data-part="bubble" data-variant="prompt" data-sweep=""><Sweep />{text}</div>
44
+ <div data-part="status" style="gap:2px">
45
+ {onEdit && <IconButton icon={EditIcon} label="Edit message" onClick={open} />}
46
+ <IconButton icon={CopyIcon} label="Copy" onClick={(e: Event) => copyConfirm(e.currentTarget as Element, text, bubble)} />
47
+ </div>
48
+ </>
49
+ )}
50
+ </div>
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,65 @@
1
+ import { DownIcon } from '@weft-js/warp-icons'
2
+ import { announce, nextIndex, popover, uid } from '@weft-js/warp-core'
3
+ import { derived, state, type State } from '@weft-js/core'
4
+ import { Badge } from '../Badge'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import { held } from './bits'
7
+ import '../styles'
8
+ import '@weft-js/warp-core/styles/components/popover.css'
9
+ import '@weft-js/warp-core/styles/app/prompt.css'
10
+ import '@weft-js/warp-core/styles/app/suggest.css'
11
+ import '@weft-js/warp-core/styles/app/model-menu.css'
12
+
13
+ /** A model on offer. */
14
+ export type ModelOption = { id: string; name: string; short?: string; provider?: string | undefined; description?: string | undefined; capabilities?: string[]; context?: string; cost?: string }
15
+
16
+ /**
17
+ * ModelPicker (07 §1.3): the model chip opens a Popover holding a listbox grouped by provider. Each option shows its
18
+ * capabilities, context size and relative cost; the current one is checked. ↑/↓, Home/End move, Enter picks, Escape
19
+ * closes; focus goes back to the chip. `compact` shows the short name.
20
+ */
21
+ export function ModelPicker({ models, value, onChange, compact }: { models: ModelOption[]; value?: string | State<string | undefined> | undefined; onChange?: ((id: string) => void) | undefined; compact?: boolean | undefined }) {
22
+ const id = uid('model')
23
+ const open = state(false)
24
+ const picked = held<string | undefined>(() => value, undefined, () => (v: string | undefined) => onChange?.(v!))
25
+ const current = derived(() => models.find(m => m.id === picked.value) ?? models[0])
26
+ const groups = derived(() => [...new Set(models.map(m => m.provider ?? ''))].map(name => ({ name, models: models.filter(m => (m.provider ?? '') === name) })))
27
+ let chip: HTMLButtonElement | undefined
28
+ let panel: HTMLElement | undefined
29
+ use(popover, () => panel, () => ({ open: open.value, trigger: chip!, side: 'top' as const, align: 'end' as const, onClose: () => (open.value = false) }))
30
+ const choose = (m: ModelOption) => {
31
+ picked.set(m.id)
32
+ open.value = false
33
+ announce(panel!.ownerDocument, `Model: ${m.name}`)
34
+ }
35
+ const onKey = (e: KeyboardEvent) => {
36
+ const list = [...panel!.querySelectorAll<HTMLElement>('[role="option"]')]
37
+ const at = list.indexOf(e.target as HTMLElement)
38
+ const to = nextIndex(e.key, at, list.length, { orientation: 'vertical' })
39
+ if (to >= 0) { e.preventDefault(); list[to]!.focus() }
40
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); list[at]?.click() }
41
+ }
42
+ return (
43
+ <span data-scope="prompt" style="display:contents">
44
+ <button ref={n => (chip = n)} type="button" data-part="model" aria-haspopup="listbox" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} onClick={() => (open.value = !open.value)}>
45
+ <i></i><span data-part="sr-only">Model: </span><span>{compact ? (current.value?.short ?? current.value?.name) : current.value?.name}</span><DownIcon />
46
+ </button>
47
+ <div ref={n => (panel = n)} id={id} data-scope="popover" data-part="content" popover="manual" style="padding:4px;min-inline-size:280px">
48
+ <div data-scope="model-menu" role="listbox" aria-label="Model" onKeyDown={onKey}>
49
+ {groups.value.map(g => (
50
+ <div key={g.name} role="group" aria-label={g.name || 'Models'}>
51
+ <div data-scope="suggest" style="all:unset"><div data-part="group" aria-hidden="true">{g.name || 'Models'}</div></div>
52
+ {g.models.map(m => (
53
+ <div key={m.id} role="option" tabindex="-1" aria-selected={m.id === current.value?.id ? 'true' : 'false'} data-initial={m.id === current.value?.id ? '' : undefined} onClick={() => choose(m)}>
54
+ <b>{m.name}</b>
55
+ <small>{m.description} {(m.capabilities ?? []).map(c => <Badge key={c} tone="neutral">{c}</Badge>)}</small>
56
+ <code>{[m.context, m.cost].filter(Boolean).join(' · ')}</code>
57
+ </div>
58
+ ))}
59
+ </div>
60
+ ))}
61
+ </div>
62
+ </div>
63
+ </span>
64
+ )
65
+ }
@@ -0,0 +1,197 @@
1
+ import { CommandIcon, FileIcon, GlobeIcon, MicIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from '@weft-js/warp-icons'
2
+ import { announce, applySuggest, autosize, installSweep, suggestQuery, uid } from '@weft-js/warp-core'
3
+ import { derived, onMount, state, type State } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import { Bar, held, IconButton, Sweep } from './bits'
7
+ import { ContextMeter, type ContextUse } from './ContextMeter'
8
+ import { ModelPicker, type ModelOption } from './ModelPicker'
9
+ import '../styles'
10
+ import '@weft-js/warp-core/styles/app/prompt.css'
11
+ import '@weft-js/warp-core/styles/app/composer.css'
12
+ import '@weft-js/warp-core/styles/app/suggest.css'
13
+
14
+ /** A tool the composer's row can offer. */
15
+ export type PromptTool = 'attach' | 'slash' | 'mic' | 'web'
16
+ /** A file attached to the prompt. */
17
+ export type PromptFile = { id: string; name: string; progress?: number }
18
+ /** A completion shown under the prompt. */
19
+ export type PromptSuggestion = { value: string; description?: string | undefined }
20
+ /** What the prompt hands out through `control`. */
21
+ export interface PromptControl { insert(text: string): void; focus(): void }
22
+
23
+ /** Props for Prompt. */
24
+ export interface PromptProps {
25
+ value?: State<string> | string
26
+ /** Called with the draft on every keystroke. */
27
+ onValueChange?: (v: string) => void
28
+ onSubmit?: (text: string) => void
29
+ /** Pressing Stop or Escape while busy. Stop keeps what arrived. */
30
+ onStop?: () => void
31
+ /** A message sent while busy goes to the queue. */
32
+ onQueue?: (text: string) => void
33
+ busy?: boolean | undefined
34
+ /** Why Send is off ("You're offline"): the button stays focusable and says why. */
35
+ blocked?: string
36
+ label?: string | undefined
37
+ placeholder?: string
38
+ tools?: PromptTool[]
39
+ /** The picked model's id: a state, or a plain value with `onModelChange`. */
40
+ model?: State<string> | string
41
+ models?: ModelOption[]
42
+ /** Called with the id of the model picked. */
43
+ onModelChange?: (id: string) => void
44
+ context?: ContextUse
45
+ /** Called from the context meter's Compact: summarise the conversation to free room. */
46
+ onCompact?: () => void
47
+ /** Attached files shown as chips above the input, with upload progress. */
48
+ files?: PromptFile[]
49
+ /** Your own attachment row, in place of `files`. */
50
+ attachments?: unknown
51
+ /** Called with the files picked or dropped. */
52
+ onAttach?: (files: File[]) => void
53
+ /** Called with a file's id when its chip is removed. */
54
+ onRemoveFile?: (id: string) => void
55
+ /** What `/` offers: one suggestion each, with a description. */
56
+ commands?: PromptSuggestion[]
57
+ /** What `@` offers: one suggestion each, with a description. */
58
+ mentions?: PromptSuggestion[]
59
+ /** Whether the Web toggle is on. */
60
+ web?: boolean
61
+ /** Called with the Web toggle's new state. */
62
+ onWebChange?: (on: boolean) => void
63
+ /** Whether dictation is on: the mic button turns critical. */
64
+ mic?: boolean
65
+ /** Called when the mic button is pressed. */
66
+ onMic?: () => void
67
+ /** Phone: fewer tools, the model's short name. */
68
+ compact?: boolean | undefined
69
+ /** Hands over insert() and focus(), for SpeechInput's Insert. */
70
+ control?: ((c: PromptControl) => void) | undefined
71
+ /** Above the composer: Suggestions. */
72
+ children?: unknown
73
+ }
74
+
75
+ /**
76
+ * Prompt (07 §1.1, §1.6): one composer in the field's sweep. It grows to 8 lines; Enter sends, Shift+Enter adds a line,
77
+ * Escape stops. Send and Stop are one button, so focus never drops. `/` and `@` open a suggest listbox on the textarea
78
+ * (aria-activedescendant); files come by button or drop, the drag holding the sweep. Busy sends go to `onQueue`.
79
+ */
80
+ export function Prompt(p: PromptProps) {
81
+ const id = uid('prompt')
82
+ const draft = held<string>(() => p.value, '', () => p.onValueChange)
83
+ const toolset = derived(() => p.tools ?? ['attach', 'slash', 'mic', 'web'])
84
+ const shown = (t: PromptTool) => toolset.value.includes(t)
85
+ const open = state<ReturnType<typeof suggestQuery>>(null)
86
+ const active = state(0)
87
+ const drag = state(false)
88
+ const options = derived(() => {
89
+ const q = open.value
90
+ if (!q) return []
91
+ const list = (q.trigger === '/' ? p.commands : p.mentions) ?? []
92
+ return list.filter(o => o.value.slice(1).toLowerCase().startsWith(q.query))
93
+ })
94
+ const listed = derived(() => options.value.length > 0)
95
+ const oid = (o: PromptSuggestion) => `${id}-o-${o.value.slice(1)}`
96
+ const current = derived(() => options.value[Math.min(active.value, options.value.length - 1)])
97
+ const activeId = derived(() => (current.value ? oid(current.value) : undefined))
98
+ const hasFiles = derived(() => p.attachments !== undefined || !!p.files?.length)
99
+ let box: HTMLTextAreaElement | undefined
100
+ let host: HTMLElement | undefined
101
+ let picker: HTMLInputElement | undefined
102
+ use(autosize, () => box, () => draft.value)
103
+ onMount(() => {
104
+ p.control?.({
105
+ insert(text) {
106
+ draft.set(draft.value ? `${draft.value} ${text}` : text)
107
+ box!.focus({ preventScroll: true })
108
+ installSweep().confirm(host)
109
+ announce(box!.ownerDocument, 'Inserted into the message box.')
110
+ },
111
+ focus: () => box!.focus(),
112
+ })
113
+ })
114
+ const look = () => {
115
+ open.value = suggestQuery(box!.value, box!.selectionStart)
116
+ active.value = 0
117
+ }
118
+ const pick = (value: string) => {
119
+ const q = open.value!
120
+ const next = applySuggest(box!.value, q.start, box!.selectionStart, value)
121
+ draft.set(next.text)
122
+ open.value = null
123
+ box!.value = next.text
124
+ box!.focus()
125
+ box!.setSelectionRange(next.caret, next.caret)
126
+ }
127
+ const send = () => {
128
+ const text = draft.value.trim()
129
+ if (!text) return
130
+ if (p.busy) {
131
+ if (!p.onQueue) return
132
+ p.onQueue(text)
133
+ announce(box!.ownerDocument, 'Queued. It will run after the current step.')
134
+ } else if (p.blocked) return
135
+ else p.onSubmit?.(text)
136
+ draft.set('')
137
+ open.value = null
138
+ }
139
+ const press = () => (p.busy ? p.onStop?.() : send())
140
+ const onKey = (e: KeyboardEvent) => {
141
+ if (listed.value) {
142
+ const n = options.value.length
143
+ if (e.key === 'Escape') { e.preventDefault(); open.value = null; return }
144
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); active.value = (active.value + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; return }
145
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pick(current.value!.value); return }
146
+ }
147
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send() }
148
+ else if (e.key === 'Escape' && p.busy) { e.preventDefault(); p.onStop?.() }
149
+ }
150
+ const add = (list: FileList | null | undefined) => { const f = [...(list ?? [])]; if (f.length) p.onAttach?.(f) }
151
+ const onDrop = (e: DragEvent) => { e.preventDefault(); drag.value = false; add(e.dataTransfer?.files) }
152
+ const over = (e: DragEvent) => { e.preventDefault(); drag.value = true }
153
+ return (
154
+ <div data-scope="prompt" style="position:relative;padding:0 var(--warp-space-group) var(--warp-space-group)">
155
+ {p.children}
156
+ <div ref={n => (host = n)} data-scope="composer" data-sweep="" data-pin={drag.value ? '' : undefined} style="margin:0" onDragOver={over} onDragLeave={() => (drag.value = false)} onDrop={onDrop}>
157
+ <Sweep />
158
+ {hasFiles.value && (
159
+ <div data-part="files">
160
+ {p.attachments ?? (p.files ?? []).map(f => (
161
+ <span key={f.id} data-part="file">
162
+ <FileIcon /><span>{f.name}</span>
163
+ <IconButton icon={XIcon} label={`Remove ${f.name}`} onClick={() => p.onRemoveFile?.(f.id)} />
164
+ {f.progress !== undefined && f.progress < 1 && <Bar label={`Uploading ${f.name}`} value={f.progress} />}
165
+ </span>
166
+ ))}
167
+ </div>
168
+ )}
169
+ <label data-part="sr-only" for={`${id}-in`}>{p.label ?? 'Message the assistant'}</label>
170
+ <textarea ref={n => (box = n)} id={`${id}-in`} rows="1" enterkeyhint="send" placeholder={p.placeholder ?? 'Ask, or type / for commands'} aria-describedby={p.blocked ? `${id}-hint ${id}-why` : `${id}-hint`} aria-controls={listed.value ? `${id}-list` : undefined} aria-activedescendant={activeId.value} value={draft.value} onInput={e => { draft.set((e.currentTarget).value); look() }} onKeyDown={onKey} onClick={look} onBlur={() => (open.value = null)}></textarea>
171
+ {p.blocked && <span data-part="hint" data-state="error" id={`${id}-why`}>{p.blocked}</span>}
172
+ <div data-part="row">
173
+ {shown('attach') && <IconButton icon={PaperclipIcon} label="Attach file" onClick={() => picker?.click()} />}
174
+ {shown('attach') && <input ref={n => (picker = n)} type="file" multiple hidden onChange={e => { add((e.currentTarget).files); (e.currentTarget).value = '' }} />}
175
+ {shown('slash') && !!p.commands?.length && <IconButton icon={CommandIcon} label="Commands" onClick={() => { draft.set('/'); box!.value = '/'; box!.focus(); box!.setSelectionRange(1, 1); look() }} />}
176
+ {shown('mic') && <IconButton icon={MicIcon} label="Dictate" tone={p.mic ? 'critical' : 'neutral'} aria-pressed={p.mic ? 'true' : 'false'} onClick={p.onMic} />}
177
+ {shown('web') && !p.compact && <Button emphasis="ghost" tone="neutral" size="sm" aria-pressed={p.web ? 'true' : 'false'} onClick={() => p.onWebChange?.(!p.web)}><GlobeIcon /><span data-part="label">Web</span></Button>}
178
+ <span data-part="spacer"></span>
179
+ {p.context && <ContextMeter used={p.context.used} limit={p.context.limit} parts={p.context.parts} onCompact={p.onCompact} />}
180
+ {p.models && <ModelPicker models={p.models} value={p.model} onChange={p.onModelChange} compact={p.compact} />}
181
+ <Button data-part="send" data-icon-only="" tone={p.busy ? 'neutral' : undefined} label={p.busy ? 'Stop' : 'Send'} aria-disabled={p.blocked && !p.busy ? 'true' : undefined} onClick={press}>{p.busy ? <StopIcon /> : <SendIcon />}</Button>
182
+ </div>
183
+ <span data-part="sr-only" id={`${id}-hint`}>Enter sends, Shift+Enter adds a line, Escape stops a running answer.</span>
184
+ {listed.value && (
185
+ <div data-scope="suggest" role="listbox" id={`${id}-list`} aria-label={open.value?.trigger === '/' ? 'Commands' : 'Mentions'}>
186
+ <div data-part="group" aria-hidden="true">{open.value?.trigger === '/' ? 'Commands' : 'Mentions'}</div>
187
+ {options.value.map(o => (
188
+ <div key={o.value} id={oid(o)} role="option" aria-selected={o === current.value ? 'true' : 'false'} onMouseDown={e => { e.preventDefault(); pick(o.value) }}>
189
+ <span data-part="cmd">{o.value}</span><small>{o.description}</small><kbd>{o === current.value ? '↵' : ''}</kbd>
190
+ </div>
191
+ ))}
192
+ </div>
193
+ )}
194
+ </div>
195
+ </div>
196
+ )
197
+ }
@@ -0,0 +1,54 @@
1
+ import { parseInline, parseText, type AgentSource, type Inline, type TextBlock } from '@weft-js/warp-core'
2
+ import { derived, untrack } from '@weft-js/core'
3
+ import { Cite } from './Cite'
4
+ import '../styles'
5
+ import '@weft-js/warp-core/styles/app/assistant.css'
6
+
7
+ function Cited({ n, sources, base }: { n: number; sources: AgentSource[]; base: string }) {
8
+ const source = derived(() => sources[n - 1])
9
+ return <Cite n={n} source={source.value} href={`#${base}-s${n}`} />
10
+ }
11
+
12
+ type Mark = Extract<Inline, { kind: 'cite' }>
13
+ type Run = Exclude<Inline, Mark>
14
+ type Para = Extract<TextBlock, { kind: 'p' }>
15
+ type List = Extract<TextBlock, { kind: 'ul' }>
16
+ type Fence = Extract<TextBlock, { kind: 'code' }>
17
+
18
+ function Span({ part, sources, base }: { part: Inline; sources: AgentSource[]; base: string }) {
19
+ const kind = derived(() => part.kind)
20
+ const view = derived(() => {
21
+ const k = kind.value
22
+ return untrack(() => (k === 'cite' ? <Cited n={(part as Mark).n} sources={sources} base={base} /> : k === 'code' ? <code>{(part as Run).text}</code> : <span>{(part as Run).text}</span>))
23
+ })
24
+ return <>{view.value}</>
25
+ }
26
+
27
+ function Spans({ text, sources, base }: { text: string; sources: AgentSource[]; base: string }) {
28
+ const parts = derived<Inline[]>(() => parseInline(text))
29
+ return <>{parts.value.map(p => <Span key={p.at} part={p} sources={sources} base={base} />)}</>
30
+ }
31
+
32
+ function Block({ block, sources, base }: { block: TextBlock; sources: AgentSource[]; base: string }) {
33
+ const kind = derived(() => block.kind)
34
+ // a block that changes kind (a paragraph becoming a list) rebuilds; one that only grows is patched
35
+ const view = derived(() => {
36
+ const k = kind.value
37
+ return untrack(() => {
38
+ if (k === 'p') return <p><Spans text={(block as Para).text} sources={sources} base={base} /></p>
39
+ if (k === 'ul') return <ul>{(block as List).items.map(i => <li key={i.at}><Spans text={i.text} sources={sources} base={base} /></li>)}</ul>
40
+ return <pre role="group" tabindex="0" aria-label={(block as Fence).lang ? `Code, ${(block as Fence).lang}` : 'Code'}><code>{(block as Fence).text}</code></pre>
41
+ })
42
+ })
43
+ return <>{view.value}</>
44
+ }
45
+
46
+ /**
47
+ * Answer text, the safe default until warp has Markdown: paragraphs, `- ` lists, code fences (open ones already render
48
+ * as code), inline code and `[n]` cites. Text only: nothing in the model's output becomes markup. Blocks are keyed by
49
+ * where they start, so streaming patches the last one in place.
50
+ */
51
+ export function Prose({ text, sources = [], base = 'src' }: { text: string; sources?: AgentSource[]; base?: string }) {
52
+ const blocks = derived(() => parseText(text))
53
+ return <>{blocks.value.map(b => <Block key={b.at} block={b} sources={sources} base={base} />)}</>
54
+ }
@@ -0,0 +1,52 @@
1
+ import { CheckIcon } from '@weft-js/warp-icons'
2
+ import { announce, uid, type AgentQuestion } from '@weft-js/warp-core'
3
+ import { derived, onMount, state, untrack } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { Checkbox, Radio } from '../Choice'
6
+ import { Input } from '../Field'
7
+ import '../styles'
8
+ import '@weft-js/warp-core/styles/app/question.css'
9
+
10
+ /**
11
+ * Question (07 §1.3): the run pauses and asks. A fieldset with the question as legend, radios or checkboxes, optional
12
+ * free text; Continue is the solid. Once answered it reads back the choice.
13
+ */
14
+ export function Question({ question, onAnswer, quiet }: { question: AgentQuestion; onAnswer?: (answer: string | string[]) => void; quiet?: boolean | undefined }) {
15
+ const name = uid('q')
16
+ const type = derived(() => (question.multiple ? 'checkbox' : 'radio'))
17
+ const picks = state<string[]>(untrack(() => (question.multiple ? [] : question.options.slice(0, 1))))
18
+ const free = state('')
19
+ const other = '__other'
20
+ const options = derived(() => [...question.options.map(o => ({ value: o, label: o })), ...(question.free ? [{ value: other, label: 'Something else…' }] : [])])
21
+ const answered = derived(() => question.answer)
22
+ const said = derived(() => { const a = answered.value; return Array.isArray(a) ? a.join(', ') : a })
23
+ let form: HTMLFormElement | undefined
24
+ onMount(() => { if (!untrack(() => quiet) && form) announce(form.ownerDocument, 'The assistant has a question.', true) })
25
+ const toggle = (v: string, on: boolean) => (picks.value = question.multiple ? (on ? [...picks.value, v] : picks.value.filter(x => x !== v)) : [v])
26
+ const submit = (e: Event) => {
27
+ e.preventDefault()
28
+ const chosen = picks.value.map(p => (p === other ? free.value.trim() : p)).filter(Boolean)
29
+ if (chosen.length) onAnswer?.(question.multiple ? chosen : chosen[0]!)
30
+ }
31
+ // one build per phase: a list around this reads its nodes again as siblings arrive, and picks must survive that
32
+ const done = derived(() => answered.value !== undefined)
33
+ const view = derived(() => (done.value ? (
34
+ <p><CheckIcon /> You chose: {said.value}</p>
35
+ ) : untrack(() => (
36
+ <form ref={n => (form = n)} data-scope="question" onSubmit={submit}>
37
+ <fieldset disabled={question.closed}>
38
+ <legend>{question.prompt}</legend>
39
+ {options.value.map(o => (
40
+ type.value === 'radio'
41
+ ? <Radio key={o.value} name={name} checked={picks.value.includes(o.value)} onChange={(e: Event & { currentTarget: HTMLInputElement }) => toggle(o.value, e.currentTarget.checked)}>{o.label}</Radio>
42
+ : <Checkbox key={o.value} name={name} checked={picks.value.includes(o.value)} onChange={(e: Event & { currentTarget: HTMLInputElement }) => toggle(o.value, e.currentTarget.checked)}>{o.label}</Checkbox>
43
+ ))}
44
+ {picks.value.includes(other) && <Input label="Your answer" value={free.value} onInput={e => (free.value = e.currentTarget.value)} />}
45
+ </fieldset>
46
+ <div>
47
+ <Button size="sm" type="submit" disabled={question.closed}>Continue</Button> <small>{question.closed ? 'The run stopped before this was answered.' : 'Waiting for you'}</small>
48
+ </div>
49
+ </form>
50
+ ))))
51
+ return <>{view.value}</>
52
+ }
@@ -0,0 +1,19 @@
1
+ import { RightIcon } from '@weft-js/warp-icons'
2
+ import { derived } from '@weft-js/core'
3
+ import { Shimmer } from './Shimmer'
4
+ import '../styles'
5
+ import '@weft-js/warp-core/styles/app/reasoning.css'
6
+
7
+ /** Reasoning (07 §1.2): a native details. "Thinking…" while it streams, open; it folds itself and keeps the duration. */
8
+ export function Reasoning({ text, streaming, seconds }: { text: string; streaming?: boolean | undefined; seconds?: number | undefined }) {
9
+ const label = derived(() => (seconds !== undefined ? `Thought for ${seconds} s` : 'Thought'))
10
+ return (
11
+ <details data-scope="reasoning" data-state={streaming ? 'streaming' : 'done'} open={!!streaming}>
12
+ <summary>
13
+ <RightIcon data-part="chev" />
14
+ {streaming ? <Shimmer>Thinking…</Shimmer> : <span>{label.value}</span>}
15
+ </summary>
16
+ <div data-part="body">{text}</div>
17
+ </details>
18
+ )
19
+ }
@@ -0,0 +1,7 @@
1
+ import '../styles'
2
+ import '@weft-js/warp-core/styles/app/shimmer.css'
3
+
4
+ /** Placeholder text with a light passing over it (07 §1.3); static under reduced motion. */
5
+ export function Shimmer({ children, ...rest }: { children?: unknown; [attr: string]: unknown }) {
6
+ return <span data-scope="shimmer" {...rest}>{children}</span>
7
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentSource } from '@weft-js/warp-core'
2
+ import { derived } from '@weft-js/core'
3
+ import { Sweep, useSweep } from './bits'
4
+ import '../styles'
5
+ import '@weft-js/warp-core/styles/app/sources.css'
6
+
7
+ /** Sources (07 §1.1): numbered cards, the same numbers as the cites. `base` ties them to their Cite hrefs. */
8
+ export function Sources({ items, base }: { items: AgentSource[]; base: string }) {
9
+ useSweep()
10
+ const rows = derived(() => items.map((s, i) => ({ s, n: i + 1, id: `${base}-s${i + 1}` })))
11
+ return (
12
+ <div data-scope="sources" role="group" aria-label="Sources">
13
+ {rows.value.map(r => (
14
+ <a key={r.n} id={r.id} href={r.s.url ?? `#${r.id}`} data-sweep="">
15
+ <Sweep />
16
+ <i>{r.n}</i>
17
+ <b>{r.s.title}</b>
18
+ {r.s.path !== undefined && <small>{r.s.path}</small>}
19
+ </a>
20
+ ))}
21
+ </div>
22
+ )
23
+ }