claude-code-hub 1.11.0 → 1.13.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
@@ -13,6 +13,9 @@ const guardedApps = new Set();
13
13
  // App id → the topics its hub:hello subscribes to. An app is live from its hello until the next
14
14
  // iframe load: e.source is the same WindowProxy across reloads, so a new document starts unknown.
15
15
  const liveApps = new Map();
16
+ // Window → {appId, topics}: an app's page framed inside another app's page, such as a cck pane
17
+ // (protocol §5.1). A pane does not reload when its parent does, so a closed window is dropped on send.
18
+ const nestedApps = new Map();
16
19
  // Apps whose next document the hub loads from an action URL.
17
20
  const linkLoads = new Set();
18
21
  const HUB_PROTOCOLS = [1];
@@ -48,7 +51,7 @@ const palette = {
48
51
  rows: [],
49
52
  sel: 0,
50
53
  query: '',
51
- confirmRestart: null,
54
+ confirmOp: null,
52
55
  message: null,
53
56
  };
54
57
 
@@ -80,10 +83,14 @@ function themePayload() {
80
83
  return { theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
81
84
  }
82
85
 
83
- function sendTheme(appId) {
86
+ function themeEvent() {
84
87
  const payload = themePayload();
85
88
  const vars = themeVars[payload.colorTheme ?? 'ember']?.[payload.theme];
86
- sendTopic(appId, 'theme.changed', vars ? { ...payload, vars } : payload);
89
+ return vars ? { ...payload, vars } : payload;
90
+ }
91
+
92
+ function sendTheme(appId) {
93
+ sendTopic(appId, 'theme.changed', themeEvent());
87
94
  }
88
95
 
89
96
  // {<themeId>: {dark, light}}, each a map of the apps' core CSS variables, from /api/config, which
@@ -160,6 +167,36 @@ function appIdOf(source) {
160
167
  return Object.keys(iframes).find((id) => iframes[id].contentWindow === source);
161
168
  }
162
169
 
170
+ function appIdByOrigin(origin) {
171
+ return Object.keys(apps).find((id) => originOf(id) === origin);
172
+ }
173
+
174
+ // A frame below a tab's frame. A cross-origin WindowProxy still exposes parent, and a popup or
175
+ // another top window never reaches a tab.
176
+ function underTab(source) {
177
+ for (let w = source?.parent; w && w !== window; w = w.parent) {
178
+ if (appIdOf(w)) return true;
179
+ if (w === w.parent) return false;
180
+ }
181
+ return false;
182
+ }
183
+
184
+ function postNested(win, message) {
185
+ const nested = nestedApps.get(win);
186
+ if (!nested) return;
187
+ if (win.closed) {
188
+ nestedApps.delete(win);
189
+ return;
190
+ }
191
+ win.postMessage(message, originOf(nested.appId));
192
+ }
193
+
194
+ function sendNestedTheme(win, payload = themeEvent()) {
195
+ if (nestedApps.get(win)?.topics.has('theme.changed')) {
196
+ postNested(win, { type: 'hub:event', topic: 'theme.changed', payload });
197
+ }
198
+ }
199
+
163
200
  // The last value lives in themeState and projectState, so a replay after welcome is a send.
164
201
  function sendTopic(appId, topic, payload) {
165
202
  if (liveApps.get(appId)?.has(topic)) postTo(appId, { type: 'hub:event', topic, payload });
@@ -243,7 +280,8 @@ async function init() {
243
280
  applyHubTheme();
244
281
  buildIframes();
245
282
  const saved = storedTab();
246
- switchTab(apps[saved] ? saved : Object.keys(apps)[0]);
283
+ const tabs = tabIds();
284
+ switchTab(tabs.includes(saved) ? saved : (tabs[0] ?? Object.keys(apps)[0]));
247
285
  listenMessages();
248
286
  listenKeys();
249
287
  bindPalette();
@@ -306,16 +344,17 @@ function hideToast(toast) {
306
344
  }
307
345
 
308
346
  // Null on success. A 409 with live terminals asks for force: true on the next call.
309
- async function requestRestart(id, force) {
347
+ // op is restart or stop.
348
+ async function requestAppOp(id, op, force) {
310
349
  try {
311
- await sendJson('POST', `/api/apps/${encodeURIComponent(id)}/restart`, { force });
350
+ await sendJson('POST', `/api/apps/${encodeURIComponent(id)}/${op}`, { force });
312
351
  return null;
313
352
  } catch (err) {
314
353
  const name = apps[id]?.name ?? id;
315
354
  const n = err.status === 409 && err.data?.terminals;
316
- if (!n) return { message: `${name} did not restart: ${err.message}` };
355
+ if (!n) return { message: `${name} did not ${op}: ${err.message}` };
317
356
  const them = n === 1 ? 'it' : 'them';
318
- return { terminals: n, message: `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A restart ends ${them}.` };
357
+ return { terminals: n, message: `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A ${op} ends ${them}.` };
319
358
  }
320
359
  }
321
360
 
@@ -361,6 +400,7 @@ function reloadIframes() {
361
400
  projectState = null;
362
401
  published.clear();
363
402
  missed.clear();
403
+ nestedApps.clear();
364
404
  // A new element, because assigning src is only a fragment navigation when the app's URL
365
405
  // differs from the new one by the #t= fragment alone, and the old dir's document stays.
366
406
  for (const id of Object.keys(iframes)) replaceIframe(id);
@@ -432,12 +472,19 @@ function makeIframe(id) {
432
472
  return iframe;
433
473
  }
434
474
 
475
+ // A hidden app (`hidden` in config.json) has no tab keys, so its frame loads only when the
476
+ // launcher or an action opens it.
477
+ function tabIds() {
478
+ return Object.keys(apps).filter((id) => !apps[id].hidden);
479
+ }
480
+
481
+ function addIframe(id) {
482
+ iframes[id] = makeIframe(id);
483
+ document.getElementById('iframe-container').appendChild(iframes[id]);
484
+ }
485
+
435
486
  function buildIframes() {
436
- const container = document.getElementById('iframe-container');
437
- for (const id of Object.keys(apps)) {
438
- iframes[id] = makeIframe(id);
439
- container.appendChild(iframes[id]);
440
- }
487
+ for (const id of tabIds()) addIframe(id);
441
488
  }
442
489
 
443
490
  const TAB_KEY = 'hub-tab';
@@ -452,6 +499,7 @@ function storedTab() {
452
499
 
453
500
  function switchTab(appId) {
454
501
  if (!apps[appId]) return;
502
+ if (!iframes[appId]) addIframe(appId);
455
503
  if (appId !== activeApp) previousApp = activeApp;
456
504
  activeApp = appId;
457
505
  try {
@@ -476,18 +524,26 @@ function onIframeLoad(appId) {
476
524
  if (appId === activeApp) hideLoading();
477
525
  }
478
526
 
479
- function onHello(appId, data) {
527
+ function welcomeFor(data) {
480
528
  const common = Array.isArray(data.protocol) ? HUB_PROTOCOLS.filter((v) => data.protocol.includes(v)) : [];
481
- if (!common.length) return;
482
- liveApps.set(appId, new Set(Array.isArray(data.subscribes) ? data.subscribes : []));
483
- postTo(appId, {
529
+ if (!common.length) return null;
530
+ return {
484
531
  type: 'hub:welcome',
485
532
  protocol: Math.max(...common),
486
533
  forward: Object.keys(bindings()),
487
534
  keys: actionKeys(),
488
535
  themes: hubThemes,
489
536
  actions: Object.keys(actions),
490
- });
537
+ };
538
+ }
539
+
540
+ const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
541
+
542
+ function onHello(appId, data) {
543
+ const welcome = welcomeFor(data);
544
+ if (!welcome) return;
545
+ liveApps.set(appId, helloTopics(data));
546
+ postTo(appId, welcome);
491
547
  sendTheme(appId);
492
548
  // A document the hub loaded from an action URL got its project from that URL. The hub's own
493
549
  // project would undo the link, so it goes out only with the next change.
@@ -522,9 +578,9 @@ function fillUrl(template, params) {
522
578
  }
523
579
 
524
580
  // Protocol §7. The result does not wait for the handler: the hub queues nothing.
525
- function onInvoke(appId, { id, action, params = {} }) {
581
+ function onInvoke({ id, action, params = {} }, send) {
526
582
  if (typeof id !== 'string') return;
527
- const reply = (result) => postTo(appId, { type: 'hub:result', id, ...result });
583
+ const reply = (result) => send({ type: 'hub:result', id, ...result });
528
584
  const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
529
585
  if (!spec) return reply({ ok: false, reason: 'unhandled' });
530
586
  if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
@@ -546,12 +602,13 @@ function listenMessages() {
546
602
  });
547
603
  window.addEventListener('message', (e) => {
548
604
  const appId = appIdOf(e.source);
549
- if (!appId || e.origin !== originOf(appId)) return;
605
+ if (!appId) return onNestedMessage(e);
606
+ if (e.origin !== originOf(appId)) return;
550
607
  const data = e.data ?? {};
551
608
  if (data.type === 'hub:hello') {
552
609
  onHello(appId, data);
553
610
  } else if (data.type === 'hub:invoke') {
554
- onInvoke(appId, data);
611
+ onInvoke(data, (m) => postTo(appId, m));
555
612
  } else if (data.type === 'hub:publish') {
556
613
  onPublish(appId, data);
557
614
  } else if (data.type === 'hub:keydown') {
@@ -570,35 +627,71 @@ function listenMessages() {
570
627
  })
571
628
  .catch(() => {});
572
629
  } else if (data.type === 'hub:openExternal') {
573
- // In the installed PWA window a framed sub-app's own target=_blank opens
574
- // nothing, so the SDK hands external links up to the top frame instead.
575
- let url;
576
- try {
577
- url = new URL(String(data.url));
578
- } catch (_) {
579
- return;
580
- }
581
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
582
- window.open(url.href, '_blank', 'noopener');
630
+ openExternal(data);
583
631
  } else if (data.type === 'hub:theme') {
584
- // colorTheme is optional and sticky.
585
- if (data.theme !== 'light' && data.theme !== 'dark') return;
586
- const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
587
- const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
588
- if (!changed) return;
589
- themeState.theme = data.theme;
590
- if (hasColor) {
591
- themeState.colorTheme = data.colorTheme;
592
- if (activeConfigDir) themeState.colorThemes[activeConfigDir] = data.colorTheme;
593
- }
594
- localStorage.setItem('hub-theme', JSON.stringify(themeState));
595
- applyHubTheme();
596
- // The sender gets the echo too, for the vars.
597
- for (const id of Object.keys(iframes)) sendTheme(id);
632
+ onThemeMessage(data);
598
633
  }
599
634
  });
600
635
  }
601
636
 
637
+ // Protocol §5.1. A nested page gets the welcome and theme.changed, and may send hello, invoke,
638
+ // keydown, openExternal and theme. hub:active, the other topics, publish, closeGuard and the
639
+ // terminal token belong to the app's tab, which keeps them per app id.
640
+ function onNestedMessage(e) {
641
+ const data = e.data ?? {};
642
+ const known = nestedApps.get(e.source);
643
+ const appId = data.type === 'hub:hello' && underTab(e.source) ? appIdByOrigin(e.origin) : known?.appId;
644
+ if (!appId || e.origin !== originOf(appId)) return;
645
+ if (data.type === 'hub:hello') {
646
+ const welcome = welcomeFor(data);
647
+ if (!welcome) return;
648
+ const topics = helloTopics(data);
649
+ nestedApps.set(e.source, { appId, topics });
650
+ postNested(e.source, welcome);
651
+ sendNestedTheme(e.source);
652
+ } else if (data.type === 'hub:invoke') {
653
+ onInvoke(data, (m) => postNested(e.source, m));
654
+ } else if (data.type === 'hub:keydown') {
655
+ handleForwardedKey(data);
656
+ } else if (data.type === 'hub:openExternal') {
657
+ openExternal(data);
658
+ } else if (data.type === 'hub:theme') {
659
+ onThemeMessage(data);
660
+ }
661
+ }
662
+
663
+ // In the installed PWA window a framed sub-app's own target=_blank opens
664
+ // nothing, so the SDK hands external links up to the top frame instead.
665
+ function openExternal(data) {
666
+ let url;
667
+ try {
668
+ url = new URL(String(data.url));
669
+ } catch (_) {
670
+ return;
671
+ }
672
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
673
+ window.open(url.href, '_blank', 'noopener');
674
+ }
675
+
676
+ function onThemeMessage(data) {
677
+ // colorTheme is optional and sticky.
678
+ if (data.theme !== 'light' && data.theme !== 'dark') return;
679
+ const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
680
+ const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
681
+ if (!changed) return;
682
+ themeState.theme = data.theme;
683
+ if (hasColor) {
684
+ themeState.colorTheme = data.colorTheme;
685
+ if (activeConfigDir) themeState.colorThemes[activeConfigDir] = data.colorTheme;
686
+ }
687
+ localStorage.setItem('hub-theme', JSON.stringify(themeState));
688
+ applyHubTheme();
689
+ // The sender gets the echo too, for the vars.
690
+ for (const id of Object.keys(iframes)) sendTheme(id);
691
+ const payload = themeEvent();
692
+ for (const win of nestedApps.keys()) sendNestedTheme(win, payload);
693
+ }
694
+
602
695
  function handleForwardedKey(d) {
603
696
  const e = {
604
697
  key: d.key,
@@ -624,7 +717,7 @@ function hubActions() {
624
717
  {
625
718
  id: 'hub.appByNumber',
626
719
  combo: `${IS_MAC ? 'ctrl+alt' : 'alt'}+{n}`,
627
- run: (i) => switchTab(Object.keys(apps)[i]),
720
+ run: (i) => switchTab(tabIds()[i]),
628
721
  count: 9,
629
722
  },
630
723
  ];
@@ -636,7 +729,7 @@ function hubActions() {
636
729
  // that has it by default.
637
730
  function bindings() {
638
731
  if (keymap) return keymap;
639
- const tabs = Object.keys(apps).length;
732
+ const tabs = tabIds().length;
640
733
  const own = (b) => Object.hasOwn(userKeys, b.id);
641
734
  const expand = (action) => {
642
735
  const combo = comboFor(action);
@@ -678,10 +771,9 @@ function comboLabel(combo) {
678
771
  }
679
772
 
680
773
  function cycleTab(delta) {
681
- const ids = Object.keys(apps);
774
+ const ids = tabIds();
682
775
  const idx = ids.indexOf(activeApp);
683
- const next = ids[(idx + delta + ids.length) % ids.length];
684
- switchTab(next);
776
+ switchTab(idx < 0 ? ids.at(delta > 0 ? 0 : -1) : ids[(idx + delta + ids.length) % ids.length]);
685
777
  }
686
778
 
687
779
  function listenKeys() {
@@ -881,6 +973,26 @@ const APP_ICONS = {
881
973
  ['circle', { cx: 12, cy: 12, r: 3 }],
882
974
  ['path', { d: 'm16 16-1.9-1.9' }],
883
975
  ],
976
+ network: [
977
+ ['rect', { x: 16, y: 16, width: 6, height: 6, rx: 1 }],
978
+ ['rect', { x: 2, y: 16, width: 6, height: 6, rx: 1 }],
979
+ ['rect', { x: 9, y: 2, width: 6, height: 6, rx: 1 }],
980
+ ['path', { d: 'M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3' }],
981
+ ['path', { d: 'M12 12V8' }],
982
+ ],
983
+ };
984
+
985
+ const ROW_ICONS = {
986
+ copy: [
987
+ ['rect', { width: 14, height: 14, x: 8, y: 8, rx: 2 }],
988
+ ['path', { d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2' }],
989
+ ],
990
+ restart: [
991
+ ['path', { d: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' }],
992
+ ['path', { d: 'M21 3v5h-5' }],
993
+ ],
994
+ start: [['polygon', { points: '6 3 20 12 6 21 6 3' }]],
995
+ stop: [['rect', { width: 18, height: 18, x: 3, y: 3, rx: 2 }]],
884
996
  };
885
997
 
886
998
  const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -891,9 +1003,7 @@ function svgNode(tag, attrs) {
891
1003
  return node;
892
1004
  }
893
1005
 
894
- function appIcon(app) {
895
- const shapes = APP_ICONS[app.icon];
896
- if (!shapes) return el('span', 'palette-icon letter', (app.name || '?').slice(0, 1).toUpperCase());
1006
+ function lucideSvg(shapes) {
897
1007
  const svg = svgNode('svg', {
898
1008
  viewBox: '0 0 24 24',
899
1009
  fill: 'none',
@@ -904,13 +1014,20 @@ function appIcon(app) {
904
1014
  'aria-hidden': 'true',
905
1015
  });
906
1016
  svg.append(...shapes.map(([tag, attrs]) => svgNode(tag, attrs)));
1017
+ return svg;
1018
+ }
1019
+
1020
+ function appIcon(app) {
1021
+ const shapes = APP_ICONS[app.icon];
1022
+ if (!shapes) return el('span', 'palette-icon letter', (app.name || '?').slice(0, 1).toUpperCase());
907
1023
  const box = el('span', 'palette-icon');
908
- box.append(svg);
1024
+ box.append(lucideSvg(shapes));
909
1025
  return box;
910
1026
  }
911
1027
 
912
1028
  function appRows(q) {
913
- const list = Object.entries(apps).map(([id, a], i) => ({ kind: 'app', id, app: a, index: i }));
1029
+ const tabs = tabIds();
1030
+ const list = Object.entries(apps).map(([id, a]) => ({ kind: 'app', id, app: a, index: tabs.indexOf(id) }));
914
1031
  if (!q) return list;
915
1032
  const hit = list.filter((r) => r.app.name.toLowerCase().includes(q) || r.id.includes(q));
916
1033
  return hit.length ? hit : list.filter((r) => subseq(r.app.name.toLowerCase(), q));
@@ -918,7 +1035,8 @@ function appRows(q) {
918
1035
 
919
1036
  // A click on the button runs the mode's rowKeys entry for `key`, the same as Ctrl+<key>.
920
1037
  function rowButton(className, glyph, key, title, label) {
921
- const btn = el('button', className, glyph);
1038
+ const btn = el('button', `palette-btn ${className}`);
1039
+ btn.append(ROW_ICONS[glyph] ? lucideSvg(ROW_ICONS[glyph]) : glyph);
922
1040
  btn.type = 'button';
923
1041
  btn.title = title;
924
1042
  btn.setAttribute('aria-label', label);
@@ -974,23 +1092,41 @@ async function copyToHint(text) {
974
1092
  }
975
1093
  }
976
1094
 
977
- // The terminal provider answers 409 with a terminal count; the second Ctrl+R on the same app sends force.
978
- async function restartFromPalette(id) {
979
- const force = palette.confirmRestart === id;
980
- palette.confirmRestart = null;
981
- setPaletteHint(`Restarting ${apps[id]?.name ?? id}…`);
982
- const failed = await withPaletteBusy(() => requestRestart(id, force));
1095
+ function toggleFromPalette(id) {
1096
+ return appOpFromPalette(id, palette.appStats?.[id]?.stopped ? 'start' : 'stop');
1097
+ }
1098
+
1099
+ const APP_OPS = {
1100
+ restart: { key: 'R', verb: 'Restarting' },
1101
+ start: { key: 'S', verb: 'Starting' },
1102
+ stop: { key: 'S', verb: 'Stopping' },
1103
+ };
1104
+
1105
+ // The terminal provider answers 409 with a terminal count; the same key again on the same app sends force.
1106
+ async function appOpFromPalette(id, op) {
1107
+ const confirm = `${op}\n${id}`;
1108
+ const force = palette.confirmOp === confirm;
1109
+ palette.confirmOp = null;
1110
+ const name = apps[id]?.name ?? id;
1111
+ setPaletteHint(`${APP_OPS[op].verb} ${name}…`);
1112
+ const failed = await withPaletteBusy(() => requestAppOp(id, op, force));
983
1113
  if (failed?.terminals) {
984
- palette.confirmRestart = id;
985
- setPaletteHint(`${failed.message} Press Ctrl+R again to restart.`);
1114
+ palette.confirmOp = confirm;
1115
+ setPaletteHint(`${failed.message} Press Ctrl+${APP_OPS[op].key} again to ${op}.`);
986
1116
  return;
987
1117
  }
988
1118
  if (failed) {
989
1119
  setPaletteHint(failed.message);
990
1120
  return;
991
1121
  }
1122
+ if (iframes[id]) replaceIframe(id);
1123
+ if (op === 'stop') {
1124
+ if (palette.appStats) palette.appStats[id] = { ...palette.appStats[id], stopped: true, rss: 0 };
1125
+ setPaletteHint(`Stopped ${name}`);
1126
+ renderPalette();
1127
+ return;
1128
+ }
992
1129
  closePalette();
993
- replaceIframe(id);
994
1130
  switchTab(id);
995
1131
  }
996
1132
 
@@ -1060,10 +1196,10 @@ const PALETTE_MODES = {
1060
1196
  placeholder: 'Search apps...',
1061
1197
  label: 'Switch app',
1062
1198
  get hint() {
1063
- const keys = 'Enter switch · Ctrl+C copy the URL · Ctrl+R restart the app';
1199
+ const keys = 'Enter switch · Ctrl+C copy URL · Ctrl+R restart · Ctrl+S start/stop';
1064
1200
  if (!hubInfo.version) return keys;
1065
1201
  const plugin = hubInfo.plugin ? [pluginNode(hubInfo.plugin), ' · '] : [];
1066
- return [`Claude Code Hub ${hubInfo.version} · `, ...plugin, keys];
1202
+ return [`Hub ${hubInfo.version} · `, ...plugin, keys];
1067
1203
  },
1068
1204
  empty: 'No matching app',
1069
1205
  rows: (_typed, q) => appRows(q),
@@ -1077,23 +1213,36 @@ const PALETTE_MODES = {
1077
1213
  const s = palette.appStats?.[row.id];
1078
1214
  if (s?.version) li.append(el('span', 'ver', s.version));
1079
1215
  if (s?.plugin) li.append(pluginNode(s.plugin));
1216
+ if (s?.linked) {
1217
+ const badge = el('span', 'linked', 'linked');
1218
+ badge.title = 'Runs from a local folder (ext install). Restart (Ctrl+R) loads its code changes.';
1219
+ li.append(badge);
1220
+ }
1080
1221
  if (s?.slowMs) {
1081
1222
  const badge = el('span', 'slow', 'slow port');
1082
1223
  badge.title = `Its port delays requests by up to ${(s.slowMs / 1000).toFixed(1)} s. Restart (Ctrl+R) moves it to a new port.`;
1083
1224
  li.append(badge);
1084
1225
  }
1085
- const stats = el('span', 'stats', s?.rss ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '');
1226
+ const usage = s?.rss ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '';
1227
+ const stats = el('span', 'stats', s?.stopped ? 'stopped' : usage);
1086
1228
  stats.title = 'Memory and CPU of the app process. Child processes are not counted. 100% is one core.';
1087
1229
  li.append(stats);
1088
1230
  const combo = appNumberCombo(row.index);
1089
- const key = row.id === activeApp ? 'active' : combo ? comboLabel(combo) : '';
1231
+ const key = row.id === activeApp ? 'active' : combo ? comboLabel(combo) : row.app.hidden ? 'hidden' : '';
1090
1232
  li.append(el('span', 'age', key));
1091
1233
  li.append(
1092
- rowButton('palette-copy', '⧉', 'c', 'Copy the URL (Ctrl+C)', `Copy the URL of ${row.app.name}`),
1093
- rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`),
1234
+ rowButton('palette-copy', 'copy', 'c', 'Copy the URL (Ctrl+C)', `Copy the URL of ${row.app.name}`),
1235
+ rowButton('palette-restart', 'restart', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`),
1236
+ s?.stopped
1237
+ ? rowButton('palette-toggle', 'start', 's', 'Start the app (Ctrl+S)', `Start ${row.app.name}`)
1238
+ : rowButton('palette-toggle', 'stop', 's', 'Stop the app (Ctrl+S)', `Stop ${row.app.name}`),
1094
1239
  );
1095
1240
  },
1096
- rowKeys: { c: (row) => copyToHint(apps[row.id].url), r: (row) => restartFromPalette(row.id) },
1241
+ rowKeys: {
1242
+ c: (row) => copyToHint(apps[row.id].url),
1243
+ r: (row) => appOpFromPalette(row.id, 'restart'),
1244
+ s: (row) => toggleFromPalette(row.id),
1245
+ },
1097
1246
  commit(row) {
1098
1247
  closePalette();
1099
1248
  switchTab(row.id);
@@ -1151,7 +1300,7 @@ function openPalette(mode) {
1151
1300
  palette.mode = mode;
1152
1301
  palette.query = '';
1153
1302
  palette.sel = null;
1154
- palette.confirmRestart = null;
1303
+ palette.confirmOp = null;
1155
1304
  palette.message = null;
1156
1305
  input.value = '';
1157
1306
  input.placeholder = spec.placeholder;
@@ -1294,12 +1443,18 @@ function bindPalette() {
1294
1443
  } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
1295
1444
  e.preventDefault();
1296
1445
  movePaletteSel(-1);
1297
- } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && ['c', 'd', 'r'].includes(e.key.toLowerCase())) {
1446
+ } else if (
1447
+ e.ctrlKey &&
1448
+ !e.altKey &&
1449
+ !e.shiftKey &&
1450
+ !e.metaKey &&
1451
+ ['c', 'd', 'r', 's'].includes(e.key.toLowerCase())
1452
+ ) {
1298
1453
  const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
1299
1454
  // Ctrl+C with text selected in the input keeps the browser's copy.
1300
1455
  if (e.key.toLowerCase() === 'c' && (!run || input.selectionStart !== input.selectionEnd)) return;
1301
1456
  // Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
1302
- // Ctrl+R would otherwise reload the hub page.
1457
+ // Ctrl+R would otherwise reload the hub page, and Ctrl+S save it.
1303
1458
  e.preventDefault();
1304
1459
  const row = palette.rows[palette.sel];
1305
1460
  if (run && row && row.kind !== 'literal') run(row);
package/public/index.html CHANGED
@@ -290,6 +290,15 @@
290
290
  font-size: 11px;
291
291
  }
292
292
 
293
+ .palette-row .linked {
294
+ margin-left: 10px;
295
+ padding: 0 6px;
296
+ border-radius: 4px;
297
+ color: var(--text-dim);
298
+ border: 1px solid var(--text-dim);
299
+ font-size: 11px;
300
+ }
301
+
293
302
  .palette-row .ver,
294
303
  .palette-hint .ver {
295
304
  color: var(--text-dim);
@@ -334,9 +343,10 @@
334
343
 
335
344
  .palette-row.selected .palette-icon { color: var(--accent); }
336
345
 
337
- .palette-row .palette-remove,
338
- .palette-row .palette-copy,
339
- .palette-row .palette-restart {
346
+ .palette-row .palette-btn {
347
+ display: inline-flex;
348
+ align-items: center;
349
+ align-self: center;
340
350
  margin-left: auto;
341
351
  padding: 0 6px;
342
352
  border: none;
@@ -349,20 +359,17 @@
349
359
  opacity: 0;
350
360
  }
351
361
 
352
- .palette-row:hover .palette-remove,
353
- .palette-row.selected .palette-remove,
354
- .palette-row:hover .palette-copy,
355
- .palette-row.selected .palette-copy,
356
- .palette-row:hover .palette-restart,
357
- .palette-row.selected .palette-restart { opacity: 1; }
362
+ .palette-row:hover .palette-btn,
363
+ .palette-row.selected .palette-btn { opacity: 1; }
364
+
365
+ .palette-row .palette-btn:hover { color: var(--accent); }
358
366
 
359
367
  .palette-row .palette-remove:hover { color: #e5484d; }
360
368
 
361
- .palette-row .age + .palette-copy,
362
- .palette-row .palette-copy + .palette-restart { margin-left: 0; }
369
+ .palette-row .age + .palette-btn,
370
+ .palette-row .palette-btn + .palette-btn { margin-left: 0; }
363
371
 
364
- .palette-row .palette-copy:hover,
365
- .palette-row .palette-restart:hover { color: var(--accent); }
372
+ .palette-row .palette-btn svg { width: 14px; height: 14px; }
366
373
 
367
374
  .palette-hint {
368
375
  padding: 8px 14px;