sideby 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +16 -0
- package/dist/src/cli/commands.js +457 -5
- package/dist/src/cli/json-schemas.js +112 -0
- package/dist/src/cli/main.js +93 -3
- package/dist/src/core/ask.d.ts +150 -0
- package/dist/src/core/ask.js +365 -0
- package/dist/src/core/attachments.d.ts +50 -0
- package/dist/src/core/attachments.js +442 -0
- package/dist/src/core/autostart.d.ts +88 -0
- package/dist/src/core/autostart.js +256 -0
- package/dist/src/core/config.d.ts +97 -0
- package/dist/src/core/config.js +278 -10
- package/dist/src/core/launch.d.ts +15 -4
- package/dist/src/core/launch.js +27 -23
- package/dist/src/core/local-build.d.ts +5 -0
- package/dist/src/core/local-build.js +23 -3
- package/dist/src/core/probe.d.ts +78 -0
- package/dist/src/core/probe.js +127 -0
- package/dist/src/core/same-login.d.ts +9 -4
- package/dist/src/core/same-login.js +9 -4
- package/dist/src/core/secret-file.d.ts +26 -0
- package/dist/src/core/secret-file.js +126 -7
- package/dist/src/core/terminal.d.ts +93 -0
- package/dist/src/core/terminal.js +371 -0
- package/dist/src/core/update.d.ts +11 -0
- package/dist/src/core/update.js +38 -0
- package/dist/src/families/claude/index.js +3 -0
- package/dist/src/families/claude/quota-cache.d.ts +24 -7
- package/dist/src/families/claude/quota-cache.js +72 -21
- package/dist/src/families/codex/index.js +7 -0
- package/dist/src/families/codex/login-key.d.ts +8 -0
- package/dist/src/families/codex/login-key.js +33 -0
- package/dist/src/families/grok/index.js +3 -0
- package/dist/src/families/login-keys.d.ts +6 -0
- package/dist/src/families/login-keys.js +6 -0
- package/dist/src/families/pi/index.js +4 -0
- package/dist/src/panel/background.js +3 -2
- package/dist/src/panel/handler.d.ts +29 -2
- package/dist/src/panel/handler.js +393 -9
- package/dist/src/panel/i18n.d.ts +172 -2
- package/dist/src/panel/i18n.js +375 -35
- package/dist/src/panel/page-logic.d.ts +196 -0
- package/dist/src/panel/page-logic.js +377 -0
- package/dist/src/panel/page-script.js +947 -126
- package/dist/src/panel/page-styles.js +69 -19
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +2 -0
- package/dist/src/panel/server.d.ts +16 -2
- package/dist/src/panel/server.js +41 -13
- package/dist/src/plugins/check.d.ts +47 -0
- package/dist/src/plugins/check.js +544 -0
- package/dist/src/plugins/scaffold.d.ts +21 -0
- package/dist/src/plugins/scaffold.js +123 -0
- package/dist/src/quota/statusline-tap.js +1 -1
- package/dist/src/runtime.d.ts +10 -0
- package/dist/src/runtime.js +47 -16
- package/dist/src/types.d.ts +19 -0
- package/package.json +2 -2
- package/schemas/ask.json +180 -0
- package/schemas/autostart.json +165 -0
- package/schemas/config-get.json +132 -0
- package/schemas/config-set.json +107 -0
- package/schemas/config.json +73 -6
- package/schemas/key.json +93 -0
- package/schemas/plugins-check.json +113 -0
- package/schemas/plugins-new.json +87 -0
- package/schemas/terminal.json +152 -0
- package/schemas/try.json +109 -0
- package/schemas/update.json +4 -0
- package/skills/sideby/SKILL.md +12 -6
- package/skills/sideby/references/accounts.md +1 -1
- package/skills/sideby/references/ai-tasks.md +116 -0
- package/skills/sideby/references/panel.md +14 -0
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
// The Panel page's inline script. Plain browser JavaScript in a string (no template literals inside, so String.raw
|
|
2
2
|
// keeps it verbatim); the pure helpers from page-logic.ts are prepended so the tested code is the code that runs.
|
|
3
|
-
import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, canLeaveLocal, fixPrompt, formatDuration, handoffChangeOf, handoffHintAccount, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelFixes, panelHint, panelLang, passedWindow, pinnedIn, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, sortAccounts, suggestAlias, suggestName, switchFailure, updatedAge, windowState, withPinned, } from './page-logic.js';
|
|
3
|
+
import { aiTaskAccounts, aliasArgsOf, aliasIssue, askCopy, attentionReasons, attentionWhy, autostartNote, cacheHitRate, canLeaveLocal, fileSize, fixPrompt, formatDuration, handoffChangeOf, handoffHintAccount, handoffTaskFacts, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, menuPlacement, mergeDaily, newAccountDir, nextReset, panelFixes, panelHint, panelLang, panelMigration, panelPrefs, passedWindow, pinnedIn, quotaAskable, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, rowBadge, sortAccounts, stripSecrets, suggestAlias, suggestName, switchFailure, taskFindings, updatedAge, windowState, withPinned, } from './page-logic.js';
|
|
4
4
|
const LOGIC = [
|
|
5
|
+
aiTaskAccounts,
|
|
6
|
+
askCopy,
|
|
7
|
+
quotaAskable,
|
|
8
|
+
stripSecrets,
|
|
9
|
+
taskFindings,
|
|
5
10
|
aliasArgsOf,
|
|
6
11
|
aliasIssue,
|
|
7
12
|
attentionReasons,
|
|
8
13
|
attentionWhy,
|
|
14
|
+
autostartNote,
|
|
9
15
|
cacheHitRate,
|
|
16
|
+
fileSize,
|
|
10
17
|
fixPrompt,
|
|
11
18
|
formatDuration,
|
|
12
19
|
handoffChangeOf,
|
|
13
20
|
handoffHintAccount,
|
|
21
|
+
handoffTaskFacts,
|
|
14
22
|
inDisplayOrder,
|
|
15
23
|
loginClusters,
|
|
16
24
|
loginKey,
|
|
17
25
|
maskEmail,
|
|
18
26
|
matchesQuery,
|
|
27
|
+
menuPlacement,
|
|
19
28
|
mergeDaily,
|
|
20
29
|
newAccountDir,
|
|
21
30
|
nextReset,
|
|
@@ -24,11 +33,14 @@ const LOGIC = [
|
|
|
24
33
|
panelFixes,
|
|
25
34
|
panelHint,
|
|
26
35
|
panelLang,
|
|
36
|
+
panelMigration,
|
|
37
|
+
panelPrefs,
|
|
27
38
|
passedWindow,
|
|
28
39
|
quotaPressure,
|
|
29
40
|
remoteControlSummary,
|
|
30
41
|
remoteControlTodo,
|
|
31
42
|
reorder,
|
|
43
|
+
rowBadge,
|
|
32
44
|
sortAccounts,
|
|
33
45
|
suggestAlias,
|
|
34
46
|
suggestName,
|
|
@@ -82,6 +94,7 @@ const MAIN = String.raw `
|
|
|
82
94
|
if (raw.hideEmail === true) p.hideEmail = true
|
|
83
95
|
if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
|
|
84
96
|
if (raw.rcHide === true) p.rcHide = true
|
|
97
|
+
if (raw.cfgDone === true) p.cfgDone = true
|
|
85
98
|
if (typeof raw.family === 'string') p.family = raw.family
|
|
86
99
|
if (raw.collapsed && typeof raw.collapsed === 'object')
|
|
87
100
|
for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
|
|
@@ -92,7 +105,16 @@ const MAIN = String.raw `
|
|
|
92
105
|
function savePrefs() { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch {} }
|
|
93
106
|
// A host lang other than auto wins over the saved choice (which stays saved) and hides the switch.
|
|
94
107
|
const LANG_FIXED = BOOT.lang === 'en' || BOOT.lang === 'zh'
|
|
95
|
-
|
|
108
|
+
// Theme and language live in config panel.* (spec §3.29), so the CLI and an AI assistant can change them too. A
|
|
109
|
+
// choice this browser saved before is written there once (cfgDone), then config is the only source. A read-only
|
|
110
|
+
// Panel cannot write config and keeps the browser's choice.
|
|
111
|
+
const CFG = !BOOT.readOnly
|
|
112
|
+
let cfgPanel = Object.assign({}, BOOT.panel || {})
|
|
113
|
+
let cfgSaving = 0
|
|
114
|
+
const cfgMigrate = CFG && !prefs.cfgDone ? panelMigration(cfgPanel, { theme: prefs.theme, lang: prefs.lang }) : null
|
|
115
|
+
if (cfgMigrate) Object.assign(cfgPanel, cfgMigrate)
|
|
116
|
+
function viewPrefs() { return panelPrefs(cfgPanel, { theme: prefs.theme, lang: prefs.lang }, CFG) }
|
|
117
|
+
let lang = panelLang(BOOT.lang, viewPrefs().language, navigator.language)
|
|
96
118
|
|
|
97
119
|
// ---------- text ----------
|
|
98
120
|
function t(key, vars) {
|
|
@@ -278,6 +300,8 @@ const MAIN = String.raw `
|
|
|
278
300
|
swap: 'M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7',
|
|
279
301
|
// Lucide "pin" (ISC license)
|
|
280
302
|
pin: 'M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z',
|
|
303
|
+
// Lucide "link" (ISC license)
|
|
304
|
+
link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
|
281
305
|
bot: 'M6 9h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM12 5v4M9.5 13.5v1M14.5 13.5v1',
|
|
282
306
|
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
|
|
283
307
|
list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
|
|
@@ -285,6 +309,8 @@ const MAIN = String.raw `
|
|
|
285
309
|
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
286
310
|
mail: 'M3 6h18v12H3zM3 7l9 6 9-6',
|
|
287
311
|
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9S9.5 5.5 12 3z',
|
|
312
|
+
image: 'M3 5h18v14H3zM3 16l5-5 4 4 3-3 6 6M15 9h.01',
|
|
313
|
+
film: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9h4M3 15h4M17 9h4M17 15h4',
|
|
288
314
|
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
|
289
315
|
eyeOff: 'M3 3l18 18M10.6 5.1A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.6 6.6A17.4 17.4 0 0 0 2 12s3.5 7 10 7a10.4 10.4 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2',
|
|
290
316
|
}
|
|
@@ -553,24 +579,39 @@ const MAIN = String.raw `
|
|
|
553
579
|
|
|
554
580
|
// ---------- floating panels: menus and popovers ----------
|
|
555
581
|
// One shared base for everything that floats next to a button: it is placed under (or above) its anchor, kept
|
|
556
|
-
// inside the window, closed by a click outside, Escape
|
|
582
|
+
// inside the window, closed by a click outside, Escape or a resize, and only one is open at a time. One taller than
|
|
583
|
+
// the room it gets scrolls inside itself (menuPlacement), and that scroll never closes it; a scroll of the page
|
|
584
|
+
// moves it along with its anchor and closes it only when the anchor leaves the window.
|
|
557
585
|
// openMenu() fills it with menu items; openPopover() with any content that render() returns (re-run by refreshPopover()).
|
|
558
|
-
|
|
586
|
+
// returnTo: where focus goes back on close when that is not the anchor (a right-click menu's point).
|
|
587
|
+
const menu = { el: null, anchor: null, kind: null, render: null, align: 'end', returnTo: null }
|
|
588
|
+
function placeFloat() {
|
|
589
|
+
const el = menu.el
|
|
590
|
+
const r = menu.anchor.getBoundingClientRect()
|
|
591
|
+
if (r.bottom < 0 || r.top > window.innerHeight) { closeMenu(); return }
|
|
592
|
+
// Its full height, not the capped one: a menu that got more room grows back.
|
|
593
|
+
const height = el.scrollHeight + el.offsetHeight - el.clientHeight
|
|
594
|
+
const p = menuPlacement(r, { width: el.offsetWidth, height }, { width: window.innerWidth, height: window.innerHeight }, menu.align)
|
|
595
|
+
el.style.setProperty('left', p.left + 'px')
|
|
596
|
+
el.style.setProperty('top', p.top + 'px')
|
|
597
|
+
el.style.setProperty('max-height', p.maxHeight + 'px')
|
|
598
|
+
if (menu.kind === 'settings') settingsFit()
|
|
599
|
+
}
|
|
559
600
|
function openFloat(anchor, el, kind, align) {
|
|
560
601
|
closeMenu()
|
|
561
602
|
document.body.append(el)
|
|
562
|
-
const r = anchor.getBoundingClientRect()
|
|
563
|
-
const w = el.offsetWidth
|
|
564
|
-
const hgt = el.offsetHeight
|
|
565
|
-
const x = align === 'start' ? r.left : r.right - w
|
|
566
|
-
const left = Math.max(8, Math.min(window.innerWidth - w - 8, x))
|
|
567
|
-
const top = r.bottom + 4 + hgt > window.innerHeight - 8 && r.top - hgt - 4 > 8 ? r.top - hgt - 4 : r.bottom + 4
|
|
568
|
-
el.style.setProperty('left', left + 'px')
|
|
569
|
-
el.style.setProperty('top', top + 'px')
|
|
570
603
|
anchor.setAttribute('aria-expanded', 'true')
|
|
571
604
|
menu.el = el
|
|
572
605
|
menu.anchor = anchor
|
|
573
606
|
menu.kind = kind
|
|
607
|
+
menu.align = align
|
|
608
|
+
placeFloat()
|
|
609
|
+
// The wheel over it never scrolls the page behind: overscroll-behavior covers a menu that scrolls, this one that
|
|
610
|
+
// does not or has reached its end.
|
|
611
|
+
el.addEventListener('wheel', (e) => {
|
|
612
|
+
const end = e.deltaY < 0 ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1
|
|
613
|
+
if (end) e.preventDefault()
|
|
614
|
+
}, { passive: false })
|
|
574
615
|
}
|
|
575
616
|
function openMenu(anchor, items, label) {
|
|
576
617
|
const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
|
|
@@ -586,7 +627,7 @@ const MAIN = String.raw `
|
|
|
586
627
|
// A popover: a small panel of text and buttons, lighter than a dialog. A second click on its anchor closes it.
|
|
587
628
|
function openPopover(anchor, kind, label, render, align = 'start') {
|
|
588
629
|
if (menu.anchor === anchor) { closeMenu(true); return }
|
|
589
|
-
const el = h('div', { class: 'menu popover', role: 'dialog', 'aria-label': label })
|
|
630
|
+
const el = h('div', { class: 'menu popover pop-' + kind, role: 'dialog', 'aria-label': label })
|
|
590
631
|
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); closeMenu(true) } })
|
|
591
632
|
setKids(el, render())
|
|
592
633
|
openFloat(anchor, el, kind, align)
|
|
@@ -595,25 +636,71 @@ const MAIN = String.raw `
|
|
|
595
636
|
if (first) first.focus()
|
|
596
637
|
}
|
|
597
638
|
function refreshPopover(kind) {
|
|
598
|
-
if (menu.el && menu.kind === kind && menu.render) setKids(menu.el, menu.render())
|
|
639
|
+
if (menu.el && menu.kind === kind && menu.render) { setKids(menu.el, menu.render()); placeFloat() }
|
|
599
640
|
}
|
|
600
641
|
function closeMenu(restore) {
|
|
601
642
|
if (!menu.el) return
|
|
602
643
|
menu.el.remove()
|
|
603
644
|
menu.anchor.setAttribute('aria-expanded', 'false')
|
|
604
|
-
|
|
645
|
+
const back = menu.returnTo || menu.anchor
|
|
646
|
+
if (restore && back.isConnected) back.focus()
|
|
647
|
+
if (menu.anchor.classList.contains('ctx-anchor')) menu.anchor.remove()
|
|
605
648
|
menu.el = null
|
|
606
649
|
menu.anchor = null
|
|
607
650
|
menu.kind = null
|
|
608
651
|
menu.render = null
|
|
652
|
+
menu.returnTo = null
|
|
653
|
+
}
|
|
654
|
+
// Right-click on an account row or card (spec §3.27) opens the row's "…" menu at the pointer; the ContextMenu key
|
|
655
|
+
// and Shift+F10 open it at the focused element in the row. Inputs, links, buttons and selected text keep the
|
|
656
|
+
// browser's own menu. Each row carries its opener as el.sidebyMenu(anchor).
|
|
657
|
+
let kbMenuAt = 0
|
|
658
|
+
function menuTarget(node) {
|
|
659
|
+
let el = node && node.closest ? node.closest('.row, .card, .login-dir') : null
|
|
660
|
+
while (el && !el.sidebyMenu) el = el.parentElement ? el.parentElement.closest('.row, .card, .login-dir') : null
|
|
661
|
+
return el && $('accounts').contains(el) ? el : null
|
|
662
|
+
}
|
|
663
|
+
function openContextMenu(target, x, y, back) {
|
|
664
|
+
const pt = h('span', { class: 'ctx-anchor', 'aria-hidden': 'true', css: { left: x + 'px', top: y + 'px' } })
|
|
665
|
+
document.body.append(pt)
|
|
666
|
+
target.sidebyMenu(pt)
|
|
667
|
+
if (menu.anchor !== pt) { pt.remove(); return }
|
|
668
|
+
// Opens to the right of the point, or to its left where the right edge is too near, as system menus do.
|
|
669
|
+
menu.align = x + menu.el.offsetWidth + 8 > window.innerWidth ? 'end' : 'start'
|
|
670
|
+
menu.returnTo = back && back !== document.body && target.contains(back) ? back : target.querySelector('.more')
|
|
671
|
+
placeFloat()
|
|
672
|
+
}
|
|
673
|
+
function onContextMenu(e) {
|
|
674
|
+
if (Date.now() - kbMenuAt < 600) { e.preventDefault(); return }
|
|
675
|
+
if (ui.arrange || !e.target.closest || e.target.closest('input, textarea, select, a, button, [contenteditable], .tip')) return
|
|
676
|
+
if (window.getSelection && String(window.getSelection())) return
|
|
677
|
+
const target = menuTarget(e.target)
|
|
678
|
+
if (!target) return
|
|
679
|
+
e.preventDefault()
|
|
680
|
+
openContextMenu(target, e.clientX, e.clientY, document.activeElement)
|
|
681
|
+
}
|
|
682
|
+
function onMenuKey(e) {
|
|
683
|
+
if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return
|
|
684
|
+
if (menu.el || (dlg.el && dlg.el.open) || ui.arrange) return
|
|
685
|
+
const at = document.activeElement
|
|
686
|
+
// Text fields and links keep the browser's own menu, as with the mouse; the row's own buttons open the row menu.
|
|
687
|
+
if (at && at.closest && at.closest('input, textarea, select, a, [contenteditable]')) return
|
|
688
|
+
const target = menuTarget(at)
|
|
689
|
+
if (!target) return
|
|
690
|
+
e.preventDefault()
|
|
691
|
+
kbMenuAt = Date.now()
|
|
692
|
+
const r = (at && at !== document.body ? at : target).getBoundingClientRect()
|
|
693
|
+
openContextMenu(target, r.left, r.bottom, at)
|
|
609
694
|
}
|
|
610
695
|
function menuKeys(e) {
|
|
611
696
|
const items = Array.from(menu.el.querySelectorAll('.menu-item:not(:disabled)'))
|
|
612
697
|
const i = items.indexOf(document.activeElement)
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
else if (e.key === '
|
|
698
|
+
// A menu that scrolls inside keeps the focused item in sight; focus() alone does not always scroll it there.
|
|
699
|
+
const go = (it) => { it.focus({ preventScroll: true }); it.scrollIntoView({ block: 'nearest' }) }
|
|
700
|
+
if (e.key === 'ArrowDown') go(items[(i + 1) % items.length])
|
|
701
|
+
else if (e.key === 'ArrowUp') go(items[(i - 1 + items.length) % items.length])
|
|
702
|
+
else if (e.key === 'Home') go(items[0])
|
|
703
|
+
else if (e.key === 'End') go(items[items.length - 1])
|
|
617
704
|
else if (e.key === 'Escape') closeMenu(true)
|
|
618
705
|
else if (e.key === 'Tab') { closeMenu(true); return }
|
|
619
706
|
else return
|
|
@@ -621,7 +708,8 @@ const MAIN = String.raw `
|
|
|
621
708
|
}
|
|
622
709
|
document.addEventListener('pointerdown', (e) => { if (menu.el && !menu.el.contains(e.target) && !menu.anchor.contains(e.target)) closeMenu() })
|
|
623
710
|
window.addEventListener('resize', () => closeMenu())
|
|
624
|
-
|
|
711
|
+
// Capture also sees the scroll of an inner scroller (a dialog, a list); the menu's own scroll is left alone.
|
|
712
|
+
window.addEventListener('scroll', (e) => { if (menu.el && !menu.el.contains(e.target)) placeFloat() }, { passive: true, capture: true })
|
|
625
713
|
|
|
626
714
|
// What one directory does: start it, its short commands, its desktop app, its folder, model, tuning, Health.
|
|
627
715
|
function dirCopies(a) {
|
|
@@ -647,20 +735,22 @@ const MAIN = String.raw `
|
|
|
647
735
|
function loginItems(a) {
|
|
648
736
|
return [
|
|
649
737
|
browserFor(a) ? { icon: 'globe', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
|
|
738
|
+
a.kind !== 'api' && !BOOT.readOnly ? { icon: 'terminal', label: t('menu.signIn'), run: () => signIn(a) } : null,
|
|
650
739
|
a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
|
|
740
|
+
keyable(a) ? { icon: 'key', label: t('menu.enterKey'), run: () => openKey(a) } : null,
|
|
651
741
|
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
652
742
|
]
|
|
653
743
|
}
|
|
654
744
|
function likeItem(a) { return BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) } }
|
|
655
745
|
// Grouped by what the user wants to do: open, copy, manage.
|
|
656
746
|
function accountMenu(a, anchor) {
|
|
657
|
-
const [browser, login, email] = loginItems(a)
|
|
747
|
+
const [browser, signInItem, login, key, email] = loginItems(a)
|
|
658
748
|
const opens = [deskItem(a), browser].filter(Boolean)
|
|
659
749
|
const copies = dirCopies(a)
|
|
660
750
|
const items = [pinItem(a),
|
|
661
751
|
opens.length ? '-' : null, opens.length ? { header: t('menu.groupOpen') } : null, ...opens,
|
|
662
752
|
'-', { header: t('menu.groupCopy') }, ...copies, login, email,
|
|
663
|
-
'-', { header: t('menu.groupManage') }, ...dirTools(a), likeItem(a)]
|
|
753
|
+
'-', { header: t('menu.groupManage') }, signInItem, key, ...dirTools(a), likeItem(a)]
|
|
664
754
|
openMenu(anchor, items, t('menu.label', { ref: a.ref }))
|
|
665
755
|
}
|
|
666
756
|
// A Same Login row (ADR-0026): pin first, then a section per directory, then what the login shares.
|
|
@@ -682,15 +772,19 @@ const MAIN = String.raw `
|
|
|
682
772
|
const text = t('row.copyRun', { cmd })
|
|
683
773
|
return h('button', { class: 'icon-btn row-run', type: 'button', 'aria-label': text, title: text, onclick: (e) => { e.stopPropagation(); copyText(cmd) } }, icon('terminal'))
|
|
684
774
|
}
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
function
|
|
775
|
+
// A pinned row (spec §3.25, §3.27) says so with a small muted pin right before its name, and nothing at its end;
|
|
776
|
+
// pinning and unpinning are in the row's menu and in the arrange mode.
|
|
777
|
+
function pinMark(a) {
|
|
778
|
+
return isPinned(a) ? h('span', { class: 'pin-mark', role: 'img', title: t('pin.mark'), 'aria-label': t('pin.mark') }, icon('pin')) : null
|
|
779
|
+
}
|
|
780
|
+
// Before the name (spec §3.27, 2026-10-18): a pinned row always shows its pin, and a click unpins it; any other
|
|
781
|
+
// row shows a fainter pin in the same place on hover or keyboard focus, and a click pins it. The slot keeps its
|
|
782
|
+
// width when empty, so the name never moves. A read-only Panel shows only the mark.
|
|
783
|
+
function pinSlot(a) {
|
|
784
|
+
if (BOOT.readOnly) return pinMark(a)
|
|
688
785
|
const on = isPinned(a)
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const text = full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(a.family).title })
|
|
692
|
-
return h('button', { class: 'icon-btn pin-btn row-pin' + (on ? ' is-on' : ''), type: 'button', 'aria-pressed': String(on), 'aria-label': text, title: text, 'aria-disabled': full ? 'true' : null,
|
|
693
|
-
onclick: (e) => { e.stopPropagation(); if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))
|
|
786
|
+
const label = t(on ? 'pin.unpin' : 'pin.pin')
|
|
787
|
+
return h('button', { class: 'pin-slot' + (on ? ' is-on' : ''), type: 'button', 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); togglePin(a) } }, icon('pin'))
|
|
694
788
|
}
|
|
695
789
|
function pinItem(a) {
|
|
696
790
|
if (BOOT.readOnly) return null
|
|
@@ -765,12 +859,14 @@ const MAIN = String.raw `
|
|
|
765
859
|
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
|
|
766
860
|
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.getAttribute('data-i18n'))
|
|
767
861
|
$('refresh').setAttribute('aria-label', t('top.refresh'))
|
|
862
|
+
$('ask-ai').setAttribute('aria-label', t('ask.button'))
|
|
768
863
|
renderSettingsButton()
|
|
769
864
|
}
|
|
770
865
|
function applyTheme() {
|
|
771
866
|
if (BOOT.colorScheme !== 'auto') return
|
|
772
|
-
|
|
773
|
-
|
|
867
|
+
const theme = viewPrefs().theme
|
|
868
|
+
if (theme === 'system') document.documentElement.removeAttribute('data-theme')
|
|
869
|
+
else document.documentElement.setAttribute('data-theme', theme)
|
|
774
870
|
}
|
|
775
871
|
function stat(label, value, sub, extra, opts) {
|
|
776
872
|
const kids = [h('div', { class: 'stat-text' }, h('div', { class: 'stat-label' }, label), h('div', { class: 'stat-value' }, value), h('div', { class: 'stat-sub' }, sub)), extra]
|
|
@@ -955,7 +1051,7 @@ const MAIN = String.raw `
|
|
|
955
1051
|
const setup = setupFor(a.family)
|
|
956
1052
|
// One setup turns quota on for every account of the Family, so the switch lives in the group header only.
|
|
957
1053
|
// A check that asks the Host (Grok) and is turned off, or failed (spec §3.26): say why and what to do.
|
|
958
|
-
if (q.reason === 'fetch-failed') return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.fetchFailed')), tipRaw(t('quota.fetchFailed'), q.detail || ''))
|
|
1054
|
+
if (q.reason === 'fetch-failed') return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.fetchFailed')), tipRaw(t('quota.fetchFailed'), q.detail || ''), askWhy(a, q))
|
|
959
1055
|
if (q.reason === 'not-enabled' && checkOf(a.family)) return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.checkOff')), tipRaw(t('quota.checkOff'), q.detail || t('quota.checkOffHint')))
|
|
960
1056
|
if (q.reason === 'not-enabled') {
|
|
961
1057
|
const host = famTitle(a)
|
|
@@ -966,7 +1062,12 @@ const MAIN = String.raw `
|
|
|
966
1062
|
}
|
|
967
1063
|
if (q.reason === 'api-account') return h('div', { class: 'q-note' }, t('quota.api'), tip('api'))
|
|
968
1064
|
const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
|
|
969
|
-
return h('div', { class: 'q-note', title: q.reason === 'no-session' ? t('quota.noSessionHint') : q.detail || null }, text ? t(text) : t('quota.unavailable', { reason: q.reason }))
|
|
1065
|
+
return h('div', { class: 'q-note', title: q.reason === 'no-session' ? t('quota.noSessionHint') : q.detail || null }, text ? t(text) : t('quota.unavailable', { reason: q.reason }), askWhy(a, q))
|
|
1066
|
+
}
|
|
1067
|
+
// "Ask AI why" in a quota cell with no data or an error (spec §3.28).
|
|
1068
|
+
function askWhy(a, q) {
|
|
1069
|
+
if (!quotaAskable(q.reason)) return null
|
|
1070
|
+
return h('button', { class: 'link-btn small q-ask', type: 'button', onclick: (e) => { e.stopPropagation(); openAsk({ task: 'quota', context: { account: a.ref, family: a.family, quotaReason: q.reason + (q.detail ? ': ' + q.detail : ''), version: BOOT.version } }) } }, t('ask.quotaWhy'))
|
|
970
1071
|
}
|
|
971
1072
|
function quotaBlock(a, now, cols) {
|
|
972
1073
|
const q = a.quota
|
|
@@ -1020,53 +1121,82 @@ const MAIN = String.raw `
|
|
|
1020
1121
|
return h('button', { class: 'chip chip-btn health' + (lvl ? ' chip-' + lvl : ''), type: 'button', title: t('health.filterHint'), 'aria-label': t('health.chipLabel', { ref: a.ref, status: text }), onclick: () => filterHealth(a.ref) },
|
|
1021
1122
|
h('span', { class: 'dot' + (lvl ? ' dot-' + lvl : ''), 'aria-hidden': 'true' }), text)
|
|
1022
1123
|
}
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
|
|
1026
|
-
function attentionDot(a) {
|
|
1124
|
+
// Who the attention words speak about. Inside a login group the quota warning is the first row's, so another
|
|
1125
|
+
// row's word is only about the row itself.
|
|
1126
|
+
function attentionSubject(a) {
|
|
1027
1127
|
const lead = loginLead(a)
|
|
1028
|
-
|
|
1128
|
+
return !lead ? a
|
|
1029
1129
|
: lead !== a.ref ? Object.assign({}, a, { quota: undefined })
|
|
1030
1130
|
: Object.assign({}, a, { quota: a.quota && Object.assign({}, a.quota, { from: undefined }) })
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1131
|
+
}
|
|
1132
|
+
// A word, not only a colour (spec §3.8): what kind of look the account needs; the reason is on hover.
|
|
1133
|
+
function attentionWord(subject, reason) {
|
|
1134
|
+
const lvl = reason === 'health-fail' ? 'fail' : 'warn'
|
|
1034
1135
|
const why = whyOf(subject, Date.now())
|
|
1035
|
-
|
|
1036
|
-
const word = r.includes('health-fail') ? t('att.fix') : r.includes('quota-high') ? t('att.quota') : t('att.check')
|
|
1136
|
+
const word = reason === 'health-fail' ? t('att.fix') : reason === 'quota-high' ? t('att.quota') : t('att.check')
|
|
1037
1137
|
return h('span', { class: 'tag tag-' + lvl + ' att-tag', title: why, 'aria-label': why }, h('span', { class: 'dot dot-' + lvl, 'aria-hidden': 'true' }), word)
|
|
1038
1138
|
}
|
|
1139
|
+
// The word a directory inside a Same Login's details shows when it needs a fix or a look.
|
|
1140
|
+
function attentionDot(a) {
|
|
1141
|
+
const subject = attentionSubject(a)
|
|
1142
|
+
const r = reasonsOf(subject, Date.now())
|
|
1143
|
+
const reason = ['health-fail', 'quota-high', 'health-warn'].find((x) => r.includes(x))
|
|
1144
|
+
return reason ? attentionWord(subject, reason) : null
|
|
1145
|
+
}
|
|
1146
|
+
// The one status badge after the name (spec §3.27): needs a fix, running low, the other attention words, then
|
|
1147
|
+
// recommended, then API. The rest is in the details or on hover.
|
|
1148
|
+
function statusBadge(a, members) {
|
|
1149
|
+
const subject = attentionSubject(a)
|
|
1150
|
+
const next = members ? members.some(isNext) : isNext(a)
|
|
1151
|
+
const k = rowBadge(reasonsOf(subject, Date.now()), { next, api: a.kind === 'api' })
|
|
1152
|
+
if (k === 'health-fail' || k === 'quota-high' || k === 'health-warn') return attentionWord(subject, k)
|
|
1153
|
+
if (k === 'signed-out')
|
|
1154
|
+
return h('button', { class: 'tag tag-fail tag-btn', type: 'button', title: t('acct.signInHint'), 'aria-label': t('acct.signedOutLabel', { ref: a.ref }), onclick: (e) => { e.stopPropagation(); signIn(a) } }, h('span', { class: 'dot dot-fail', 'aria-hidden': 'true' }), t('acct.signedOut'))
|
|
1155
|
+
if (k === 'not-installed') return h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled'))
|
|
1156
|
+
if (k === 'next') return nextTag(a)
|
|
1157
|
+
if (k === 'api') return h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api'))
|
|
1158
|
+
return null
|
|
1159
|
+
}
|
|
1160
|
+
// The short command, as quiet text after the name; every alias and its arguments are on hover.
|
|
1161
|
+
function aliasText(a) {
|
|
1162
|
+
const all = a.aliases || []
|
|
1163
|
+
if (!all.length) return null
|
|
1164
|
+
const lines = all.map((x) => { const args = aliasArgsOf(a, x); return x + ' · ' + (args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias')) })
|
|
1165
|
+
return h('span', { class: 'alias', title: lines.join('\n') }, all[0])
|
|
1166
|
+
}
|
|
1167
|
+
// A Same Login row (spec §3.21) gets a link mark after the name; what it means is on hover.
|
|
1168
|
+
function loginMark(members) {
|
|
1169
|
+
if (!members || members.length < 2) return null
|
|
1170
|
+
const text = t('login.label', { names: members.map((m) => m.name).join(', ') }) + '\n' + t('login.explain')
|
|
1171
|
+
return h('span', { class: 'login-mark', role: 'img', title: text, 'aria-label': text }, icon('link'))
|
|
1172
|
+
}
|
|
1039
1173
|
// members: the directories of a Same Login shown as this one row (spec §3.21).
|
|
1040
1174
|
function nameLine(a, members) {
|
|
1041
|
-
const next = members ? members.some(isNext) : isNext(a)
|
|
1042
1175
|
return h('div', { class: 'ident-name' },
|
|
1176
|
+
pinSlot(a),
|
|
1043
1177
|
h('span', { class: 'name', title: a.name }, a.name),
|
|
1044
|
-
|
|
1045
|
-
(a
|
|
1046
|
-
|
|
1047
|
-
return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
|
|
1048
|
-
}),
|
|
1049
|
-
members ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: members.length })) : null,
|
|
1050
|
-
a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
|
|
1051
|
-
next
|
|
1052
|
-
? nextTag(a)
|
|
1053
|
-
: null,
|
|
1054
|
-
a.login === 'logged-out' && a.kind !== 'api'
|
|
1055
|
-
? h('button', { class: 'tag tag-fail tag-btn', type: 'button', title: t('acct.signInHint'), 'aria-label': t('acct.signedOutLabel', { ref: a.ref }), onclick: () => copyText('sideby login ' + a.ref) }, h('span', { class: 'dot dot-fail', 'aria-hidden': 'true' }), t('acct.signedOut'))
|
|
1056
|
-
: null,
|
|
1057
|
-
a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled')) : null)
|
|
1178
|
+
loginMark(members),
|
|
1179
|
+
aliasText(a),
|
|
1180
|
+
statusBadge(a, members))
|
|
1058
1181
|
}
|
|
1059
1182
|
// An organization name as the vendor wrote it, without a trailing comma or full stop it sometimes carries.
|
|
1060
1183
|
function orgText(org) { return org ? String(org).replace(/[\s,,;;、。]+$/u, '') : '' }
|
|
1061
1184
|
// The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
|
|
1062
1185
|
function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
|
|
1063
|
-
// Who the account is signed in as, under its name; masked while "Hide
|
|
1186
|
+
// Who the account is signed in as, under its name, in full unless the cell is too narrow; masked while "Hide
|
|
1187
|
+
// emails" is on. The Browser Sign-in is in the details (spec §3.27).
|
|
1064
1188
|
function emailLine(a) {
|
|
1065
1189
|
const id = a.identity
|
|
1066
|
-
if (!id ||
|
|
1067
|
-
const email =
|
|
1068
|
-
|
|
1069
|
-
|
|
1190
|
+
if (!id || !id.email) return null
|
|
1191
|
+
const email = emailText(id.email)
|
|
1192
|
+
const full = [email, orgText(id.org)].filter(Boolean).join(' · ')
|
|
1193
|
+
return h('div', { class: 'ident-email', title: full },
|
|
1194
|
+
h('button', { class: 'email email-btn', type: 'button', title: full + '\n' + t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: (e) => { e.stopPropagation(); copyText(id.email) } }, email))
|
|
1195
|
+
}
|
|
1196
|
+
// The Browser Sign-in as a line of the details: which Chrome browser user signed in to the account's website.
|
|
1197
|
+
function browserItem(a) {
|
|
1198
|
+
const b = browserButton(a)
|
|
1199
|
+
return b ? h('div', { class: 'rd-item rd-browser' }, h('span', { class: 'rd-label' }, t('row.browser')), b) : null
|
|
1070
1200
|
}
|
|
1071
1201
|
function subLine(a) {
|
|
1072
1202
|
const plan = a.quota && a.quota.status === 'ok' && a.quota.plan
|
|
@@ -1348,14 +1478,16 @@ const MAIN = String.raw `
|
|
|
1348
1478
|
if (ui.arrange) return arrangeRow(a, [a])
|
|
1349
1479
|
const open = isOpen(a)
|
|
1350
1480
|
const email = emailLine(a)
|
|
1351
|
-
return h('div', { class: 'row fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
|
|
1481
|
+
return withMenu(h('div', { class: 'row fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
|
|
1352
1482
|
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a))),
|
|
1353
1483
|
quotaBlock(a, now, cols),
|
|
1354
1484
|
lastUsed(a),
|
|
1355
|
-
h('div', { class: 'row-end' }, runButton(a),
|
|
1485
|
+
h('div', { class: 'row-end' }, runButton(a), moreButton(a)),
|
|
1356
1486
|
open ? rowDetail(a, Boolean(email)) : null,
|
|
1357
|
-
notes(a))
|
|
1487
|
+
notes(a)), (anchor) => accountMenu(a, anchor))
|
|
1358
1488
|
}
|
|
1489
|
+
// What a right-click on el opens (see openContextMenu).
|
|
1490
|
+
function withMenu(el, open) { el.sidebyMenu = open; return el }
|
|
1359
1491
|
// Directories signed in as one login (spec §3.21, ADR-0026) are one row: the vendor counts one quota, so the row
|
|
1360
1492
|
// shows it once, with the email and the newest use; opening it lists each directory with its own short command,
|
|
1361
1493
|
// last use, tokens, Health, a button that copies its start command and its own menu.
|
|
@@ -1385,20 +1517,21 @@ const MAIN = String.raw `
|
|
|
1385
1517
|
if (ui.arrange) return arrangeRow(a, ms)
|
|
1386
1518
|
const open = isOpen(a)
|
|
1387
1519
|
const email = emailLine(a)
|
|
1388
|
-
return h('div', { class: 'row row-login fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'aria-label': t('login.label', { names: ms.map((m) => m.name).join(', ') }), 'data-ref': a.ref, 'data-key': loginKey(ms.map((m) => m.ref)), 'data-sig': sig(ms.map(rowSig)), onclick: foldClick(a) },
|
|
1520
|
+
return withMenu(h('div', { class: 'row row-login fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'aria-label': t('login.label', { names: ms.map((m) => m.name).join(', ') }), 'data-ref': a.ref, 'data-key': loginKey(ms.map((m) => m.ref)), 'data-sig': sig(ms.map(rowSig)), onclick: foldClick(a) },
|
|
1389
1521
|
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a, ms), email || subLine(a))),
|
|
1390
1522
|
quotaBlock(a, now, cols),
|
|
1391
1523
|
lastUsed(newestOf(ms)),
|
|
1392
|
-
h('div', { class: 'row-end' }, runButton(a),
|
|
1524
|
+
h('div', { class: 'row-end' }, runButton(a), loginMoreButton(ms)),
|
|
1393
1525
|
open ? loginDetail(ms) : null,
|
|
1394
|
-
notes(a))
|
|
1526
|
+
notes(a)), (anchor) => loginMenu(ms, anchor))
|
|
1395
1527
|
}
|
|
1396
1528
|
function loginDetail(ms) {
|
|
1397
1529
|
return h('div', { class: 'row-detail login-detail' },
|
|
1398
1530
|
h('p', { class: 'rd-note' }, t('login.explain')),
|
|
1531
|
+
browserItem(ms[0]),
|
|
1399
1532
|
h('div', { class: 'login-dirs', role: 'list' }, ms.map((m) => {
|
|
1400
1533
|
const run = 'sideby run ' + m.ref
|
|
1401
|
-
return h('div', { class: 'login-dir', role: 'listitem', 'data-ref': m.ref },
|
|
1534
|
+
return withMenu(h('div', { class: 'login-dir', role: 'listitem', 'data-ref': m.ref },
|
|
1402
1535
|
h('div', { class: 'ld-name' },
|
|
1403
1536
|
h('span', { class: 'name' }, m.name),
|
|
1404
1537
|
m.ref !== ms[0].ref ? attentionDot(m) : null,
|
|
@@ -1409,7 +1542,7 @@ const MAIN = String.raw `
|
|
|
1409
1542
|
usageBlock(m),
|
|
1410
1543
|
healthChip(m),
|
|
1411
1544
|
h('button', { class: 'btn btn-sm', type: 'button', title: run, onclick: () => copyText(run) }, icon('terminal'), t('login.copyRun')),
|
|
1412
|
-
moreButton(m))
|
|
1545
|
+
moreButton(m)), (anchor) => accountMenu(m, anchor))
|
|
1413
1546
|
})))
|
|
1414
1547
|
}
|
|
1415
1548
|
// What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
|
|
@@ -1422,19 +1555,21 @@ const MAIN = String.raw `
|
|
|
1422
1555
|
const same = sameLoginNote(a)
|
|
1423
1556
|
return h('div', { class: 'row-detail' },
|
|
1424
1557
|
withSub || same ? h('div', { class: 'rd-item rd-run' }, h('span', { class: 'rd-label' }, t('row.runs')), withSub ? subLine(a) : null, same) : null,
|
|
1558
|
+
browserItem(a),
|
|
1425
1559
|
h('div', { class: 'rd-item rd-usage' }, h('span', { class: 'rd-label' }, t('col.tokens', { n: BOOT.usageDays })), usageBlock(a)),
|
|
1426
1560
|
h('div', { class: 'rd-item rd-health' }, h('span', { class: 'rd-label' }, t('col.health')), healthChip(a)))
|
|
1427
1561
|
}
|
|
1428
1562
|
// A card shows everything at once; it has room for it.
|
|
1429
1563
|
function card(a, now, cols) {
|
|
1430
|
-
return h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
|
|
1564
|
+
return withMenu(h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
|
|
1431
1565
|
h('header', { class: 'card-head' },
|
|
1432
1566
|
badge(a.family, 'sm'),
|
|
1433
1567
|
h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
|
|
1434
|
-
h('div', { class: 'row-end' },
|
|
1568
|
+
h('div', { class: 'row-end' }, moreButton(a))),
|
|
1435
1569
|
notes(a),
|
|
1570
|
+
browserItem(a),
|
|
1436
1571
|
quotaBlock(a, now, cols),
|
|
1437
|
-
h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
|
|
1572
|
+
h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a)))), (anchor) => accountMenu(a, anchor))
|
|
1438
1573
|
}
|
|
1439
1574
|
// The list's column titles, once per group under its header. Screen readers get each cell's own label instead.
|
|
1440
1575
|
function colHead(cols, list) {
|
|
@@ -1660,7 +1795,7 @@ const MAIN = String.raw `
|
|
|
1660
1795
|
fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
|
|
1661
1796
|
f.installed ? h('span', { class: 'muted small' }, tx('empty.runOnce', { bin: h('code', null, f.bin) })) : f.installCommand ? codeCopy(f.installCommand) : link(t('empty.install'), f.installUrl, 'btn btn-sm')))) : h('p', { class: 'muted small' }, t('empty.noFamilies')),
|
|
1662
1797
|
BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')),
|
|
1663
|
-
BOOT.readOnly ? null : h('p', { class: 'muted small' }, t('ai.emptyHint'), ' ', h('button', { class: 'link-btn', type: 'button', onclick:
|
|
1798
|
+
BOOT.readOnly ? null : h('p', { class: 'muted small' }, t('ai.emptyHint'), ' ', h('button', { class: 'link-btn', type: 'button', onclick: () => openAsk({ task: 'account', context: { version: BOOT.version } }) }, t('ask.title.account'))))
|
|
1664
1799
|
}
|
|
1665
1800
|
function clearFilters() {
|
|
1666
1801
|
ui.query = ''
|
|
@@ -1789,7 +1924,7 @@ const MAIN = String.raw `
|
|
|
1789
1924
|
}
|
|
1790
1925
|
function arrangeBar() {
|
|
1791
1926
|
return h('div', { class: 'arrange-bar', role: 'region', 'aria-label': t('sort.arrange'), 'data-key': 'arrange-bar', 'data-sig': sig([ordered(), lang]) },
|
|
1792
|
-
h('span', { class: 'arrange-text' }, icon('grip'), h('span', null, t('order.bar')), h('button', { class: 'link-btn small', type: 'button', onclick:
|
|
1927
|
+
h('span', { class: 'arrange-text' }, icon('grip'), h('span', null, t('order.bar')), h('button', { class: 'link-btn small', type: 'button', onclick: () => openAsk() }, t('ai.orderHint'))),
|
|
1793
1928
|
h('div', { class: 'arrange-actions' },
|
|
1794
1929
|
h('button', { class: 'link-btn', type: 'button', title: t('order.resetHint'), disabled: ordered() ? undefined : true, onclick: resetOrder }, t('order.resetButton')),
|
|
1795
1930
|
h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: endArrange }, t('order.done'))))
|
|
@@ -1808,13 +1943,15 @@ const MAIN = String.raw `
|
|
|
1808
1943
|
const pinLabel = full ? t('pin.full', { n: PIN_MAX }) : t(pinned ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(fam).title })
|
|
1809
1944
|
return h('div', { class: 'row row-arrange' + (pinned ? ' is-pinned' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': 'arr:' + loginKey(ms.map((m) => m.ref)), 'data-sig': sig([ms.map(rowSig), at, lo, hi, full]) },
|
|
1810
1945
|
h('div', { class: 'ident' }, dragHandle('account', fam, a.name, label), badge(fam, 'sm'), h('div', { class: 'ident-text' }, h('div', { class: 'ident-name' },
|
|
1946
|
+
pinMark(a),
|
|
1811
1947
|
h('span', { class: 'name', title: a.name }, a.name),
|
|
1812
|
-
(
|
|
1813
|
-
|
|
1948
|
+
loginMark(ms),
|
|
1949
|
+
aliasText(a)))),
|
|
1814
1950
|
h('div', { class: 'arrange-tools' },
|
|
1815
1951
|
h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.up', { name: label }), title: t('order.up', { name: label }), disabled: at <= lo ? true : undefined, onclick: () => moveUnit('account', fam, at, at - 1, { focus: a.name, label, button: 'up' }) }, icon('arrowUp')),
|
|
1816
1952
|
h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.down', { name: label }), title: t('order.down', { name: label }), disabled: at >= hi ? true : undefined, onclick: () => moveUnit('account', fam, at, at + 1, { focus: a.name, label, button: 'down' }) }, icon('arrowDown')),
|
|
1817
|
-
|
|
1953
|
+
// Words, not a lone pin icon at the end of the row: the arrange mode is where the order and the pins are set.
|
|
1954
|
+
h('button', { class: 'btn btn-sm pin-btn', type: 'button', 'aria-pressed': String(pinned), 'aria-label': pinLabel, title: pinLabel, 'aria-disabled': full ? 'true' : null, onclick: () => { if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, t(pinned ? 'pin.unpin' : 'pin.pin'))))
|
|
1818
1955
|
}
|
|
1819
1956
|
// A grip to drag a Family group or an account (a Same Login moves as one) into a new place, or to move it with
|
|
1820
1957
|
// the arrow keys. Only in the arrange mode.
|
|
@@ -2118,10 +2255,11 @@ const MAIN = String.raw `
|
|
|
2118
2255
|
h('div', null,
|
|
2119
2256
|
h('h2', { id: 'h-checkup' }, t('health.title')),
|
|
2120
2257
|
h('p', { class: 'muted small' }, status, h('span', { class: 'sep', 'aria-hidden': 'true' }, '·'),
|
|
2121
|
-
tx('health.lead', { shared: h('span', { class: 'label-tip' }, t('health.sharedTerm'), tip('shared'))
|
|
2258
|
+
tx('health.lead', { shared: h('span', { class: 'label-tip' }, t('health.sharedTerm'), tip('shared')) }))),
|
|
2122
2259
|
h('div', { class: 'actions' },
|
|
2123
2260
|
h('button', { class: 'btn', type: 'button', disabled: busy || !S.state, 'aria-busy': S.checking ? 'true' : null, title: !S.state && !busy ? t('health.waiting') : null, onclick: runCheck }, S.checking ? spinner() : null, S.checking ? t('health.running') : t('health.run')),
|
|
2124
|
-
prompt ? h('button', { class: 'btn', type: 'button', title: t('fix.copyAiTitle'), onclick: () => copyText(prompt, t('fix.promptShown')) }, icon('copy'), t('fix.copyAi')) : null,
|
|
2261
|
+
prompt && BOOT.readOnly ? h('button', { class: 'btn', type: 'button', title: t('fix.copyAiTitle'), onclick: () => copyText(prompt, t('fix.promptShown')) }, icon('copy'), t('fix.copyAi')) : null,
|
|
2262
|
+
prompt && !BOOT.readOnly ? h('button', { class: 'btn', type: 'button', title: t('ask.fixTitle'), onclick: () => openAsk({ task: 'fix', context: { findings: taskFindings(forAi(findings)), version: BOOT.version } }) }, icon('bot'), t('ask.title.fix')) : null,
|
|
2125
2263
|
BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', disabled: busy || !fixable, 'aria-busy': S.fixing ? 'true' : null, title: fixWhy, onclick: runFix },
|
|
2126
2264
|
S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
|
|
2127
2265
|
if (ref) out.push(h('div', { class: 'filter-bar' },
|
|
@@ -2561,7 +2699,9 @@ const MAIN = String.raw `
|
|
|
2561
2699
|
})
|
|
2562
2700
|
const body = h('form', { id: 'nf-form', class: 'form', novalidate: true, onsubmit: (ev) => { ev.preventDefault(); create() } },
|
|
2563
2701
|
h('p', { class: 'muted small' }, t('nf.intro')),
|
|
2564
|
-
h('fieldset', { class: 'field fam-field' }, h('legend', null, t('nf.tool')),
|
|
2702
|
+
h('fieldset', { class: 'field fam-field' }, h('legend', null, t('nf.tool')), h('div', { class: 'fam-grid' }, cards,
|
|
2703
|
+
h('button', { class: 'fam-card fam-other', type: 'button', onclick: () => openAsk({ task: 'host', context: { version: BOOT.version } }) }, h('span', { class: 'badge badge-sm badge-logo', 'aria-hidden': 'true' }, icon('plus')),
|
|
2704
|
+
h('span', { class: 'fam-text' }, h('span', { class: 'fam-title' }, t('ask.otherTool')), h('span', { class: 'fam-sub' }, t('ask.otherToolSub')))))),
|
|
2565
2705
|
h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
|
|
2566
2706
|
// The first screen asks only for the tool and a name; the rest waits behind "More options".
|
|
2567
2707
|
h('details', { class: 'more-opts', open: nf.api || Boolean(nf.alias) || Boolean(nf.error) || undefined },
|
|
@@ -2570,7 +2710,8 @@ const MAIN = String.raw `
|
|
|
2570
2710
|
h('div', { class: 'check-row' }, h('label', { class: 'check' }, e.api, h('span', null, h('b', null, t('nf.api')), h('span', { class: 'muted small' }, t('nf.apiHint')))), tip('api')),
|
|
2571
2711
|
e.preview),
|
|
2572
2712
|
e.error,
|
|
2573
|
-
e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null
|
|
2713
|
+
e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null,
|
|
2714
|
+
h('p', { class: 'small ask-setup' }, h('button', { class: 'link-btn', type: 'button', onclick: () => openAsk({ task: 'account', context: nf.family ? { family: nf.family, version: BOOT.version } : { version: BOOT.version } }) }, icon('bot'), t('ask.setUp'))))
|
|
2574
2715
|
setKids(el, dialogFrame(t('nf.title'), null, body, [
|
|
2575
2716
|
h('button', { class: 'btn', type: 'button', onclick: () => el.close() }, t('btn.cancel')),
|
|
2576
2717
|
e.submit,
|
|
@@ -2678,8 +2819,11 @@ const MAIN = String.raw `
|
|
|
2678
2819
|
: h('div', { class: 'note note-warn' }, h('span', { class: 'small' }, f.message))),
|
|
2679
2820
|
h('p', { class: 'muted small' }, t('nf.next')),
|
|
2680
2821
|
h('ol', { class: 'steps' }, r.nextSteps.map(stepRow)))
|
|
2822
|
+
const made = S.state && S.state.accounts.find((x) => x.ref === r.account.ref)
|
|
2823
|
+
const keyNow = r.account.kind === 'api' || (made && keyable(made)) ? { ref: r.account.ref, family: r.account.family, kind: r.account.kind } : null
|
|
2681
2824
|
return dialogFrame(t('nf.title'), r.account.family, body, [
|
|
2682
2825
|
h('button', { class: 'btn', type: 'button', onclick: () => openCreate({ family: r.account.family, api: nf.api }) }, t('nf.another')),
|
|
2826
|
+
keyNow ? h('button', { class: 'btn', type: 'button', onclick: () => openKey(keyNow) }, icon('key'), t('menu.enterKey')) : null,
|
|
2683
2827
|
h('button', { class: 'btn btn-primary', type: 'button', onclick: () => dlg.el.close() }, t('btn.done')),
|
|
2684
2828
|
])
|
|
2685
2829
|
}
|
|
@@ -2782,6 +2926,13 @@ const MAIN = String.raw `
|
|
|
2782
2926
|
}
|
|
2783
2927
|
return { auto: s.auto, sameFamily: s.sameFamily, prepareAt: s.prepareAt, threshold: s.threshold, wait: s.waitIfResetWithinMinutes, countdown: s.countdownSeconds, orders }
|
|
2784
2928
|
}
|
|
2929
|
+
// "Let AI tune it" (spec §3.29): the AI gets the facts of sideby handoff status --json, as far as the page has them.
|
|
2930
|
+
function askHandoff() {
|
|
2931
|
+
const st = ahState()
|
|
2932
|
+
if (!st) return
|
|
2933
|
+
const status = ah && ah.status ? ah.status : null
|
|
2934
|
+
openAsk({ task: 'handoff', context: { handoff: handoffTaskFacts(st.settings, st.starters, S.state.accounts, Date.now(), status), version: BOOT.version } })
|
|
2935
|
+
}
|
|
2785
2936
|
async function openHandoff() {
|
|
2786
2937
|
if (!ahState()) return
|
|
2787
2938
|
ah = { form: handoffForm(), loading: true }
|
|
@@ -2870,7 +3021,8 @@ const MAIN = String.raw `
|
|
|
2870
3021
|
const st = ahState()
|
|
2871
3022
|
if (!st || !ah) return
|
|
2872
3023
|
const f = ah.form
|
|
2873
|
-
const body = [h('p', { class: 'muted small' }, t('ah.intro')
|
|
3024
|
+
const body = [h('p', { class: 'muted small' }, t('ah.intro'), ' ',
|
|
3025
|
+
h('button', { class: 'link-btn', type: 'button', onclick: askHandoff }, icon('bot'), t('ask.title.handoff')))]
|
|
2874
3026
|
if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
|
|
2875
3027
|
body.push(ahCheck('auto', t('ah.auto'), null))
|
|
2876
3028
|
body.push(ahCheck('sameFamily', t('ah.sameFamily'), t('ah.sameFamilyWarn')))
|
|
@@ -3187,6 +3339,7 @@ const MAIN = String.raw `
|
|
|
3187
3339
|
const kids = [h('div', { class: 'section-head' },
|
|
3188
3340
|
h('h2', { id: 'h-handoff' }, t('hp.title')),
|
|
3189
3341
|
h('div', { class: 'row-actions' },
|
|
3342
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: askHandoff }, icon('bot'), t('ask.title.handoff')),
|
|
3190
3343
|
h('button', { class: 'btn btn-sm', type: 'button', onclick: openHandoff }, t('hp.advanced')),
|
|
3191
3344
|
h('button', { class: 'btn btn-sm', type: 'button', disabled: hp.loading, onclick: loadHandoffPage }, hp.loading ? spinner() : icon('refresh'), t('top.refresh'))))]
|
|
3192
3345
|
const on = st.settings.auto
|
|
@@ -3331,6 +3484,7 @@ const MAIN = String.raw `
|
|
|
3331
3484
|
const out = []
|
|
3332
3485
|
if (!LANG_FIXED) out.push('lang')
|
|
3333
3486
|
if (BOOT.colorScheme === 'auto') out.push('theme')
|
|
3487
|
+
out.push('startup')
|
|
3334
3488
|
if (S.state && S.state.accounts.length && !BOOT.readOnly) out.push('order')
|
|
3335
3489
|
out.push('ai')
|
|
3336
3490
|
if (S.state && rcSummary()) out.push('rc')
|
|
@@ -3345,59 +3499,208 @@ const MAIN = String.raw `
|
|
|
3345
3499
|
const warn = secs.includes('rc') && rcSummary() === 'on'
|
|
3346
3500
|
setKids(b, [icon('gear'), h('span', { class: 'hide-sm' }, t('set.button')), warn ? h('span', { class: 'dot set-dot', 'aria-hidden': 'true' }) : null])
|
|
3347
3501
|
b.setAttribute('aria-label', t('set.button') + (warn ? ': ' + t('set.rcOn') : ''))
|
|
3348
|
-
|
|
3502
|
+
refreshSettings()
|
|
3349
3503
|
if (dlg.kind === 'rc') renderRc()
|
|
3350
3504
|
}
|
|
3351
|
-
|
|
3505
|
+
// The popover and the full settings dialog show the same groups (spec §3.29), top to bottom: Appearance, Startup,
|
|
3506
|
+
// Accounts and quota, Let AI help. leave() closes whichever holds them before a link goes elsewhere; a link that
|
|
3507
|
+
// opens a dialog keeps the dialog element open (opens: true), since one dialog element is shared.
|
|
3508
|
+
function settingsGroups(leave) {
|
|
3352
3509
|
const secs = settingsSections()
|
|
3353
|
-
const out = []
|
|
3354
3510
|
const seg = (label, opts, cur, pick) => h('div', { class: 'set-row' }, h('span', { class: 'set-label' }, label),
|
|
3355
3511
|
h('div', { class: 'seg', role: 'group', 'aria-label': label }, opts.map(([v, text]) =>
|
|
3356
3512
|
h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === cur), onclick: () => pick(v) }, text))))
|
|
3357
|
-
const link = (label, state, cls, run) => h('button', { class: 'menu-item set-link', type: 'button', onclick: () => {
|
|
3358
|
-
h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
|
|
3359
|
-
|
|
3360
|
-
if (secs.includes('
|
|
3361
|
-
if (
|
|
3362
|
-
|
|
3363
|
-
if (
|
|
3513
|
+
const link = (label, state, cls, run, opens) => h('button', { class: 'menu-item set-link', type: 'button', onclick: () => { leave(opens); run() } },
|
|
3514
|
+
h('span', { class: 'menu-label' }, label), state ? h('span', { class: 'chip chip-sm ' + cls }, state) : null, icon('chevron'))
|
|
3515
|
+
const look = []
|
|
3516
|
+
if (secs.includes('lang')) look.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
|
|
3517
|
+
if (secs.includes('theme')) look.push(seg(t('set.theme'), CHOICES.theme.map((v) => [v, t('set.theme.' + v)]), viewPrefs().theme, setTheme))
|
|
3518
|
+
const start = [autostartRow()]
|
|
3519
|
+
if (!BOOT.readOnly) start.push(link(t('term.title'), '', '', openTerminals, true))
|
|
3520
|
+
const acct = []
|
|
3364
3521
|
// The arrange mode (spec §3.25) has a place here too, where people look for "how do I change this".
|
|
3365
3522
|
if (secs.includes('order')) {
|
|
3366
3523
|
const pins = Object.values(displayOf().pinned || {}).some((x) => x.length)
|
|
3367
|
-
|
|
3524
|
+
acct.push(link(t('set.order'), t(ordered() || pins ? 'set.orderMine' : 'set.orderDefault'), '', () => {
|
|
3368
3525
|
setPage('accounts')
|
|
3369
3526
|
startArrange()
|
|
3370
3527
|
const bar = document.querySelector('#accounts .arrange-bar')
|
|
3371
3528
|
if (bar) bar.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
|
|
3372
3529
|
}))
|
|
3373
3530
|
}
|
|
3374
|
-
if (secs.includes('rc')) {
|
|
3375
|
-
const sum = rcSummary()
|
|
3376
|
-
out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
|
|
3377
|
-
}
|
|
3378
|
-
if (secs.includes('ah')) {
|
|
3379
|
-
const on = ahState().settings.auto
|
|
3380
|
-
out.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
|
|
3381
|
-
}
|
|
3382
3531
|
// Quota checks that ask the Host (spec §3.26): one switch per Family, with what it does in plain words.
|
|
3383
3532
|
if (secs.includes('checks')) {
|
|
3384
|
-
if (out.length) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
|
|
3385
3533
|
for (const c of S.state.quotaChecks) {
|
|
3386
3534
|
const label = t('set.quotaCheck', { host: c.title })
|
|
3387
|
-
|
|
3535
|
+
acct.push(h('div', { class: 'set-row set-check' },
|
|
3388
3536
|
h('div', { class: 'set-text' }, h('span', { class: 'set-label' }, label), h('span', { class: 'set-hint' }, c.blocked ? c.blocked : t('set.quotaCheckHint', { host: c.title, n: c.everyMinutes }))),
|
|
3389
3537
|
BOOT.readOnly ? h('span', { class: 'chip chip-sm' + (c.on ? ' chip-ok' : '') }, c.on ? t('ah.on') : t('ah.off'))
|
|
3390
3538
|
: h('div', { class: 'seg', role: 'group', 'aria-label': label }, [[true, t('ah.on')], [false, t('ah.off')]].map(([v, text]) =>
|
|
3391
|
-
h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === c.on), onclick: () => { if (v !== c.on) {
|
|
3539
|
+
h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === c.on), onclick: () => { if (v !== c.on) { leave(false); setCheck(c.family, v) } } }, text)))))
|
|
3392
3540
|
}
|
|
3393
3541
|
}
|
|
3542
|
+
if (secs.includes('rc')) {
|
|
3543
|
+
const sum = rcSummary()
|
|
3544
|
+
acct.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc, true))
|
|
3545
|
+
}
|
|
3546
|
+
if (secs.includes('ah')) {
|
|
3547
|
+
const on = ahState().settings.auto
|
|
3548
|
+
acct.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
|
|
3549
|
+
}
|
|
3550
|
+
// Everything here can also be asked of an AI assistant through the sideby skill (spec §3.8).
|
|
3551
|
+
const ai = [
|
|
3552
|
+
link(t('ai.title'), t(skillInstalled() ? 'ai.installed' : 'ai.notInstalled'), skillInstalled() ? 'chip-ok' : '', () => openAsk(), true),
|
|
3553
|
+
link(t('ask.plugin'), '', '', () => openAsk({ task: 'plugin', context: { version: BOOT.version } }), true),
|
|
3554
|
+
]
|
|
3555
|
+
return [
|
|
3556
|
+
{ title: t('set.g.look'), items: look },
|
|
3557
|
+
{ title: t('set.g.start'), items: start },
|
|
3558
|
+
{ title: t('set.g.accounts'), items: acct },
|
|
3559
|
+
{ title: t('set.g.ai'), items: ai },
|
|
3560
|
+
].filter((g) => g.items.length)
|
|
3561
|
+
}
|
|
3562
|
+
function settingsBody(leave) {
|
|
3563
|
+
const out = []
|
|
3564
|
+
settingsGroups(leave).forEach((g, i) => {
|
|
3565
|
+
if (i) out.push(h('div', { class: 'menu-sep set-sep', role: 'separator' }))
|
|
3566
|
+
out.push(h('div', { class: 'set-group', role: 'group', 'aria-label': g.title }, h('div', { class: 'set-group-title', 'aria-hidden': 'true' }, g.title), g.items))
|
|
3567
|
+
})
|
|
3394
3568
|
return out
|
|
3395
3569
|
}
|
|
3570
|
+
// Taller than the window, the popover scrolls inside and ends with "More settings…", the same groups in a dialog.
|
|
3571
|
+
function settingsPop() {
|
|
3572
|
+
if (!as.status && !as.loading && !as.error) loadAutostart()
|
|
3573
|
+
return [...settingsBody(() => closeMenu()),
|
|
3574
|
+
h('button', { class: 'menu-item set-more', type: 'button', hidden: true, onclick: () => { closeMenu(); openSettingsDialog() } }, h('span', { class: 'menu-label' }, t('set.more')), icon('chevron'))]
|
|
3575
|
+
}
|
|
3576
|
+
function settingsFit() {
|
|
3577
|
+
const el = menu.el
|
|
3578
|
+
const more = el && el.querySelector('.set-more')
|
|
3579
|
+
if (!more) return
|
|
3580
|
+
more.hidden = true
|
|
3581
|
+
more.hidden = !(el.scrollHeight > el.clientHeight + 1)
|
|
3582
|
+
}
|
|
3583
|
+
function openSettingsDialog() {
|
|
3584
|
+
if (!as.status && !as.loading) loadAutostart()
|
|
3585
|
+
showDialog('settings')
|
|
3586
|
+
renderSettingsDialog()
|
|
3587
|
+
}
|
|
3588
|
+
function renderSettingsDialog() {
|
|
3589
|
+
const leave = (opens) => { if (!opens && dlg.kind === 'settings') dlg.el.close() }
|
|
3590
|
+
setKids(dlg.el, dialogFrame(t('set.title'), null, h('div', { class: 'set-dialog' }, settingsBody(leave)),
|
|
3591
|
+
[h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
|
|
3592
|
+
}
|
|
3593
|
+
function refreshSettings() {
|
|
3594
|
+
refreshPopover('settings')
|
|
3595
|
+
if (dlg.kind === 'settings') renderSettingsDialog()
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
// ---------- start at login (spec §3.29, ADR-0029) ----------
|
|
3599
|
+
// The switch reads its state when the settings open; moving it shows the file and what will run, and only the
|
|
3600
|
+
// confirmation writes. A read-only Panel shows the state only.
|
|
3601
|
+
const as = { status: null, loading: false, error: null, want: null, plan: null, planning: false, planError: null, busy: false }
|
|
3602
|
+
async function loadAutostart() {
|
|
3603
|
+
as.loading = true
|
|
3604
|
+
try { as.status = await api('/api/autostart'); as.error = null } catch (e) { as.error = e }
|
|
3605
|
+
as.loading = false
|
|
3606
|
+
refreshSettings()
|
|
3607
|
+
}
|
|
3608
|
+
function autostartRow() {
|
|
3609
|
+
const s = as.status
|
|
3610
|
+
const n = autostartNote(s, BOOT.readOnly)
|
|
3611
|
+
const label = t('as.label')
|
|
3612
|
+
const hint = [as.error ? t('as.failed', { msg: as.error.message }) : t(n.key)]
|
|
3613
|
+
if (n.action === 'fix') hint.push(' ', h('button', { class: 'link-btn small', type: 'button', onclick: () => autostartAsk(true) }, t('as.fix')))
|
|
3614
|
+
if (s && s.state === 'foreign') hint.push(' ', h('button', { class: 'link-btn small', type: 'button', title: s.path, onclick: () => copyText(s.path) }, t('as.copyPath')))
|
|
3615
|
+
let ctl = null
|
|
3616
|
+
if (!s) ctl = as.loading ? spinner() : null
|
|
3617
|
+
else if (n.toggle) ctl = h('div', { class: 'seg', role: 'group', 'aria-label': label }, [[true, t('ah.on')], [false, t('ah.off')]].map(([v, text]) =>
|
|
3618
|
+
h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === n.on), onclick: () => { if (v !== n.on) autostartAsk(v) } }, text)))
|
|
3619
|
+
else if (s.supported && s.state !== 'foreign') ctl = h('span', { class: 'chip chip-sm' + (n.on ? ' chip-ok' : '') }, n.on ? t('ah.on') : t('ah.off'))
|
|
3620
|
+
return h('div', { class: 'set-row set-check' },
|
|
3621
|
+
h('div', { class: 'set-text' }, h('span', { class: 'set-label' }, label), h('span', { class: 'set-hint' }, hint)), ctl)
|
|
3622
|
+
}
|
|
3623
|
+
async function autostartAsk(on) {
|
|
3624
|
+
if (menu.kind === 'settings') closeMenu()
|
|
3625
|
+
as.want = on
|
|
3626
|
+
as.plan = null
|
|
3627
|
+
as.planError = null
|
|
3628
|
+
as.planning = true
|
|
3629
|
+
showDialog('autostart')
|
|
3630
|
+
renderAutostart()
|
|
3631
|
+
try { as.plan = (await api('/api/autostart', { on })).plan } catch (e) { as.planError = e }
|
|
3632
|
+
as.planning = false
|
|
3633
|
+
if (dlg.kind === 'autostart') renderAutostart()
|
|
3634
|
+
}
|
|
3635
|
+
function renderAutostart() {
|
|
3636
|
+
const p = as.plan
|
|
3637
|
+
const on = as.want
|
|
3638
|
+
const body = []
|
|
3639
|
+
if (as.planning) body.push(h('p', { class: 'muted' }, spinner(), ' ', t('as.planning')))
|
|
3640
|
+
else if (as.planError) body.push(errorNote(as.planError, () => autostartAsk(on)))
|
|
3641
|
+
else if (p && p.blocked) {
|
|
3642
|
+
const own = 'as.block.' + p.blocked.code
|
|
3643
|
+
body.push(h('p', { class: 'note note-fail' }, Object.hasOwn(MESSAGES.en, own) ? t(own) : p.blocked.message))
|
|
3644
|
+
if (['npx-cache', 'temp-dir', 'unsafe-path'].includes(p.blocked.code)) body.push(codeCopy('npm i -g sideby'))
|
|
3645
|
+
if (p.blocked.code === 'foreign') body.push(codeCopy(p.path))
|
|
3646
|
+
} else if (p && !p.changed) body.push(h('p', null, t(on ? 'as.alreadyOn' : 'as.alreadyOff')))
|
|
3647
|
+
else if (p && on) {
|
|
3648
|
+
body.push(h('p', null, t('as.writes')), h('code', { class: 'as-code' }, p.path))
|
|
3649
|
+
body.push(h('p', null, t('as.runs')), h('code', { class: 'as-code' }, (p.programArguments || []).join(' ')))
|
|
3650
|
+
body.push(h('p', { class: 'muted small' }, t('as.noWindow')))
|
|
3651
|
+
} else if (p) {
|
|
3652
|
+
body.push(h('p', null, t('as.removes')), h('code', { class: 'as-code' }, p.path))
|
|
3653
|
+
body.push(h('p', { class: 'muted small' }, t('as.offNote')))
|
|
3654
|
+
}
|
|
3655
|
+
const foot = [h('button', { class: 'btn', type: 'button', disabled: as.busy, onclick: () => dlg.el.close() }, t(p && p.changed && !p.blocked ? 'btn.cancel' : 'btn.close'))]
|
|
3656
|
+
if (p && p.changed && !p.blocked)
|
|
3657
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: as.busy, 'aria-busy': as.busy ? 'true' : null, onclick: autostartApply }, as.busy ? spinner() : null, t(on ? 'as.turnOn' : 'as.turnOff')))
|
|
3658
|
+
setKids(dlg.el, dialogFrame(t(on ? 'as.onTitle' : 'as.offTitle'), null, body, foot))
|
|
3659
|
+
}
|
|
3660
|
+
async function autostartApply() {
|
|
3661
|
+
const on = as.want
|
|
3662
|
+
as.busy = true
|
|
3663
|
+
dlg.busy = true
|
|
3664
|
+
renderAutostart()
|
|
3665
|
+
let r = null
|
|
3666
|
+
try { r = await api('/api/autostart', { on, confirm: true, programArguments: as.plan.programArguments || [] }) } catch (e) { as.planError = e }
|
|
3667
|
+
as.busy = false
|
|
3668
|
+
dlg.busy = false
|
|
3669
|
+
if (r && r.applied) {
|
|
3670
|
+
as.status = r.status
|
|
3671
|
+
if (dlg.kind === 'autostart') dlg.el.close()
|
|
3672
|
+
toast(t(on ? 'as.doneOn' : 'as.doneOff') + (on && !r.loaded ? ' ' + t('as.nextLogin') : ''))
|
|
3673
|
+
refreshSettings()
|
|
3674
|
+
return
|
|
3675
|
+
}
|
|
3676
|
+
if (r) { as.plan = r.plan; if (r.changedSince) toast(t('as.changedSince')) }
|
|
3677
|
+
if (dlg.kind === 'autostart') renderAutostart()
|
|
3678
|
+
}
|
|
3396
3679
|
function setLang(v) {
|
|
3397
3680
|
if (LANG_FIXED || v === lang) return
|
|
3681
|
+
if (CFG) { cfgPanel.language = v; saveCfg({ language: v }) } else { prefs.lang = v; savePrefs() }
|
|
3682
|
+
showLang(v)
|
|
3683
|
+
}
|
|
3684
|
+
// Writes config panel.* (spec §3.29). A failure says so; the next state read then shows what config holds.
|
|
3685
|
+
async function saveCfg(patch, quiet) {
|
|
3686
|
+
cfgSaving++
|
|
3687
|
+
try { await api('/api/panel/settings', patch); return true }
|
|
3688
|
+
catch (e) { if (!quiet) toast(t('set.saveFailed', { msg: e.message }), { kind: 'fail' }); return false }
|
|
3689
|
+
finally { cfgSaving-- }
|
|
3690
|
+
}
|
|
3691
|
+
// A theme or language changed elsewhere (the CLI, an AI assistant) shows at the next state read.
|
|
3692
|
+
function adoptPanel(next) {
|
|
3693
|
+
if (!CFG || cfgSaving) return
|
|
3694
|
+
next = next || {}
|
|
3695
|
+
if (next.theme === cfgPanel.theme && next.language === cfgPanel.language) return
|
|
3696
|
+
cfgPanel = Object.assign({}, next)
|
|
3697
|
+
applyTheme()
|
|
3698
|
+
const l = panelLang(BOOT.lang, viewPrefs().language, navigator.language)
|
|
3699
|
+
if (l !== lang) showLang(l)
|
|
3700
|
+
else refreshSettings()
|
|
3701
|
+
}
|
|
3702
|
+
function showLang(v) {
|
|
3398
3703
|
lang = v
|
|
3399
|
-
prefs.lang = lang
|
|
3400
|
-
savePrefs()
|
|
3401
3704
|
renderStatic()
|
|
3402
3705
|
buildToolbar()
|
|
3403
3706
|
renderAll()
|
|
@@ -3408,32 +3711,23 @@ const MAIN = String.raw `
|
|
|
3408
3711
|
else if (dlg.kind === 'browser') renderBrowser()
|
|
3409
3712
|
else if (dlg.kind === 'update' && up) renderUpdate()
|
|
3410
3713
|
else if (dlg.kind === 'rc') renderRc()
|
|
3714
|
+
else if (dlg.kind === 'ask' && ak) { if (ak.els.text) ak.text = ak.els.text.value; renderAsk() }
|
|
3715
|
+
else if (dlg.kind === 'key' && ky && ky.step !== 'enter') renderKey()
|
|
3716
|
+
else if (dlg.kind === 'terminal' && tm) renderTerminals()
|
|
3717
|
+
else if (dlg.kind === 'autostart') renderAutostart()
|
|
3411
3718
|
}
|
|
3412
3719
|
function setTheme(v) {
|
|
3413
|
-
prefs.theme = v
|
|
3414
|
-
savePrefs()
|
|
3720
|
+
if (CFG) { cfgPanel.theme = v; saveCfg({ theme: v }) } else { prefs.theme = v; savePrefs() }
|
|
3415
3721
|
applyTheme()
|
|
3416
|
-
|
|
3722
|
+
refreshSettings()
|
|
3417
3723
|
}
|
|
3418
3724
|
// The sideby Agent Skill: what to say to an AI assistant instead of clicking, and how to install it when needed.
|
|
3419
3725
|
function skillInstalled() {
|
|
3420
3726
|
const u = S.state && S.state.update
|
|
3421
3727
|
return Boolean(u && u.skillUpdate && u.skillUpdate.installed && u.skillUpdate.installed.length)
|
|
3422
3728
|
}
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
showDialog('ai')
|
|
3426
|
-
const asks = AI_ASKS.map((k) => {
|
|
3427
|
-
const text = t(k)
|
|
3428
|
-
return h('li', null, h('button', { class: 'ai-ask', type: 'button', title: t('ai.copy'), onclick: () => copyText(text) }, icon('copy'), h('span', null, text)))
|
|
3429
|
-
})
|
|
3430
|
-
setKids(dlg.el, dialogFrame(t('ai.title'), null, h('div', { class: 'form' },
|
|
3431
|
-
h('p', null, t('ai.intro')),
|
|
3432
|
-
skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)),
|
|
3433
|
-
h('p', { class: 'small muted' }, t('ai.examples')),
|
|
3434
|
-
h('ul', { class: 'ai-asks' }, asks)),
|
|
3435
|
-
[h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
|
|
3436
|
-
}
|
|
3729
|
+
// Example phrases: a click puts one in the Let AI do it box (spec §3.28).
|
|
3730
|
+
const AI_ASKS = ['ai.ask.quota', 'ai.ask.new', 'ai.ask.api', 'ai.ask.pin', 'ai.ask.fix', 'ai.ask.handoff', 'ai.ask.rc', 'ai.ask.tool']
|
|
3437
3731
|
function openRc() {
|
|
3438
3732
|
if (!S.state || !rcSummary()) return
|
|
3439
3733
|
rc.target = null
|
|
@@ -3469,6 +3763,524 @@ const MAIN = String.raw `
|
|
|
3469
3763
|
setKids(dlg.el, dialogFrame(t('rc.dialogTitle'), null, body, foot))
|
|
3470
3764
|
}
|
|
3471
3765
|
|
|
3766
|
+
// ---------- AI Tasks (spec §3.28) ----------
|
|
3767
|
+
// One dialog for every "let AI do it" entry: what to do, then the account and the terminal it will use, then one
|
|
3768
|
+
// button. When nothing can run it, or the panel is read-only, it copies the prompt instead.
|
|
3769
|
+
let ak = null
|
|
3770
|
+
const TERM_NAMES = { terminal: 'Terminal', iterm2: 'iTerm2', ghostty: 'Ghostty', orca: 'Orca', wezterm: 'WezTerm', kitty: 'kitty', alacritty: 'Alacritty', custom: 'Custom' }
|
|
3771
|
+
function termName(id) {
|
|
3772
|
+
const list = ak && ak.terms ? ak.terms.terminals : []
|
|
3773
|
+
const f = list.find((x) => x.id === id)
|
|
3774
|
+
return f ? f.name : TERM_NAMES[id] || id
|
|
3775
|
+
}
|
|
3776
|
+
function askBody() {
|
|
3777
|
+
const body = { task: ak.task, lang }
|
|
3778
|
+
const text = ak.els && ak.els.text ? ak.els.text.value.trim() : ak.text
|
|
3779
|
+
if (text) body.text = text
|
|
3780
|
+
if (ak.context) body.context = ak.context
|
|
3781
|
+
if (ak.account && ak.accountChanged) body.account = ak.account
|
|
3782
|
+
if (ak.terminal && ak.terminalChanged) body.terminal = ak.terminal
|
|
3783
|
+
const ids = ak.files.filter((f) => f.status === 'ready').map((f) => f.id)
|
|
3784
|
+
if (ids.length) body.attachments = ids
|
|
3785
|
+
return body
|
|
3786
|
+
}
|
|
3787
|
+
function openAsk(opts) {
|
|
3788
|
+
opts = opts || {}
|
|
3789
|
+
closeMenu()
|
|
3790
|
+
ak = { task: opts.task || 'free', text: opts.text || '', context: opts.context || null, account: null, accountChanged: false, terminal: null, terminalChanged: false, pick: null, reason: null, terms: null, loading: true, busy: false, result: null, error: null, removed: false, files: [], els: {} }
|
|
3791
|
+
showDialog('ask')
|
|
3792
|
+
hookAskDrops()
|
|
3793
|
+
const mine = ak
|
|
3794
|
+
// Uploads still running when the dialog closes are stopped; the server drops staged files it never received.
|
|
3795
|
+
dlg.onClose = () => { mine.files.forEach((f) => { if (f.xhr) f.xhr.abort() }) }
|
|
3796
|
+
renderAsk()
|
|
3797
|
+
if (ak.els.text) ak.els.text.focus()
|
|
3798
|
+
askLoad()
|
|
3799
|
+
}
|
|
3800
|
+
async function askLoad() {
|
|
3801
|
+
const mine = ak
|
|
3802
|
+
const body = askBody()
|
|
3803
|
+
delete body.account
|
|
3804
|
+
try {
|
|
3805
|
+
const [pick, terms] = await Promise.all([
|
|
3806
|
+
api('/api/ask/prompt', body),
|
|
3807
|
+
BOOT.readOnly ? Promise.resolve(null) : api('/api/terminals').catch(() => null),
|
|
3808
|
+
])
|
|
3809
|
+
if (ak !== mine) return
|
|
3810
|
+
ak.pick = pick.account
|
|
3811
|
+
ak.reason = pick.reason || null
|
|
3812
|
+
if (!ak.accountChanged) ak.account = pick.account
|
|
3813
|
+
ak.terms = terms
|
|
3814
|
+
if (terms && !ak.terminalChanged) ak.terminal = terms.guess.id
|
|
3815
|
+
} catch (e) { if (ak === mine) ak.error = e }
|
|
3816
|
+
if (ak !== mine) return
|
|
3817
|
+
ak.loading = false
|
|
3818
|
+
if (dlg.kind === 'ask') renderAsk()
|
|
3819
|
+
}
|
|
3820
|
+
// Title, placeholder and examples of the dialog for its task (spec §3.28 "每个入口的提示").
|
|
3821
|
+
function askStrings() { return askCopy(ak.task, (k) => Object.hasOwn(MESSAGES.en, k), AI_ASKS) }
|
|
3822
|
+
function askChoices() { return aiTaskAccounts((S.state && S.state.accounts) || []) }
|
|
3823
|
+
function askTaskLine() {
|
|
3824
|
+
if (ak.task === 'free') return null
|
|
3825
|
+
const ctx = ak.context || {}
|
|
3826
|
+
return h('p', { class: 'note' }, t('ask.task.' + ak.task, { n: (ctx.findings || []).length, ref: ctx.account || '' }))
|
|
3827
|
+
}
|
|
3828
|
+
function askTextField() {
|
|
3829
|
+
const e = ak.els
|
|
3830
|
+
const copy = askStrings()
|
|
3831
|
+
e.text = h('textarea', { id: 'ak-text', class: 'ask-text', rows: ak.task === 'free' ? '3' : '2', maxlength: '8000', placeholder: t(copy.placeholder), 'aria-describedby': 'ak-warn' })
|
|
3832
|
+
e.text.value = ak.text
|
|
3833
|
+
e.warn = h('p', { class: 'field-error field-note', id: 'ak-warn', role: 'status' })
|
|
3834
|
+
const warnNow = () => { e.warn.textContent = stripSecrets(e.text.value).removed ? t('ask.keyWarn') : '' }
|
|
3835
|
+
e.text.addEventListener('input', () => { ak.text = e.text.value; warnNow() })
|
|
3836
|
+
warnNow()
|
|
3837
|
+
const label = h('label', { class: 'field', for: 'ak-text' }, h('span', null, t(ak.task === 'free' ? 'ask.what' : 'ask.whatMore')), e.text)
|
|
3838
|
+
const chips = h('div', { class: 'field' }, h('p', { class: 'small muted' }, t('ask.examples')),
|
|
3839
|
+
h('div', { class: 'ask-chips' }, copy.chips.map((k) => h('button', { class: 'chip chip-btn', type: 'button', onclick: () => { e.text.value = t(k); ak.text = e.text.value; warnNow(); e.text.focus() } }, t(k)))))
|
|
3840
|
+
return [h('div', { class: 'field' }, label, e.warn), askAttachField(), chips]
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
// Attachments (spec §3.28 "附件"): pasted, dropped or picked files upload at once, one request each, so a wrong
|
|
3844
|
+
// type or a file that is too big is named right away. The page shows image thumbnails from the file itself as a
|
|
3845
|
+
// data: URL (the CSP allows no blob:); the server decides the type from the first bytes.
|
|
3846
|
+
const ATTACH_MAX = 5
|
|
3847
|
+
const ATTACH_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp,video/mp4,video/quicktime,video/webm'
|
|
3848
|
+
let askHooked = false
|
|
3849
|
+
function hookAskDrops() {
|
|
3850
|
+
if (askHooked) return
|
|
3851
|
+
askHooked = true
|
|
3852
|
+
const el = dlg.el
|
|
3853
|
+
const active = () => dlg.kind === 'ask' && ak && !ak.result && !BOOT.readOnly
|
|
3854
|
+
const hasFiles = (e) => e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') >= 0
|
|
3855
|
+
el.addEventListener('paste', (e) => {
|
|
3856
|
+
const files = e.clipboardData ? Array.from(e.clipboardData.files || []) : []
|
|
3857
|
+
if (!active() || !files.length) return
|
|
3858
|
+
// A file copied in Finder also carries its name as text; only the file is wanted.
|
|
3859
|
+
e.preventDefault()
|
|
3860
|
+
askAddFiles(files)
|
|
3861
|
+
})
|
|
3862
|
+
el.addEventListener('dragover', (e) => {
|
|
3863
|
+
if (!active() || !hasFiles(e)) return
|
|
3864
|
+
e.preventDefault()
|
|
3865
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
3866
|
+
el.classList.add('drop-on')
|
|
3867
|
+
})
|
|
3868
|
+
el.addEventListener('dragleave', (e) => { if (e.target === el || !el.contains(e.relatedTarget)) el.classList.remove('drop-on') })
|
|
3869
|
+
el.addEventListener('drop', (e) => {
|
|
3870
|
+
el.classList.remove('drop-on')
|
|
3871
|
+
if (!active() || !hasFiles(e)) return
|
|
3872
|
+
e.preventDefault()
|
|
3873
|
+
askAddFiles(Array.from(e.dataTransfer.files || []))
|
|
3874
|
+
})
|
|
3875
|
+
}
|
|
3876
|
+
function askAttachField() {
|
|
3877
|
+
if (BOOT.readOnly) return null
|
|
3878
|
+
const input = h('input', { type: 'file', multiple: true, accept: ATTACH_ACCEPT, class: 'sr-only', tabindex: '-1', 'aria-hidden': 'true' })
|
|
3879
|
+
input.addEventListener('change', () => { askAddFiles(Array.from(input.files || [])); input.value = '' })
|
|
3880
|
+
ak.els.files = h('div', { class: 'ask-files-wrap' })
|
|
3881
|
+
renderAskFiles()
|
|
3882
|
+
return h('div', { class: 'field ask-attach' },
|
|
3883
|
+
h('div', { class: 'ask-attach-row' },
|
|
3884
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => input.click() }, icon('image'), t('ask.attach')),
|
|
3885
|
+
h('span', { class: 'small muted' }, t('ask.attachHint')), input),
|
|
3886
|
+
ak.els.files)
|
|
3887
|
+
}
|
|
3888
|
+
function renderAskFiles() {
|
|
3889
|
+
const box = ak && ak.els.files
|
|
3890
|
+
if (!box) return
|
|
3891
|
+
const files = ak.files
|
|
3892
|
+
const tiles = files.map((f) => {
|
|
3893
|
+
const remove = h('button', { class: 'icon-btn ask-file-x', type: 'button', 'aria-label': t('ask.attachRemove', { name: f.name }), onclick: () => askRemoveFile(f) }, icon('x'))
|
|
3894
|
+
let face
|
|
3895
|
+
if (f.status === 'uploading') {
|
|
3896
|
+
f.pctEl = h('span', { class: 'small' }, t('ask.attachUploading', { pct: Math.round(f.progress * 100) }))
|
|
3897
|
+
face = h('div', { class: 'ask-file-face' }, spinner(), f.pctEl)
|
|
3898
|
+
} else if (f.status === 'error') face = h('div', { class: 'ask-file-face' }, icon('x'))
|
|
3899
|
+
else if (f.thumb) face = h('img', { class: 'ask-file-img', src: f.thumb, alt: f.name })
|
|
3900
|
+
else face = h('div', { class: 'ask-file-face' }, icon(f.kind === 'video' ? 'film' : 'image'), f.kind === 'video' ? h('span', { class: 'small' }, t('ask.attachVideo')) : null)
|
|
3901
|
+
return h('li', { class: 'ask-file' + (f.status === 'error' ? ' is-error' : '') },
|
|
3902
|
+
face,
|
|
3903
|
+
h('span', { class: 'ask-file-name small', title: f.name }, f.name),
|
|
3904
|
+
h('span', { class: 'small muted' }, fileSize(f.bytes || f.size)),
|
|
3905
|
+
remove)
|
|
3906
|
+
})
|
|
3907
|
+
const errors = files.filter((f) => f.status === 'error').map((f) => h('p', { class: 'field-error' }, f.error))
|
|
3908
|
+
const live = files.some((f) => f.status !== 'error')
|
|
3909
|
+
setKids(box, [
|
|
3910
|
+
tiles.length ? h('ul', { class: 'ask-files', 'aria-label': t('ask.attach') }, tiles) : null,
|
|
3911
|
+
errors,
|
|
3912
|
+
ak.attachFull ? h('p', { class: 'field-error' }, t('ask.attachMax')) : null,
|
|
3913
|
+
live ? h('p', { class: 'note note-warn small' }, t('ask.attachWarn')) : null,
|
|
3914
|
+
])
|
|
3915
|
+
}
|
|
3916
|
+
function askAddFiles(list) {
|
|
3917
|
+
if (!ak || !list.length) return
|
|
3918
|
+
ak.attachFull = false
|
|
3919
|
+
for (const file of list) {
|
|
3920
|
+
if (ak.files.filter((f) => f.status !== 'error').length >= ATTACH_MAX) { ak.attachFull = true; break }
|
|
3921
|
+
const f = { key: Math.random(), name: file.name || 'file', size: file.size, bytes: 0, kind: '', status: 'uploading', progress: 0, id: null, thumb: null, error: '', xhr: null, done: null }
|
|
3922
|
+
ak.files.push(f)
|
|
3923
|
+
// Over the largest limit there is no point uploading: the server would refuse it.
|
|
3924
|
+
if (file.size > 200 * 1024 * 1024) { f.status = 'error'; f.error = t('ask.attachTooBig', { name: f.name }); continue }
|
|
3925
|
+
f.done = askUpload(f, file, ak)
|
|
3926
|
+
}
|
|
3927
|
+
renderAskFiles()
|
|
3928
|
+
}
|
|
3929
|
+
function askRemoveFile(f) {
|
|
3930
|
+
if (f.xhr) f.xhr.abort()
|
|
3931
|
+
// Free the staged copy now; if this fails the server clears it after a day anyway.
|
|
3932
|
+
if (f.id) api('/api/ask/attach/remove', { ids: [f.id] }).catch(() => {})
|
|
3933
|
+
ak.files = ak.files.filter((x) => x !== f)
|
|
3934
|
+
ak.attachFull = false
|
|
3935
|
+
renderAskFiles()
|
|
3936
|
+
}
|
|
3937
|
+
function askPost(file, onProgress, xhrOut) {
|
|
3938
|
+
return new Promise((resolve) => {
|
|
3939
|
+
const xhr = new XMLHttpRequest()
|
|
3940
|
+
xhrOut(xhr)
|
|
3941
|
+
xhr.open('POST', BOOT.basePath + '/api/ask/attach')
|
|
3942
|
+
xhr.setRequestHeader('x-sideby-token', token)
|
|
3943
|
+
xhr.setRequestHeader('content-type', file.type || 'application/octet-stream')
|
|
3944
|
+
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total) }
|
|
3945
|
+
xhr.onload = () => { let data = null; try { data = JSON.parse(xhr.responseText) } catch {} resolve({ status: xhr.status, data }) }
|
|
3946
|
+
xhr.onerror = () => resolve({ status: 0, data: null })
|
|
3947
|
+
xhr.onabort = () => resolve({ status: -1, data: null })
|
|
3948
|
+
xhr.send(file)
|
|
3949
|
+
})
|
|
3950
|
+
}
|
|
3951
|
+
async function askUpload(f, file, mine) {
|
|
3952
|
+
const progress = (p) => { f.progress = p; if (f.pctEl) f.pctEl.textContent = t('ask.attachUploading', { pct: Math.round(p * 100) }) }
|
|
3953
|
+
let r = await askPost(file, progress, (x) => { f.xhr = x })
|
|
3954
|
+
if (r.status === 403 && r.data && r.data.code === 'bad-token') {
|
|
3955
|
+
try { await refreshToken(); r = await askPost(file, progress, (x) => { f.xhr = x }) } catch {}
|
|
3956
|
+
}
|
|
3957
|
+
f.xhr = null
|
|
3958
|
+
if (r.status === -1 || ak !== mine) return
|
|
3959
|
+
if (r.status === 200 && r.data && r.data.id) {
|
|
3960
|
+
f.id = r.data.id
|
|
3961
|
+
f.kind = r.data.kind
|
|
3962
|
+
f.bytes = r.data.bytes
|
|
3963
|
+
if (f.kind === 'image') f.thumb = await thumbnail(file)
|
|
3964
|
+
f.status = 'ready'
|
|
3965
|
+
} else {
|
|
3966
|
+
f.status = 'error'
|
|
3967
|
+
f.error = t(r.status === 413 ? 'ask.attachTooBig' : r.status === 415 ? 'ask.attachType' : r.status === 507 ? 'ask.attachFull' : 'ask.attachFailed', { name: f.name })
|
|
3968
|
+
}
|
|
3969
|
+
if (ak === mine && dlg.kind === 'ask') renderAskFiles()
|
|
3970
|
+
}
|
|
3971
|
+
// A small JPEG data: URL of an image, drawn on a canvas so a 20 MB screenshot is not kept in the page.
|
|
3972
|
+
function thumbnail(file) {
|
|
3973
|
+
return new Promise((resolve) => {
|
|
3974
|
+
const reader = new FileReader()
|
|
3975
|
+
reader.onerror = () => resolve(null)
|
|
3976
|
+
reader.onload = () => {
|
|
3977
|
+
const img = new Image()
|
|
3978
|
+
img.onerror = () => resolve(null)
|
|
3979
|
+
img.onload = () => {
|
|
3980
|
+
try {
|
|
3981
|
+
const scale = Math.min(1, 192 / Math.max(img.width, img.height))
|
|
3982
|
+
const c = document.createElement('canvas')
|
|
3983
|
+
c.width = Math.max(1, Math.round(img.width * scale))
|
|
3984
|
+
c.height = Math.max(1, Math.round(img.height * scale))
|
|
3985
|
+
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height)
|
|
3986
|
+
resolve(c.toDataURL('image/jpeg', 0.8))
|
|
3987
|
+
} catch { resolve(null) }
|
|
3988
|
+
}
|
|
3989
|
+
img.src = String(reader.result)
|
|
3990
|
+
}
|
|
3991
|
+
reader.readAsDataURL(file)
|
|
3992
|
+
})
|
|
3993
|
+
}
|
|
3994
|
+
// Open and Copy prompt wait for uploads still running, so the task carries every file the person added.
|
|
3995
|
+
async function askUploadsDone() {
|
|
3996
|
+
const pending = ak.files.filter((f) => f.status === 'uploading' && f.done).map((f) => f.done)
|
|
3997
|
+
if (pending.length) await Promise.all(pending)
|
|
3998
|
+
}
|
|
3999
|
+
function askConfirm() {
|
|
4000
|
+
if (BOOT.readOnly) return h('p', { class: 'note' }, t('ask.readOnly'))
|
|
4001
|
+
if (ak.loading) return h('p', { class: 'muted small' }, spinner(), ' ', t('ask.loading'))
|
|
4002
|
+
if (ak.error) return errorNote(ak.error, () => { ak.error = null; ak.loading = true; renderAsk(); askLoad() })
|
|
4003
|
+
const choices = askChoices()
|
|
4004
|
+
const out = []
|
|
4005
|
+
if (!ak.account) {
|
|
4006
|
+
out.push(h('div', { class: 'note note-warn' }, h('span', null, t('ask.noAccount.' + (ak.reason || 'no-accounts'))), h('span', { class: 'note-line' }, t('ask.noAccountHint'))))
|
|
4007
|
+
const main = ak.reason === 'none-signed-in' && S.state ? S.state.accounts.find((a) => a.isMain && a.kind !== 'api') : null
|
|
4008
|
+
if (main) out.push(h('div', null, h('button', { class: 'btn btn-sm', type: 'button', onclick: () => signIn(main) }, icon('key'), t('ask.signInMain', { ref: main.ref }))))
|
|
4009
|
+
}
|
|
4010
|
+
const rows = []
|
|
4011
|
+
if (choices.length) {
|
|
4012
|
+
const sel = h('select', { id: 'ak-account', 'aria-label': t('ask.account') },
|
|
4013
|
+
ak.account ? null : h('option', { value: '' }, t('ask.chooseAccount')),
|
|
4014
|
+
choices.map((a) => h('option', { value: a.ref }, a.ref + (a.kind === 'api' ? ' · API' : ''))))
|
|
4015
|
+
sel.value = ak.account || ''
|
|
4016
|
+
sel.addEventListener('change', () => { ak.account = sel.value || null; ak.accountChanged = Boolean(sel.value); renderAsk() })
|
|
4017
|
+
rows.push(h('label', { class: 'field', for: 'ak-account' }, h('span', null, t('ask.account')), sel))
|
|
4018
|
+
}
|
|
4019
|
+
const installed = ak.terms ? ak.terms.installed : []
|
|
4020
|
+
if (installed.length && ak.account) {
|
|
4021
|
+
const sel = h('select', { id: 'ak-term', 'aria-label': t('ask.terminal') }, installed.map((id) => h('option', { value: id }, termName(id))))
|
|
4022
|
+
sel.value = ak.terminal || installed[0]
|
|
4023
|
+
sel.addEventListener('change', () => { ak.terminal = sel.value; ak.terminalChanged = true; renderAsk() })
|
|
4024
|
+
rows.push(h('label', { class: 'field', for: 'ak-term' }, h('span', null, t('ask.terminal')), sel))
|
|
4025
|
+
}
|
|
4026
|
+
if (rows.length) out.push(h('div', { class: 'ask-pick' }, rows))
|
|
4027
|
+
if (ak.account) out.push(h('p', { class: 'small' }, t('ask.confirm', { terminal: termName(ak.terminal || 'terminal'), ref: ak.account })))
|
|
4028
|
+
return out
|
|
4029
|
+
}
|
|
4030
|
+
function askResult() {
|
|
4031
|
+
const r = ak.result
|
|
4032
|
+
const term = termName(r.terminal || ak.terminal || 'terminal')
|
|
4033
|
+
return [
|
|
4034
|
+
h('div', { class: 'note note-warn' }, h('b', null, t('ask.failed', { terminal: term })), h('span', { class: 'note-line' }, t('ask.paste'))),
|
|
4035
|
+
codeCopy(r.command),
|
|
4036
|
+
r.automation ? h('p', { class: 'small' }, t('ask.automation', { terminal: term })) : null,
|
|
4037
|
+
r.reason === 'not-installed' ? h('p', { class: 'small muted' }, t('ask.notInstalled')) : null,
|
|
4038
|
+
!r.automation && r.reason !== 'not-installed' && r.detail ? h('p', { class: 'small muted' }, r.detail) : null,
|
|
4039
|
+
]
|
|
4040
|
+
}
|
|
4041
|
+
function askYourself() {
|
|
4042
|
+
return h('details', { class: 'more-opts' }, h('summary', null, t('ask.yourself')),
|
|
4043
|
+
h('p', { class: 'small' }, t('ai.intro')),
|
|
4044
|
+
skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)))
|
|
4045
|
+
}
|
|
4046
|
+
function renderAsk() {
|
|
4047
|
+
if (!ak) return
|
|
4048
|
+
const el = dlg.el
|
|
4049
|
+
const body = [h('p', { class: 'muted small' }, t('ask.intro')), askTaskLine()]
|
|
4050
|
+
const foot = [h('button', { class: 'btn', type: 'button', disabled: ak.busy, onclick: () => el.close() }, t(ak.result ? 'btn.close' : 'btn.cancel'))]
|
|
4051
|
+
if (ak.result) {
|
|
4052
|
+
body.push(askResult())
|
|
4053
|
+
if (ak.result.removedSecrets) body.push(h('p', { class: 'small muted' }, t('ask.keyRemoved')))
|
|
4054
|
+
} else {
|
|
4055
|
+
body.push(askTextField(), askConfirm(), askYourself())
|
|
4056
|
+
const canOpen = !BOOT.readOnly && !ak.loading && !ak.error && ak.account
|
|
4057
|
+
if (canOpen) {
|
|
4058
|
+
const term = termName(ak.terminal || 'terminal')
|
|
4059
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: ak.busy, 'aria-busy': ak.busy ? 'true' : null, onclick: askOpen }, ak.busy ? spinner() : icon('terminal'), ak.busy ? t(ak.files.some((f) => f.status === 'uploading') ? 'ask.attachWaiting' : 'ask.opening') : t('ask.open', { terminal: term })))
|
|
4060
|
+
} else if (!ak.loading || BOOT.readOnly) {
|
|
4061
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: ak.busy, onclick: askCopyPrompt }, icon('copy'), t('ask.copyPrompt')))
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
setKids(el, dialogFrame(t(askStrings().title), null, body, foot))
|
|
4065
|
+
}
|
|
4066
|
+
async function askCopyPrompt() {
|
|
4067
|
+
const mine = ak
|
|
4068
|
+
ak.busy = true
|
|
4069
|
+
renderAsk()
|
|
4070
|
+
try {
|
|
4071
|
+
await askUploadsDone()
|
|
4072
|
+
const r = await api('/api/ask/prompt', askBody())
|
|
4073
|
+
await copyText(r.prompt, t('ask.promptShown'))
|
|
4074
|
+
if (r.removedSecrets) toast(t('ask.keyRemoved'), { kind: 'info' })
|
|
4075
|
+
} catch (e) { toast(e.message, { kind: 'fail' }) }
|
|
4076
|
+
if (ak !== mine) return
|
|
4077
|
+
ak.busy = false
|
|
4078
|
+
if (dlg.kind === 'ask') renderAsk()
|
|
4079
|
+
}
|
|
4080
|
+
async function askOpen() {
|
|
4081
|
+
const mine = ak
|
|
4082
|
+
ak.busy = true
|
|
4083
|
+
renderAsk()
|
|
4084
|
+
try {
|
|
4085
|
+
await askUploadsDone()
|
|
4086
|
+
if (ak !== mine) return
|
|
4087
|
+
renderAsk()
|
|
4088
|
+
const r = await api('/api/ask', askBody())
|
|
4089
|
+
if (ak !== mine) return
|
|
4090
|
+
ak.busy = false
|
|
4091
|
+
if (r.opened) {
|
|
4092
|
+
toast(t('ask.opened', { terminal: termName(r.terminal), ref: r.account }))
|
|
4093
|
+
if (r.removedSecrets) toast(t('ask.keyRemoved'), { kind: 'info' })
|
|
4094
|
+
dlg.el.close()
|
|
4095
|
+
return
|
|
4096
|
+
}
|
|
4097
|
+
if (!r.command) {
|
|
4098
|
+
// No account can run it after all: the dialog turns into Copy prompt.
|
|
4099
|
+
ak.account = null
|
|
4100
|
+
ak.reason = r.reason || null
|
|
4101
|
+
renderAsk()
|
|
4102
|
+
return
|
|
4103
|
+
}
|
|
4104
|
+
ak.result = r
|
|
4105
|
+
renderAsk()
|
|
4106
|
+
copyText(r.command, t('ask.copiedCmd'))
|
|
4107
|
+
} catch (e) {
|
|
4108
|
+
if (ak !== mine) return
|
|
4109
|
+
ak.busy = false
|
|
4110
|
+
toast(e.message, { kind: 'fail' })
|
|
4111
|
+
renderAsk()
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
// Sign in: "sideby login <ref>" in the person's terminal, no AI involved; the command is copied when it cannot open.
|
|
4116
|
+
async function signIn(a) {
|
|
4117
|
+
const cmd = 'sideby login ' + a.ref
|
|
4118
|
+
if (BOOT.readOnly) { copyText(cmd); return }
|
|
4119
|
+
try {
|
|
4120
|
+
const r = await api('/api/login', { account: a.ref })
|
|
4121
|
+
if (r.opened) { toast(t('login.opened', { terminal: TERM_NAMES[r.terminal] || r.terminal, ref: a.ref })); return }
|
|
4122
|
+
await copyText(cmd, cmd)
|
|
4123
|
+
toast(r.automation ? t('login.automation', { terminal: TERM_NAMES[r.terminal] || r.terminal }) : t('login.failed'), { kind: 'info' })
|
|
4124
|
+
} catch (e) {
|
|
4125
|
+
await copyText(cmd, cmd)
|
|
4126
|
+
toast(e.message, { kind: 'fail' })
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
function keyable(a) { return !BOOT.readOnly && (a.kind === 'api' || Boolean(family(a.family).keyAlways)) }
|
|
4130
|
+
|
|
4131
|
+
// Enter key (spec §3.28): a password field, saved into the account's proxy.env; then an optional "Try it" that says
|
|
4132
|
+
// what it runs and that it spends a few tokens before it runs.
|
|
4133
|
+
let ky = null
|
|
4134
|
+
function openKey(a) {
|
|
4135
|
+
if (!keyable(a)) return
|
|
4136
|
+
const fam = family(a.family)
|
|
4137
|
+
ky = { ref: a.ref, family: a.family, var: fam.keyVar || '', step: 'enter', busy: false, error: null, saved: null, tried: null, els: {} }
|
|
4138
|
+
showDialog('key')
|
|
4139
|
+
renderKey()
|
|
4140
|
+
if (ky.els.value) ky.els.value.focus()
|
|
4141
|
+
}
|
|
4142
|
+
function renderKey() {
|
|
4143
|
+
if (!ky) return
|
|
4144
|
+
const el = dlg.el
|
|
4145
|
+
const fam = family(ky.family)
|
|
4146
|
+
const body = []
|
|
4147
|
+
const foot = [h('button', { class: 'btn', type: 'button', disabled: ky.busy, onclick: () => el.close() }, t(ky.step === 'enter' ? 'btn.cancel' : 'btn.close'))]
|
|
4148
|
+
if (ky.step === 'enter') {
|
|
4149
|
+
const e = ky.els
|
|
4150
|
+
e.value = h('input', { id: 'ky-value', type: 'password', autocomplete: 'off', spellcheck: 'false', 'aria-describedby': 'ky-err' })
|
|
4151
|
+
e.varInput = h('input', { id: 'ky-var', type: 'text', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false', maxlength: '64' })
|
|
4152
|
+
e.varInput.value = ky.var
|
|
4153
|
+
e.err = h('p', { class: 'field-error', id: 'ky-err', role: 'alert' }, ky.error ? ky.error.message : '')
|
|
4154
|
+
e.varInput.addEventListener('input', () => { ky.var = e.varInput.value.trim(); e.label.textContent = t('key.label', { var: ky.var || '?' }) })
|
|
4155
|
+
e.label = h('span', null, t('key.label', { var: ky.var || '?' }))
|
|
4156
|
+
body.push(h('p', { class: 'muted small' }, t('key.intro')),
|
|
4157
|
+
h('form', { id: 'ky-form', class: 'form', novalidate: true, onsubmit: (ev) => { ev.preventDefault(); keySave() } },
|
|
4158
|
+
h('label', { class: 'field', for: 'ky-value' }, e.label, e.value),
|
|
4159
|
+
fam.keyVar ? h('details', { class: 'more-opts' }, h('summary', null, t('key.otherVar')),
|
|
4160
|
+
h('label', { class: 'field', for: 'ky-var' }, h('span', null, t('key.varLabel')), e.varInput), h('p', { class: 'field-hint' }, t('key.varHint')))
|
|
4161
|
+
: h('div', { class: 'field' }, h('p', { class: 'field-hint' }, t('key.noVar')), h('label', { class: 'field', for: 'ky-var' }, h('span', null, t('key.varLabel')), e.varInput)),
|
|
4162
|
+
e.err))
|
|
4163
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'submit', form: 'ky-form', disabled: ky.busy, 'aria-busy': ky.busy ? 'true' : null }, ky.busy ? spinner() : null, ky.busy ? t('key.saving') : t('key.save')))
|
|
4164
|
+
} else {
|
|
4165
|
+
body.push(h('div', { class: 'created-head', role: 'status' }, h('span', { class: 'check-icon', 'aria-hidden': 'true' }, '✓'), h('span', null, t('key.saved', { var: ky.saved.var, file: ky.saved.path }))))
|
|
4166
|
+
const cmd = fam.tryCommand
|
|
4167
|
+
if (!cmd) body.push(h('p', { class: 'small muted' }, tx('key.cannotTry', { cmd: h('code', null, 'sideby run ' + ky.ref) })))
|
|
4168
|
+
else if (ky.step === 'saved') {
|
|
4169
|
+
body.push(h('p', { class: 'small' }, t('key.tryLead')))
|
|
4170
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'button', onclick: () => { ky.step = 'confirm'; renderKey() } }, t('key.try')))
|
|
4171
|
+
} else if (ky.step === 'confirm' || ky.step === 'trying') {
|
|
4172
|
+
body.push(h('p', { class: 'small' }, t('key.tryCost')), h('pre', { class: 'diff' }, cmd.map((v) => /^[A-Za-z0-9_./:=-]+$/.test(v) ? v : JSON.stringify(v)).join(' ')))
|
|
4173
|
+
if (ky.step === 'trying') body.push(h('p', { class: 'muted small' }, spinner(), ' ', t('key.trying')))
|
|
4174
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: ky.step === 'trying', 'aria-busy': ky.step === 'trying' ? 'true' : null, onclick: keyTry }, ky.step === 'trying' ? spinner() : null, t('key.tryRun')))
|
|
4175
|
+
} else if (ky.step === 'tried') {
|
|
4176
|
+
const r = ky.tried
|
|
4177
|
+
if (r.ok) body.push(h('div', { class: 'note' }, t('key.tryOk', { ref: ky.ref })))
|
|
4178
|
+
else {
|
|
4179
|
+
body.push(h('div', { class: 'note note-fail' }, t(r.timedOut ? 'key.tryTimeout' : 'key.tryFail')),
|
|
4180
|
+
h('p', { class: 'small muted' }, t('key.output')),
|
|
4181
|
+
h('pre', { class: 'diff' }, r.output || t('key.noOutput')))
|
|
4182
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'button', onclick: () => { const ref = ky.ref; const fam = ky.family; openAsk({ task: 'account', context: { account: ref, family: fam }, text: t('key.explainText', { ref, output: r.output || t('key.noOutput') }) }) } }, icon('bot'), t('key.explain')))
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
if (ky.error) body.push(errorNote(ky.error, null))
|
|
4186
|
+
}
|
|
4187
|
+
setKids(el, dialogFrame(t('key.title', { ref: ky.ref }), ky.family, body, foot))
|
|
4188
|
+
}
|
|
4189
|
+
async function keySave() {
|
|
4190
|
+
const value = ky.els.value.value
|
|
4191
|
+
const name = ky.var.trim()
|
|
4192
|
+
if (!value.trim()) { ky.els.err.textContent = t('key.empty'); ky.els.value.focus(); return }
|
|
4193
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) { ky.els.err.textContent = t('key.badVar'); return }
|
|
4194
|
+
const mine = ky
|
|
4195
|
+
ky.busy = true
|
|
4196
|
+
dlg.busy = true
|
|
4197
|
+
ky.error = null
|
|
4198
|
+
renderKey()
|
|
4199
|
+
try {
|
|
4200
|
+
ky.saved = await api('/api/key', { account: ky.ref, var: name, value })
|
|
4201
|
+
ky.step = 'saved'
|
|
4202
|
+
} catch (e) { ky.error = e }
|
|
4203
|
+
dlg.busy = false
|
|
4204
|
+
if (ky !== mine) return
|
|
4205
|
+
ky.busy = false
|
|
4206
|
+
renderKey()
|
|
4207
|
+
if (ky.step === 'enter' && ky.els.value) ky.els.value.focus()
|
|
4208
|
+
}
|
|
4209
|
+
async function keyTry() {
|
|
4210
|
+
const mine = ky
|
|
4211
|
+
ky.step = 'trying'
|
|
4212
|
+
ky.error = null
|
|
4213
|
+
renderKey()
|
|
4214
|
+
try {
|
|
4215
|
+
ky.tried = await api('/api/try', { account: ky.ref })
|
|
4216
|
+
ky.step = 'tried'
|
|
4217
|
+
} catch (e) { ky.error = e; ky.step = 'confirm' }
|
|
4218
|
+
if (ky !== mine || dlg.kind !== 'key') return
|
|
4219
|
+
renderKey()
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
// Preferred terminal and the custom command (spec §3.28): the installed list, "Guess for me" with where the guess
|
|
4223
|
+
// comes from, and a template for any other terminal.
|
|
4224
|
+
let tm = null
|
|
4225
|
+
function openTerminals() {
|
|
4226
|
+
closeMenu()
|
|
4227
|
+
tm = { data: null, busy: false, error: null, draft: null, customOpen: false }
|
|
4228
|
+
showDialog('terminal')
|
|
4229
|
+
renderTerminals()
|
|
4230
|
+
api('/api/terminals').then((d) => { if (tm) { tm.data = d; renderTerminals() } }, (e) => { if (tm) { tm.error = e; renderTerminals() } })
|
|
4231
|
+
}
|
|
4232
|
+
async function termSave(body) {
|
|
4233
|
+
tm.busy = true
|
|
4234
|
+
tm.error = null
|
|
4235
|
+
renderTerminals()
|
|
4236
|
+
try { tm.data = await api('/api/terminal', body); tm.draft = null; toast(t('term.saved')) } catch (e) { tm.error = e }
|
|
4237
|
+
tm.busy = false
|
|
4238
|
+
if (dlg.kind === 'terminal') renderTerminals()
|
|
4239
|
+
}
|
|
4240
|
+
// "Not sure how?": AI writes the custom command, starting from what is typed or saved and what is installed.
|
|
4241
|
+
function termAskAi(d) {
|
|
4242
|
+
const name = (id) => { const f = d.terminals.find((x) => x.id === id); return f ? f.name : id }
|
|
4243
|
+
const typed = tm.draft !== null ? tm.draft.trim() : ''
|
|
4244
|
+
const custom = typed || d.custom || ''
|
|
4245
|
+
const context = { terminals: d.installed.filter((id) => id !== 'custom').map(name), version: BOOT.version }
|
|
4246
|
+
if (custom) context.terminalCustom = custom
|
|
4247
|
+
openAsk({ task: 'terminal', context })
|
|
4248
|
+
}
|
|
4249
|
+
function renderTerminals() {
|
|
4250
|
+
if (!tm) return
|
|
4251
|
+
const body = [h('p', { class: 'muted small' }, t('term.intro'))]
|
|
4252
|
+
const d = tm.data
|
|
4253
|
+
if (!d && !tm.error) body.push(h('p', { class: 'muted small' }, spinner(), ' ', t('term.loading')))
|
|
4254
|
+
if (d) {
|
|
4255
|
+
const name = (id) => { const f = d.terminals.find((x) => x.id === id); return f ? f.name : id }
|
|
4256
|
+
if (!d.installed.length) body.push(h('p', { class: 'note' }, t('term.none')))
|
|
4257
|
+
else {
|
|
4258
|
+
const sel = h('select', { id: 'tm-pick', disabled: tm.busy, 'aria-label': t('term.pick') },
|
|
4259
|
+
h('option', { value: '' }, t('term.auto')),
|
|
4260
|
+
d.installed.map((id) => h('option', { value: id }, name(id))))
|
|
4261
|
+
sel.value = d.preferred && d.installed.includes(d.preferred) ? d.preferred : ''
|
|
4262
|
+
sel.addEventListener('change', () => termSave(sel.value ? { id: sel.value } : {}))
|
|
4263
|
+
body.push(h('label', { class: 'field', for: 'tm-pick' }, h('span', null, t('term.pick')), sel),
|
|
4264
|
+
h('p', { class: 'field-hint' }, t('term.guess.' + d.guess.source, { name: name(d.guess.id) })))
|
|
4265
|
+
}
|
|
4266
|
+
const input = h('input', { id: 'tm-custom', type: 'text', autocomplete: 'off', spellcheck: 'false', placeholder: 'myterm --cwd {cwd} -e {cmd}', disabled: tm.busy })
|
|
4267
|
+
// What was typed stays after a refusal, and so does the open section.
|
|
4268
|
+
input.value = tm.draft !== null ? tm.draft : d.custom || ''
|
|
4269
|
+
input.addEventListener('input', () => { tm.draft = input.value })
|
|
4270
|
+
const more = h('details', { class: 'more-opts', open: tm.customOpen || d.custom ? true : undefined }, h('summary', null, t('term.custom')),
|
|
4271
|
+
h('label', { class: 'field', for: 'tm-custom' }, h('span', null, t('term.custom')), input),
|
|
4272
|
+
h('p', { class: 'field-hint' }, t('term.customHint')),
|
|
4273
|
+
BOOT.readOnly ? null : h('p', { class: 'small' }, h('button', { class: 'link-btn', type: 'button', onclick: () => termAskAi(d) }, icon('bot'), t('term.askAi'))),
|
|
4274
|
+
h('div', { class: 'actions' },
|
|
4275
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: tm.busy, onclick: () => termSave({ custom: input.value.trim() }) }, t('term.customSave')),
|
|
4276
|
+
d.custom ? h('button', { class: 'btn btn-sm', type: 'button', disabled: tm.busy, onclick: () => termSave({ custom: '' }) }, t('term.customClear')) : null))
|
|
4277
|
+
more.addEventListener('toggle', () => { tm.customOpen = more.open })
|
|
4278
|
+
body.push(more)
|
|
4279
|
+
}
|
|
4280
|
+
if (tm.error) body.push(errorNote(tm.error, null))
|
|
4281
|
+
setKids(dlg.el, dialogFrame(t('term.title'), null, body, [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
|
|
4282
|
+
}
|
|
4283
|
+
|
|
3472
4284
|
// ---------- banners ----------
|
|
3473
4285
|
function renderBanners() {
|
|
3474
4286
|
const out = []
|
|
@@ -3529,6 +4341,7 @@ const MAIN = String.raw `
|
|
|
3529
4341
|
}
|
|
3530
4342
|
// A read-only Panel never records checks, so keep the result of its own last check over the disk.
|
|
3531
4343
|
if (!BOOT.readOnly || !S.history) S.history = state.history || null
|
|
4344
|
+
adoptPanel(state.panel)
|
|
3532
4345
|
} else if (!error.network) S.loadError = error // a lost connection has its own notice
|
|
3533
4346
|
}
|
|
3534
4347
|
S.loading = false
|
|
@@ -3929,6 +4742,7 @@ const MAIN = String.raw `
|
|
|
3929
4742
|
$('update').addEventListener('click', openUpdate)
|
|
3930
4743
|
$('ver-top').addEventListener('click', () => openVerPop($('ver-top')))
|
|
3931
4744
|
$('refresh').addEventListener('click', refreshAll)
|
|
4745
|
+
$('ask-ai').addEventListener('click', () => openAsk())
|
|
3932
4746
|
$('accounts').addEventListener('dragover', onDragOver)
|
|
3933
4747
|
$('accounts').addEventListener('drop', onDrop)
|
|
3934
4748
|
// Escape leaves the arrange mode, as "Done" does, unless a menu or dialog takes it first.
|
|
@@ -3937,6 +4751,13 @@ const MAIN = String.raw `
|
|
|
3937
4751
|
endArrange()
|
|
3938
4752
|
})
|
|
3939
4753
|
$('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
|
|
4754
|
+
document.addEventListener('contextmenu', onContextMenu)
|
|
4755
|
+
document.addEventListener('keydown', onMenuKey)
|
|
4756
|
+
// The first load of this version writes this browser's earlier theme and language into config, once (spec §3.29).
|
|
4757
|
+
if (CFG && !prefs.cfgDone) {
|
|
4758
|
+
if (!cfgMigrate) { prefs.cfgDone = true; savePrefs() }
|
|
4759
|
+
else saveCfg(cfgMigrate, true).then((ok) => { if (ok) { prefs.cfgDone = true; savePrefs() } })
|
|
4760
|
+
}
|
|
3940
4761
|
applyTheme()
|
|
3941
4762
|
renderStatic()
|
|
3942
4763
|
buildToolbar()
|