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:
+374
-1
@@ -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)">×</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" />
|
||||
|
||||
Reference in New Issue
Block a user