reigncode-app 1.3.2

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 (300) hide show
  1. package/AGENTS.md +30 -0
  2. package/Dockerfile +21 -0
  3. package/README.md +51 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/AGENTS.md +226 -0
  7. package/e2e/actions.ts +1018 -0
  8. package/e2e/app/home.spec.ts +24 -0
  9. package/e2e/app/navigation.spec.ts +10 -0
  10. package/e2e/app/palette.spec.ts +20 -0
  11. package/e2e/app/server-default.spec.ts +58 -0
  12. package/e2e/app/session.spec.ts +16 -0
  13. package/e2e/app/titlebar-history.spec.ts +120 -0
  14. package/e2e/commands/input-focus.spec.ts +15 -0
  15. package/e2e/commands/panels.spec.ts +33 -0
  16. package/e2e/commands/tab-close.spec.ts +32 -0
  17. package/e2e/files/file-open.spec.ts +31 -0
  18. package/e2e/files/file-tree.spec.ts +56 -0
  19. package/e2e/files/file-viewer.spec.ts +156 -0
  20. package/e2e/fixtures.ts +154 -0
  21. package/e2e/models/model-picker.spec.ts +48 -0
  22. package/e2e/models/models-visibility.spec.ts +61 -0
  23. package/e2e/projects/project-edit.spec.ts +43 -0
  24. package/e2e/projects/projects-close.spec.ts +54 -0
  25. package/e2e/projects/projects-switch.spec.ts +116 -0
  26. package/e2e/projects/workspace-new-session.spec.ts +94 -0
  27. package/e2e/projects/workspaces.spec.ts +375 -0
  28. package/e2e/prompt/context.spec.ts +95 -0
  29. package/e2e/prompt/prompt-async.spec.ts +76 -0
  30. package/e2e/prompt/prompt-drop-file-uri.spec.ts +22 -0
  31. package/e2e/prompt/prompt-drop-file.spec.ts +30 -0
  32. package/e2e/prompt/prompt-history.spec.ts +184 -0
  33. package/e2e/prompt/prompt-mention.spec.ts +26 -0
  34. package/e2e/prompt/prompt-multiline.spec.ts +24 -0
  35. package/e2e/prompt/prompt-shell.spec.ts +62 -0
  36. package/e2e/prompt/prompt-slash-open.spec.ts +22 -0
  37. package/e2e/prompt/prompt-slash-share.spec.ts +64 -0
  38. package/e2e/prompt/prompt-slash-terminal.spec.ts +18 -0
  39. package/e2e/prompt/prompt.spec.ts +55 -0
  40. package/e2e/selectors.ts +75 -0
  41. package/e2e/session/session-child-navigation.spec.ts +37 -0
  42. package/e2e/session/session-composer-dock.spec.ts +530 -0
  43. package/e2e/session/session-model-persistence.spec.ts +359 -0
  44. package/e2e/session/session-review.spec.ts +426 -0
  45. package/e2e/session/session-undo-redo.spec.ts +233 -0
  46. package/e2e/session/session.spec.ts +174 -0
  47. package/e2e/settings/settings-keybinds.spec.ts +389 -0
  48. package/e2e/settings/settings-models.spec.ts +122 -0
  49. package/e2e/settings/settings-providers.spec.ts +136 -0
  50. package/e2e/settings/settings.spec.ts +519 -0
  51. package/e2e/sidebar/sidebar-popover-actions.spec.ts +118 -0
  52. package/e2e/sidebar/sidebar-session-links.spec.ts +30 -0
  53. package/e2e/sidebar/sidebar.spec.ts +40 -0
  54. package/e2e/status/status-popover.spec.ts +94 -0
  55. package/e2e/terminal/terminal-init.spec.ts +28 -0
  56. package/e2e/terminal/terminal-reconnect.spec.ts +46 -0
  57. package/e2e/terminal/terminal-tabs.spec.ts +168 -0
  58. package/e2e/terminal/terminal.spec.ts +18 -0
  59. package/e2e/thinking-level.spec.ts +25 -0
  60. package/e2e/tsconfig.json +9 -0
  61. package/e2e/utils.ts +63 -0
  62. package/happydom.ts +75 -0
  63. package/index.html +23 -0
  64. package/package.json +77 -0
  65. package/playwright.config.ts +45 -0
  66. package/public/_headers +17 -0
  67. package/public/oc-theme-preload.js +35 -0
  68. package/script/e2e-local.ts +180 -0
  69. package/src/addons/serialize.test.ts +319 -0
  70. package/src/addons/serialize.ts +634 -0
  71. package/src/app.tsx +308 -0
  72. package/src/components/debug-bar.tsx +443 -0
  73. package/src/components/dialog-connect-provider.tsx +617 -0
  74. package/src/components/dialog-custom-provider-form.ts +158 -0
  75. package/src/components/dialog-custom-provider.test.ts +80 -0
  76. package/src/components/dialog-custom-provider.tsx +329 -0
  77. package/src/components/dialog-edit-project.tsx +255 -0
  78. package/src/components/dialog-fork.tsx +108 -0
  79. package/src/components/dialog-manage-models.tsx +101 -0
  80. package/src/components/dialog-release-notes.tsx +144 -0
  81. package/src/components/dialog-select-directory.tsx +392 -0
  82. package/src/components/dialog-select-file.tsx +466 -0
  83. package/src/components/dialog-select-mcp.tsx +107 -0
  84. package/src/components/dialog-select-model-unpaid.tsx +137 -0
  85. package/src/components/dialog-select-model.tsx +220 -0
  86. package/src/components/dialog-select-provider.tsx +86 -0
  87. package/src/components/dialog-select-server.tsx +649 -0
  88. package/src/components/dialog-settings.tsx +73 -0
  89. package/src/components/file-tree.test.ts +78 -0
  90. package/src/components/file-tree.tsx +507 -0
  91. package/src/components/link.tsx +26 -0
  92. package/src/components/model-tooltip.tsx +91 -0
  93. package/src/components/prompt-input/attachments.test.ts +44 -0
  94. package/src/components/prompt-input/attachments.ts +201 -0
  95. package/src/components/prompt-input/build-request-parts.test.ts +312 -0
  96. package/src/components/prompt-input/build-request-parts.ts +175 -0
  97. package/src/components/prompt-input/context-items.tsx +88 -0
  98. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  99. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  100. package/src/components/prompt-input/editor-dom.ts +148 -0
  101. package/src/components/prompt-input/files.ts +66 -0
  102. package/src/components/prompt-input/history.test.ts +153 -0
  103. package/src/components/prompt-input/history.ts +256 -0
  104. package/src/components/prompt-input/image-attachments.tsx +58 -0
  105. package/src/components/prompt-input/paste.ts +24 -0
  106. package/src/components/prompt-input/placeholder.test.ts +48 -0
  107. package/src/components/prompt-input/placeholder.ts +15 -0
  108. package/src/components/prompt-input/slash-popover.tsx +141 -0
  109. package/src/components/prompt-input/submit.test.ts +346 -0
  110. package/src/components/prompt-input/submit.ts +579 -0
  111. package/src/components/prompt-input.tsx +1595 -0
  112. package/src/components/server/server-row.tsx +130 -0
  113. package/src/components/session/index.ts +5 -0
  114. package/src/components/session/session-context-breakdown.test.ts +61 -0
  115. package/src/components/session/session-context-breakdown.ts +132 -0
  116. package/src/components/session/session-context-format.ts +20 -0
  117. package/src/components/session/session-context-metrics.test.ts +101 -0
  118. package/src/components/session/session-context-metrics.ts +82 -0
  119. package/src/components/session/session-context-tab.tsx +339 -0
  120. package/src/components/session/session-header.tsx +486 -0
  121. package/src/components/session/session-new-view.tsx +91 -0
  122. package/src/components/session/session-sortable-tab.tsx +70 -0
  123. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  124. package/src/components/session-context-usage.tsx +122 -0
  125. package/src/components/settings-general.tsx +585 -0
  126. package/src/components/settings-keybinds.tsx +453 -0
  127. package/src/components/settings-list.tsx +5 -0
  128. package/src/components/settings-models.tsx +137 -0
  129. package/src/components/settings-providers.tsx +251 -0
  130. package/src/components/status-popover.tsx +419 -0
  131. package/src/components/terminal.tsx +653 -0
  132. package/src/components/titlebar-history.test.ts +63 -0
  133. package/src/components/titlebar-history.ts +57 -0
  134. package/src/components/titlebar.tsx +312 -0
  135. package/src/constants/file-picker.ts +89 -0
  136. package/src/context/command-keybind.test.ts +69 -0
  137. package/src/context/command.test.ts +25 -0
  138. package/src/context/command.tsx +437 -0
  139. package/src/context/comments.test.ts +186 -0
  140. package/src/context/comments.tsx +243 -0
  141. package/src/context/file/content-cache.ts +88 -0
  142. package/src/context/file/path.test.ts +360 -0
  143. package/src/context/file/path.ts +151 -0
  144. package/src/context/file/tree-store.ts +170 -0
  145. package/src/context/file/types.ts +41 -0
  146. package/src/context/file/view-cache.ts +146 -0
  147. package/src/context/file/watcher.test.ts +149 -0
  148. package/src/context/file/watcher.ts +53 -0
  149. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  150. package/src/context/file.tsx +280 -0
  151. package/src/context/global-sdk.tsx +232 -0
  152. package/src/context/global-sync/bootstrap.ts +206 -0
  153. package/src/context/global-sync/child-store.test.ts +38 -0
  154. package/src/context/global-sync/child-store.ts +281 -0
  155. package/src/context/global-sync/event-reducer.test.ts +552 -0
  156. package/src/context/global-sync/event-reducer.ts +359 -0
  157. package/src/context/global-sync/eviction.ts +28 -0
  158. package/src/context/global-sync/queue.ts +83 -0
  159. package/src/context/global-sync/session-cache.test.ts +102 -0
  160. package/src/context/global-sync/session-cache.ts +62 -0
  161. package/src/context/global-sync/session-load.ts +25 -0
  162. package/src/context/global-sync/session-prefetch.test.ts +96 -0
  163. package/src/context/global-sync/session-prefetch.ts +100 -0
  164. package/src/context/global-sync/session-trim.test.ts +59 -0
  165. package/src/context/global-sync/session-trim.ts +56 -0
  166. package/src/context/global-sync/types.ts +133 -0
  167. package/src/context/global-sync/utils.ts +25 -0
  168. package/src/context/global-sync.test.ts +122 -0
  169. package/src/context/global-sync.tsx +408 -0
  170. package/src/context/highlights.tsx +233 -0
  171. package/src/context/language.tsx +248 -0
  172. package/src/context/layout-scroll.test.ts +64 -0
  173. package/src/context/layout-scroll.ts +126 -0
  174. package/src/context/layout.test.ts +69 -0
  175. package/src/context/layout.tsx +937 -0
  176. package/src/context/local.tsx +422 -0
  177. package/src/context/model-variant.test.ts +86 -0
  178. package/src/context/model-variant.ts +52 -0
  179. package/src/context/models.tsx +163 -0
  180. package/src/context/notification.tsx +373 -0
  181. package/src/context/permission-auto-respond.test.ts +102 -0
  182. package/src/context/permission-auto-respond.ts +51 -0
  183. package/src/context/permission.tsx +277 -0
  184. package/src/context/platform.tsx +99 -0
  185. package/src/context/prompt.tsx +297 -0
  186. package/src/context/sdk.tsx +49 -0
  187. package/src/context/server.tsx +295 -0
  188. package/src/context/settings.tsx +241 -0
  189. package/src/context/sync-optimistic.test.ts +123 -0
  190. package/src/context/sync.tsx +618 -0
  191. package/src/context/terminal-title.ts +51 -0
  192. package/src/context/terminal.test.ts +82 -0
  193. package/src/context/terminal.tsx +437 -0
  194. package/src/entry.tsx +144 -0
  195. package/src/env.d.ts +18 -0
  196. package/src/hooks/use-providers.ts +44 -0
  197. package/src/i18n/ar.ts +855 -0
  198. package/src/i18n/br.ts +867 -0
  199. package/src/i18n/bs.ts +943 -0
  200. package/src/i18n/da.ts +937 -0
  201. package/src/i18n/de.ts +879 -0
  202. package/src/i18n/en.ts +948 -0
  203. package/src/i18n/es.ts +950 -0
  204. package/src/i18n/fr.ts +878 -0
  205. package/src/i18n/ja.ts +861 -0
  206. package/src/i18n/ko.ts +860 -0
  207. package/src/i18n/no.ts +944 -0
  208. package/src/i18n/parity.test.ts +32 -0
  209. package/src/i18n/pl.ts +865 -0
  210. package/src/i18n/ru.ts +946 -0
  211. package/src/i18n/th.ts +933 -0
  212. package/src/i18n/tr.ts +952 -0
  213. package/src/i18n/zh.ts +930 -0
  214. package/src/i18n/zht.ts +925 -0
  215. package/src/index.css +29 -0
  216. package/src/index.ts +6 -0
  217. package/src/pages/directory-layout.tsx +88 -0
  218. package/src/pages/error.tsx +327 -0
  219. package/src/pages/home.tsx +131 -0
  220. package/src/pages/layout/deep-links.ts +50 -0
  221. package/src/pages/layout/helpers.test.ts +211 -0
  222. package/src/pages/layout/helpers.ts +98 -0
  223. package/src/pages/layout/inline-editor.tsx +126 -0
  224. package/src/pages/layout/sidebar-items.tsx +437 -0
  225. package/src/pages/layout/sidebar-project.tsx +384 -0
  226. package/src/pages/layout/sidebar-shell.tsx +125 -0
  227. package/src/pages/layout/sidebar-workspace.tsx +504 -0
  228. package/src/pages/layout.tsx +2509 -0
  229. package/src/pages/session/composer/index.ts +2 -0
  230. package/src/pages/session/composer/session-composer-region.tsx +255 -0
  231. package/src/pages/session/composer/session-composer-state.test.ts +128 -0
  232. package/src/pages/session/composer/session-composer-state.ts +249 -0
  233. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  234. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  235. package/src/pages/session/composer/session-question-dock.tsx +449 -0
  236. package/src/pages/session/composer/session-request-tree.ts +52 -0
  237. package/src/pages/session/composer/session-revert-dock.tsx +99 -0
  238. package/src/pages/session/composer/session-todo-dock.tsx +330 -0
  239. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  240. package/src/pages/session/file-tab-scroll.ts +67 -0
  241. package/src/pages/session/file-tabs.tsx +456 -0
  242. package/src/pages/session/handoff.ts +36 -0
  243. package/src/pages/session/helpers.test.ts +181 -0
  244. package/src/pages/session/helpers.ts +198 -0
  245. package/src/pages/session/message-gesture.test.ts +62 -0
  246. package/src/pages/session/message-gesture.ts +21 -0
  247. package/src/pages/session/message-id-from-hash.ts +6 -0
  248. package/src/pages/session/message-timeline.tsx +1013 -0
  249. package/src/pages/session/review-tab.tsx +170 -0
  250. package/src/pages/session/session-layout.ts +20 -0
  251. package/src/pages/session/session-model-helpers.test.ts +51 -0
  252. package/src/pages/session/session-model-helpers.ts +16 -0
  253. package/src/pages/session/session-side-panel.tsx +453 -0
  254. package/src/pages/session/terminal-label.ts +16 -0
  255. package/src/pages/session/terminal-panel.test.ts +25 -0
  256. package/src/pages/session/terminal-panel.tsx +326 -0
  257. package/src/pages/session/use-session-commands.tsx +495 -0
  258. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  259. package/src/pages/session/use-session-hash-scroll.ts +197 -0
  260. package/src/pages/session.tsx +1841 -0
  261. package/src/sst-env.d.ts +12 -0
  262. package/src/testing/model-selection.ts +80 -0
  263. package/src/testing/prompt.ts +56 -0
  264. package/src/testing/session-composer.ts +84 -0
  265. package/src/testing/terminal.ts +118 -0
  266. package/src/theme-preload.test.ts +46 -0
  267. package/src/utils/agent.ts +23 -0
  268. package/src/utils/aim.ts +138 -0
  269. package/src/utils/base64.ts +10 -0
  270. package/src/utils/comment-note.ts +88 -0
  271. package/src/utils/id.ts +99 -0
  272. package/src/utils/notification-click.test.ts +27 -0
  273. package/src/utils/notification-click.ts +13 -0
  274. package/src/utils/persist.test.ts +115 -0
  275. package/src/utils/persist.ts +476 -0
  276. package/src/utils/prompt.test.ts +44 -0
  277. package/src/utils/prompt.ts +203 -0
  278. package/src/utils/runtime-adapters.test.ts +62 -0
  279. package/src/utils/runtime-adapters.ts +39 -0
  280. package/src/utils/same.ts +6 -0
  281. package/src/utils/scoped-cache.test.ts +69 -0
  282. package/src/utils/scoped-cache.ts +104 -0
  283. package/src/utils/server-errors.test.ts +131 -0
  284. package/src/utils/server-errors.ts +80 -0
  285. package/src/utils/server-health.test.ts +123 -0
  286. package/src/utils/server-health.ts +91 -0
  287. package/src/utils/server.ts +22 -0
  288. package/src/utils/solid-dnd.tsx +49 -0
  289. package/src/utils/sound.ts +117 -0
  290. package/src/utils/terminal-writer.test.ts +64 -0
  291. package/src/utils/terminal-writer.ts +65 -0
  292. package/src/utils/time.ts +22 -0
  293. package/src/utils/uuid.test.ts +78 -0
  294. package/src/utils/uuid.ts +12 -0
  295. package/src/utils/worktree.test.ts +46 -0
  296. package/src/utils/worktree.ts +73 -0
  297. package/sst-env.d.ts +10 -0
  298. package/tsconfig.json +26 -0
  299. package/vite.config.ts +15 -0
  300. package/vite.js +26 -0
@@ -0,0 +1,91 @@
1
+ import { Show, type Component } from "solid-js"
2
+ import { useLanguage } from "@/context/language"
3
+
4
+ type InputKey = "text" | "image" | "audio" | "video" | "pdf"
5
+ type InputMap = Record<InputKey, boolean>
6
+
7
+ type ModelInfo = {
8
+ id: string
9
+ name: string
10
+ provider: {
11
+ name: string
12
+ }
13
+ capabilities?: {
14
+ reasoning: boolean
15
+ input: InputMap
16
+ }
17
+ modalities?: {
18
+ input: Array<string>
19
+ }
20
+ reasoning?: boolean
21
+ limit: {
22
+ context: number
23
+ }
24
+ }
25
+
26
+ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
27
+ const language = useLanguage()
28
+ const sourceName = (model: ModelInfo) => {
29
+ const value = `${model.id} ${model.name}`.toLowerCase()
30
+
31
+ if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
32
+ if (/gpt|o[1-4]|codex|openai/.test(value)) return language.t("model.provider.openai")
33
+ if (/gemini|palm|bard|google/.test(value)) return language.t("model.provider.google")
34
+ if (/grok|xai/.test(value)) return language.t("model.provider.xai")
35
+ if (/llama|meta/.test(value)) return language.t("model.provider.meta")
36
+
37
+ return model.provider.name
38
+ }
39
+ const inputLabel = (value: string) => {
40
+ if (value === "text") return language.t("model.input.text")
41
+ if (value === "image") return language.t("model.input.image")
42
+ if (value === "audio") return language.t("model.input.audio")
43
+ if (value === "video") return language.t("model.input.video")
44
+ if (value === "pdf") return language.t("model.input.pdf")
45
+ return value
46
+ }
47
+ const title = () => {
48
+ const tags: Array<string> = []
49
+ if (props.latest) tags.push(language.t("model.tag.latest"))
50
+ if (props.free) tags.push(language.t("model.tag.free"))
51
+ const suffix = tags.length ? ` (${tags.join(", ")})` : ""
52
+ return `${sourceName(props.model)} ${props.model.name}${suffix}`
53
+ }
54
+ const inputs = () => {
55
+ if (props.model.capabilities) {
56
+ const input = props.model.capabilities.input
57
+ const order: Array<InputKey> = ["text", "image", "audio", "video", "pdf"]
58
+ const entries = order.filter((key) => input[key]).map((key) => inputLabel(key))
59
+ return entries.length ? entries.join(", ") : undefined
60
+ }
61
+ const raw = props.model.modalities?.input
62
+ if (!raw) return
63
+ const entries = raw.map((value) => inputLabel(value))
64
+ return entries.length ? entries.join(", ") : undefined
65
+ }
66
+ const reasoning = () => {
67
+ if (props.model.capabilities)
68
+ return props.model.capabilities.reasoning
69
+ ? language.t("model.tooltip.reasoning.allowed")
70
+ : language.t("model.tooltip.reasoning.none")
71
+ return props.model.reasoning
72
+ ? language.t("model.tooltip.reasoning.allowed")
73
+ : language.t("model.tooltip.reasoning.none")
74
+ }
75
+ const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
76
+
77
+ return (
78
+ <div class="flex flex-col gap-1 py-1">
79
+ <div class="text-13-medium">{title()}</div>
80
+ <Show when={inputs()}>
81
+ {(value) => (
82
+ <div class="text-12-regular text-text-invert-base">
83
+ {language.t("model.tooltip.allows", { inputs: value() })}
84
+ </div>
85
+ )}
86
+ </Show>
87
+ <div class="text-12-regular text-text-invert-base">{reasoning()}</div>
88
+ <div class="text-12-regular text-text-invert-base">{context()}</div>
89
+ </div>
90
+ )
91
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { attachmentMime } from "./files"
3
+ import { pasteMode } from "./paste"
4
+
5
+ describe("attachmentMime", () => {
6
+ test("keeps PDFs when the browser reports the mime", async () => {
7
+ const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
8
+ expect(await attachmentMime(file)).toBe("application/pdf")
9
+ })
10
+
11
+ test("normalizes structured text types to text/plain", async () => {
12
+ const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
13
+ expect(await attachmentMime(file)).toBe("text/plain")
14
+ })
15
+
16
+ test("accepts text files even with a misleading browser mime", async () => {
17
+ const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
18
+ expect(await attachmentMime(file)).toBe("text/plain")
19
+ })
20
+
21
+ test("rejects binary files", async () => {
22
+ const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
23
+ expect(await attachmentMime(file)).toBeUndefined()
24
+ })
25
+ })
26
+
27
+ describe("pasteMode", () => {
28
+ test("uses native paste for short single-line text", () => {
29
+ expect(pasteMode("hello world")).toBe("native")
30
+ })
31
+
32
+ test("uses manual paste for multiline text", () => {
33
+ expect(
34
+ pasteMode(`{
35
+ "ok": true
36
+ }`),
37
+ ).toBe("manual")
38
+ expect(pasteMode("a\r\nb")).toBe("manual")
39
+ })
40
+
41
+ test("uses manual paste for large text", () => {
42
+ expect(pasteMode("x".repeat(8000))).toBe("manual")
43
+ })
44
+ })
@@ -0,0 +1,201 @@
1
+ import { onCleanup, onMount } from "solid-js"
2
+ import { showToast } from "@reign-labs/ui/toast"
3
+ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
4
+ import { useLanguage } from "@/context/language"
5
+ import { uuid } from "@/utils/uuid"
6
+ import { getCursorPosition } from "./editor-dom"
7
+ import { attachmentMime } from "./files"
8
+ import { normalizePaste, pasteMode } from "./paste"
9
+
10
+ function dataUrl(file: File, mime: string) {
11
+ return new Promise<string>((resolve) => {
12
+ const reader = new FileReader()
13
+ reader.addEventListener("error", () => resolve(""))
14
+ reader.addEventListener("load", () => {
15
+ const value = typeof reader.result === "string" ? reader.result : ""
16
+ const idx = value.indexOf(",")
17
+ if (idx === -1) {
18
+ resolve(value)
19
+ return
20
+ }
21
+ resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
22
+ })
23
+ reader.readAsDataURL(file)
24
+ })
25
+ }
26
+
27
+ type PromptAttachmentsInput = {
28
+ editor: () => HTMLDivElement | undefined
29
+ isDialogActive: () => boolean
30
+ setDraggingType: (type: "image" | "@mention" | null) => void
31
+ focusEditor: () => void
32
+ addPart: (part: ContentPart) => boolean
33
+ readClipboardImage?: () => Promise<File | null>
34
+ }
35
+
36
+ export function createPromptAttachments(input: PromptAttachmentsInput) {
37
+ const prompt = usePrompt()
38
+ const language = useLanguage()
39
+
40
+ const warn = () => {
41
+ showToast({
42
+ title: language.t("prompt.toast.pasteUnsupported.title"),
43
+ description: language.t("prompt.toast.pasteUnsupported.description"),
44
+ })
45
+ }
46
+
47
+ const add = async (file: File, toast = true) => {
48
+ const mime = await attachmentMime(file)
49
+ if (!mime) {
50
+ if (toast) warn()
51
+ return false
52
+ }
53
+
54
+ const editor = input.editor()
55
+ if (!editor) return false
56
+
57
+ const url = await dataUrl(file, mime)
58
+ if (!url) return false
59
+
60
+ const attachment: ImageAttachmentPart = {
61
+ type: "image",
62
+ id: uuid(),
63
+ filename: file.name,
64
+ mime,
65
+ dataUrl: url,
66
+ }
67
+ const cursor = prompt.cursor() ?? getCursorPosition(editor)
68
+ prompt.set([...prompt.current(), attachment], cursor)
69
+ return true
70
+ }
71
+
72
+ const addAttachment = (file: File) => add(file)
73
+
74
+ const addAttachments = async (files: File[], toast = true) => {
75
+ let found = false
76
+
77
+ for (const file of files) {
78
+ const ok = await add(file, false)
79
+ if (ok) found = true
80
+ }
81
+
82
+ if (!found && files.length > 0 && toast) warn()
83
+ return found
84
+ }
85
+
86
+ const removeAttachment = (id: string) => {
87
+ const current = prompt.current()
88
+ const next = current.filter((part) => part.type !== "image" || part.id !== id)
89
+ prompt.set(next, prompt.cursor())
90
+ }
91
+
92
+ const handlePaste = async (event: ClipboardEvent) => {
93
+ const clipboardData = event.clipboardData
94
+ if (!clipboardData) return
95
+
96
+ event.preventDefault()
97
+ event.stopPropagation()
98
+
99
+ const files = Array.from(clipboardData.items).flatMap((item) => {
100
+ if (item.kind !== "file") return []
101
+ const file = item.getAsFile()
102
+ return file ? [file] : []
103
+ })
104
+
105
+ if (files.length > 0) {
106
+ await addAttachments(files)
107
+ return
108
+ }
109
+
110
+ const plainText = clipboardData.getData("text/plain") ?? ""
111
+
112
+ // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
113
+ if (input.readClipboardImage && !plainText) {
114
+ const file = await input.readClipboardImage()
115
+ if (file) {
116
+ await addAttachment(file)
117
+ return
118
+ }
119
+ }
120
+
121
+ if (!plainText) return
122
+
123
+ const text = normalizePaste(plainText)
124
+
125
+ const put = () => {
126
+ if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
127
+ input.focusEditor()
128
+ return input.addPart({ type: "text", content: text, start: 0, end: 0 })
129
+ }
130
+
131
+ if (pasteMode(text) === "manual") {
132
+ put()
133
+ return
134
+ }
135
+
136
+ const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
137
+ if (inserted) return
138
+
139
+ put()
140
+ }
141
+
142
+ const handleGlobalDragOver = (event: DragEvent) => {
143
+ if (input.isDialogActive()) return
144
+
145
+ event.preventDefault()
146
+ const hasFiles = event.dataTransfer?.types.includes("Files")
147
+ const hasText = event.dataTransfer?.types.includes("text/plain")
148
+ if (hasFiles) {
149
+ input.setDraggingType("image")
150
+ } else if (hasText) {
151
+ input.setDraggingType("@mention")
152
+ }
153
+ }
154
+
155
+ const handleGlobalDragLeave = (event: DragEvent) => {
156
+ if (input.isDialogActive()) return
157
+ if (!event.relatedTarget) {
158
+ input.setDraggingType(null)
159
+ }
160
+ }
161
+
162
+ const handleGlobalDrop = async (event: DragEvent) => {
163
+ if (input.isDialogActive()) return
164
+
165
+ event.preventDefault()
166
+ input.setDraggingType(null)
167
+
168
+ const plainText = event.dataTransfer?.getData("text/plain")
169
+ const filePrefix = "file:"
170
+ if (plainText?.startsWith(filePrefix)) {
171
+ const filePath = plainText.slice(filePrefix.length)
172
+ input.focusEditor()
173
+ input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 })
174
+ return
175
+ }
176
+
177
+ const dropped = event.dataTransfer?.files
178
+ if (!dropped) return
179
+
180
+ await addAttachments(Array.from(dropped))
181
+ }
182
+
183
+ onMount(() => {
184
+ document.addEventListener("dragover", handleGlobalDragOver)
185
+ document.addEventListener("dragleave", handleGlobalDragLeave)
186
+ document.addEventListener("drop", handleGlobalDrop)
187
+ })
188
+
189
+ onCleanup(() => {
190
+ document.removeEventListener("dragover", handleGlobalDragOver)
191
+ document.removeEventListener("dragleave", handleGlobalDragLeave)
192
+ document.removeEventListener("drop", handleGlobalDrop)
193
+ })
194
+
195
+ return {
196
+ addAttachment,
197
+ addAttachments,
198
+ removeAttachment,
199
+ handlePaste,
200
+ }
201
+ }
@@ -0,0 +1,312 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { Prompt } from "@/context/prompt"
3
+ import { buildRequestParts } from "./build-request-parts"
4
+
5
+ describe("buildRequestParts", () => {
6
+ test("builds typed request and optimistic parts without cast path", () => {
7
+ const prompt: Prompt = [
8
+ { type: "text", content: "hello", start: 0, end: 5 },
9
+ {
10
+ type: "file",
11
+ path: "src/foo.ts",
12
+ content: "@src/foo.ts",
13
+ start: 5,
14
+ end: 16,
15
+ selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
16
+ },
17
+ { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
18
+ ]
19
+
20
+ const result = buildRequestParts({
21
+ prompt,
22
+ context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
23
+ images: [
24
+ { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
25
+ ],
26
+ text: "hello @src/foo.ts @planner",
27
+ messageID: "msg_1",
28
+ sessionID: "ses_1",
29
+ sessionDirectory: "/repo",
30
+ })
31
+
32
+ expect(result.requestParts[0]?.type).toBe("text")
33
+ expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
34
+ expect(
35
+ result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
36
+ ).toBe(true)
37
+ expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
38
+ expect(
39
+ result.requestParts.some(
40
+ (part) =>
41
+ part.type === "text" &&
42
+ part.synthetic &&
43
+ part.metadata?.opencodeComment &&
44
+ (part.metadata.opencodeComment as { comment?: string }).comment === "check this",
45
+ ),
46
+ ).toBe(true)
47
+
48
+ expect(result.optimisticParts).toHaveLength(result.requestParts.length)
49
+ expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
50
+ })
51
+
52
+ test("keeps multiple uploaded attachments in order", () => {
53
+ const result = buildRequestParts({
54
+ prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
55
+ context: [],
56
+ images: [
57
+ { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
58
+ {
59
+ type: "image",
60
+ id: "img_2",
61
+ filename: "b.pdf",
62
+ mime: "application/pdf",
63
+ dataUrl: "data:application/pdf;base64,BBB",
64
+ },
65
+ ],
66
+ text: "check these",
67
+ messageID: "msg_multi",
68
+ sessionID: "ses_multi",
69
+ sessionDirectory: "/repo",
70
+ })
71
+
72
+ const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
73
+
74
+ expect(files).toHaveLength(2)
75
+ expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
76
+ })
77
+
78
+ test("deduplicates context files when prompt already includes same path", () => {
79
+ const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
80
+
81
+ const result = buildRequestParts({
82
+ prompt,
83
+ context: [
84
+ { key: "ctx:dup", type: "file", path: "src/foo.ts" },
85
+ { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
86
+ ],
87
+ images: [],
88
+ text: "@src/foo.ts",
89
+ messageID: "msg_2",
90
+ sessionID: "ses_2",
91
+ sessionDirectory: "/repo",
92
+ })
93
+
94
+ const fooFiles = result.requestParts.filter(
95
+ (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
96
+ )
97
+ const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
98
+
99
+ expect(fooFiles).toHaveLength(2)
100
+ expect(synthetic).toHaveLength(1)
101
+ })
102
+
103
+ test("handles Windows paths correctly (simulated on macOS)", () => {
104
+ const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
105
+
106
+ const result = buildRequestParts({
107
+ prompt,
108
+ context: [],
109
+ images: [],
110
+ text: "@src\\foo.ts",
111
+ messageID: "msg_win_1",
112
+ sessionID: "ses_win_1",
113
+ sessionDirectory: "D:\\projects\\myapp", // Windows path
114
+ })
115
+
116
+ // Should create valid file URLs
117
+ const filePart = result.requestParts.find((part) => part.type === "file")
118
+ expect(filePart).toBeDefined()
119
+ if (filePart?.type === "file") {
120
+ // URL should be parseable
121
+ expect(() => new URL(filePart.url)).not.toThrow()
122
+ // Should not have encoded backslashes in wrong place
123
+ expect(filePart.url).not.toContain("%5C")
124
+ // Should have normalized to forward slashes
125
+ expect(filePart.url).toContain("/src/foo.ts")
126
+ }
127
+ })
128
+
129
+ test("handles Windows absolute path with special characters", () => {
130
+ const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
131
+
132
+ const result = buildRequestParts({
133
+ prompt,
134
+ context: [],
135
+ images: [],
136
+ text: "@file#name.txt",
137
+ messageID: "msg_win_2",
138
+ sessionID: "ses_win_2",
139
+ sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
140
+ })
141
+
142
+ const filePart = result.requestParts.find((part) => part.type === "file")
143
+ expect(filePart).toBeDefined()
144
+ if (filePart?.type === "file") {
145
+ // URL should be parseable
146
+ expect(() => new URL(filePart.url)).not.toThrow()
147
+ // Special chars should be encoded
148
+ expect(filePart.url).toContain("file%23name.txt")
149
+ // Should have Windows drive letter properly encoded
150
+ expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
151
+ }
152
+ })
153
+
154
+ test("handles Linux absolute paths correctly", () => {
155
+ const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
156
+
157
+ const result = buildRequestParts({
158
+ prompt,
159
+ context: [],
160
+ images: [],
161
+ text: "@src/app.ts",
162
+ messageID: "msg_linux_1",
163
+ sessionID: "ses_linux_1",
164
+ sessionDirectory: "/home/user/project",
165
+ })
166
+
167
+ const filePart = result.requestParts.find((part) => part.type === "file")
168
+ expect(filePart).toBeDefined()
169
+ if (filePart?.type === "file") {
170
+ // URL should be parseable
171
+ expect(() => new URL(filePart.url)).not.toThrow()
172
+ // Should be a normal Unix path
173
+ expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
174
+ }
175
+ })
176
+
177
+ test("handles macOS paths correctly", () => {
178
+ const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
179
+
180
+ const result = buildRequestParts({
181
+ prompt,
182
+ context: [],
183
+ images: [],
184
+ text: "@README.md",
185
+ messageID: "msg_mac_1",
186
+ sessionID: "ses_mac_1",
187
+ sessionDirectory: "/Users/kelvin/Projects/opencode",
188
+ })
189
+
190
+ const filePart = result.requestParts.find((part) => part.type === "file")
191
+ expect(filePart).toBeDefined()
192
+ if (filePart?.type === "file") {
193
+ // URL should be parseable
194
+ expect(() => new URL(filePart.url)).not.toThrow()
195
+ // Should be a normal Unix path
196
+ expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
197
+ }
198
+ })
199
+
200
+ test("handles context files with Windows paths", () => {
201
+ const prompt: Prompt = []
202
+
203
+ const result = buildRequestParts({
204
+ prompt,
205
+ context: [
206
+ { key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
207
+ { key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
208
+ ],
209
+ images: [],
210
+ text: "test",
211
+ messageID: "msg_win_ctx",
212
+ sessionID: "ses_win_ctx",
213
+ sessionDirectory: "D:\\workspace\\app",
214
+ })
215
+
216
+ const fileParts = result.requestParts.filter((part) => part.type === "file")
217
+ expect(fileParts).toHaveLength(2)
218
+
219
+ // All file URLs should be valid
220
+ fileParts.forEach((part) => {
221
+ if (part.type === "file") {
222
+ expect(() => new URL(part.url)).not.toThrow()
223
+ expect(part.url).not.toContain("%5C") // No encoded backslashes
224
+ }
225
+ })
226
+ })
227
+
228
+ test("handles absolute Windows paths (user manually specifies full path)", () => {
229
+ const prompt: Prompt = [
230
+ { type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
231
+ ]
232
+
233
+ const result = buildRequestParts({
234
+ prompt,
235
+ context: [],
236
+ images: [],
237
+ text: "@D:\\other\\project\\file.ts",
238
+ messageID: "msg_abs",
239
+ sessionID: "ses_abs",
240
+ sessionDirectory: "C:\\current\\project",
241
+ })
242
+
243
+ const filePart = result.requestParts.find((part) => part.type === "file")
244
+ expect(filePart).toBeDefined()
245
+ if (filePart?.type === "file") {
246
+ // Should handle absolute path that differs from sessionDirectory
247
+ expect(() => new URL(filePart.url)).not.toThrow()
248
+ expect(filePart.url).toContain("/D:/other/project/file.ts")
249
+ }
250
+ })
251
+
252
+ test("handles selection with query parameters on Windows", () => {
253
+ const prompt: Prompt = [
254
+ {
255
+ type: "file",
256
+ path: "src\\App.tsx",
257
+ content: "@src\\App.tsx",
258
+ start: 0,
259
+ end: 11,
260
+ selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
261
+ },
262
+ ]
263
+
264
+ const result = buildRequestParts({
265
+ prompt,
266
+ context: [],
267
+ images: [],
268
+ text: "@src\\App.tsx",
269
+ messageID: "msg_sel",
270
+ sessionID: "ses_sel",
271
+ sessionDirectory: "C:\\project",
272
+ })
273
+
274
+ const filePart = result.requestParts.find((part) => part.type === "file")
275
+ expect(filePart).toBeDefined()
276
+ if (filePart?.type === "file") {
277
+ // Should have query parameters
278
+ expect(filePart.url).toContain("?start=10&end=20")
279
+ // Should be valid URL
280
+ expect(() => new URL(filePart.url)).not.toThrow()
281
+ // Query params should parse correctly
282
+ const url = new URL(filePart.url)
283
+ expect(url.searchParams.get("start")).toBe("10")
284
+ expect(url.searchParams.get("end")).toBe("20")
285
+ }
286
+ })
287
+
288
+ test("handles file paths with dots and special segments on Windows", () => {
289
+ const prompt: Prompt = [
290
+ { type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
291
+ ]
292
+
293
+ const result = buildRequestParts({
294
+ prompt,
295
+ context: [],
296
+ images: [],
297
+ text: "@..\\..\\shared\\util.ts",
298
+ messageID: "msg_dots",
299
+ sessionID: "ses_dots",
300
+ sessionDirectory: "C:\\projects\\myapp\\src",
301
+ })
302
+
303
+ const filePart = result.requestParts.find((part) => part.type === "file")
304
+ expect(filePart).toBeDefined()
305
+ if (filePart?.type === "file") {
306
+ // Should be valid URL
307
+ expect(() => new URL(filePart.url)).not.toThrow()
308
+ // Should preserve .. segments (backend normalizes)
309
+ expect(filePart.url).toContain("/..")
310
+ }
311
+ })
312
+ })