ompchamber 1.2.1 → 2.0.1
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.
- package/README.md +143 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.cb81980498.js +2 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.22588845a9.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
- package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.1fd9e1c543.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +124 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
- package/src/client/components/workspace/diff-panel/index.tsx +8 -1
- package/src/client/components/workspace/editor/index.tsx +18 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/editor/save-file.ts +62 -0
- package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
- package/src/client/hooks/editor/use-file-editor.ts +38 -29
- package/src/client/hooks/editor/use-file-transfer.ts +5 -2
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/action.test.ts +64 -0
- package/src/server/routes/fs/action.ts +10 -1
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/line-endings.test.ts +38 -0
- package/src/shared/lib/code/line-endings.ts +56 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/m.2e1ea3db9e.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.1fd9e1c543.js.LICENSE.txt} +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { json, type ActionFunctionArgs } from '@/server/lib/remix-compat';
|
|
2
|
-
import { ripgrep } from 'ripgrep';
|
|
3
2
|
import { isMockMode } from '@/server/mock.server';
|
|
4
3
|
import { getDefaultFsRoot, resolveRoot } from '@/server/lib/fs/root';
|
|
5
4
|
import { scopeToRepo } from '@/server/lib/fs/repo-scope';
|
|
5
|
+
import { runRipgrep } from '@/server/lib/fs/ripgrep';
|
|
6
6
|
import { createSseStream } from '@/server/lib/sse';
|
|
7
7
|
|
|
8
8
|
export interface SearchMatch {
|
|
@@ -86,38 +86,29 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
86
86
|
async onStart(handlers) {
|
|
87
87
|
const send = (event: string, data: unknown) => handlers.send(event, data);
|
|
88
88
|
|
|
89
|
-
// WASI preopens map the guest "." onto the real target directory.
|
|
90
|
-
const preopens = { '.': targetDir };
|
|
91
89
|
const decoder = new TextDecoder();
|
|
92
90
|
let pending = '';
|
|
93
91
|
let count = 0;
|
|
94
92
|
let stderrText = '';
|
|
95
93
|
|
|
96
94
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (batch.length) {
|
|
112
|
-
count += batch.length;
|
|
113
|
-
send('matches', batch);
|
|
114
|
-
}
|
|
115
|
-
},
|
|
95
|
+
const code = await runRipgrep(buildArgs(q, matchCase, wholeWord, useRegex, includeFiles), targetDir, {
|
|
96
|
+
onStdout(chunk) {
|
|
97
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
98
|
+
const lines = pending.split('\n');
|
|
99
|
+
pending = lines.pop() ?? '';
|
|
100
|
+
const batch: SearchMatch[] = [];
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
const match = parseRgLine(line);
|
|
103
|
+
if (match) batch.push(match);
|
|
104
|
+
}
|
|
105
|
+
if (batch.length) {
|
|
106
|
+
count += batch.length;
|
|
107
|
+
send('matches', batch);
|
|
108
|
+
}
|
|
116
109
|
},
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
stderrText += decoder.decode(chunk, { stream: true });
|
|
120
|
-
},
|
|
110
|
+
onStderr(chunk) {
|
|
111
|
+
stderrText += decoder.decode(chunk, { stream: true });
|
|
121
112
|
},
|
|
122
113
|
});
|
|
123
114
|
// rg exit codes: 0 = matches, 1 = no matches, 2 = error (bad regex, IO failure).
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
reorderQueue,
|
|
23
23
|
} from '@/server/lib/queue/store.server';
|
|
24
24
|
import { getRpcSession } from '@/server/lib/omp/rpc/manager';
|
|
25
|
-
import {
|
|
25
|
+
import { deliverQueueNow } from '@/server/lib/queue/delivery.server';
|
|
26
26
|
|
|
27
27
|
/** GET — the session's queued follow-ups in delivery order. */
|
|
28
28
|
export async function getQueue({ params }: LoaderFunctionArgs) {
|
|
@@ -108,6 +108,17 @@ export async function reorderQueueItems({ request, params }: ActionFunctionArgs)
|
|
|
108
108
|
* the server a delivery slot. The server-side claim makes this a no-op when a
|
|
109
109
|
* run is active or the head was already claimed; an idle session with queued
|
|
110
110
|
* items gets its head delivered exactly once.
|
|
111
|
+
*
|
|
112
|
+
* The attempt runs IMMEDIATELY rather than arming the usual settle timer. This
|
|
113
|
+
* is the recovery path, and the failure it recovers from is a lost timer — a
|
|
114
|
+
* long-lived `--hot` dev server whose `setTimeout` callbacks stop firing leaves
|
|
115
|
+
* `scheduleQueueDelivery` scheduling into the void, so a nudge that merely
|
|
116
|
+
* re-armed another timer would fail exactly like the delivery it was meant to
|
|
117
|
+
* rescue. The 500ms settle exists to let omp's own turn-end bookkeeping finish;
|
|
118
|
+
* by the time a client nudges, that window has long passed.
|
|
119
|
+
*
|
|
120
|
+
* The response carries the CANONICAL queue, so a client polling this as a
|
|
121
|
+
* safety net gets its panel reconciled in the same round trip.
|
|
111
122
|
*/
|
|
112
123
|
export async function nudgeQueueDelivery({ request, params }: ActionFunctionArgs) {
|
|
113
124
|
const sessionId = requireParam(params, 'sessionId');
|
|
@@ -115,7 +126,9 @@ export async function nudgeQueueDelivery({ request, params }: ActionFunctionArgs
|
|
|
115
126
|
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 });
|
|
116
127
|
|
|
117
128
|
const session = getRpcSession(sessionId);
|
|
118
|
-
if (!session?.isAlive())
|
|
119
|
-
|
|
120
|
-
|
|
129
|
+
if (!session?.isAlive()) {
|
|
130
|
+
return json({ success: true, delivered: false, reason: 'session-not-live', queue: await listQueue(sessionId) });
|
|
131
|
+
}
|
|
132
|
+
const delivered = await deliverQueueNow(session);
|
|
133
|
+
return json({ success: true, delivered, queue: await listQueue(sessionId) });
|
|
121
134
|
}
|
|
@@ -4,6 +4,7 @@ import * as settingsAgents from '@/server/routes/settings/agents';
|
|
|
4
4
|
import * as settingsBehavior from '@/server/routes/settings/behavior';
|
|
5
5
|
import * as settingsCommands from '@/server/routes/settings/commands';
|
|
6
6
|
import * as settingsMcp from '@/server/routes/settings/mcp';
|
|
7
|
+
import * as settingsModelOverride from '@/server/routes/settings/model-override';
|
|
7
8
|
import * as settingsMcpTest from '@/server/routes/settings/mcp-test';
|
|
8
9
|
import * as settingsOmpConfig from '@/server/routes/settings/omp-config';
|
|
9
10
|
import * as settingsProjects from '@/server/routes/settings/projects';
|
|
@@ -18,6 +19,7 @@ export const settingsBindings: HandlerBinding[] = [
|
|
|
18
19
|
...bindingsFor(settingsBehavior, '/api/settings/behavior'),
|
|
19
20
|
...bindingsFor(settingsCommands, '/api/settings/commands'),
|
|
20
21
|
...bindingsFor(settingsMcp, '/api/settings/mcp'),
|
|
22
|
+
...bindingsFor(settingsModelOverride, '/api/settings/model-override'),
|
|
21
23
|
...bindingsFor(settingsMcpTest, '/api/settings/mcp-test'),
|
|
22
24
|
...bindingsFor(settingsOmpConfig, '/api/settings/omp-config'),
|
|
23
25
|
...bindingsFor(settingsProjects, '/api/settings/projects'),
|
|
@@ -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
|
-
|
|
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:
|
|
162
|
-
output:
|
|
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 —
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { detectLineEnding, toDiskText, toLf } from '@/shared/lib/code/line-endings';
|
|
8
|
+
|
|
9
|
+
describe('line endings', () => {
|
|
10
|
+
test('the first terminator decides the style', () => {
|
|
11
|
+
expect(detectLineEnding('a\r\nb\n')).toBe('crlf');
|
|
12
|
+
expect(detectLineEnding('a\nb\r\n')).toBe('lf');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('a file with no terminator, or one using lone CR, reads as lf', () => {
|
|
16
|
+
expect(detectLineEnding('single line')).toBe('lf');
|
|
17
|
+
expect(detectLineEnding('\rleading')).toBe('lf');
|
|
18
|
+
expect(detectLineEnding('a\rb\r')).toBe('lf');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('the buffer form carries no CR', () => {
|
|
22
|
+
expect(toLf('a\r\nb\r\n')).toBe('a\nb\n');
|
|
23
|
+
expect(toLf('a\rb\r')).toBe('a\nb\n');
|
|
24
|
+
expect(toLf('a\nb\n')).toBe('a\nb\n');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('writing back restores the detected ending', () => {
|
|
28
|
+
expect(toDiskText('a\nb\n', 'crlf')).toBe('a\r\nb\r\n');
|
|
29
|
+
expect(toDiskText('a\nb\n', 'lf')).toBe('a\nb\n');
|
|
30
|
+
// A mixed buffer still normalizes before it is re-encoded.
|
|
31
|
+
expect(toDiskText('a\r\nb\n', 'crlf')).toBe('a\r\nb\r\n');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('an unedited CRLF round trip is byte-identical', () => {
|
|
35
|
+
const disk = 'const a = 1;\r\nconst b = 2;\r\n';
|
|
36
|
+
expect(toDiskText(toLf(disk), detectLineEnding(disk))).toBe(disk);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Line-ending preservation for the file-editing surfaces.
|
|
8
|
+
*
|
|
9
|
+
* Two independent transports destroy the distinction, so the file's own ending
|
|
10
|
+
* has to be carried out of band rather than inferred from the buffer:
|
|
11
|
+
*
|
|
12
|
+
* - A `<textarea>` cannot hold a CRLF. The HTML spec defines its *API value* as
|
|
13
|
+
* the raw value with every carriage return removed, so the instant a CRLF file
|
|
14
|
+
* is placed in the editing surface the buffer is LF-only — verified in
|
|
15
|
+
* Chromium: `textarea.value = 'a\r\nb'` reads back `'a\nb'`.
|
|
16
|
+
* - `multipart/form-data` normalizes the other way: every bare LF in a field
|
|
17
|
+
* value becomes CRLF in transit (the HTML serializer on the way out, Bun's
|
|
18
|
+
* parser agreeing on the way in). A save payload therefore cannot say "LF" no
|
|
19
|
+
* matter what the buffer held.
|
|
20
|
+
*
|
|
21
|
+
* Saving the buffer as-is rewrote the whole file's line endings on a
|
|
22
|
+
* one-character edit — every line showed as changed in `git diff`. The ending is
|
|
23
|
+
* detected at read and travels as its own field; the server builds the bytes.
|
|
24
|
+
* An unedited save is then byte-identical and an edited save a one-line diff.
|
|
25
|
+
*
|
|
26
|
+
* Detection is by the FIRST terminator rather than a vote over all of them: a
|
|
27
|
+
* file is overwhelmingly uniform, and the first ending is the one the author's
|
|
28
|
+
* editor writes. A file with no terminator, or one using lone CR (classic Mac
|
|
29
|
+
* OS), reads as `lf`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type LineEnding = 'lf' | 'crlf';
|
|
33
|
+
|
|
34
|
+
/** The ending the file uses, judged by its first terminator. */
|
|
35
|
+
export function detectLineEnding(text: string): LineEnding {
|
|
36
|
+
const index = text.indexOf('\n');
|
|
37
|
+
return index > 0 && text[index - 1] === '\r' ? 'crlf' : 'lf';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The buffer form: CRLF and lone CR collapsed to LF, matching what a textarea
|
|
42
|
+
* can actually hold. A string already free of CR is returned unchanged.
|
|
43
|
+
*/
|
|
44
|
+
export function toLf(text: string): string {
|
|
45
|
+
return text.includes('\r') ? text.replace(/\r\n?/g, '\n') : text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The disk form: `text` normalized — whatever mix of endings it carries, and
|
|
50
|
+
* whether or not it came back through a transport that rewrote them — and then
|
|
51
|
+
* written with `ending`.
|
|
52
|
+
*/
|
|
53
|
+
export function toDiskText(text: string, ending: LineEnding): string {
|
|
54
|
+
const lf = toLf(text);
|
|
55
|
+
return ending === 'crlf' ? lf.replace(/\n/g, '\r\n') : lf;
|
|
56
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
+
});
|