diff --git a/AGENTS.md b/AGENTS.md index 2f594bc..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 丢弃。 --- @@ -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 694db07..69945ac 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 - **📝 编辑与预览**: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_*` 工具 +- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具;**自定义字体**(终端卡片二级设置:字体族 + 9–32px 字号,实时生效,留空跟随主题等宽字体) - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取 - **🧩 后台任务页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台任务**(当前树全部后台任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `job_output`;两击确认可强制终止) - **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠 diff --git a/README_EN.md b/README_EN.md index ec5208d..fcc6d8e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -22,7 +22,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 - **📝 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 +- **💻 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; **custom font** (terminal card's secondary settings: font family + 9–32px size, applied live, empty follows the theme's monospace font) - **🌿 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 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/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 f24861c..ed858c7 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -337,6 +337,17 @@ 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; diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 0dba6ee..2ec4dd0 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -141,37 +141,105 @@ function Switch(props: { /** * The body of a feature's secondary settings popup: one row (title/desc + - * custom switch) per declared toggle. Extracted so the rows are testable + * 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 ( -
- - {title} - {textOf(toggle.desc) !== '' && {textOf(toggle.desc)}} - - { onToggle(toggle, next) }} - /> -
+ ) })}
) } +/** + * 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). @@ -251,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) @@ -260,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 => { @@ -397,7 +487,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { { togglePref({ interceptOpenPath: next }) }} + onChange={(next) => { applyPref({ interceptOpenPath: next }) }} /> @@ -476,6 +566,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { toggles={settingsFor.settings?.toggles ?? []} prefs={prefs} onToggle={onToggleSetting} + onCommit={onCommitSetting} /> )} diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index 5990ad4..83a34b5 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -27,6 +27,7 @@ import { t } from './locales.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 +83,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, @@ -185,11 +189,31 @@ 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. + } + } + }) + connect() return () => { closed = true 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 0b4a2d0..18d974d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -136,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: '常规', @@ -340,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/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/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 1014914..40f435a 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -172,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'])