claude-code-hub 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -3,7 +3,7 @@ let apps = {};
3
3
  let keymap = null;
4
4
  // config.json `keys`, {actionId: combo | null}, from /api/config.
5
5
  let userKeys = {};
6
- const { comboOf, keyParts } = ClaudeHubKeys;
6
+ const { comboOf, codeOf, keyParts } = ClaudeHubKeys;
7
7
  let activeApp = null;
8
8
  // The app launcher's cursor starts here, so Ctrl+Alt+A then Enter goes back to the last tab.
9
9
  let previousApp = null;
@@ -305,13 +305,20 @@ async function init() {
305
305
  switchTab(tabs.includes(saved) ? saved : (tabs[0] ?? Object.keys(apps)[0]));
306
306
  listenMessages();
307
307
  listenKeys();
308
+ window.addEventListener('keydown', onShortcutKey, true);
309
+ const keysOverlay = document.getElementById('shortcuts');
310
+ keysOverlay.addEventListener('mousedown', (e) => {
311
+ if (e.target === keysOverlay) closeShortcuts();
312
+ });
308
313
  if (voiceOn) listenVoiceRelease();
309
314
  if (config.voice?.auto && voiceInBrowser()) noteBrowserVoice();
310
315
  bindPalette();
311
316
  bindSettings();
317
+ bindSetup();
312
318
  registerSW();
313
319
  bindErrorToast();
314
320
  showUpdateToast();
321
+ if (!config.onboarding) maybeShowSetup();
315
322
  reloadOnHubRestart();
316
323
  }
317
324
 
@@ -342,6 +349,7 @@ async function showUpdateToast() {
342
349
  } catch {
343
350
  return;
344
351
  }
352
+ openUpdateAfterUpgrade(info);
345
353
  const latest = info.update?.latest;
346
354
  let dismissed = null;
347
355
  try {
@@ -351,6 +359,7 @@ async function showUpdateToast() {
351
359
  const toast = document.getElementById('update-toast');
352
360
  document.getElementById('update-latest').textContent = latest;
353
361
  document.getElementById('update-current').textContent = info.version;
362
+ document.getElementById('update-command').textContent = HUB_UPDATE_COMMAND;
354
363
  document.getElementById('update-notes').href = info.update.url;
355
364
  let timer = 0;
356
365
  const hide = () => {
@@ -361,6 +370,10 @@ async function showUpdateToast() {
361
370
  toast.addEventListener('mouseenter', () => clearTimeout(timer));
362
371
  toast.addEventListener('mouseleave', arm);
363
372
  document.getElementById('update-close').addEventListener('click', hide);
373
+ document.getElementById('update-review').addEventListener('click', () => {
374
+ hide();
375
+ openUpdate();
376
+ });
364
377
  document.getElementById('update-skip').addEventListener('click', () => {
365
378
  try {
366
379
  localStorage.setItem(UPDATE_DISMISSED_KEY, latest);
@@ -371,6 +384,28 @@ async function showUpdateToast() {
371
384
  arm();
372
385
  }
373
386
 
387
+ // A new hub version ships new plugins, so the first load after an upgrade offers to update them.
388
+ const LAST_VERSION_KEY = 'hub-last-version';
389
+
390
+ async function openUpdateAfterUpgrade(info) {
391
+ let last = null;
392
+ try {
393
+ last = localStorage.getItem(LAST_VERSION_KEY);
394
+ localStorage.setItem(LAST_VERSION_KEY, info.version);
395
+ } catch {
396
+ return;
397
+ }
398
+ if (!last || last === info.version) return;
399
+ let data;
400
+ try {
401
+ data = await sendJson('GET', '/api/setup');
402
+ } catch {
403
+ return;
404
+ }
405
+ if (data.pluginsDone || openOverlay) return;
406
+ openSetup(data, 'update', info);
407
+ }
408
+
374
409
  function showToast(toast) {
375
410
  toast.classList.remove('leaving');
376
411
  toast.hidden = false;
@@ -857,10 +892,21 @@ function runVoiceCommand({ text, command = {} }) {
857
892
  showErrorToast("Didn't get that", new Error(`“${text}”`));
858
893
  }
859
894
 
860
- // `voice.dictate`, the one action the hub handles itself: an app's mic gets the provider's text with no command.
895
+ // `voice.dictate`, an action the hub handles itself: an app's mic gets the provider's text with no command.
861
896
  const DICTATE = 'voice.dictate';
897
+ const HUB_UPDATES = 'hub.updates';
862
898
  // With the browser provider an app's mic runs its own browser speech, so the hub does not offer it.
863
899
  const hubDictates = () => voiceOn && !voiceInBrowser();
900
+ const HUB_OWNED = {
901
+ [DICTATE]: { available: hubDictates, run: (params, reply) => dictate(params.op, reply) },
902
+ [HUB_UPDATES]: {
903
+ available: () => true,
904
+ run: (_params, reply) => {
905
+ openUpdate();
906
+ reply({ ok: true, handledBy: 'hub' });
907
+ },
908
+ },
909
+ };
864
910
 
865
911
  async function dictate(op, reply) {
866
912
  const busy = op === 'start' ? voice.state !== 'idle' : voice.state !== 'dictating';
@@ -1112,10 +1158,9 @@ function welcomeFor(data, appId) {
1112
1158
  return {
1113
1159
  type: 'hub:welcome',
1114
1160
  protocol: Math.max(...common),
1115
- forward: Object.keys(bindings()),
1116
- keys: actionKeys(),
1161
+ ...keyFields(),
1117
1162
  themes: hubThemes,
1118
- actions: [...Object.keys(actions), ...(hubDictates() ? [DICTATE] : [])],
1163
+ actions: [...Object.keys(actions), ...Object.keys(HUB_OWNED).filter((id) => HUB_OWNED[id].available())],
1119
1164
  origins: Object.keys(apps).map(originOf),
1120
1165
  settings: apps[appId]?.settings ?? {},
1121
1166
  voice: voiceOn
@@ -1134,6 +1179,19 @@ function sendSettings(appId, list) {
1134
1179
  for (const [win, nested] of nestedApps) if (nested.appId === appId) postNested(win, message);
1135
1180
  }
1136
1181
 
1182
+ function keyFields() {
1183
+ return { forward: Object.keys(bindings()), keys: actionKeys() };
1184
+ }
1185
+
1186
+ // The user's keys after a save in the shortcuts modal, applied without a restart.
1187
+ function setUserKeys(keys) {
1188
+ userKeys = keys ?? {};
1189
+ keymap = null;
1190
+ const message = { type: 'hub:keys', ...keyFields() };
1191
+ for (const appId of liveApps.keys()) postTo(appId, message);
1192
+ for (const win of nestedApps.keys()) postNested(win, message);
1193
+ }
1194
+
1137
1195
  const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
1138
1196
 
1139
1197
  function onHello(appId, data) {
@@ -1181,7 +1239,8 @@ function fillUrl(template, params) {
1181
1239
  function onInvoke({ id, action, params = {}, open }, send) {
1182
1240
  if (typeof id !== 'string') return;
1183
1241
  const reply = (result) => send({ type: 'hub:result', id, ...result });
1184
- if (action === DICTATE && hubDictates()) return dictate(params.op, reply);
1242
+ const owned = Object.hasOwn(HUB_OWNED, action) ? HUB_OWNED[action] : null;
1243
+ if (owned?.available()) return owned.run(params, reply);
1185
1244
  const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
1186
1245
  if (!spec) return reply({ ok: false, reason: 'unhandled' });
1187
1246
  if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
@@ -1748,14 +1807,13 @@ function setPaletteHint(message) {
1748
1807
  drawPaletteHint();
1749
1808
  }
1750
1809
 
1751
- // The plugin in the active config dir: its version, or a ⚠ that copies the install command.
1810
+ // The plugin in the active config dir: its version, or a ⚠ that opens the update window.
1752
1811
  function pluginNode(p) {
1753
1812
  const problem = {
1754
1813
  missing: { label: 'plugin not installed', cause: `${p.id} is not installed in this config dir.` },
1755
1814
  disabled: {
1756
1815
  label: `plugin ${p.installed} is off`,
1757
1816
  cause: `${p.id} ${p.installed} is disabled. Enable it with /plugin in Claude Code.`,
1758
- noCopy: true,
1759
1817
  },
1760
1818
  mismatch: {
1761
1819
  label: `plugin ${p.installed}`,
@@ -1767,11 +1825,11 @@ function pluginNode(p) {
1767
1825
  badge.type = 'button';
1768
1826
  badge.title = problem.cause;
1769
1827
  badge.setAttribute('aria-label', problem.cause);
1770
- if (problem.noCopy) return badge;
1771
- badge.title += `\nClick to copy: ${p.installCommand}\nThen restart open sessions.`;
1828
+ if (p.state === 'disabled') return badge;
1829
+ badge.title += '\nClick to update the plugins.';
1772
1830
  badge.addEventListener('click', (e) => {
1773
1831
  e.stopPropagation();
1774
- copyToHint(p.installCommand);
1832
+ openUpdate();
1775
1833
  });
1776
1834
  return badge;
1777
1835
  }
@@ -1952,7 +2010,7 @@ const PALETTE_MODES = {
1952
2010
  };
1953
2011
 
1954
2012
  // The hub settings modal (Ctrl+,). Each change is saved at once; data is the last /api/settings answer.
1955
- const settingsModal = { open: false, data: null, error: null, busy: false };
2013
+ const settingsModal = { open: false, data: null, error: null, busy: false, section: 'general' };
1956
2014
 
1957
2015
  const SWITCH_CHOICES = [
1958
2016
  ['palette', 'Open the list', 'Opens the config-dir list. Pick a dir, or type a path to add one.'],
@@ -2091,6 +2149,25 @@ function renderSettings() {
2091
2149
  manage.addEventListener('click', () => openPalette('configDir'));
2092
2150
  dirs.append(settingRow('Config dirs', manage));
2093
2151
 
2152
+ const hubSec = el('section', 'set-sec');
2153
+ hubSec.append(el('h4', '', 'Hub'));
2154
+ const setup = el('button', '', 'Open…');
2155
+ setup.type = 'button';
2156
+ setup.dataset.focus = 'setup';
2157
+ setup.addEventListener('click', () => openSetup());
2158
+ hubSec.append(
2159
+ settingRow(
2160
+ 'Setup checklist',
2161
+ setup,
2162
+ 'Install the app, start with the system, add config dirs and install plugins.',
2163
+ ),
2164
+ );
2165
+ const updates = el('button', '', 'Check…');
2166
+ updates.type = 'button';
2167
+ updates.dataset.focus = 'updates';
2168
+ updates.addEventListener('click', () => openUpdate());
2169
+ hubSec.append(settingRow('Updates', updates, 'The hub version, and the plugins in every config dir.'));
2170
+
2094
2171
  const appsSec = el('section', 'set-sec');
2095
2172
  appsSec.append(el('h4', '', 'Apps'));
2096
2173
  const table = el('table', 'set-apps');
@@ -2131,24 +2208,66 @@ function renderSettings() {
2131
2208
  '“Launcher only” keeps the app running but gives it no tab or number key; open it from the app launcher. “On demand” starts it the first time you open it.',
2132
2209
  ),
2133
2210
  );
2134
- body.replaceChildren(dirs, appsSec, ...s.apps.filter((a) => a.settings?.length).map(appSettingsSection));
2211
+ const keysSec = el('section', 'set-sec');
2212
+ keysSec.append(el('h4', '', 'Keyboard shortcuts'));
2213
+ const customize = el('button', '', 'Customize…');
2214
+ customize.type = 'button';
2215
+ customize.dataset.focus = 'shortcuts';
2216
+ customize.addEventListener('click', openShortcuts);
2217
+ const custom = Object.keys(s.keys ?? {}).length;
2218
+ keysSec.append(
2219
+ settingRow('Hub keys', customize, custom ? `${custom} changed from the defaults.` : 'All keys are the defaults.'),
2220
+ );
2221
+ const pages = [
2222
+ ['general', 'General', [dirs, hubSec]],
2223
+ ['apps', 'Apps', [appsSec]],
2224
+ ['keys', 'Keyboard', [keysSec]],
2225
+ ...s.apps.filter((a) => a.settings?.length).map((a) => [`app:${a.id}`, a.name, [appSettingsSection(a)]]),
2226
+ ];
2227
+ const current = pages.find(([id]) => id === settingsModal.section) ?? pages[0];
2228
+ settingsModal.section = current[0];
2229
+ const nav = el('nav', 'set-nav');
2230
+ nav.setAttribute('role', 'tablist');
2231
+ nav.setAttribute('aria-orientation', 'vertical');
2232
+ for (const page of pages) {
2233
+ const tab = el('button', '', page[1]);
2234
+ tab.type = 'button';
2235
+ tab.setAttribute('role', 'tab');
2236
+ tab.setAttribute('aria-selected', String(page === current));
2237
+ tab.tabIndex = page === current ? 0 : -1;
2238
+ tab.dataset.focus = 'page';
2239
+ tab.addEventListener('click', () => {
2240
+ settingsModal.section = page[0];
2241
+ renderSettings();
2242
+ });
2243
+ nav.append(tab);
2244
+ }
2245
+ const pane = el('div', 'set-pane');
2246
+ pane.setAttribute('role', 'tabpanel');
2247
+ pane.append(...current[2]);
2248
+ body.replaceChildren(nav, pane);
2135
2249
 
2136
2250
  let status = el('span', '', 'Saved as you change it');
2137
2251
  if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
2138
2252
  else if (s.restartNeeded) status = el('span', 'set-restart', 'Restart the hub to apply app changes');
2139
2253
  const keys = el('span', 'set-keys');
2140
- keys.append(kbd('Tab'), ' move · ', kbd('←'), kbd('→'), ' change');
2254
+ keys.append(kbd('↑'), kbd('↓'), ' page · ', kbd('Tab'), ' move · ', kbd('←'), kbd('→'), ' change');
2141
2255
  foot.replaceChildren(status, keys);
2142
2256
 
2143
2257
  // A save redraws the modal, so put focus back on the control that had it.
2144
2258
  if (focusKey) {
2145
- const target = body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]`);
2259
+ const target =
2260
+ focusKey === 'page'
2261
+ ? nav.querySelector('[aria-selected="true"]')
2262
+ : body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]`);
2146
2263
  // A radio group gets its checked button; a button that became disabled hands focus to its neighbour.
2147
2264
  const focusable = target?.matches('button:not(:disabled)')
2148
2265
  ? target
2149
2266
  : (target?.querySelector('[aria-checked="true"]:not(:disabled)') ??
2150
2267
  target?.parentElement.querySelector('button:not(:disabled)'));
2151
2268
  (focusable ?? document.querySelector('#settings .settings-box')).focus();
2269
+ } else if (document.activeElement?.matches('#settings .settings-box')) {
2270
+ nav.querySelector('[aria-selected="true"]').focus();
2152
2271
  }
2153
2272
  }
2154
2273
 
@@ -2172,23 +2291,557 @@ function appSettingsSection(a) {
2172
2291
  return sec;
2173
2292
  }
2174
2293
 
2294
+ const ACTION_NAMES = {
2295
+ 'hub.projectPicker': 'Project picker',
2296
+ 'hub.configDirPicker': 'Config-dir picker',
2297
+ 'hub.appLauncher': 'App launcher',
2298
+ 'hub.settings': 'Settings',
2299
+ 'hub.prevApp': 'Previous app',
2300
+ 'hub.nextApp': 'Next app',
2301
+ 'hub.appByNumber': 'App by number',
2302
+ 'hub.voice': 'Hold to talk',
2303
+ };
2304
+
2305
+ const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta', 'AltGraph', 'CapsLock', 'Dead', 'Process']);
2306
+
2307
+ const shortcuts = { open: false, recording: null, error: null, busy: false, layout: null };
2308
+
2309
+ const expandCombo = (c) => (c.includes('{n}') ? Array.from({ length: 9 }, (_, i) => c.replace('{n}', i + 1)) : [c]);
2310
+
2311
+ async function openShortcuts() {
2312
+ Object.assign(shortcuts, { open: true, recording: null, error: null });
2313
+ showOverlay('shortcuts', closeShortcuts);
2314
+ renderShortcuts();
2315
+ document.querySelector('#shortcuts .settings-box').focus();
2316
+ try {
2317
+ const [data, layout] = await Promise.all([
2318
+ settingsModal.data ?? sendJson('GET', '/api/settings'),
2319
+ navigator.keyboard?.getLayoutMap?.(),
2320
+ ]);
2321
+ settingsModal.data = data;
2322
+ shortcuts.layout = layout ?? null;
2323
+ } catch (err) {
2324
+ shortcuts.error ??= err.message;
2325
+ }
2326
+ if (shortcuts.open) renderShortcuts();
2327
+ }
2328
+
2329
+ function closeShortcuts() {
2330
+ Object.assign(shortcuts, { open: false, recording: null });
2331
+ hideOverlay('shortcuts');
2332
+ }
2333
+
2334
+ // Capture phase, so a recorded press never reaches the hub's own bindings or the overlay's Escape.
2335
+ function onShortcutKey(e) {
2336
+ if (!shortcuts.open || shortcuts.busy) return;
2337
+ if (e.key === 'Escape') {
2338
+ e.preventDefault();
2339
+ e.stopPropagation();
2340
+ if (shortcuts.recording) {
2341
+ shortcuts.recording = null;
2342
+ renderShortcuts();
2343
+ } else {
2344
+ closeShortcuts();
2345
+ settingsModal.section = 'keys';
2346
+ openSettings();
2347
+ }
2348
+ return;
2349
+ }
2350
+ if (!shortcuts.recording) return;
2351
+ e.preventDefault();
2352
+ e.stopPropagation();
2353
+ if (e.repeat || MODIFIER_KEYS.has(e.key) || e.isComposing) return;
2354
+ recordKey(shortcuts.recording, comboOf(e));
2355
+ }
2356
+
2357
+ function recordKey(id, combo) {
2358
+ const parts = combo.split('+');
2359
+ const fail = (msg) => {
2360
+ shortcuts.error = msg;
2361
+ renderShortcuts();
2362
+ };
2363
+ if (parts.length < 2 || parts.at(-1) === '') return fail('Hold Ctrl, Alt, Shift or ⌘ with the key.');
2364
+ let value = combo;
2365
+ if (id === 'hub.appByNumber') {
2366
+ if (!/\+[1-9]$/.test(combo)) return fail('Press the modifiers with a digit, for example Alt+1.');
2367
+ value = combo.replace(/[1-9]$/, '{n}');
2368
+ }
2369
+ const taken = expandCombo(value);
2370
+ const keeper = settingsModal.data?.keeps?.find((k) => k.combos.some((c) => taken.includes(c)));
2371
+ if (keeper) return fail(`${comboLabel(combo)} is used by ${keeper.name}. Pick another key.`);
2372
+ saveKey(id, value);
2373
+ }
2374
+
2375
+ // value: a combo, null for no key, undefined for the default. Another action that has the combo
2376
+ // loses it, written as null, so the change shows in its row.
2377
+ async function saveKey(id, value) {
2378
+ const next = { ...userKeys };
2379
+ const action = hubActions().find((a) => a.id === id);
2380
+ if (value === undefined || value === action.combo) delete next[id];
2381
+ else next[id] = value;
2382
+ const mine = new Set(expandCombo(value ?? action.combo));
2383
+ for (const other of hubActions()) {
2384
+ const combo = comboFor(other);
2385
+ if (other.id !== id && combo && expandCombo(combo).some((c) => mine.has(c))) next[other.id] = null;
2386
+ }
2387
+ shortcuts.busy = true;
2388
+ try {
2389
+ settingsModal.data = await sendJson('POST', '/api/settings', { keys: next });
2390
+ setUserKeys(settingsModal.data.keys);
2391
+ shortcuts.error = null;
2392
+ } catch (err) {
2393
+ shortcuts.error = err.message;
2394
+ } finally {
2395
+ shortcuts.busy = false;
2396
+ shortcuts.recording = null;
2397
+ }
2398
+ if (shortcuts.open) renderShortcuts();
2399
+ }
2400
+
2401
+ // The combo as the hub names it, plus the character the user's layout prints on that key when it is
2402
+ // not Latin: the hub matches such a key by its US position.
2403
+ function comboNode(combo) {
2404
+ const node = el('span', 'key-combo');
2405
+ node.append(kbd(comboLabel(combo)));
2406
+ const code = codeOf(combo.split('+').at(-1));
2407
+ const local = code && shortcuts.layout?.get(code);
2408
+ if (local && /[^\x20-\x7e]/.test(local)) node.append(el('span', 'key-local', local));
2409
+ return node;
2410
+ }
2411
+
2412
+ function keyButton(text, focus, disabled, onClick) {
2413
+ const b = el('button', '', text);
2414
+ b.type = 'button';
2415
+ b.dataset.focus = focus;
2416
+ b.disabled = disabled || shortcuts.busy;
2417
+ b.addEventListener('click', onClick);
2418
+ return b;
2419
+ }
2420
+
2421
+ function renderShortcuts() {
2422
+ const body = document.getElementById('shortcuts-body');
2423
+ const foot = document.getElementById('shortcuts-foot');
2424
+ const s = settingsModal.data;
2425
+ const focusKey = document.activeElement?.closest?.('[data-focus]')?.dataset.focus;
2426
+
2427
+ const table = el('table', 'set-apps keys-table');
2428
+ const head = el('tr', '');
2429
+ head.append(el('th', '', 'Action'), el('th', '', 'Key'), el('th', '', ''));
2430
+ table.append(head);
2431
+ for (const a of hubActions()) {
2432
+ const combo = comboFor(a);
2433
+ const custom = Object.hasOwn(userKeys, a.id);
2434
+ const tr = el('tr', shortcuts.recording === a.id ? 'recording' : '');
2435
+ const name = el('td', '', ACTION_NAMES[a.id] ?? a.id);
2436
+ if (a.off) name.append(el('span', 'key-tag', 'voice off'));
2437
+ if (custom) name.append(el('span', 'key-tag', 'changed'));
2438
+ const key = el('td', '');
2439
+ if (shortcuts.recording === a.id) key.append(el('span', 'key-prompt', 'Press keys…'));
2440
+ else if (combo) key.append(comboNode(combo));
2441
+ else key.append(el('span', 'set-help', 'No key'));
2442
+ const btns = el('td', 'key-btns');
2443
+ btns.append(
2444
+ keyButton('Change', `rec:${a.id}`, false, () => {
2445
+ shortcuts.recording = a.id;
2446
+ shortcuts.error = null;
2447
+ renderShortcuts();
2448
+ }),
2449
+ keyButton('Reset', `reset:${a.id}`, !custom, () => saveKey(a.id, undefined)),
2450
+ keyButton('Off', `off:${a.id}`, !combo, () => saveKey(a.id, null)),
2451
+ );
2452
+ tr.append(name, key, btns);
2453
+ table.append(tr);
2454
+ }
2455
+ const hubSec = el('section', 'set-sec');
2456
+ hubSec.append(el('h4', '', 'Hub'), table);
2457
+ for (const line of s?.keyWarnings ?? []) hubSec.append(el('span', 'set-help set-error', line));
2458
+ hubSec.append(
2459
+ el(
2460
+ 'span',
2461
+ 'set-help',
2462
+ 'Keys match the character on the key. On a layout with no Latin letters, they match the key in the same place on a US keyboard.',
2463
+ ),
2464
+ );
2465
+
2466
+ const sections = [hubSec];
2467
+ for (const app of s?.keeps ?? []) {
2468
+ const sec = el('section', 'set-sec');
2469
+ sec.append(el('h4', '', `${app.name} keeps`));
2470
+ const list = el('div', 'key-keeps');
2471
+ for (const c of app.combos) list.append(comboNode(c));
2472
+ sec.append(list);
2473
+ sections.push(sec);
2474
+ }
2475
+ body.replaceChildren(...sections);
2476
+
2477
+ const status = shortcuts.error
2478
+ ? el('span', 'set-error', shortcuts.error)
2479
+ : el('span', '', shortcuts.recording ? 'Press the new key, Esc cancels' : 'Saved as you change it');
2480
+ const hints = el('span', 'set-keys');
2481
+ hints.append(kbd('Tab'), ' move · ', kbd('Esc'), ' back');
2482
+ foot.replaceChildren(status, hints);
2483
+
2484
+ if (focusKey) {
2485
+ const target = body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]:not(:disabled)`);
2486
+ (target ?? document.querySelector('#shortcuts .settings-box')).focus();
2487
+ }
2488
+ }
2489
+
2175
2490
  function kbd(text) {
2176
2491
  return el('kbd', '', text);
2177
2492
  }
2178
2493
 
2494
+ // Chrome and Edge fire this once per load, only while the page can be installed and is not yet.
2495
+ let installPrompt = null;
2496
+ window.addEventListener('beforeinstallprompt', (e) => {
2497
+ e.preventDefault();
2498
+ installPrompt = e;
2499
+ if (setupModal.open) renderSetup();
2500
+ });
2501
+
2502
+ const setupModal = {
2503
+ open: false,
2504
+ mode: 'setup',
2505
+ data: null,
2506
+ update: null,
2507
+ updatedDirs: new Set(),
2508
+ error: null,
2509
+ busy: null,
2510
+ output: null,
2511
+ };
2512
+ const isInstalledApp = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
2513
+ const IS_SAFARI = /^((?!chrome|android|edg).)*safari/i.test(navigator.userAgent);
2514
+
2515
+ // Every step is read from the machine each time, so a step done by hand shows as done. Only
2516
+ // finishing or skipping is saved.
2517
+ function setupSteps(s) {
2518
+ if (setupModal.mode === 'update')
2519
+ return [
2520
+ { id: 'hub', done: !setupModal.update?.update },
2521
+ { id: 'plugins', done: s.pluginsDone },
2522
+ ];
2523
+ const steps = [{ id: 'app', done: isInstalledApp() }];
2524
+ if (s.tray.supported) steps.push({ id: 'tray', done: s.tray.autostart });
2525
+ steps.push({ id: 'dirs', done: s.candidates.length === 0 }, { id: 'plugins', done: s.pluginsDone });
2526
+ return steps;
2527
+ }
2528
+
2529
+ async function maybeShowSetup() {
2530
+ let data;
2531
+ try {
2532
+ data = await sendJson('GET', '/api/setup');
2533
+ } catch {
2534
+ return;
2535
+ }
2536
+ if (setupSteps(data).every((st) => st.done)) {
2537
+ sendJson('POST', '/api/setup', { onboarding: 'done' }).catch(() => {});
2538
+ return;
2539
+ }
2540
+ if (openOverlay) return;
2541
+ openSetup(data);
2542
+ }
2543
+
2544
+ async function openSetup(data = null, mode = 'setup', update = null) {
2545
+ setupModal.open = true;
2546
+ setupModal.mode = mode;
2547
+ setupModal.error = null;
2548
+ setupModal.output = null;
2549
+ setupModal.updatedDirs.clear();
2550
+ setupModal.data = data;
2551
+ setupModal.update = update;
2552
+ showOverlay('setup', closeSetup);
2553
+ renderSetup();
2554
+ document.querySelector('#setup .setup-box').focus();
2555
+ const needUpdate = mode === 'update' && !update;
2556
+ if (data && !needUpdate) return;
2557
+ try {
2558
+ const [setup, info] = await Promise.all([
2559
+ data ?? sendJson('GET', '/api/setup'),
2560
+ needUpdate ? sendJson('GET', '/api/update') : update,
2561
+ ]);
2562
+ setupModal.update = info;
2563
+ setupModal.data = setup;
2564
+ } catch (err) {
2565
+ setupModal.error = err.message;
2566
+ }
2567
+ if (setupModal.open) renderSetup();
2568
+ }
2569
+
2570
+ function openUpdate() {
2571
+ return openSetup(null, 'update');
2572
+ }
2573
+
2574
+ function closeSetup() {
2575
+ setupModal.open = false;
2576
+ hideOverlay('setup');
2577
+ }
2578
+
2579
+ async function endSetup(value) {
2580
+ try {
2581
+ await sendJson('POST', '/api/setup', { onboarding: value });
2582
+ } catch {}
2583
+ closeSetup();
2584
+ }
2585
+
2586
+ async function setupAction(key, fn) {
2587
+ if (setupModal.busy) return;
2588
+ setupModal.busy = key;
2589
+ setupModal.error = null;
2590
+ setupModal.output = null;
2591
+ renderSetup();
2592
+ try {
2593
+ const r = await fn();
2594
+ if (r?.setup) setupModal.data = r.setup;
2595
+ noteUpdatedDirs(r?.results);
2596
+ } catch (err) {
2597
+ setupModal.error = err.message;
2598
+ setupModal.output = err.data?.output ? { step: key.split(':')[0], text: err.data.output } : null;
2599
+ if (err.data?.setup) setupModal.data = err.data.setup;
2600
+ noteUpdatedDirs(err.data?.results);
2601
+ } finally {
2602
+ setupModal.busy = null;
2603
+ }
2604
+ if (setupModal.open) renderSetup();
2605
+ }
2606
+
2607
+ function noteUpdatedDirs(results) {
2608
+ for (const r of results ?? []) if (r.ok) setupModal.updatedDirs.add(r.dir);
2609
+ }
2610
+
2611
+ function setupButton(text, key, fn, primary = false) {
2612
+ const b = el('button', primary ? 'primary' : '', setupModal.busy === key ? 'Working…' : text);
2613
+ b.type = 'button';
2614
+ b.disabled = !!setupModal.busy;
2615
+ b.dataset.focus = key;
2616
+ b.addEventListener('click', () => setupAction(key, fn));
2617
+ return b;
2618
+ }
2619
+
2620
+ function installStep(step) {
2621
+ const action =
2622
+ installPrompt && !step.done
2623
+ ? setupButton(
2624
+ 'Install',
2625
+ 'app',
2626
+ async () => {
2627
+ const prompt = installPrompt;
2628
+ installPrompt = null;
2629
+ await prompt.prompt();
2630
+ await prompt.userChoice;
2631
+ },
2632
+ true,
2633
+ )
2634
+ : null;
2635
+ let desc = 'The hub runs in its own window, with no browser tabs or address bar.';
2636
+ if (step.done) desc = 'The hub runs as an installed app.';
2637
+ else if (!installPrompt)
2638
+ desc += IS_SAFARI
2639
+ ? ' In Safari, choose File → Add to Dock.'
2640
+ : ' Use the install icon in the address bar, or the browser menu → Install. Already installed? Open it from the Start menu or Applications.';
2641
+ return { title: 'Install as an app', desc, action };
2642
+ }
2643
+
2644
+ function trayStep(step, s) {
2645
+ const where = IS_MAC ? 'menu bar' : 'notification area';
2646
+ const desc = step.done
2647
+ ? `Starts when you sign in. Tray icon: ${s.tray.running ? 'running' : 'not running'}.`
2648
+ : `Adds an icon to the ${where} and starts the hub when you sign in.`;
2649
+ const action = step.done ? null : setupButton('Turn on', 'tray', () => sendJson('POST', '/api/setup/tray'), true);
2650
+ return { title: 'Autostart', tag: IS_MAC ? 'experimental' : null, desc, action };
2651
+ }
2652
+
2653
+ function dirsStep(step, s) {
2654
+ const list = el('div', 'setup-list');
2655
+ for (const d of s.dirs) {
2656
+ const item = el('div', 'setup-item');
2657
+ item.append(el('code', '', d.path), el('span', 'setup-ok', d.active ? 'active' : 'added'));
2658
+ list.append(item);
2659
+ }
2660
+ for (const path of s.candidates) {
2661
+ const item = el('div', 'setup-item');
2662
+ const add = setupButton('Add', `dir:${path}`, async () => {
2663
+ await sendJson('POST', '/api/config-dirs', { path });
2664
+ return { setup: await sendJson('GET', '/api/setup') };
2665
+ });
2666
+ item.append(el('code', '', path), add);
2667
+ list.append(item);
2668
+ }
2669
+ const desc = step.done
2670
+ ? 'The hub lists every Claude config dir it found. Switch dirs with the config-dir key.'
2671
+ : 'Found more Claude config dirs. Each dir you add gets its own Kanban, Cost and Memory.';
2672
+ return { title: 'Choose Claude config dirs', desc, extra: list };
2673
+ }
2674
+
2675
+ const HUB_UPDATE_COMMAND = 'npm i -g claude-code-hub@latest';
2676
+
2677
+ function hubStep(step) {
2678
+ const u = setupModal.update;
2679
+ if (step.done)
2680
+ return { title: 'Update the hub', desc: `Version ${u?.version ?? '?'}. No newer version found on npm.` };
2681
+ const item = el('div', 'setup-item');
2682
+ const copy = el('button', 'primary', 'Copy');
2683
+ copy.type = 'button';
2684
+ copy.dataset.focus = 'copy';
2685
+ copy.addEventListener('click', async () => {
2686
+ try {
2687
+ await navigator.clipboard.writeText(HUB_UPDATE_COMMAND);
2688
+ copy.textContent = 'Copied';
2689
+ } catch {
2690
+ copy.textContent = 'Copy failed';
2691
+ }
2692
+ });
2693
+ const notes = el('a', '', 'Release notes');
2694
+ notes.href = u.update.url;
2695
+ notes.target = '_blank';
2696
+ notes.rel = 'noopener';
2697
+ item.append(el('code', '', HUB_UPDATE_COMMAND), copy, notes);
2698
+ const list = el('div', 'setup-list');
2699
+ list.append(item);
2700
+ return {
2701
+ title: 'Update the hub',
2702
+ tag: `${u.version} → ${u.update.latest}`,
2703
+ desc: 'Stop the hub, run this command, then start the hub again. It also updates Kanban, Cost, Marketplace and Memory.',
2704
+ extra: list,
2705
+ };
2706
+ }
2707
+
2708
+ function pluginNote(p) {
2709
+ if (p.state === 'mismatch') return `${p.name}: ${p.installed} → ${p.bundled}`;
2710
+ return `${p.name}: ${p.state === 'missing' ? 'not installed' : 'turned off'}`;
2711
+ }
2712
+
2713
+ function pluginsStep(step, s) {
2714
+ const update = setupModal.mode === 'update';
2715
+ const list = el('div', 'setup-list');
2716
+ for (const d of s.dirs) {
2717
+ const item = el('div', 'setup-item');
2718
+ const right = d.pending
2719
+ ? setupButton(update ? 'Update' : 'Install', `plugins:${d.path}`, () =>
2720
+ sendJson('POST', '/api/setup/plugins', { dir: d.path }),
2721
+ )
2722
+ : el('span', 'setup-ok good', update ? '✓ up to date' : '✓ installed');
2723
+ item.append(el('code', '', d.path), right);
2724
+ const notes = d.plugins.filter((p) => p.state !== 'ok').map(pluginNote);
2725
+ if (notes.length) item.append(el('span', 'set-help', notes.join(' · ')));
2726
+ list.append(item);
2727
+ }
2728
+ if (setupModal.updatedDirs.size)
2729
+ list.append(
2730
+ el(
2731
+ 'div',
2732
+ 'setup-notice',
2733
+ `Restart open Claude sessions in these dirs to load the new plugins: ${[...setupModal.updatedDirs].join(', ')}`,
2734
+ ),
2735
+ );
2736
+ const action =
2737
+ s.dirs.filter((d) => d.pending).length > 1
2738
+ ? setupButton(
2739
+ update ? 'Update all dirs' : 'Install in all dirs',
2740
+ 'plugins:all',
2741
+ () => sendJson('POST', '/api/setup/plugins', { all: true }),
2742
+ true,
2743
+ )
2744
+ : null;
2745
+ return {
2746
+ title: update ? 'Update plugins' : 'Install plugins',
2747
+ desc: update
2748
+ ? 'The hub plugin and the app plugins in every config dir. A dir that fails does not stop the others.'
2749
+ : 'Kanban shows live agent activity only in a dir with its plugin. Open sessions keep the old plugin until they restart.',
2750
+ action,
2751
+ extra: list,
2752
+ };
2753
+ }
2754
+
2755
+ const SETUP_STEP_VIEWS = { app: installStep, tray: trayStep, dirs: dirsStep, plugins: pluginsStep, hub: hubStep };
2756
+
2757
+ const SETUP_MODES = {
2758
+ setup: {
2759
+ title: 'Welcome to Claude Code Hub',
2760
+ sub: 'A few quick steps make the hub feel like a desktop app.',
2761
+ status: 'Skip any step. Open this again from Hub settings.',
2762
+ end: (allDone) => [allDone ? 'Finish' : 'Skip setup', () => endSetup(allDone ? 'done' : 'skipped')],
2763
+ },
2764
+ update: {
2765
+ title: 'Updates',
2766
+ sub: 'Nothing changes until you click. Restart the hub by hand.',
2767
+ status: 'Open this again from Hub settings → Updates.',
2768
+ end: () => ['Close', closeSetup],
2769
+ },
2770
+ };
2771
+
2772
+ function renderSetup() {
2773
+ const body = document.getElementById('setup-body');
2774
+ const foot = document.getElementById('setup-foot');
2775
+ const s = setupModal.data;
2776
+ const focusKey = document.activeElement?.closest?.('[data-focus]')?.dataset.focus;
2777
+ const mode = SETUP_MODES[setupModal.mode];
2778
+ document.getElementById('setup-title').textContent = mode.title;
2779
+ document.getElementById('setup-sub').textContent = mode.sub;
2780
+ if (!s) {
2781
+ body.replaceChildren(el('div', 'set-help', setupModal.error ?? 'Loading…'));
2782
+ foot.replaceChildren();
2783
+ return;
2784
+ }
2785
+ const steps = setupSteps(s);
2786
+ const doneCount = steps.filter((st) => st.done).length;
2787
+ const current = steps.find((st) => !st.done);
2788
+ const meter = el('div', 'setup-meter');
2789
+ const lead = el('span', 'setup-lead');
2790
+ lead.append(el('b', '', `${doneCount} of ${steps.length}`), ' done');
2791
+ const bar = el('div', 'setup-progress');
2792
+ const fill = el('i', '');
2793
+ fill.style.width = `${(doneCount / steps.length) * 100}%`;
2794
+ bar.append(fill);
2795
+ meter.append(lead, bar);
2796
+ const list = el('ol', 'setup-steps');
2797
+ steps.forEach((st, i) => {
2798
+ const view = SETUP_STEP_VIEWS[st.id](st, s);
2799
+ const li = el('li', `setup-step${st.done ? ' done' : ''}${st === current ? ' current' : ''}`);
2800
+ const title = el('span', 'setup-title', view.title);
2801
+ if (view.tag) title.append(el('span', 'setup-tag', view.tag));
2802
+ li.append(el('span', 'setup-mark', st.done ? '✓' : String(i + 1)), title, view.action ?? el('span', ''));
2803
+ li.append(el('span', 'setup-desc', view.desc));
2804
+ if (view.extra) li.append(view.extra);
2805
+ if (setupModal.output?.step === st.id) li.append(el('pre', 'setup-out', setupModal.output.text));
2806
+ list.append(li);
2807
+ });
2808
+ body.replaceChildren(meter, list);
2809
+
2810
+ const status = setupModal.error ? el('span', 'set-error', setupModal.error) : el('span', 'set-keys', mode.status);
2811
+ const allDone = doneCount === steps.length;
2812
+ const [endText, onEnd] = mode.end(allDone);
2813
+ const end = el('button', allDone ? 'primary' : '', endText);
2814
+ end.type = 'button';
2815
+ end.dataset.focus = 'end';
2816
+ end.addEventListener('click', () => onEnd());
2817
+ foot.replaceChildren(status, end);
2818
+
2819
+ if (focusKey) {
2820
+ const target = document.querySelector(`#setup [data-focus="${CSS.escape(focusKey)}"]:not(:disabled)`);
2821
+ (target ?? document.querySelector('#setup .setup-box')).focus();
2822
+ }
2823
+ }
2824
+
2825
+ function bindSetup() {
2826
+ const overlay = document.getElementById('setup');
2827
+ overlay.addEventListener('mousedown', (e) => {
2828
+ if (e.target === overlay) closeSetup();
2829
+ });
2830
+ matchMedia('(display-mode: standalone)').addEventListener('change', () => setupModal.open && renderSetup());
2831
+ }
2832
+
2179
2833
  function bindSettings() {
2180
2834
  const overlay = document.getElementById('settings');
2181
2835
  overlay.addEventListener('mousedown', (e) => {
2182
2836
  if (e.target === overlay) closeSettings();
2183
2837
  });
2184
2838
  overlay.addEventListener('keydown', (e) => {
2185
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
2186
- const group = e.target.closest('[role="radiogroup"]');
2187
- if (!group) return;
2839
+ const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -1, ArrowDown: 1 }[e.key];
2840
+ const group = step && e.target.closest('[role="radiogroup"], [role="tablist"]');
2841
+ if (!group || (group.getAttribute('role') === 'tablist') !== (e.key === 'ArrowUp' || e.key === 'ArrowDown')) return;
2188
2842
  e.preventDefault();
2189
- const radios = [...group.querySelectorAll('[role="radio"]:not(:disabled)')];
2190
- const i = radios.indexOf(e.target);
2191
- radios[i + (e.key === 'ArrowRight' ? 1 : -1)]?.click();
2843
+ const items = [...group.querySelectorAll('[role="radio"]:not(:disabled), [role="tab"]')];
2844
+ items[items.indexOf(e.target) + step]?.click();
2192
2845
  });
2193
2846
  }
2194
2847