@followbin/dsh-selection 0.0.0-stage → 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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -2
  3. package/README.zh-CN.md +152 -0
  4. package/THIRD_PARTY_NOTICES.md +22 -0
  5. package/client.js +1070 -0
  6. package/cordis.patch.yml +4 -0
  7. package/icon.svg +1 -0
  8. package/index.js +41 -0
  9. package/locale/en.json +6 -0
  10. package/locale/zh.json +6 -0
  11. package/package.json +90 -4
  12. package/src/host/files.mjs +29 -0
  13. package/src/host/http.mjs +153 -0
  14. package/src/host/llm.mjs +194 -0
  15. package/src/host/log.mjs +40 -0
  16. package/src/host/service.mjs +334 -0
  17. package/src/host/settings.mjs +282 -0
  18. package/src/host/skills.mjs +88 -0
  19. package/src/host/supervisor.mjs +126 -0
  20. package/src/host/usage.mjs +128 -0
  21. package/src/shared/icons.mjs +66 -0
  22. package/src/shared/open-with-system.mjs +15 -0
  23. package/src/shared/paths.mjs +7 -0
  24. package/src/shared/protocol.mjs +4 -0
  25. package/src/worker/controller.mjs +274 -0
  26. package/src/worker/main.mjs +147 -0
  27. package/src/worker/native-window.mjs +319 -0
  28. package/src/worker/open-url.mjs +20 -0
  29. package/src/worker/overlay.mjs +266 -0
  30. package/src/worker/page.mjs +14 -0
  31. package/src/worker/placement.mjs +58 -0
  32. package/src/worker/speech.mjs +136 -0
  33. package/ui/markdown.js +122 -0
  34. package/ui/toolbar.css +162 -0
  35. package/ui/toolbar.html +54 -0
  36. package/ui/toolbar.js +552 -0
  37. package/vendor/selection-hook/LICENSE +21 -0
  38. package/vendor/selection-hook/index.d.ts +517 -0
  39. package/vendor/selection-hook/index.js +662 -0
  40. package/vendor/selection-hook/package.json +10 -0
  41. package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
  42. package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
  43. package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
  44. package/vendor/selection-hook/prebuilds/win32-x64/selection-hook.node +0 -0
@@ -0,0 +1,274 @@
1
+ // What the toolbar does in response to the mouse, the keyboard, the page and the Host. No native code
2
+ // here: main.mjs connects it to selection-hook, the overlay window and the Host channel.
3
+
4
+ const COPY_FEEDBACK_MS = 700
5
+ // Keys pressed right after the toolbar appears may be selection-hook's own simulated copy.
6
+ const KEY_DISMISS_GRACE_MS = 300
7
+ const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'Fn'])
8
+ // Search engines read only the start of a query anyway.
9
+ const SEARCH_MAX_LENGTH = 2000
10
+
11
+ /**
12
+ * @param {{
13
+ * overlay: Awaited<ReturnType<typeof import('./overlay.mjs').createOverlay>>,
14
+ * speech: { stop: () => void, toggle: (text: string) => void },
15
+ * send: (message: object) => void,
16
+ * log: (level: string, message: string, fields?: object) => void,
17
+ * writeClipboard: (text: string) => void,
18
+ * openUrl: (url: string) => void,
19
+ * positions: { invalid: number, fullSelection: number },
20
+ * idPrefix: string,
21
+ * now?: () => number,
22
+ * later?: (callback: () => void, ms: number) => unknown,
23
+ * }} options `positions` are selection-hook's INVALID_COORDINATE and PositionLevel.SEL_FULL.
24
+ * `idPrefix` must differ between worker processes, so a late answer never lands in a new panel.
25
+ */
26
+ export function createController({
27
+ overlay, speech, send, log, writeClipboard, openUrl, positions, idPrefix,
28
+ now = Date.now, later = setTimeout,
29
+ }) {
30
+ let config = null
31
+ /** `text` goes to the model and the panel; `full` is what Copy, Read aloud and Search use. */
32
+ /** @type {{ id: number, text: string, full: string, app: string } | null} */
33
+ let selection = null
34
+ /** The skill the panel was opened with; the header's retry and language menu run it again. */
35
+ /** @type {{ skillId: string, kind: string, language?: string } | null} */
36
+ let panel = null
37
+ /** @type {{ requestId: string, skillId: string, kind: string, language?: string, question?: string, answer: string, history: object[] } | null} */
38
+ let active = null
39
+ let pinned = false
40
+ let shownAt = 0
41
+ const held = new Set()
42
+ // Whether the latest mouse press landed on the overlay. A gesture that starts there (dragging the
43
+ // window, selecting part of an answer) belongs to the overlay, even if it ends somewhere else.
44
+ let pressedOnOverlay = false
45
+ let nextSelection = 0
46
+ let nextRequest = 0
47
+
48
+ const valid = (point) => Boolean(point) && point.x !== positions.invalid && point.y !== positions.invalid
49
+
50
+ function configure(next) {
51
+ config = next
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
+ })
57
+ if (!next.enabled) dismiss({ force: true, reason: 'disabled' })
58
+ }
59
+
60
+ function onSelection(data) {
61
+ if (!config?.enabled || (config.trigger === 'modifier' && !held.has('Alt'))) return
62
+ const text = (data.text ?? '').trim()
63
+ if (!text || config.disabledApps.includes(data.programName)) return
64
+ // Dragging the overlay looks like a drag-selection to the hook, which then re-reads whatever is still
65
+ // selected in the app behind it. A gesture that began on the overlay never replaces it.
66
+ if (pressedOnOverlay && overlay.mode !== 'hidden') return
67
+ const anchor = anchorOf(data)
68
+ if (!anchor) return
69
+ if (overlay.mode !== 'hidden') log('debug', 'overlay replaced by a new selection', { mode: overlay.mode, bounds: overlay.bounds })
70
+ cancelActive()
71
+ speech.stop()
72
+ pinned = false
73
+ panel = null
74
+ overlay.setKeyable(false)
75
+ selection = { id: ++nextSelection, text: cut(text, config.maxTextLength), full: text, app: data.programName }
76
+ shownAt = now()
77
+ overlay.showBar(anchor)
78
+ // The text stays here until the user picks an action; the Host only learns which app it came from.
79
+ send({ type: 'selection', app: data.programName })
80
+ }
81
+
82
+ function anchorOf(data) {
83
+ if (data.posLevel >= positions.fullSelection && valid(data.endBottom) && valid(data.startTop)) {
84
+ return { x: data.endBottom.x, y: data.endBottom.y, top: data.startTop.y, precise: true }
85
+ }
86
+ if (!valid(data.mousePosEnd)) return null
87
+ const start = valid(data.mousePosStart) ? data.mousePosStart : data.mousePosEnd
88
+ return { x: data.mousePosEnd.x, y: data.mousePosEnd.y, top: Math.min(start.y, data.mousePosEnd.y) - 10, precise: false }
89
+ }
90
+
91
+ function onMouseDown(point) {
92
+ pressedOnOverlay = overlay.mode !== 'hidden' && overlay.contains(point)
93
+ if (overlay.mode !== 'hidden' && !pressedOnOverlay) dismiss({ reason: 'click-outside', point })
94
+ }
95
+
96
+ function onMouseWheel(point) {
97
+ if (overlay.mode === 'bar' && !overlay.contains(point)) dismiss({ reason: 'scroll-outside', point })
98
+ }
99
+
100
+ function onKeyDown(key) {
101
+ if (MODIFIER_KEYS.has(key)) {
102
+ held.add(key)
103
+ return
104
+ }
105
+ if (key === 'Escape') {
106
+ // The hook also sees keys typed into the panel. There the page handles Escape itself, and an
107
+ // input method may be using it to cancel a composition.
108
+ if (overlay.mode === 'panel' && overlay.isKey()) return
109
+ speech.stop()
110
+ dismiss({ force: true, reason: 'escape' })
111
+ } else if (overlay.mode === 'bar' && !held.has('Meta') && !held.has('Control') && now() - shownAt > KEY_DISMISS_GRACE_MS) {
112
+ // Typing elsewhere means the user has moved on; a shortcut such as ⌘C does not.
113
+ dismiss({ reason: 'typing' })
114
+ }
115
+ }
116
+
117
+ function onKeyUp(key) {
118
+ held.delete(key)
119
+ }
120
+
121
+ function onPage(message) {
122
+ switch (message?.type) {
123
+ case 'skill':
124
+ return runSkill(message.id)
125
+ case 'menu':
126
+ if (message.action === 'toolbar-style') return switchStyle()
127
+ if (message.action !== 'disable-app' && message.action !== 'settings') return
128
+ if (message.action === 'disable-app' && selection) send({ type: 'disable-app', app: selection.app })
129
+ if (message.action === 'settings') send({ type: 'open-settings' })
130
+ return dismiss({ force: true, reason: `menu-${message.action}` })
131
+ case 'drag':
132
+ overlay.drag(message.phase, { x: message.x, y: message.y })
133
+ return
134
+ case 'retry':
135
+ if (message.requestId) {
136
+ // A failure notice retries its own request, and only while that request is the one on screen.
137
+ if (active?.requestId === message.requestId) {
138
+ startRequest(active.skillId, active.kind, { language: active.language, question: active.question, history: active.history })
139
+ }
140
+ } else if (panel?.kind === 'prompt') {
141
+ startRequest(panel.skillId, 'prompt', { language: panel.language, history: [] })
142
+ }
143
+ return
144
+ case 'language':
145
+ if (panel?.skillId === 'translate' && typeof message.code === 'string') {
146
+ panel.language = message.code
147
+ startRequest('translate', 'prompt', { language: message.code, history: [] })
148
+ }
149
+ return
150
+ case 'ask':
151
+ if (panel && typeof message.question === 'string' && message.question.trim()) {
152
+ startRequest('ask', 'ask', { question: message.question.trim().slice(0, 2000), history: conversation() })
153
+ }
154
+ return
155
+ case 'copy-result':
156
+ if (typeof message.text === 'string') writeClipboard(message.text)
157
+ return
158
+ case 'open-url':
159
+ if (typeof message.url === 'string') openUrl(message.url)
160
+ return
161
+ case 'pin':
162
+ pinned = Boolean(message.pinned)
163
+ return
164
+ case 'keyboard':
165
+ overlay.setKeyable(Boolean(message.needed))
166
+ return
167
+ case 'close':
168
+ speech.stop()
169
+ return dismiss({ force: true, reason: 'close-button' })
170
+ }
171
+ }
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
+
181
+ function runSkill(id) {
182
+ const skill = config?.skills.find((candidate) => candidate.id === id)
183
+ if (!skill || !selection) return
184
+ switch (skill.kind) {
185
+ case 'prompt':
186
+ panel = { skillId: skill.id, kind: 'prompt' }
187
+ overlay.showPanel({ skill, text: selection.text, app: selection.app, languages: config.languages, translateTarget: config.translateTarget })
188
+ return startRequest(skill.id, 'prompt', { history: [] })
189
+ case 'ask':
190
+ cancelActive()
191
+ panel = { skillId: 'ask', kind: 'ask' }
192
+ active = { requestId: '', skillId: 'ask', kind: 'ask', answer: '', history: [] }
193
+ overlay.showPanel({ skill, text: selection.text, app: selection.app, languages: config.languages })
194
+ // The ask box needs keystrokes; the non-activating panel takes them without activating this process.
195
+ overlay.setKeyable(true)
196
+ return
197
+ case 'speak':
198
+ return speech.toggle(selection.full)
199
+ case 'copy': {
200
+ writeClipboard(selection.full)
201
+ overlay.flash('copy')
202
+ const copied = selection.id
203
+ later(() => {
204
+ if (selection?.id === copied && overlay.mode === 'bar') dismiss({ reason: 'copied' })
205
+ }, COPY_FEEDBACK_MS)
206
+ return
207
+ }
208
+ case 'search':
209
+ openUrl(config.searchTemplate.replace('%s', encodeURIComponent(cut(selection.full, SEARCH_MAX_LENGTH))))
210
+ return dismiss({ force: true, reason: 'search' })
211
+ }
212
+ }
213
+
214
+ function startRequest(skillId, kind, { language, question, history }) {
215
+ if (!selection) return
216
+ cancelActive()
217
+ const requestId = `${idPrefix}-${selection.id}-${++nextRequest}`
218
+ active = { requestId, skillId, kind, language, question, answer: '', history }
219
+ overlay.begin(requestId, { question })
220
+ send({ type: 'run', requestId, skillId, text: selection.text, language, question, history })
221
+ }
222
+
223
+ /**
224
+ * The panel's finished turns, for a follow-up question. The first turn of a panel opened with
225
+ * another skill is that skill's answer; the Host shows the model what it was asked to do there.
226
+ */
227
+ function conversation() {
228
+ if (!active) return []
229
+ const history = [...active.history]
230
+ if (active.answer) {
231
+ history.push(active.question
232
+ ? { question: active.question, answer: active.answer }
233
+ : { skillId: active.skillId, language: active.language, answer: active.answer })
234
+ }
235
+ return history
236
+ }
237
+
238
+ function onResult({ requestId, chunk, done, error }) {
239
+ if (!active || active.requestId !== requestId) return
240
+ if (chunk) {
241
+ active.answer += chunk
242
+ overlay.append(requestId, chunk)
243
+ }
244
+ if (error) overlay.fail(requestId, error)
245
+ else if (done) overlay.done(requestId)
246
+ }
247
+
248
+ function cancelActive() {
249
+ if (active?.requestId) send({ type: 'cancel', requestId: active.requestId })
250
+ active = null
251
+ }
252
+
253
+ function dismiss({ force = false, reason = 'other', point } = {}) {
254
+ if (overlay.mode === 'hidden' || (pinned && !force)) return
255
+ // Positions only, never text: enough to tell a misplaced hit test from an intended dismissal.
256
+ log('debug', 'overlay dismissed', { reason, mode: overlay.mode, point, bounds: overlay.bounds })
257
+ cancelActive()
258
+ pinned = false
259
+ pressedOnOverlay = false
260
+ selection = null
261
+ panel = null
262
+ overlay.setKeyable(false)
263
+ overlay.hide()
264
+ }
265
+
266
+ return { configure, onSelection, onMouseDown, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
267
+ }
268
+
269
+ /** Shortens text without splitting a surrogate pair, which encodeURIComponent and JSON would reject. */
270
+ export function cut(text, max) {
271
+ if (text.length <= max) return text
272
+ const end = /[\uD800-\uDBFF]/.test(text[max - 1]) ? max - 1 : max
273
+ return text.slice(0, end)
274
+ }
@@ -0,0 +1,147 @@
1
+ // Selection worker: a child process of the DSH Host. selection-hook watches the mouse and reads the
2
+ // selection; the overlay shows the toolbar and results; controller.mjs decides what happens.
3
+ // Model calls happen in the Host, never here.
4
+ import { createRequire } from 'node:module'
5
+ import { RESTART_NOW } from '../shared/protocol.mjs'
6
+ import { createController } from './controller.mjs'
7
+ import { openUrl } from './open-url.mjs'
8
+ import { createOverlay } from './overlay.mjs'
9
+ import { createSpeech } from './speech.mjs'
10
+
11
+ const require = createRequire(import.meta.url)
12
+ const SelectionHook = require('../../vendor/selection-hook/index.js')
13
+
14
+ const PERMISSION_POLL_MS = 1000
15
+
16
+ const send = (message) => process.send?.(message)
17
+ const log = (level, message, fields) => send({ type: 'log', level, message, fields })
18
+ const hook = new SelectionHook()
19
+ // A bug in one handler must not take the toolbar down: it is logged and the next event still works.
20
+ const guarded = (name, handler) => (...args) => {
21
+ try {
22
+ handler(...args)
23
+ } catch (error) {
24
+ log('error', `${name} failed`, { error: String(error?.stack ?? error).slice(0, 800) })
25
+ }
26
+ }
27
+
28
+ let config = null
29
+ /** @type {Awaited<ReturnType<typeof createOverlay>> | null} */
30
+ let overlay = null
31
+ /** @type {ReturnType<typeof createController> | null} */
32
+ let controller = null
33
+ let speech = null
34
+ let altDown = false
35
+
36
+ process.on('disconnect', () => shutdown(0))
37
+ process.on('message', (message) => {
38
+ if (message?.type === 'config') applyConfig(message.config)
39
+ else if (message?.type === 'result') guarded('result', () => controller?.onResult(message))()
40
+ else if (message?.type === 'shutdown') shutdown(0)
41
+ })
42
+
43
+ await new Promise((resolve) => {
44
+ const waitForConfig = setInterval(() => {
45
+ if (!config) return
46
+ clearInterval(waitForConfig)
47
+ resolve()
48
+ }, 20)
49
+ send({ type: 'hello' })
50
+ })
51
+ await waitForAccessibility()
52
+
53
+ speech = createSpeech({ log, onChange: (speaking) => overlay?.setSpeaking(speaking) })
54
+ overlay = await createOverlay({ onMessage: guarded('page message', (message) => controller?.onPage(message)), log })
55
+ controller = createController({
56
+ overlay,
57
+ speech,
58
+ send,
59
+ log,
60
+ writeClipboard: (text) => hook.writeToClipboard(text),
61
+ openUrl: (url) => openUrl(url, log),
62
+ positions: { invalid: SelectionHook.INVALID_COORDINATE, fullSelection: SelectionHook.PositionLevel.SEL_FULL },
63
+ idPrefix: String(process.pid),
64
+ })
65
+ applyConfig(config)
66
+
67
+ hook.on('text-selection', guarded('selection', (data) => controller.onSelection(data)))
68
+ hook.on('mouse-down', guarded('mouse-down', (point) => controller.onMouseDown(point)))
69
+ hook.on('mouse-wheel', guarded('mouse-wheel', (point) => controller.onMouseWheel(point)))
70
+ hook.on('key-down', guarded('key-down', ({ uniKey }) => {
71
+ if (uniKey === 'Alt' && !altDown) {
72
+ altDown = true
73
+ applyHookFilters(config)
74
+ }
75
+ controller.onKeyDown(uniKey)
76
+ }))
77
+ hook.on('key-up', guarded('key-up', ({ uniKey }) => {
78
+ if (uniKey === 'Alt' && altDown) {
79
+ altDown = false
80
+ applyHookFilters(config)
81
+ }
82
+ controller.onKeyUp(uniKey)
83
+ }))
84
+ hook.on('error', (error) => log('warn', 'selection-hook error', { error: String(error?.message ?? error) }))
85
+
86
+ const hookStarted = hook.start({ debug: false, enableClipboard: config.clipboardFallback })
87
+ applyHookFilters(config)
88
+ send({ type: 'ready', hookStarted, platform: process.platform, pid: process.pid })
89
+
90
+ function applyConfig(next) {
91
+ config = next
92
+ if (!controller) return
93
+ controller.configure(next)
94
+ applyHookFilters(next)
95
+ }
96
+
97
+ // The hook reads every selection before the controller sees it, and a read can be a simulated ⌘C in
98
+ // the app. While the toolbar cannot appear (turned off, or ⌥ not held), an empty include list makes
99
+ // the native engine skip the read entirely.
100
+ function applyHookFilters(next) {
101
+ const reading = next.enabled && (next.trigger !== 'modifier' || altDown)
102
+ if (next.clipboardFallback && reading) hook.enableClipboard()
103
+ else hook.disableClipboard()
104
+ if (!reading) hook.setGlobalFilterMode(SelectionHook.FilterMode.INCLUDE_LIST, [])
105
+ else if (next.disabledApps.length) hook.setGlobalFilterMode(SelectionHook.FilterMode.EXCLUDE_LIST, next.disabledApps)
106
+ else hook.setGlobalFilterMode(SelectionHook.FilterMode.DEFAULT, [])
107
+ }
108
+
109
+ // Without the accessibility grant macOS delivers no events to selection-hook. The grant belongs to the
110
+ // responsible process: DeepSeek Harness inside DSH, or the terminal app when running the dev script.
111
+ async function waitForAccessibility() {
112
+ if (process.platform !== 'darwin') return
113
+ if (hook.macIsProcessTrusted()) {
114
+ send({ type: 'permission', trusted: true })
115
+ return
116
+ }
117
+ const prompt = !process.argv.includes('--no-prompt')
118
+ if (prompt) hook.macRequestProcessTrust()
119
+ send({ type: 'permission', trusted: false, prompted: prompt })
120
+ await new Promise((resolve) => {
121
+ const timer = setInterval(() => {
122
+ if (!hook.macIsProcessTrusted()) return
123
+ clearInterval(timer)
124
+ resolve()
125
+ }, PERMISSION_POLL_MS)
126
+ })
127
+ send({ type: 'permission', trusted: true })
128
+ // Event taps created before the grant stay deaf; a fresh process starts listening right away.
129
+ hook.cleanup()
130
+ process.exit(RESTART_NOW)
131
+ }
132
+
133
+ function shutdown(code) {
134
+ try {
135
+ speech?.stop()
136
+ hook.stop()
137
+ hook.cleanup()
138
+ } catch {
139
+ // Best effort on the way out.
140
+ }
141
+ try {
142
+ overlay?.dispose()
143
+ } catch {
144
+ // Same.
145
+ }
146
+ process.exit(code)
147
+ }