clideck 1.22.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/activity.js +56 -0
  4. package/agent-presets.json +93 -0
  5. package/assets/clideck-themes.jpg +0 -0
  6. package/bin/clideck.js +2 -0
  7. package/config.js +96 -0
  8. package/handlers.js +297 -0
  9. package/opencode-bridge.js +148 -0
  10. package/opencode-plugin/clideck-bridge.js +24 -0
  11. package/package.json +47 -0
  12. package/paths.js +41 -0
  13. package/plugin-loader.js +285 -0
  14. package/plugins/trim-clip/clideck-plugin.json +13 -0
  15. package/plugins/trim-clip/client.js +31 -0
  16. package/plugins/trim-clip/index.js +10 -0
  17. package/plugins/voice-input/clideck-plugin.json +49 -0
  18. package/plugins/voice-input/client.js +196 -0
  19. package/plugins/voice-input/index.js +342 -0
  20. package/plugins/voice-input/python/mel_filters.npz +0 -0
  21. package/plugins/voice-input/python/whisper_turbo.py +416 -0
  22. package/plugins/voice-input/python/worker.py +135 -0
  23. package/public/fx/bold-beep-idle.mp3 +0 -0
  24. package/public/fx/default-beep.mp3 +0 -0
  25. package/public/fx/echo-beep-idle.mp3 +0 -0
  26. package/public/fx/musical-beep-idle.mp3 +0 -0
  27. package/public/fx/small-bleep-idle.mp3 +0 -0
  28. package/public/fx/soft-beep.mp3 +0 -0
  29. package/public/fx/space-idle.mp3 +0 -0
  30. package/public/img/claude-code.png +0 -0
  31. package/public/img/clideck-logo-icon.png +0 -0
  32. package/public/img/clideck-logo-terminal-panel.png +0 -0
  33. package/public/img/codex.png +0 -0
  34. package/public/img/gemini.png +0 -0
  35. package/public/img/opencode.png +0 -0
  36. package/public/index.html +243 -0
  37. package/public/js/app.js +794 -0
  38. package/public/js/color-mode.js +51 -0
  39. package/public/js/confirm.js +27 -0
  40. package/public/js/creator.js +201 -0
  41. package/public/js/drag.js +134 -0
  42. package/public/js/folder-picker.js +81 -0
  43. package/public/js/hotkeys.js +90 -0
  44. package/public/js/nav.js +56 -0
  45. package/public/js/profiles.js +22 -0
  46. package/public/js/prompts.js +325 -0
  47. package/public/js/settings.js +489 -0
  48. package/public/js/state.js +15 -0
  49. package/public/js/terminals.js +905 -0
  50. package/public/js/toast.js +62 -0
  51. package/public/js/utils.js +27 -0
  52. package/public/tailwind.css +1 -0
  53. package/server.js +126 -0
  54. package/sessions.js +375 -0
  55. package/telemetry-receiver.js +129 -0
  56. package/themes.js +247 -0
  57. package/transcript.js +90 -0
  58. package/utils.js +66 -0
@@ -0,0 +1,794 @@
1
+ import { state, send } from './state.js';
2
+ import { esc, binName } from './utils.js';
3
+ import { addTerminal, removeTerminal, select, startRename, startProjectRename, setSessionTheme, openMenu, closeMenu, setStatus, updateMuteIndicator, updatePreview, markUnread, applyFilter, setTab, renderResumable, regroupSessions, toggleProjectCollapse, setSessionProject, estimateSize, restartComplete, positionMenu } from './terminals.js';
4
+ import { renderSettings } from './settings.js';
5
+ import { openCreator, closeCreator } from './creator.js';
6
+ import { handleDirsResponse, openFolderPicker } from './folder-picker.js';
7
+ import { confirmClose } from './confirm.js';
8
+ import { applyTheme } from './profiles.js';
9
+ import { toggleMode, applyMode } from './color-mode.js';
10
+ import { showToast } from './toast.js';
11
+ import './nav.js';
12
+ import { initDrag } from './drag.js';
13
+ import { registerHotkey, unregisterHotkey } from './hotkeys.js';
14
+ import { renderPrompts } from './prompts.js';
15
+
16
+ function connect() {
17
+ state.ws = new WebSocket(`ws://${location.host}`);
18
+
19
+ state.ws.onopen = () => {
20
+ for (const [, e] of state.terms) { e.ro.disconnect(); e.term.dispose(); e.el.remove(); }
21
+ state.terms.clear();
22
+ document.getElementById('session-list').innerHTML = '';
23
+ state.active = null;
24
+ document.getElementById('empty').style.display = 'flex';
25
+ };
26
+
27
+ state.ws.onmessage = ({ data }) => {
28
+ const msg = JSON.parse(data);
29
+ switch (msg.type) {
30
+ case 'config':
31
+ state.cfg = msg.config;
32
+ applyMode(state.cfg.colorMode || 'dark');
33
+ regroupSessions();
34
+ renderSettings();
35
+ renderPrompts();
36
+ for (const [, entry] of state.terms) applyTheme(entry.term, entry.themeId);
37
+ break;
38
+ case 'themes':
39
+ state.themes = msg.themes;
40
+ renderSettings();
41
+ break;
42
+ case 'presets':
43
+ state.presets = msg.presets;
44
+ renderSettings();
45
+ break;
46
+ case 'sessions.resumable':
47
+ state.resumable = msg.list;
48
+ renderResumable();
49
+ break;
50
+ case 'sessions':
51
+ msg.list.forEach(s => addTerminal(s.id, s.name, s.themeId, s.commandId, s.projectId, s.muted, s.lastPreview));
52
+ if (msg.list.length) select(msg.list[0].id);
53
+ break;
54
+ case 'created':
55
+ if (!state.terms.has(msg.id)) addTerminal(msg.id, msg.name, msg.themeId, msg.commandId, msg.projectId, msg.muted, msg.lastPreview);
56
+ select(msg.id);
57
+ applyFilter();
58
+ break;
59
+ case 'output': {
60
+ const entry = state.terms.get(msg.id);
61
+ if (entry && !entry.queue(msg.data)) entry.term.write(msg.data);
62
+ updatePreview(msg.id);
63
+ markUnread(msg.id);
64
+ break;
65
+ }
66
+ case 'closed':
67
+ removeTerminal(msg.id);
68
+ break;
69
+ case 'session.restarted':
70
+ console.log('[restart] got session.restarted from server', msg);
71
+ restartComplete(msg.id, msg);
72
+ break;
73
+ // Telemetry/bridge working/idle
74
+ case 'session.status':
75
+ setStatus(msg.id, msg.working);
76
+ break;
77
+ // Bridge preview text (OpenCode plugin)
78
+ case 'session.preview': {
79
+ const pe = state.terms.get(msg.id);
80
+ if (pe && msg.text) {
81
+ pe.lastPreviewText = msg.text;
82
+ pe.lastActivityAt = Date.now();
83
+ const el = document.querySelector(`.group[data-id="${msg.id}"] .session-preview`);
84
+ if (el) el.textContent = msg.text;
85
+ // Persist bridge preview on server — picked up by 30s auto-save
86
+ send({ type: 'session.setPreview', id: msg.id, text: msg.text, timestamp: new Date().toISOString() });
87
+ }
88
+ break;
89
+ }
90
+ case 'stats': {
91
+ for (const [sid, st] of Object.entries(msg.stats)) {
92
+ const entry = state.terms.get(sid);
93
+ if (!entry) continue;
94
+ const cmd = state.cfg.commands.find(c => c.id === entry.commandId);
95
+ if (cmd?.bridge) continue;
96
+ const net = Math.max(st.rawRateOut || 0, st.rawRateIn || 0);
97
+ const burstUp = (st.burstMs || 0) > (entry.prevBurst || 0) && st.burstMs > 0;
98
+ const userTyping = (st.rawRateIn || 0) > 0 && (st.rawRateIn || 0) < 50;
99
+ entry.prevBurst = st.burstMs || 0;
100
+
101
+ // Working: burst increasing + net >= 800B + no typing
102
+ const isWorking = burstUp && net >= 800 && !userTyping;
103
+ // Idle: burst not increasing + net < 800B
104
+ const isIdle = !burstUp && net < 800;
105
+
106
+ // Sustain for ~1.5s (2 ticks)
107
+ if (isWorking) entry.workTicks = (entry.workTicks || 0) + 1;
108
+ else entry.workTicks = 0;
109
+ if (isIdle) entry.idleTicks = (entry.idleTicks || 0) + 1;
110
+ else entry.idleTicks = 0;
111
+
112
+ if (entry.workTicks >= 2) {
113
+ if (!entry.working) send({ type: 'session.statusReport', id: sid, working: true });
114
+ setStatus(sid, true);
115
+ } else if (entry.idleTicks >= 2) {
116
+ if (entry.working) send({ type: 'session.statusReport', id: sid, working: false });
117
+ setStatus(sid, false);
118
+ }
119
+ }
120
+ break;
121
+ }
122
+ case 'transcript.cache':
123
+ state.transcriptCache = msg.cache;
124
+ for (const [id, text] of Object.entries(msg.cache)) {
125
+ const entry = state.terms.get(id);
126
+ if (entry) entry.searchText = text;
127
+ }
128
+ break;
129
+ case 'transcript.append': {
130
+ state.transcriptCache[msg.id] = (state.transcriptCache[msg.id] || '') + '\n' + msg.text;
131
+ const entry = state.terms.get(msg.id);
132
+ if (entry) {
133
+ entry.searchText = (entry.searchText || '') + '\n' + msg.text;
134
+ if (state.filter.query) applyFilter();
135
+ }
136
+ break;
137
+ }
138
+ case 'dirs':
139
+ handleDirsResponse(msg);
140
+ break;
141
+ case 'session.theme': {
142
+ const entry = state.terms.get(msg.id);
143
+ if (entry) {
144
+ entry.themeId = msg.themeId;
145
+ applyTheme(entry.term, msg.themeId);
146
+ }
147
+ break;
148
+ }
149
+ case 'session.setProject': {
150
+ const entry = state.terms.get(msg.id);
151
+ if (entry) { entry.projectId = msg.projectId; regroupSessions(); }
152
+ break;
153
+ }
154
+ case 'session.mute': {
155
+ const entry = state.terms.get(msg.id);
156
+ if (entry) { entry.muted = !!msg.muted; updateMuteIndicator(msg.id); }
157
+ break;
158
+ }
159
+ case 'session.needsSetup': {
160
+ const entry = state.terms.get(msg.id);
161
+ if (entry) showTelemetrySetup(entry.commandId, msg.id);
162
+ break;
163
+ }
164
+ case 'renamed': {
165
+ const el = document.querySelector(`.group[data-id="${msg.id}"] .name`);
166
+ if (el && el.contentEditable !== 'true') el.textContent = msg.name;
167
+ break;
168
+ }
169
+ case 'telemetry.autosetup.result': {
170
+ const toast = document.querySelector(`[data-setup-preset="${msg.presetId}"]`);
171
+ if (!toast) break;
172
+ const actionsEl = toast.querySelector('.setup-actions');
173
+ if (msg.success) {
174
+ const sid = toast.dataset.sessionId;
175
+ const cmdId = toast.dataset.commandId;
176
+ actionsEl.innerHTML = `
177
+ <div class="flex-1 flex items-center gap-1.5 text-xs text-emerald-400">
178
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>
179
+ Configured
180
+ </div>
181
+ <button class="restart-btn px-3 py-2 text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors">Restart Session</button>
182
+ <button class="dismiss-btn px-3 py-2 text-xs text-slate-500 hover:text-slate-300 transition-colors">Dismiss</button>`;
183
+ actionsEl.querySelector('.dismiss-btn').onclick = () => toast.remove();
184
+ actionsEl.querySelector('.restart-btn').onclick = () => {
185
+ const entry = state.terms.get(sid);
186
+ send({ type: 'session.restart', id: sid, themeId: entry?.themeId, cols: entry?.term?.cols, rows: entry?.term?.rows });
187
+ toast.remove();
188
+ };
189
+ } else {
190
+ shownSetup.delete(msg.presetId);
191
+ const btn = toast.querySelector('.auto-setup-btn');
192
+ btn.textContent = 'Failed — configure manually';
193
+ btn.className = 'auto-setup-btn flex-1 px-3 py-2 text-xs font-medium bg-red-600/20 text-red-400 border border-red-500/30 rounded-lg cursor-default';
194
+ }
195
+ break;
196
+ }
197
+ case 'sessions.saved':
198
+ flashSaveIndicator();
199
+ break;
200
+ case 'plugins':
201
+ loadPlugins(msg.list);
202
+ break;
203
+ default:
204
+ if (msg.type?.startsWith('plugin.')) dispatchPluginMessage(msg);
205
+ break;
206
+ }
207
+ };
208
+
209
+ state.ws.onclose = () => setTimeout(connect, 1000);
210
+ }
211
+
212
+ // Sidebar events
213
+ const sessionList = document.getElementById('session-list');
214
+
215
+ sessionList.addEventListener('click', (e) => {
216
+ closeCreator();
217
+ closeProjectCreator();
218
+
219
+ // Project header click — toggle collapse
220
+ const projHeader = e.target.closest('.project-header');
221
+ if (projHeader && !e.target.closest('.project-menu-btn')) {
222
+ toggleProjectCollapse(projHeader.dataset.projectId);
223
+ return;
224
+ }
225
+ // Project menu button
226
+ if (e.target.closest('.project-menu-btn')) {
227
+ const projId = e.target.closest('.project-group')?.dataset.projectId;
228
+ if (projId) openProjectMenu(projId, e.target.closest('.project-menu-btn'));
229
+ return;
230
+ }
231
+
232
+ // Previous sessions menu button
233
+ if (e.target.closest('.prev-sessions-menu-btn')) {
234
+ openPrevSessionsMenu(e.target.closest('.prev-sessions-menu-btn'));
235
+ return;
236
+ }
237
+
238
+ // Resumable session click
239
+ const resumableRow = e.target.closest('[data-resumable-id]');
240
+ if (resumableRow) {
241
+ send({ type: 'session.resume', id: resumableRow.dataset.resumableId });
242
+ return;
243
+ }
244
+
245
+ const item = e.target.closest('.group');
246
+ if (!item) return;
247
+
248
+ // Menu button
249
+ if (e.target.closest('.menu-btn')) {
250
+ openMenu(item.dataset.id, e.target.closest('.menu-btn'));
251
+ return;
252
+ }
253
+
254
+ select(item.dataset.id);
255
+ });
256
+
257
+ sessionList.addEventListener('dblclick', (e) => {
258
+ const nameEl = e.target.closest('.name');
259
+ if (nameEl) {
260
+ const id = e.target.closest('.group[data-id]')?.dataset.id;
261
+ if (id) startRename(id);
262
+ }
263
+ // Project name rename
264
+ const projNameEl = e.target.closest('.project-name');
265
+ if (projNameEl) {
266
+ const projId = e.target.closest('.project-group')?.dataset.projectId;
267
+ if (projId) startProjectRename(projId);
268
+ }
269
+ });
270
+
271
+ // Session delete from context menu — always confirm
272
+ sessionList.addEventListener('session-delete', async (e) => {
273
+ const id = e.detail.id;
274
+ const ok = await confirmClose();
275
+ if (!ok) return;
276
+ send({ type: 'close', id });
277
+ });
278
+
279
+ // Mode toggle theme switch — dispatched from color-mode.js to avoid circular import
280
+ let modeToastQueued = false;
281
+ document.addEventListener('clideck-theme-switch', (e) => {
282
+ setSessionTheme(e.detail.id, e.detail.themeId, { showBanner: false });
283
+ if (!modeToastQueued) {
284
+ modeToastQueued = true;
285
+ queueMicrotask(() => {
286
+ modeToastQueued = false;
287
+ showModeToast();
288
+ });
289
+ }
290
+ });
291
+
292
+ function showModeToast() {
293
+ showToast('If a terminal looks off, right-click the session and choose <strong class="text-slate-200">Refresh session</strong>.', {
294
+ type: 'warn', duration: 4000, id: 'mode', html: true,
295
+ });
296
+ }
297
+
298
+ document.getElementById('btn-new').addEventListener('click', openCreator);
299
+ document.getElementById('btn-new-project').addEventListener('click', () => {
300
+ closeCreator();
301
+ openProjectCreator();
302
+ });
303
+
304
+ // Search & filter toolbar
305
+ document.getElementById('search-input').addEventListener('input', (e) => {
306
+ state.filter.query = e.target.value;
307
+ applyFilter();
308
+ });
309
+ document.querySelectorAll('.filter-tab').forEach(btn => {
310
+ btn.addEventListener('click', () => setTab(btn.dataset.tab));
311
+ });
312
+
313
+
314
+
315
+ // Telemetry setup notification — shown once per agent type
316
+ const shownSetup = new Set();
317
+ function showTelemetrySetup(commandId, sessionId) {
318
+ const cmd = state.cfg.commands.find(c => c.id === commandId);
319
+ if (!cmd) return;
320
+ // Skip if telemetry is already configured via settings
321
+ if (cmd.telemetryEnabled && cmd.telemetryStatus?.ok) return;
322
+ const bin = binName(cmd.command);
323
+ const preset = state.presets.find(p => binName(p.command) === bin);
324
+ const setupRaw = preset.telemetrySetup || preset.pluginSetup;
325
+ if (!setupRaw || shownSetup.has(preset.presetId)) return;
326
+ shownSetup.add(preset.presetId);
327
+
328
+ const port = location.port || '4000';
329
+ const setupText = setupRaw.replace(/\{\{port\}\}/g, port);
330
+ const [desc, ...codeParts] = setupText.split('\n\n');
331
+ const code = codeParts.join('\n\n');
332
+ const auto = preset.telemetryAutoSetup;
333
+ const iconSrc = preset.icon?.startsWith('/') ? preset.icon : null;
334
+ const title = preset.bridge ? 'Bridge Plugin' : 'Status Tracking';
335
+
336
+ const toast = document.createElement('div');
337
+ toast.dataset.setupPreset = preset.presetId;
338
+ toast.dataset.sessionId = sessionId;
339
+ toast.dataset.commandId = commandId;
340
+ toast.className = 'fixed bottom-5 right-5 z-[500] w-[360px] bg-slate-800/95 backdrop-blur-sm border border-slate-700/60 rounded-xl shadow-2xl shadow-black/60';
341
+ toast.style.opacity = '0';
342
+ toast.style.transform = 'translateY(12px)';
343
+ toast.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
344
+
345
+ toast.innerHTML = `
346
+ <div class="flex items-center gap-2.5 px-4 pt-3.5 pb-1">
347
+ ${iconSrc ? `<img src="${esc(iconSrc)}" class="w-5 h-5 object-contain flex-shrink-0">` : ''}
348
+ <span class="text-[13px] font-semibold text-slate-200">${esc(preset.name)} — ${title}</span>
349
+ <button class="dismiss-btn ml-auto w-6 h-6 flex items-center justify-center rounded-md text-slate-500 hover:text-slate-300 hover:bg-slate-700/50 transition-colors">
350
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
351
+ </button>
352
+ </div>
353
+ <p class="px-4 pt-1 pb-2.5 text-xs text-slate-400 leading-relaxed">${esc(desc)}</p>
354
+ ${code ? `<div class="mx-4 mb-3 px-3 py-2.5 bg-slate-900/70 rounded-lg border border-slate-700/40">
355
+ <pre class="text-[11px] text-emerald-400/80 font-mono leading-relaxed whitespace-pre-wrap">${esc(code)}</pre>
356
+ </div>` : ''}
357
+ <div class="setup-actions px-4 pb-3.5 flex items-center gap-2">
358
+ ${auto ? `<button class="auto-setup-btn flex-1 px-3 py-2 text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors">
359
+ ${esc(auto.label)}
360
+ </button>` : ''}
361
+ <button class="dismiss-btn px-3 py-2 text-xs text-slate-500 hover:text-slate-300 transition-colors">Dismiss</button>
362
+ </div>`;
363
+
364
+ toast.querySelectorAll('.dismiss-btn').forEach(b => b.onclick = () => {
365
+ shownSetup.delete(preset.presetId);
366
+ toast.remove();
367
+ });
368
+
369
+ const autoBtn = toast.querySelector('.auto-setup-btn');
370
+ if (autoBtn) {
371
+ autoBtn.onclick = () => {
372
+ autoBtn.disabled = true;
373
+ autoBtn.innerHTML = `<svg class="w-3.5 h-3.5 inline animate-spin -mt-px mr-1.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2a10 10 0 0 1 10 10"/></svg>Configuring…`;
374
+ autoBtn.className = 'auto-setup-btn flex-1 px-3 py-2 text-xs font-medium bg-slate-700 text-slate-300 rounded-lg cursor-wait';
375
+ send({ type: 'telemetry.autosetup', presetId: preset.presetId });
376
+ };
377
+ }
378
+
379
+ document.body.appendChild(toast);
380
+ requestAnimationFrame(() => {
381
+ toast.style.opacity = '1';
382
+ toast.style.transform = 'translateY(0)';
383
+ });
384
+ }
385
+
386
+ // --- Project context menu ---
387
+ let projectMenuCleanup = null;
388
+ function openProjectMenu(projectId, anchorEl) {
389
+ if (projectMenuCleanup) projectMenuCleanup();
390
+ const proj = (state.cfg.projects || []).find(p => p.id === projectId);
391
+ const rect = anchorEl.getBoundingClientRect();
392
+ const menu = document.createElement('div');
393
+ menu.className = 'fixed z-[400] min-w-[160px] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1';
394
+ // Count dormant (resumable) sessions in this project
395
+ const dormantIds = state.resumable.filter(s => s.projectId === projectId).map(s => s.id);
396
+ const hasDormant = dormantIds.length > 0;
397
+
398
+ menu.innerHTML = `
399
+ <div class="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-slate-600">Color</div>
400
+ <div class="px-3 pb-2 flex gap-1.5">
401
+ ${PROJECT_COLORS.map(c => `
402
+ <button class="color-pick w-5 h-5 rounded-full transition-transform hover:scale-125 ${proj?.color === c ? 'ring-2 ring-white/40 scale-110' : ''}" data-color="${c}" style="background:${c}"></button>
403
+ `).join('')}
404
+ </div>
405
+ <div class="border-t border-slate-700/50 my-1"></div>
406
+ <button class="pm-action flex items-center gap-2 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="rename">
407
+ <svg class="w-4 h-4 flex-shrink-0 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
408
+ Rename
409
+ </button>
410
+ <button class="pm-action flex items-center gap-2 w-full px-3 py-2 text-sm ${hasDormant ? 'text-slate-300 hover:bg-slate-700 cursor-pointer' : 'text-slate-600 cursor-default'} transition-colors text-left" data-action="clear-dormant" ${hasDormant ? '' : 'disabled'}>
411
+ <svg class="w-4 h-4 flex-shrink-0 ${hasDormant ? 'text-slate-400' : 'text-slate-600'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><line x1="18" y1="9" x2="12" y2="15"/><line x1="12" y1="9" x2="18" y2="15"/></svg>
412
+ Clear dormant sessions
413
+ </button>
414
+ <button class="pm-action flex items-center gap-2 w-full px-3 py-2 text-sm text-red-400 hover:bg-slate-700 transition-colors text-left" data-action="delete">
415
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
416
+ Delete project
417
+ </button>`;
418
+ positionMenu(menu, rect);
419
+ const onClick = (e) => {
420
+ // Color pick
421
+ const colorBtn = e.target.closest('.color-pick');
422
+ if (colorBtn && proj) {
423
+ proj.color = colorBtn.dataset.color;
424
+ send({ type: 'config.update', config: state.cfg });
425
+ regroupSessions();
426
+ if (projectMenuCleanup) projectMenuCleanup();
427
+ return;
428
+ }
429
+ const btn = e.target.closest('.pm-action');
430
+ if (!btn) return;
431
+ if (projectMenuCleanup) projectMenuCleanup();
432
+ if (btn.dataset.action === 'rename') {
433
+ startProjectRename(projectId);
434
+ return;
435
+ }
436
+ if (btn.dataset.action === 'clear-dormant') {
437
+ const ids = state.resumable.filter(s => s.projectId === projectId).map(s => s.id);
438
+ if (!ids.length) return;
439
+ confirmClose(`Clear ${ids.length} dormant session${ids.length > 1 ? 's' : ''} from "${proj?.name}"?`, 'Clear').then(ok => {
440
+ if (ok) for (const id of ids) send({ type: 'close', id });
441
+ });
442
+ return;
443
+ }
444
+ if (btn.dataset.action === 'delete') {
445
+ const count = [...state.terms.values()].filter(e => e.projectId === projectId).length;
446
+ const msg = count
447
+ ? `Delete project "${proj?.name}"? This will close ${count} active session${count > 1 ? 's' : ''}.`
448
+ : `Delete project "${proj?.name}"?`;
449
+ confirmClose(msg, 'Delete').then(ok => {
450
+ if (ok) send({ type: 'project.delete', id: projectId });
451
+ });
452
+ }
453
+ };
454
+ const onOutside = (e) => { if (!menu.contains(e.target)) { if (projectMenuCleanup) projectMenuCleanup(); } };
455
+ menu.addEventListener('click', onClick);
456
+ requestAnimationFrame(() => document.addEventListener('click', onOutside));
457
+ projectMenuCleanup = () => {
458
+ menu.removeEventListener('click', onClick);
459
+ document.removeEventListener('click', onOutside);
460
+ menu.remove();
461
+ projectMenuCleanup = null;
462
+ };
463
+ }
464
+
465
+ // --- Previous Sessions menu ---
466
+ let prevMenuCleanup = null;
467
+ function openPrevSessionsMenu(anchorEl) {
468
+ if (prevMenuCleanup) prevMenuCleanup();
469
+ const rect = anchorEl.getBoundingClientRect();
470
+ const menu = document.createElement('div');
471
+ menu.className = 'fixed z-[400] min-w-[160px] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1';
472
+
473
+ const dormantIds = state.resumable.filter(s => !s.projectId).map(s => s.id);
474
+
475
+ menu.innerHTML = `
476
+ <button class="pv-action flex items-center gap-2 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="clear-dormant">
477
+ <svg class="w-4 h-4 flex-shrink-0 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><line x1="18" y1="9" x2="12" y2="15"/><line x1="12" y1="9" x2="18" y2="15"/></svg>
478
+ Clear dormant sessions
479
+ </button>`;
480
+ positionMenu(menu, rect);
481
+ const onClick = (e) => {
482
+ const btn = e.target.closest('.pv-action');
483
+ if (!btn) return;
484
+ if (prevMenuCleanup) prevMenuCleanup();
485
+ confirmClose(`Clear ${dormantIds.length} dormant session${dormantIds.length > 1 ? 's' : ''}?`, 'Clear').then(ok => {
486
+ if (ok) for (const id of dormantIds) send({ type: 'close', id });
487
+ });
488
+ };
489
+ const onOutside = (e) => { if (!menu.contains(e.target)) { if (prevMenuCleanup) prevMenuCleanup(); } };
490
+ menu.addEventListener('click', onClick);
491
+ requestAnimationFrame(() => document.addEventListener('click', onOutside));
492
+ prevMenuCleanup = () => {
493
+ menu.removeEventListener('click', onClick);
494
+ document.removeEventListener('click', onOutside);
495
+ menu.remove();
496
+ prevMenuCleanup = null;
497
+ };
498
+ }
499
+
500
+ // --- Project creator ---
501
+ const PROJECT_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#ef4444', '#06b6d4', '#84cc16'];
502
+ const FOLDER_SVG = `<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
503
+
504
+ function closeProjectCreator() {
505
+ document.getElementById('project-creator')?.remove();
506
+ }
507
+
508
+ function openProjectCreator() {
509
+ if (document.getElementById('project-creator')) { closeProjectCreator(); return; }
510
+ // Close session creator if open
511
+ closeCreator();
512
+
513
+ const defaultPath = state.cfg.defaultPath || '';
514
+
515
+ const card = document.createElement('div');
516
+ card.id = 'project-creator';
517
+ card.className = 'p-3 border-b border-slate-700/50 bg-slate-800/30';
518
+ card.innerHTML = `
519
+ <div class="text-[10px] font-semibold uppercase tracking-wider text-slate-500 mb-2">New Project</div>
520
+ <div class="flex items-center gap-1.5 mb-2">
521
+ <input id="pc-path" type="text" value="${esc(defaultPath)}" placeholder="Project folder path"
522
+ class="flex-1 px-3 py-1.5 text-xs bg-slate-900 border border-slate-700 rounded-md text-slate-400 placeholder-slate-600 outline-none focus:border-blue-500 transition-colors font-mono">
523
+ <button id="pc-browse" class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-md border border-slate-700 text-slate-500 hover:text-slate-300 hover:bg-slate-700 transition-colors" title="Browse">
524
+ ${FOLDER_SVG}
525
+ </button>
526
+ </div>
527
+ <input id="pc-name" type="text" maxlength="35" placeholder="Project name"
528
+ class="w-full px-3 py-2 text-sm bg-slate-900 border border-slate-700 rounded-md text-slate-200 placeholder-slate-500 outline-none focus:border-blue-500 transition-colors mb-2">
529
+ <div class="flex items-center gap-2">
530
+ <button id="pc-create" class="px-4 py-1.5 text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-md transition-colors">Create</button>
531
+ <button id="pc-cancel" class="px-3 py-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors">Cancel</button>
532
+ </div>`;
533
+
534
+ const list = document.getElementById('session-list');
535
+ list.parentElement.insertBefore(card, list);
536
+
537
+ const nameInput = card.querySelector('#pc-name');
538
+ const pathInput = card.querySelector('#pc-path');
539
+ pathInput.focus();
540
+
541
+ // Auto-fill project name from last folder in path
542
+ const autoFillName = () => {
543
+ const path = pathInput.value.trim();
544
+ if (!path) return;
545
+ const lastFolder = path.replace(/[\\/]+$/, '').split(/[\\/]/).pop();
546
+ if (lastFolder && !nameInput.dataset.userEdited) {
547
+ nameInput.value = lastFolder;
548
+ }
549
+ };
550
+ pathInput.addEventListener('input', autoFillName);
551
+ pathInput.addEventListener('change', autoFillName);
552
+ nameInput.addEventListener('input', () => { nameInput.dataset.userEdited = '1'; });
553
+
554
+ const doCreate = () => {
555
+ const path = pathInput.value.trim();
556
+ const lastFolder = path ? path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : '';
557
+ const name = nameInput.value.trim() || lastFolder;
558
+ if (!name) { nameInput.focus(); return; }
559
+ const projects = state.cfg.projects || [];
560
+ projects.push({
561
+ id: crypto.randomUUID(),
562
+ name,
563
+ path: path || undefined,
564
+ color: PROJECT_COLORS[projects.length % PROJECT_COLORS.length],
565
+ collapsed: false,
566
+ });
567
+ state.cfg.projects = projects;
568
+ closeProjectCreator();
569
+ regroupSessions();
570
+ send({ type: 'config.update', config: state.cfg });
571
+ };
572
+
573
+ card.querySelector('#pc-create').addEventListener('click', doCreate);
574
+ card.querySelector('#pc-cancel').addEventListener('click', closeProjectCreator);
575
+ card.querySelector('#pc-browse').addEventListener('click', () => {
576
+ openFolderPicker(pathInput.value.trim() || defaultPath, (path) => {
577
+ pathInput.value = path;
578
+ autoFillName();
579
+ });
580
+ });
581
+ nameInput.addEventListener('keydown', (e) => {
582
+ if (e.key === 'Enter') doCreate();
583
+ if (e.key === 'Escape') closeProjectCreator();
584
+ });
585
+ pathInput.addEventListener('keydown', (e) => {
586
+ if (e.key === 'Enter') doCreate();
587
+ if (e.key === 'Escape') closeProjectCreator();
588
+ });
589
+ }
590
+
591
+ document.getElementById('btn-theme-toggle').addEventListener('click', toggleMode);
592
+
593
+ // --- Plugin system (frontend) ---
594
+
595
+ const pluginMessageHandlers = new Map();
596
+ const loadedPlugins = new Set();
597
+
598
+ function dispatchPluginMessage(msg) {
599
+ const fn = pluginMessageHandlers.get(msg.type);
600
+ if (fn) {
601
+ try { fn(msg); }
602
+ catch (e) { console.error(`[plugin] client handler error for ${msg.type}:`, e); }
603
+ }
604
+ }
605
+
606
+ function addPluginToolbarButton(pluginId, opts) {
607
+ const toolbar = document.getElementById('plugin-toolbar');
608
+ const btn = document.createElement('button');
609
+ btn.className = 'plugin-btn w-8 h-8 flex items-center justify-center rounded-lg bg-slate-800/80 border border-slate-700/50 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors backdrop-blur-sm';
610
+ btn.title = opts.title || '';
611
+ btn.innerHTML = opts.icon || '';
612
+ btn.dataset.pluginId = pluginId;
613
+ if (opts.id) btn.dataset.actionId = opts.id;
614
+ btn.addEventListener('click', () => {
615
+ if (typeof opts.onClick === 'function') opts.onClick();
616
+ });
617
+ toolbar.appendChild(btn);
618
+ return btn;
619
+ }
620
+
621
+ function getPluginExpanded() {
622
+ try { return JSON.parse(localStorage.getItem('clideck.pluginsExpanded') || '{}'); } catch { return {}; }
623
+ }
624
+ function setPluginExpanded(id, open) {
625
+ const map = getPluginExpanded();
626
+ if (open) map[id] = true; else delete map[id];
627
+ localStorage.setItem('clideck.pluginsExpanded', JSON.stringify(map));
628
+ }
629
+
630
+ function renderPluginsPanel(list) {
631
+ const container = document.getElementById('plugins-list');
632
+ if (!list.length) {
633
+ container.innerHTML = `<div class="flex flex-col items-center justify-center h-full px-6 text-center">
634
+ <p class="text-sm text-slate-400 mb-1">No plugins installed</p>
635
+ <p class="text-xs text-slate-600 leading-relaxed">Plugins live in <code class="px-1 py-0.5 rounded bg-slate-800 text-slate-400 text-[11px]">~/.clideck/plugins/</code><br>Each one is a folder with a <code class="px-1 py-0.5 rounded bg-slate-800 text-slate-400 text-[11px]">clideck-plugin.json</code> and <code class="px-1 py-0.5 rounded bg-slate-800 text-slate-400 text-[11px]">index.js</code></p>
636
+ </div>`;
637
+ return;
638
+ }
639
+ const expanded = getPluginExpanded();
640
+ container.innerHTML = list.map((p, i) => {
641
+ const open = !!expanded[p.id];
642
+ return `
643
+ <div class="plugin-card ${i > 0 ? 'border-t border-slate-700/50' : ''}">
644
+ <button class="plugin-toggle w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-slate-800/50 transition-colors" data-plugin-id="${esc(p.id)}">
645
+ <span class="flex-1 text-sm font-medium text-slate-200">${esc(p.name)}</span>
646
+ <span class="text-[10px] text-slate-500">v${esc(p.version)}</span>
647
+ <svg class="plugin-chevron w-4 h-4 text-slate-500 transition-transform duration-200 ${open ? '' : 'collapsed'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M19 9l-7 7-7-7"/></svg>
648
+ </button>
649
+ <div class="plugin-body ${open ? '' : 'hidden'}">
650
+ <div class="px-4 pb-3">
651
+ ${(p.settings || []).map(s => renderSettingField(p.id, s, p.settingValues[s.key] ?? s.default)).join('')}
652
+ </div>
653
+ </div>
654
+ </div>`;
655
+ }).join('');
656
+
657
+ container.querySelectorAll('.plugin-toggle').forEach(btn => {
658
+ btn.addEventListener('click', () => {
659
+ const id = btn.dataset.pluginId;
660
+ const body = btn.nextElementSibling;
661
+ if (!body) return;
662
+ const chevron = btn.querySelector('.plugin-chevron');
663
+ const nowHidden = body.classList.toggle('hidden');
664
+ chevron.classList.toggle('collapsed', nowHidden);
665
+ setPluginExpanded(id, !nowHidden);
666
+ });
667
+ });
668
+
669
+ container.querySelectorAll('[data-setting]').forEach(el => {
670
+ const pluginId = el.dataset.plugin;
671
+ const key = el.dataset.setting;
672
+ const onChange = (value) => send({ type: 'plugin.settings.update', pluginId, key, value });
673
+ if (el.type === 'checkbox') el.addEventListener('change', () => onChange(el.checked));
674
+ else if (el.tagName === 'SELECT') el.addEventListener('change', () => onChange(el.value));
675
+ else if (el.type === 'number') el.addEventListener('change', () => onChange(Number(el.value)));
676
+ else el.addEventListener('input', () => onChange(el.value));
677
+ });
678
+ }
679
+
680
+ function renderSettingField(pluginId, setting, value) {
681
+ const id = `ps-${pluginId}-${setting.key}`;
682
+ const attrs = `data-plugin="${esc(pluginId)}" data-setting="${esc(setting.key)}"`;
683
+ const label = esc(setting.label || setting.key);
684
+ const desc = setting.description ? `<p class="text-[11px] text-slate-600 mt-0.5">${esc(setting.description)}</p>` : '';
685
+
686
+ if (setting.type === 'toggle') {
687
+ return `<label class="flex items-center gap-2 mt-2 cursor-pointer">
688
+ <input type="checkbox" id="${id}" ${attrs} ${value ? 'checked' : ''} class="accent-blue-500">
689
+ <span class="text-xs text-slate-400">${label}</span>
690
+ </label>${desc}`;
691
+ }
692
+ if (setting.type === 'select') {
693
+ const opts = (setting.options || []).map(o => {
694
+ const optVal = typeof o === 'object' ? o.value : o;
695
+ const optLabel = typeof o === 'object' ? o.label : o;
696
+ return `<option value="${esc(String(optVal))}" ${String(value) === String(optVal) ? 'selected' : ''}>${esc(String(optLabel))}</option>`;
697
+ }).join('');
698
+ return `<div class="mt-2">
699
+ <label class="block text-xs text-slate-400 mb-1">${label}</label>
700
+ <select id="${id}" ${attrs} class="w-full px-2 py-1.5 text-xs bg-slate-800 border border-slate-700 rounded-md text-slate-200 outline-none focus:border-blue-500 transition-colors">${opts}</select>
701
+ ${desc}
702
+ </div>`;
703
+ }
704
+ if (setting.type === 'number') {
705
+ const min = setting.min != null ? `min="${setting.min}"` : '';
706
+ const max = setting.max != null ? `max="${setting.max}"` : '';
707
+ return `<div class="mt-2">
708
+ <label class="block text-xs text-slate-400 mb-1">${label}</label>
709
+ <input type="number" id="${id}" ${attrs} value="${value ?? ''}" ${min} ${max} class="w-full px-2 py-1.5 text-xs bg-slate-800 border border-slate-700 rounded-md text-slate-200 outline-none focus:border-blue-500 transition-colors">
710
+ ${desc}
711
+ </div>`;
712
+ }
713
+ // Default: text
714
+ return `<div class="mt-2">
715
+ <label class="block text-xs text-slate-400 mb-1">${label}</label>
716
+ <input type="text" id="${id}" ${attrs} value="${esc(String(value ?? ''))}" ${setting.placeholder ? `placeholder="${esc(setting.placeholder)}"` : ''} class="w-full px-2 py-1.5 text-xs bg-slate-800 border border-slate-700 rounded-md text-slate-200 placeholder-slate-600 outline-none focus:border-blue-500 transition-colors">
717
+ ${desc}
718
+ </div>`;
719
+ }
720
+
721
+ async function loadPlugins(list) {
722
+ renderPluginsPanel(list);
723
+
724
+ // Render server-registered toolbar actions
725
+ const toolbar = document.getElementById('plugin-toolbar');
726
+ toolbar.querySelectorAll('.plugin-btn[data-server]').forEach(b => b.remove());
727
+ for (const plugin of list) {
728
+ for (const action of plugin.actions || []) {
729
+ if (action.slot !== 'toolbar') continue;
730
+ const btn = document.createElement('button');
731
+ btn.className = 'plugin-btn w-8 h-8 flex items-center justify-center rounded-lg bg-slate-800/80 border border-slate-700/50 text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors backdrop-blur-sm';
732
+ btn.title = action.title || '';
733
+ btn.innerHTML = action.icon || '';
734
+ btn.dataset.pluginId = plugin.id;
735
+ btn.dataset.server = '1';
736
+ btn.addEventListener('click', () => {
737
+ send({ type: `plugin.${plugin.id}.${action.id}`, action: action.id });
738
+ });
739
+ toolbar.appendChild(btn);
740
+ }
741
+ }
742
+
743
+ // Load client-side plugins
744
+ for (const plugin of list) {
745
+ if (!plugin.hasClient || loadedPlugins.has(plugin.id)) continue;
746
+ loadedPlugins.add(plugin.id);
747
+ try {
748
+ const mod = await import(`/plugins/${plugin.id}/client.js`);
749
+ if (typeof mod.init === 'function') {
750
+ mod.init({
751
+ pluginId: plugin.id,
752
+ send(event, data = {}) { send({ ...data, type: `plugin.${plugin.id}.${event}` }); },
753
+ onMessage(event, fn) { pluginMessageHandlers.set(`plugin.${plugin.id}.${event}`, fn); },
754
+ addToolbarButton(opts) { return addPluginToolbarButton(plugin.id, opts); },
755
+ getActiveSessionId() { return state.active; },
756
+ getTerminalSelection() { const e = state.terms.get(state.active); return e ? e.term.getSelection() : ''; },
757
+ writeToSession(id, text) { send({ type: 'input', id, data: text }); },
758
+ toast(message, opts) { return showToast(message, opts); },
759
+ registerHotkey(combo, callback) { return registerHotkey(plugin.id, combo, callback); },
760
+ unregisterHotkey(combo) { unregisterHotkey(plugin.id, combo); },
761
+ });
762
+ }
763
+ } catch (e) { console.error(`[plugin:${plugin.id}] client load failed:`, e); }
764
+ }
765
+ }
766
+
767
+ let saveTimer = null;
768
+ function flashSaveIndicator() {
769
+ const el = document.getElementById('save-indicator');
770
+ if (!el) return;
771
+ clearTimeout(saveTimer);
772
+ el.classList.add('saving');
773
+ el.classList.remove('saved');
774
+ saveTimer = setTimeout(() => {
775
+ el.classList.remove('saving');
776
+ el.classList.add('saved');
777
+ saveTimer = setTimeout(() => el.classList.remove('saved'), 4000);
778
+ }, 1500);
779
+ }
780
+
781
+ function initSessionScrollbarVisibility() {
782
+ const el = document.getElementById('session-list');
783
+ if (!el) return;
784
+ let t;
785
+ el.addEventListener('scroll', () => {
786
+ el.classList.add('is-scrolling');
787
+ clearTimeout(t);
788
+ t = setTimeout(() => el.classList.remove('is-scrolling'), 220);
789
+ }, { passive: true });
790
+ }
791
+
792
+ initDrag();
793
+ initSessionScrollbarVisibility();
794
+ connect();