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,489 @@
1
+ import { state, send } from './state.js';
2
+ import { esc, debounce, agentIcon, binName } from './utils.js';
3
+ import { openFolderPicker } from './folder-picker.js';
4
+
5
+ // ── Category navigation ──
6
+
7
+ function switchCategory(catId) {
8
+ document.querySelectorAll('.settings-cat').forEach(btn => {
9
+ const match = btn.dataset.cat === catId;
10
+ btn.classList.toggle('text-slate-200', match);
11
+ btn.classList.toggle('bg-slate-800/60', match);
12
+ btn.classList.toggle('active-cat', match);
13
+ btn.classList.toggle('text-slate-500', !match);
14
+ btn.classList.toggle('hover:text-slate-300', !match);
15
+ btn.classList.toggle('hover:bg-slate-800/30', !match);
16
+ });
17
+ document.querySelectorAll('.settings-panel').forEach(p => p.classList.add('hidden'));
18
+ const panel = document.getElementById(`settings-${catId}`);
19
+ if (panel) panel.classList.remove('hidden');
20
+ }
21
+
22
+ document.getElementById('settings-nav').addEventListener('click', (e) => {
23
+ const btn = e.target.closest('.settings-cat');
24
+ if (btn) switchCategory(btn.dataset.cat);
25
+ });
26
+
27
+ // ── Render all ──
28
+
29
+ export function renderSettings() {
30
+ document.getElementById('cfg-default-path').value = state.cfg.defaultPath || '';
31
+ document.getElementById('cfg-confirm-close').checked = state.cfg.confirmClose !== false;
32
+ renderAgentList();
33
+ renderThemeSection();
34
+ renderNotifications();
35
+ }
36
+
37
+ // ── CLI Agents ──
38
+
39
+ // ── Icon picker ──
40
+
41
+ let iconPickerCleanup = null;
42
+
43
+ function closeIconPicker() {
44
+ if (iconPickerCleanup) iconPickerCleanup();
45
+ }
46
+
47
+ function getAllIcons() {
48
+ const icons = [{ value: 'terminal', label: 'Terminal' }];
49
+ for (const p of (state.presets || [])) {
50
+ if (p.icon && p.icon !== 'terminal' && !icons.find(i => i.value === p.icon)) {
51
+ icons.push({ value: p.icon, label: p.name });
52
+ }
53
+ }
54
+ return icons;
55
+ }
56
+
57
+ function openIconPicker(triggerEl, cardIdx) {
58
+ closeIconPicker();
59
+ const rect = triggerEl.getBoundingClientRect();
60
+ const menu = document.createElement('div');
61
+ menu.className = 'fixed z-[500] bg-slate-800 border border-slate-600 rounded-lg shadow-xl shadow-black/40 p-2 flex gap-2';
62
+ menu.style.top = (rect.bottom + 4) + 'px';
63
+ menu.style.left = rect.left + 'px';
64
+
65
+ const icons = getAllIcons();
66
+ menu.innerHTML = icons.map(ic =>
67
+ `<div class="icon-pick cursor-pointer rounded-lg p-1.5 hover:bg-slate-700 transition-colors" data-icon="${esc(ic.value)}" title="${esc(ic.label)}">
68
+ ${agentIcon(ic.value)}
69
+ </div>`
70
+ ).join('');
71
+
72
+ document.body.appendChild(menu);
73
+
74
+ const onClick = (e) => {
75
+ const item = e.target.closest('.icon-pick');
76
+ if (item) {
77
+ state.cfg.commands[cardIdx].icon = item.dataset.icon;
78
+ renderAgentList();
79
+ saveConfig();
80
+ }
81
+ closeIconPicker();
82
+ };
83
+ const onOutside = (e) => {
84
+ if (!menu.contains(e.target) && !triggerEl.contains(e.target)) closeIconPicker();
85
+ };
86
+ menu.addEventListener('click', onClick);
87
+ requestAnimationFrame(() => document.addEventListener('click', onOutside));
88
+
89
+ iconPickerCleanup = () => {
90
+ menu.removeEventListener('click', onClick);
91
+ document.removeEventListener('click', onOutside);
92
+ menu.remove();
93
+ iconPickerCleanup = null;
94
+ };
95
+ }
96
+
97
+ function telemetryPreset(cmd) {
98
+ const bin = binName(cmd.command);
99
+ return (state.presets || []).find(p => binName(p.command) === bin);
100
+ }
101
+
102
+ function integrationSection(c) {
103
+ const preset = telemetryPreset(c);
104
+ if (!preset) return '';
105
+ const isClaude = preset.presetId === 'claude-code';
106
+ const isBridge = !!preset.bridge;
107
+ if (!preset.telemetryEnv && !isBridge) return '';
108
+
109
+ const enabled = isClaude || !!c.telemetryEnabled;
110
+ const title = 'CliDeck integration';
111
+ const subtitle = '(live status &amp; resume)';
112
+
113
+ if (isClaude) {
114
+ return `
115
+ <div class="mt-3 pt-3 border-t border-slate-700/50">
116
+ <div class="flex items-center gap-2 text-sm text-slate-300">
117
+ <span style="width:8px;height:8px;border-radius:50%;background:#34d399;display:inline-block"></span>
118
+ ${title} <span class="text-xs text-slate-500">${subtitle}</span>
119
+ </div>
120
+ <div class="mt-1 text-[11px] text-slate-500">Built-in</div>
121
+ </div>`;
122
+ }
123
+
124
+ const detail = isBridge ? 'Bridge plugin' : esc(preset.telemetryConfigPath || '');
125
+ const toggleBg = enabled ? '#3b82f6' : '#475569';
126
+ const knobX = enabled ? '18' : '2';
127
+
128
+ return `
129
+ <div class="mt-3 pt-3 border-t border-slate-700/50">
130
+ <label class="flex items-center justify-between text-sm text-slate-300 cursor-pointer select-none">
131
+ <span>${title} <span class="text-xs text-slate-500">${subtitle}</span></span>
132
+ <span style="position:relative;display:inline-block;width:36px;height:20px">
133
+ <input type="checkbox" ${enabled ? 'checked' : ''} class="agent-telemetry-toggle" data-preset="${esc(preset.presetId)}" style="position:absolute;opacity:0;width:100%;height:100%;cursor:pointer;margin:0;z-index:1">
134
+ <span style="position:absolute;inset:0;border-radius:10px;background:${toggleBg};transition:background .2s"></span>
135
+ <span style="position:absolute;top:2px;left:${knobX}px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left .2s"></span>
136
+ </span>
137
+ </label>
138
+ <div class="mt-1 text-[11px] text-slate-500" ${!isBridge ? 'style="font-family:monospace"' : ''}>${detail}</div>
139
+ </div>`;
140
+ }
141
+
142
+ function renderAgentList() {
143
+ document.getElementById('agent-list').innerHTML = state.cfg.commands.map((c, i) => {
144
+ const isBuiltIn = !!telemetryPreset(c);
145
+ return `
146
+ <div class="agent-card p-4 bg-slate-800/50 border border-slate-700/50 rounded-lg" data-idx="${i}">
147
+ <div class="flex items-center gap-3 mb-3">
148
+ <div class="agent-icon-btn cursor-pointer rounded-lg hover:ring-2 hover:ring-slate-500 transition-shadow" title="Change icon">
149
+ ${agentIcon(c.icon)}
150
+ </div>
151
+ <input type="text" value="${esc(c.label)}" class="agent-name flex-1 px-2 py-1.5 text-sm bg-slate-900 border border-slate-700 rounded text-slate-200 placeholder-slate-500 outline-none focus:border-blue-500 transition-colors" placeholder="Agent name">
152
+ <label class="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer select-none" title="Enabled">
153
+ <input type="checkbox" ${c.enabled ? 'checked' : ''} class="agent-enabled accent-blue-500">
154
+ On
155
+ </label>
156
+ ${isBuiltIn ? '' : '<button class="agent-del text-slate-500 hover:text-red-400 px-1 text-lg transition-colors" title="Remove">&times;</button>'}
157
+ </div>
158
+ <div class="mb-3">
159
+ <label class="block text-xs text-slate-500 mb-1">Command</label>
160
+ <input type="text" value="${esc(c.command)}" class="agent-command w-full px-2 py-1.5 text-sm bg-slate-900 border border-slate-700 rounded text-slate-200 placeholder-slate-500 outline-none focus:border-blue-500 transition-colors font-mono" placeholder="e.g. claude, codex, gemini">
161
+ </div>
162
+ <div class="mb-3">
163
+ <label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer select-none">
164
+ <input type="checkbox" ${c.isAgent ? 'checked' : ''} class="agent-is-agent accent-blue-500">
165
+ AI Agent
166
+ <span class="text-xs text-slate-500">(enables resume support)</span>
167
+ </label>
168
+ </div>
169
+ <div class="agent-resume-section ${c.isAgent ? '' : 'hidden'} pl-4 border-l-2 border-slate-700 space-y-3">
170
+ <label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer select-none">
171
+ <input type="checkbox" ${c.canResume ? 'checked' : ''} class="agent-can-resume accent-blue-500">
172
+ Supports session resume
173
+ </label>
174
+ <div class="agent-resume-fields ${c.canResume ? '' : 'hidden'} space-y-2">
175
+ <div>
176
+ <label class="block text-xs text-slate-500 mb-1">Resume command <span class="text-slate-600">— use {{sessionId}} as placeholder</span></label>
177
+ <input type="text" value="${esc(c.resumeCommand || '')}" class="agent-resume-cmd w-full px-2 py-1.5 text-sm bg-slate-900 border border-slate-700 rounded text-slate-200 placeholder-slate-500 outline-none focus:border-blue-500 transition-colors font-mono" placeholder="e.g. claude --resume {{sessionId}}">
178
+ </div>
179
+ </div>
180
+ ${integrationSection(c)}
181
+ </div>
182
+ </div>`;
183
+ }).join('');
184
+ }
185
+
186
+ // ── Add Agent (preset picker) ──
187
+
188
+ let presetMenuCleanup = null;
189
+
190
+ function closePresetMenu() {
191
+ if (presetMenuCleanup) presetMenuCleanup();
192
+ }
193
+
194
+ function openPresetMenu(anchorEl) {
195
+ closePresetMenu();
196
+ const rect = anchorEl.getBoundingClientRect();
197
+ const menu = document.createElement('div');
198
+ menu.className = 'fixed z-[500] min-w-[220px] bg-slate-800 border border-slate-600 rounded-lg shadow-xl shadow-black/40 py-1';
199
+ menu.style.top = (rect.bottom + 4) + 'px';
200
+ menu.style.right = (window.innerWidth - rect.right) + 'px';
201
+
202
+ const presets = state.presets || [];
203
+ menu.innerHTML = presets.map(p => `
204
+ <div class="preset-item flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-700 transition-colors text-sm" data-preset="${p.presetId}">
205
+ ${agentIcon(p.icon)}
206
+ <span class="text-slate-200">${esc(p.name)}</span>
207
+ </div>
208
+ `).join('') + `
209
+ <div class="border-t border-slate-700 my-1"></div>
210
+ <div class="preset-item flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-700 transition-colors text-sm" data-preset="custom">
211
+ <div class="w-8 h-8 rounded bg-slate-700 flex items-center justify-center text-slate-400 text-lg">+</div>
212
+ <span class="text-slate-200">Custom</span>
213
+ </div>
214
+ `;
215
+
216
+ document.body.appendChild(menu);
217
+
218
+ const onClick = (e) => {
219
+ const item = e.target.closest('.preset-item');
220
+ if (!item) return;
221
+ const presetId = item.dataset.preset;
222
+ if (presetId === 'custom') {
223
+ state.cfg.commands.push({
224
+ id: crypto.randomUUID(), label: '', icon: 'terminal', command: '',
225
+ enabled: true, defaultPath: '', isAgent: false, canResume: false,
226
+ resumeCommand: null, sessionIdPattern: null,
227
+ telemetryEnabled: false, telemetryStatus: null,
228
+ });
229
+ } else {
230
+ const p = presets.find(x => x.presetId === presetId);
231
+ if (p) state.cfg.commands.push({
232
+ id: crypto.randomUUID(), label: p.name, icon: p.icon, command: p.command,
233
+ enabled: true, defaultPath: '', isAgent: p.isAgent, canResume: p.canResume,
234
+ resumeCommand: p.resumeCommand, sessionIdPattern: p.sessionIdPattern,
235
+ outputMarker: p.outputMarker || null,
236
+ telemetryEnabled: p.presetId === 'claude-code',
237
+ telemetryStatus: p.presetId === 'claude-code' ? { ok: true } : null,
238
+ bridge: p.bridge,
239
+ });
240
+ }
241
+ renderAgentList();
242
+ saveConfig();
243
+ closePresetMenu();
244
+ };
245
+ const onOutside = (e) => {
246
+ if (!menu.contains(e.target) && !anchorEl.contains(e.target)) closePresetMenu();
247
+ };
248
+ menu.addEventListener('click', onClick);
249
+ requestAnimationFrame(() => document.addEventListener('click', onOutside));
250
+
251
+ presetMenuCleanup = () => {
252
+ menu.removeEventListener('click', onClick);
253
+ document.removeEventListener('click', onOutside);
254
+ menu.remove();
255
+ presetMenuCleanup = null;
256
+ };
257
+ }
258
+
259
+ document.getElementById('btn-add-agent').addEventListener('click', (e) => openPresetMenu(e.currentTarget));
260
+
261
+ // ── Agent list events ──
262
+
263
+ const agentList = document.getElementById('agent-list');
264
+
265
+ agentList.addEventListener('click', (e) => {
266
+ const iconBtn = e.target.closest('.agent-icon-btn');
267
+ if (iconBtn) {
268
+ const idx = +iconBtn.closest('.agent-card').dataset.idx;
269
+ openIconPicker(iconBtn, idx);
270
+ return;
271
+ }
272
+ if (e.target.classList.contains('agent-del')) {
273
+ const idx = +e.target.closest('.agent-card').dataset.idx;
274
+ state.cfg.commands.splice(idx, 1);
275
+ renderAgentList();
276
+ saveConfig();
277
+ }
278
+ });
279
+
280
+ agentList.addEventListener('change', (e) => {
281
+ if (e.target.classList.contains('agent-is-agent')) {
282
+ const card = e.target.closest('.agent-card');
283
+ card.querySelector('.agent-resume-section').classList.toggle('hidden', !e.target.checked);
284
+ }
285
+ if (e.target.classList.contains('agent-can-resume')) {
286
+ const card = e.target.closest('.agent-card');
287
+ card.querySelector('.agent-resume-fields').classList.toggle('hidden', !e.target.checked);
288
+ }
289
+ if (e.target.classList.contains('agent-telemetry-toggle')) {
290
+ const presetId = e.target.dataset.preset;
291
+ send({ type: 'telemetry.configure', presetId, enable: e.target.checked });
292
+ return; // config broadcast from server will re-render
293
+ }
294
+ saveConfig();
295
+ });
296
+
297
+ agentList.addEventListener('input', debounce(saveConfig, 500));
298
+
299
+ // ── Appearance (theme picker) ──
300
+
301
+ function themePreviewHTML(themeId) {
302
+ const t = state.themes.find(th => th.id === themeId)?.theme;
303
+ if (!t) return '';
304
+ const s = (color, text) => `<span style="color:${color}">${esc(text)}</span>`;
305
+ const lines = [
306
+ `${s(t.green, '~')} ${s(t.blue, 'project')} ${s(t.foreground, '$ ')}${s(t.foreground, 'claude')}`,
307
+ `${s(t.brightBlack, '● Editing src/app.ts')}`,
308
+ `${s(t.cyan, 'function')} ${s(t.yellow, 'greet')}${s(t.foreground, '(name: ')}${s(t.green, 'string')}${s(t.foreground, ') {')}`,
309
+ `${s(t.foreground, ' return ')}${s(t.green, '"Hello, ${name}"')}`,
310
+ `${s(t.foreground, '}')}`,
311
+ `${s(t.green, '~')} ${s(t.blue, 'project')} ${s(t.foreground, '$ ')}${s(t.brightBlack, '▊')}`,
312
+ ];
313
+ return `<div style="background:${t.background};padding:6px 8px">${lines.join('\n')}</div>`;
314
+ }
315
+
316
+ let themeMenuCleanup = null;
317
+
318
+ export function closeThemeMenu() {
319
+ if (themeMenuCleanup) themeMenuCleanup();
320
+ }
321
+
322
+ function openThemeMenu(triggerEl) {
323
+ closeThemeMenu();
324
+ const hidden = document.getElementById('cfg-default-theme');
325
+
326
+ const rect = triggerEl.getBoundingClientRect();
327
+ const maxH = 400, gap = 4;
328
+ const spaceBelow = window.innerHeight - rect.bottom - gap;
329
+ const spaceAbove = rect.top - gap;
330
+ const openAbove = spaceBelow < maxH && spaceAbove > spaceBelow;
331
+ const menuH = Math.min(maxH, openAbove ? spaceAbove : spaceBelow);
332
+
333
+ const menu = document.createElement('div');
334
+ menu.className = 'fixed z-[500] min-w-[260px] bg-slate-800 border border-slate-600 rounded-lg shadow-xl shadow-black/40 py-1 overflow-y-auto';
335
+ menu.style.maxHeight = menuH + 'px';
336
+ menu.style.left = rect.left + 'px';
337
+ if (openAbove) menu.style.bottom = (window.innerHeight - rect.top + gap) + 'px';
338
+ else menu.style.top = (rect.bottom + gap) + 'px';
339
+
340
+ menu.innerHTML = state.themes.map(t => {
341
+ return `<div class="theme-option px-3 py-2 cursor-pointer hover:bg-slate-700 transition-colors ${t.id === hidden.value ? 'bg-blue-500/15 border-l-2 border-blue-400' : ''}" data-value="${t.id}">
342
+ <div class="text-sm text-slate-200 mb-1">${esc(t.name)}</div>
343
+ <div class="text-[10px] font-mono leading-[1.4] whitespace-pre rounded overflow-hidden" style="background:${t.theme.background};padding:4px 6px"><span style="color:${t.theme.green}">~</span> <span style="color:${t.theme.blue}">src</span> <span style="color:${t.theme.foreground}">$ ls</span>\n<span style="color:${t.theme.yellow}">app.ts</span> <span style="color:${t.theme.cyan}">utils.ts</span> <span style="color:${t.theme.brightBlack}">README</span></div>
344
+ </div>`;
345
+ }).join('');
346
+
347
+ document.body.appendChild(menu);
348
+
349
+ const onClick = (e) => {
350
+ const item = e.target.closest('.theme-option');
351
+ if (item) {
352
+ hidden.value = item.dataset.value;
353
+ triggerEl.querySelector('.theme-label').textContent = state.themes.find(t => t.id === item.dataset.value)?.name || 'Default';
354
+ document.getElementById('default-theme-preview').innerHTML = themePreviewHTML(item.dataset.value);
355
+ saveConfig();
356
+ }
357
+ closeThemeMenu();
358
+ };
359
+ const onOutside = (e) => {
360
+ if (!menu.contains(e.target) && !triggerEl.contains(e.target)) closeThemeMenu();
361
+ };
362
+ menu.addEventListener('click', onClick);
363
+ requestAnimationFrame(() => document.addEventListener('click', onOutside));
364
+
365
+ themeMenuCleanup = () => {
366
+ menu.removeEventListener('click', onClick);
367
+ document.removeEventListener('click', onOutside);
368
+ menu.remove();
369
+ themeMenuCleanup = null;
370
+ };
371
+ }
372
+
373
+ function renderThemeSection() {
374
+ const themeId = state.cfg.defaultTheme || 'default';
375
+ const selected = state.themes.find(t => t.id === themeId);
376
+ const label = selected ? esc(selected.name) : 'Default';
377
+ document.getElementById('cfg-default-theme').value = themeId;
378
+ document.getElementById('default-theme-label').textContent = label;
379
+ document.getElementById('default-theme-preview').innerHTML = themePreviewHTML(themeId);
380
+ }
381
+
382
+ // ── Notifications ──
383
+
384
+ function renderNotifications() {
385
+ const enabled = !!state.cfg.notifyIdle;
386
+ document.getElementById('cfg-notify-idle').checked = enabled;
387
+ document.getElementById('cfg-notify-min-work').value = state.cfg.notifyMinWork || 10;
388
+
389
+ const permStatus = document.getElementById('notify-permission-status');
390
+ if (enabled && 'Notification' in window) {
391
+ const perm = Notification.permission;
392
+ permStatus.classList.remove('hidden');
393
+ if (perm === 'granted') {
394
+ permStatus.textContent = 'Enabled';
395
+ permStatus.className = 'text-[11px] ml-auto text-emerald-500';
396
+ } else if (perm === 'denied') {
397
+ permStatus.textContent = 'Blocked — check browser site settings';
398
+ permStatus.className = 'text-[11px] ml-auto text-red-400';
399
+ } else {
400
+ permStatus.textContent = 'Permission pending — toggle to re-prompt';
401
+ permStatus.className = 'text-[11px] ml-auto text-yellow-500';
402
+ }
403
+ } else {
404
+ permStatus.classList.add('hidden');
405
+ }
406
+
407
+ const soundEnabled = state.cfg.notifySoundEnabled !== false;
408
+ document.getElementById('cfg-notify-sound').checked = soundEnabled;
409
+ document.getElementById('notify-sound-row').classList.toggle('hidden', !soundEnabled);
410
+ document.getElementById('cfg-notify-sound-pick').value = state.cfg.notifySound || 'default-beep';
411
+ }
412
+
413
+ document.getElementById('cfg-notify-idle').addEventListener('change', (e) => {
414
+ if (e.target.checked && 'Notification' in window && Notification.permission === 'default') {
415
+ Notification.requestPermission().then(() => renderNotifications());
416
+ }
417
+ saveConfig();
418
+ renderNotifications();
419
+ });
420
+
421
+ document.getElementById('cfg-notify-min-work').addEventListener('change', saveConfig);
422
+
423
+ document.getElementById('cfg-notify-sound').addEventListener('change', (e) => {
424
+ document.getElementById('notify-sound-row').classList.toggle('hidden', !e.target.checked);
425
+ saveConfig();
426
+ });
427
+
428
+ document.getElementById('cfg-notify-sound-pick').addEventListener('change', saveConfig);
429
+
430
+ document.getElementById('btn-sound-preview').addEventListener('click', () => {
431
+ const sound = document.getElementById('cfg-notify-sound-pick').value;
432
+ new Audio(`/fx/${sound}.mp3`).play().catch(() => {});
433
+ });
434
+
435
+ // ── Save ──
436
+
437
+ function saveConfig() {
438
+ // Agents
439
+ const agentCards = document.querySelectorAll('.agent-card');
440
+ state.cfg.commands = [...agentCards].map((card, i) => {
441
+ const existing = state.cfg.commands[i] || {};
442
+ const command = card.querySelector('.agent-command').value.trim() || state.cfg.defaultShell || '/bin/zsh';
443
+ const isClaude = binName(command) === 'claude';
444
+ return {
445
+ id: existing.id || crypto.randomUUID(),
446
+ label: card.querySelector('.agent-name').value.trim() || 'Untitled',
447
+ icon: existing.icon || 'terminal',
448
+ command,
449
+ enabled: card.querySelector('.agent-enabled').checked,
450
+ defaultPath: existing.defaultPath || '',
451
+ isAgent: card.querySelector('.agent-is-agent').checked,
452
+ canResume: card.querySelector('.agent-can-resume').checked,
453
+ resumeCommand: card.querySelector('.agent-resume-cmd')?.value.trim() || null,
454
+ sessionIdPattern: existing.sessionIdPattern || null,
455
+ outputMarker: existing.outputMarker || null,
456
+ telemetryEnabled: isClaude ? true : (existing.telemetryEnabled || false),
457
+ telemetryStatus: isClaude ? { ok: true } : (existing.telemetryStatus || null),
458
+ bridge: existing.bridge,
459
+ };
460
+ });
461
+
462
+ state.cfg.defaultTheme = document.getElementById('cfg-default-theme').value;
463
+ state.cfg.defaultPath = document.getElementById('cfg-default-path').value.trim();
464
+ state.cfg.confirmClose = document.getElementById('cfg-confirm-close').checked;
465
+ state.cfg.notifyIdle = document.getElementById('cfg-notify-idle').checked;
466
+ state.cfg.notifyMinWork = parseInt(document.getElementById('cfg-notify-min-work').value, 10) || 10;
467
+ state.cfg.notifySoundEnabled = document.getElementById('cfg-notify-sound').checked;
468
+ state.cfg.notifySound = document.getElementById('cfg-notify-sound-pick').value;
469
+ // Preserve fields not managed by this form
470
+ // (projects, prompts, etc. live on state.cfg and must not be dropped)
471
+ send({ type: 'config.update', config: state.cfg });
472
+ }
473
+
474
+ // ── Events: General ──
475
+ document.getElementById('cfg-default-path').addEventListener('input', debounce(saveConfig, 500));
476
+ document.getElementById('cfg-confirm-close').addEventListener('change', saveConfig);
477
+ // ── Events: Appearance ──
478
+ document.getElementById('default-theme-trigger').addEventListener('click', (e) => {
479
+ openThemeMenu(e.currentTarget);
480
+ });
481
+
482
+ // ── Browse ──
483
+ document.getElementById('btn-browse-path').addEventListener('click', () => {
484
+ const current = document.getElementById('cfg-default-path').value.trim();
485
+ openFolderPicker(current, (path) => {
486
+ document.getElementById('cfg-default-path').value = path;
487
+ saveConfig();
488
+ });
489
+ });
@@ -0,0 +1,15 @@
1
+ export const state = {
2
+ ws: null,
3
+ terms: new Map(),
4
+ active: null,
5
+ cfg: { commands: [], defaultPath: '', defaultTheme: 'catppuccin-mocha' },
6
+ themes: [],
7
+ presets: [],
8
+ resumable: [],
9
+ filter: { query: '', tab: 'all' },
10
+ transcriptCache: {},
11
+ };
12
+
13
+ export function send(msg) {
14
+ state.ws.send(JSON.stringify(msg));
15
+ }