flexdesk 0.8.0 → 0.9.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.
@@ -16,6 +16,66 @@
16
16
  * loads it into the primary tile. The toggles flip the corresponding
17
17
  * panel-tile on/off. Typing searches the injected entity catalog
18
18
  * (fetched lazily on first open).
19
+ *
20
+ * ══ C39. THE GROUPED LAYOUT — "FIND ANYTHING" ═══════════════════════════
21
+ *
22
+ * An embedder whose palette is its product's main opener wants a box that
23
+ * finds everything and shows nothing it did not ask for. Every option below
24
+ * is OFF unless passed, so the box above is what an embedder that passes
25
+ * none of them still gets:
26
+ *
27
+ * ┌─────────────────────────────────────────┐
28
+ * │ 🔍 [scope ×] search… Esc │
29
+ * ├─────────────────────────────────────────┤
30
+ * │ TABLES │ a header per group, groups
31
+ * │ ▦ Cust·omers Sales ↵ open Tab rows │ ordered by their best hit;
32
+ * │ ▦ Cust·omer contacts Table │ matched characters marked
33
+ * │ ⌄ Show all 9 tables │ the cap's way out
34
+ * ├─────────────────────────────────────────┤
35
+ * │ ↑↓ move ↵ open note │
36
+ * └─────────────────────────────────────────┘
37
+ *
38
+ * chrome `{ pages: false, panels: false }` omits the chip strip and
39
+ * the panel toggles.
40
+ * layout `'grouped'` turns on the layout above: groups, the tiered
41
+ * scorer with marked matches, the taxonomy's word for a kind
42
+ * instead of the kind's id, key hints on the active row.
43
+ * groupCap rows per group before *Show all N* (grouped, unscoped; 4).
44
+ * empty `() => rows | Promise<rows>` — what the box shows before
45
+ * anything is typed, under `emptyLabel` ('Recent').
46
+ * commands `() => rows` — the embedder's commands, beside the built-in
47
+ * ones. A row with `action` runs it; one without is handed to
48
+ * `onPick`. In the grouped layout they compete only once
49
+ * `commandMinChars` (2) are typed, and `>` restricts to them.
50
+ * drill `(pick) => scope | null` — Tab on a row enters a scope:
51
+ * `{ id?, label, icon?, placeholder?, note?, emptyText?,
52
+ * minChars?, search(q, signal) }`. `search` answers remotely;
53
+ * it is debounced, and a stale answer is aborted and never
54
+ * drawn over a newer one.
55
+ * cache true: a re-open draws the previous catalogue at once and
56
+ * refreshes behind it. `palette.invalidate()` drops it.
57
+ * tips strings drawn under Recent when nothing is typed.
58
+ * footerNote a quiet sentence at the footer's right end.
59
+ *
60
+ * A row may carry, besides `{kind, id, label, hint}`:
61
+ * context drawn after the label, NOT searched
62
+ * keywords searched, NOT drawn (a string or an array of strings)
63
+ * cells short values drawn in place of `context` (a scope's rows)
64
+ * group the group key, when it is not `kind`
65
+ * groupLabel the group's header, when the group is not a taxonomy kind
66
+ * kindLabel the word drawn on the right, when the taxonomy's is wrong
67
+ * boost added to the match score (an embedder's "recently opened")
68
+ * modes which of 'split' / 'window' the row offers (key hints)
69
+ * drillLabel the Tab hint ("search rows"); Tab is offered only with it
70
+ * shortcut a key binding drawn on the right
71
+ * verb the Enter hint ('open' by default, 'run' for a command)
72
+ * `hint` keeps its old meaning — drawn AND searched — for a row that sets
73
+ * neither `context` nor `keywords`. A group's header is the taxonomy's
74
+ * `plural` for the kind, else its `label`.
75
+ *
76
+ * `onPick(pick, { mode, scope })` is told how the pick was made: 'open'
77
+ * (Enter, click), 'split' (Ctrl+Enter, Ctrl+click) or 'window'
78
+ * (Shift+Enter, Shift+click); `scope` is the drilled scope's `id`, if any.
19
79
  */
20
80
 
21
81
  import { HelpModal } from '../help/help_modal.js';
@@ -26,46 +86,68 @@ const ROOT_ID = 'twm-cmdpal';
26
86
  // carry an `action` so `commit()` runs them instead of opening a tile.
27
87
  const STATIC_COMMANDS = [
28
88
  { kind: 'command', id: 'shortcuts', label: 'Keyboard shortcuts',
29
- hint: 'Show all key bindings (?)', icon: 'keyboard',
89
+ hint: 'Show all key bindings (?)', icon: 'keyboard', shortcut: '?', verb: 'run',
30
90
  action: () => HelpModal.open('keyboard-shortcuts') },
31
91
  ];
32
92
 
93
+ const DEFAULT_GROUP_CAP = 4;
94
+ const DEFAULT_COMMAND_MIN_CHARS = 2;
95
+ const REMOTE_DEBOUNCE_MS = 150;
96
+ const MAX_ROWS = 200;
97
+
33
98
  /** @param taxonomy injected ontology — supplies the chip strip + row icons
34
99
  * @param catalog injected entity catalog — supplies the searchable rows
35
100
  * @param placeholder input placeholder. It names the embedder's entity types
36
101
  * ("try foo:bar"), so the embedder owns the string.
37
- * @param onPick C30. `(pick) => truthy` CLAIMS the open, exactly the way
38
- * `ManagedWindow.onMaximize` claims the maximise gesture.
39
- * See `commit` for what it is for; omitted, the palette
40
- * behaves as it always has. */
102
+ * @param onPick C30. `(pick, how) => truthy` CLAIMS the open, exactly the
103
+ * way `ManagedWindow.onMaximize` claims the maximise
104
+ * gesture. See `commit` for what it is for; omitted, the
105
+ * palette behaves as it always has.
106
+ * @param chrome, layout, groupCap, empty, emptyLabel, commands,
107
+ * commandMinChars, drill, cache, tips, footerNote — C39, above. */
41
108
  export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = 'Search…',
42
- onPick = null }) {
109
+ onPick = null,
110
+ chrome = {}, layout = 'flat',
111
+ groupCap = DEFAULT_GROUP_CAP,
112
+ empty = null, emptyLabel = 'Recent',
113
+ commands = null,
114
+ commandMinChars = DEFAULT_COMMAND_MIN_CHARS,
115
+ drill = null, cache = false,
116
+ tips = null, footerNote = '' }) {
43
117
  if (!taxonomy) throw new Error('createCommandPalette: a taxonomy is required');
44
118
  if (!catalog) throw new Error('createCommandPalette: an entity catalog is required');
119
+ const grouped = layout === 'grouped';
45
120
  let overlay = null;
46
121
  let entities = [];
122
+ let cached = null; // C39 `cache`: the last loaded catalogue
123
+ let onClose = null;
47
124
 
48
125
  const isOpen = () => !!overlay;
49
126
  const close = () => {
50
127
  if (!overlay) return;
128
+ try { onClose?.(); } catch { /* ignore */ }
129
+ onClose = null;
51
130
  overlay.remove();
52
131
  overlay = null;
53
132
  };
54
133
  const toggle = () => { isOpen() ? close() : open(); };
134
+ const invalidate = () => { cached = null; };
55
135
 
56
136
  const open = async () => {
57
137
  if (overlay) return;
58
138
  overlay = document.createElement('div');
59
139
  overlay.id = ROOT_ID;
60
- overlay.className = 'twm-cmdpal-overlay';
61
- overlay.innerHTML = _markup(taxonomy, placeholder, wm);
140
+ overlay.className = 'twm-cmdpal-overlay' + (grouped ? ' twm-cmdpal-overlay--grouped' : '');
141
+ overlay.innerHTML = _markup(taxonomy, placeholder, wm, { chrome, grouped });
62
142
  document.body.appendChild(overlay);
143
+ const mine = overlay;
63
144
  overlay.addEventListener('click', (e) => {
64
145
  if (e.target === overlay) close();
65
146
  });
66
147
 
67
148
  const input = overlay.querySelector('.twm-cmdpal__input');
68
149
  const list = overlay.querySelector('[data-role="list"]');
150
+ const foot = overlay.querySelector('[data-role="footer"]');
69
151
  const chipSlot = overlay.querySelector('[data-role="type-chip"]');
70
152
  const chips = overlay.querySelectorAll('[data-shortcut]');
71
153
  const toggles = overlay.querySelectorAll('[data-toggle]');
@@ -101,7 +183,32 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
101
183
  let typeFilter = null; // null | string[] (navKinds)
102
184
  let typeFilterLabel = null; // chip text, derived from the resolved kinds
103
185
 
186
+ // C39 state: the rows the empty box shows, a drilled scope and its
187
+ // in-flight remote search.
188
+ let emptyRows = [];
189
+ let scope = null;
190
+ let remote = { seq: 0, ctl: null, timer: null, rows: [], pending: false, error: null };
191
+ const cancelRemote = () => {
192
+ clearTimeout(remote.timer);
193
+ try { remote.ctl?.abort(); } catch { /* ignore */ }
194
+ remote.ctl = null;
195
+ };
196
+ onClose = cancelRemote;
197
+
104
198
  const renderChip = () => {
199
+ if (scope) {
200
+ chipSlot.innerHTML = `
201
+ <span class="twm-cmdpal__type-chip" title="${_esc(scope.label)}">
202
+ <span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${_esc(scope.icon || 'arrow_right')}</span>
203
+ <span class="twm-cmdpal__type-chip-label twm-cmdpal__type-chip-label--scope">${_esc(scope.label)}</span>
204
+ <button type="button" class="twm-cmdpal__type-chip-x"
205
+ data-action="clear-type"
206
+ aria-label="Leave ${_esc(scope.label)}">×</button>
207
+ </span>`;
208
+ chipSlot.querySelector('[data-action="clear-type"]')
209
+ ?.addEventListener('click', (ev) => { ev.preventDefault(); leaveScope(); input.focus(); });
210
+ return;
211
+ }
105
212
  if (!typeFilter) {
106
213
  chipSlot.innerHTML = '';
107
214
  return;
@@ -135,7 +242,8 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
135
242
  };
136
243
 
137
244
  const render = () => {
138
- list.innerHTML = filtered.slice(0, 200).map((e, i) => `
245
+ if (grouped) { renderGrouped(); return; }
246
+ list.innerHTML = filtered.slice(0, MAX_ROWS).map((e, i) => `
139
247
  <div class="twm-cmdpal__item${i === active ? ' twm-cmdpal__item--active' : ''}"
140
248
  data-idx="${i}">
141
249
  <span class="material-symbols-outlined twm-cmdpal__icon">${e.icon || 'arrow_right'}</span>
@@ -148,6 +256,122 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
148
256
  el.addEventListener('click', () => commit(Number(el.dataset.idx)));
149
257
  });
150
258
  };
259
+
260
+ // ── C39: the grouped layout ─────────────────────────────────────
261
+ //
262
+ // `filtered` stays the ONE flat list the keys walk; `sections` says
263
+ // where its headers go. A "show all" row is an item like any other
264
+ // (`more: <group key>`), so arrows and Enter need no special case.
265
+ let sections = []; // [{ title, start, count }]
266
+
267
+ // A row may name its own group (`groupLabel`) when the group is not a
268
+ // taxonomy kind — "Go to" over an embedder's pages, say.
269
+ const namedGroups = new Map();
270
+ const groupLabel = (key) => {
271
+ if (namedGroups.has(key)) return namedGroups.get(key);
272
+ if (key === 'command') return 'Commands';
273
+ const m = taxonomy.meta(key);
274
+ return m?.plural || m?.label || _titleCase(key);
275
+ };
276
+ const kindLabel = (row) => {
277
+ if (row.kindLabel) return row.kindLabel;
278
+ if (row.kind === 'command') return 'Command';
279
+ return taxonomy.meta(row.kind)?.label || _titleCase(row.kind);
280
+ };
281
+
282
+ const rowHtml = (r, i) => {
283
+ const on = i === active ? ' twm-cmdpal__item--active' : '';
284
+ if (r.more) {
285
+ return `<div class="twm-cmdpal__item twm-cmdpal__item--more${on}" data-idx="${i}" role="option">
286
+ <span class="material-symbols-outlined twm-cmdpal__icon">expand_more</span>
287
+ <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_esc(r.label)}</span></span>
288
+ <span class="twm-cmdpal__side"><span class="twm-cmdpal__acts"><span><kbd>Enter</kbd> show all</span></span></span>
289
+ </div>`;
290
+ }
291
+ const drawn = _drawn(r);
292
+ const ctx = r.cells?.length
293
+ ? `<span class="twm-cmdpal__cells">${r.cells.map(_esc).join(' · ')}</span>`
294
+ : drawn ? `<span class="twm-cmdpal__context">${_esc(drawn)}</span>` : '';
295
+ const verb = r.verb || (r.action || r.kind === 'command' ? 'run' : 'open');
296
+ const modes = Array.isArray(r.modes) ? r.modes : [];
297
+ const acts = [`<span><kbd>Enter</kbd> ${_esc(verb)}</span>`];
298
+ if (r.drillLabel && drill) acts.push(`<span><kbd>Tab</kbd> ${_esc(r.drillLabel)}</span>`);
299
+ if (modes.includes('split')) acts.push('<span><kbd>Ctrl Enter</kbd> split</span>');
300
+ if (modes.includes('window')) acts.push('<span><kbd>Shift Enter</kbd> window</span>');
301
+ const side = r.shortcut
302
+ ? `<kbd class="twm-cmdpal__shortcut">${_esc(r.shortcut)}</kbd>`
303
+ : `<span class="twm-cmdpal__kind">${_esc(kindLabel(r))}</span>`;
304
+ return `<div class="twm-cmdpal__item${on}" data-idx="${i}" role="option">
305
+ <span class="material-symbols-outlined twm-cmdpal__icon">${_esc(r.icon || 'arrow_right')}</span>
306
+ <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_mark(r.label || r.id, r._hits)}</span>${ctx}</span>
307
+ <span class="twm-cmdpal__side">${side}<span class="twm-cmdpal__acts">${acts.join('')}</span></span>
308
+ </div>`;
309
+ };
310
+
311
+ const renderGrouped = () => {
312
+ let html = '';
313
+ for (const s of sections) {
314
+ if (!s.count) continue;
315
+ if (s.title) html += `<div class="twm-cmdpal__group" role="presentation">${_esc(s.title)}</div>`;
316
+ for (let i = s.start; i < s.start + s.count; i++) html += rowHtml(filtered[i], i);
317
+ }
318
+ const q = input.value.trim();
319
+ if (!filtered.length) {
320
+ if (scope) {
321
+ const msg = remote.error ? remote.error
322
+ : remote.pending ? 'Searching…'
323
+ : q ? `Nothing in ${scope.label} matches “${q}”.`
324
+ : (scope.emptyText || `Type to search ${scope.label}.`);
325
+ html += `<div class="twm-cmdpal__empty">${_esc(msg)}</div>`;
326
+ } else if (q && q !== '>') {
327
+ html += `<div class="twm-cmdpal__empty">Nothing matches “${_esc(q)}”.</div>`;
328
+ }
329
+ } else if (scope && remote.pending) {
330
+ html += '<div class="twm-cmdpal__empty twm-cmdpal__empty--quiet">Searching…</div>';
331
+ }
332
+ if (!q && !scope && !typeFilter && Array.isArray(tips) && tips.length) {
333
+ html += `<div class="twm-cmdpal__tips">${tips.map((t) => `<span>${_esc(t)}</span>`).join('')}</div>`;
334
+ }
335
+ list.innerHTML = html;
336
+ renderFooter();
337
+ };
338
+
339
+ // Moving the pointer re-paints CLASSES, never the rows: a list
340
+ // rebuilt between mousedown and mouseup loses the click (the press
341
+ // and the release land on different nodes).
342
+ const paintActive = () => {
343
+ list.querySelectorAll('[data-idx]').forEach((el) => {
344
+ const on = Number(el.dataset.idx) === active;
345
+ el.classList.toggle('twm-cmdpal__item--active', on);
346
+ el.setAttribute('aria-selected', on ? 'true' : 'false');
347
+ if (on) el.scrollIntoView?.({ block: 'nearest' });
348
+ });
349
+ };
350
+
351
+ const renderFooter = () => {
352
+ if (!foot || !grouped) return;
353
+ const parts = ['<span><kbd>↑</kbd><kbd>↓</kbd> move</span>', '<span><kbd>Enter</kbd> open</span>'];
354
+ if (scope || typeFilter) parts.push('<span><kbd>Backspace</kbd> leave</span>');
355
+ parts.push('<span><kbd>Esc</kbd> close</span>');
356
+ const note = scope?.note || footerNote;
357
+ foot.innerHTML = parts.join('')
358
+ + (note ? `<span class="twm-cmdpal__footnote">${_esc(note)}</span>` : '');
359
+ };
360
+
361
+ if (grouped) {
362
+ list.addEventListener('mousemove', (e) => {
363
+ const el = e.target.closest?.('[data-idx]');
364
+ if (!el) return;
365
+ const i = Number(el.dataset.idx);
366
+ if (i !== active) { active = i; paintActive(); }
367
+ });
368
+ list.addEventListener('click', (e) => {
369
+ const el = e.target.closest?.('[data-idx]');
370
+ if (!el) return;
371
+ commit(Number(el.dataset.idx), _modeOf(e));
372
+ });
373
+ }
374
+
151
375
  /**
152
376
  * ══ C30. THE PALETTE IS A DOOR, NOT A PLACEMENT POLICY ══════════
153
377
  *
@@ -177,18 +401,23 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
177
401
  * and unclaimed picks still land in the primary tile, so no existing
178
402
  * embedder changes behaviour by upgrading.
179
403
  *
180
- * `pick` is the catalogue row verbatim (`{kind, id, label, hint,
181
- * props?}`) rather than a re-shaped argument: the embedder wrote the
182
- * `shape` that produced it, so it is the one party that can read it.
404
+ * `pick` is the catalogue row (`{kind, id, label, hint, props?}`)
405
+ * rather than a re-shaped argument: the embedder wrote the `shape`
406
+ * that produced it, so it is the one party that can read it. C39 adds
407
+ * a second argument, `{ mode, scope }`; an embedder that reads one
408
+ * argument is unaffected.
183
409
  */
184
- const commit = (i) => {
185
- const pick = filtered[i];
186
- if (!pick) return;
410
+ const commit = (i, mode = 'open') => {
411
+ const picked = filtered[i];
412
+ if (!picked) return;
413
+ if (picked.more) { showAll(picked.more); return; }
414
+ const pick = _public(picked);
415
+ const how = { mode, scope: scope?.id ?? null };
187
416
  close();
188
- if (pick.action) { try { pick.action(); } catch (err) { console.error(err); } return; }
417
+ if (pick.action) { try { pick.action(how); } catch (err) { console.error(err); } return; }
189
418
  if (onPick) {
190
419
  let handled = false;
191
- try { handled = onPick(pick) ?? false; }
420
+ try { handled = onPick(pick, how) ?? false; }
192
421
  catch (err) { console.error('[cmdpal] onPick threw', err); }
193
422
  if (handled) return;
194
423
  }
@@ -201,7 +430,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
201
430
  * true when a chip was set so the caller can decide whether
202
431
  * to re-render. */
203
432
  const tryConsumePrefix = () => {
204
- if (typeFilter) return false;
433
+ if (typeFilter || scope) return false;
205
434
  const m = input.value.match(/^([a-zA-Z][a-zA-Z0-9_-]*):/);
206
435
  if (!m) return false;
207
436
  const kinds = catalog.resolveTypePrefix(m[1]);
@@ -209,12 +438,168 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
209
438
  typeFilter = kinds;
210
439
  typeFilterLabel = _titleCase(m[1]);
211
440
  input.value = input.value.slice(m[0].length);
441
+ if (grouped) {
442
+ input.value = input.value.replace(/^\s+/, '');
443
+ typeFilterLabel = groupLabel(kinds[0]);
444
+ }
212
445
  renderChip();
213
446
  return true;
214
447
  };
215
448
 
449
+ /** *Show all N <group>* — the cap's way out. It narrows the box to
450
+ * that group exactly as a typed `kind:` would, so Backspace leaves
451
+ * it the same way. */
452
+ const showAll = (key) => {
453
+ typeFilter = [key];
454
+ typeFilterLabel = groupLabel(key);
455
+ if (input.value.startsWith('>')) input.value = input.value.slice(1);
456
+ renderChip();
457
+ refilter();
458
+ input.focus();
459
+ };
460
+
461
+ const enterScope = (row) => {
462
+ if (!drill || !row || row.more) return false;
463
+ let next = null;
464
+ try { next = drill(_public(row)); } catch (err) { console.error('[cmdpal] drill threw', err); }
465
+ if (!next || typeof next.search !== 'function') return false;
466
+ cancelRemote();
467
+ scope = next;
468
+ typeFilter = null;
469
+ typeFilterLabel = null;
470
+ input.value = '';
471
+ input.placeholder = next.placeholder || `Search ${next.label}…`;
472
+ remote = { seq: 0, ctl: null, timer: null, rows: [], pending: false, error: null };
473
+ renderChip();
474
+ refilter();
475
+ return true;
476
+ };
477
+ const leaveScope = () => {
478
+ cancelRemote();
479
+ remote.seq++;
480
+ scope = null;
481
+ typeFilter = null;
482
+ typeFilterLabel = null;
483
+ input.placeholder = placeholder;
484
+ renderChip();
485
+ refilter();
486
+ };
487
+
488
+ const runRemote = (q) => {
489
+ cancelRemote();
490
+ const seq = ++remote.seq;
491
+ if (q.length < (scope.minChars ?? 1)) {
492
+ remote.rows = []; remote.pending = false; remote.error = null;
493
+ return;
494
+ }
495
+ remote.pending = true;
496
+ remote.error = null;
497
+ const s = scope;
498
+ remote.timer = setTimeout(async () => {
499
+ const ctl = typeof AbortController === 'function' ? new AbortController() : null;
500
+ remote.ctl = ctl;
501
+ let rows = [];
502
+ let error = null;
503
+ try { rows = await s.search(q, ctl?.signal); }
504
+ catch (err) {
505
+ if (err?.name === 'AbortError') return;
506
+ error = err?.userMessage || 'The search failed.';
507
+ console.warn('[cmdpal] scope search failed', err);
508
+ }
509
+ // A newer keystroke, a left scope or a closed box: drop it.
510
+ if (seq !== remote.seq || scope !== s || overlay !== mine) return;
511
+ remote.rows = _decorateIcons(Array.isArray(rows) ? rows : [], taxonomy)
512
+ .map((r) => ({ ...r, _hits: _match(String(r.label || ''), q)?.hits || [] }));
513
+ remote.pending = false;
514
+ remote.error = error;
515
+ filtered = remote.rows.slice(0, MAX_ROWS);
516
+ sections = [{ title: s.label, start: 0, count: filtered.length }];
517
+ active = 0;
518
+ render();
519
+ }, REMOTE_DEBOUNCE_MS);
520
+ };
521
+
522
+ const refilterGrouped = () => {
523
+ const raw = input.value;
524
+ if (scope) {
525
+ runRemote(raw.trim());
526
+ filtered = remote.rows;
527
+ sections = [{ title: scope.label, start: 0, count: filtered.length }];
528
+ active = 0;
529
+ render();
530
+ return;
531
+ }
532
+ const commandsOnly = !typeFilter && raw.startsWith('>');
533
+ const q = (commandsOnly ? raw.slice(1) : raw).trim();
534
+ const filterSet = typeFilter ? new Set(typeFilter) : null;
535
+ if (!q && !commandsOnly && !filterSet) {
536
+ filtered = emptyRows.slice(0, MAX_ROWS).map((r) => ({ ...r, _hits: [] }));
537
+ sections = [{ title: filtered.length ? emptyLabel : '', start: 0, count: filtered.length }];
538
+ active = 0;
539
+ render();
540
+ return;
541
+ }
542
+ let pool = entities;
543
+ if (commandsOnly) pool = pool.filter((e) => e.kind === 'command');
544
+ else if (filterSet) pool = pool.filter((e) => filterSet.has(_groupOf(e)) || filterSet.has(e.kind));
545
+ else if (q.length < commandMinChars) pool = pool.filter((e) => e.kind !== 'command');
546
+
547
+ const scored = [];
548
+ if (!q) {
549
+ for (const e of pool) scored.push({ ...e, _hits: [], _score: 0 });
550
+ scored.sort((a, b) => String(a.label || a.id).localeCompare(String(b.label || b.id)));
551
+ } else {
552
+ for (const e of pool) {
553
+ let m = _match(String(e.label || e.id || ''), q);
554
+ if (!m) {
555
+ const extra = _searched(e);
556
+ const k = extra ? _match(extra, q) : null;
557
+ if (k) m = { s: Math.min(k.s, 20), hits: [] };
558
+ }
559
+ if (!m) continue;
560
+ // A command a few points under an object it ties with: typing a
561
+ // table's name must not lead with "Customise interface".
562
+ const penalty = e.kind === 'command' && !commandsOnly ? 5 : 0;
563
+ scored.push({ ...e, _hits: m.hits, _score: m.s + (Number(e.boost) || 0) - penalty });
564
+ }
565
+ }
566
+ // Group, and order the groups by their best hit (ties: first seen).
567
+ const order = [];
568
+ const by = new Map();
569
+ for (const r of scored) {
570
+ const key = commandsOnly ? 'command' : _groupOf(r);
571
+ if (!by.has(key)) { by.set(key, []); order.push(key); }
572
+ by.get(key).push(r);
573
+ if (r.groupLabel && !namedGroups.has(key)) namedGroups.set(key, r.groupLabel);
574
+ }
575
+ // A group is as good as its best row, compared the way rows are
576
+ // (score, then the shorter label): "Customers settings" must not
577
+ // lead the table called "Customers" because both are prefixes.
578
+ const top = (k) => by.get(k).slice().sort(_byScore)[0];
579
+ const keys = order.slice().sort((a, b) => (q ? _byScore(top(a), top(b)) : 0)
580
+ || order.indexOf(a) - order.indexOf(b));
581
+ const capped = !filterSet && !commandsOnly;
582
+ filtered = [];
583
+ sections = [];
584
+ for (const key of keys) {
585
+ const rows = by.get(key);
586
+ if (q) rows.sort(_byScore);
587
+ const shown = capped ? rows.slice(0, groupCap) : rows;
588
+ const start = filtered.length;
589
+ filtered.push(...shown);
590
+ if (shown.length < rows.length) {
591
+ filtered.push({ more: key, label: `Show all ${rows.length} ${groupLabel(key).toLowerCase()}` });
592
+ }
593
+ sections.push({ title: groupLabel(key), start, count: filtered.length - start });
594
+ if (filtered.length >= MAX_ROWS) break;
595
+ }
596
+ active = 0;
597
+ render();
598
+ };
599
+
216
600
  const refilter = () => {
217
601
  tryConsumePrefix();
602
+ if (grouped) { refilterGrouped(); return; }
218
603
  const q = input.value.trim().toLowerCase();
219
604
  const filterSet = typeFilter ? new Set(typeFilter) : null;
220
605
  const pool = filterSet
@@ -237,25 +622,32 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
237
622
  };
238
623
  input.addEventListener('input', refilter);
239
624
  input.addEventListener('keydown', (e) => {
625
+ const move = (to) => {
626
+ active = to;
627
+ if (grouped) paintActive(); else render();
628
+ };
240
629
  if (e.key === 'ArrowDown') {
241
630
  e.preventDefault();
242
- active = Math.min(filtered.length - 1, active + 1);
243
- render();
631
+ move(Math.min(filtered.length - 1, active + 1));
244
632
  } else if (e.key === 'ArrowUp') {
245
633
  e.preventDefault();
246
- active = Math.max(0, active - 1);
247
- render();
634
+ move(Math.max(0, active - 1));
248
635
  } else if (e.key === 'Enter') {
249
636
  e.preventDefault();
250
- commit(active);
637
+ commit(active, _modeOf(e));
251
638
  } else if (e.key === 'Escape') {
252
639
  close();
253
- } else if (e.key === 'Backspace' && input.value === '' && typeFilter) {
640
+ } else if (grouped && e.key === 'Tab' && !e.shiftKey) {
641
+ const row = filtered[active];
642
+ if (row?.more) { e.preventDefault(); showAll(row.more); }
643
+ else if (row?.drillLabel && enterScope(row)) e.preventDefault();
644
+ } else if (e.key === 'Backspace' && input.value === '' && (typeFilter || scope)) {
254
645
  // Empty input + active chip → consume Backspace to drop
255
646
  // the chip. Same idiom as Gmail / Linear / VS Code's
256
647
  // command palette: the chip behaves like a token before
257
648
  // the caret.
258
649
  e.preventDefault();
650
+ if (scope) { leaveScope(); return; }
259
651
  typeFilter = null;
260
652
  typeFilterLabel = null;
261
653
  renderChip();
@@ -268,23 +660,48 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
268
660
  // — the palette stays usable for shortcuts/commands while we wait.
269
661
  // The set is rebuilt on every open so the palette reflects
270
662
  // entities the user just added this session (cheap; the catalog
271
- // runs every source in parallel).
272
- entities = _decorateIcons(STATIC_COMMANDS.slice(), taxonomy);
663
+ // runs every source in parallel). With `cache`, the previous set
664
+ // is drawn first and the rebuild replaces it when it lands.
665
+ const commandRows = () => {
666
+ let extra = [];
667
+ if (typeof commands === 'function') {
668
+ try { extra = (commands() || []).map((c) => ({ kind: 'command', ...c })); }
669
+ catch (err) { console.warn('[cmdpal] commands() threw', err); }
670
+ }
671
+ return [...STATIC_COMMANDS, ...extra];
672
+ };
673
+ // Objects before commands in the grouped layout, so that a tie that
674
+ // survives every rule goes to the object (first seen wins).
675
+ const withCommands = (rows) => (grouped ? [...rows, ...commandRows()] : [...commandRows(), ...rows]);
676
+ entities = _decorateIcons(withCommands(cache && cached ? cached : []), taxonomy);
677
+ if (grouped) {
678
+ refilter();
679
+ if (typeof empty === 'function') {
680
+ try {
681
+ const got = await empty();
682
+ if (overlay !== mine) return;
683
+ emptyRows = _decorateIcons(Array.isArray(got) ? got : [], taxonomy);
684
+ if (!input.value && !typeFilter && !scope) refilter();
685
+ } catch (err) { console.warn('[cmdpal] empty() failed', err); }
686
+ }
687
+ }
273
688
  if (api) {
274
689
  try {
275
- entities = _decorateIcons(
276
- [...STATIC_COMMANDS, ...await catalog.loadAll(api)], taxonomy);
277
- if (input.value) refilter();
690
+ const loaded = await catalog.loadAll(api);
691
+ if (cache) cached = loaded;
692
+ if (overlay !== mine) return;
693
+ entities = _decorateIcons(withCommands(loaded), taxonomy);
694
+ if (grouped ? (!scope && (input.value || typeFilter)) : input.value) refilter();
278
695
  } catch (err) {
279
696
  console.warn('[cmdpal] entity load failed', err);
280
697
  }
281
698
  }
282
699
  };
283
700
 
284
- return { open, close, toggle, isOpen };
701
+ return { open, close, toggle, isOpen, invalidate };
285
702
  }
286
703
 
287
- function _markup(taxonomy, placeholder, wm) {
704
+ function _markup(taxonomy, placeholder, wm, { chrome = {}, grouped = false } = {}) {
288
705
  // ONLY THE PANELS THIS SHELL CAN ACTUALLY SHOW.
289
706
  //
290
707
  // The three toggles were hardcoded, so the palette offered `panel:left` to
@@ -299,7 +716,7 @@ function _markup(taxonomy, placeholder, wm) {
299
716
  { side: 'right', icon: 'dock_to_left', label: 'Right panel' },
300
717
  { side: 'bottom', icon: 'dock_to_bottom', label: 'Bottom panel' },
301
718
  ];
302
- const panelToggles = PANEL_CHIPS
719
+ const panelToggles = chrome.panels === false ? '' : PANEL_CHIPS
303
720
  .filter((p) => wm.content?.has?.(`panel:${p.side}`) !== false)
304
721
  .map((p) => `
305
722
  <button class="twm-chip" data-toggle="${p.side}">
@@ -307,12 +724,32 @@ function _markup(taxonomy, placeholder, wm) {
307
724
  ${p.label}
308
725
  </button>`).join('');
309
726
 
310
- const chips = taxonomy.topNavEntries().map((k) => `
727
+ const chips = chrome.pages === false ? '' : taxonomy.topNavEntries().map((k) => `
311
728
  <button class="twm-chip" data-shortcut="${k.kind}">
312
729
  <span class="material-symbols-outlined">${k.icon}</span>
313
730
  ${k.label}
314
731
  </button>
315
732
  `).join('');
733
+ if (grouped) {
734
+ // C39. A strip with nothing in it is not drawn at all: an empty
735
+ // bordered div is a 1px line between the input and the results.
736
+ return `
737
+ <div class="twm-cmdpal twm-cmdpal--grouped" role="dialog" aria-label="Command palette">
738
+ <div class="twm-cmdpal__inputrow">
739
+ <span class="material-symbols-outlined twm-cmdpal__search-icon">search</span>
740
+ <span class="twm-cmdpal__type-chip-slot" data-role="type-chip"></span>
741
+ <input type="text" class="twm-cmdpal__input"
742
+ placeholder="${_esc(placeholder)}"
743
+ autocomplete="off" spellcheck="false" role="combobox"
744
+ aria-expanded="true" aria-controls="twm-cmdpal-list" />
745
+ <kbd class="twm-cmdpal__esc">Esc</kbd>
746
+ </div>
747
+ ${chips ? `<div class="twm-cmdpal__chips">${chips}</div>` : ''}
748
+ ${panelToggles ? `<div class="twm-cmdpal__toggles">${panelToggles}</div>` : ''}
749
+ <div class="twm-cmdpal__list" id="twm-cmdpal-list" role="listbox" data-role="list"></div>
750
+ <div class="twm-cmdpal__footer" data-role="footer"></div>
751
+ </div>`;
752
+ }
316
753
  return `
317
754
  <div class="twm-cmdpal" role="dialog" aria-label="Command palette">
318
755
  <div class="twm-cmdpal__inputrow">
@@ -364,6 +801,109 @@ function _search(entities, q) {
364
801
  return scored.map((x) => x.e);
365
802
  }
366
803
 
804
+ // ── C39: matching, and what a row draws and searches ─────────────────────
805
+
806
+ /** What a row draws after its label: `context`, else the old `hint`. */
807
+ function _drawn(r) {
808
+ return r.context !== undefined ? r.context : (r.hint || '');
809
+ }
810
+ /** What a row is searched by besides its label: `keywords`, else `hint`. */
811
+ function _searched(r) {
812
+ const k = r.keywords !== undefined ? r.keywords : r.hint;
813
+ return Array.isArray(k) ? k.join(' ') : String(k || '');
814
+ }
815
+ function _groupOf(r) { return r.group || r.kind; }
816
+
817
+ /** Strip the palette's private fields before a row leaves it. */
818
+ function _public(r) {
819
+ const { _hits, _score, ...rest } = r;
820
+ return rest;
821
+ }
822
+
823
+ function _byScore(a, b) {
824
+ return b._score - a._score
825
+ || String(a.label || '').length - String(b.label || '').length
826
+ || String(a.label || '').localeCompare(String(b.label || ''));
827
+ }
828
+
829
+ function _modeOf(e) {
830
+ if (e?.ctrlKey || e?.metaKey) return 'split';
831
+ if (e?.shiftKey) return 'window';
832
+ return 'open';
833
+ }
834
+
835
+ const _WORD_BREAK = /[\s_\-–—.:/\\()[\]&,+·›]/;
836
+
837
+ /**
838
+ * Score `text` against `query`, returning `{ s, hits }` — the tier's score
839
+ * and the matched character positions — or null.
840
+ *
841
+ * 100 exact
842
+ * 90 prefix
843
+ * ~72 every query word starts a word of the text, in order
844
+ * (`ord li` → *Order lines*, `cust no` → `customer_no`)
845
+ * 60 substring
846
+ * ≤30 subsequence, worse the more it is spread out
847
+ */
848
+ export function matchLabel(text, query) { return _match(text, query); }
849
+
850
+ function _match(text, query) {
851
+ const t = String(text || '').toLowerCase();
852
+ const q = String(query || '').toLowerCase().trim();
853
+ if (!q || !t) return null;
854
+ const span = (a, n) => Array.from({ length: n }, (_, i) => a + i);
855
+ if (t === q) return { s: 100, hits: span(0, q.length) };
856
+ if (t.startsWith(q)) return { s: 90, hits: span(0, q.length) };
857
+
858
+ const words = q.split(/\s+/).filter(Boolean);
859
+ {
860
+ const hits = [];
861
+ let from = 0;
862
+ let ok = true;
863
+ for (const w of words) {
864
+ let at = -1;
865
+ for (let i = from; i <= t.length - w.length; i++) {
866
+ if ((i === 0 || _WORD_BREAK.test(t[i - 1])) && t.startsWith(w, i)) { at = i; break; }
867
+ }
868
+ if (at < 0) { ok = false; break; }
869
+ hits.push(...span(at, w.length));
870
+ from = at + w.length;
871
+ }
872
+ if (ok) return { s: 75 - Math.min(words.length, 5), hits };
873
+ }
874
+
875
+ const sub = t.indexOf(q);
876
+ if (sub >= 0) return { s: 60, hits: span(sub, q.length) };
877
+
878
+ const flat = q.replace(/\s+/g, '');
879
+ const hits = [];
880
+ let j = 0;
881
+ for (let i = 0; i < t.length && j < flat.length; i++) {
882
+ if (t[i] === flat[j]) { hits.push(i); j++; }
883
+ }
884
+ if (j < flat.length) return null;
885
+ const spread = hits[hits.length - 1] - hits[0] + 1 - flat.length;
886
+ if (spread > 3 * flat.length + 6) return null; // letters strewn across a long label
887
+ return { s: Math.max(1, 30 - spread), hits };
888
+ }
889
+
890
+ /** The label with matched characters wrapped in `<mark>`, escaped. */
891
+ function _mark(text, hits) {
892
+ const s = String(text ?? '');
893
+ if (!hits?.length) return _esc(s);
894
+ const set = new Set(hits);
895
+ let out = '';
896
+ let open = false;
897
+ for (let i = 0; i < s.length; i++) {
898
+ const on = set.has(i);
899
+ if (on && !open) { out += '<mark>'; open = true; }
900
+ if (!on && open) { out += '</mark>'; open = false; }
901
+ out += _esc(s[i]);
902
+ }
903
+ if (open) out += '</mark>';
904
+ return out;
905
+ }
906
+
367
907
  function _esc(s) {
368
908
  return String(s ?? '').replace(/[&<>"']/g, (c) => ({
369
909
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',