cli-jaw 0.1.11 → 0.1.12

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 (63) hide show
  1. package/README.ko.md +44 -13
  2. package/README.md +12 -11
  3. package/README.zh-CN.md +43 -12
  4. package/dist/bin/commands/doctor.js +13 -2
  5. package/dist/bin/commands/doctor.js.map +1 -1
  6. package/dist/bin/commands/mcp.js +15 -18
  7. package/dist/bin/commands/mcp.js.map +1 -1
  8. package/dist/bin/commands/serve.js +3 -28
  9. package/dist/bin/commands/serve.js.map +1 -1
  10. package/dist/bin/commands/skill.js +9 -6
  11. package/dist/bin/commands/skill.js.map +1 -1
  12. package/dist/lib/mcp-sync.js +123 -31
  13. package/dist/lib/mcp-sync.js.map +1 -1
  14. package/{scripts → dist/scripts}/check-copilot-gap.js +24 -17
  15. package/dist/scripts/check-copilot-gap.js.map +1 -0
  16. package/{scripts/check-deps-offline.mjs → dist/scripts/check-deps-offline.js} +24 -20
  17. package/dist/scripts/check-deps-offline.js.map +1 -0
  18. package/dist/scripts/fresh-install-smoke.js +120 -0
  19. package/dist/scripts/fresh-install-smoke.js.map +1 -0
  20. package/{scripts/i18n-registry.py → dist/scripts/i18n-registry.js} +115 -122
  21. package/dist/scripts/i18n-registry.js.map +1 -0
  22. package/dist/server.js +34 -26
  23. package/dist/server.js.map +1 -1
  24. package/dist/src/cli/command-context.js +13 -3
  25. package/dist/src/cli/command-context.js.map +1 -1
  26. package/dist/src/prompt/builder.js +28 -1
  27. package/dist/src/prompt/builder.js.map +1 -1
  28. package/package.json +9 -5
  29. package/public/dist/bundle.js +72 -77
  30. package/public/dist/bundle.js.map +4 -4
  31. package/public/index.html +1 -3
  32. package/public/js/{api.js → api.ts} +18 -12
  33. package/public/js/{constants.js → constants.ts} +44 -24
  34. package/public/js/features/{appname.js → appname.ts} +13 -12
  35. package/public/js/features/{chat.js → chat.ts} +46 -37
  36. package/public/js/features/{employees.js → employees.ts} +67 -38
  37. package/public/js/features/heartbeat.ts +90 -0
  38. package/public/js/features/{i18n.js → i18n.ts} +20 -20
  39. package/public/js/features/memory.ts +125 -0
  40. package/public/js/features/{settings.js → settings.ts} +125 -93
  41. package/public/js/features/{sidebar.js → sidebar.ts} +15 -16
  42. package/public/js/features/{skills.js → skills.ts} +29 -16
  43. package/public/js/features/{slash-commands.js → slash-commands.ts} +34 -29
  44. package/public/js/features/{theme.js → theme.ts} +4 -4
  45. package/public/js/{locale.js → locale.ts} +3 -3
  46. package/public/js/main.ts +280 -0
  47. package/public/js/{render.js → render.ts} +34 -107
  48. package/public/js/state.ts +38 -0
  49. package/public/js/{ui.js → ui.ts} +60 -63
  50. package/public/js/{ws.js → ws.ts} +46 -20
  51. package/public/locales/en.json +1 -0
  52. package/public/locales/ko.json +1 -0
  53. package/scripts/check-copilot-gap.ts +75 -0
  54. package/scripts/check-deps-offline.ts +98 -0
  55. package/scripts/fresh-install-smoke.ts +130 -0
  56. package/scripts/i18n-registry.ts +230 -0
  57. package/scripts/postinstall-guard.cjs +5 -0
  58. package/dist/bin/cli-claw.js +0 -96
  59. package/dist/bin/cli-claw.js.map +0 -1
  60. package/public/js/features/heartbeat.js +0 -80
  61. package/public/js/features/memory.js +0 -85
  62. package/public/js/main.js +0 -278
  63. package/public/js/state.js +0 -16
@@ -3,23 +3,29 @@ import { getPreferredLocale } from '../locale.js';
3
3
  import { t } from './i18n.js';
4
4
  import { api } from '../api.js';
5
5
 
6
- let cmdList = []; // { name, desc, args, category }[]
7
- let filtered = []; // currently filtered list
8
- let selectedIdx = -1; // -1 = none
6
+ interface SlashCommand {
7
+ name: string;
8
+ desc: string;
9
+ args?: string;
10
+ category?: string;
11
+ }
12
+
13
+ let cmdList: SlashCommand[] = [];
14
+ let filtered: SlashCommand[] = [];
15
+ let selectedIdx = -1;
9
16
  let isOpen = false;
10
- let closeTimer = null;
17
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
11
18
 
12
- // File paths like /Users/junny/... or /tmp/foo — not commands
13
- function looksLikeFilePath(text) {
19
+ function looksLikeFilePath(text: string): boolean {
14
20
  const after = String(text || '').slice(1).trim();
15
21
  const tok = after.split(/\s+/)[0] || '';
16
22
  return tok.includes('/') || tok.includes('\\');
17
23
  }
18
24
 
19
- const dropdown = () => document.getElementById('cmdDropdown');
20
- const input = () => document.getElementById('chatInput');
25
+ const dropdown = (): HTMLElement | null => document.getElementById('cmdDropdown');
26
+ const input = (): HTMLTextAreaElement | null => document.getElementById('chatInput') as HTMLTextAreaElement | null;
21
27
 
22
- function escapeHtml(str) {
28
+ function escapeHtml(str: string): string {
23
29
  return String(str || '')
24
30
  .replace(/&/g, '&amp;')
25
31
  .replace(/</g, '&lt;')
@@ -27,12 +33,12 @@ function escapeHtml(str) {
27
33
  .replace(/"/g, '&quot;');
28
34
  }
29
35
 
30
- function filterCommands(partial) {
36
+ function filterCommands(partial: string): SlashCommand[] {
31
37
  const prefix = String(partial || '').toLowerCase();
32
38
  return cmdList.filter(c => (`/${c.name}`).startsWith(prefix));
33
39
  }
34
40
 
35
- function showDropdown() {
41
+ function showDropdown(): void {
36
42
  const el = dropdown();
37
43
  const inp = input();
38
44
  if (!el || !inp) return;
@@ -45,18 +51,16 @@ function showDropdown() {
45
51
  el.style.display = 'block';
46
52
  requestAnimationFrame(() => el.classList.add('visible'));
47
53
  isOpen = true;
48
-
49
54
  inp.setAttribute('aria-expanded', 'true');
50
55
  }
51
56
 
52
- function render() {
57
+ function render(): void {
53
58
  const el = dropdown();
54
59
  const inp = input();
55
60
  if (!el || !inp) return;
56
61
 
57
62
  if (!filtered.length) {
58
63
  if (!inp.value.startsWith('/') || looksLikeFilePath(inp.value)) { close(); return; }
59
-
60
64
  el.innerHTML = `
61
65
  <div class="cmd-item cmd-empty" role="option" aria-disabled="true">
62
66
  ${t('cmd.noMatch')}
@@ -83,11 +87,11 @@ function render() {
83
87
  showDropdown();
84
88
  inp.setAttribute('aria-activedescendant', selectedIdx >= 0 ? `cmd-item-${selectedIdx}` : '');
85
89
 
86
- const selected = el.querySelector('.selected');
87
- if (selected) selected.scrollIntoView({ block: 'nearest' });
90
+ const selectedEl = el.querySelector('.selected');
91
+ if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
88
92
  }
89
93
 
90
- function applySelection(execute) {
94
+ function applySelection(execute: boolean): void {
91
95
  const cmd = filtered[selectedIdx];
92
96
  const inp = input();
93
97
  if (!cmd || !inp) { close(); return; }
@@ -104,19 +108,19 @@ function applySelection(execute) {
104
108
  inp.dispatchEvent(new Event('cmd-execute', { bubbles: true }));
105
109
  }
106
110
 
107
- export async function loadCommands() {
111
+ export async function loadCommands(): Promise<void> {
108
112
  try {
109
113
  const locale = getPreferredLocale();
110
114
  const url = `/api/commands?interface=web&locale=${encodeURIComponent(locale)}`;
111
- const data = await api(url, { headers: { 'Accept-Language': locale } });
115
+ const data = await api<SlashCommand[]>(url, { headers: { 'Accept-Language': locale } });
112
116
  cmdList = data || [];
113
117
  } catch (err) {
114
- console.warn('[slash-commands] loadCommands failed:', err.message);
118
+ console.warn('[slash-commands] loadCommands failed:', (err as Error).message);
115
119
  cmdList = [];
116
120
  }
117
121
  }
118
122
 
119
- export function close() {
123
+ export function close(): void {
120
124
  const el = dropdown();
121
125
 
122
126
  if (closeTimer) {
@@ -143,19 +147,18 @@ export function close() {
143
147
  }
144
148
  }
145
149
 
146
- export function update(text) {
150
+ export function update(text: string): void {
147
151
  const raw = String(text || '');
148
152
  if (!raw.startsWith('/') || raw.includes(' ') || raw.includes('\n') || looksLikeFilePath(raw)) {
149
153
  close();
150
154
  return;
151
155
  }
152
-
153
156
  filtered = filterCommands(raw);
154
157
  selectedIdx = filtered.length ? 0 : -1;
155
158
  render();
156
159
  }
157
160
 
158
- export function handleKeydown(e) {
161
+ export function handleKeydown(e: KeyboardEvent): boolean {
159
162
  if (!isOpen) {
160
163
  if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !e.isComposing) {
161
164
  update(input()?.value || '');
@@ -207,8 +210,9 @@ export function handleKeydown(e) {
207
210
  return false;
208
211
  }
209
212
 
210
- export function handleClick(e) {
211
- const item = e.target.closest('.cmd-item');
213
+ export function handleClick(e: Event): void {
214
+ const target = e.target as HTMLElement;
215
+ const item = target.closest('.cmd-item') as HTMLElement | null;
212
216
  if (!item) return;
213
217
  const idx = parseInt(item.dataset.index || '-1', 10);
214
218
  if (Number.isNaN(idx) || idx < 0) return;
@@ -216,16 +220,17 @@ export function handleClick(e) {
216
220
  applySelection(true);
217
221
  }
218
222
 
219
- export function handleOutsideClick(e) {
223
+ export function handleOutsideClick(e: Event): void {
220
224
  if (!isOpen) return;
221
225
  const el = dropdown();
222
226
  const inp = input();
223
227
  if (!el || !inp) return;
224
- if (!el.contains(e.target) && e.target !== inp) {
228
+ const target = e.target as Node;
229
+ if (!el.contains(target) && target !== inp) {
225
230
  close();
226
231
  }
227
232
  }
228
233
 
229
- export function isDropdownOpen() {
234
+ export function isDropdownOpen(): boolean {
230
235
  return isOpen;
231
236
  }
@@ -6,7 +6,7 @@ const STORAGE_KEY = 'theme';
6
6
  const HLJS_DARK = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css';
7
7
  const HLJS_LIGHT = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css';
8
8
 
9
- export function initTheme() {
9
+ export function initTheme(): void {
10
10
  // Detect: localStorage → OS preference → default dark
11
11
  const saved = localStorage.getItem(STORAGE_KEY);
12
12
  const prefer = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
@@ -16,14 +16,14 @@ export function initTheme() {
16
16
  document.getElementById('toggleTheme')?.addEventListener('click', toggleTheme);
17
17
  }
18
18
 
19
- function toggleTheme() {
19
+ function toggleTheme(): void {
20
20
  const current = document.documentElement.getAttribute('data-theme') || 'dark';
21
21
  const next = current === 'dark' ? 'light' : 'dark';
22
22
  applyTheme(next);
23
23
  localStorage.setItem(STORAGE_KEY, next);
24
24
  }
25
25
 
26
- function applyTheme(theme) {
26
+ function applyTheme(theme: string): void {
27
27
  document.documentElement.setAttribute('data-theme', theme);
28
28
 
29
29
  // Update button icon (SVG sun/moon swap)
@@ -33,7 +33,7 @@ function applyTheme(theme) {
33
33
  }
34
34
 
35
35
  // Swap highlight.js theme
36
- const hljsLink = document.getElementById('hljsTheme');
36
+ const hljsLink = document.getElementById('hljsTheme') as HTMLLinkElement | null;
37
37
  if (hljsLink) {
38
38
  hljsLink.href = theme === 'dark' ? HLJS_DARK : HLJS_LIGHT;
39
39
  }
@@ -1,8 +1,8 @@
1
1
  // ── Web Locale Helpers ──
2
2
 
3
- const LOCALE_KEYS = ['claw_locale', 'claw.locale'];
3
+ const LOCALE_KEYS: string[] = ['claw_locale', 'claw.locale'];
4
4
 
5
- export function getPreferredLocale() {
5
+ export function getPreferredLocale(): string {
6
6
  try {
7
7
  for (const key of LOCALE_KEYS) {
8
8
  const saved = localStorage.getItem(key);
@@ -12,7 +12,7 @@ export function getPreferredLocale() {
12
12
  return navigator.language || 'ko';
13
13
  }
14
14
 
15
- export function syncStoredLocale(locale) {
15
+ export function syncStoredLocale(locale: string): void {
16
16
  const value = String(locale || '').trim();
17
17
  if (!value) return;
18
18
  try {
@@ -0,0 +1,280 @@
1
+ // ── App Entry Point ──
2
+ // All event bindings happen here (no inline onclick in HTML)
3
+
4
+ // ── Global Error Boundary ──
5
+ window.addEventListener('unhandledrejection', (e) => {
6
+ console.error('[unhandled]', e.reason);
7
+ e.preventDefault();
8
+ });
9
+ window.addEventListener('error', (e) => {
10
+ console.error('[error]', e.message, e.filename, e.lineno);
11
+ });
12
+
13
+ import { connect } from './ws.js';
14
+ import { switchTab, handleSave, loadStats, loadMessages, loadMemory, initMsgCopy } from './ui.js';
15
+ import { sendMessage, handleKey, clearAttachedFiles, removeAttachedFile, clearChat, initDragDrop, initAutoResize } from './features/chat.js';
16
+ import {
17
+ loadCommands, update as updateSlashDropdown, handleKeydown as handleSlashKeydown,
18
+ handleClick as handleSlashClick, handleOutsideClick as handleSlashOutsideClick,
19
+ } from './features/slash-commands.js';
20
+ import { loadSkills, toggleSkill, filterSkills } from './features/skills.js';
21
+ import {
22
+ loadSettings, setPerm, handleModelSelect, applyCustomModel, onCliChange,
23
+ saveActiveCliSettings, savePerCli, updateSettings, openPromptModal,
24
+ closePromptModal, savePromptFromModal, syncMcpServers, installMcpGlobal,
25
+ loadCliStatus, setTelegram, saveTelegramSettings, saveFallbackOrder
26
+ } from './features/settings.js';
27
+ import {
28
+ loadEmployees, addEmployee, deleteEmployee, updateEmployee,
29
+ onEmpCliChange, onEmpRoleChange
30
+ } from './features/employees.js';
31
+ import {
32
+ openHeartbeatModal, closeHeartbeatModal, addHeartbeatJob,
33
+ removeHeartbeatJob, toggleHeartbeatJob, saveHeartbeatJobs,
34
+ initHeartbeatBadge
35
+ } from './features/heartbeat.js';
36
+ import {
37
+ openMemoryModal, closeMemoryModal, switchMemTab, setMemEnabled,
38
+ saveMemSettings, deleteMemFile, viewMemFile
39
+ } from './features/memory.js';
40
+ import { state } from './state.js';
41
+ import { loadCliRegistry, getCliKeys } from './constants.js';
42
+ import { initAppName } from './features/appname.js';
43
+ import { initSidebar, toggleLeft, toggleRight } from './features/sidebar.js';
44
+ import { initTheme } from './features/theme.js';
45
+ import { initI18n, setLang, getLang, t } from './features/i18n.js';
46
+
47
+ // ── Chat Actions ──
48
+ document.getElementById('btnSend')?.addEventListener('click', sendMessage);
49
+ const chatInput = document.getElementById('chatInput') as HTMLTextAreaElement | null;
50
+ chatInput?.addEventListener('keydown', (e) => {
51
+ if (handleSlashKeydown(e as KeyboardEvent)) return;
52
+ handleKey(e as KeyboardEvent);
53
+ });
54
+ let slashInputRaf = 0;
55
+ chatInput?.addEventListener('input', (e) => {
56
+ if ((e as InputEvent).isComposing) return;
57
+ if (slashInputRaf) cancelAnimationFrame(slashInputRaf);
58
+ slashInputRaf = requestAnimationFrame(() => {
59
+ updateSlashDropdown((e.target as HTMLTextAreaElement)?.value || '');
60
+ slashInputRaf = 0;
61
+ });
62
+ });
63
+ chatInput?.addEventListener('cmd-execute', () => {
64
+ void sendMessage();
65
+ });
66
+ document.getElementById('cmdDropdown')?.addEventListener('click', handleSlashClick);
67
+ document.addEventListener('click', handleSlashOutsideClick);
68
+ document.getElementById('filePreviewClear')?.addEventListener('click', clearAttachedFiles);
69
+ document.getElementById('filePreviewList')?.addEventListener('click', (e) => {
70
+ const btn = (e.target as HTMLElement)?.closest('[data-file-idx]') as HTMLElement | null;
71
+ if (btn) removeAttachedFile(+(btn.dataset.fileIdx || '0'));
72
+ });
73
+ document.querySelector('.btn-attach')?.addEventListener('click', () => {
74
+ (document.getElementById('fileInput') as HTMLInputElement | null)?.click();
75
+ });
76
+
77
+ // ── Left Sidebar ──
78
+ document.getElementById('memorySidebarBtn')?.addEventListener('click', openMemoryModal);
79
+ document.getElementById('btnClearChat')?.addEventListener('click', clearChat);
80
+ document.getElementById('hbSidebarBtn')?.addEventListener('click', openHeartbeatModal);
81
+
82
+ // Language toggle
83
+ document.getElementById('langToggle')?.addEventListener('click', async () => {
84
+ const next = getLang() === 'ko' ? 'en' : 'ko';
85
+ await setLang(next);
86
+ const btn = document.getElementById('langToggle');
87
+ if (btn) btn.textContent = `🌐 ${t('lang.' + next)}`;
88
+ // Reconnect WS with new locale
89
+ if (state.ws) { state.ws.close(); }
90
+ });
91
+
92
+ // ── Tab Bar (event delegation) ──
93
+ document.querySelector('.tab-bar')?.addEventListener('click', (e) => {
94
+ const btn = (e.target as HTMLElement)?.closest('.tab-btn') as HTMLElement | null;
95
+ if (!btn) return;
96
+ const tabs = [...(btn.parentElement?.children || [])].filter(c => c.classList.contains('tab-btn'));
97
+ const idx = tabs.indexOf(btn);
98
+ const names = ['agents', 'skills', 'settings'];
99
+ if (names[idx]) switchTab(names[idx], btn);
100
+ });
101
+
102
+ // ── Save Button ──
103
+ document.querySelector('.sidebar-save-bar .btn-save')?.addEventListener('click', handleSave);
104
+
105
+ // ── Agents Tab ──
106
+ document.getElementById('selCli')?.addEventListener('change', () => onCliChange());
107
+ document.getElementById('selModel')?.addEventListener('change', () => saveActiveCliSettings());
108
+ document.getElementById('selEffort')?.addEventListener('change', () => saveActiveCliSettings());
109
+ document.querySelector('[data-action="addEmployee"]')?.addEventListener('click', addEmployee);
110
+
111
+ // ── Employees (Event Delegation) ──
112
+ document.getElementById('employeesList')?.addEventListener('click', (e) => {
113
+ const del = (e.target as HTMLElement)?.closest('[data-emp-delete]') as HTMLElement | null;
114
+ if (del) { deleteEmployee(del.dataset.empDelete || ''); return; }
115
+ });
116
+ document.getElementById('employeesList')?.addEventListener('change', (e) => {
117
+ const tgt = e.target as HTMLSelectElement;
118
+ const name = tgt.closest('[data-emp-name]') as HTMLElement | null;
119
+ if (name) { updateEmployee(name.dataset.empName || '', { name: tgt.value }); return; }
120
+ const cli = tgt.closest('[data-emp-cli]') as HTMLElement | null;
121
+ if (cli) { onEmpCliChange(cli.dataset.empCli || '', tgt.value); return; }
122
+ const model = tgt.closest('[data-emp-model]') as HTMLElement | null;
123
+ if (model) {
124
+ if (tgt.value === '__custom__') {
125
+ const val = prompt(t('model.promptInput'));
126
+ if (val?.trim()) {
127
+ const opt = document.createElement('option');
128
+ opt.value = val.trim(); opt.textContent = val.trim();
129
+ const customOpt = tgt.querySelector('option[value="__custom__"]');
130
+ if (customOpt) tgt.insertBefore(opt, customOpt);
131
+ tgt.value = val.trim();
132
+ updateEmployee(model.dataset.empModel || '', { model: val.trim() });
133
+ } else { tgt.value = 'default'; }
134
+ } else { updateEmployee(model.dataset.empModel || '', { model: tgt.value }); }
135
+ return;
136
+ }
137
+ const role = tgt.closest('[data-emp-role]') as HTMLElement | null;
138
+ if (role) { onEmpRoleChange(role.dataset.empRole || '', tgt.value); return; }
139
+ const custom = tgt.closest('[data-emp-custom]') as HTMLElement | null;
140
+ if (custom) { updateEmployee(custom.dataset.empCustom || '', { role: tgt.value }); return; }
141
+ });
142
+
143
+ // ── Skills Tab (Event Delegation) ──
144
+ document.getElementById('skillsList')?.addEventListener('click', (e) => {
145
+ const toggle = (e.target as HTMLElement)?.closest('[data-skill-id]') as HTMLElement | null;
146
+ if (toggle) {
147
+ toggleSkill(toggle.dataset.skillId || '', toggle.dataset.skillEnabled === 'true');
148
+ }
149
+ });
150
+ // Skill filter buttons (event delegation)
151
+ document.querySelector('#tabSkills')?.addEventListener('click', (e) => {
152
+ const filterBtn = (e.target as HTMLElement)?.closest('.skill-filter') as HTMLElement | null;
153
+ if (filterBtn) {
154
+ const cat = filterBtn.dataset.filter || 'all';
155
+ filterSkills(cat, filterBtn);
156
+ }
157
+ });
158
+
159
+ // ── Settings Tab ──
160
+ document.querySelector('[data-action="openPrompt"]')?.addEventListener('click', openPromptModal);
161
+ document.getElementById('tgOff')?.addEventListener('click', () => setTelegram(false));
162
+ document.getElementById('tgOn')?.addEventListener('click', () => setTelegram(true));
163
+ document.getElementById('tgToken')?.addEventListener('change', saveTelegramSettings);
164
+ document.getElementById('tgChatIds')?.addEventListener('change', saveTelegramSettings);
165
+ document.getElementById('fallbackOrderList')?.addEventListener('change', saveFallbackOrder);
166
+
167
+ // Per-CLI model selects
168
+ function bindPerCliControlEvents(): void {
169
+ for (const cli of getCliKeys()) {
170
+ const cap = cli.charAt(0).toUpperCase() + cli.slice(1);
171
+ const sel = document.getElementById('model' + cap) as HTMLSelectElement | null;
172
+ if (sel) sel.addEventListener('change', function (this: HTMLSelectElement) { handleModelSelect(cli, this); });
173
+ const custom = document.getElementById('customModel' + cap) as HTMLInputElement | null;
174
+ if (custom) custom.addEventListener('change', function (this: HTMLInputElement) { applyCustomModel(cli, this); });
175
+ const effort = document.getElementById('effort' + cap);
176
+ if (effort) effort.addEventListener('change', savePerCli);
177
+ }
178
+ }
179
+
180
+ // MCP
181
+ document.querySelector('[data-action="syncMcp"]')?.addEventListener('click', syncMcpServers);
182
+ document.querySelector('[data-action="installMcp"]')?.addEventListener('click', installMcpGlobal);
183
+ document.querySelector('[data-action="refreshCli"]')?.addEventListener('click', () => loadCliStatus(true));
184
+ document.getElementById('cliStatusInterval')?.addEventListener('change', function (this: HTMLSelectElement) {
185
+ localStorage.setItem('cliStatusInterval', this.value);
186
+ });
187
+
188
+ // ── Prompt Modal ──
189
+ document.getElementById('promptModal')?.addEventListener('click', (e) => closePromptModal(e));
190
+ document.querySelector('#promptModal .modal-box')?.addEventListener('click', (e) => e.stopPropagation());
191
+ document.querySelector('[data-action="closePrompt"]')?.addEventListener('click', () => closePromptModal());
192
+ document.querySelector('[data-action="cancelPrompt"]')?.addEventListener('click', () => closePromptModal());
193
+ document.querySelector('[data-action="savePrompt"]')?.addEventListener('click', savePromptFromModal);
194
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePromptModal(); });
195
+
196
+ // ── Heartbeat Modal ──
197
+ document.getElementById('heartbeatModal')?.addEventListener('click', (e) => closeHeartbeatModal(e));
198
+ document.querySelector('#heartbeatModal .modal-box')?.addEventListener('click', (e) => e.stopPropagation());
199
+ document.querySelector('[data-action="closeHeartbeat"]')?.addEventListener('click', () => closeHeartbeatModal());
200
+ document.querySelector('[data-action="addHeartbeat"]')?.addEventListener('click', addHeartbeatJob);
201
+
202
+ // Heartbeat jobs (event delegation)
203
+ document.getElementById('hbJobsList')?.addEventListener('click', (e) => {
204
+ const toggle = (e.target as HTMLElement)?.closest('[data-hb-toggle]') as HTMLElement | null;
205
+ if (toggle) { toggleHeartbeatJob(+(toggle.dataset.hbToggle || '0')); return; }
206
+ const remove = (e.target as HTMLElement)?.closest('[data-hb-remove]') as HTMLElement | null;
207
+ if (remove) { removeHeartbeatJob(+(remove.dataset.hbRemove || '0')); return; }
208
+ });
209
+ document.getElementById('hbJobsList')?.addEventListener('change', (e) => {
210
+ const tgt = e.target as HTMLInputElement;
211
+ const name = tgt.closest('[data-hb-name]') as HTMLElement | null;
212
+ if (name) { state.heartbeatJobs[+(name.dataset.hbName || '0')].name = tgt.value; saveHeartbeatJobs(); return; }
213
+ const min = tgt.closest('[data-hb-minutes]') as HTMLElement | null;
214
+ if (min) { state.heartbeatJobs[+(min.dataset.hbMinutes || '0')].schedule = { kind: 'every', minutes: +tgt.value }; saveHeartbeatJobs(); return; }
215
+ const prompt = tgt.closest('[data-hb-prompt]') as HTMLElement | null;
216
+ if (prompt) { state.heartbeatJobs[+(prompt.dataset.hbPrompt || '0')].prompt = tgt.value; saveHeartbeatJobs(); return; }
217
+ });
218
+
219
+ // ── Memory Modal ──
220
+ document.getElementById('memoryModal')?.addEventListener('click', (e) => closeMemoryModal(e));
221
+ document.querySelector('#memoryModal .modal-box')?.addEventListener('click', (e) => e.stopPropagation());
222
+ document.querySelector('[data-action="closeMemory"]')?.addEventListener('click', () => closeMemoryModal());
223
+ document.getElementById('memTabBtnSettings')?.addEventListener('click', () => switchMemTab('settings'));
224
+ document.getElementById('memTabBtnFiles')?.addEventListener('click', () => switchMemTab('files'));
225
+ document.getElementById('memOn')?.addEventListener('click', () => setMemEnabled(true));
226
+ document.getElementById('memOff')?.addEventListener('click', () => setMemEnabled(false));
227
+ document.getElementById('memFlushEvery')?.addEventListener('change', saveMemSettings);
228
+ document.getElementById('memCli')?.addEventListener('change', saveMemSettings);
229
+ document.getElementById('memModel')?.addEventListener('change', saveMemSettings);
230
+ document.getElementById('memRetention')?.addEventListener('change', saveMemSettings);
231
+
232
+ // Memory files (event delegation)
233
+ document.getElementById('memFilesList')?.addEventListener('click', (e) => {
234
+ const del = (e.target as HTMLElement)?.closest('[data-mem-delete]') as HTMLElement | null;
235
+ if (del) { e.stopPropagation(); deleteMemFile(del.dataset.memDelete || ''); return; }
236
+ const view = (e.target as HTMLElement)?.closest('[data-mem-view]') as HTMLElement | null;
237
+ if (view) { viewMemFile(view.dataset.memView || ''); return; }
238
+ const back = (e.target as HTMLElement)?.closest('[data-mem-back]');
239
+ if (back) { openMemoryModal(); return; }
240
+ });
241
+
242
+ // ── Init ──
243
+ async function bootstrap(): Promise<void> {
244
+ await initI18n();
245
+ const langBtn = document.getElementById('langToggle');
246
+ if (langBtn) langBtn.textContent = `🌐 ${t('lang.' + getLang())}`;
247
+ await loadCliRegistry();
248
+ bindPerCliControlEvents();
249
+ connect();
250
+ initDragDrop();
251
+ initAutoResize();
252
+ await loadCommands();
253
+ await loadSettings();
254
+ loadCliStatus();
255
+ loadMemory();
256
+ // loadMessages() is handled by ws.js onopen (clear + reload)
257
+ loadEmployees();
258
+ initHeartbeatBadge();
259
+ initAppName();
260
+ initSidebar();
261
+ initTheme();
262
+ initMsgCopy();
263
+ }
264
+
265
+ void bootstrap().catch((err: unknown) => {
266
+ console.error('[bootstrap]', err);
267
+ });
268
+
269
+ // ── Keyboard: Escape closes modals ──────────────────
270
+ document.addEventListener('keydown', (e) => {
271
+ if (e.key === 'Escape') {
272
+ document.querySelectorAll('.modal-overlay.open').forEach(m => {
273
+ m.classList.remove('open');
274
+ });
275
+ }
276
+ });
277
+
278
+ // ── Mobile sidebar toggle (sidebar.js functions reuse) ──
279
+ document.getElementById('mobileMenuLeft')?.addEventListener('click', toggleLeft);
280
+ document.getElementById('mobileMenuRight')?.addEventListener('click', toggleRight);