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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `detectBusyShells` decides the terminal cap's budget, so it has to be right
|
|
8
|
+
* about the two states that matter: a shell at its prompt (not counted) and a
|
|
9
|
+
* shell running a command (counted). Both are observed against a real PTY,
|
|
10
|
+
* because the whole question is what the *kernel* says the terminal's
|
|
11
|
+
* foreground group is — a mocked probe would only test the mock.
|
|
12
|
+
*
|
|
13
|
+
* The probe's answer is cross-checked against `ps -o tpgid=`, which is the
|
|
14
|
+
* definition of the field being read: an offset that drifted makes the probe
|
|
15
|
+
* return null (its own pid guard rejects a mismatched struct) and fail these
|
|
16
|
+
* assertions rather than silently report a garbage group.
|
|
17
|
+
*
|
|
18
|
+
* Waiting is driven by the PTY stream, not by sleeps: a shell's prompt (or the
|
|
19
|
+
* marker a command prints before it parks) is the real signal that the
|
|
20
|
+
* foreground group has moved, so the assertions never race a guessed delay.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
24
|
+
|
|
25
|
+
import { processProbe } from '@/server/lib/lifecycle/proc';
|
|
26
|
+
import { shellLaunch } from '@/server/lib/terminal/shell';
|
|
27
|
+
import { detectBusyShells, type ShellProcess } from '@/server/lib/terminal/foreground';
|
|
28
|
+
|
|
29
|
+
const describePosix = process.platform === 'win32' ? describe.skip : describe;
|
|
30
|
+
|
|
31
|
+
/** The shell the test drives, and the prompt it announces itself with. */
|
|
32
|
+
const SHELL = '/bin/sh';
|
|
33
|
+
/**
|
|
34
|
+
* Spelled `CHAM''BER` on the command line below: the PTY echoes what is typed,
|
|
35
|
+
* so a literal needle would be satisfied by the echo before the shell ever
|
|
36
|
+
* parsed the line — the same trap the busy marker avoids.
|
|
37
|
+
*/
|
|
38
|
+
const PROMPT = 'CHAMBER> ';
|
|
39
|
+
|
|
40
|
+
/** The value `ps` prints for the same field the probe reads. */
|
|
41
|
+
async function psTpgid(pid: number): Promise<number> {
|
|
42
|
+
const proc = Bun.spawn(['ps', '-o', 'tpgid=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' });
|
|
43
|
+
const text = await new Response(proc.stdout).text();
|
|
44
|
+
await proc.exited;
|
|
45
|
+
return Number(text.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PtyShell {
|
|
49
|
+
id: string;
|
|
50
|
+
pid: number;
|
|
51
|
+
term: Bun.Terminal;
|
|
52
|
+
proc: Bun.Subprocess;
|
|
53
|
+
/** Resolves the next time the PTY emits `needle`; output seen so far counts. */
|
|
54
|
+
until: (needle: string) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const spawned: PtyShell[] = [];
|
|
58
|
+
|
|
59
|
+
async function spawnShell(id: string): Promise<PtyShell> {
|
|
60
|
+
let seen = '';
|
|
61
|
+
let pending: { needle: string; resolve: () => void } | null = null;
|
|
62
|
+
const term = new Bun.Terminal({
|
|
63
|
+
cols: 80,
|
|
64
|
+
rows: 24,
|
|
65
|
+
name: 'xterm-256color',
|
|
66
|
+
data(_term, bytes) {
|
|
67
|
+
seen += new TextDecoder().decode(bytes);
|
|
68
|
+
if (pending && seen.includes(pending.needle)) {
|
|
69
|
+
const { resolve } = pending;
|
|
70
|
+
pending = null;
|
|
71
|
+
resolve();
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
// The production launch argv, not a bare `/bin/sh`. The runtime must spawn
|
|
76
|
+
// `detached: true`, and `setsid()` drops the controlling terminal — a shell
|
|
77
|
+
// that never re-opens the PTY reports `tpgid` as -1 (verified on Linux), which
|
|
78
|
+
// reads as "a command owns the terminal" and is what made the prompt
|
|
79
|
+
// assertion below fail there. `shellLaunch` is the shim that re-acquires it,
|
|
80
|
+
// so this is the shell shape the probe is actually asked about.
|
|
81
|
+
const proc = Bun.spawn(shellLaunch(SHELL), { terminal: term, detached: true, cwd: '/tmp' });
|
|
82
|
+
const shell: PtyShell = {
|
|
83
|
+
id,
|
|
84
|
+
pid: proc.pid,
|
|
85
|
+
term,
|
|
86
|
+
proc,
|
|
87
|
+
until(needle) {
|
|
88
|
+
if (seen.includes(needle)) return Promise.resolve();
|
|
89
|
+
return new Promise<void>((resolve) => {
|
|
90
|
+
pending = { needle, resolve };
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
spawned.push(shell);
|
|
95
|
+
return shell;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
for (const shell of spawned) {
|
|
100
|
+
try {
|
|
101
|
+
shell.term.write('\u0003exit\n');
|
|
102
|
+
} catch {
|
|
103
|
+
// Already closed.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await Promise.all(spawned.map((shell) => shell.proc.exited.catch(() => undefined)));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describePosix('processProbe.foregroundGroup', () => {
|
|
110
|
+
test('reports the same group ps does for a live pid', async () => {
|
|
111
|
+
const fromProbe = processProbe.foregroundGroup(process.pid);
|
|
112
|
+
expect(fromProbe).not.toBeNull();
|
|
113
|
+
expect(fromProbe).toBe(await psTpgid(process.pid));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('returns null for a pid that no longer exists', async () => {
|
|
117
|
+
const corpse = Bun.spawn(['/bin/sh', '-c', 'exit 0']);
|
|
118
|
+
const deadPid = corpse.pid;
|
|
119
|
+
await corpse.exited;
|
|
120
|
+
expect(processProbe.foregroundGroup(deadPid)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describePosix('detectBusyShells', () => {
|
|
125
|
+
test('a shell at its prompt is not busy, and one running a command is', async () => {
|
|
126
|
+
const shell = await spawnShell('probe');
|
|
127
|
+
const shells: ShellProcess[] = [{ id: shell.id, pid: shell.pid }];
|
|
128
|
+
|
|
129
|
+
// The prompt is the shell printing from its own foreground group. It is
|
|
130
|
+
// named by us rather than assumed: the default is `$ ` for a user and `# `
|
|
131
|
+
// for root, and the assertion is about the foreground group, not the glyph.
|
|
132
|
+
shell.term.write("PS1='CHAM''BER> '\n");
|
|
133
|
+
await shell.until(PROMPT);
|
|
134
|
+
expect((await detectBusyShells(shells)).has(shell.id)).toBe(false);
|
|
135
|
+
|
|
136
|
+
// The marker is printed by a child that already owns the terminal, and the
|
|
137
|
+
// typed text spells it `REA''DY` — a literal needle would be matched by the
|
|
138
|
+
// PTY's own echo of the input, before the shell had even parsed the line.
|
|
139
|
+
shell.term.write("sh -c 'echo REA''DY; sleep 30'\n");
|
|
140
|
+
await shell.until('READY\r\n');
|
|
141
|
+
expect((await detectBusyShells(shells)).has(shell.id)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('falls back to ps when one pid cannot be probed, and still answers', async () => {
|
|
145
|
+
const shell = await spawnShell('fallback');
|
|
146
|
+
const corpse = Bun.spawn(['/bin/sh', '-c', 'exit 0']);
|
|
147
|
+
const ghostPid = corpse.pid;
|
|
148
|
+
await corpse.exited;
|
|
149
|
+
|
|
150
|
+
shell.term.write("sh -c 'echo REA''DY; sleep 30'\n");
|
|
151
|
+
await shell.until('READY\r\n');
|
|
152
|
+
|
|
153
|
+
// A dead pid is one the probe declines, which routes the whole set through
|
|
154
|
+
// `ps` — the path the BSDs take for every question.
|
|
155
|
+
const shells: ShellProcess[] = [
|
|
156
|
+
{ id: shell.id, pid: shell.pid },
|
|
157
|
+
{ id: 'ghost', pid: ghostPid },
|
|
158
|
+
];
|
|
159
|
+
const busy = await detectBusyShells(shells);
|
|
160
|
+
expect(busy.has(shell.id)).toBe(true);
|
|
161
|
+
expect(busy.has('ghost')).toBe(false);
|
|
162
|
+
expect(busy.size).toBe(1);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -19,11 +19,19 @@
|
|
|
19
19
|
* switched a few times pins the budget with shells that are all idle at a
|
|
20
20
|
* prompt, and the next attach is rejected with nothing actually running.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* The value comes from the platform's `ProcessProbe` — `libproc` on macOS,
|
|
23
|
+
* `/proc` on Linux — which is a synchronous read of a field the kernel already
|
|
24
|
+
* has, and one `ps` subprocess answered it before. Measured on 8 live shells:
|
|
25
|
+
* 30 ms for the spawn against 0.07 ms for the probe. `ps` stays as the fallback
|
|
26
|
+
* for the platforms with no probe (the BSDs) and for the case where a probe
|
|
27
|
+
* declines to answer, so the answer never depends on the fast path working.
|
|
28
|
+
*
|
|
29
|
+
* Advisory either way: any failure resolves to "not busy", because refusing a
|
|
30
|
+
* shell to save a slot is worse than letting one extra through.
|
|
25
31
|
*/
|
|
26
32
|
|
|
33
|
+
import { processProbe } from '@/server/lib/lifecycle/proc';
|
|
34
|
+
|
|
27
35
|
const PS_TIMEOUT_MS = 1500;
|
|
28
36
|
|
|
29
37
|
/** `pid` is the shell's process (it is the PTY session leader, so pgid === pid). */
|
|
@@ -32,10 +40,15 @@ export interface ShellProcess {
|
|
|
32
40
|
pid: number;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The same question through `ps`, for a platform whose probe declined.
|
|
45
|
+
*
|
|
46
|
+
* Kept whole rather than merged into the probe path: it is the only mechanism
|
|
47
|
+
* that works everywhere a `ps` is installed, and its subprocess machinery
|
|
48
|
+
* (timeout, kill, reaping) is what the probe exists to avoid paying for.
|
|
49
|
+
*/
|
|
50
|
+
async function busyShellsViaPs(shells: ShellProcess[]): Promise<Set<string>> {
|
|
36
51
|
const busy = new Set<string>();
|
|
37
|
-
if (shells.length === 0 || process.platform === 'win32') return busy;
|
|
38
|
-
|
|
39
52
|
const byPid = new Map<number, string>();
|
|
40
53
|
for (const shell of shells) byPid.set(shell.pid, shell.id);
|
|
41
54
|
|
|
@@ -71,3 +84,18 @@ export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<stri
|
|
|
71
84
|
}
|
|
72
85
|
return busy;
|
|
73
86
|
}
|
|
87
|
+
|
|
88
|
+
export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<string>> {
|
|
89
|
+
const busy = new Set<string>();
|
|
90
|
+
if (shells.length === 0 || process.platform === 'win32') return busy;
|
|
91
|
+
|
|
92
|
+
// All-or-nothing: one shell the probe cannot answer for sends the whole set
|
|
93
|
+
// through `ps`, which keeps a single answer for a single question instead of
|
|
94
|
+
// mixing two sources whose failure modes differ.
|
|
95
|
+
for (const shell of shells) {
|
|
96
|
+
const tpgid = processProbe.foregroundGroup(shell.pid);
|
|
97
|
+
if (tpgid === null) return busyShellsViaPs(shells);
|
|
98
|
+
if (tpgid !== shell.pid) busy.add(shell.id);
|
|
99
|
+
}
|
|
100
|
+
return busy;
|
|
101
|
+
}
|
|
@@ -93,18 +93,19 @@ export function trimUtf8ToBytes(bytes: Uint8Array, maxBytes: number): Uint8Array
|
|
|
93
93
|
/**
|
|
94
94
|
* Split `input` into the bytes that may be replayed and the trailing fragment
|
|
95
95
|
* of an unfinished control sequence. Dropped sequences are queries only.
|
|
96
|
+
*
|
|
97
|
+
* The scan for the next ESC is `indexOf`, not a byte-at-a-time loop: this runs
|
|
98
|
+
* over the whole replay buffer, and a JS loop spends its time on the bytes
|
|
99
|
+
* between sequences rather than on the sequences themselves. Measured on
|
|
100
|
+
* 512 KiB: 0.2 ms scanning byte-by-byte against 0.0085 ms with `indexOf`.
|
|
96
101
|
*/
|
|
97
102
|
export function sanitizeReplayChunk(input: Uint8Array): { kept: Uint8Array; rest: Uint8Array } {
|
|
98
103
|
const parts: Uint8Array[] = [];
|
|
99
104
|
let from = 0;
|
|
100
|
-
let i = 0;
|
|
101
105
|
let dropped = false;
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
i += 1;
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
107
|
+
let i = input.indexOf(ESC);
|
|
108
|
+
while (i !== -1) {
|
|
108
109
|
const scan = scanEscape(input, i);
|
|
109
110
|
if (scan === null) {
|
|
110
111
|
if (i > from) parts.push(input.subarray(from, i));
|
|
@@ -122,7 +123,7 @@ export function sanitizeReplayChunk(input: Uint8Array): { kept: Uint8Array; rest
|
|
|
122
123
|
from = scan.end;
|
|
123
124
|
dropped = true;
|
|
124
125
|
}
|
|
125
|
-
i = scan.end;
|
|
126
|
+
i = input.indexOf(ESC, scan.end);
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
if (!dropped) return { kept: input, rest: EMPTY };
|
|
@@ -11,15 +11,43 @@ import { brotliCompressSync, constants } from 'node:zlib';
|
|
|
11
11
|
* The incoming body is buffered and a fresh `Response` is returned, because a
|
|
12
12
|
* `Response` body can only be read once — reading it here would otherwise
|
|
13
13
|
* leave Elysia nothing to send.
|
|
14
|
+
*
|
|
15
|
+
* Encodings are offered best-first: zstd, brotli, gzip. On a 905 KiB JSON body
|
|
16
|
+
* the three measured 0.48 ms / 8 KiB, 1.05 ms / 7 KiB and 2.22 ms / 19 KiB —
|
|
17
|
+
* zstd is both the fastest and, within 14% of brotli's ratio, effectively as
|
|
18
|
+
* small, and it stays ahead at every payload size measured (0.004 ms against
|
|
19
|
+
* 0.007 ms for gzip at 2 KiB). Brotli remains for a client that accepts `br`
|
|
20
|
+
* but not `zstd`, and gzip is the floor every client has.
|
|
14
21
|
*/
|
|
15
22
|
const MIN_BYTES = 1024;
|
|
16
23
|
|
|
17
24
|
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|wasm|svg))/;
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
/** Compression encodings this server can produce, best first. */
|
|
27
|
+
const ENCODINGS = ['zstd', 'br', 'gzip'] as const;
|
|
28
|
+
|
|
29
|
+
type Encoding = (typeof ENCODINGS)[number];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The best encoding the client accepts, or null.
|
|
33
|
+
*
|
|
34
|
+
* Plain substring matching, matching the previous `includes('br')` behavior: a
|
|
35
|
+
* browser's `Accept-Encoding` is an explicit set of alternatives, not a
|
|
36
|
+
* preference ordering this server has to weigh.
|
|
37
|
+
*/
|
|
38
|
+
function negotiateEncoding(acceptEncoding: string): Encoding | null {
|
|
39
|
+
for (const encoding of ENCODINGS) {
|
|
40
|
+
if (acceptEncoding.includes(encoding)) return encoding;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function compress(body: Uint8Array<ArrayBuffer>, encoding: Encoding): Uint8Array {
|
|
46
|
+
if (encoding === 'zstd') return Bun.zstdCompressSync(body, { level: 3 });
|
|
47
|
+
if (encoding === 'br') {
|
|
48
|
+
return brotliCompressSync(body, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } });
|
|
49
|
+
}
|
|
50
|
+
return Bun.gzipSync(body, { level: 6 });
|
|
23
51
|
}
|
|
24
52
|
|
|
25
53
|
export async function maybeCompress(response: Response, acceptEncoding: string | null): Promise<Response> {
|
|
@@ -29,7 +57,7 @@ export async function maybeCompress(response: Response, acceptEncoding: string |
|
|
|
29
57
|
if (!COMPRESSIBLE.test(contentType)) return response;
|
|
30
58
|
if (response.status === 204 || response.status === 304) return response;
|
|
31
59
|
|
|
32
|
-
const encoding =
|
|
60
|
+
const encoding = negotiateEncoding(acceptEncoding);
|
|
33
61
|
if (!encoding) return response;
|
|
34
62
|
|
|
35
63
|
const buffer = new Uint8Array(await response.arrayBuffer()) as Uint8Array<ArrayBuffer>;
|
|
@@ -3,6 +3,8 @@ import { join } from 'path';
|
|
|
3
3
|
import { getDb } from '@/server/db.server';
|
|
4
4
|
import { tryServeStatic } from '@/server/plugins/static';
|
|
5
5
|
import { tryProxyDevAsset } from '@/server/plugins/dev-assets';
|
|
6
|
+
import { resolveTheme } from '@/shared/lib/theme/catalog';
|
|
7
|
+
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
6
8
|
|
|
7
9
|
const CLIENT_INDEX = join(process.cwd(), 'dist/client/index.html');
|
|
8
10
|
|
|
@@ -58,10 +60,6 @@ async function readSettings(): Promise<Record<string, unknown>> {
|
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
function themeColor(theme: string): string {
|
|
62
|
-
return theme === 'one-dark-pro-soft' ? '#282c34' : '#faf8f3';
|
|
63
|
-
}
|
|
64
|
-
|
|
65
63
|
const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
|
|
66
64
|
|
|
67
65
|
/**
|
|
@@ -111,16 +109,25 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
111
109
|
|
|
112
110
|
const settings = await readSettings();
|
|
113
111
|
const chamberSettings = (settings.omp_chamber_settings ?? {}) as { theme?: string };
|
|
114
|
-
|
|
112
|
+
// Resolved through the catalog: an id this build no longer ships (a
|
|
113
|
+
// downgrade, the retired `noir` alias) renders the default palette instead of
|
|
114
|
+
// an unstyled page.
|
|
115
|
+
const theme = resolveTheme(chamberSettings.theme);
|
|
115
116
|
const initialIsMobile = MOBILE_UA.test(request.headers.get('user-agent') ?? '');
|
|
116
117
|
|
|
117
118
|
const template = await readTemplate();
|
|
118
119
|
const bootstrap = JSON.stringify({ initialIsMobile, appSettings: settings }).replace(/</g, '\\u003c');
|
|
120
|
+
// The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
|
|
121
|
+
// a second tag: with both present Chrome reads the first one, so an appended
|
|
122
|
+
// tag would leave a dark theme painting light browser chrome.
|
|
123
|
+
const themeStyle = `<style id="${THEME_STYLE_ELEMENT_ID}">${themeStyleSheet()}</style>`;
|
|
119
124
|
|
|
120
125
|
return new Response(
|
|
121
126
|
template
|
|
122
|
-
.replace('data-theme="paper"', `data-theme="${theme}"`)
|
|
123
|
-
.replace('
|
|
127
|
+
.replace('data-theme="paper"', `data-theme="${theme.id}"`)
|
|
128
|
+
.replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
|
|
129
|
+
.replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${theme.canvas}" />`)
|
|
130
|
+
.replace('<!--app-head-->', themeStyle)
|
|
124
131
|
.replace('<!--app-bootstrap-->', `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
|
|
125
132
|
{ headers: HTML_HEADERS },
|
|
126
133
|
);
|
|
@@ -14,8 +14,20 @@
|
|
|
14
14
|
|
|
15
15
|
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
16
16
|
import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
|
|
17
|
+
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
18
|
+
import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
|
|
17
19
|
import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
|
|
18
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
abortBtw,
|
|
22
|
+
askBtw,
|
|
23
|
+
getBtwState,
|
|
24
|
+
promoteBtw,
|
|
25
|
+
removeBtw,
|
|
26
|
+
respondBtwDialog,
|
|
27
|
+
setBtwApprovalMode,
|
|
28
|
+
setBtwModel,
|
|
29
|
+
setBtwThinkingLevel,
|
|
30
|
+
} from '@/server/lib/btw/service.server';
|
|
19
31
|
|
|
20
32
|
/** Actions that can reject for a reason the client should show as-is. */
|
|
21
33
|
const STATUS_BY_CODE: Record<string, number> = {
|
|
@@ -39,6 +51,40 @@ function readImages(raw: unknown): { images?: BtwImages } | { error: string } {
|
|
|
39
51
|
return images ? { images } : {};
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Text files the client inlined for this question. Validated for shape only:
|
|
56
|
+
* the payload is a string the composer produced from bytes it already read, and
|
|
57
|
+
* the prompt builder escapes it into a fenced block.
|
|
58
|
+
*/
|
|
59
|
+
function readTextFiles(raw: unknown): AttachedTextFileData[] {
|
|
60
|
+
if (!Array.isArray(raw)) return [];
|
|
61
|
+
return raw.flatMap((entry) => {
|
|
62
|
+
if (!entry || typeof entry !== 'object') return [];
|
|
63
|
+
const file = entry as Partial<AttachedTextFileData>;
|
|
64
|
+
if (typeof file.name !== 'string' || typeof file.content !== 'string') return [];
|
|
65
|
+
return [{
|
|
66
|
+
name: file.name,
|
|
67
|
+
content: file.content,
|
|
68
|
+
mimeType: typeof file.mimeType === 'string' ? file.mimeType : 'text/plain',
|
|
69
|
+
size: typeof file.size === 'number' ? file.size : file.content.length,
|
|
70
|
+
}];
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The response body for one dialog, in the shape omp's RPC accepts: an answer
|
|
76
|
+
* value, a confirmation, or a cancellation. Anything else is refused rather
|
|
77
|
+
* than forwarded — a malformed response would leave the child blocked.
|
|
78
|
+
*/
|
|
79
|
+
function readDialogResponse(raw: unknown): Record<string, unknown> | null {
|
|
80
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
81
|
+
const response = raw as { value?: unknown; confirmed?: unknown; cancelled?: unknown };
|
|
82
|
+
if (response.cancelled === true) return { cancelled: true };
|
|
83
|
+
if (typeof response.confirmed === 'boolean') return { confirmed: response.confirmed };
|
|
84
|
+
if (typeof response.value === 'string') return { value: response.value };
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
42
88
|
export async function action({ params, request }: ActionFunctionArgs) {
|
|
43
89
|
const { sessionId } = params;
|
|
44
90
|
if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
|
|
@@ -54,12 +100,53 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
|
|
54
100
|
const question = typeof body?.question === 'string' ? body.question : '';
|
|
55
101
|
const parsed = readImages(body?.images);
|
|
56
102
|
if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
|
|
57
|
-
|
|
103
|
+
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
104
|
+
const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
|
|
105
|
+
return json({
|
|
106
|
+
success: true,
|
|
107
|
+
data: await askBtw(sessionId, {
|
|
108
|
+
topicId,
|
|
109
|
+
question,
|
|
110
|
+
images: parsed.images,
|
|
111
|
+
textFiles: readTextFiles(body?.textFiles),
|
|
112
|
+
...(provider && modelId ? { model: { provider, id: modelId } } : {}),
|
|
113
|
+
...(typeof body?.thinkingLevel === 'string' ? { thinkingLevel: body.thinkingLevel } : {}),
|
|
114
|
+
...(isApprovalMode(body?.accessMode) ? { approvalMode: body.accessMode } : {}),
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
58
117
|
}
|
|
59
118
|
case 'abort': {
|
|
60
119
|
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
61
120
|
return json({ success: true, data: await abortBtw(sessionId, topicId) });
|
|
62
121
|
}
|
|
122
|
+
case 'set_model': {
|
|
123
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
124
|
+
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
125
|
+
const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
|
|
126
|
+
if (!provider || !modelId) return json({ error: 'provider and modelId are required', code: 'model_required' }, { status: 400 });
|
|
127
|
+
return json({ success: true, data: await setBtwModel(sessionId, topicId, provider, modelId) });
|
|
128
|
+
}
|
|
129
|
+
case 'set_thinking_level': {
|
|
130
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
131
|
+
const level = typeof body?.level === 'string' ? body.level : '';
|
|
132
|
+
if (!level) return json({ error: 'level is required', code: 'level_required' }, { status: 400 });
|
|
133
|
+
return json({ success: true, data: await setBtwThinkingLevel(sessionId, topicId, level) });
|
|
134
|
+
}
|
|
135
|
+
case 'set_access_mode': {
|
|
136
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
137
|
+
if (!isApprovalMode(body?.accessMode)) {
|
|
138
|
+
return json({ error: 'accessMode must be always-ask, write or yolo', code: 'invalid_access_mode' }, { status: 400 });
|
|
139
|
+
}
|
|
140
|
+
return json({ success: true, data: await setBtwApprovalMode(sessionId, topicId, body.accessMode) });
|
|
141
|
+
}
|
|
142
|
+
case 'dialog_response': {
|
|
143
|
+
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
144
|
+
const id = typeof body?.id === 'string' ? body.id : '';
|
|
145
|
+
if (!id) return json({ error: 'id is required', code: 'dialog_required' }, { status: 400 });
|
|
146
|
+
const response = readDialogResponse(body?.response);
|
|
147
|
+
if (!response) return json({ error: 'a response value is required', code: 'dialog_required' }, { status: 400 });
|
|
148
|
+
return json({ success: true, data: await respondBtwDialog(sessionId, topicId, id, response) });
|
|
149
|
+
}
|
|
63
150
|
case 'promote': {
|
|
64
151
|
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
65
152
|
return json({ success: true, data: await promoteBtw(sessionId, topicId) });
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The save branch's byte contract.
|
|
8
|
+
*
|
|
9
|
+
* `multipart/form-data` normalizes every bare LF in a field value to CRLF in
|
|
10
|
+
* transit — the HTML serializer does it on the way out and Bun's parser agrees
|
|
11
|
+
* on the way in — so `content` reaches this route CRLF-normalized no matter what
|
|
12
|
+
* the editor buffer held. The file's own ending therefore arrives as its own
|
|
13
|
+
* `eol` field, and the bytes written to disk are built from it. Without that
|
|
14
|
+
* step every save rewrote the whole file's line endings: an LF file came back
|
|
15
|
+
* CRLF, which `git diff` reports as every line changed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { action } from '@/server/routes/fs/action';
|
|
22
|
+
|
|
23
|
+
/** Probe files live in the repo root — the one directory the root allow-list always accepts. */
|
|
24
|
+
const PROBE = path.join(process.cwd(), 'tmp-eol-probe.txt');
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fs.rmSync(PROBE, { force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function save(content: string, eol?: string) {
|
|
31
|
+
const form = new FormData();
|
|
32
|
+
form.append('actionType', 'save');
|
|
33
|
+
form.append('root', process.cwd());
|
|
34
|
+
form.append('path', 'tmp-eol-probe.txt');
|
|
35
|
+
form.append('content', content);
|
|
36
|
+
if (eol) form.append('eol', eol);
|
|
37
|
+
const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
|
|
38
|
+
expect(res.status).toBe(200);
|
|
39
|
+
return new Uint8Array(await Bun.file(PROBE).arrayBuffer());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('fs save line endings', () => {
|
|
43
|
+
test('an lf request writes lf, whatever multipart did in transit', async () => {
|
|
44
|
+
const bytes = await save('a\nb\n', 'lf');
|
|
45
|
+
expect([...bytes]).toEqual([0x61, 0x0a, 0x62, 0x0a]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a crlf request writes crlf', async () => {
|
|
49
|
+
const bytes = await save('a\nb\n', 'crlf');
|
|
50
|
+
expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a payload that already carries crlf is not doubled', async () => {
|
|
54
|
+
const bytes = await save('a\r\nb\r\n', 'crlf');
|
|
55
|
+
expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// No `eol` means the route has no instruction, so the payload is written as
|
|
59
|
+
// multipart delivered it — which is CRLF, since the transport normalizes.
|
|
60
|
+
test('a payload without an eol field is written as delivered', async () => {
|
|
61
|
+
const bytes = await save('a\nb\n');
|
|
62
|
+
expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -5,6 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import { isMockMode } from '@/server/mock.server';
|
|
6
6
|
import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
|
|
7
7
|
import { runShell } from '@/server/lib/fs/shell';
|
|
8
|
+
import { toDiskText } from '@/shared/lib/code/line-endings';
|
|
8
9
|
export async function action({ request }: ActionFunctionArgs) {
|
|
9
10
|
const formData = await request.formData();
|
|
10
11
|
const actionType = formData.get('actionType') as string;
|
|
@@ -24,7 +25,15 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
24
25
|
|
|
25
26
|
try {
|
|
26
27
|
if (actionType === 'save') {
|
|
27
|
-
const
|
|
28
|
+
const raw = (formData.get('content') as string) ?? '';
|
|
29
|
+
const eol = formData.get('eol');
|
|
30
|
+
// The bytes are built here rather than written verbatim: `multipart/
|
|
31
|
+
// form-data` normalizes every bare LF in a field value to CRLF in transit
|
|
32
|
+
// (the HTML serializer does it on the way out, Bun's parser agrees on the
|
|
33
|
+
// way in — same result either way), so an LF file would land on disk as
|
|
34
|
+
// CRLF and show up as a full-file diff. `eol` is the ending the client
|
|
35
|
+
// read the file with; without it the payload is written untouched.
|
|
36
|
+
const content = eol === 'crlf' || eol === 'lf' ? toDiskText(raw, eol) : raw;
|
|
28
37
|
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
29
38
|
await Bun.write(fullPath, content);
|
|
30
39
|
return json({ success: true });
|
|
@@ -181,14 +181,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
181
181
|
await remoteRefresh;
|
|
182
182
|
const syncCount = syncRequested ? await gitSyncCount(targetDir) : undefined;
|
|
183
183
|
|
|
184
|
+
// No `repos`/`reposPending`/`activeRepo` here: the repo list travels on the
|
|
185
|
+
// `?reposOnly=1` branch, keyed to the root it was discovered for. Echoing a
|
|
186
|
+
// repo back in a status response is what let a panel adopt a repo it had
|
|
187
|
+
// never chosen — and adopt it under whichever workspace was active when the
|
|
188
|
+
// echo arrived.
|
|
184
189
|
return json({
|
|
185
190
|
changes,
|
|
186
191
|
branch: branch || 'main',
|
|
187
192
|
branches: branches.length ? branches : ['main'],
|
|
188
193
|
remoteBranches,
|
|
189
|
-
repos,
|
|
190
|
-
reposPending: pending,
|
|
191
|
-
activeRepo: repo,
|
|
192
194
|
syncCount,
|
|
193
195
|
});
|
|
194
196
|
} catch (error: any) {
|
|
@@ -197,9 +199,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|
|
197
199
|
branch: 'main',
|
|
198
200
|
branches: ['main'],
|
|
199
201
|
remoteBranches: [],
|
|
200
|
-
repos: repos.length ? repos : ['.'],
|
|
201
|
-
reposPending: pending,
|
|
202
|
-
activeRepo: repo,
|
|
203
202
|
syncCount: { ahead: 0, behind: 0 },
|
|
204
203
|
error: error?.message || 'Git error',
|
|
205
204
|
}, { status: 200 });
|