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/features/voice/feature.json +1 -1
- package/lib/keymap.js +1 -1
- package/lib/onboarding.js +59 -0
- package/lib/settings.js +8 -1
- package/lib/tray.js +37 -17
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +19 -2
- package/packages/claude-hub-sdk/src/keys.js +52 -9
- package/packages/claude-hub-sdk/src/stub.js +1 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/keys.md +4 -2
- package/public/app.js +689 -22
- package/public/index.html +156 -2
- package/server.js +135 -4
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`,
|
|
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
|
-
|
|
1116
|
-
keys: actionKeys(),
|
|
1162
|
+
...keyFields(),
|
|
1117
1163
|
themes: hubThemes,
|
|
1118
|
-
actions: [...Object.keys(actions), ...(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
1771
|
-
badge.title +=
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2190
|
-
|
|
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
|
|