claude-code-hub 1.34.0 → 1.36.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';
@@ -1065,8 +1111,9 @@ function addIframe(id) {
1065
1111
  document.getElementById('iframe-container').appendChild(iframes[id]);
1066
1112
  }
1067
1113
 
1114
+ // A deferredStart app's first page load starts it, so its frame waits until the tab opens.
1068
1115
  function buildIframes() {
1069
- for (const id of tabIds()) addIframe(id);
1116
+ for (const id of tabIds()) if (!apps[id].deferredStart) addIframe(id);
1070
1117
  }
1071
1118
 
1072
1119
  const TAB_KEY = 'hub-tab';
@@ -1112,10 +1159,9 @@ function welcomeFor(data, appId) {
1112
1159
  return {
1113
1160
  type: 'hub:welcome',
1114
1161
  protocol: Math.max(...common),
1115
- forward: Object.keys(bindings()),
1116
- keys: actionKeys(),
1162
+ ...keyFields(),
1117
1163
  themes: hubThemes,
1118
- actions: [...Object.keys(actions), ...(hubDictates() ? [DICTATE] : [])],
1164
+ actions: [...Object.keys(actions), ...Object.keys(HUB_OWNED).filter((id) => HUB_OWNED[id].available())],
1119
1165
  origins: Object.keys(apps).map(originOf),
1120
1166
  settings: apps[appId]?.settings ?? {},
1121
1167
  voice: voiceOn
@@ -1134,6 +1180,19 @@ function sendSettings(appId, list) {
1134
1180
  for (const [win, nested] of nestedApps) if (nested.appId === appId) postNested(win, message);
1135
1181
  }
1136
1182
 
1183
+ function keyFields() {
1184
+ return { forward: Object.keys(bindings()), keys: actionKeys() };
1185
+ }
1186
+
1187
+ // The user's keys after a save in the shortcuts modal, applied without a restart.
1188
+ function setUserKeys(keys) {
1189
+ userKeys = keys ?? {};
1190
+ keymap = null;
1191
+ const message = { type: 'hub:keys', ...keyFields() };
1192
+ for (const appId of liveApps.keys()) postTo(appId, message);
1193
+ for (const win of nestedApps.keys()) postNested(win, message);
1194
+ }
1195
+
1137
1196
  const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
1138
1197
 
1139
1198
  function onHello(appId, data) {
@@ -1181,7 +1240,8 @@ function fillUrl(template, params) {
1181
1240
  function onInvoke({ id, action, params = {}, open }, send) {
1182
1241
  if (typeof id !== 'string') return;
1183
1242
  const reply = (result) => send({ type: 'hub:result', id, ...result });
1184
- if (action === DICTATE && hubDictates()) return dictate(params.op, reply);
1243
+ const owned = Object.hasOwn(HUB_OWNED, action) ? HUB_OWNED[action] : null;
1244
+ if (owned?.available()) return owned.run(params, reply);
1185
1245
  const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
1186
1246
  if (!spec) return reply({ ok: false, reason: 'unhandled' });
1187
1247
  if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
@@ -1597,6 +1657,19 @@ const APP_ICONS = {
1597
1657
  ['path', { d: 'M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3' }],
1598
1658
  ['path', { d: 'M12 12V8' }],
1599
1659
  ],
1660
+ 'notebook-pen': [
1661
+ ['path', { d: 'M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4' }],
1662
+ ['path', { d: 'M2 6h4' }],
1663
+ ['path', { d: 'M2 10h4' }],
1664
+ ['path', { d: 'M2 14h4' }],
1665
+ ['path', { d: 'M2 18h4' }],
1666
+ [
1667
+ 'path',
1668
+ {
1669
+ d: 'M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z',
1670
+ },
1671
+ ],
1672
+ ],
1600
1673
  puzzle: [
1601
1674
  [
1602
1675
  'path',
@@ -1748,14 +1821,13 @@ function setPaletteHint(message) {
1748
1821
  drawPaletteHint();
1749
1822
  }
1750
1823
 
1751
- // The plugin in the active config dir: its version, or a ⚠ that copies the install command.
1824
+ // The plugin in the active config dir: its version, or a ⚠ that opens the update window.
1752
1825
  function pluginNode(p) {
1753
1826
  const problem = {
1754
1827
  missing: { label: 'plugin not installed', cause: `${p.id} is not installed in this config dir.` },
1755
1828
  disabled: {
1756
1829
  label: `plugin ${p.installed} is off`,
1757
1830
  cause: `${p.id} ${p.installed} is disabled. Enable it with /plugin in Claude Code.`,
1758
- noCopy: true,
1759
1831
  },
1760
1832
  mismatch: {
1761
1833
  label: `plugin ${p.installed}`,
@@ -1767,11 +1839,11 @@ function pluginNode(p) {
1767
1839
  badge.type = 'button';
1768
1840
  badge.title = problem.cause;
1769
1841
  badge.setAttribute('aria-label', problem.cause);
1770
- if (problem.noCopy) return badge;
1771
- badge.title += `\nClick to copy: ${p.installCommand}\nThen restart open sessions.`;
1842
+ if (p.state === 'disabled') return badge;
1843
+ badge.title += '\nClick to update the plugins.';
1772
1844
  badge.addEventListener('click', (e) => {
1773
1845
  e.stopPropagation();
1774
- copyToHint(p.installCommand);
1846
+ openUpdate();
1775
1847
  });
1776
1848
  return badge;
1777
1849
  }
@@ -1952,7 +2024,7 @@ const PALETTE_MODES = {
1952
2024
  };
1953
2025
 
1954
2026
  // 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 };
2027
+ const settingsModal = { open: false, data: null, error: null, busy: false, section: 'general' };
1956
2028
 
1957
2029
  const SWITCH_CHOICES = [
1958
2030
  ['palette', 'Open the list', 'Opens the config-dir list. Pick a dir, or type a path to add one.'],
@@ -2091,6 +2163,25 @@ function renderSettings() {
2091
2163
  manage.addEventListener('click', () => openPalette('configDir'));
2092
2164
  dirs.append(settingRow('Config dirs', manage));
2093
2165
 
2166
+ const hubSec = el('section', 'set-sec');
2167
+ hubSec.append(el('h4', '', 'Hub'));
2168
+ const setup = el('button', '', 'Open…');
2169
+ setup.type = 'button';
2170
+ setup.dataset.focus = 'setup';
2171
+ setup.addEventListener('click', () => openSetup());
2172
+ hubSec.append(
2173
+ settingRow(
2174
+ 'Setup checklist',
2175
+ setup,
2176
+ 'Install the app, start with the system, add config dirs and install plugins.',
2177
+ ),
2178
+ );
2179
+ const updates = el('button', '', 'Check…');
2180
+ updates.type = 'button';
2181
+ updates.dataset.focus = 'updates';
2182
+ updates.addEventListener('click', () => openUpdate());
2183
+ hubSec.append(settingRow('Updates', updates, 'The hub version, and the plugins in every config dir.'));
2184
+
2094
2185
  const appsSec = el('section', 'set-sec');
2095
2186
  appsSec.append(el('h4', '', 'Apps'));
2096
2187
  const table = el('table', 'set-apps');
@@ -2131,24 +2222,66 @@ function renderSettings() {
2131
2222
  '“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
2223
  ),
2133
2224
  );
2134
- body.replaceChildren(dirs, appsSec, ...s.apps.filter((a) => a.settings?.length).map(appSettingsSection));
2225
+ const keysSec = el('section', 'set-sec');
2226
+ keysSec.append(el('h4', '', 'Keyboard shortcuts'));
2227
+ const customize = el('button', '', 'Customize…');
2228
+ customize.type = 'button';
2229
+ customize.dataset.focus = 'shortcuts';
2230
+ customize.addEventListener('click', openShortcuts);
2231
+ const custom = Object.keys(s.keys ?? {}).length;
2232
+ keysSec.append(
2233
+ settingRow('Hub keys', customize, custom ? `${custom} changed from the defaults.` : 'All keys are the defaults.'),
2234
+ );
2235
+ const pages = [
2236
+ ['general', 'General', [dirs, hubSec]],
2237
+ ['apps', 'Apps', [appsSec]],
2238
+ ['keys', 'Keyboard', [keysSec]],
2239
+ ...s.apps.filter((a) => a.settings?.length).map((a) => [`app:${a.id}`, a.name, [appSettingsSection(a)]]),
2240
+ ];
2241
+ const current = pages.find(([id]) => id === settingsModal.section) ?? pages[0];
2242
+ settingsModal.section = current[0];
2243
+ const nav = el('nav', 'set-nav');
2244
+ nav.setAttribute('role', 'tablist');
2245
+ nav.setAttribute('aria-orientation', 'vertical');
2246
+ for (const page of pages) {
2247
+ const tab = el('button', '', page[1]);
2248
+ tab.type = 'button';
2249
+ tab.setAttribute('role', 'tab');
2250
+ tab.setAttribute('aria-selected', String(page === current));
2251
+ tab.tabIndex = page === current ? 0 : -1;
2252
+ tab.dataset.focus = 'page';
2253
+ tab.addEventListener('click', () => {
2254
+ settingsModal.section = page[0];
2255
+ renderSettings();
2256
+ });
2257
+ nav.append(tab);
2258
+ }
2259
+ const pane = el('div', 'set-pane');
2260
+ pane.setAttribute('role', 'tabpanel');
2261
+ pane.append(...current[2]);
2262
+ body.replaceChildren(nav, pane);
2135
2263
 
2136
2264
  let status = el('span', '', 'Saved as you change it');
2137
2265
  if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
2138
2266
  else if (s.restartNeeded) status = el('span', 'set-restart', 'Restart the hub to apply app changes');
2139
2267
  const keys = el('span', 'set-keys');
2140
- keys.append(kbd('Tab'), ' move · ', kbd('←'), kbd('→'), ' change');
2268
+ keys.append(kbd('↑'), kbd('↓'), ' page · ', kbd('Tab'), ' move · ', kbd('←'), kbd('→'), ' change');
2141
2269
  foot.replaceChildren(status, keys);
2142
2270
 
2143
2271
  // A save redraws the modal, so put focus back on the control that had it.
2144
2272
  if (focusKey) {
2145
- const target = body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]`);
2273
+ const target =
2274
+ focusKey === 'page'
2275
+ ? nav.querySelector('[aria-selected="true"]')
2276
+ : body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]`);
2146
2277
  // A radio group gets its checked button; a button that became disabled hands focus to its neighbour.
2147
2278
  const focusable = target?.matches('button:not(:disabled)')
2148
2279
  ? target
2149
2280
  : (target?.querySelector('[aria-checked="true"]:not(:disabled)') ??
2150
2281
  target?.parentElement.querySelector('button:not(:disabled)'));
2151
2282
  (focusable ?? document.querySelector('#settings .settings-box')).focus();
2283
+ } else if (document.activeElement?.matches('#settings .settings-box')) {
2284
+ nav.querySelector('[aria-selected="true"]').focus();
2152
2285
  }
2153
2286
  }
2154
2287
 
@@ -2172,23 +2305,557 @@ function appSettingsSection(a) {
2172
2305
  return sec;
2173
2306
  }
2174
2307
 
2308
+ const ACTION_NAMES = {
2309
+ 'hub.projectPicker': 'Project picker',
2310
+ 'hub.configDirPicker': 'Config-dir picker',
2311
+ 'hub.appLauncher': 'App launcher',
2312
+ 'hub.settings': 'Settings',
2313
+ 'hub.prevApp': 'Previous app',
2314
+ 'hub.nextApp': 'Next app',
2315
+ 'hub.appByNumber': 'App by number',
2316
+ 'hub.voice': 'Hold to talk',
2317
+ };
2318
+
2319
+ const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta', 'AltGraph', 'CapsLock', 'Dead', 'Process']);
2320
+
2321
+ const shortcuts = { open: false, recording: null, error: null, busy: false, layout: null };
2322
+
2323
+ const expandCombo = (c) => (c.includes('{n}') ? Array.from({ length: 9 }, (_, i) => c.replace('{n}', i + 1)) : [c]);
2324
+
2325
+ async function openShortcuts() {
2326
+ Object.assign(shortcuts, { open: true, recording: null, error: null });
2327
+ showOverlay('shortcuts', closeShortcuts);
2328
+ renderShortcuts();
2329
+ document.querySelector('#shortcuts .settings-box').focus();
2330
+ try {
2331
+ const [data, layout] = await Promise.all([
2332
+ settingsModal.data ?? sendJson('GET', '/api/settings'),
2333
+ navigator.keyboard?.getLayoutMap?.(),
2334
+ ]);
2335
+ settingsModal.data = data;
2336
+ shortcuts.layout = layout ?? null;
2337
+ } catch (err) {
2338
+ shortcuts.error ??= err.message;
2339
+ }
2340
+ if (shortcuts.open) renderShortcuts();
2341
+ }
2342
+
2343
+ function closeShortcuts() {
2344
+ Object.assign(shortcuts, { open: false, recording: null });
2345
+ hideOverlay('shortcuts');
2346
+ }
2347
+
2348
+ // Capture phase, so a recorded press never reaches the hub's own bindings or the overlay's Escape.
2349
+ function onShortcutKey(e) {
2350
+ if (!shortcuts.open || shortcuts.busy) return;
2351
+ if (e.key === 'Escape') {
2352
+ e.preventDefault();
2353
+ e.stopPropagation();
2354
+ if (shortcuts.recording) {
2355
+ shortcuts.recording = null;
2356
+ renderShortcuts();
2357
+ } else {
2358
+ closeShortcuts();
2359
+ settingsModal.section = 'keys';
2360
+ openSettings();
2361
+ }
2362
+ return;
2363
+ }
2364
+ if (!shortcuts.recording) return;
2365
+ e.preventDefault();
2366
+ e.stopPropagation();
2367
+ if (e.repeat || MODIFIER_KEYS.has(e.key) || e.isComposing) return;
2368
+ recordKey(shortcuts.recording, comboOf(e));
2369
+ }
2370
+
2371
+ function recordKey(id, combo) {
2372
+ const parts = combo.split('+');
2373
+ const fail = (msg) => {
2374
+ shortcuts.error = msg;
2375
+ renderShortcuts();
2376
+ };
2377
+ if (parts.length < 2 || parts.at(-1) === '') return fail('Hold Ctrl, Alt, Shift or ⌘ with the key.');
2378
+ let value = combo;
2379
+ if (id === 'hub.appByNumber') {
2380
+ if (!/\+[1-9]$/.test(combo)) return fail('Press the modifiers with a digit, for example Alt+1.');
2381
+ value = combo.replace(/[1-9]$/, '{n}');
2382
+ }
2383
+ const taken = expandCombo(value);
2384
+ const keeper = settingsModal.data?.keeps?.find((k) => k.combos.some((c) => taken.includes(c)));
2385
+ if (keeper) return fail(`${comboLabel(combo)} is used by ${keeper.name}. Pick another key.`);
2386
+ saveKey(id, value);
2387
+ }
2388
+
2389
+ // value: a combo, null for no key, undefined for the default. Another action that has the combo
2390
+ // loses it, written as null, so the change shows in its row.
2391
+ async function saveKey(id, value) {
2392
+ const next = { ...userKeys };
2393
+ const action = hubActions().find((a) => a.id === id);
2394
+ if (value === undefined || value === action.combo) delete next[id];
2395
+ else next[id] = value;
2396
+ const mine = new Set(expandCombo(value ?? action.combo));
2397
+ for (const other of hubActions()) {
2398
+ const combo = comboFor(other);
2399
+ if (other.id !== id && combo && expandCombo(combo).some((c) => mine.has(c))) next[other.id] = null;
2400
+ }
2401
+ shortcuts.busy = true;
2402
+ try {
2403
+ settingsModal.data = await sendJson('POST', '/api/settings', { keys: next });
2404
+ setUserKeys(settingsModal.data.keys);
2405
+ shortcuts.error = null;
2406
+ } catch (err) {
2407
+ shortcuts.error = err.message;
2408
+ } finally {
2409
+ shortcuts.busy = false;
2410
+ shortcuts.recording = null;
2411
+ }
2412
+ if (shortcuts.open) renderShortcuts();
2413
+ }
2414
+
2415
+ // The combo as the hub names it, plus the character the user's layout prints on that key when it is
2416
+ // not Latin: the hub matches such a key by its US position.
2417
+ function comboNode(combo) {
2418
+ const node = el('span', 'key-combo');
2419
+ node.append(kbd(comboLabel(combo)));
2420
+ const code = codeOf(combo.split('+').at(-1));
2421
+ const local = code && shortcuts.layout?.get(code);
2422
+ if (local && /[^\x20-\x7e]/.test(local)) node.append(el('span', 'key-local', local));
2423
+ return node;
2424
+ }
2425
+
2426
+ function keyButton(text, focus, disabled, onClick) {
2427
+ const b = el('button', '', text);
2428
+ b.type = 'button';
2429
+ b.dataset.focus = focus;
2430
+ b.disabled = disabled || shortcuts.busy;
2431
+ b.addEventListener('click', onClick);
2432
+ return b;
2433
+ }
2434
+
2435
+ function renderShortcuts() {
2436
+ const body = document.getElementById('shortcuts-body');
2437
+ const foot = document.getElementById('shortcuts-foot');
2438
+ const s = settingsModal.data;
2439
+ const focusKey = document.activeElement?.closest?.('[data-focus]')?.dataset.focus;
2440
+
2441
+ const table = el('table', 'set-apps keys-table');
2442
+ const head = el('tr', '');
2443
+ head.append(el('th', '', 'Action'), el('th', '', 'Key'), el('th', '', ''));
2444
+ table.append(head);
2445
+ for (const a of hubActions()) {
2446
+ const combo = comboFor(a);
2447
+ const custom = Object.hasOwn(userKeys, a.id);
2448
+ const tr = el('tr', shortcuts.recording === a.id ? 'recording' : '');
2449
+ const name = el('td', '', ACTION_NAMES[a.id] ?? a.id);
2450
+ if (a.off) name.append(el('span', 'key-tag', 'voice off'));
2451
+ if (custom) name.append(el('span', 'key-tag', 'changed'));
2452
+ const key = el('td', '');
2453
+ if (shortcuts.recording === a.id) key.append(el('span', 'key-prompt', 'Press keys…'));
2454
+ else if (combo) key.append(comboNode(combo));
2455
+ else key.append(el('span', 'set-help', 'No key'));
2456
+ const btns = el('td', 'key-btns');
2457
+ btns.append(
2458
+ keyButton('Change', `rec:${a.id}`, false, () => {
2459
+ shortcuts.recording = a.id;
2460
+ shortcuts.error = null;
2461
+ renderShortcuts();
2462
+ }),
2463
+ keyButton('Reset', `reset:${a.id}`, !custom, () => saveKey(a.id, undefined)),
2464
+ keyButton('Off', `off:${a.id}`, !combo, () => saveKey(a.id, null)),
2465
+ );
2466
+ tr.append(name, key, btns);
2467
+ table.append(tr);
2468
+ }
2469
+ const hubSec = el('section', 'set-sec');
2470
+ hubSec.append(el('h4', '', 'Hub'), table);
2471
+ for (const line of s?.keyWarnings ?? []) hubSec.append(el('span', 'set-help set-error', line));
2472
+ hubSec.append(
2473
+ el(
2474
+ 'span',
2475
+ 'set-help',
2476
+ '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.',
2477
+ ),
2478
+ );
2479
+
2480
+ const sections = [hubSec];
2481
+ for (const app of s?.keeps ?? []) {
2482
+ const sec = el('section', 'set-sec');
2483
+ sec.append(el('h4', '', `${app.name} keeps`));
2484
+ const list = el('div', 'key-keeps');
2485
+ for (const c of app.combos) list.append(comboNode(c));
2486
+ sec.append(list);
2487
+ sections.push(sec);
2488
+ }
2489
+ body.replaceChildren(...sections);
2490
+
2491
+ const status = shortcuts.error
2492
+ ? el('span', 'set-error', shortcuts.error)
2493
+ : el('span', '', shortcuts.recording ? 'Press the new key, Esc cancels' : 'Saved as you change it');
2494
+ const hints = el('span', 'set-keys');
2495
+ hints.append(kbd('Tab'), ' move · ', kbd('Esc'), ' back');
2496
+ foot.replaceChildren(status, hints);
2497
+
2498
+ if (focusKey) {
2499
+ const target = body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]:not(:disabled)`);
2500
+ (target ?? document.querySelector('#shortcuts .settings-box')).focus();
2501
+ }
2502
+ }
2503
+
2175
2504
  function kbd(text) {
2176
2505
  return el('kbd', '', text);
2177
2506
  }
2178
2507
 
2508
+ // Chrome and Edge fire this once per load, only while the page can be installed and is not yet.
2509
+ let installPrompt = null;
2510
+ window.addEventListener('beforeinstallprompt', (e) => {
2511
+ e.preventDefault();
2512
+ installPrompt = e;
2513
+ if (setupModal.open) renderSetup();
2514
+ });
2515
+
2516
+ const setupModal = {
2517
+ open: false,
2518
+ mode: 'setup',
2519
+ data: null,
2520
+ update: null,
2521
+ updatedDirs: new Set(),
2522
+ error: null,
2523
+ busy: null,
2524
+ output: null,
2525
+ };
2526
+ const isInstalledApp = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
2527
+ const IS_SAFARI = /^((?!chrome|android|edg).)*safari/i.test(navigator.userAgent);
2528
+
2529
+ // Every step is read from the machine each time, so a step done by hand shows as done. Only
2530
+ // finishing or skipping is saved.
2531
+ function setupSteps(s) {
2532
+ if (setupModal.mode === 'update')
2533
+ return [
2534
+ { id: 'hub', done: !setupModal.update?.update },
2535
+ { id: 'plugins', done: s.pluginsDone },
2536
+ ];
2537
+ const steps = [{ id: 'app', done: isInstalledApp() }];
2538
+ if (s.tray.supported) steps.push({ id: 'tray', done: s.tray.autostart });
2539
+ steps.push({ id: 'dirs', done: s.candidates.length === 0 }, { id: 'plugins', done: s.pluginsDone });
2540
+ return steps;
2541
+ }
2542
+
2543
+ async function maybeShowSetup() {
2544
+ let data;
2545
+ try {
2546
+ data = await sendJson('GET', '/api/setup');
2547
+ } catch {
2548
+ return;
2549
+ }
2550
+ if (setupSteps(data).every((st) => st.done)) {
2551
+ sendJson('POST', '/api/setup', { onboarding: 'done' }).catch(() => {});
2552
+ return;
2553
+ }
2554
+ if (openOverlay) return;
2555
+ openSetup(data);
2556
+ }
2557
+
2558
+ async function openSetup(data = null, mode = 'setup', update = null) {
2559
+ setupModal.open = true;
2560
+ setupModal.mode = mode;
2561
+ setupModal.error = null;
2562
+ setupModal.output = null;
2563
+ setupModal.updatedDirs.clear();
2564
+ setupModal.data = data;
2565
+ setupModal.update = update;
2566
+ showOverlay('setup', closeSetup);
2567
+ renderSetup();
2568
+ document.querySelector('#setup .setup-box').focus();
2569
+ const needUpdate = mode === 'update' && !update;
2570
+ if (data && !needUpdate) return;
2571
+ try {
2572
+ const [setup, info] = await Promise.all([
2573
+ data ?? sendJson('GET', '/api/setup'),
2574
+ needUpdate ? sendJson('GET', '/api/update') : update,
2575
+ ]);
2576
+ setupModal.update = info;
2577
+ setupModal.data = setup;
2578
+ } catch (err) {
2579
+ setupModal.error = err.message;
2580
+ }
2581
+ if (setupModal.open) renderSetup();
2582
+ }
2583
+
2584
+ function openUpdate() {
2585
+ return openSetup(null, 'update');
2586
+ }
2587
+
2588
+ function closeSetup() {
2589
+ setupModal.open = false;
2590
+ hideOverlay('setup');
2591
+ }
2592
+
2593
+ async function endSetup(value) {
2594
+ try {
2595
+ await sendJson('POST', '/api/setup', { onboarding: value });
2596
+ } catch {}
2597
+ closeSetup();
2598
+ }
2599
+
2600
+ async function setupAction(key, fn) {
2601
+ if (setupModal.busy) return;
2602
+ setupModal.busy = key;
2603
+ setupModal.error = null;
2604
+ setupModal.output = null;
2605
+ renderSetup();
2606
+ try {
2607
+ const r = await fn();
2608
+ if (r?.setup) setupModal.data = r.setup;
2609
+ noteUpdatedDirs(r?.results);
2610
+ } catch (err) {
2611
+ setupModal.error = err.message;
2612
+ setupModal.output = err.data?.output ? { step: key.split(':')[0], text: err.data.output } : null;
2613
+ if (err.data?.setup) setupModal.data = err.data.setup;
2614
+ noteUpdatedDirs(err.data?.results);
2615
+ } finally {
2616
+ setupModal.busy = null;
2617
+ }
2618
+ if (setupModal.open) renderSetup();
2619
+ }
2620
+
2621
+ function noteUpdatedDirs(results) {
2622
+ for (const r of results ?? []) if (r.ok) setupModal.updatedDirs.add(r.dir);
2623
+ }
2624
+
2625
+ function setupButton(text, key, fn, primary = false) {
2626
+ const b = el('button', primary ? 'primary' : '', setupModal.busy === key ? 'Working…' : text);
2627
+ b.type = 'button';
2628
+ b.disabled = !!setupModal.busy;
2629
+ b.dataset.focus = key;
2630
+ b.addEventListener('click', () => setupAction(key, fn));
2631
+ return b;
2632
+ }
2633
+
2634
+ function installStep(step) {
2635
+ const action =
2636
+ installPrompt && !step.done
2637
+ ? setupButton(
2638
+ 'Install',
2639
+ 'app',
2640
+ async () => {
2641
+ const prompt = installPrompt;
2642
+ installPrompt = null;
2643
+ await prompt.prompt();
2644
+ await prompt.userChoice;
2645
+ },
2646
+ true,
2647
+ )
2648
+ : null;
2649
+ let desc = 'The hub runs in its own window, with no browser tabs or address bar.';
2650
+ if (step.done) desc = 'The hub runs as an installed app.';
2651
+ else if (!installPrompt)
2652
+ desc += IS_SAFARI
2653
+ ? ' In Safari, choose File → Add to Dock.'
2654
+ : ' Use the install icon in the address bar, or the browser menu → Install. Already installed? Open it from the Start menu or Applications.';
2655
+ return { title: 'Install as an app', desc, action };
2656
+ }
2657
+
2658
+ function trayStep(step, s) {
2659
+ const where = IS_MAC ? 'menu bar' : 'notification area';
2660
+ const desc = step.done
2661
+ ? `Starts when you sign in. Tray icon: ${s.tray.running ? 'running' : 'not running'}.`
2662
+ : `Adds an icon to the ${where} and starts the hub when you sign in.`;
2663
+ const action = step.done ? null : setupButton('Turn on', 'tray', () => sendJson('POST', '/api/setup/tray'), true);
2664
+ return { title: 'Autostart', tag: IS_MAC ? 'experimental' : null, desc, action };
2665
+ }
2666
+
2667
+ function dirsStep(step, s) {
2668
+ const list = el('div', 'setup-list');
2669
+ for (const d of s.dirs) {
2670
+ const item = el('div', 'setup-item');
2671
+ item.append(el('code', '', d.path), el('span', 'setup-ok', d.active ? 'active' : 'added'));
2672
+ list.append(item);
2673
+ }
2674
+ for (const path of s.candidates) {
2675
+ const item = el('div', 'setup-item');
2676
+ const add = setupButton('Add', `dir:${path}`, async () => {
2677
+ await sendJson('POST', '/api/config-dirs', { path });
2678
+ return { setup: await sendJson('GET', '/api/setup') };
2679
+ });
2680
+ item.append(el('code', '', path), add);
2681
+ list.append(item);
2682
+ }
2683
+ const desc = step.done
2684
+ ? 'The hub lists every Claude config dir it found. Switch dirs with the config-dir key.'
2685
+ : 'Found more Claude config dirs. Each dir you add gets its own Kanban, Cost and Memory.';
2686
+ return { title: 'Choose Claude config dirs', desc, extra: list };
2687
+ }
2688
+
2689
+ const HUB_UPDATE_COMMAND = 'npm i -g claude-code-hub@latest';
2690
+
2691
+ function hubStep(step) {
2692
+ const u = setupModal.update;
2693
+ if (step.done)
2694
+ return { title: 'Update the hub', desc: `Version ${u?.version ?? '?'}. No newer version found on npm.` };
2695
+ const item = el('div', 'setup-item');
2696
+ const copy = el('button', 'primary', 'Copy');
2697
+ copy.type = 'button';
2698
+ copy.dataset.focus = 'copy';
2699
+ copy.addEventListener('click', async () => {
2700
+ try {
2701
+ await navigator.clipboard.writeText(HUB_UPDATE_COMMAND);
2702
+ copy.textContent = 'Copied';
2703
+ } catch {
2704
+ copy.textContent = 'Copy failed';
2705
+ }
2706
+ });
2707
+ const notes = el('a', '', 'Release notes');
2708
+ notes.href = u.update.url;
2709
+ notes.target = '_blank';
2710
+ notes.rel = 'noopener';
2711
+ item.append(el('code', '', HUB_UPDATE_COMMAND), copy, notes);
2712
+ const list = el('div', 'setup-list');
2713
+ list.append(item);
2714
+ return {
2715
+ title: 'Update the hub',
2716
+ tag: `${u.version} → ${u.update.latest}`,
2717
+ desc: 'Stop the hub, run this command, then start the hub again. It also updates Kanban, Cost, Marketplace and Memory.',
2718
+ extra: list,
2719
+ };
2720
+ }
2721
+
2722
+ function pluginNote(p) {
2723
+ if (p.state === 'mismatch') return `${p.name}: ${p.installed} → ${p.bundled}`;
2724
+ return `${p.name}: ${p.state === 'missing' ? 'not installed' : 'turned off'}`;
2725
+ }
2726
+
2727
+ function pluginsStep(step, s) {
2728
+ const update = setupModal.mode === 'update';
2729
+ const list = el('div', 'setup-list');
2730
+ for (const d of s.dirs) {
2731
+ const item = el('div', 'setup-item');
2732
+ const right = d.pending
2733
+ ? setupButton(update ? 'Update' : 'Install', `plugins:${d.path}`, () =>
2734
+ sendJson('POST', '/api/setup/plugins', { dir: d.path }),
2735
+ )
2736
+ : el('span', 'setup-ok good', update ? '✓ up to date' : '✓ installed');
2737
+ item.append(el('code', '', d.path), right);
2738
+ const notes = d.plugins.filter((p) => p.state !== 'ok').map(pluginNote);
2739
+ if (notes.length) item.append(el('span', 'set-help', notes.join(' · ')));
2740
+ list.append(item);
2741
+ }
2742
+ if (setupModal.updatedDirs.size)
2743
+ list.append(
2744
+ el(
2745
+ 'div',
2746
+ 'setup-notice',
2747
+ `Restart open Claude sessions in these dirs to load the new plugins: ${[...setupModal.updatedDirs].join(', ')}`,
2748
+ ),
2749
+ );
2750
+ const action =
2751
+ s.dirs.filter((d) => d.pending).length > 1
2752
+ ? setupButton(
2753
+ update ? 'Update all dirs' : 'Install in all dirs',
2754
+ 'plugins:all',
2755
+ () => sendJson('POST', '/api/setup/plugins', { all: true }),
2756
+ true,
2757
+ )
2758
+ : null;
2759
+ return {
2760
+ title: update ? 'Update plugins' : 'Install plugins',
2761
+ desc: update
2762
+ ? 'The hub plugin and the app plugins in every config dir. A dir that fails does not stop the others.'
2763
+ : 'Kanban shows live agent activity only in a dir with its plugin. Open sessions keep the old plugin until they restart.',
2764
+ action,
2765
+ extra: list,
2766
+ };
2767
+ }
2768
+
2769
+ const SETUP_STEP_VIEWS = { app: installStep, tray: trayStep, dirs: dirsStep, plugins: pluginsStep, hub: hubStep };
2770
+
2771
+ const SETUP_MODES = {
2772
+ setup: {
2773
+ title: 'Welcome to Claude Code Hub',
2774
+ sub: 'A few quick steps make the hub feel like a desktop app.',
2775
+ status: 'Skip any step. Open this again from Hub settings.',
2776
+ end: (allDone) => [allDone ? 'Finish' : 'Skip setup', () => endSetup(allDone ? 'done' : 'skipped')],
2777
+ },
2778
+ update: {
2779
+ title: 'Updates',
2780
+ sub: 'Nothing changes until you click. Restart the hub by hand.',
2781
+ status: 'Open this again from Hub settings → Updates.',
2782
+ end: () => ['Close', closeSetup],
2783
+ },
2784
+ };
2785
+
2786
+ function renderSetup() {
2787
+ const body = document.getElementById('setup-body');
2788
+ const foot = document.getElementById('setup-foot');
2789
+ const s = setupModal.data;
2790
+ const focusKey = document.activeElement?.closest?.('[data-focus]')?.dataset.focus;
2791
+ const mode = SETUP_MODES[setupModal.mode];
2792
+ document.getElementById('setup-title').textContent = mode.title;
2793
+ document.getElementById('setup-sub').textContent = mode.sub;
2794
+ if (!s) {
2795
+ body.replaceChildren(el('div', 'set-help', setupModal.error ?? 'Loading…'));
2796
+ foot.replaceChildren();
2797
+ return;
2798
+ }
2799
+ const steps = setupSteps(s);
2800
+ const doneCount = steps.filter((st) => st.done).length;
2801
+ const current = steps.find((st) => !st.done);
2802
+ const meter = el('div', 'setup-meter');
2803
+ const lead = el('span', 'setup-lead');
2804
+ lead.append(el('b', '', `${doneCount} of ${steps.length}`), ' done');
2805
+ const bar = el('div', 'setup-progress');
2806
+ const fill = el('i', '');
2807
+ fill.style.width = `${(doneCount / steps.length) * 100}%`;
2808
+ bar.append(fill);
2809
+ meter.append(lead, bar);
2810
+ const list = el('ol', 'setup-steps');
2811
+ steps.forEach((st, i) => {
2812
+ const view = SETUP_STEP_VIEWS[st.id](st, s);
2813
+ const li = el('li', `setup-step${st.done ? ' done' : ''}${st === current ? ' current' : ''}`);
2814
+ const title = el('span', 'setup-title', view.title);
2815
+ if (view.tag) title.append(el('span', 'setup-tag', view.tag));
2816
+ li.append(el('span', 'setup-mark', st.done ? '✓' : String(i + 1)), title, view.action ?? el('span', ''));
2817
+ li.append(el('span', 'setup-desc', view.desc));
2818
+ if (view.extra) li.append(view.extra);
2819
+ if (setupModal.output?.step === st.id) li.append(el('pre', 'setup-out', setupModal.output.text));
2820
+ list.append(li);
2821
+ });
2822
+ body.replaceChildren(meter, list);
2823
+
2824
+ const status = setupModal.error ? el('span', 'set-error', setupModal.error) : el('span', 'set-keys', mode.status);
2825
+ const allDone = doneCount === steps.length;
2826
+ const [endText, onEnd] = mode.end(allDone);
2827
+ const end = el('button', allDone ? 'primary' : '', endText);
2828
+ end.type = 'button';
2829
+ end.dataset.focus = 'end';
2830
+ end.addEventListener('click', () => onEnd());
2831
+ foot.replaceChildren(status, end);
2832
+
2833
+ if (focusKey) {
2834
+ const target = document.querySelector(`#setup [data-focus="${CSS.escape(focusKey)}"]:not(:disabled)`);
2835
+ (target ?? document.querySelector('#setup .setup-box')).focus();
2836
+ }
2837
+ }
2838
+
2839
+ function bindSetup() {
2840
+ const overlay = document.getElementById('setup');
2841
+ overlay.addEventListener('mousedown', (e) => {
2842
+ if (e.target === overlay) closeSetup();
2843
+ });
2844
+ matchMedia('(display-mode: standalone)').addEventListener('change', () => setupModal.open && renderSetup());
2845
+ }
2846
+
2179
2847
  function bindSettings() {
2180
2848
  const overlay = document.getElementById('settings');
2181
2849
  overlay.addEventListener('mousedown', (e) => {
2182
2850
  if (e.target === overlay) closeSettings();
2183
2851
  });
2184
2852
  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;
2853
+ const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -1, ArrowDown: 1 }[e.key];
2854
+ const group = step && e.target.closest('[role="radiogroup"], [role="tablist"]');
2855
+ if (!group || (group.getAttribute('role') === 'tablist') !== (e.key === 'ArrowUp' || e.key === 'ArrowDown')) return;
2188
2856
  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();
2857
+ const items = [...group.querySelectorAll('[role="radio"]:not(:disabled), [role="tab"]')];
2858
+ items[items.indexOf(e.target) + step]?.click();
2192
2859
  });
2193
2860
  }
2194
2861