@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,58 @@
1
+ // Where the overlay window goes. No native code: overlay.mjs supplies the selection, the page's card
2
+ // layout and the screen, all in the same top-left-origin points.
3
+
4
+ const SCREEN_INSET = 6
5
+ // With selection bounds the anchor is the selection's bottom edge; with only the mouse position it is
6
+ // roughly mid-line, so keep half a line more distance.
7
+ const GAP_PRECISE = 8
8
+ const GAP_MOUSE = 18
9
+
10
+ /**
11
+ * The top edge of a card, how tall it may grow, and whether it should fill that height at once. The
12
+ * top edge stays put afterwards, so text being read never moves.
13
+ * - Below the selection when at least `minRoom` fits there; the card then grows down as it fills,
14
+ * up to the bottom of the screen.
15
+ * - Otherwise right above the selection, at its full `minRoom` from the start (`fill`), so its
16
+ * bottom edge meets the selection while the answer fills it from the top.
17
+ * @param {{ x: number, y: number, top: number, precise: boolean }} anchor
18
+ * @param {number} minRoom
19
+ * @param {{ x: number, y: number, width: number, height: number } | null} screen
20
+ * @returns {{ top: number, room: number, fill: boolean }}
21
+ */
22
+ export function place(anchor, minRoom, screen) {
23
+ const gap = anchor.precise ? GAP_PRECISE : GAP_MOUSE
24
+ const below = anchor.y + gap
25
+ if (!screen) return { top: below, room: Infinity, fill: false }
26
+ const floor = screen.y + screen.height - SCREEN_INSET
27
+ if (floor - below >= minRoom) return { top: below, room: floor - below, fill: false }
28
+ const ceiling = anchor.top - gap
29
+ const top = Math.max(screen.y + SCREEN_INSET, ceiling - minRoom)
30
+ if (ceiling - top >= minRoom) return { top, room: ceiling - top, fill: true }
31
+ // A selection filling nearly the whole screen leaves no room above either; then cover it.
32
+ return { top, room: floor - top, fill: false }
33
+ }
34
+
35
+ /** How tall a card whose top edge is at `top` may grow before it reaches the bottom of the screen. */
36
+ export function roomBelow(top, screen) {
37
+ return screen ? screen.y + screen.height - SCREEN_INSET - top : Infinity
38
+ }
39
+
40
+ /**
41
+ * Window bounds from a wanted top-left, with every card inside the screen: it moves left only as far
42
+ * as the right edge requires, and likewise up. When a card is taller than the screen, its top edge,
43
+ * with the panel's controls, is the part that stays visible.
44
+ * @param {{ origin: { x: number, y: number }, layout: { width: number, height: number, primary: object, hits: object[] }, screen: object | null }} options
45
+ */
46
+ export function fitWindow({ origin, layout, screen }) {
47
+ let { x, y } = origin
48
+ if (screen) {
49
+ const cards = layout.hits.length ? layout.hits : [layout.primary]
50
+ const left = Math.min(...cards.map((card) => card.x))
51
+ const right = Math.max(...cards.map((card) => card.x + card.width))
52
+ const top = Math.min(...cards.map((card) => card.y))
53
+ const bottom = Math.max(...cards.map((card) => card.y + card.height))
54
+ x = Math.max(screen.x + SCREEN_INSET - left, Math.min(x, screen.x + screen.width - SCREEN_INSET - right))
55
+ y = Math.max(screen.y + SCREEN_INSET - top, Math.min(y, screen.y + screen.height - SCREEN_INSET - bottom))
56
+ }
57
+ return { x: Math.round(x), y: Math.round(y), width: layout.width, height: layout.height }
58
+ }
@@ -0,0 +1,136 @@
1
+ // Reads text aloud with the system voice: `say` on macOS, SAPI through PowerShell on Windows.
2
+ // The text goes through stdin, so nothing in it is ever parsed as a command-line argument.
3
+ import { execFile, spawn } from 'node:child_process'
4
+
5
+ /**
6
+ * @param {{ log: (level: string, message: string, fields?: object) => void, onChange: (speaking: boolean) => void }} options
7
+ */
8
+ export function createSpeech({ log, onChange }) {
9
+ let current = null
10
+ let voices = null
11
+ // Bumped on every start and stop so a slow voice lookup cannot resurrect a cancelled request.
12
+ let generation = 0
13
+
14
+ async function macVoiceFor(text) {
15
+ voices ??= await listMacVoices().catch((error) => {
16
+ log('warn', 'listing voices failed', { error: error.message })
17
+ return []
18
+ })
19
+ return pickVoice(voices, localeOf(text))
20
+ }
21
+
22
+ async function speak(text) {
23
+ stop()
24
+ const token = ++generation
25
+ const args = process.platform === 'darwin' ? await macArgs(text) : []
26
+ if (token !== generation) return
27
+ const process_ = process.platform === 'darwin'
28
+ ? spawn('say', [...args, '-f', '-'], { stdio: ['pipe', 'ignore', 'pipe'] })
29
+ // stdin would otherwise be decoded with the OEM code page and garble anything but ASCII.
30
+ : spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
31
+ '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Add-Type -AssemblyName System.Speech; '
32
+ + '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Speak([Console]::In.ReadToEnd())'],
33
+ { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true })
34
+ current = process_
35
+ onChange(true)
36
+ process_.on('error', (error) => log('warn', 'speech failed to start', { error: error.message }))
37
+ process_.on('exit', () => {
38
+ if (current === process_) {
39
+ current = null
40
+ onChange(false)
41
+ }
42
+ })
43
+ // Stopping before the voice has read all of stdin closes the pipe; EPIPE is expected then.
44
+ process_.stdin.on('error', () => {})
45
+ process_.stdin.end(text)
46
+ }
47
+
48
+ async function macArgs(text) {
49
+ const voice = await macVoiceFor(text)
50
+ return voice ? ['-v', voice] : []
51
+ }
52
+
53
+ function stop() {
54
+ generation += 1
55
+ if (!current) return
56
+ const process_ = current
57
+ current = null
58
+ process_.kill()
59
+ onChange(false)
60
+ }
61
+
62
+ return {
63
+ /** Starts reading, or stops if something is already being read. */
64
+ toggle(text) {
65
+ if (current) stop()
66
+ else void speak(text)
67
+ },
68
+ stop,
69
+ get speaking() { return current !== null },
70
+ }
71
+ }
72
+
73
+ function listMacVoices() {
74
+ return new Promise((resolve, reject) => {
75
+ execFile('say', ['-v', '?'], { encoding: 'utf8', timeout: 5000 }, (error, stdout) => {
76
+ if (error) return reject(error)
77
+ resolve(parseVoices(stdout))
78
+ })
79
+ })
80
+ }
81
+
82
+ // `say -v ?` lists voices alphabetically, so the first one for a language is often a novelty voice
83
+ // (Albert, Bad News…). Well-known voices go first; novelty voices are never chosen.
84
+ const PREFERRED_VOICES = {
85
+ en_US: ['Samantha', 'Alex', 'Ava', 'Allison', 'Tom'],
86
+ zh_CN: ['Tingting', 'Lili', 'Yu-shu'],
87
+ ja_JP: ['Kyoko', 'Otoya'],
88
+ ko_KR: ['Yuna', 'Sora'],
89
+ ru_RU: ['Milena', 'Yuri', 'Katya'],
90
+ }
91
+ const NOVELTY_VOICES = new Set([
92
+ 'Albert', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bubbles', 'Cellos', 'Good News', 'Jester', 'Organ',
93
+ 'Superstar', 'Trinoids', 'Whisper', 'Wobble', 'Zarvox',
94
+ ])
95
+
96
+ /** @returns {string | undefined} the voice name to pass to `say -v`, or undefined for the system voice */
97
+ export function pickVoice(voices, locale) {
98
+ const base = (name) => name.replace(/\s*\(.*\)$/, '')
99
+ for (const name of PREFERRED_VOICES[locale] ?? []) {
100
+ const voice = voices.find((candidate) => candidate.locale === locale && base(candidate.name) === name)
101
+ if (voice) return voice.name
102
+ }
103
+ const usable = (voice) => !NOVELTY_VOICES.has(base(voice.name))
104
+ return voices.find((voice) => voice.locale === locale && usable(voice))?.name
105
+ ?? voices.find((voice) => voice.locale.startsWith(locale.slice(0, 2)) && usable(voice))?.name
106
+ }
107
+
108
+ /** Reads the output of `say -v ?`: one voice per line, then its locale, then a sample sentence. */
109
+ export function parseVoices(stdout) {
110
+ const voices = []
111
+ for (const line of stdout.split('\n')) {
112
+ const match = /^(.+?)\s+([a-z]{2,3}_[A-Z0-9]{2,3})\s+#/.exec(line)
113
+ if (match) voices.push({ name: match[1].trim(), locale: match[2] })
114
+ }
115
+ return voices
116
+ }
117
+
118
+ /** Picks a voice locale from the script the text is written in. */
119
+ export function localeOf(text) {
120
+ let han = 0
121
+ let kana = 0
122
+ let hangul = 0
123
+ let cyrillic = 0
124
+ for (const char of text) {
125
+ const code = char.codePointAt(0)
126
+ if (code >= 0x3040 && code <= 0x30ff) kana += 1
127
+ else if ((code >= 0xac00 && code <= 0xd7af) || (code >= 0x1100 && code <= 0x11ff)) hangul += 1
128
+ else if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf)) han += 1
129
+ else if (code >= 0x0400 && code <= 0x04ff) cyrillic += 1
130
+ }
131
+ if (kana > 0) return 'ja_JP'
132
+ if (hangul > 0) return 'ko_KR'
133
+ if (han > 0) return 'zh_CN'
134
+ if (cyrillic > 0) return 'ru_RU'
135
+ return 'en_US'
136
+ }
package/ui/markdown.js ADDED
@@ -0,0 +1,122 @@
1
+ // A deliberately small Markdown renderer for model output. Everything is HTML-escaped first and only
2
+ // tags produced here reach the page; links keep their target in data-href and open in the browser.
3
+ // Classic script: the toolbar page inlines it, the tests import it, both read globalThis.DshMarkdown.
4
+ (function (root) {
5
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
6
+ const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (char) => ESCAPES[char])
7
+
8
+ const FENCE = /^\s*(```|~~~)/
9
+ const HEADING = /^(#{1,6})\s+(.*)$/
10
+ const QUOTE = /^\s*>\s?/
11
+ const LIST_ITEM = /^\s*([-*+]|\d+[.)])\s+(.*)$/
12
+ const RULE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/
13
+ const TABLE_ROW = /^\s*\|.*\|\s*$/
14
+ const TABLE_DIVIDER = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
15
+
16
+ // A link target has no spaces, and parentheses only in balanced pairs, as in .../Python_(programming_language).
17
+ const LINK = /\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)/g
18
+
19
+ function emphasis(html) {
20
+ return html
21
+ .replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
22
+ .replace(/__([^_\n]+)__/g, '<strong>$1</strong>')
23
+ .replace(/(^|[^*\w])\*([^*\n]+)\*(?![*\w])/g, '$1<em>$2</em>')
24
+ .replace(/~~([^~\n]+)~~/g, '<del>$1</del>')
25
+ }
26
+
27
+ function inline(text) {
28
+ return String(text).replace(/\u0000/g, '').split(/(`[^`\n]+`)/g).map((part) => {
29
+ if (/^`[^`\n]+`$/.test(part)) return `<code>${escapeHtml(part.slice(1, -1))}</code>`
30
+ // Links are set aside while emphasis is applied, so an underscore or asterisk in a URL stays
31
+ // part of it, and **[a link](…)** still turns bold.
32
+ const links = []
33
+ const marked = part.replace(LINK, (match, label, href) => {
34
+ links.push(`<a data-href="${escapeHtml(href)}">${emphasis(escapeHtml(label))}</a>`)
35
+ return `\u0000${links.length - 1}\u0000`
36
+ })
37
+ return emphasis(escapeHtml(marked)).replace(/\u0000(\d+)\u0000/g, (match, index) => links[Number(index)])
38
+ }).join('')
39
+ }
40
+
41
+ function isBlockStart(line) {
42
+ return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || LIST_ITEM.test(line) || RULE.test(line) || TABLE_ROW.test(line)
43
+ }
44
+
45
+ function cells(row) {
46
+ return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim())
47
+ }
48
+
49
+ function render(markdown) {
50
+ const lines = String(markdown ?? '').replace(/\r\n?/g, '\n').split('\n')
51
+ const out = []
52
+ let i = 0
53
+ while (i < lines.length) {
54
+ const line = lines[i]
55
+ if (!line.trim()) {
56
+ i += 1
57
+ continue
58
+ }
59
+ const fence = FENCE.exec(line)
60
+ if (fence) {
61
+ const code = []
62
+ i += 1
63
+ while (i < lines.length && !lines[i].trimStart().startsWith(fence[1])) code.push(lines[i++])
64
+ i += 1
65
+ out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`)
66
+ continue
67
+ }
68
+ const heading = HEADING.exec(line)
69
+ if (heading) {
70
+ const level = heading[1].length <= 2 ? 3 : 4
71
+ out.push(`<h${level}>${inline(heading[2])}</h${level}>`)
72
+ i += 1
73
+ continue
74
+ }
75
+ if (QUOTE.test(line)) {
76
+ const quote = []
77
+ while (i < lines.length && QUOTE.test(lines[i])) quote.push(lines[i++].replace(QUOTE, ''))
78
+ out.push(`<blockquote>${render(quote.join('\n'))}</blockquote>`)
79
+ continue
80
+ }
81
+ if (RULE.test(line)) {
82
+ out.push('<hr>')
83
+ i += 1
84
+ continue
85
+ }
86
+ if (TABLE_ROW.test(line) && i + 1 < lines.length && TABLE_DIVIDER.test(lines[i + 1])) {
87
+ const head = cells(line)
88
+ i += 2
89
+ const body = []
90
+ while (i < lines.length && TABLE_ROW.test(lines[i])) body.push(cells(lines[i++]))
91
+ out.push(`<table><thead><tr>${head.map((cell) => `<th>${inline(cell)}</th>`).join('')}</tr></thead>`
92
+ + `<tbody>${body.map((row) => `<tr>${row.map((cell) => `<td>${inline(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`)
93
+ continue
94
+ }
95
+ const item = LIST_ITEM.exec(line)
96
+ if (item) {
97
+ const ordered = /\d/.test(item[1])
98
+ const items = []
99
+ while (i < lines.length) {
100
+ const next = LIST_ITEM.exec(lines[i])
101
+ if (next && /\d/.test(next[1]) === ordered) {
102
+ items.push(next[2])
103
+ } else if (lines[i].trim() && /^\s{2,}/.test(lines[i]) && items.length) {
104
+ items[items.length - 1] += `\n${lines[i].trim()}`
105
+ } else {
106
+ break
107
+ }
108
+ i += 1
109
+ }
110
+ const tag = ordered ? 'ol' : 'ul'
111
+ out.push(`<${tag}>${items.map((text) => `<li>${text.split('\n').map(inline).join('<br>')}</li>`).join('')}</${tag}>`)
112
+ continue
113
+ }
114
+ const paragraph = []
115
+ while (i < lines.length && lines[i].trim() && !(paragraph.length && isBlockStart(lines[i]))) paragraph.push(lines[i++])
116
+ out.push(`<p>${paragraph.map(inline).join('<br>')}</p>`)
117
+ }
118
+ return out.join('')
119
+ }
120
+
121
+ root.DshMarkdown = { render, escapeHtml }
122
+ })(globalThis)
package/ui/toolbar.css ADDED
@@ -0,0 +1,162 @@
1
+ :root {
2
+ --surface: #ffffff;
3
+ --text: #1f1f1f;
4
+ --muted: #8a8a8e;
5
+ --soft: #f4f4f5;
6
+ --hairline: rgba(0, 0, 0, 0.08);
7
+ --hover: rgba(0, 0, 0, 0.05);
8
+ --press: rgba(0, 0, 0, 0.09);
9
+ --accent: #4d6bfe;
10
+ --danger: #d4302b;
11
+ --shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.06), 0 8px 20px rgba(0, 0, 0, 0.1);
12
+ --code: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
13
+ color-scheme: light;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --surface: #2b2b2e;
18
+ --text: #f2f2f3;
19
+ --muted: #9d9da3;
20
+ --soft: #37373b;
21
+ --hairline: rgba(255, 255, 255, 0.1);
22
+ --hover: rgba(255, 255, 255, 0.07);
23
+ --press: rgba(255, 255, 255, 0.12);
24
+ --accent: #8197ff;
25
+ --danger: #ff6b63;
26
+ --shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.12), 0 2px 6px rgba(0, 0, 0, 0.3), 0 8px 20px rgba(0, 0, 0, 0.4);
27
+ color-scheme: dark;
28
+ }
29
+ }
30
+
31
+ * { box-sizing: border-box; margin: 0; }
32
+ /* Only the root hides overflow. Set on body as well, it would stop propagating to the viewport and
33
+ clip body's own box, cutting off the ⋯ menu, which is positioned outside the toolbar's flow. */
34
+ html { background: transparent; overflow: hidden; }
35
+ /* The margins hold the whole shadow; a clipped shadow shows as a hard grey box around the card. */
36
+ body {
37
+ background: transparent;
38
+ padding: 12px 18px 24px;
39
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", "Microsoft YaHei UI", sans-serif;
40
+ color: var(--text);
41
+ -webkit-font-smoothing: antialiased;
42
+ user-select: none;
43
+ -webkit-user-select: none;
44
+ cursor: default;
45
+ }
46
+ .stage { position: relative; display: inline-block; vertical-align: top; }
47
+ .card { background: var(--surface); box-shadow: var(--shadow); }
48
+ svg { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
49
+
50
+ button, select, textarea { font: inherit; color: inherit; }
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); }
55
+ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
56
+ .icon-button { width: 30px; height: 30px; padding: 0; color: var(--text); }
57
+ .icon-button[disabled] { opacity: 0.35; pointer-events: none; }
58
+
59
+ /* Toolbar */
60
+ .bar { display: none; align-items: center; gap: 2px; padding: 4px; border-radius: 12px; white-space: nowrap; cursor: grab; }
61
+ [data-mode="bar"] .bar { display: inline-flex; }
62
+ .bar-skills { display: flex; gap: 2px; }
63
+ .skill { height: 32px; padding: 0 10px 0 9px; font-weight: 500; letter-spacing: 0.1px; }
64
+ .skill.done svg { color: #1f9d55; }
65
+ .divider { width: 1px; height: 16px; margin: 0 4px; background: var(--hairline); }
66
+ .more-button[aria-expanded="true"], .mini-button[aria-expanded="true"] { background: var(--press); }
67
+ /* Mini toolbar: one icon, and every skill in its menu. */
68
+ .mini-button { display: none; color: var(--accent); }
69
+ .mini-button svg { width: 20px; height: 20px; }
70
+ [data-style="mini"] .bar { padding: 3px; border-radius: 10px; }
71
+ [data-style="mini"] .bar-skills, [data-style="mini"] .divider, [data-style="mini"] .more-button { display: none; }
72
+ [data-style="mini"] .mini-button { display: inline-flex; }
73
+
74
+ /* ⋯ menu, opened below the more button */
75
+ .menu { display: none; position: absolute; min-width: 190px; padding: 6px; border-radius: 12px; z-index: 1; }
76
+ [data-mode="bar"][data-menu="open"] .menu { display: block; }
77
+ .menu-item { display: flex; width: 100%; justify-content: flex-start; height: 34px; padding: 0 10px; gap: 10px; white-space: nowrap; }
78
+ .menu-item.plain { padding-left: 12px; }
79
+ .menu-divider { height: 1px; margin: 6px 8px; background: var(--hairline); }
80
+ .menu-skills:empty + .menu-divider { display: none; }
81
+
82
+ /* Result panel */
83
+ .panel { display: none; flex-direction: column; width: 420px; max-height: var(--room, none); border-radius: 14px; overflow: hidden; }
84
+ [data-mode="panel"] .panel { display: flex; }
85
+ .panel-head { flex: none; display: flex; align-items: center; gap: 2px; height: 44px; padding: 0 6px 0 14px; cursor: grab; }
86
+ [data-dragging="true"], [data-dragging="true"] * { cursor: grabbing !important; }
87
+ .panel-icon { display: inline-flex; color: var(--accent); margin-right: 6px; }
88
+ .panel-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
89
+ .panel-app { min-width: 0; margin-left: 8px; color: var(--muted); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
90
+ .spacer { flex: 1; }
91
+ /* Drawn by us rather than as a native pop-up button; the list that opens is still the system menu. */
92
+ .panel-language {
93
+ display: none; height: 26px; max-width: 120px; margin-right: 4px; padding: 0 24px 0 10px;
94
+ border: 0; border-radius: 7px; outline: 0; -webkit-appearance: none; appearance: none;
95
+ background: var(--soft) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a8a8e' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 7px center / 12px 12px;
96
+ color: var(--text); font-size: 12px; font-weight: 500; line-height: 26px; cursor: default;
97
+ text-overflow: ellipsis; white-space: nowrap; overflow: hidden;
98
+ }
99
+ [data-pointer] .panel-language:hover { background-color: var(--press); }
100
+ [data-kind="translate"] .panel-language { display: inline-block; }
101
+ .tool { width: 28px; height: 28px; color: var(--muted); }
102
+ [data-pointer] .tool:hover { color: var(--text); }
103
+ .tool[aria-pressed="true"] { color: var(--accent); background: var(--hover); }
104
+ .tool.done { color: #1f9d55; }
105
+ [data-kind="ask"] .tool[data-tool="retry"] { display: none; }
106
+
107
+ .panel-body { flex: 0 1 auto; min-height: 0; padding: 0 16px 14px; max-height: 340px; overflow-y: auto; overscroll-behavior: contain; }
108
+ [data-fill] .panel { height: var(--room); }
109
+ [data-fill] .panel-body { flex-grow: 1; max-height: none; }
110
+ /* A styled scrollbar, so Windows draws a thin bar without its classic arrow buttons. */
111
+ .panel-body::-webkit-scrollbar { width: 8px; }
112
+ .panel-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 4px; border: 2px solid var(--surface); }
113
+ .source {
114
+ padding: 7px 10px; border-radius: 8px; background: var(--soft); color: var(--muted); font-size: 12px; line-height: 1.5;
115
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word;
116
+ }
117
+ .source.expanded { display: block; -webkit-line-clamp: unset; }
118
+ .result { margin-top: 10px; }
119
+ [data-kind="ask"] .result { display: none; }
120
+ .turn { display: flex; flex-direction: column; margin-top: 12px; }
121
+ .question { align-self: flex-end; max-width: 85%; padding: 6px 10px; border-radius: 10px 10px 2px 10px; background: var(--soft); white-space: pre-wrap; word-break: break-word; }
122
+ .answer { margin-top: 8px; }
123
+
124
+ .pending::after { content: ""; display: inline-block; width: 18px; height: 6px; margin-left: 2px;
125
+ background: radial-gradient(circle, var(--muted) 2px, transparent 2.5px) 0 0 / 6px 6px repeat-x;
126
+ animation: pending 1s steps(4) infinite; vertical-align: middle; }
127
+ .pending:empty::before { content: attr(data-pending); color: var(--muted); }
128
+ @keyframes pending { from { width: 0 } to { width: 24px } }
129
+ .failure { color: var(--danger); }
130
+ .failure small { display: block; margin-top: 2px; color: var(--muted); font-size: 11.5px; word-break: break-word; }
131
+ .failure button { margin-top: 6px; height: 26px; padding: 0 10px; background: var(--soft); color: var(--text); }
132
+
133
+ .composer { flex: none; display: none; align-items: flex-end; gap: 6px; padding: 8px 10px 10px 14px; border-top: 1px solid var(--hairline); }
134
+ [data-kind="ask"] .composer, [data-composer="shown"] .composer { display: flex; }
135
+ .composer textarea {
136
+ flex: 1; min-height: 22px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
137
+ overflow-y: hidden; scrollbar-width: none;
138
+ line-height: 1.5; user-select: text; -webkit-user-select: text; cursor: text;
139
+ }
140
+ /* Past its tallest size it scrolls with the wheel and the caret, without a bar. */
141
+ .composer textarea::-webkit-scrollbar { display: none; }
142
+ .composer textarea::placeholder { color: var(--muted); }
143
+ .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[disabled] { opacity: 0.35; }
146
+
147
+ /* Model output */
148
+ .markdown { font-size: 13.5px; line-height: 1.65; user-select: text; -webkit-user-select: text; cursor: text; word-break: break-word; }
149
+ .markdown > * + * { margin-top: 0.6em; }
150
+ .markdown h1, .markdown h2, .markdown h3, .markdown h4 { font-size: 14px; font-weight: 600; }
151
+ .markdown ul, .markdown ol { padding-left: 1.4em; }
152
+ .markdown li + li { margin-top: 0.2em; }
153
+ .markdown code { font-family: var(--code); font-size: 12px; padding: 1px 4px; border-radius: 4px; background: var(--soft); }
154
+ .markdown pre { padding: 8px 10px; border-radius: 8px; background: var(--soft); overflow-x: auto; }
155
+ .markdown pre code { padding: 0; background: none; font-size: 12px; line-height: 1.5; }
156
+ .markdown blockquote { padding-left: 10px; border-left: 3px solid var(--hairline); color: var(--muted); }
157
+ .markdown a { color: var(--accent); text-decoration: none; cursor: pointer; }
158
+ [data-pointer] .markdown a:hover { text-decoration: underline; }
159
+ .markdown table { border-collapse: collapse; font-size: 12.5px; }
160
+ .markdown th, .markdown td { padding: 4px 8px; border: 1px solid var(--hairline); text-align: left; vertical-align: top; }
161
+ .markdown th { background: var(--soft); font-weight: 600; }
162
+ .markdown hr { border: 0; border-top: 1px solid var(--hairline); }
@@ -0,0 +1,54 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:">
7
+ <title>DSH Selection</title>
8
+ <link rel="stylesheet" href="toolbar.css">
9
+ </head>
10
+ <body data-mode="hidden">
11
+ <div class="stage">
12
+ <div class="card bar" role="toolbar">
13
+ <div class="bar-skills"></div>
14
+ <span class="divider" aria-hidden="true"></span>
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>
17
+ </div>
18
+
19
+ <div class="card menu" role="menu">
20
+ <div class="menu-skills"></div>
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>
23
+ <button type="button" class="menu-item plain" role="menuitem" data-menu-action="disable-app" data-i18n="disableApp"></button>
24
+ <button type="button" class="menu-item plain" role="menuitem" data-menu-action="settings" data-i18n="settings"></button>
25
+ </div>
26
+
27
+ <section class="card panel" aria-live="polite">
28
+ <header class="panel-head">
29
+ <span class="panel-icon"></span>
30
+ <h1 class="panel-title"></h1>
31
+ <span class="panel-app"></span>
32
+ <span class="spacer"></span>
33
+ <select class="panel-language" data-i18n-label="translateTo"></select>
34
+ <button type="button" class="icon-button tool" data-tool="pin" data-i18n-label="pin" aria-pressed="false"></button>
35
+ <button type="button" class="icon-button tool" data-tool="copy" data-i18n-label="copyResult"></button>
36
+ <button type="button" class="icon-button tool" data-tool="retry" data-i18n-label="retry"></button>
37
+ <button type="button" class="icon-button tool" data-tool="close" data-i18n-label="close"></button>
38
+ </header>
39
+ <div class="panel-body">
40
+ <p class="source" title=""></p>
41
+ <div class="result markdown"></div>
42
+ <div class="thread"></div>
43
+ </div>
44
+ <form class="composer">
45
+ <textarea rows="1" maxlength="2000" data-i18n-placeholder="askPlaceholder"></textarea>
46
+ <button type="submit" class="icon-button send" data-i18n-label="send"></button>
47
+ </form>
48
+ </section>
49
+ </div>
50
+ <script src="icons.js"></script>
51
+ <script src="markdown.js"></script>
52
+ <script src="toolbar.js"></script>
53
+ </body>
54
+ </html>