@workerdeck/ui 0.22.0 → 1.0.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 (145) hide show
  1. package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +80 -24
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -124
  14. package/build/workspace.mjs +13 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +4 -6
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +345 -923
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +108 -215
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -21,7 +21,8 @@ function Svg({ className, children }: IconProps & { children: React.ReactNode })
21
21
  strokeLinecap="round"
22
22
  strokeLinejoin="round"
23
23
  aria-hidden="true"
24
- className={className}>
24
+ className={className}
25
+ >
25
26
  {children}
26
27
  </svg>
27
28
  )
@@ -34,38 +35,48 @@ const FileBody = (
34
35
  </>
35
36
  )
36
37
 
37
- const File = ({ className }: IconProps) => <Svg className={className}>{FileBody}</Svg>
38
- const FileText = ({ className }: IconProps) => (
39
- <Svg className={className}>
40
- {FileBody}
41
- <path d="M10 9H8" />
42
- <path d="M16 13H8" />
43
- <path d="M16 17H8" />
44
- </Svg>
45
- )
46
- const FileSpreadsheet = ({ className }: IconProps) => (
47
- <Svg className={className}>
48
- {FileBody}
49
- <path d="M8 13h2" />
50
- <path d="M14 13h2" />
51
- <path d="M8 17h2" />
52
- <path d="M14 17h2" />
53
- </Svg>
54
- )
55
- const FileCode = ({ className }: IconProps) => (
56
- <Svg className={className}>
57
- {FileBody}
58
- <path d="M10 12.5 8 15l2 2.5" />
59
- <path d="m14 12.5 2 2.5-2 2.5" />
60
- </Svg>
61
- )
62
- const ImageIcon = ({ className }: IconProps) => (
63
- <Svg className={className}>
64
- <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
65
- <circle cx="9" cy="9" r="2" />
66
- <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
67
- </Svg>
68
- )
38
+ function File({ className }: IconProps) {
39
+ return <Svg className={className}>{FileBody}</Svg>
40
+ }
41
+ function FileText({ className }: IconProps) {
42
+ return (
43
+ <Svg className={className}>
44
+ {FileBody}
45
+ <path d="M10 9H8" />
46
+ <path d="M16 13H8" />
47
+ <path d="M16 17H8" />
48
+ </Svg>
49
+ )
50
+ }
51
+ function FileSpreadsheet({ className }: IconProps) {
52
+ return (
53
+ <Svg className={className}>
54
+ {FileBody}
55
+ <path d="M8 13h2" />
56
+ <path d="M14 13h2" />
57
+ <path d="M8 17h2" />
58
+ <path d="M14 17h2" />
59
+ </Svg>
60
+ )
61
+ }
62
+ function FileCode({ className }: IconProps) {
63
+ return (
64
+ <Svg className={className}>
65
+ {FileBody}
66
+ <path d="M10 12.5 8 15l2 2.5" />
67
+ <path d="m14 12.5 2 2.5-2 2.5" />
68
+ </Svg>
69
+ )
70
+ }
71
+ function ImageIcon({ className }: IconProps) {
72
+ return (
73
+ <Svg className={className}>
74
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
75
+ <circle cx="9" cy="9" r="2" />
76
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
77
+ </Svg>
78
+ )
79
+ }
69
80
 
70
81
  type FileStripProps = {
71
82
  files: PromptAreaFile[]
@@ -79,17 +90,21 @@ const COLLAPSE_THRESHOLD = 3
79
90
 
80
91
  /** Pick a lucide icon key based on MIME type. */
81
92
  function getFileIconKey(type?: string): 'pdf' | 'spreadsheet' | 'code' | 'image' | 'default' {
82
- if (!type) return 'default'
83
- if (type === 'application/pdf') return 'pdf'
84
- if (type.includes('spreadsheet') || type === 'text/csv') return 'spreadsheet'
85
- if (
86
- type.startsWith('text/') ||
87
- type.includes('javascript') ||
88
- type.includes('json') ||
89
- type.includes('xml')
90
- )
93
+ if (!type) {
94
+ return 'default'
95
+ }
96
+ if (type === 'application/pdf') {
97
+ return 'pdf'
98
+ }
99
+ if (type.includes('spreadsheet') || type === 'text/csv') {
100
+ return 'spreadsheet'
101
+ }
102
+ if (type.startsWith('text/') || type.includes('javascript') || type.includes('json') || type.includes('xml')) {
91
103
  return 'code'
92
- if (type.startsWith('image/')) return 'image'
104
+ }
105
+ if (type.startsWith('image/')) {
106
+ return 'image'
107
+ }
93
108
  return 'default'
94
109
  }
95
110
 
@@ -103,16 +118,24 @@ const FILE_ICONS = {
103
118
 
104
119
  /** Format bytes into a human-readable string. */
105
120
  function formatFileSize(bytes: number): string {
106
- if (bytes < 1024) return `${bytes} B`
107
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
108
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
121
+ if (bytes < 1024) {
122
+ return `${bytes} B`
123
+ }
124
+ if (bytes < 1024 * 1024) {
125
+ return `${(bytes / 1024).toFixed(1)} KB`
126
+ }
127
+ if (bytes < 1024 * 1024 * 1024) {
128
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
129
+ }
109
130
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
110
131
  }
111
132
 
112
133
  /** Extract a short extension label from a filename (e.g., "PDF", "CSV"). */
113
134
  function getExtensionLabel(name: string): string | null {
114
135
  const dot = name.lastIndexOf('.')
115
- if (dot === -1 || dot === name.length - 1) return null
136
+ if (dot === -1 || dot === name.length - 1) {
137
+ return null
138
+ }
116
139
  return name.slice(dot + 1).toUpperCase()
117
140
  }
118
141
 
@@ -140,19 +163,14 @@ function FileCard({
140
163
  compact ? 'h-10 w-36 px-2' : 'h-14 w-48 px-3',
141
164
  onClick && 'cursor-pointer',
142
165
  )}
143
- onClick={() => onClick?.(file)}>
166
+ onClick={() => onClick?.(file)}
167
+ >
144
168
  {(() => {
145
169
  const Icon = FILE_ICONS[getFileIconKey(file.type)]
146
- return (
147
- <Icon
148
- className={cn('text-muted-foreground flex-shrink-0', compact ? 'h-4 w-4' : 'h-5 w-5')}
149
- />
150
- )
170
+ return <Icon className={cn('text-muted-foreground flex-shrink-0', compact ? 'h-4 w-4' : 'h-5 w-5')} />
151
171
  })()}
152
172
  <div className="min-w-0 flex-1">
153
- <div
154
- className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')}
155
- title={file.name}>
173
+ <div className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')} title={file.name}>
156
174
  {file.name}
157
175
  </div>
158
176
  {!compact && meta && <div className="text-muted-foreground truncate text-xs">{meta}</div>}
@@ -175,15 +193,15 @@ export function FileStrip({ files, onRemove, onClick, className }: FileStripProp
175
193
  const toggleRef = useRef<HTMLButtonElement>(null)
176
194
 
177
195
  useEffect(() => {
178
- if (!expanded) return
196
+ if (!expanded) {
197
+ return
198
+ }
179
199
  const handleClick = (e: MouseEvent) => {
180
200
  const target = e.target
181
- if (!(target instanceof Node)) return
182
- if (
183
- popoverRef.current &&
184
- !popoverRef.current.contains(target) &&
185
- !toggleRef.current?.contains(target)
186
- ) {
201
+ if (!(target instanceof Node)) {
202
+ return
203
+ }
204
+ if (popoverRef.current && !popoverRef.current.contains(target) && !toggleRef.current?.contains(target)) {
187
205
  setExpanded(false)
188
206
  }
189
207
  }
@@ -191,7 +209,9 @@ export function FileStrip({ files, onRemove, onClick, className }: FileStripProp
191
209
  return () => document.removeEventListener('mousedown', handleClick)
192
210
  }, [expanded])
193
211
 
194
- if (files.length === 0) return null
212
+ if (files.length === 0) {
213
+ return null
214
+ }
195
215
 
196
216
  const collapsible = files.length > COLLAPSE_THRESHOLD
197
217
  const compact = collapsible
@@ -202,13 +222,7 @@ export function FileStrip({ files, onRemove, onClick, className }: FileStripProp
202
222
  <div className={cn('relative', className)}>
203
223
  <div className="flex flex-wrap gap-2" role="list" aria-label="Attached files">
204
224
  {(collapsible ? visibleFiles : files).map((file) => (
205
- <FileCard
206
- key={file.id}
207
- file={file}
208
- compact={compact}
209
- onRemove={onRemove}
210
- onClick={onClick}
211
- />
225
+ <FileCard key={file.id} file={file} compact={compact} onRemove={onRemove} onClick={onClick} />
212
226
  ))}
213
227
  {collapsible && (
214
228
  <div role="listitem">
@@ -219,7 +233,8 @@ export function FileStrip({ files, onRemove, onClick, className }: FileStripProp
219
233
  className={cn(
220
234
  'border-border text-muted-foreground hover:bg-surface-hover flex flex-shrink-0 cursor-pointer items-center justify-center rounded-lg border transition-colors',
221
235
  compact ? 'h-10 px-3 text-xs' : 'h-14 px-4 text-sm',
222
- )}>
236
+ )}
237
+ >
223
238
  {expanded ? 'Show less' : `+${hiddenCount} more`}
224
239
  </button>
225
240
  </div>
@@ -231,16 +246,11 @@ export function FileStrip({ files, onRemove, onClick, className }: FileStripProp
231
246
  ref={popoverRef}
232
247
  className={cn(
233
248
  'bg-surface border-border absolute bottom-full left-0 z-10 mb-2 max-h-48 overflow-y-auto rounded-lg border p-2 shadow-lg',
234
- )}>
249
+ )}
250
+ >
235
251
  <div className="flex flex-wrap gap-2" role="list" aria-label="More attached files">
236
252
  {files.slice(COLLAPSE_THRESHOLD).map((file) => (
237
- <FileCard
238
- key={file.id}
239
- file={file}
240
- compact={compact}
241
- onRemove={onRemove}
242
- onClick={onClick}
243
- />
253
+ <FileCard key={file.id} file={file} compact={compact} onRemove={onRemove} onClick={onClick} />
244
254
  ))}
245
255
  </div>
246
256
  </div>
@@ -14,9 +14,7 @@
14
14
  */
15
15
  import { isHTMLElement, isTextNode } from './dom-helpers.ts'
16
16
 
17
- // ---------------------------------------------------------------------------
18
17
  // Inline style / emphasis detection
19
- // ---------------------------------------------------------------------------
20
18
 
21
19
  /** Reads a single declaration value from an inline `style` attribute string. */
22
20
  function getStyleValue(style: string, prop: string): string {
@@ -26,7 +24,9 @@ function getStyleValue(style: string, prop: string): string {
26
24
 
27
25
  /** Whether a CSS font-weight value is bold (`bold`, `bolder`, or >= 600). */
28
26
  function isBoldWeight(value: string): boolean {
29
- if (value === 'bold' || value === 'bolder') return true
27
+ if (value === 'bold' || value === 'bolder') {
28
+ return true
29
+ }
30
30
  const numeric = Number.parseInt(value, 10)
31
31
  return !Number.isNaN(numeric) && numeric >= 600
32
32
  }
@@ -45,22 +45,17 @@ function inlineEmphasis(node: HTMLElement): { prefix: string; suffix: string } {
45
45
  const bold = weight ? isBoldWeight(weight) : tag === 'B' || tag === 'STRONG'
46
46
 
47
47
  const fontStyle = getStyleValue(style, 'font-style')
48
- const italic = fontStyle
49
- ? fontStyle === 'italic' || fontStyle === 'oblique'
50
- : tag === 'I' || tag === 'EM'
48
+ const italic = fontStyle ? fontStyle === 'italic' || fontStyle === 'oblique' : tag === 'I' || tag === 'EM'
51
49
 
52
50
  const decoration = getStyleValue(style, 'text-decoration')
53
- const strike =
54
- tag === 'S' || tag === 'DEL' || tag === 'STRIKE' || decoration.includes('line-through')
51
+ const strike = tag === 'S' || tag === 'DEL' || tag === 'STRIKE' || decoration.includes('line-through')
55
52
 
56
53
  const prefix = (strike ? '~~' : '') + (bold ? '**' : '') + (italic ? '*' : '')
57
54
  const suffix = (italic ? '*' : '') + (bold ? '**' : '') + (strike ? '~~' : '')
58
55
  return { prefix, suffix }
59
56
  }
60
57
 
61
- // ---------------------------------------------------------------------------
62
58
  // Text handling
63
- // ---------------------------------------------------------------------------
64
59
 
65
60
  /** Collapses HTML whitespace runs (incl. `&nbsp;` -> U+00A0) to single spaces. */
66
61
  function collapseWhitespace(text: string): string {
@@ -72,16 +67,16 @@ function escapeText(text: string): string {
72
67
  return text.replace(/\*/g, '\\*')
73
68
  }
74
69
 
75
- // ---------------------------------------------------------------------------
76
70
  // Block serializers
77
- // ---------------------------------------------------------------------------
78
71
 
79
72
  /** Derives a fenced-block language from `class="language-ts"` or `lang="ts"`. */
80
73
  function detectCodeLang(pre: HTMLElement): string {
81
74
  const code = pre.querySelector('code')
82
75
  const classNames = `${pre.className} ${code?.className ?? ''}`
83
76
  const fromClass = /(?:language|lang)-([\w-]+)/.exec(classNames)
84
- if (fromClass) return fromClass[1]
77
+ if (fromClass) {
78
+ return fromClass[1]
79
+ }
85
80
  return pre.getAttribute('lang') ?? code?.getAttribute('lang') ?? ''
86
81
  }
87
82
 
@@ -93,7 +88,9 @@ function serializePre(pre: HTMLElement): string {
93
88
 
94
89
  function serializeInlineCode(node: HTMLElement): string {
95
90
  const content = node.textContent ?? ''
96
- if (content.includes('`')) return `\`\` ${content} \`\``
91
+ if (content.includes('`')) {
92
+ return `\`\` ${content} \`\``
93
+ }
97
94
  return `\`${content}\``
98
95
  }
99
96
 
@@ -105,8 +102,12 @@ function isSafeHref(href: string): boolean {
105
102
  function serializeAnchor(node: HTMLElement, depth: number): string {
106
103
  const href = node.getAttribute('href') ?? ''
107
104
  const label = serializeChildren(node, depth).trim()
108
- if (!isSafeHref(href)) return label
109
- if (!label || label === href) return href
105
+ if (!isSafeHref(href)) {
106
+ return label
107
+ }
108
+ if (!label || label === href) {
109
+ return href
110
+ }
110
111
  return `[${label}](${href})`
111
112
  }
112
113
 
@@ -115,7 +116,9 @@ function serializeImage(node: HTMLElement): string {
115
116
  // Gate the src through the same allow-list as anchors: only http(s)/mailto
116
117
  // survive, so a `javascript:`/`vbscript:`/`data:` src never reaches the
117
118
  // emitted markdown (defense-in-depth for consumers that render it as HTML).
118
- if (!src || !isSafeHref(src)) return ''
119
+ if (!src || !isSafeHref(src)) {
120
+ return ''
121
+ }
119
122
  return `![${node.getAttribute('alt') ?? ''}](${src})`
120
123
  }
121
124
 
@@ -141,7 +144,9 @@ function serializeList(list: HTMLElement, depth: number): string {
141
144
  const lines: string[] = []
142
145
 
143
146
  for (const child of Array.from(list.childNodes)) {
144
- if (!isHTMLElement(child) || child.tagName !== 'LI') continue
147
+ if (!isHTMLElement(child) || child.tagName !== 'LI') {
148
+ continue
149
+ }
145
150
 
146
151
  const marker = ordered ? `${index}. ` : '- '
147
152
  index++
@@ -163,14 +168,14 @@ function serializeList(list: HTMLElement, depth: number): string {
163
168
 
164
169
  function serializeTable(table: HTMLElement, depth: number): string {
165
170
  const rows = Array.from(table.querySelectorAll('tr'))
166
- if (rows.length === 0) return ''
171
+ if (rows.length === 0) {
172
+ return ''
173
+ }
167
174
 
168
175
  const cells = rows.map((row) =>
169
176
  Array.from(row.children)
170
177
  .filter((cell) => cell.tagName === 'TD' || cell.tagName === 'TH')
171
- .map((cell) =>
172
- serializeChildren(cell, depth).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim(),
173
- ),
178
+ .map((cell) => serializeChildren(cell, depth).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim()),
174
179
  )
175
180
 
176
181
  const header = cells[0]
@@ -180,9 +185,7 @@ function serializeTable(table: HTMLElement, depth: number): string {
180
185
  return [toRow(header), toRow(separator), ...cells.slice(1).map(toRow)].join('\n')
181
186
  }
182
187
 
183
- // ---------------------------------------------------------------------------
184
188
  // Recursive walker
185
- // ---------------------------------------------------------------------------
186
189
 
187
190
  function serializeChildren(node: Node, depth: number): string {
188
191
  let out = ''
@@ -193,8 +196,12 @@ function serializeChildren(node: Node, depth: number): string {
193
196
  }
194
197
 
195
198
  function serializeNode(node: Node, depth: number): string {
196
- if (isTextNode(node)) return escapeText(collapseWhitespace(node.textContent ?? ''))
197
- if (!isHTMLElement(node)) return ''
199
+ if (isTextNode(node)) {
200
+ return escapeText(collapseWhitespace(node.textContent ?? ''))
201
+ }
202
+ if (!isHTMLElement(node)) {
203
+ return ''
204
+ }
198
205
 
199
206
  const tag = node.tagName
200
207
  switch (tag) {
@@ -202,42 +209,56 @@ function serializeNode(node: Node, depth: number): string {
202
209
  case 'STYLE':
203
210
  case 'NOSCRIPT':
204
211
  case 'HEAD':
205
- case 'TITLE':
212
+ case 'TITLE': {
206
213
  return ''
207
- case 'BR':
214
+ }
215
+ case 'BR': {
208
216
  return '\n'
209
- case 'HR':
217
+ }
218
+ case 'HR': {
210
219
  return '\n\n---\n\n'
220
+ }
211
221
  case 'H1':
212
222
  case 'H2':
213
223
  case 'H3':
214
224
  case 'H4':
215
225
  case 'H5':
216
- case 'H6':
226
+ case 'H6': {
217
227
  return `\n\n${'#'.repeat(Number(tag[1]))} ${serializeChildren(node, depth).trim()}\n\n`
218
- case 'P':
228
+ }
229
+ case 'P': {
219
230
  return `\n\n${serializeChildren(node, depth).trim()}\n\n`
220
- case 'DIV':
231
+ }
232
+ case 'DIV': {
221
233
  return `\n${serializeChildren(node, depth).trim()}\n`
222
- case 'BLOCKQUOTE':
234
+ }
235
+ case 'BLOCKQUOTE': {
223
236
  return serializeBlockquote(node, depth)
237
+ }
224
238
  case 'UL':
225
- case 'OL':
239
+ case 'OL': {
226
240
  return `\n\n${serializeList(node, depth)}\n\n`
227
- case 'LI':
241
+ }
242
+ case 'LI': {
228
243
  // A stray <li> outside a list wrapper — emit its content as a line.
229
244
  return `${serializeChildren(node, depth).trim()}\n`
230
- case 'PRE':
245
+ }
246
+ case 'PRE': {
231
247
  return serializePre(node)
232
- case 'CODE':
248
+ }
249
+ case 'CODE': {
233
250
  // Inline code only: <pre> handles its own <code> via textContent.
234
251
  return serializeInlineCode(node)
235
- case 'A':
252
+ }
253
+ case 'A': {
236
254
  return serializeAnchor(node, depth)
237
- case 'IMG':
255
+ }
256
+ case 'IMG': {
238
257
  return serializeImage(node)
239
- case 'TABLE':
258
+ }
259
+ case 'TABLE': {
240
260
  return `\n\n${serializeTable(node, depth)}\n\n`
261
+ }
241
262
  default: {
242
263
  // Inline emphasis (B/STRONG/I/EM/S/DEL + styled SPAN/FONT) and the
243
264
  // "span soup" unwrap case both resolve here: emphasis markers when the
@@ -248,9 +269,7 @@ function serializeNode(node: Node, depth: number): string {
248
269
  }
249
270
  }
250
271
 
251
- // ---------------------------------------------------------------------------
252
272
  // Output normalization
253
- // ---------------------------------------------------------------------------
254
273
 
255
274
  /** Trims trailing spaces and caps consecutive blank lines at one. */
256
275
  function normalizeOutput(markdown: string): string {
@@ -260,9 +279,7 @@ function normalizeOutput(markdown: string): string {
260
279
  .trim()
261
280
  }
262
281
 
263
- // ---------------------------------------------------------------------------
264
282
  // Public API
265
- // ---------------------------------------------------------------------------
266
283
 
267
284
  /**
268
285
  * Converts an HTML string to markdown source text. Returns '' for empty or
@@ -271,8 +288,12 @@ function normalizeOutput(markdown: string): string {
271
288
  * display; only `*`/`**`/`***` and bare URLs get visually decorated inline.
272
289
  */
273
290
  export function htmlToMarkdown(html: string): string {
274
- if (!html) return ''
291
+ if (!html) {
292
+ return ''
293
+ }
275
294
  const doc = new DOMParser().parseFromString(html, 'text/html')
276
- if (!doc.body) return ''
295
+ if (!doc.body) {
296
+ return ''
297
+ }
277
298
  return normalizeOutput(serializeChildren(doc.body, 0))
278
299
  }
@@ -12,7 +12,9 @@ type ImageStripProps = {
12
12
  }
13
13
 
14
14
  export function ImageStrip({ images, onRemove, onClick, className }: ImageStripProps) {
15
- if (images.length === 0) return null
15
+ if (images.length === 0) {
16
+ return null
17
+ }
16
18
 
17
19
  return (
18
20
  <div className={cn('flex flex-wrap gap-2', className)} role="list" aria-label="Attached images">
@@ -20,28 +22,16 @@ export function ImageStrip({ images, onRemove, onClick, className }: ImageStripP
20
22
  <div
21
23
  key={image.id}
22
24
  role="listitem"
23
- className={cn(
24
- 'border-border relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border',
25
- onClick && 'cursor-pointer',
26
- )}
27
- onClick={() => onClick?.(image)}>
28
- {/* eslint-disable-next-line @next/next/no-img-element -- registry component used outside Next.js */}
29
- <img
30
- src={image.url}
31
- alt={image.alt ?? 'Attached image'}
32
- className="h-full w-full object-cover"
33
- />
25
+ className={cn('border-border relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border', onClick && 'cursor-pointer')}
26
+ onClick={() => onClick?.(image)}
27
+ >
28
+ <img src={image.url} alt={image.alt ?? 'Attached image'} className="h-full w-full object-cover" />
34
29
  {image.loading && (
35
30
  <div className="absolute inset-0 flex items-center justify-center bg-black/40">
36
31
  <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
37
32
  </div>
38
33
  )}
39
- {onRemove && (
40
- <RemoveButton
41
- onClick={() => onRemove(image)}
42
- label={`Remove ${image.alt ?? 'image'}`}
43
- />
44
- )}
34
+ {onRemove && <RemoveButton onClick={() => onRemove(image)} label={`Remove ${image.alt ?? 'image'}`} />}
45
35
  </div>
46
36
  ))}
47
37
  </div>
@@ -6,18 +6,5 @@
6
6
  export { PromptArea } from './prompt-area.tsx'
7
7
  export { usePromptAreaState } from './use-prompt-area-state.ts'
8
8
  export { commandTrigger, mentionTrigger, hashtagTrigger } from './trigger-presets.ts'
9
- export {
10
- segmentsToPlainText,
11
- plainTextToSegments,
12
- isSegmentsEmpty,
13
- getChipsByTrigger,
14
- } from './segment-helpers.ts'
15
- export type {
16
- PromptAreaHandle,
17
- PromptAreaProps,
18
- Segment,
19
- ChipSegment,
20
- TextSegment,
21
- TriggerConfig,
22
- TriggerSuggestion,
23
- } from './types.ts'
9
+ export { segmentsToPlainText, plainTextToSegments, isSegmentsEmpty, getChipsByTrigger } from './segment-helpers.ts'
10
+ export type { PromptAreaHandle, PromptAreaProps, Segment, ChipSegment, TextSegment, TriggerConfig, TriggerSuggestion } from './types.ts'