@neurocode-ai/session-ui 1.18.8

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 (114) hide show
  1. package/package.json +71 -0
  2. package/src/components/apply-patch-file.test.ts +43 -0
  3. package/src/components/apply-patch-file.ts +78 -0
  4. package/src/components/basic-tool.css +358 -0
  5. package/src/components/basic-tool.stories.tsx +133 -0
  6. package/src/components/basic-tool.tsx +343 -0
  7. package/src/components/dock-prompt.stories.tsx +62 -0
  8. package/src/components/dock-prompt.tsx +23 -0
  9. package/src/components/file-media.tsx +291 -0
  10. package/src/components/file-search.tsx +72 -0
  11. package/src/components/file-ssr.tsx +197 -0
  12. package/src/components/file.css +46 -0
  13. package/src/components/file.tsx +1202 -0
  14. package/src/components/line-comment-annotations.tsx +637 -0
  15. package/src/components/line-comment-styles.ts +292 -0
  16. package/src/components/line-comment.stories.tsx +115 -0
  17. package/src/components/line-comment.tsx +439 -0
  18. package/src/components/markdown-cache.tsx +78 -0
  19. package/src/components/markdown-code-state.test.ts +32 -0
  20. package/src/components/markdown-code-state.ts +22 -0
  21. package/src/components/markdown-inline-code-kind.test.ts +46 -0
  22. package/src/components/markdown-inline-code-kind.ts +1915 -0
  23. package/src/components/markdown-preload.test.ts +18 -0
  24. package/src/components/markdown-shiki.worker.ts +104 -0
  25. package/src/components/markdown-stream.test.ts +194 -0
  26. package/src/components/markdown-stream.ts +110 -0
  27. package/src/components/markdown-worker-protocol.test.ts +81 -0
  28. package/src/components/markdown-worker-protocol.ts +48 -0
  29. package/src/components/markdown-worker-queue.test.ts +49 -0
  30. package/src/components/markdown-worker-queue.ts +64 -0
  31. package/src/components/markdown-worker-transport.test.ts +56 -0
  32. package/src/components/markdown-worker-transport.ts +41 -0
  33. package/src/components/markdown-worker.ts +122 -0
  34. package/src/components/markdown.css +377 -0
  35. package/src/components/markdown.stories.tsx +53 -0
  36. package/src/components/markdown.tsx +678 -0
  37. package/src/components/message-file.test.ts +63 -0
  38. package/src/components/message-file.ts +34 -0
  39. package/src/components/message-nav.css +127 -0
  40. package/src/components/message-nav.stories.tsx +7 -0
  41. package/src/components/message-nav.tsx +102 -0
  42. package/src/components/message-part-text.ts +3 -0
  43. package/src/components/message-part.css +1569 -0
  44. package/src/components/message-part.stories.tsx +7 -0
  45. package/src/components/message-part.test.ts +28 -0
  46. package/src/components/message-part.tsx +2662 -0
  47. package/src/components/session-diff.test.ts +135 -0
  48. package/src/components/session-diff.ts +145 -0
  49. package/src/components/session-retry.tsx +74 -0
  50. package/src/components/session-review.css +247 -0
  51. package/src/components/session-review.stories.tsx +7 -0
  52. package/src/components/session-review.tsx +657 -0
  53. package/src/components/session-turn.css +241 -0
  54. package/src/components/session-turn.stories.tsx +7 -0
  55. package/src/components/session-turn.tsx +543 -0
  56. package/src/components/shell-submessage-motion.stories.tsx +346 -0
  57. package/src/components/shell-submessage.css +23 -0
  58. package/src/components/timeline-playground.stories.tsx +2090 -0
  59. package/src/components/tool-count-label.css +57 -0
  60. package/src/components/tool-count-label.tsx +58 -0
  61. package/src/components/tool-count-summary.css +102 -0
  62. package/src/components/tool-count-summary.stories.tsx +238 -0
  63. package/src/components/tool-count-summary.tsx +52 -0
  64. package/src/components/tool-error-card.css +91 -0
  65. package/src/components/tool-error-card.stories.tsx +92 -0
  66. package/src/components/tool-error-card.tsx +157 -0
  67. package/src/components/tool-status-title.css +89 -0
  68. package/src/components/tool-status-title.tsx +133 -0
  69. package/src/context/data.tsx +64 -0
  70. package/src/context/index.ts +1 -0
  71. package/src/pierre/comment-hover.ts +83 -0
  72. package/src/pierre/commented-lines.ts +91 -0
  73. package/src/pierre/diff-selection.ts +71 -0
  74. package/src/pierre/file-find.ts +485 -0
  75. package/src/pierre/file-runtime.ts +114 -0
  76. package/src/pierre/file-selection.ts +85 -0
  77. package/src/pierre/index.ts +186 -0
  78. package/src/pierre/media.ts +110 -0
  79. package/src/pierre/selection-bridge.ts +132 -0
  80. package/src/pierre/virtualizer.ts +100 -0
  81. package/src/pierre/worker.ts +52 -0
  82. package/src/styles/index.css +14 -0
  83. package/src/v2/components/attachment-card-v2.css +70 -0
  84. package/src/v2/components/attachment-card-v2.tsx +33 -0
  85. package/src/v2/components/basic-tool-v2.css +163 -0
  86. package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
  87. package/src/v2/components/basic-tool-v2.tsx +139 -0
  88. package/src/v2/components/comment-card-v2.tsx +64 -0
  89. package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
  90. package/src/v2/components/prompt-input/attachments.ts +266 -0
  91. package/src/v2/components/prompt-input/index.tsx +706 -0
  92. package/src/v2/components/prompt-input/interaction.ts +482 -0
  93. package/src/v2/components/prompt-input/machine.test.ts +164 -0
  94. package/src/v2/components/prompt-input/machine.ts +261 -0
  95. package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
  96. package/src/v2/components/prompt-input/store.test.ts +116 -0
  97. package/src/v2/components/prompt-input/store.ts +152 -0
  98. package/src/v2/components/prompt-input/types.ts +106 -0
  99. package/src/v2/components/session-file-panel-v2.tsx +43 -0
  100. package/src/v2/components/session-progress-indicator-v2.css +875 -0
  101. package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
  102. package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
  103. package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
  104. package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
  105. package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
  106. package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
  107. package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
  108. package/src/v2/components/session-review-v2.css +458 -0
  109. package/src/v2/components/session-review-v2.tsx +340 -0
  110. package/src/v2/components/tool-error-card-v2.css +200 -0
  111. package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
  112. package/src/v2/components/tool-error-card-v2.tsx +166 -0
  113. package/sst-env.d.ts +10 -0
  114. package/tsconfig.json +19 -0
@@ -0,0 +1,439 @@
1
+ import { useFilteredList } from "@neurocode-ai/ui/hooks"
2
+ import { getDirectory, getFilename } from "@neurocode-ai/core/util/path"
3
+ import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
4
+ import { Button } from "@neurocode-ai/ui/button"
5
+ import { FileIcon } from "@neurocode-ai/ui/file-icon"
6
+ import { Icon } from "@neurocode-ai/ui/icon"
7
+ import { installLineCommentStyles } from "./line-comment-styles"
8
+ import { useI18n } from "@neurocode-ai/ui/context/i18n"
9
+
10
+ installLineCommentStyles()
11
+
12
+ export type LineCommentVariant = "default" | "editor" | "add"
13
+
14
+ function InlineGlyph(props: { icon: "comment" | "plus" }) {
15
+ return (
16
+ <svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
17
+ <Show
18
+ when={props.icon === "comment"}
19
+ fallback={
20
+ <path
21
+ d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
22
+ stroke="currentColor"
23
+ stroke-linecap="square"
24
+ />
25
+ }
26
+ >
27
+ <path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
28
+ </Show>
29
+ </svg>
30
+ )
31
+ }
32
+
33
+ export type LineCommentAnchorProps = {
34
+ id?: string
35
+ top?: number
36
+ inline?: boolean
37
+ hideButton?: boolean
38
+ open: boolean
39
+ variant?: LineCommentVariant
40
+ icon?: "comment" | "plus"
41
+ buttonLabel?: string
42
+ onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
43
+ onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
44
+ onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
45
+ class?: string
46
+ popoverClass?: string
47
+ children?: JSX.Element
48
+ }
49
+
50
+ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
51
+ const hidden = () => !props.inline && props.top === undefined
52
+ const variant = () => props.variant ?? "default"
53
+ const icon = () => props.icon ?? "comment"
54
+ const inlineBody = () => props.inline && props.hideButton
55
+
56
+ return (
57
+ <div
58
+ data-component="line-comment"
59
+ data-prevent-autofocus=""
60
+ data-variant={variant()}
61
+ data-comment-id={props.id}
62
+ data-open={props.open ? "" : undefined}
63
+ data-inline={props.inline ? "" : undefined}
64
+ classList={{
65
+ [props.class ?? ""]: !!props.class,
66
+ }}
67
+ style={
68
+ props.inline
69
+ ? undefined
70
+ : {
71
+ top: `${props.top ?? 0}px`,
72
+ opacity: hidden() ? 0 : 1,
73
+ "pointer-events": hidden() ? "none" : "auto",
74
+ }
75
+ }
76
+ >
77
+ <Show
78
+ when={inlineBody()}
79
+ fallback={
80
+ <>
81
+ <button
82
+ type="button"
83
+ aria-label={props.buttonLabel}
84
+ data-slot="line-comment-button"
85
+ on:mousedown={(e) => e.stopPropagation()}
86
+ on:mouseup={(e) => e.stopPropagation()}
87
+ on:click={props.onClick as any}
88
+ on:mouseenter={props.onMouseEnter as any}
89
+ >
90
+ <Show
91
+ when={props.inline}
92
+ fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
93
+ >
94
+ <InlineGlyph icon={icon()} />
95
+ </Show>
96
+ </button>
97
+ <Show when={props.open}>
98
+ <div
99
+ data-slot="line-comment-popover"
100
+ classList={{
101
+ [props.popoverClass ?? ""]: !!props.popoverClass,
102
+ }}
103
+ on:mousedown={(e) => e.stopPropagation()}
104
+ on:focusout={props.onPopoverFocusOut as any}
105
+ >
106
+ {props.children}
107
+ </div>
108
+ </Show>
109
+ </>
110
+ }
111
+ >
112
+ <div
113
+ data-slot="line-comment-popover"
114
+ data-inline-body=""
115
+ classList={{
116
+ [props.popoverClass ?? ""]: !!props.popoverClass,
117
+ }}
118
+ on:mousedown={(e) => e.stopPropagation()}
119
+ on:click={props.onClick as any}
120
+ on:mouseenter={props.onMouseEnter as any}
121
+ on:focusout={props.onPopoverFocusOut as any}
122
+ >
123
+ {props.children}
124
+ </div>
125
+ </Show>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
131
+ comment: JSX.Element
132
+ selection: JSX.Element
133
+ actions?: JSX.Element
134
+ }
135
+
136
+ export const LineComment = (props: LineCommentProps) => {
137
+ const i18n = useI18n()
138
+ const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
139
+
140
+ return (
141
+ <LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
142
+ <div data-slot="line-comment-content">
143
+ <div data-slot="line-comment-head">
144
+ <div data-slot="line-comment-text">{split.comment}</div>
145
+ <Show when={split.actions}>
146
+ <div data-slot="line-comment-tools">{split.actions}</div>
147
+ </Show>
148
+ </div>
149
+ <div data-slot="line-comment-label">
150
+ {i18n.t("ui.lineComment.label.prefix")}
151
+ {split.selection}
152
+ {i18n.t("ui.lineComment.label.suffix")}
153
+ </div>
154
+ </div>
155
+ </LineCommentAnchor>
156
+ )
157
+ }
158
+
159
+ export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
160
+ label?: string
161
+ }
162
+
163
+ export const LineCommentAdd = (props: LineCommentAddProps) => {
164
+ const [split, rest] = splitProps(props, ["label"])
165
+ const i18n = useI18n()
166
+
167
+ return (
168
+ <LineCommentAnchor
169
+ {...rest}
170
+ open={false}
171
+ variant="add"
172
+ icon="plus"
173
+ buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
174
+ />
175
+ )
176
+ }
177
+
178
+ export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
179
+ value: string
180
+ selection: JSX.Element
181
+ onInput: (value: string) => void
182
+ onCancel: VoidFunction
183
+ onSubmit: (value: string) => void
184
+ placeholder?: string
185
+ rows?: number
186
+ autofocus?: boolean
187
+ cancelLabel?: string
188
+ submitLabel?: string
189
+ mention?: {
190
+ items: (query: string) => string[] | Promise<string[]>
191
+ }
192
+ }
193
+
194
+ export const LineCommentEditor = (props: LineCommentEditorProps) => {
195
+ const i18n = useI18n()
196
+ const [split, rest] = splitProps(props, [
197
+ "value",
198
+ "selection",
199
+ "onInput",
200
+ "onCancel",
201
+ "onSubmit",
202
+ "placeholder",
203
+ "rows",
204
+ "autofocus",
205
+ "cancelLabel",
206
+ "submitLabel",
207
+ "mention",
208
+ ])
209
+
210
+ const refs = {
211
+ textarea: undefined as HTMLTextAreaElement | undefined,
212
+ }
213
+ const [open, setOpen] = createSignal(false)
214
+
215
+ function selectMention(item: { path: string } | undefined) {
216
+ if (!item) return
217
+
218
+ const textarea = refs.textarea
219
+ const query = currentMention()
220
+ if (!textarea || !query) return
221
+
222
+ const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
223
+ const cursor = query.start + item.path.length + 2
224
+
225
+ split.onInput(value)
226
+ closeMention()
227
+
228
+ requestAnimationFrame(() => {
229
+ textarea.focus()
230
+ textarea.setSelectionRange(cursor, cursor)
231
+ })
232
+ }
233
+
234
+ const mention = useFilteredList<{ path: string }>({
235
+ items: async (query) => {
236
+ if (!split.mention) return []
237
+ if (!query.trim()) return []
238
+ const paths = await split.mention.items(query)
239
+ return paths.map((path) => ({ path }))
240
+ },
241
+ key: (item) => item.path,
242
+ filterKeys: ["path"],
243
+ skipFilter: () => true,
244
+ onSelect: selectMention,
245
+ })
246
+
247
+ const focus = () => refs.textarea?.focus()
248
+ const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
249
+ e.preventDefault()
250
+ e.stopPropagation()
251
+ }
252
+ const click =
253
+ (fn: VoidFunction): JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> =>
254
+ (e) => {
255
+ e.stopPropagation()
256
+ fn()
257
+ }
258
+
259
+ const closeMention = () => {
260
+ setOpen(false)
261
+ mention.clear()
262
+ }
263
+
264
+ const currentMention = () => {
265
+ const textarea = refs.textarea
266
+ if (!textarea) return
267
+ if (!split.mention) return
268
+ if (textarea.selectionStart !== textarea.selectionEnd) return
269
+
270
+ const end = textarea.selectionStart
271
+ const match = textarea.value.slice(0, end).match(/@(\S*)$/)
272
+ if (!match) return
273
+
274
+ return {
275
+ query: match[1] ?? "",
276
+ start: end - match[0].length,
277
+ end,
278
+ }
279
+ }
280
+
281
+ const syncMention = () => {
282
+ const item = currentMention()
283
+ if (!item) {
284
+ closeMention()
285
+ return
286
+ }
287
+
288
+ setOpen(true)
289
+ mention.onInput(item.query)
290
+ }
291
+
292
+ const selectActiveMention = () => {
293
+ const items = mention.flat()
294
+ if (items.length === 0) return
295
+ const active = mention.active()
296
+ selectMention(items.find((item) => item.path === active) ?? items[0])
297
+ }
298
+
299
+ const submit = () => {
300
+ const value = split.value.trim()
301
+ if (!value) return
302
+ split.onSubmit(value)
303
+ }
304
+
305
+ onMount(() => {
306
+ if (split.autofocus === false) return
307
+ requestAnimationFrame(focus)
308
+ })
309
+
310
+ return (
311
+ <LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
312
+ <div data-slot="line-comment-editor">
313
+ <textarea
314
+ ref={(el) => {
315
+ refs.textarea = el
316
+ }}
317
+ data-slot="line-comment-textarea"
318
+ rows={split.rows ?? 3}
319
+ placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
320
+ value={split.value}
321
+ on:input={(e) => {
322
+ const value = (e.currentTarget as HTMLTextAreaElement).value
323
+ split.onInput(value)
324
+ syncMention()
325
+ }}
326
+ on:click={() => syncMention()}
327
+ on:select={() => syncMention()}
328
+ on:keydown={(e) => {
329
+ const event = e as KeyboardEvent
330
+ if (event.isComposing || event.keyCode === 229) return
331
+ event.stopPropagation()
332
+ if (open()) {
333
+ if (e.key === "Escape") {
334
+ event.preventDefault()
335
+ closeMention()
336
+ return
337
+ }
338
+
339
+ if (e.key === "Tab") {
340
+ if (mention.flat().length === 0) return
341
+ event.preventDefault()
342
+ selectActiveMention()
343
+ return
344
+ }
345
+
346
+ const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
347
+ const ctrlNav =
348
+ event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
349
+ if ((nav || ctrlNav) && mention.flat().length > 0) {
350
+ mention.onKeyDown(event)
351
+ event.preventDefault()
352
+ return
353
+ }
354
+ }
355
+
356
+ if (e.key === "Escape") {
357
+ event.preventDefault()
358
+ e.currentTarget.blur()
359
+ split.onCancel()
360
+ return
361
+ }
362
+ if (e.key !== "Enter") return
363
+ if (e.shiftKey) return
364
+ event.preventDefault()
365
+ submit()
366
+ }}
367
+ />
368
+ <Show when={open() && mention.flat().length > 0}>
369
+ <div data-slot="line-comment-mention-list">
370
+ <For each={mention.flat().slice(0, 10)}>
371
+ {(item) => {
372
+ const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
373
+ const name = item.path.endsWith("/") ? "" : getFilename(item.path)
374
+ return (
375
+ <button
376
+ type="button"
377
+ data-slot="line-comment-mention-item"
378
+ data-active={mention.active() === item.path ? "" : undefined}
379
+ onMouseDown={(event) => event.preventDefault()}
380
+ onMouseEnter={() => mention.setActive(item.path)}
381
+ onClick={() => selectMention(item)}
382
+ >
383
+ <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
384
+ <div data-slot="line-comment-mention-path">
385
+ <span data-slot="line-comment-mention-dir">{directory}</span>
386
+ <Show when={name}>
387
+ <span data-slot="line-comment-mention-file">{name}</span>
388
+ </Show>
389
+ </div>
390
+ </button>
391
+ )
392
+ }}
393
+ </For>
394
+ </div>
395
+ </Show>
396
+ <div data-slot="line-comment-actions">
397
+ <div data-slot="line-comment-editor-label">
398
+ {i18n.t("ui.lineComment.editorLabel.prefix")}
399
+ {split.selection}
400
+ {i18n.t("ui.lineComment.editorLabel.suffix")}
401
+ </div>
402
+ <Show
403
+ when={!props.inline}
404
+ fallback={
405
+ <>
406
+ <button
407
+ type="button"
408
+ data-slot="line-comment-action"
409
+ data-variant="ghost"
410
+ on:mousedown={hold as any}
411
+ on:click={click(split.onCancel) as any}
412
+ >
413
+ {split.cancelLabel ?? i18n.t("ui.common.cancel")}
414
+ </button>
415
+ <button
416
+ type="button"
417
+ data-slot="line-comment-action"
418
+ data-variant="primary"
419
+ disabled={split.value.trim().length === 0}
420
+ on:mousedown={hold as any}
421
+ on:click={click(submit) as any}
422
+ >
423
+ {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
424
+ </button>
425
+ </>
426
+ }
427
+ >
428
+ <Button size="small" variant="ghost" onClick={split.onCancel}>
429
+ {split.cancelLabel ?? i18n.t("ui.common.cancel")}
430
+ </Button>
431
+ <Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
432
+ {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
433
+ </Button>
434
+ </Show>
435
+ </div>
436
+ </div>
437
+ </LineCommentAnchor>
438
+ )
439
+ }
@@ -0,0 +1,78 @@
1
+ import { checksum } from "@neurocode-ai/core/util/encode"
2
+ import DOMPurify from "dompurify"
3
+ import { project } from "./markdown-stream"
4
+
5
+ export type MarkdownCacheEntry = {
6
+ raw: string
7
+ hash: string
8
+ html: string
9
+ }
10
+
11
+ const max = 200
12
+ const cache = new Map<string, MarkdownCacheEntry>()
13
+ const config = {
14
+ USE_PROFILES: { html: true, mathMl: true },
15
+ SANITIZE_NAMED_PROPS: true,
16
+ FORBID_TAGS: ["style"],
17
+ FORBID_CONTENTS: ["style", "script"],
18
+ ADD_TAGS: ["svg", "path"],
19
+ ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns", "target"],
20
+ }
21
+
22
+ if (typeof window !== "undefined" && DOMPurify.isSupported) {
23
+ DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
24
+ if (!(node instanceof HTMLAnchorElement)) return
25
+ if (node.target !== "_blank") return
26
+
27
+ const rel = node.getAttribute("rel") ?? ""
28
+ const set = new Set(rel.split(/\s+/).filter(Boolean))
29
+ set.add("noopener")
30
+ set.add("noreferrer")
31
+ node.setAttribute("rel", Array.from(set).join(" "))
32
+ })
33
+ }
34
+
35
+ export function sanitizeMarkdown(html: string) {
36
+ if (!DOMPurify.isSupported) return ""
37
+ return DOMPurify.sanitize(html, config)
38
+ }
39
+
40
+ export function getCachedMarkdown(key: string) {
41
+ return cache.get(key)
42
+ }
43
+
44
+ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
45
+ cache.delete(key)
46
+ cache.set(key, value)
47
+
48
+ if (cache.size <= max) return
49
+
50
+ const first = cache.keys().next().value
51
+ if (!first) return
52
+ cache.delete(first)
53
+ }
54
+
55
+ export async function preloadMarkdown(
56
+ text: string,
57
+ cacheKey: string,
58
+ parser: { parse(text: string): string | Promise<string> },
59
+ ) {
60
+ await Promise.all(
61
+ project(undefined, text, false).blocks.map(async (block, index) => {
62
+ if (block.mode === "code") return
63
+ const key = `${cacheKey}:${index}:${block.mode}`
64
+ const cached = getCachedMarkdown(key)
65
+ if (cached?.raw === block.raw) {
66
+ touchCachedMarkdown(key, cached)
67
+ return
68
+ }
69
+ const hash = checksum(block.raw)
70
+ if (!hash) return
71
+ touchCachedMarkdown(key, {
72
+ raw: block.raw,
73
+ hash,
74
+ html: sanitizeMarkdown(await Promise.resolve(parser.parse(block.src))),
75
+ })
76
+ }),
77
+ )
78
+ }
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "bun:test"
2
+ import { shouldResetCodeTokens } from "./markdown-code-state"
3
+
4
+ const previous = {
5
+ language: "ts",
6
+ generation: 1,
7
+ stableCount: 3,
8
+ unstable: [],
9
+ raw: "```ts\nconst x = 1\n```",
10
+ }
11
+
12
+ test("resets tokens for a non-prefix replacement with the same generation and token count", () => {
13
+ expect(
14
+ shouldResetCodeTokens(previous, {
15
+ language: "ts",
16
+ generation: 1,
17
+ stableCount: 3,
18
+ raw: "```ts\nlet y = 2\n```",
19
+ }),
20
+ ).toBe(true)
21
+ })
22
+
23
+ test("retains tokens for an append-only streaming update", () => {
24
+ expect(
25
+ shouldResetCodeTokens(previous, {
26
+ language: "ts",
27
+ generation: 1,
28
+ stableCount: 4,
29
+ raw: `${previous.raw}\nmore`,
30
+ }),
31
+ ).toBe(false)
32
+ })
@@ -0,0 +1,22 @@
1
+ import type { MarkdownToken } from "./markdown-worker-protocol"
2
+
3
+ export type RenderedCodeState = {
4
+ language: string
5
+ generation: number
6
+ stableCount: number
7
+ unstable: MarkdownToken[]
8
+ raw: string
9
+ }
10
+
11
+ export function shouldResetCodeTokens(
12
+ previous: RenderedCodeState | undefined,
13
+ next: { language: string; generation: number; stableCount: number; raw: string },
14
+ ) {
15
+ return (
16
+ !previous ||
17
+ previous.language !== next.language ||
18
+ previous.generation !== next.generation ||
19
+ next.stableCount < previous.stableCount ||
20
+ !next.raw.startsWith(previous.raw)
21
+ )
22
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { inlineCodeKind } from "./markdown-inline-code-kind"
3
+
4
+ describe("inlineCodeKind", () => {
5
+ test("leaves code expressions as normal inline code", () => {
6
+ expect(
7
+ inlineCodeKind(
8
+ `case "question.asked": ... input.setStore("question", question.sessionID, [question]) / splice/insert`,
9
+ ),
10
+ ).toBeUndefined()
11
+ expect(inlineCodeKind(`<SessionQuestionDock request={request} ... />`)).toBeUndefined()
12
+ expect(inlineCodeKind(`from sync.data.question + sync.data.session.`)).toBeUndefined()
13
+ expect(inlineCodeKind(`@neurocode-ai/app <StatusPopover />)`)).toBeUndefined()
14
+ expect(inlineCodeKind(`sync.data.session`)).toBeUndefined()
15
+ expect(inlineCodeKind(`window.api`)).toBeUndefined()
16
+ expect(inlineCodeKind(`1.2`)).toBeUndefined()
17
+ })
18
+
19
+ test("detects file and directory paths", () => {
20
+ expect(inlineCodeKind(`app.tsx`)).toBe("path")
21
+ expect(inlineCodeKind(`vite.config.mjs`)).toBe("path")
22
+ expect(inlineCodeKind(`eslint.config.cjs`)).toBe("path")
23
+ expect(inlineCodeKind(`app.d.ts`)).toBe("path")
24
+ expect(inlineCodeKind(`component.svelte`)).toBe("path")
25
+ expect(inlineCodeKind(`schema.graphql`)).toBe("path")
26
+ expect(inlineCodeKind(`Dockerfile`)).toBe("path")
27
+ expect(inlineCodeKind(`Dockerfile.dev`)).toBe("path")
28
+ expect(inlineCodeKind(`.gitignore`)).toBe("path")
29
+ expect(inlineCodeKind(`Cargo.lock`)).toBe("path")
30
+ expect(inlineCodeKind(`go.sum`)).toBe("path")
31
+ expect(inlineCodeKind(`bun.lockb`)).toBe("path")
32
+ expect(inlineCodeKind(`terraform.tfvars`)).toBe("path")
33
+ expect(inlineCodeKind(`pnpm-lock.yaml`)).toBe("path")
34
+ expect(inlineCodeKind(`packages/desktop-electron`)).toBe("path")
35
+ expect(inlineCodeKind(`~/.config/opencode`)).toBe("path")
36
+ expect(inlineCodeKind(`@neurocode-ai/app`)).toBe("path")
37
+ expect(inlineCodeKind(`session/status`)).toBe("path")
38
+ })
39
+
40
+ test("detects urls", () => {
41
+ expect(inlineCodeKind(`https://opencode.ai/docs`)).toBe("url")
42
+ expect(inlineCodeKind(`http://localhost:4444`)).toBe("url")
43
+ expect(inlineCodeKind(`file:///tmp/opencode`)).toBeUndefined()
44
+ expect(inlineCodeKind(`ftp://opencode.ai/docs`)).toBeUndefined()
45
+ })
46
+ })