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
package/public/js/app.js
ADDED
|
@@ -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();
|