cli-jaw 1.5.0 → 1.6.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 (230) hide show
  1. package/README.ko.md +26 -19
  2. package/README.md +50 -22
  3. package/README.zh-CN.md +26 -19
  4. package/dist/bin/cli-jaw.js +5 -1
  5. package/dist/bin/cli-jaw.js.map +1 -1
  6. package/dist/bin/commands/dispatch.js +57 -0
  7. package/dist/bin/commands/dispatch.js.map +1 -0
  8. package/dist/lib/mcp-sync.js +1 -1
  9. package/dist/lib/mcp-sync.js.map +1 -1
  10. package/dist/lib/upload.js +14 -1
  11. package/dist/lib/upload.js.map +1 -1
  12. package/dist/server.js +57 -42
  13. package/dist/server.js.map +1 -1
  14. package/dist/src/agent/events.js +72 -13
  15. package/dist/src/agent/events.js.map +1 -1
  16. package/dist/src/agent/spawn.js +71 -29
  17. package/dist/src/agent/spawn.js.map +1 -1
  18. package/dist/src/cli/acp-client.js +4 -2
  19. package/dist/src/cli/acp-client.js.map +1 -1
  20. package/dist/src/cli/commands.js +6 -6
  21. package/dist/src/cli/commands.js.map +1 -1
  22. package/dist/src/cli/handlers.js +11 -9
  23. package/dist/src/cli/handlers.js.map +1 -1
  24. package/dist/src/cli/registry.js +7 -1
  25. package/dist/src/cli/registry.js.map +1 -1
  26. package/dist/src/core/config.js +15 -8
  27. package/dist/src/core/config.js.map +1 -1
  28. package/dist/src/core/db.js +18 -3
  29. package/dist/src/core/db.js.map +1 -1
  30. package/dist/src/core/employees.js +34 -0
  31. package/dist/src/core/employees.js.map +1 -0
  32. package/dist/src/discord/bot.js +72 -9
  33. package/dist/src/discord/bot.js.map +1 -1
  34. package/dist/src/discord/commands.js +12 -8
  35. package/dist/src/discord/commands.js.map +1 -1
  36. package/dist/src/orchestrator/distribute.js +57 -35
  37. package/dist/src/orchestrator/distribute.js.map +1 -1
  38. package/dist/src/orchestrator/gateway.js +3 -1
  39. package/dist/src/orchestrator/gateway.js.map +1 -1
  40. package/dist/src/orchestrator/pipeline.js +120 -27
  41. package/dist/src/orchestrator/pipeline.js.map +1 -1
  42. package/dist/src/orchestrator/research.js +5 -5
  43. package/dist/src/orchestrator/research.js.map +1 -1
  44. package/dist/src/orchestrator/scope.js +55 -0
  45. package/dist/src/orchestrator/scope.js.map +1 -0
  46. package/dist/src/orchestrator/state-machine.js +23 -21
  47. package/dist/src/orchestrator/state-machine.js.map +1 -1
  48. package/dist/src/orchestrator/worker-registry.js +9 -2
  49. package/dist/src/orchestrator/worker-registry.js.map +1 -1
  50. package/dist/src/prompt/builder.js +76 -37
  51. package/dist/src/prompt/builder.js.map +1 -1
  52. package/dist/src/prompt/templates/a1-system.md +40 -0
  53. package/dist/src/prompt/templates/employee.md +12 -4
  54. package/dist/src/prompt/templates/orchestration.md +17 -1
  55. package/dist/src/telegram/bot.js +7 -1
  56. package/dist/src/telegram/bot.js.map +1 -1
  57. package/package.json +4 -2
  58. package/public/assets/fonts/GeistVF.woff2 +0 -0
  59. package/public/assets/fonts/JetBrainsMono-Variable.woff2 +0 -0
  60. package/public/assets/providers/claude-color.svg +1 -0
  61. package/public/assets/providers/claude.svg +1 -0
  62. package/public/assets/providers/copilot-color.svg +1 -0
  63. package/public/assets/providers/copilot.svg +1 -0
  64. package/public/assets/providers/discord.svg +1 -0
  65. package/public/assets/providers/gemini-color.svg +1 -0
  66. package/public/assets/providers/gemini.svg +1 -0
  67. package/public/assets/providers/openai.svg +1 -0
  68. package/public/assets/providers/opencode.svg +1 -0
  69. package/public/assets/providers/telegram.svg +1 -0
  70. package/public/css/chat.css +79 -51
  71. package/public/css/diagram.css +348 -0
  72. package/public/css/layout.css +38 -11
  73. package/public/css/markdown.css +64 -18
  74. package/public/css/modals.css +3 -3
  75. package/public/css/orc-state.css +9 -9
  76. package/public/css/sidebar.css +37 -3
  77. package/public/css/tool-ui.css +46 -11
  78. package/public/css/variables.css +63 -63
  79. package/public/dist/assets/api-Bbmmo0o1.js +1 -0
  80. package/public/dist/assets/architecture-YZFGNWBL-BxiHqtOH.js +1 -0
  81. package/public/dist/assets/architectureDiagram-Q4EWVU46-CJTGDw5p.js +1 -0
  82. package/public/dist/assets/blockDiagram-DXYQGD6D-Btl5Y-Er.js +1 -0
  83. package/public/dist/assets/c4Diagram-AHTNJAMY-sDfjW4ZF.js +1 -0
  84. package/public/dist/assets/classDiagram-6PBFFD2Q-ByCgggL2.js +1 -0
  85. package/public/dist/assets/classDiagram-v2-HSJHXN6E-t1amaXkU.js +1 -0
  86. package/public/dist/assets/constants-C8_0OtK2.js +1 -0
  87. package/public/dist/assets/cose-bilkent-S5V4N54A-eOSFGdaE.js +1 -0
  88. package/public/dist/assets/dagre-KV5264BT-OCB5j8Q6.js +1 -0
  89. package/public/dist/assets/diagram-5BDNPKRD-C-4fgK5P.js +1 -0
  90. package/public/dist/assets/diagram-G4DWMVQ6-C6vmHKDV.js +1 -0
  91. package/public/dist/assets/diagram-MMDJMWI5-BMCo_wmt.js +1 -0
  92. package/public/dist/assets/diagram-TYMM5635-DaCLdttJ.js +1 -0
  93. package/public/dist/assets/employees-D5n7mX5v.js +39 -0
  94. package/public/dist/assets/erDiagram-SMLLAGMA-BiTRdm5s.js +1 -0
  95. package/public/dist/assets/flowDiagram-DWJPFMVM-sv6jVi0d.js +1 -0
  96. package/public/dist/assets/ganttDiagram-T4ZO3ILL-jCP3OW23.js +1 -0
  97. package/public/dist/assets/gitGraph-7Q5UKJZL-BIeKLxpm.js +1 -0
  98. package/public/dist/assets/gitGraphDiagram-UUTBAWPF-CokylnbW.js +1 -0
  99. package/public/dist/assets/index-CLd0BsAu.js +49 -0
  100. package/public/dist/assets/index-D6ci1wCN.css +1 -0
  101. package/public/dist/assets/info-OMHHGYJF-CIEl5dWF.js +1 -0
  102. package/public/dist/assets/infoDiagram-42DDH7IO-BBnK1Bh2.js +1 -0
  103. package/public/dist/assets/ishikawaDiagram-UXIWVN3A-qXIz0VhS.js +1 -0
  104. package/public/dist/assets/journeyDiagram-VCZTEJTY-BdrOLof3.js +1 -0
  105. package/public/dist/assets/kanban-definition-6JOO6SKY-CcX7CE04.js +1 -0
  106. package/public/dist/assets/mermaid.core-BoxIvw7E.js +1 -0
  107. package/public/dist/assets/mindmap-definition-QFDTVHPH-CdXARskX.js +1 -0
  108. package/public/dist/assets/packet-4T2RLAQJ-Bz4ZwYZ3.js +1 -0
  109. package/public/dist/assets/pie-ZZUOXDRM-AtGL3wZ2.js +1 -0
  110. package/public/dist/assets/pieDiagram-DEJITSTG-B5SOq9Yr.js +1 -0
  111. package/public/dist/assets/quadrantDiagram-34T5L4WZ-D0uNWgpI.js +1 -0
  112. package/public/dist/assets/radar-PYXPWWZC-AbJSfeqB.js +1 -0
  113. package/public/dist/assets/render-C8N0rp4L.js +25 -0
  114. package/public/dist/assets/requirementDiagram-MS252O5E-62td6IQ-.js +1 -0
  115. package/public/dist/assets/sankeyDiagram-XADWPNL6-Crg_02iC.js +1 -0
  116. package/public/dist/assets/sequenceDiagram-FGHM5R23-fbCocZHj.js +1 -0
  117. package/public/dist/assets/settings-BJR-IGM5.js +41 -0
  118. package/public/dist/assets/settings-Dm6OnPmY.js +1 -0
  119. package/public/dist/assets/skills-D6jv9AIs.js +1 -0
  120. package/public/dist/assets/skills-uywskdFh.js +12 -0
  121. package/public/dist/assets/slash-commands-CCDonT40.js +1 -0
  122. package/public/dist/assets/{slash-commands-DMsE88bu.js → slash-commands-DWvL-VDU.js} +1 -1
  123. package/public/dist/assets/stateDiagram-FHFEXIEX-Gy3DhXxQ.js +1 -0
  124. package/public/dist/assets/stateDiagram-v2-QKLJ7IA2-DJc-FW9O.js +1 -0
  125. package/public/dist/assets/timeline-definition-GMOUNBTQ-B3cA9DgY.js +1 -0
  126. package/public/dist/assets/treeView-SZITEDCU-Cn58DIbB.js +1 -0
  127. package/public/dist/assets/treemap-W4RFUUIX-DPcYjDzH.js +1 -0
  128. package/public/dist/assets/ui-C1daR00l.js +1 -0
  129. package/public/dist/assets/ui-D-oFkXed.js +131 -0
  130. package/public/dist/assets/vendor-icons-1Ec7ZWcT.js +1 -0
  131. package/public/dist/assets/{vendor-mermaid-COidH9HB.js → vendor-mermaid-lvHqQdfg.js} +4 -4
  132. package/public/dist/assets/vennDiagram-DHZGUBPP-DkBfxilo.js +1 -0
  133. package/public/dist/assets/wardley-RL74JXVD-6Dg0PJ4H.js +1 -0
  134. package/public/dist/assets/wardleyDiagram-NUSXRM2D-Dsntl_vy.js +1 -0
  135. package/public/dist/assets/ws-Dnn8HG8B.js +2 -0
  136. package/public/dist/assets/xychartDiagram-5P7HB3ND-B_McB5GE.js +1 -0
  137. package/public/dist/index.html +63 -55
  138. package/public/index.html +62 -53
  139. package/public/js/constants.ts +8 -2
  140. package/public/js/diagram/iframe-renderer.ts +559 -0
  141. package/public/js/diagram/types.ts +129 -0
  142. package/public/js/diagram/widget-validator.ts +82 -0
  143. package/public/js/features/chat.ts +24 -12
  144. package/public/js/features/employees.ts +3 -2
  145. package/public/js/features/heartbeat.ts +4 -3
  146. package/public/js/features/memory.ts +4 -3
  147. package/public/js/features/process-block.ts +12 -11
  148. package/public/js/features/settings-cli-status.ts +10 -7
  149. package/public/js/features/settings-core.ts +13 -3
  150. package/public/js/features/settings-discord.ts +9 -0
  151. package/public/js/features/settings-mcp.ts +8 -7
  152. package/public/js/features/settings-stt.ts +4 -4
  153. package/public/js/features/settings-templates.ts +10 -8
  154. package/public/js/features/settings-types.ts +1 -1
  155. package/public/js/features/settings.ts +1 -1
  156. package/public/js/features/sidebar.ts +37 -7
  157. package/public/js/features/skills.ts +4 -3
  158. package/public/js/features/theme.ts +4 -0
  159. package/public/js/features/tool-ui.ts +6 -5
  160. package/public/js/features/voice-recorder.ts +2 -1
  161. package/public/js/icons.ts +257 -0
  162. package/public/js/main.ts +9 -3
  163. package/public/js/provider-icons.ts +88 -0
  164. package/public/js/render.ts +493 -30
  165. package/public/js/streaming-render.ts +3 -3
  166. package/public/js/ui.ts +38 -18
  167. package/public/js/ws.ts +17 -10
  168. package/public/locales/en.json +89 -88
  169. package/public/locales/ko.json +89 -88
  170. package/scripts/release-1.6.0.sh +69 -0
  171. package/scripts/release-preview.sh +1 -1
  172. package/dist/lib/token-keepalive.js +0 -34
  173. package/dist/lib/token-keepalive.js.map +0 -1
  174. package/public/dist/assets/api-BlPw3bUI.js +0 -1
  175. package/public/dist/assets/architecture-YZFGNWBL-BkS7SZQi.js +0 -1
  176. package/public/dist/assets/architectureDiagram-Q4EWVU46-Dl3iBKeR.js +0 -1
  177. package/public/dist/assets/blockDiagram-DXYQGD6D-DPr4D17p.js +0 -1
  178. package/public/dist/assets/c4Diagram-AHTNJAMY-CfoxJtDk.js +0 -1
  179. package/public/dist/assets/classDiagram-6PBFFD2Q-BOAdHnnB.js +0 -1
  180. package/public/dist/assets/classDiagram-v2-HSJHXN6E-B2QCJXWC.js +0 -1
  181. package/public/dist/assets/constants-DshMUJbo.js +0 -1
  182. package/public/dist/assets/cose-bilkent-S5V4N54A-CA14jk7w.js +0 -1
  183. package/public/dist/assets/dagre-KV5264BT-BVwNaSwC.js +0 -1
  184. package/public/dist/assets/diagram-5BDNPKRD-CHqIAvdc.js +0 -1
  185. package/public/dist/assets/diagram-G4DWMVQ6-DxvsCvTP.js +0 -1
  186. package/public/dist/assets/diagram-MMDJMWI5-DqOPO7dl.js +0 -1
  187. package/public/dist/assets/diagram-TYMM5635-C9xMWPQn.js +0 -1
  188. package/public/dist/assets/employees-CFRlsbHm.js +0 -39
  189. package/public/dist/assets/erDiagram-SMLLAGMA-BTmpfRkm.js +0 -1
  190. package/public/dist/assets/flowDiagram-DWJPFMVM-DZBSQAKA.js +0 -1
  191. package/public/dist/assets/ganttDiagram-T4ZO3ILL-TAEMCRbT.js +0 -1
  192. package/public/dist/assets/gitGraph-7Q5UKJZL-bbxndRNA.js +0 -1
  193. package/public/dist/assets/gitGraphDiagram-UUTBAWPF-CBxtx0MB.js +0 -1
  194. package/public/dist/assets/index-1Gg-6jeC.css +0 -1
  195. package/public/dist/assets/index-CQHqXjrK.js +0 -49
  196. package/public/dist/assets/info-OMHHGYJF-DRXq_Ywr.js +0 -1
  197. package/public/dist/assets/infoDiagram-42DDH7IO-C7FFwX4m.js +0 -1
  198. package/public/dist/assets/ishikawaDiagram-UXIWVN3A-DEZJE79j.js +0 -1
  199. package/public/dist/assets/journeyDiagram-VCZTEJTY-ZHLiY6GV.js +0 -1
  200. package/public/dist/assets/kanban-definition-6JOO6SKY-NP7pY7ML.js +0 -1
  201. package/public/dist/assets/mermaid.core-CHuluSlD.js +0 -1
  202. package/public/dist/assets/mindmap-definition-QFDTVHPH-Bd1OgfN_.js +0 -1
  203. package/public/dist/assets/packet-4T2RLAQJ-CGu8ST97.js +0 -1
  204. package/public/dist/assets/pie-ZZUOXDRM-CDG65mhS.js +0 -1
  205. package/public/dist/assets/pieDiagram-DEJITSTG-BbMBHLDM.js +0 -1
  206. package/public/dist/assets/quadrantDiagram-34T5L4WZ-CDCDgI1e.js +0 -1
  207. package/public/dist/assets/radar-PYXPWWZC-1gS-6ylu.js +0 -1
  208. package/public/dist/assets/render-YHvL5VM_.js +0 -6
  209. package/public/dist/assets/requirementDiagram-MS252O5E-DkmvOtYz.js +0 -1
  210. package/public/dist/assets/sankeyDiagram-XADWPNL6-GNcXIYsL.js +0 -1
  211. package/public/dist/assets/sequenceDiagram-FGHM5R23-BFrw2n6x.js +0 -1
  212. package/public/dist/assets/settings-BQF_u5px.js +0 -37
  213. package/public/dist/assets/settings-C5Q9IPjJ.js +0 -1
  214. package/public/dist/assets/skills-9Pd2rOw-.js +0 -1
  215. package/public/dist/assets/skills-BI7sQAdk.js +0 -12
  216. package/public/dist/assets/slash-commands-FMpJzlDB.js +0 -1
  217. package/public/dist/assets/stateDiagram-FHFEXIEX-BLgKnllT.js +0 -1
  218. package/public/dist/assets/stateDiagram-v2-QKLJ7IA2-CbzuWsSa.js +0 -1
  219. package/public/dist/assets/timeline-definition-GMOUNBTQ-CcZTJ3gL.js +0 -1
  220. package/public/dist/assets/treeView-SZITEDCU-BDqBsaH1.js +0 -1
  221. package/public/dist/assets/treemap-W4RFUUIX-BaWHA3Di.js +0 -1
  222. package/public/dist/assets/ui-BukgLHuh.js +0 -29
  223. package/public/dist/assets/ui-Bvz1JfTE.js +0 -1
  224. package/public/dist/assets/vennDiagram-DHZGUBPP-BWV_j1bh.js +0 -1
  225. package/public/dist/assets/wardley-RL74JXVD-C7gKA3d7.js +0 -1
  226. package/public/dist/assets/wardleyDiagram-NUSXRM2D-BUoq_wug.js +0 -1
  227. package/public/dist/assets/ws-S_AZgx7L.js +0 -2
  228. package/public/dist/assets/xychartDiagram-5P7HB3ND-PvT5Ec82.js +0 -1
  229. /package/public/dist/assets/{api-B8XKQ4OT.js → api-Ci-lgwRp.js} +0 -0
  230. /package/public/dist/assets/{locale-BjoAcbis.js → locale-DIXc-_34.js} +0 -0
@@ -0,0 +1,82 @@
1
+ // ── Widget HTML Validator ──
2
+ // Client-side validation for AI-generated diagram-html before iframe injection.
3
+ // Defense-in-depth layer on top of sandbox="allow-scripts" + CSP.
4
+
5
+ const CDN_ALLOWLIST = [
6
+ 'cdnjs.cloudflare.com',
7
+ 'cdn.jsdelivr.net',
8
+ 'unpkg.com',
9
+ 'esm.sh',
10
+ 'fonts.googleapis.com',
11
+ 'fonts.gstatic.com',
12
+ ];
13
+
14
+ const DANGEROUS_PATTERNS: readonly RegExp[] = [
15
+ /\beval\s*\(/,
16
+ /\bnew\s+Function\s*\(/,
17
+ /\bdocument\.cookie\b/,
18
+ /\bwindow\.opener\b/,
19
+ /\bwindow\.top\b/,
20
+ /\bparent\.postMessage\b(?!.*jaw-)/,
21
+ /\blocation\.href\s*=/,
22
+ /\bwindow\.location\b/,
23
+ /\bsetTimeout\s*\(\s*["'`]/,
24
+ /\bsetInterval\s*\(\s*["'`]/,
25
+ /\.constructor\s*\.\s*constructor/,
26
+ /\bdocument\.write\s*\(/,
27
+ /\binsertAdjacentHTML\s*\(/,
28
+ /\bimport\s*\(/,
29
+ ];
30
+
31
+ // No warn-only patterns currently — innerHTML removed (too common in onerror fallbacks)
32
+ const WARN_PATTERNS: readonly RegExp[] = [];
33
+
34
+ export interface ValidationResult {
35
+ valid: boolean;
36
+ reason?: string;
37
+ warnings: string[];
38
+ }
39
+
40
+ export function validateWidgetHtml(html: string): ValidationResult {
41
+ const warnings: string[] = [];
42
+
43
+ // 1. Size cap (redundant with activateWidgets but defense-in-depth)
44
+ if (html.length > 524_288) {
45
+ return { valid: false, reason: 'Payload too large (>512KB)', warnings };
46
+ }
47
+
48
+ // 2. External URL validation — only allowlisted CDN domains
49
+ const urlPattern = /(?:src|href)\s*=\s*["']https?:\/\/([^/"']+)/gi;
50
+ let match;
51
+ while ((match = urlPattern.exec(html)) !== null) {
52
+ const domain = match[1];
53
+ if (!CDN_ALLOWLIST.some(a => domain === a || domain.endsWith('.' + a))) {
54
+ return { valid: false, reason: `Blocked domain: ${domain}`, warnings };
55
+ }
56
+ }
57
+
58
+ // 3. CSS url() with external resources
59
+ const cssUrlPattern = /url\s*\(\s*['"]?https?:\/\/([^)'"]+)/gi;
60
+ while ((match = cssUrlPattern.exec(html)) !== null) {
61
+ const domain = match[1].split('/')[0];
62
+ if (!CDN_ALLOWLIST.some(a => domain === a || domain.endsWith('.' + a))) {
63
+ warnings.push(`CSS url() references external domain: ${domain}`);
64
+ }
65
+ }
66
+
67
+ // 4. Dangerous patterns — block
68
+ for (const pattern of DANGEROUS_PATTERNS) {
69
+ if (pattern.test(html)) {
70
+ return { valid: false, reason: `Dangerous pattern: ${pattern.source}`, warnings };
71
+ }
72
+ }
73
+
74
+ // 5. Warning-only patterns
75
+ for (const pattern of WARN_PATTERNS) {
76
+ if (pattern.test(html)) {
77
+ warnings.push(`DOM sink detected: ${pattern.source}`);
78
+ }
79
+ }
80
+
81
+ return { valid: true, warnings };
82
+ }
@@ -8,12 +8,18 @@ import { api, apiJson, apiFire } from '../api.js';
8
8
  import { escapeHtml } from '../render.js';
9
9
  import { getVirtualScroll } from '../virtual-scroll.js';
10
10
  import { clearCache } from './idb-cache.js';
11
+ import { ICONS } from '../icons.js';
11
12
 
12
13
  let activeObjectURLs: string[] = [];
13
14
 
14
15
  interface CommandResult { code?: string; text?: string; type?: string; }
15
16
  interface MessageResult { queued?: boolean; pending?: number; continued?: boolean; error?: string; }
16
17
 
18
+ function getCommandTimeoutMs(text: string): number {
19
+ // Native compaction can take materially longer than the default command round-trip.
20
+ return /^\/compact(?:\s|$)/i.test(String(text || '').trim()) ? 5 * 60 * 1000 : 10_000;
21
+ }
22
+
17
23
  export async function sendMessage(): Promise<void> {
18
24
  const input = document.getElementById('chatInput') as HTMLTextAreaElement | null;
19
25
  const btn = document.getElementById('btnSend');
@@ -41,12 +47,13 @@ export async function sendMessage(): Promise<void> {
41
47
  slashCmd.close();
42
48
  try {
43
49
  let signal: AbortSignal; let timer: ReturnType<typeof setTimeout> | undefined;
50
+ const timeoutMs = getCommandTimeoutMs(text);
44
51
  if (typeof AbortSignal?.timeout === 'function') {
45
- signal = AbortSignal.timeout(10000);
52
+ signal = AbortSignal.timeout(timeoutMs);
46
53
  } else {
47
54
  const ac = new AbortController();
48
55
  signal = ac.signal;
49
- timer = setTimeout(() => ac.abort(), 10000);
56
+ timer = setTimeout(() => ac.abort(), timeoutMs);
50
57
  }
51
58
  const locale = getPreferredLocale();
52
59
  const res = await fetch('/api/command', {
@@ -72,7 +79,7 @@ export async function sendMessage(): Promise<void> {
72
79
  const chatEl = document.getElementById('chatMessages');
73
80
  if (chatEl) chatEl.innerHTML = '';
74
81
  }
75
- if (result?.text) addSystemMsg(result.text, '', result.type);
82
+ if (result?.text) addSystemMsg(escapeHtml(result.text), '', result.type);
76
83
  } catch (err) {
77
84
  addSystemMsg(t('chat.cmd.fail', { msg: (err as Error).message }), '', 'error');
78
85
  }
@@ -81,7 +88,7 @@ export async function sendMessage(): Promise<void> {
81
88
 
82
89
  if (state.attachedFiles.length) {
83
90
  const names = state.attachedFiles.map((f: File) => f.name).join(', ');
84
- const displayMsg = `[📎 ${names}] ${text}`;
91
+ const displayMsg = `📎 [${names}] ${text}`;
85
92
  addMessage('user', displayMsg);
86
93
  input.value = '';
87
94
  resetInputHeight();
@@ -107,7 +114,7 @@ export async function sendMessage(): Promise<void> {
107
114
  });
108
115
  const data: MessageResult = await res.json().catch(() => ({}));
109
116
  if (!res.ok) {
110
- addSystemMsg(`❌ ${data.error || t('chat.requestFail', { status: res.status })}`, '', 'error');
117
+ addSystemMsg(`${ICONS.error} ${escapeHtml(data.error || t('chat.requestFail', { status: res.status }))}`, '', 'error');
111
118
  return;
112
119
  }
113
120
  if (data.queued) {
@@ -182,8 +189,8 @@ function renderFilePreview(): void {
182
189
  }
183
190
  return `<div class="file-chip">
184
191
  ${thumb}
185
- <span class="file-chip-name">📎 ${escapeHtml(f.name)} (${size}KB)</span>
186
- <button class="file-chip-remove" data-file-idx="${i}" title="Remove">✕</button>
192
+ <span class="file-chip-name">${ICONS.paperclip} ${escapeHtml(f.name)} (${size}KB)</span>
193
+ <button class="file-chip-remove" data-file-idx="${i}" title="Remove">${ICONS.close}</button>
187
194
  </div>`;
188
195
  }).join('');
189
196
  }
@@ -198,10 +205,15 @@ export async function clearChat(): Promise<void> {
198
205
  clearCache().catch(() => {});
199
206
  }
200
207
 
201
- // ── Auto-resize textarea ──
208
+ // ── Auto-resize textarea (RAF-batched to avoid blocking input) ──
209
+ let resizeRaf = 0;
202
210
  function autoResize(el: HTMLTextAreaElement): void {
203
- el.style.height = 'auto';
204
- el.style.height = el.scrollHeight + 'px';
211
+ if (resizeRaf) return;
212
+ resizeRaf = requestAnimationFrame(() => {
213
+ resizeRaf = 0;
214
+ el.style.height = 'auto';
215
+ el.style.height = el.scrollHeight + 'px';
216
+ });
205
217
  }
206
218
 
207
219
  export function initAutoResize(): void {
@@ -280,7 +292,7 @@ export async function sendVoiceToServer(blob: Blob, ext: string, mime: string):
280
292
 
281
293
  // Build user-facing display message
282
294
  const displayParts: string[] = ['🎤 [음성 메시지]'];
283
- if (pendingFiles.length) displayParts.push(`[📎 ${pendingFiles.map(f => f.name).join(', ')}]`);
295
+ if (pendingFiles.length) displayParts.push(`📎 [${pendingFiles.map(f => f.name).join(', ')}]`);
284
296
  if (pendingText) displayParts.push(pendingText);
285
297
  addMessage('user', displayParts.join(' '));
286
298
 
@@ -306,7 +318,7 @@ export async function sendVoiceToServer(blob: Blob, ext: string, mime: string):
306
318
  const sttResult = await sttRes.json().catch(() => null);
307
319
  if (!sttResult?.text) throw new Error('Empty STT result');
308
320
 
309
- addSystemMsg(`🎤 STT (${sttResult.engine}, ${sttResult.elapsed?.toFixed(1)}s): "${sttResult.text.slice(0, 100)}"`, '', 'info');
321
+ addSystemMsg(`${ICONS.mic} STT (${escapeHtml(sttResult.engine || '')}, ${sttResult.elapsed?.toFixed(1)}s): "${escapeHtml(sttResult.text.slice(0, 100))}"`, '', 'info');
310
322
 
311
323
  // Step 2: Upload pending files (if any)
312
324
  let filePaths: string[] = [];
@@ -6,6 +6,7 @@ import { escapeHtml } from '../render.js';
6
6
  import { getAgentPhase } from '../ws.js';
7
7
  import { t } from './i18n.js';
8
8
  import { api, apiJson, apiFire } from '../api.js';
9
+ import { ICONS } from '../icons.js';
9
10
 
10
11
  interface Employee {
11
12
  id: string;
@@ -80,7 +81,7 @@ export function renderEmployees(): void {
80
81
  <input style="flex:1;background:none;border:none;color:var(--text);font-size:12px;font-weight:600;font-family:inherit;outline:none"
81
82
  value="${escapeHtml(a.name || 'Agent')}"
82
83
  data-emp-name="${escapeHtml(a.id)}">
83
- <button style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:12px" data-emp-delete="${escapeHtml(a.id)}" title="${t('emp.delete')}">✕</button>
84
+ <button style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:12px" data-emp-delete="${escapeHtml(a.id)}" title="${t('emp.delete')}">${ICONS.close}</button>
84
85
  </div>
85
86
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:4px">
86
87
  <div>
@@ -108,7 +109,7 @@ export function renderEmployees(): void {
108
109
  placeholder="${t('emp.customRole')}">${isCustom ? escapeHtml(a.role || '') : ''}</textarea>
109
110
  </div>
110
111
  <div style="margin-top:4px;font-size:10px;display:flex;align-items:center;gap:6px">
111
- <span style="color:${a.status === 'running' ? '#fbbf24' : 'var(--green)'}">● ${escapeHtml(a.status || 'idle')}</span>
112
+ <span style="color:${a.status === 'running' ? '#fbbf24' : 'var(--green)'}"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:currentColor;vertical-align:middle;margin-right:4px"></span>${escapeHtml(a.status || 'idle')}</span>
112
113
  ${phaseBadge}
113
114
  </div>
114
115
  </div>`;
@@ -4,6 +4,7 @@ import type { HeartbeatJob, HeartbeatSchedule } from '../state.js';
4
4
  import { t } from './i18n.js';
5
5
  import { api, apiJson } from '../api.js';
6
6
  import { escapeHtml } from '../render.js';
7
+ import { ICONS } from '../icons.js';
7
8
  import {
8
9
  validateHeartbeatScheduleInput,
9
10
  type HeartbeatScheduleValidationCode,
@@ -55,7 +56,7 @@ export function renderHeartbeatJobs(): void {
55
56
  data-hb-name="${i}">
56
57
  <button class="hb-toggle ${job.enabled ? 'on' : 'off'}"
57
58
  data-hb-toggle="${i}" aria-label="${escapeHtml(String(job.name || 'job') + ' toggle')}"></button>
58
- <button class="hb-del" data-hb-remove="${i}">✕</button>
59
+ <button class="hb-del" data-hb-remove="${i}">${ICONS.close}</button>
59
60
  </div>
60
61
  <div class="hb-job-schedule">
61
62
  <select data-hb-kind="${i}">
@@ -76,7 +77,7 @@ export function renderHeartbeatJobs(): void {
76
77
  }
77
78
  const active = jobs.filter(j => j.enabled).length;
78
79
  const btn = document.getElementById('hbSidebarBtn');
79
- if (btn) btn.textContent = `💓 Heartbeat (${active})`;
80
+ if (btn) btn.innerHTML = `${ICONS.heartPulse} Heartbeat (${active})`;
80
81
  }
81
82
 
82
83
  export function addHeartbeatJob(): void {
@@ -127,7 +128,7 @@ export async function initHeartbeatBadge(): Promise<void> {
127
128
  const d = await api<HeartbeatData>('/api/heartbeat');
128
129
  const active = (d?.jobs || []).map(normalizeHeartbeatJob).filter(j => j.enabled).length;
129
130
  const btn = document.getElementById('hbSidebarBtn');
130
- if (btn) btn.textContent = `💓 Heartbeat (${active})`;
131
+ if (btn) btn.innerHTML = `${ICONS.heartPulse} Heartbeat (${active})`;
131
132
  } catch { /* ignore */ }
132
133
  }
133
134
 
@@ -1,6 +1,7 @@
1
1
  // ── Memory Feature ──
2
2
  import { escapeHtml } from '../render.js';
3
3
  import { api, apiJson } from '../api.js';
4
+ import { ICONS } from '../icons.js';
4
5
 
5
6
  interface MemoryFile {
6
7
  name: string;
@@ -74,7 +75,7 @@ function syncSidebarBadge(status: AdvancedMemoryStatus | null, basicCount: numbe
74
75
  : status?.state === 'not_initialized'
75
76
  ? 'Indexing'
76
77
  : (status?.state || `(${basicCount})`);
77
- sideBtn.textContent = `🧠 Memory · ${state}`;
78
+ sideBtn.innerHTML = `${ICONS.brain} Memory · ${escapeHtml(state)}`;
78
79
  }
79
80
 
80
81
  function renderStatusBanner(status: AdvancedMemoryStatus | null) {
@@ -174,7 +175,7 @@ function renderBasicFiles(files: MemoryFile[]) {
174
175
  <span style="font-size:12px;font-family:monospace">${escapeHtml(f.name)}</span>
175
176
  <span style="font-size:10px;color:var(--accent);margin-left:6px">${f.entries} entries</span>
176
177
  </div>
177
- <button data-mem-delete="${escapeHtml(f.name)}" style="background:none;border:none;color:#f55;cursor:pointer;font-size:14px">🗑️</button>
178
+ <button data-mem-delete="${escapeHtml(f.name)}" style="background:none;border:none;color:#f55;cursor:pointer;font-size:14px">${ICONS.trash}</button>
178
179
  </div>
179
180
  `).join('');
180
181
  }
@@ -331,7 +332,7 @@ export async function viewMemFile(name: string): Promise<void> {
331
332
  container.innerHTML = `
332
333
  <div style="margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
333
334
  <span style="font-size:12px;font-weight:600">${escapeHtml(data.name)}</span>
334
- <button data-mem-back style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:11px">← back</button>
335
+ <button data-mem-back style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:11px">${ICONS.arrowLeft} back</button>
335
336
  </div>
336
337
  <pre style="background:var(--bg);padding:8px;border-radius:4px;font-size:11px;white-space:pre-wrap;max-height:50vh;overflow-y:auto;color:var(--text)">${escapeHtml(data.content)}</pre>
337
338
  `;
@@ -2,6 +2,7 @@
2
2
  // Collapsible panel showing tool/thinking activity during agent responses.
3
3
 
4
4
  import { escapeHtml } from '../render.js';
5
+ import { ICONS } from '../icons.js';
5
6
 
6
7
  export interface ProcessStep {
7
8
  id: string;
@@ -23,13 +24,13 @@ export interface ProcessBlockState {
23
24
  function buildSummaryText(steps: ProcessStep[]): string {
24
25
  const counts: Record<string, number> = {};
25
26
  for (const s of steps) {
26
- const key = s.type === 'thinking' ? '💭 Thinking'
27
- : s.type === 'search' ? '🔍 Search'
28
- : '🔧 Tool';
27
+ const key = s.type === 'thinking' ? `${ICONS.thinking} Thinking`
28
+ : s.type === 'search' ? `${ICONS.search} Search`
29
+ : `${ICONS.tool} Tool`;
29
30
  counts[key] = (counts[key] || 0) + 1;
30
31
  }
31
32
  return Object.entries(counts)
32
- .map(([k, n]) => n > 1 ? `${k}×${n}` : k)
33
+ .map(([k, n]) => n > 1 ? `${k}&times;${n}` : k)
33
34
  .join(' + ');
34
35
  }
35
36
 
@@ -68,7 +69,7 @@ function renderStep(step: ProcessStep): string {
68
69
  <span class="process-step-label">${label}</span>
69
70
  ${snippetHtml}
70
71
  </span>
71
- <span class="process-step-chevron">▸</span>
72
+ <span class="process-step-chevron">${ICONS.chevronRight}</span>
72
73
  </button>
73
74
  <div class="process-step-details collapsed" id="${detailId}">
74
75
  <pre class="process-step-full">${escapeHtml(detail)}</pre>
@@ -87,9 +88,9 @@ function blockShell(summaryText = '', collapsed = false): string {
87
88
  return `<div class="process-block${collapsed ? ' collapsed' : ''}">
88
89
  <button class="process-summary" aria-expanded="${collapsed ? 'false' : 'true'}">
89
90
  <span class="process-dot ${collapsed ? 'done' : 'running'}"></span>
90
- <span class="process-summary-text">${escapeHtml(summaryText)}</span>
91
+ <span class="process-summary-text">${summaryText}</span>
91
92
  <span class="process-duration"></span>
92
- <span class="process-chevron">${collapsed ? '▸' : '▾'}</span>
93
+ <span class="process-chevron">${collapsed ? ICONS.chevronRight : ICONS.chevronDown}</span>
93
94
  </button>
94
95
  <div class="process-details">
95
96
  <div class="process-steps-inner"></div>
@@ -106,7 +107,7 @@ function toggleStepDetails(toggle: HTMLElement): void {
106
107
  details.classList.toggle('collapsed', !expanding);
107
108
  wrapper.classList.toggle('expanded', expanding);
108
109
  toggle.setAttribute('aria-expanded', expanding ? 'true' : 'false');
109
- if (chevron) chevron.textContent = expanding ? '▾' : '▸';
110
+ if (chevron) chevron.innerHTML = expanding ? ICONS.chevronDown : ICONS.chevronRight;
110
111
  }
111
112
 
112
113
  export function bindProcessBlockInteractions(root: HTMLElement): void {
@@ -129,7 +130,7 @@ export function bindProcessBlockInteractions(root: HTMLElement): void {
129
130
  block.classList.toggle('collapsed', !expanding);
130
131
  summary.setAttribute('aria-expanded', expanding ? 'true' : 'false');
131
132
  const chevron = summary.querySelector('.process-chevron');
132
- if (chevron) chevron.textContent = expanding ? '▾' : '▸';
133
+ if (chevron) chevron.innerHTML = expanding ? ICONS.chevronDown : ICONS.chevronRight;
133
134
  }
134
135
  });
135
136
  root.dataset.processBlockBound = '1';
@@ -153,7 +154,7 @@ export function buildProcessBlockHtml(steps: ProcessStep[], collapsed = true): s
153
154
 
154
155
  function updateSummary(pb: ProcessBlockState): void {
155
156
  const summaryText = pb.element.querySelector('.process-summary-text');
156
- if (summaryText) summaryText.textContent = buildSummaryText(pb.steps);
157
+ if (summaryText) summaryText.innerHTML = buildSummaryText(pb.steps);
157
158
 
158
159
  const anyRunning = pb.steps.some(s => s.status === 'running');
159
160
  const dot = pb.element.querySelector('.process-dot');
@@ -223,7 +224,7 @@ export function collapseBlock(pb: ProcessBlockState): void {
223
224
  const btn = pb.element.querySelector('.process-summary');
224
225
  if (btn) btn.setAttribute('aria-expanded', 'false');
225
226
  const chevron = pb.element.querySelector('.process-chevron');
226
- if (chevron) chevron.textContent = '▸';
227
+ if (chevron) chevron.innerHTML = ICONS.chevronRight;
227
228
 
228
229
  for (const step of pb.steps) {
229
230
  if (step.status === 'running') step.status = 'done';
@@ -3,6 +3,8 @@ import { api } from '../api.js';
3
3
  import { escapeHtml } from '../render.js';
4
4
  import { t } from './i18n.js';
5
5
  import { state } from '../state.js';
6
+ import { ICONS } from '../icons.js';
7
+ import { providerIcon } from '../provider-icons.js';
6
8
  import type { QuotaEntry } from './settings-types.js';
7
9
 
8
10
  export async function loadCliStatus(force = false): Promise<void> {
@@ -113,14 +115,15 @@ function renderCliStatus(data: { cliStatus: Record<string, { available: boolean
113
115
  }).join('');
114
116
  } else if (q?.error && info.available) {
115
117
  const msg = q.reason === 'rate_limited' ? 'Rate limited — retry in a moment' : 'Usage data unavailable';
116
- windowsHtml = `<div style="font-size:10px;color:var(--text-dim);margin:2px 0 0 16px;opacity:0.7">⚠ ${msg}</div>`;
118
+ windowsHtml = `<div style="font-size:10px;color:var(--text-dim);margin:2px 0 0 16px;opacity:0.7">${ICONS.warning} ${msg}</div>`;
117
119
  }
118
120
 
119
121
  html += `
120
122
  <div class="settings-group" style="margin-bottom:6px;padding:8px 10px">
121
123
  <div class="cli-status-row">
122
124
  <span class="cli-dot ${dotClass}"></span>
123
- <span class="cli-name" style="font-weight:600">${escapeHtml(name)}</span>${name === 'copilot' ? `<button id="copilotKeychainBtn" style="font-size:9px;margin-left:6px;padding:1px 5px;background:var(--border);color:var(--text-dim);border:1px solid var(--text-dim);border-radius:3px;cursor:pointer;vertical-align:middle;line-height:1" title="${t('copilot.keychainHint')}">🔑</button>` : ''}
125
+ <span class="cli-provider-icon" aria-hidden="true">${providerIcon(name) || ''}</span>
126
+ <span class="cli-name" style="font-weight:600">${escapeHtml(name)}</span>${name === 'copilot' ? `<button id="copilotKeychainBtn" style="font-size:9px;margin-left:6px;padding:1px 5px;background:var(--border);color:var(--text-dim);border:1px solid var(--text-dim);border-radius:3px;cursor:pointer;vertical-align:middle;line-height:1" title="${t('copilot.keychainHint')}">${ICONS.key}</button>` : ''}
124
127
  </div>
125
128
  ${accountLine}
126
129
  ${authHint}
@@ -140,7 +143,7 @@ function renderCliStatus(data: { cliStatus: Record<string, { available: boolean
140
143
  if (!hasReadyCli && allEntries.length > 0 && el) {
141
144
  el.insertAdjacentHTML('afterbegin',
142
145
  `<div style="padding:8px 10px;margin-bottom:8px;background:#fbbf2422;border:1px solid #fbbf24;border-radius:6px;font-size:11px;color:#fbbf24">
143
- ${t('cli.noReadyCli')}
146
+ ${ICONS.warning} ${t('cli.noReadyCli')}
144
147
  </div>`
145
148
  );
146
149
  }
@@ -150,15 +153,15 @@ function renderCliStatus(data: { cliStatus: Record<string, { available: boolean
150
153
  kcBtn.addEventListener('click', async () => {
151
154
  const btn = kcBtn as HTMLButtonElement;
152
155
  btn.disabled = true;
153
- btn.textContent = '⏳';
156
+ btn.innerHTML = ICONS.hourglass;
154
157
  try {
155
158
  const res = await api<{ ok: boolean }>('/api/copilot/refresh', { method: 'POST' });
156
- btn.textContent = res?.ok ? '✅' : '❌';
159
+ btn.innerHTML = res?.ok ? ICONS.check : ICONS.error;
157
160
  if (res?.ok) await loadCliStatus(true);
158
161
  } catch {
159
- btn.textContent = '❌';
162
+ btn.innerHTML = ICONS.error;
160
163
  }
161
- setTimeout(() => { btn.textContent = '🔑'; btn.disabled = false; }, 2000);
164
+ setTimeout(() => { btn.innerHTML = ICONS.key; btn.disabled = false; }, 2000);
162
165
  });
163
166
  }
164
167
  }
@@ -10,6 +10,7 @@ import { loadTelegramSettings } from './settings-telegram.js';
10
10
  import { loadDiscordSettings } from './settings-discord.js';
11
11
  import { loadActiveChannel, loadFallbackOrder } from './settings-channel.js';
12
12
  import { loadMcpServers } from './settings-mcp.js';
13
+ import { providerIcon } from '../provider-icons.js';
13
14
 
14
15
  function toCap(cli: string): string {
15
16
  return cli.charAt(0).toUpperCase() + cli.slice(1);
@@ -150,7 +151,10 @@ export async function loadSettings(): Promise<void> {
150
151
  const cwdEl = document.getElementById('inpCwd');
151
152
  if (cwdEl) cwdEl.textContent = s.workingDir;
152
153
  const headerEl = document.getElementById('headerCli');
153
- if (headerEl) headerEl.textContent = s.cli;
154
+ if (headerEl) {
155
+ const icon = providerIcon(s.cli);
156
+ headerEl.innerHTML = icon ? `${icon} ${escapeHtml(s.cli)}` : escapeHtml(s.cli);
157
+ }
154
158
  setPerm(s.permissions, false);
155
159
 
156
160
  if (s.perCli) {
@@ -214,7 +218,10 @@ export async function updateSettings(): Promise<void> {
214
218
  cli: (document.getElementById('selCli') as HTMLSelectElement)?.value || 'claude',
215
219
  };
216
220
  const hdr = document.getElementById('headerCli');
217
- if (hdr) hdr.textContent = s.cli;
221
+ if (hdr) {
222
+ const ico = providerIcon(s.cli);
223
+ hdr.innerHTML = ico ? `${ico} ${escapeHtml(s.cli)}` : escapeHtml(s.cli);
224
+ }
218
225
  await apiJson('/api/settings', 'PUT', s);
219
226
  }
220
227
 
@@ -295,7 +302,10 @@ export function onCliChange(save = true): void {
295
302
  const modelSel = document.getElementById('selModel') as HTMLSelectElement | null;
296
303
  setSelectOptions(modelSel, models, { includeCustom: true, includeDefault: true });
297
304
  const hdrCli = document.getElementById('headerCli');
298
- if (hdrCli) hdrCli.textContent = cli;
305
+ if (hdrCli) {
306
+ const ico = providerIcon(cli);
307
+ hdrCli.innerHTML = ico ? `${ico} ${escapeHtml(cli)}` : escapeHtml(cli);
308
+ }
299
309
  syncActiveEffortOptions(cli);
300
310
 
301
311
  const oldInput = document.getElementById('selModelCustom');
@@ -30,6 +30,12 @@ export async function setDiscordAllowBots(allow: boolean): Promise<void> {
30
30
  await apiJson('/api/settings', 'PUT', { discord: { allowBots: allow } });
31
31
  }
32
32
 
33
+ export async function setDiscordMentionOnly(enabled: boolean): Promise<void> {
34
+ document.getElementById('dcMentionOn')?.classList.toggle('active', enabled);
35
+ document.getElementById('dcMentionOff')?.classList.toggle('active', !enabled);
36
+ await apiJson('/api/settings', 'PUT', { discord: { mentionOnly: enabled } });
37
+ }
38
+
33
39
  export function loadDiscordSettings(s: SettingsData): void {
34
40
  if (!s.discord) return;
35
41
  const dc = s.discord;
@@ -49,4 +55,7 @@ export function loadDiscordSettings(s: SettingsData): void {
49
55
  const allowBots = !!dc.allowBots;
50
56
  document.getElementById('dcAllowBotsOn')?.classList.toggle('active', allowBots);
51
57
  document.getElementById('dcAllowBotsOff')?.classList.toggle('active', !allowBots);
58
+ const mentionOnly = !!dc.mentionOnly;
59
+ document.getElementById('dcMentionOn')?.classList.toggle('active', mentionOnly);
60
+ document.getElementById('dcMentionOff')?.classList.toggle('active', !mentionOnly);
52
61
  }
@@ -2,6 +2,7 @@
2
2
  import { api, apiJson } from '../api.js';
3
3
  import { escapeHtml } from '../render.js';
4
4
  import { t } from './i18n.js';
5
+ import { ICONS } from '../icons.js';
5
6
 
6
7
  interface McpData { servers: Record<string, { command: string; args?: string[] }>; }
7
8
  interface McpSyncResult { results: Record<string, boolean>; }
@@ -29,12 +30,12 @@ export async function syncMcpServers(): Promise<void> {
29
30
  resultEl.textContent = t('mcp.syncing');
30
31
  try {
31
32
  const d = await apiJson('/api/mcp/sync', 'POST', {}) as McpSyncResult | null;
32
- if (!d) { resultEl.textContent = '❌ sync failed'; return; }
33
+ if (!d) { resultEl.innerHTML = `${ICONS.error} sync failed`; return; }
33
34
  const r = d.results || {};
34
35
  resultEl.innerHTML = Object.entries(r).map(([k, v]) =>
35
- `${v ? '✅' : '⏭️'} ${escapeHtml(k)}`
36
+ `${v ? ICONS.check : ICONS.skip} ${escapeHtml(k)}`
36
37
  ).join(' &nbsp; ');
37
- } catch (e) { resultEl.textContent = '❌ ' + (e as Error).message; }
38
+ } catch (e) { resultEl.innerHTML = `${ICONS.error} ${escapeHtml((e as Error).message)}`; }
38
39
  }
39
40
 
40
41
  export async function installMcpGlobal(): Promise<void> {
@@ -44,11 +45,11 @@ export async function installMcpGlobal(): Promise<void> {
44
45
  resultEl.textContent = t('mcp.installing');
45
46
  try {
46
47
  const d = await apiJson('/api/mcp/install', 'POST', {}) as McpInstallResult | null;
47
- if (!d) { resultEl.textContent = '❌ install failed'; return; }
48
+ if (!d) { resultEl.innerHTML = `${ICONS.error} install failed`; return; }
48
49
  resultEl.innerHTML = Object.entries(d.results || {}).map(([k, v]) => {
49
- const icon = v.status === 'installed' ? '✅' : v.status === 'skip' ? '⏭️' : '❌';
50
- return `${icon} <b>${escapeHtml(k)}</b>: ${escapeHtml(v.status)}${v.bin ? ' ' + escapeHtml(v.bin) : ''}`;
50
+ const ic = v.status === 'installed' ? ICONS.check : v.status === 'skip' ? ICONS.skip : ICONS.error;
51
+ return `${ic} <b>${escapeHtml(k)}</b>: ${escapeHtml(v.status)}${v.bin ? ` ${ICONS.arrowRight} ` + escapeHtml(v.bin) : ''}`;
51
52
  }).join('<br>');
52
53
  loadMcpServers();
53
- } catch (e) { resultEl.textContent = '❌ ' + (e as Error).message; }
54
+ } catch (e) { resultEl.innerHTML = `${ICONS.error} ${escapeHtml((e as Error).message)}`; }
54
55
  }
@@ -13,7 +13,7 @@ export function initSttSettings(sttConfig: Record<string, any>): void {
13
13
  const vertexJson = document.getElementById('sttVertexJson') as HTMLTextAreaElement | null;
14
14
 
15
15
  if (engine) engine.value = sttConfig.engine || 'auto';
16
- if (geminiKey) geminiKey.placeholder = sttConfig.geminiKeySet ? `✅ 입력됨 ····${sttConfig.geminiKeyLast4 || ''}` : 'AIza...';
16
+ if (geminiKey) geminiKey.placeholder = sttConfig.geminiKeySet ? `✓ 입력됨 ····${sttConfig.geminiKeyLast4 || ''}` : 'AIza...';
17
17
  if (geminiModel) {
18
18
  const saved = sttConfig.geminiModel || 'gemini-2.5-flash-lite';
19
19
  const hasOption = Array.from(geminiModel.options).some(o => o.value === saved);
@@ -22,7 +22,7 @@ export function initSttSettings(sttConfig: Record<string, any>): void {
22
22
  }
23
23
  if (whisperModel) whisperModel.value = sttConfig.whisperModel || 'mlx-community/whisper-large-v3-turbo';
24
24
  if (openaiBaseUrl) openaiBaseUrl.value = sttConfig.openaiBaseUrl || '';
25
- if (openaiKey) openaiKey.placeholder = sttConfig.openaiKeySet ? `✅ 입력됨 ····${sttConfig.openaiKeyLast4 || ''}` : 'sk-...';
25
+ if (openaiKey) openaiKey.placeholder = sttConfig.openaiKeySet ? `✓ 입력됨 ····${sttConfig.openaiKeyLast4 || ''}` : 'sk-...';
26
26
  if (openaiModel) openaiModel.value = sttConfig.openaiModel || '';
27
27
  if (vertexJson) vertexJson.value = sttConfig.vertexConfig || '';
28
28
 
@@ -55,8 +55,8 @@ export function initSttSettings(sttConfig: Record<string, any>): void {
55
55
  console.log('[stt] saving:', { engine: patch.stt.engine, hasGeminiKey: !!patch.stt.geminiApiKey, hasOpenaiKey: !!patch.stt.openaiApiKey });
56
56
  try {
57
57
  await apiJson('/api/settings', 'PUT', patch);
58
- if (geminiKey?.value) { const l4 = geminiKey.value.slice(-4); geminiKey.value = ''; geminiKey.placeholder = `✅ 입력됨 ····${l4}`; }
59
- if (openaiKey?.value) { const l4 = openaiKey.value.slice(-4); openaiKey.value = ''; openaiKey.placeholder = `✅ 입력됨 ····${l4}`; }
58
+ if (geminiKey?.value) { const l4 = geminiKey.value.slice(-4); geminiKey.value = ''; geminiKey.placeholder = `✓ 입력됨 ····${l4}`; }
59
+ if (openaiKey?.value) { const l4 = openaiKey.value.slice(-4); openaiKey.value = ''; openaiKey.placeholder = `✓ 입력됨 ····${l4}`; }
60
60
  } catch (e) {
61
61
  console.error('[stt] save failed:', e);
62
62
  }
@@ -1,5 +1,7 @@
1
1
  // ── Prompt & Template Modals ──
2
2
  import { api, apiJson } from '../api.js';
3
+ import { ICONS } from '../icons.js';
4
+ import { escapeHtml } from '../render.js';
3
5
 
4
6
  // ── Prompt Modal ──
5
7
 
@@ -55,7 +57,7 @@ function renderTree(tree: TreeNode[]): void {
55
57
  if (!tmpl) continue;
56
58
  const node = document.createElement('div');
57
59
  node.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:6px 10px;margin:2px 0 2px 24px;font-size:12px;cursor:pointer;transition:border-color .15s';
58
- node.textContent = `📄 ${tmpl.filename}`;
60
+ node.innerHTML = `${ICONS.file} ${escapeHtml(tmpl.filename)}`;
59
61
  node.addEventListener('mouseenter', () => { node.style.borderColor = 'var(--accent2)'; });
60
62
  node.addEventListener('mouseleave', () => { node.style.borderColor = 'var(--border)'; });
61
63
  node.addEventListener('click', () => { openTemplateEditor(tmpl); });
@@ -71,22 +73,22 @@ function openTemplateEditor(tmpl: TemplateInfo): void {
71
73
  editor.readOnly = true;
72
74
  _devMode = false;
73
75
  const label = document.getElementById('templateEditorLabel');
74
- if (label) label.textContent = `📄 ${tmpl.filename}`;
76
+ if (label) label.innerHTML = `${ICONS.file} ${escapeHtml(tmpl.filename)}`;
75
77
  const vars = tmpl.content.match(/\{\{[A-Z_]+\}\}/g);
76
78
  const varsEl = document.getElementById('templateVars');
77
79
  if (varsEl) varsEl.textContent = vars ? `vars: ${[...new Set(vars)].join(', ')}` : 'no variables';
78
80
  const saveBtn = document.getElementById('templateSaveBtn');
79
81
  if (saveBtn) saveBtn.style.display = 'none';
80
82
  const toggle = document.getElementById('templateDevToggle');
81
- if (toggle) { toggle.style.color = 'var(--text-dim)'; toggle.style.borderColor = 'var(--border)'; toggle.textContent = '🔧 개발자 모드'; }
83
+ if (toggle) { toggle.style.color = 'var(--text-dim)'; toggle.style.borderColor = 'var(--border)'; toggle.innerHTML = `${ICONS.tool} 개발자 모드`; }
82
84
  const title = document.getElementById('templateModalTitle');
83
- if (title) title.textContent = `📄 ${tmpl.filename}`;
85
+ if (title) title.innerHTML = `${ICONS.file} ${escapeHtml(tmpl.filename)}`;
84
86
  showTemplateView('editor');
85
87
  }
86
88
 
87
89
  export function toggleDevMode(): void {
88
90
  if (!_devMode) {
89
- if (!confirm('⚠️ 프롬프트를 직접 수정하면 예상치 못한 동작이 발생할 수 있습니다.\n계속하시겠습니까?')) return;
91
+ if (!confirm(' 프롬프트를 직접 수정하면 예상치 못한 동작이 발생할 수 있습니다.\n계속하시겠습니까?')) return;
90
92
  }
91
93
  _devMode = !_devMode;
92
94
  const editor = document.getElementById('templateEditor') as HTMLTextAreaElement;
@@ -97,7 +99,7 @@ export function toggleDevMode(): void {
97
99
  if (toggle) {
98
100
  toggle.style.color = _devMode ? 'var(--stop-btn)' : 'var(--text-dim)';
99
101
  toggle.style.borderColor = _devMode ? 'var(--stop-btn)' : 'var(--border)';
100
- toggle.textContent = _devMode ? '🔓 개발자 모드 ON' : '🔧 개발자 모드';
102
+ toggle.innerHTML = _devMode ? `${ICONS.lockOpen} 개발자 모드 ON` : `${ICONS.tool} 개발자 모드`;
101
103
  }
102
104
  }
103
105
 
@@ -107,7 +109,7 @@ export async function saveTemplateFromModal(): Promise<void> {
107
109
  if (!id) return;
108
110
  await apiJson(`/api/prompt-templates/${id}`, 'PUT', { content: editor.value });
109
111
  const label = document.getElementById('templateEditorLabel');
110
- if (label) { label.textContent = '✅ 저장 + 핫리로드 완료!'; setTimeout(() => { label.textContent = `📄 ${id}.md`; }, 2000); }
112
+ if (label) { label.innerHTML = `${ICONS.check} 저장 + 핫리로드 완료!`; setTimeout(() => { label.innerHTML = `${ICONS.file} ${escapeHtml(id)}.md`; }, 2000); }
111
113
  const t = _templates.find(x => x.id === id);
112
114
  if (t) t.content = editor.value;
113
115
  }
@@ -118,7 +120,7 @@ function showTemplateView(view: 'tree' | 'editor'): void {
118
120
  if (treeView) treeView.style.display = view === 'tree' ? '' : 'none';
119
121
  if (editorView) editorView.style.display = view === 'editor' ? 'flex' : 'none';
120
122
  const title = document.getElementById('templateModalTitle');
121
- if (title && view === 'tree') title.textContent = '📝 프롬프트 구조';
123
+ if (title && view === 'tree') title.innerHTML = `${ICONS.plan} 프롬프트 구조`;
122
124
  }
123
125
 
124
126
  export function templateGoBack(): void { showTemplateView('tree'); }