clideck 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/activity.js +56 -0
  4. package/agent-presets.json +93 -0
  5. package/assets/clideck-themes.jpg +0 -0
  6. package/bin/clideck.js +2 -0
  7. package/config.js +96 -0
  8. package/handlers.js +297 -0
  9. package/opencode-bridge.js +148 -0
  10. package/opencode-plugin/clideck-bridge.js +24 -0
  11. package/package.json +47 -0
  12. package/paths.js +41 -0
  13. package/plugin-loader.js +285 -0
  14. package/plugins/trim-clip/clideck-plugin.json +13 -0
  15. package/plugins/trim-clip/client.js +31 -0
  16. package/plugins/trim-clip/index.js +10 -0
  17. package/plugins/voice-input/clideck-plugin.json +49 -0
  18. package/plugins/voice-input/client.js +196 -0
  19. package/plugins/voice-input/index.js +342 -0
  20. package/plugins/voice-input/python/mel_filters.npz +0 -0
  21. package/plugins/voice-input/python/whisper_turbo.py +416 -0
  22. package/plugins/voice-input/python/worker.py +135 -0
  23. package/public/fx/bold-beep-idle.mp3 +0 -0
  24. package/public/fx/default-beep.mp3 +0 -0
  25. package/public/fx/echo-beep-idle.mp3 +0 -0
  26. package/public/fx/musical-beep-idle.mp3 +0 -0
  27. package/public/fx/small-bleep-idle.mp3 +0 -0
  28. package/public/fx/soft-beep.mp3 +0 -0
  29. package/public/fx/space-idle.mp3 +0 -0
  30. package/public/img/claude-code.png +0 -0
  31. package/public/img/clideck-logo-icon.png +0 -0
  32. package/public/img/clideck-logo-terminal-panel.png +0 -0
  33. package/public/img/codex.png +0 -0
  34. package/public/img/gemini.png +0 -0
  35. package/public/img/opencode.png +0 -0
  36. package/public/index.html +243 -0
  37. package/public/js/app.js +794 -0
  38. package/public/js/color-mode.js +51 -0
  39. package/public/js/confirm.js +27 -0
  40. package/public/js/creator.js +201 -0
  41. package/public/js/drag.js +134 -0
  42. package/public/js/folder-picker.js +81 -0
  43. package/public/js/hotkeys.js +90 -0
  44. package/public/js/nav.js +56 -0
  45. package/public/js/profiles.js +22 -0
  46. package/public/js/prompts.js +325 -0
  47. package/public/js/settings.js +489 -0
  48. package/public/js/state.js +15 -0
  49. package/public/js/terminals.js +905 -0
  50. package/public/js/toast.js +62 -0
  51. package/public/js/utils.js +27 -0
  52. package/public/tailwind.css +1 -0
  53. package/server.js +126 -0
  54. package/sessions.js +375 -0
  55. package/telemetry-receiver.js +129 -0
  56. package/themes.js +247 -0
  57. package/transcript.js +90 -0
  58. package/utils.js +66 -0
@@ -0,0 +1,51 @@
1
+ import { state, send } from './state.js';
2
+ import { applyTheme } from './profiles.js';
3
+
4
+ const LIGHT_THEMES = new Set(['github-light', 'solarized-light', 'catppuccin-latte', 'one-light', 'rose-pine-dawn']);
5
+ const DARK_DEFAULTS = new Set(['catppuccin-mocha', 'solarized-dark', 'dracula', 'default']);
6
+ const LIGHT_DEFAULT = 'github-light';
7
+ const DARK_DEFAULT = 'catppuccin-mocha';
8
+
9
+ export function isLightTheme(id) { return LIGHT_THEMES.has(id); }
10
+
11
+ function isDarkDefault(id) { return DARK_DEFAULTS.has(id); }
12
+
13
+ export function getMode() {
14
+ return state.cfg.colorMode || 'dark';
15
+ }
16
+
17
+ export function modeDefault(mode) {
18
+ return mode === 'light' ? LIGHT_DEFAULT : DARK_DEFAULT;
19
+ }
20
+
21
+ export function applyMode(mode) {
22
+ document.documentElement.classList.toggle('light', mode === 'light');
23
+ }
24
+
25
+ export function toggleMode() {
26
+ // Guard: don't send config before the real config has arrived from backend
27
+ if (!state.cfg.commands.length) return;
28
+
29
+ const next = getMode() === 'dark' ? 'light' : 'dark';
30
+ state.cfg.colorMode = next;
31
+ applyMode(next);
32
+
33
+ const newDefault = modeDefault(next);
34
+ const isGoingLight = next === 'light';
35
+
36
+ // Switch terminals that are on a mode-default theme
37
+ for (const [id, entry] of state.terms) {
38
+ const shouldSwitch = isGoingLight ? isDarkDefault(entry.themeId) : isLightTheme(entry.themeId);
39
+ if (shouldSwitch) {
40
+ // Use setSessionTheme via app.js to avoid circular import
41
+ // setSessionTheme handles polarity detection and restart banner
42
+ document.dispatchEvent(new CustomEvent('clideck-theme-switch', { detail: { id, themeId: newDefault } }));
43
+ }
44
+ }
45
+
46
+ // Update default theme so new sessions follow the mode
47
+ const shouldSwitchDefault = isGoingLight ? isDarkDefault(state.cfg.defaultTheme) : isLightTheme(state.cfg.defaultTheme);
48
+ if (shouldSwitchDefault) state.cfg.defaultTheme = newDefault;
49
+
50
+ send({ type: 'config.update', config: state.cfg });
51
+ }
@@ -0,0 +1,27 @@
1
+ const overlay = document.getElementById('confirm-close');
2
+ const messageEl = document.getElementById('cc-message');
3
+ const confirmBtn = document.getElementById('cc-confirm');
4
+ const cancelBtn = document.getElementById('cc-cancel');
5
+ let pendingResolve = null;
6
+
7
+ const DEFAULT_MSG = 'Close this session? The terminal process will be killed.';
8
+
9
+ export function confirmClose(message, confirmLabel) {
10
+ return new Promise((resolve) => {
11
+ pendingResolve = resolve;
12
+ messageEl.textContent = message || DEFAULT_MSG;
13
+ confirmBtn.textContent = confirmLabel || 'Delete';
14
+ overlay.classList.remove('hidden');
15
+ overlay.classList.add('flex');
16
+ });
17
+ }
18
+
19
+ function close(result) {
20
+ overlay.classList.add('hidden');
21
+ overlay.classList.remove('flex');
22
+ if (pendingResolve) { pendingResolve(result); pendingResolve = null; }
23
+ }
24
+
25
+ confirmBtn.addEventListener('click', () => close(true));
26
+ cancelBtn.addEventListener('click', () => close(false));
27
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
@@ -0,0 +1,201 @@
1
+ import { state, send } from './state.js';
2
+ import { esc, agentIcon, binName } from './utils.js';
3
+ import { openFolderPicker } from './folder-picker.js';
4
+ import { estimateSize } from './terminals.js';
5
+
6
+ const ADJECTIVES = [
7
+ 'Blue', 'Red', 'Green', 'Purple', 'Golden', 'Silver', 'Coral', 'Amber',
8
+ 'Mint', 'Crimson', 'Teal', 'Rose', 'Jade', 'Copper', 'Ivory', 'Rusty',
9
+ ];
10
+ const ANIMALS = [
11
+ 'Panda', 'Falcon', 'Fox', 'Wolf', 'Owl', 'Tiger', 'Bear', 'Eagle',
12
+ 'Dolphin', 'Lynx', 'Hawk', 'Raven', 'Otter', 'Panther', 'Crane', 'Bison',
13
+ ];
14
+ const MRU_KEY = 'termui-last-preset';
15
+ 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>`;
16
+
17
+ function randomName() {
18
+ const a = ADJECTIVES[Math.random() * ADJECTIVES.length | 0];
19
+ const b = ANIMALS[Math.random() * ANIMALS.length | 0];
20
+ return `${a} ${b}`;
21
+ }
22
+
23
+ function sortedPresets() {
24
+ const all = [...state.presets].filter(p => {
25
+ const cmd = state.cfg.commands.find(c =>
26
+ binName(c.command) === binName(p.command)
27
+ );
28
+ return !cmd || cmd.enabled !== false;
29
+ });
30
+ const shell = all.filter(p => !p.isAgent);
31
+ const agents = all.filter(p => p.isAgent);
32
+ const lastId = localStorage.getItem(MRU_KEY);
33
+ if (lastId) {
34
+ const idx = agents.findIndex(p => p.presetId === lastId);
35
+ if (idx > 0) agents.unshift(...agents.splice(idx, 1));
36
+ }
37
+ return [...agents, ...shell];
38
+ }
39
+
40
+ function createFromPreset(preset, sessionName, cwd, projectId) {
41
+ // Find existing command matching this preset
42
+ let cmd = state.cfg.commands.find(c =>
43
+ binName(c.command) === binName(preset.command)
44
+ );
45
+ // Auto-create the command if it doesn't exist yet
46
+ if (!cmd) {
47
+ cmd = {
48
+ id: crypto.randomUUID(),
49
+ label: preset.name,
50
+ icon: preset.icon,
51
+ command: preset.command,
52
+ enabled: true,
53
+ defaultPath: '',
54
+ isAgent: preset.isAgent,
55
+ canResume: preset.canResume,
56
+ resumeCommand: preset.resumeCommand,
57
+ sessionIdPattern: preset.sessionIdPattern,
58
+ outputMarker: preset.outputMarker || null,
59
+ telemetryEnabled: preset.presetId === 'claude-code',
60
+ telemetryStatus: preset.presetId === 'claude-code' ? { ok: true } : null,
61
+ bridge: preset.bridge,
62
+ };
63
+ state.cfg.commands.push(cmd);
64
+ send({ type: 'config.update', config: state.cfg });
65
+ }
66
+ send({ type: 'create', commandId: cmd.id, name: sessionName, cwd, projectId: projectId || undefined, ...estimateSize() });
67
+ localStorage.setItem(MRU_KEY, preset.presetId);
68
+ }
69
+
70
+ export function openCreator() {
71
+ // Toggle off if already open
72
+ if (document.getElementById('session-creator')) {
73
+ closeCreator();
74
+ return;
75
+ }
76
+ // Close project creator if open
77
+ document.getElementById('project-creator')?.remove();
78
+ if (!state.presets.length) return;
79
+
80
+ const fallbackName = randomName();
81
+ const presets = sortedPresets();
82
+ const defaultPath = state.cfg.defaultPath || '';
83
+
84
+ const card = document.createElement('div');
85
+ card.id = 'session-creator';
86
+ card.className = 'p-3 border-b border-slate-700/50 bg-slate-800/30';
87
+ card.innerHTML = `
88
+ ${(state.cfg.projects?.length) ? `
89
+ <input type="hidden" id="creator-project" value="">
90
+ <button type="button" id="creator-project-trigger" class="w-full px-3 py-1.5 text-xs bg-slate-900 border border-slate-700 rounded-md text-slate-400 text-left flex items-center justify-between outline-none hover:border-slate-500 transition-colors cursor-pointer mb-2">
91
+ <span id="creator-project-label">No project</span>
92
+ <span class="text-slate-600 ml-2">&#9662;</span>
93
+ </button>` : ''}
94
+ <input id="creator-name" type="text" maxlength="35" placeholder="Session / Agent name"
95
+ 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">
96
+ <div class="flex items-center gap-1.5 mb-2">
97
+ <input id="creator-cwd" type="text" value="${esc(defaultPath)}" placeholder="Working directory"
98
+ 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">
99
+ <button id="creator-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">
100
+ ${FOLDER_SVG}
101
+ </button>
102
+ </div>
103
+ <div class="space-y-0.5">
104
+ ${presets.map(p => `
105
+ <button class="preset-btn w-full flex items-center gap-2.5 px-3 py-2 rounded-md hover:bg-slate-700/70 text-sm text-slate-300 transition-colors text-left" data-preset="${p.presetId}">
106
+ ${agentIcon(p.icon, 24)}
107
+ <span>${esc(p.name)}</span>
108
+ </button>`).join('')}
109
+ </div>`;
110
+
111
+ const list = document.getElementById('session-list');
112
+ list.parentElement.insertBefore(card, list);
113
+
114
+ const nameInput = card.querySelector('#creator-name');
115
+ const cwdInput = card.querySelector('#creator-cwd');
116
+ nameInput.focus();
117
+
118
+ nameInput.addEventListener('keydown', (e) => {
119
+ if (e.key === 'Escape') closeCreator();
120
+ });
121
+ cwdInput.addEventListener('keydown', (e) => {
122
+ if (e.key === 'Escape') closeCreator();
123
+ });
124
+
125
+ card.querySelector('#creator-browse').addEventListener('click', () => {
126
+ openFolderPicker(cwdInput.value.trim() || defaultPath, (path) => {
127
+ cwdInput.value = path;
128
+ });
129
+ });
130
+
131
+ // Project picker dropdown
132
+ const projTrigger = card.querySelector('#creator-project-trigger');
133
+ if (projTrigger) {
134
+ let projMenuCleanup = null;
135
+ projTrigger.addEventListener('click', () => {
136
+ if (projMenuCleanup) { projMenuCleanup(); return; }
137
+ const rect = projTrigger.getBoundingClientRect();
138
+ const hidden = card.querySelector('#creator-project');
139
+ const label = card.querySelector('#creator-project-label');
140
+ const projects = state.cfg.projects || [];
141
+
142
+ const menu = document.createElement('div');
143
+ menu.className = 'fixed z-[500] bg-slate-800 border border-slate-600 rounded-lg shadow-xl shadow-black/40 py-1 overflow-y-auto';
144
+ menu.style.maxHeight = '200px';
145
+ menu.style.left = rect.left + 'px';
146
+ menu.style.top = (rect.bottom + 4) + 'px';
147
+ menu.style.width = rect.width + 'px';
148
+
149
+ menu.innerHTML = `
150
+ <div class="proj-option px-3 py-1.5 cursor-pointer hover:bg-slate-700 transition-colors text-xs text-slate-400 ${!hidden.value ? 'bg-slate-700/50' : ''}" data-value="">No project</div>
151
+ ${projects.map(p => `
152
+ <div class="proj-option flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-slate-700 transition-colors text-xs text-slate-300 ${hidden.value === p.id ? 'bg-slate-700/50' : ''}" data-value="${p.id}">
153
+ <span class="w-2 h-2 rounded-full flex-shrink-0" style="background:${p.color || '#3b82f6'}"></span>
154
+ ${esc(p.name)}
155
+ </div>`).join('')}`;
156
+
157
+ document.body.appendChild(menu);
158
+
159
+ const onClick = (e) => {
160
+ const item = e.target.closest('.proj-option');
161
+ if (!item) return;
162
+ hidden.value = item.dataset.value;
163
+ const proj = projects.find(p => p.id === item.dataset.value);
164
+ label.textContent = proj ? proj.name : 'No project';
165
+ // Auto-set working directory from project path
166
+ if (proj?.path) cwdInput.value = proj.path;
167
+ else cwdInput.value = defaultPath;
168
+ projMenuCleanup();
169
+ };
170
+ const onOutside = (e) => {
171
+ if (!menu.contains(e.target) && !projTrigger.contains(e.target)) projMenuCleanup();
172
+ };
173
+ menu.addEventListener('click', onClick);
174
+ requestAnimationFrame(() => document.addEventListener('click', onOutside));
175
+
176
+ projMenuCleanup = () => {
177
+ menu.removeEventListener('click', onClick);
178
+ document.removeEventListener('click', onOutside);
179
+ menu.remove();
180
+ projMenuCleanup = null;
181
+ };
182
+ });
183
+ }
184
+
185
+ card.addEventListener('click', (e) => {
186
+ const btn = e.target.closest('.preset-btn');
187
+ if (!btn) return;
188
+ const preset = state.presets.find(p => p.presetId === btn.dataset.preset);
189
+ if (!preset) return;
190
+ const name = nameInput.value.trim() || fallbackName;
191
+ const cwd = cwdInput.value.trim() || undefined;
192
+ const projectSelect = card.querySelector('#creator-project');
193
+ const projectId = projectSelect?.value || undefined;
194
+ createFromPreset(preset, name, cwd, projectId);
195
+ closeCreator();
196
+ });
197
+ }
198
+
199
+ export function closeCreator() {
200
+ document.getElementById('session-creator')?.remove();
201
+ }
@@ -0,0 +1,134 @@
1
+ import { state, send } from './state.js';
2
+ import { setSessionProject, regroupSessions } from './terminals.js';
3
+
4
+ let dragState = null;
5
+
6
+ const DRAG_THRESHOLD = 5;
7
+
8
+ export function initDrag() {
9
+ const list = document.getElementById('session-list');
10
+
11
+ list.addEventListener('pointerdown', (e) => {
12
+ if (e.button !== 0) return;
13
+ const row = e.target.closest('.group[data-id]');
14
+ if (!row || e.target.closest('.menu-btn') || e.target.closest('button')) return;
15
+
16
+ const id = row.dataset.id;
17
+ const rect = row.getBoundingClientRect();
18
+ dragState = {
19
+ id,
20
+ startX: e.clientX,
21
+ startY: e.clientY,
22
+ offsetY: e.clientY - rect.top,
23
+ row,
24
+ ghost: null,
25
+ active: false,
26
+ dropTarget: null,
27
+ pointerId: e.pointerId,
28
+ };
29
+ });
30
+
31
+ list.addEventListener('pointermove', (e) => {
32
+ if (!dragState) return;
33
+
34
+ if (!dragState.active) {
35
+ const dx = Math.abs(e.clientX - dragState.startX);
36
+ const dy = Math.abs(e.clientY - dragState.startY);
37
+ if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) return;
38
+ dragState.row.setPointerCapture(dragState.pointerId);
39
+ startDrag(dragState);
40
+ }
41
+
42
+ // Move ghost
43
+ dragState.ghost.style.top = (e.clientY - dragState.offsetY) + 'px';
44
+
45
+ // Find drop target
46
+ updateDropTarget(e.clientY);
47
+ });
48
+
49
+ list.addEventListener('pointerup', (e) => {
50
+ if (!dragState) return;
51
+ if (dragState.active) endDrag();
52
+ dragState = null;
53
+ });
54
+
55
+ list.addEventListener('pointercancel', () => {
56
+ if (dragState?.active) cancelDrag();
57
+ dragState = null;
58
+ });
59
+ }
60
+
61
+ function startDrag(ds) {
62
+ ds.active = true;
63
+ ds.row.style.opacity = '0.3';
64
+
65
+ // Create ghost
66
+ const ghost = ds.row.cloneNode(true);
67
+ ghost.className = ds.row.className + ' fixed z-[500] pointer-events-none shadow-xl shadow-black/50 bg-slate-800 border border-slate-600 rounded-lg w-[320px]';
68
+ ghost.style.top = (ds.startY - ds.offsetY) + 'px';
69
+ ghost.style.left = ds.row.getBoundingClientRect().left + 'px';
70
+ ghost.style.width = ds.row.offsetWidth + 'px';
71
+ ghost.style.transition = 'none';
72
+ document.body.appendChild(ghost);
73
+ ds.ghost = ghost;
74
+
75
+ // Add drop indicators
76
+ document.querySelectorAll('.project-header').forEach(h => {
77
+ h.classList.add('drop-zone');
78
+ });
79
+ }
80
+
81
+ function updateDropTarget(clientY) {
82
+ // Clear previous
83
+ document.querySelectorAll('.drop-highlight').forEach(el => el.classList.remove('drop-highlight'));
84
+ dragState.dropTarget = null;
85
+
86
+ // Check if hovering over a project header
87
+ for (const header of document.querySelectorAll('.project-header')) {
88
+ const rect = header.getBoundingClientRect();
89
+ if (clientY >= rect.top && clientY <= rect.bottom) {
90
+ header.classList.add('drop-highlight');
91
+ dragState.dropTarget = { type: 'project', projectId: header.dataset.projectId };
92
+ return;
93
+ }
94
+ }
95
+
96
+ // Check if above all project groups (= ungrouped area)
97
+ const firstGroup = document.querySelector('.project-group');
98
+ if (firstGroup) {
99
+ const rect = firstGroup.getBoundingClientRect();
100
+ if (clientY < rect.top) {
101
+ dragState.dropTarget = { type: 'ungrouped' };
102
+ }
103
+ }
104
+ }
105
+
106
+ function endDrag() {
107
+ const ds = dragState;
108
+ ds.row.style.opacity = '';
109
+ ds.ghost?.remove();
110
+ document.querySelectorAll('.drop-highlight, .drop-zone').forEach(el => {
111
+ el.classList.remove('drop-highlight', 'drop-zone');
112
+ });
113
+
114
+ if (ds.dropTarget) {
115
+ const entry = state.terms.get(ds.id);
116
+ if (!entry) return;
117
+
118
+ if (ds.dropTarget.type === 'project' && entry.projectId !== ds.dropTarget.projectId) {
119
+ setSessionProject(ds.id, ds.dropTarget.projectId);
120
+ } else if (ds.dropTarget.type === 'ungrouped' && entry.projectId) {
121
+ setSessionProject(ds.id, null);
122
+ }
123
+ }
124
+ }
125
+
126
+ function cancelDrag() {
127
+ if (dragState) {
128
+ dragState.row.style.opacity = '';
129
+ dragState.ghost?.remove();
130
+ document.querySelectorAll('.drop-highlight, .drop-zone').forEach(el => {
131
+ el.classList.remove('drop-highlight', 'drop-zone');
132
+ });
133
+ }
134
+ }
@@ -0,0 +1,81 @@
1
+ import { state, send } from './state.js';
2
+ import { esc } from './utils.js';
3
+
4
+ function isRoot(p) { return p === '/' || /^[A-Za-z]:\\$/.test(p); }
5
+ function parentOf(p) {
6
+ const up = p.replace(/[\\/][^\\/]+[\\/]?$/, '');
7
+ if (!up) {
8
+ const drive = p.match(/^([A-Za-z]:)/);
9
+ return drive ? drive[1] + '\\' : '/';
10
+ }
11
+ return /^[A-Za-z]:$/.test(up) ? up + '\\' : up;
12
+ }
13
+ function joinChild(base, name) {
14
+ const sep = base.includes('\\') ? '\\' : '/';
15
+ return base.endsWith(sep) ? base + name : base + sep + name;
16
+ }
17
+
18
+ const overlay = document.getElementById('folder-picker');
19
+ const pathBar = document.getElementById('fp-path');
20
+ const listing = document.getElementById('fp-listing');
21
+ const selectBtn = document.getElementById('fp-select');
22
+ let currentPath = '';
23
+ let pendingPath = '';
24
+ let onSelect = null;
25
+
26
+ export function openFolderPicker(startPath, callback) {
27
+ currentPath = '';
28
+ onSelect = callback;
29
+ overlay.classList.remove('hidden');
30
+ overlay.classList.add('flex');
31
+ navigate(startPath || state.cfg.defaultPath || '/');
32
+ }
33
+
34
+ export function closeFolderPicker() {
35
+ overlay.classList.add('hidden');
36
+ overlay.classList.remove('flex');
37
+ onSelect = null;
38
+ }
39
+
40
+ function navigate(path) {
41
+ pendingPath = path;
42
+ pathBar.textContent = path;
43
+ listing.innerHTML = '<div class="p-4 text-center text-slate-500 text-sm">Loading...</div>';
44
+ selectBtn.disabled = true;
45
+ send({ type: 'dirs.list', path });
46
+ }
47
+
48
+ export function handleDirsResponse(msg) {
49
+ if (overlay.classList.contains('hidden')) return;
50
+ if (msg.path !== pendingPath) return;
51
+ if (msg.error) {
52
+ listing.innerHTML = `<div class="p-4 text-center text-red-400 text-sm">${esc(msg.error)}</div>`;
53
+ return;
54
+ }
55
+ currentPath = msg.path;
56
+ selectBtn.disabled = false;
57
+ let html = '';
58
+ if (!isRoot(currentPath)) {
59
+ const parent = parentOf(currentPath);
60
+ html += `<div class="fp-item px-4 py-1.5 cursor-pointer hover:bg-slate-700 text-sm text-slate-400 transition-colors" data-path="${esc(parent)}">..</div>`;
61
+ }
62
+ if (msg.entries.length === 0 && !html) {
63
+ html = '<div class="p-4 text-center text-slate-500 text-sm">Empty directory</div>';
64
+ }
65
+ html += msg.entries.map(name =>
66
+ `<div class="fp-item px-4 py-1.5 cursor-pointer hover:bg-slate-700 text-sm text-slate-200 transition-colors" data-path="${esc(joinChild(currentPath, name))}">${esc(name)}</div>`
67
+ ).join('');
68
+ listing.innerHTML = html;
69
+ }
70
+
71
+ listing.addEventListener('click', (e) => {
72
+ const item = e.target.closest('.fp-item');
73
+ if (item) navigate(item.dataset.path);
74
+ });
75
+
76
+ document.getElementById('fp-select').addEventListener('click', () => {
77
+ if (onSelect && currentPath) onSelect(currentPath);
78
+ closeFolderPicker();
79
+ });
80
+
81
+ document.getElementById('fp-cancel').addEventListener('click', closeFolderPicker);
@@ -0,0 +1,90 @@
1
+ // Central hotkey dispatcher — works in terminal focus and outside it.
2
+ // Plugins register shortcuts via registerHotkey(combo, callback).
3
+
4
+ import { handleTerminalKey } from './prompts.js';
5
+
6
+ const registry = new Map(); // normalized combo → { pluginId, callback }
7
+
8
+ // Normalize a KeyboardEvent into a canonical combo string
9
+ function normalizeEvent(e) {
10
+ const parts = [];
11
+ if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
12
+ if (e.altKey) parts.push('Alt');
13
+ if (e.shiftKey) parts.push('Shift');
14
+ parts.push(e.code);
15
+ return parts.join('+');
16
+ }
17
+
18
+ // Normalize a user-provided combo string (e.g. "Alt+Ctrl+F4" → "Ctrl+Alt+F4")
19
+ function normalizeCombo(combo) {
20
+ const parts = combo.split('+').map(p => p.trim());
21
+ const mods = [];
22
+ let key = parts[parts.length - 1];
23
+ // Normalize key token to match e.code values
24
+ if (/^f\d+$/i.test(key)) key = key.toUpperCase(); // f4 → F4
25
+ else if (/^[a-z]$/i.test(key)) key = 'Key' + key.toUpperCase(); // k → KeyK
26
+ else if (/^[0-9]$/.test(key)) key = 'Digit' + key; // 1 → Digit1
27
+ else if (key.toLowerCase() === 'escape') key = 'Escape';
28
+ else if (key.toLowerCase() === 'space') key = 'Space';
29
+ else if (key.toLowerCase() === 'enter') key = 'Enter';
30
+ else if (key.toLowerCase() === 'tab') key = 'Tab';
31
+ for (const p of parts.slice(0, -1)) {
32
+ const lower = p.toLowerCase();
33
+ if (lower === 'ctrl' || lower === 'cmd' || lower === 'meta') mods.push('Ctrl');
34
+ else if (lower === 'alt') mods.push('Alt');
35
+ else if (lower === 'shift') mods.push('Shift');
36
+ }
37
+ // Dedupe and sort in canonical order
38
+ const order = ['Ctrl', 'Alt', 'Shift'];
39
+ const sorted = order.filter(m => mods.includes(m));
40
+ sorted.push(key);
41
+ return sorted.join('+');
42
+ }
43
+
44
+ function isInput(target) {
45
+ return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
46
+ }
47
+
48
+ function dispatch(e) {
49
+ const combo = normalizeEvent(e);
50
+ const entry = registry.get(combo);
51
+ if (!entry) return true;
52
+ e.preventDefault();
53
+ e.stopPropagation();
54
+ entry.callback(e);
55
+ return false;
56
+ }
57
+
58
+ // Catch keys outside terminals — skip text inputs
59
+ document.addEventListener('keydown', (e) => {
60
+ if (isInput(e.target)) return;
61
+ dispatch(e);
62
+ }, true);
63
+
64
+ export function registerHotkey(pluginId, combo, callback) {
65
+ const norm = normalizeCombo(combo);
66
+ if (registry.has(norm)) {
67
+ console.warn(`[hotkeys] "${norm}" already registered by ${registry.get(norm).pluginId}, ignoring ${pluginId}`);
68
+ return false;
69
+ }
70
+ registry.set(norm, { pluginId, callback });
71
+ return true;
72
+ }
73
+
74
+ export function unregisterHotkey(pluginId, combo) {
75
+ const norm = normalizeCombo(combo);
76
+ const entry = registry.get(norm);
77
+ if (entry && entry.pluginId === pluginId) registry.delete(norm);
78
+ }
79
+
80
+ // Attach to an xterm terminal instance — xterm's hidden textarea is an input,
81
+ // so we bypass the isInput check and dispatch directly.
82
+ // Prompt autocomplete (// trigger) runs first, then hotkey dispatch.
83
+ export function attachToTerminal(term) {
84
+ term.attachCustomKeyEventHandler((e) => {
85
+ const promptResult = handleTerminalKey(e);
86
+ if (promptResult === false) return false;
87
+ if (e.type !== 'keydown') return true;
88
+ return dispatch(e);
89
+ });
90
+ }
@@ -0,0 +1,56 @@
1
+ import { closeThemeMenu } from './settings.js';
2
+ import { closeDropdown } from './prompts.js';
3
+
4
+ const ALL_PANELS = ['chats', 'prompts', 'plugins', 'settings'];
5
+ const PANEL_TITLES = { chats: 'Sessions', prompts: 'Prompts', plugins: 'Plugins', settings: 'Settings' };
6
+ const ACTIVE = ['text-slate-200', 'bg-slate-800'];
7
+ const INACTIVE = ['text-slate-500', 'hover:text-slate-300', 'hover:bg-slate-800/50'];
8
+
9
+ function setRailActive(id) {
10
+ document.querySelectorAll('#nav-rail .rail-btn').forEach(btn => {
11
+ const match = (btn.dataset.panel === id) || (btn.id === 'rail-settings' && id === 'settings');
12
+ ACTIVE.forEach(c => btn.classList.toggle(c, match));
13
+ INACTIVE.forEach(c => btn.classList.toggle(c, !match));
14
+ });
15
+ }
16
+
17
+ function hideAllPanels() {
18
+ ALL_PANELS.forEach(id => {
19
+ const el = document.getElementById(`panel-${id}`);
20
+ if (el) { el.classList.add('hidden'); el.classList.remove('flex'); }
21
+ });
22
+ }
23
+
24
+ function showSettings() {
25
+ hideAllPanels();
26
+ document.getElementById('panel-settings').classList.remove('hidden');
27
+ document.getElementById('panel-settings').classList.add('flex');
28
+ document.getElementById('settings-overlay').classList.remove('hidden');
29
+ document.getElementById('btn-new').classList.add('opacity-30', 'pointer-events-none');
30
+ setRailActive('settings');
31
+ document.title = 'CliDeck — Settings';
32
+ }
33
+
34
+ function hideSettings() {
35
+ closeThemeMenu();
36
+ document.getElementById('settings-overlay').classList.add('hidden');
37
+ document.getElementById('btn-new').classList.remove('opacity-30', 'pointer-events-none');
38
+ }
39
+
40
+ export function switchPanel(panelId) {
41
+ hideSettings();
42
+ hideAllPanels();
43
+ document.getElementById('session-creator')?.remove();
44
+ const el = document.getElementById(`panel-${panelId}`);
45
+ if (el) { el.classList.remove('hidden'); el.classList.add('flex'); }
46
+ setRailActive(panelId);
47
+ document.title = 'CliDeck — ' + (PANEL_TITLES[panelId] || 'CliDeck');
48
+ }
49
+
50
+ document.getElementById('nav-rail').addEventListener('click', (e) => {
51
+ const btn = e.target.closest('.rail-btn');
52
+ if (!btn) return;
53
+ closeDropdown();
54
+ if (btn.id === 'rail-settings') showSettings();
55
+ else if (btn.dataset.panel) switchPanel(btn.dataset.panel);
56
+ });
@@ -0,0 +1,22 @@
1
+ import { state } from './state.js';
2
+
3
+ const FALLBACK = { background: '#1e1e2e', foreground: '#cdd6f4' };
4
+
5
+ export function resolveTheme(themeId) {
6
+ return state.themes.find(t => t.id === themeId)?.theme || FALLBACK;
7
+ }
8
+
9
+ export function resolveAccent(themeId) {
10
+ return state.themes.find(t => t.id === themeId)?.accent || '#89b4fa';
11
+ }
12
+
13
+ export function applyTheme(term, themeId) {
14
+ term.options.theme = resolveTheme(themeId);
15
+ }
16
+
17
+ export function themePreviewColors(themeId) {
18
+ const preset = state.themes.find(t => t.id === themeId);
19
+ if (!preset) return [];
20
+ const t = preset.theme;
21
+ return [t.background, t.foreground, t.red, t.green, t.yellow, t.blue, t.magenta, t.cyan];
22
+ }