diff --git a/web-ui/index.html b/web-ui/index.html
index bdbb003..606b4eb 100644
--- a/web-ui/index.html
+++ b/web-ui/index.html
@@ -2377,6 +2377,96 @@
flex: 1;
}
.midi-router-status { font-size: 11px; color: var(--muted); }
+/* ── Code Editor View ────────────────────────────────────────── */
+#code-view {
+ display: none;
+ flex-direction: column;
+ height: 100%;
+ background: var(--bg);
+ overflow: hidden;
+}
+#code-view.active { display: flex; }
+.code-tabs-bar {
+ display: flex;
+ align-items: center;
+ background: #1e1e1e;
+ border-bottom: 1px solid #333;
+ flex-shrink: 0;
+ min-height: 35px;
+ overflow-x: auto;
+}
+.code-file-tab {
+ display: flex; align-items: center; gap: 5px;
+ padding: 0 14px; height: 35px; cursor: pointer;
+ font-family: 'IBM Plex Mono', monospace; font-size: 12px;
+ color: #969696; background: transparent;
+ border-right: 1px solid #333;
+ white-space: nowrap; user-select: none; flex-shrink: 0;
+ border-top: 2px solid transparent;
+}
+.code-file-tab.active { color: #d4d4d4; background: #1e1e1e; border-top-color: #3b78ff; }
+.code-file-tab:hover:not(.active) { color: #cccccc; background: #2d2d2d; }
+.code-file-tab-close { font-size: 13px; color: #555; margin-left:4px; opacity:0; transition: opacity .15s; }
+.code-file-tab:hover .code-file-tab-close, .code-file-tab.active .code-file-tab-close { opacity: 1; }
+.code-file-tab-close:hover { color: #e74856; }
+.code-new-tab-btn {
+ padding: 0 10px; height: 35px; cursor: pointer;
+ color: #555; font-size: 18px; background: transparent;
+ border: none; flex-shrink: 0;
+}
+.code-new-tab-btn:hover { color: #ccc; }
+.code-toolbar {
+ margin-left: auto; display: flex; align-items: center; gap: 6px;
+ padding: 0 10px; flex-shrink: 0;
+}
+.code-tool-btn {
+ padding: 3px 10px; border-radius: 5px; border: 1px solid #444;
+ background: #2d2d2d; color: #ccc; cursor: pointer; font-size: 12px;
+ white-space: nowrap;
+}
+.code-tool-btn:hover { background: #3a3a3a; color: #fff; }
+.code-tool-btn.primary { background: #0e639c; border-color: #0e639c; color: #fff; }
+.code-tool-btn.primary:hover { background: #1177bb; }
+.code-body {
+ display: flex; flex: 1; min-height: 0; overflow: hidden;
+}
+#code-editor-container {
+ flex: 1; min-width: 0; position: relative; background: #1e1e1e;
+}
+.code-vresize {
+ width: 5px; cursor: col-resize; background: #333; flex-shrink: 0;
+ transition: background .15s;
+}
+.code-vresize:hover { background: #3b78ff; }
+#code-ai-panel {
+ width: 360px; min-width: 220px; max-width: 60%;
+ display: flex; flex-direction: column;
+ background: var(--bg); border-left: 1px solid var(--line);
+ flex-shrink: 0;
+}
+.code-ai-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 8px 12px; border-bottom: 1px solid var(--line);
+ font-size: 12px; font-weight: 700; color: var(--muted); flex-shrink: 0;
+}
+#code-ai-messages {
+ flex: 1; overflow-y: auto; padding: 10px 12px;
+ display: flex; flex-direction: column; gap: 8px;
+}
+.code-ai-msg { font-size: 12px; line-height: 1.55; }
+.code-ai-msg.user { color: var(--brand); font-weight: 600; }
+.code-ai-msg.assistant { color: var(--text); white-space: pre-wrap; }
+.code-ai-msg.thinking { color: var(--muted); font-style: italic; font-size: 11px; }
+.code-ai-input-row {
+ display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--line); flex-shrink: 0;
+}
+#code-ai-input {
+ flex: 1; resize: none; border: 1px solid var(--line); border-radius: 8px;
+ padding: 6px 10px; font-size: 12px; font-family: inherit;
+ background: var(--panel-2); color: var(--text); min-height: 36px; max-height: 100px;
+}
+#code-ai-input:focus { outline: none; border-color: var(--brand); }
+
/* ── Chat inline terminal ─────────────────────────────────────── */
#chat-terminal-panel {
display: none;
@@ -2423,6 +2513,7 @@
#chat-term-container { position: relative; flex: 1; min-height: 0; overflow: hidden; }
#chat-term-btn.active { color: var(--brand) !important; }
+
@@ -2489,6 +2580,7 @@
@@ -2651,6 +2743,39 @@
+
+
+ No skills yet.
Click "+ Create" to make one!
';
} else {
el.innerHTML = skills.map(s => {
- const skillEmoji = (s.emoji === '🧩' || s.emoji === '🦞' || s.emoji === '⚠️') ? s.emoji : '🧩';
+ const skillEmoji = (s.emoji && s.emoji !== '🦞' && s.emoji !== '⚠️') ? s.emoji : '🧩';
const globalBadge = s.globalOnly ? '
@@ -12940,6 +13071,248 @@ function handleErrorResponseWsEvent(e) {
);
}
+// ── Code Editor View ─────────────────────────────────────────────────────────
+let monacoEditor = null;
+let monacoReady = false;
+let codeFiles = []; // [{id, name, content, language, model}]
+let activeCodeFileId = null;
+let codeAiHistory = [];
+let codeAiAbort = null;
+let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
+
+const CODE_LANG_MAP = {
+ js:'javascript', ts:'typescript', jsx:'javascript', tsx:'typescript',
+ py:'python', rb:'ruby', go:'go', rs:'rust', java:'java', cs:'csharp',
+ cpp:'cpp', c:'c', php:'php', html:'html', css:'css', scss:'scss',
+ json:'json', yaml:'yaml', yml:'yaml', md:'markdown', sh:'shell',
+ bash:'shell', sql:'sql', xml:'xml', toml:'ini', vue:'html',
+};
+
+function codeDetectLang(name) {
+ const ext = (name || '').split('.').pop().toLowerCase();
+ return CODE_LANG_MAP[ext] || 'plaintext';
+}
+
+function initCodeView() {
+ if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
+ if (typeof require === 'undefined') return;
+ require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
+ require(['vs/editor/editor.main'], function() {
+ monacoReady = true;
+ const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
+ monacoEditor = monaco.editor.create(document.getElementById('code-editor-container'), {
+ theme: isDark ? 'vs-dark' : 'vs',
+ language: 'plaintext',
+ automaticLayout: true,
+ fontSize: 14,
+ lineHeight: 22,
+ fontFamily: "'IBM Plex Mono', 'Fira Code', 'Courier New', monospace",
+ minimap: { enabled: true },
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ renderWhitespace: 'selection',
+ smoothScrolling: true,
+ cursorBlinking: 'smooth',
+ tabSize: 2,
+ });
+ monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile);
+ if (!codeFiles.length) codeNewFile('main.py', '# 여기에 코드를 작성하세요\n');
+ else renderCodeFileTabs();
+ setupCodeVResize();
+ });
+}
+
+function codeNewFile(name, content) {
+ const fname = name || prompt('파일 이름:', 'untitled.py');
+ if (!fname || !fname.trim()) return;
+ const id = 'cf_' + Math.random().toString(36).slice(2, 8);
+ const lang = codeDetectLang(fname);
+ const model = monacoReady ? monaco.editor.createModel(content || '', lang) : null;
+ codeFiles.push({ id, name: fname.trim(), content: content || '', language: lang, model });
+ switchCodeFile(id);
+}
+
+function switchCodeFile(id) {
+ const prev = codeFiles.find(f => f.id === activeCodeFileId);
+ if (prev && monacoEditor) prev.content = monacoEditor.getValue();
+ activeCodeFileId = id;
+ const file = codeFiles.find(f => f.id === id);
+ if (!file || !monacoEditor) { renderCodeFileTabs(); return; }
+ if (!file.model) file.model = monaco.editor.createModel(file.content, file.language);
+ monacoEditor.setModel(file.model);
+ monacoEditor.focus();
+ renderCodeFileTabs();
+}
+
+function closeCodeFile(id, ev) {
+ if (ev) ev.stopPropagation();
+ const idx = codeFiles.findIndex(f => f.id === id);
+ if (idx === -1) return;
+ const file = codeFiles[idx];
+ if (file.model) file.model.dispose();
+ codeFiles.splice(idx, 1);
+ if (!codeFiles.length) { codeNewFile('untitled.py', ''); return; }
+ if (activeCodeFileId === id)
+ switchCodeFile(codeFiles[Math.min(idx, codeFiles.length - 1)].id);
+ else
+ renderCodeFileTabs();
+}
+
+function renderCodeFileTabs() {
+ const bar = document.getElementById('code-file-tabs');
+ if (!bar) return;
+ bar.innerHTML = codeFiles.map(f => {
+ const active = f.id === activeCodeFileId;
+ return `
+ ${escHtml(f.name)}
+ ×
+
`;
+ }).join('');
+}
+
+function codeSaveFile() {
+ const file = codeFiles.find(f => f.id === activeCodeFileId);
+ if (!file || !monacoEditor) return;
+ file.content = monacoEditor.getValue();
+ // POST to workspace
+ api('/api/workspace/write', { path: file.name, content: file.content })
+ .then(() => codeAiAppend('system', `💾 저장됨: ${file.name}`))
+ .catch(() => {
+ // fallback: download
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(new Blob([file.content], { type: 'text/plain' }));
+ a.download = file.name; a.click();
+ });
+}
+
+function codeRunInTerminal() {
+ const file = codeFiles.find(f => f.id === activeCodeFileId);
+ if (!file || !monacoEditor) return;
+ file.content = monacoEditor.getValue();
+ const ext = file.name.split('.').pop().toLowerCase();
+ const cmd = ext === 'py' ? `python3 ${file.name}` : ext === 'js' ? `node ${file.name}` :
+ ext === 'ts' ? `npx ts-node ${file.name}` : `./${file.name}`;
+ // Open terminal if not open and run command
+ if (!chatTermOpen) toggleChatTerminal();
+ setTimeout(() => {
+ const s = chatTermShells.find(s => s.id === activeChatTermShellId);
+ if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: s.id, data: cmd + '\r' });
+ }, chatTermOpen ? 0 : 400);
+}
+
+function codeSendSelectionToAI() {
+ if (!monacoEditor) return;
+ const sel = monacoEditor.getSelection();
+ const selected = sel && !sel.isEmpty() ? monacoEditor.getModel().getValueInRange(sel) : monacoEditor.getValue();
+ const file = codeFiles.find(f => f.id === activeCodeFileId);
+ const lang = file ? file.language : 'code';
+ const prompt = `다음 ${lang} 코드를 분석해줘:\n\`\`\`${lang}\n${selected}\n\`\`\``;
+ document.getElementById('code-ai-input').value = prompt;
+ codeAiSend();
+}
+
+async function codeAiSend() {
+ const input = document.getElementById('code-ai-input');
+ const message = input.value.trim();
+ if (!message) return;
+ input.value = ''; input.style.height = '';
+
+ codeAiAppend('user', message);
+ codeAiHistory.push({ role: 'user', content: message });
+
+ const thinkEl = codeAiAppend('thinking', '생각 중...');
+
+ try {
+ if (codeAiAbort) codeAiAbort.abort();
+ codeAiAbort = new AbortController();
+ const historyForAPI = codeAiHistory.slice(-10, -1).map(m => ({ role: m.role, content: m.content }));
+ const res = await fetch('/api/chat', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
+ signal: codeAiAbort.signal,
+ body: JSON.stringify({ message, history: historyForAPI, useTools: true, sessionId: codeAiSessionId }),
+ });
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ const reader = res.body.getReader();
+ const dec = new TextDecoder();
+ let buf = '', reply = '';
+ thinkEl.textContent = '';
+ thinkEl.className = 'code-ai-msg assistant';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += dec.decode(value, { stream: true });
+ const lines = buf.split('\n');
+ buf = lines.pop();
+ for (const line of lines) {
+ if (!line.startsWith('data:')) continue;
+ const data = line.slice(5).trim();
+ if (data === '[DONE]') continue;
+ try {
+ const ev = JSON.parse(data);
+ if (ev.type === 'token' && ev.content) { reply += ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); }
+ else if (ev.type === 'final' && ev.content) { reply = ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); }
+ else if (ev.type === 'text_delta' && ev.delta) { reply += ev.delta; thinkEl.textContent = reply; codeAiScrollBottom(); }
+ } catch {}
+ }
+ }
+ codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
+ if (!reply) thinkEl.textContent = '(응답 없음)';
+ } catch(e) {
+ thinkEl.className = 'code-ai-msg thinking';
+ thinkEl.textContent = '오류: ' + (e.message || e);
+ }
+}
+
+function codeAiAppend(role, text) {
+ const msgs = document.getElementById('code-ai-messages');
+ if (!msgs) return null;
+ // Remove initial placeholder
+ const placeholder = msgs.querySelector('.code-ai-msg.thinking');
+ if (placeholder && placeholder.textContent.includes('AI에게 전송')) placeholder.remove();
+ const el = document.createElement('div');
+ el.className = 'code-ai-msg ' + role;
+ el.textContent = text;
+ msgs.appendChild(el);
+ codeAiScrollBottom();
+ return el;
+}
+
+function codeAiScrollBottom() {
+ const msgs = document.getElementById('code-ai-messages');
+ if (msgs) msgs.scrollTop = msgs.scrollHeight;
+}
+
+function codeAiClear() {
+ const msgs = document.getElementById('code-ai-messages');
+ if (msgs) msgs.innerHTML = '
코드를 선택하고 "AI에게 전송"을 누르거나, 아래에 질문을 입력하세요.
';
+ codeAiHistory = [];
+}
+
+// Vertical resize handle between editor and AI panel
+function setupCodeVResize() {
+ const handle = document.getElementById('code-vresize');
+ const aiPanel = document.getElementById('code-ai-panel');
+ if (!handle || !aiPanel) return;
+ let startX = 0, startW = 0;
+ handle.addEventListener('mousedown', function(e) {
+ startX = e.clientX; startW = aiPanel.offsetWidth;
+ document.addEventListener('mousemove', onVMove);
+ document.addEventListener('mouseup', onVUp);
+ e.preventDefault();
+ });
+ function onVMove(e) {
+ const dx = startX - e.clientX;
+ const newW = Math.max(220, Math.min(window.innerWidth * 0.6, startW + dx));
+ aiPanel.style.width = newW + 'px';
+ if (monacoEditor) monacoEditor.layout();
+ }
+ function onVUp() {
+ document.removeEventListener('mousemove', onVMove);
+ document.removeEventListener('mouseup', onVUp);
+ }
+}
+