From 7ebf8cf86f5c53b196e3f210976604a09ffa0652 Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 22 May 2026 00:20:30 +0900 Subject: [PATCH] Add VS Code-style code editor tab with AI chat panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바에 💻 코드 탭 추가: - Monaco Editor (vs-dark/light 테마 자동 연동) - 파일 탭 관리 (새 파일, 전환, 닫기), Ctrl+S 저장 - 오른쪽 AI 채팅 패널 (Coder 세션, SSE 스트리밍) - "선택 코드 → AI 전송" 버튼 - "▶ 실행" → 하단 터미널 패널로 명령 전송 - 에디터/AI 패널 수직 드래그 리사이즈 - 스킬 탭 이모지 버그 수정 (조건 반전, 커스텀 이모지 표시 안 되던 문제) Co-Authored-By: Claude Sonnet 4.6 --- web-ui/index.html | 375 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 374 insertions(+), 1 deletion(-) 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 @@ + + +
+ +
+
+ +
+ + + +
+
+ +
+
+
+
+
+ 💻 Coder AI + +
+
+
코드를 선택하고 "AI에게 전송"을 누르거나, 아래에 질문을 입력하세요.
+
+
+ + +
+
+
+
@@ -4680,8 +4805,14 @@ function setSidebarTab(tab) { sidebarTab = tab; document.getElementById('tab-jobs').classList.toggle('active', tab === 'jobs'); document.getElementById('tab-skills').classList.toggle('active', tab === 'skills'); + document.getElementById('tab-code').classList.toggle('active', tab === 'code'); document.getElementById('sidebar-jobs').style.display = tab === 'jobs' ? 'flex' : 'none'; document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none'; + // code tab: 메인 영역을 코드 에디터로 전환 + document.getElementById('chat-view').style.display = tab === 'code' ? 'none' : ''; + const cv = document.getElementById('code-view'); + if (tab === 'code') { cv.classList.add('active'); initCodeView(); } + else { cv.classList.remove('active'); } if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); } } @@ -10483,7 +10614,7 @@ async function loadInstalledSkills() { el.innerHTML = '
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 ? '[전체]' : ''; return `
@@ -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); + } +} +
Zoomed image