sideby 0.16.0 → 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 +460 -5
- package/dist/src/cli/json-schemas.js +115 -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 +17 -0
- package/dist/src/core/local-build.js +39 -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 +17 -0
- package/dist/src/core/update.js +63 -1
- 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 +396 -9
- package/dist/src/panel/i18n.d.ts +175 -2
- package/dist/src/panel/i18n.js +381 -35
- package/dist/src/panel/page-logic.d.ts +214 -0
- package/dist/src/panel/page-logic.js +394 -0
- package/dist/src/panel/page-script.js +984 -134
- 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/local.json +4 -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
- package/skills/sideby/references/update.md +1 -1
|
@@ -1,32 +1,46 @@
|
|
|
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, fixPrompt, formatDuration, handoffChangeOf, handoffHintAccount, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelFixes, panelHint, panelLang, passedWindow, pinnedIn, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, sortAccounts, suggestAlias, suggestName, 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,
|
|
31
|
+
canLeaveLocal,
|
|
32
|
+
switchFailure,
|
|
22
33
|
panelFixes,
|
|
23
34
|
panelHint,
|
|
24
35
|
panelLang,
|
|
36
|
+
panelMigration,
|
|
37
|
+
panelPrefs,
|
|
25
38
|
passedWindow,
|
|
26
39
|
quotaPressure,
|
|
27
40
|
remoteControlSummary,
|
|
28
41
|
remoteControlTodo,
|
|
29
42
|
reorder,
|
|
43
|
+
rowBadge,
|
|
30
44
|
sortAccounts,
|
|
31
45
|
suggestAlias,
|
|
32
46
|
suggestName,
|
|
@@ -80,6 +94,7 @@ const MAIN = String.raw `
|
|
|
80
94
|
if (raw.hideEmail === true) p.hideEmail = true
|
|
81
95
|
if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
|
|
82
96
|
if (raw.rcHide === true) p.rcHide = true
|
|
97
|
+
if (raw.cfgDone === true) p.cfgDone = true
|
|
83
98
|
if (typeof raw.family === 'string') p.family = raw.family
|
|
84
99
|
if (raw.collapsed && typeof raw.collapsed === 'object')
|
|
85
100
|
for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
|
|
@@ -90,7 +105,16 @@ const MAIN = String.raw `
|
|
|
90
105
|
function savePrefs() { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch {} }
|
|
91
106
|
// A host lang other than auto wins over the saved choice (which stays saved) and hides the switch.
|
|
92
107
|
const LANG_FIXED = BOOT.lang === 'en' || BOOT.lang === 'zh'
|
|
93
|
-
|
|
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)
|
|
94
118
|
|
|
95
119
|
// ---------- text ----------
|
|
96
120
|
function t(key, vars) {
|
|
@@ -276,6 +300,8 @@ const MAIN = String.raw `
|
|
|
276
300
|
swap: 'M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7',
|
|
277
301
|
// Lucide "pin" (ISC license)
|
|
278
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',
|
|
279
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',
|
|
280
306
|
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
|
|
281
307
|
list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
|
|
@@ -283,6 +309,8 @@ const MAIN = String.raw `
|
|
|
283
309
|
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
284
310
|
mail: 'M3 6h18v12H3zM3 7l9 6 9-6',
|
|
285
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',
|
|
286
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',
|
|
287
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',
|
|
288
316
|
}
|
|
@@ -551,24 +579,39 @@ const MAIN = String.raw `
|
|
|
551
579
|
|
|
552
580
|
// ---------- floating panels: menus and popovers ----------
|
|
553
581
|
// One shared base for everything that floats next to a button: it is placed under (or above) its anchor, kept
|
|
554
|
-
// 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.
|
|
555
585
|
// openMenu() fills it with menu items; openPopover() with any content that render() returns (re-run by refreshPopover()).
|
|
556
|
-
|
|
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
|
+
}
|
|
557
600
|
function openFloat(anchor, el, kind, align) {
|
|
558
601
|
closeMenu()
|
|
559
602
|
document.body.append(el)
|
|
560
|
-
const r = anchor.getBoundingClientRect()
|
|
561
|
-
const w = el.offsetWidth
|
|
562
|
-
const hgt = el.offsetHeight
|
|
563
|
-
const x = align === 'start' ? r.left : r.right - w
|
|
564
|
-
const left = Math.max(8, Math.min(window.innerWidth - w - 8, x))
|
|
565
|
-
const top = r.bottom + 4 + hgt > window.innerHeight - 8 && r.top - hgt - 4 > 8 ? r.top - hgt - 4 : r.bottom + 4
|
|
566
|
-
el.style.setProperty('left', left + 'px')
|
|
567
|
-
el.style.setProperty('top', top + 'px')
|
|
568
603
|
anchor.setAttribute('aria-expanded', 'true')
|
|
569
604
|
menu.el = el
|
|
570
605
|
menu.anchor = anchor
|
|
571
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 })
|
|
572
615
|
}
|
|
573
616
|
function openMenu(anchor, items, label) {
|
|
574
617
|
const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
|
|
@@ -584,7 +627,7 @@ const MAIN = String.raw `
|
|
|
584
627
|
// A popover: a small panel of text and buttons, lighter than a dialog. A second click on its anchor closes it.
|
|
585
628
|
function openPopover(anchor, kind, label, render, align = 'start') {
|
|
586
629
|
if (menu.anchor === anchor) { closeMenu(true); return }
|
|
587
|
-
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 })
|
|
588
631
|
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); closeMenu(true) } })
|
|
589
632
|
setKids(el, render())
|
|
590
633
|
openFloat(anchor, el, kind, align)
|
|
@@ -593,25 +636,71 @@ const MAIN = String.raw `
|
|
|
593
636
|
if (first) first.focus()
|
|
594
637
|
}
|
|
595
638
|
function refreshPopover(kind) {
|
|
596
|
-
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() }
|
|
597
640
|
}
|
|
598
641
|
function closeMenu(restore) {
|
|
599
642
|
if (!menu.el) return
|
|
600
643
|
menu.el.remove()
|
|
601
644
|
menu.anchor.setAttribute('aria-expanded', 'false')
|
|
602
|
-
|
|
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()
|
|
603
648
|
menu.el = null
|
|
604
649
|
menu.anchor = null
|
|
605
650
|
menu.kind = null
|
|
606
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)
|
|
607
694
|
}
|
|
608
695
|
function menuKeys(e) {
|
|
609
696
|
const items = Array.from(menu.el.querySelectorAll('.menu-item:not(:disabled)'))
|
|
610
697
|
const i = items.indexOf(document.activeElement)
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
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])
|
|
615
704
|
else if (e.key === 'Escape') closeMenu(true)
|
|
616
705
|
else if (e.key === 'Tab') { closeMenu(true); return }
|
|
617
706
|
else return
|
|
@@ -619,7 +708,8 @@ const MAIN = String.raw `
|
|
|
619
708
|
}
|
|
620
709
|
document.addEventListener('pointerdown', (e) => { if (menu.el && !menu.el.contains(e.target) && !menu.anchor.contains(e.target)) closeMenu() })
|
|
621
710
|
window.addEventListener('resize', () => closeMenu())
|
|
622
|
-
|
|
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 })
|
|
623
713
|
|
|
624
714
|
// What one directory does: start it, its short commands, its desktop app, its folder, model, tuning, Health.
|
|
625
715
|
function dirCopies(a) {
|
|
@@ -645,20 +735,22 @@ const MAIN = String.raw `
|
|
|
645
735
|
function loginItems(a) {
|
|
646
736
|
return [
|
|
647
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,
|
|
648
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,
|
|
649
741
|
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
650
742
|
]
|
|
651
743
|
}
|
|
652
744
|
function likeItem(a) { return BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) } }
|
|
653
745
|
// Grouped by what the user wants to do: open, copy, manage.
|
|
654
746
|
function accountMenu(a, anchor) {
|
|
655
|
-
const [browser, login, email] = loginItems(a)
|
|
747
|
+
const [browser, signInItem, login, key, email] = loginItems(a)
|
|
656
748
|
const opens = [deskItem(a), browser].filter(Boolean)
|
|
657
749
|
const copies = dirCopies(a)
|
|
658
750
|
const items = [pinItem(a),
|
|
659
751
|
opens.length ? '-' : null, opens.length ? { header: t('menu.groupOpen') } : null, ...opens,
|
|
660
752
|
'-', { header: t('menu.groupCopy') }, ...copies, login, email,
|
|
661
|
-
'-', { header: t('menu.groupManage') }, ...dirTools(a), likeItem(a)]
|
|
753
|
+
'-', { header: t('menu.groupManage') }, signInItem, key, ...dirTools(a), likeItem(a)]
|
|
662
754
|
openMenu(anchor, items, t('menu.label', { ref: a.ref }))
|
|
663
755
|
}
|
|
664
756
|
// A Same Login row (ADR-0026): pin first, then a section per directory, then what the login shares.
|
|
@@ -680,15 +772,19 @@ const MAIN = String.raw `
|
|
|
680
772
|
const text = t('row.copyRun', { cmd })
|
|
681
773
|
return h('button', { class: 'icon-btn row-run', type: 'button', 'aria-label': text, title: text, onclick: (e) => { e.stopPropagation(); copyText(cmd) } }, icon('terminal'))
|
|
682
774
|
}
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
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)
|
|
686
785
|
const on = isPinned(a)
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const text = full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(a.family).title })
|
|
690
|
-
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,
|
|
691
|
-
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'))
|
|
692
788
|
}
|
|
693
789
|
function pinItem(a) {
|
|
694
790
|
if (BOOT.readOnly) return null
|
|
@@ -763,12 +859,14 @@ const MAIN = String.raw `
|
|
|
763
859
|
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
|
|
764
860
|
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.getAttribute('data-i18n'))
|
|
765
861
|
$('refresh').setAttribute('aria-label', t('top.refresh'))
|
|
862
|
+
$('ask-ai').setAttribute('aria-label', t('ask.button'))
|
|
766
863
|
renderSettingsButton()
|
|
767
864
|
}
|
|
768
865
|
function applyTheme() {
|
|
769
866
|
if (BOOT.colorScheme !== 'auto') return
|
|
770
|
-
|
|
771
|
-
|
|
867
|
+
const theme = viewPrefs().theme
|
|
868
|
+
if (theme === 'system') document.documentElement.removeAttribute('data-theme')
|
|
869
|
+
else document.documentElement.setAttribute('data-theme', theme)
|
|
772
870
|
}
|
|
773
871
|
function stat(label, value, sub, extra, opts) {
|
|
774
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]
|
|
@@ -953,7 +1051,7 @@ const MAIN = String.raw `
|
|
|
953
1051
|
const setup = setupFor(a.family)
|
|
954
1052
|
// One setup turns quota on for every account of the Family, so the switch lives in the group header only.
|
|
955
1053
|
// A check that asks the Host (Grok) and is turned off, or failed (spec §3.26): say why and what to do.
|
|
956
|
-
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))
|
|
957
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')))
|
|
958
1056
|
if (q.reason === 'not-enabled') {
|
|
959
1057
|
const host = famTitle(a)
|
|
@@ -964,7 +1062,12 @@ const MAIN = String.raw `
|
|
|
964
1062
|
}
|
|
965
1063
|
if (q.reason === 'api-account') return h('div', { class: 'q-note' }, t('quota.api'), tip('api'))
|
|
966
1064
|
const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
|
|
967
|
-
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'))
|
|
968
1071
|
}
|
|
969
1072
|
function quotaBlock(a, now, cols) {
|
|
970
1073
|
const q = a.quota
|
|
@@ -1018,53 +1121,82 @@ const MAIN = String.raw `
|
|
|
1018
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) },
|
|
1019
1122
|
h('span', { class: 'dot' + (lvl ? ' dot-' + lvl : ''), 'aria-hidden': 'true' }), text)
|
|
1020
1123
|
}
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
|
|
1024
|
-
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) {
|
|
1025
1127
|
const lead = loginLead(a)
|
|
1026
|
-
|
|
1128
|
+
return !lead ? a
|
|
1027
1129
|
: lead !== a.ref ? Object.assign({}, a, { quota: undefined })
|
|
1028
1130
|
: Object.assign({}, a, { quota: a.quota && Object.assign({}, a.quota, { from: undefined }) })
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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'
|
|
1032
1135
|
const why = whyOf(subject, Date.now())
|
|
1033
|
-
|
|
1034
|
-
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')
|
|
1035
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)
|
|
1036
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
|
+
}
|
|
1037
1173
|
// members: the directories of a Same Login shown as this one row (spec §3.21).
|
|
1038
1174
|
function nameLine(a, members) {
|
|
1039
|
-
const next = members ? members.some(isNext) : isNext(a)
|
|
1040
1175
|
return h('div', { class: 'ident-name' },
|
|
1176
|
+
pinSlot(a),
|
|
1041
1177
|
h('span', { class: 'name', title: a.name }, a.name),
|
|
1042
|
-
|
|
1043
|
-
(a
|
|
1044
|
-
|
|
1045
|
-
return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
|
|
1046
|
-
}),
|
|
1047
|
-
members ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: members.length })) : null,
|
|
1048
|
-
a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
|
|
1049
|
-
next
|
|
1050
|
-
? nextTag(a)
|
|
1051
|
-
: null,
|
|
1052
|
-
a.login === 'logged-out' && a.kind !== 'api'
|
|
1053
|
-
? 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'))
|
|
1054
|
-
: null,
|
|
1055
|
-
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))
|
|
1056
1181
|
}
|
|
1057
1182
|
// An organization name as the vendor wrote it, without a trailing comma or full stop it sometimes carries.
|
|
1058
1183
|
function orgText(org) { return org ? String(org).replace(/[\s,,;;、。]+$/u, '') : '' }
|
|
1059
1184
|
// The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
|
|
1060
1185
|
function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
|
|
1061
|
-
// 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).
|
|
1062
1188
|
function emailLine(a) {
|
|
1063
1189
|
const id = a.identity
|
|
1064
|
-
if (!id ||
|
|
1065
|
-
const email =
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
|
1068
1200
|
}
|
|
1069
1201
|
function subLine(a) {
|
|
1070
1202
|
const plan = a.quota && a.quota.status === 'ok' && a.quota.plan
|
|
@@ -1346,14 +1478,16 @@ const MAIN = String.raw `
|
|
|
1346
1478
|
if (ui.arrange) return arrangeRow(a, [a])
|
|
1347
1479
|
const open = isOpen(a)
|
|
1348
1480
|
const email = emailLine(a)
|
|
1349
|
-
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) },
|
|
1350
1482
|
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a))),
|
|
1351
1483
|
quotaBlock(a, now, cols),
|
|
1352
1484
|
lastUsed(a),
|
|
1353
|
-
h('div', { class: 'row-end' }, runButton(a),
|
|
1485
|
+
h('div', { class: 'row-end' }, runButton(a), moreButton(a)),
|
|
1354
1486
|
open ? rowDetail(a, Boolean(email)) : null,
|
|
1355
|
-
notes(a))
|
|
1487
|
+
notes(a)), (anchor) => accountMenu(a, anchor))
|
|
1356
1488
|
}
|
|
1489
|
+
// What a right-click on el opens (see openContextMenu).
|
|
1490
|
+
function withMenu(el, open) { el.sidebyMenu = open; return el }
|
|
1357
1491
|
// Directories signed in as one login (spec §3.21, ADR-0026) are one row: the vendor counts one quota, so the row
|
|
1358
1492
|
// shows it once, with the email and the newest use; opening it lists each directory with its own short command,
|
|
1359
1493
|
// last use, tokens, Health, a button that copies its start command and its own menu.
|
|
@@ -1383,20 +1517,21 @@ const MAIN = String.raw `
|
|
|
1383
1517
|
if (ui.arrange) return arrangeRow(a, ms)
|
|
1384
1518
|
const open = isOpen(a)
|
|
1385
1519
|
const email = emailLine(a)
|
|
1386
|
-
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) },
|
|
1387
1521
|
h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a, ms), email || subLine(a))),
|
|
1388
1522
|
quotaBlock(a, now, cols),
|
|
1389
1523
|
lastUsed(newestOf(ms)),
|
|
1390
|
-
h('div', { class: 'row-end' }, runButton(a),
|
|
1524
|
+
h('div', { class: 'row-end' }, runButton(a), loginMoreButton(ms)),
|
|
1391
1525
|
open ? loginDetail(ms) : null,
|
|
1392
|
-
notes(a))
|
|
1526
|
+
notes(a)), (anchor) => loginMenu(ms, anchor))
|
|
1393
1527
|
}
|
|
1394
1528
|
function loginDetail(ms) {
|
|
1395
1529
|
return h('div', { class: 'row-detail login-detail' },
|
|
1396
1530
|
h('p', { class: 'rd-note' }, t('login.explain')),
|
|
1531
|
+
browserItem(ms[0]),
|
|
1397
1532
|
h('div', { class: 'login-dirs', role: 'list' }, ms.map((m) => {
|
|
1398
1533
|
const run = 'sideby run ' + m.ref
|
|
1399
|
-
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 },
|
|
1400
1535
|
h('div', { class: 'ld-name' },
|
|
1401
1536
|
h('span', { class: 'name' }, m.name),
|
|
1402
1537
|
m.ref !== ms[0].ref ? attentionDot(m) : null,
|
|
@@ -1407,7 +1542,7 @@ const MAIN = String.raw `
|
|
|
1407
1542
|
usageBlock(m),
|
|
1408
1543
|
healthChip(m),
|
|
1409
1544
|
h('button', { class: 'btn btn-sm', type: 'button', title: run, onclick: () => copyText(run) }, icon('terminal'), t('login.copyRun')),
|
|
1410
|
-
moreButton(m))
|
|
1545
|
+
moreButton(m)), (anchor) => accountMenu(m, anchor))
|
|
1411
1546
|
})))
|
|
1412
1547
|
}
|
|
1413
1548
|
// What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
|
|
@@ -1420,19 +1555,21 @@ const MAIN = String.raw `
|
|
|
1420
1555
|
const same = sameLoginNote(a)
|
|
1421
1556
|
return h('div', { class: 'row-detail' },
|
|
1422
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),
|
|
1423
1559
|
h('div', { class: 'rd-item rd-usage' }, h('span', { class: 'rd-label' }, t('col.tokens', { n: BOOT.usageDays })), usageBlock(a)),
|
|
1424
1560
|
h('div', { class: 'rd-item rd-health' }, h('span', { class: 'rd-label' }, t('col.health')), healthChip(a)))
|
|
1425
1561
|
}
|
|
1426
1562
|
// A card shows everything at once; it has room for it.
|
|
1427
1563
|
function card(a, now, cols) {
|
|
1428
|
-
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 },
|
|
1429
1565
|
h('header', { class: 'card-head' },
|
|
1430
1566
|
badge(a.family, 'sm'),
|
|
1431
1567
|
h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
|
|
1432
|
-
h('div', { class: 'row-end' },
|
|
1568
|
+
h('div', { class: 'row-end' }, moreButton(a))),
|
|
1433
1569
|
notes(a),
|
|
1570
|
+
browserItem(a),
|
|
1434
1571
|
quotaBlock(a, now, cols),
|
|
1435
|
-
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))
|
|
1436
1573
|
}
|
|
1437
1574
|
// The list's column titles, once per group under its header. Screen readers get each cell's own label instead.
|
|
1438
1575
|
function colHead(cols, list) {
|
|
@@ -1658,7 +1795,7 @@ const MAIN = String.raw `
|
|
|
1658
1795
|
fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
|
|
1659
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')),
|
|
1660
1797
|
BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')),
|
|
1661
|
-
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'))))
|
|
1662
1799
|
}
|
|
1663
1800
|
function clearFilters() {
|
|
1664
1801
|
ui.query = ''
|
|
@@ -1787,7 +1924,7 @@ const MAIN = String.raw `
|
|
|
1787
1924
|
}
|
|
1788
1925
|
function arrangeBar() {
|
|
1789
1926
|
return h('div', { class: 'arrange-bar', role: 'region', 'aria-label': t('sort.arrange'), 'data-key': 'arrange-bar', 'data-sig': sig([ordered(), lang]) },
|
|
1790
|
-
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'))),
|
|
1791
1928
|
h('div', { class: 'arrange-actions' },
|
|
1792
1929
|
h('button', { class: 'link-btn', type: 'button', title: t('order.resetHint'), disabled: ordered() ? undefined : true, onclick: resetOrder }, t('order.resetButton')),
|
|
1793
1930
|
h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: endArrange }, t('order.done'))))
|
|
@@ -1806,13 +1943,15 @@ const MAIN = String.raw `
|
|
|
1806
1943
|
const pinLabel = full ? t('pin.full', { n: PIN_MAX }) : t(pinned ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(fam).title })
|
|
1807
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]) },
|
|
1808
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),
|
|
1809
1947
|
h('span', { class: 'name', title: a.name }, a.name),
|
|
1810
|
-
(
|
|
1811
|
-
|
|
1948
|
+
loginMark(ms),
|
|
1949
|
+
aliasText(a)))),
|
|
1812
1950
|
h('div', { class: 'arrange-tools' },
|
|
1813
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')),
|
|
1814
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')),
|
|
1815
|
-
|
|
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'))))
|
|
1816
1955
|
}
|
|
1817
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
|
|
1818
1957
|
// the arrow keys. Only in the arrange mode.
|
|
@@ -2116,10 +2255,11 @@ const MAIN = String.raw `
|
|
|
2116
2255
|
h('div', null,
|
|
2117
2256
|
h('h2', { id: 'h-checkup' }, t('health.title')),
|
|
2118
2257
|
h('p', { class: 'muted small' }, status, h('span', { class: 'sep', 'aria-hidden': 'true' }, '·'),
|
|
2119
|
-
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')) }))),
|
|
2120
2259
|
h('div', { class: 'actions' },
|
|
2121
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')),
|
|
2122
|
-
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,
|
|
2123
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 },
|
|
2124
2264
|
S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
|
|
2125
2265
|
if (ref) out.push(h('div', { class: 'filter-bar' },
|
|
@@ -2559,7 +2699,9 @@ const MAIN = String.raw `
|
|
|
2559
2699
|
})
|
|
2560
2700
|
const body = h('form', { id: 'nf-form', class: 'form', novalidate: true, onsubmit: (ev) => { ev.preventDefault(); create() } },
|
|
2561
2701
|
h('p', { class: 'muted small' }, t('nf.intro')),
|
|
2562
|
-
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')))))),
|
|
2563
2705
|
h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
|
|
2564
2706
|
// The first screen asks only for the tool and a name; the rest waits behind "More options".
|
|
2565
2707
|
h('details', { class: 'more-opts', open: nf.api || Boolean(nf.alias) || Boolean(nf.error) || undefined },
|
|
@@ -2568,7 +2710,8 @@ const MAIN = String.raw `
|
|
|
2568
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')),
|
|
2569
2711
|
e.preview),
|
|
2570
2712
|
e.error,
|
|
2571
|
-
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'))))
|
|
2572
2715
|
setKids(el, dialogFrame(t('nf.title'), null, body, [
|
|
2573
2716
|
h('button', { class: 'btn', type: 'button', onclick: () => el.close() }, t('btn.cancel')),
|
|
2574
2717
|
e.submit,
|
|
@@ -2676,8 +2819,11 @@ const MAIN = String.raw `
|
|
|
2676
2819
|
: h('div', { class: 'note note-warn' }, h('span', { class: 'small' }, f.message))),
|
|
2677
2820
|
h('p', { class: 'muted small' }, t('nf.next')),
|
|
2678
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
|
|
2679
2824
|
return dialogFrame(t('nf.title'), r.account.family, body, [
|
|
2680
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,
|
|
2681
2827
|
h('button', { class: 'btn btn-primary', type: 'button', onclick: () => dlg.el.close() }, t('btn.done')),
|
|
2682
2828
|
])
|
|
2683
2829
|
}
|
|
@@ -2780,6 +2926,13 @@ const MAIN = String.raw `
|
|
|
2780
2926
|
}
|
|
2781
2927
|
return { auto: s.auto, sameFamily: s.sameFamily, prepareAt: s.prepareAt, threshold: s.threshold, wait: s.waitIfResetWithinMinutes, countdown: s.countdownSeconds, orders }
|
|
2782
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
|
+
}
|
|
2783
2936
|
async function openHandoff() {
|
|
2784
2937
|
if (!ahState()) return
|
|
2785
2938
|
ah = { form: handoffForm(), loading: true }
|
|
@@ -2868,7 +3021,8 @@ const MAIN = String.raw `
|
|
|
2868
3021
|
const st = ahState()
|
|
2869
3022
|
if (!st || !ah) return
|
|
2870
3023
|
const f = ah.form
|
|
2871
|
-
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')))]
|
|
2872
3026
|
if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
|
|
2873
3027
|
body.push(ahCheck('auto', t('ah.auto'), null))
|
|
2874
3028
|
body.push(ahCheck('sameFamily', t('ah.sameFamily'), t('ah.sameFamilyWarn')))
|
|
@@ -3185,6 +3339,7 @@ const MAIN = String.raw `
|
|
|
3185
3339
|
const kids = [h('div', { class: 'section-head' },
|
|
3186
3340
|
h('h2', { id: 'h-handoff' }, t('hp.title')),
|
|
3187
3341
|
h('div', { class: 'row-actions' },
|
|
3342
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: askHandoff }, icon('bot'), t('ask.title.handoff')),
|
|
3188
3343
|
h('button', { class: 'btn btn-sm', type: 'button', onclick: openHandoff }, t('hp.advanced')),
|
|
3189
3344
|
h('button', { class: 'btn btn-sm', type: 'button', disabled: hp.loading, onclick: loadHandoffPage }, hp.loading ? spinner() : icon('refresh'), t('top.refresh'))))]
|
|
3190
3345
|
const on = st.settings.auto
|
|
@@ -3329,6 +3484,7 @@ const MAIN = String.raw `
|
|
|
3329
3484
|
const out = []
|
|
3330
3485
|
if (!LANG_FIXED) out.push('lang')
|
|
3331
3486
|
if (BOOT.colorScheme === 'auto') out.push('theme')
|
|
3487
|
+
out.push('startup')
|
|
3332
3488
|
if (S.state && S.state.accounts.length && !BOOT.readOnly) out.push('order')
|
|
3333
3489
|
out.push('ai')
|
|
3334
3490
|
if (S.state && rcSummary()) out.push('rc')
|
|
@@ -3343,59 +3499,208 @@ const MAIN = String.raw `
|
|
|
3343
3499
|
const warn = secs.includes('rc') && rcSummary() === 'on'
|
|
3344
3500
|
setKids(b, [icon('gear'), h('span', { class: 'hide-sm' }, t('set.button')), warn ? h('span', { class: 'dot set-dot', 'aria-hidden': 'true' }) : null])
|
|
3345
3501
|
b.setAttribute('aria-label', t('set.button') + (warn ? ': ' + t('set.rcOn') : ''))
|
|
3346
|
-
|
|
3502
|
+
refreshSettings()
|
|
3347
3503
|
if (dlg.kind === 'rc') renderRc()
|
|
3348
3504
|
}
|
|
3349
|
-
|
|
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) {
|
|
3350
3509
|
const secs = settingsSections()
|
|
3351
|
-
const out = []
|
|
3352
3510
|
const seg = (label, opts, cur, pick) => h('div', { class: 'set-row' }, h('span', { class: 'set-label' }, label),
|
|
3353
3511
|
h('div', { class: 'seg', role: 'group', 'aria-label': label }, opts.map(([v, text]) =>
|
|
3354
3512
|
h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === cur), onclick: () => pick(v) }, text))))
|
|
3355
|
-
const link = (label, state, cls, run) => h('button', { class: 'menu-item set-link', type: 'button', onclick: () => {
|
|
3356
|
-
h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
|
|
3357
|
-
|
|
3358
|
-
if (secs.includes('
|
|
3359
|
-
if (
|
|
3360
|
-
|
|
3361
|
-
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 = []
|
|
3362
3521
|
// The arrange mode (spec §3.25) has a place here too, where people look for "how do I change this".
|
|
3363
3522
|
if (secs.includes('order')) {
|
|
3364
3523
|
const pins = Object.values(displayOf().pinned || {}).some((x) => x.length)
|
|
3365
|
-
|
|
3524
|
+
acct.push(link(t('set.order'), t(ordered() || pins ? 'set.orderMine' : 'set.orderDefault'), '', () => {
|
|
3366
3525
|
setPage('accounts')
|
|
3367
3526
|
startArrange()
|
|
3368
3527
|
const bar = document.querySelector('#accounts .arrange-bar')
|
|
3369
3528
|
if (bar) bar.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
|
|
3370
3529
|
}))
|
|
3371
3530
|
}
|
|
3372
|
-
if (secs.includes('rc')) {
|
|
3373
|
-
const sum = rcSummary()
|
|
3374
|
-
out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
|
|
3375
|
-
}
|
|
3376
|
-
if (secs.includes('ah')) {
|
|
3377
|
-
const on = ahState().settings.auto
|
|
3378
|
-
out.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
|
|
3379
|
-
}
|
|
3380
3531
|
// Quota checks that ask the Host (spec §3.26): one switch per Family, with what it does in plain words.
|
|
3381
3532
|
if (secs.includes('checks')) {
|
|
3382
|
-
if (out.length) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
|
|
3383
3533
|
for (const c of S.state.quotaChecks) {
|
|
3384
3534
|
const label = t('set.quotaCheck', { host: c.title })
|
|
3385
|
-
|
|
3535
|
+
acct.push(h('div', { class: 'set-row set-check' },
|
|
3386
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 }))),
|
|
3387
3537
|
BOOT.readOnly ? h('span', { class: 'chip chip-sm' + (c.on ? ' chip-ok' : '') }, c.on ? t('ah.on') : t('ah.off'))
|
|
3388
3538
|
: h('div', { class: 'seg', role: 'group', 'aria-label': label }, [[true, t('ah.on')], [false, t('ah.off')]].map(([v, text]) =>
|
|
3389
|
-
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)))))
|
|
3390
3540
|
}
|
|
3391
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
|
+
})
|
|
3392
3568
|
return out
|
|
3393
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
|
+
}
|
|
3394
3679
|
function setLang(v) {
|
|
3395
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) {
|
|
3396
3703
|
lang = v
|
|
3397
|
-
prefs.lang = lang
|
|
3398
|
-
savePrefs()
|
|
3399
3704
|
renderStatic()
|
|
3400
3705
|
buildToolbar()
|
|
3401
3706
|
renderAll()
|
|
@@ -3406,32 +3711,23 @@ const MAIN = String.raw `
|
|
|
3406
3711
|
else if (dlg.kind === 'browser') renderBrowser()
|
|
3407
3712
|
else if (dlg.kind === 'update' && up) renderUpdate()
|
|
3408
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()
|
|
3409
3718
|
}
|
|
3410
3719
|
function setTheme(v) {
|
|
3411
|
-
prefs.theme = v
|
|
3412
|
-
savePrefs()
|
|
3720
|
+
if (CFG) { cfgPanel.theme = v; saveCfg({ theme: v }) } else { prefs.theme = v; savePrefs() }
|
|
3413
3721
|
applyTheme()
|
|
3414
|
-
|
|
3722
|
+
refreshSettings()
|
|
3415
3723
|
}
|
|
3416
3724
|
// The sideby Agent Skill: what to say to an AI assistant instead of clicking, and how to install it when needed.
|
|
3417
3725
|
function skillInstalled() {
|
|
3418
3726
|
const u = S.state && S.state.update
|
|
3419
3727
|
return Boolean(u && u.skillUpdate && u.skillUpdate.installed && u.skillUpdate.installed.length)
|
|
3420
3728
|
}
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
showDialog('ai')
|
|
3424
|
-
const asks = AI_ASKS.map((k) => {
|
|
3425
|
-
const text = t(k)
|
|
3426
|
-
return h('li', null, h('button', { class: 'ai-ask', type: 'button', title: t('ai.copy'), onclick: () => copyText(text) }, icon('copy'), h('span', null, text)))
|
|
3427
|
-
})
|
|
3428
|
-
setKids(dlg.el, dialogFrame(t('ai.title'), null, h('div', { class: 'form' },
|
|
3429
|
-
h('p', null, t('ai.intro')),
|
|
3430
|
-
skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)),
|
|
3431
|
-
h('p', { class: 'small muted' }, t('ai.examples')),
|
|
3432
|
-
h('ul', { class: 'ai-asks' }, asks)),
|
|
3433
|
-
[h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
|
|
3434
|
-
}
|
|
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']
|
|
3435
3731
|
function openRc() {
|
|
3436
3732
|
if (!S.state || !rcSummary()) return
|
|
3437
3733
|
rc.target = null
|
|
@@ -3467,6 +3763,524 @@ const MAIN = String.raw `
|
|
|
3467
3763
|
setKids(dlg.el, dialogFrame(t('rc.dialogTitle'), null, body, foot))
|
|
3468
3764
|
}
|
|
3469
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
|
+
|
|
3470
4284
|
// ---------- banners ----------
|
|
3471
4285
|
function renderBanners() {
|
|
3472
4286
|
const out = []
|
|
@@ -3527,6 +4341,7 @@ const MAIN = String.raw `
|
|
|
3527
4341
|
}
|
|
3528
4342
|
// A read-only Panel never records checks, so keep the result of its own last check over the disk.
|
|
3529
4343
|
if (!BOOT.readOnly || !S.history) S.history = state.history || null
|
|
4344
|
+
adoptPanel(state.panel)
|
|
3530
4345
|
} else if (!error.network) S.loadError = error // a lost connection has its own notice
|
|
3531
4346
|
}
|
|
3532
4347
|
S.loading = false
|
|
@@ -3571,6 +4386,8 @@ const MAIN = String.raw `
|
|
|
3571
4386
|
function updState() { return BOOT.updates && S.state ? S.state.update || null : null }
|
|
3572
4387
|
// The skill agents read is part of the version (spec §3.23): behind a current CLI it is the one thing to update.
|
|
3573
4388
|
const isLocal = (u) => u.method.kind === 'local'
|
|
4389
|
+
// A Local Build the Panel can leave for npm itself (ADR-0024): then that switch is the update, not a command.
|
|
4390
|
+
const leavesLocal = (u) => canLeaveLocal(u, BOOT.readOnly)
|
|
3574
4391
|
const skillOld = (u) => !!(u && !u.available && !isLocal(u) && u.skillUpdate && u.skillUpdate.outdated)
|
|
3575
4392
|
const skillDue = (u) => skillOld(u) && u.skillUpdate.canRun
|
|
3576
4393
|
function skillFor(u) {
|
|
@@ -3690,7 +4507,8 @@ const MAIN = String.raw `
|
|
|
3690
4507
|
if (u && u.available) {
|
|
3691
4508
|
kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
|
|
3692
4509
|
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
3693
|
-
|
|
4510
|
+
if (!(isLocal(u) && leavesLocal(u)))
|
|
4511
|
+
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
|
|
3694
4512
|
} else if (skillOld(u)) {
|
|
3695
4513
|
kids.push(h('p', { class: 'small' }, t('upd.skillBehind', { s: skillFor(u) })))
|
|
3696
4514
|
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (skillDue(u) ? ' btn-primary' : ''), type: 'button', onclick: toDialog }, t('upd.open'))))
|
|
@@ -3722,30 +4540,42 @@ const MAIN = String.raw `
|
|
|
3722
4540
|
kids.push(h('p', { class: 'small' }, t('loc.stuck')))
|
|
3723
4541
|
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload'))))
|
|
3724
4542
|
} else {
|
|
3725
|
-
|
|
3726
|
-
|
|
4543
|
+
const newer = toNpm && u.available && u.latest
|
|
4544
|
+
kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (newer ? ' btn-primary' : ''), type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo(toNpm ? 'npm' : 'local') },
|
|
4545
|
+
vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : newer ? t('loc.toNpmV', { v: u.latest }) : t(toNpm ? 'loc.toNpm' : 'loc.toLocal'))))
|
|
3727
4546
|
}
|
|
3728
4547
|
if (vs.error) kids.push(errorNote(vs.error, null))
|
|
3729
4548
|
return h('div', { class: 'popover-foot' }, kids)
|
|
3730
4549
|
}
|
|
4550
|
+
function renderSwitch() {
|
|
4551
|
+
renderVerPop()
|
|
4552
|
+
if (dlg.kind === 'update' && up) renderUpdate()
|
|
4553
|
+
}
|
|
3731
4554
|
async function localSwitchTo(to) {
|
|
3732
4555
|
vs.busy = true
|
|
3733
4556
|
vs.to = to
|
|
3734
4557
|
vs.error = null
|
|
3735
4558
|
vs.stuck = false
|
|
3736
|
-
|
|
4559
|
+
renderSwitch()
|
|
3737
4560
|
try {
|
|
3738
4561
|
await api('/api/local', { to, confirm: true })
|
|
3739
4562
|
} catch (e) {
|
|
3740
|
-
// A background Panel restarting from the new code may drop this answer
|
|
3741
|
-
|
|
4563
|
+
// A background Panel restarting from the new code may drop this answer, and a Portal moving to it may answer
|
|
4564
|
+
// 5xx (spec §3.23): the state then says whether the switch happened. Only a switch that did not is an error.
|
|
4565
|
+
const kind = switchFailure(e)
|
|
4566
|
+
if (kind === 'fail') { vs.busy = false; vs.error = e; renderSwitch(); return }
|
|
4567
|
+
if (kind === 'check') {
|
|
4568
|
+
await load({ silent: true })
|
|
4569
|
+
const ls = updState() && updState().localSwitch
|
|
4570
|
+
if (!ls || ls.running !== to) { vs.busy = false; vs.error = e; renderSwitch(); return }
|
|
4571
|
+
}
|
|
3742
4572
|
}
|
|
3743
4573
|
vs.waitUntil = Date.now() + 20000
|
|
3744
4574
|
localWait()
|
|
3745
4575
|
}
|
|
3746
4576
|
function localWait() {
|
|
3747
4577
|
if (S.newBuild) { location.reload(); return }
|
|
3748
|
-
if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true;
|
|
4578
|
+
if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true; renderSwitch(); return }
|
|
3749
4579
|
setTimeout(() => { void load({ silent: true }).then(localWait, localWait) }, 1500)
|
|
3750
4580
|
}
|
|
3751
4581
|
async function verPopCheck() {
|
|
@@ -3834,6 +4664,14 @@ const MAIN = String.raw `
|
|
|
3834
4664
|
else body.push(...updSkill(u))
|
|
3835
4665
|
} else if (up.skillDone && !skillOld(u)) {
|
|
3836
4666
|
body.push(h('p', { class: 'note' }, t('upd.skillDoneOnly')))
|
|
4667
|
+
} else if (u.available && isLocal(u) && (leavesLocal(u) || vs.busy || vs.stuck)) {
|
|
4668
|
+
// A Local Build the Panel can leave (ADR-0024): switching to the release is the update, and brings the skill.
|
|
4669
|
+
body.push(h('p', null, t('upd.localSwitch', { v: u.latest }), ' ',
|
|
4670
|
+
h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
|
|
4671
|
+
if (vs.busy) body.push(h('p', { class: 'muted small' }, spinner(), ' ', t('upd.reloading')))
|
|
4672
|
+
if (vs.stuck) body.push(h('p', { class: 'note' }, t('loc.stuck')))
|
|
4673
|
+
if (vs.error) body.push(errorNote(vs.error, null))
|
|
4674
|
+
body.push(updManual([h('p', { class: 'small' }, t('upd.localManual')), codeCopy(u.command)]))
|
|
3837
4675
|
} else if (u.available && isLocal(u)) {
|
|
3838
4676
|
// A Local Build (ADR-0024): one way back to npm, which brings the skill along.
|
|
3839
4677
|
body.push(h('p', null, t('upd.localAvail', { v: u.latest }), ' ',
|
|
@@ -3888,7 +4726,11 @@ const MAIN = String.raw `
|
|
|
3888
4726
|
if (up.error) body.push(errorNote(up.error, null))
|
|
3889
4727
|
const foot = [h('button', { class: 'btn', type: 'button', disabled: up.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
3890
4728
|
const primary = (label, run) => h('button', { class: 'btn btn-primary', type: 'button', disabled: up.busy, 'aria-busy': up.busy ? 'true' : null, onclick: run }, up.busy ? spinner() : null, up.busy ? t('upd.updating') : label)
|
|
3891
|
-
if (u.available && u
|
|
4729
|
+
if (u.available && isLocal(u) && vs.stuck) foot.push(h('button', { class: 'btn btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload')))
|
|
4730
|
+
else if (u.available && isLocal(u) && (leavesLocal(u) || vs.busy))
|
|
4731
|
+
foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo('npm') },
|
|
4732
|
+
vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : t('loc.toNpmV', { v: u.latest })))
|
|
4733
|
+
else if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
|
|
3892
4734
|
else if (skillDue(u) && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.skillNow'), updSkillRun))
|
|
3893
4735
|
setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
|
|
3894
4736
|
}
|
|
@@ -3900,6 +4742,7 @@ const MAIN = String.raw `
|
|
|
3900
4742
|
$('update').addEventListener('click', openUpdate)
|
|
3901
4743
|
$('ver-top').addEventListener('click', () => openVerPop($('ver-top')))
|
|
3902
4744
|
$('refresh').addEventListener('click', refreshAll)
|
|
4745
|
+
$('ask-ai').addEventListener('click', () => openAsk())
|
|
3903
4746
|
$('accounts').addEventListener('dragover', onDragOver)
|
|
3904
4747
|
$('accounts').addEventListener('drop', onDrop)
|
|
3905
4748
|
// Escape leaves the arrange mode, as "Done" does, unless a menu or dialog takes it first.
|
|
@@ -3908,6 +4751,13 @@ const MAIN = String.raw `
|
|
|
3908
4751
|
endArrange()
|
|
3909
4752
|
})
|
|
3910
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
|
+
}
|
|
3911
4761
|
applyTheme()
|
|
3912
4762
|
renderStatic()
|
|
3913
4763
|
buildToolbar()
|