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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/activity.js +56 -0
- package/agent-presets.json +93 -0
- package/assets/clideck-themes.jpg +0 -0
- package/bin/clideck.js +2 -0
- package/config.js +96 -0
- package/handlers.js +297 -0
- package/opencode-bridge.js +148 -0
- package/opencode-plugin/clideck-bridge.js +24 -0
- package/package.json +47 -0
- package/paths.js +41 -0
- package/plugin-loader.js +285 -0
- package/plugins/trim-clip/clideck-plugin.json +13 -0
- package/plugins/trim-clip/client.js +31 -0
- package/plugins/trim-clip/index.js +10 -0
- package/plugins/voice-input/clideck-plugin.json +49 -0
- package/plugins/voice-input/client.js +196 -0
- package/plugins/voice-input/index.js +342 -0
- package/plugins/voice-input/python/mel_filters.npz +0 -0
- package/plugins/voice-input/python/whisper_turbo.py +416 -0
- package/plugins/voice-input/python/worker.py +135 -0
- package/public/fx/bold-beep-idle.mp3 +0 -0
- package/public/fx/default-beep.mp3 +0 -0
- package/public/fx/echo-beep-idle.mp3 +0 -0
- package/public/fx/musical-beep-idle.mp3 +0 -0
- package/public/fx/small-bleep-idle.mp3 +0 -0
- package/public/fx/soft-beep.mp3 +0 -0
- package/public/fx/space-idle.mp3 +0 -0
- package/public/img/claude-code.png +0 -0
- package/public/img/clideck-logo-icon.png +0 -0
- package/public/img/clideck-logo-terminal-panel.png +0 -0
- package/public/img/codex.png +0 -0
- package/public/img/gemini.png +0 -0
- package/public/img/opencode.png +0 -0
- package/public/index.html +243 -0
- package/public/js/app.js +794 -0
- package/public/js/color-mode.js +51 -0
- package/public/js/confirm.js +27 -0
- package/public/js/creator.js +201 -0
- package/public/js/drag.js +134 -0
- package/public/js/folder-picker.js +81 -0
- package/public/js/hotkeys.js +90 -0
- package/public/js/nav.js +56 -0
- package/public/js/profiles.js +22 -0
- package/public/js/prompts.js +325 -0
- package/public/js/settings.js +489 -0
- package/public/js/state.js +15 -0
- package/public/js/terminals.js +905 -0
- package/public/js/toast.js +62 -0
- package/public/js/utils.js +27 -0
- package/public/tailwind.css +1 -0
- package/server.js +126 -0
- package/sessions.js +375 -0
- package/telemetry-receiver.js +129 -0
- package/themes.js +247 -0
- package/transcript.js +90 -0
- package/utils.js +66 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
import { state, send } from './state.js';
|
|
2
|
+
import { esc } from './utils.js';
|
|
3
|
+
import { resolveTheme, resolveAccent, applyTheme } from './profiles.js';
|
|
4
|
+
import { attachToTerminal } from './hotkeys.js';
|
|
5
|
+
import { closeDropdown } from './prompts.js';
|
|
6
|
+
function isLightBg(themeId) {
|
|
7
|
+
const bg = resolveTheme(themeId)?.background;
|
|
8
|
+
if (!bg || bg[0] !== '#') return false;
|
|
9
|
+
const r = parseInt(bg.slice(1, 3), 16), g = parseInt(bg.slice(3, 5), 16), b = parseInt(bg.slice(5, 7), 16);
|
|
10
|
+
return (r * 299 + g * 587 + b * 114) / 1000 > 150;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// --- Helpers ---
|
|
14
|
+
|
|
15
|
+
const RECENT_MS = 15 * 60 * 1000; // 15 minutes
|
|
16
|
+
|
|
17
|
+
function isRecent(ts) { return Date.now() - ts < RECENT_MS; }
|
|
18
|
+
|
|
19
|
+
function formatTime(ts) {
|
|
20
|
+
const d = new Date(ts), now = new Date();
|
|
21
|
+
const days = Math.floor((now - d) / 86400000);
|
|
22
|
+
if (days === 0 && d.getDate() === now.getDate())
|
|
23
|
+
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
24
|
+
if (days < 2 && (now.getDate() - d.getDate() === 1 || (now.getDate() < d.getDate() && days < 2)))
|
|
25
|
+
return 'Yesterday';
|
|
26
|
+
if (days < 7) return d.toLocaleDateString([], { weekday: 'long' });
|
|
27
|
+
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function updateTimeEl(el, ts) {
|
|
31
|
+
el.textContent = formatTime(ts);
|
|
32
|
+
el.classList.toggle('recent', isRecent(ts));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
const TERMINAL_SVG = `<svg class="w-3.5 h-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`;
|
|
37
|
+
const MIN_CONTRAST_RATIO = 4.5;
|
|
38
|
+
|
|
39
|
+
const DARK_BALLS = ['#00e5ff', '#5df0d6', '#9b8cff'];
|
|
40
|
+
const LIGHT_BALLS = ['#0891b2', '#059669', '#7c3aed'];
|
|
41
|
+
|
|
42
|
+
function startBounce(container) {
|
|
43
|
+
const isDark = !document.documentElement.classList.contains('light');
|
|
44
|
+
const colors = isDark ? DARK_BALLS : LIGHT_BALLS;
|
|
45
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
46
|
+
svg.setAttribute('width', '30');
|
|
47
|
+
svg.setAttribute('height', '14');
|
|
48
|
+
svg.setAttribute('viewBox', '5 18 90 28');
|
|
49
|
+
svg.style.opacity = '0.75';
|
|
50
|
+
container.innerHTML = '';
|
|
51
|
+
container.appendChild(svg);
|
|
52
|
+
|
|
53
|
+
const floor = 40, gravity = 0.18, restitution = 0.7;
|
|
54
|
+
const rand = (lo, hi) => lo + Math.random() * (hi - lo);
|
|
55
|
+
const radii = [4.5, 4, 3.5];
|
|
56
|
+
|
|
57
|
+
const balls = colors.map((fill, i) => {
|
|
58
|
+
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
59
|
+
c.setAttribute('r', radii[i]);
|
|
60
|
+
c.setAttribute('fill', fill);
|
|
61
|
+
svg.appendChild(c);
|
|
62
|
+
return { el: c, x: 10 + i * rand(14, 22), y: floor - rand(0, 15), vx: rand(0.6, 1.3), vy: -rand(2.5, 5), r: radii[i] };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let raf;
|
|
66
|
+
function step() {
|
|
67
|
+
balls.forEach(b => {
|
|
68
|
+
b.vy += gravity;
|
|
69
|
+
b.x += b.vx;
|
|
70
|
+
b.y += b.vy;
|
|
71
|
+
if (b.y > floor) { b.y = floor; b.vy *= -restitution; }
|
|
72
|
+
});
|
|
73
|
+
for (let i = 0; i < balls.length; i++) {
|
|
74
|
+
for (let j = i + 1; j < balls.length; j++) {
|
|
75
|
+
const a = balls[i], b = balls[j];
|
|
76
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
77
|
+
const dist = Math.hypot(dx, dy), min = a.r + b.r;
|
|
78
|
+
if (dist < min) {
|
|
79
|
+
const nx = dx / dist, ny = dy / dist;
|
|
80
|
+
const p = a.vx * nx + a.vy * ny - b.vx * nx - b.vy * ny;
|
|
81
|
+
a.vx -= p * nx; a.vy -= p * ny;
|
|
82
|
+
b.vx += p * nx; b.vy += p * ny;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
balls.forEach(b => {
|
|
87
|
+
if (b.x > 100) { b.x = rand(5, 15); b.y = floor - rand(0, 10); b.vx = rand(0.6, 1.3); b.vy = -rand(2.5, 5); }
|
|
88
|
+
b.el.setAttribute('cx', b.x);
|
|
89
|
+
b.el.setAttribute('cy', b.y);
|
|
90
|
+
});
|
|
91
|
+
raf = requestAnimationFrame(step);
|
|
92
|
+
}
|
|
93
|
+
step();
|
|
94
|
+
return () => cancelAnimationFrame(raf);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function iconHtml(commandId) {
|
|
98
|
+
const icon = state.cfg.commands.find(c => c.id === commandId)?.icon || 'terminal';
|
|
99
|
+
if (icon.startsWith('/'))
|
|
100
|
+
return `<img src="${esc(icon)}" class="w-5 h-5 object-contain" draggable="false">`;
|
|
101
|
+
return TERMINAL_SVG;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shortPath(p) {
|
|
105
|
+
return p ? p.replace(/^\/(?:Users|home)\/[^/]+/, '~') : '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Session context menu ---
|
|
109
|
+
|
|
110
|
+
let menuCleanup = null;
|
|
111
|
+
|
|
112
|
+
function closeMenu() {
|
|
113
|
+
if (menuCleanup) menuCleanup();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function positionMenu(menu, anchorRect) {
|
|
117
|
+
menu.style.visibility = 'hidden';
|
|
118
|
+
document.body.appendChild(menu);
|
|
119
|
+
const mh = menu.offsetHeight;
|
|
120
|
+
const gap = 4;
|
|
121
|
+
const spaceBelow = window.innerHeight - anchorRect.bottom - gap;
|
|
122
|
+
menu.style.top = (spaceBelow >= mh
|
|
123
|
+
? anchorRect.bottom + gap
|
|
124
|
+
: Math.max(gap, anchorRect.top - gap - mh)) + 'px';
|
|
125
|
+
menu.style.right = (window.innerWidth - anchorRect.right) + 'px';
|
|
126
|
+
menu.style.visibility = '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function openMenu(sessionId, anchorEl) {
|
|
130
|
+
closeMenu();
|
|
131
|
+
|
|
132
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
133
|
+
const menu = document.createElement('div');
|
|
134
|
+
menu.className = 'fixed z-[400] min-w-[160px] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1';
|
|
135
|
+
|
|
136
|
+
const entry = state.terms.get(sessionId);
|
|
137
|
+
const projects = state.cfg.projects || [];
|
|
138
|
+
|
|
139
|
+
let html = '';
|
|
140
|
+
|
|
141
|
+
// Project submenu items
|
|
142
|
+
if (projects.length) {
|
|
143
|
+
html += `<div class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-slate-600">Move to project</div>`;
|
|
144
|
+
for (const p of projects) {
|
|
145
|
+
const active = entry?.projectId === p.id;
|
|
146
|
+
html += `<button class="menu-action flex items-center gap-2 w-full px-3 py-1.5 text-sm ${active ? 'text-blue-400' : 'text-slate-300'} hover:bg-slate-700 transition-colors text-left" data-action="project" data-project-id="${p.id}">
|
|
147
|
+
<span class="w-2 h-2 rounded-full flex-shrink-0" style="background:${projectColor(p)}"></span>
|
|
148
|
+
${esc(p.name)}${active ? ' ✓' : ''}
|
|
149
|
+
</button>`;
|
|
150
|
+
}
|
|
151
|
+
if (entry?.projectId) {
|
|
152
|
+
html += `<button class="menu-action flex items-center gap-2 w-full px-3 py-1.5 text-sm text-slate-500 hover:bg-slate-700 transition-colors text-left" data-action="unproject">
|
|
153
|
+
<span class="w-2 h-2 rounded-full flex-shrink-0 border border-slate-600"></span>
|
|
154
|
+
Remove from project
|
|
155
|
+
</button>`;
|
|
156
|
+
}
|
|
157
|
+
html += `<div class="border-t border-slate-700/50 my-1"></div>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const muted = !!entry?.muted;
|
|
161
|
+
html += `
|
|
162
|
+
<button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="rename">
|
|
163
|
+
<span class="flex-shrink-0 text-slate-400"><svg class="w-4 h-4" 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></span>
|
|
164
|
+
Rename
|
|
165
|
+
</button>
|
|
166
|
+
<button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="mute">
|
|
167
|
+
<span class="flex-shrink-0 text-slate-400"><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">${muted
|
|
168
|
+
? '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>'
|
|
169
|
+
: '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>'}</svg></span>
|
|
170
|
+
${muted ? 'Unmute' : 'Mute'}
|
|
171
|
+
</button>
|
|
172
|
+
<button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="theme">
|
|
173
|
+
<span class="flex-shrink-0 text-slate-400"><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"><circle cx="12" cy="12" r="10"/><path d="M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8"/></svg></span>
|
|
174
|
+
Theme
|
|
175
|
+
</button>
|
|
176
|
+
<button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="refresh">
|
|
177
|
+
<span class="flex-shrink-0 text-slate-400"><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="M4 4v5h4.5M20 20v-5h-4.5M4 9a9 9 0 0 1 15.36-5.36M20 15a9 9 0 0 1-15.36 5.36"/></svg></span>
|
|
178
|
+
Refresh session
|
|
179
|
+
</button>
|
|
180
|
+
<button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-red-400 hover:bg-slate-700 transition-colors text-left" data-action="delete">
|
|
181
|
+
<span class="flex-shrink-0 text-red-400"><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="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></span>
|
|
182
|
+
Delete
|
|
183
|
+
</button>`;
|
|
184
|
+
|
|
185
|
+
menu.innerHTML = html;
|
|
186
|
+
positionMenu(menu, rect);
|
|
187
|
+
|
|
188
|
+
const onClick = (e) => {
|
|
189
|
+
const btn = e.target.closest('.menu-action');
|
|
190
|
+
if (!btn) return;
|
|
191
|
+
closeMenu();
|
|
192
|
+
const action = btn.dataset.action;
|
|
193
|
+
if (action === 'rename') {
|
|
194
|
+
startRename(sessionId);
|
|
195
|
+
} else if (action === 'mute') {
|
|
196
|
+
toggleMute(sessionId);
|
|
197
|
+
} else if (action === 'refresh') {
|
|
198
|
+
const re = state.terms.get(sessionId);
|
|
199
|
+
if (re) send({ type: 'session.restart', id: sessionId, themeId: re.themeId, cols: re.term.cols, rows: re.term.rows });
|
|
200
|
+
} else if (action === 'delete') {
|
|
201
|
+
document.getElementById('session-list').dispatchEvent(
|
|
202
|
+
new CustomEvent('session-delete', { detail: { id: sessionId } })
|
|
203
|
+
);
|
|
204
|
+
} else if (action === 'theme') {
|
|
205
|
+
const iconEl = document.querySelector(`.group[data-id="${sessionId}"] .session-icon`);
|
|
206
|
+
if (iconEl) openThemePicker(sessionId, iconEl);
|
|
207
|
+
} else if (action === 'project') {
|
|
208
|
+
setSessionProject(sessionId, btn.dataset.projectId);
|
|
209
|
+
} else if (action === 'unproject') {
|
|
210
|
+
setSessionProject(sessionId, null);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const onOutside = (e) => {
|
|
214
|
+
if (!menu.contains(e.target)) closeMenu();
|
|
215
|
+
};
|
|
216
|
+
menu.addEventListener('click', onClick);
|
|
217
|
+
requestAnimationFrame(() => document.addEventListener('click', onOutside));
|
|
218
|
+
|
|
219
|
+
menuCleanup = () => {
|
|
220
|
+
menu.removeEventListener('click', onClick);
|
|
221
|
+
document.removeEventListener('click', onOutside);
|
|
222
|
+
menu.remove();
|
|
223
|
+
menuCleanup = null;
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Theme picker (per-session) ---
|
|
228
|
+
|
|
229
|
+
let pickerCleanup = null;
|
|
230
|
+
|
|
231
|
+
function openThemePicker(sessionId, anchorEl) {
|
|
232
|
+
closeThemePicker();
|
|
233
|
+
const entry = state.terms.get(sessionId);
|
|
234
|
+
if (!entry) return;
|
|
235
|
+
|
|
236
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
237
|
+
const picker = document.createElement('div');
|
|
238
|
+
picker.className = 'fixed z-[400] min-w-[220px] max-h-[400px] overflow-y-auto bg-slate-800 border border-slate-600 rounded-lg shadow-xl shadow-black/40 py-1';
|
|
239
|
+
picker.style.top = (rect.bottom + 4) + 'px';
|
|
240
|
+
picker.style.left = rect.left + 'px';
|
|
241
|
+
|
|
242
|
+
const currentIsLight = isLightBg(entry.themeId);
|
|
243
|
+
picker.innerHTML = state.themes.map(t => {
|
|
244
|
+
const polarityFlip = isLightBg(t.id) !== currentIsLight;
|
|
245
|
+
return `<div class="theme-pick px-3 py-2 cursor-pointer hover:bg-slate-700 transition-colors ${t.id === entry.themeId ? 'bg-blue-500/15 border-l-2 border-blue-400' : ''}" data-theme="${t.id}">
|
|
246
|
+
<div class="text-sm text-slate-200 mb-1">${esc(t.name)}</div>
|
|
247
|
+
<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>${polarityFlip ? '<div class="text-[10px] text-slate-500 mt-1">Restart session to apply color mode</div>' : ''}
|
|
248
|
+
</div>`;
|
|
249
|
+
}).join('');
|
|
250
|
+
|
|
251
|
+
document.body.appendChild(picker);
|
|
252
|
+
|
|
253
|
+
const onClick = (e) => {
|
|
254
|
+
const item = e.target.closest('.theme-pick');
|
|
255
|
+
if (item) setSessionTheme(sessionId, item.dataset.theme);
|
|
256
|
+
closeThemePicker();
|
|
257
|
+
};
|
|
258
|
+
const onOutside = (e) => {
|
|
259
|
+
if (!picker.contains(e.target) && e.target !== anchorEl) closeThemePicker();
|
|
260
|
+
};
|
|
261
|
+
picker.addEventListener('click', onClick);
|
|
262
|
+
requestAnimationFrame(() => document.addEventListener('click', onOutside));
|
|
263
|
+
|
|
264
|
+
pickerCleanup = () => {
|
|
265
|
+
picker.removeEventListener('click', onClick);
|
|
266
|
+
document.removeEventListener('click', onOutside);
|
|
267
|
+
picker.remove();
|
|
268
|
+
pickerCleanup = null;
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function closeThemePicker() {
|
|
273
|
+
if (pickerCleanup) pickerCleanup();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- Terminal size estimation (for PTY spawn) ---
|
|
277
|
+
|
|
278
|
+
export function estimateSize() {
|
|
279
|
+
const el = document.getElementById('terminals');
|
|
280
|
+
// Account for inset-1 padding (4px each side)
|
|
281
|
+
const w = el.clientWidth - 8, h = el.clientHeight - 8;
|
|
282
|
+
// Menlo 13px: ~7.8px wide, ~17px tall
|
|
283
|
+
return { cols: Math.max(Math.floor(w / 7.8), 80), rows: Math.max(Math.floor(h / 17), 24) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- Terminal management ---
|
|
287
|
+
|
|
288
|
+
export function addTerminal(id, name, themeId, commandId, projectId, muted, lastPreview) {
|
|
289
|
+
if (state.terms.has(id)) return;
|
|
290
|
+
themeId = themeId || state.cfg.defaultTheme || 'default';
|
|
291
|
+
|
|
292
|
+
const item = document.createElement('div');
|
|
293
|
+
item.className = 'group flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors select-none';
|
|
294
|
+
item.dataset.id = id;
|
|
295
|
+
item.innerHTML = `
|
|
296
|
+
<div class="session-icon w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 overflow-hidden pointer-events-none">
|
|
297
|
+
${iconHtml(commandId)}
|
|
298
|
+
</div>
|
|
299
|
+
<div class="flex-1 min-w-0 pointer-events-none">
|
|
300
|
+
<div class="flex items-baseline gap-2">
|
|
301
|
+
<span class="name flex-1 font-semibold text-[13px] text-slate-200 truncate pointer-events-auto cursor-default">${esc(name)}</span>
|
|
302
|
+
<span class="session-time recent text-[11px] flex-shrink-0">${formatTime(Date.now())}</span>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="flex items-center gap-1 mt-0.5">
|
|
305
|
+
<span class="session-status flex-shrink-0 leading-none" style="transition:opacity 0.2s"></span>
|
|
306
|
+
<span class="session-preview flex-1 text-xs text-slate-500 truncate"></span>
|
|
307
|
+
<span class="unread-dot hidden w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"></span>
|
|
308
|
+
<button class="menu-btn opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 flex-shrink-0 transition-opacity pointer-events-auto" title="Menu">
|
|
309
|
+
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 20 20"><path d="M10 14l-4-4h8l-4 4z" fill="currentColor"/></svg>
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>`;
|
|
313
|
+
|
|
314
|
+
// Show saved preview from last session if available (survives reconnect/sleep)
|
|
315
|
+
if (lastPreview) item.querySelector('.session-preview').textContent = lastPreview;
|
|
316
|
+
|
|
317
|
+
document.getElementById('session-list').appendChild(item);
|
|
318
|
+
const statusEl = item.querySelector('.session-status');
|
|
319
|
+
const cmd = state.cfg.commands.find(c => c.id === commandId);
|
|
320
|
+
const hasBridge = !!cmd?.bridge;
|
|
321
|
+
const stopBounce = hasBridge ? null : startBounce(statusEl);
|
|
322
|
+
|
|
323
|
+
const el = document.createElement('div');
|
|
324
|
+
el.className = 'term-wrap';
|
|
325
|
+
el.style.backgroundColor = resolveTheme(themeId).background;
|
|
326
|
+
document.getElementById('terminals').appendChild(el);
|
|
327
|
+
|
|
328
|
+
const term = new Terminal({
|
|
329
|
+
fontSize: 13,
|
|
330
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
331
|
+
theme: resolveTheme(themeId),
|
|
332
|
+
// Keep ANSI/truecolor output readable across dark and light terminal themes.
|
|
333
|
+
minimumContrastRatio: MIN_CONTRAST_RATIO,
|
|
334
|
+
cursorBlink: true,
|
|
335
|
+
scrollback: 5000,
|
|
336
|
+
});
|
|
337
|
+
const fit = new FitAddon.FitAddon();
|
|
338
|
+
term.loadAddon(fit);
|
|
339
|
+
term.onData(data => send({ type: 'input', id, data }));
|
|
340
|
+
|
|
341
|
+
term.open(el);
|
|
342
|
+
attachToTerminal(term);
|
|
343
|
+
let fitted = false, pending = [];
|
|
344
|
+
const ro = new ResizeObserver(() => {
|
|
345
|
+
if (!el.offsetWidth) return;
|
|
346
|
+
fit.fit();
|
|
347
|
+
send({ type: 'resize', id, cols: term.cols, rows: term.rows });
|
|
348
|
+
if (!fitted) {
|
|
349
|
+
fitted = true;
|
|
350
|
+
for (const chunk of pending) term.write(chunk);
|
|
351
|
+
pending = null;
|
|
352
|
+
updatePreview(id);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
ro.observe(el);
|
|
356
|
+
// Safety: if RO hasn't fired within 500ms, flush anyway to avoid unbounded queue
|
|
357
|
+
setTimeout(() => { if (!fitted) { fitted = true; for (const chunk of pending) term.write(chunk); pending = null; updatePreview(id); } }, 500);
|
|
358
|
+
state.terms.set(id, { term, fit, el, ro, themeId, commandId, projectId: projectId || null, muted: !!muted, working: !hasBridge, workStartedAt: hasBridge ? null : Date.now(), stopBounce, queue: (data) => { if (!fitted) { pending.push(data); return true; } return false; }, lastActivityAt: Date.now(), unread: false, lastPreviewText: lastPreview || '', searchText: '' });
|
|
359
|
+
document.getElementById('empty').style.display = 'none';
|
|
360
|
+
document.getElementById('terminals').style.pointerEvents = '';
|
|
361
|
+
if (muted) requestAnimationFrame(() => updateMuteIndicator(id));
|
|
362
|
+
|
|
363
|
+
regroupSessions();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function removeTerminal(id) {
|
|
367
|
+
const entry = state.terms.get(id);
|
|
368
|
+
if (!entry) return;
|
|
369
|
+
if (entry.stopBounce) entry.stopBounce();
|
|
370
|
+
entry.ro?.disconnect();
|
|
371
|
+
entry.term.dispose();
|
|
372
|
+
entry.el.remove();
|
|
373
|
+
state.terms.delete(id);
|
|
374
|
+
document.querySelector(`.group[data-id="${id}"]`)?.remove();
|
|
375
|
+
|
|
376
|
+
if (state.active === id) {
|
|
377
|
+
const next = state.terms.keys().next().value;
|
|
378
|
+
if (next) select(next);
|
|
379
|
+
else {
|
|
380
|
+
state.active = null;
|
|
381
|
+
document.getElementById('empty').style.display = 'flex';
|
|
382
|
+
document.getElementById('terminals').style.pointerEvents = 'none';
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
regroupSessions();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function select(id) {
|
|
389
|
+
if (state.active === id) return;
|
|
390
|
+
closeDropdown();
|
|
391
|
+
|
|
392
|
+
const prev = document.querySelector('.group.active-session');
|
|
393
|
+
if (prev) prev.classList.remove('active-session');
|
|
394
|
+
document.querySelector('.term-wrap.active')?.classList.remove('active');
|
|
395
|
+
|
|
396
|
+
const item = document.querySelector(`.group[data-id="${id}"]`);
|
|
397
|
+
if (item) item.classList.add('active-session');
|
|
398
|
+
|
|
399
|
+
const entry = state.terms.get(id);
|
|
400
|
+
if (entry) {
|
|
401
|
+
entry.el.classList.add('active');
|
|
402
|
+
if (entry.unread) {
|
|
403
|
+
entry.unread = false;
|
|
404
|
+
const dot = document.querySelector(`.group[data-id="${id}"] .unread-dot`);
|
|
405
|
+
if (dot) dot.classList.add('hidden');
|
|
406
|
+
updateUnreadBadge();
|
|
407
|
+
if (state.filter.tab === 'unread') setTab('all');
|
|
408
|
+
}
|
|
409
|
+
entry.term.scrollToBottom();
|
|
410
|
+
if (!document.querySelector('[contenteditable="true"]')) entry.term.focus();
|
|
411
|
+
}
|
|
412
|
+
state.active = id;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- Preview & status ---
|
|
416
|
+
|
|
417
|
+
export function markUnread(id) {
|
|
418
|
+
const entry = state.terms.get(id);
|
|
419
|
+
if (!entry || id === state.active || entry.unread) return;
|
|
420
|
+
entry.unread = true;
|
|
421
|
+
const dot = document.querySelector(`.group[data-id="${id}"] .unread-dot`);
|
|
422
|
+
if (dot) dot.classList.remove('hidden');
|
|
423
|
+
updateUnreadBadge();
|
|
424
|
+
applyFilter();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function updatePreview(id) {
|
|
428
|
+
const entry = state.terms.get(id);
|
|
429
|
+
if (!entry) return;
|
|
430
|
+
const last = readLastAgentLine(entry.term, entry.commandId);
|
|
431
|
+
const el = document.querySelector(`.group[data-id="${id}"] .session-preview`);
|
|
432
|
+
if (el && last && el.textContent !== last) {
|
|
433
|
+
el.textContent = last;
|
|
434
|
+
entry.lastPreviewText = last;
|
|
435
|
+
entry.lastActivityAt = Date.now();
|
|
436
|
+
// Persist preview on server — picked up by 30s auto-save
|
|
437
|
+
send({ type: 'session.setPreview', id, text: last, timestamp: new Date().toISOString() });
|
|
438
|
+
}
|
|
439
|
+
const timeEl = document.querySelector(`.group[data-id="${id}"] .session-time`);
|
|
440
|
+
if (timeEl) updateTimeEl(timeEl, entry.lastActivityAt);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Read the terminal buffer bottom-up, return the last line
|
|
444
|
+
// where most characters use default foreground and aren't dim
|
|
445
|
+
function readLineText(buf, y) {
|
|
446
|
+
const line = buf.getLine(y);
|
|
447
|
+
if (!line) return '';
|
|
448
|
+
let text = '';
|
|
449
|
+
for (let x = 0; x < line.length; x++) {
|
|
450
|
+
text += line.getCell(x)?.getChars() || ' ';
|
|
451
|
+
}
|
|
452
|
+
return text.trimEnd();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Platform-specific marker alternatives.
|
|
456
|
+
// Claude Code uses ⏺ (U+23FA) on Mac but ● (U+25CF) on Windows.
|
|
457
|
+
const MARKER_ALTS = { '\u23FA': ['\u23FA', '\u25CF'] };
|
|
458
|
+
|
|
459
|
+
function readLastAgentLine(term, commandId) {
|
|
460
|
+
const marker = state.cfg.commands.find(c => c.id === commandId)?.outputMarker;
|
|
461
|
+
if (!marker) return '';
|
|
462
|
+
const markers = MARKER_ALTS[marker] || [marker];
|
|
463
|
+
const buf = term.buffer.active;
|
|
464
|
+
for (let y = buf.baseY + buf.cursorY; y >= 0; y--) {
|
|
465
|
+
const text = readLineText(buf, y).trim();
|
|
466
|
+
if (!text) continue;
|
|
467
|
+
const match = markers.find(m => text.startsWith(m));
|
|
468
|
+
if (!match) continue;
|
|
469
|
+
const content = text.slice(match.length).trim();
|
|
470
|
+
if (content) return content;
|
|
471
|
+
}
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function setStatus(id, working) {
|
|
476
|
+
const entry = state.terms.get(id);
|
|
477
|
+
if (!entry || entry.working === working) return;
|
|
478
|
+
|
|
479
|
+
const wasWorking = entry.working;
|
|
480
|
+
entry.working = working;
|
|
481
|
+
|
|
482
|
+
// Notify on working → idle transition
|
|
483
|
+
if (wasWorking && !working && !entry.muted) {
|
|
484
|
+
const workDuration = (Date.now() - (entry.workStartedAt || 0)) / 1000;
|
|
485
|
+
const minWork = state.cfg.notifyMinWork || 10;
|
|
486
|
+
if (workDuration >= minWork) {
|
|
487
|
+
// Sound: plays unless this session is the one you're looking at
|
|
488
|
+
if (state.cfg.notifySoundEnabled !== false && state.active !== id) {
|
|
489
|
+
new Audio(`/fx/${(state.cfg.notifySound || 'default-beep')}.mp3`).play().catch(() => {});
|
|
490
|
+
}
|
|
491
|
+
// Browser notification: plays when the CliDeck tab is not focused
|
|
492
|
+
if (state.cfg.notifyIdle && !document.hasFocus()
|
|
493
|
+
&& 'Notification' in window && Notification.permission === 'granted') {
|
|
494
|
+
const sessionName = document.querySelector(`.group[data-id="${id}"] .name`)?.textContent || 'Session';
|
|
495
|
+
const proj = state.cfg.projects?.find(p => p.id === entry.projectId);
|
|
496
|
+
const title = proj ? `${proj.name}: ${sessionName}` : sessionName;
|
|
497
|
+
const n = new Notification(title, { body: `Is now idle.\n${entry.lastPreviewText || ''}`, icon: '/img/clideck-logo-icon.png', tag: id });
|
|
498
|
+
n.onclick = () => { window.focus(); select(id); n.close(); };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (working) entry.workStartedAt = Date.now();
|
|
504
|
+
|
|
505
|
+
const el = document.querySelector(`.group[data-id="${id}"] .session-status`);
|
|
506
|
+
if (!el) return;
|
|
507
|
+
|
|
508
|
+
// Stop previous animation if any
|
|
509
|
+
if (entry.stopBounce) { entry.stopBounce(); entry.stopBounce = null; }
|
|
510
|
+
|
|
511
|
+
// Fade out, swap, fade in
|
|
512
|
+
el.style.opacity = '0';
|
|
513
|
+
setTimeout(() => {
|
|
514
|
+
if (working) {
|
|
515
|
+
el.className = 'session-status flex-shrink-0 leading-none';
|
|
516
|
+
entry.stopBounce = startBounce(el);
|
|
517
|
+
} else {
|
|
518
|
+
el.className = 'session-status dormant flex-shrink-0 text-[11px] leading-none';
|
|
519
|
+
el.innerHTML = '<span>z<sup>z</sup>Z</span>';
|
|
520
|
+
}
|
|
521
|
+
el.style.opacity = '1';
|
|
522
|
+
}, 200);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// --- Mute ---
|
|
526
|
+
|
|
527
|
+
const MUTE_SVG = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`;
|
|
528
|
+
|
|
529
|
+
function toggleMute(id) {
|
|
530
|
+
const entry = state.terms.get(id);
|
|
531
|
+
if (!entry) return;
|
|
532
|
+
entry.muted = !entry.muted;
|
|
533
|
+
send({ type: 'session.mute', id, muted: entry.muted });
|
|
534
|
+
updateMuteIndicator(id);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function updateMuteIndicator(id) {
|
|
538
|
+
const entry = state.terms.get(id);
|
|
539
|
+
if (!entry) return;
|
|
540
|
+
const row = document.querySelector(`.group[data-id="${id}"]`);
|
|
541
|
+
if (!row) return;
|
|
542
|
+
let icon = row.querySelector('.mute-icon');
|
|
543
|
+
if (entry.muted) {
|
|
544
|
+
if (!icon) {
|
|
545
|
+
icon = document.createElement('span');
|
|
546
|
+
icon.className = 'mute-icon flex-shrink-0 text-slate-600';
|
|
547
|
+
icon.innerHTML = MUTE_SVG;
|
|
548
|
+
const dot = row.querySelector('.unread-dot');
|
|
549
|
+
dot.parentNode.insertBefore(icon, dot);
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
icon?.remove();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// --- Theme ---
|
|
557
|
+
|
|
558
|
+
export function setSessionTheme(id, themeId, { showBanner = true } = {}) {
|
|
559
|
+
const entry = state.terms.get(id);
|
|
560
|
+
if (!entry) return;
|
|
561
|
+
const oldLight = isLightBg(entry.themeId);
|
|
562
|
+
const newLight = isLightBg(themeId);
|
|
563
|
+
entry.themeId = themeId;
|
|
564
|
+
applyTheme(entry.term, themeId);
|
|
565
|
+
entry.el.style.backgroundColor = resolveTheme(themeId).background;
|
|
566
|
+
send({ type: 'session.theme', id, themeId });
|
|
567
|
+
if (showBanner && oldLight !== newLight) showRestartBanner(id, themeId);
|
|
568
|
+
else hideRestartBanner(id);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function showRestartBanner(id, themeId) {
|
|
572
|
+
const group = document.querySelector(`.group[data-id="${id}"]`);
|
|
573
|
+
if (!group || group.querySelector('.restart-banner')) return;
|
|
574
|
+
const entry = state.terms.get(id);
|
|
575
|
+
if (!entry) return;
|
|
576
|
+
const mode = isLightBg(themeId) ? 'light' : 'dark';
|
|
577
|
+
const banner = document.createElement('div');
|
|
578
|
+
banner.className = 'restart-banner flex items-center gap-1.5 px-2.5 py-1 text-[11px] text-blue-400 cursor-pointer hover:text-blue-300 transition-colors';
|
|
579
|
+
banner.style.marginLeft = '2.75rem'; // align with text (past icon)
|
|
580
|
+
banner.innerHTML = `<svg class="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h4.5M20 20v-5h-4.5M4 9a9 9 0 0 1 15.36-5.36M20 15a9 9 0 0 1-15.36 5.36"/></svg><span>Restart to apply ${mode} theme</span>`;
|
|
581
|
+
banner.addEventListener('click', (e) => {
|
|
582
|
+
e.stopPropagation();
|
|
583
|
+
console.log('[restart] click banner, sending session.restart', { id, themeId: entry.themeId });
|
|
584
|
+
send({ type: 'session.restart', id, themeId: entry.themeId, cols: entry.term.cols, rows: entry.term.rows });
|
|
585
|
+
});
|
|
586
|
+
group.appendChild(banner);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function hideRestartBanner(id) {
|
|
590
|
+
document.querySelector(`.group[data-id="${id}"] .restart-banner`)?.remove();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function restartComplete(id, msg) {
|
|
594
|
+
hideRestartBanner(id);
|
|
595
|
+
const entry = state.terms.get(id);
|
|
596
|
+
if (!entry) return;
|
|
597
|
+
entry.term.clear();
|
|
598
|
+
if (state.active !== id) select(id);
|
|
599
|
+
else entry.term.focus();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// --- Rename ---
|
|
603
|
+
|
|
604
|
+
export function startRename(id) {
|
|
605
|
+
const el = document.querySelector(`.group[data-id="${id}"] .name`);
|
|
606
|
+
if (!el || el.contentEditable === 'true') return;
|
|
607
|
+
const original = el.textContent;
|
|
608
|
+
el.contentEditable = 'true';
|
|
609
|
+
el.style.userSelect = 'text';
|
|
610
|
+
el.style.webkitUserSelect = 'text';
|
|
611
|
+
el.classList.add('cursor-text');
|
|
612
|
+
el.focus();
|
|
613
|
+
document.getSelection().selectAllChildren(el);
|
|
614
|
+
|
|
615
|
+
let cancelled = false;
|
|
616
|
+
const finish = () => {
|
|
617
|
+
el.removeEventListener('keydown', onKey);
|
|
618
|
+
el.contentEditable = 'false';
|
|
619
|
+
el.style.userSelect = '';
|
|
620
|
+
el.style.webkitUserSelect = '';
|
|
621
|
+
el.classList.remove('cursor-text');
|
|
622
|
+
if (cancelled) el.textContent = original;
|
|
623
|
+
else {
|
|
624
|
+
const name = el.textContent.trim() || original;
|
|
625
|
+
el.textContent = name;
|
|
626
|
+
send({ type: 'rename', id, name });
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
const onKey = (e) => {
|
|
630
|
+
if (e.key === 'Enter') { e.preventDefault(); el.blur(); }
|
|
631
|
+
if (e.key === 'Escape') { cancelled = true; el.blur(); }
|
|
632
|
+
};
|
|
633
|
+
el.addEventListener('blur', finish, { once: true });
|
|
634
|
+
el.addEventListener('keydown', onKey);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function startProjectRename(projectId) {
|
|
638
|
+
const el = document.querySelector(`.project-group[data-project-id="${projectId}"] .project-name`);
|
|
639
|
+
if (!el || el.contentEditable === 'true') return;
|
|
640
|
+
const original = el.textContent;
|
|
641
|
+
el.contentEditable = 'true';
|
|
642
|
+
el.classList.add('text-slate-200');
|
|
643
|
+
el.focus();
|
|
644
|
+
document.getSelection().selectAllChildren(el);
|
|
645
|
+
|
|
646
|
+
let cancelled = false;
|
|
647
|
+
const finish = () => {
|
|
648
|
+
el.removeEventListener('keydown', onKey);
|
|
649
|
+
el.contentEditable = 'false';
|
|
650
|
+
el.classList.remove('text-slate-200');
|
|
651
|
+
if (cancelled) { el.textContent = original; return; }
|
|
652
|
+
const name = el.textContent.trim() || original;
|
|
653
|
+
el.textContent = name;
|
|
654
|
+
const proj = (state.cfg.projects || []).find(p => p.id === projectId);
|
|
655
|
+
if (proj) {
|
|
656
|
+
proj.name = name;
|
|
657
|
+
send({ type: 'config.update', config: state.cfg });
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
const onKey = (e) => {
|
|
661
|
+
if (e.key === 'Enter') { e.preventDefault(); el.blur(); }
|
|
662
|
+
if (e.key === 'Escape') { cancelled = true; el.blur(); }
|
|
663
|
+
};
|
|
664
|
+
el.addEventListener('blur', finish, { once: true });
|
|
665
|
+
el.addEventListener('keydown', onKey);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// --- Project grouping ---
|
|
669
|
+
|
|
670
|
+
const CHEVRON_SVG = `<svg class="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>`;
|
|
671
|
+
|
|
672
|
+
const PROJECT_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#ef4444', '#06b6d4', '#84cc16'];
|
|
673
|
+
|
|
674
|
+
function projectColor(project) {
|
|
675
|
+
return project.color || PROJECT_COLORS[0];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function regroupSessions() {
|
|
679
|
+
const list = document.getElementById('session-list');
|
|
680
|
+
const projects = state.cfg.projects || [];
|
|
681
|
+
|
|
682
|
+
// Detach all session rows (preserve DOM nodes)
|
|
683
|
+
const rows = new Map();
|
|
684
|
+
for (const [id] of state.terms) {
|
|
685
|
+
const row = document.querySelector(`.group[data-id="${id}"]`);
|
|
686
|
+
if (row) { row.remove(); rows.set(id, row); }
|
|
687
|
+
}
|
|
688
|
+
// Remove old project headers, resumable rows, and resumable section
|
|
689
|
+
list.querySelectorAll('.project-group').forEach(el => el.remove());
|
|
690
|
+
list.querySelectorAll('[data-resumable-id]').forEach(el => el.remove());
|
|
691
|
+
document.getElementById('resumable-section')?.remove();
|
|
692
|
+
|
|
693
|
+
// Render project groups
|
|
694
|
+
for (const proj of projects) {
|
|
695
|
+
const header = document.createElement('div');
|
|
696
|
+
header.className = 'project-group';
|
|
697
|
+
header.dataset.projectId = proj.id;
|
|
698
|
+
|
|
699
|
+
const collapsed = proj.collapsed;
|
|
700
|
+
header.innerHTML = `
|
|
701
|
+
<div class="group project-header flex items-center gap-1.5 px-2.5 py-1.5 cursor-pointer hover:bg-slate-800/30 transition-colors select-none" data-project-id="${proj.id}">
|
|
702
|
+
<span class="project-chevron ${collapsed ? 'collapsed' : ''} text-slate-500">${CHEVRON_SVG}</span>
|
|
703
|
+
<span class="w-2 h-2 rounded-full flex-shrink-0" style="background:${projectColor(proj)}"></span>
|
|
704
|
+
<span class="project-name flex-1 text-[11px] font-semibold uppercase tracking-wider text-slate-500 truncate">${esc(proj.name)}</span>
|
|
705
|
+
<span class="project-count text-[10px] text-slate-600">0</span>
|
|
706
|
+
<button class="project-menu-btn opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-400 flex-shrink-0 transition-opacity p-0.5" title="Project menu">
|
|
707
|
+
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5" fill="currentColor"/><circle cx="10" cy="10" r="1.5" fill="currentColor"/><circle cx="10" cy="16" r="1.5" fill="currentColor"/></svg>
|
|
708
|
+
</button>
|
|
709
|
+
</div>
|
|
710
|
+
<div class="project-sessions ${collapsed ? 'hidden' : ''}"></div>`;
|
|
711
|
+
|
|
712
|
+
list.appendChild(header);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Place active sessions into their groups or ungrouped at top
|
|
716
|
+
const ungrouped = [];
|
|
717
|
+
for (const [id, entry] of state.terms) {
|
|
718
|
+
const row = rows.get(id);
|
|
719
|
+
if (!row) continue;
|
|
720
|
+
if (entry.projectId) {
|
|
721
|
+
const container = list.querySelector(`.project-group[data-project-id="${entry.projectId}"] .project-sessions`);
|
|
722
|
+
if (container) { container.appendChild(row); continue; }
|
|
723
|
+
}
|
|
724
|
+
ungrouped.push(row);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const firstGroup = list.querySelector('.project-group');
|
|
728
|
+
for (const row of ungrouped) list.insertBefore(row, firstGroup);
|
|
729
|
+
|
|
730
|
+
// Place resumable sessions into their project groups or ungrouped section
|
|
731
|
+
const ungroupedResumable = [];
|
|
732
|
+
for (const s of state.resumable) {
|
|
733
|
+
const row = buildResumableRow(s);
|
|
734
|
+
if (s.projectId) {
|
|
735
|
+
const container = list.querySelector(`.project-group[data-project-id="${s.projectId}"] .project-sessions`);
|
|
736
|
+
if (container) { container.appendChild(row); continue; }
|
|
737
|
+
}
|
|
738
|
+
ungroupedResumable.push(row);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Ungrouped resumable → "Previous Sessions" section at the bottom
|
|
742
|
+
if (ungroupedResumable.length) {
|
|
743
|
+
const section = document.createElement('div');
|
|
744
|
+
section.id = 'resumable-section';
|
|
745
|
+
section.innerHTML = `<div class="resumable-header group flex items-center gap-1.5 px-2.5 py-2 mt-1 border-t border-slate-700/50">
|
|
746
|
+
<span class="flex-1 text-[10px] font-semibold uppercase tracking-wider text-slate-600">Previous Sessions</span>
|
|
747
|
+
<button class="prev-sessions-menu-btn opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-400 flex-shrink-0 transition-opacity p-0.5" title="Previous sessions menu">
|
|
748
|
+
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5" fill="currentColor"/><circle cx="10" cy="10" r="1.5" fill="currentColor"/><circle cx="10" cy="16" r="1.5" fill="currentColor"/></svg>
|
|
749
|
+
</button>
|
|
750
|
+
</div>`;
|
|
751
|
+
for (const row of ungroupedResumable) section.appendChild(row);
|
|
752
|
+
list.appendChild(section);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Update counts (active + resumable inside each project)
|
|
756
|
+
for (const proj of projects) {
|
|
757
|
+
const container = list.querySelector(`.project-group[data-project-id="${proj.id}"] .project-sessions`);
|
|
758
|
+
const countEl = list.querySelector(`.project-group[data-project-id="${proj.id}"] .project-count`);
|
|
759
|
+
if (container && countEl) countEl.textContent = container.children.length;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
applyFilter();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function toggleProjectCollapse(projectId) {
|
|
766
|
+
const proj = (state.cfg.projects || []).find(p => p.id === projectId);
|
|
767
|
+
if (!proj) return;
|
|
768
|
+
proj.collapsed = !proj.collapsed;
|
|
769
|
+
send({ type: 'config.update', config: state.cfg });
|
|
770
|
+
|
|
771
|
+
const group = document.querySelector(`.project-group[data-project-id="${projectId}"]`);
|
|
772
|
+
if (!group) return;
|
|
773
|
+
const sessions = group.querySelector('.project-sessions');
|
|
774
|
+
const chevron = group.querySelector('.project-chevron');
|
|
775
|
+
if (sessions) sessions.classList.toggle('hidden', proj.collapsed);
|
|
776
|
+
if (chevron) chevron.classList.toggle('collapsed', proj.collapsed);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function setSessionProject(id, projectId) {
|
|
780
|
+
const entry = state.terms.get(id);
|
|
781
|
+
if (!entry) return;
|
|
782
|
+
entry.projectId = projectId;
|
|
783
|
+
send({ type: 'session.setProject', id, projectId });
|
|
784
|
+
regroupSessions();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// --- Resumable sessions ---
|
|
788
|
+
|
|
789
|
+
const RESUME_SVG = `<svg class="w-3 h-3 ml-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>`;
|
|
790
|
+
|
|
791
|
+
function buildResumableRow(s) {
|
|
792
|
+
const cmd = state.cfg.commands.find(c => c.id === s.commandId);
|
|
793
|
+
const label = cmd?.label || 'Session';
|
|
794
|
+
const time = formatTime(new Date(s.savedAt).getTime());
|
|
795
|
+
const path = shortPath(s.cwd);
|
|
796
|
+
const row = document.createElement('div');
|
|
797
|
+
row.className = 'group resumable-row flex items-center gap-2 px-2.5 py-2 cursor-pointer hover:bg-slate-800/30 transition-colors';
|
|
798
|
+
row.dataset.resumableId = s.id;
|
|
799
|
+
row.innerHTML = `
|
|
800
|
+
<div class="w-8 h-8 rounded-full bg-slate-800/50 flex items-center justify-center flex-shrink-0 overflow-hidden opacity-40">
|
|
801
|
+
${iconHtml(s.commandId)}
|
|
802
|
+
</div>
|
|
803
|
+
<div class="flex-1 min-w-0">
|
|
804
|
+
<div class="flex items-baseline gap-2">
|
|
805
|
+
<span class="resumable-name flex-1 font-semibold text-[13px] text-slate-400 truncate">${esc(s.name)}</span>
|
|
806
|
+
<span class="text-[11px] text-slate-600 flex-shrink-0">${time}</span>
|
|
807
|
+
</div>
|
|
808
|
+
<div class="flex items-center gap-1 mt-0.5">
|
|
809
|
+
<span class="flex-1 text-xs text-slate-600 truncate">${s.lastPreview ? esc(s.lastPreview) : esc(label) + (path ? ' · ' + esc(path) : '')}</span>
|
|
810
|
+
<button class="resume-btn opacity-0 group-hover:opacity-100 text-slate-600 hover:text-emerald-400 flex-shrink-0 transition-all flex items-center gap-0.5 text-[11px] font-medium" title="Resume session">
|
|
811
|
+
Resume${RESUME_SVG}
|
|
812
|
+
</button>
|
|
813
|
+
</div>
|
|
814
|
+
</div>`;
|
|
815
|
+
return row;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function renderResumable() {
|
|
819
|
+
// Just rebuild rows and let regroupSessions place them
|
|
820
|
+
regroupSessions();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// --- Filtering ---
|
|
824
|
+
|
|
825
|
+
export function applyFilter() {
|
|
826
|
+
const { query, tab } = state.filter;
|
|
827
|
+
const q = query.toLowerCase();
|
|
828
|
+
|
|
829
|
+
// Filter active sessions
|
|
830
|
+
for (const [id, entry] of state.terms) {
|
|
831
|
+
const el = document.querySelector(`.group[data-id="${id}"]`);
|
|
832
|
+
if (!el) continue;
|
|
833
|
+
const matchTab = tab === 'all' || entry.unread;
|
|
834
|
+
const name = el.querySelector('.name')?.textContent.toLowerCase() || '';
|
|
835
|
+
const matchQuery = !q || name.includes(q) || (entry.searchText || '').toLowerCase().includes(q);
|
|
836
|
+
el.style.display = matchTab && matchQuery ? '' : 'none';
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Filter all resumable rows (both inside projects and ungrouped)
|
|
840
|
+
for (const row of document.querySelectorAll('[data-resumable-id]')) {
|
|
841
|
+
if (tab === 'unread') { row.style.display = 'none'; continue; }
|
|
842
|
+
const name = row.querySelector('.resumable-name')?.textContent.toLowerCase() || '';
|
|
843
|
+
const tx = (state.transcriptCache?.[row.dataset.resumableId] || '').toLowerCase();
|
|
844
|
+
row.style.display = !q || name.includes(q) || tx.includes(q) ? '' : 'none';
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Show/hide project groups
|
|
848
|
+
for (const group of document.querySelectorAll('.project-group')) {
|
|
849
|
+
const sessions = group.querySelector('.project-sessions');
|
|
850
|
+
const hasVisible = sessions && [...sessions.children].some(c => c.style.display !== 'none');
|
|
851
|
+
let show;
|
|
852
|
+
if (q) {
|
|
853
|
+
const projName = group.querySelector('.project-name')?.textContent.toLowerCase() || '';
|
|
854
|
+
show = projName.includes(q) || hasVisible;
|
|
855
|
+
} else {
|
|
856
|
+
show = tab === 'all' || hasVisible;
|
|
857
|
+
}
|
|
858
|
+
group.style.display = show ? '' : 'none';
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Ungrouped resumable section
|
|
862
|
+
const section = document.getElementById('resumable-section');
|
|
863
|
+
if (!section) return;
|
|
864
|
+
if (tab === 'unread') { section.style.display = 'none'; return; }
|
|
865
|
+
section.style.display = '';
|
|
866
|
+
const anyVisible = [...section.querySelectorAll('[data-resumable-id]')].some(r => r.style.display !== 'none');
|
|
867
|
+
const header = section.querySelector('.resumable-header');
|
|
868
|
+
if (header) header.style.display = anyVisible ? '' : 'none';
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export function setTab(tab) {
|
|
872
|
+
state.filter.tab = tab;
|
|
873
|
+
document.querySelectorAll('.filter-tab').forEach(btn => {
|
|
874
|
+
const active = btn.dataset.tab === tab;
|
|
875
|
+
const base = 'filter-tab flex-1 text-[11px] font-medium py-[5px] rounded-md transition-all';
|
|
876
|
+
const extra = btn.dataset.tab === 'unread' ? ' flex items-center justify-center gap-1' : '';
|
|
877
|
+
btn.className = base + extra + (active ? ' bg-slate-700/60 text-slate-200' : ' text-slate-500 hover:text-slate-400');
|
|
878
|
+
});
|
|
879
|
+
applyFilter();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function updateUnreadBadge() {
|
|
883
|
+
let count = 0;
|
|
884
|
+
for (const [, entry] of state.terms) if (entry.unread) count++;
|
|
885
|
+
const badge = document.getElementById('unread-badge');
|
|
886
|
+
if (badge) {
|
|
887
|
+
badge.textContent = count || '';
|
|
888
|
+
badge.classList.toggle('hidden', count === 0);
|
|
889
|
+
}
|
|
890
|
+
const rail = document.getElementById('rail-unread');
|
|
891
|
+
if (rail) {
|
|
892
|
+
rail.textContent = count || '';
|
|
893
|
+
rail.classList.toggle('hidden', count === 0);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Refresh displayed timestamps every 60s so they age naturally
|
|
898
|
+
setInterval(() => {
|
|
899
|
+
for (const [id, entry] of state.terms) {
|
|
900
|
+
const timeEl = document.querySelector(`.group[data-id="${id}"] .session-time`);
|
|
901
|
+
if (timeEl) updateTimeEl(timeEl, entry.lastActivityAt);
|
|
902
|
+
}
|
|
903
|
+
}, 60000);
|
|
904
|
+
|
|
905
|
+
export { openMenu, closeMenu, setStatus, updateMuteIndicator, positionMenu, PROJECT_COLORS };
|