sideby 0.3.0 → 0.4.1

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 +332 -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 +114 -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 +63 -0
  33. package/dist/src/core/tuning-apply.js +264 -0
  34. package/dist/src/core/tuning.d.ts +113 -0
  35. package/dist/src/core/tuning.js +192 -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 +203 -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 +98 -0
  87. package/dist/src/panel/i18n.js +196 -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 +461 -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 +212 -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 +126 -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,254 @@ 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
+ plan.overrides && plan.overrides.length ? h('p', { class: 'note note-warn' }, t('tune.overrides', { list: plan.overrides.map((o) => o.model + ' (' + tokens(o.value) + ')').join(', ') })) : null,
1319
+ h('p', { class: 'muted small' }, t('tune.newSessions')),
1320
+ h('div', { class: 'row-actions' },
1321
+ h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: useConfirm }, t('tune.confirm')),
1322
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { useUi = null; tuneRedraw() } }, t('btn.cancel'))))
1323
+ }
1324
+ // The card for one settings file.
1325
+ function tuneGroupCard(g, entries) {
1326
+ const pick = g.pick
1327
+ const sizes = new Set([100000, 150000, 200000, 300000, 500000, 800000, 1000000])
1328
+ let top = 200000
1329
+ for (const e of entries) if (e.tuning.status === 'ok') for (const m of e.tuning.models) top = Math.max(top, m.contextWindow)
1330
+ for (const v of [...sizes]) if (v > top) sizes.delete(v)
1331
+ if (pick) sizes.add(pick.threshold)
1332
+ if (g.current && !g.outOfRange) sizes.add(g.current)
1333
+ const key = (v) => (v === null ? 'default' : String(v))
1334
+ const chosen = tuneSel[g.id] !== undefined ? tuneSel[g.id] : key(g.current)
1335
+ const sel = h('select', { 'aria-label': t('tune.setting') },
1336
+ h('option', { value: 'default' }, t('tune.hostDefaultLong')),
1337
+ [...sizes].sort((x, y) => x - y).map((v) => h('option', { value: String(v) }, tokens(v) + (pick && v === pick.threshold ? ' (' + t('tune.suggestedTag') + ')' : ''))))
1338
+ sel.value = chosen
1339
+ sel.addEventListener('change', () => { tuneSel[g.id] = sel.value; tuneRedraw() })
1340
+ const value = chosen === 'default' ? null : Number(chosen)
1341
+ const mine = useUi && useUi.gid === g.id
1342
+ const busy = mine && (useUi.step === 'asking' || useUi.step === 'busy')
1343
+ const asking = mine && (useUi.step === 'confirm' || busy)
1344
+ const rows = [h('p', { class: 'small' }, g.members.length > 1 ? t('tune.groupShared', { list: g.members.join(', ') }) : h('b', { class: 'mono' }, g.members[0]))]
1345
+ rows.push(h('div', { class: 'tune-vals' },
1346
+ h('div', null, h('span', { class: 'muted small' }, t('tune.now')), h('div', { class: 'tune-num' }, tuneSize(g.current))),
1347
+ pick ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'tune-num tune-best' }, tokens(pick.threshold))) : null))
1348
+ if (pick && pick.savedPercent !== null) rows.push(h('p', { class: 'small' }, t('tune.saved', { n: Math.round(pick.savedPercent) })))
1349
+ if (pick) rows.push(h('p', { class: 'muted small' }, t('tune.basis', { model: pick.model, ref: pick.ref })))
1350
+ else {
1351
+ // Say why there is no suggestion: when a model is already at its cheapest, that is the news, not "not enough use".
1352
+ const why = entries.flatMap((e) => (e.tuning.status === 'ok' ? e.tuning.models : [])).some((m) => m.reason === 'already-best') ? t('tune.reason.already-best') : t('tune.groupNone')
1353
+ rows.push(h('p', { class: 'note' }, why))
1354
+ }
1355
+ if (g.overrides && g.overrides.length) rows.push(h('p', { class: 'note note-warn' }, t('tune.overrides', { list: g.overrides.map((o) => o.model + ' (' + tokens(o.value) + ')').join(', ') })))
1356
+ if (g.outOfRange) rows.push(h('p', { class: 'note note-warn' }, t('tune.outOfRange', { size: tokens(g.current) })))
1357
+ if (!BOOT.readOnly) {
1358
+ 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) }))))
1359
+ rows.push(h('div', { class: 'tune-setting' },
1360
+ h('b', { class: 'small' }, t('tune.setting')),
1361
+ h('div', { class: 'row-actions' }, sel,
1362
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis')))))
1363
+ rows.push(useBox(g, false))
1364
+ if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
1365
+ rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
1366
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => useUndo(g) }, t('tune.undo'))))
1367
+ rows.push(useBox(g, true))
1368
+ }
1369
+ return h('div', { class: 'tune-model tune-group' }, rows)
1370
+ }
1371
+ // What was seen for one model: information only, behind a click.
1372
+ function tuneModel(m, minCalls) {
1373
+ const rows = [h('div', { class: 'tune-head' }, h('b', { class: 'mono' }, m.model), h('span', { class: 'muted small' }, t('tune.calls', { n: m.calls })))]
1374
+ rows.push(h('div', { class: 'tune-vals' },
1375
+ h('div', null, h('span', { class: 'muted small' }, t('tune.now')), h('div', { class: 'small' }, tuneSize(m.configured))),
1376
+ m.suggestion ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'small' }, tokens(m.suggestion.threshold))) : null))
1377
+ if (!m.suggestion) rows.push(h('p', { class: 'muted small' }, t('tune.reason.' + m.reason, { min: minCalls })))
1378
+ 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')))
1379
+ return h('div', { class: 'tune-detail' }, rows)
1380
+ }
1381
+ function tuneDetails(entries, minCalls, label) {
1382
+ const kids = []
1383
+ for (const e of entries) {
1384
+ if (e.tuning.status !== 'ok' || !e.tuning.models.length) continue
1385
+ kids.push(h('div', { class: 'tune-acct-head title-logo' }, badge(e.family, 'sm'), h('b', { class: 'mono' }, e.ref)))
1386
+ for (const m of e.tuning.models) kids.push(tuneModel(m, minCalls))
1387
+ }
1388
+ return kids.length ? h('details', { class: 'tune-details' }, h('summary', null, label), kids) : null
1389
+ }
1390
+ // Everything for a response: a card per settings file, then what Codex accounts show (it only observes).
1391
+ function tuneBody(data) {
1392
+ const out = []
1393
+ const byRef = {}
1394
+ for (const e of data.accounts) byRef[e.ref] = e
1395
+ for (const g of data.groups) {
1396
+ const entries = g.members.map((r) => byRef[r]).filter(Boolean)
1397
+ out.push(h('div', { class: 'tune-acct' }, tuneGroupCard(g, entries), tuneDetails(entries, data.minCalls, t('tune.details'))))
1398
+ }
1399
+ const rest = data.accounts.filter((e) => !data.groups.some((g) => g.members.includes(e.ref)))
1400
+ const seen = rest.filter((e) => e.tuning.status === 'ok' && e.tuning.models.length)
1401
+ if (seen.length) out.push(h('div', { class: 'tune-acct' }, tuneDetails(seen, data.minCalls, t('tune.detailsObserved'))))
1402
+ return out
1403
+ }
1404
+ async function openTune(a) {
1405
+ tuneState.ref = a.ref; tuneState.family = a.family; tuneState.data = null; tuneState.error = null
1406
+ // A finished change is not shown again when the dialog opens next time.
1407
+ if (useUi && (useUi.step === 'done' || useUi.step === 'undone')) useUi = null
1408
+ showDialog('tune')
1409
+ await loadTuneDialog()
1410
+ }
1411
+ async function loadTuneDialog() {
1412
+ const ref = tuneState.ref
1413
+ tuneState.loading = true
1414
+ renderTune()
1415
+ try {
1416
+ const r = await api('/api/tuning', { account: ref })
1417
+ if (tuneState.ref === ref) { tuneState.data = r; tuneState.error = null }
1418
+ } catch (e) { if (tuneState.ref === ref) tuneState.error = e }
1419
+ tuneState.loading = false
1420
+ if (dlg.kind === 'tune' && tuneState.ref === ref) renderTune()
1421
+ }
1422
+ function renderTune() {
1423
+ if (!tuneState.ref) return
1424
+ const body = [h('p', { class: 'muted small' }, t('tune.intro', { days: BOOT.usageDays }))]
1425
+ const d = tuneState.data
1426
+ const one = d && d.accounts[0]
1427
+ if (tuneState.loading && !d) body.push(h('p', { class: 'muted' }, spinner(), ' ', t('tune.loading')))
1428
+ else if (tuneState.error) body.push(errorNote(tuneState.error, loadTuneDialog))
1429
+ else if (d && d.groups.length) body.push(tuneBody(d))
1430
+ else if (one && one.tuning.status === 'ok') body.push(tuneBody(d))
1431
+ 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')))
1432
+ const foot = [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]
1433
+ setKids(dlg.el, dialogFrame(t('tune.title') + ' · ' + tuneState.ref, tuneState.family, body, foot))
1434
+ }
1435
+
1436
+ // The page: every account that can tune, on a page of its own. The address ending in #tuning opens it.
1437
+ function tuningAvailable() { return Boolean(S.state && S.state.families.some((f) => f.tuning)) }
1438
+ function setPage(next) {
1439
+ curPage = next
1440
+ try { history.replaceState(null, '', next === 'tuning' ? '#tuning' : location.pathname + location.search) } catch {}
1441
+ renderPageChrome()
1442
+ }
1443
+ // The page tabs, and which sections show; runs with every render, so a reload or a language switch keeps them.
1444
+ function renderPageChrome() {
1445
+ const nav = $('pages')
1446
+ const can = tuningAvailable()
1447
+ if (!can && curPage === 'tuning' && S.state) curPage = 'accounts'
1448
+ nav.hidden = !can
1449
+ const tabs = [['accounts', t('page.accounts')], ['tuning', t('page.tuning')]]
1450
+ setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
1451
+ h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
1452
+ const on = can && curPage === 'tuning'
1453
+ for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = on
1454
+ $('tuning-page').hidden = !on
1455
+ if (on) {
1456
+ if (!tp.data && !tp.loading && !tp.error) loadTuningPage()
1457
+ else renderTuningPage()
1458
+ }
1459
+ }
1460
+ async function loadTuningPage() {
1461
+ tp.loading = true
1462
+ tp.error = null
1463
+ renderTuningPage()
1464
+ try { tp.data = await api('/api/tuning', {}); tp.at = Date.now() } catch (e) { tp.error = e }
1465
+ tp.loading = false
1466
+ renderTuningPage()
1467
+ }
1468
+ function renderTuningPage() {
1469
+ const box = $('tuning-page')
1470
+ if (box.hidden) return
1471
+ const kids = [h('div', { class: 'section-head' },
1472
+ h('h2', { id: 'h-tuning' }, t('tpage.title')),
1473
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: tp.loading, onclick: loadTuningPage }, tp.loading ? spinner() : icon('refresh'), t('top.refresh')))]
1474
+ kids.push(h('p', { class: 'muted small' }, t('tune.intro', { days: BOOT.usageDays })))
1475
+ const d = tp.data
1476
+ if (tp.loading && !d) kids.push(h('p', { class: 'muted' }, spinner(), ' ', t('tune.loading')))
1477
+ if (tp.error) kids.push(errorNote(tp.error, loadTuningPage))
1478
+ if (d) {
1479
+ const seen = d.accounts.filter((a) => a.tuning.status === 'ok' && a.tuning.models.length)
1480
+ const idle = d.accounts.filter((a) => !seen.includes(a) && a.tuning.status === 'unavailable' && a.tuning.reason !== 'unrecognized').map((a) => a.ref)
1481
+ const broken = d.accounts.filter((a) => a.tuning.status === 'unavailable' && a.tuning.reason === 'unrecognized')
1482
+ if (!seen.length && !d.groups.length) kids.push(h('p', { class: 'small' }, t('tpage.none')))
1483
+ kids.push(tuneBody(d))
1484
+ 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')))
1485
+ if (idle.length) kids.push(h('p', { class: 'muted small' }, t('tpage.idle', { list: idle.join(', ') })))
1486
+ }
1487
+ setKids(box, kids)
1488
+ }
1489
+ window.addEventListener('hashchange', () => { curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'; renderPageChrome() })
1490
+
1220
1491
  // New account. The form is built once per opening so typing keeps focus; only its derived parts re-render.
1221
1492
  let nf = null
1222
1493
  function familyStatus(f) {
@@ -1510,6 +1781,184 @@ const MAIN = String.raw `
1510
1781
  load()
1511
1782
  }
1512
1783
 
1784
+ // ---------- auto handoff ----------
1785
+ // Settings come with /api/state; readiness (which runs a check of the sideby on PATH) only when the dialog opens.
1786
+ let ah = null
1787
+ const HINT_QUIET_MS = 24 * 3600000
1788
+ function ahState() { return S.state && S.state.autoHandoff }
1789
+ function renderHandoffButton() {
1790
+ const b = $('handoff')
1791
+ const st = ahState()
1792
+ b.hidden = !st
1793
+ if (!st) return
1794
+ const on = st.settings.auto
1795
+ 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'))])
1796
+ b.setAttribute('aria-label', t('ah.button') + ': ' + (on ? t('ah.on') : t('ah.off')))
1797
+ }
1798
+ function handoffHint() {
1799
+ const st = ahState()
1800
+ if (!st || BOOT.readOnly) return null
1801
+ if (prefs.ahHintAt && Date.now() - prefs.ahHintAt < HINT_QUIET_MS) return null
1802
+ return handoffHintAccount(S.state.accounts, st.settings, st.starters, Date.now())
1803
+ }
1804
+ function handoffForm() {
1805
+ const st = ahState()
1806
+ const s = st.settings
1807
+ const orders = {}
1808
+ for (const f of st.starters) {
1809
+ const cur = s.families[f]
1810
+ orders[f] = { policy: cur && cur.policy === 'order' ? 'order' : 'pressure', order: cur ? cur.order.slice() : [] }
1811
+ }
1812
+ return { auto: s.auto, sameFamily: s.sameFamily, prepareAt: s.prepareAt, threshold: s.threshold, wait: s.waitIfResetWithinMinutes, countdown: s.countdownSeconds, orders }
1813
+ }
1814
+ async function openHandoff() {
1815
+ if (!ahState()) return
1816
+ ah = { form: handoffForm(), loading: true }
1817
+ showDialog('handoff')
1818
+ renderHandoff()
1819
+ await loadHandoffStatus()
1820
+ }
1821
+ async function loadHandoffStatus() {
1822
+ const mine = ah
1823
+ try {
1824
+ const st = await api('/api/handoff/status', {})
1825
+ if (ah === mine) { ah.status = st; ah.loading = false; ah.error = null }
1826
+ } catch (e) { if (ah === mine) { ah.error = e; ah.loading = false } }
1827
+ if (dlg.kind === 'handoff') renderHandoff()
1828
+ }
1829
+ function ahEdit(fn) {
1830
+ fn(ah.form)
1831
+ ah.preview = null
1832
+ ah.nothing = false
1833
+ ah.saved = null
1834
+ renderHandoff()
1835
+ }
1836
+ function ahNumber(key, label, min, max) {
1837
+ const id = 'ah-' + key
1838
+ const input = h('input', { id, type: 'number', min: String(min), max: String(max), step: '1', value: String(ah.form[key]), disabled: BOOT.readOnly })
1839
+ input.addEventListener('change', () => {
1840
+ const n = Math.round(Number(input.value))
1841
+ if (Number.isFinite(n)) ahEdit((f) => { f[key] = Math.min(max, Math.max(min, n)) })
1842
+ })
1843
+ return h('label', { class: 'field', for: id }, h('span', null, label), input)
1844
+ }
1845
+ function ahCheck(key, label, hintText) {
1846
+ const input = h('input', { type: 'checkbox', disabled: BOOT.readOnly })
1847
+ input.checked = Boolean(ah.form[key])
1848
+ input.addEventListener('change', () => ahEdit((f) => { f[key] = input.checked }))
1849
+ 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)))
1850
+ }
1851
+ function accountLabel(ref) {
1852
+ const a = S.state.accounts.find((x) => x.ref === ref || (x.aliases || []).includes(ref))
1853
+ const alias = a && a.aliases && a.aliases[0]
1854
+ return a ? (alias && alias !== ref ? ref + ' (' + alias + ')' : a.ref) : ref
1855
+ }
1856
+ function ahFamily(fam) {
1857
+ const o = ah.form.orders[fam]
1858
+ const f = family(fam)
1859
+ const policy = h('select', { 'aria-label': t('ah.order', { host: f.title }), disabled: BOOT.readOnly },
1860
+ h('option', { value: 'pressure' }, t('ah.byQuota')), h('option', { value: 'order' }, t('ah.myOrder')))
1861
+ policy.value = o.policy
1862
+ policy.addEventListener('change', () => ahEdit((form) => { form.orders[fam].policy = policy.value }))
1863
+ 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))]
1864
+ if (o.policy === 'order') {
1865
+ 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 })
1866
+ kids.push(h('ol', { class: 'ah-order' }, o.order.map((ref, i) => h('li', null,
1867
+ h('span', { class: 'mono' }, (i + 1) + '. ' + accountLabel(ref)),
1868
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: BOOT.readOnly || i === 0, 'aria-label': t('ah.up'), onclick: () => move(i, -1) }, '↑'),
1869
+ 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) }, '↓'),
1870
+ 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'))))))
1871
+ const taken = new Set(o.order)
1872
+ const free = S.state.accounts.filter((a) => !taken.has(a.ref) && !(a.aliases || []).some((n) => taken.has(n)))
1873
+ if (!o.order.length) kids.push(h('p', { class: 'note note-warn' }, t('ah.emptyOrder')))
1874
+ if (free.length && !BOOT.readOnly) {
1875
+ 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))))
1876
+ pick.addEventListener('change', () => { if (pick.value) ahEdit((form) => { form.orders[fam].order.push(pick.value) }) })
1877
+ kids.push(h('div', { class: 'ah-add' }, pick))
1878
+ }
1879
+ } else kids.push(h('p', { class: 'muted small' }, t('ah.byQuotaHint')))
1880
+ return h('div', { class: 'ah-fam' }, kids)
1881
+ }
1882
+ function ahReadiness() {
1883
+ if (ah.loading) return h('p', { class: 'muted' }, spinner(), ' ', t('ah.checking'))
1884
+ if (ah.error) return errorNote(ah.error, loadHandoffStatus)
1885
+ const st = ah.status
1886
+ if (!st) return null
1887
+ const rows = []
1888
+ if (st.sideby.problem) rows.push(h('p', { class: 'note note-fail' }, st.sideby.problem))
1889
+ for (const f of st.families) {
1890
+ 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'))
1891
+ 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),
1892
+ f.problems.map((p) => h('p', { class: 'note note-warn' }, p)),
1893
+ f.notes.map((n) => h('p', { class: 'muted small' }, n))))
1894
+ }
1895
+ 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)))))
1896
+ return rows
1897
+ }
1898
+ function renderHandoff() {
1899
+ const st = ahState()
1900
+ if (!st || !ah) return
1901
+ const f = ah.form
1902
+ const body = [h('p', { class: 'muted small' }, t('ah.intro'))]
1903
+ if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
1904
+ body.push(ahCheck('auto', t('ah.auto'), null))
1905
+ body.push(ahCheck('sameFamily', t('ah.sameFamily'), t('ah.sameFamilyWarn')))
1906
+ body.push(h('div', { class: 'ah-grid' },
1907
+ ahNumber('prepareAt', t('ah.prepareAt'), 1, 99),
1908
+ ahNumber('threshold', t('ah.threshold'), 2, 100),
1909
+ ahNumber('wait', t('ah.wait'), 0, 300),
1910
+ ahNumber('countdown', t('ah.countdown'), 0, 60)))
1911
+ if (f.prepareAt >= f.threshold) body.push(h('p', { class: 'note note-warn' }, t('ah.badLevels')))
1912
+ for (const fam of st.starters) body.push(ahFamily(fam))
1913
+ // The result of Review or Apply sits right under the form, where the button that made it is.
1914
+ if (ah.applyError) body.push(errorNote(ah.applyError, null))
1915
+ if (ah.saved) body.push(h('p', { class: 'note' }, t('ah.saved', { file: ah.saved })))
1916
+ if (ah.nothing) body.push(h('p', { class: 'muted small' }, t('ah.nothing')))
1917
+ if (ah.preview) body.push(h('p', { class: 'small' }, tx('ah.changes', { file: h('code', null, ah.preview.file) })), diffView(ah.preview.diff))
1918
+ body.push(h('h3', { class: 'small' }, t('ah.readiness')))
1919
+ body.push(ahReadiness())
1920
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: ah.applying, onclick: () => dlg.el.close() }, t('btn.close'))]
1921
+ if (!BOOT.readOnly) {
1922
+ const bad = f.prepareAt >= f.threshold
1923
+ 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')))
1924
+ else foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: bad || ah.reviewing, onclick: reviewHandoff }, ah.reviewing ? spinner() : null, t('ah.review')))
1925
+ }
1926
+ setKids(dlg.el, dialogFrame(t('ah.title'), null, body, foot))
1927
+ }
1928
+ async function reviewHandoff() {
1929
+ const change = handoffChangeOf(ahState().settings, ah.form)
1930
+ if (!Object.keys(change).length) { ah.nothing = true; renderHandoff(); return }
1931
+ ah.reviewing = true
1932
+ ah.applyError = null
1933
+ renderHandoff()
1934
+ try {
1935
+ const r = await api('/api/handoff/configure', { change })
1936
+ ah.preview = r.changed ? { change, file: r.file, diff: r.diff } : null
1937
+ ah.nothing = !r.changed
1938
+ } catch (e) { ah.applyError = e }
1939
+ ah.reviewing = false
1940
+ renderHandoff()
1941
+ }
1942
+ async function applyHandoff() {
1943
+ ah.applying = true
1944
+ dlg.busy = true
1945
+ renderHandoff()
1946
+ try {
1947
+ const r = await api('/api/handoff/configure', { change: ah.preview.change, confirm: true })
1948
+ ah.saved = r.file
1949
+ ah.preview = null
1950
+ toast(r.settings.auto ? t('toast.ahOn') : t('toast.ahOff'))
1951
+ await load({ silent: true })
1952
+ if (ahState()) ah.form = handoffForm()
1953
+ ah.loading = true
1954
+ ah.status = null
1955
+ } catch (e) { ah.applyError = e }
1956
+ ah.applying = false
1957
+ dlg.busy = false
1958
+ renderHandoff()
1959
+ if (ah.loading) loadHandoffStatus()
1960
+ }
1961
+
1513
1962
  // ---------- banners ----------
1514
1963
  function renderBanners() {
1515
1964
  const out = []
@@ -1518,6 +1967,12 @@ const MAIN = String.raw `
1518
1967
  h('button', { class: 'btn btn-sm', type: 'button', onclick: () => load({ check: !S.report }) }, t('btn.retry'))))
1519
1968
  if (S.state) {
1520
1969
  if (BOOT.readOnly) out.push(h('div', { class: 'banner banner-quiet' }, h('div', { class: 'muted small' }, t('banner.readOnly'))))
1970
+ const hint = handoffHint()
1971
+ if (hint) out.push(h('div', { class: 'banner', role: 'status' },
1972
+ h('div', null, t('ah.hint', { ref: hint.ref, p: hint.pressure })),
1973
+ h('div', { class: 'banner-actions' },
1974
+ h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openHandoff }, t('ah.setUp')),
1975
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.ahHintAt = Date.now(); savePrefs(); renderBanners() } }, t('ah.dismiss')))))
1521
1976
  const errs = S.state.pluginErrors
1522
1977
  if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
1523
1978
  h('b', null, tn('banner.plugins', errs.length) + ' '), t('banner.pluginsHint'),
@@ -1527,7 +1982,7 @@ const MAIN = String.raw `
1527
1982
  }
1528
1983
 
1529
1984
  // ---------- loading ----------
1530
- function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup() }
1985
+ function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup(); renderPageChrome() }
1531
1986
  // A load that starts while another runs is queued, never dropped, so a change made meanwhile (a new
1532
1987
  // account, a fix) always ends up on screen; and only the newest response is ever rendered. A silent load is a
1533
1988
  // background refresh: no spinner, and its render waits while the viewer is busy (see uiBusy).
@@ -1601,6 +2056,7 @@ const MAIN = String.raw `
1601
2056
  // ---------- start ----------
1602
2057
  $('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
1603
2058
  $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
2059
+ $('handoff').addEventListener('click', openHandoff)
1604
2060
  $('lang').addEventListener('click', () => {
1605
2061
  if (LANG_FIXED) return
1606
2062
  lang = lang === 'zh' ? 'en' : 'zh'
@@ -1611,6 +2067,7 @@ const MAIN = String.raw `
1611
2067
  renderAll()
1612
2068
  if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
1613
2069
  else if (dlg.kind === 'setup' && qs) renderSetup()
2070
+ else if (dlg.kind === 'tune' && tuneState) renderTune()
1614
2071
  })
1615
2072
  $('theme').addEventListener('click', () => {
1616
2073
  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); }