ompchamber 1.2.0 → 2.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 (223) hide show
  1. package/README.md +131 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.74ff36419f.js +2 -0
  14. package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
  15. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  16. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  17. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  18. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  19. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  20. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  21. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  22. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  23. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  24. package/dist/client/static/js/async/q.33d6779f98.js +21 -0
  25. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  26. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  27. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  28. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  29. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  31. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  32. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  33. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  34. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  35. package/dist/client/static/js/index.c05535dbde.js +38 -0
  36. package/package.json +3 -3
  37. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  38. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  39. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  40. package/src/client/components/settings/Modal.tsx +11 -2
  41. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  42. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  43. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  45. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  46. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  47. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  48. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  49. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  50. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  51. package/src/client/components/workspace/btw-panel/index.tsx +129 -45
  52. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  53. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  54. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  59. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  63. package/src/client/components/workspace/editor/index.tsx +11 -2
  64. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  65. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  66. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  67. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  68. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  69. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  70. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  71. package/src/client/data/theme/terminal.ts +50 -52
  72. package/src/client/hooks/chat/btw/index.ts +139 -18
  73. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  74. package/src/client/hooks/chat/btw/mode.ts +111 -16
  75. package/src/client/hooks/chat/composer/index.ts +10 -4
  76. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  77. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  78. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  79. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  80. package/src/client/hooks/chat/timeline/index.ts +8 -20
  81. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  82. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  83. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  84. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  85. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  86. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  87. package/src/client/hooks/settings/providers.ts +34 -3
  88. package/src/client/hooks/ui/open-file.ts +7 -1
  89. package/src/client/hooks/ui/theme.ts +82 -57
  90. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  91. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  92. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  93. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  94. package/src/client/hooks/workspace/search-stream.ts +24 -5
  95. package/src/client/main.tsx +6 -0
  96. package/src/client/tailwind.css +19 -56
  97. package/src/server/lib/btw/child.server.ts +41 -0
  98. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  99. package/src/server/lib/btw/prompt.ts +13 -5
  100. package/src/server/lib/btw/registry.server.ts +13 -4
  101. package/src/server/lib/btw/runtime.server.ts +149 -156
  102. package/src/server/lib/btw/schema.server.ts +27 -0
  103. package/src/server/lib/btw/service.server.ts +106 -5
  104. package/src/server/lib/btw/store.server.ts +57 -6
  105. package/src/server/lib/db/schema.ts +4 -0
  106. package/src/server/lib/db/settings-store.test.ts +65 -0
  107. package/src/server/lib/db/settings-store.ts +25 -0
  108. package/src/server/lib/fs/atomic-write.ts +42 -4
  109. package/src/server/lib/fs/config-lock.ts +74 -0
  110. package/src/server/lib/fs/git-ignore.ts +55 -5
  111. package/src/server/lib/fs/ripgrep.ts +119 -0
  112. package/src/server/lib/lifecycle/identity.ts +10 -64
  113. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  114. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  115. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  116. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  117. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  118. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  119. package/src/server/lib/models/provider-registry.server.ts +2 -2
  120. package/src/server/lib/models/registry.server.ts +1 -1
  121. package/src/server/lib/omp/config/behavior.ts +17 -17
  122. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  123. package/src/server/lib/omp/config/document.ts +128 -0
  124. package/src/server/lib/omp/config/extensions.ts +11 -10
  125. package/src/server/lib/omp/config/mcp.ts +4 -57
  126. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  127. package/src/server/lib/omp/config/models-config.ts +92 -0
  128. package/src/server/lib/omp/config/models-validation.ts +159 -0
  129. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  130. package/src/server/lib/omp/config/providers.ts +241 -211
  131. package/src/server/lib/omp/config/roles.ts +6 -14
  132. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  133. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  134. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  135. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  136. package/src/server/lib/omp/rpc/manager.ts +21 -21
  137. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  138. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  139. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  140. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  141. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  142. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  143. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  144. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  145. package/src/server/lib/queue/delivery-harness.ts +88 -0
  146. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  147. package/src/server/lib/queue/delivery.server.ts +105 -13
  148. package/src/server/lib/queue/store.server.ts +30 -5
  149. package/src/server/lib/terminal/foreground.test.ts +164 -0
  150. package/src/server/lib/terminal/foreground.ts +34 -6
  151. package/src/server/lib/terminal/history.ts +8 -7
  152. package/src/server/plugins/compress.ts +33 -5
  153. package/src/server/plugins/ssr.ts +14 -7
  154. package/src/server/routes/btw/topics.ts +89 -2
  155. package/src/server/routes/fs/git.ts +5 -6
  156. package/src/server/routes/fs/search.ts +17 -26
  157. package/src/server/routes/sessions/queue.ts +17 -4
  158. package/src/server/routes/settings/index.ts +2 -0
  159. package/src/server/routes/settings/model-override.ts +69 -0
  160. package/src/server/routes/settings/provider-models.ts +35 -12
  161. package/src/server/routes/settings/providers.ts +31 -1
  162. package/src/server/routes/settings/root.ts +8 -3
  163. package/src/shared/lib/chat/btw/client.ts +34 -0
  164. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  165. package/src/shared/lib/code/shiki-themes.ts +9 -5
  166. package/src/shared/lib/fs/file-tree.ts +17 -12
  167. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  168. package/src/shared/lib/markdown/autolink.ts +50 -0
  169. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  170. package/src/shared/lib/markdown/document-urls.ts +118 -0
  171. package/src/shared/lib/markdown/marked.ts +27 -15
  172. package/src/shared/lib/markdown/mermaid.ts +2 -2
  173. package/src/shared/lib/models/catalog.ts +1 -1
  174. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  175. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  176. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  177. package/src/shared/lib/settings/client.ts +10 -4
  178. package/src/shared/lib/settings/diff.test.ts +31 -0
  179. package/src/shared/lib/settings/diff.ts +37 -0
  180. package/src/shared/lib/theme/catalog.test.ts +168 -0
  181. package/src/shared/lib/theme/catalog.ts +98 -0
  182. package/src/shared/lib/theme/color.ts +88 -0
  183. package/src/shared/lib/theme/css.ts +70 -0
  184. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  185. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  186. package/src/shared/lib/theme/palettes/light.ts +313 -0
  187. package/src/shared/types/btw.ts +83 -4
  188. package/src/shared/types/index.ts +1 -0
  189. package/src/shared/types/settings/provider.ts +8 -1
  190. package/src/shared/types/settings/state.ts +10 -4
  191. package/src/shared/types/theme.ts +48 -0
  192. package/dist/client/static/css/index.b938f4a693.css +0 -2
  193. package/dist/client/static/js/77.8022a800b5.js +0 -139
  194. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  195. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  196. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  197. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  198. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  199. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  200. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  201. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  202. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  203. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  204. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  205. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  206. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  207. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  208. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  209. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  210. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  211. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  212. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  213. package/dist/client/static/js/index.57b39ffa7f.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
  215. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  216. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  217. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  218. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  219. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  220. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  221. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
  222. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/{index.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -0,0 +1,69 @@
1
+ import { json } from '@/server/lib/remix-compat';
2
+ import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
3
+ import { methodNotAllowed } from '@/server/lib/route-adapter';
4
+ import { isMockMode } from '@/server/mock.server';
5
+ import { writeModelOverride, type ReasoningEffort } from '@/server/lib/omp/config/model-overrides';
6
+ import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
7
+
8
+ /**
9
+ * PUT /api/settings/model-override — the per-model knobs omp actually honours.
10
+ *
11
+ * omp resolves `maxTokens` and the reasoning `thinking` block per model from
12
+ * models.yml `modelOverrides`, and has no per-model temperature/topP at all
13
+ * (those exist only as global `config.yml` settings, editable in Settings →
14
+ * OMP Engine). Writing the chamber's model dialog anywhere else made its
15
+ * controls inert, so this route is the one that reaches the agent.
16
+ *
17
+ * Body: { provider, modelId, maxTokens?: number|null, reasoningEffort?: string|null }
18
+ */
19
+
20
+ const EFFORTS: readonly ReasoningEffort[] = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
21
+
22
+ export async function action({ request, params }: ActionFunctionArgs) {
23
+ if (request.method !== 'PUT' && request.method !== 'POST') {
24
+ return methodNotAllowed({ request, params });
25
+ }
26
+ try {
27
+ const body = await request.json() as {
28
+ provider?: unknown;
29
+ modelId?: unknown;
30
+ maxTokens?: unknown;
31
+ reasoningEffort?: unknown;
32
+ };
33
+ const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
34
+ const modelId = typeof body.modelId === 'string' ? body.modelId.trim() : '';
35
+ if (!provider || !modelId) {
36
+ return json({ error: 'provider and modelId are required' }, { status: 400 });
37
+ }
38
+
39
+ // `null` means "clear the override"; `undefined` means "leave it alone".
40
+ let maxTokens: number | null | undefined;
41
+ if (body.maxTokens === null) maxTokens = null;
42
+ else if (typeof body.maxTokens === 'number' && Number.isFinite(body.maxTokens) && body.maxTokens > 0) {
43
+ maxTokens = Math.round(body.maxTokens);
44
+ } else if (body.maxTokens !== undefined) {
45
+ return json({ error: 'maxTokens must be a positive number or null' }, { status: 400 });
46
+ }
47
+
48
+ let reasoningEffort: ReasoningEffort | null | undefined;
49
+ if (body.reasoningEffort === null) reasoningEffort = null;
50
+ else if (typeof body.reasoningEffort === 'string' && (EFFORTS as readonly string[]).includes(body.reasoningEffort)) {
51
+ reasoningEffort = body.reasoningEffort as ReasoningEffort;
52
+ } else if (body.reasoningEffort !== undefined) {
53
+ return json({ error: `reasoningEffort must be one of ${EFFORTS.join(', ')} or null` }, { status: 400 });
54
+ }
55
+
56
+ if (maxTokens === undefined && reasoningEffort === undefined) {
57
+ return json({ error: 'nothing to write' }, { status: 400 });
58
+ }
59
+
60
+ // Mock mode has no models.yml to edit; the overlay already carries the value.
61
+ if (isMockMode()) return json({ success: true, written: false, isMock: true });
62
+
63
+ const result = await writeModelOverride({ provider, modelId, maxTokens, reasoningEffort });
64
+ if (result.written) invalidateModelsCaches();
65
+ return json({ success: result.written, ...result });
66
+ } catch (error) {
67
+ return json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
68
+ }
69
+ }
@@ -124,10 +124,26 @@ async function fetchRemoteModels(baseUrl: string, apiKey?: string): Promise<Fetc
124
124
  };
125
125
  }
126
126
 
127
+ /**
128
+ * omp's `api` for a custom provider, inferred from its endpoint. omp accepts
129
+ * eleven `api` values and there is no way to ask a gateway which one it speaks,
130
+ * so the chamber only claims the two dialects it can recognise by URL; every
131
+ * other endpoint gets the OpenAI-compatible default, which is what the
132
+ * model-listing probe already assumes. A gateway that speaks something else
133
+ * (Azure, Bedrock, Vertex, …) needs its `api` set by hand in models.yml.
134
+ */
135
+ function inferProviderApi(baseUrl: string): 'openai-completions' | 'anthropic-messages' | 'google-generative-ai' {
136
+ if (/anthropic\./i.test(baseUrl)) return 'anthropic-messages';
137
+ if (/generativelanguage\.googleapis\.com/i.test(baseUrl)) return 'google-generative-ai';
138
+ return 'openai-completions';
139
+ }
140
+
127
141
  /**
128
142
  * omp's models.yml schema requires all four cost fields when cost is present,
129
143
  * so a price is only seeded when both sides are known; missing ones default
130
144
  * to 0. Context/output labels ("128K ctx · 16K out") parse back to tokens.
145
+ * Cache prices come from the catalog and are kept when known — dropping them
146
+ * makes every cached turn look free in omp's usage accounting.
131
147
  */
132
148
  function toOmpSeed(model: ProviderModel): {
133
149
  id: string;
@@ -148,7 +164,14 @@ function toOmpSeed(model: ProviderModel): {
148
164
  };
149
165
  const contextWindow = parseTokens(model.contextWindow, 'ctx');
150
166
  const outTokens = parseTokens(model.contextWindow, 'out');
151
- const hasCost = typeof model.priceInput === 'number' && typeof model.priceOutput === 'number';
167
+ // omp rejects the whole file on a non-finite number, so a price has to be a
168
+ // real finite value before it may be written at all.
169
+ const price = (value: unknown): number | undefined => (
170
+ typeof value === 'number' && Number.isFinite(value) ? value : undefined
171
+ );
172
+ const priceInput = price(model.priceInput);
173
+ const priceOutput = price(model.priceOutput);
174
+ const hasCost = priceInput !== undefined && priceOutput !== undefined;
152
175
  return {
153
176
  id: model.id,
154
177
  ...(model.name && model.name !== model.id ? { name: model.name } : {}),
@@ -158,10 +181,10 @@ function toOmpSeed(model: ProviderModel): {
158
181
  ...(outTokens && outTokens > 0 ? { maxTokens: Math.round(outTokens) } : {}),
159
182
  ...(hasCost ? {
160
183
  cost: {
161
- input: model.priceInput as number,
162
- output: model.priceOutput as number,
163
- cacheRead: 0,
164
- cacheWrite: 0,
184
+ input: priceInput,
185
+ output: priceOutput,
186
+ cacheRead: price(model.priceCacheRead) ?? 0,
187
+ cacheWrite: price(model.priceCacheWrite) ?? 0,
165
188
  },
166
189
  } : {}),
167
190
  };
@@ -170,8 +193,8 @@ function toOmpSeed(model: ProviderModel): {
170
193
  /**
171
194
  * Fill in metadata the listing endpoint did not provide from the models.dev
172
195
  * catalog: vision/reasoning/tool flags, context + output window, and
173
- * per-1M-token pricing. Only empty fields are filled — anything the provider
174
- * reported itself is kept.
196
+ * per-1M-token pricing (cache rates included). Only empty fields are filled —
197
+ * anything the provider reported itself is kept.
175
198
  */
176
199
  async function enrichFromCatalog(
177
200
  models: ProviderModel[],
@@ -201,6 +224,10 @@ async function enrichFromCatalog(
201
224
  hasTools: model.hasTools || info.tool_call === true,
202
225
  priceInput: model.priceInput ?? (typeof info.cost?.input === 'number' ? info.cost.input : undefined),
203
226
  priceOutput: model.priceOutput ?? (typeof info.cost?.output === 'number' ? info.cost.output : undefined),
227
+ priceCacheRead: model.priceCacheRead
228
+ ?? (typeof info.cost?.cache_read === 'number' ? info.cost.cache_read : undefined),
229
+ priceCacheWrite: model.priceCacheWrite
230
+ ?? (typeof info.cost?.cache_write === 'number' ? info.cost.cache_write : undefined),
204
231
  };
205
232
  });
206
233
  }
@@ -244,11 +271,7 @@ export async function action({ request }: ActionFunctionArgs) {
244
271
  const upsert = await upsertOmpProviderModels(providerSlug, {
245
272
  baseUrl,
246
273
  apiKey,
247
- api: /anthropic\.com/i.test(baseUrl)
248
- ? 'anthropic-messages'
249
- : /generativelanguage\.googleapis\.com/i.test(baseUrl)
250
- ? 'google-generative-ai'
251
- : 'openai-completions',
274
+ api: inferProviderApi(baseUrl),
252
275
  models: enriched.map(toOmpSeed),
253
276
  });
254
277
  if (upsert.written) invalidateModelsCaches();
@@ -7,6 +7,7 @@ import { isMockMode } from '@/server/mock.server';
7
7
  import type { ProviderItem } from '@/shared/types';
8
8
  import { createSettingsListStore } from '@/server/lib/db/settings-store';
9
9
  import { disableNativeProvider, enableNativeProvider } from '@/server/lib/omp/config/disabled-providers';
10
+ import { removeOmpProvider } from '@/server/lib/omp/config/providers';
10
11
  import { PROVIDERS_SETTINGS_KEY as SETTINGS_KEY, deduplicateProviderItems, mergeProviders } from '@/server/lib/models/provider-registry.server';
11
12
  import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
12
13
 
@@ -111,8 +112,37 @@ export async function action({ request, params }: ActionFunctionArgs) {
111
112
  const id = url.searchParams.get('id');
112
113
  if (!id) return json({ error: 'id is required' }, { status: 400 });
113
114
 
115
+ const stored = await readStoredProviders();
116
+ const target = stored.find((provider) => provider.id === id);
114
117
  const list = await providersStore.remove(db, id, { absent: DEFAULT_PROVIDERS_LIST, empty: DEFAULT_PROVIDERS_LIST });
115
- return respondWithProviders(list);
118
+
119
+ // Removing the overlay row alone left the provider registered in
120
+ // models.yml, so omp kept serving it and the next load merged it straight
121
+ // back into this list — a delete that visibly did nothing. Unregister it
122
+ // from the agent's own registry too.
123
+ let removedFromOmp = false;
124
+ if (target?.slug && !isMockMode()) {
125
+ try {
126
+ removedFromOmp = (await removeOmpProvider(target.slug)).removed;
127
+ // A provider omp still holds in disabledProviders would linger as a
128
+ // "disconnected" ghost entry after its models are gone.
129
+ await enableNativeProvider(target.slug);
130
+ } catch (error) {
131
+ // The overlay row is already gone; report the native failure instead
132
+ // of failing the whole request, so the UI can tell the user the entry
133
+ // is still in models.yml.
134
+ const merged = await mergeProviders(list).catch(() => null);
135
+ return json({
136
+ success: true,
137
+ providers: merged?.providers ?? list,
138
+ removedFromOmp: false,
139
+ warning: error instanceof Error ? error.message : 'models.yml cleanup failed',
140
+ });
141
+ }
142
+ }
143
+ invalidateModelsCaches();
144
+ const merged = await mergeProviders(list);
145
+ return json({ success: true, providers: merged.providers, removedFromOmp });
116
146
  }
117
147
 
118
148
  if (request.method === 'POST' || request.method === 'PUT') {
@@ -3,7 +3,7 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-
3
3
  import { methodNotAllowed } from '@/server/lib/route-adapter';
4
4
  import { getDb } from '@/server/db.server';
5
5
  import { isMockMode } from '@/server/mock.server';
6
- import { readAllSettingsJson, writeSettingsJson, writeSettingsRaw } from '@/server/lib/db/settings-store';
6
+ import { isPlainObject, mergeSettingsJson, readAllSettingsJson, writeSettingsJson, writeSettingsRaw } from '@/server/lib/db/settings-store';
7
7
 
8
8
  export async function loader({ request: _request }: LoaderFunctionArgs) {
9
9
  try {
@@ -24,9 +24,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
24
24
  const data = await request.json();
25
25
  const db = await getDb();
26
26
 
27
- // Data should be an object of key-value pairs
27
+ // Data should be an object of key-value pairs. Object values merge into
28
+ // whatever the key already holds, so a writer that sends one key's patch —
29
+ // the settings modal now sends only what changed — cannot discard the keys
30
+ // another writer stored in between. Arrays and scalars still replace.
28
31
  for (const [key, value] of Object.entries(data)) {
29
- if (typeof value === 'object') {
32
+ if (isPlainObject(value)) {
33
+ await mergeSettingsJson(db, key, value);
34
+ } else if (typeof value === 'object') {
30
35
  await writeSettingsJson(db, key, value);
31
36
  } else {
32
37
  await writeSettingsRaw(db, key, String(value));
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import type { AgentImage, BtwFrame, BtwState, StreamTransport } from '@/shared/types';
17
+ import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
18
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
17
19
  import { connectEvents } from '@/shared/lib/chat/omp/sse';
18
20
  import { connectSocket } from '@/shared/lib/chat/omp/socket';
19
21
  import { btwEventsUrl, btwSocketUrl } from '@/shared/lib/chat/omp/transport';
@@ -70,6 +72,13 @@ export interface AskBtwInput {
70
72
  topicId?: string;
71
73
  question: string;
72
74
  images?: AgentImage[];
75
+ /** Text files inlined for this question (read at attach time by the composer). */
76
+ textFiles?: AttachedTextFileData[];
77
+ /** Composer picks for a FIRST question — a topic that does not exist yet has
78
+ * no row to hold them, so they ride the ask and reach the spawn. */
79
+ model?: { provider: string; id: string };
80
+ thinkingLevel?: string;
81
+ approvalMode?: ApprovalMode;
73
82
  }
74
83
 
75
84
  export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<BtwState> {
@@ -78,6 +87,10 @@ export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<B
78
87
  question: input.question,
79
88
  ...(input.topicId ? { topicId: input.topicId } : {}),
80
89
  ...(input.images?.length ? { images: input.images } : {}),
90
+ ...(input.textFiles?.length ? { textFiles: input.textFiles } : {}),
91
+ ...(input.model ? { provider: input.model.provider, modelId: input.model.id } : {}),
92
+ ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}),
93
+ ...(input.approvalMode ? { accessMode: input.approvalMode } : {}),
81
94
  });
82
95
  }
83
96
 
@@ -85,6 +98,27 @@ export function abortBtwQuestion(sessionId: string, topicId: string): Promise<Bt
85
98
  return requestBtw<BtwState>(sessionId, 'POST', { action: 'abort', topicId });
86
99
  }
87
100
 
101
+ export function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
102
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_model', topicId, provider, modelId });
103
+ }
104
+
105
+ export function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
106
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_thinking_level', topicId, level });
107
+ }
108
+
109
+ export function setBtwAccessMode(sessionId: string, topicId: string, accessMode: ApprovalMode): Promise<BtwState> {
110
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_access_mode', topicId, accessMode });
111
+ }
112
+
113
+ export function respondBtwDialog(
114
+ sessionId: string,
115
+ topicId: string,
116
+ id: string,
117
+ response: { value: string } | { confirmed: boolean } | { cancelled: true },
118
+ ): Promise<BtwState> {
119
+ return requestBtw<BtwState>(sessionId, 'POST', { action: 'dialog_response', topicId, id, response });
120
+ }
121
+
88
122
  export function promoteBtwTopic(sessionId: string, topicId: string): Promise<{ sessionId: string }> {
89
123
  return requestBtw<{ sessionId: string }>(sessionId, 'POST', { action: 'promote', topicId });
90
124
  }
@@ -90,10 +90,17 @@ export const DEVICE_VERBS: Record<string, string> = {
90
90
  * Phrase shown while the model streams reasoning or prose rather than running a
91
91
  * tool. A tool call always names itself from its own name (see
92
92
  * `describeAssistantPhase`), so there is no in-between "preparing" state.
93
+ *
94
+ * `sideQuestion` is the BTW panel's FALLBACK phase, used only before the side
95
+ * child has reported anything: once it does, the panel shows the child's own
96
+ * per-phase verb (`Thinking`, `Writing response`, or the running tool's name).
97
+ * It is named for the feature rather than reusing `thinking`, which would claim
98
+ * a reasoning phase that has not been observed yet.
93
99
  */
94
100
  export const PHASE_VERBS = {
95
101
  thinking: 'Thinking',
96
102
  writing: 'Writing response',
103
+ sideQuestion: 'Answering side question',
97
104
  } as const;
98
105
 
99
106
  export function asString(value: unknown): string | undefined {
@@ -3,12 +3,16 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
+ /**
7
+ * The two Shiki palettes every token carries.
8
+ *
9
+ * Shiki emits both palettes on every token span as `--shiki-light` /
10
+ * `--shiki-dark` custom properties with no inline color, so switching themes
11
+ * recolors code with zero JS and no re-highlight. `tailwind.css` selects which
12
+ * one paints, keyed off `<html data-theme-variant>` rather than a list of theme
13
+ * ids — that list is what a new theme used to fall through.
14
+ */
6
15
  export const SHIKI_LIGHT = 'one-light';
7
16
  export const SHIKI_DARK = 'one-dark-pro';
8
17
 
9
18
  export const SHIKI_THEMES = { light: SHIKI_LIGHT, dark: SHIKI_DARK } as const;
10
-
11
- /** Collapse any app theme id to the syntax theme key it uses. */
12
- export function resolveSyntaxThemeKey(theme?: string): 'light' | 'dark' {
13
- return theme === 'noir' || theme === 'one-dark-pro-soft' ? 'dark' : 'light';
14
- }
@@ -1,6 +1,9 @@
1
- export function setChildrenAt(nodes: any[], path: string, children: any[]): any[] {
1
+ import type { FsNode } from '@/shared/types';
2
+
3
+ /** Replace the children of the node at `path`, rebuilding only that branch. */
4
+ export function setChildrenAt(nodes: FsNode[], path: string, children: FsNode[]): FsNode[] {
2
5
  return nodes.map(node => {
3
- if (node.path === path) return { ...node, children, is_expanded: 1 };
6
+ if (node.path === path) return { ...node, children };
4
7
  if (Array.isArray(node.children)) {
5
8
  const nested = setChildrenAt(node.children, path, children);
6
9
  if (nested !== node.children) return { ...node, children: nested };
@@ -9,18 +12,20 @@ export function setChildrenAt(nodes: any[], path: string, children: any[]): any[
9
12
  });
10
13
  }
11
14
 
12
- export function rehydrateTree(nodes: any[], cache: Record<string, any[]>, expanded: Set<string>): any[] {
15
+ /**
16
+ * Re-attach the children already loaded for a folder, so a re-listing of the
17
+ * same tree does not collapse what the user had open.
18
+ *
19
+ * Which folders are open is the caller's `expandedPaths` set, keyed by paths
20
+ * RELATIVE to the listed root — which is why the caller must drop both that set
21
+ * and the cache when the listed root changes: `src/` under one workspace would
22
+ * otherwise rehydrate into `src/` under the next.
23
+ */
24
+ export function rehydrateTree(nodes: FsNode[], cache: Record<string, FsNode[]>): FsNode[] {
13
25
  return nodes.map(node => {
14
26
  if (node.type !== 'folder') return node;
15
27
  const cached = cache[node.path];
16
- const wasExpanded = expanded.has(node.path);
17
- if (cached) {
18
- return {
19
- ...node,
20
- children: rehydrateTree(cached, cache, expanded),
21
- is_expanded: wasExpanded ? 1 : 0,
22
- };
23
- }
24
- return node;
28
+ if (!cached) return node;
29
+ return { ...node, children: rehydrateTree(cached, cache) };
25
30
  });
26
31
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { renderMarkdown } from '@/shared/lib/markdown/marked';
8
+
9
+ describe('autolink vs code span', () => {
10
+ test('a backticked email stays a code span, not a mailto link', () => {
11
+ const html = renderMarkdown('addresses (`NNN+login@users.noreply.github.com`) and more');
12
+ expect(html).toContain('<code class="inline-code">NNN+login@users.noreply.github.com</code>');
13
+ expect(html).not.toContain('mailto:');
14
+ expect(html).not.toContain('`');
15
+ });
16
+
17
+ test('a backticked URL stays a code span', () => {
18
+ const html = renderMarkdown('see `https://bun.sh/docs` for more');
19
+ expect(html).toContain('<code class="inline-code">https://bun.sh/docs</code>');
20
+ });
21
+
22
+ test('ordinary autolinks are untouched', () => {
23
+ expect(renderMarkdown('see https://bun.sh/docs for more')).toContain('<a href="https://bun.sh/docs">');
24
+ expect(renderMarkdown('write to a@b.com now')).toContain('<a href="mailto:a@b.com">');
25
+ });
26
+
27
+ test('a URL next to a code span keeps both', () => {
28
+ const html = renderMarkdown('https://bun.sh then `npm i`');
29
+ expect(html).toContain('<a href="https://bun.sh">');
30
+ expect(html).toContain('<code class="inline-code">npm i</code>');
31
+ });
32
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * linkify-it autolinking that yields to code spans.
8
+ *
9
+ * linkify-it accepts a backtick as an email local-part character (RFC 5321's
10
+ * `!#$%&'*+/=?^_`{|}~-` set), so `` `NNN+login@users.noreply.github.com` ``
11
+ * matches as ONE autolink whose raw text spans both backticks. The extension
12
+ * runs before marked's own inline rules, so that match is consumed and the code
13
+ * span never forms: the backticks end up as link text and the href carries
14
+ * `%60` escapes (`mailto:%60NNN+…%60`) — a broken link AND a code span that
15
+ * shows its delimiters.
16
+ *
17
+ * A code span wins over an autolink in CommonMark, and the inline lexer reaches
18
+ * the OPENING backtick first — so refusing every match whose raw contains a
19
+ * backtick is enough to hand that position back to the `codespan` rule, which
20
+ * then swallows the whole span. Autolinks without a backtick in them are
21
+ * untouched, which is every ordinary URL and email.
22
+ */
23
+
24
+ import type {
25
+ MarkedExtension,
26
+ RendererExtension,
27
+ Token,
28
+ TokenizerAndRendererExtension,
29
+ TokenizerThis,
30
+ } from 'marked';
31
+ import markedLinkifyIt from 'marked-linkify-it';
32
+
33
+ /** Refuse an autolink whose raw text carries a backtick (see the file header). */
34
+ function guardAutolink(
35
+ extension: TokenizerAndRendererExtension | RendererExtension,
36
+ ): TokenizerAndRendererExtension | RendererExtension {
37
+ if (!('tokenizer' in extension) || !extension.tokenizer) return extension;
38
+ const tokenize = extension.tokenizer;
39
+ return {
40
+ ...extension,
41
+ tokenizer(this: TokenizerThis, source: string, tokens: Token[]) {
42
+ const token = tokenize.call(this, source, tokens);
43
+ return token && !token.raw.includes('`') ? token : undefined;
44
+ },
45
+ };
46
+ }
47
+
48
+ export function codeSafeAutolink(): MarkedExtension {
49
+ return { extensions: (markedLinkifyIt().extensions ?? []).map(guardAutolink) };
50
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { resolveDocumentReference, rewriteDocumentReferences } from '@/shared/lib/markdown/document-urls';
8
+
9
+ const doc = (path: string, root?: string, repo?: string) => ({ path, root, repo });
10
+
11
+ describe('resolveDocumentReference', () => {
12
+ test('resolves beside the document, not beside the app', () => {
13
+ expect(resolveDocumentReference('docs/hero.png', doc('README.md'))?.path).toBe('docs/hero.png');
14
+ expect(resolveDocumentReference('img/a.png', doc('docs/guide.md'))?.path).toBe('docs/img/a.png');
15
+ });
16
+
17
+ test('walks out of the document folder', () => {
18
+ expect(resolveDocumentReference('../README.md', doc('docs/guide.md'))?.path).toBe('README.md');
19
+ expect(resolveDocumentReference('./a.png', doc('docs/guide.md'))?.path).toBe('docs/a.png');
20
+ });
21
+
22
+ test('a leading slash is the scope root, not the document folder', () => {
23
+ expect(resolveDocumentReference('/docs/a.png', doc('manual/v2/guide.md'))?.path).toBe('docs/a.png');
24
+ });
25
+
26
+ test('cannot climb above the scope root', () => {
27
+ expect(resolveDocumentReference('../../../../etc/passwd', doc('README.md'))?.path).toBe('etc/passwd');
28
+ });
29
+
30
+ test('keeps the scope in the raw URL', () => {
31
+ const resolved = resolveDocumentReference('a.png', doc('docs/guide.md', '/ws', 'projects/x'));
32
+ expect(resolved?.url).toBe('/api/fs/raw?path=docs%2Fa.png&root=%2Fws&repo=projects%2Fx');
33
+ // `repo: '.'` means the root itself, so the param is omitted.
34
+ expect(resolveDocumentReference('a.png', doc('docs/guide.md', '/ws', '.'))?.url)
35
+ .toBe('/api/fs/raw?path=docs%2Fa.png&root=%2Fws');
36
+ });
37
+
38
+ test('a query or fragment belongs to the reference, not the path', () => {
39
+ expect(resolveDocumentReference('img/a.png?v=2#frag', doc('docs/guide.md'))?.path).toBe('docs/img/a.png');
40
+ });
41
+
42
+ test('decodes percent escapes', () => {
43
+ expect(resolveDocumentReference('my%20image.png', doc('README.md'))?.path).toBe('my image.png');
44
+ });
45
+
46
+ test('leaves anything that is not a relative file reference alone', () => {
47
+ for (const reference of ['https://x.test/a.png', 'mailto:a@b.c', 'data:image/png;base64,AA', '//cdn.test/a.png', '#section', ' ', '']) {
48
+ expect(resolveDocumentReference(reference, doc('README.md'))).toBeNull();
49
+ }
50
+ });
51
+ });
52
+
53
+ describe('rewriteDocumentReferences', () => {
54
+ test('points images at the raw route and links at the file they name', () => {
55
+ expect(rewriteDocumentReferences('<img src="docs/a.png"><a href="AGENTS.md">x</a>', doc('README.md')))
56
+ .toBe('<img src="/api/fs/raw?path=docs%2Fa.png"><a href="/api/fs/raw?path=AGENTS.md" data-file-path="AGENTS.md">x</a>');
57
+ });
58
+
59
+ test('escapes the query separator so the attribute survives HTML parsing', () => {
60
+ expect(rewriteDocumentReferences('<img src="a.png">', doc('docs/g.md', '/ws')))
61
+ .toBe('<img src="/api/fs/raw?path=docs%2Fa.png&amp;root=%2Fws">');
62
+ });
63
+
64
+ test('leaves external references and escaped code untouched', () => {
65
+ const html = '<a href="https://x.test">x</a><code class="language-html">&lt;img src=&quot;a.png&quot;&gt;</code>';
66
+ expect(rewriteDocumentReferences(html, doc('README.md'))).toBe(html);
67
+ });
68
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Resolve the relative references of a markdown *document* (an opened file's
8
+ * preview) to the workspace files they actually name.
9
+ *
10
+ * A previewed file is not a chat message: `![](docs/hero.png)` means the image
11
+ * on disk beside the document, and `[Architecture](AGENTS.md)` means that file.
12
+ * Neither resolves on its own — the preview is an app route, so a relative URL
13
+ * is requested from the server's own origin (`/docs/hero.png` → 404, and a
14
+ * click would navigate the console away from itself). Each one is rewritten to
15
+ * the workspace file it points at: images through `/api/fs/raw` (real bytes,
16
+ * real content-type) and links through the same URL plus the resolved path in
17
+ * `data-file-path`, which the panel turns back into an editor tab.
18
+ *
19
+ * Only plain relative references are touched. Anything with a scheme
20
+ * (`https:`, `mailto:`, `data:`), a protocol-relative `//host`, or a bare
21
+ * `#anchor` is the document's own business and is left exactly as written.
22
+ */
23
+
24
+ import { buildFsRawUrl } from '@/shared/lib/fs/paths';
25
+
26
+ /**
27
+ * Where the document lives. `path` is relative to the picked scope exactly the
28
+ * way `/api/fs/raw` and the file panel read it: relative to `repo` when one is
29
+ * picked, otherwise to `root`.
30
+ */
31
+ export interface DocumentScope {
32
+ path: string;
33
+ root?: string;
34
+ repo?: string;
35
+ }
36
+
37
+ export interface ResolvedDocumentReference {
38
+ /** Scope-relative path of the referenced file — what an editor tab opens. */
39
+ path: string;
40
+ /** Raw-bytes URL of the same file, for an `<img src>` or a middle-click. */
41
+ url: string;
42
+ }
43
+
44
+ /** A scheme (`https:`), protocol-relative host, fragment, or empty ref. */
45
+ const ABSOLUTE_REFERENCE_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|$)/i;
46
+
47
+ /** Collapse `.`/`..` segments; ascending above the scope root is dropped. */
48
+ function normalizeSegments(segments: string[]): string {
49
+ const out: string[] = [];
50
+ for (const segment of segments) {
51
+ if (!segment || segment === '.') continue;
52
+ if (segment === '..') {
53
+ out.pop();
54
+ continue;
55
+ }
56
+ out.push(segment);
57
+ }
58
+ return out.join('/');
59
+ }
60
+
61
+ /** `%20`-style escapes are how a markdown link writes a space. */
62
+ function decodeReference(value: string): string {
63
+ try {
64
+ return decodeURIComponent(value);
65
+ } catch {
66
+ return value;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Resolve one reference written inside `scope`'s document, or null when it is
72
+ * not a relative file reference (leave those untouched).
73
+ */
74
+ export function resolveDocumentReference(
75
+ reference: string,
76
+ scope: DocumentScope,
77
+ ): ResolvedDocumentReference | null {
78
+ const raw = reference.trim();
79
+ if (ABSOLUTE_REFERENCE_RE.test(raw)) return null;
80
+
81
+ // `?v=1` and `#section` address the preview, not the file on disk.
82
+ const withoutQuery = raw.split(/[?#]/)[0];
83
+ const decoded = decodeReference(withoutQuery);
84
+ if (!decoded) return null;
85
+
86
+ // A leading slash is root-relative (GitHub's repo-root rule), and the scope
87
+ // frame already IS that root, so it resolves without the document's folder.
88
+ const rootRelative = /^[/\\]/.test(decoded);
89
+ const documentDir = scope.path.replace(/\\/g, '/').split('/').slice(0, -1);
90
+ const segments = decoded.split(/[\\/]+/);
91
+ const path = normalizeSegments(rootRelative ? segments : [...documentDir, ...segments]);
92
+ if (!path) return null;
93
+
94
+ return { path, url: buildFsRawUrl({ path, root: scope.root, repo: scope.repo }) };
95
+ }
96
+
97
+ /** Attribute values are injected as HTML, so `&` and `"` must be entities. */
98
+ function escapeAttribute(value: string): string {
99
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
100
+ }
101
+
102
+ /**
103
+ * Rewrite the relative `src`/`href` attributes of rendered document HTML.
104
+ * Fenced code is already escaped by the renderer, so a literal `src="` inside
105
+ * a code block cannot be mistaken for an attribute.
106
+ */
107
+ export function rewriteDocumentReferences(html: string, scope: DocumentScope): string {
108
+ return html.replace(/(\s(?:src|href))="([^"]*)"/g, (match, attribute: string, value: string) => {
109
+ const resolved = resolveDocumentReference(value, scope);
110
+ if (!resolved) return match;
111
+ const url = ` ${attribute.trim()}="${escapeAttribute(resolved.url)}"`;
112
+ // The resolved path rides along on links only: it is what a click opens as
113
+ // a tab, and a mid-click still has the raw URL to fall back to.
114
+ return attribute.trim() === 'href'
115
+ ? `${url} data-file-path="${escapeAttribute(resolved.path)}"`
116
+ : url;
117
+ });
118
+ }