sideby 0.3.0 → 0.4.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 (115) hide show
  1. package/README.md +27 -10
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +8 -1
  4. package/dist/src/cli/commands.js +330 -9
  5. package/dist/src/cli/format.d.ts +3 -1
  6. package/dist/src/cli/format.js +25 -0
  7. package/dist/src/cli/json-schemas.js +113 -0
  8. package/dist/src/cli/main.d.ts +5 -0
  9. package/dist/src/cli/main.js +40 -8
  10. package/dist/src/core/config.d.ts +40 -0
  11. package/dist/src/core/config.js +83 -11
  12. package/dist/src/core/file-memo.d.ts +23 -0
  13. package/dist/src/core/file-memo.js +37 -0
  14. package/dist/src/core/handoff-brief.d.ts +17 -0
  15. package/dist/src/core/handoff-brief.js +75 -0
  16. package/dist/src/core/handoff-chain.d.ts +74 -0
  17. package/dist/src/core/handoff-chain.js +74 -0
  18. package/dist/src/core/handoff-doctor.d.ts +13 -0
  19. package/dist/src/core/handoff-doctor.js +35 -0
  20. package/dist/src/core/handoff-run.d.ts +31 -0
  21. package/dist/src/core/handoff-run.js +346 -0
  22. package/dist/src/core/handoff-select.d.ts +42 -0
  23. package/dist/src/core/handoff-select.js +88 -0
  24. package/dist/src/core/handoff-status.d.ts +66 -0
  25. package/dist/src/core/handoff-status.js +198 -0
  26. package/dist/src/core/launch.d.ts +15 -0
  27. package/dist/src/core/launch.js +25 -2
  28. package/dist/src/core/memo-store.d.ts +20 -0
  29. package/dist/src/core/memo-store.js +92 -0
  30. package/dist/src/core/resume.d.ts +7 -2
  31. package/dist/src/core/resume.js +22 -9
  32. package/dist/src/core/tuning-apply.d.ts +51 -0
  33. package/dist/src/core/tuning-apply.js +237 -0
  34. package/dist/src/core/tuning.d.ts +98 -0
  35. package/dist/src/core/tuning.js +176 -0
  36. package/dist/src/families/claude/brief.d.ts +5 -0
  37. package/dist/src/families/claude/brief.js +63 -0
  38. package/dist/src/families/claude/handoff-hook.d.ts +2 -0
  39. package/dist/src/families/claude/handoff-hook.js +31 -0
  40. package/dist/src/families/claude/handoff.d.ts +5 -0
  41. package/dist/src/families/claude/handoff.js +115 -0
  42. package/dist/src/families/claude/index.js +8 -1
  43. package/dist/src/families/claude/tuning.d.ts +22 -0
  44. package/dist/src/families/claude/tuning.js +187 -0
  45. package/dist/src/families/claude/usage.d.ts +1 -0
  46. package/dist/src/families/claude/usage.js +2 -2
  47. package/dist/src/families/codex/brief.d.ts +5 -0
  48. package/dist/src/families/codex/brief.js +49 -0
  49. package/dist/src/families/codex/handoff-hook.d.ts +2 -0
  50. package/dist/src/families/codex/handoff-hook.js +16 -0
  51. package/dist/src/families/codex/handoff.d.ts +5 -0
  52. package/dist/src/families/codex/handoff.js +121 -0
  53. package/dist/src/families/codex/index.js +4 -0
  54. package/dist/src/families/codex/rate-limits.d.ts +16 -0
  55. package/dist/src/families/codex/rate-limits.js +92 -0
  56. package/dist/src/families/codex/rollout.d.ts +1 -2
  57. package/dist/src/families/codex/rollout.js +4 -48
  58. package/dist/src/families/codex/tuning.d.ts +16 -0
  59. package/dist/src/families/codex/tuning.js +145 -0
  60. package/dist/src/families/grok/brief.d.ts +5 -0
  61. package/dist/src/families/grok/brief.js +74 -0
  62. package/dist/src/families/grok/handoff-hook.d.ts +4 -0
  63. package/dist/src/families/grok/handoff-hook.js +26 -0
  64. package/dist/src/families/grok/handoff.d.ts +47 -0
  65. package/dist/src/families/grok/handoff.js +219 -0
  66. package/dist/src/families/grok/index.js +2 -0
  67. package/dist/src/families/pi/handoff.d.ts +2 -0
  68. package/dist/src/families/pi/handoff.js +13 -0
  69. package/dist/src/families/pi/index.js +2 -0
  70. package/dist/src/families/shared/brief.d.ts +24 -0
  71. package/dist/src/families/shared/brief.js +42 -0
  72. package/dist/src/families/shared/handoff-args.d.ts +18 -0
  73. package/dist/src/families/shared/handoff-args.js +57 -0
  74. package/dist/src/families/shared/sessions.d.ts +4 -0
  75. package/dist/src/families/shared/sessions.js +19 -0
  76. package/dist/src/families/shared/toml.d.ts +5 -0
  77. package/dist/src/families/shared/toml.js +30 -0
  78. package/dist/src/handoff/hook.d.ts +16 -0
  79. package/dist/src/handoff/hook.js +166 -0
  80. package/dist/src/handoff/messages.d.ts +7 -0
  81. package/dist/src/handoff/messages.js +27 -0
  82. package/dist/src/handoff/reader.d.ts +11 -0
  83. package/dist/src/handoff/reader.js +9 -0
  84. package/dist/src/panel/handler.d.ts +6 -0
  85. package/dist/src/panel/handler.js +110 -0
  86. package/dist/src/panel/i18n.d.ts +96 -0
  87. package/dist/src/panel/i18n.js +192 -0
  88. package/dist/src/panel/page-logic.d.ts +49 -0
  89. package/dist/src/panel/page-logic.js +67 -0
  90. package/dist/src/panel/page-script.js +454 -4
  91. package/dist/src/panel/page-styles.js +28 -2
  92. package/dist/src/panel/page.js +4 -1
  93. package/dist/src/quota/claude-statusline.js +4 -7
  94. package/dist/src/runtime.d.ts +40 -3
  95. package/dist/src/runtime.js +210 -41
  96. package/dist/src/types.d.ts +93 -0
  97. package/dist/testing/index.d.ts +5 -0
  98. package/dist/testing/index.js +1 -0
  99. package/package.json +16 -4
  100. package/schemas/config.json +73 -0
  101. package/schemas/families.json +5 -1
  102. package/schemas/handoff-disable.json +106 -0
  103. package/schemas/handoff-enable.json +106 -0
  104. package/schemas/handoff-ready.json +23 -0
  105. package/schemas/handoff-setup.json +60 -0
  106. package/schemas/handoff-status.json +137 -0
  107. package/schemas/handoff-teardown.json +30 -0
  108. package/schemas/list.json +5 -1
  109. package/schemas/tune-change.json +108 -0
  110. package/schemas/tune.json +243 -0
  111. package/skills/sideby/SKILL.md +5 -3
  112. package/skills/sideby/references/badge.md +1 -1
  113. package/skills/sideby/references/diagnose.md +4 -0
  114. package/skills/sideby/references/handoff.md +24 -2
  115. package/README.zh-CN.md +0 -112
@@ -1,12 +1,15 @@
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, cacheHitRate, formatDuration, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
3
+ import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, formatDuration, handoffChangeOf, handoffHintAccount, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
4
4
  const LOGIC = [
5
5
  aliasArgsOf,
6
6
  aliasIssue,
7
7
  attentionReasons,
8
+ attentionWhy,
8
9
  cacheHitRate,
9
10
  formatDuration,
11
+ handoffChangeOf,
12
+ handoffHintAccount,
10
13
  maskEmail,
11
14
  matchesQuery,
12
15
  mergeDaily,
@@ -65,6 +68,7 @@ const MAIN = String.raw `
65
68
  const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, hideEmail: false }
66
69
  for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
67
70
  if (raw.hideEmail === true) p.hideEmail = true
71
+ if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
68
72
  if (typeof raw.family === 'string') p.family = raw.family
69
73
  if (raw.collapsed && typeof raw.collapsed === 'object')
70
74
  for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
@@ -571,6 +575,7 @@ const MAIN = String.raw `
571
575
  a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
572
576
  a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
573
577
  '-',
578
+ family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
574
579
  { icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
575
580
  BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
576
581
  ]
@@ -605,6 +610,8 @@ const MAIN = String.raw `
605
610
  return { fail, warn }
606
611
  }
607
612
  function reasonsOf(a, now) { return attentionReasons(a, healthOf(a.ref), now, WARN) }
613
+ // One line on why an Account needs a look, such as "7d at 62% (warns from 60%)".
614
+ function whyOf(a, now) { const m = attentionWhy(a, healthOf(a.ref), now, WARN); return m ? t(m.key, m.vars) : '' }
608
615
  function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
609
616
 
610
617
  // ---------- header and summary ----------
@@ -617,6 +624,7 @@ const MAIN = String.raw `
617
624
  }
618
625
  function renderTop() {
619
626
  renderUpdated()
627
+ renderHandoffButton()
620
628
  // A background refresh keeps the button still; only one the viewer asked for spins it.
621
629
  const busy = S.loading && !S.silent
622
630
  const refresh = $('refresh')
@@ -901,6 +909,9 @@ const MAIN = String.raw `
901
909
  const out = []
902
910
  if (a.error) out.push(h('div', { class: 'note note-fail' }, h('b', null, t('acct.readError')), ' ', h('span', null, a.error)))
903
911
  if (a.hostInstalled === false) out.push(h('div', { class: 'note note-warn' }, tx('acct.installHost', { host: fam.title, link: link(t('acct.installIt'), fam.installUrl) || '' })))
912
+ // The other reasons already show in the row (read error, missing tool, sign-in, Health); a full window does not.
913
+ const why = attentionWhy(a, healthOf(a.ref), Date.now(), WARN)
914
+ if (why && why.key === 'why.quota-high') out.push(h('div', { class: 'note note-warn' }, t('acct.nearLimit', why.vars)))
904
915
  return out.length ? h('div', { class: 'row-notes' }, out) : null
905
916
  }
906
917
  // What a row or card shows depends on more than the account (health, view preferences, language); the
@@ -950,11 +961,23 @@ const MAIN = String.raw `
950
961
  const any = all.some((a) => a.quota && a.quota.status === 'ok')
951
962
  return waiting && !any
952
963
  }
964
+ // The group's attention tag says who and why: with one Account its name and reason, and a click shows its row;
965
+ // with more, the count, each one's reason on hover, and a click shows only them (as the summary card does).
966
+ function attentionTag(needing, now) {
967
+ if (needing.length === 1) {
968
+ const a = needing[0]
969
+ const label = a.name + ' · ' + whyOf(a, now)
970
+ return h('button', { class: 'tag tag-warn tag-btn', type: 'button', title: t('group.attentionShow', { name: a.name }), onclick: () => flashRow(a.ref) }, label)
971
+ }
972
+ const list = needing.map((a) => a.name + ': ' + whyOf(a, now)).join('\n')
973
+ return h('button', { class: 'tag tag-warn tag-btn', type: 'button', title: list, 'aria-pressed': String(ui.attention), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } }, tn('group.attention', needing.length))
974
+ }
953
975
  function group(fam, list, total, now, cols) {
954
976
  const info = family(fam)
955
977
  const filtering = Boolean(ui.query.trim()) || ui.attention
956
978
  const collapsed = !filtering && prefs.collapsed[fam] === true
957
- const need = list.filter((a) => reasonsOf(a, now).length).length
979
+ const needing = list.filter((a) => reasonsOf(a, now).length)
980
+ const need = needing.length
958
981
  const setup = setupFor(fam)
959
982
  const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
960
983
  const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
@@ -969,7 +992,7 @@ const MAIN = String.raw `
969
992
  h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
970
993
  h('div', { class: 'group-meta' },
971
994
  waitingForQuota(fam) ? h('span', { class: 'group-wait', role: 'status' }, h('span', { class: 'pulse', 'aria-hidden': 'true' }), t('quota.waiting', { host: info.title })) : null,
972
- need ? h('span', { class: 'tag tag-warn' }, tn('group.attention', need)) : null,
995
+ need ? attentionTag(needing, now) : null,
973
996
  off && setup && setup.plan.status === 'ready' && !BOOT.readOnly
974
997
  ? h('span', { class: 'group-quota' },
975
998
  h('span', { class: 'muted small' }, t('quota.turnOnFamilyNote', { host: info.title })),
@@ -1217,6 +1240,247 @@ const MAIN = String.raw `
1217
1240
  if (!el.open) { el.showModal(); raiseToasts() }
1218
1241
  }
1219
1242
 
1243
+ // Context tuning (spec §3.18, ADR-0012). The setting lives in one settings file, which Accounts often share, so
1244
+ // there is one card per file: what is suggested, one button that does it (after saying what it changes), one size
1245
+ // menu, and the way back. Each account's own numbers sit behind a click.
1246
+ const tuneState = { ref: null, family: null, loading: false, data: null, error: null }
1247
+ // The one change being made right now: step is asking (fetching the plan), confirm, busy, done or undone.
1248
+ let useUi = null
1249
+ // What each card's size menu shows while the user is choosing, by group id; dropped once a change is made.
1250
+ const tuneSel = {}
1251
+ const tp = { data: null, loading: false, error: null, at: 0 }
1252
+ let curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'
1253
+ function tuneSize(n) { return n === null || n === undefined ? t('tune.hostDefault') : tokens(n) }
1254
+ function tuneRedraw() {
1255
+ if (dlg.kind === 'tune') renderTune()
1256
+ if (curPage === 'tuning') renderTuningPage()
1257
+ }
1258
+ async function tuneReload() {
1259
+ if (curPage === 'tuning') await loadTuningPage()
1260
+ if (dlg.kind === 'tune' && tuneState.ref) await loadTuneDialog()
1261
+ }
1262
+ async function useAsk(g, value) {
1263
+ useUi = { gid: g.id, target: g.target, value, step: 'asking', plan: null, error: null, message: '' }
1264
+ tuneRedraw()
1265
+ try {
1266
+ const r = await api('/api/tuning/apply', { account: g.target, value })
1267
+ useUi = { ...useUi, step: 'confirm', plan: r.plan }
1268
+ } catch (e) { useUi = { ...useUi, step: 'confirm', error: e } }
1269
+ tuneRedraw()
1270
+ }
1271
+ async function useConfirm() {
1272
+ const cur = useUi
1273
+ useUi = { ...cur, step: 'busy' }
1274
+ tuneRedraw()
1275
+ try {
1276
+ const r = await api('/api/tuning/apply', { account: cur.target, value: cur.value, confirm: true })
1277
+ useUi = { ...cur, step: 'done', plan: r.plan, error: null, message: r.applied ? '' : r.message, ok: r.applied }
1278
+ delete tuneSel[cur.gid]
1279
+ } catch (e) { useUi = { ...cur, step: 'confirm', error: e } }
1280
+ tuneRedraw()
1281
+ await tuneReload()
1282
+ }
1283
+ async function useUndo(g) {
1284
+ useUi = { gid: g.id, target: g.target, step: 'busy', plan: null, error: null, message: '' }
1285
+ tuneRedraw()
1286
+ try {
1287
+ const r = await api('/api/tuning/undo', { account: g.target })
1288
+ useUi = { ...useUi, step: 'undone', message: r.undone ? '' : r.message, ok: r.undone }
1289
+ delete tuneSel[g.id]
1290
+ } catch (e) { useUi = { ...useUi, step: 'undone', error: e, ok: false } }
1291
+ tuneRedraw()
1292
+ await tuneReload()
1293
+ }
1294
+ // What the change will do, in words a person can check before saying yes. ending picks the result of a finished
1295
+ // change; the rest (asking, the question, an error) is the part before it.
1296
+ function useBox(g, ending) {
1297
+ const u = useUi
1298
+ if (!u || u.gid !== g.id) return null
1299
+ const finished = u.step === 'done' || u.step === 'undone'
1300
+ if (finished !== Boolean(ending)) return null
1301
+ if (u.step === 'asking' || u.step === 'busy') return h('p', { class: 'muted small' }, spinner(), ' ', t('tune.working'))
1302
+ if (finished) {
1303
+ if (u.error) return errorNote(u.error, null)
1304
+ if (u.ok === false) return h('p', { class: 'note note-warn' }, u.message)
1305
+ return h('p', { class: 'note' }, u.step === 'done' ? t('tune.done', { size: tuneSize(u.value) }) : t('tune.undone'), ' ',
1306
+ u.step === 'done' && !BOOT.readOnly ? h('button', { class: 'btn btn-sm', type: 'button', onclick: () => useUndo(g) }, t('tune.undo')) : null)
1307
+ }
1308
+ if (u.error) return errorNote(u.error, null)
1309
+ const plan = u.plan
1310
+ if (plan.status === 'blocked') return h('p', { class: 'note note-warn' }, plan.reason)
1311
+ if (plan.status === 'unchanged') return h('p', { class: 'note' }, t('tune.already', { size: tuneSize(u.value) }))
1312
+ return h('div', { class: 'tune-confirm' },
1313
+ h('p', { class: 'small' }, h('b', null, t('tune.confirmTitle'))),
1314
+ h('ul', { class: 'small' },
1315
+ h('li', null, t('tune.change') + ': ' + tuneSize(plan.current) + ' → ' + tuneSize(plan.next)),
1316
+ h('li', null, t('tune.file') + ': ', h('code', null, plan.file + (plan.creates ? ' ' + t('tune.willCreate') : '')))),
1317
+ plan.affected.length > 1 ? h('p', { class: 'note note-warn' }, t('tune.shared', { list: plan.affected.join(', ') })) : null,
1318
+ h('p', { class: 'muted small' }, t('tune.newSessions')),
1319
+ h('div', { class: 'row-actions' },
1320
+ h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: useConfirm }, t('tune.confirm')),
1321
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { useUi = null; tuneRedraw() } }, t('btn.cancel'))))
1322
+ }
1323
+ // The card for one settings file.
1324
+ function tuneGroupCard(g, entries) {
1325
+ const pick = g.pick
1326
+ const sizes = new Set([50000, 80000, 100000, 120000, 160000, 200000, 300000, 500000, 800000])
1327
+ let top = 200000
1328
+ for (const e of entries) if (e.tuning.status === 'ok') for (const m of e.tuning.models) top = Math.max(top, m.contextWindow)
1329
+ for (const v of [...sizes]) if (v > top) sizes.delete(v)
1330
+ if (pick) sizes.add(pick.threshold)
1331
+ if (g.current) sizes.add(g.current)
1332
+ const key = (v) => (v === null ? 'default' : String(v))
1333
+ const chosen = tuneSel[g.id] !== undefined ? tuneSel[g.id] : key(g.current)
1334
+ const sel = h('select', { 'aria-label': t('tune.setting') },
1335
+ h('option', { value: 'default' }, t('tune.hostDefaultLong')),
1336
+ [...sizes].sort((x, y) => x - y).map((v) => h('option', { value: String(v) }, tokens(v) + (pick && v === pick.threshold ? ' (' + t('tune.suggestedTag') + ')' : ''))))
1337
+ sel.value = chosen
1338
+ sel.addEventListener('change', () => { tuneSel[g.id] = sel.value; tuneRedraw() })
1339
+ const value = chosen === 'default' ? null : Number(chosen)
1340
+ const mine = useUi && useUi.gid === g.id
1341
+ const busy = mine && (useUi.step === 'asking' || useUi.step === 'busy')
1342
+ const asking = mine && (useUi.step === 'confirm' || busy)
1343
+ const rows = [h('p', { class: 'small' }, g.members.length > 1 ? t('tune.groupShared', { list: g.members.join(', ') }) : h('b', { class: 'mono' }, g.members[0]))]
1344
+ rows.push(h('div', { class: 'tune-vals' },
1345
+ h('div', null, h('span', { class: 'muted small' }, t('tune.now')), h('div', { class: 'tune-num' }, tuneSize(g.current))),
1346
+ pick ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'tune-num tune-best' }, tokens(pick.threshold))) : null))
1347
+ if (pick && pick.savedPercent !== null) rows.push(h('p', { class: 'small' }, t('tune.saved', { n: Math.round(pick.savedPercent) })))
1348
+ if (pick) rows.push(h('p', { class: 'muted small' }, t('tune.basis', { model: pick.model, ref: pick.ref })))
1349
+ else rows.push(h('p', { class: 'note' }, t('tune.groupNone')))
1350
+ if (!BOOT.readOnly) {
1351
+ if (pick && !asking) rows.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn btn-primary', type: 'button', onclick: () => useAsk(g, pick.threshold) }, t('tune.use', { size: tokens(pick.threshold) }))))
1352
+ rows.push(h('div', { class: 'tune-setting' },
1353
+ h('b', { class: 'small' }, t('tune.setting')),
1354
+ h('div', { class: 'row-actions' }, sel,
1355
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis')))))
1356
+ rows.push(useBox(g, false))
1357
+ if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
1358
+ rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
1359
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => useUndo(g) }, t('tune.undo'))))
1360
+ rows.push(useBox(g, true))
1361
+ }
1362
+ return h('div', { class: 'tune-model tune-group' }, rows)
1363
+ }
1364
+ // What was seen for one model: information only, behind a click.
1365
+ function tuneModel(m, minCalls) {
1366
+ const rows = [h('div', { class: 'tune-head' }, h('b', { class: 'mono' }, m.model), h('span', { class: 'muted small' }, t('tune.calls', { n: m.calls })))]
1367
+ rows.push(h('div', { class: 'tune-vals' },
1368
+ h('div', null, h('span', { class: 'muted small' }, t('tune.now')), h('div', { class: 'small' }, tuneSize(m.configured))),
1369
+ m.suggestion ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'small' }, tokens(m.suggestion.threshold))) : null))
1370
+ if (!m.suggestion) rows.push(h('p', { class: 'muted small' }, t('tune.reason.' + m.reason, { min: minCalls })))
1371
+ rows.push(h('p', { class: 'muted small' }, m.compactions.total ? tn('tune.compactions', m.compactions.total, { size: tokens(m.compactions.preTokensP50 || 0) }) : t('tune.noCompactions')))
1372
+ return h('div', { class: 'tune-detail' }, rows)
1373
+ }
1374
+ function tuneDetails(entries, minCalls, label) {
1375
+ const kids = []
1376
+ for (const e of entries) {
1377
+ if (e.tuning.status !== 'ok' || !e.tuning.models.length) continue
1378
+ kids.push(h('div', { class: 'tune-acct-head title-logo' }, badge(e.family, 'sm'), h('b', { class: 'mono' }, e.ref)))
1379
+ for (const m of e.tuning.models) kids.push(tuneModel(m, minCalls))
1380
+ }
1381
+ return kids.length ? h('details', { class: 'tune-details' }, h('summary', null, label), kids) : null
1382
+ }
1383
+ // Everything for a response: a card per settings file, then what Codex accounts show (it only observes).
1384
+ function tuneBody(data) {
1385
+ const out = []
1386
+ const byRef = {}
1387
+ for (const e of data.accounts) byRef[e.ref] = e
1388
+ for (const g of data.groups) {
1389
+ const entries = g.members.map((r) => byRef[r]).filter(Boolean)
1390
+ out.push(h('div', { class: 'tune-acct' }, tuneGroupCard(g, entries), tuneDetails(entries, data.minCalls, t('tune.details'))))
1391
+ }
1392
+ const rest = data.accounts.filter((e) => !data.groups.some((g) => g.members.includes(e.ref)))
1393
+ const seen = rest.filter((e) => e.tuning.status === 'ok' && e.tuning.models.length)
1394
+ if (seen.length) out.push(h('div', { class: 'tune-acct' }, tuneDetails(seen, data.minCalls, t('tune.detailsObserved'))))
1395
+ return out
1396
+ }
1397
+ async function openTune(a) {
1398
+ tuneState.ref = a.ref; tuneState.family = a.family; tuneState.data = null; tuneState.error = null
1399
+ // A finished change is not shown again when the dialog opens next time.
1400
+ if (useUi && (useUi.step === 'done' || useUi.step === 'undone')) useUi = null
1401
+ showDialog('tune')
1402
+ await loadTuneDialog()
1403
+ }
1404
+ async function loadTuneDialog() {
1405
+ const ref = tuneState.ref
1406
+ tuneState.loading = true
1407
+ renderTune()
1408
+ try {
1409
+ const r = await api('/api/tuning', { account: ref })
1410
+ if (tuneState.ref === ref) { tuneState.data = r; tuneState.error = null }
1411
+ } catch (e) { if (tuneState.ref === ref) tuneState.error = e }
1412
+ tuneState.loading = false
1413
+ if (dlg.kind === 'tune' && tuneState.ref === ref) renderTune()
1414
+ }
1415
+ function renderTune() {
1416
+ if (!tuneState.ref) return
1417
+ const body = [h('p', { class: 'muted small' }, t('tune.intro', { days: BOOT.usageDays }))]
1418
+ const d = tuneState.data
1419
+ const one = d && d.accounts[0]
1420
+ if (tuneState.loading && !d) body.push(h('p', { class: 'muted' }, spinner(), ' ', t('tune.loading')))
1421
+ else if (tuneState.error) body.push(errorNote(tuneState.error, loadTuneDialog))
1422
+ else if (d && d.groups.length) body.push(tuneBody(d))
1423
+ else if (one && one.tuning.status === 'ok') body.push(tuneBody(d))
1424
+ else if (one) body.push(h('p', { class: 'note', title: one.tuning.detail || null }, t(one.tuning.reason === 'no-session' ? 'tune.none' : one.tuning.reason === 'no-source' ? 'tune.noSource' : 'tune.unreadable')))
1425
+ const foot = [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]
1426
+ setKids(dlg.el, dialogFrame(t('tune.title') + ' · ' + tuneState.ref, tuneState.family, body, foot))
1427
+ }
1428
+
1429
+ // The page: every account that can tune, on a page of its own. The address ending in #tuning opens it.
1430
+ function tuningAvailable() { return Boolean(S.state && S.state.families.some((f) => f.tuning)) }
1431
+ function setPage(next) {
1432
+ curPage = next
1433
+ try { history.replaceState(null, '', next === 'tuning' ? '#tuning' : location.pathname + location.search) } catch {}
1434
+ renderPageChrome()
1435
+ }
1436
+ // The page tabs, and which sections show; runs with every render, so a reload or a language switch keeps them.
1437
+ function renderPageChrome() {
1438
+ const nav = $('pages')
1439
+ const can = tuningAvailable()
1440
+ if (!can && curPage === 'tuning' && S.state) curPage = 'accounts'
1441
+ nav.hidden = !can
1442
+ const tabs = [['accounts', t('page.accounts')], ['tuning', t('page.tuning')]]
1443
+ setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
1444
+ h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
1445
+ const on = can && curPage === 'tuning'
1446
+ for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = on
1447
+ $('tuning-page').hidden = !on
1448
+ if (on) {
1449
+ if (!tp.data && !tp.loading && !tp.error) loadTuningPage()
1450
+ else renderTuningPage()
1451
+ }
1452
+ }
1453
+ async function loadTuningPage() {
1454
+ tp.loading = true
1455
+ tp.error = null
1456
+ renderTuningPage()
1457
+ try { tp.data = await api('/api/tuning', {}); tp.at = Date.now() } catch (e) { tp.error = e }
1458
+ tp.loading = false
1459
+ renderTuningPage()
1460
+ }
1461
+ function renderTuningPage() {
1462
+ const box = $('tuning-page')
1463
+ if (box.hidden) return
1464
+ const kids = [h('div', { class: 'section-head' },
1465
+ h('h2', { id: 'h-tuning' }, t('tpage.title')),
1466
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: tp.loading, onclick: loadTuningPage }, tp.loading ? spinner() : icon('refresh'), t('top.refresh')))]
1467
+ kids.push(h('p', { class: 'muted small' }, t('tune.intro', { days: BOOT.usageDays })))
1468
+ const d = tp.data
1469
+ if (tp.loading && !d) kids.push(h('p', { class: 'muted' }, spinner(), ' ', t('tune.loading')))
1470
+ if (tp.error) kids.push(errorNote(tp.error, loadTuningPage))
1471
+ if (d) {
1472
+ const seen = d.accounts.filter((a) => a.tuning.status === 'ok' && a.tuning.models.length)
1473
+ const idle = d.accounts.filter((a) => !seen.includes(a) && a.tuning.status === 'unavailable' && a.tuning.reason !== 'unrecognized').map((a) => a.ref)
1474
+ const broken = d.accounts.filter((a) => a.tuning.status === 'unavailable' && a.tuning.reason === 'unrecognized')
1475
+ if (!seen.length && !d.groups.length) kids.push(h('p', { class: 'small' }, t('tpage.none')))
1476
+ kids.push(tuneBody(d))
1477
+ for (const a of broken) kids.push(h('p', { class: 'note note-fail' }, h('b', { class: 'mono' }, a.ref), ' ', a.tuning.detail || t('tune.unreadable')))
1478
+ if (idle.length) kids.push(h('p', { class: 'muted small' }, t('tpage.idle', { list: idle.join(', ') })))
1479
+ }
1480
+ setKids(box, kids)
1481
+ }
1482
+ window.addEventListener('hashchange', () => { curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'; renderPageChrome() })
1483
+
1220
1484
  // New account. The form is built once per opening so typing keeps focus; only its derived parts re-render.
1221
1485
  let nf = null
1222
1486
  function familyStatus(f) {
@@ -1510,6 +1774,184 @@ const MAIN = String.raw `
1510
1774
  load()
1511
1775
  }
1512
1776
 
1777
+ // ---------- auto handoff ----------
1778
+ // Settings come with /api/state; readiness (which runs a check of the sideby on PATH) only when the dialog opens.
1779
+ let ah = null
1780
+ const HINT_QUIET_MS = 24 * 3600000
1781
+ function ahState() { return S.state && S.state.autoHandoff }
1782
+ function renderHandoffButton() {
1783
+ const b = $('handoff')
1784
+ const st = ahState()
1785
+ b.hidden = !st
1786
+ if (!st) return
1787
+ const on = st.settings.auto
1788
+ setKids(b, [h('span', { class: 'hide-sm' }, t('ah.button')), h('span', { class: 'chip chip-sm' + (on ? ' chip-ok' : '') }, on ? t('ah.on') : t('ah.off'))])
1789
+ b.setAttribute('aria-label', t('ah.button') + ': ' + (on ? t('ah.on') : t('ah.off')))
1790
+ }
1791
+ function handoffHint() {
1792
+ const st = ahState()
1793
+ if (!st || BOOT.readOnly) return null
1794
+ if (prefs.ahHintAt && Date.now() - prefs.ahHintAt < HINT_QUIET_MS) return null
1795
+ return handoffHintAccount(S.state.accounts, st.settings, st.starters, Date.now())
1796
+ }
1797
+ function handoffForm() {
1798
+ const st = ahState()
1799
+ const s = st.settings
1800
+ const orders = {}
1801
+ for (const f of st.starters) {
1802
+ const cur = s.families[f]
1803
+ orders[f] = { policy: cur && cur.policy === 'order' ? 'order' : 'pressure', order: cur ? cur.order.slice() : [] }
1804
+ }
1805
+ return { auto: s.auto, sameFamily: s.sameFamily, prepareAt: s.prepareAt, threshold: s.threshold, wait: s.waitIfResetWithinMinutes, countdown: s.countdownSeconds, orders }
1806
+ }
1807
+ async function openHandoff() {
1808
+ if (!ahState()) return
1809
+ ah = { form: handoffForm(), loading: true }
1810
+ showDialog('handoff')
1811
+ renderHandoff()
1812
+ await loadHandoffStatus()
1813
+ }
1814
+ async function loadHandoffStatus() {
1815
+ const mine = ah
1816
+ try {
1817
+ const st = await api('/api/handoff/status', {})
1818
+ if (ah === mine) { ah.status = st; ah.loading = false; ah.error = null }
1819
+ } catch (e) { if (ah === mine) { ah.error = e; ah.loading = false } }
1820
+ if (dlg.kind === 'handoff') renderHandoff()
1821
+ }
1822
+ function ahEdit(fn) {
1823
+ fn(ah.form)
1824
+ ah.preview = null
1825
+ ah.nothing = false
1826
+ ah.saved = null
1827
+ renderHandoff()
1828
+ }
1829
+ function ahNumber(key, label, min, max) {
1830
+ const id = 'ah-' + key
1831
+ const input = h('input', { id, type: 'number', min: String(min), max: String(max), step: '1', value: String(ah.form[key]), disabled: BOOT.readOnly })
1832
+ input.addEventListener('change', () => {
1833
+ const n = Math.round(Number(input.value))
1834
+ if (Number.isFinite(n)) ahEdit((f) => { f[key] = Math.min(max, Math.max(min, n)) })
1835
+ })
1836
+ return h('label', { class: 'field', for: id }, h('span', null, label), input)
1837
+ }
1838
+ function ahCheck(key, label, hintText) {
1839
+ const input = h('input', { type: 'checkbox', disabled: BOOT.readOnly })
1840
+ input.checked = Boolean(ah.form[key])
1841
+ input.addEventListener('change', () => ahEdit((f) => { f[key] = input.checked }))
1842
+ return h('div', { class: 'check-row' }, h('label', { class: 'check' }, input, h('span', null, h('b', null, label), hintText ? h('span', { class: 'muted small' }, hintText) : null)))
1843
+ }
1844
+ function accountLabel(ref) {
1845
+ const a = S.state.accounts.find((x) => x.ref === ref || (x.aliases || []).includes(ref))
1846
+ const alias = a && a.aliases && a.aliases[0]
1847
+ return a ? (alias && alias !== ref ? ref + ' (' + alias + ')' : a.ref) : ref
1848
+ }
1849
+ function ahFamily(fam) {
1850
+ const o = ah.form.orders[fam]
1851
+ const f = family(fam)
1852
+ const policy = h('select', { 'aria-label': t('ah.order', { host: f.title }), disabled: BOOT.readOnly },
1853
+ h('option', { value: 'pressure' }, t('ah.byQuota')), h('option', { value: 'order' }, t('ah.myOrder')))
1854
+ policy.value = o.policy
1855
+ policy.addEventListener('change', () => ahEdit((form) => { form.orders[fam].policy = policy.value }))
1856
+ const kids = [h('div', { class: 'ah-fam-head' }, h('b', { class: 'title-logo' }, badge(fam, 'sm'), t('ah.order', { host: f.title })), h('span', null, policy))]
1857
+ if (o.policy === 'order') {
1858
+ const move = (i, d) => ahEdit((form) => { const l = form.orders[fam].order; const x = l[i]; l[i] = l[i + d]; l[i + d] = x })
1859
+ kids.push(h('ol', { class: 'ah-order' }, o.order.map((ref, i) => h('li', null,
1860
+ h('span', { class: 'mono' }, (i + 1) + '. ' + accountLabel(ref)),
1861
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: BOOT.readOnly || i === 0, 'aria-label': t('ah.up'), onclick: () => move(i, -1) }, '↑'),
1862
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: BOOT.readOnly || i === o.order.length - 1, 'aria-label': t('ah.down'), onclick: () => move(i, 1) }, '↓'),
1863
+ h('button', { class: 'icon-btn', type: 'button', disabled: BOOT.readOnly, 'aria-label': t('ah.remove'), onclick: () => ahEdit((form) => { form.orders[fam].order.splice(i, 1) }) }, icon('x'))))))
1864
+ const taken = new Set(o.order)
1865
+ const free = S.state.accounts.filter((a) => !taken.has(a.ref) && !(a.aliases || []).some((n) => taken.has(n)))
1866
+ if (!o.order.length) kids.push(h('p', { class: 'note note-warn' }, t('ah.emptyOrder')))
1867
+ if (free.length && !BOOT.readOnly) {
1868
+ const pick = h('select', { 'aria-label': t('ah.add') }, h('option', { value: '' }, t('ah.add') + '…'), free.map((a) => h('option', { value: a.ref }, accountLabel(a.ref))))
1869
+ pick.addEventListener('change', () => { if (pick.value) ahEdit((form) => { form.orders[fam].order.push(pick.value) }) })
1870
+ kids.push(h('div', { class: 'ah-add' }, pick))
1871
+ }
1872
+ } else kids.push(h('p', { class: 'muted small' }, t('ah.byQuotaHint')))
1873
+ return h('div', { class: 'ah-fam' }, kids)
1874
+ }
1875
+ function ahReadiness() {
1876
+ if (ah.loading) return h('p', { class: 'muted' }, spinner(), ' ', t('ah.checking'))
1877
+ if (ah.error) return errorNote(ah.error, loadHandoffStatus)
1878
+ const st = ah.status
1879
+ if (!st) return null
1880
+ const rows = []
1881
+ if (st.sideby.problem) rows.push(h('p', { class: 'note note-fail' }, st.sideby.problem))
1882
+ for (const f of st.families) {
1883
+ const state = f.starts.length ? (f.ready ? h('span', { class: 'chip chip-sm chip-ok' }, t('ah.ready')) : h('span', { class: 'chip chip-sm chip-warn' }, t('ah.notYet'))) : h('span', { class: 'chip chip-sm' }, t('ah.takesOver'))
1884
+ rows.push(h('div', { class: 'ah-fam' }, h('div', { class: 'ah-fam-head' }, h('b', { class: 'title-logo' }, badge(f.family, 'sm'), family(f.family).title), state),
1885
+ f.problems.map((p) => h('p', { class: 'note note-warn' }, p)),
1886
+ f.notes.map((n) => h('p', { class: 'muted small' }, n))))
1887
+ }
1888
+ if (st.nextSteps.length) rows.push(h('p', { class: 'small' }, h('b', null, t('ah.steps'))), h('ol', { class: 'ah-steps' }, st.nextSteps.map((s) => h('li', null, codeCopy(s)))))
1889
+ return rows
1890
+ }
1891
+ function renderHandoff() {
1892
+ const st = ahState()
1893
+ if (!st || !ah) return
1894
+ const f = ah.form
1895
+ const body = [h('p', { class: 'muted small' }, t('ah.intro'))]
1896
+ if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
1897
+ body.push(ahCheck('auto', t('ah.auto'), null))
1898
+ body.push(ahCheck('sameFamily', t('ah.sameFamily'), t('ah.sameFamilyWarn')))
1899
+ body.push(h('div', { class: 'ah-grid' },
1900
+ ahNumber('prepareAt', t('ah.prepareAt'), 1, 99),
1901
+ ahNumber('threshold', t('ah.threshold'), 2, 100),
1902
+ ahNumber('wait', t('ah.wait'), 0, 300),
1903
+ ahNumber('countdown', t('ah.countdown'), 0, 60)))
1904
+ if (f.prepareAt >= f.threshold) body.push(h('p', { class: 'note note-warn' }, t('ah.badLevels')))
1905
+ for (const fam of st.starters) body.push(ahFamily(fam))
1906
+ // The result of Review or Apply sits right under the form, where the button that made it is.
1907
+ if (ah.applyError) body.push(errorNote(ah.applyError, null))
1908
+ if (ah.saved) body.push(h('p', { class: 'note' }, t('ah.saved', { file: ah.saved })))
1909
+ if (ah.nothing) body.push(h('p', { class: 'muted small' }, t('ah.nothing')))
1910
+ if (ah.preview) body.push(h('p', { class: 'small' }, tx('ah.changes', { file: h('code', null, ah.preview.file) })), diffView(ah.preview.diff))
1911
+ body.push(h('h3', { class: 'small' }, t('ah.readiness')))
1912
+ body.push(ahReadiness())
1913
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: ah.applying, onclick: () => dlg.el.close() }, t('btn.close'))]
1914
+ if (!BOOT.readOnly) {
1915
+ const bad = f.prepareAt >= f.threshold
1916
+ if (ah.preview) foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: ah.applying, 'aria-busy': ah.applying ? 'true' : null, onclick: applyHandoff }, ah.applying ? spinner() : null, ah.applying ? t('ah.applying') : t('ah.apply')))
1917
+ else foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: bad || ah.reviewing, onclick: reviewHandoff }, ah.reviewing ? spinner() : null, t('ah.review')))
1918
+ }
1919
+ setKids(dlg.el, dialogFrame(t('ah.title'), null, body, foot))
1920
+ }
1921
+ async function reviewHandoff() {
1922
+ const change = handoffChangeOf(ahState().settings, ah.form)
1923
+ if (!Object.keys(change).length) { ah.nothing = true; renderHandoff(); return }
1924
+ ah.reviewing = true
1925
+ ah.applyError = null
1926
+ renderHandoff()
1927
+ try {
1928
+ const r = await api('/api/handoff/configure', { change })
1929
+ ah.preview = r.changed ? { change, file: r.file, diff: r.diff } : null
1930
+ ah.nothing = !r.changed
1931
+ } catch (e) { ah.applyError = e }
1932
+ ah.reviewing = false
1933
+ renderHandoff()
1934
+ }
1935
+ async function applyHandoff() {
1936
+ ah.applying = true
1937
+ dlg.busy = true
1938
+ renderHandoff()
1939
+ try {
1940
+ const r = await api('/api/handoff/configure', { change: ah.preview.change, confirm: true })
1941
+ ah.saved = r.file
1942
+ ah.preview = null
1943
+ toast(r.settings.auto ? t('toast.ahOn') : t('toast.ahOff'))
1944
+ await load({ silent: true })
1945
+ if (ahState()) ah.form = handoffForm()
1946
+ ah.loading = true
1947
+ ah.status = null
1948
+ } catch (e) { ah.applyError = e }
1949
+ ah.applying = false
1950
+ dlg.busy = false
1951
+ renderHandoff()
1952
+ if (ah.loading) loadHandoffStatus()
1953
+ }
1954
+
1513
1955
  // ---------- banners ----------
1514
1956
  function renderBanners() {
1515
1957
  const out = []
@@ -1518,6 +1960,12 @@ const MAIN = String.raw `
1518
1960
  h('button', { class: 'btn btn-sm', type: 'button', onclick: () => load({ check: !S.report }) }, t('btn.retry'))))
1519
1961
  if (S.state) {
1520
1962
  if (BOOT.readOnly) out.push(h('div', { class: 'banner banner-quiet' }, h('div', { class: 'muted small' }, t('banner.readOnly'))))
1963
+ const hint = handoffHint()
1964
+ if (hint) out.push(h('div', { class: 'banner', role: 'status' },
1965
+ h('div', null, t('ah.hint', { ref: hint.ref, p: hint.pressure })),
1966
+ h('div', { class: 'banner-actions' },
1967
+ h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openHandoff }, t('ah.setUp')),
1968
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.ahHintAt = Date.now(); savePrefs(); renderBanners() } }, t('ah.dismiss')))))
1521
1969
  const errs = S.state.pluginErrors
1522
1970
  if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
1523
1971
  h('b', null, tn('banner.plugins', errs.length) + ' '), t('banner.pluginsHint'),
@@ -1527,7 +1975,7 @@ const MAIN = String.raw `
1527
1975
  }
1528
1976
 
1529
1977
  // ---------- loading ----------
1530
- function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup() }
1978
+ function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup(); renderPageChrome() }
1531
1979
  // A load that starts while another runs is queued, never dropped, so a change made meanwhile (a new
1532
1980
  // account, a fix) always ends up on screen; and only the newest response is ever rendered. A silent load is a
1533
1981
  // background refresh: no spinner, and its render waits while the viewer is busy (see uiBusy).
@@ -1601,6 +2049,7 @@ const MAIN = String.raw `
1601
2049
  // ---------- start ----------
1602
2050
  $('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
1603
2051
  $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
2052
+ $('handoff').addEventListener('click', openHandoff)
1604
2053
  $('lang').addEventListener('click', () => {
1605
2054
  if (LANG_FIXED) return
1606
2055
  lang = lang === 'zh' ? 'en' : 'zh'
@@ -1611,6 +2060,7 @@ const MAIN = String.raw `
1611
2060
  renderAll()
1612
2061
  if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
1613
2062
  else if (dlg.kind === 'setup' && qs) renderSetup()
2063
+ else if (dlg.kind === 'tune' && tuneState) renderTune()
1614
2064
  })
1615
2065
  $('theme').addEventListener('click', () => {
1616
2066
  const order = CHOICES.theme
@@ -116,6 +116,18 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
116
116
  .seg { display: inline-flex; align-items: center; gap: 2px; height: var(--ctl-h); padding: 2px; border: 1px solid var(--border-strong); border-radius: var(--ctl-radius); background: var(--surface); }
117
117
  .seg-btn { display: inline-flex; align-items: center; gap: 5px; height: 100%; padding: 0 10px; border: 0; border-radius: calc(var(--ctl-radius) - 2px); background: transparent; color: var(--muted); font: inherit; font-size: var(--control-size); cursor: pointer; }
118
118
  .seg-btn:hover { color: var(--text); }
119
+ .seg-btn[aria-selected=true] { background: var(--active); color: var(--text); font-weight: var(--strong-weight); }
120
+ .pages { margin: 0 0 14px; }
121
+ .row-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
122
+ .tune-confirm { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 10px; }
123
+ .tune-confirm ul { margin: 0; padding-left: 18px; }
124
+ .tune-undo { margin: 0; }
125
+ .tune-details { margin-top: 4px; }
126
+ .tune-details summary { cursor: pointer; color: var(--muted); font-size: var(--small); }
127
+ .tune-detail { border-left: 2px solid var(--border); padding: 4px 0 4px 10px; margin: 6px 0; display: flex; flex-direction: column; gap: 4px; }
128
+ .tune-setting { display: flex; flex-direction: column; gap: 8px; border: 1px dashed var(--border-strong); border-radius: calc(var(--radius) - 4px); padding: 10px 12px; }
129
+ .tune-acct { display: flex; flex-direction: column; gap: 8px; margin: 18px 0; }
130
+ .tune-acct-head { display: flex; align-items: center; gap: 8px; }
119
131
  .seg-btn[aria-pressed=true] { background: var(--active); color: var(--text); font-weight: var(--strong-weight); }
120
132
  .seg-btn:focus-visible { outline: var(--focus-outline); outline-offset: 1px; box-shadow: var(--focus-ring); }
121
133
  .acct-head { margin-bottom: 10px; }
@@ -322,6 +334,20 @@ a.btn:hover { text-decoration: none; }
322
334
  .banner-fail { background: var(--fail-soft); border-color: transparent; color: var(--fail); }
323
335
  .banner-warn { background: var(--warn-soft); border-color: transparent; color: var(--warn); }
324
336
  .banner ul { margin: 6px 0 0; padding-left: 18px; }
337
+ .banner-actions { display: flex; gap: 8px; flex: none; }
338
+ .ah-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: end; }
339
+ .tune-model { border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
340
+ .tune-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
341
+ .tune-vals { display: flex; gap: 24px; }
342
+ .tune-num { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
343
+ .tune-best { color: var(--ok, var(--accent)); }
344
+ .ah-fam { border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
345
+ .ah-fam-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
346
+ .ah-order { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
347
+ .ah-order li { display: flex; align-items: center; gap: 6px; }
348
+ .ah-order li .mono { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
349
+ .ah-add { display: flex; gap: 6px; }
350
+ .ah-steps { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
325
351
  .disabled-why { color: var(--muted); font-size: var(--small); margin-top: 10px; }
326
352
  .actions { display: flex; gap: 8px; flex-wrap: wrap; }
327
353
  .spin { display: inline-block; width: 13px; height: 13px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent; animation: spin .8s linear infinite; vertical-align: -2px; flex: none; }
@@ -354,11 +380,11 @@ a.btn:hover { text-decoration: none; }
354
380
  .field { display: flex; flex-direction: column; gap: 6px; border: 0; padding: 0; margin: 0; min-width: 0; }
355
381
  .field > span, .field > legend { font-weight: var(--strong-weight); font-size: var(--small); padding: 0; margin-bottom: 6px; }
356
382
  .field > span { margin-bottom: 0; }
357
- input[type=text], input[type=search], select { height: var(--field-h); border-radius: var(--ctl-radius); border: 1px solid var(--border-strong); background-color: var(--surface); color: var(--text); font: inherit; padding: 0 12px; width: 100%; transition: border-color .12s, box-shadow .12s; }
383
+ input[type=text], input[type=search], input[type=number], select { height: var(--field-h); border-radius: var(--ctl-radius); border: 1px solid var(--border-strong); background-color: var(--surface); color: var(--text); font: inherit; padding: 0 12px; width: 100%; transition: border-color .12s, box-shadow .12s; }
358
384
  input[type=search] { -webkit-appearance: none; appearance: none; }
359
385
  input[type=search]::-webkit-search-cancel-button { -webkit-appearance: none; }
360
386
  input::placeholder { color: var(--faint); }
361
- input[type=text]:hover:not(:disabled), input[type=search]:hover, select:hover:not(:disabled) { border-color: var(--btn-hover-border); }
387
+ input[type=text]:hover:not(:disabled), input[type=number]:hover:not(:disabled), input[type=search]:hover, select:hover:not(:disabled) { border-color: var(--btn-hover-border); }
362
388
  input[type=text]:focus, input[type=search]:focus, select:focus { border-color: var(--focus); }
363
389
  input[type=text]:disabled, select:disabled { background-color: var(--surface-2); color: var(--faint); cursor: not-allowed; }
364
390
  input.invalid { border-color: var(--fail); }
@@ -67,14 +67,17 @@ export function renderPage(opts) {
67
67
  <span id="updated" class="muted small">Loading…</span>
68
68
  <button id="lang" class="icon-btn lang-btn" type="button"${lang === 'auto' ? '' : ' hidden'}>${lang === 'zh' ? 'EN' : '中文'}</button>
69
69
  <button id="theme" class="icon-btn" type="button" hidden></button>
70
+ <button id="handoff" class="btn" type="button" hidden></button>
70
71
  <button id="refresh" class="btn" type="button" aria-label="Refresh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg><span class="hide-sm" data-i18n="top.refresh">Refresh</span></button>
71
72
  </div>
72
73
  </div>
73
74
  </header>
74
75
  <main class="wrap">
75
76
  <div id="banners"></div>
77
+ <nav id="pages" class="pages" role="tablist" hidden></nav>
76
78
  <section id="summary" class="summary" aria-busy="true">${SKELETON_STAT.repeat(4)}</section>
77
- <section class="block" aria-labelledby="h-accounts">
79
+ <section id="tuning-page" class="block" aria-labelledby="h-tuning" hidden></section>
80
+ <section id="accounts-page" class="block" aria-labelledby="h-accounts">
78
81
  <div class="section-head acct-head"><h2 id="h-accounts"><span data-i18n="acct.title">Accounts</span><span id="acct-count" class="muted small"></span></h2></div>
79
82
  <div id="toolbar" class="toolbar" hidden></div>
80
83
  <div id="accounts" aria-busy="true"><div class="group group-list">${SKELETON_ROW.repeat(4)}</div></div>