claude-code-hub 0.42.0 → 1.0.1

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
@@ -1,8 +1,16 @@
1
1
  let apps = {};
2
2
  let activeApp = null;
3
+ // The app launcher's cursor starts here, so Ctrl+Alt+A then Enter goes back to the last tab.
4
+ let previousApp = null;
3
5
  const iframes = {};
4
6
  const loadedApps = new Set();
5
7
  const guardedApps = new Set();
8
+ // App id → the topics its hub:hello subscribes to. An app is live from its hello until the next
9
+ // iframe load: e.source is the same WindowProxy across reloads, so a new document starts unknown.
10
+ const liveApps = new Map();
11
+ // Apps whose next document the hub loads from an action URL.
12
+ const linkLoads = new Set();
13
+ const HUB_PROTOCOLS = [1];
6
14
  // {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
7
15
  // reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
8
16
  // global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
@@ -14,9 +22,14 @@ let activeConfigDir = null;
14
22
  // hub-theme: starting unset means there is nothing to race against each sub-app's own
15
23
  // project self-restore on boot. Once set it never returns to null.
16
24
  let projectState = null;
25
+ // Topic → {from, payload}: the last value of each topic an app published. Cleared with projectState.
26
+ const published = new Map();
27
+ // App id → the published topics it missed while hidden. It gets their last value when it comes on screen.
28
+ const missed = new Map();
29
+ const MAX_EVENT_CHARS = 16 * 1024;
17
30
  // The config dir the hub falls back to (~/.claude). Stays out of the window title.
18
31
  let defaultConfigDir = null;
19
- // mode: 'project' (Ctrl+Alt+P) or 'configDir' (Ctrl+Alt+W). One widget, two row sources.
32
+ // mode: 'project' (Ctrl+Alt+P), 'configDir' (Ctrl+Alt+W) or 'app' (Ctrl+Alt+A). One widget, three row sources.
20
33
  const palette = {
21
34
  open: false,
22
35
  mode: 'project',
@@ -25,6 +38,7 @@ const palette = {
25
38
  rows: [],
26
39
  sel: 0,
27
40
  query: '',
41
+ confirmRestart: null,
28
42
  };
29
43
 
30
44
  function loadThemeState() {
@@ -39,8 +53,10 @@ function loadThemeState() {
39
53
  }
40
54
 
41
55
  // The color theme of the active config dir, falling back to the last one picked anywhere.
56
+ // An id the registry lacks shows as Ember everywhere, so the hub page and every app agree.
42
57
  function activeColorTheme() {
43
- return themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
58
+ const id = themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
59
+ return id && hubThemes.length && !themeVars[id] ? 'ember' : id;
44
60
  }
45
61
 
46
62
  function osTheme() {
@@ -49,14 +65,36 @@ function osTheme() {
49
65
 
50
66
  // Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
51
67
  // so an unset theme resolves to the OS preference here and reaches every iframe on load.
52
- function themeMessage() {
53
- return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
68
+ function themePayload() {
69
+ return { theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
54
70
  }
55
71
 
56
- // {<themeId>: {dark, light}}, each a map of CSS variables, from /api/config, which derives it from
57
- // lib/themes.json — the same registry that generates each sub-app's themes.css. Empty until
58
- // config arrives, and empty if the registry is unreadable; then the hub keeps index.html's colors.
59
- let themePalettes = {};
72
+ function sendTheme(appId) {
73
+ const payload = themePayload();
74
+ const vars = themeVars[payload.colorTheme ?? 'ember']?.[payload.theme];
75
+ sendTopic(appId, 'theme.changed', vars ? { ...payload, vars } : payload);
76
+ }
77
+
78
+ // {<themeId>: {dark, light}}, each a map of the apps' core CSS variables, from /api/config, which
79
+ // derives it from lib/themes.json — the same registry that generates each sub-app's themes.css.
80
+ // Empty until config arrives, and empty if the registry is unreadable; then the hub keeps
81
+ // index.html's colors.
82
+ let themeVars = {};
83
+ // The picker list that welcome carries.
84
+ let hubThemes = [];
85
+ // {<action>: {app, params, url, mode}} from the manifests, one handler each, from /api/config.
86
+ let actions = {};
87
+ // The hub page's names for the core variables. --bg is the apps' --bg-deep, so the loading screen
88
+ // hands off to an iframe without a flash of another color.
89
+ const HUB_VARS = {
90
+ '--accent': '--accent',
91
+ '--bg': '--bg-deep',
92
+ '--surface': '--bg-surface',
93
+ '--surface-hover': '--bg-hover',
94
+ '--border': '--border',
95
+ '--text': '--text-primary',
96
+ '--text-dim': '--text-muted',
97
+ };
60
98
  // The last applied variables, so the loading screen paints in the theme before /api/config lands.
61
99
  const PALETTE_KEY = 'hub-palette';
62
100
 
@@ -67,7 +105,8 @@ function applyHubTheme() {
67
105
  const root = document.documentElement;
68
106
  root.classList.toggle('light', mode === 'light');
69
107
  const key = `${activeColorTheme()}/${mode}`;
70
- let vars = themePalettes[activeColorTheme()]?.[mode];
108
+ const core = themeVars[activeColorTheme()]?.[mode];
109
+ let vars = core && Object.fromEntries(Object.entries(HUB_VARS).map(([hub, name]) => [hub, core[name]]));
71
110
  try {
72
111
  if (vars) localStorage.setItem(PALETTE_KEY, JSON.stringify({ key, vars }));
73
112
  else {
@@ -94,15 +133,6 @@ function parentOf(p) {
94
133
  return i <= 0 ? '' : p.slice(0, i);
95
134
  }
96
135
 
97
- function projectMessage() {
98
- return {
99
- type: 'hub:project',
100
- project: projectState.project,
101
- encoded: projectState.encoded,
102
- name: projectState.name,
103
- };
104
- }
105
-
106
136
  function originOf(appId) {
107
137
  return new URL(apps[appId].url).origin;
108
138
  }
@@ -110,7 +140,7 @@ function originOf(appId) {
110
140
  function postTo(appId, message) {
111
141
  // Before the iframe commits its src its contentWindow is still on the hub's own
112
142
  // origin, so a send addressed to the sub-app origin is refused and logged. The
113
- // load handler posts the current state anyway, so skipping here loses nothing.
143
+ // app's hello brings the current state anyway, so skipping here loses nothing.
114
144
  if (!loadedApps.has(appId)) return;
115
145
  iframes[appId]?.contentWindow?.postMessage(message, originOf(appId));
116
146
  }
@@ -119,28 +149,62 @@ function appIdOf(source) {
119
149
  return Object.keys(iframes).find((id) => iframes[id].contentWindow === source);
120
150
  }
121
151
 
122
- // exceptWindow lets an echo of a sub-app's own message skip that sub-app.
123
- function broadcast(message, exceptWindow) {
124
- for (const [id, iframe] of Object.entries(iframes)) {
125
- if (exceptWindow && iframe.contentWindow === exceptWindow) continue;
126
- postTo(id, message);
127
- }
152
+ // The last value lives in themeState and projectState, so a replay after welcome is a send.
153
+ function sendTopic(appId, topic, payload) {
154
+ if (liveApps.get(appId)?.has(topic)) postTo(appId, { type: 'hub:event', topic, payload });
128
155
  }
129
156
 
130
157
  function setProject(absPath) {
131
158
  projectState = { project: absPath, encoded: encodeProjectPath(absPath), name: basename(absPath) };
132
- broadcast(projectMessage());
159
+ for (const id of Object.keys(iframes)) sendProject(id);
133
160
  }
134
161
 
135
- function postProjectTo(appId) {
162
+ function sendProject(appId) {
136
163
  if (!projectState) return;
137
- postTo(appId, projectMessage());
164
+ sendTopic(appId, 'project.changed', projectState);
165
+ }
166
+
167
+ // Protocol §10. A payload with a string `project` gets the hub's encoded form and short name, as in project.changed.
168
+ function onPublish(appId, { topic, payload }) {
169
+ if (!apps[appId].publishes.includes(topic)) return;
170
+ if (payload !== null && !isPlainObject(payload)) return;
171
+ try {
172
+ if (JSON.stringify(payload).length > MAX_EVENT_CHARS) return;
173
+ } catch {
174
+ return;
175
+ }
176
+ const event =
177
+ typeof payload?.project === 'string'
178
+ ? { ...payload, encoded: encodeProjectPath(payload.project), projectName: basename(payload.project) }
179
+ : payload;
180
+ published.set(topic, { from: appId, payload: event });
181
+ for (const id of Object.keys(iframes)) {
182
+ if (id === appId) continue;
183
+ if (id === activeApp) sendTopic(id, topic, event);
184
+ else if (liveApps.get(id)?.has(topic)) missed.set(id, (missed.get(id) ?? new Set()).add(topic));
185
+ }
186
+ }
187
+
188
+ function sendPublished(appId) {
189
+ missed.delete(appId);
190
+ for (const [topic, { from, payload }] of published) if (from !== appId) sendTopic(appId, topic, payload);
191
+ }
192
+
193
+ function sendMissed(appId) {
194
+ for (const topic of missed.get(appId) ?? []) {
195
+ const last = published.get(topic);
196
+ if (last && last.from !== appId) sendTopic(appId, topic, last.payload);
197
+ }
198
+ missed.delete(appId);
138
199
  }
139
200
 
140
201
  // Sub-apps can't detect this themselves: inactive iframes are display:none, and a nested
141
202
  // document's visibilityState still follows the top-level tab. Used for polling/auto-refresh.
142
203
  function postActiveTo(appId) {
143
- postTo(appId, { type: 'hub:active', active: appId === activeApp });
204
+ if (!liveApps.has(appId)) return;
205
+ const active = appId === activeApp;
206
+ if (active) sendMissed(appId);
207
+ postTo(appId, { type: 'hub:active', active });
144
208
  }
145
209
 
146
210
  async function init() {
@@ -154,10 +218,13 @@ async function init() {
154
218
  defaultConfigDir = config.defaultConfigDir ?? null;
155
219
  activeConfigDir = config.activeConfigDir ?? null;
156
220
  applyTitle(config.activeConfigDir);
157
- themePalettes = config.themePalettes ?? {};
221
+ themeVars = config.themeVars ?? {};
222
+ hubThemes = config.themes ?? [];
223
+ actions = config.actions ?? {};
158
224
  applyHubTheme();
159
225
  buildIframes();
160
- switchTab(Object.keys(apps)[0]);
226
+ const saved = storedTab();
227
+ switchTab(apps[saved] ? saved : Object.keys(apps)[0]);
161
228
  listenMessages();
162
229
  listenKeys();
163
230
  bindPalette();
@@ -177,8 +244,33 @@ function applyTitle(dir) {
177
244
  function reloadIframes() {
178
245
  loadedApps.clear();
179
246
  projectState = null;
180
- // Assigning src navigates even when the URL is unchanged.
181
- for (const [id, iframe] of Object.entries(iframes)) iframe.src = appSrc(id);
247
+ published.clear();
248
+ missed.clear();
249
+ // A new element, because assigning src is only a fragment navigation when the app's URL
250
+ // differs from the new one by the #t= fragment alone, and the old dir's document stays.
251
+ for (const id of Object.keys(iframes)) replaceIframe(id);
252
+ }
253
+
254
+ function replaceIframe(id) {
255
+ const old = iframes[id];
256
+ liveApps.delete(id);
257
+ linkLoads.delete(id);
258
+ loadedApps.delete(id);
259
+ const fresh = makeIframe(id);
260
+ fresh.className = old.className;
261
+ fresh.inert = old.inert;
262
+ const focused = document.activeElement === old;
263
+ old.replaceWith(fresh);
264
+ iframes[id] = fresh;
265
+ if (focused) fresh.focus();
266
+ }
267
+
268
+ // The old document stays live until the new one fires load, and a hub:action sent to it in between is lost.
269
+ function loadApp(id, src, { link = false } = {}) {
270
+ liveApps.delete(id);
271
+ if (link) linkLoads.add(id);
272
+ else linkLoads.delete(id);
273
+ iframes[id].src = src;
182
274
  }
183
275
 
184
276
  // The terminal token rides in the fragment so it never reaches a server log or a Referer.
@@ -187,57 +279,17 @@ function appSrc(id, path = '') {
187
279
  return apps[id].url + path + (token ? `#t=${token}` : '');
188
280
  }
189
281
 
190
- const LOADING_VERBS = {
191
- kanban: [
192
- 'Shuffling cards…',
193
- 'Herding tasks…',
194
- 'Waking sessions…',
195
- 'Sorting the board…',
196
- 'Pinning sticky notes…',
197
- 'Moving cards to done…',
198
- 'Nudging agents…',
199
- 'Drawing columns…',
200
- 'Reading the backlog…',
201
- ],
202
- cost: [
203
- 'Counting tokens…',
204
- 'Tallying the bill…',
205
- 'Stacking bars…',
206
- 'Crunching numbers…',
207
- 'Checking the cache hits…',
208
- 'Adding up the days…',
209
- 'Balancing the books…',
210
- 'Plotting the trend…',
211
- 'Weighing the models…',
212
- ],
213
- memory: [
214
- 'Recalling memories…',
215
- 'Walking the tree…',
216
- 'Dusting off notes…',
217
- 'Reading CLAUDE.md…',
218
- 'Following imports…',
219
- 'Tracing the context…',
220
- 'Flipping through pages…',
221
- 'Connecting the dots…',
222
- 'Checking what sticks…',
223
- ],
224
- marketplace: [
225
- 'Unpacking plugins…',
226
- 'Polishing skills…',
227
- 'Dusting off agents…',
228
- 'Wiring up hooks…',
229
- 'Fetching manifests…',
230
- 'Reading SKILL.md files…',
231
- 'Listing commands…',
232
- 'Scanning MCP servers…',
233
- 'Gathering plugins…',
234
- ],
235
- };
282
+ // From each app's manifest. Before /api/config lands there are none, and the first line is generic.
283
+ function loadingVerbs() {
284
+ const own = apps[activeApp]?.loading?.verbs ?? [];
285
+ const verbs = own.length ? own : Object.values(apps).flatMap((a) => a.loading?.verbs ?? []);
286
+ return verbs.length ? verbs : ['Loading…'];
287
+ }
236
288
 
237
289
  let loadingTimer = null;
238
290
 
239
291
  function showLoading() {
240
- const verbs = LOADING_VERBS[activeApp] ?? Object.values(LOADING_VERBS).flat();
292
+ const verbs = loadingVerbs();
241
293
  const text = document.getElementById('loading-text');
242
294
  let i = Math.floor(Math.random() * verbs.length);
243
295
  text.textContent = verbs[i];
@@ -255,23 +307,41 @@ function hideLoading() {
255
307
  document.getElementById('loading-overlay').classList.add('fade-out');
256
308
  }
257
309
 
310
+ function makeIframe(id) {
311
+ const iframe = document.createElement('iframe');
312
+ iframe.id = `iframe-${id}`;
313
+ iframe.src = appSrc(id);
314
+ iframe.className = 'hidden';
315
+ iframe.allow = 'clipboard-write; microphone';
316
+ iframe.addEventListener('load', () => onIframeLoad(id));
317
+ return iframe;
318
+ }
319
+
258
320
  function buildIframes() {
259
321
  const container = document.getElementById('iframe-container');
260
322
  for (const id of Object.keys(apps)) {
261
- const iframe = document.createElement('iframe');
262
- iframe.id = `iframe-${id}`;
263
- iframe.src = appSrc(id);
264
- iframe.className = 'hidden';
265
- iframe.allow = 'clipboard-write; microphone';
266
- iframe.addEventListener('load', () => onIframeLoad(id));
267
- container.appendChild(iframe);
268
- iframes[id] = iframe;
323
+ iframes[id] = makeIframe(id);
324
+ container.appendChild(iframes[id]);
325
+ }
326
+ }
327
+
328
+ const TAB_KEY = 'hub-tab';
329
+
330
+ function storedTab() {
331
+ try {
332
+ return localStorage.getItem(TAB_KEY);
333
+ } catch {
334
+ return null;
269
335
  }
270
336
  }
271
337
 
272
338
  function switchTab(appId) {
273
339
  if (!apps[appId]) return;
340
+ if (appId !== activeApp) previousApp = activeApp;
274
341
  activeApp = appId;
342
+ try {
343
+ localStorage.setItem(TAB_KEY, appId);
344
+ } catch {}
275
345
 
276
346
  for (const [id, iframe] of Object.entries(iframes)) {
277
347
  iframe.classList.toggle('hidden', id !== appId);
@@ -285,20 +355,73 @@ function switchTab(appId) {
285
355
  }
286
356
 
287
357
  function onIframeLoad(appId) {
358
+ liveApps.delete(appId);
288
359
  loadedApps.add(appId);
289
360
  guardedApps.delete(appId);
290
361
  if (appId === activeApp) hideLoading();
291
- const postState = () => {
292
- postTo(appId, themeMessage());
293
- postProjectTo(appId);
294
- postActiveTo(appId);
295
- postTo(appId, { type: 'hub:keys', keys: Object.keys(bindings()) });
296
- };
297
- postState();
298
- // Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
299
- // an async /hub-config fetch that resolves after this load event, so the first post can be
300
- // dropped. Safe to repeat — every shim's apply is idempotent.
301
- setTimeout(postState, 400);
362
+ }
363
+
364
+ function onHello(appId, data) {
365
+ const common = Array.isArray(data.protocol) ? HUB_PROTOCOLS.filter((v) => data.protocol.includes(v)) : [];
366
+ if (!common.length) return;
367
+ liveApps.set(appId, new Set(Array.isArray(data.subscribes) ? data.subscribes : []));
368
+ postTo(appId, {
369
+ type: 'hub:welcome',
370
+ protocol: Math.max(...common),
371
+ forward: Object.keys(bindings()),
372
+ themes: hubThemes,
373
+ actions: Object.keys(actions),
374
+ });
375
+ sendTheme(appId);
376
+ // A document the hub loaded from an action URL got its project from that URL. The hub's own
377
+ // project would undo the link, so it goes out only with the next change.
378
+ if (!linkLoads.delete(appId)) sendProject(appId);
379
+ sendPublished(appId);
380
+ postActiveTo(appId);
381
+ }
382
+
383
+ // A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
384
+ function navigateApp(appId, url) {
385
+ const src = appSrc(appId, url);
386
+ if (!URL.canParse(src) || new URL(src).origin !== originOf(appId)) return;
387
+ loadApp(appId, src, { link: true });
388
+ }
389
+
390
+ function isPlainObject(v) {
391
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
392
+ }
393
+
394
+ function badParams(declared, params) {
395
+ if (!isPlainObject(params)) return true;
396
+ const given = Object.entries(params);
397
+ if (given.some(([name, value]) => !Object.hasOwn(declared, name) || typeof value !== 'string')) return true;
398
+ return Object.entries(declared).some(([name, type]) => type === 'string' && !Object.hasOwn(params, name));
399
+ }
400
+
401
+ // Null when the template names a param the call left out.
402
+ function fillUrl(template, params) {
403
+ const slot = /\{([^}]+)\}/g;
404
+ if ([...template.matchAll(slot)].some(([, name]) => !Object.hasOwn(params, name))) return null;
405
+ return template.replace(slot, (_, name) => encodeURIComponent(params[name]));
406
+ }
407
+
408
+ // Protocol §7. The result does not wait for the handler: the hub queues nothing.
409
+ function onInvoke(appId, { id, action, params = {} }) {
410
+ if (typeof id !== 'string') return;
411
+ const reply = (result) => postTo(appId, { type: 'hub:result', id, ...result });
412
+ const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
413
+ if (!spec) return reply({ ok: false, reason: 'unhandled' });
414
+ if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
415
+ const { app: handler, mode, url: template } = spec;
416
+ switchTab(handler);
417
+ if (mode === 'message' && liveApps.has(handler)) {
418
+ postTo(handler, { type: 'hub:action', id, action, params });
419
+ } else {
420
+ // Setting src reloads the app, so a guarded app only gets the switch.
421
+ const url = template && fillUrl(template, params);
422
+ if (url && !guardedApps.has(handler)) navigateApp(handler, url);
423
+ }
424
+ reply({ ok: true, handledBy: handler });
302
425
  }
303
426
 
304
427
  function listenMessages() {
@@ -309,13 +432,12 @@ function listenMessages() {
309
432
  const appId = appIdOf(e.source);
310
433
  if (!appId || e.origin !== originOf(appId)) return;
311
434
  const data = e.data ?? {};
312
- if (data.type === 'hub:navigate') {
313
- if (!apps[data.app]) return;
314
- switchTab(data.app);
315
- if (typeof data.url !== 'string' || !data.url) return;
316
- // A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
317
- const src = appSrc(data.app, data.url);
318
- if (URL.canParse(src) && new URL(src).origin === originOf(data.app)) iframes[data.app].src = src;
435
+ if (data.type === 'hub:hello') {
436
+ onHello(appId, data);
437
+ } else if (data.type === 'hub:invoke') {
438
+ onInvoke(appId, data);
439
+ } else if (data.type === 'hub:publish') {
440
+ onPublish(appId, data);
319
441
  } else if (data.type === 'hub:keydown') {
320
442
  handleForwardedKey(data);
321
443
  } else if (data.type === 'hub:closeGuard') {
@@ -333,7 +455,7 @@ function listenMessages() {
333
455
  .catch(() => {});
334
456
  } else if (data.type === 'hub:openExternal') {
335
457
  // In the installed PWA window a framed sub-app's own target=_blank opens
336
- // nothing, so the shims hand external links up to the top frame instead.
458
+ // nothing, so the SDK hands external links up to the top frame instead.
337
459
  let url;
338
460
  try {
339
461
  url = new URL(String(data.url));
@@ -343,7 +465,7 @@ function listenMessages() {
343
465
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
344
466
  window.open(url.href, '_blank', 'noopener');
345
467
  } else if (data.type === 'hub:theme') {
346
- // Legacy senders pass only {theme}; colorTheme is optional and sticky.
468
+ // colorTheme is optional and sticky.
347
469
  if (data.theme !== 'light' && data.theme !== 'dark') return;
348
470
  const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
349
471
  const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
@@ -355,32 +477,31 @@ function listenMessages() {
355
477
  }
356
478
  localStorage.setItem('hub-theme', JSON.stringify(themeState));
357
479
  applyHubTheme();
358
- broadcast(themeMessage(), e.source);
480
+ // The sender gets the echo too, for the vars.
481
+ for (const id of Object.keys(iframes)) sendTheme(id);
359
482
  }
360
483
  });
361
484
  }
362
485
 
363
486
  function handleForwardedKey(d) {
364
- // The modifier fields are new. A sub-app running an older service-worker-cached bundle sends
365
- // {key} only; it forwards nothing but the pre-existing bindings, and each of those is
366
- // unambiguous from the key alone, so normalize instead of dispatching twice.
367
- const legacy = typeof d.alt !== 'boolean';
368
487
  const e = {
369
488
  key: d.key,
370
489
  code: d.code,
371
- altKey: legacy || d.alt,
372
- ctrlKey: legacy ? d.key.startsWith('Arrow') : d.ctrl,
490
+ ctrlKey: d.ctrl === true,
491
+ altKey: d.alt === true,
373
492
  shiftKey: d.shift === true,
493
+ metaKey: d.meta === true,
374
494
  };
375
495
  bindings()[comboOf(e)]?.run();
376
496
  }
377
497
 
378
- // The one keymap. Its combo names are what the hub:keys message lists for the shims.
498
+ // The one keymap. Its combo names are what welcome.forward lists for the apps.
379
499
  // inPalette: the binding still fires while the palette is open.
380
500
  function bindings() {
381
501
  const map = {
382
502
  'ctrl+alt+p': { run: () => togglePalette('project'), inPalette: true },
383
503
  'ctrl+alt+w': { run: () => togglePalette('configDir'), inPalette: true },
504
+ 'ctrl+alt+a': { run: () => togglePalette('app'), inPalette: true },
384
505
  'ctrl+alt+ArrowLeft': { run: () => cycleTab(-1) },
385
506
  'ctrl+alt+ArrowRight': { run: () => cycleTab(1) },
386
507
  };
@@ -392,7 +513,7 @@ function bindings() {
392
513
  return map;
393
514
  }
394
515
 
395
- // Each shim's hubCombo() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
516
+ // The SDK's comboOf() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
396
517
  // '+' to the key as bindingKey() names it.
397
518
  function comboOf(e) {
398
519
  const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
@@ -569,6 +690,114 @@ async function removeConfigDir(dirPath) {
569
690
  if (palette.open && palette.mode === 'configDir') renderPalette();
570
691
  }
571
692
 
693
+ // Lucide icons (ISC license) for the names the built-in apps and the Inspector use. Any other
694
+ // manifest icon shows the app's first letter.
695
+ const APP_ICONS = {
696
+ columns: [
697
+ ['rect', { width: 18, height: 18, x: 3, y: 3, rx: 2 }],
698
+ ['path', { d: 'M12 3v18' }],
699
+ ],
700
+ store: [
701
+ ['path', { d: 'm2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7' }],
702
+ ['path', { d: 'M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8' }],
703
+ ['path', { d: 'M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4' }],
704
+ ['path', { d: 'M2 7h20' }],
705
+ [
706
+ 'path',
707
+ {
708
+ d: 'M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7',
709
+ },
710
+ ],
711
+ ],
712
+ 'dollar-sign': [
713
+ ['line', { x1: 12, x2: 12, y1: 2, y2: 22 }],
714
+ ['path', { d: 'M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' }],
715
+ ],
716
+ database: [
717
+ ['ellipse', { cx: 12, cy: 5, rx: 9, ry: 3 }],
718
+ ['path', { d: 'M3 5V19A9 3 0 0 0 21 19V5' }],
719
+ ['path', { d: 'M3 12A9 3 0 0 0 21 12' }],
720
+ ],
721
+ 'scan-search': [
722
+ ['path', { d: 'M3 7V5a2 2 0 0 1 2-2h2' }],
723
+ ['path', { d: 'M17 3h2a2 2 0 0 1 2 2v2' }],
724
+ ['path', { d: 'M21 17v2a2 2 0 0 1-2 2h-2' }],
725
+ ['path', { d: 'M7 21H5a2 2 0 0 1-2-2v-2' }],
726
+ ['circle', { cx: 12, cy: 12, r: 3 }],
727
+ ['path', { d: 'm16 16-1.9-1.9' }],
728
+ ],
729
+ };
730
+
731
+ const SVG_NS = 'http://www.w3.org/2000/svg';
732
+
733
+ function svgNode(tag, attrs) {
734
+ const node = document.createElementNS(SVG_NS, tag);
735
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
736
+ return node;
737
+ }
738
+
739
+ function appIcon(app) {
740
+ const shapes = APP_ICONS[app.icon];
741
+ if (!shapes) return el('span', 'palette-icon letter', (app.name || '?').slice(0, 1).toUpperCase());
742
+ const svg = svgNode('svg', {
743
+ viewBox: '0 0 24 24',
744
+ fill: 'none',
745
+ stroke: 'currentColor',
746
+ 'stroke-width': 2,
747
+ 'stroke-linecap': 'round',
748
+ 'stroke-linejoin': 'round',
749
+ 'aria-hidden': 'true',
750
+ });
751
+ svg.append(...shapes.map(([tag, attrs]) => svgNode(tag, attrs)));
752
+ const box = el('span', 'palette-icon');
753
+ box.append(svg);
754
+ return box;
755
+ }
756
+
757
+ function appRows(q) {
758
+ const list = Object.entries(apps).map(([id, a], i) => ({ kind: 'app', id, app: a, index: i }));
759
+ if (!q) return list;
760
+ const hit = list.filter((r) => r.app.name.toLowerCase().includes(q) || r.id.includes(q));
761
+ return hit.length ? hit : list.filter((r) => subseq(r.app.name.toLowerCase(), q));
762
+ }
763
+
764
+ // A click on the button runs the mode's rowKeys entry for `key`, the same as Ctrl+<key>.
765
+ function rowButton(className, glyph, key, title, label) {
766
+ const btn = el('button', className, glyph);
767
+ btn.type = 'button';
768
+ btn.title = title;
769
+ btn.setAttribute('aria-label', label);
770
+ btn.dataset.rowKey = key;
771
+ return btn;
772
+ }
773
+
774
+ function setPaletteHint(text) {
775
+ document.getElementById('palette-hint').textContent = text;
776
+ }
777
+
778
+ // The terminal provider answers 409 with a terminal count; the second Ctrl+R on the same app sends force.
779
+ async function restartFromPalette(id) {
780
+ const name = apps[id]?.name ?? id;
781
+ const force = palette.confirmRestart === id;
782
+ palette.confirmRestart = null;
783
+ setPaletteHint(`Restarting ${name}…`);
784
+ try {
785
+ await withPaletteBusy(() => sendJson('POST', `/api/apps/${encodeURIComponent(id)}/restart`, { force }));
786
+ } catch (err) {
787
+ if (err.status === 409 && err.data?.terminals) {
788
+ palette.confirmRestart = id;
789
+ const n = err.data.terminals;
790
+ setPaletteHint(
791
+ `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A restart ends ${n === 1 ? 'it' : 'them'}. Press Ctrl+R again to restart.`,
792
+ );
793
+ } else setPaletteHint(`${name} did not restart: ${err.message}`);
794
+ return;
795
+ }
796
+ closePalette();
797
+ replaceIframe(id);
798
+ switchTab(id);
799
+ }
800
+
572
801
  // `typed` is the trimmed query, `q` its lowercase form for matching.
573
802
  const PALETTE_MODES = {
574
803
  project: {
@@ -626,14 +855,33 @@ const PALETTE_MODES = {
626
855
  li.append(el('span', 'age', 'active'));
627
856
  return;
628
857
  }
629
- const remove = el('button', 'palette-remove', '×');
630
- remove.type = 'button';
631
- remove.title = 'Remove from list (Ctrl+D)';
632
- remove.dataset.remove = row.path;
633
- li.append(remove);
858
+ li.append(rowButton('palette-remove', '×', 'd', 'Remove from list (Ctrl+D)', `Remove ${row.path}`));
634
859
  },
860
+ rowKeys: { d: (row) => removeConfigDir(row.path) },
635
861
  commit: commitConfigDir,
636
862
  },
863
+ app: {
864
+ placeholder: 'Search apps...',
865
+ label: 'Switch app',
866
+ hint: 'Enter switch · Ctrl+R restart the app',
867
+ empty: 'No matching app',
868
+ rows: (_typed, q) => appRows(q),
869
+ initialSel(rows) {
870
+ const prev = rows.findIndex((r) => r.id === previousApp);
871
+ return prev < 0 ? 0 : prev;
872
+ },
873
+ fillRow(li, row, q) {
874
+ li.append(appIcon(row.app), markedSpan('name', row.app.name, q));
875
+ const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
876
+ li.append(el('span', 'age', key));
877
+ li.append(rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`));
878
+ },
879
+ rowKeys: { r: (row) => restartFromPalette(row.id) },
880
+ commit(row) {
881
+ closePalette();
882
+ switchTab(row.id);
883
+ },
884
+ },
637
885
  };
638
886
 
639
887
  function renderPalette() {
@@ -686,10 +934,11 @@ function openPalette(mode) {
686
934
  palette.mode = mode;
687
935
  palette.query = '';
688
936
  palette.sel = null;
937
+ palette.confirmRestart = null;
689
938
  input.value = '';
690
939
  input.placeholder = spec.placeholder;
691
940
  document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
692
- document.getElementById('palette-hint').textContent = spec.hint ?? '';
941
+ setPaletteHint(spec.hint ?? '');
693
942
  document.getElementById('palette').hidden = false;
694
943
  // Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
695
944
  // or take focus back, and then Escape is handled inside it — the palette stays open and only the
@@ -698,8 +947,7 @@ function openPalette(mode) {
698
947
  renderPalette();
699
948
  input.focus();
700
949
  // Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
701
- spec
702
- .load()
950
+ Promise.resolve(spec.load?.())
703
951
  .then(() => {
704
952
  if (palette.open && palette.mode === mode) renderPalette();
705
953
  })
@@ -745,7 +993,7 @@ async function sendJson(method, url, body) {
745
993
  body: body === undefined ? undefined : JSON.stringify(body),
746
994
  });
747
995
  const data = await res.json().catch(() => ({}));
748
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
996
+ if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status, data });
749
997
  return data;
750
998
  }
751
999
 
@@ -823,22 +1071,24 @@ function bindPalette() {
823
1071
  } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
824
1072
  e.preventDefault();
825
1073
  movePaletteSel(-1);
826
- } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'd') {
1074
+ } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && ['d', 'r'].includes(e.key.toLowerCase())) {
827
1075
  // Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
1076
+ // Ctrl+R would otherwise reload the hub page.
828
1077
  e.preventDefault();
1078
+ const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
829
1079
  const row = palette.rows[palette.sel];
830
- if (row?.kind === 'dir') removeConfigDir(row.path);
1080
+ if (run && row && row.kind !== 'literal') run(row);
831
1081
  }
832
1082
  });
833
1083
  document.getElementById('palette-list').addEventListener('click', (e) => {
834
- const remove = e.target.closest('.palette-remove');
835
- if (remove) {
1084
+ const btn = e.target.closest('[data-row-key]');
1085
+ const li = e.target.closest('.palette-row');
1086
+ if (btn) {
836
1087
  e.stopPropagation();
837
- removeConfigDir(remove.dataset.remove);
1088
+ PALETTE_MODES[palette.mode].rowKeys[btn.dataset.rowKey](palette.rows[Number(li.dataset.idx)]);
838
1089
  document.getElementById('palette-input').focus();
839
1090
  return;
840
1091
  }
841
- const li = e.target.closest('.palette-row');
842
1092
  if (!li) return;
843
1093
  palette.sel = Number(li.dataset.idx);
844
1094
  commitPalette();