clay-server 2.32.0 → 2.32.1-beta.2

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.
@@ -35,6 +35,38 @@ export function initFileBrowser(_ctx) {
35
35
  }
36
36
  });
37
37
 
38
+ // --- Keyboard navigation ---
39
+ // Arrow keys move a "keyboard focus" ring through visible tree items,
40
+ // Enter activates (opens a file or toggles a folder), Left/Right
41
+ // collapses/expands folders and ascends/descends into them. The tree
42
+ // gets a tabindex so it can receive focus and keydown events.
43
+ if (ctx.fileTreeEl) {
44
+ ctx.fileTreeEl.setAttribute('tabindex', '0');
45
+ ctx.fileTreeEl.addEventListener('keydown', handleTreeKeyDown);
46
+ // When the user clicks any tree row, promote it to keyboard focus
47
+ // as well so subsequent arrow keys continue from that position.
48
+ ctx.fileTreeEl.addEventListener('click', function (e) {
49
+ var row = e.target && e.target.closest && e.target.closest('.file-tree-item');
50
+ if (row) setKbFocus(row);
51
+ });
52
+ }
53
+
54
+ // Search input <-> tree keyboard bridge:
55
+ // Enter in the search box drops focus back onto the tree and
56
+ // highlights the first visible result so the user can keep
57
+ // navigating with arrows. ArrowDown does the same for convenience.
58
+ var searchInput = document.getElementById('fb-search-input');
59
+ if (searchInput) {
60
+ searchInput.addEventListener('keydown', function (e) {
61
+ if (e.key === 'Enter' || e.key === 'ArrowDown') {
62
+ e.preventDefault();
63
+ focusFileTree();
64
+ var items = getVisibleTreeItems();
65
+ if (items.length) setKbFocus(items[0]);
66
+ }
67
+ });
68
+ }
69
+
38
70
  // --- Drag-and-drop file paths into message input ---
39
71
  var inputEl = document.getElementById("input");
40
72
  var dropHintEl = null;
@@ -210,6 +242,145 @@ export function initFileBrowser(_ctx) {
210
242
  }
211
243
  }
212
244
 
245
+ // --- Keyboard navigation helpers ---
246
+
247
+ var _kbFocused = null;
248
+
249
+ export function focusFileTree() {
250
+ if (!ctx || !ctx.fileTreeEl) return;
251
+ ctx.fileTreeEl.focus();
252
+ if (!_kbFocused) {
253
+ var first = ctx.fileTreeEl.querySelector('.file-tree-item');
254
+ if (first) setKbFocus(first);
255
+ }
256
+ }
257
+
258
+ function setKbFocus(el) {
259
+ if (_kbFocused && _kbFocused !== el) _kbFocused.classList.remove('fb-kb-focus');
260
+ _kbFocused = el || null;
261
+ if (el) {
262
+ el.classList.add('fb-kb-focus');
263
+ if (el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
264
+ }
265
+ }
266
+
267
+ function isDirRow(row) {
268
+ // Folder rows carry the chevron; file rows use a spacer instead.
269
+ return !!(row && row.querySelector && row.querySelector('.file-tree-chevron'));
270
+ }
271
+
272
+ function getVisibleTreeItems() {
273
+ if (!ctx || !ctx.fileTreeEl) return [];
274
+ var all = ctx.fileTreeEl.querySelectorAll('.file-tree-item');
275
+ var out = [];
276
+ for (var i = 0; i < all.length; i++) {
277
+ var el = all[i];
278
+ // Skip anything nested inside a hidden .file-tree-children subtree.
279
+ var hidden = false;
280
+ var p = el.parentNode;
281
+ while (p && p !== ctx.fileTreeEl) {
282
+ if (p.classList && p.classList.contains('file-tree-children') && p.classList.contains('hidden')) {
283
+ hidden = true;
284
+ break;
285
+ }
286
+ p = p.parentNode;
287
+ }
288
+ if (!hidden) out.push(el);
289
+ }
290
+ return out;
291
+ }
292
+
293
+ function moveKbFocus(delta) {
294
+ var items = getVisibleTreeItems();
295
+ if (items.length === 0) return;
296
+ var idx = _kbFocused ? items.indexOf(_kbFocused) : -1;
297
+ if (idx === -1) {
298
+ idx = delta > 0 ? 0 : items.length - 1;
299
+ } else {
300
+ idx = Math.max(0, Math.min(items.length - 1, idx + delta));
301
+ }
302
+ setKbFocus(items[idx]);
303
+ }
304
+
305
+ function kbExpandOrDescend() {
306
+ if (!_kbFocused) return;
307
+ if (!isDirRow(_kbFocused)) return;
308
+ if (!_kbFocused.classList.contains('expanded')) {
309
+ _kbFocused.click(); // triggers the existing expand handler
310
+ return;
311
+ }
312
+ // Already expanded — move focus to first child.
313
+ var children = _kbFocused.nextElementSibling;
314
+ if (children && children.classList.contains('file-tree-children')) {
315
+ var first = children.querySelector('.file-tree-item');
316
+ if (first) setKbFocus(first);
317
+ }
318
+ }
319
+
320
+ function kbCollapseOrAscend() {
321
+ if (!_kbFocused) return;
322
+ if (isDirRow(_kbFocused) && _kbFocused.classList.contains('expanded')) {
323
+ _kbFocused.click(); // triggers existing collapse handler
324
+ return;
325
+ }
326
+ // On a file or a collapsed folder — move focus to the parent folder.
327
+ var container = _kbFocused.parentNode;
328
+ if (container && container.classList && container.classList.contains('file-tree-children')) {
329
+ var parentRow = container.previousElementSibling;
330
+ if (parentRow && parentRow.classList && parentRow.classList.contains('file-tree-item')) {
331
+ setKbFocus(parentRow);
332
+ }
333
+ }
334
+ }
335
+
336
+ function kbActivate() {
337
+ if (_kbFocused) _kbFocused.click();
338
+ }
339
+
340
+ function handleTreeKeyDown(e) {
341
+ // Any arrow key with an empty tree (e.g. "No files found" for a
342
+ // search query) hands focus back to the search input so the user
343
+ // can fix the query without clicking.
344
+ var isArrow = e.key === 'ArrowDown' || e.key === 'ArrowUp'
345
+ || e.key === 'ArrowLeft' || e.key === 'ArrowRight';
346
+ if (isArrow && getVisibleTreeItems().length === 0) {
347
+ e.preventDefault();
348
+ var emptySearch = document.getElementById('fb-search-input');
349
+ if (emptySearch) {
350
+ if (_kbFocused) _kbFocused.classList.remove('fb-kb-focus');
351
+ _kbFocused = null;
352
+ emptySearch.focus();
353
+ try { emptySearch.select(); } catch (err) { /* ignore */ }
354
+ }
355
+ return;
356
+ }
357
+
358
+ if (e.key === 'ArrowDown') { e.preventDefault(); moveKbFocus(1); return; }
359
+ if (e.key === 'ArrowUp') {
360
+ e.preventDefault();
361
+ // At the top of the visible tree, pressing Up hands focus to the
362
+ // search box — a natural escape upward. Enter there brings focus
363
+ // back to the tree (handled by the search input's keydown below).
364
+ var items = getVisibleTreeItems();
365
+ if (items.length && _kbFocused === items[0]) {
366
+ var searchInput = document.getElementById('fb-search-input');
367
+ if (searchInput) {
368
+ if (_kbFocused) _kbFocused.classList.remove('fb-kb-focus');
369
+ searchInput.focus();
370
+ try { searchInput.select(); } catch (err) { /* ignore */ }
371
+ return;
372
+ }
373
+ }
374
+ moveKbFocus(-1);
375
+ return;
376
+ }
377
+ if (e.key === 'ArrowRight'){ e.preventDefault(); kbExpandOrDescend(); return; }
378
+ if (e.key === 'ArrowLeft') { e.preventDefault(); kbCollapseOrAscend(); return; }
379
+ if (e.key === 'Enter') { e.preventDefault(); kbActivate(); return; }
380
+ if (e.key === 'Home') { e.preventDefault(); var items3 = getVisibleTreeItems(); if (items3.length) setKbFocus(items3[0]); return; }
381
+ if (e.key === 'End') { e.preventDefault(); var items4 = getVisibleTreeItems(); if (items4.length) setKbFocus(items4[items4.length - 1]); return; }
382
+ }
383
+
213
384
  // --- File watch helpers ---
214
385
  function sendWatch(filePath) {
215
386
  if (ctx.ws && ctx.connected) {
@@ -816,9 +816,14 @@ export function initInput(_ctx) {
816
816
  inputEl.dispatchEvent(new Event("input", { bubbles: true }));
817
817
  });
818
818
 
819
- // Mate avatar overlay on @ button — rotate random mate faces
819
+ // Mate avatar overlay on @ button.
820
+ // The overlay is a quiet hint that you can @-mention a Mate for advice
821
+ // on the current session. To keep it from feeling like constant motion
822
+ // in the user's peripheral vision (see issue #325 item 2), the avatar
823
+ // is picked once per session entry and does NOT rotate on a timer.
824
+ // The avatar is rendered desaturated in CSS and only regains color on
825
+ // hover, so the change moment itself stays visually quiet too.
820
826
  var _lastOverlayIdx = -1;
821
- var _overlayInterval = null;
822
827
 
823
828
  function getAvailableMates() {
824
829
  var mates = store.get('cachedMatesList') || [];
@@ -861,21 +866,17 @@ export function initInput(_ctx) {
861
866
  }, 300);
862
867
  }
863
868
 
864
- function rotateMateOverlay() {
869
+ function refreshMateOverlay() {
865
870
  var available = getAvailableMates();
866
871
  setOverlayAvatar(pickNextMate(available));
867
872
  }
868
873
 
869
- function startOverlayRotation() {
870
- if (_overlayInterval) clearInterval(_overlayInterval);
871
- rotateMateOverlay();
872
- _overlayInterval = setInterval(rotateMateOverlay, 10000);
873
- }
874
-
875
- // Update overlay when mate list changes
874
+ // Refresh on initial mate list load and on session transition only.
876
875
  store.subscribe(function (state, prev) {
877
876
  if (state.cachedMatesList !== prev.cachedMatesList) {
878
- startOverlayRotation();
877
+ refreshMateOverlay();
878
+ } else if (state.activeSessionId !== prev.activeSessionId) {
879
+ refreshMateOverlay();
879
880
  }
880
881
  });
881
882
  }
@@ -5,6 +5,18 @@ import { getMermaidThemeVars } from './theme.js';
5
5
  // Initialize markdown parser
6
6
  marked.use({ gfm: true, breaks: false });
7
7
 
8
+ // Open external links in a new tab
9
+ marked.use({
10
+ renderer: {
11
+ link({ href, title, text }) {
12
+ var isExternal = href && (href.startsWith('http://') || href.startsWith('https://'));
13
+ var titleAttr = title ? ' title="' + title + '"' : '';
14
+ var targetAttr = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
15
+ return '<a href="' + href + '"' + titleAttr + targetAttr + '>' + text + '</a>';
16
+ }
17
+ }
18
+ });
19
+
8
20
  // Initialize mermaid
9
21
  mermaid.initialize({
10
22
  startOnLoad: false,
@@ -0,0 +1,404 @@
1
+ // project-switcher.js — macOS Cmd+Tab-style quick switcher.
2
+ //
3
+ // Two modes share the same overlay:
4
+ // Cmd/Ctrl+E → projects only (non-Mate), activates via switchProject
5
+ // Cmd/Ctrl+M → Mates only, activates via openDm
6
+ //
7
+ // While the modifier is still held, repeating the same shortcut cycles
8
+ // forward and Shift cycles back. Releasing the modifier commits —
9
+ // matching Cmd+Tab semantics. Enter commits explicitly; Escape cancels.
10
+ //
11
+ // Each mode has its own in-memory MRU list so the ordering stays
12
+ // mode-specific (visiting projects shouldn't reorder the mate list,
13
+ // and vice versa). No persistence — MRU is a transient hint and
14
+ // project rule forbids localStorage for preferences.
15
+
16
+ import { store } from './store.js';
17
+ import { refreshIcons } from './icons.js';
18
+ import { getCachedProjects, switchProject } from './app-projects.js';
19
+ import { openDm } from './app-dm.js';
20
+ import { mateAvatarUrl } from './avatar.js';
21
+ import { parseEmojis } from './markdown.js';
22
+
23
+ var _projectMru = []; // project slugs, most recent first
24
+ var _mateMru = []; // mate ids (mate_XXX), most recent first
25
+ var _overlay = null;
26
+ var _list = null;
27
+ var _headerEl = null;
28
+ var _open = false;
29
+ var _mode = 'project'; // 'project' | 'mate'
30
+ var _highlightedIndex = 0;
31
+ var _entries = [];
32
+
33
+ export function openSwitcherForMode(mode) {
34
+ if (mode !== 'project' && mode !== 'mate') return;
35
+ if (_open) closeSwitcher(false);
36
+ openSwitcher(mode);
37
+ }
38
+
39
+ export function initProjectSwitcher() {
40
+ buildOverlayIfMissing();
41
+ _list = _overlay.querySelector('.cmd-palette-results');
42
+ _headerEl = _overlay.querySelector('.project-switcher-header');
43
+
44
+ // Seed MRUs from current state.
45
+ var seedSlug = store.get('currentSlug');
46
+ if (seedSlug) _projectMru = [seedSlug];
47
+ var seedTarget = store.get('dmTargetUser');
48
+ if (seedTarget && seedTarget.isMate && seedTarget.id) _mateMru = [seedTarget.id];
49
+
50
+ // Track MRUs by watching the store for navigation changes.
51
+ store.subscribe(function (state, prev) {
52
+ if (state.currentSlug && state.currentSlug !== prev.currentSlug) {
53
+ bumpMru(_projectMru, state.currentSlug);
54
+ _projectMru = _projectMru; // (keep reference; bumpMru mutates via reassignment)
55
+ }
56
+ var curMate = mateIdFrom(state.dmTargetUser);
57
+ var prevMate = mateIdFrom(prev.dmTargetUser);
58
+ if (curMate && curMate !== prevMate) bumpMru(_mateMru, curMate);
59
+ });
60
+
61
+ var backdrop = _overlay.querySelector('.cmd-palette-backdrop');
62
+ if (backdrop) backdrop.addEventListener('click', function () { closeSwitcher(false); });
63
+
64
+ document.addEventListener('keydown', handleKeyDown, true);
65
+ document.addEventListener('keyup', handleKeyUp, true);
66
+ window.addEventListener('blur', function () { if (_open) closeSwitcher(false); });
67
+
68
+ // Wire hotkey hint pills next to the icon strip so the keybinding is
69
+ // discoverable in the same surface users use for clicking. Clicking
70
+ // the pill is equivalent to pressing the shortcut itself.
71
+ var isMac = /Mac|iPhone|iPod|iPad/i.test(navigator.platform);
72
+ var projectHintLabel = isMac ? '\u2318E' : 'Ctrl+E';
73
+ var mateHintLabel = isMac ? '\u2318M' : 'Ctrl+M';
74
+ var projectHint = document.getElementById('icon-strip-hint-project');
75
+ var mateHint = document.getElementById('icon-strip-hint-mate');
76
+ if (projectHint) {
77
+ projectHint.textContent = projectHintLabel;
78
+ projectHint.title = 'Switch project (' + projectHintLabel + ')';
79
+ projectHint.addEventListener('click', function () { openSwitcherForMode('project'); });
80
+ }
81
+ if (mateHint) {
82
+ mateHint.textContent = mateHintLabel;
83
+ mateHint.title = 'Switch mate (' + mateHintLabel + ')';
84
+ mateHint.addEventListener('click', function () { openSwitcherForMode('mate'); });
85
+ }
86
+
87
+ // Mate hint visibility piggybacks on cachedMatesList: show the pill
88
+ // whenever the mate list has any entries, hide otherwise. The users
89
+ // strip inside the same wrapper also hides itself (sidebar-mates.js
90
+ // toggles that), and the wrapper shrinks to nothing when both are
91
+ // hidden — no extra visibility bookkeeping needed on the wrapper.
92
+ function refreshMateHintVisibility() {
93
+ if (!mateHint) return;
94
+ var mates = store.get('cachedMatesList') || [];
95
+ mateHint.classList.toggle('hidden', mates.length === 0);
96
+ }
97
+ refreshMateHintVisibility();
98
+ store.subscribe(function (state, prev) {
99
+ if (state.cachedMatesList !== prev.cachedMatesList) refreshMateHintVisibility();
100
+ });
101
+ }
102
+
103
+ function buildOverlayIfMissing() {
104
+ _overlay = document.getElementById('project-switcher');
105
+ if (_overlay) return;
106
+ var isMac = /Mac|iPhone|iPod|iPad/i.test(navigator.platform);
107
+ var modKbd = isMac ? '<kbd>\u2318</kbd>' : '<kbd>Ctrl</kbd>';
108
+ _overlay = document.createElement('div');
109
+ _overlay.id = 'project-switcher';
110
+ _overlay.className = 'cmd-palette hidden project-switcher';
111
+ _overlay.innerHTML =
112
+ '<div class="cmd-palette-backdrop"></div>' +
113
+ '<div class="cmd-palette-dialog project-switcher-dialog">' +
114
+ '<div class="project-switcher-header">Switch project</div>' +
115
+ '<div class="cmd-palette-results"></div>' +
116
+ '<div class="cmd-palette-footer project-switcher-footer">' +
117
+ '<span class="project-switcher-shortcuts">' + modKbd + '<kbd class="proj-next-key">E</kbd> next \u00b7 <kbd>\u21E7</kbd>' + modKbd + '<kbd class="proj-prev-key">E</kbd> prev \u00b7 <kbd>\u23CE</kbd> select \u00b7 <kbd>Esc</kbd> cancel</span>' +
118
+ '</div>' +
119
+ '</div>';
120
+ document.body.appendChild(_overlay);
121
+ }
122
+
123
+ function mateIdFrom(target) {
124
+ return target && target.isMate && target.id ? target.id : null;
125
+ }
126
+
127
+ function bumpMru(list, key) {
128
+ // Mutate the original array to preserve the module-level reference
129
+ // so subscribers / future reads see the update without a reassign.
130
+ var idx = list.indexOf(key);
131
+ if (idx !== -1) list.splice(idx, 1);
132
+ list.unshift(key);
133
+ }
134
+
135
+ function handleKeyDown(e) {
136
+ if (!e.key) return;
137
+ var isMod = (e.metaKey || e.ctrlKey) && !e.altKey;
138
+ var k = e.key.toLowerCase();
139
+
140
+ // Mode shortcuts: Cmd/Ctrl+E (projects) and Cmd/Ctrl+M (mates).
141
+ // Pressing the other shortcut while open switches mode; pressing the
142
+ // same shortcut cycles.
143
+ if (isMod && (k === 'e' || k === 'm')) {
144
+ var requestedMode = k === 'e' ? 'project' : 'mate';
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ if (!_open) {
148
+ openSwitcher(requestedMode);
149
+ } else if (_mode !== requestedMode) {
150
+ // Switched modes mid-flight. Rebuild the list for the new mode.
151
+ openSwitcher(requestedMode);
152
+ } else if (e.shiftKey) {
153
+ cycle(-1);
154
+ } else {
155
+ cycle(1);
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (!_open) return;
161
+
162
+ if (e.key === 'Escape') {
163
+ e.preventDefault();
164
+ e.stopPropagation();
165
+ closeSwitcher(false);
166
+ return;
167
+ }
168
+ if (e.key === 'Enter') {
169
+ e.preventDefault();
170
+ e.stopPropagation();
171
+ closeSwitcher(true);
172
+ return;
173
+ }
174
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); cycle(1); return; }
175
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); cycle(-1); return; }
176
+ }
177
+
178
+ function handleKeyUp(e) {
179
+ if (!_open) return;
180
+ if (e.key === 'Meta' || e.key === 'Control') {
181
+ closeSwitcher(true);
182
+ }
183
+ }
184
+
185
+ function openSwitcher(mode) {
186
+ _mode = mode;
187
+ _entries = buildEntries(mode);
188
+ if (_entries.length === 0) {
189
+ // Nothing to switch to; don't show an empty modal.
190
+ if (_open) closeSwitcher(false);
191
+ return;
192
+ }
193
+ if (_headerEl) {
194
+ _headerEl.textContent = mode === 'mate' ? 'Switch mate' : 'Switch project';
195
+ }
196
+ updateFooterShortcut(mode);
197
+ renderEntries();
198
+ _highlightedIndex = pickInitialIndex();
199
+ paintHighlight();
200
+ _overlay.classList.remove('hidden');
201
+ _open = true;
202
+ refreshIcons();
203
+ // UI chrome matches the rest of Clay by swapping native emoji into
204
+ // Twemoji <img>s (same pattern as title bar, icon strip, project
205
+ // settings). The global COLR font fallback alone isn't reliable for
206
+ // UI surfaces because OS emoji fonts tend to match first.
207
+ parseEmojis(_list);
208
+ }
209
+
210
+ function updateFooterShortcut(mode) {
211
+ var nextKey = _overlay.querySelector('.proj-next-key');
212
+ var prevKey = _overlay.querySelector('.proj-prev-key');
213
+ var letter = mode === 'mate' ? 'M' : 'E';
214
+ if (nextKey) nextKey.textContent = letter;
215
+ if (prevKey) prevKey.textContent = letter;
216
+ }
217
+
218
+ function pickInitialIndex() {
219
+ // Highlight the "previous" selectable entry (Cmd+Tab semantics),
220
+ // while keeping the list in a stable natural order. Uses MRU to
221
+ // find which project/mate was visited before the current one, then
222
+ // locates its position in _entries.
223
+ if (_entries.length === 0) return 0;
224
+ var mru = _mode === 'mate' ? _mateMru : _projectMru;
225
+ var currentKey = currentActiveKey();
226
+ for (var m = 0; m < mru.length; m++) {
227
+ var key = mru[m];
228
+ if (key === currentKey) continue;
229
+ for (var i = 0; i < _entries.length; i++) {
230
+ if (_entries[i].key === key && !_entries[i].disabled) return i;
231
+ }
232
+ }
233
+ // Fallback: first non-current, non-disabled entry.
234
+ for (var j = 0; j < _entries.length; j++) {
235
+ if (!_entries[j].disabled && _entries[j].key !== currentKey) return j;
236
+ }
237
+ for (var d = 0; d < _entries.length; d++) {
238
+ if (!_entries[d].disabled) return d;
239
+ }
240
+ return 0;
241
+ }
242
+
243
+ function currentActiveKey() {
244
+ if (_mode === 'mate') {
245
+ return mateIdFrom(store.get('dmTargetUser'));
246
+ }
247
+ return store.get('currentSlug');
248
+ }
249
+
250
+ function buildEntries(mode) {
251
+ if (mode === 'mate') return buildMateEntries();
252
+ return buildProjectEntries();
253
+ }
254
+
255
+ function buildProjectEntries() {
256
+ var projects = (getCachedProjects() || []).filter(function (p) { return !p.isMate; });
257
+ // Keep natural (server-provided) order so the list looks the same
258
+ // every time the switcher opens. MRU only drives initial highlight.
259
+ return projects.map(function (p) {
260
+ // Worktrees can end up "outside project path" (parent workspace
261
+ // unmounted or moved); those are effectively unreachable from
262
+ // this session and shouldn't be switchable.
263
+ var unreachable = p.isWorktree && p.worktreeAccessible === false;
264
+ return {
265
+ key: p.slug,
266
+ title: p.title || p.project || p.slug,
267
+ icon: p.icon || null,
268
+ fallbackIcon: 'folder',
269
+ isCurrent: p.slug === store.get('currentSlug'),
270
+ disabled: unreachable,
271
+ disabledReason: unreachable ? 'Outside project path' : null,
272
+ };
273
+ });
274
+ }
275
+
276
+ function buildMateEntries() {
277
+ var mates = store.get('cachedMatesList') || [];
278
+ var currentMateId = mateIdFrom(store.get('dmTargetUser'));
279
+ // Natural list order; MRU is only for choosing the initial highlight.
280
+ return mates.map(function (m) {
281
+ return {
282
+ key: m.id,
283
+ title: m.displayName || m.name || m.id,
284
+ icon: null,
285
+ avatarUrl: mateAvatarUrl(m, 88),
286
+ fallbackIcon: 'user',
287
+ isCurrent: m.id === currentMateId,
288
+ disabled: false,
289
+ disabledReason: null,
290
+ };
291
+ });
292
+ }
293
+
294
+ function closeSwitcher(commit) {
295
+ if (!_open) return;
296
+ _open = false;
297
+ _overlay.classList.add('hidden');
298
+ if (!commit) return;
299
+ var entry = _entries[_highlightedIndex];
300
+ if (!entry || entry.disabled) return;
301
+ if (_mode === 'mate') {
302
+ if (entry.key !== currentActiveKey()) openDm(entry.key);
303
+ } else {
304
+ if (entry.key !== store.get('currentSlug')) switchProject(entry.key);
305
+ }
306
+ }
307
+
308
+ function cycle(delta) {
309
+ if (_entries.length === 0) return;
310
+ // Skip over disabled entries so unreachable worktrees aren't part
311
+ // of the cycle. If every entry is disabled (shouldn't happen, since
312
+ // openSwitcher bails on empty lists), fall back to plain stepping.
313
+ var step = delta > 0 ? 1 : -1;
314
+ var idx = _highlightedIndex;
315
+ for (var i = 0; i < _entries.length; i++) {
316
+ idx = (idx + step + _entries.length) % _entries.length;
317
+ if (!_entries[idx].disabled) {
318
+ _highlightedIndex = idx;
319
+ paintHighlight();
320
+ return;
321
+ }
322
+ }
323
+ }
324
+
325
+ function renderEntries() {
326
+ if (!_list) return;
327
+ _list.innerHTML = '';
328
+ for (var i = 0; i < _entries.length; i++) {
329
+ _list.appendChild(buildEntryNode(_entries[i], i));
330
+ }
331
+ }
332
+
333
+ function buildEntryNode(entry, index) {
334
+ var item = document.createElement('button');
335
+ item.type = 'button';
336
+ item.className = 'cmd-palette-item project-switcher-item';
337
+ if (entry.disabled) item.classList.add('disabled');
338
+ if (entry.disabled && entry.disabledReason) item.title = entry.disabledReason;
339
+ item.dataset.index = String(index);
340
+
341
+ var iconWrap = document.createElement('div');
342
+ iconWrap.className = 'cmd-palette-item-icon';
343
+ if (entry.icon) {
344
+ iconWrap.textContent = entry.icon;
345
+ } else if (entry.avatarUrl) {
346
+ var img = document.createElement('img');
347
+ img.src = entry.avatarUrl;
348
+ img.alt = '';
349
+ img.className = 'project-switcher-avatar';
350
+ iconWrap.appendChild(img);
351
+ } else {
352
+ var fallback = document.createElement('i');
353
+ fallback.setAttribute('data-lucide', entry.fallbackIcon || 'folder');
354
+ iconWrap.appendChild(fallback);
355
+ }
356
+ item.appendChild(iconWrap);
357
+
358
+ var body = document.createElement('div');
359
+ body.className = 'cmd-palette-item-body';
360
+ var titleRow = document.createElement('div');
361
+ titleRow.className = 'cmd-palette-item-title-row';
362
+ var title = document.createElement('span');
363
+ title.className = 'cmd-palette-item-title';
364
+ title.textContent = entry.title;
365
+ titleRow.appendChild(title);
366
+ if (entry.isCurrent) {
367
+ var badge = document.createElement('span');
368
+ badge.className = 'project-switcher-current';
369
+ badge.textContent = 'current';
370
+ titleRow.appendChild(badge);
371
+ }
372
+ if (entry.disabled && entry.disabledReason) {
373
+ var reason = document.createElement('span');
374
+ reason.className = 'project-switcher-disabled-reason';
375
+ reason.textContent = entry.disabledReason;
376
+ titleRow.appendChild(reason);
377
+ }
378
+ body.appendChild(titleRow);
379
+ item.appendChild(body);
380
+
381
+ item.addEventListener('mouseenter', function () {
382
+ if (entry.disabled) return;
383
+ _highlightedIndex = index;
384
+ paintHighlight();
385
+ });
386
+ item.addEventListener('click', function () {
387
+ if (entry.disabled) return;
388
+ _highlightedIndex = index;
389
+ closeSwitcher(true);
390
+ });
391
+ return item;
392
+ }
393
+
394
+ function paintHighlight() {
395
+ if (!_list) return;
396
+ var items = _list.querySelectorAll('.cmd-palette-item');
397
+ for (var i = 0; i < items.length; i++) {
398
+ items[i].classList.toggle('active', i === _highlightedIndex);
399
+ }
400
+ var active = items[_highlightedIndex];
401
+ if (active && active.scrollIntoView) {
402
+ active.scrollIntoView({ block: 'nearest' });
403
+ }
404
+ }