@workerdeck/ui 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Pre-built trigger configuration factories for common AI chat patterns.
3
+ *
4
+ * Each factory returns a full `TriggerConfig` with sensible defaults.
5
+ * Pass only what you need to override.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * <PromptArea
10
+ * triggers={[
11
+ * mentionTrigger({ onSearch: searchUsers }),
12
+ * commandTrigger({ onSearch: searchCommands }),
13
+ * hashtagTrigger(),
14
+ * ]}
15
+ * />
16
+ * ```
17
+ */
18
+
19
+ import type { TriggerConfig, TriggerPosition } from './types.ts'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Shared option type — everything in TriggerConfig except the keys each
23
+ // factory sets by default.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ type TriggerPresetOptions = Omit<Partial<TriggerConfig>, 'char' | 'position' | 'mode'>
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // @mention — dropdown at any position
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export type MentionTriggerOptions = TriggerPresetOptions & {
33
+ /** Override the trigger character. Defaults to `'@'`. */
34
+ char?: string
35
+ }
36
+
37
+ /**
38
+ * Creates a **mention** trigger (`@`).
39
+ *
40
+ * Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'pill'`,
41
+ * accessible label `"mention"`.
42
+ */
43
+ export function mentionTrigger(opts: MentionTriggerOptions = {}): TriggerConfig {
44
+ const { char = '@', ...rest } = opts
45
+ return {
46
+ char,
47
+ position: 'any',
48
+ mode: 'dropdown',
49
+ chipStyle: 'pill',
50
+ accessibilityLabel: 'mention',
51
+ ...rest,
52
+ }
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // /command — dropdown anywhere (opt into line-start-only with `position`)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export type CommandTriggerOptions = TriggerPresetOptions & {
60
+ /** Override the trigger character. Defaults to `'/'`. */
61
+ char?: string
62
+ /**
63
+ * Where the command trigger is valid. Defaults to `'any'`, so commands fire
64
+ * anywhere a `/` follows whitespace — not just at the start of a line.
65
+ * Set to `'start'` to restrict the dropdown to the very start of the input
66
+ * or immediately after a newline (the classic slash-command behavior).
67
+ */
68
+ position?: TriggerPosition
69
+ }
70
+
71
+ /**
72
+ * Creates a **command** trigger (`/`).
73
+ *
74
+ * Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'inline'`,
75
+ * accessible label `"command"`.
76
+ *
77
+ * By default commands work everywhere in the input. Pass `position: 'start'`
78
+ * to limit them to the start of a line.
79
+ */
80
+ export function commandTrigger(opts: CommandTriggerOptions = {}): TriggerConfig {
81
+ const { char = '/', position = 'any', ...rest } = opts
82
+ return {
83
+ char,
84
+ position,
85
+ mode: 'dropdown',
86
+ chipStyle: 'inline',
87
+ accessibilityLabel: 'command',
88
+ ...rest,
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // #hashtag — dropdown at any position, auto-resolve on space
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export type HashtagTriggerOptions = TriggerPresetOptions & {
97
+ /** Override the trigger character. Defaults to `'#'`. */
98
+ char?: string
99
+ }
100
+
101
+ /**
102
+ * Creates a **hashtag / tag** trigger (`#`).
103
+ *
104
+ * Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'pill'`,
105
+ * `resolveOnSpace: true`, accessible label `"tag"`.
106
+ */
107
+ export function hashtagTrigger(opts: HashtagTriggerOptions = {}): TriggerConfig {
108
+ const { char = '#', ...rest } = opts
109
+ return {
110
+ char,
111
+ position: 'any',
112
+ mode: 'dropdown',
113
+ chipStyle: 'pill',
114
+ resolveOnSpace: true,
115
+ accessibilityLabel: 'tag',
116
+ ...rest,
117
+ }
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Generic callback trigger (e.g., for file pickers, model selectors)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export type CallbackTriggerOptions = Omit<Partial<TriggerConfig>, 'mode'> & {
125
+ /** The trigger character. Required. */
126
+ char: string
127
+ }
128
+
129
+ /**
130
+ * Creates a **callback** trigger that fires `onActivate` instead of showing
131
+ * a dropdown. Useful for opening file pickers, model selectors, etc.
132
+ *
133
+ * Defaults: `position: 'start'`, `mode: 'callback'`.
134
+ */
135
+ export function callbackTrigger(opts: CallbackTriggerOptions): TriggerConfig {
136
+ const { char, ...rest } = opts
137
+ return {
138
+ char,
139
+ position: 'start',
140
+ mode: 'callback',
141
+ ...rest,
142
+ }
143
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * PromptArea component types
3
+ *
4
+ * A lightweight contentEditable-based text input that supports:
5
+ * - Trigger characters (/, @, #) that activate handlers
6
+ * - Immutable chips for resolved mentions/commands
7
+ * - Configurable trigger behavior (dropdown vs callback)
8
+ * - Simple inline markdown rendering
9
+ */
10
+
11
+ /**
12
+ * A segment of content within the editable text.
13
+ * The document model is an ordered array of these segments.
14
+ */
15
+ export type TextSegment = {
16
+ type: 'text'
17
+ text: string
18
+ }
19
+
20
+ export type ChipSegment = {
21
+ type: 'chip'
22
+ /** The trigger character that created this chip (e.g., '@', '#') */
23
+ trigger: string
24
+ /** The resolved value/ID (e.g., user ID, file ID) */
25
+ value: string
26
+ /** The display text shown in the chip */
27
+ displayText: string
28
+ /** Optional data payload attached to the chip */
29
+ data?: unknown
30
+ /**
31
+ * True when this chip was auto-created by pressing space (resolveOnSpace).
32
+ * Backspace on an auto-resolved chip reverts it to plain text instead of deleting.
33
+ */
34
+ autoResolved?: boolean
35
+ }
36
+
37
+ export type Segment = TextSegment | ChipSegment
38
+
39
+ /**
40
+ * Determines where a trigger character is valid.
41
+ * - 'start': Only valid at the very start of input or after a newline (e.g., slash commands)
42
+ * - 'any': Valid after any whitespace boundary (e.g., @mentions)
43
+ */
44
+ export type TriggerPosition = 'start' | 'any'
45
+
46
+ /**
47
+ * Defines how a trigger behaves when activated.
48
+ * - 'dropdown': Shows a popover with suggestions from `onSearch`
49
+ * - 'callback': Inserts the char, then fires `onActivate` with the typed query
50
+ * - 'launch': Fires `onActivate` on keydown and SUPPRESSES the char (it never
51
+ * enters the editor) — for opening an external surface (dialog, palette) where
52
+ * no in-editor text should appear. Honors `position` like the other modes.
53
+ */
54
+ export type TriggerMode = 'dropdown' | 'callback' | 'launch'
55
+
56
+ /**
57
+ * Visual style for rendered chips.
58
+ * - 'pill': Button-like pill with background color, padding, border-radius (default)
59
+ * - 'inline': Bold inline text that flows naturally with surrounding content
60
+ */
61
+ export type ChipStyle = 'pill' | 'inline'
62
+
63
+ /**
64
+ * A suggestion item shown in the trigger dropdown.
65
+ */
66
+ export type TriggerSuggestion = {
67
+ /** Unique value/ID for this suggestion */
68
+ value: string
69
+ /** Display label shown in the dropdown */
70
+ label: string
71
+ /** Optional description shown below the label */
72
+ description?: string
73
+ /** Optional icon element rendered before the label */
74
+ icon?: React.ReactNode
75
+ /** Optional arbitrary data passed through on selection */
76
+ data?: unknown
77
+ }
78
+
79
+ /**
80
+ * Configuration for a trigger character.
81
+ */
82
+ export type TriggerConfig = {
83
+ /** The trigger character (e.g., '/', '@', '#') */
84
+ char: string
85
+ /** Where this trigger is valid */
86
+ position: TriggerPosition
87
+ /** How this trigger behaves */
88
+ mode: TriggerMode
89
+ /**
90
+ * For 'dropdown' mode: called with the current query to fetch suggestions.
91
+ * Should return a list of suggestions to display.
92
+ *
93
+ * Receives an options object with an `AbortSignal` that is aborted when a
94
+ * newer search supersedes this one. Pass it to `fetch()` or other async
95
+ * APIs to cancel in-flight work automatically.
96
+ */
97
+ onSearch?: (
98
+ query: string,
99
+ options: { signal: AbortSignal },
100
+ ) => TriggerSuggestion[] | Promise<TriggerSuggestion[]>
101
+ /**
102
+ * For 'dropdown' mode: called when a suggestion is selected.
103
+ * Return the display text for the chip, or void to use `suggestion.label`.
104
+ */
105
+ onSelect?: (suggestion: TriggerSuggestion) => string | void
106
+ /**
107
+ * For 'callback' and 'launch' modes: called when the trigger is activated.
108
+ * Receives the full input text and cursor position. For 'launch' it fires on
109
+ * keydown (before the char would insert); for 'callback' it fires after.
110
+ */
111
+ onActivate?: (context: TriggerActivateContext) => void
112
+ /**
113
+ * When true, pressing space while this trigger is active (with a non-empty query)
114
+ * auto-resolves the typed text into a chip without selecting from the dropdown.
115
+ * The auto-resolved chip can be reverted to plain text with backspace.
116
+ * Useful for free-form tags (e.g., #hashtag).
117
+ */
118
+ resolveOnSpace?: boolean
119
+ /**
120
+ * For 'dropdown' mode: when true, clicking a chip created by this trigger
121
+ * reopens the suggestion dropdown anchored to the chip, and selecting a
122
+ * suggestion replaces the chip in place. The empty-query suggestions are
123
+ * shown with the chip's current value preselected. `onChipClick` still
124
+ * fires, so side effects (analytics, etc.) keep working.
125
+ */
126
+ reopenOnChipClick?: boolean
127
+ /**
128
+ * Visual style for chips created by this trigger.
129
+ * - 'pill' (default): Button-like pill with background, padding, border-radius
130
+ * - 'inline': Bold inline text without pill styling
131
+ */
132
+ chipStyle?: ChipStyle
133
+ /** CSS class name(s) applied to chips created by this trigger */
134
+ chipClassName?: string
135
+ /** Label used for accessibility (e.g., "mention", "command") */
136
+ accessibilityLabel?: string
137
+ /**
138
+ * Debounce delay in milliseconds before calling `onSearch`.
139
+ * Defaults to 0 (immediate). The initial empty-query search always fires
140
+ * immediately regardless of this setting so the dropdown appears instantly.
141
+ */
142
+ searchDebounceMs?: number
143
+ /**
144
+ * Called when `onSearch` rejects or throws (non-abort errors only).
145
+ * Use this to log errors or show toast notifications.
146
+ */
147
+ onSearchError?: (error: unknown) => void
148
+ /**
149
+ * Message shown in the dropdown when `onSearch` returns an empty array.
150
+ * If omitted, the popover hides when there are no results (current behavior).
151
+ */
152
+ emptyMessage?: string
153
+ }
154
+
155
+ /**
156
+ * Context passed to callback-mode trigger handlers.
157
+ */
158
+ export type TriggerActivateContext = {
159
+ /** The full plain text content at the time of activation */
160
+ text: string
161
+ /** The cursor offset position */
162
+ cursorPosition: number
163
+ /** Function to insert a chip at the current cursor position */
164
+ insertChip: (chip: Omit<ChipSegment, 'type'>) => void
165
+ }
166
+
167
+ /**
168
+ * Represents an active trigger being typed by the user.
169
+ */
170
+ export type ActiveTrigger = {
171
+ /** The trigger config that was activated */
172
+ config: TriggerConfig
173
+ /** Position (character offset) where the trigger character was typed */
174
+ startOffset: number
175
+ /** The text typed after the trigger character so far */
176
+ query: string
177
+ }
178
+
179
+ /**
180
+ * An image attachment displayed in the prompt area.
181
+ * State is managed externally by the parent component.
182
+ */
183
+ export type PromptAreaImage = {
184
+ /** Unique identifier for this image */
185
+ id: string
186
+ /** URL to display (CDN URL or temporary blob URL for preview) */
187
+ url: string
188
+ /** Optional alt text for accessibility */
189
+ alt?: string
190
+ /** When true, shows a loading indicator over the thumbnail */
191
+ loading?: boolean
192
+ }
193
+
194
+ /**
195
+ * A file attachment displayed in the prompt area.
196
+ * State is managed externally by the parent component.
197
+ */
198
+ export type PromptAreaFile = {
199
+ /** Unique identifier for this file */
200
+ id: string
201
+ /** Display filename (e.g., "report.pdf") */
202
+ name: string
203
+ /** File size in bytes */
204
+ size?: number
205
+ /** MIME type (used for icon selection, e.g., "application/pdf") */
206
+ type?: string
207
+ /** When true, shows a loading indicator over the file card */
208
+ loading?: boolean
209
+ }
210
+
211
+ /**
212
+ * Props for the PromptArea component.
213
+ */
214
+ export type PromptAreaProps = {
215
+ /** The document segments (controlled) */
216
+ value: Segment[]
217
+ /** Called when the content changes */
218
+ onChange: (segments: Segment[]) => void
219
+ /** Trigger configurations */
220
+ triggers?: TriggerConfig[]
221
+ /** Placeholder text when empty. Pass an array of strings to animate between them. */
222
+ placeholder?: string | string[]
223
+ /** Additional CSS class for the container */
224
+ className?: string
225
+ /** Whether the input is disabled */
226
+ disabled?: boolean
227
+ /** Whether to render simple inline markdown (bold, italic, URLs, lists) */
228
+ markdown?: boolean
229
+ /**
230
+ * When markdown is on, the editor rewrites typed list markers (`- ` / `* `)
231
+ * to a `•` bullet glyph in the model. Set to `false` to keep the original
232
+ * marker in the value/`onChange` text — needed when a host renders the output
233
+ * as real markdown, where `•` is not a valid list marker. Default `true`.
234
+ */
235
+ normalizeBullets?: boolean
236
+ /** Called when Enter is pressed (without Shift) */
237
+ onSubmit?: (segments: Segment[]) => void
238
+ /** Called when Escape is pressed */
239
+ onEscape?: () => void
240
+ /** Called when a chip element is clicked. Receives the chip's segment data. */
241
+ onChipClick?: (chip: ChipSegment) => void
242
+ /** Called when a new chip is added (dropdown selection, auto-resolve, paste, or imperative insert) */
243
+ onChipAdd?: (chip: ChipSegment) => void
244
+ /** Called when a chip is deleted (backspace or forward delete) */
245
+ onChipDelete?: (chip: ChipSegment) => void
246
+ /** Called when a URL link is clicked. Receives the URL string. */
247
+ onLinkClick?: (url: string) => void
248
+ /** Called after content is pasted. Receives the resulting segments and the paste source. */
249
+ onPaste?: (data: { segments: Segment[]; source: 'internal' | 'external' }) => void
250
+ /** Called after an undo operation. Receives the restored segments. */
251
+ onUndo?: (segments: Segment[]) => void
252
+ /** Called after a redo operation. Receives the restored segments. */
253
+ onRedo?: (segments: Segment[]) => void
254
+ /** Minimum height in pixels */
255
+ minHeight?: number
256
+ /** Maximum height in pixels */
257
+ maxHeight?: number
258
+ /**
259
+ * Maximum number of plain-text characters allowed, enforced on typed input:
260
+ * once the editor exceeds the cap it is truncated back to this length, with
261
+ * the caret kept where the edit happened. Chips count as their
262
+ * `trigger + displayText` length.
263
+ *
264
+ * The cap applies to typing only. Paste is not capped — divert it via
265
+ * `onRawPaste` if needed — and the imperative `setText` / `appendText` also
266
+ * bypass it, so a programmatic write can exceed the cap until the next
267
+ * keystroke truncates.
268
+ */
269
+ maxLength?: number
270
+ /** Auto-focus on mount */
271
+ autoFocus?: boolean
272
+ /** When true, the area auto-grows to fit content on focus and shrinks on blur */
273
+ autoGrow?: boolean
274
+ /** Accessible label for the input */
275
+ 'aria-label'?: string
276
+ /** data-test-id for e2e testing */
277
+ 'data-test-id'?: string
278
+ /** Array of image attachments to display */
279
+ images?: PromptAreaImage[]
280
+ /** Where to render the image strip relative to the text area. Defaults to 'above'. */
281
+ imagePosition?: 'above' | 'below'
282
+ /** Called when the user pastes an image from clipboard. Receives the File object. */
283
+ onImagePaste?: (file: File) => void
284
+ /** Called when the user clicks the remove button on an image */
285
+ onImageRemove?: (image: PromptAreaImage) => void
286
+ /** Called when the user clicks an image thumbnail */
287
+ onImageClick?: (image: PromptAreaImage) => void
288
+ /** Array of file attachments to display */
289
+ files?: PromptAreaFile[]
290
+ /** Where to render the file strip relative to the text area. Defaults to 'above'. */
291
+ filePosition?: 'above' | 'below'
292
+ /** Called when the user clicks the remove button on a file */
293
+ onFileRemove?: (file: PromptAreaFile) => void
294
+ /** Called when the user clicks a file attachment */
295
+ onFileClick?: (file: PromptAreaFile) => void
296
+ /**
297
+ * Called on keydown before PromptArea's own handling. Call `preventDefault()`
298
+ * to suppress the built-in behaviour (submit, trigger navigation, etc.) for
299
+ * that key and take over entirely.
300
+ */
301
+ onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void
302
+ /**
303
+ * Called on blur with the native FocusEvent, so consumers can inspect
304
+ * `relatedTarget` (e.g. to retain focus when a composer toolbar is clicked).
305
+ */
306
+ onBlur?: (e: React.FocusEvent<HTMLDivElement>) => void
307
+ /**
308
+ * Called at the start of a paste, before PromptArea reads the clipboard. Call
309
+ * `preventDefault()` to take over the paste completely — e.g. to divert large
310
+ * text or non-image files to an upload pipeline. The built-in segment/image
311
+ * paste handling is skipped when the event's default is prevented.
312
+ */
313
+ onRawPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void
314
+ /**
315
+ * Whether pressing Enter (without Shift) submits. Defaults to true. Set false
316
+ * to make Enter insert a newline instead (e.g. on touch devices where submit
317
+ * is a dedicated button).
318
+ */
319
+ submitOnEnter?: boolean
320
+ /** Forwarded to the editable element. */
321
+ spellCheck?: boolean
322
+ /** Forwarded to the editable element as `aria-describedby`. */
323
+ 'aria-describedby'?: string
324
+ }
325
+
326
+ /**
327
+ * Ref handle exposed by PromptArea via useImperativeHandle.
328
+ */
329
+ export type PromptAreaHandle = {
330
+ /** Focus the editable area */
331
+ focus: () => void
332
+ /** Blur the editable area */
333
+ blur: () => void
334
+ /** Insert a chip at the current cursor position */
335
+ insertChip: (chip: Omit<ChipSegment, 'type'>) => void
336
+ /** Get the current plain text (without chip markup) */
337
+ getPlainText: () => string
338
+ /** Clear all content */
339
+ clear: () => void
340
+ /**
341
+ * Replace all content with plain text (chips dropped), caret moved to the
342
+ * end. Not capped by `maxLength` (see its docs); undoable.
343
+ */
344
+ setText: (text: string) => void
345
+ /**
346
+ * Append plain text at the end (existing chips preserved), caret moved to the
347
+ * end. Not capped by `maxLength` (see its docs); undoable.
348
+ */
349
+ appendText: (text: string) => void
350
+ /** Caret offset in plain-text characters, or null when unavailable. */
351
+ getCursorPosition: () => number | null
352
+ /** Move the caret to a plain-text offset. */
353
+ setCursorPosition: (offset: number) => void
354
+ /** Move the caret to the end of the content. */
355
+ setCursorToEnd: () => void
356
+ /** Current selection as plain-text offsets, or null when there is none. */
357
+ getSelection: () => { start: number; end: number } | null
358
+ /** Set the selection between two plain-text offsets. */
359
+ setSelection: (start: number, end: number) => void
360
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Hook that toggles a PromptArea between its markdown and plain-text variants.
3
+ *
4
+ * PromptArea's `markdown` prop controls whether inline markdown (bold, italic,
5
+ * URLs, and list bullets) is rendered and whether typed list markers normalize
6
+ * to `•`. Flipping it at runtime is non-destructive: the segment value is kept,
7
+ * only its rendering changes (bullets convert `•` ↔ `-` when `normalizeBullets`
8
+ * is on). This hook owns that boolean as a named "mode" and hands you a `toggle`
9
+ * plus a `markdown` value ready to spread onto the component.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * function Composer() {
14
+ * const { bind } = usePromptAreaState()
15
+ * const { markdown, mode, toggle } = useMarkdownMode()
16
+ *
17
+ * return (
18
+ * <>
19
+ * <PromptArea {...bind} markdown={markdown} />
20
+ * <button onClick={toggle} aria-pressed={markdown}>
21
+ * {mode === 'markdown' ? 'Markdown' : 'Plain text'}
22
+ * </button>
23
+ * </>
24
+ * )
25
+ * }
26
+ * ```
27
+ */
28
+
29
+ 'use client'
30
+
31
+ import { useCallback, useMemo, useState } from 'react'
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Mode
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * The two rendering variants of a PromptArea.
39
+ * - `'markdown'`: inline markdown is decorated and list markers normalize to `•`.
40
+ * - `'plain'`: raw source text is shown verbatim with no decoration.
41
+ */
42
+ export type PromptAreaMode = 'markdown' | 'plain'
43
+
44
+ /** Returns the other mode. Pure — handy for building custom toggles. */
45
+ export function oppositeMode(mode: PromptAreaMode): PromptAreaMode {
46
+ return mode === 'markdown' ? 'plain' : 'markdown'
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Options / return type
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export type UseMarkdownModeOptions = {
54
+ /** Starting mode for the uncontrolled hook. Defaults to `'markdown'`. */
55
+ initialMode?: PromptAreaMode
56
+ /**
57
+ * Controlled mode. When provided, the hook mirrors this value and never owns
58
+ * its own state — drive changes through `onModeChange`.
59
+ */
60
+ mode?: PromptAreaMode
61
+ /** Called with the next mode whenever `toggle`/`setMode` change it. */
62
+ onModeChange?: (mode: PromptAreaMode) => void
63
+ }
64
+
65
+ export type MarkdownModeState = {
66
+ /** The active mode. */
67
+ mode: PromptAreaMode
68
+ /** `true` in markdown mode — spread onto `<PromptArea markdown={markdown} />`. */
69
+ markdown: boolean
70
+ /** `true` in plain-text mode (the inverse of `markdown`). */
71
+ isPlainText: boolean
72
+ /** Switch to an explicit mode. No-op if already in it. */
73
+ setMode: (mode: PromptAreaMode) => void
74
+ /** Flip between markdown and plain text. */
75
+ toggle: () => void
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Hook
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export function useMarkdownMode(options: UseMarkdownModeOptions = {}): MarkdownModeState {
83
+ const { initialMode = 'markdown', mode: controlledMode, onModeChange } = options
84
+ const isControlled = controlledMode !== undefined
85
+
86
+ const [internalMode, setInternalMode] = useState<PromptAreaMode>(
87
+ () => controlledMode ?? initialMode,
88
+ )
89
+
90
+ const mode = isControlled ? controlledMode : internalMode
91
+
92
+ const setMode = useCallback(
93
+ (next: PromptAreaMode) => {
94
+ if (next === mode) return
95
+ if (!isControlled) setInternalMode(next)
96
+ onModeChange?.(next)
97
+ },
98
+ [mode, isControlled, onModeChange],
99
+ )
100
+
101
+ const toggle = useCallback(() => setMode(oppositeMode(mode)), [mode, setMode])
102
+
103
+ return useMemo(
104
+ () => ({
105
+ mode,
106
+ markdown: mode === 'markdown',
107
+ isPlainText: mode === 'plain',
108
+ setMode,
109
+ toggle,
110
+ }),
111
+ [mode, setMode, toggle],
112
+ )
113
+ }