diff --git a/README.md b/README.md index 70ac3a23..73dafd63 100644 --- a/README.md +++ b/README.md @@ -408,12 +408,14 @@ macOS/Linux arm64 与 x64 缺少二进制时,OpenPI 会从官方 Release 下 无参数时,OpenPI 展示当前状态并引导修改;带自然语言时只改指定项: - + ```text /openpi-setup 开启下一步预测,选择 Registry 里的轻量模型,minimal 推理 /openpi-setup 让模型在合适时自主发现并采用 OpenPI 能力 /openpi-setup workflow 同时跑 16 个 agent,总调用最多 256 +/openpi-setup Web 主题跟随系统 +/openpi-setup Web 使用深色主题 /openpi-setup Footer 两行:cwd flex model / context cost flex git /openpi-setup Bash 展开,Write/Edit 保持紧凑 /openpi-setup 编辑后自动跑 npm run format @@ -434,12 +436,12 @@ Footer 布局以 `footerLines` 作为唯一持久化格式。旧版 `footerItems | Capability discovery | `explicit`;`adaptive` 必须显式开启 | | Next-action Suggestion | 关闭;启用时显式选择 Registry 模型与 reasoning | | Workflow 并发 / 总调用 | 8 / 128;硬上限 64 / 1024 | +| Web 主题 | `system`;可选 `light` / `dark` | | 大型 Header | 关闭 | | Dashboard Footer | 开启;单行 `plain` | | Subagent / Bash / Write/Edit | `compact` / `compact` / `compact` | | Post-edit 命令 | 关闭;单条命令最多 500 字符 | | 内置角色模型 | 全部继承父模型 | -| 主题 | 保留用户现有选择 | diff --git a/SETUP.md b/SETUP.md index 0d03718d..81284907 100644 --- a/SETUP.md +++ b/SETUP.md @@ -32,6 +32,8 @@ Add the included theme to `~/.pi/agent/settings.json` while keeping your existin Pi will load the extensions and theme the next time it starts. OpenPI's Background, Subagent, and Workflow Skill files remain in the package, but ordinary turns do not advertise them in the system prompt; the matching path is disclosed only after explicit capability intent or after the model loads that group through the opt-in adaptive gateway. +The terminal theme above remains Pi-owned. OpenPI Web has a separate package-owned `system` / `light` / `dark` preference configured only through `/openpi-setup`; `system` is the default and follows the browser or operating-system color scheme. + ## Configure this package Use the single canonical package-owned command. `/my-pi-setup` remains a compatibility alias. With no arguments, the current model explains the configurable areas and uses `ask_user`: first run initializes them; later runs explain the saved state and ask whether to keep it, change one area, or review everything. With arguments, it treats the rest as a targeted natural-language request. Persist still goes through the typed `configure_my_pi_setup` tool. The writer stays hidden while a busy Session queues the setup request, becomes active only when that exact request is delivered to the model, and is hidden again afterward. One successful apply completes the episode. If the run settles without a successful apply, OpenPI appends a visible, durable closure receipt to the Session and later model context; it says that the writer is hidden and re-entry requires `/openpi-setup `. OpenPI also verifies that the active writer belongs to the package source before announcing an active setup episode; duplicate or mismatched sources fail closed without injecting the setup request. A later configuration change starts a new episode with `/openpi-setup ` rather than reusing the hidden tool: @@ -43,6 +45,8 @@ Use the single canonical package-owned command. `/my-pi-setup` remains a compati /openpi-setup 开启下一步预测,使用 seal/deepseek-v4-flash,关闭推理 /openpi-setup 关闭下一步预测 /openpi-setup workflow 同时跑 16 个 agent,总任务最多 256 个 +/openpi-setup Web theme follows the system +/openpi-setup use dark theme in OpenPI Web /openpi-setup 显示大标题 /openpi-setup 切换 Footer 为 powerline /openpi-setup 用 mono powerline Footer @@ -58,9 +62,11 @@ Use the single canonical package-owned command. `/my-pi-setup` remains a compati Capability discovery defaults to `explicit`, preserving the zero-resident OpenPI tool surface until the user asks for a capability. The case-insensitive English words `subagent` and `workflow` are reserved authorization words: entering either word is sufficient to load its capability group, and the interactive editor shows it in Claude Code-style lavender before submission, with a darker purple fallback for light themes. This makes discussion that contains either English word an intentional opt-in tradeoff; conditional and negated clauses remain inert, while Chinese capability names still require a recognized action request. `adaptive` is an explicit opt-in that keeps only `openpi_load_tools` visible and allows the model to load a useful group on its own; because this can start Subagents, Workflows, or background processes, normal permission and configured concurrency/call limits still apply. Changing the setting updates the current Session immediately, while already loaded groups remain stable for that Session. Accepted Suggestion text is classified only after it becomes real editor input. The visual feedback and runtime activation share one fail-closed intent classifier. Next-action suggestions default to off. Run `/openpi-setup` to explicitly choose an available model and reasoning level. After a fully settled main-agent run, one suggestion may appear as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. `Right` accepts it into the editor without submitting, while any other editor input dismisses it. Suggestions are ephemeral and never enter session history or model context. Workflows default to 8 concurrent agents and 128 total agent calls per run; configurable hard maxima are 64 and 1024. The large decorative header defaults off and the custom dashboard footer defaults on with a one-line plain layout (`model context |flex| git pr cwd`; `thinking`, `cache`, `cost`, and `throughput` remain opt-in metrics). Footer presets are `powerline`, `powerline-mono`, and `compact`; style can also be set independently to `plain`, `powerline`, or `powerline-mono`. Custom layouts use a 2D `footerLines` array with at most one `flex` per row for left/right alignment. Nerd Font affects powerline separator glyphs (``) and adds Codex-style outline icons to compact read, terminal, edit, search, and directory activity rows; all accompanying text remains readable without it. Footer metrics use one Codicon outline family (`` model, `` context, and `` directory) plus `⎇` for the branch. A Nerd Font containing Codicons renders them as designed; without one, the text labels remain readable even if an icon falls back to an empty box. Footer changes apply immediately in the active TUI session. Subagent results default to a compact status summary; full mode remains available as a per-user setting, and `app.tools.expand` (`Ctrl+O` by default) reveals the available child report. Ordinary `read`, `grep`, `find`, and `ls` operations render as one-line semantic activity summaries. Bash and Write/Edit default to the same activity-row projection, showing the target, running/success/failure state, and useful line or diff counts without replaying stdout or previews. Select full independently for Bash or Write/Edit to keep Pi's native rendering expanded. `app.tools.expand` temporarily restores the native arguments, output, errors, diff, timing, images, truncation notices, and full-output metadata; it never changes Session history or model context. An optional post-edit command is off by default: set one (for example `npm run format`, maximum 500 characters) and it runs once in the background after each interactive-TUI turn with successful Write/Edit operations, with failures reported as a notification. It deliberately does not guess whether arbitrary Bash commands changed files. Built-in Agent roles `explorer`, `implementer`, `reviewer`, and `advisor` are shared by `subagent_spawn.agent_type` and Workflow `agent(..., { agent_type })`; all inherit the parent model by default. `/openpi-setup` may assign a currently available Registry model to any subset; clearing one returns it to inheritance and omitted roles stay unchanged. Model precedence is explicit call > selected role-file model > setup assignment > parent inheritance; effort is explicit call > selected role > parent. A trusted project `.pi/agents/.md` overrides global `~/.pi/agent/agents/.md`, which overrides the complete built-in role definition; overrides are diagnosed. Role-model changes apply to the next spawn or Workflow agent call without reload. Configuration is stored privately at `~/.pi/agent/my-pi-setup.json`. +OpenPI Web theme defaults to `system`; `light` and `dark` are explicit canonical setup choices, and the browser consumes them from each authoritative snapshot without writing a competing local preference. + Legacy `footerItems` is accepted and migrated at the input boundary, but new setup writes persist only canonical `footerLines`. Configurations written by this version are not guaranteed to retain their Footer layout when read by an older OpenPI version. - + ## Optional cross-session communication diff --git a/extensions/setup/index.ts b/extensions/setup/index.ts index b0f700c4..352d6eaf 100644 --- a/extensions/setup/index.ts +++ b/extensions/setup/index.ts @@ -36,11 +36,13 @@ import { POST_EDIT_COMMAND_MAX_CHARS, REASONING_LEVELS, SETUP_CONFIG_CHANGED_CHANNEL, + WEB_THEMES, type FooterLayoutItem, type CapabilityDiscoveryMode, type FooterPreset, type FooterStyle, type MyPiSetupConfig, + type WebTheme, } from "../shared/setup-config.ts"; const subagentRoleModelValueSchema = Type.Union([ @@ -107,7 +109,7 @@ export function buildInteractiveSetupPrompt(options: { }) { const configurationState = options.savedConfigExists ? [ - "This package has already been configured. Explain the current settings in the user's language, then ask whether they want to keep them or change Capability discovery, Next-action suggestions, Workflow limits, UI/Footer, result detail display, Post-edit, Agent role models, or review everything.", + "This package has already been configured. Explain the current settings in the user's language, then ask whether they want to keep them or change Capability discovery, Next-action suggestions, Workflow limits, UI theme/Footer, result detail display, Post-edit, Agent role models, or review everything.", "If the user keeps the current settings, do not call configure_my_pi_setup. If they choose a category, ask only the follow-up needed for that category.", ] : [ @@ -130,7 +132,7 @@ export function buildInteractiveSetupPrompt(options: { "- Capability discovery: explicit is the safe default and keeps OpenPI model tools absent until the user asks for a capability. adaptive is opt-in and keeps only the small openpi_load_tools gateway visible, allowing the model to load Subagents, Workflows, background terminals, structured search, or Session tracking when it judges them useful. Loaded groups remain session-stable, and normal permission, concurrency, and workflow limits still apply.", "- Next-action suggestions: disabled, or model-generated after a fully settled main-agent run. A suggestion appears as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. Right accepts it without submitting, and any other editor input dismisses it. Enabling requires an available provider/model and reasoning level and adds one small model call per settled run.", "- Workflow fan-out: concurrency controls simultaneous agents and resource pressure; max agent calls controls the total capacity of one workflow. Valid ranges are 1-64 and 1-1024.", - "- UI: the large header costs vertical space; the custom footer is a declarative dashboard. Presets: powerline (one-line ANSI256 blocks), powerline-mono (one-line high-contrast gray powerline), and compact (one-line plain text); the default is plain with model/context on the left and git/pr/cwd on the right. Style can also be set independently: plain, powerline, powerline-mono. Custom lines are a 2D layout of cwd/model/thinking/context/cache/cost/throughput/git/pr plus at most one flex per line for left/right alignment. Footer metrics use Codicon outline glyphs for model, context, and directory; a Nerd Font renders them as designed while the text stays readable without it. Changes apply immediately in the active TUI session.", + "- UI: the Web theme is system (default), light, or dark and is projected from this canonical configuration without browser-local overrides. The large header costs vertical space; the custom footer is a declarative dashboard. Presets: powerline (one-line ANSI256 blocks), powerline-mono (one-line high-contrast gray powerline), and compact (one-line plain text); the default is plain with model/context on the left and git/pr/cwd on the right. Style can also be set independently: plain, powerline, powerline-mono. Custom lines are a 2D layout of cwd/model/thinking/context/cache/cost/throughput/git/pr plus at most one flex per line for left/right alignment. Footer metrics use Codicon outline glyphs for model, context, and directory; a Nerd Font renders them as designed while the text stays readable without it. Changes apply immediately in the active TUI session; Web theme changes apply on its next canonical snapshot.", "- Operational activity for Subagents, Workflows, and background terminals is core status and always remains visible whenever the custom footer is enabled.", "- Post-edit command: one optional shell command (maximum 500 characters) run in the background after a turn with successful Write/Edit operations (e.g. `npm run format`). Off by default, interactive TUI sessions only, failures surface as a notification. This is a single command, not an event-hook system.", "- Result detail display: Subagent results, Bash operations, and Write/Edit operations can each default to full or compact; all three default to compact. Compact Subagent results show only bounded status rows and keep raw child reports behind app.tools.expand; compact Bash and Write/Edit operations use one-line semantic activity summaries. Read, grep, find, and ls use the same compact activity-row projection. Ctrl+O restores Pi's native full arguments, output, errors, diffs, and timing. Recommend compact for users who scan activity first and inspect evidence on demand.", @@ -139,6 +141,7 @@ export function buildInteractiveSetupPrompt(options: { "Natural-language configuration examples the user might ask for:", '- "let the model discover OpenPI capabilities when useful" → capability_discovery=adaptive', '- "only use OpenPI capabilities when I ask" → capability_discovery=explicit', + '- "use dark theme in OpenPI Web" → ui_web_theme=dark', '- "switch footer to powerline" → ui_footer_preset=powerline', '- "use mono powerline" → ui_footer_preset=powerline-mono', '- "compact footer" → ui_footer_preset=compact', @@ -309,7 +312,7 @@ export default function openPiSetup(pi: ExtensionAPI) { name: "configure_my_pi_setup", label: "Configure OpenPI", description: - "Apply a user-requested configuration change for this Pi setup. Configures capability discovery (explicit or opt-in adaptive), next-action suggestions, workflow fan-out, UI/Footer (presets, style, multi-line layout), result detail display, optional Post-edit, and built-in Agent-role model assignments shared by subagent_spawn and workflow agent_type. Role models must be available in the Pi registry; null clears a role back to parent-model inheritance. Footer examples: powerline preset, powerline-mono, compact, or custom ui_footer_lines with flex. Preserve current values for settings the user did not ask to change. Changes apply immediately to the capability gateway and active TUI footer.", + "Apply a user-requested configuration change for this Pi setup. Configures capability discovery (explicit or opt-in adaptive), next-action suggestions, workflow fan-out, the canonical OpenPI Web theme, UI/Footer (presets, style, multi-line layout), result detail display, optional Post-edit, and built-in Agent-role model assignments shared by subagent_spawn and workflow agent_type. Role models must be available in the Pi registry; null clears a role back to parent-model inheritance. Footer examples: powerline preset, powerline-mono, compact, or custom ui_footer_lines with flex. Preserve current values for settings the user did not ask to change. Changes apply immediately to the capability gateway and active TUI footer; Web observes theme changes through canonical snapshots.", parameters: Type.Object({ capability_discovery: Type.Optional( StringEnum(CAPABILITY_DISCOVERY_MODES, { @@ -356,6 +359,12 @@ export default function openPiSetup(pi: ExtensionAPI) { "Whether to show the large decorative Pi header. Defaults to false; omit to preserve the current value.", }), ), + ui_web_theme: Type.Optional( + StringEnum(WEB_THEMES, { + description: + "Canonical OpenPI Web theme: system follows the browser/OS color scheme, light and dark force that appearance. Stored in package setup rather than browser storage. Omit to preserve the current value.", + }), + ), ui_custom_footer: Type.Optional( Type.Boolean({ description: @@ -503,6 +512,9 @@ export default function openPiSetup(pi: ExtensionAPI) { current.workflows.maxAgentCalls, }, ui: { + webTheme: + (params.ui_web_theme as WebTheme | undefined) ?? + current.ui.webTheme, showHeader: params.ui_show_header ?? current.ui.showHeader, customFooter: params.ui_custom_footer ?? current.ui.customFooter, ...footer, diff --git a/extensions/shared/setup-config.ts b/extensions/shared/setup-config.ts index 25795a26..20609d64 100644 --- a/extensions/shared/setup-config.ts +++ b/extensions/shared/setup-config.ts @@ -63,6 +63,9 @@ export type FooterLines = readonly (readonly FooterLayoutItem[])[]; export const DETAIL_DISPLAYS = ["full", "compact"] as const; export type DetailDisplay = (typeof DETAIL_DISPLAYS)[number]; +export const WEB_THEMES = ["system", "light", "dark"] as const; +export type WebTheme = (typeof WEB_THEMES)[number]; + export const CAPABILITY_DISCOVERY_MODES = ["explicit", "adaptive"] as const; export type CapabilityDiscoveryMode = (typeof CAPABILITY_DISCOVERY_MODES)[number]; @@ -149,6 +152,7 @@ export interface MyPiSetupConfig { readonly maxAgentCalls: number; }; readonly ui: { + readonly webTheme: WebTheme; readonly showHeader: boolean; readonly customFooter: boolean; readonly footerStyle: FooterStyle; @@ -179,6 +183,7 @@ export const DEFAULT_SETUP_CONFIG: MyPiSetupConfig = { maxAgentCalls: DEFAULT_WORKFLOW_MAX_AGENT_CALLS, }, ui: { + webTheme: "system", showHeader: false, customFooter: true, footerStyle: DEFAULT_FOOTER_STYLE, @@ -226,6 +231,9 @@ const isCapabilityDiscoveryMode = ( typeof value === "string" && CAPABILITY_DISCOVERY_MODES.includes(value as CapabilityDiscoveryMode); +const isWebTheme = (value: unknown): value is WebTheme => + typeof value === "string" && WEB_THEMES.includes(value as WebTheme); + export function flattenFooterItems(lines: FooterLines): readonly FooterItem[] { const items: FooterItem[] = []; const seen = new Set(); @@ -485,6 +493,7 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig { ), }, ui: { + webTheme: isWebTheme(ui.webTheme) ? ui.webTheme : "system", showHeader: typeof ui.showHeader === "boolean" ? ui.showHeader : false, customFooter: typeof ui.customFooter === "boolean" ? ui.customFooter : true, @@ -1016,7 +1025,7 @@ export function formatSetupConfig(config = loadSetupConfig()) { `Capability discovery: ${config.capabilities.discovery}`, suggestions, `Workflows: ${config.workflows.concurrency} concurrent agents · ${config.workflows.maxAgentCalls} total calls`, - `UI: large header ${config.ui.showHeader ? "on" : "off"} · custom footer ${footer}`, + `UI: Web theme ${config.ui.webTheme} · large header ${config.ui.showHeader ? "on" : "off"} · custom footer ${footer}`, `Subagent results: ${config.ui.subagentResultDisplay === "full" ? "full by default" : "compact status summary (Ctrl+O expands full output)"}`, `Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`, `Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`, diff --git a/scripts/check-config-contract.mjs b/scripts/check-config-contract.mjs index ead40f76..308b8980 100644 --- a/scripts/check-config-contract.mjs +++ b/scripts/check-config-contract.mjs @@ -44,6 +44,13 @@ const CONFIG_FIELD_CONTRACT = [ readmeTerms: ["总调用"], setupTerms: ["total agent calls"], }, + { + path: "ui.webTheme", + writerTokens: ["params.ui_web_theme"], + statusTokens: ["config.ui.webTheme"], + readmeTerms: ["Web 主题"], + setupTerms: ["Web theme"], + }, { path: "ui.showHeader", writerTokens: ["params.ui_show_header"], diff --git a/tests/extensions/setup/index.test.ts b/tests/extensions/setup/index.test.ts index 8883a3c9..f2512ca2 100644 --- a/tests/extensions/setup/index.test.ts +++ b/tests/extensions/setup/index.test.ts @@ -226,6 +226,7 @@ test("registers the canonical setup command, legacy alias, and one constrained t assert.equal("suggestions_enabled" in parameters.properties, true); assert.equal("suggestion_model" in parameters.properties, true); assert.equal("capability_discovery" in parameters.properties, true); + assert.equal("ui_web_theme" in parameters.properties, true); const postEdit = parameters.properties.post_edit_command as { description?: string; }; @@ -256,6 +257,9 @@ test("post-edit stays off or preserved unless the setup request changes it", asy await apply({ ui_show_header: true }); assert.equal(loadSetupConfig().postEdit.command, ""); + await apply({ ui_web_theme: "dark" }); + assert.equal(loadSetupConfig().ui.webTheme, "dark"); + await apply({ post_edit_command: " npm run format " }); assert.equal(loadSetupConfig().postEdit.command, "npm run format"); @@ -686,6 +690,8 @@ test("builds a model-guided first-run setup prompt with impacts", () => { assert.match(message, /Right accepts it without submitting/); assert.match(message, /concurrency controls simultaneous agents/); assert.match(message, /large header costs vertical space/); + assert.match(message, /Web theme is system \(default\), light, or dark/); + assert.match(message, /ui_web_theme=dark/); assert.match(message, /powerline.*powerline-mono.*compact/); assert.match(message, /Nerd Font/); assert.match(message, /ui_footer_preset=powerline/); @@ -754,7 +760,7 @@ test("builds a focused review prompt when configuration already exists", () => { assert.match(message, /Explain the current settings/); assert.match( message, - /keep them or change Capability discovery, Next-action suggestions, Workflow limits, UI\/Footer, result detail display, Post-edit, Agent role models/, + /keep them or change Capability discovery, Next-action suggestions, Workflow limits, UI theme\/Footer, result detail display, Post-edit, Agent role models/, ); assert.match(message, /keeps the current settings, do not call/); assert.match(message, /available only for this setup run/i); diff --git a/tests/extensions/suggestions/config.test.ts b/tests/extensions/suggestions/config.test.ts index 0c8c06d4..0ba10468 100644 --- a/tests/extensions/suggestions/config.test.ts +++ b/tests/extensions/suggestions/config.test.ts @@ -14,6 +14,7 @@ import { } from "../../../extensions/shared/setup-config.ts"; const defaultUi = { + webTheme: "system" as const, showHeader: false, customFooter: true, footerStyle: "plain" as const, @@ -27,7 +28,7 @@ test("setup defaults to disabled next-action suggestions", () => { assert.deepEqual(parseSetupConfig(undefined), DEFAULT_SETUP_CONFIG); assert.equal( formatSetupConfig(parseSetupConfig(undefined)), - `Capability discovery: explicit\nNext-action suggestions: disabled\nWorkflows: 8 concurrent agents · 128 total calls\nUI: large header off · custom footer on · plain · ${formatFooterLines(DEFAULT_FOOTER_LINES)}\nSubagent results: compact status summary (Ctrl+O expands full output)\nBash operations: one-line activity summary (Ctrl+O restores native evidence)\nWrite/Edit operations: one-line activity summary (Ctrl+O restores native evidence)\nPost-edit command: off\nAgent role models (Subagents + Workflows): explorer inherit · implementer inherit · reviewer inherit · advisor inherit`, + `Capability discovery: explicit\nNext-action suggestions: disabled\nWorkflows: 8 concurrent agents · 128 total calls\nUI: Web theme system · large header off · custom footer on · plain · ${formatFooterLines(DEFAULT_FOOTER_LINES)}\nSubagent results: compact status summary (Ctrl+O expands full output)\nBash operations: one-line activity summary (Ctrl+O restores native evidence)\nWrite/Edit operations: one-line activity summary (Ctrl+O restores native evidence)\nPost-edit command: off\nAgent role models (Subagents + Workflows): explorer inherit · implementer inherit · reviewer inherit · advisor inherit`, ); }); @@ -59,7 +60,7 @@ test("setup config accepts suggestion models and migrates the recap key", () => }); assert.equal( formatSetupConfig(configured), - `Capability discovery: explicit\nNext-action suggestions: seal/deepseek-v4-flash · off · Right accepts\nWorkflows: 8 concurrent agents · 128 total calls\nUI: large header off · custom footer on · plain · ${formatFooterLines(DEFAULT_FOOTER_LINES)}\nSubagent results: compact status summary (Ctrl+O expands full output)\nBash operations: one-line activity summary (Ctrl+O restores native evidence)\nWrite/Edit operations: one-line activity summary (Ctrl+O restores native evidence)\nPost-edit command: off\nAgent role models (Subagents + Workflows): explorer inherit · implementer inherit · reviewer inherit · advisor inherit`, + `Capability discovery: explicit\nNext-action suggestions: seal/deepseek-v4-flash · off · Right accepts\nWorkflows: 8 concurrent agents · 128 total calls\nUI: Web theme system · large header off · custom footer on · plain · ${formatFooterLines(DEFAULT_FOOTER_LINES)}\nSubagent results: compact status summary (Ctrl+O expands full output)\nBash operations: one-line activity summary (Ctrl+O restores native evidence)\nWrite/Edit operations: one-line activity summary (Ctrl+O restores native evidence)\nPost-edit command: off\nAgent role models (Subagents + Workflows): explorer inherit · implementer inherit · reviewer inherit · advisor inherit`, ); assert.deepEqual( @@ -126,6 +127,7 @@ test("UI defaults to a compact header and one-line plain footer", () => { assert.deepEqual( parseSetupConfig({ ui: { showHeader: true, customFooter: false } }).ui, { + webTheme: "system", showHeader: true, customFooter: false, footerStyle: "plain", @@ -135,6 +137,14 @@ test("UI defaults to a compact header and one-line plain footer", () => { fileMutationDisplay: "compact", }, ); + assert.equal( + parseSetupConfig({ ui: { webTheme: "dark" } }).ui.webTheme, + "dark", + ); + assert.equal( + parseSetupConfig({ ui: { webTheme: "unexpected" } }).ui.webTheme, + "system", + ); assert.equal( parseSetupConfig({ ui: { subagentResultDisplay: "compact" } }).ui .subagentResultDisplay, diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index c35a3f88..1edbbdd0 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -65,6 +65,7 @@ const SNAPSHOT = { protocolVersion: 1, generatedAt: new Date().toISOString(), cursor: 1, + preferences: { theme: "dark" as const }, currentSessionId: "s1", workspaces: [{ path: "/tmp/ws", name: "ws", current: true }], sessions: [ @@ -357,6 +358,7 @@ async function renderApp( clearInterval, setTimeout, clearTimeout, + matchMedia: () => ({ matches: false, addEventListener() {} }), }; context.globalThis = context; vm.createContext(context as vm.Context); @@ -382,6 +384,7 @@ async function renderApp( snapshotFetches: () => snapshotFetches, readerCancellations: () => readerCancellations, context, + documentElement: documentStub.documentElement, state: vm.runInContext("state", context as vm.Context) as typeof SNAPSHOT & Record, selectSession: vm.runInContext("selectSession", context as vm.Context) as ( @@ -434,7 +437,8 @@ function deferred() { } test("app.js renders a full session without runtime errors", async () => { - const { elements } = await renderApp(); + const { documentElement, elements } = await renderApp(); + assert.equal(documentElement.dataset.theme, "dark"); const conversation = elements.get("conversation"); assert.ok(conversation, "conversation element exists"); assert.match(conversation.innerHTML, /message-row user/); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..288b986b 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -169,10 +169,8 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn assert.match(appSource, /history\.replaceState/); assert.match(appSource, /\/events\?cursor=/); assert.doesNotMatch(appSource, /localStorage|openpi\.archived-sessions/); - assert.doesNotMatch( - appSource, - /applyTheme|message-edit-input|enterMessageEdit/, - ); + assert.match(appSource, /applyThemePreference/); + assert.doesNotMatch(appSource, /message-edit-input|enterMessageEdit/); assert.doesNotMatch(appSource, /language-picker|open-settings/u); const marked = await fetch(`${launched.origin}/marked.js`); @@ -239,7 +237,7 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn ); assert.match(stylesSource, /\.workspace-delete-dialog/); assert.match(stylesSource, /\.runtime-activity \{/); - assert.doesNotMatch(stylesSource, /html\[data-theme=/); + assert.match(stylesSource, /:root\[data-theme="dark"\]/); const unauthorized = await fetch(`${launched.origin}/api/snapshot`); assert.equal(unauthorized.status, 401); @@ -251,6 +249,7 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn const snapshot = (await response.json()) as { protocolVersion: number; cursor: number; + preferences: { theme: string }; currentSessionId: string; workspaces: Array<{ path: string }>; sessions: Array<{ cwd: string; ungrouped?: boolean }>; @@ -258,6 +257,7 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn runtime: { status: string; capabilities: Record }; }; assert.equal(snapshot.protocolVersion, 1); + assert.equal(snapshot.preferences.theme, "system"); assert.ok(snapshot.cursor >= 1); assert.equal(snapshot.currentSessionId, sessionManager.getSessionId()); assert.ok(Array.isArray(snapshot.models)); diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..9e25948f 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -10,6 +10,7 @@ import { import { URL } from "node:url"; import { promisify } from "node:util"; import { subscribeWebCapabilities } from "../../extensions/shared/web-observer-registry.ts"; +import { loadSetupConfig } from "../../extensions/shared/setup-config.ts"; import { PiWebAdapter } from "../adapter/pi-adapter.ts"; import { jsonByteLength, @@ -566,6 +567,7 @@ export class WebHost { protocolVersion: WEB_PROTOCOL_VERSION, generatedAt: new Date().toISOString(), cursor, + preferences: { theme: loadSetupConfig().ui.webTheme }, ...projection, }; let finalBytes = jsonByteLength(snapshot); diff --git a/web/protocol/types.ts b/web/protocol/types.ts index a7429b6e..dd01ea3c 100644 --- a/web/protocol/types.ts +++ b/web/protocol/types.ts @@ -105,6 +105,9 @@ export interface WebSnapshot { protocolVersion: typeof WEB_PROTOCOL_VERSION; generatedAt: string; cursor: number; + preferences: { + theme: "system" | "light" | "dark"; + }; /** Absent until the browser selects or creates a real Web Session. */ currentSessionId?: string; workspaces: WebWorkspaceSummary[]; diff --git a/web/ui/app.js b/web/ui/app.js index bcff449d..542cfe52 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -30,6 +30,7 @@ const state = { snapshotGeneration: 0, livePhase: "idle", liveRetry: null, + themePreference: "system", query: "", selectedWorkspace: null, language: navigator.language?.toLowerCase().startsWith("zh") ? "zh" : "en", @@ -126,6 +127,19 @@ function applyLanguage() { } const $ = (id) => document.getElementById(id); +const supportedThemes = new Set(["system", "light", "dark"]); +const systemTheme = window.matchMedia?.("(prefers-color-scheme: dark)"); +function applyThemePreference(preference) { + state.themePreference = supportedThemes.has(preference) ? preference : "system"; + const resolved = state.themePreference === "system" + ? systemTheme?.matches ? "dark" : "light" + : state.themePreference; + document.documentElement.dataset.theme = resolved; + document.documentElement.style.colorScheme = resolved; +} +systemTheme?.addEventListener?.("change", () => { + if (state.themePreference === "system") applyThemePreference("system"); +}); const tokenStorageKey = "openpi.web.token"; const fragmentToken = new URLSearchParams(location.hash.slice(1)).get("token"); let token = fragmentToken; @@ -605,6 +619,7 @@ async function refreshSnapshot({ return false; } state.snapshot = snapshot; + applyThemePreference(snapshot.preferences?.theme); if ( state.snapshot.runtime.status !== "running" && !state.promptAdmissionPending @@ -1394,5 +1409,6 @@ $("prompt-input")?.addEventListener("keydown", (event) => { } }); +applyThemePreference("system"); applyLanguage(); void connectEvents(); diff --git a/web/ui/styles.css b/web/ui/styles.css index 0270ed9a..3abd92f6 100644 --- a/web/ui/styles.css +++ b/web/ui/styles.css @@ -1,6 +1,8 @@ :root { color-scheme: light; --page: #ffffff; + --page-grid: #faf7ed; + --grid-line: rgba(182, 165, 119, .2); --sidebar: #faf7ed; --sidebar-hover: #f3ead0; --sidebar-active: #eee9dc; @@ -16,16 +18,84 @@ --focus: #4c88cf; --healthy: #28835a; --error: #b42318; + --surface-hover: #fafbfc; + --surface-soft: #f1f3f5; + --surface-selected: #eef1f4; + --surface-code: #f5f6f7; + --secondary-text: #39434d; + --tertiary-text: #5f6973; + --placeholder: #7f8993; + --icon-muted: #9aa2aa; + --clear-muted: #737b83; + --danger-border: #f0c6c6; + --danger: #e55353; + --danger-hover: #fff3f3; + --inverse-text: #ffffff; + --warm-border: #d8cfae; + --reconnecting: #9a6700; + --input-surface: rgba(255, 255, 255, .48); + --hover-overlay: rgba(255, 255, 255, .72); + --header-surface: rgba(251, 250, 245, .76); + --details-hover: rgba(238, 241, 244, .72); + --details-surface: rgba(247, 248, 249, .82); + --dock-start: rgba(250, 247, 237, 0); + --dock-end: rgba(250, 247, 237, .94); + --link-underline: rgba(40, 103, 178, .35); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --page: #17191c; + --page-grid: #111315; + --grid-line: rgba(135, 144, 153, .12); + --sidebar: #1d2024; + --sidebar-hover: #292d32; + --sidebar-active: #30353b; + --border: #353a40; + --border-strong: #48515a; + --text: #edf0f2; + --muted: #a8b0b8; + --subtle: #7f8993; + --accent: #74a9e6; + --warm-accent: #303026; + --warm-accent-hover: #3b3a2d; + --warm-accent-disabled: #252722; + --focus: #78aee9; + --healthy: #62c795; + --error: #ff8c84; + --surface-hover: #25292e; + --surface-soft: #282d32; + --surface-selected: #30363d; + --surface-code: #20242a; + --secondary-text: #cbd1d7; + --tertiary-text: #b2bac2; + --placeholder: #929ba4; + --icon-muted: #858f98; + --clear-muted: #a1aab3; + --danger-border: #704240; + --danger: #ff8c84; + --danger-hover: #382322; + --inverse-text: #111315; + --warm-border: #615d49; + --reconnecting: #e5b64f; + --input-surface: rgba(17, 19, 21, .48); + --hover-overlay: rgba(255, 255, 255, .07); + --header-surface: rgba(29, 32, 36, .8); + --details-hover: rgba(255, 255, 255, .06); + --details-surface: rgba(32, 36, 42, .88); + --dock-start: rgba(17, 19, 21, 0); + --dock-end: rgba(17, 19, 21, .94); + --link-underline: rgba(116, 169, 230, .45); } * { box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; } body { margin: 0; - background-color: #faf7ed; + background-color: var(--page-grid); background-image: - linear-gradient(rgba(182, 165, 119, .2) 1px, transparent 1px), - linear-gradient(90deg, rgba(182, 165, 119, .2) 1px, transparent 1px); + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); background-size: 24px 24px; color: var(--text); font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -75,7 +145,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); flex-direction: column; padding: 8px 10px; overflow: hidden; - border-right: 1px solid #e8ebee; + border-right: 1px solid var(--border); background: var(--sidebar); } .sidebar-brand { @@ -135,7 +205,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); color: var(--text); font-weight: 550; } -.new-session-button:hover { background: #fafbfc; border-color: #b9c2ca; } +.new-session-button:hover { background: var(--surface-hover); border-color: var(--border-strong); } .new-session-button svg { width: 17px; height: 17px; flex: 0 0 auto; stroke-width: 1.7; } .workspace-heading { @@ -158,20 +228,20 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); width: 100%; height: 32px; padding: 0 30px 0 29px; - border: 1px solid #dfe3e7; + border: 1px solid var(--border); border-radius: 16px; outline: 0; - background: rgba(255, 255, 255, .48); + background: var(--input-surface); color: var(--text); font-size: 12px; } -.session-search input::placeholder { color: #7f8993; } -.session-search input:hover { border-color: #cfd5db; } -.session-search input:focus { border-color: #cfd5db; background: var(--page); box-shadow: none; } -.session-search-icon { position: absolute; top: 9px; left: 9px; z-index: 1; width: 14px; height: 14px; color: #9aa2aa; pointer-events: none; } -.session-search-clear { position: absolute; top: 4px; right: 4px; display: grid; width: 24px; height: 24px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #737b83; } +.session-search input::placeholder { color: var(--placeholder); } +.session-search input:hover { border-color: var(--border-strong); } +.session-search input:focus { border-color: var(--border-strong); background: var(--page); box-shadow: none; } +.session-search-icon { position: absolute; top: 9px; left: 9px; z-index: 1; width: 14px; height: 14px; color: var(--icon-muted); pointer-events: none; } +.session-search-clear { position: absolute; top: 4px; right: 4px; display: grid; width: 24px; height: 24px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--clear-muted); } .session-search-clear .icon { width: 14px; height: 14px; stroke-width: 2; } -.session-search-clear:hover { background: #e9ecef; color: var(--text); } +.session-search-clear:hover { background: var(--surface-selected); color: var(--text); } .workspace-tree { min-height: 0; @@ -193,7 +263,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); gap: 4px; align-items: center; padding: 0 4px; - color: #39434d; + color: var(--secondary-text); cursor: pointer; } .workspace-button:hover, .session:hover { background: var(--sidebar-hover); } @@ -217,7 +287,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); } .workspace-toggle { width: 20px; height: 28px; } .workspace-action { width: 24px; height: 28px; } -.workspace-toggle:hover, .workspace-action:hover { background: rgba(255, 255, 255, .72); color: var(--text); } +.workspace-toggle:hover, .workspace-action:hover { background: var(--hover-overlay); color: var(--text); } .workspace-action .icon { width: 17px; height: 17px; } .workspace-row-actions { display: flex; width: 52px; align-items: center; justify-content: flex-end; gap: 2px; } .workspace-chevron { width: 12px; height: 12px; color: var(--subtle); transition: transform 140ms ease; } @@ -238,7 +308,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); gap: 8px; align-items: center; padding: 0 8px 0 29px; - color: #5f6973; + color: var(--tertiary-text); } .session.active { background: var(--sidebar-active); color: var(--text); } .session-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -288,7 +358,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); background: transparent; text-align: left; } -.workspace-menu button:hover { background: #f1f3f5; } +.workspace-menu button:hover { background: var(--surface-soft); } .workspace-menu .danger-action { color: var(--error); } .workspace-delete-dialog { width: min(380px, calc(100% - 32px)); padding: 0; border: 0; border-radius: 24px; background: var(--page); box-shadow: 0 20px 60px rgba(31, 37, 44, .2); color: var(--text); } .workspace-delete-dialog::backdrop { background: rgba(20, 26, 32, .2); } @@ -297,8 +367,8 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); .workspace-delete-dialog p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.65; } .workspace-delete-dialog .dialog-actions { margin-top: 6px; } .workspace-delete-dialog .dialog-actions button { min-width: 64px; height: 36px; border-radius: 18px; } -.workspace-delete-dialog .dialog-actions .danger-action { border-color: #f0c6c6; color: #e55353; background: transparent; } -.workspace-delete-dialog .dialog-actions .danger-action:hover { background: #fff3f3; } +.workspace-delete-dialog .dialog-actions .danger-action { border-color: var(--danger-border); color: var(--danger); background: transparent; } +.workspace-delete-dialog .dialog-actions .danger-action:hover { background: var(--danger-hover); } .workspace-rename-dialog { width: min(360px, calc(100% - 32px)); padding: 0; @@ -313,7 +383,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); .workspace-rename-dialog input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 5px; color: var(--text); } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } .dialog-actions button { height: 32px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--page); } -.dialog-actions #save-workspace-name { border-color: var(--text); background: var(--text); color: #fff; } +.dialog-actions #save-workspace-name { border-color: var(--text); background: var(--text); color: var(--inverse-text); } body.sidebar-collapsed .session-sidebar { align-items: center; padding: 8px; } body.sidebar-collapsed .sidebar-brand { position: relative; width: 40px; justify-content: center; padding: 0; } body.sidebar-collapsed .brand-word, body.sidebar-collapsed .new-session-button span, @@ -355,7 +425,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } gap: 12px; padding: 0 20px; border-bottom: 1px solid var(--border); - background: rgba(251, 250, 245, .76); + background: var(--header-surface); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } @@ -367,7 +437,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .session-header small { color: var(--muted); font-size: 11px; } .connection-state { display: inline-flex; align-items: center; gap: 6px; color: var(--healthy); font-size: 11px; } .connection-state::before { width: 6px; height: 6px; border-radius: 50%; background: currentColor; content: ""; } -.connection-state.reconnecting { color: #9a6700; } +.connection-state.reconnecting { color: var(--reconnecting); } .conversation { min-height: 0; overflow-y: auto; scroll-behavior: smooth; } .landing .conversation { display: grid; place-items: center; } @@ -393,7 +463,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .message-content { min-width: 0; max-width: min(78%, 560px); } .message-row.assistant .message-content { width: 100%; max-width: none; } .message-row.assistant .message-details { width: min(760px, calc(100% - 4px)); margin-right: auto; margin-left: auto; } -.message-body { color: #252c33; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; white-space: pre-wrap; } +.message-body { color: var(--text); font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; white-space: pre-wrap; } .message-row.user .message-body { padding: 10px 14px; border-radius: 18px 18px 4px 18px; background: var(--sidebar-active); color: var(--text); } .message-body.markdown { white-space: normal; } .message-body.markdown > :first-child { margin-top: 0; } @@ -413,24 +483,24 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .message-body.markdown li + li { margin-top: 4px; } .message-body.markdown blockquote { margin: 0 0 12px; padding: 2px 14px; border-left: 3px solid var(--border-strong); color: var(--muted); } .message-body.markdown hr { margin: 18px 0; border: 0; border-top: 1px solid var(--border); } -.message-body.markdown a { color: var(--accent); text-decoration: underline; text-decoration-color: rgba(40, 103, 178, .35); text-underline-offset: 2px; } -.message-body.markdown code { padding: 2px 5px; border-radius: 4px; background: #eef1f4; color: #39434d; font: .9em ui-monospace, SFMono-Regular, Menlo, monospace; } -.message-body.markdown pre { margin: 0 0 14px; overflow-x: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; background: #f5f6f7; } +.message-body.markdown a { color: var(--accent); text-decoration: underline; text-decoration-color: var(--link-underline); text-underline-offset: 2px; } +.message-body.markdown code { padding: 2px 5px; border-radius: 4px; background: var(--surface-selected); color: var(--secondary-text); font: .9em ui-monospace, SFMono-Regular, Menlo, monospace; } +.message-body.markdown pre { margin: 0 0 14px; overflow-x: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-code); } .message-body.markdown pre code { padding: 0; background: transparent; color: var(--text); font-size: 12px; white-space: pre; } .message-body.markdown table { width: 100%; margin: 0 0 14px; border-collapse: collapse; font-size: .95em; } .message-body.markdown th, .message-body.markdown td { padding: 7px 9px; border: 1px solid var(--border); text-align: left; vertical-align: top; } -.message-body.markdown th { background: #f4f5f6; font-weight: 650; } +.message-body.markdown th { background: var(--surface-soft); font-weight: 650; } .message-body.markdown input[type="checkbox"] { margin-right: 7px; accent-color: var(--accent); } .message-details { width: min(760px, calc(100% - 48px)); margin: 4px auto; color: var(--muted); } .message-details summary { display: flex; min-height: 34px; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 7px; cursor: pointer; list-style: none; font-size: 13px; } .message-details summary::-webkit-details-marker { display: none; } -.message-details summary:hover { background: rgba(238, 241, 244, .72); } +.message-details summary:hover { background: var(--details-hover); } .details-mark { width: 8px; height: 8px; flex: 0 0 auto; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform 140ms ease; } .message-details[open] .details-mark { transform: rotate(45deg); } .details-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .assistant-detail { margin: 2px 0 4px; } .assistant-detail .details-body { max-height: 280px; } -.details-body { max-height: 360px; margin: 2px 0 0 24px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; background: rgba(247, 248, 249, .82); } +.details-body { max-height: 360px; margin: 2px 0 0 24px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--details-surface); } .tool-evidence { color: var(--muted); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; white-space: pre-wrap; } .composer-dock { @@ -439,7 +509,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } align-items: center; gap: 8px; padding: 12px 24px 22px; - background: linear-gradient(to bottom, rgba(250, 247, 237, 0), rgba(250, 247, 237, .94) 18px); + background: linear-gradient(to bottom, var(--dock-start), var(--dock-end) 18px); } .composer { position: relative; @@ -454,9 +524,9 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } background: var(--page); box-shadow: 0 8px 26px rgba(53, 78, 117, .1); } -.composer:focus-within { border-color: #d8cfae; box-shadow: 0 0 0 2px rgba(216, 207, 174, .28), 0 8px 26px rgba(86, 76, 48, .1); } +.composer:focus-within { border-color: var(--warm-border); box-shadow: 0 0 0 2px color-mix(in srgb, var(--warm-border) 28%, transparent), 0 8px 26px rgba(0, 0, 0, .14); } .composer.dormant { border-style: dashed; box-shadow: none; cursor: pointer; } -.composer.dormant:hover { border-color: #8d98a3; } +.composer.dormant:hover { border-color: var(--border-strong); } .composer.dormant textarea { cursor: pointer; } .composer textarea { width: 100%; min-width: 0; min-height: 52px; max-height: 220px; flex: 0 0 auto; padding: 0 2px; resize: none; overflow-y: hidden; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 14px; line-height: 1.55; scrollbar-width: thin; scrollbar-color: rgba(125, 132, 138, .45) transparent; } .composer textarea::placeholder { color: var(--subtle); } @@ -472,7 +542,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .model-picker-wrap { position: relative; min-width: 0; max-width: 48%; } .model-picker { display: inline-flex; min-width: 150px; max-width: 100%; height: 34px; align-items: center; justify-content: space-between; gap: 8px; margin-right: 0; padding: 0 9px 0 10px; border: 1px solid transparent; border-radius: 8px; outline: 0; background: transparent; color: var(--muted); font-size: 13px; text-align: left; } .model-picker:not(:disabled) { cursor: pointer; } -.model-picker:not(:disabled):hover, .model-picker[aria-expanded="true"] { border-color: var(--border); background: #f7f8f9; color: var(--text); } +.model-picker:not(:disabled):hover, .model-picker[aria-expanded="true"] { border-color: var(--border); background: var(--surface-hover); color: var(--text); } .model-picker:disabled { color: var(--subtle); cursor: default; } .model-picker-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .model-picker-chevron { width: 14px; height: 14px; flex: 0 0 auto; color: var(--subtle); transition: transform 140ms ease; } @@ -480,7 +550,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .model-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 8; width: 240px; max-height: 300px; overflow-y: auto; padding: 5px; border: 1px solid var(--border); border-radius: 10px; background: var(--page); box-shadow: 0 12px 28px rgba(31, 37, 44, .16); scrollbar-width: thin; scrollbar-color: rgba(125, 132, 138, .45) transparent; } .model-menu[hidden] { display: none; } .model-menu button { display: flex; width: 100%; min-height: 34px; align-items: center; justify-content: space-between; gap: 10px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text); font-size: 13px; text-align: left; } -.model-menu button:hover, .model-menu button[aria-selected="true"] { background: #eef1f4; } +.model-menu button:hover, .model-menu button[aria-selected="true"] { background: var(--surface-selected); } .model-menu-check { visibility: hidden; color: var(--text); font-size: 15px; } .model-menu button[aria-selected="true"] .model-menu-check { visibility: visible; } .workspace-tree::-webkit-scrollbar, .model-menu::-webkit-scrollbar, .composer textarea::-webkit-scrollbar { width: 6px; height: 6px; } @@ -488,7 +558,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .workspace-tree::-webkit-scrollbar-thumb, .model-menu::-webkit-scrollbar-thumb, .composer textarea::-webkit-scrollbar-thumb { border-radius: 999px; background: rgba(125, 132, 138, .45); } .workspace-picker-row { display: flex; width: min(780px, 100%); min-width: 0; align-items: center; margin: 0 auto; padding-left: 15px; } .workspace-picker { position: relative; display: inline-flex; width: fit-content; max-width: 100%; min-width: 0; height: 36px; align-items: center; justify-content: center; gap: 0; padding: 0 10px 0 0; padding-right: 28px; padding-left: 28px; border: 1px solid transparent; border-radius: 18px; background: transparent; color: var(--text); font-size: 13px; line-height: 1; text-align: center; transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease; } -.workspace-picker:hover, .workspace-picker[aria-expanded="true"] { border-color: #d8cfae; background: var(--sidebar-active); box-shadow: 0 3px 10px rgba(86, 76, 48, .08); } +.workspace-picker:hover, .workspace-picker[aria-expanded="true"] { border-color: var(--warm-border); background: var(--sidebar-active); box-shadow: 0 3px 10px rgba(0, 0, 0, .1); } .workspace-picker span { min-width: 0; overflow: hidden; text-align: center; text-overflow: ellipsis; white-space: nowrap; } .workspace-symbol { position: absolute; left: 8px; width: 15px; height: 15px; } .context-chevron { position: absolute; right: 8px; width: 13px; height: 13px; } @@ -496,8 +566,8 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .workspace-picker-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 5; width: 225px; padding: 5px; border: 1px solid var(--border); border-radius: 12px; background: var(--page); box-shadow: 0 12px 28px rgba(31, 37, 44, .16); } .workspace-picker-menu[hidden] { display: none; } .workspace-picker-menu button { display: flex; width: 100%; height: 38px; align-items: center; gap: 8px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font-size: 12px; text-align: left; } -.workspace-picker-menu button:hover { background: #f1f3f5; } -.workspace-picker-menu button[aria-selected="true"] { background: #f4f5f6; } +.workspace-picker-menu button:hover { background: var(--surface-soft); } +.workspace-picker-menu button[aria-selected="true"] { background: var(--surface-soft); } .workspace-menu-icon { width: 16px; height: 16px; flex: 0 0 auto; color: var(--muted); } .workspace-picker-menu .workspace-menu-check { visibility: hidden; margin-left: auto; font-size: 16px; } .workspace-picker-menu button[aria-selected="true"] .workspace-menu-check { visibility: visible; }