sideby 0.16.1 → 0.17.0

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