@followbin/dsh-selection 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,12 +8,20 @@ Select text in any app and a small toolbar appears next to it: translate, summar
8
8
 
9
9
  ## Install
10
10
 
11
- You need DSH Desktop 0.2.2 or later on macOS 14 or later. Windows support is written but not tested yet; see [Known limitations](#known-limitations).
11
+ You need DSH Desktop 0.2.2 or later on macOS 14 or later. Windows support is written but not tested yet; see [Known limitations](#known-limitations). The screenshots below show DSH in Chinese; the English interface is laid out the same way.
12
12
 
13
13
  1. In DSH, open **Plugins** in the sidebar and click **Add plugin**.
14
14
  2. Enter `@followbin/dsh-selection` and click **Install**.
15
+
16
+ <img src="docs/images/zh/install-add.webp" alt="The Add plugin dialog with @followbin/dsh-selection entered" width="430">
17
+
15
18
  3. When the dialog says **Installed**, click **Enable now**.
16
- 4. macOS asks whether "DeepSeek Harness" may control your computer using accessibility features. Choose *Open System Settings* and switch DeepSeek Harness on ([why](#permissions-macos)).
19
+
20
+ <img src="docs/images/zh/install-enable.webp" alt="The Installed dialog with its Enable now button" width="430">
21
+
22
+ 4. macOS asks whether "DeepSeek Harness" may control your computer using accessibility features. Choose *Open System Settings* (the highlighted default button is *Deny*) and switch DeepSeek Harness on ([why](#permissions-macos)).
23
+
24
+ <img src="docs/images/zh/install-permission.webp" alt="macOS asking whether DeepSeek Harness may use accessibility features, with Selection assistant now in DSH's sidebar" width="700">
17
25
 
18
26
  Now select some text in any app. Settings are under **Selection assistant** in DSH's sidebar.
19
27
 
@@ -24,6 +32,7 @@ To install the newest code from GitHub instead, enter `https://github.com/follow
24
32
  ## Features
25
33
 
26
34
  - **Toolbar**: the first few skills sit on the toolbar, the rest live in the ⋯ menu together with "Disable in this app" and "More settings…".
35
+ - **Mini toolbar**: a single icon instead; rest the pointer on it and every skill drops down. Switch between the two from the toolbar's menu or the settings page.
27
36
  - **Built-in skills**: Translate, Summarize, Explain, Ask DSH, Read aloud, Copy, Polish, Search.
28
37
  - **Custom skills**: a name, an icon and an instruction. Try them on sample text before saving.
29
38
  - **Answers stream in** with Markdown, can be copied, regenerated, translated into another language or pinned on screen. When an answer is complete, a box below it takes follow-up questions, for every skill.
@@ -44,11 +53,12 @@ Windows needs no extra permission.
44
53
  ![The toolbar below a selection](docs/images/en/toolbar.png)
45
54
 
46
55
  - Select text and the toolbar appears below the selection. Press **Esc**, click elsewhere or start typing to dismiss it.
56
+ - With the mini toolbar, rest the pointer on the icon (or click it) to open the skills; they close once the pointer has left.
47
57
  - **Ask DSH** opens a small chat about the selection. Type a question and press Enter; follow-up questions keep the context.
48
58
  - Drag the toolbar, or a panel by its header, to move it; it stays where you put it.
49
59
  - In a result panel, the pin keeps it open while you click elsewhere (a new selection still replaces it); the language menu re-translates into another language. Click into the answer to select part of it and copy with ⌘C.
50
60
  - Once an answer is complete, type a follow-up in the box below it, for example "more formal" after a translation. Regenerating, or choosing another language, starts the conversation over.
51
- - **⋯ → Disable in this app** stops the toolbar in the current app. **⋯ → More settings…** opens the settings page in DSH.
61
+ - **⋯ → Use the mini toolbar** turns the toolbar into a single icon, and **Use the full toolbar** in its menu turns it back. **⋯ → Disable in this app** stops the toolbar in the current app. **⋯ → More settings…** opens the settings page in DSH.
52
62
 
53
63
  ![An explanation, then a follow-up question and its answer](docs/images/en/follow-up.png)
54
64
 
@@ -65,8 +75,9 @@ Open **Selection assistant** in DSH's sidebar. The page has tabs: General, Skill
65
75
  | --- | --- | --- |
66
76
  | Show the toolbar when text is selected | on | |
67
77
  | Skills, their order, on/off | 8 built-ins | Drag, or focus the handle and press ↑ ↓. Custom skills can be edited and deleted. |
78
+ | Toolbar style | full | Mini is a single icon whose menu holds every skill. Also switchable from the toolbar's menu. |
68
79
  | Show the toolbar | automatically | Or only while holding ⌥ / Alt during the selection. |
69
- | Skills on the toolbar | 5 | The rest go into the ⋯ menu. |
80
+ | Skills on the toolbar | 5 | The rest go into the ⋯ menu. Full toolbar only. |
70
81
  | My language | 简体中文 | Auto translation goes into this language, or into English if the text is already in it. |
71
82
  | Translate into | auto | |
72
83
  | Summaries, explanations and answers in | my language | Or the language of the selected text, or a fixed language. |
@@ -131,7 +142,7 @@ After changing `src/worker/` or `ui/`, click **Restart the assistant** on the se
131
142
  | `pnpm test` | Unit tests for the Host (settings, prompts, model client, HTTP guards, worker messages), the worker's rules, the toolbar page and the settings page (the pages run in jsdom). Plain Node 20 or later is fine. |
132
143
  | `pnpm selftest` | Opens the toolbar, the ⋯ menu and both panel types, then checks native window state, layout and that the frontmost app never changes. No permission needed. Add `--interactive` to click around for 20 seconds. |
133
144
  | `pnpm dev` | Runs the real toolbar outside DSH with a demo model. Settings go to `~/.dsh/selection-dev`. On macOS the accessibility prompt is for your terminal app. |
134
- | `pnpm screenshots` | Redraws the images in `docs/images/` from the real toolbar page and settings page with sample text. Add `en` or `zh` for one language. Needs Google Chrome and ffmpeg. |
145
+ | `pnpm screenshots` | Redraws the toolbar, follow-up, settings and walkthrough images in `docs/images/` from the real pages with sample text; the install screenshots are taken by hand. Add `en` or `zh` for one language. Needs Google Chrome and ffmpeg. |
135
146
  | `pnpm vendor:selection-hook [version]` | Refreshes `vendor/selection-hook` from npm. |
136
147
 
137
148
  How the pieces fit together, and why some unusual choices were made, is in [docs/architecture.md](docs/architecture.md).
package/README.zh-CN.md CHANGED
@@ -12,8 +12,16 @@
12
12
 
13
13
  1. 在 DSH 侧边栏打开「插件」,点「添加插件」。
14
14
  2. 输入 `@followbin/dsh-selection`,点「安装」。
15
+
16
+ <img src="docs/images/zh/install-add.webp" alt="「添加插件」里填好 @followbin/dsh-selection" width="430">
17
+
15
18
  3. 显示「已安装」后,点「立即启用」。
16
- 4. macOS 会询问是否允许「DeepSeek Harness」使用辅助功能控制这台电脑。点「打开系统设置」,把 DeepSeek Harness 打开([为什么需要](#权限macos))。
19
+
20
+ <img src="docs/images/zh/install-enable.webp" alt="安装完成,点「立即启用」" width="430">
21
+
22
+ 4. macOS 会询问是否允许「DeepSeek Harness」使用辅助功能控制这台电脑。点「打开系统设置」(高亮的默认按钮是「拒绝」,别直接按回车),把 DeepSeek Harness 打开([为什么需要](#权限macos))。
23
+
24
+ <img src="docs/images/zh/install-permission.webp" alt="macOS 请求辅助功能权限,DSH 侧边栏已出现「划词助手」" width="700">
17
25
 
18
26
  然后在任意应用里选中一段文字试试。设置在 DSH 侧边栏的「划词助手」里。
19
27
 
@@ -24,6 +32,7 @@
24
32
  ## 功能
25
33
 
26
34
  - **工具栏**:前几个技能直接显示,其余收进「⋯」菜单;菜单里还有「在此应用中禁用」和「更多设置…」。
35
+ - **迷你模式**:工具栏换成一个小图标,鼠标停在上面就展开全部技能。在工具栏菜单或设置页里随时切换。
27
36
  - **内置技能**:翻译、总结、解释、问问 DSH、朗读、复制、润色、搜索。
28
37
  - **自定义技能**:起个名字、选个图标、写一句指令,保存前可以拿示例文字试一试。
29
38
  - **结果面板**:流式显示,支持 Markdown,可以复制、重新生成、切换翻译语言、固定在屏幕上。每个技能回答完后,下面都有输入框可以接着问。
@@ -44,11 +53,12 @@ Windows 不需要额外权限。
44
53
  ![选区下方的工具栏](docs/images/zh/toolbar.png)
45
54
 
46
55
  - 选中文字后,工具栏出现在选区下方。按 **Esc**、点别处或开始打字都会收起它。
56
+ - 迷你模式下,鼠标停在图标上(或点一下)就展开技能,移开后自动收起。
47
57
  - **问问 DSH** 会打开一个针对这段文字的小对话框。输入问题后按回车,追问会保留上下文。
48
58
  - 按住工具栏,或按住面板的标题栏拖动,可以把它移开;放下后就停在那里。
49
59
  - 结果面板上的图钉可以把面板固定住,点别处也不会收起(再次划词仍会替换它);翻译面板的语言下拉框可以换一种语言重新翻译。在回答里点一下,就能选中其中一部分并用 ⌘C 复制。
50
60
  - 回答完成后,可以在下方输入框里接着问,比如翻译完问「再正式一点」。点重新生成或换一种语言时,对话会从头开始。
51
- - **⋯ → 在此应用中禁用** 会让当前应用里不再弹出;**⋯ → 更多设置…** 会打开 DSH 里的设置页。
61
+ - **⋯ → 切换为迷你模式** 把工具栏换成一个图标,图标菜单里的 **切换为常规模式** 换回来;**⋯ → 在此应用中禁用** 会让当前应用里不再弹出;**⋯ → 更多设置…** 会打开 DSH 里的设置页。
52
62
 
53
63
  ![翻译完成后接着追问](docs/images/zh/follow-up.png)
54
64
 
@@ -65,8 +75,9 @@ Windows 不需要额外权限。
65
75
  | --- | --- | --- |
66
76
  | 选中文字时显示工具栏 | 开 | |
67
77
  | 技能、顺序与开关 | 8 个内置技能 | 拖动排序,也可以聚焦拖动把手后按 ↑ ↓。自定义技能可以编辑、删除。 |
78
+ | 工具栏样式 | 常规 | 迷你模式只有一个图标,菜单里是全部技能;也可以在工具栏菜单里切换。 |
68
79
  | 弹出方式 | 选中后自动弹出 | 也可以设为按住 ⌥ / Alt 选中时才弹出。 |
69
- | 工具栏上的技能数 | 5 | 其余收进「⋯」菜单。 |
80
+ | 工具栏上的技能数 | 5 | 其余收进「⋯」菜单。只在常规模式下有效。 |
70
81
  | 我的母语 | 简体中文 | 自动翻译会译成母语;原文已经是母语时译成英文。 |
71
82
  | 翻译成 | 自动 | |
72
83
  | 总结、解释和回答使用 | 我的母语 | 也可以跟随原文的语言,或固定某种语言。 |
@@ -131,7 +142,7 @@ env ELECTRON_RUN_AS_NODE=1 "/Applications/DeepSeek Harness.app/Contents/MacOS/De
131
142
  | `pnpm test` | 单元测试:Host(设置、提示词、模型调用、HTTP 防护、worker 消息)、worker 的行为规则、工具栏页面和设置页(页面跑在 jsdom 里)。普通 Node 20 及以上就能跑。 |
132
143
  | `pnpm selftest` | 依次打开工具栏、「⋯」菜单和两种结果面板,检查原生窗口状态、布局,以及前台应用始终没变。不需要权限。加 `--interactive` 可以手动点 20 秒。 |
133
144
  | `pnpm dev` | 不经过 DSH、用演示模型运行真实的工具栏,设置存在 `~/.dsh/selection-dev`。在 macOS 上,授权弹窗针对的是你的终端应用。 |
134
- | `pnpm screenshots` | 用真实的工具栏页面和设置页、配上示例文字,重新生成 `docs/images/` 里的图。加 `en` 或 `zh` 只生成一种语言。需要 Google Chrome 和 ffmpeg。 |
145
+ | `pnpm screenshots` | 用真实的工具栏页面和设置页、配上示例文字,重新生成 `docs/images/` 里的工具栏、追问、设置页截图和动图;安装步骤的截图是手工截的。加 `en` 或 `zh` 只生成一种语言。需要 Google Chrome 和 ffmpeg。 |
135
146
  | `pnpm vendor:selection-hook [版本]` | 从 npm 更新 `vendor/selection-hook`。 |
136
147
 
137
148
  各部分怎么协作、为什么有些做法不寻常,见 [docs/architecture.md](docs/architecture.md)。
@@ -7,7 +7,7 @@ dsh-selection ships or depends on the following software. Each is used under its
7
7
  | [selection-hook](https://github.com/0xfullex/selection-hook) 2.1.2 | Vendored in `vendor/selection-hook` (JavaScript loader and N-API prebuilds for macOS and Windows): global input hooks and reading the selected text | MIT | © 2025 0xfullex |
8
8
  | [@webviewjs/webview](https://github.com/webviewjs/webview) 0.6.0 | npm dependency: system web view windows (built on tao and wry) | MIT | © 2024 Twilight |
9
9
  | [koffi](https://koffi.dev/) 3.3.6 (`@koromix/koffi-*` platform packages) | npm dependency: calling macOS and Windows system APIs | MIT | © 2026 Niels Martignène |
10
- | [Lucide](https://lucide.dev/) icons | Path data copied into `src/shared/icons.mjs`, `client.js` and `icon.svg` | ISC (portions MIT, from Feather) | © Lucide Contributors 2022; portions © Cole Bemis 2013–2022 |
10
+ | [Lucide](https://lucide.dev/) icons | Path data copied into `src/shared/icons.mjs` | ISC (portions MIT, from Feather) | © Lucide Contributors 2022; portions © Cole Bemis 2013–2022 |
11
11
 
12
12
  The full MIT text for selection-hook is in `vendor/selection-hook/LICENSE`; the other packages include their license files in `node_modules`.
13
13
 
package/client.js CHANGED
@@ -36,6 +36,7 @@ window.__ModuleLoader__.load({
36
36
  'enabled.hint': '关掉后划词助手不会再弹出,设置保留。',
37
37
  'skills.title': '技能',
38
38
  'skills.hint': '前 {count} 个启用的技能显示在工具栏上,其余收进 ⋯ 菜单。拖动或用方向键调整顺序。',
39
+ 'skills.hintMini': '迷你模式下,所有启用的技能都在图标展开的菜单里。拖动或用方向键调整顺序。',
39
40
  'skills.overflow': '以下收进 ⋯ 菜单',
40
41
  'skills.custom': '自定义',
41
42
  'skills.add': '新建技能',
@@ -63,6 +64,10 @@ window.__ModuleLoader__.load({
63
64
  'toolbar.trigger.auto': '选中文字后自动弹出',
64
65
  'toolbar.trigger.modifier': '按住 ⌥ / Alt 选中时才弹出',
65
66
  'toolbar.size': '工具栏上的技能数',
67
+ 'toolbar.style': '样式',
68
+ 'toolbar.style.full': '常规',
69
+ 'toolbar.style.mini': '迷你',
70
+ 'toolbar.style.hint': '迷你模式只显示一个图标,鼠标停在上面会展开全部技能。在工具栏的菜单里也能切换。',
66
71
  'language.title': '语言',
67
72
  'language.native': '我的母语',
68
73
  'language.translate': '翻译成',
@@ -158,6 +163,7 @@ window.__ModuleLoader__.load({
158
163
  'enabled.hint': 'Turning this off hides the assistant and keeps your settings.',
159
164
  'skills.title': 'Skills',
160
165
  'skills.hint': 'The first {count} enabled skills sit on the toolbar; the rest go into the ⋯ menu. Drag or use the arrow keys to reorder.',
166
+ 'skills.hintMini': 'With the mini toolbar, every enabled skill is in the icon’s menu. Drag or use the arrow keys to reorder.',
161
167
  'skills.overflow': 'In the ⋯ menu',
162
168
  'skills.custom': 'Custom',
163
169
  'skills.add': 'New skill',
@@ -185,6 +191,10 @@ window.__ModuleLoader__.load({
185
191
  'toolbar.trigger.auto': 'Automatically after selecting text',
186
192
  'toolbar.trigger.modifier': 'Only while holding ⌥ / Alt during selection',
187
193
  'toolbar.size': 'Skills on the toolbar',
194
+ 'toolbar.style': 'Style',
195
+ 'toolbar.style.full': 'Full',
196
+ 'toolbar.style.mini': 'Mini',
197
+ 'toolbar.style.hint': 'The mini toolbar is a single icon; rest the pointer on it to see every skill. You can also switch from the toolbar’s menu.',
188
198
  'language.title': 'Language',
189
199
  'language.native': 'My language',
190
200
  'language.translate': 'Translate into',
@@ -576,7 +586,7 @@ window.__ModuleLoader__.load({
576
586
  checked: entry.enabled, label: nameOf(entry),
577
587
  onChange: (enabled) => update({ skills: settings.skills.map((skill) => (skill.id === entry.id ? { ...skill, enabled } : skill)) }),
578
588
  })))
579
- if (entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
589
+ if (settings.toolbarStyle !== 'mini' && entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
580
590
  rows.push(h('div', { key: '__overflow', className: 'dss-overflow' }, t('skills.overflow')))
581
591
  }
582
592
  })
@@ -868,11 +878,16 @@ window.__ModuleLoader__.load({
868
878
  h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) }))),
869
879
  h(Section, { title: t('toolbar.title') },
870
880
  h('div', { className: 'dss-card' },
881
+ h(Row, { label: t('toolbar.style'), hint: t('toolbar.style.hint') },
882
+ h('div', { className: 'dss-segmented', role: 'group', 'aria-label': t('toolbar.style'), style: { marginTop: 0 } },
883
+ ['full', 'mini'].map((style) => h('button', {
884
+ key: style, type: 'button', 'aria-pressed': String(settings.toolbarStyle === style), onClick: () => update({ toolbarStyle: style }),
885
+ }, t(`toolbar.style.${style}`))))),
871
886
  h(Row, { label: t('toolbar.trigger') },
872
887
  h('select', { className: 'dss-select', value: settings.trigger, onChange: (event) => update({ trigger: event.target.value }) },
873
888
  h('option', { value: 'auto' }, t('toolbar.trigger.auto')),
874
889
  h('option', { value: 'modifier' }, t('toolbar.trigger.modifier')))),
875
- h(Row, { label: t('toolbar.size') },
890
+ settings.toolbarStyle === 'mini' ? null : h(Row, { label: t('toolbar.size') },
876
891
  h(NumberInput, { value: settings.toolbarSize, min: limits.toolbarSize.min, max: limits.toolbarSize.max, label: t('toolbar.size'), onChange: (toolbarSize) => update({ toolbarSize }) })))),
877
892
  h(Section, { title: t('language.title') },
878
893
  h('div', { className: 'dss-card' },
@@ -890,7 +905,7 @@ window.__ModuleLoader__.load({
890
905
  catalog.languages.map((language) => h('option', { key: language.code, value: language.code }, language.name))))))),
891
906
 
892
907
  panel('skills',
893
- h(Section, { title: t('skills.title'), hint: fill(t('skills.hint'), { count: settings.toolbarSize }) },
908
+ h(Section, { title: t('skills.title'), hint: settings.toolbarStyle === 'mini' ? t('skills.hintMini') : fill(t('skills.hint'), { count: settings.toolbarSize }) },
894
909
  h(SkillList, { t, settings, catalog, update, onEdit: (entry) => setEditing(entry) }),
895
910
  editing
896
911
  ? h(SkillEditor, { key: editing.id || 'new', t, catalog, limits, initial: editing.id ? editing : null, onSave: saveSkill, onCancel: () => setEditing(null), prepare: saveNow })
@@ -1013,7 +1028,7 @@ window.__ModuleLoader__.load({
1013
1028
  }
1014
1029
 
1015
1030
  // Dashed selection frame around lines of text (Lucide "text-select", ISC).
1016
- const SIDEBAR_ICON = 'M5 3a2 2 0 0 0-2 2 M19 3a2 2 0 0 1 2 2 M21 19a2 2 0 0 1-2 2 M5 21a2 2 0 0 1-2-2 M9 3h1 M9 21h1 M14 3h1 M14 21h1 M3 9v1 M21 9v1 M3 14v1 M21 14v1 M7 8h10 M7 12h10 M7 16h6'
1031
+ const SIDEBAR_ICON = 'M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M12 6.8c.47 2.87 2.33 4.73 5.2 5.2-2.87.47-4.73 2.33-5.2 5.2-.47-2.87-2.33-4.73-5.2-5.2 2.87-.47 4.73-2.33 5.2-5.2Z'
1017
1032
 
1018
1033
  function SidebarIcon({ size = 18, active }) {
1019
1034
  return h('svg', {
package/icon.svg CHANGED
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4d6bfe" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/><path d="M7 8h10"/><path d="M7 12h10"/><path d="M7 16h6"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none"><path d="M7.5 12.5V10C7.5 8.62 8.62 7.5 10 7.5H12.5M23.5 7.5H26C27.38 7.5 28.5 8.62 28.5 10V12.5M28.5 23.5V26C28.5 27.38 27.38 28.5 26 28.5H23.5M12.5 28.5H10C8.62 28.5 7.5 27.38 7.5 26V23.5" stroke="url(#dsh-selection-frame)" stroke-width="2.4" stroke-linecap="round"/><path d="M18 10.8C18.655 14.77 21.23 17.345 25.2 18C21.23 18.655 18.655 21.23 18 25.2C17.345 21.23 14.77 18.655 10.8 18C14.77 17.345 17.345 14.77 18 10.8Z" fill="url(#dsh-selection-spark)" stroke="none"/><defs><linearGradient id="dsh-selection-frame" x1="7.5" y1="7.5" x2="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop stop-color="#6AECB6"/><stop offset="1" stop-color="#37A6B1"/></linearGradient><linearGradient id="dsh-selection-spark" x1="10.8" y1="10.8" x2="25.2" y2="25.2" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient></defs></svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@followbin/dsh-selection",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Select text in any app and let DSH translate, summarize, explain, answer or read it aloud.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -101,6 +101,7 @@ export function createSelectionService({
101
101
  enabled: current.enabled,
102
102
  trigger: current.trigger,
103
103
  toolbarSize: current.toolbarSize,
104
+ toolbarStyle: current.toolbarStyle,
104
105
  skills: enabledSkills(current),
105
106
  disabledApps: current.disabledApps,
106
107
  clipboardFallback: current.clipboardFallback,
@@ -131,6 +132,15 @@ export function createSelectionService({
131
132
  case 'cancel':
132
133
  runs.get(message.requestId)?.abort()
133
134
  break
135
+ case 'toolbar-style':
136
+ if (message.style === 'full' || message.style === 'mini') {
137
+ try {
138
+ settings.update((current) => ({ ...current, toolbarStyle: message.style }))
139
+ } catch (error) {
140
+ log('error', 'saving settings failed', { error: String(error?.message ?? error) })
141
+ }
142
+ }
143
+ break
134
144
  case 'disable-app':
135
145
  if (typeof message.app === 'string' && message.app) {
136
146
  try {
@@ -57,6 +57,7 @@ export function defaultSettings() {
57
57
  enabled: true,
58
58
  trigger: 'auto',
59
59
  toolbarSize: 5,
60
+ toolbarStyle: 'full',
60
61
  skills: BUILTIN_SKILLS.map((skill) => ({ id: skill.id, enabled: true })),
61
62
  disabledApps: [],
62
63
  nativeLanguage: 'zh-CN',
@@ -83,6 +84,7 @@ export function normalizeSettings(input) {
83
84
  enabled: typeof value.enabled === 'boolean' ? value.enabled : defaults.enabled,
84
85
  trigger: value.trigger === 'modifier' ? 'modifier' : 'auto',
85
86
  toolbarSize: clampInt(value.toolbarSize, LIMITS.toolbarSize, defaults.toolbarSize),
87
+ toolbarStyle: value.toolbarStyle === 'mini' ? 'mini' : 'full',
86
88
  skills: normalizeSkills(value.skills),
87
89
  disabledApps: normalizeApps(value.disabledApps),
88
90
  nativeLanguage: LANGUAGE_CODES.has(value.nativeLanguage) ? value.nativeLanguage : defaults.nativeLanguage,
@@ -266,6 +268,7 @@ function isObject(value) {
266
268
  * @property {boolean} enabled Show the toolbar when text is selected.
267
269
  * @property {'auto' | 'modifier'} trigger `modifier` shows it only while ⌥ / Alt is held.
268
270
  * @property {number} toolbarSize How many enabled skills sit on the toolbar; the rest go to the ⋯ menu.
271
+ * @property {'full' | 'mini'} toolbarStyle Full: skill buttons on the toolbar. Mini: one icon whose menu holds every skill.
269
272
  * @property {Array<{ id: string, enabled: boolean, custom?: true, name?: string, icon?: string, prompt?: string }>} skills
270
273
  * @property {string[]} disabledApps Program names (as reported by the OS) where the toolbar never shows.
271
274
  * @property {string} nativeLanguage Language the user reads best; auto translation flips around it.
@@ -35,8 +35,31 @@ export const ICONS = Object.freeze({
35
35
  plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
36
36
  edit: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>',
37
37
  cursor: '<path d="M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1"/><path d="M7 22h1a4 4 0 0 0 4-4v-1"/><path d="M7 2h1a4 4 0 0 1 4 4v1"/>',
38
+ minimize: '<path d="m14 10 7-7"/><path d="M20 10h-6V4"/><path d="m3 21 7-7"/><path d="M4 14h6v6"/>',
39
+ maximize: '<path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/><path d="M9 21H3v-6"/>',
38
40
  })
39
41
 
42
+ const BRAND_FRAME = 'M7.5 12.5V10C7.5 8.62 8.62 7.5 10 7.5H12.5M23.5 7.5H26C27.38 7.5 28.5 8.62 28.5 10V12.5M28.5 23.5V26C28.5 27.38 27.38 28.5 26 28.5H23.5M12.5 28.5H10C8.62 28.5 7.5 27.38 7.5 26V23.5'
43
+ const BRAND_SPARK = 'M18 10.8C18.655 14.77 21.23 17.345 25.2 18C21.23 18.655 18.655 21.23 18 25.2C17.345 21.23 14.77 18.655 10.8 18C14.77 17.345 17.345 14.77 18 10.8Z'
44
+
45
+ /**
46
+ * The plugin's own mark, drawn for it rather than taken from Lucide: a selection frame around a spark,
47
+ * in the colours of DSH's plugin icons. `id` prefixes the gradient ids, which a document needs unique.
48
+ * The full view box keeps the margin DSH's own plugin icons have; a cropped one fits a small button.
49
+ */
50
+ export function brandIcon(id, viewBox = '0 0 36 36') {
51
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="${viewBox}" fill="none">`
52
+ + `<path d="${BRAND_FRAME}" stroke="url(#${id}-frame)" stroke-width="2.4" stroke-linecap="round"/>`
53
+ + `<path d="${BRAND_SPARK}" fill="url(#${id}-spark)" stroke="none"/>`
54
+ + '<defs>'
55
+ + `<linearGradient id="${id}-frame" x1="7.5" y1="7.5" x2="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop stop-color="#6AECB6"/><stop offset="1" stop-color="#37A6B1"/></linearGradient>`
56
+ + `<linearGradient id="${id}-spark" x1="10.8" y1="10.8" x2="25.2" y2="25.2" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient>`
57
+ + '</defs></svg>'
58
+ }
59
+
60
+ /** The same mark as a one-colour line drawing on a 24×24 grid, for DSH's sidebar. */
61
+ export const BRAND_LINE = 'M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M12 6.8c.47 2.87 2.33 4.73 5.2 5.2-2.87.47-4.73 2.33-5.2 5.2-.47-2.87-2.33-4.73-5.2-5.2 2.87-.47 4.73-2.33 5.2-5.2Z'
62
+
40
63
  /** Icons a user may pick for a custom skill. */
41
64
  export const CUSTOM_SKILL_ICONS = Object.freeze([
42
65
  'sparkles', 'star', 'heart', 'zap', 'lightbulb', 'list', 'quote', 'globe', 'link', 'code', 'polish', 'summarize', 'explain', 'ask',
@@ -49,7 +49,11 @@ export function createController({
49
49
 
50
50
  function configure(next) {
51
51
  config = next
52
- overlay.configure({ bar: next.skills.slice(0, next.toolbarSize), more: next.skills.slice(next.toolbarSize) })
52
+ overlay.configure({
53
+ bar: next.skills.slice(0, next.toolbarSize),
54
+ more: next.skills.slice(next.toolbarSize),
55
+ style: next.toolbarStyle === 'mini' ? 'mini' : 'full',
56
+ })
53
57
  if (!next.enabled) dismiss({ force: true, reason: 'disabled' })
54
58
  }
55
59
 
@@ -119,6 +123,7 @@ export function createController({
119
123
  case 'skill':
120
124
  return runSkill(message.id)
121
125
  case 'menu':
126
+ if (message.action === 'toolbar-style') return switchStyle()
122
127
  if (message.action !== 'disable-app' && message.action !== 'settings') return
123
128
  if (message.action === 'disable-app' && selection) send({ type: 'disable-app', app: selection.app })
124
129
  if (message.action === 'settings') send({ type: 'open-settings' })
@@ -165,6 +170,14 @@ export function createController({
165
170
  }
166
171
  }
167
172
 
173
+ /** Swaps the full toolbar and the mini icon where they are, and has the Host save the choice. */
174
+ function switchStyle() {
175
+ if (!config) return
176
+ const style = config.toolbarStyle === 'mini' ? 'full' : 'mini'
177
+ configure({ ...config, toolbarStyle: style })
178
+ send({ type: 'toolbar-style', style })
179
+ }
180
+
168
181
  function runSkill(id) {
169
182
  const skill = config?.skills.find((candidate) => candidate.id === id)
170
183
  if (!skill || !selection) return
@@ -57,6 +57,9 @@ export async function createOverlay({ onMessage, log }) {
57
57
  focused: false,
58
58
  alwaysOnTop: true,
59
59
  windowsSkipTaskbar: true,
60
+ // tao keeps a system shadow on undecorated windows unless told not to; on Windows it outlines the
61
+ // whole transparent window with a 1px border and adds hidden insets around the client area.
62
+ windowsUndecoratedShadow: false,
60
63
  macosHasShadow: false,
61
64
  })
62
65
  // WebView2 defaults its data folder next to the executable, i.e. inside the DSH install directory.
@@ -179,7 +182,7 @@ export async function createOverlay({ onMessage, log }) {
179
182
  }
180
183
 
181
184
  return {
182
- /** @param {{ bar: object[], more: object[] }} toolbar Skills on the toolbar and in the ⋯ menu. */
185
+ /** @param {{ bar: object[], more: object[], style: 'full' | 'mini' }} toolbar Skills on the toolbar and in its menu, and how the toolbar is drawn. */
183
186
  configure: (toolbar) => call('configure', toolbar),
184
187
  /** @param {Anchor} point In selection-hook coordinates. */
185
188
  showBar(point) {
@@ -1,14 +1,14 @@
1
1
  // The toolbar page as one HTML string: the web view loads it with no file or network access.
2
2
  // Kept apart from overlay.mjs, which loads the native web view, so tests can load the same page.
3
3
  import { readFileSync } from 'node:fs'
4
- import { ICONS } from '../shared/icons.mjs'
4
+ import { ICONS, brandIcon } from '../shared/icons.mjs'
5
5
 
6
6
  /** Inlines the page's stylesheet, scripts and icon table. */
7
7
  export function pageHtml() {
8
8
  const read = (name) => readFileSync(new URL(`../../ui/${name}`, import.meta.url), 'utf8')
9
9
  return read('toolbar.html')
10
10
  .replace('<link rel="stylesheet" href="toolbar.css">', () => `<style>${read('toolbar.css')}</style>`)
11
- .replace('<script src="icons.js"></script>', () => `<script>window.DSH_ICONS = ${JSON.stringify(ICONS)}</script>`)
11
+ .replace('<script src="icons.js"></script>', () => `<script>window.DSH_ICONS = ${JSON.stringify(ICONS)}; window.DSH_BRAND = ${JSON.stringify(brandIcon('brand', '5 5 26 26'))}</script>`)
12
12
  .replace('<script src="markdown.js"></script>', () => `<script>${read('markdown.js')}</script>`)
13
13
  .replace('<script src="toolbar.js"></script>', () => `<script>${read('toolbar.js')}</script>`)
14
14
  }
package/ui/toolbar.css CHANGED
@@ -63,7 +63,13 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
63
63
  .skill { height: 32px; padding: 0 10px 0 9px; font-weight: 500; letter-spacing: 0.1px; }
64
64
  .skill.done svg { color: #1f9d55; }
65
65
  .divider { width: 1px; height: 16px; margin: 0 4px; background: var(--hairline); }
66
- .more-button[aria-expanded="true"] { background: var(--press); }
66
+ .more-button[aria-expanded="true"], .mini-button[aria-expanded="true"] { background: var(--press); }
67
+ /* Mini toolbar: one icon, and every skill in its menu. */
68
+ .mini-button { display: none; color: var(--accent); }
69
+ .mini-button svg { width: 20px; height: 20px; }
70
+ [data-style="mini"] .bar { padding: 3px; border-radius: 10px; }
71
+ [data-style="mini"] .bar-skills, [data-style="mini"] .divider, [data-style="mini"] .more-button { display: none; }
72
+ [data-style="mini"] .mini-button { display: inline-flex; }
67
73
 
68
74
  /* ⋯ menu, opened below the more button */
69
75
  .menu { display: none; position: absolute; min-width: 190px; padding: 6px; border-radius: 12px; z-index: 1; }
@@ -101,6 +107,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
101
107
  .panel-body { flex: 0 1 auto; min-height: 0; padding: 0 16px 14px; max-height: 340px; overflow-y: auto; overscroll-behavior: contain; }
102
108
  [data-fill] .panel { height: var(--room); }
103
109
  [data-fill] .panel-body { flex-grow: 1; max-height: none; }
110
+ /* A styled scrollbar, so Windows draws a thin bar without its classic arrow buttons. */
104
111
  .panel-body::-webkit-scrollbar { width: 8px; }
105
112
  .panel-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 4px; border: 2px solid var(--surface); }
106
113
  .source {
@@ -127,8 +134,11 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
127
134
  [data-kind="ask"] .composer, [data-composer="shown"] .composer { display: flex; }
128
135
  .composer textarea {
129
136
  flex: 1; min-height: 22px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
137
+ overflow-y: hidden; scrollbar-width: none;
130
138
  line-height: 1.5; user-select: text; -webkit-user-select: text; cursor: text;
131
139
  }
140
+ /* Past its tallest size it scrolls with the wheel and the caret, without a bar. */
141
+ .composer textarea::-webkit-scrollbar { display: none; }
132
142
  .composer textarea::placeholder { color: var(--muted); }
133
143
  .send { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff; }
134
144
  [data-pointer] .send:hover { background: var(--accent); filter: brightness(1.08); }
package/ui/toolbar.html CHANGED
@@ -13,11 +13,13 @@
13
13
  <div class="bar-skills"></div>
14
14
  <span class="divider" aria-hidden="true"></span>
15
15
  <button type="button" class="icon-button more-button" data-i18n-label="more" aria-haspopup="menu" aria-expanded="false"></button>
16
+ <button type="button" class="icon-button mini-button" data-i18n-label="assistant" aria-haspopup="menu" aria-expanded="false"></button>
16
17
  </div>
17
18
 
18
19
  <div class="card menu" role="menu">
19
20
  <div class="menu-skills"></div>
20
21
  <div class="menu-divider" aria-hidden="true"></div>
22
+ <button type="button" class="menu-item plain" role="menuitem" data-menu-action="toolbar-style"></button>
21
23
  <button type="button" class="menu-item plain" role="menuitem" data-menu-action="disable-app" data-i18n="disableApp"></button>
22
24
  <button type="button" class="menu-item plain" role="menuitem" data-menu-action="settings" data-i18n="settings"></button>
23
25
  </div>
package/ui/toolbar.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const STRINGS = {
4
4
  zh: {
5
5
  more: '更多', disableApp: '在此应用中禁用', settings: '更多设置…', copied: '已复制', stop: '停止',
6
+ assistant: '划词助手', miniStyle: '切换为迷你模式', fullStyle: '切换为常规模式',
6
7
  close: '关闭', copyResult: '复制结果', retry: '重新生成', pin: '固定窗口', translateTo: '译为', auto: '自动',
7
8
  askPlaceholder: '就这段内容问点什么…', followPlaceholder: '接着问…', send: '发送', waiting: '正在生成', retryShort: '重试', from: '来自 {app}',
8
9
  errors: {
@@ -15,6 +16,7 @@
15
16
  },
16
17
  en: {
17
18
  more: 'More', disableApp: 'Disable in this app', settings: 'More settings…', copied: 'Copied', stop: 'Stop',
19
+ assistant: 'Selection assistant', miniStyle: 'Use the mini toolbar', fullStyle: 'Use the full toolbar',
18
20
  close: 'Close', copyResult: 'Copy result', retry: 'Regenerate', pin: 'Keep open', translateTo: 'Translate to', auto: 'Auto',
19
21
  askPlaceholder: 'Ask something about this text…', followPlaceholder: 'Ask a follow-up…', send: 'Send', waiting: 'Generating', retryShort: 'Retry', from: 'from {app}',
20
22
  errors: {
@@ -38,8 +40,10 @@
38
40
  const els = {
39
41
  barSkills: $('.bar-skills'),
40
42
  more: $('.more-button'),
43
+ mini: $('.mini-button'),
41
44
  menu: $('.menu'),
42
45
  menuSkills: $('.menu-skills'),
46
+ styleItem: $('[data-menu-action="toolbar-style"]'),
43
47
  panel: $('.panel'),
44
48
  panelIcon: $('.panel-icon'),
45
49
  panelTitle: $('.panel-title'),
@@ -64,6 +68,7 @@
64
68
  })
65
69
  document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t[node.dataset.i18nPlaceholder] })
66
70
  els.more.innerHTML = icon('more')
71
+ els.mini.innerHTML = window.DSH_BRAND || icon('sparkles')
67
72
  $('[data-menu-action="disable-app"]').insertAdjacentHTML('afterbegin', icon('ban'))
68
73
  $('[data-menu-action="settings"]').insertAdjacentHTML('afterbegin', icon('settings'))
69
74
  for (const [tool, name] of [['pin', 'pin'], ['copy', 'copy'], ['retry', 'retry'], ['close', 'close']]) {
@@ -73,6 +78,15 @@
73
78
 
74
79
  const emptyState = (requestId = null, pending = false) => ({ requestId, text: '', turn: null, pending, failure: null })
75
80
  let skills = { bar: [], more: [] }
81
+ let style = 'full'
82
+ let hoverTimer = null
83
+
84
+ // The menu offers whichever style is not in use.
85
+ function drawStyleItem() {
86
+ els.styleItem.innerHTML = `${icon(style === 'mini' ? 'maximize' : 'minimize')}<span></span>`
87
+ els.styleItem.querySelector('span').textContent = style === 'mini' ? t.fullStyle : t.miniStyle
88
+ }
89
+ drawStyleItem()
76
90
  let state = emptyState()
77
91
  let renderTimer = null
78
92
  // Timers rather than requestAnimationFrame: the view can be occluded or the screen locked, and then
@@ -92,11 +106,13 @@
92
106
  function setMenu(open) {
93
107
  if (open) {
94
108
  const bar = $('.bar')
95
- els.menu.style.left = `${bar.offsetLeft + els.more.offsetLeft - 6}px`
109
+ const button = style === 'mini' ? els.mini : els.more
110
+ els.menu.style.left = `${bar.offsetLeft + button.offsetLeft - 6}px`
96
111
  els.menu.style.top = `${bar.offsetTop + bar.offsetHeight + 6}px`
97
112
  }
98
113
  body.dataset.menu = open ? 'open' : 'closed'
99
- els.more.setAttribute('aria-expanded', String(open))
114
+ els.more.setAttribute('aria-expanded', String(open && style === 'full'))
115
+ els.mini.setAttribute('aria-expanded', String(open && style === 'mini'))
100
116
  queueSize()
101
117
  }
102
118
 
@@ -151,11 +167,22 @@
151
167
  window.bar = {
152
168
  configure(next) {
153
169
  skills = next
154
- els.barSkills.replaceChildren(...next.bar.map((skill) => skillButton(skill, 'skill')))
155
- els.menuSkills.replaceChildren(...next.more.map((skill) => skillButton(skill, 'menu-item')))
170
+ const nextStyle = next.style === 'mini' ? 'mini' : 'full'
171
+ if (nextStyle !== style) {
172
+ setMenu(false)
173
+ style = nextStyle
174
+ }
175
+ body.dataset.style = style
176
+ // The mini toolbar has no skill buttons of its own: every skill is in its menu.
177
+ const onBar = style === 'mini' ? [] : next.bar
178
+ const inMenu = style === 'mini' ? [...next.bar, ...next.more] : next.more
179
+ els.barSkills.replaceChildren(...onBar.map((skill) => skillButton(skill, 'skill')))
180
+ els.menuSkills.replaceChildren(...inMenu.map((skill) => skillButton(skill, 'menu-item')))
181
+ drawStyleItem()
156
182
  queueSize()
157
183
  },
158
184
  showBar() {
185
+ clearTimeout(hoverTimer)
159
186
  delete body.dataset.pointer
160
187
  body.dataset.mode = 'bar'
161
188
  setMenu(false)
@@ -250,8 +277,8 @@
250
277
  queueSize()
251
278
  },
252
279
  flash(skillId) {
253
- // A skill run from the ⋯ menu confirms on the ⋯ button: the menu has closed by then.
254
- flashDone(els.barSkills.querySelector(`[data-skill="${skillId}"]`) || els.more, t.copied)
280
+ // A skill run from the menu confirms on the button that opens the menu: the menu has closed by then.
281
+ flashDone(els.barSkills.querySelector(`[data-skill="${skillId}"]`) || (style === 'mini' ? els.mini : els.more), t.copied)
255
282
  },
256
283
  /**
257
284
  * How tall the panel may grow before it scrolls inside, from the worker's placement. With `fill`
@@ -265,6 +292,7 @@
265
292
  queueSize()
266
293
  },
267
294
  reset() {
295
+ clearTimeout(hoverTimer)
268
296
  delete body.dataset.pointer
269
297
  body.dataset.mode = 'hidden'
270
298
  body.dataset.menu = 'closed'
@@ -339,7 +367,11 @@
339
367
 
340
368
  function resizeInput() {
341
369
  els.input.style.height = 'auto'
342
- els.input.style.height = `${Math.min(els.input.scrollHeight, 96)}px`
370
+ const height = els.input.scrollHeight
371
+ els.input.style.height = `${Math.min(height, 96)}px`
372
+ // Scrollable only past its tallest size: at fractional display scaling one line can overflow by a
373
+ // fraction of a pixel, which would otherwise let it scroll.
374
+ els.input.style.overflowY = height > 96 ? 'auto' : 'hidden'
343
375
  els.send.disabled = !els.input.value.trim()
344
376
  }
345
377
 
@@ -358,6 +390,11 @@
358
390
  post({ type: 'skill', id: skill.dataset.skill })
359
391
  return
360
392
  }
393
+ if (event.target.closest('.mini-button')) {
394
+ clearTimeout(hoverTimer)
395
+ setMenu(true)
396
+ return
397
+ }
361
398
  if (event.target.closest('.more-button')) {
362
399
  setMenu(body.dataset.menu !== 'open')
363
400
  return
@@ -478,6 +515,29 @@
478
515
  if (body.dataset.mode === 'panel') post({ type: 'keyboard', needed: true })
479
516
  })
480
517
 
518
+ // The mini toolbar opens its menu once the pointer rests on the icon, and closes it once the pointer
519
+ // has left the window for a moment. Leaving the icon alone is not enough: a menu that does not fit
520
+ // below makes the worker move the whole window, which can take the icon from under the pointer.
521
+ const HOVER_OPEN_MS = 120
522
+ const HOVER_CLOSE_MS = 300
523
+ els.mini.addEventListener('pointerenter', () => {
524
+ clearTimeout(hoverTimer)
525
+ if (style !== 'mini' || body.dataset.menu === 'open' || press) return
526
+ hoverTimer = setTimeout(() => setMenu(true), HOVER_OPEN_MS)
527
+ })
528
+ els.mini.addEventListener('pointerleave', () => {
529
+ if (body.dataset.menu !== 'open') clearTimeout(hoverTimer)
530
+ })
531
+ els.mini.addEventListener('pointerdown', () => clearTimeout(hoverTimer))
532
+ document.addEventListener('pointerout', (event) => {
533
+ if (event.relatedTarget || style !== 'mini' || body.dataset.menu !== 'open') return
534
+ clearTimeout(hoverTimer)
535
+ hoverTimer = setTimeout(() => setMenu(false), HOVER_CLOSE_MS)
536
+ })
537
+ document.addEventListener('pointerover', () => {
538
+ if (body.dataset.menu === 'open') clearTimeout(hoverTimer)
539
+ })
540
+
481
541
  // WebKit keeps :hover on whatever was under the pointer when it last saw one, through hiding and
482
542
  // showing the window, and this process is never the active app, so a pointer that does not move
483
543
  // over the page is not tracked. Hover styles therefore wait for a real movement after each show.