diff --git a/AGENTS.md b/AGENTS.md index 9220cc3..e7d9a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,15 +127,27 @@ interface TabDescriptor { createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null /** * 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行 - * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关, - * 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。 + * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行, + * 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于 + * 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number + * 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。 */ settings?: { toggles?: readonly { - /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */ + /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */ key: string title: string | (() => string) desc?: string | (() => string) + /** 行控件类型;缺省 'switch'(向后兼容)。 */ + type?: 'switch' | 'text' | 'number' + /** number 行的提交钳制下限。 */ + min?: number + /** number 行的提交钳制上限。 */ + max?: number + /** text 行的输入占位符。 */ + placeholder?: string + /** 输入框后的单位后缀(如 'px')。 */ + unit?: string }[] } /** 渲染函数 */ @@ -384,7 +396,7 @@ interface BetterSidebarService { } ``` -> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。 +> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。 --- @@ -484,7 +496,7 @@ function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { / function parseCsv(text: string): string[][] { /* ... */ } ``` -**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。 +**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,浏览器硬刷新即可(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。 --- @@ -494,7 +506,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮" - **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts) - **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现 -- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化) +- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化) - **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试 - **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据) - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录) diff --git a/README.md b/README.md index 2ab6547..cfe561f 100644 --- a/README.md +++ b/README.md @@ -16,22 +16,29 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 6c4293e1bec2e935031bf0e986d6ec65 +## 🆕 最近更新 + +- **终端自定义字体**:设置页终端卡片新增字体族 + 9–32px 字号设置,实时生效 +- **侧边卡片设置页重构**:分组容器卡片、计数徽标、自定义开关、加宽设置弹窗 +- **修复**:WKWebView 下底部面板展开后终端空白(xterm 零尺寸容器初始化崩溃,[#25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25)) +- **文档**:client 改动硬刷新即可生效,无需重启 DSH + ## ✨ 功能一览 -- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径 -- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿 -- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`) -- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板) -- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具 -- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取 -- **🧩 后台任务页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台任务**(当前树全部后台任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `job_output`;两击确认可强制终止) -- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠 -- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区 -- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅 -- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开) -- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑 -- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md)) -- **🌏 多语言**:界面文案跟随 DSH 的语言设置(zh/en)实时切换——Host 偏好优先于浏览器语言,词典注册进 DSH 的 i18n 命名空间;切换语言无需刷新 +- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径 +- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe) +- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,Office / 终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`) +- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开 +- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效) +- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等 +- **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止) +- **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关) +- **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开 +- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板 +- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开 +- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑 +- **🔌 服务化**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务,见 [AGENTS.md](./AGENTS.md)) +- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新 ## 🚀 安装 @@ -49,7 +56,7 @@ curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/s irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex ``` -装完**重启 DSH 并硬刷新**(Cmd/Ctrl+Shift+R)即可看到侧边栏。 +装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
指定版本 / 装完自动重启(可选) @@ -127,7 +134,7 @@ npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sideba dsh plugin --profile web add dsh-better-sidebar ``` -或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**重启 DSH 并硬刷新**(Cmd/Ctrl+Shift+R)。 +或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
@@ -159,10 +166,10 @@ dsh plugin --profile web add dsh-better-sidebar - id: better-sidebar name: 'dsh-better-sidebar' 4. 在 ~/.dsh/profiles/web 执行 pnpm install -5. 重启 DSH 并硬刷新 +5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) ``` -更新:`git pull && pnpm install && pnpm build` → 重启 DSH(仅 client 改动可硬刷新)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.10.3"` 再 `pnpm install`。 +更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.10.3"` 再 `pnpm install`。 @@ -240,3 +247,7 @@ pnpm watch # tsdown --watch ## 🖥️ 平台支持 Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。 + +## 🔗 友情链接 + +- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流 diff --git a/README_EN.md b/README_EN.md index 5f43479..a546073 100644 --- a/README_EN.md +++ b/README_EN.md @@ -16,22 +16,29 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 6c4293e1bec2e935031bf0e986d6ec65 +## 🆕 Recent Updates + +- **Custom terminal fonts**: font-family + 9–32px size settings on the terminal card, applied live +- **Side card settings redesign**: grouped container cards, count badges, custom toggles, wider settings dialog +- **Fix**: blank terminal after expanding the bottom panel on WKWebView (xterm initialized in a zero-size container, [#25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25)) +- **Docs**: client changes take effect on a hard refresh — no DSH restart needed + ## ✨ Features -- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open in the sidebar, `@file` reference at end of line into the input box, right-click to copy path -- **📝 Edit & Preview**: CodeMirror 6 multi-language highlighting + Ctrl/Cmd+S atomic save; inline preview for images / Markdown (preview/edit toggle) / HTML (sandboxed iframe preview, relative resources loadable) / PDF / Word / Excel / PPT; drafts survive tab switches -- **⚡ Client-side Lazy Loading**: heavy dependencies (Office / terminal / code editor) are chunked and loaded on demand — only ~325KB core is fetched at startup; Univer (~20MB) is fetched only when opening .xlsx, the docx viewer only when opening .docx, xterm only when opening a terminal; brief loading on first open, then instant (see `docs/plans/2026-08-12-lazy-chunks-design.md`) -- **🌐 Browser**: embedded web browsing tabs (multiple), back/forward/refresh + "Open in browser"; pages run in a **sandboxed iframe** (opaque origin: cannot access UI data or local files, rejects localhost and other local addresses); the UI shows sandbox status live and can be temporarily unlocked (red warning when off); sites that refuse embedding (X-Frame-Options) show a reason panel; http(s) links in chat/UI open in the sidebar by default (panel auto-expands when collapsed) -- **💻 Terminal**: xterm.js + node-pty real shell (max 3 UI instances per session), tab keep-alive with reconnect/replay; optionally injects 8 `terminal_*` tools for the model -- **🌿 Git Panel**: real diff + VSCode-style diff tabs, lazy-loaded history, right-click to stage/discard/commit/revert/pick -- **🧩 Background Tasks**: full agent topology of the main session, click-through to execution records, live tool-call polling, auto-expand for new subagents; background tasks shown on the same page (all background tasks in the current tree, bash/pwsh type badges + exit codes, click to view live output — auto-follows the bottom, non-consuming peek that doesn't disturb the model's `job_output`; double-click confirm to force-kill) -- **🪟 Bottom Panel**: an independent second workbench (same kind of tabs as the right sidebar) that only squeezes the middle Agent output area without covering the left/right sidebars; auto-opens a new terminal on first expand (can be disabled in the terminal card's secondary settings); one-click collapse via the top-right x -- **📱 Mobile**: at viewport < 768px (true mobile width, not the host's 1024 breakpoint) only the right sidebar shows — when entering narrow screens, bottom-panel tabs merge directly into the right sidebar's tab bar, leaving a single toggle at top-right and a full-width drawer panel; new sessions start collapsed; files/external links in chat auto-expand without squeezing the conversation area -- **🔧 Split-pane Workbench**: drag tabs to split/merge panes (cross-panel dragging supported), divider to adjust ratios; persistent button cluster at top-right (bottom bar + side pull glyph) to collapse/expand both panels; the two panels share a corner double-direction drag to resize, rAF direct-DOM writes keep it smooth -- **🔁 Session Isolation**: layout/panes/tabs/panel states persisted per session (localStorage), stale state auto-purged; chat "produced files" open in the sidebar (auto-expands panels when collapsed) -- **⚙️ Declarative Settings**: the "Side Cards" section of the settings page renders a registry-driven feature list (small card grid, highlighted = enabled), each toggleable independently; secondary settings (auto-expand subagents, terminal tools, auto-open terminal on first bottom-panel expand, sandbox switches, etc.) are edited in a native dialog via the gear button -- **🔌 Service API**: exposes the `ctx.betterSidebar` service — other plugins can register sidebar tabs and file viewers (the 7 built-in tabs + 9 viewers go through the same service, see [AGENTS.md](./AGENTS.md)) -- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching — Host preference takes priority over browser language, dictionaries registered into DSH's i18n namespace; no refresh needed +- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path +- **📝 Edit & Preview**: CodeMirror 6 editing (Ctrl/Cmd+S atomic save, drafts survive tab switches); inline preview for images / Markdown / HTML / PDF / Word / Excel / PPT (HTML in a sandboxed iframe) +- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (Office / terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`) +- **🌐 Browser**: multiple embedded web tabs, back/forward/refresh; pages run in a sandboxed iframe (no access to UI data or local files, rejects local addresses), temporarily unlockable (red warning); sites refusing embedding show a reason panel; external links open in the sidebar by default +- **💻 Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model; custom font (family + 9–32px size, applied live) +- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc. +- **🧩 Background Tasks**: main-session agent topology with click-through to execution records; background tasks on the same page (type badges + exit codes, live-output peek, force-kill) +- **🪟 Bottom Panel**: an independent second workbench that only squeezes the center output area; auto-opens a terminal on first expand (toggleable) +- **📱 Mobile**: narrow viewports (<768px) merge into a full-width drawer with the bottom panel's tabs folded into the sidebar; files/links auto-expand the panel +- **🔧 Split-pane Workbench**: drag tabs to split/merge panes (cross-panel supported), divider to adjust ratios; one-click collapse/expand both panels from the top-right buttons +- **🔁 Session Isolation**: layout / tabs / panel states persisted per session, stale state auto-purged; "produced files" open in the sidebar +- **⚙️ Declarative Settings**: the "Side Cards" settings section renders a registry-driven toggle grid, each item independently switchable; secondary settings (auto-expand, terminal tools, sandbox, etc.) edited in a native dialog via the gear button +- **🔌 Service API**: exposes `ctx.betterSidebar` — other plugins can register tabs and file viewers (the 7 built-in tabs + 9 viewers share the same service, see [AGENTS.md](./AGENTS.md)) +- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed ## 🚀 Installation @@ -49,7 +56,7 @@ curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/s irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex ``` -Then **restart DSH and hard-refresh** (Cmd/Ctrl+Shift+R) to see the sidebar. +Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
Pin a version / auto-restart (optional) @@ -127,7 +134,7 @@ The one-click script does four things, all idempotent (safe to re-run): dsh plugin --profile web add dsh-better-sidebar ``` -or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.10.3"`) and run `pnpm install`. Then restart DSH and hard-refresh (Cmd/Ctrl+Shift+R). +or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.10.3"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
@@ -162,7 +169,7 @@ To debug local changes or track the dev branch, point the dependency at a local 5. Restart DSH and hard-refresh ``` -Update: `git pull && pnpm install && pnpm build` → restart DSH (client-only changes can just hard-refresh). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.10.3"` and re-run `pnpm install`. +Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.10.3"` and re-run `pnpm install`. @@ -240,3 +247,7 @@ pnpm watch # tsdown --watch ## 🖥️ Platform Support Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT). + +## 🔗 Friends + +- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 7ba2b07..b1b4564 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -598,7 +598,7 @@ function parseCsv(text: string): string[][] { /* ... */ } 1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`; 2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`); 3. 在 profile 目录 `pnpm install`; -4. 重启 `dsh web` + 浏览器硬刷新(Cmd/Ctrl+Shift+R)。 +4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。 --- diff --git a/docs/plans/2026-08-14-terminal-font-design.md b/docs/plans/2026-08-14-terminal-font-design.md new file mode 100644 index 0000000..543ac67 --- /dev/null +++ b/docs/plans/2026-08-14-terminal-font-design.md @@ -0,0 +1,111 @@ +# 终端自定义字体(设置页二级设置)设计 + +**日期**:2026-08-14 +**状态**:已实施(本文档含实施偏差记录) +**目标版本**:v0.11.0 + +## 1. 目标 + +在 DSH 设置页「侧边卡片」分区中,终端 tab 卡片的二级设置弹窗(齿轮按钮,README 所称"终端卡片二级设置")新增两行: + +1. **终端字体**(text 输入):自定义 CSS font-family stack,如 `"JetBrains Mono", monospace`;留空 = 跟随主题等宽字体(`--ds-font-family-code` token)。 +2. **终端字号**(number 输入):9–32px,默认 13,带 `px` 后缀。 + +配置持久化到 `SidebarPrefs`(`dsh-better-sidebar` 命名空间,host 侧 PrefsSchema 校验),对**所有已打开终端实时生效**(UI tab 与 agent tab 共用 TerminalView):设置页提交 → `store.setPrefs` 通知 → xterm `options.fontFamily/fontSize` 更新 + 重排网格。 + +## 2. 非目标 + +- 不在终端视图内加菜单/按钮(用户明确选择仅设置页齿轮弹窗)。 +- 不做 per-tab / per-terminal 隔离(全局偏好,与 SidebarPrefs 现有语义一致)。 +- 不自动给字体族加引号、不做字体合法性嗅探(用户负责合法 CSS font-family;placeholder 示范格式)。 +- 不改 pty / WS / 终端连接生命周期:字体变化不重连、不重启 shell。 + +## 3. 现状回顾 + +- 终端描述符(`builtins/tabs.tsx`)的 `settings.toggles` 只支持**布尔开关**(`SidebarSettingToggle` 无类型字段),`FeatureSettingsRows`(`SideCardSection.tsx`)只渲染 Switch 行。 +- `SidebarPrefs` / `PrefsSchema` / `parsePrefs` 三处同步维护(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)。 +- `TerminalView` 硬编码 `fontSize: 13` + `fontFamily: tokenValue('--ds-font-family-code') || 内置栈`,且不响应偏好变化。 + +## 4. 设计 + +### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`) + +```ts +interface SidebarPrefs { + // … + terminalFontFamily: string // '' = 跟随主题等宽字体 + terminalFontSize: number // 9–32,默认 13 +} + +// prefs-shared.ts +export const TERMINAL_FONT_SIZE_MIN = 9 +export const TERMINAL_FONT_SIZE_MAX = 32 +export const TERMINAL_FONT_SIZE_DEFAULT = 13 +export function clampTerminalFontSize(value: number): number // round + clamp(仿 clampWidthPercent) +``` + +- `PrefsSchema`:`terminalFontFamily: z.string().default('')`、`terminalFontSize: z.number().step(1).min(9).max(32).default(13)` —— 旧文档解析自动补默认,无迁移。 +- `parsePrefs`:family 需 string 否则默认;size 需有限数并 `clampTerminalFontSize` 否则默认。 + +### 4.2 声明式 API 扩展(`client/service.ts`,向后兼容) + +```ts +type SidebarSettingToggleType = 'switch' | 'text' | 'number' + +interface SidebarSettingToggle { + key: string + title: string | (() => string) + desc?: string | (() => string) + type?: SidebarSettingToggleType // 缺省 'switch',外部插件现有用法不变 + min?: number // number 行的下限(提交时钳制) + max?: number // number 行的上限 + placeholder?: string // text 行的占位符 + unit?: string // 输入框后的单位后缀(如 'px') +} +``` + +### 4.3 终端描述符声明(`builtins/tabs.tsx`) + +`settings.toggles` 追加两行:`{ key: 'terminalFontFamily', type: 'text', placeholder: … }` 与 `{ key: 'terminalFontSize', type: 'number', min: 9, max: 32, unit: 'px' }`(标题/描述走 locales)。 + +### 4.4 设置行渲染(`SideCardSection.tsx` + CSS) + +- `FeatureSettingsRows` 按 `toggle.type` 分支:`switch` 走现有 Switch 行;`text`/`number` 走新 `TypedRow`(局部 draft state,`Input` 原语,blur/Enter 提交,number 行带 unit 后缀)。 +- `TypedRow` 的 `key` 含**已提交值**:提交失败 → prefs 回滚 → key 变化 → 行重挂载为存储值(输入中 draft 不因 key 变化被打断,因为 key 只随已提交值变)。 +- 父级 `onCommitSetting(toggle, raw): string`:number 解析+钳制(非法/空 → 走 `Number('')=0` 钳到下限,与宽度行 `commitWidth` 先例一致);text 原样;乐观 `setPrefs` → `commit` → 失败 `applyOutcome` 回滚(复用既有 `applyPref` 管道,原 `togglePref` 更名)。 + +### 4.5 终端消费(`client/terminal-font.ts` 新模块 + `TerminalView.tsx`) + +```ts +// terminal-font.ts(纯函数,可单测) +export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace' +export function resolveTerminalFont(prefs, themeFontFamily): + { fontFamily: 非空用户值 ?? themeFontFamily ?? 内置栈, fontSize: clampTerminalFontSize(prefs.terminalFontSize) } +``` + +- `TerminalView` 构造 Terminal 时用解析值;主 effect 内 `store.subscribe` 回调 diff `term.options.fontFamily/fontSize`,变化则更新 options + `fit.fit()` + `sendResize()`(网格尺寸随字体变化);cleanup 退订。懒加载 chunk 无新增依赖(store 为运行时 prop)。 + +### 4.6 i18n(`locales.ts`) + +zh/en 各新增:`settingsFontFamilyTitle/Desc/Placeholder`、`settingsFontSizeTitle/Desc/Suffix`(px)。 + +## 5. 测试 + +- `tests/unit.spec.ts`:`parsePrefs`/`loadPrefs` 新字段(合法 string 直通、越界钳制 9/32、非 number 回退、缺省默认);全等字面量补字段。 +- `tests/plugin-shape.spec.ts`:PrefsSchema 默认值 + overridden 全等。 +- `tests/builtins.spec.ts`:终端 toggles keys = 4 个,后两个 type/min/max 断言。 +- `tests/smoke.spec.ts`:settings.get 全默认字面量补字段。 +- `tests/side-card-section.spec.tsx`(SSR):text/number 行渲染(value/min/max/placeholder/unit,非 checkbox)。 +- `tests/side-card-section-rows.spec.tsx`(新,jsdom + createRoot):blur 提交回调、number 钳制、空输入钳到下限、提交失败回滚草稿(key 重挂载)。 +- `tests/terminal-font.spec.ts`(新):`resolveTerminalFont` 回退链 + `clampTerminalFontSize`。 + +## 6. 限制与取舍 + +- 外部插件的自定义 toggle key 照旧被 host PrefsSchema 校验丢弃(现有 seam 行为);`type: 'text'/'number'` 是公开 API 扩展,外部插件可用但 key 仍须是宿主 schema 字段。 +- number 输入的"非法值"在浏览器里只会以空串出现(number input 拒绝非数字字符),因此 `Number('')=0 → 钳到下限` 是唯一可达路径,与宽度行先例一致。 +- 字号实时生效依赖 xterm 的 options setter + re-fit;若未来 xterm 版本行为变化,回退方案是重建 Terminal(不推荐,会丢滚动缓冲)。 + +## 7. 实施偏差记录 + +- 计划中的"非法输入回退当前存储值"用例在实现中发现不可达(浏览器 number input 的值清洗),改为断言"空输入钳到下限"(与宽度行一致)。 +- `togglePref` 更名为 `applyPref`(它本就接受任意 patch,不只是布尔翻转)。 diff --git a/docs/plans/2026-08-14-terminal-open-when-sized-design.md b/docs/plans/2026-08-14-terminal-open-when-sized-design.md new file mode 100644 index 0000000..cc36f31 --- /dev/null +++ b/docs/plans/2026-08-14-terminal-open-when-sized-design.md @@ -0,0 +1,90 @@ +# 终端延迟初始化(零尺寸容器 xterm open 崩溃修复)设计 + +**日期**:2026-08-14 +**状态**:已实施(本文档含实施偏差记录) +**目标版本**:v0.11.x(issue #25 修复) + +## 1. 目标 + +修复 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25):WKWebView 下点击「展开底部面板」后底部面板内容空白、无终端,控制台报 `Cannot read properties of undefined (reading 'dimensions')` 与 `(reading 'handleResize')`。 + +根因:`TerminalView` 的挂载 effect 在容器可能为零尺寸时**同步**执行 `term.open(host); fit.fit()`。xterm 5.3.0 在零尺寸容器里 open 时渲染器创建失败,`RenderService._renderer.value` 保持 undefined,随后 Viewport 的 `_innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)崩溃。Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现。 + +修复:把 `term.open(host)` + `fit.fit()` 推迟到容器有实际尺寸之后再执行(xterm 在隐藏/零尺寸容器初始化的标准修法),open 恰好执行一次。 + +## 2. 非目标 + +- 不换 xterm 渲染器(打包产物只有 DomRenderer 是现状,不是本次根因;`rendererType` 保持默认)。 +- 不改 pty / WS / 终端连接生命周期:推迟 open 不推迟连接、不重启 shell、不丢 transcript。 +- 不改 host 半与 chunk 打包结构(新模块随 terminal chunk 打入 `lib/client-terminal.js`)。 +- 不 bump 版本号(release 流程负责)。 + +## 3. 现状回顾 + +- `src/client/TerminalView.tsx` 第 99–101 行同步 `term.open(host); fit.fit()`。 +- 零尺寸挂载路径真实存在且不止一条: + - 底部面板首次展开自动开终端(`Sidebar.tsx` 的 `bottomOpenedOnce` effect)在面板 slide-in transition 的同一次 commit 挂载 TerminalView,WKWebView 下此刻 host 高度为 0; + - 所有 tab 保持挂载(`split-pane.tsx`:非激活 tab 用 `.paneTabHidden { display: none }` 隐藏),面板折叠时保持挂载(`.panelHidden`)——任何 `display:none` / 零尺寸祖先都会踩同一坑。 +- 前提已验证(xterm 5.3.0 与 @xterm/addon-fit 0.10.0 源码): + - `FitAddon.fit()` 在 open 之前是安全 no-op(`proposeDimensions` 先查 `this._terminal.element`,undefined 即返回)。 + - `WriteBuffer.write()` 不依赖 open 状态:数据解析进 buffer,open 后渲染——推迟 open 不丢初始 transcript。 + - `TerminalView` 已有的 ResizeObserver / 字体订阅里的 `fit.fit()` 在 open 前 no-op、open 后照常 re-fit,无需改动。 + +## 4. 设计 + +### 4.1 新模块 `src/client/open-when-sized.ts`(纯逻辑,可单测) + +```ts +export function openWhenSized( + host: HTMLElement, + open: () => void, + raf: (cb: FrameRequestCallback) => number = requestAnimationFrame, + caf: (id: number) => void = cancelAnimationFrame, +): () => void +``` + +- 单条代码路径:`raf(step)` → step 检查 `host.isConnected`(false 则停止,防卸载后空转)→ `clientWidth > 0 && clientHeight > 0` 则调 `open()` 并停止;否则再排一帧。 +- 返回 cancel:`caf(frame)` 并置空(幂等),cleanup 调用。 +- helper 不 try/catch(保持纯净),异常由调用方处理。 + +### 4.2 消费点 `src/client/TerminalView.tsx` + +- 移除 effect 顶部的 `term.open(host); fit.fit()`(保留 `term.loadAddon(fit)`)。 +- 在 effect 尾部(`sendResize` / `connect` 定义之后、`connect()` 调用之前)注册: + +```ts +const cancelOpen = openWhenSized(host, () => { + try { + term.open(host) + fit.fit() + sendResize() + } catch (error) { + console.error('[dsh-better-sidebar] xterm open failed:', error) + } +}) +``` + +- 回调里补 `sendResize()`:推迟路径下 socket 可能已先连上(`connect()` 在 effect 尾部同步调用),open+fit 后必须把真实 cols/rows 发出去,否则 pty 停留在默认 80x24 直到下次 resize;已尺寸快速路径下 socket 尚未 open,`sendResize` 内部守卫(`readyState === OPEN`)自然 no-op,无行为变化。 +- cleanup 里在 `term.dispose()` 前加 `cancelOpen()`。 +- 其余(connect / observer / fontSub / schemeSub / onData / 主题)不动。 + +## 5. 测试 + +`tests/open-when-sized.spec.ts`(jsdom + 注入手动步进的假 raf/caf;`Object.defineProperty` 桩 `clientWidth/clientHeight`;`appendChild/remove` 控制 `isConnected`): + +1. 已尺寸 → tick 一次后 open 恰好一次,之后不再排帧; +2. 零尺寸 → 多次 tick 不 open,设尺寸后下一次 tick open 一次,后续 tick 不重复; +3. 只有一维缺失 → 补齐后 open; +4. cancel 后永不 open、无残留帧、cancel 幂等; +5. host 脱离文档 → 轮询停止、不 open; +6. open 抛错 → 异常向调用方传播(调用方负责 try/catch),不重复调用。 + +## 6. 限制与取舍 + +- rAF 轮询在 host 长期隐藏(`display:none` 祖先)期间持续排帧,每帧只读两个属性,开销可忽略;open 后立即停止。用 ResizeObserver 触发也可行,但轮询是 issue 中建议的标准修法,且不依赖 observer 对 transition 中间态的回调时序。 +- 已尺寸快速路径比原行为晚一帧(rAF 回调时机),无感知差异。 +- 若容器永远零尺寸(异常布局),终端永不 open、不崩溃——对不可见内容是可接受的降级。 + +## 7. 实施偏差记录 + +无(按设计实施)。 diff --git a/dsh.plugin.json b/dsh.plugin.json index 8eb53df..9cb7c41 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.10.3", + "version": "0.11.0", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index 7e76dca..4bbc496 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.10.3", + "version": "0.11.0", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 2517744..ed858c7 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -1,20 +1,28 @@ /** * Side card settings section chrome (DSH-native tokens throughout): + * - the section is a column of GROUP CONTAINER cards (the PluginCard recipe: + * l2 hairline, 16px radius, layer-3 fill) with a heading + inventory count + * badge (the settings catalogHeading recipe), opening with a one-line + * intro (the DSH section heading+intro recipe); + * - 常规 holds full-width settings rows (the DSH settings-row recipe: + * title/desc left + control right, hairline separators) whose toggles are + * custom switches — a real checkbox (native semantics and focus) driving + * a styled track/thumb; * - the feature inventories are responsive GRIDS of small toggle cards - * (auto-fill columns — several cards per row, wrapping); - * - a card's on/off state IS its visual state: enabled = brand border + - * tinted fill + a check badge pinned to the far right of the top row, - * disabled = neutral hairline + dimmed text; + * (auto-fill columns — several cards per row, wrapping): icon chip + + * title + type id, the card's on/off state IS its visual state + * (enabled = brand border + tinted fill + circular check badge, + * disabled = neutral hairline + dimmed); * - features that declare related settings carry a gear corner button - * opening a native Modal whose rows are the DSH settings-row recipe - * (title/desc left + native accent checkbox, hairline separators); + * opening a native Modal (wider than the primitive default) whose rows + * follow the same row recipe with switch controls and a Done footer; * - the width input stays a native settings row (a value, not a switch). */ .section { display: flex; flex-direction: column; - gap: 8px; + gap: 14px; width: 100%; /* Tall inventories (7 tab cards + 9 viewer cards + general rows) must never be clipped by the settings shell: fill the shell's options area @@ -24,15 +32,55 @@ overflow-y: auto; } -.section > :last-child { - border-bottom: none; +/* One-line section intro (the DSH section heading+intro recipe). */ +.intro { + margin: 0; + padding: 0 2px; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +/* Group container: the DSH card recipe (PluginCard) — l2 hairline, r16, + layer-3 fill; holds its own heading + stacked rows / card grid. */ +.group { + flex: none; + display: flex; + flex-direction: column; + gap: 8px; + padding: 18px 20px 20px; + box-sizing: border-box; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: var(--dsw-alias-bg-layer-3); +} + +/* Group heading: 13/600 + the inventory count badge (the settings + catalogHeading recipe: count in tabular-nums, tertiary). */ +.groupHeading { + display: flex; + align-items: baseline; + gap: 7px; + padding: 0 2px 6px; + font-size: 13px; + line-height: 20px; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.count { + font-size: 12px; + line-height: 18px; + font-weight: 400; + color: var(--dsw-alias-label-tertiary); + font-variant-numeric: tabular-nums; } /* Responsive inventory: as many small cards per row as the width allows. */ .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + gap: 10px; } /* One small toggle card (visual chrome; the interactive part is .cardMain). */ @@ -40,7 +88,7 @@ position: relative; display: flex; flex-direction: column; - border-radius: 8px; + border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2); background: transparent; font: inherit; @@ -52,6 +100,7 @@ /* Hover affordance on disabled cards only (enabled cards are already lit). */ .card:not(.cardOn):hover { background: var(--dsw-alias-interactive-bg-hover); + border-color: var(--dsw-alias-label-dimmed); } /* Enabled = highlighted: brand border + tinted fill. */ @@ -64,9 +113,9 @@ .cardMain { display: flex; flex-direction: column; - gap: 2px; + gap: 6px; width: 100%; - padding: 10px 12px; + padding: 12px; border: 0; border-radius: inherit; background: transparent; @@ -85,18 +134,28 @@ .cardTop { display: flex; align-items: center; - gap: 6px; + gap: 8px; min-width: 0; } -.cardIcon { +/* The icon sits in a rounded chip: brand-tinted while the card is on, + neutral (layer-2 fill + hairline) while off. */ +.cardIconChip { display: inline-flex; align-items: center; + justify-content: center; flex: none; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-tertiary); } -.cardOn .cardIcon { +.cardOn .cardIconChip { + border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent); + background: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 12%, transparent); color: var(--dsw-alias-button-primary-fill); } @@ -105,6 +164,7 @@ min-width: 0; font-size: 13px; line-height: 20px; + font-weight: 600; color: var(--dsw-alias-label-secondary); white-space: nowrap; overflow: hidden; @@ -115,12 +175,18 @@ color: var(--dsw-alias-label-primary); } -/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */ +/* The check badge: a circular brand-filled pin at the card's far right + (enabled only), the icon in the inverted surface token (the .save recipe). */ .cardCheck { display: inline-flex; align-items: center; + justify-content: center; flex: none; - color: var(--dsw-alias-button-primary-fill); + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-bg-layer-3); } .cardDesc { @@ -136,24 +202,28 @@ color: var(--dsw-alias-label-secondary); } -/* The gear corner button: bottom-right of cards that declare related - settings; the desc gets right padding so the ellipsis never runs under it. */ +/* Cards with a settings gear keep the desc clear of it: the gear sits in + the desc row, sharing the check badge's right edge (a vertical column of + controls at the card's right side). */ .cardWithGear .cardDesc { - padding-right: 18px; + padding-right: 30px; } +/* The gear corner button: directly UNDER the check badge on the card's + right edge — the two controls are vertically aligned (same right inset, + one row apart) and the SAME 16px size, not side by side. */ .cardGear { position: absolute; - right: 6px; - bottom: 6px; + right: 12px; + top: 46px; display: inline-flex; align-items: center; justify-content: center; - width: 20px; - height: 20px; + width: 16px; + height: 16px; padding: 0; border: 0; - border-radius: 6px; + border-radius: 50%; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; @@ -164,26 +234,21 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */ -.sectionHeading { - padding: 14px 0 0; - font-size: 12px; - line-height: 17px; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--dsw-alias-label-secondary); -} - -/* Native settings row recipe (the width input row + popup rows). */ +/* Settings-row recipe (the DSH General section rows): title/desc left, + control right, hairline separators (stripped on the last row). */ .row { display: flex; align-items: center; justify-content: space-between; gap: 16px; - padding: 12px 0; + padding: 12px 2px; border-bottom: 1px solid var(--dsw-alias-border-l2); } +.row:last-child { + border-bottom: none; +} + .rowText { display: flex; flex-direction: column; @@ -199,45 +264,66 @@ .desc { font-size: 12px; - line-height: 17px; - color: var(--dsw-alias-label-secondary); + line-height: 18px; + color: var(--dsw-alias-label-tertiary); } -/* Native checkbox: 16px, brand accent (the settings-row recipe). */ -.toggle { +/* Custom switch: a real checkbox (native semantics + focus, visually + hidden) driving a styled 36x20 track with a sliding thumb. */ +.switch { + position: relative; + display: inline-flex; flex: none; - width: 16px; - height: 16px; + cursor: pointer; +} + +.switchInput { + position: absolute; + width: 1px; + height: 1px; margin: 0; - accent-color: var(--dsw-alias-button-primary-fill); - cursor: pointer; + opacity: 0; } -.toggle:focus-visible { - outline: 2px solid var(--dsw-alias-border-l4); - outline-offset: 2px; -} - -/* Secondary settings popup body: stacked native rows, trailing separator - stripped by the trailing-child rule. */ -.popupRows { - display: flex; - flex-direction: column; - width: 100%; -} - -.popupRow { - display: flex; +.switchTrack { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 16px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); - cursor: pointer; + width: 36px; + height: 20px; + padding: 2px; + box-sizing: border-box; + border-radius: 10px; + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); + transition: background 0.15s ease, border-color 0.15s ease; } -.popupRows > :last-child { - border-bottom: none; +.switchThumb { + display: block; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--dsw-alias-label-secondary); + transition: transform 0.15s ease, background 0.15s ease; +} + +.switch:hover .switchTrack { + border-color: var(--dsw-alias-label-dimmed); +} + +.switchInput:checked + .switchTrack { + border-color: var(--dsw-alias-button-primary-fill); + background: var(--dsw-alias-button-primary-fill); +} + +.switchInput:checked + .switchTrack .switchThumb { + transform: translateX(16px); + background: var(--dsw-alias-bg-layer-3); +} + +.switchInput:focus-visible + .switchTrack { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; } .control { @@ -251,12 +337,74 @@ width: 76px; } +/* Text/number rows in the settings popup: the free-form input (e.g. the + terminal font family) gets a generous width; numeric rows (font size) + keep the compact width of the general number inputs. */ +.typedInput { + width: 200px; +} + +.typedInputNumber { + width: 76px; +} + .suffix { font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-secondary); } +/* Secondary settings popup: a wider dialog than the primitive default (the + dialog portals to document.body; the doubled class keeps the width rule + above the primitive's .dialog regardless of stylesheet order). */ +.popupDialog.popupDialog { + width: min(460px, 100%); +} + +/* Popup rows: the same settings-row recipe with switch controls. */ +.popupRows { + display: flex; + flex-direction: column; + width: 100%; +} + +.popupRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 2px; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.popupRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.popupRows > :last-child { + border-bottom: none; +} + +/* The popup's Done button: the settings primary-button recipe + (PluginCard .save: label-primary fill, inverted surface text). */ +.done { + appearance: none; + border: 1px solid transparent; + border-radius: 8px; + padding: 5px 14px; + font: inherit; + font-size: 13px; + line-height: 1.5; + cursor: pointer; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.done:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + .error { padding: 10px 0 2px; font-size: 12px; @@ -265,7 +413,9 @@ } @media (prefers-reduced-motion: reduce) { - .card { + .card, + .switchTrack, + .switchThumb { transition: none; } } diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index fb159ac..2ec4dd0 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -4,21 +4,30 @@ * * The section is DECLARATIVE — it renders the enable/disable inventory from * the sidebar service's registries instead of hardcoding rows: - * - 常规: new conversations open the panel by default (a toggle card), and - * the default panel width as a percent of the window (number input row). + * - 常规: new conversations open the panel by default (a toggle row), the + * default panel width as a percent of the window (number input row), and + * the open-path interception toggle — the DSH settings-row recipe + * (title/desc left + control right, hairline separators). * - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and * external plugins alike), laid out in a responsive grid that wraps - * several cards per row — icon + title + type id, clicked to toggle the - * switch persisted in `prefs.tabsEnabled[id]`. - * - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title + - * the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`. + * several cards per row — icon chip + title + type id, clicked to toggle + * the switch persisted in `prefs.tabsEnabled[id]`. + * - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title + * + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`. + * + * Every group lives in a container card (the DSH PluginCard recipe: l2 + * hairline, 16px radius, layer-3 fill) with a heading and an inventory count + * badge (the settings catalogHeading recipe); the section opens with a + * one-line intro (the DSH section heading+intro recipe). * * A card's on/off state is its VISUAL STATE: enabled = highlighted (brand - * border + tinted fill + a check badge pinned to the card's far right), - * disabled = neutral and dimmed. Features that declare `settings.toggles` - * carry a gear corner button that opens a native Modal with the related - * settings as native checkbox rows (e.g. the Subagent page's "auto-open - * when a subagent appears", the Terminal page's model terminal tools). + * border + tinted fill + a circular check badge pinned to the card's far + * right), disabled = neutral and dimmed. Features that declare + * `settings.toggles` carry a gear corner button that opens a native Modal + * (wider than the primitive default) with the related settings as + * title/desc + custom-switch rows and a Done footer. The toggles + * themselves are custom switches: a real checkbox (native semantics and + * focus) driving a styled track/thumb. * * Writes ride the plugin's own fenced settings route (the host calls the * settings seam in-process — the DSH settings RPC domain does not serve @@ -32,8 +41,6 @@ import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' import { IconCheckOutline16, - IconCodeOutline16, - IconPanelLeftOutline16, IconSettingsOutline16, Input, Modal, @@ -106,40 +113,133 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean { } /** - * The body of a feature's secondary settings popup: one native checkbox row - * per declared toggle. Extracted so the rows are testable without opening - * the Modal (the Modal portal renders only while open). + * The custom switch: a real checkbox (hidden, native semantics and focus) + * driving a styled track/thumb. Used by the general toggle rows and the + * secondary settings popup rows. + */ +function Switch(props: { + checked: boolean + onChange: (next: boolean) => void + label: string +}) { + const { checked, onChange, label } = props + return ( + + ) +} + +/** + * The body of a feature's secondary settings popup: one row (title/desc + + * control) per declared setting. Switches render the custom switch; text and + * number rows render a free-form / numeric input committed on blur/Enter + * (clamped to the declared min/max). Extracted so the rows are testable + * without opening the Modal (the Modal portal renders only while open). */ export function FeatureSettingsRows(props: { toggles: readonly SidebarSettingToggle[] prefs: SidebarPrefs onToggle: (toggle: SidebarSettingToggle, next: boolean) => void + /** Commit one text/number row; returns the canonical value the row should + * display (clamped for numbers, the current pref when the input is + * invalid). Optional: rows with no handler keep their draft. */ + onCommit?: (toggle: SidebarSettingToggle, raw: string) => string }) { - const { toggles, prefs, onToggle } = props + const { toggles, prefs, onToggle, onCommit } = props return (
{toggles.map(toggle => { const title = textOf(toggle.title) + if ((toggle.type ?? 'switch') === 'switch') { + return ( +
+ + {title} + {textOf(toggle.desc) !== '' && {textOf(toggle.desc)}} + + { onToggle(toggle, next) }} + /> +
+ ) + } + const value = String((prefs as unknown as Record)[toggle.key] ?? '') + // Keyed by the committed value: a failed commit reverts prefs, the + // key changes, and the row remounts with the stored value (typing + // never changes the key, so mid-edit drafts survive re-renders). return ( - + ) })}
) } +/** + * One text/number row: a controlled input whose draft is local state, + * committed on blur/Enter through the parent's onCommit. The parent's + * canonical return is adopted (clamped numbers, stored value for invalid + * input); a `unit` suffix renders after the input (e.g. 'px'). + */ +function TypedRow(props: { + toggle: SidebarSettingToggle + title: string + value: string + onCommit?: (toggle: SidebarSettingToggle, raw: string) => string +}) { + const { toggle, title, value, onCommit } = props + const [draft, setDraft] = useState(value) + const commit = (): void => { + const canonical = onCommit?.(toggle, draft) ?? draft + setDraft(canonical) + } + const number = toggle.type === 'number' + return ( +
+ + {title} + {textOf(toggle.desc) !== '' && {textOf(toggle.desc)}} + + + { setDraft(event.currentTarget.value) }} + onBlur={commit} + onKeyDown={event => { + if (event.key === 'Enter') event.currentTarget.blur() + }} + /> + {toggle.unit !== undefined && {toggle.unit}} + +
+ ) +} + /** * Render the Side card preferences section. * @param props - composed slot props (runtime share + injected store/service). @@ -219,8 +319,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { setWidthDraft(String(settled.defaultWidthPercent)) } - /** Optimistically flip one boolean pref, then commit (revert on failure). */ - const togglePref = (patch: Record): void => { + /** Optimistically apply one pref patch, then commit (revert on failure). */ + const applyPref = (patch: Record): void => { const previous = prefs setPrefs({ ...previous, ...patch } as SidebarPrefs) setError(null) @@ -228,22 +328,44 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { } const onToggle = (next: boolean): void => { - togglePref({ openByDefault: next }) + applyPref({ openByDefault: next }) } /** Flip one per-tab enable switch (merge into the tabsEnabled map). */ const onToggleTab = (id: string, next: boolean): void => { - togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } }) + applyPref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } }) } /** Flip one per-viewer enable switch (merge into the viewersEnabled map). */ const onToggleViewer = (id: string, next: boolean): void => { - togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } }) + applyPref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } }) } /** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */ const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => { - togglePref({ [toggle.key]: next }) + applyPref({ [toggle.key]: next }) + } + + /** + * Commit one declaratively-declared text/number row. Numbers are parsed + * and clamped to the toggle's declared min/max (an unparsable input falls + * back to the CURRENT stored value, mirroring the width row); text rows + * persist as-is (empty is meaningful, e.g. the theme-default font). + * Returns the canonical value the row should display. + */ + const onCommitSetting = (toggle: SidebarSettingToggle, raw: string): string => { + if (toggle.type === 'number') { + const parsed = Number(raw) + const fallback = String((prefs as unknown as Record)[toggle.key] ?? '') + if (!Number.isFinite(parsed)) return fallback + let clamped = Math.round(parsed) + if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped) + if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped) + applyPref({ [toggle.key]: clamped }) + return String(clamped) + } + applyPref({ [toggle.key]: raw }) + return raw } const commitWidth = (): void => { @@ -262,9 +384,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { /** * One SMALL toggle card for the responsive inventory grid: the card's main - * area is the switch (click to flips, visual state IS the state), the - * check badge sits at the far right, and a feature that declares related - * settings carries a gear corner button opening its settings popup. + * area is the switch (click to flips, visual state IS the state), the icon + * sits in a rounded chip, the check badge pins to the far right, and a + * feature that declares related settings carries a gear corner button + * opening its settings popup. */ const renderCard = (props: { title: string @@ -289,12 +412,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { > {props.icon !== null && props.icon !== undefined && ( - {props.icon} + {props.icon} )} {props.title} {props.enabled && ( - + )} @@ -308,7 +431,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { title={t('settingsPopup')} onClick={props.onOpenSettings} > - + )} @@ -317,101 +440,133 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { return (
-
{t('settingsGeneralTitle')}
- {renderCard({ - title: t('settingsOpenTitle'), - desc: t('settingsOpenDesc'), - icon: , - enabled: prefs.openByDefault, - onToggle, - })} -
- - {t('settingsWidthTitle')} - {t('settingsWidthDesc')} - - - { setWidthDraft(event.currentTarget.value) }} - onBlur={commitWidth} - onKeyDown={event => { - if (event.key === 'Enter') event.currentTarget.blur() - }} +

{t('settingsIntro')}

+ + {/* 常规: the DSH settings-row recipe — title/desc left, control right. */} +
+
{t('settingsGeneralTitle')}
+
+ + {t('settingsOpenTitle')} + {t('settingsOpenDesc')} + + - {t('settingsWidthSuffix')} - +
+
+ + {t('settingsWidthTitle')} + {t('settingsWidthDesc')} + + + { setWidthDraft(event.currentTarget.value) }} + onBlur={commitWidth} + onKeyDown={event => { + if (event.key === 'Enter') event.currentTarget.blur() + }} + /> + {t('settingsWidthSuffix')} + +
+
+ + {t('settingsOpenPathTitle')} + {t('settingsOpenPathDesc')} + + { applyPref({ interceptOpenPath: next }) }} + /> +
- {renderCard({ - title: t('settingsOpenPathTitle'), - desc: t('settingsOpenPathDesc'), - icon: , - enabled: prefs.interceptOpenPath, - onToggle: (next) => { togglePref({ interceptOpenPath: next }) }, - })} {/* 侧边栏内容: one small card per registered tab type in a responsive grid; features declaring `settings.toggles` open their settings in the popup (gear corner button) instead of nested inline rows. */} -
{t('settingsTabsTitle')}
-
- {tabs.map(tab => ( - - {renderCard({ - title: textOf(tab.title), - desc: tab.id, - icon: iconOf(tab.icon, 16), - enabled: prefs.tabsEnabled[tab.id] !== false, - onToggle: (next) => { onToggleTab(tab.id, next) }, - // The settings gear only while the feature is enabled: its - // related settings are dormant while the feature is off. - onOpenSettings: prefs.tabsEnabled[tab.id] !== false - && (tab.settings?.toggles?.length ?? 0) > 0 - ? () => { setSettingsFor(tab) } - : undefined, - })} - - ))} +
+
+ {t('settingsTabsTitle')} + {tabs.length} +
+
+ {tabs.map(tab => ( + + {renderCard({ + title: textOf(tab.title), + desc: tab.id, + icon: iconOf(tab.icon, 16), + enabled: prefs.tabsEnabled[tab.id] !== false, + onToggle: (next) => { onToggleTab(tab.id, next) }, + // The settings gear only while the feature is enabled: its + // related settings are dormant while the feature is off. + onOpenSettings: prefs.tabsEnabled[tab.id] !== false + && (tab.settings?.toggles?.length ?? 0) > 0 + ? () => { setSettingsFor(tab) } + : undefined, + })} + + ))} +
{/* 文件预览: one small card per registered file viewer. */} -
{t('settingsViewersTitle')}
-
- {viewers.map(viewer => ( - - {renderCard({ - title: textOf(viewer.title) || viewer.id, - desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '), - icon: iconOf(viewer.icon, 16), - enabled: prefs.viewersEnabled[viewer.id] !== false, - onToggle: (next) => { onToggleViewer(viewer.id, next) }, - })} - - ))} +
+
+ {t('settingsViewersTitle')} + {viewers.length} +
+
+ {viewers.map(viewer => ( + + {renderCard({ + title: textOf(viewer.title) || viewer.id, + desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '), + icon: iconOf(viewer.icon, 16), + enabled: prefs.viewersEnabled[viewer.id] !== false, + onToggle: (next) => { onToggleViewer(viewer.id, next) }, + })} + + ))} +
{/* The secondary settings popup: a feature's declared related settings - as native checkbox rows (Modal chrome is the app's own). Mounted - only while a feature is open — the Modal primitive runs hooks - unconditionally, so a closed-but-mounted Modal would break SSR - (and the renderToString spec) under the test dual-react split. */} + as title/desc + switch rows in a wider-than-default Modal with a + Done footer (Modal chrome is the app's own). Mounted only while a + feature is open — the Modal primitive runs hooks unconditionally, + so a closed-but-mounted Modal would break SSR (and the + renderToString spec) under the test dual-react split. */} {settingsFor !== null && ( { setSettingsFor(null) }} title={textOf(settingsFor.title)} + description={t('settingsPopupDesc', { feature: textOf(settingsFor.title) })} closeLabel={t('close')} + className={css.popupDialog} + footer={( + + )} > )} diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index 5990ad4..d4ec208 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -24,9 +24,11 @@ import { Terminal, type ITheme } from 'xterm' import { FitAddon } from '@xterm/addon-fit' import 'xterm/css/xterm.css' import { t } from './locales.ts' +import { openWhenSized } from './open-when-sized.ts' import type { SessionScope } from './api.ts' import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts' +import { resolveTerminalFont } from './terminal-font.ts' import css from './sidebar.module.css' /** How many consecutive unreasoned failures before showing the error banner. */ @@ -82,10 +84,13 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: useEffect(() => { const host = hostRef.current if (host === null) return + // The custom font prefs (side card settings, terminal card) resolve at + // mount; store changes re-apply them live below. + const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code')) const term = new Terminal({ cursorBlink: true, - fontSize: 13, - fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + fontSize: font.fontSize, + fontFamily: font.fontFamily, allowTransparency: true, convertEol: false, scrollback: 4000, @@ -93,8 +98,6 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: }) const fit = new FitAddon() term.loadAddon(fit) - term.open(host) - fit.fit() // Re-theme in place when the app's scheme flips (tokens + palette). const applyTheme = (): void => { term.options.theme = xtermTheme() @@ -185,11 +188,52 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: }) observer.observe(host) + // Custom font prefs (the terminal card's secondary settings) apply LIVE: + // on any store change re-resolve and diff the two options, re-fitting + // when they moved (the grid dimensions may change with the font). The + // subscribe fires on every store change (tabs, panels…), so the diff is + // what keeps this cheap. + const fontSub = store.subscribe(() => { + const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code')) + if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) { + term.options.fontFamily = next.fontFamily + term.options.fontSize = next.fontSize + try { + fit.fit() + sendResize() + } catch { + // The terminal may be mid-dispose; ignore. + } + } + }) + + // The terminal must not be opened in a zero-size container: xterm's + // renderer creation fails there and the next Viewport refresh crashes + // reading `.dimensions` off the undefined renderer (blank terminal on + // WKWebView when the bottom panel's expand slide leaves the host at + // height 0; any display:none-hidden ancestor does the same). Defer + // open+fit until the host has a real size — writes arriving meanwhile + // are buffered by xterm's WriteBuffer and render once open, and + // FitAddon.fit() is a safe no-op before open. sendResize() here covers + // the deferred path where the socket may already be open with the + // default 80x24 dims. + const cancelOpen = openWhenSized(host, () => { + try { + term.open(host) + fit.fit() + sendResize() + } catch (error) { + console.error('[dsh-better-sidebar] xterm open failed:', error) + } + }) + connect() return () => { closed = true + cancelOpen() window.clearTimeout(retry) observer.disconnect() + fontSub() schemeSub() inputSub.dispose() // The close frame tells the host the owning tab is GONE (immediate diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index dac51c6..943d593 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -19,6 +19,7 @@ import { DiffTab } from '../DiffTab.tsx' import { SubagentView } from '../SubagentView.tsx' import { BrowserView } from '../BrowserView.tsx' import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx' +import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts' import type { ComponentType } from 'react' import type { SessionScope } from '../api.ts' import type { SidebarStore } from '../state.ts' @@ -137,10 +138,11 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { icon: (size: number) => , order: 40, available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT, - // Declarative settings: the model-facing terminal tools switch and the - // bottom-panel first-expansion auto-terminal switch render under this - // row in the Side card settings page (the Terminal page's own related - // settings; the host gates the toolset on the tools one independently). + // Declarative settings: the model-facing terminal tools switch, the + // bottom-panel first-expansion auto-terminal switch, and the custom + // font family/size rows render under this card in the Side card + // settings page (the host gates the toolset on the tools one + // independently; the font rows apply live to every terminal). settings: { toggles: [{ key: 'agentTerminalTools', @@ -150,6 +152,20 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { key: 'bottomPanelAutoTerminal', title: () => t('settingsBottomTerminalTitle'), desc: () => t('settingsBottomTerminalDesc'), + }, { + key: 'terminalFontFamily', + type: 'text', + title: () => t('settingsFontFamilyTitle'), + desc: () => t('settingsFontFamilyDesc'), + placeholder: t('settingsFontFamilyPlaceholder'), + }, { + key: 'terminalFontSize', + type: 'number', + title: () => t('settingsFontSizeTitle'), + desc: () => t('settingsFontSizeDesc'), + min: TERMINAL_FONT_SIZE_MIN, + max: TERMINAL_FONT_SIZE_MAX, + unit: 'px', }], }, createTab: (state) => { diff --git a/src/client/locales.ts b/src/client/locales.ts index 49cd471..18d974d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -106,6 +106,9 @@ export const zh = { copyAbsolute: '复制绝对地址', download: '下载', settingsNav: '侧边卡片', + settingsIntro: '管理侧边卡片的显示内容与默认行为', + settingsPopupDesc: '为「{feature}」配置相关选项', + settingsDone: '完成', settingsOpenTitle: '新会话默认打开', settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局', settingsWidthTitle: '默认宽度占比', @@ -133,6 +136,12 @@ export const zh = { settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', settingsBottomTerminalTitle: '底部面板首次展开自动开终端', settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)', + settingsFontFamilyTitle: '终端字体', + settingsFontFamilyDesc: '自定义终端字体族(CSS font-family,如 "JetBrains Mono", monospace;留空跟随主题等宽字体)', + settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace', + settingsFontSizeTitle: '终端字号', + settingsFontSizeDesc: '终端字号(9–32,默认 13)', + settingsFontSizeSuffix: 'px', settingsTabsTitle: '侧边栏内容', settingsViewersTitle: '文件预览', settingsGeneralTitle: '常规', @@ -307,6 +316,9 @@ export const en: Record = { copyAbsolute: 'Copy absolute path', download: 'Download', settingsNav: 'Side card', + settingsIntro: 'Manage what the side card shows and how it behaves', + settingsPopupDesc: 'Configure related options for {feature}', + settingsDone: 'Done', settingsOpenTitle: 'Open by default for new conversations', settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts', settingsWidthTitle: 'Default width share', @@ -334,6 +346,12 @@ export const en: Record = { settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)', settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion', settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)', + settingsFontFamilyTitle: 'Terminal font family', + settingsFontFamilyDesc: 'Custom terminal font family (a CSS font-family stack like "JetBrains Mono", monospace; leave empty to follow the theme\'s monospace font)', + settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace', + settingsFontSizeTitle: 'Terminal font size', + settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)', + settingsFontSizeSuffix: 'px', settingsTabsTitle: 'Sidebar content', settingsViewersTitle: 'File viewers', settingsGeneralTitle: 'General', diff --git a/src/client/open-when-sized.ts b/src/client/open-when-sized.ts new file mode 100644 index 0000000..481b554 --- /dev/null +++ b/src/client/open-when-sized.ts @@ -0,0 +1,43 @@ +/** + * Deferred one-shot open for hosts that may not have a real size yet. + * + * xterm's `Terminal.open()` must not run in a zero-size container: the + * renderer creation fails there (the DomRenderer is built from the host's + * dimensions), leaving the render service's renderer `undefined`, and the + * next Viewport refresh crashes reading `.dimensions` off it. WebKit-based + * hosts (WKWebView) reliably report zero while the bottom panel's expand + * slide is in flight; any `display:none`-hidden ancestor does the same. + * + * The caller's `open` callback (open + fit + resize) is invoked exactly + * once, on the first frame where the host reports a real size. While the + * host stays zero-sized the polling continues every frame; it stops when + * the host leaves the document (`isConnected`), so a pending open never + * fires after unmount. The returned cancel function drops a pending frame + * immediately (idempotent). + * + * `raf`/`caf` are injectable so tests can drive the polling deterministically. + */ +export function openWhenSized( + host: HTMLElement, + open: () => void, + raf: (cb: FrameRequestCallback) => number = requestAnimationFrame, + caf: (id: number) => void = cancelAnimationFrame, +): () => void { + let frame: number | null = null + const step = (): void => { + frame = null + if (!host.isConnected) return + if (host.clientWidth > 0 && host.clientHeight > 0) { + open() + return + } + frame = raf(step) + } + frame = raf(step) + return () => { + if (frame !== null) { + caf(frame) + frame = null + } + } +} diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 5006dc9..8afeb0c 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -10,12 +10,13 @@ */ import type { api } from './api.ts' import { + clampTerminalFontSize, clampWidthPercent, SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs, } from '../prefs-shared.ts' -export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent } +export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent } export type { SidebarPrefs } /** The settings wire face the preferences need (a subset of the plugin api). */ @@ -49,6 +50,12 @@ export function parsePrefs(value: unknown): SidebarPrefs { bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean' ? record.bottomPanelAutoTerminal : SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal, + terminalFontFamily: typeof record.terminalFontFamily === 'string' + ? record.terminalFontFamily + : SIDEBAR_PREFS_DEFAULTS.terminalFontFamily, + terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize) + ? clampTerminalFontSize(record.terminalFontSize) + : SIDEBAR_PREFS_DEFAULTS.terminalFontSize, interceptOpenPath: typeof record.interceptOpenPath === 'boolean' ? record.interceptOpenPath : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath, diff --git a/src/client/service.ts b/src/client/service.ts index 52fc345..9dfb24e 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -28,9 +28,15 @@ import { import { isNarrowWidth } from './breakpoints.ts' import type { SessionScope } from './api.ts' -/** One declarative boolean setting of a tab/viewer, rendered as a nested - * switch row in the Side card settings page (e.g. the Subagent page's - * "auto-open when a subagent appears"). */ +/** The row control a declarative setting renders as in the settings popup. */ +export type SidebarSettingToggleType = 'switch' | 'text' | 'number' + +/** One declarative setting of a tab/viewer, rendered as a nested row in the + * Side card settings page (e.g. the Subagent page's "auto-open when a + * subagent appears" switch, or the terminal's custom font rows). `type` + * selects the control: 'switch' (default) renders the custom switch, + * 'text' a free-form input committed on blur/Enter, 'number' a numeric + * input clamped to `min`/`max`. */ export interface SidebarSettingToggle { /** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */ key: string @@ -38,15 +44,26 @@ export interface SidebarSettingToggle { title: string | (() => string) /** Row description (i18n friendly). */ desc?: string | (() => string) + /** Row control type; defaults to 'switch' (backward compatible). */ + type?: SidebarSettingToggleType + /** Lower bound for `type: 'number'` rows (clamped on commit). */ + min?: number + /** Upper bound for `type: 'number'` rows (clamped on commit). */ + max?: number + /** Input placeholder for `type: 'text'` rows. */ + placeholder?: string + /** Unit suffix rendered after the input (e.g. 'px' for a size row). */ + unit?: string } /** Declarative settings of one registered tab or file viewer. */ export interface SidebarSettingsDeclaration { /** - * Extra boolean toggles rendered under the feature's own row in the + * Extra settings rows rendered under the feature's own row in the * settings page (only while the feature is enabled). Keys must be fields * of the host's PrefsSchema (built-ins: 'autoOpenSubagent', - * 'agentTerminalTools'); unknown keys are dropped by the settings seam. + * 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped + * by the settings seam. */ toggles?: readonly SidebarSettingToggle[] } diff --git a/src/client/terminal-font.ts b/src/client/terminal-font.ts new file mode 100644 index 0000000..9da7679 --- /dev/null +++ b/src/client/terminal-font.ts @@ -0,0 +1,28 @@ +/** + * Terminal font resolution: the user's custom font prefs (SidebarPrefs, + * configured under the terminal card's secondary settings) turned into the + * xterm options. Kept as a pure module (no DOM, no xterm) so the fallback + * chain and clamping are unit-testable without mounting a terminal. + */ +import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts' + +/** The built-in fallback stack when neither the user nor the theme sets one. */ +export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace' + +/** + * Resolve the xterm font options for the given prefs. + * @param prefs - the current side card preferences. + * @param themeFontFamily - the app's theme code font (`--ds-font-family-code` + * token value, read live by the caller); undefined when the token is absent. + * @returns the `fontFamily` / `fontSize` xterm options. + */ +export function resolveTerminalFont( + prefs: SidebarPrefs, + themeFontFamily: string | undefined, +): { fontFamily: string; fontSize: number } { + const custom = prefs.terminalFontFamily.trim() + return { + fontFamily: custom !== '' ? custom : (themeFontFamily || DEFAULT_TERMINAL_FONT_FAMILY), + fontSize: clampTerminalFontSize(prefs.terminalFontSize), + } +} diff --git a/src/config.ts b/src/config.ts index 0ddd0a4..b66e86d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,9 @@ import z from 'schemastery' import { SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_NS, + TERMINAL_FONT_SIZE_DEFAULT, + TERMINAL_FONT_SIZE_MAX, + TERMINAL_FONT_SIZE_MIN, WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, @@ -18,6 +21,9 @@ import { export { SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_NS, + TERMINAL_FONT_SIZE_DEFAULT, + TERMINAL_FONT_SIZE_MAX, + TERMINAL_FONT_SIZE_MIN, WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, @@ -82,6 +88,8 @@ export const PrefsSchema: z = z.object({ autoOpenJobs: z.boolean().default(true), agentTerminalTools: z.boolean().default(false), bottomPanelAutoTerminal: z.boolean().default(true), + terminalFontFamily: z.string().default(''), + terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT), interceptOpenPath: z.boolean().default(true), htmlViewerNoSandbox: z.boolean().default(false), htmlViewerDefaultUnsafe: z.boolean().default(false), diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 680c30d..48d0106 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -33,6 +33,18 @@ export interface SidebarPrefs { * user explicitly enables it in the side card settings. */ agentTerminalTools: boolean + /** + * Custom terminal font-family stack (a CSS font-family value, e.g. + * `'JetBrains Mono', monospace`). Empty string follows the app's theme + * monospace font (`--ds-font-family-code`). Applied live to every + * terminal tab; configured under the terminal card's secondary settings. + */ + terminalFontFamily: string + /** + * Custom terminal font size in px (9–32). Applied live to every terminal + * tab; configured under the terminal card's secondary settings. + */ + terminalFontSize: number /** * Whether expanding the bottom panel for the FIRST time in a session tries * to open a fresh terminal tab there (the terminal quota/type still gates @@ -104,6 +116,11 @@ export const WIDTH_PERCENT_MIN = 20 export const WIDTH_PERCENT_MAX = 60 export const WIDTH_PERCENT_DEFAULT = 30 +/** Range contract of {@link SidebarPrefs.terminalFontSize}. */ +export const TERMINAL_FONT_SIZE_MIN = 9 +export const TERMINAL_FONT_SIZE_MAX = 32 +export const TERMINAL_FONT_SIZE_DEFAULT = 13 + /** Fallback prefs used whenever the settings document is unreachable or malformed. */ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { openByDefault: true, @@ -112,6 +129,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, + terminalFontFamily: '', + terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, @@ -125,3 +144,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { export function clampWidthPercent(value: number): number { return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value))) } + +/** Clamp one terminal font size into the contract range (shared by schema and client reads). */ +export function clampTerminalFontSize(value: number): number { + return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value))) +} diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 34dd6f3..cfcf11f 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -47,10 +47,22 @@ describe('built-in tab registrations', () => { expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs']) }) - it('the terminal tab declares the model terminal-tools related setting', () => { + it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => { const { service } = setup() const toggles = service.getTab('terminal')?.settings?.toggles ?? [] - expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal']) + expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalFontFamily', 'terminalFontSize']) + // The font rows are text/number inputs (not switches), with the size + // row bounded by the shared 9–32 contract. + expect(toggles[2]?.type).toBe('text') + expect(toggles[2]?.title).toBeDefined() + expect(toggles[2]?.placeholder).toBeDefined() + expect(toggles[3]?.type).toBe('number') + expect(toggles[3]?.min).toBe(9) + expect(toggles[3]?.max).toBe(32) + expect(toggles[3]?.unit).toBe('px') + // The first two rows stay plain boolean switches. + expect(toggles[0]?.type ?? 'switch').toBe('switch') + expect(toggles[1]?.type ?? 'switch').toBe('switch') }) it('the browser tab declares its sandbox and link-takeover related settings', () => { diff --git a/tests/open-when-sized.spec.ts b/tests/open-when-sized.spec.ts new file mode 100644 index 0000000..2dcf7cf --- /dev/null +++ b/tests/open-when-sized.spec.ts @@ -0,0 +1,138 @@ +/** + * openWhenSized tests: the deferred one-shot open guard for hosts that may + * not have a real size yet (xterm crashes when opened in a zero-size + * container — the WKWebView bottom-panel blank-terminal bug, issue #25). + * The polling must open exactly once, only once the host reports a real + * size, stop when the host leaves the document, and cancel cleanly. + */ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { openWhenSized } from '../src/client/open-when-sized.ts' + +/** A manually-stepped raf/caf pair so the polling is deterministic. */ +function makeScheduler(): { + raf: (cb: FrameRequestCallback) => number + caf: (id: number) => void + tick: () => void + pending: () => number +} { + let nextId = 0 + const frames = new Map() + return { + raf: (cb) => { const id = ++nextId; frames.set(id, cb); return id }, + caf: (id) => { frames.delete(id) }, + tick: () => { + const cbs = [...frames.values()] + frames.clear() + for (const cb of cbs) cb(0) + }, + pending: () => frames.size, + } +} + +/** A host whose reported size can be changed between ticks. */ +function makeHost(width: number, height: number): { + el: HTMLElement + setSize: (w: number, h: number) => void +} { + const el = document.createElement('div') + document.body.appendChild(el) + const setSize = (w: number, h: number): void => { + Object.defineProperty(el, 'clientWidth', { value: w, configurable: true }) + Object.defineProperty(el, 'clientHeight', { value: h, configurable: true }) + } + setSize(width, height) + return { el, setSize } +} + +describe('openWhenSized', () => { + it('opens on the first tick when the host already has a size, then stops', () => { + const { raf, caf, tick, pending } = makeScheduler() + const host = makeHost(320, 200) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + expect(opened).toBe(1) + expect(pending()).toBe(0) + tick() + expect(opened).toBe(1) + cancel() + }) + + it('defers while the host is zero-sized and opens exactly once once sized', () => { + const { raf, caf, tick, pending } = makeScheduler() + const host = makeHost(0, 0) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + tick() + tick() + expect(opened).toBe(0) + expect(pending()).toBe(1) + host.setSize(320, 200) + tick() + expect(opened).toBe(1) + expect(pending()).toBe(0) + tick() + expect(opened).toBe(1) + cancel() + }) + + it('opens when only one dimension was missing', () => { + const { raf, caf, tick } = makeScheduler() + const host = makeHost(320, 0) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + expect(opened).toBe(0) + host.setSize(320, 120) + tick() + expect(opened).toBe(1) + cancel() + }) + + it('cancels a pending open and leaves no scheduled frames', () => { + const { raf, caf, tick, pending } = makeScheduler() + const host = makeHost(0, 0) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + cancel() + expect(pending()).toBe(0) + host.setSize(320, 200) + tick() + expect(opened).toBe(0) + // Idempotent: cancelling again is a no-op. + cancel() + }) + + it('stops polling when the host leaves the document', () => { + const { raf, caf, tick, pending } = makeScheduler() + const host = makeHost(0, 0) + let opened = 0 + const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf) + tick() + host.el.remove() + tick() + expect(opened).toBe(0) + expect(pending()).toBe(0) + // Even if the host somehow got a size after detaching, nothing opens. + host.setSize(320, 200) + tick() + expect(opened).toBe(0) + cancel() + }) + + it('does not swallow exceptions from open (the caller owns error handling)', () => { + const { raf, caf, tick } = makeScheduler() + const host = makeHost(320, 200) + let calls = 0 + const cancel = openWhenSized(host.el, () => { + calls += 1 + throw new Error('boom') + }, raf, caf) + expect(() => tick()).toThrow('boom') + expect(calls).toBe(1) + cancel() + }) +}) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 195545a..56a1aba 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -50,6 +50,10 @@ describe('dsh-better-sidebar plugin export shape', () => { // The terminal tools default OFF (the feature is dormant until the user // enables it in the side card settings). expect(resolved.agentTerminalTools).toBe(false) + // The terminal font customizations default to the theme (empty family) + // and 13px. + expect(resolved.terminalFontFamily).toBe('') + expect(resolved.terminalFontSize).toBe(13) // The enable-switch maps resolve to {} (everything on) for old documents. expect(resolved.tabsEnabled).toEqual({}) expect(resolved.viewersEnabled).toEqual({}) @@ -59,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => { const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) }) }) diff --git a/tests/side-card-section-rows.spec.tsx b/tests/side-card-section-rows.spec.tsx new file mode 100644 index 0000000..08dac8e --- /dev/null +++ b/tests/side-card-section-rows.spec.tsx @@ -0,0 +1,175 @@ +/** + * Interactive tests for the settings popup's text/number rows: the draft is + * local state committed on blur through the parent's onCommit; the parent's + * canonical return is adopted (clamped numbers, stored value for invalid + * input), and the row remounts when the committed pref value changes (a + * failed commit reverts prefs → the stored value reappears). + * + * Rendered with createRoot + act() in jsdom (the SSR specs stay in + * side-card-section.spec.tsx; this file exercises the event paths). + */ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { createElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true +import type { SidebarSettingToggle } from '../src/client/service.ts' +import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx' +import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' + +/** Render the rows into a detached container under React's act(). */ +function mount(node: ReactNode): { container: HTMLDivElement; rerender: (node: ReactNode) => void; unmount: () => void } { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + act(() => { root.render(node) }) + return { + container, + rerender: (next) => { act(() => { root.render(next) }) }, + unmount: () => { + act(() => { root.unmount() }) + container.remove() + }, + } +} + +/** Type into an input and commit it via blur (React 18: input event + + * focusout). The native setter bypasses React's value tracker so the + * change is actually seen. */ +function typeAndBlur(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + act(() => { + setter.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) +} + +const prefs = { ...SIDEBAR_PREFS_DEFAULTS } + +describe('FeatureSettingsRows typed rows (interactive)', () => { + it('commits the raw text on blur and adopts the canonical return', () => { + const commits: Array<[string, string]> = [] + const toggle: SidebarSettingToggle = { + key: 'terminalFontFamily', + type: 'text', + title: () => 'Font family', + } + const { container, unmount } = mount(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs, + onToggle: () => {}, + onCommit: (t, raw) => { + commits.push([t.key, raw]) + return raw + }, + })) + const input = container.querySelector('input')! + typeAndBlur(input, 'Monaco') + expect(commits).toEqual([['terminalFontFamily', 'Monaco']]) + // The canonical return is adopted into the draft. + expect(input.value).toBe('Monaco') + unmount() + }) + + it('clamps numbers into the declared bounds on commit', () => { + const commits: Array<[string, number]> = [] + const toggle: SidebarSettingToggle = { + key: 'terminalFontSize', + type: 'number', + title: () => 'Font size', + min: 9, + max: 32, + } + const { container, unmount } = mount(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs: { ...prefs, terminalFontSize: 13 }, + onToggle: () => {}, + onCommit: (t, raw) => { + const parsed = Number(raw) + const clamped = Math.min(32, Math.max(9, Math.round(parsed))) + commits.push([t.key, clamped]) + return String(clamped) + }, + })) + const input = container.querySelector('input')! + typeAndBlur(input, '40') + expect(commits).toEqual([['terminalFontSize', 32]]) + expect(input.value).toBe('32') + unmount() + }) + + it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => { + const commits: Array<[string, number]> = [] + const toggle: SidebarSettingToggle = { + key: 'terminalFontSize', + type: 'number', + title: () => 'Font size', + min: 9, + max: 32, + } + const { container, unmount } = mount(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs: { ...prefs, terminalFontSize: 13 }, + onToggle: () => {}, + // The parent mirrors the real handler: an emptied number parses to 0 + // and clamps into the bounds (a browser number input never holds a + // non-numeric string — the draft can only be empty or numeric). + onCommit: (t, raw) => { + const clamped = Math.min(32, Math.max(9, Math.round(Number(raw)))) + commits.push([t.key, clamped]) + return String(clamped) + }, + })) + const input = container.querySelector('input')! + typeAndBlur(input, '') + expect(commits).toEqual([['terminalFontSize', 9]]) + expect(input.value).toBe('9') + unmount() + }) + + it('reverts the draft to the committed value after a failed commit reverts prefs', () => { + const toggle: SidebarSettingToggle = { + key: 'terminalFontFamily', + type: 'text', + title: () => 'Font family', + } + // The parent mirrors the real handler: the optimistic commit adopts the + // typed value, and a FAILED write reverts prefs to the stored one. + let prefsNow = { ...prefs, terminalFontFamily: 'Old' } + const { container, rerender, unmount } = mount(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs: prefsNow, + onToggle: () => {}, + onCommit: (_t, raw) => raw, + })) + const input = container.querySelector('input')! + typeAndBlur(input, 'New') + // Optimistic commit: the draft adopts the typed value. + expect(input.value).toBe('New') + // The optimistic pref lands (parent state): the key changes, the row + // remounts with the same value — no visible reset while editing. + prefsNow = { ...prefsNow, terminalFontFamily: 'New' } + rerender(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs: prefsNow, + onToggle: () => {}, + onCommit: (_t, raw) => raw, + })) + expect(container.querySelector('input')!.value).toBe('New') + // The write fails: prefs revert to the stored value and the row + // remounts with it (the stale draft is gone). + prefsNow = { ...prefsNow, terminalFontFamily: 'Old' } + rerender(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs: prefsNow, + onToggle: () => {}, + onCommit: (_t, raw) => raw, + })) + expect(container.querySelector('input')!.value).toBe('Old') + unmount() + }) +}) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index faffe9e..40f435a 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -5,9 +5,11 @@ * copy. The toggles are CARDS in a responsive grid: the card's main area is * the switch, the visual state IS the state (highlighted = enabled), * announced via `aria-pressed`, and the check badge sits at the far right. - * Features that declare related settings carry a gear corner button whose - * popup rows (native checkboxes) are tested through the extracted - * FeatureSettingsRows component (the Modal portal renders only while open). + * The general rows follow the DSH settings-row recipe with custom SWITCHES + * (real checkboxes driving a styled track). Features that declare related + * settings carry a gear corner button whose popup rows (switch controls) + * are tested through the extracted FeatureSettingsRows component (the Modal + * portal renders only while open). * * Rendered with renderToString (mount effects — the settings RPC sync — do * not run in SSR; the initial store prefs are the render input). @@ -78,14 +80,27 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('>explorer<') expect(html).toContain('data-icon="subagent"') expect(html).toContain('>Subagents<') - // Default prefs: openByDefault + interceptOpenPath + both tabs + the - // image viewer are all enabled → 5 cards pressed, none pressed=false. + // Default prefs: openByDefault + interceptOpenPath switches checked, and + // both tabs + the image viewer cards pressed (3 aria-pressed cards). // The nested auto-open toggle is NOT an inline card (it lives in the popup). - expect(pressedCount(html, 'true')).toBe(5) + expect(pressedCount(html, 'true')).toBe(3) expect(pressedCount(html, 'false')).toBe(0) + // The general toggles are custom switches (real checkboxes, checked). + expect(html.match(/checked=""/g)?.length).toBe(2) expect(html).not.toContain('Auto-open Subagents') }) + it('renders the section intro and group headings with inventory counts', () => { + const { store, service } = mount() + const html = renderSection(store, service) + expect(html).toContain('Manage what the side card shows and how it behaves') + // Group headings carry the inventory count badge (2 tabs, 1 viewer). + expect(html).toContain('>Sidebar contentFile viewers2') + expect(html).toContain('>1') + }) + it('renders one small card per registered viewer: icon + title + exts', () => { const { store, service } = mount() const html = renderSection(store, service) @@ -111,8 +126,9 @@ describe('SideCardSection declarative inventory', () => { expect(html).toContain('>Subagents<') expect(html).toContain('>Image<') expect(pressedCount(html, 'false')).toBe(2) - // openByDefault + interceptOpenPath + the explorer stay pressed. - expect(pressedCount(html, 'true')).toBe(3) + // The explorer card stays pressed; the general switches stay checked. + expect(pressedCount(html, 'true')).toBe(1) + expect(html.match(/checked=""/g)?.length).toBe(2) }) it('hides the gear of a disabled feature (its related settings are dormant)', () => { @@ -134,7 +150,7 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => { desc: () => 'Expand on new subagent', }] - it('renders one native checkbox row per declared toggle with its current value', () => { + it('renders one switch row per declared toggle with its current value', () => { const html = renderToString(createElement(FeatureSettingsRows, { toggles, prefs, @@ -142,7 +158,9 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => { })) expect(html).toContain('Auto-open Subagents') expect(html).toContain('Expand on new subagent') - // The checkbox reflects the prefs value (false here → unchecked). + // The row's switch is a real checkbox (aria-label = the toggle title) + // reflecting the prefs value (false here → unchecked). + expect(html).toContain('aria-label="Auto-open Subagents"') expect(html).not.toContain('checked=""') }) @@ -154,4 +172,47 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => { })) expect(html).toContain('checked=""') }) + + it('renders a text row as an input seeded with the pref value (empty = theme default)', () => { + const html = renderToString(createElement(FeatureSettingsRows, { + toggles: [{ + key: 'terminalFontFamily', + type: 'text', + title: () => 'Font family', + desc: () => 'CSS stack', + placeholder: '"JetBrains Mono", monospace', + }], + prefs: { ...prefs, terminalFontFamily: '"JetBrains Mono", monospace' }, + onToggle: () => {}, + onCommit: () => '', + })) + expect(html).toContain('Font family') + expect(html).toContain('placeholder=""JetBrains Mono", monospace"') + // The input carries the pref value (no switch for text rows). + expect(html).toContain('value=""JetBrains Mono", monospace"') + expect(html).not.toContain('type="checkbox"') + }) + + it('renders a number row with the pref value, the declared bounds and a unit suffix', () => { + const html = renderToString(createElement(FeatureSettingsRows, { + toggles: [{ + key: 'terminalFontSize', + type: 'number', + title: () => 'Font size', + min: 9, + max: 32, + unit: 'px', + }], + prefs: { ...prefs, terminalFontSize: 18 }, + onToggle: () => {}, + onCommit: () => '18', + })) + expect(html).toContain('Font size') + expect(html).toContain('type="number"') + expect(html).toContain('value="18"') + expect(html).toContain('min="9"') + expect(html).toContain('max="32"') + expect(html).toContain('px') + expect(html).not.toContain('type="checkbox"') + }) }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 601eed5..29d12e3 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -517,6 +517,8 @@ describe('side card settings routes', () => { autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, + terminalFontFamily: '', + terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, diff --git a/tests/terminal-font.spec.ts b/tests/terminal-font.spec.ts new file mode 100644 index 0000000..3441589 --- /dev/null +++ b/tests/terminal-font.spec.ts @@ -0,0 +1,42 @@ +/** + * Terminal font resolution tests: the custom font prefs (side card settings, + * terminal card) resolve into xterm options with a strict fallback chain — + * user family > theme code font > the built-in monospace stack, and the size + * clamped into the 9–32 contract. + */ +import { describe, expect, it } from 'vitest' +import { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../src/prefs-shared.ts' +import { DEFAULT_TERMINAL_FONT_FAMILY, resolveTerminalFont } from '../src/client/terminal-font.ts' + +describe('clampTerminalFontSize', () => { + it('rounds and clamps into the 9–32 contract', () => { + expect(clampTerminalFontSize(13)).toBe(13) + expect(clampTerminalFontSize(15.6)).toBe(16) + expect(clampTerminalFontSize(5)).toBe(TERMINAL_FONT_SIZE_MIN) + expect(clampTerminalFontSize(40)).toBe(TERMINAL_FONT_SIZE_MAX) + }) +}) + +describe('resolveTerminalFont', () => { + it('falls back to the theme code font when the family pref is empty', () => { + const { fontFamily, fontSize } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font') + expect(fontFamily).toBe('var-theme-font') + expect(fontSize).toBe(13) + }) + + it('falls back to the built-in monospace stack when neither the pref nor the theme provides one', () => { + expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily).toBe(DEFAULT_TERMINAL_FONT_FAMILY) + // A whitespace-only pref counts as empty (theme default). + expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily) + .toBe(DEFAULT_TERMINAL_FONT_FAMILY) + }) + + it('prefers the custom family verbatim and clamps the size', () => { + const { fontFamily, fontSize } = resolveTerminalFont( + { ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: '"JetBrains Mono", monospace', terminalFontSize: 40 }, + 'var-theme-font', + ) + expect(fontFamily).toBe('"JetBrains Mono", monospace') + expect(fontSize).toBe(TERMINAL_FONT_SIZE_MAX) + }) +}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index fac1fd5..4b18516 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1153,6 +1153,8 @@ describe('side card preferences', () => { autoOpenJobs: true, agentTerminalTools: true, bottomPanelAutoTerminal: true, + terminalFontFamily: '', + terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, @@ -1172,6 +1174,8 @@ describe('side card preferences', () => { autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, + terminalFontFamily: '', + terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, @@ -1191,6 +1195,8 @@ describe('side card preferences', () => { autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, + terminalFontFamily: '', + terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, @@ -1225,6 +1231,22 @@ describe('side card preferences', () => { expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true) }) + it('resolves the terminal font prefs (family passthrough, size clamp)', async () => { + // Absent → theme default (empty family) + default size. + expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('') + expect((await loadPrefs(wire({}))).terminalFontSize).toBe(13) + // A custom family survives verbatim; a malformed one falls back. + expect((await loadPrefs(wire({ terminalFontFamily: '"JetBrains Mono", monospace' }))).terminalFontFamily) + .toBe('"JetBrains Mono", monospace') + expect((await loadPrefs(wire({ terminalFontFamily: 42 }))).terminalFontFamily).toBe('') + // The size clamps into 9–32 (rounded); non-numbers fall back. + expect((await loadPrefs(wire({ terminalFontSize: 5 }))).terminalFontSize).toBe(9) + expect((await loadPrefs(wire({ terminalFontSize: 40 }))).terminalFontSize).toBe(32) + expect((await loadPrefs(wire({ terminalFontSize: 15.6 }))).terminalFontSize).toBe(16) + expect((await loadPrefs(wire({ terminalFontSize: 'big' }))).terminalFontSize).toBe(13) + expect((await loadPrefs(wire({ terminalFontSize: 18 }))).terminalFontSize).toBe(18) + }) + it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => { // A non-object map falls back to {} (everything enabled). expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({}) @@ -1242,9 +1264,9 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false) @@ -1280,7 +1302,7 @@ describe('side card preferences', () => { it('skips the default explorer tab when the explorer type is disabled', () => { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} }) store.setSession('no-explorer') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) @@ -1288,7 +1310,7 @@ describe('side card preferences', () => { expect(state.splits.kind).toBe('leaf') // Re-enabling seeds the explorer tab again. const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) openStore.setSession('with-explorer') const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])