Add VS Code-style code editor tab with AI chat panel

사이드바에 💻 코드 탭 추가:
- Monaco Editor (vs-dark/light 테마 자동 연동)
- 파일 탭 관리 (새 파일, 전환, 닫기), Ctrl+S 저장
- 오른쪽 AI 채팅 패널 (Coder 세션, SSE 스트리밍)
- "선택 코드 → AI 전송" 버튼
- "▶ 실행" → 하단 터미널 패널로 명령 전송
- 에디터/AI 패널 수직 드래그 리사이즈
- 스킬 탭 이모지 버그 수정 (조건 반전, 커스텀 이모지 표시 안 되던 문제)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-22 00:20:30 +09:00
co-authored by Claude Sonnet 4.6
parent 7dac40381e
commit 7ebf8cf86f
+374 -1
View File
@@ -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; }
</style>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js"></script>
<script src="https://cdn.jsdelivr.net/combine/npm/tone@14,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.6.0" defer></script>
</head>
@@ -2489,6 +2580,7 @@
<div id="sidebar-tabs">
<button class="sidebar-tab active" id="tab-jobs" onclick="setSidebarTab('jobs')">세션</button>
<button class="sidebar-tab" id="tab-skills" onclick="setSidebarTab('skills')">🧩 스킬</button>
<button class="sidebar-tab" id="tab-code" onclick="setSidebarTab('code')">💻 코드</button>
</div>
<!-- SESSIONS TAB -->
@@ -2651,6 +2743,39 @@
</div>
</div>
</div>
<!-- ── Code Editor View ─────────────────────────────────────── -->
<div id="code-view">
<!-- File tabs bar -->
<div class="code-tabs-bar">
<div id="code-file-tabs" style="display:flex;align-items:stretch"></div>
<button class="code-new-tab-btn" onclick="codeNewFile()" title="새 파일">+</button>
<div class="code-toolbar">
<button class="code-tool-btn" onclick="codeSendSelectionToAI()" title="선택 영역을 AI에게 전송">💬 AI에게 전송</button>
<button class="code-tool-btn" onclick="codeRunInTerminal()" title="터미널에서 실행">▶ 실행</button>
<button class="code-tool-btn" onclick="codeSaveFile()" title="파일 저장 (Ctrl+S)">💾 저장</button>
</div>
</div>
<!-- Editor + AI panel -->
<div class="code-body">
<div id="code-editor-container"></div>
<div class="code-vresize" id="code-vresize"></div>
<div id="code-ai-panel">
<div class="code-ai-header">
<span>💻 Coder AI</span>
<button onclick="codeAiClear()" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:11px">지우기</button>
</div>
<div id="code-ai-messages">
<div class="code-ai-msg thinking">코드를 선택하고 "AI에게 전송"을 누르거나, 아래에 질문을 입력하세요.</div>
</div>
<div class="code-ai-input-row">
<textarea id="code-ai-input" placeholder="AI에게 질문..." rows="1"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();codeAiSend();}"></textarea>
<button class="code-tool-btn primary" onclick="codeAiSend()" style="flex-shrink:0;align-self:flex-end">전송</button>
</div>
</div>
</div>
</div>
</main>
<!-- Task Modal -->
@@ -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 = '<div class="empty-state" style="padding:16px">No skills yet.<br>Click "+ Create" to make one!</div>';
} 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 ? '<span style="font-size:9px;opacity:.6;margin-left:4px">[전체]</span>' : '';
return `
<div class="skill-card ${s.enabled ? 'enabled' : ''}" data-skill-id="${escHtml(s.id)}" onclick="toggleSkill('${escHtml(s.id)}')">
@@ -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 `<div class="code-file-tab${active ? ' active' : ''}" onclick="switchCodeFile('${f.id}')">
<span>${escHtml(f.name)}</span>
<span class="code-file-tab-close" onclick="closeCodeFile('${f.id}',event)">&times;</span>
</div>`;
}).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 = '<div class="code-ai-msg thinking">코드를 선택하고 "AI에게 전송"을 누르거나, 아래에 질문을 입력하세요.</div>';
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);
}
}
</script>
<div id="image-zoom-modal" onclick="closeImageZoom()">
<img id="image-zoom-img" src="" alt="Zoomed image" />