ompchamber 1.2.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -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.74ff36419f.js +2 -0
- package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -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.33d6779f98.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/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.c05535dbde.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/editor/index.tsx +11 -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/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/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/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/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/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.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.c05535dbde.js.LICENSE.txt} +0 -0
|
@@ -8,17 +8,19 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Stack (run in order, all server/client safe — parsed synchronously):
|
|
10
10
|
* 1. remend — heal incomplete markdown while streaming (before parse)
|
|
11
|
-
* 2. marked — GFM true, breaks true, autolink via
|
|
12
|
-
* punctuation is not swallowed into URLs
|
|
13
|
-
*
|
|
11
|
+
* 2. marked — GFM true, breaks true, autolink via linkify-it (CJK
|
|
12
|
+
* punctuation is not swallowed into URLs, and a match carrying a backtick
|
|
13
|
+
* is left to the code-span rule — see autolink.ts), plus a KaTeX
|
|
14
|
+
* extension for \(...\) inline, \[...\] block, and $$...$$ display math
|
|
14
15
|
*
|
|
15
16
|
* The resulting HTML is NOT sanitized here — run the output through
|
|
16
|
-
* DOMPurify (see sanitize.ts) before injecting
|
|
17
|
-
*
|
|
17
|
+
* DOMPurify (see sanitize.ts) before injecting. What the source's own HTML
|
|
18
|
+
* means is the caller's call: `renderMarkdown` escapes it (assistant output,
|
|
19
|
+
* where markup is data) unless `allowHtml` says the source is a document.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import { Marked } from 'marked';
|
|
21
|
-
import
|
|
23
|
+
import { codeSafeAutolink } from '@/shared/lib/markdown/autolink';
|
|
22
24
|
import remend from 'remend';
|
|
23
25
|
import { highlightCode } from '@/shared/lib/code/syntax-highlight';
|
|
24
26
|
import { MATH_PENDING_CLASS } from '@/shared/lib/markdown/katex';
|
|
@@ -64,7 +66,7 @@ const marked = new Marked({
|
|
|
64
66
|
breaks: true,
|
|
65
67
|
});
|
|
66
68
|
|
|
67
|
-
marked.use(
|
|
69
|
+
marked.use(codeSafeAutolink());
|
|
68
70
|
|
|
69
71
|
interface CodeToken {
|
|
70
72
|
type: string;
|
|
@@ -217,19 +219,29 @@ function escapeHtmlOutsideCode(source: string): string {
|
|
|
217
219
|
* parsing so the placeholder URL never leaks into the DOM as an href. */
|
|
218
220
|
const INCOMPLETE_LINK_RE = /\[([^\]]*)\]\(streamdown:incomplete-link\)/g;
|
|
219
221
|
|
|
222
|
+
export interface RenderMarkdownOptions {
|
|
223
|
+
/**
|
|
224
|
+
* Leave the source's own HTML markup intact so marked parses it as markup.
|
|
225
|
+
*
|
|
226
|
+
* OFF (default) is the agent-output contract: assistant HTML is data, never
|
|
227
|
+
* markup, so `<div align="center">` and `<br>` arrive as visible text and
|
|
228
|
+
* DOMPurify is only the second line of defence. ON is the file-preview
|
|
229
|
+
* contract, where the source IS the document — a README's `<p align="center">`
|
|
230
|
+
* block, badge `<img>` and inline `<br>` are the layout its author wrote, and
|
|
231
|
+
* escaping them shows the tags instead of the page. DOMPurify still sanitizes
|
|
232
|
+
* the result, so scripts and event handlers never survive either way.
|
|
233
|
+
*/
|
|
234
|
+
allowHtml?: boolean;
|
|
235
|
+
}
|
|
236
|
+
|
|
220
237
|
/**
|
|
221
238
|
* Heal streaming markdown (remend) then parse to HTML via marked.
|
|
222
239
|
* Synchronous and side-effect free — safe in both server loaders and the
|
|
223
240
|
* browser. The output must be sanitized with DOMPurify before injection.
|
|
224
|
-
*
|
|
225
|
-
* Raw HTML in the source is escaped (instruksi: assistant HTML is escaped,
|
|
226
|
-
* never rendered) — `<script>`/`<img onerror>` come through as literal text
|
|
227
|
-
* while backtick code is preserved. DOMPurify remains the second line of
|
|
228
|
-
* defence.
|
|
229
241
|
*/
|
|
230
|
-
export function renderMarkdown(markdown: string): string {
|
|
231
|
-
const
|
|
232
|
-
const healed = remend(
|
|
242
|
+
export function renderMarkdown(markdown: string, options: RenderMarkdownOptions = {}): string {
|
|
243
|
+
const source = options.allowHtml ? markdown : escapeHtmlOutsideCode(markdown);
|
|
244
|
+
const healed = remend(source, { katex: true, inlineKatex: true })
|
|
233
245
|
.replace(INCOMPLETE_LINK_RE, (_, text: string) => `<span class="md-incomplete-link">${text}</span>`);
|
|
234
246
|
return (marked.parse(healed) as string).trim();
|
|
235
247
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { sanitizeMermaidSvg } from '@/shared/lib/markdown/sanitize';
|
|
21
|
+
import { isDarkTheme } from '@/shared/lib/theme/catalog';
|
|
21
22
|
|
|
22
23
|
export interface MermaidRenderResult {
|
|
23
24
|
rendered: number;
|
|
@@ -34,8 +35,7 @@ let mermaidPromise: Promise<MermaidModule['default']> | null = null;
|
|
|
34
35
|
let lastThemeKey: ThemeKey | null = null;
|
|
35
36
|
|
|
36
37
|
function resolveThemeKey(): ThemeKey {
|
|
37
|
-
|
|
38
|
-
return theme === 'one-dark-pro-soft' || theme === 'noir' || theme === 'dark' ? 'dark' : 'default';
|
|
38
|
+
return isDarkTheme(document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme')) ? 'dark' : 'default';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function cacheKey(themeKey: ThemeKey, source: string): string {
|
|
@@ -17,7 +17,7 @@ export interface CatalogModelInfo {
|
|
|
17
17
|
reasoning?: boolean;
|
|
18
18
|
tool_call?: boolean;
|
|
19
19
|
limit?: { context?: number; output?: number };
|
|
20
|
-
cost?: { input?: number; output?: number; cache_read?: number };
|
|
20
|
+
cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
type ModelsDevCatalog = Record<string, { models?: Record<string, CatalogModelInfo> }>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Additive migrations for `session_stream_state`. `CREATE TABLE IF NOT EXISTS`
|
|
8
|
+
* leaves an existing table alone, so a database created before `owner_pid`
|
|
9
|
+
* existed would miss the column and every status read would fail on it.
|
|
10
|
+
*
|
|
11
|
+
* Split out of `@/server/lib/db/schema` for the same reason
|
|
12
|
+
* `@/shared/lib/workspace/schema-migrations` is: the DDL block there owns table
|
|
13
|
+
* creation, and each later column arrives in the module that explains it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { DbClient } from '@/server/lib/db/client';
|
|
17
|
+
|
|
18
|
+
async function addColumn(db: DbClient, statement: string): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
await db.exec(statement);
|
|
21
|
+
} catch {
|
|
22
|
+
// Already present — a fresh install gets the column from the DDL.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function migrateSessionStreamStateColumns(db: DbClient): Promise<void> {
|
|
27
|
+
// Which chamber process is running the session, so any instance reading this
|
|
28
|
+
// table can judge a `stream` row it does not own (see stream-state.server.ts).
|
|
29
|
+
await addColumn(db, 'ALTER TABLE session_stream_state ADD COLUMN owner_pid INTEGER;');
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The staleness rule behind the sidebar's self-heal. Getting it wrong is
|
|
8
|
+
* user-visible in both directions: too eager clears the spinner of a run that
|
|
9
|
+
* is still working, too lazy leaves a spinner turning forever for a run whose
|
|
10
|
+
* process is gone.
|
|
11
|
+
*
|
|
12
|
+
* The rule is deliberately a pure function of the ROW plus OS liveness, with no
|
|
13
|
+
* caller context. That is what makes several chamber instances agree: they all
|
|
14
|
+
* read the same row and ask the same question about the same pid.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import { isStaleStreamRow } from '@/shared/lib/omp/session/stream-state.server';
|
|
20
|
+
|
|
21
|
+
const OWNER = 4242;
|
|
22
|
+
const otherInstance = 9999;
|
|
23
|
+
/** OWNER and the other instance are alive; anything else, including 999999, is not. */
|
|
24
|
+
const alive = (pid: number) => pid === OWNER || pid === otherInstance;
|
|
25
|
+
|
|
26
|
+
describe('isStaleStreamRow', () => {
|
|
27
|
+
test('a row whose owner is alive is live, whoever is asking', () => {
|
|
28
|
+
expect(isStaleStreamRow({ session_id: 's1', owner_pid: OWNER }, alive)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a row whose owner is gone is stale', () => {
|
|
32
|
+
expect(isStaleStreamRow({ session_id: 's1', owner_pid: 999999 }, alive)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('the verdict does not depend on which instance is asking', () => {
|
|
36
|
+
// The regression this shape exists for: several instances read ONE row and
|
|
37
|
+
// must report the same status. Judging by the reader's own runtime registry
|
|
38
|
+
// made a second instance call another instance's live run stale — the row
|
|
39
|
+
// is live here, and every asker must say so.
|
|
40
|
+
const row = { session_id: 's1', owner_pid: otherInstance };
|
|
41
|
+
const verdicts = [otherInstance, OWNER, 12345].map(() => isStaleStreamRow(row, alive));
|
|
42
|
+
expect(verdicts).toEqual([false, false, false]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('an ownerless row is stale, because nothing can vouch for its run', () => {
|
|
46
|
+
expect(isStaleStreamRow({ session_id: 's1', owner_pid: null }, alive)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a row owned by the reader is judged by liveness like any other', () => {
|
|
50
|
+
// Self-ownership is not special-cased: the reader's own pid is alive while
|
|
51
|
+
// it runs, so its live rows survive.
|
|
52
|
+
expect(isStaleStreamRow({ session_id: 's1', owner_pid: OWNER }, (pid) => pid === OWNER)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -5,35 +5,49 @@
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Server-authoritative per-session stream status, persisted in SQLite so the
|
|
8
|
-
* sidebar can render spinner/check from the SAME loader that already
|
|
8
|
+
* sidebar can render spinner/check from the SAME loader that already refreshes
|
|
9
9
|
* on stream events (`omp:session-updated` → revalidator). Statuses:
|
|
10
10
|
*
|
|
11
|
-
* stream — a run is in flight (prompt dispatch →
|
|
11
|
+
* stream — a run is in flight (prompt dispatch → agent_end)
|
|
12
12
|
* finish — the run ended normally (agent_end), not yet seen
|
|
13
13
|
* abort — the user stopped it, not yet seen
|
|
14
14
|
*
|
|
15
15
|
* A terminal status is a one-shot badge: opening the session acknowledges it
|
|
16
16
|
* (`markStreamSeen` deletes the row), which is exactly the "check appears
|
|
17
|
-
* once, disappears when the session is opened" contract.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* once, disappears when the session is opened" contract.
|
|
18
|
+
*
|
|
19
|
+
* `stream` rows record the pid of the chamber process running that session, so
|
|
20
|
+
* a `stream` row whose owner is gone self-heals to `finish` — see
|
|
21
|
+
* `healStaleStreamStatuses`. Ownership travels in the row because the database
|
|
22
|
+
* is SHARED by every chamber instance on the machine (~/.ompchamber/db.sqlite)
|
|
23
|
+
* while the runtime session registry is per process: without the column, a
|
|
24
|
+
* second instance opening its sidebar judged the first instance's live run by
|
|
25
|
+
* its own empty registry and cleared a spinner that was still working.
|
|
20
26
|
*
|
|
21
27
|
* All writes are fire-and-forget: a status write must never fail a prompt,
|
|
22
28
|
* and the sidebar re-reads on the next revalidate anyway.
|
|
23
29
|
*/
|
|
24
30
|
|
|
25
31
|
import { getDb } from '@/server/db.server';
|
|
32
|
+
import { isProcessAlive } from '@/server/lib/lifecycle/identity';
|
|
26
33
|
|
|
27
34
|
export type SessionStreamStatus = 'stream' | 'finish' | 'abort';
|
|
28
35
|
|
|
29
36
|
export async function markStreamStatus(sessionId: string, status: SessionStreamStatus): Promise<void> {
|
|
30
37
|
try {
|
|
31
38
|
const db = await getDb();
|
|
39
|
+
// The owner describes the process running a live row; a terminal badge
|
|
40
|
+
// belongs to no process, and leaving a pid on it would invite a later read
|
|
41
|
+
// to judge the badge by the liveness of a process it never concerned.
|
|
42
|
+
const ownerPid = status === 'stream' ? process.pid : null;
|
|
32
43
|
await db.run(
|
|
33
|
-
`INSERT INTO session_stream_state (session_id, status, updated_at)
|
|
34
|
-
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
35
|
-
ON CONFLICT(session_id) DO UPDATE SET
|
|
36
|
-
|
|
44
|
+
`INSERT INTO session_stream_state (session_id, status, owner_pid, updated_at)
|
|
45
|
+
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
|
46
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
47
|
+
status = excluded.status,
|
|
48
|
+
owner_pid = excluded.owner_pid,
|
|
49
|
+
updated_at = excluded.updated_at`,
|
|
50
|
+
[sessionId, status, ownerPid],
|
|
37
51
|
);
|
|
38
52
|
} catch {
|
|
39
53
|
// Status tracking is best-effort by design.
|
|
@@ -97,23 +111,58 @@ export async function loadStreamStatuses(): Promise<Record<string, SessionStream
|
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
114
|
+
* Whether a `stream` row has no live run behind it and must heal to `finish`.
|
|
115
|
+
*
|
|
116
|
+
* The owner pid is the only input: a row is live exactly while the process that
|
|
117
|
+
* wrote it is still running. EVERY chamber instance therefore reaches the same
|
|
118
|
+
* verdict from the same row — which is the point. Judging a row by the reader's
|
|
119
|
+
* own runtime registry instead made instances disagree about one run: a second
|
|
120
|
+
* instance opening its sidebar saw an empty registry, called the first
|
|
121
|
+
* instance's working run stale, and cleared a spinner that was still turning.
|
|
122
|
+
*
|
|
123
|
+
* A row with no owner predates the column and heals: new code always records a
|
|
124
|
+
* pid for `stream`, so an ownerless `stream` row can only come from an older
|
|
125
|
+
* process, whose run nothing can vouch for.
|
|
126
|
+
*/
|
|
127
|
+
export function isStaleStreamRow(
|
|
128
|
+
row: { session_id: string; owner_pid: number | null },
|
|
129
|
+
isOwnerAlive: (ownerPid: number) => boolean,
|
|
130
|
+
): boolean {
|
|
131
|
+
if (row.owner_pid === null) return true;
|
|
132
|
+
return !isOwnerAlive(row.owner_pid);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Self-heal: a `stream` row outlives its owner when a chamber process exits
|
|
137
|
+
* mid-run (crash, restart, SIGKILL) — nothing will ever write the terminal
|
|
138
|
+
* status for it. At read time every such row flips to `finish`, which is the
|
|
139
|
+
* honest outcome: the run is not going to finish.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately takes no caller context. Liveness is the same question for every
|
|
142
|
+
* instance, so it is answered once, here, from the OS — the database is shared
|
|
143
|
+
* by all of them and they must all read the same status out of it.
|
|
144
|
+
*
|
|
145
|
+
* Runs on the sidebar loader's path, so the authoritative status travels with
|
|
146
|
+
* the same fetch that refreshes the list.
|
|
103
147
|
*/
|
|
104
|
-
export async function healStaleStreamStatuses(
|
|
148
|
+
export async function healStaleStreamStatuses(): Promise<void> {
|
|
105
149
|
try {
|
|
106
150
|
const db = await getDb();
|
|
107
|
-
const rows = (await db.all("SELECT session_id FROM session_stream_state WHERE status = 'stream'")) as {
|
|
151
|
+
const rows = (await db.all("SELECT session_id, owner_pid FROM session_stream_state WHERE status = 'stream'")) as {
|
|
108
152
|
session_id: string;
|
|
153
|
+
owner_pid: number | null;
|
|
109
154
|
}[];
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
155
|
+
const stale = rows.filter((row) => isStaleStreamRow(row, isProcessAlive)).map((row) => row.session_id);
|
|
156
|
+
if (stale.length === 0) return;
|
|
157
|
+
// `status = 'stream'` is re-checked in the WHERE clause: a run that started
|
|
158
|
+
// between the read above and this write owns its row now, and healing it
|
|
159
|
+
// would clear the spinner of a session that is demonstrably working.
|
|
160
|
+
const placeholders = stale.map(() => '?').join(', ');
|
|
161
|
+
await db.run(
|
|
162
|
+
`UPDATE session_stream_state SET status = 'finish', updated_at = CURRENT_TIMESTAMP
|
|
163
|
+
WHERE status = 'stream' AND session_id IN (${placeholders})`,
|
|
164
|
+
stale,
|
|
165
|
+
);
|
|
117
166
|
} catch {
|
|
118
167
|
// Best-effort.
|
|
119
168
|
}
|
|
@@ -76,14 +76,20 @@ export function writeSetting(key: string, value: unknown): void {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* Write a patch of chamber-blob keys (`theme`, `streamTransport`, …). The
|
|
80
|
-
*
|
|
81
|
-
*
|
|
79
|
+
* Write a patch of chamber-blob keys (`theme`, `streamTransport`, …). The
|
|
80
|
+
* snapshot exposes the blob's keys flat, so an edit applies to the next read
|
|
81
|
+
* without a round-trip.
|
|
82
|
+
*
|
|
83
|
+
* Only the patch travels, and the server merges it into the stored blob. The
|
|
84
|
+
* whole local blob must not be republished: this page's copy was taken when it
|
|
85
|
+
* booted, so a tab left open across an external change (a second tab, a direct
|
|
86
|
+
* API call) would overwrite the newer keys with its own stale ones on the next
|
|
87
|
+
* unrelated write — which is exactly how a theme chosen elsewhere reverted.
|
|
82
88
|
*/
|
|
83
89
|
export function writeChamberSettings(patch: Record<string, any>): void {
|
|
84
90
|
chamberBlob = { ...chamberBlob, ...patch };
|
|
85
91
|
snapshot = { ...current(), ...patch };
|
|
86
|
-
sendSettings({ [CHAMBER_SETTINGS_KEY]:
|
|
92
|
+
sendSettings({ [CHAMBER_SETTINGS_KEY]: patch });
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
/** POST a settings patch to SQLite. Fire-and-forget; failures are non-fatal. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { diffSettings } from '@/shared/lib/settings/diff';
|
|
4
|
+
|
|
5
|
+
describe('diffSettings', () => {
|
|
6
|
+
test('an object patch persists only the keys it actually changed', () => {
|
|
7
|
+
const prev = { theme: 'paper', soundAlerts: true, binaryPath: '/bin/omp' };
|
|
8
|
+
const next = { ...prev, theme: 'nord-dark' };
|
|
9
|
+
expect(diffSettings(prev, next, { theme: 'nord-dark' })).toEqual({ theme: 'nord-dark' });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('a no-op patch persists nothing', () => {
|
|
13
|
+
const prev = { theme: 'paper', soundAlerts: true };
|
|
14
|
+
expect(diffSettings(prev, { ...prev }, { theme: 'paper' })).toEqual({});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('an updater function persists every key whose value it moved', () => {
|
|
18
|
+
const prev = { keybindingSend: 'Enter', keybindingNewLine: 'Shift + Enter' };
|
|
19
|
+
const next = { keybindingSend: 'Ctrl / Cmd + Enter', keybindingNewLine: 'Enter' };
|
|
20
|
+
expect(diffSettings(prev, next, (state) => ({ ...state, ...next }))).toEqual(next);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('an unrelated update never republishes the theme it read', () => {
|
|
24
|
+
// The regression this exists for: a tab that booted on `paper` toggling an
|
|
25
|
+
// unrelated switch in a settings modal whose snapshot predates an external
|
|
26
|
+
// theme change must not write `theme` back.
|
|
27
|
+
const prev = { theme: 'paper', chatCompletionSound: true };
|
|
28
|
+
const patch = { chatCompletionSound: false };
|
|
29
|
+
expect(diffSettings(prev, { ...prev, ...patch }, patch)).toEqual({ chatCompletionSound: false });
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The subset of a settings update that should be persisted.
|
|
8
|
+
*
|
|
9
|
+
* The settings modal keeps a full `SettingsState` snapshot from the moment it
|
|
10
|
+
* opened and hands every edit to one writer. Writing the whole object back
|
|
11
|
+
* republishes values the user never touched, from a snapshot that may be older
|
|
12
|
+
* than what another writer (a second tab, an API call) has since stored — the
|
|
13
|
+
* stale copy wins purely because it was written last.
|
|
14
|
+
*
|
|
15
|
+
* A key is persisted only when the update changed it:
|
|
16
|
+
*
|
|
17
|
+
* - **Object patch** — the keys it names, but only those whose value actually
|
|
18
|
+
* differs, so a no-op re-render does not touch the row.
|
|
19
|
+
* - **Updater function** — every key whose value differs between `prev` and
|
|
20
|
+
* `next`; the function's own intent is not inspectable, so the diff is.
|
|
21
|
+
*
|
|
22
|
+
* Values are compared by identity, which is what `SettingsState` holds: strings,
|
|
23
|
+
* numbers and booleans. A nested object would compare by reference and be
|
|
24
|
+
* reported as changed — acceptable here, and better than dropping a real edit.
|
|
25
|
+
*/
|
|
26
|
+
export function diffSettings<T extends Record<string, any>>(
|
|
27
|
+
prev: T,
|
|
28
|
+
next: T,
|
|
29
|
+
update: Partial<T> | ((prev: T) => T),
|
|
30
|
+
): Partial<T> {
|
|
31
|
+
const patch: Partial<T> = {};
|
|
32
|
+
const keys = typeof update === 'function' ? (Object.keys(next) as (keyof T)[]) : (Object.keys(update) as (keyof T)[]);
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
if (next[key] !== prev[key]) patch[key] = next[key];
|
|
35
|
+
}
|
|
36
|
+
return patch;
|
|
37
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The catalog is the single source of truth for what the chamber can paint, and
|
|
8
|
+
* every failure mode here is silent at runtime: an unknown id renders the
|
|
9
|
+
* default palette (a theme that "doesn't work"), a missing color renders as an
|
|
10
|
+
* empty declaration (an invisible swatch, a transparent panel), a wrong variant
|
|
11
|
+
* leaves a dark theme with light scrollbars and light-theme code blocks.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_THEME_ID,
|
|
18
|
+
THEMES,
|
|
19
|
+
THEME_SWATCHES,
|
|
20
|
+
isDarkTheme,
|
|
21
|
+
resolveTheme,
|
|
22
|
+
resolveThemeId,
|
|
23
|
+
} from '@/shared/lib/theme/catalog';
|
|
24
|
+
import { themeStyleSheet } from '@/shared/lib/theme/css';
|
|
25
|
+
import { mixHex, parseHexColor } from '@/shared/lib/theme/color';
|
|
26
|
+
import { getXtermTheme } from '@/client/data/theme/terminal';
|
|
27
|
+
|
|
28
|
+
const COLOR_KEYS = ['canvas', 'paper', 'ink', 'error', 'success', 'info', 'warning', 'meta'] as const;
|
|
29
|
+
const COLOR_VALUE = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8})$/i;
|
|
30
|
+
|
|
31
|
+
describe('theme catalog', () => {
|
|
32
|
+
test('ships the chamber palettes plus every ported variant', () => {
|
|
33
|
+
expect(THEMES.length).toBe(45);
|
|
34
|
+
// 21 ported light + paper/contrast; 21 ported dark + one-dark-pro-soft.
|
|
35
|
+
expect(THEMES.filter((t) => t.variant === 'light').length).toBe(23);
|
|
36
|
+
expect(THEMES.filter((t) => t.variant === 'dark').length).toBe(22);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('ids are unique and each palette carries a paintable color for every slot', () => {
|
|
40
|
+
const ids = new Set<string>();
|
|
41
|
+
for (const theme of THEMES) {
|
|
42
|
+
expect(ids.has(theme.id)).toBe(false);
|
|
43
|
+
ids.add(theme.id);
|
|
44
|
+
expect(theme.name.length).toBeGreaterThan(0);
|
|
45
|
+
expect(theme.family.length).toBeGreaterThan(0);
|
|
46
|
+
for (const key of COLOR_KEYS) {
|
|
47
|
+
expect(theme[key]).toMatch(COLOR_VALUE);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('the default is the shipped E-Ink palette', () => {
|
|
53
|
+
expect(DEFAULT_THEME_ID).toBe('paper');
|
|
54
|
+
expect(resolveTheme(DEFAULT_THEME_ID).name).toBe('E-Ink Paper');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('an id the catalog does not know resolves to the default, never to nothing', () => {
|
|
58
|
+
// `noir` shipped until this change as a byte-identical alias of
|
|
59
|
+
// `one-dark-pro-soft`; a settings row naming it must still render.
|
|
60
|
+
for (const unknown of ['noir', 'nonsense', '', undefined, null]) {
|
|
61
|
+
expect(resolveTheme(unknown).id).toBe(DEFAULT_THEME_ID);
|
|
62
|
+
expect(resolveThemeId(unknown)).toBe(DEFAULT_THEME_ID);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('every ported family pairs a light entry with a dark one', () => {
|
|
67
|
+
const byFamily = new Map<string, Set<string>>();
|
|
68
|
+
for (const theme of THEMES) {
|
|
69
|
+
const variants = byFamily.get(theme.family) ?? new Set<string>();
|
|
70
|
+
variants.add(theme.variant);
|
|
71
|
+
byFamily.set(theme.family, variants);
|
|
72
|
+
}
|
|
73
|
+
// `paper`/`contrast` (light-only) and `one-dark-pro-soft` (dark-only) are
|
|
74
|
+
// the chamber's own single-variant palettes; every ported family has both.
|
|
75
|
+
const singles = [...byFamily].filter(([, variants]) => variants.size === 1).map(([family]) => family);
|
|
76
|
+
expect(singles.sort()).toEqual(['contrast', 'one-dark-pro-soft', 'paper']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('swatches name every color the cards can show', () => {
|
|
80
|
+
expect(THEME_SWATCHES.map((s) => s.key)).toEqual([...COLOR_KEYS]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('generated stylesheet', () => {
|
|
85
|
+
const css = themeStyleSheet();
|
|
86
|
+
|
|
87
|
+
test('defines every palette under a selector that beats :root', () => {
|
|
88
|
+
for (const theme of THEMES) {
|
|
89
|
+
// `html[...]` (0,1,1) outranks the stylesheet's `:root` (0,1,0); a bare
|
|
90
|
+
// `[data-theme]` would tie and lose on document order.
|
|
91
|
+
expect(css).toContain(`html[data-theme="${theme.id}"]`);
|
|
92
|
+
}
|
|
93
|
+
expect(css.match(/html\[data-theme="/g)?.length).toBe(THEMES.length);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('carries each palette\'s colors and its color-scheme', () => {
|
|
97
|
+
expect(css).toContain('--theme-canvas: #1F2430;');
|
|
98
|
+
expect(css).toContain('color-scheme: dark;');
|
|
99
|
+
expect(css).toContain('color-scheme: light;');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('derives the hover shades instead of pinning a hex that fits one palette', () => {
|
|
103
|
+
expect(css).toContain('--theme-info-hover: color-mix(in srgb, var(--theme-info) 85%, var(--theme-ink));');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('theme variant verdict', () => {
|
|
108
|
+
test('follows the palette, not a hand-kept id list', () => {
|
|
109
|
+
expect(isDarkTheme('paper')).toBe(false);
|
|
110
|
+
expect(isDarkTheme('one-dark-pro-soft')).toBe(true);
|
|
111
|
+
expect(isDarkTheme('nord-dark')).toBe(true);
|
|
112
|
+
expect(isDarkTheme('nord-light')).toBe(false);
|
|
113
|
+
expect(isDarkTheme('tokyonight-light')).toBe(false);
|
|
114
|
+
expect(isDarkTheme('tokyonight-dark')).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('every dark palette is dark and every light palette is not', () => {
|
|
118
|
+
for (const theme of THEMES) {
|
|
119
|
+
expect(isDarkTheme(theme.id)).toBe(theme.variant === 'dark');
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('color math', () => {
|
|
125
|
+
test('parses the notations the ported palettes actually use', () => {
|
|
126
|
+
expect(parseHexColor('#FFF')).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
|
127
|
+
expect(parseHexColor('#f4f1ea')).toEqual({ r: 244, g: 241, b: 234, a: 1 });
|
|
128
|
+
expect(parseHexColor('#dbd7caee')?.a).toBeCloseTo(0.933, 3);
|
|
129
|
+
expect(parseHexColor('rebeccapurple')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('mixes toward the blend color and composites its alpha', () => {
|
|
133
|
+
expect(mixHex('#000000', '#ffffff', 0.5)).toBe('#808080');
|
|
134
|
+
expect(mixHex('#000000', '#ffffff', 0)).toBe('#000000');
|
|
135
|
+
expect(mixHex('#000000', '#ffffff', 1)).toBe('#ffffff');
|
|
136
|
+
// A fully transparent blend leaves the base untouched rather than
|
|
137
|
+
// producing a black hole.
|
|
138
|
+
expect(mixHex('#123456', '#ffffff00', 1)).toBe('#123456');
|
|
139
|
+
expect(mixHex('nonsense', '#ffffff', 1)).toBe('nonsense');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('terminal palette', () => {
|
|
144
|
+
test('follows the active theme rather than a fixed light/dark pair', () => {
|
|
145
|
+
const nord = getXtermTheme('nord-dark');
|
|
146
|
+
const paper = getXtermTheme('paper');
|
|
147
|
+
expect(nord.background).toBe('#1F2430');
|
|
148
|
+
expect(nord.foreground).toBe('#E5E9F0');
|
|
149
|
+
expect(paper.background).toBe('#f4f1ea');
|
|
150
|
+
expect(paper.foreground).toBe('#141310');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('emits literal colors — xterm cannot parse color-mix()', () => {
|
|
154
|
+
for (const theme of THEMES) {
|
|
155
|
+
for (const value of Object.values(getXtermTheme(theme.id))) {
|
|
156
|
+
expect(value).toMatch(COLOR_VALUE);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('takes its signal colors from the palette', () => {
|
|
162
|
+
const dracula = getXtermTheme('dracula-dark');
|
|
163
|
+
expect(dracula.red).toBe('#FF5555');
|
|
164
|
+
expect(dracula.green).toBe('#50FA7B');
|
|
165
|
+
expect(dracula.yellow).toBe('#FFB86C');
|
|
166
|
+
expect(dracula.cursor).toBe('#BD93F9');
|
|
167
|
+
});
|
|
168
|
+
});
|