@followbin/dsh-selection 0.1.0 → 0.2.1
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 +17 -6
- package/README.zh-CN.md +16 -5
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/client.js +159 -6
- package/icon.svg +1 -1
- package/index.js +5 -0
- package/package.json +1 -1
- package/src/host/service.mjs +13 -0
- package/src/host/settings.mjs +3 -0
- package/src/shared/icons.mjs +23 -0
- package/src/worker/controller.mjs +24 -3
- package/src/worker/main.mjs +3 -0
- package/src/worker/overlay.mjs +30 -1
- package/src/worker/page.mjs +2 -2
- package/ui/toolbar.css +20 -9
- package/ui/toolbar.html +2 -0
- package/ui/toolbar.js +96 -17
package/README.md
CHANGED
|
@@ -8,22 +8,31 @@ 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
|
-
|
|
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
|
|
|
20
28
|
To install the newest code from GitHub instead, enter `https://github.com/followbin/dsh-selection` in step 2.
|
|
21
29
|
|
|
22
|
-
**Updating**: when a new version is published, the plugin's page
|
|
30
|
+
**Updating**: when a new version is published, **General → Version** on the plugin's settings page offers **Update to x.y.z**. It is the same update as **Versions → Update** on the plugin's page under **Plugins**: DSH stops the old version, installs the new one and puts the old one back if that fails. A copy installed from GitHub or a local folder cannot update this way: uninstall it and install it again. Your settings and usage are kept, because they are stored in `~/.dsh/selection`, outside the plugin.
|
|
23
31
|
|
|
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
|

|
|
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
|

|
|
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
|
|
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,18 +12,27 @@
|
|
|
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
|
-
|
|
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
|
|
|
20
28
|
想装 GitHub 上的最新代码,第 2 步改填 `https://github.com/followbin/dsh-selection`。
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
**更新**:发布新版本后,插件设置页「常规 → 版本」里会出现「更新到 x.y.z」,点一下就更新。这和在「插件」里打开它、点「版本 → 更新」是同一个操作:DSH 先停掉旧版、装上新版,失败会还原。从 GitHub 或本地目录装的不能这样更新,要先卸载再重新安装。设置和用量存在 `~/.dsh/selection`,不在插件目录里,卸载不会丢。
|
|
23
31
|
|
|
24
32
|
## 功能
|
|
25
33
|
|
|
26
34
|
- **工具栏**:前几个技能直接显示,其余收进「⋯」菜单;菜单里还有「在此应用中禁用」和「更多设置…」。
|
|
35
|
+
- **迷你模式**:工具栏换成一个小图标,鼠标停在上面就展开全部技能。在工具栏菜单或设置页里随时切换。
|
|
27
36
|
- **内置技能**:翻译、总结、解释、问问 DSH、朗读、复制、润色、搜索。
|
|
28
37
|
- **自定义技能**:起个名字、选个图标、写一句指令,保存前可以拿示例文字试一试。
|
|
29
38
|
- **结果面板**:流式显示,支持 Markdown,可以复制、重新生成、切换翻译语言、固定在屏幕上。每个技能回答完后,下面都有输入框可以接着问。
|
|
@@ -44,11 +53,12 @@ Windows 不需要额外权限。
|
|
|
44
53
|

|
|
45
54
|
|
|
46
55
|
- 选中文字后,工具栏出现在选区下方。按 **Esc**、点别处或开始打字都会收起它。
|
|
56
|
+
- 迷你模式下,鼠标停在图标上(或点一下)就展开技能,移开后自动收起。
|
|
47
57
|
- **问问 DSH** 会打开一个针对这段文字的小对话框。输入问题后按回车,追问会保留上下文。
|
|
48
58
|
- 按住工具栏,或按住面板的标题栏拖动,可以把它移开;放下后就停在那里。
|
|
49
59
|
- 结果面板上的图钉可以把面板固定住,点别处也不会收起(再次划词仍会替换它);翻译面板的语言下拉框可以换一种语言重新翻译。在回答里点一下,就能选中其中一部分并用 ⌘C 复制。
|
|
50
60
|
- 回答完成后,可以在下方输入框里接着问,比如翻译完问「再正式一点」。点重新生成或换一种语言时,对话会从头开始。
|
|
51
|
-
- **⋯ → 在此应用中禁用** 会让当前应用里不再弹出;**⋯ → 更多设置…** 会打开 DSH 里的设置页。
|
|
61
|
+
- **⋯ → 切换为迷你模式** 把工具栏换成一个图标,图标菜单里的 **切换为常规模式** 换回来;**⋯ → 在此应用中禁用** 会让当前应用里不再弹出;**⋯ → 更多设置…** 会打开 DSH 里的设置页。
|
|
52
62
|
|
|
53
63
|

|
|
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/`
|
|
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)。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
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,25 @@ 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': '迷你模式只显示一个图标,鼠标停在上面会展开全部技能。在工具栏的菜单里也能切换。',
|
|
71
|
+
'version.label': '版本',
|
|
72
|
+
'version.current': '当前 {version}',
|
|
73
|
+
'version.latest': '当前 {version},已是最新版本',
|
|
74
|
+
'version.available': '当前 {installed},可以更新到 {version}',
|
|
75
|
+
'version.needsDsh': '当前 {installed};新版本 {version} 需要先更新 DSH',
|
|
76
|
+
'version.notUpdatable': '当前 {version}。从 GitHub 或本地目录安装的版本不能在这里更新,可以到「插件」页卸载后重新安装',
|
|
77
|
+
'version.checkFailed': '当前 {version};检查更新失败:{reason}',
|
|
78
|
+
'version.updating': '正在更新到 {version},工具栏会短暂停止',
|
|
79
|
+
'version.updated': '已更新到 {version},新版本已经在运行',
|
|
80
|
+
'version.restart': '已安装 {version},重启 DSH 后生效',
|
|
81
|
+
'version.failed': '更新到 {version} 失败:{reason}。可以到「插件」页查看详情',
|
|
82
|
+
'version.check': '检查更新',
|
|
83
|
+
'version.checking': '正在检查…',
|
|
84
|
+
'version.update': '更新到 {version}',
|
|
85
|
+
'version.updatingShort': '正在更新…',
|
|
66
86
|
'language.title': '语言',
|
|
67
87
|
'language.native': '我的母语',
|
|
68
88
|
'language.translate': '翻译成',
|
|
@@ -158,6 +178,7 @@ window.__ModuleLoader__.load({
|
|
|
158
178
|
'enabled.hint': 'Turning this off hides the assistant and keeps your settings.',
|
|
159
179
|
'skills.title': 'Skills',
|
|
160
180
|
'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.',
|
|
181
|
+
'skills.hintMini': 'With the mini toolbar, every enabled skill is in the icon’s menu. Drag or use the arrow keys to reorder.',
|
|
161
182
|
'skills.overflow': 'In the ⋯ menu',
|
|
162
183
|
'skills.custom': 'Custom',
|
|
163
184
|
'skills.add': 'New skill',
|
|
@@ -185,6 +206,25 @@ window.__ModuleLoader__.load({
|
|
|
185
206
|
'toolbar.trigger.auto': 'Automatically after selecting text',
|
|
186
207
|
'toolbar.trigger.modifier': 'Only while holding ⌥ / Alt during selection',
|
|
187
208
|
'toolbar.size': 'Skills on the toolbar',
|
|
209
|
+
'toolbar.style': 'Style',
|
|
210
|
+
'toolbar.style.full': 'Full',
|
|
211
|
+
'toolbar.style.mini': 'Mini',
|
|
212
|
+
'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.',
|
|
213
|
+
'version.label': 'Version',
|
|
214
|
+
'version.current': 'Version {version}',
|
|
215
|
+
'version.latest': 'Version {version}, the latest',
|
|
216
|
+
'version.available': 'Version {installed}; {version} is available',
|
|
217
|
+
'version.needsDsh': 'Version {installed}; {version} needs a newer DSH',
|
|
218
|
+
'version.notUpdatable': 'Version {version}. A copy installed from GitHub or a local folder cannot be updated here; uninstall it on the Plugins page and install it again',
|
|
219
|
+
'version.checkFailed': 'Version {version}; checking for updates failed: {reason}',
|
|
220
|
+
'version.updating': 'Updating to {version}; the toolbar stops for a moment',
|
|
221
|
+
'version.updated': 'Updated to {version}; the new version is running',
|
|
222
|
+
'version.restart': '{version} is installed and takes effect after DSH restarts',
|
|
223
|
+
'version.failed': 'Updating to {version} failed: {reason}. The Plugins page has the details',
|
|
224
|
+
'version.check': 'Check for updates',
|
|
225
|
+
'version.checking': 'Checking…',
|
|
226
|
+
'version.update': 'Update to {version}',
|
|
227
|
+
'version.updatingShort': 'Updating…',
|
|
188
228
|
'language.title': 'Language',
|
|
189
229
|
'language.native': 'My language',
|
|
190
230
|
'language.translate': 'Translate into',
|
|
@@ -576,7 +616,7 @@ window.__ModuleLoader__.load({
|
|
|
576
616
|
checked: entry.enabled, label: nameOf(entry),
|
|
577
617
|
onChange: (enabled) => update({ skills: settings.skills.map((skill) => (skill.id === entry.id ? { ...skill, enabled } : skill)) }),
|
|
578
618
|
})))
|
|
579
|
-
if (entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
|
|
619
|
+
if (settings.toolbarStyle !== 'mini' && entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
|
|
580
620
|
rows.push(h('div', { key: '__overflow', className: 'dss-overflow' }, t('skills.overflow')))
|
|
581
621
|
}
|
|
582
622
|
})
|
|
@@ -707,6 +747,104 @@ window.__ModuleLoader__.load({
|
|
|
707
747
|
|
|
708
748
|
// ---------- Page ----------
|
|
709
749
|
|
|
750
|
+
// Updates go through DSH's plugin manager, reached the way DSH's own Plugins page reaches it: it stops
|
|
751
|
+
// this version, installs the new one and puts the old one back if that fails. Null where this DSH
|
|
752
|
+
// does not let a page reach it.
|
|
753
|
+
const PACKAGE = '@followbin/dsh-selection'
|
|
754
|
+
const updates = (() => {
|
|
755
|
+
let manager = null
|
|
756
|
+
let state = { phase: 'none' }
|
|
757
|
+
const listeners = new Set()
|
|
758
|
+
const set = (next) => {
|
|
759
|
+
state = next
|
|
760
|
+
for (const listener of listeners) listener(state)
|
|
761
|
+
}
|
|
762
|
+
const reasonOf = (value) => String(value?.message ?? value ?? 'unknown')
|
|
763
|
+
return {
|
|
764
|
+
get: () => state,
|
|
765
|
+
subscribe(listener) {
|
|
766
|
+
listeners.add(listener)
|
|
767
|
+
return () => listeners.delete(listener)
|
|
768
|
+
},
|
|
769
|
+
attach(next) {
|
|
770
|
+
manager = next
|
|
771
|
+
if (state.phase !== 'updating') set({ phase: next ? 'idle' : 'none' })
|
|
772
|
+
},
|
|
773
|
+
async check() {
|
|
774
|
+
if (!manager || state.phase === 'checking' || state.phase === 'updating') return
|
|
775
|
+
set({ phase: 'checking' })
|
|
776
|
+
let list
|
|
777
|
+
try {
|
|
778
|
+
const answer = await manager.listVersions(PACKAGE, {})
|
|
779
|
+
list = answer.ok ? answer.value : { status: 'refused', problem: 'unknown', reason: reasonOf(answer.error) }
|
|
780
|
+
} catch (error) {
|
|
781
|
+
list = { status: 'refused', problem: 'unknown', reason: reasonOf(error) }
|
|
782
|
+
}
|
|
783
|
+
if (list.status === 'listed') {
|
|
784
|
+
// Newest first; a prerelease, a deprecated version or one without a bundle is never offered.
|
|
785
|
+
const usable = list.versions.filter((entry) => entry.bundle && !entry.prerelease && !entry.deprecated)
|
|
786
|
+
const next = usable.find((entry) => !entry.incompatible)
|
|
787
|
+
const blocked = usable.find((entry) => entry.incompatible)
|
|
788
|
+
if (next) set({ phase: 'available', installed: list.installed, version: next.version, registry: list.registry })
|
|
789
|
+
else if (blocked) set({ phase: 'needs-dsh', installed: list.installed, version: blocked.version })
|
|
790
|
+
else set({ phase: 'latest', installed: list.installed })
|
|
791
|
+
} else {
|
|
792
|
+
set(list.problem === 'not-updatable' ? { phase: 'not-updatable' } : { phase: 'check-failed', reason: list.reason || list.problem })
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
async update() {
|
|
796
|
+
if (!manager || state.phase !== 'available') return
|
|
797
|
+
const { version, registry } = state
|
|
798
|
+
set({ phase: 'updating', version })
|
|
799
|
+
const requestId = globalThis.crypto?.randomUUID?.() ?? `dss-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
800
|
+
let answer
|
|
801
|
+
try {
|
|
802
|
+
answer = await manager.updateBundle(PACKAGE, version, { registry, requestId })
|
|
803
|
+
} catch (error) {
|
|
804
|
+
answer = { ok: false, error }
|
|
805
|
+
}
|
|
806
|
+
// Once applied, DSH replaces this page with the new version's.
|
|
807
|
+
const result = answer.ok ? answer.value.result : null
|
|
808
|
+
if (result?.application === 'applied') set({ phase: 'updated', version })
|
|
809
|
+
else if (result?.application === 'restart-required') set({ phase: 'restart', version })
|
|
810
|
+
else set({ phase: 'failed', version, reason: answer.ok ? (result?.error?.code ?? result?.application ?? 'unknown') : reasonOf(answer.error) })
|
|
811
|
+
},
|
|
812
|
+
}
|
|
813
|
+
})()
|
|
814
|
+
|
|
815
|
+
function VersionRow({ t, version }) {
|
|
816
|
+
const [state, setState] = useState(updates.get())
|
|
817
|
+
useEffect(() => updates.subscribe(setState), [])
|
|
818
|
+
// Checks once when the page opens, or when DSH's plugin manager arrives after it.
|
|
819
|
+
useEffect(() => {
|
|
820
|
+
if (state.phase === 'idle') void updates.check()
|
|
821
|
+
}, [state.phase])
|
|
822
|
+
const installed = state.installed || version
|
|
823
|
+
const hint = {
|
|
824
|
+
none: fill(t('version.current'), { version }),
|
|
825
|
+
idle: fill(t('version.current'), { version }),
|
|
826
|
+
checking: fill(t('version.current'), { version }),
|
|
827
|
+
latest: fill(t('version.latest'), { version: installed }),
|
|
828
|
+
available: fill(t('version.available'), { installed, version: state.version }),
|
|
829
|
+
'needs-dsh': fill(t('version.needsDsh'), { installed, version: state.version }),
|
|
830
|
+
'not-updatable': fill(t('version.notUpdatable'), { version }),
|
|
831
|
+
'check-failed': fill(t('version.checkFailed'), { version, reason: state.reason }),
|
|
832
|
+
updating: fill(t('version.updating'), { version: state.version }),
|
|
833
|
+
updated: fill(t('version.updated'), { version: state.version }),
|
|
834
|
+
restart: fill(t('version.restart'), { version: state.version }),
|
|
835
|
+
failed: fill(t('version.failed'), { version: state.version, reason: state.reason }),
|
|
836
|
+
}[state.phase]
|
|
837
|
+
let action = null
|
|
838
|
+
if (state.phase === 'available') {
|
|
839
|
+
action = h('button', { type: 'button', className: 'dss-button primary', onClick: () => void updates.update() }, fill(t('version.update'), { version: state.version }))
|
|
840
|
+
} else if (state.phase === 'checking' || state.phase === 'updating') {
|
|
841
|
+
action = h('button', { type: 'button', className: 'dss-button', disabled: true }, t(state.phase === 'checking' ? 'version.checking' : 'version.updatingShort'))
|
|
842
|
+
} else if (['latest', 'needs-dsh', 'check-failed', 'failed'].includes(state.phase)) {
|
|
843
|
+
action = h('button', { type: 'button', className: 'dss-button', onClick: () => void updates.check() }, t('version.check'))
|
|
844
|
+
}
|
|
845
|
+
return h(Row, { label: t('version.label'), hint }, action)
|
|
846
|
+
}
|
|
847
|
+
|
|
710
848
|
function SettingsPage(props) {
|
|
711
849
|
const t = typeof props.t === 'function' ? props.t : (key) => zh[key] || key
|
|
712
850
|
const [state, setState] = useState(null)
|
|
@@ -865,14 +1003,20 @@ window.__ModuleLoader__.load({
|
|
|
865
1003
|
|
|
866
1004
|
panel('general',
|
|
867
1005
|
h('div', { className: 'dss-card', style: { marginTop: 20 } },
|
|
868
|
-
h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) }))
|
|
1006
|
+
h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) })),
|
|
1007
|
+
h(VersionRow, { t, version: state.version })),
|
|
869
1008
|
h(Section, { title: t('toolbar.title') },
|
|
870
1009
|
h('div', { className: 'dss-card' },
|
|
1010
|
+
h(Row, { label: t('toolbar.style'), hint: t('toolbar.style.hint') },
|
|
1011
|
+
h('div', { className: 'dss-segmented', role: 'group', 'aria-label': t('toolbar.style'), style: { marginTop: 0 } },
|
|
1012
|
+
['full', 'mini'].map((style) => h('button', {
|
|
1013
|
+
key: style, type: 'button', 'aria-pressed': String(settings.toolbarStyle === style), onClick: () => update({ toolbarStyle: style }),
|
|
1014
|
+
}, t(`toolbar.style.${style}`))))),
|
|
871
1015
|
h(Row, { label: t('toolbar.trigger') },
|
|
872
1016
|
h('select', { className: 'dss-select', value: settings.trigger, onChange: (event) => update({ trigger: event.target.value }) },
|
|
873
1017
|
h('option', { value: 'auto' }, t('toolbar.trigger.auto')),
|
|
874
1018
|
h('option', { value: 'modifier' }, t('toolbar.trigger.modifier')))),
|
|
875
|
-
h(Row, { label: t('toolbar.size') },
|
|
1019
|
+
settings.toolbarStyle === 'mini' ? null : h(Row, { label: t('toolbar.size') },
|
|
876
1020
|
h(NumberInput, { value: settings.toolbarSize, min: limits.toolbarSize.min, max: limits.toolbarSize.max, label: t('toolbar.size'), onChange: (toolbarSize) => update({ toolbarSize }) })))),
|
|
877
1021
|
h(Section, { title: t('language.title') },
|
|
878
1022
|
h('div', { className: 'dss-card' },
|
|
@@ -890,7 +1034,7 @@ window.__ModuleLoader__.load({
|
|
|
890
1034
|
catalog.languages.map((language) => h('option', { key: language.code, value: language.code }, language.name))))))),
|
|
891
1035
|
|
|
892
1036
|
panel('skills',
|
|
893
|
-
h(Section, { title: t('skills.title'), hint: fill(t('skills.hint'), { count: settings.toolbarSize }) },
|
|
1037
|
+
h(Section, { title: t('skills.title'), hint: settings.toolbarStyle === 'mini' ? t('skills.hintMini') : fill(t('skills.hint'), { count: settings.toolbarSize }) },
|
|
894
1038
|
h(SkillList, { t, settings, catalog, update, onEdit: (entry) => setEditing(entry) }),
|
|
895
1039
|
editing
|
|
896
1040
|
? h(SkillEditor, { key: editing.id || 'new', t, catalog, limits, initial: editing.id ? editing : null, onSave: saveSkill, onCancel: () => setEditing(null), prepare: saveNow })
|
|
@@ -1012,8 +1156,8 @@ window.__ModuleLoader__.load({
|
|
|
1012
1156
|
return h(PageBoundary, props, h(SettingsPage, props))
|
|
1013
1157
|
}
|
|
1014
1158
|
|
|
1015
|
-
//
|
|
1016
|
-
const SIDEBAR_ICON = '
|
|
1159
|
+
// The plugin's mark as a line drawing; src/shared/icons.mjs has the same path as BRAND_LINE.
|
|
1160
|
+
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
1161
|
|
|
1018
1162
|
function SidebarIcon({ size = 18, active }) {
|
|
1019
1163
|
return h('svg', {
|
|
@@ -1039,6 +1183,15 @@ window.__ModuleLoader__.load({
|
|
|
1039
1183
|
'dsh-selection: settings page',
|
|
1040
1184
|
)
|
|
1041
1185
|
ctx.effect(() => events.start(), 'dsh-selection: event stream')
|
|
1186
|
+
// Updates need DSH's plugin manager; a DSH that gives a page none still shows everything else.
|
|
1187
|
+
if (typeof ctx.inject === 'function') {
|
|
1188
|
+
ctx.inject(['remote', 'remote.pluginManager'], (sub) => {
|
|
1189
|
+
sub.effect(() => {
|
|
1190
|
+
updates.attach(sub.remote.pluginManager)
|
|
1191
|
+
return () => updates.attach(null)
|
|
1192
|
+
}, 'dsh-selection: updates')
|
|
1193
|
+
})
|
|
1194
|
+
}
|
|
1042
1195
|
// "More settings…" in the toolbar menu asks the Host to bring DSH forward on this page.
|
|
1043
1196
|
ctx.effect(() => events.subscribe((event) => {
|
|
1044
1197
|
if (event !== 'navigate') return
|
package/icon.svg
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
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/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// DSH plugin entry (Host side). Starts the selection service and mounts its HTTP API.
|
|
2
2
|
// The settings page lives in client.js; the toolbar runs in a worker process (src/worker).
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
import { createHttpHandler, ROUTE } from './src/host/http.mjs'
|
|
5
6
|
import { createLogger } from './src/host/log.mjs'
|
|
6
7
|
import { createSelectionService } from './src/host/service.mjs'
|
|
7
8
|
import { dataDirectory } from './src/shared/paths.mjs'
|
|
8
9
|
|
|
10
|
+
// Shown on the settings page beside the update check.
|
|
11
|
+
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
|
|
12
|
+
|
|
9
13
|
export const name = 'dsh-selection'
|
|
10
14
|
export const inject = ['webServer', 'connection']
|
|
11
15
|
|
|
@@ -26,6 +30,7 @@ export function apply(ctx, config = {}) {
|
|
|
26
30
|
dataDir,
|
|
27
31
|
desktop,
|
|
28
32
|
promptForPermission: config.promptForPermission !== false,
|
|
33
|
+
version,
|
|
29
34
|
})
|
|
30
35
|
log('info', 'plugin started', { platform: process.platform, desktop })
|
|
31
36
|
ctx.effect(() => () => service.dispose(), 'dsh-selection: service')
|
package/package.json
CHANGED
package/src/host/service.mjs
CHANGED
|
@@ -30,12 +30,14 @@ export class BadRequest extends Error {}
|
|
|
30
30
|
* platform?: string,
|
|
31
31
|
* startWorker?: typeof startWorker,
|
|
32
32
|
* openExternal?: (url: string) => void,
|
|
33
|
+
* version?: string,
|
|
33
34
|
* }} options `desktop` is false when the Host is not on the user's machine (e.g. a remote web Host).
|
|
34
35
|
* Tests replace `platform`, `startWorker` and `openExternal`.
|
|
35
36
|
*/
|
|
36
37
|
export function createSelectionService({
|
|
37
38
|
getLlm, log, dataDir = dataDirectory(), desktop, promptForPermission = true,
|
|
38
39
|
platform = process.platform, startWorker: launchWorker = startWorker, openExternal = (url) => openWithSystem(url, log),
|
|
40
|
+
version = '',
|
|
39
41
|
}) {
|
|
40
42
|
const settings = createSettingsStore({ file: join(dataDir, 'settings.json'), log })
|
|
41
43
|
const model = createModelClient({ getLlm, log })
|
|
@@ -101,6 +103,7 @@ export function createSelectionService({
|
|
|
101
103
|
enabled: current.enabled,
|
|
102
104
|
trigger: current.trigger,
|
|
103
105
|
toolbarSize: current.toolbarSize,
|
|
106
|
+
toolbarStyle: current.toolbarStyle,
|
|
104
107
|
skills: enabledSkills(current),
|
|
105
108
|
disabledApps: current.disabledApps,
|
|
106
109
|
clipboardFallback: current.clipboardFallback,
|
|
@@ -131,6 +134,15 @@ export function createSelectionService({
|
|
|
131
134
|
case 'cancel':
|
|
132
135
|
runs.get(message.requestId)?.abort()
|
|
133
136
|
break
|
|
137
|
+
case 'toolbar-style':
|
|
138
|
+
if (message.style === 'full' || message.style === 'mini') {
|
|
139
|
+
try {
|
|
140
|
+
settings.update((current) => ({ ...current, toolbarStyle: message.style }))
|
|
141
|
+
} catch (error) {
|
|
142
|
+
log('error', 'saving settings failed', { error: String(error?.message ?? error) })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
break
|
|
134
146
|
case 'disable-app':
|
|
135
147
|
if (typeof message.app === 'string' && message.app) {
|
|
136
148
|
try {
|
|
@@ -231,6 +243,7 @@ export function createSelectionService({
|
|
|
231
243
|
defaults: defaultSettings(),
|
|
232
244
|
status,
|
|
233
245
|
recentApps: [...recentApps],
|
|
246
|
+
version,
|
|
234
247
|
catalog: {
|
|
235
248
|
builtins: BUILTIN_SKILLS,
|
|
236
249
|
languages: LANGUAGES,
|
package/src/host/settings.mjs
CHANGED
|
@@ -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.
|
package/src/shared/icons.mjs
CHANGED
|
@@ -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',
|
|
@@ -20,12 +20,14 @@ const SEARCH_MAX_LENGTH = 2000
|
|
|
20
20
|
* idPrefix: string,
|
|
21
21
|
* now?: () => number,
|
|
22
22
|
* later?: (callback: () => void, ms: number) => unknown,
|
|
23
|
+
* trackPointer?: (on: boolean) => void,
|
|
23
24
|
* }} options `positions` are selection-hook's INVALID_COORDINATE and PositionLevel.SEL_FULL.
|
|
24
25
|
* `idPrefix` must differ between worker processes, so a late answer never lands in a new panel.
|
|
26
|
+
* `trackPointer` turns the hook's mouse moves on while the overlay shows, and off again.
|
|
25
27
|
*/
|
|
26
28
|
export function createController({
|
|
27
29
|
overlay, speech, send, log, writeClipboard, openUrl, positions, idPrefix,
|
|
28
|
-
now = Date.now, later = setTimeout,
|
|
30
|
+
now = Date.now, later = setTimeout, trackPointer = () => {},
|
|
29
31
|
}) {
|
|
30
32
|
let config = null
|
|
31
33
|
/** `text` goes to the model and the panel; `full` is what Copy, Read aloud and Search use. */
|
|
@@ -49,7 +51,11 @@ export function createController({
|
|
|
49
51
|
|
|
50
52
|
function configure(next) {
|
|
51
53
|
config = next
|
|
52
|
-
overlay.configure({
|
|
54
|
+
overlay.configure({
|
|
55
|
+
bar: next.skills.slice(0, next.toolbarSize),
|
|
56
|
+
more: next.skills.slice(next.toolbarSize),
|
|
57
|
+
style: next.toolbarStyle === 'mini' ? 'mini' : 'full',
|
|
58
|
+
})
|
|
53
59
|
if (!next.enabled) dismiss({ force: true, reason: 'disabled' })
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -71,6 +77,7 @@ export function createController({
|
|
|
71
77
|
selection = { id: ++nextSelection, text: cut(text, config.maxTextLength), full: text, app: data.programName }
|
|
72
78
|
shownAt = now()
|
|
73
79
|
overlay.showBar(anchor)
|
|
80
|
+
trackPointer(true)
|
|
74
81
|
// The text stays here until the user picks an action; the Host only learns which app it came from.
|
|
75
82
|
send({ type: 'selection', app: data.programName })
|
|
76
83
|
}
|
|
@@ -89,6 +96,10 @@ export function createController({
|
|
|
89
96
|
if (overlay.mode !== 'hidden' && !pressedOnOverlay) dismiss({ reason: 'click-outside', point })
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
function onMouseMove(point) {
|
|
100
|
+
if (overlay.mode !== 'hidden') overlay.pointer(point)
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
function onMouseWheel(point) {
|
|
93
104
|
if (overlay.mode === 'bar' && !overlay.contains(point)) dismiss({ reason: 'scroll-outside', point })
|
|
94
105
|
}
|
|
@@ -119,6 +130,7 @@ export function createController({
|
|
|
119
130
|
case 'skill':
|
|
120
131
|
return runSkill(message.id)
|
|
121
132
|
case 'menu':
|
|
133
|
+
if (message.action === 'toolbar-style') return switchStyle()
|
|
122
134
|
if (message.action !== 'disable-app' && message.action !== 'settings') return
|
|
123
135
|
if (message.action === 'disable-app' && selection) send({ type: 'disable-app', app: selection.app })
|
|
124
136
|
if (message.action === 'settings') send({ type: 'open-settings' })
|
|
@@ -165,6 +177,14 @@ export function createController({
|
|
|
165
177
|
}
|
|
166
178
|
}
|
|
167
179
|
|
|
180
|
+
/** Swaps the full toolbar and the mini icon where they are, and has the Host save the choice. */
|
|
181
|
+
function switchStyle() {
|
|
182
|
+
if (!config) return
|
|
183
|
+
const style = config.toolbarStyle === 'mini' ? 'full' : 'mini'
|
|
184
|
+
configure({ ...config, toolbarStyle: style })
|
|
185
|
+
send({ type: 'toolbar-style', style })
|
|
186
|
+
}
|
|
187
|
+
|
|
168
188
|
function runSkill(id) {
|
|
169
189
|
const skill = config?.skills.find((candidate) => candidate.id === id)
|
|
170
190
|
if (!skill || !selection) return
|
|
@@ -248,9 +268,10 @@ export function createController({
|
|
|
248
268
|
panel = null
|
|
249
269
|
overlay.setKeyable(false)
|
|
250
270
|
overlay.hide()
|
|
271
|
+
trackPointer(false)
|
|
251
272
|
}
|
|
252
273
|
|
|
253
|
-
return { configure, onSelection, onMouseDown, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
|
|
274
|
+
return { configure, onSelection, onMouseDown, onMouseMove, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
|
|
254
275
|
}
|
|
255
276
|
|
|
256
277
|
/** Shortens text without splitting a surrogate pair, which encodeURIComponent and JSON would reject. */
|
package/src/worker/main.mjs
CHANGED
|
@@ -61,12 +61,15 @@ controller = createController({
|
|
|
61
61
|
openUrl: (url) => openUrl(url, log),
|
|
62
62
|
positions: { invalid: SelectionHook.INVALID_COORDINATE, fullSelection: SelectionHook.PositionLevel.SEL_FULL },
|
|
63
63
|
idPrefix: String(process.pid),
|
|
64
|
+
// Mouse moves cost CPU on every move anywhere, so the hook reports them only while the overlay shows.
|
|
65
|
+
trackPointer: (on) => (on ? hook.enableMouseMoveEvent() : hook.disableMouseMoveEvent()),
|
|
64
66
|
})
|
|
65
67
|
applyConfig(config)
|
|
66
68
|
|
|
67
69
|
hook.on('text-selection', guarded('selection', (data) => controller.onSelection(data)))
|
|
68
70
|
hook.on('mouse-down', guarded('mouse-down', (point) => controller.onMouseDown(point)))
|
|
69
71
|
hook.on('mouse-wheel', guarded('mouse-wheel', (point) => controller.onMouseWheel(point)))
|
|
72
|
+
hook.on('mouse-move', guarded('mouse-move', (point) => controller.onMouseMove(point)))
|
|
70
73
|
hook.on('key-down', guarded('key-down', ({ uniKey }) => {
|
|
71
74
|
if (uniKey === 'Alt' && !altDown) {
|
|
72
75
|
altDown = true
|
package/src/worker/overlay.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { fitWindow, place, roomBelow } from './placement.mjs'
|
|
|
10
10
|
const PUMP_VISIBLE_MS = 16
|
|
11
11
|
const PUMP_HIDDEN_MS = 250
|
|
12
12
|
const PAGE_READY_TIMEOUT_MS = 5000
|
|
13
|
+
// Pointer updates for hover: one per frame is as often as the page can draw them.
|
|
14
|
+
const POINTER_INTERVAL_MS = 16
|
|
13
15
|
// The least height a panel gets for its answer before it scrolls: about eight lines plus the header and
|
|
14
16
|
// the follow-up box. Below the selection it may grow further, down to the bottom of the screen.
|
|
15
17
|
const PANEL_MIN_ROOM = 280
|
|
@@ -57,6 +59,9 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
57
59
|
focused: false,
|
|
58
60
|
alwaysOnTop: true,
|
|
59
61
|
windowsSkipTaskbar: true,
|
|
62
|
+
// tao keeps a system shadow on undecorated windows unless told not to; on Windows it outlines the
|
|
63
|
+
// whole transparent window with a 1px border and adds hidden insets around the client area.
|
|
64
|
+
windowsUndecoratedShadow: false,
|
|
60
65
|
macosHasShadow: false,
|
|
61
66
|
})
|
|
62
67
|
// WebView2 defaults its data folder next to the executable, i.e. inside the DSH install directory.
|
|
@@ -76,6 +81,10 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
76
81
|
let bounds = null
|
|
77
82
|
// How tall the panel was last allowed to grow, and whether to fill that at once, as told to the page.
|
|
78
83
|
let room = null
|
|
84
|
+
// The pointer as passed to the page: at most once a frame, and once more when it leaves the window.
|
|
85
|
+
let pointerTimer = null
|
|
86
|
+
let pointerNext = null
|
|
87
|
+
let pointerInside = false
|
|
79
88
|
// Once the user drags the window it stays where they put it: later size changes keep its top-left.
|
|
80
89
|
let moved = false
|
|
81
90
|
let drag = null
|
|
@@ -179,7 +188,7 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
179
188
|
}
|
|
180
189
|
|
|
181
190
|
return {
|
|
182
|
-
/** @param {{ bar: object[], more: object[] }} toolbar Skills on the toolbar and in the
|
|
191
|
+
/** @param {{ bar: object[], more: object[], style: 'full' | 'mini' }} toolbar Skills on the toolbar and in its menu, and how the toolbar is drawn. */
|
|
183
192
|
configure: (toolbar) => call('configure', toolbar),
|
|
184
193
|
/** @param {Anchor} point In selection-hook coordinates. */
|
|
185
194
|
showBar(point) {
|
|
@@ -229,6 +238,9 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
229
238
|
},
|
|
230
239
|
hide() {
|
|
231
240
|
if (mode === 'hidden') return
|
|
241
|
+
clearTimeout(pointerTimer)
|
|
242
|
+
pointerTimer = null
|
|
243
|
+
pointerInside = false
|
|
232
244
|
overlayWindow.hide()
|
|
233
245
|
mode = 'hidden'
|
|
234
246
|
placement = null
|
|
@@ -239,6 +251,23 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
239
251
|
call('reset')
|
|
240
252
|
setPump(PUMP_HIDDEN_MS)
|
|
241
253
|
},
|
|
254
|
+
/**
|
|
255
|
+
* Passes the pointer (selection-hook coordinates) to the page while the window shows: macOS sends
|
|
256
|
+
* no mouse moves to the window of a background process, so the page draws hover from these.
|
|
257
|
+
*/
|
|
258
|
+
pointer(point) {
|
|
259
|
+
if (mode === 'hidden' || !bounds) return
|
|
260
|
+
const { x, y } = toLogical(point)
|
|
261
|
+
const inside = x >= bounds.x && x < bounds.x + bounds.width && y >= bounds.y && y < bounds.y + bounds.height
|
|
262
|
+
if (!inside && !pointerInside) return
|
|
263
|
+
pointerInside = inside
|
|
264
|
+
pointerNext = inside ? { x: Math.round(x - bounds.x), y: Math.round(y - bounds.y) } : null
|
|
265
|
+
if (pointerTimer) return
|
|
266
|
+
pointerTimer = setTimeout(() => {
|
|
267
|
+
pointerTimer = null
|
|
268
|
+
if (mode !== 'hidden') call('pointer', pointerNext)
|
|
269
|
+
}, POINTER_INTERVAL_MS)
|
|
270
|
+
},
|
|
242
271
|
get mode() { return mode },
|
|
243
272
|
/** Where the window was placed, in the same coordinates as `contains` (self tests and diagnostics). */
|
|
244
273
|
get bounds() { return bounds },
|
package/src/worker/page.mjs
CHANGED
|
@@ -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
|
@@ -49,9 +49,10 @@ svg { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; s
|
|
|
49
49
|
|
|
50
50
|
button, select, textarea { font: inherit; color: inherit; }
|
|
51
51
|
button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; background: none; border-radius: 8px; cursor: default; }
|
|
52
|
-
/* Hover
|
|
53
|
-
|
|
54
|
-
button
|
|
52
|
+
/* Hover is data-hover, set from the pointer the worker reports (see bar.pointer in toolbar.js): macOS
|
|
53
|
+
sends no mouse moves to the window of a background process, so :hover would never apply. */
|
|
54
|
+
button[data-hover] { background: var(--hover); }
|
|
55
|
+
button:active { background: var(--press); }
|
|
55
56
|
button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
56
57
|
.icon-button { width: 30px; height: 30px; padding: 0; color: var(--text); }
|
|
57
58
|
.icon-button[disabled] { opacity: 0.35; pointer-events: none; }
|
|
@@ -63,7 +64,13 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
63
64
|
.skill { height: 32px; padding: 0 10px 0 9px; font-weight: 500; letter-spacing: 0.1px; }
|
|
64
65
|
.skill.done svg { color: #1f9d55; }
|
|
65
66
|
.divider { width: 1px; height: 16px; margin: 0 4px; background: var(--hairline); }
|
|
66
|
-
.more-button[aria-expanded="true"] { background: var(--press); }
|
|
67
|
+
.more-button[aria-expanded="true"], .mini-button[aria-expanded="true"] { background: var(--press); }
|
|
68
|
+
/* Mini toolbar: one icon, and every skill in its menu. */
|
|
69
|
+
.mini-button { display: none; color: var(--accent); }
|
|
70
|
+
.mini-button svg { width: 20px; height: 20px; }
|
|
71
|
+
[data-style="mini"] .bar { padding: 3px; border-radius: 10px; }
|
|
72
|
+
[data-style="mini"] .bar-skills, [data-style="mini"] .divider, [data-style="mini"] .more-button { display: none; }
|
|
73
|
+
[data-style="mini"] .mini-button { display: inline-flex; }
|
|
67
74
|
|
|
68
75
|
/* ⋯ menu, opened below the more button */
|
|
69
76
|
.menu { display: none; position: absolute; min-width: 190px; padding: 6px; border-radius: 12px; z-index: 1; }
|
|
@@ -90,10 +97,10 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
90
97
|
color: var(--text); font-size: 12px; font-weight: 500; line-height: 26px; cursor: default;
|
|
91
98
|
text-overflow: ellipsis; white-space: nowrap; overflow: hidden;
|
|
92
99
|
}
|
|
93
|
-
|
|
100
|
+
.panel-language[data-hover] { background-color: var(--press); }
|
|
94
101
|
[data-kind="translate"] .panel-language { display: inline-block; }
|
|
95
102
|
.tool { width: 28px; height: 28px; color: var(--muted); }
|
|
96
|
-
[data-
|
|
103
|
+
.tool[data-hover] { color: var(--text); }
|
|
97
104
|
.tool[aria-pressed="true"] { color: var(--accent); background: var(--hover); }
|
|
98
105
|
.tool.done { color: #1f9d55; }
|
|
99
106
|
[data-kind="ask"] .tool[data-tool="retry"] { display: none; }
|
|
@@ -101,6 +108,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
101
108
|
.panel-body { flex: 0 1 auto; min-height: 0; padding: 0 16px 14px; max-height: 340px; overflow-y: auto; overscroll-behavior: contain; }
|
|
102
109
|
[data-fill] .panel { height: var(--room); }
|
|
103
110
|
[data-fill] .panel-body { flex-grow: 1; max-height: none; }
|
|
111
|
+
/* A styled scrollbar, so Windows draws a thin bar without its classic arrow buttons. */
|
|
104
112
|
.panel-body::-webkit-scrollbar { width: 8px; }
|
|
105
113
|
.panel-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 4px; border: 2px solid var(--surface); }
|
|
106
114
|
.source {
|
|
@@ -126,12 +134,15 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
126
134
|
.composer { flex: none; display: none; align-items: flex-end; gap: 6px; padding: 8px 10px 10px 14px; border-top: 1px solid var(--hairline); }
|
|
127
135
|
[data-kind="ask"] .composer, [data-composer="shown"] .composer { display: flex; }
|
|
128
136
|
.composer textarea {
|
|
129
|
-
flex: 1; min-height:
|
|
137
|
+
flex: 1; min-height: 28px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
|
|
138
|
+
overflow-y: hidden; scrollbar-width: none;
|
|
130
139
|
line-height: 1.5; user-select: text; -webkit-user-select: text; cursor: text;
|
|
131
140
|
}
|
|
141
|
+
/* Past its tallest size it scrolls with the wheel and the caret, without a bar. */
|
|
142
|
+
.composer textarea::-webkit-scrollbar { display: none; }
|
|
132
143
|
.composer textarea::placeholder { color: var(--muted); }
|
|
133
144
|
.send { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff; }
|
|
134
|
-
[data-
|
|
145
|
+
.send[data-hover] { background: var(--accent); filter: brightness(1.08); }
|
|
135
146
|
.send[disabled] { opacity: 0.35; }
|
|
136
147
|
|
|
137
148
|
/* Model output */
|
|
@@ -145,7 +156,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
145
156
|
.markdown pre code { padding: 0; background: none; font-size: 12px; line-height: 1.5; }
|
|
146
157
|
.markdown blockquote { padding-left: 10px; border-left: 3px solid var(--hairline); color: var(--muted); }
|
|
147
158
|
.markdown a { color: var(--accent); text-decoration: none; cursor: pointer; }
|
|
148
|
-
|
|
159
|
+
.markdown a[data-hover] { text-decoration: underline; }
|
|
149
160
|
.markdown table { border-collapse: collapse; font-size: 12.5px; }
|
|
150
161
|
.markdown th, .markdown td { padding: 4px 8px; border: 1px solid var(--hairline); text-align: left; vertical-align: top; }
|
|
151
162
|
.markdown th { background: var(--soft); font-weight: 600; }
|
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,34 @@
|
|
|
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
|
+
// The mini toolbar opens its menu once the pointer rests on the icon, and closes it once the pointer
|
|
83
|
+
// has been out of the window for a moment.
|
|
84
|
+
const HOVER_OPEN_MS = 120
|
|
85
|
+
const HOVER_CLOSE_MS = 300
|
|
86
|
+
let openTimer = null
|
|
87
|
+
let closeTimer = null
|
|
88
|
+
/** The control drawn as hovered. */
|
|
89
|
+
let hovered = null
|
|
90
|
+
|
|
91
|
+
// The menu offers whichever style is not in use.
|
|
92
|
+
function drawStyleItem() {
|
|
93
|
+
els.styleItem.innerHTML = `${icon(style === 'mini' ? 'maximize' : 'minimize')}<span></span>`
|
|
94
|
+
els.styleItem.querySelector('span').textContent = style === 'mini' ? t.fullStyle : t.miniStyle
|
|
95
|
+
}
|
|
96
|
+
drawStyleItem()
|
|
97
|
+
|
|
98
|
+
function stopHoverTimers() {
|
|
99
|
+
clearTimeout(openTimer)
|
|
100
|
+
clearTimeout(closeTimer)
|
|
101
|
+
openTimer = null
|
|
102
|
+
closeTimer = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clearHover() {
|
|
106
|
+
hovered?.removeAttribute('data-hover')
|
|
107
|
+
hovered = null
|
|
108
|
+
}
|
|
76
109
|
let state = emptyState()
|
|
77
110
|
let renderTimer = null
|
|
78
111
|
// Timers rather than requestAnimationFrame: the view can be occluded or the screen locked, and then
|
|
@@ -90,13 +123,16 @@
|
|
|
90
123
|
}
|
|
91
124
|
|
|
92
125
|
function setMenu(open) {
|
|
126
|
+
stopHoverTimers()
|
|
93
127
|
if (open) {
|
|
94
128
|
const bar = $('.bar')
|
|
95
|
-
|
|
129
|
+
const button = style === 'mini' ? els.mini : els.more
|
|
130
|
+
els.menu.style.left = `${bar.offsetLeft + button.offsetLeft - 6}px`
|
|
96
131
|
els.menu.style.top = `${bar.offsetTop + bar.offsetHeight + 6}px`
|
|
97
132
|
}
|
|
98
133
|
body.dataset.menu = open ? 'open' : 'closed'
|
|
99
|
-
els.more.setAttribute('aria-expanded', String(open))
|
|
134
|
+
els.more.setAttribute('aria-expanded', String(open && style === 'full'))
|
|
135
|
+
els.mini.setAttribute('aria-expanded', String(open && style === 'mini'))
|
|
100
136
|
queueSize()
|
|
101
137
|
}
|
|
102
138
|
|
|
@@ -151,12 +187,23 @@
|
|
|
151
187
|
window.bar = {
|
|
152
188
|
configure(next) {
|
|
153
189
|
skills = next
|
|
154
|
-
|
|
155
|
-
|
|
190
|
+
const nextStyle = next.style === 'mini' ? 'mini' : 'full'
|
|
191
|
+
if (nextStyle !== style) {
|
|
192
|
+
setMenu(false)
|
|
193
|
+
style = nextStyle
|
|
194
|
+
}
|
|
195
|
+
body.dataset.style = style
|
|
196
|
+
// The mini toolbar has no skill buttons of its own: every skill is in its menu.
|
|
197
|
+
const onBar = style === 'mini' ? [] : next.bar
|
|
198
|
+
const inMenu = style === 'mini' ? [...next.bar, ...next.more] : next.more
|
|
199
|
+
els.barSkills.replaceChildren(...onBar.map((skill) => skillButton(skill, 'skill')))
|
|
200
|
+
els.menuSkills.replaceChildren(...inMenu.map((skill) => skillButton(skill, 'menu-item')))
|
|
201
|
+
drawStyleItem()
|
|
156
202
|
queueSize()
|
|
157
203
|
},
|
|
158
204
|
showBar() {
|
|
159
|
-
|
|
205
|
+
stopHoverTimers()
|
|
206
|
+
clearHover()
|
|
160
207
|
body.dataset.mode = 'bar'
|
|
161
208
|
setMenu(false)
|
|
162
209
|
},
|
|
@@ -164,7 +211,7 @@
|
|
|
164
211
|
if (body.dataset.mode === 'bar') setMenu(Boolean(open))
|
|
165
212
|
},
|
|
166
213
|
showPanel({ skill, text, app, languages, translateTarget }) {
|
|
167
|
-
|
|
214
|
+
clearHover()
|
|
168
215
|
body.dataset.mode = 'panel'
|
|
169
216
|
body.dataset.menu = 'closed'
|
|
170
217
|
body.dataset.kind = skill.kind === 'ask' ? 'ask' : skill.id === 'translate' ? 'translate' : 'prompt'
|
|
@@ -233,6 +280,7 @@
|
|
|
233
280
|
if (requestId !== state.requestId) return
|
|
234
281
|
state.pending = false
|
|
235
282
|
body.dataset.composer = 'shown'
|
|
283
|
+
resizeInput()
|
|
236
284
|
scheduleRender()
|
|
237
285
|
},
|
|
238
286
|
fail(requestId, failure) {
|
|
@@ -250,8 +298,8 @@
|
|
|
250
298
|
queueSize()
|
|
251
299
|
},
|
|
252
300
|
flash(skillId) {
|
|
253
|
-
// A skill run from the
|
|
254
|
-
flashDone(els.barSkills.querySelector(`[data-skill="${skillId}"]`) || els.more, t.copied)
|
|
301
|
+
// A skill run from the menu confirms on the button that opens the menu: the menu has closed by then.
|
|
302
|
+
flashDone(els.barSkills.querySelector(`[data-skill="${skillId}"]`) || (style === 'mini' ? els.mini : els.more), t.copied)
|
|
255
303
|
},
|
|
256
304
|
/**
|
|
257
305
|
* How tall the panel may grow before it scrolls inside, from the worker's placement. With `fill`
|
|
@@ -264,8 +312,36 @@
|
|
|
264
312
|
else delete body.dataset.fill
|
|
265
313
|
queueSize()
|
|
266
314
|
},
|
|
315
|
+
/**
|
|
316
|
+
* Where the pointer is in the window, or null once it has left, from the worker's global mouse
|
|
317
|
+
* hook: macOS sends no mouse moves to the window of a background process, so hover is drawn here.
|
|
318
|
+
*/
|
|
319
|
+
pointer(point) {
|
|
320
|
+
const target = point ? document.elementFromPoint(point.x, point.y) : null
|
|
321
|
+
const control = target?.closest('button, select, a') ?? null
|
|
322
|
+
if (control !== hovered) {
|
|
323
|
+
clearHover()
|
|
324
|
+
control?.setAttribute('data-hover', '')
|
|
325
|
+
hovered = control
|
|
326
|
+
}
|
|
327
|
+
if (style !== 'mini' || body.dataset.mode !== 'bar') return
|
|
328
|
+
if (body.dataset.menu !== 'open') {
|
|
329
|
+
if (!target?.closest('.mini-button') || press) {
|
|
330
|
+
clearTimeout(openTimer)
|
|
331
|
+
openTimer = null
|
|
332
|
+
} else if (openTimer === null) {
|
|
333
|
+
openTimer = setTimeout(() => setMenu(true), HOVER_OPEN_MS)
|
|
334
|
+
}
|
|
335
|
+
} else if (point) {
|
|
336
|
+
clearTimeout(closeTimer)
|
|
337
|
+
closeTimer = null
|
|
338
|
+
} else if (closeTimer === null) {
|
|
339
|
+
closeTimer = setTimeout(() => setMenu(false), HOVER_CLOSE_MS)
|
|
340
|
+
}
|
|
341
|
+
},
|
|
267
342
|
reset() {
|
|
268
|
-
|
|
343
|
+
stopHoverTimers()
|
|
344
|
+
clearHover()
|
|
269
345
|
body.dataset.mode = 'hidden'
|
|
270
346
|
body.dataset.menu = 'closed'
|
|
271
347
|
if (document.activeElement) document.activeElement.blur()
|
|
@@ -282,6 +358,7 @@
|
|
|
282
358
|
type: 'probe',
|
|
283
359
|
mode: body.dataset.mode,
|
|
284
360
|
menu: body.dataset.menu,
|
|
361
|
+
hovered: hovered ? hovered.getAttribute('aria-label') || hovered.textContent.trim() : null,
|
|
285
362
|
viewport: { width: innerWidth, height: innerHeight },
|
|
286
363
|
card: { width: Math.round(box.width), height: Math.round(box.height) },
|
|
287
364
|
buttons: buttons.map((button) => button.getAttribute('aria-label') || button.textContent.trim()),
|
|
@@ -339,7 +416,12 @@
|
|
|
339
416
|
|
|
340
417
|
function resizeInput() {
|
|
341
418
|
els.input.style.height = 'auto'
|
|
342
|
-
|
|
419
|
+
const height = els.input.scrollHeight
|
|
420
|
+
// A hidden box measures nothing; it is measured again when it shows.
|
|
421
|
+
if (height) els.input.style.height = `${Math.min(height, 96)}px`
|
|
422
|
+
// Scrollable only past its tallest size: at fractional display scaling one line can overflow by a
|
|
423
|
+
// fraction of a pixel, which would otherwise let it scroll.
|
|
424
|
+
els.input.style.overflowY = height > 96 ? 'auto' : 'hidden'
|
|
343
425
|
els.send.disabled = !els.input.value.trim()
|
|
344
426
|
}
|
|
345
427
|
|
|
@@ -358,6 +440,10 @@
|
|
|
358
440
|
post({ type: 'skill', id: skill.dataset.skill })
|
|
359
441
|
return
|
|
360
442
|
}
|
|
443
|
+
if (event.target.closest('.mini-button')) {
|
|
444
|
+
setMenu(true)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
361
447
|
if (event.target.closest('.more-button')) {
|
|
362
448
|
setMenu(body.dataset.menu !== 'open')
|
|
363
449
|
return
|
|
@@ -478,13 +564,6 @@
|
|
|
478
564
|
if (body.dataset.mode === 'panel') post({ type: 'keyboard', needed: true })
|
|
479
565
|
})
|
|
480
566
|
|
|
481
|
-
// WebKit keeps :hover on whatever was under the pointer when it last saw one, through hiding and
|
|
482
|
-
// showing the window, and this process is never the active app, so a pointer that does not move
|
|
483
|
-
// over the page is not tracked. Hover styles therefore wait for a real movement after each show.
|
|
484
|
-
document.addEventListener('pointermove', () => {
|
|
485
|
-
if (!('pointer' in body.dataset)) body.dataset.pointer = ''
|
|
486
|
-
})
|
|
487
|
-
|
|
488
567
|
// Dropping a link or file would navigate the view away from this page.
|
|
489
568
|
for (const type of ['dragover', 'drop']) document.addEventListener(type, (event) => event.preventDefault())
|
|
490
569
|
window.addEventListener('error', (event) => post({ type: 'page-error', message: String(event.message) }))
|