cli-jaw 0.1.11 → 1.0.0

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
@@ -1,18 +1,19 @@
1
1
  // ── Render Helpers ──
2
2
  // Modular markdown rendering: marked.js + highlight.js + KaTeX + Mermaid
3
3
  // All libs loaded via CDN (defer), graceful fallback if unavailable
4
+
4
5
  import { t } from './features/i18n.js';
5
6
 
6
- export function escapeHtml(str) {
7
+ export function escapeHtml(str: string): string {
7
8
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
8
9
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9
10
  }
10
11
 
11
12
  // ── XSS sanitization ──
12
- export function sanitizeHtml(html) {
13
+ export function sanitizeHtml(html: string): string {
13
14
  if (typeof DOMPurify !== 'undefined') {
14
15
  return DOMPurify.sanitize(html, {
15
- USE_PROFILES: { html: true },
16
+ USE_PROFILES: { html: true, svg: true, svgFilters: true },
16
17
  FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
17
18
  FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover', 'onfocus', 'onblur'],
18
19
  ADD_TAGS: ['use'], // Mermaid SVG compatibility
@@ -26,23 +27,23 @@ export function sanitizeHtml(html) {
26
27
  }
27
28
 
28
29
  // ── Orchestration JSON stripping ──
29
- function stripOrchestration(text) {
30
+ function stripOrchestration(text: string): string {
30
31
  let cleaned = text.replace(/```json\n[\s\S]*?\n```/g, '');
31
32
  cleaned = cleaned.replace(/\{[\s\S]*"subtasks"\s*:\s*\[[\s\S]*?\]\s*\}/g, '').trim();
32
33
  return cleaned;
33
34
  }
34
35
 
35
36
  // ── KaTeX inline/block math ──
36
- function renderMath(html) {
37
+ function renderMath(html: string): string {
37
38
  if (typeof katex === 'undefined') return html;
38
39
  // Block math: $$...$$
39
- html = html.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => {
40
+ html = html.replace(/\$\$([\s\S]+?)\$\$/g, (_: string, tex: string) => {
40
41
  try {
41
42
  return katex.renderToString(tex.trim(), { displayMode: true, throwOnError: false });
42
43
  } catch { return `<code>${escapeHtml(tex)}</code>`; }
43
44
  });
44
45
  // Inline math: $...$ (avoid matching currency like $10)
45
- html = html.replace(/(?<!\$)\$(?!\$)([^\n$]+?)\$(?!\$)/g, (_, tex) => {
46
+ html = html.replace(/(?<!\$)\$(?!\$)([^\n$]+?)\$(?!\$)/g, (_: string, tex: string) => {
46
47
  try {
47
48
  return katex.renderToString(tex.trim(), { displayMode: false, throwOnError: false });
48
49
  } catch { return `<code>${escapeHtml(tex)}</code>`; }
@@ -51,89 +52,42 @@ function renderMath(html) {
51
52
  }
52
53
 
53
54
  // ── Mermaid deferred rendering ──
54
- // NOTE: No DOMPurify on mermaid SVG — mermaid.render() with securityLevel:'loose'
55
- // handles its own sanitization. DOMPurify strips foreignObject/style tags needed for text.
56
55
  let mermaidId = 0;
57
56
 
58
- function renderMermaidBlocks() {
57
+ function renderMermaidBlocks(): void {
59
58
  if (typeof mermaid === 'undefined') return;
60
59
  document.querySelectorAll('.mermaid-pending').forEach(async (el) => {
61
60
  el.classList.remove('mermaid-pending');
62
- const code = el.textContent;
61
+ const code = el.textContent || '';
63
62
  const id = `mermaid-${++mermaidId}`;
64
63
  try {
65
64
  const { svg } = await mermaid.render(id, code);
66
- el.innerHTML = svg;
65
+ el.innerHTML = sanitizeHtml(svg);
67
66
  el.classList.add('mermaid-rendered');
68
- // Store raw SVG for overlay (before zoom button is appended)
69
- const rawSvg = el.innerHTML;
70
- // Add zoom button
71
- const zoomBtn = document.createElement('button');
72
- zoomBtn.className = 'mermaid-zoom-btn';
73
- zoomBtn.textContent = '⛶';
74
- zoomBtn.title = 'Expand diagram';
75
- zoomBtn.addEventListener('click', () => openMermaidOverlay(rawSvg));
76
- el.appendChild(zoomBtn);
77
- } catch (err) {
78
- const errMsg = err?.message || err?.str || 'Unknown error';
67
+ } catch (err: unknown) {
68
+ const errMsg = (err as { message?: string; str?: string })?.message
69
+ || (err as { str?: string })?.str || 'Unknown error';
79
70
  el.innerHTML = `
80
71
  <div style="border:1px solid #ef4444;border-radius:6px;padding:8px;margin:4px 0">
81
- <div style="color:#ef4444;font-size:11px;margin-bottom:4px">⚠️ Mermaid 렌더링 실패</div>
72
+ <div style="color:#ef4444;font-size:11px;margin-bottom:4px">⚠️ ${escapeHtml(t('mermaid.renderFail') || 'Mermaid render failed')}</div>
82
73
  <div style="color:#fbbf24;font-size:10px;margin-bottom:6px">${escapeHtml(errMsg.slice(0, 200))}</div>
83
74
  <pre style="margin:0;font-size:11px;overflow-x:auto"><code>${escapeHtml(code)}</code></pre>
84
75
  </div>`;
85
76
  }
86
77
  });
87
78
  }
88
- // ── Mermaid popup overlay ──
89
- function openMermaidOverlay(svgHtml) {
90
- // Remove existing overlay if any
91
- document.getElementById('mermaidOverlay')?.remove();
92
-
93
- const overlay = document.createElement('div');
94
- overlay.id = 'mermaidOverlay';
95
- overlay.className = 'mermaid-overlay';
96
- overlay.innerHTML = `
97
- <div class="mermaid-overlay-backdrop"></div>
98
- <div class="mermaid-overlay-content">
99
- <button class="mermaid-overlay-close">✕</button>
100
- <div class="mermaid-overlay-svg">${svgHtml}</div>
101
- </div>`;
102
- document.body.appendChild(overlay);
103
-
104
- // Make SVG fill the popup
105
- const svgEl = overlay.querySelector('svg');
106
- if (svgEl) {
107
- svgEl.removeAttribute('width');
108
- svgEl.removeAttribute('height');
109
- svgEl.style.width = '100%';
110
- svgEl.style.height = 'auto';
111
- svgEl.style.maxHeight = '85vh';
112
- }
113
-
114
- const close = () => overlay.remove();
115
- overlay.querySelector('.mermaid-overlay-backdrop').addEventListener('click', close);
116
- overlay.querySelector('.mermaid-overlay-close').addEventListener('click', (e) => {
117
- e.stopPropagation();
118
- e.preventDefault();
119
- close();
120
- });
121
- document.addEventListener('keydown', function handler(e) {
122
- if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); }
123
- });
124
- }
125
79
 
126
80
  // ── marked.js configuration ──
127
81
  let markedReady = false;
128
82
 
129
- function ensureMarked() {
83
+ function ensureMarked(): boolean {
130
84
  if (markedReady) return true;
131
85
  if (typeof marked === 'undefined') return false;
132
86
 
133
87
  const renderer = new marked.Renderer();
134
88
 
135
89
  // Code blocks: highlight.js + mermaid detection
136
- renderer.code = function ({ text, lang }) {
90
+ renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
137
91
  // Mermaid
138
92
  if (lang === 'mermaid') {
139
93
  return `<div class="mermaid-container mermaid-pending">${escapeHtml(text)}</div>`;
@@ -164,25 +118,8 @@ function ensureMarked() {
164
118
  if (typeof window.mermaid !== 'undefined') {
165
119
  window.mermaid.initialize({
166
120
  startOnLoad: false,
167
- theme: 'base',
168
- securityLevel: 'loose',
169
- themeVariables: {
170
- darkMode: true,
171
- background: '#0f172a',
172
- primaryColor: '#1e3a5f',
173
- primaryTextColor: '#e2e8f0',
174
- primaryBorderColor: '#38bdf8',
175
- lineColor: '#94a3b8',
176
- secondaryColor: '#1e293b',
177
- tertiaryColor: '#0f172a',
178
- textColor: '#e2e8f0',
179
- mainBkg: '#1e293b',
180
- nodeBorder: '#38bdf8',
181
- clusterBkg: '#1e293b',
182
- titleColor: '#e2e8f0',
183
- edgeLabelBackground: '#1e293b',
184
- nodeTextColor: '#e2e8f0',
185
- },
121
+ theme: 'dark',
122
+ securityLevel: 'strict',
186
123
  });
187
124
  }
188
125
 
@@ -191,7 +128,7 @@ function ensureMarked() {
191
128
  }
192
129
 
193
130
  // ── Fallback regex renderer (CDN 실패 시) ──
194
- function renderFallback(text) {
131
+ function renderFallback(text: string): string {
195
132
  return escapeHtml(text)
196
133
  .replace(/`{3,}(\w*)\n([\s\S]*?)`{3,}/g, '<pre><code>$2</code></pre>')
197
134
  .replace(/`([^`]+)`/g, '<code>$1</code>')
@@ -203,54 +140,44 @@ function renderFallback(text) {
203
140
  }
204
141
 
205
142
  // ── Rehighlight all code blocks (call after hljs loads) ──
206
- export function rehighlightAll() {
143
+ export function rehighlightAll(): void {
207
144
  if (typeof hljs === 'undefined') return;
208
145
  document.querySelectorAll('.code-block-wrapper pre code').forEach(el => {
209
- if (el.dataset.highlighted === 'yes') return;
146
+ if ((el as HTMLElement).dataset.highlighted === 'yes') return;
210
147
  const lang = [...el.classList].find(c => c.startsWith('language-'))?.replace('language-', '');
211
- const raw = el.textContent;
148
+ const raw = el.textContent || '';
212
149
  try {
213
150
  if (lang && hljs.getLanguage(lang)) {
214
151
  el.innerHTML = hljs.highlight(raw, { language: lang }).value;
215
152
  } else {
216
153
  el.innerHTML = hljs.highlightAuto(raw).value;
217
154
  }
218
- el.dataset.highlighted = 'yes';
155
+ (el as HTMLElement).dataset.highlighted = 'yes';
219
156
  } catch { /* ignore */ }
220
157
  });
221
158
  }
222
159
 
223
160
  // Poll for hljs load and auto-rehighlight
224
- (function waitForHljs() {
161
+ (function waitForHljs(): void {
225
162
  if (typeof hljs !== 'undefined') { rehighlightAll(); return; }
226
163
  setTimeout(waitForHljs, 200);
227
164
  })();
228
165
 
229
- // Poll for mermaid load and render pending blocks
230
- (function waitForMermaid() {
231
- if (typeof mermaid !== 'undefined') {
232
- ensureMarked(); // ensure mermaid.initialize() runs
233
- renderMermaidBlocks();
234
- return;
235
- }
236
- setTimeout(waitForMermaid, 300);
237
- })();
238
-
239
166
  // ── Copy button event delegation (one-time setup) ──
240
167
  let copyDelegationReady = false;
241
168
 
242
- function ensureCopyDelegation() {
169
+ function ensureCopyDelegation(): void {
243
170
  if (copyDelegationReady) return;
244
171
  copyDelegationReady = true;
245
- document.addEventListener('click', (e) => {
246
- const label = e.target.closest('.code-lang-label');
172
+ document.addEventListener('click', (e: MouseEvent) => {
173
+ const label = (e.target as HTMLElement)?.closest('.code-lang-label') as HTMLElement | null;
247
174
  if (!label) return;
248
175
  const wrapper = label.closest('.code-block-wrapper');
249
176
  if (!wrapper) return;
250
177
  const codeEl = wrapper.querySelector('pre code');
251
178
  if (!codeEl) return;
252
- navigator.clipboard.writeText(codeEl.textContent).then(() => {
253
- const orig = label.textContent;
179
+ navigator.clipboard.writeText(codeEl.textContent || '').then(() => {
180
+ const orig = label.textContent || '';
254
181
  label.textContent = t('code.copied');
255
182
  label.classList.add('copied');
256
183
  setTimeout(() => {
@@ -262,13 +189,13 @@ function ensureCopyDelegation() {
262
189
  }
263
190
 
264
191
  // ── Main export ──
265
- export function renderMarkdown(text) {
192
+ export function renderMarkdown(text: string): string {
266
193
  const cleaned = stripOrchestration(text);
267
- if (!cleaned) return `<em style="color:var(--text-dim)">${t('orchestrator.dispatching')}</em>`;
194
+ if (!cleaned) return `<em style="color:var(--text-dim)">${escapeHtml(t('orchestrator.dispatching'))}</em>`;
268
195
 
269
- let html;
196
+ let html: string;
270
197
  if (ensureMarked()) {
271
- html = marked.parse(cleaned);
198
+ html = marked.parse(cleaned) as string;
272
199
  // Wrap tables for horizontal scrolling
273
200
  html = html.replace(/<table/g, '<div class="table-wrapper"><table').replace(/<\/table>/g, '</table></div>');
274
201
  } else {
@@ -0,0 +1,38 @@
1
+ // ── Shared State Module ──
2
+ // All modules import this to access/modify shared state.
3
+ // Object reference ensures mutations are seen across modules.
4
+
5
+ export interface HeartbeatJob {
6
+ id: string;
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ export interface CliStatusCache {
11
+ [cli: string]: unknown;
12
+ }
13
+
14
+ export interface AppState {
15
+ ws: WebSocket | null;
16
+ agentBusy: boolean;
17
+ employees: unknown[];
18
+ allSkills: unknown[];
19
+ currentSkillFilter: string;
20
+ currentAgentDiv: HTMLElement | null;
21
+ attachedFiles: File[];
22
+ heartbeatJobs: HeartbeatJob[];
23
+ cliStatusCache: CliStatusCache | null;
24
+ cliStatusTs: number;
25
+ }
26
+
27
+ export const state: AppState = {
28
+ ws: null,
29
+ agentBusy: false,
30
+ employees: [],
31
+ allSkills: [],
32
+ currentSkillFilter: 'all',
33
+ currentAgentDiv: null,
34
+ attachedFiles: [],
35
+ heartbeatJobs: [],
36
+ cliStatusCache: null,
37
+ cliStatusTs: 0,
38
+ };
@@ -5,43 +5,42 @@ import { getAppName } from './features/appname.js';
5
5
  import { t } from './features/i18n.js';
6
6
  import { api } from './api.js';
7
7
 
8
- export function setStatus(s) {
8
+ interface ToolLogEntry { icon: string; label: string; }
9
+ interface MessageItem { role: string; content: string; }
10
+ interface MemoryItem { key: string; value: string; }
11
+
12
+ export function setStatus(s: string): void {
9
13
  const badge = document.getElementById('statusBadge');
10
14
  const btn = document.getElementById('btnSend');
11
15
  state.agentBusy = s === 'running';
12
- document.getElementById('typingIndicator').classList.toggle('active', state.agentBusy);
16
+ document.getElementById('typingIndicator')?.classList.toggle('active', state.agentBusy);
13
17
  if (s === 'running') {
14
- badge.className = 'status-badge status-running';
15
- badge.textContent = ' running';
16
- btn.textContent = '■';
17
- btn.title = t('btn.stop');
18
- btn.classList.add('stop-mode');
18
+ if (badge) { badge.className = 'status-badge status-running'; badge.textContent = '⏳ running'; }
19
+ if (btn) { btn.textContent = '■'; btn.title = t('btn.stop'); btn.classList.add('stop-mode'); }
19
20
  } else {
20
- badge.className = 'status-badge status-idle';
21
- badge.textContent = ' idle';
22
- btn.textContent = '➤';
23
- btn.title = 'Send';
24
- btn.classList.remove('stop-mode');
21
+ if (badge) { badge.className = 'status-badge status-idle'; badge.textContent = '⚡ idle'; }
22
+ if (btn) { btn.textContent = '➤'; btn.title = 'Send'; btn.classList.remove('stop-mode'); }
25
23
  updateQueueBadge(0);
26
24
  }
27
25
  }
28
26
 
29
- export function updateQueueBadge(count) {
27
+ export function updateQueueBadge(count: number): void {
30
28
  let el = document.getElementById('queueBadge');
31
29
  if (!el) {
32
30
  el = document.createElement('span');
33
31
  el.id = 'queueBadge';
34
32
  el.style.cssText = 'position:absolute;top:-6px;right:-6px;background:#f80;color:#fff;border-radius:50%;font-size:11px;min-width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-weight:bold';
35
- document.getElementById('btnSend').parentElement.style.position = 'relative';
36
- document.getElementById('btnSend').style.position = 'relative';
37
- document.getElementById('btnSend').appendChild(el);
33
+ const sendBtn = document.getElementById('btnSend');
34
+ if (sendBtn?.parentElement) sendBtn.parentElement.style.position = 'relative';
35
+ if (sendBtn) { sendBtn.style.position = 'relative'; sendBtn.appendChild(el); }
38
36
  }
39
- el.textContent = count > 0 ? count : '';
37
+ el.textContent = count > 0 ? String(count) : '';
40
38
  el.style.display = count > 0 ? 'flex' : 'none';
41
39
  }
42
40
 
43
- export function addSystemMsg(text, extraClass, type) {
41
+ export function addSystemMsg(text: string, extraClass?: string, type?: string): void {
44
42
  const container = document.getElementById('chatMessages');
43
+ if (!container) return;
45
44
  const div = document.createElement('div');
46
45
  const typeClass = type ? ` msg-type-${type}` : '';
47
46
  div.className = 'msg msg-system' + typeClass + (extraClass ? ' ' + extraClass : '');
@@ -50,19 +49,19 @@ export function addSystemMsg(text, extraClass, type) {
50
49
  container.scrollTop = container.scrollHeight;
51
50
  }
52
51
 
53
- export function appendAgentText(text) {
52
+ export function appendAgentText(text: string): void {
54
53
  if (!text) return;
55
54
  if (!state.currentAgentDiv) {
56
55
  state.currentAgentDiv = addMessage('agent', '');
57
56
  }
58
- const content = state.currentAgentDiv.querySelector('.msg-content');
59
- content.textContent += text;
57
+ const content = (state.currentAgentDiv as HTMLElement)?.querySelector('.msg-content');
58
+ if (content) content.textContent += text;
60
59
  scrollToBottom();
61
60
  }
62
61
 
63
62
  let lastFinalizeTs = 0;
64
63
 
65
- export function finalizeAgent(text, toolLog) {
64
+ export function finalizeAgent(text: string, toolLog?: ToolLogEntry[]): void {
66
65
  // Guard: prevent double-render when both agent_done + orchestrate_done fire
67
66
  const now = Date.now();
68
67
  if (!state.currentAgentDiv && now - lastFinalizeTs < 500) return;
@@ -72,17 +71,16 @@ export function finalizeAgent(text, toolLog) {
72
71
  if (!state.currentAgentDiv) {
73
72
  state.currentAgentDiv = addMessage('agent', '');
74
73
  }
75
- const content = state.currentAgentDiv.querySelector('.msg-content');
76
- state.currentAgentDiv.dataset.rawText = text;
74
+ const content = (state.currentAgentDiv as HTMLElement)?.querySelector('.msg-content');
77
75
  let toolHtml = '';
78
76
  if (toolLog && toolLog.length > 0) {
79
- const counts = {};
80
- toolLog.forEach(t => { counts[t.icon] = (counts[t.icon] || 0) + 1; });
77
+ const counts: Record<string, number> = {};
78
+ toolLog.forEach(tl => { counts[tl.icon] = (counts[tl.icon] || 0) + 1; });
81
79
  const summaryParts = Object.entries(counts).map(([icon, n]) => `${icon}×${n}`).join(' ');
82
- const logLines = toolLog.map(t => `${t.icon} ${escapeHtml(t.label)}`).join('\n');
80
+ const logLines = toolLog.map(tl => `${tl.icon} ${escapeHtml(tl.label)}`).join('\n');
83
81
  toolHtml = `<details class="tool-summary"><summary>${summaryParts}</summary><div class="tool-log">${logLines}</div></details>`;
84
82
  }
85
- content.innerHTML = toolHtml + renderMarkdown(text);
83
+ if (content) content.innerHTML = toolHtml + renderMarkdown(text);
86
84
  }
87
85
  state.currentAgentDiv = null;
88
86
  lastFinalizeTs = Date.now();
@@ -90,29 +88,27 @@ export function finalizeAgent(text, toolLog) {
90
88
  loadStats();
91
89
  }
92
90
 
93
- export function addMessage(role, text) {
91
+ export function addMessage(role: string, text: string): HTMLDivElement {
94
92
  const container = document.getElementById('chatMessages');
95
93
  const div = document.createElement('div');
96
94
  div.className = `msg msg-${role}`;
97
- div.dataset.rawText = text;
98
95
  const rendered = renderMarkdown(text);
99
- const copyBtn = '<span class="msg-copy" title="Copy raw text"></span>';
100
- div.innerHTML = `<div class="msg-label">${role === 'user' ? t('msg.you') : getAppName()}</div><div class="msg-content">${rendered}</div>${copyBtn}`;
101
- container.appendChild(div);
96
+ div.innerHTML = `<div class="msg-label">${role === 'user' ? t('msg.you') : getAppName()}</div><div class="msg-content">${rendered}</div>`;
97
+ container?.appendChild(div);
102
98
  scrollToBottom();
103
99
  return div;
104
100
  }
105
101
 
106
- export function scrollToBottom() {
102
+ export function scrollToBottom(): void {
107
103
  const c = document.getElementById('chatMessages');
108
- c.scrollTop = c.scrollHeight;
104
+ if (c) c.scrollTop = c.scrollHeight;
109
105
  }
110
106
 
111
- export function switchTab(name, targetBtn) {
107
+ export function switchTab(name: string, targetBtn: Element): void {
112
108
  document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
113
109
  document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
114
- const tabMap = { agents: 'tabAgents', settings: 'tabSettings', skills: 'tabSkills' };
115
- document.getElementById(tabMap[name]).classList.add('active');
110
+ const tabMap: Record<string, string> = { agents: 'tabAgents', settings: 'tabSettings', skills: 'tabSkills' };
111
+ document.getElementById(tabMap[name])?.classList.add('active');
116
112
  if (targetBtn) targetBtn.classList.add('active');
117
113
  // Lazy-load tab content
118
114
  if (name === 'settings') { import('./features/settings.js').then(m => m.loadSettings()); }
@@ -120,8 +116,8 @@ export function switchTab(name, targetBtn) {
120
116
  if (name === 'skills') { import('./features/skills.js').then(m => m.loadSkills()); }
121
117
  }
122
118
 
123
- export function handleSave() {
124
- const isSettings = document.getElementById('tabSettings').classList.contains('active');
119
+ export function handleSave(): void {
120
+ const isSettings = document.getElementById('tabSettings')?.classList.contains('active');
125
121
  if (isSettings) {
126
122
  import('./features/settings.js').then(m => m.savePerCli());
127
123
  } else {
@@ -129,38 +125,24 @@ export function handleSave() {
129
125
  }
130
126
  }
131
127
 
132
- export async function loadStats() {
133
- const msgs = await api('/api/messages');
128
+ export async function loadStats(): Promise<void> {
129
+ const msgs = await api<MessageItem[]>('/api/messages');
134
130
  if (!msgs) return;
135
- document.getElementById('statMsgs').textContent = t('stat.messages', { count: msgs.length });
131
+ const el = document.getElementById('statMsgs');
132
+ if (el) el.textContent = t('stat.messages', { count: msgs.length });
136
133
  }
137
134
 
138
- export async function loadMessages() {
139
- const msgs = await api('/api/messages');
135
+ export async function loadMessages(): Promise<void> {
136
+ const msgs = await api<MessageItem[]>('/api/messages');
140
137
  if (!msgs) return;
141
138
  msgs.forEach(m => addMessage(m.role === 'assistant' ? 'agent' : m.role, m.content));
142
139
  }
143
140
 
144
- // ── Copy button delegation ──
145
- export function initMsgCopy() {
146
- document.getElementById('chatMessages').addEventListener('click', async (e) => {
147
- const btn = e.target.closest('.msg-copy');
148
- if (!btn) return;
149
- const msg = btn.closest('.msg');
150
- const raw = msg?.dataset?.rawText || msg?.querySelector('.msg-content')?.innerText || '';
151
- try {
152
- await navigator.clipboard.writeText(raw);
153
- btn.classList.add('copied');
154
- setTimeout(() => { btn.classList.remove('copied'); }, 1500);
155
- } catch { setTimeout(() => { }, 1500); }
156
- });
157
- }
158
-
159
- export async function loadMemory() {
141
+ export async function loadMemory(): Promise<void> {
160
142
  try {
161
- const items = await api('/api/memory');
143
+ const items = await api<MemoryItem[]>('/api/memory');
162
144
  const list = document.getElementById('memoryList');
163
- if (!list) return;
145
+ if (!list || !items) return;
164
146
  if (items.length === 0) {
165
147
  list.innerHTML = `<li style="color:var(--text-dim)">${t('mem.empty')}</li>`;
166
148
  return;
@@ -170,3 +152,18 @@ export async function loadMemory() {
170
152
  ).join('');
171
153
  } catch { }
172
154
  }
155
+
156
+ // ── Message copy delegation ──
157
+ export function initMsgCopy(): void {
158
+ document.getElementById('chatMessages')?.addEventListener('click', (e) => {
159
+ const msgContent = (e.target as HTMLElement)?.closest('.msg-content');
160
+ if (!msgContent) return;
161
+ // Double-click to copy (not single click)
162
+ });
163
+ document.getElementById('chatMessages')?.addEventListener('dblclick', (e) => {
164
+ const msgContent = (e.target as HTMLElement)?.closest('.msg-content') as HTMLElement | null;
165
+ if (!msgContent) return;
166
+ const text = msgContent.innerText || msgContent.textContent || '';
167
+ navigator.clipboard.writeText(text).catch(() => { });
168
+ });
169
+ }
@@ -3,18 +3,42 @@ import { state } from './state.js';
3
3
  import { setStatus, updateQueueBadge, addSystemMsg, appendAgentText, finalizeAgent, addMessage } from './ui.js';
4
4
  import { t, getLang } from './features/i18n.js';
5
5
 
6
+ interface WsMessage {
7
+ type: string;
8
+ running?: boolean;
9
+ status?: string;
10
+ agentId?: string;
11
+ phase?: string;
12
+ phaseLabel?: string;
13
+ pending?: number;
14
+ path?: string;
15
+ round?: number;
16
+ agentPhases?: { agent?: string; name?: string }[];
17
+ subtasks?: { agent?: string; name?: string }[];
18
+ action?: string;
19
+ icon?: string;
20
+ label?: string;
21
+ text?: string;
22
+ toolLog?: { icon: string; label: string }[];
23
+ from?: string;
24
+ to?: string;
25
+ source?: string;
26
+ role?: string;
27
+ content?: string;
28
+ }
29
+
6
30
  // Agent phase state (populated by agent_status events from orchestrator)
7
- const agentPhaseState = {};
31
+ const agentPhaseState: Record<string, { phase: string; phaseLabel: string }> = {};
8
32
 
9
- export function connect() {
33
+ export function connect(): void {
10
34
  state.ws = new WebSocket(`ws://${location.host}?lang=${getLang()}`);
11
- state.ws.onmessage = (e) => {
12
- const msg = JSON.parse(e.data);
35
+ state.ws.onmessage = (e: MessageEvent) => {
36
+ const msg: WsMessage = JSON.parse(e.data as string);
13
37
  if (msg.type === 'agent_status') {
14
38
  if (msg.running !== undefined) {
15
39
  setStatus(msg.running ? 'running' : 'idle');
16
40
  } else {
17
- setStatus(msg.status);
41
+ setStatus(msg.status || 'idle');
18
42
  }
19
43
  // Track per-agent phase for badge rendering
20
44
  if (msg.agentId && msg.phase) {
@@ -24,42 +48,44 @@ export function connect() {
24
48
  } else if (msg.type === 'queue_update') {
25
49
  updateQueueBadge(msg.pending || 0);
26
50
  } else if (msg.type === 'worklog_created') {
27
- addSystemMsg(`📋 Worklog: ${msg.path}`);
51
+ addSystemMsg(`📋 Worklog: ${msg.path || ''}`);
28
52
  } else if (msg.type === 'round_start') {
29
53
  const agents = (msg.agentPhases || msg.subtasks || []);
30
- const names = agents.map(a => a.agent || a.name).join(', ');
31
- addSystemMsg(t('ws.roundStart', { round: msg.round, count: agents.length, names }));
54
+ const names = agents.map(a => a.agent || a.name || '').join(', ');
55
+ addSystemMsg(t('ws.roundStart', { round: msg.round || 0, count: agents.length, names }));
32
56
  } else if (msg.type === 'round_done') {
33
57
  if (msg.action === 'complete') {
34
- addSystemMsg(t('ws.roundDone', { round: msg.round }));
58
+ addSystemMsg(t('ws.roundDone', { round: msg.round || 0 }));
35
59
  } else if (msg.action === 'next') {
36
- addSystemMsg(t('ws.roundNext', { round: msg.round }));
60
+ addSystemMsg(t('ws.roundNext', { round: msg.round || 0 }));
37
61
  } else {
38
- addSystemMsg(t('ws.roundRetry', { round: msg.round }));
62
+ addSystemMsg(t('ws.roundRetry', { round: msg.round || 0 }));
39
63
  }
40
64
  } else if (msg.type === 'agent_tool') {
41
- addSystemMsg(`${msg.icon} ${msg.label}`, 'tool-activity');
65
+ addSystemMsg(`${msg.icon || ''} ${msg.label || ''}`, 'tool-activity');
42
66
  } else if (msg.type === 'agent_output') {
43
- appendAgentText(msg.text);
67
+ appendAgentText(msg.text || '');
44
68
  } else if (msg.type === 'agent_fallback') {
45
- addSystemMsg(t('ws.fallback', { from: msg.from, to: msg.to }), 'tool-activity');
69
+ addSystemMsg(t('ws.fallback', { from: msg.from || '', to: msg.to || '' }), 'tool-activity');
46
70
  } else if (msg.type === 'agent_done') {
47
- finalizeAgent(msg.text, msg.toolLog);
71
+ finalizeAgent(msg.text || '', msg.toolLog);
48
72
  } else if (msg.type === 'orchestrate_done') {
49
- finalizeAgent(msg.text);
73
+ finalizeAgent(msg.text || '');
50
74
  } else if (msg.type === 'clear') {
51
- document.getElementById('chatMessages').innerHTML = '';
75
+ const el = document.getElementById('chatMessages');
76
+ if (el) el.innerHTML = '';
52
77
  } else if (msg.type === 'agent_added' || msg.type === 'agent_updated' || msg.type === 'agent_deleted') {
53
78
  import('./features/employees.js').then(m => m.loadEmployees());
54
79
  } else if (msg.type === 'new_message' && msg.source === 'telegram') {
55
- addMessage(msg.role === 'assistant' ? 'agent' : msg.role, msg.content);
80
+ addMessage(msg.role === 'assistant' ? 'agent' : (msg.role || 'user'), msg.content || '');
56
81
  }
57
82
  };
58
83
  state.ws.onopen = () => {
59
84
  console.log('[ws] connected');
60
85
  // Restore state: reload messages to stay in sync after reconnect
61
86
  import('./ui.js').then(m => {
62
- document.getElementById('chatMessages').innerHTML = '';
87
+ const el = document.getElementById('chatMessages');
88
+ if (el) el.innerHTML = '';
63
89
  m.loadMessages();
64
90
  m.setStatus('idle');
65
91
  });
@@ -71,6 +97,6 @@ export function connect() {
71
97
  };
72
98
  }
73
99
 
74
- export function getAgentPhase(agentId) {
100
+ export function getAgentPhase(agentId: string): { phase: string; phaseLabel: string } | null {
75
101
  return agentPhaseState[agentId] || null;
76
102
  }
@@ -177,5 +177,6 @@
177
177
  "code.copy": "Copy",
178
178
  "code.copied": "Copied ✓",
179
179
  "mcp.noServers": "(no servers configured)",
180
+ "mermaid.renderFail": "Mermaid render failed",
180
181
  "orchestrator.dispatching": "🎯 Dispatching tasks..."
181
182
  }
@@ -177,5 +177,6 @@
177
177
  "code.copy": "복사",
178
178
  "code.copied": "복사됨 ✓",
179
179
  "mcp.noServers": "(서버 미설정)",
180
+ "mermaid.renderFail": "Mermaid 렌더링 실패",
180
181
  "orchestrator.dispatching": "🎯 작업 분배 중..."
181
182
  }