claude-code-hub 1.5.1 → 1.7.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.
@@ -0,0 +1,50 @@
1
+ # Change the hub keys
2
+
3
+ The hub's own keys (switch tools, open the palettes) live in the `keys` block of `<hub-dir>/config.json` (`--hub-dir`, else `CLAUDE_HUB_DIR`, else `~/.claude-hub`). Each tool's own keys are outside it. Page: `reference/shortcuts/#change-the-keys`.
4
+
5
+ ## Steps
6
+
7
+ 1. Map each key the user asks for to an action in [Actions](#actions), and check its combo against [Rules](#rules). When a combo breaks a rule, tell the user why and offer one that passes.
8
+ 2. Read `config.json` and merge the entries into its `keys` block. Every other field stays byte for byte: the hub owns `configDirs`, `activeConfigDir` and `apps`.
9
+ 3. Restart the hub: it reads `keys` once, at startup. When the tray runs it, the user picks Quit in the tray menu and starts the hub again.
10
+ 4. Read the startup output, or `<hub-dir>/hub.log` when the tray started the hub. The hub logs each entry it drops as `<config.json path>: keys "<action>": … It keeps its default`. The reason is one of: not a combo, not a combo that ends in `+{n}`, `<combo> is kept by <app>`, `<combo> is already set for "<action>"`. An id that is not an action logs `keys "<id>" is not a hub action, ignored`.
11
+ 5. Read `GET http://localhost:<port>/api/config?token=<token>` (the token is in `<hub-dir>/token`).
12
+
13
+ Done when the `keys` of `/api/config` holds every entry the user asked for, `null` ones too, and the startup output has no `config.json: keys` line. Then tell the user where to see it: each tool's help (`?`) shows the new hub keys and hides a hub row with no key, and the app launcher shows the tool-number keys.
14
+
15
+ To go back to the defaults, remove the `keys` block and restart.
16
+
17
+ ## Example
18
+
19
+ ```json
20
+ "keys": {
21
+ "hub.projectPicker": "ctrl+alt+o",
22
+ "hub.nextApp": "ctrl+alt+l",
23
+ "hub.prevApp": "ctrl+alt+h",
24
+ "hub.appByNumber": "ctrl+shift+{n}",
25
+ "hub.configDirPicker": null
26
+ }
27
+ ```
28
+
29
+ Ctrl+Alt+O opens the project palette, Ctrl+Alt+H and Ctrl+Alt+L move between tools, Ctrl+Shift+1 … 9 jumps to a tool by number, and the config-dir palette has no key. The app launcher keeps Ctrl+Alt+A.
30
+
31
+ ## Actions
32
+
33
+ | Action | Default | Default on macOS |
34
+ | --- | --- | --- |
35
+ | `hub.projectPicker` | `ctrl+alt+p` | `ctrl+alt+p` |
36
+ | `hub.configDirPicker` | `ctrl+alt+w` | `ctrl+alt+w` |
37
+ | `hub.appLauncher` | `ctrl+alt+a` | `ctrl+alt+a` |
38
+ | `hub.prevApp` | `ctrl+alt+ArrowLeft` | `ctrl+alt+ArrowLeft` |
39
+ | `hub.nextApp` | `ctrl+alt+ArrowRight` | `ctrl+alt+ArrowRight` |
40
+ | `hub.appByNumber` | `alt+{n}` | `ctrl+alt+{n}` |
41
+
42
+ ## Rules
43
+
44
+ - **Form.** One or more of `ctrl`, `alt`, `shift`, `meta`, then one key, joined by `+`. The key is a letter, a digit 1 to 9, or a `KeyboardEvent.key` name (`ArrowUp`, `F2`, `PageDown`). On macOS `alt` is Option and `meta` is Command. Modifiers and letters take any case and order (`"Alt+Ctrl+O"` reads as `ctrl+alt+o`); a key name keeps its case (`PageDown`).
45
+ - **`{n}`.** `hub.appByNumber` ends in `+{n}`, and the hub binds it for 1 to 9. It is the only action with `{n}`.
46
+ - **`null`** leaves the action with no key.
47
+ - **Taking a default.** A combo that is another action's default moves to the new action, and the other action has no key until it gets one: `"hub.nextApp": "ctrl+alt+a"` leaves the app launcher with no key. Say so to the user.
48
+ - **Kept keys.** An app keeps the combos in `keys.keeps` of its `hub-app.json`, and the hub refuses them. Kanban keeps `ctrl+alt+n`, `ctrl+alt+r` and `ctrl+alt+s`.
49
+ - **Tool keys.** A combo the hub binds never reaches the tool. Plain `ctrl+<key>` and `alt+<key>` combos belong to the tools and Claude Code's terminal (`ctrl+r`, `ctrl+c`, `alt+[`), so give the hub `ctrl+alt` or `ctrl+shift` combos.
50
+ - **AltGr.** On a layout with AltGr (Polish, German), a `ctrl+alt` letter that types a character stays text, and that hub key does nothing. Offer another letter or a `ctrl+shift` combo.
package/public/app.js CHANGED
@@ -1,4 +1,9 @@
1
1
  let apps = {};
2
+ // bindings() builds it from apps and userKeys; setApps clears it.
3
+ let keymap = null;
4
+ // config.json `keys`, {actionId: combo | null}, from /api/config.
5
+ let userKeys = {};
6
+ const { comboOf, keyParts } = ClaudeHubKeys;
2
7
  let activeApp = null;
3
8
  // The app launcher's cursor starts here, so Ctrl+Alt+A then Enter goes back to the last tab.
4
9
  let previousApp = null;
@@ -11,6 +16,9 @@ const liveApps = new Map();
11
16
  // Apps whose next document the hub loads from an action URL.
12
17
  const linkLoads = new Set();
13
18
  const HUB_PROTOCOLS = [1];
19
+ // Option+digit types a character on macOS (Option+5 is '[' on German layouts), so the tab numbers
20
+ // there are Control+Option+digit.
21
+ const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platform || '');
14
22
  // {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
15
23
  // reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
16
24
  // global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
@@ -220,7 +228,8 @@ async function init() {
220
228
  showLoading();
221
229
  const res = await fetch('/api/config');
222
230
  const config = await res.json();
223
- apps = config.apps;
231
+ userKeys = config.keys ?? {};
232
+ setApps(config.apps);
224
233
  defaultConfigDir = config.defaultConfigDir ?? null;
225
234
  activeConfigDir = config.activeConfigDir ?? null;
226
235
  applyTitle(config.activeConfigDir);
@@ -471,6 +480,7 @@ function onHello(appId, data) {
471
480
  type: 'hub:welcome',
472
481
  protocol: Math.max(...common),
473
482
  forward: Object.keys(bindings()),
483
+ keys: actionKeys(),
474
484
  themes: hubThemes,
475
485
  actions: Object.keys(actions),
476
486
  });
@@ -597,40 +607,70 @@ function handleForwardedKey(d) {
597
607
  bindings()[comboOf(e)]?.run();
598
608
  }
599
609
 
600
- // The one keymap. Its combo names are what welcome.forward lists for the apps.
601
- // inPalette: the binding still fires while the palette is open.
610
+ // The hub's actions and their default combos. hub.appByNumber is a pattern: {n} is the tab number,
611
+ // for the first nine. inPalette: the binding still fires while the palette is open. The ids are the
612
+ // keys of config.json `keys`, listed again in lib/keymap.js.
613
+ function hubActions() {
614
+ return [
615
+ { id: 'hub.projectPicker', combo: 'ctrl+alt+p', run: () => togglePalette('project'), inPalette: true },
616
+ { id: 'hub.configDirPicker', combo: 'ctrl+alt+w', run: () => togglePalette('configDir'), inPalette: true },
617
+ { id: 'hub.appLauncher', combo: 'ctrl+alt+a', run: () => togglePalette('app'), inPalette: true },
618
+ { id: 'hub.prevApp', combo: 'ctrl+alt+ArrowLeft', run: () => cycleTab(-1) },
619
+ { id: 'hub.nextApp', combo: 'ctrl+alt+ArrowRight', run: () => cycleTab(1) },
620
+ {
621
+ id: 'hub.appByNumber',
622
+ combo: `${IS_MAC ? 'ctrl+alt' : 'alt'}+{n}`,
623
+ run: (i) => switchTab(Object.keys(apps)[i]),
624
+ count: 9,
625
+ },
626
+ ];
627
+ }
628
+
629
+ // The one keymap, combo → binding. Its combos are what welcome.forward lists for the apps.
630
+ // Built on first use after setApps, so a key press only looks a combo up. userKeys comes checked
631
+ // from the server: null unbinds an action, and a combo the user sets is taken from the action
632
+ // that has it by default.
602
633
  function bindings() {
603
- const map = {
604
- 'ctrl+alt+p': { run: () => togglePalette('project'), inPalette: true },
605
- 'ctrl+alt+w': { run: () => togglePalette('configDir'), inPalette: true },
606
- 'ctrl+alt+a': { run: () => togglePalette('app'), inPalette: true },
607
- 'ctrl+alt+ArrowLeft': { run: () => cycleTab(-1) },
608
- 'ctrl+alt+ArrowRight': { run: () => cycleTab(1) },
609
- };
610
- Object.keys(apps)
611
- .slice(0, 9)
612
- .forEach((id, i) => {
613
- map[`alt+${i + 1}`] = { run: () => switchTab(id) };
634
+ if (keymap) return keymap;
635
+ const tabs = Object.keys(apps).length;
636
+ const own = (b) => Object.hasOwn(userKeys, b.id);
637
+ const expand = (action) => {
638
+ const combo = comboFor(action);
639
+ if (!combo) return [];
640
+ if (!action.count) return [[combo, { ...action, combo }]];
641
+ return Array.from({ length: Math.min(action.count, tabs) }, (_, i) => {
642
+ const c = combo.replace('{n}', i + 1);
643
+ return [c, { ...action, combo: c, index: i, run: () => action.run(i) }];
614
644
  });
615
- return map;
645
+ };
646
+ const entries = hubActions().flatMap(expand);
647
+ const userCombos = new Set(entries.filter(([, b]) => own(b)).map(([c]) => c));
648
+ keymap = Object.fromEntries(entries.filter(([c, b]) => own(b) || !userCombos.has(c)));
649
+ return keymap;
650
+ }
651
+
652
+ function comboFor(action) {
653
+ return Object.hasOwn(userKeys, action.id) ? userKeys[action.id] : action.combo;
654
+ }
655
+
656
+ // Action id → its combo, hub.appByNumber as its {n} pattern, or null when no key runs it. What
657
+ // welcome.keys lists for the apps' help.
658
+ function actionKeys() {
659
+ const bound = new Set(Object.values(bindings()).map((b) => b.id));
660
+ return Object.fromEntries(hubActions().map((a) => [a.id, bound.has(a.id) ? comboFor(a) : null]));
661
+ }
662
+
663
+ function setApps(list) {
664
+ apps = list;
665
+ keymap = null;
616
666
  }
617
667
 
618
- // The SDK's comboOf() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
619
- // '+' to the key as bindingKey() names it.
620
- function comboOf(e) {
621
- const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
622
- return [...mods, bindingKey(e)].filter(Boolean).join('+');
668
+ function appNumberCombo(i) {
669
+ return Object.values(bindings()).find((b) => b.id === 'hub.appByNumber' && b.index === i)?.combo;
623
670
  }
624
671
 
625
- // macOS composes Option+<key> into a character — Option+1 is '¡', Option+P is 'π' — and holding
626
- // Control does not undo it, so e.key alone cannot carry these bindings there. e.code is the
627
- // physical key, which is wrong for non-US layouts, hence only as a fallback. Takes a real
628
- // KeyboardEvent or a forwarded {key, code} payload; a payload without code degrades to key.
629
- function bindingKey(e) {
630
- const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
631
- if (/^[a-z1-9]$/.test(lower)) return lower;
632
- const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
633
- return m ? m[1].toLowerCase() : e.key;
672
+ function comboLabel(combo) {
673
+ return keyParts(combo, IS_MAC).join(IS_MAC ? '' : '+');
634
674
  }
635
675
 
636
676
  function cycleTab(delta) {
@@ -904,6 +944,16 @@ async function restartFromPalette(id) {
904
944
  switchTab(id);
905
945
  }
906
946
 
947
+ async function copyAppUrl(id) {
948
+ const url = apps[id].url;
949
+ try {
950
+ await navigator.clipboard.writeText(url);
951
+ setPaletteHint(`Copied ${url}`);
952
+ } catch (err) {
953
+ setPaletteHint(`Could not copy ${url}: ${err.message}`);
954
+ }
955
+ }
956
+
907
957
  // `typed` is the trimmed query, `q` its lowercase form for matching.
908
958
  const PALETTE_MODES = {
909
959
  project: {
@@ -969,7 +1019,7 @@ const PALETTE_MODES = {
969
1019
  app: {
970
1020
  placeholder: 'Search apps...',
971
1021
  label: 'Switch app',
972
- hint: 'Enter switch · Ctrl+R restart the app',
1022
+ hint: 'Enter switch · Ctrl+C copy the URL · Ctrl+R restart the app',
973
1023
  empty: 'No matching app',
974
1024
  rows: (_typed, q) => appRows(q),
975
1025
  load: loadAppStats,
@@ -978,7 +1028,7 @@ const PALETTE_MODES = {
978
1028
  return prev < 0 ? 0 : prev;
979
1029
  },
980
1030
  fillRow(li, row, q) {
981
- li.append(appIcon(row.app), markedSpan('name', row.app.name, q));
1031
+ li.append(appIcon(row.app), markedSpan('name', row.app.name, q), el('span', 'url', new URL(row.app.url).host));
982
1032
  const s = palette.appStats?.[row.id];
983
1033
  if (s?.slowMs) {
984
1034
  const badge = el('span', 'slow', 'slow port');
@@ -988,11 +1038,15 @@ const PALETTE_MODES = {
988
1038
  const stats = el('span', 'stats', s?.rss ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '');
989
1039
  stats.title = 'Memory and CPU of the app process. Child processes are not counted. 100% is one core.';
990
1040
  li.append(stats);
991
- const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
1041
+ const combo = appNumberCombo(row.index);
1042
+ const key = row.id === activeApp ? 'active' : combo ? comboLabel(combo) : '';
992
1043
  li.append(el('span', 'age', key));
993
- li.append(rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`));
1044
+ li.append(
1045
+ rowButton('palette-copy', '⧉', 'c', 'Copy the URL (Ctrl+C)', `Copy the URL of ${row.app.name}`),
1046
+ rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`),
1047
+ );
994
1048
  },
995
- rowKeys: { r: (row) => restartFromPalette(row.id) },
1049
+ rowKeys: { c: (row) => copyAppUrl(row.id), r: (row) => restartFromPalette(row.id) },
996
1050
  commit(row) {
997
1051
  closePalette();
998
1052
  switchTab(row.id);
@@ -1143,7 +1197,7 @@ async function commitConfigDir(row) {
1143
1197
  if (target === palette.configDirs.active) return;
1144
1198
  showLoading();
1145
1199
  try {
1146
- apps = (await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps;
1200
+ setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
1147
1201
  // The palette renders from this cache before its refetch lands, and initialSel keys off it.
1148
1202
  palette.configDirs.active = target;
1149
1203
  activeConfigDir = target;
@@ -1190,11 +1244,13 @@ function bindPalette() {
1190
1244
  } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
1191
1245
  e.preventDefault();
1192
1246
  movePaletteSel(-1);
1193
- } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && ['d', 'r'].includes(e.key.toLowerCase())) {
1247
+ } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && ['c', 'd', 'r'].includes(e.key.toLowerCase())) {
1248
+ const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
1249
+ // Ctrl+C with text selected in the input keeps the browser's copy.
1250
+ if (e.key.toLowerCase() === 'c' && (!run || input.selectionStart !== input.selectionEnd)) return;
1194
1251
  // Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
1195
1252
  // Ctrl+R would otherwise reload the hub page.
1196
1253
  e.preventDefault();
1197
- const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
1198
1254
  const row = palette.rows[palette.sel];
1199
1255
  if (run && row && row.kind !== 'literal') run(row);
1200
1256
  }
package/public/index.html CHANGED
@@ -272,6 +272,13 @@
272
272
 
273
273
  .palette-row .stats + .age { margin-left: 0; }
274
274
 
275
+ .palette-row .url {
276
+ color: var(--text-dim);
277
+ font-family: ui-monospace, monospace;
278
+ font-size: 12px;
279
+ align-self: center;
280
+ }
281
+
275
282
  .palette-row .slow {
276
283
  margin-left: 10px;
277
284
  padding: 0 6px;
@@ -307,6 +314,7 @@
307
314
  .palette-row.selected .palette-icon { color: var(--accent); }
308
315
 
309
316
  .palette-row .palette-remove,
317
+ .palette-row .palette-copy,
310
318
  .palette-row .palette-restart {
311
319
  margin-left: auto;
312
320
  padding: 0 6px;
@@ -322,13 +330,17 @@
322
330
 
323
331
  .palette-row:hover .palette-remove,
324
332
  .palette-row.selected .palette-remove,
333
+ .palette-row:hover .palette-copy,
334
+ .palette-row.selected .palette-copy,
325
335
  .palette-row:hover .palette-restart,
326
336
  .palette-row.selected .palette-restart { opacity: 1; }
327
337
 
328
338
  .palette-row .palette-remove:hover { color: #e5484d; }
329
339
 
330
- .palette-row .age + .palette-restart { margin-left: 0; }
340
+ .palette-row .age + .palette-copy,
341
+ .palette-row .palette-copy + .palette-restart { margin-left: 0; }
331
342
 
343
+ .palette-row .palette-copy:hover,
332
344
  .palette-row .palette-restart:hover { color: var(--accent); }
333
345
 
334
346
  .palette-hint {
@@ -477,6 +489,7 @@
477
489
  <button class="update-toast-close" id="error-close" type="button" aria-label="Close">×</button>
478
490
  </div>
479
491
 
492
+ <script src="/sdk/keys.js"></script>
480
493
  <script src="/app.js"></script>
481
494
  </body>
482
495
  </html>
package/public/sw.js CHANGED
@@ -1,5 +1,5 @@
1
- const CACHE_NAME = 'hub-shell-v2';
2
- const SHELL_ASSETS = ['/', '/index.html', '/app.js', '/manifest.json'];
1
+ const CACHE_NAME = 'hub-shell-v3';
2
+ const SHELL_ASSETS = ['/', '/index.html', '/sdk/keys.js', '/app.js', '/manifest.json'];
3
3
 
4
4
  self.addEventListener('install', (e) => {
5
5
  e.waitUntil(
package/server.js CHANGED
@@ -10,6 +10,7 @@ const os = require('os');
10
10
  const path = require('path');
11
11
  const { actionTable } = require('./lib/actions');
12
12
  const { selectApps, loadApp, conflicts } = require('./lib/apps');
13
+ const { userKeys } = require('./lib/keymap');
13
14
  const { stripCookie } = require('./lib/cookies');
14
15
  const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
15
16
  const { ping, stats } = require('./lib/ipc');
@@ -131,6 +132,7 @@ function resolveHubConfig() {
131
132
  // Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
132
133
  if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
133
134
  if (Array.isArray(saved.apps)) config.apps = saved.apps;
135
+ if (saved.keys !== undefined) config.keys = saved.keys;
134
136
  return { config, raw, fallback };
135
137
  }
136
138
 
@@ -164,6 +166,8 @@ if (!ENABLED_APPS.length) {
164
166
  process.exit(1);
165
167
  }
166
168
  const ACTIONS = actionTable(ENABLED_APPS);
169
+ const { keys: USER_KEYS, lines: keyLines } = userKeys(hubConfig.keys, ENABLED_APPS);
170
+ for (const line of keyLines) console.log(`${HUB_CONFIG_FILE}: ${line}`);
167
171
  // The first enabled app in tab order that declares the capability in its manifest.
168
172
  const provider = (cap) => ENABLED_APPS.find((a) => a.provides[cap]);
169
173
  const PROJECTS_APP = provider('projects');
@@ -886,6 +890,7 @@ app.get('/api/config', (_req, res) => {
886
890
  ...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE }),
887
891
  apps: appsConfig(),
888
892
  actions: ACTIONS,
893
+ keys: USER_KEYS,
889
894
  activeConfigDir: hubConfig.activeConfigDir,
890
895
  defaultConfigDir,
891
896
  });
@@ -1083,6 +1088,8 @@ app.get('/api/resolve-path', (req, res) => {
1083
1088
  else res.json({ path: r.path });
1084
1089
  });
1085
1090
 
1091
+ const HUB_KEYS_JS = path.join(__dirname, 'packages', 'claude-hub-sdk', 'src', 'keys.js');
1092
+ app.get('/sdk/keys.js', (_req, res) => res.sendFile(HUB_KEYS_JS));
1086
1093
  app.use(express.static(path.join(__dirname, 'public')));
1087
1094
 
1088
1095
  // Children get HUB_URL at spawn and trust only that origin, so the first pool waits for the port
@@ -1104,7 +1111,7 @@ const onReady = (actual) => {
1104
1111
  if (!TRAY_FLAG) {
1105
1112
  liveHub(HUB_DIR, HUB_TOKEN).then((running) => {
1106
1113
  if (!running) {
1107
- watchStdin();
1114
+ if (!process.argv.includes('--detached')) watchStdin();
1108
1115
  listenProxies();
1109
1116
  listenWithFallback(app, HUB_PORT, onReady, '');
1110
1117
  return;