@followbin/dsh-selection 0.2.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 CHANGED
@@ -27,7 +27,7 @@ Now select some text in any app. Settings are under **Selection assistant** in D
27
27
 
28
28
  To install the newest code from GitHub instead, enter `https://github.com/followbin/dsh-selection` in step 2.
29
29
 
30
- **Updating**: when a new version is published, the plugin's page under **Plugins** lists it in **Versions**; click **Update**. A copy installed from GitHub or a local folder has no **Versions** section: uninstall it and install it again. Your settings and usage are kept, because they are stored in `~/.dsh/selection`, outside the plugin.
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.
31
31
 
32
32
  ## Features
33
33
 
package/README.zh-CN.md CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  想装 GitHub 上的最新代码,第 2 步改填 `https://github.com/followbin/dsh-selection`。
29
29
 
30
- **更新**:发布新版本后,在「插件」里打开它,「版本」一栏会列出新版本,点「更新」即可。从 GitHub 或本地目录装的没有「版本」一栏,要先卸载再重新安装。设置和用量存在 `~/.dsh/selection`,不在插件目录里,卸载不会丢。
30
+ **更新**:发布新版本后,插件设置页「常规 → 版本」里会出现「更新到 x.y.z」,点一下就更新。这和在「插件」里打开它、点「版本 → 更新」是同一个操作:DSH 先停掉旧版、装上新版,失败会还原。从 GitHub 或本地目录装的不能这样更新,要先卸载再重新安装。设置和用量存在 `~/.dsh/selection`,不在插件目录里,卸载不会丢。
31
31
 
32
32
  ## 功能
33
33
 
package/client.js CHANGED
@@ -68,6 +68,21 @@ window.__ModuleLoader__.load({
68
68
  'toolbar.style.full': '常规',
69
69
  'toolbar.style.mini': '迷你',
70
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': '正在更新…',
71
86
  'language.title': '语言',
72
87
  'language.native': '我的母语',
73
88
  'language.translate': '翻译成',
@@ -195,6 +210,21 @@ window.__ModuleLoader__.load({
195
210
  'toolbar.style.full': 'Full',
196
211
  'toolbar.style.mini': 'Mini',
197
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…',
198
228
  'language.title': 'Language',
199
229
  'language.native': 'My language',
200
230
  'language.translate': 'Translate into',
@@ -717,6 +747,104 @@ window.__ModuleLoader__.load({
717
747
 
718
748
  // ---------- Page ----------
719
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
+
720
848
  function SettingsPage(props) {
721
849
  const t = typeof props.t === 'function' ? props.t : (key) => zh[key] || key
722
850
  const [state, setState] = useState(null)
@@ -875,7 +1003,8 @@ window.__ModuleLoader__.load({
875
1003
 
876
1004
  panel('general',
877
1005
  h('div', { className: 'dss-card', style: { marginTop: 20 } },
878
- 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 })),
879
1008
  h(Section, { title: t('toolbar.title') },
880
1009
  h('div', { className: 'dss-card' },
881
1010
  h(Row, { label: t('toolbar.style'), hint: t('toolbar.style.hint') },
@@ -1027,7 +1156,7 @@ window.__ModuleLoader__.load({
1027
1156
  return h(PageBoundary, props, h(SettingsPage, props))
1028
1157
  }
1029
1158
 
1030
- // Dashed selection frame around lines of text (Lucide "text-select", ISC).
1159
+ // The plugin's mark as a line drawing; src/shared/icons.mjs has the same path as BRAND_LINE.
1031
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'
1032
1161
 
1033
1162
  function SidebarIcon({ size = 18, active }) {
@@ -1054,6 +1183,15 @@ window.__ModuleLoader__.load({
1054
1183
  'dsh-selection: settings page',
1055
1184
  )
1056
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
+ }
1057
1195
  // "More settings…" in the toolbar menu asks the Host to bring DSH forward on this page.
1058
1196
  ctx.effect(() => events.subscribe((event) => {
1059
1197
  if (event !== 'navigate') return
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@followbin/dsh-selection",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",
@@ -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 })
@@ -241,6 +243,7 @@ export function createSelectionService({
241
243
  defaults: defaultSettings(),
242
244
  status,
243
245
  recentApps: [...recentApps],
246
+ version,
244
247
  catalog: {
245
248
  builtins: BUILTIN_SKILLS,
246
249
  languages: LANGUAGES,
@@ -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. */
@@ -75,6 +77,7 @@ export function createController({
75
77
  selection = { id: ++nextSelection, text: cut(text, config.maxTextLength), full: text, app: data.programName }
76
78
  shownAt = now()
77
79
  overlay.showBar(anchor)
80
+ trackPointer(true)
78
81
  // The text stays here until the user picks an action; the Host only learns which app it came from.
79
82
  send({ type: 'selection', app: data.programName })
80
83
  }
@@ -93,6 +96,10 @@ export function createController({
93
96
  if (overlay.mode !== 'hidden' && !pressedOnOverlay) dismiss({ reason: 'click-outside', point })
94
97
  }
95
98
 
99
+ function onMouseMove(point) {
100
+ if (overlay.mode !== 'hidden') overlay.pointer(point)
101
+ }
102
+
96
103
  function onMouseWheel(point) {
97
104
  if (overlay.mode === 'bar' && !overlay.contains(point)) dismiss({ reason: 'scroll-outside', point })
98
105
  }
@@ -261,9 +268,10 @@ export function createController({
261
268
  panel = null
262
269
  overlay.setKeyable(false)
263
270
  overlay.hide()
271
+ trackPointer(false)
264
272
  }
265
273
 
266
- return { configure, onSelection, onMouseDown, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
274
+ return { configure, onSelection, onMouseDown, onMouseMove, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
267
275
  }
268
276
 
269
277
  /** Shortens text without splitting a surrogate pair, which encodeURIComponent and JSON would reject. */
@@ -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
@@ -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
@@ -79,6 +81,10 @@ export async function createOverlay({ onMessage, log }) {
79
81
  let bounds = null
80
82
  // How tall the panel was last allowed to grow, and whether to fill that at once, as told to the page.
81
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
82
88
  // Once the user drags the window it stays where they put it: later size changes keep its top-left.
83
89
  let moved = false
84
90
  let drag = null
@@ -232,6 +238,9 @@ export async function createOverlay({ onMessage, log }) {
232
238
  },
233
239
  hide() {
234
240
  if (mode === 'hidden') return
241
+ clearTimeout(pointerTimer)
242
+ pointerTimer = null
243
+ pointerInside = false
235
244
  overlayWindow.hide()
236
245
  mode = 'hidden'
237
246
  placement = null
@@ -242,6 +251,23 @@ export async function createOverlay({ onMessage, log }) {
242
251
  call('reset')
243
252
  setPump(PUMP_HIDDEN_MS)
244
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
+ },
245
271
  get mode() { return mode },
246
272
  /** Where the window was placed, in the same coordinates as `contains` (self tests and diagnostics). */
247
273
  get bounds() { return bounds },
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 waits for a real pointer movement after each show (data-pointer, see toolbar.js). */
53
- [data-pointer] button:hover { background: var(--hover); }
54
- button:active, [data-pointer] button:active { background: var(--press); }
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; }
@@ -96,10 +97,10 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
96
97
  color: var(--text); font-size: 12px; font-weight: 500; line-height: 26px; cursor: default;
97
98
  text-overflow: ellipsis; white-space: nowrap; overflow: hidden;
98
99
  }
99
- [data-pointer] .panel-language:hover { background-color: var(--press); }
100
+ .panel-language[data-hover] { background-color: var(--press); }
100
101
  [data-kind="translate"] .panel-language { display: inline-block; }
101
102
  .tool { width: 28px; height: 28px; color: var(--muted); }
102
- [data-pointer] .tool:hover { color: var(--text); }
103
+ .tool[data-hover] { color: var(--text); }
103
104
  .tool[aria-pressed="true"] { color: var(--accent); background: var(--hover); }
104
105
  .tool.done { color: #1f9d55; }
105
106
  [data-kind="ask"] .tool[data-tool="retry"] { display: none; }
@@ -133,7 +134,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
133
134
  .composer { flex: none; display: none; align-items: flex-end; gap: 6px; padding: 8px 10px 10px 14px; border-top: 1px solid var(--hairline); }
134
135
  [data-kind="ask"] .composer, [data-composer="shown"] .composer { display: flex; }
135
136
  .composer textarea {
136
- flex: 1; min-height: 22px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
137
+ flex: 1; min-height: 28px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
137
138
  overflow-y: hidden; scrollbar-width: none;
138
139
  line-height: 1.5; user-select: text; -webkit-user-select: text; cursor: text;
139
140
  }
@@ -141,7 +142,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
141
142
  .composer textarea::-webkit-scrollbar { display: none; }
142
143
  .composer textarea::placeholder { color: var(--muted); }
143
144
  .send { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff; }
144
- [data-pointer] .send:hover { background: var(--accent); filter: brightness(1.08); }
145
+ .send[data-hover] { background: var(--accent); filter: brightness(1.08); }
145
146
  .send[disabled] { opacity: 0.35; }
146
147
 
147
148
  /* Model output */
@@ -155,7 +156,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
155
156
  .markdown pre code { padding: 0; background: none; font-size: 12px; line-height: 1.5; }
156
157
  .markdown blockquote { padding-left: 10px; border-left: 3px solid var(--hairline); color: var(--muted); }
157
158
  .markdown a { color: var(--accent); text-decoration: none; cursor: pointer; }
158
- [data-pointer] .markdown a:hover { text-decoration: underline; }
159
+ .markdown a[data-hover] { text-decoration: underline; }
159
160
  .markdown table { border-collapse: collapse; font-size: 12.5px; }
160
161
  .markdown th, .markdown td { padding: 4px 8px; border: 1px solid var(--hairline); text-align: left; vertical-align: top; }
161
162
  .markdown th { background: var(--soft); font-weight: 600; }
package/ui/toolbar.js CHANGED
@@ -79,7 +79,14 @@
79
79
  const emptyState = (requestId = null, pending = false) => ({ requestId, text: '', turn: null, pending, failure: null })
80
80
  let skills = { bar: [], more: [] }
81
81
  let style = 'full'
82
- let hoverTimer = null
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
83
90
 
84
91
  // The menu offers whichever style is not in use.
85
92
  function drawStyleItem() {
@@ -87,6 +94,18 @@
87
94
  els.styleItem.querySelector('span').textContent = style === 'mini' ? t.fullStyle : t.miniStyle
88
95
  }
89
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
+ }
90
109
  let state = emptyState()
91
110
  let renderTimer = null
92
111
  // Timers rather than requestAnimationFrame: the view can be occluded or the screen locked, and then
@@ -104,6 +123,7 @@
104
123
  }
105
124
 
106
125
  function setMenu(open) {
126
+ stopHoverTimers()
107
127
  if (open) {
108
128
  const bar = $('.bar')
109
129
  const button = style === 'mini' ? els.mini : els.more
@@ -182,8 +202,8 @@
182
202
  queueSize()
183
203
  },
184
204
  showBar() {
185
- clearTimeout(hoverTimer)
186
- delete body.dataset.pointer
205
+ stopHoverTimers()
206
+ clearHover()
187
207
  body.dataset.mode = 'bar'
188
208
  setMenu(false)
189
209
  },
@@ -191,7 +211,7 @@
191
211
  if (body.dataset.mode === 'bar') setMenu(Boolean(open))
192
212
  },
193
213
  showPanel({ skill, text, app, languages, translateTarget }) {
194
- delete body.dataset.pointer
214
+ clearHover()
195
215
  body.dataset.mode = 'panel'
196
216
  body.dataset.menu = 'closed'
197
217
  body.dataset.kind = skill.kind === 'ask' ? 'ask' : skill.id === 'translate' ? 'translate' : 'prompt'
@@ -260,6 +280,7 @@
260
280
  if (requestId !== state.requestId) return
261
281
  state.pending = false
262
282
  body.dataset.composer = 'shown'
283
+ resizeInput()
263
284
  scheduleRender()
264
285
  },
265
286
  fail(requestId, failure) {
@@ -291,9 +312,36 @@
291
312
  else delete body.dataset.fill
292
313
  queueSize()
293
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
+ },
294
342
  reset() {
295
- clearTimeout(hoverTimer)
296
- delete body.dataset.pointer
343
+ stopHoverTimers()
344
+ clearHover()
297
345
  body.dataset.mode = 'hidden'
298
346
  body.dataset.menu = 'closed'
299
347
  if (document.activeElement) document.activeElement.blur()
@@ -310,6 +358,7 @@
310
358
  type: 'probe',
311
359
  mode: body.dataset.mode,
312
360
  menu: body.dataset.menu,
361
+ hovered: hovered ? hovered.getAttribute('aria-label') || hovered.textContent.trim() : null,
313
362
  viewport: { width: innerWidth, height: innerHeight },
314
363
  card: { width: Math.round(box.width), height: Math.round(box.height) },
315
364
  buttons: buttons.map((button) => button.getAttribute('aria-label') || button.textContent.trim()),
@@ -368,7 +417,8 @@
368
417
  function resizeInput() {
369
418
  els.input.style.height = 'auto'
370
419
  const height = els.input.scrollHeight
371
- els.input.style.height = `${Math.min(height, 96)}px`
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`
372
422
  // Scrollable only past its tallest size: at fractional display scaling one line can overflow by a
373
423
  // fraction of a pixel, which would otherwise let it scroll.
374
424
  els.input.style.overflowY = height > 96 ? 'auto' : 'hidden'
@@ -391,7 +441,6 @@
391
441
  return
392
442
  }
393
443
  if (event.target.closest('.mini-button')) {
394
- clearTimeout(hoverTimer)
395
444
  setMenu(true)
396
445
  return
397
446
  }
@@ -515,36 +564,6 @@
515
564
  if (body.dataset.mode === 'panel') post({ type: 'keyboard', needed: true })
516
565
  })
517
566
 
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
-
541
- // WebKit keeps :hover on whatever was under the pointer when it last saw one, through hiding and
542
- // showing the window, and this process is never the active app, so a pointer that does not move
543
- // over the page is not tracked. Hover styles therefore wait for a real movement after each show.
544
- document.addEventListener('pointermove', () => {
545
- if (!('pointer' in body.dataset)) body.dataset.pointer = ''
546
- })
547
-
548
567
  // Dropping a link or file would navigate the view away from this page.
549
568
  for (const type of ['dragover', 'drop']) document.addEventListener(type, (event) => event.preventDefault())
550
569
  window.addEventListener('error', (event) => post({ type: 'page-error', message: String(event.message) }))