Files
homeclaw/web-ui/code.js
T
kimandClaude Sonnet 4.6 027ba8725e Release 2.9.7: 코드 에디터 로컬 파일시스템 + 프로젝트 생성 개선
- 코드 에디터 저장: 서버 API → File System Access API로 전환
  - codeSaveFileById에 저장 성공/실패/폴더미선택 피드백 메시지 추가
- 프로젝트 생성 형식: project-files JSON → @@FILE/@@ENDFILE 델리미터로 변경
  - JSON 이스케이프 오류(Unterminated string) 근본 해결
- 계획 단계 시스템 프롬프트 강화: JSON 출력 절대 금지 명시
- 프로젝트 생성 세션 ID: 계획 세션 재사용 (서버가 history 파라미터 무시하므로)
- _projFileCreated async화: 파일 쓰기 완료 후 사이드바 리프레시
- 헤더/드롭다운 폴더명 표시 동적 업데이트 (workspace/code/ 하드코딩 제거)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 18:08:45 +09:00

3159 lines
140 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// code.js — Standalone Code Editor page (code.html)
//
// This is a self-contained extract of the code-editor view that also lives
// inside the main app (web-ui/app.js). It is loaded ONLY by code.html so the
const _DBG = false; // set true to enable streaming/editor debug logs
// editor can run in its own browser tab (opened from the 💻 코드 sidebar tab),
// mirroring how the slide wizard opens pptx-wizard.html.
//
// The main app keeps its own in-page code mode (for live code streaming from
// the chat agent), so this file intentionally does not depend on app.js.
// If you change code-view behaviour, update both this file and app.js.
// ============================================================================
// ── Shared helpers (self-contained copies of app.js utilities) ──────────────
function getAuthToken() { try { return sessionStorage.getItem('smallclaw_token') || ''; } catch { return ''; } }
function setAuthToken(t) { try { t ? sessionStorage.setItem('smallclaw_token', t) : sessionStorage.removeItem('smallclaw_token'); } catch {} }
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra || {}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
async function api(path, opts = {}) {
const API = window.location.origin || 'http://127.0.0.1:18789';
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
const r = await fetch(API + path, { headers, ...restOpts });
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
const ct = String(r.headers.get('content-type') || '').toLowerCase();
const bodyText = await r.text();
if (!ct.includes('application/json')) {
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`);
}
let data = null;
try { data = bodyText ? JSON.parse(bodyText) : null; }
catch { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`); }
if (!r.ok) { const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`; throw new Error(msg); }
return data;
}
// ── 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);
let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud';
let codeAiMode = localStorage.getItem('codeAiMode') ?? 'code'; // 'code' or 'ask'
let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, resolved, rejected, onReject } }
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 updateCodeAiLabel() {
const label = codeAiModel || 'AI';
const title = document.getElementById('code-ai-title');
if (title) title.textContent = '💻 Coder-' + label;
const input = document.getElementById('code-ai-input');
if (input) input.placeholder = (codeAiMode === 'ask' ? 'Ask-' : 'Coder-') + label + '에게 질문... (예: 게임 만들어줘)';
}
function setCodeAiMode(mode) {
codeAiMode = mode;
localStorage.setItem('codeAiMode', mode);
const codeBtn = document.getElementById('code-ai-mode-code');
const askBtn = document.getElementById('code-ai-mode-ask');
if (codeBtn) codeBtn.classList.toggle('active', mode === 'code');
if (askBtn) askBtn.classList.toggle('active', mode === 'ask');
updateCodeAiLabel();
}
function initCodeView() {
updateCodeAiLabel();
setCodeAiMode(codeAiMode); // Initialize mode selector UI
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: 12,
lineHeight: 19,
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);
codeFlushPendingQueue();
if (!codeFiles.length && !codePendingQueue.length) {
// Don't create a placeholder file — the model will create files as needed
renderCodeFileTabs();
} else renderCodeFileTabs();
setupCodeVResize();
csbRestoreSettings();
setupCodeEditorDrop();
});
}
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();
_DBG && console.log(`[switchCodeFile] ${activeCodeFileId} → ${id} file=${(codeFiles.find(f => f.id === id) || {}).name || '?'}`);
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);
// If this file has an active inline diff, re-apply decorations
if (codeDiffState[id]) {
monacoEditor.setModel(file.model);
monacoEditor.focus();
_showDiffBar(id);
_applyInlineDiffDecorations(id);
} else {
monacoEditor.setModel(file.model);
monacoEditor.focus();
}
renderCodeFileTabs();
codeUpdateRunButton();
}
function closeCodeFile(id, ev) {
if (ev) ev.stopPropagation();
const idx = codeFiles.findIndex(f => f.id === id);
if (idx === -1) return;
// Clean up diff view if active
if (codeDiffState[id]) cleanupCodeDiff(id);
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;
const ds = codeDiffState[f.id];
const diff = !!ds;
const unresolved = diff ? ds.hunks.filter((_, i) => !ds.resolved.has(i)).length : 0;
return `<div class="code-file-tab${active ? ' active' : ''}${diff ? ' diff-active' : ''}" onclick="switchCodeFile('${f.id}')">
${unresolved > 0 ? `<span class="code-tab-dot">${unresolved}</span>` : ''}
<span>${escHtml(f.name)}</span>
<span class="code-file-tab-close" onclick="closeCodeFile('${f.id}',event)">&times;</span>
</div>`;
}).join('');
}
let codeDirLabel = 'code';
// ── Local file system (File System Access API) ────────────────────────────
let localDirHandle = null;
let localTermPath = localStorage.getItem('code_local_term_path') || '';
async function idbHandleGet() {
return new Promise(resolve => {
const req = indexedDB.open('code_editor_v1', 1);
req.onupgradeneeded = e => e.target.result.createObjectStore('handles');
req.onsuccess = e => {
const r = e.target.result.transaction('handles', 'readonly').objectStore('handles').get('dir');
r.onsuccess = () => resolve(r.result || null);
r.onerror = () => resolve(null);
};
req.onerror = () => resolve(null);
});
}
async function idbHandleSet(handle) {
return new Promise(resolve => {
const req = indexedDB.open('code_editor_v1', 1);
req.onupgradeneeded = e => e.target.result.createObjectStore('handles');
req.onsuccess = e => {
const tx = e.target.result.transaction('handles', 'readwrite');
tx.objectStore('handles').put(handle, 'dir');
tx.oncomplete = resolve;
tx.onerror = resolve;
};
req.onerror = resolve;
});
}
async function restoreLocalDirHandle() {
try {
const handle = await idbHandleGet();
if (!handle) return;
const perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm === 'granted') {
localDirHandle = handle;
updateLocalModeUI();
codeSbRefresh();
}
} catch {}
}
async function openLocalFolder() {
if (!window.showDirectoryPicker) {
alert('이 브라우저는 File System Access API를 지원하지 않습니다.\nChrome 또는 Edge를 사용해주세요.');
return;
}
try {
localDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
await idbHandleSet(localDirHandle);
if (!localTermPath) {
const inp = document.getElementById('csb-termpath');
if (inp && !inp.value) inp.value = '';
}
updateLocalModeUI();
codeSbRefresh();
} catch (e) {
if (e.name !== 'AbortError') codeAiAppend('system', '⚠️ 폴더 열기 실패: ' + (e.message || e));
}
}
function updateLocalModeUI() {
const btn = document.getElementById('code-local-btn');
const lbl = document.getElementById('csb-folder-label');
const folderBtn = document.getElementById('code-folder-btn');
const folderName = localDirHandle ? (localDirHandle.name || '로컬') : '폴더 없음';
if (btn) {
btn.title = localDirHandle ? `📂 ${folderName} — 클릭해서 변경` : '로컬 폴더 열기';
btn.style.color = localDirHandle ? '#22c55e' : '';
btn.textContent = localDirHandle ? '📂' : '🖥️';
btn.onclick = openLocalFolder;
}
if (lbl) lbl.textContent = localDirHandle ? folderName : '폴더 없음';
if (folderBtn) { const s = folderBtn.querySelector('span'); if (s) s.textContent = localDirHandle ? folderName : '폴더 없음'; }
const hdr = document.getElementById('code-header-dir');
if (hdr) hdr.textContent = localDirHandle ? folderName : '폴더 없음';
const dd = document.getElementById('code-dropdown-dir');
if (dd) dd.textContent = localDirHandle ? `📁 ${folderName}` : '📁 폴더 없음';
}
async function localListFiles(dirHandle, prefix) {
const files = [];
try {
for await (const [name, handle] of dirHandle.entries()) {
if (name.startsWith('.')) continue;
const rel = prefix ? `${prefix}/${name}` : name;
if (handle.kind === 'directory') files.push(...await localListFiles(handle, rel));
else files.push(rel);
}
} catch {}
return files.sort();
}
async function localReadFile(filename) {
if (!localDirHandle) return null;
try {
const parts = filename.split('/');
let dir = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i]);
const fh = await dir.getFileHandle(parts[parts.length - 1]);
return await (await fh.getFile()).text();
} catch { return null; }
}
async function localWriteFile(filename, content) {
if (!localDirHandle) return false;
try {
const parts = filename.split('/');
let dir = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i], { create: true });
const fh = await dir.getFileHandle(parts[parts.length - 1], { create: true });
const w = await fh.createWritable();
await w.write(content);
await w.close();
return true;
} catch (e) { console.error('[localWriteFile]', e); return false; }
}
async function localDeleteFile(filename) {
if (!localDirHandle) return false;
try {
const parts = filename.split('/');
let dir = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i]);
await dir.removeEntry(parts[parts.length - 1]);
return true;
} catch { return false; }
}
// ── Companion WebSocket (local Python execution) ───────────────────────────
let companionWs = null;
let companionConnected = false;
const COMPANION_URL = 'ws://127.0.0.1:9000';
let companionShellId = null; // code terminal shell ID backed by companion PTY
const localShellIds = new Set(); // shell IDs routed to companion instead of server PTY
function companionConnect() {
if (companionWs && (companionWs.readyState === WebSocket.OPEN || companionWs.readyState === WebSocket.CONNECTING)) return;
try { companionWs = new WebSocket(COMPANION_URL); } catch { return; }
companionWs.onopen = () => { companionConnected = true; updateCompanionStatus(true); };
companionWs.onclose = () => {
companionConnected = false; companionWs = null;
companionShellId = null; // 재연결 시 이전 셸 상태 초기화
updateCompanionStatus(false);
setTimeout(companionConnect, 5000);
};
companionWs.onerror = () => { companionConnected = false; };
companionWs.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
handleCompanionMessage(msg);
};
}
function updateCompanionStatus(connected) {
const dot = document.getElementById('companion-dot');
if (!dot) return;
dot.style.background = connected ? '#22c55e' : '#6b7280';
dot.title = connected ? '🐍 Python companion 연결됨' : '🐍 Python companion 미연결 (companion.py 실행 필요) — 클릭해서 재연결';
const lsBtn = document.getElementById('local-shell-btn');
if (lsBtn) lsBtn.style.color = connected ? '#22c55e' : '#6b7280';
}
function handleCompanionMessage(msg) {
// shell_* messages → local shell tab; run messages → active code terminal
const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit';
const targetId = isShellMsg && companionShellId ? companionShellId : activeCodeTermShellId;
if (!codeTermOpen) toggleCodeTerminal();
setTimeout(() => {
const s = codeTermShells.find(s => s.id === targetId) || codeTermShells.find(s => s.id === activeCodeTermShellId);
if (!s || !s.term) return;
if (msg.type === 'shell_ready') s.term.write('\x1b[32m🖥️ 로컬 셸\x1b[0m\r\n');
else if (msg.type === 'shell_output') s.term.write(msg.data);
else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[셸 종료 ${msg.code}]\x1b[0m\r\n`); companionShellId = null; }
else if (msg.type === 'started') s.term.write(`\r\n\x1b[32m▶ ${msg.filename}\x1b[0m\r\n`);
else if (msg.type === 'output') s.term.write(msg.data.replace(/(?<!\r)\n/g, '\r\n'));
else if (msg.type === 'exit') s.term.write(`\r\n\x1b[33m[exit ${msg.code}]\x1b[0m\r\n`);
else if (msg.type === 'error') s.term.write(`\r\n\x1b[31m오류: ${msg.data}\x1b[0m\r\n`);
else if (msg.type === 'killed') s.term.write(`\r\n\x1b[31m[강제 종료]\x1b[0m\r\n`);
}, codeTermOpen ? 0 : 500);
}
function companionKill() {
if (companionWs && companionConnected) companionWs.send(JSON.stringify({ type: 'kill' }));
}
function csbCodeDirChanged(val) {}
function csbCodeDirSave() {}
function csbLoadCodeDir() {}
async function codeSaveFile() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
// A pending AI diff keeps the FULL modified text live in the model, so saving
// now would persist hunks the user hasn't decided on yet. Make them resolve first.
if (codeDiffState[activeCodeFileId]) {
codeAiAppend('system', '⚠️ AI 제안 변경이 검토 대기 중입니다. 먼저 ✅/❌로 적용하거나 취소한 뒤 저장하세요.');
return;
}
file.content = monacoEditor.getValue();
if (!localDirHandle) { codeAiAppend('system', '⚠️ 폴더를 먼저 선택해주세요 (🖥️ 버튼)'); return; }
const ok = await localWriteFile(file.name, file.content);
codeAiAppend('system', ok ? `💾 저장: ${file.name}` : '⚠️ 저장 실패');
}
// ── Terminal infrastructure (PTY over WebSocket) ─────────────────────────
let termWs = null;
let termWsConnected = false;
function termWsConnect() {
if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let url = `${proto}//${location.host}/ws`;
const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session');
if (token) url += `?token=${encodeURIComponent(token)}`;
try { termWs = new WebSocket(url); } catch { return; }
termWs.onopen = () => {
termWsConnected = true;
for (const [shellId, inst] of xtermInstances) {
if (!inst.ptyReady && !localShellIds.has(shellId)) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); }
}
};
termWs.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write(msg.data); }
else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); } }
else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); }
};
termWs.onclose = () => {
termWsConnected = false; termWs = null;
for (const [,i] of xtermInstances) i.ptyReady = false;
setTimeout(termWsConnect, 3000);
};
termWs.onerror = () => { termWsConnected = false; };
}
function termWsSend(msg) {
if (termWs && termWs.readyState === WebSocket.OPEN) { termWs.send(JSON.stringify(msg)); return true; }
return false;
}
const xtermInstances = new Map();
function createXtermShell(container, shellId) {
const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8);
const sub = document.createElement('div');
sub.id = 'xterm-' + id;
sub.style.cssText = 'position:absolute;inset:0';
container.appendChild(sub);
const term = new Terminal({
theme: { background:'#0C0C0C', foreground:'#CCCCCC', cursor:'#22c55e',
selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C',
yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC',
brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' },
fontFamily: "'IBM Plex Mono', 'D2Coding', 'NanumGothicCoding', 'Malgun Gothic', 'AppleGothic', monospace",
fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000,
});
const fitAddon = new FitAddon.FitAddon();
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(fitAddon); term.loadAddon(webLinksAddon);
term.open(sub); fitAddon.fit();
const inst = { term, fitAddon, ptyReady: false };
xtermInstances.set(id, inst);
term.onData(data => {
if (localShellIds.has(id)) {
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', data }));
} else if (termWsConnected && inst.ptyReady) {
termWsSend({ type: 'pty_input', shellId: id, data });
}
});
term.onResize(({ cols, rows }) => {
if (localShellIds.has(id)) {
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'resize', cols, rows }));
} else if (termWsConnected && inst.ptyReady) {
termWsSend({ type: 'pty_resize', shellId: id, cols, rows });
}
});
if (!localShellIds.has(id) && termWsConnected) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows }); }
return { id, term, fitAddon, inst };
}
// Init WebSocket connection on load
fetch('/api/status').then(r => r.json()).catch(() => ({})).finally(termWsConnect);
// ── Chat terminal (for chat view) ─────────────────────────────────────────
let chatTermShells = [];
let activeChatTermShellId = null;
let chatTermOpen = false;
let chatTermHeight = 280;
function toggleChatTerminal() {
const panel = document.getElementById('chat-terminal-panel');
const btn = document.getElementById('chat-term-btn');
if (!panel) return;
chatTermOpen = !chatTermOpen;
if (chatTermOpen) {
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
if (!chatTermShells.length) { addChatTermShell(); }
else { setTimeout(() => { const s = chatTermShells.find(s => s.id === activeChatTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); }
} else {
panel.style.display = 'none';
if (btn) btn.classList.remove('active');
}
}
function renderChatShellTabs() {
const bar = document.getElementById('chat-shell-tabs'); if (!bar) return;
bar.innerHTML = chatTermShells.map(s => {
const a = s.id === activeChatTermShellId;
return `<div class="ct-tab${a?' active':''}" onclick="switchChatTermShell('${s.id}')"><svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg><span>${escHtml(s.name)}</span>${chatTermShells.length>1?`<span class="ct-tab-close" onclick="closeChatTermShell('${s.id}',event)">&times;</span>`:''}</div>`;
}).join('');
}
function addChatTermShell() {
const id = 'ct_' + Math.random().toString(36).slice(2,8);
chatTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null });
switchChatTermShell(id);
}
function switchChatTermShell(id) {
const cur = chatTermShells.find(s => s.id === activeChatTermShellId);
if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; }
activeChatTermShellId = id;
const shell = chatTermShells.find(s => s.id === id); if (!shell) return;
if (shell.term) {
const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display='';
if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus();
} else {
const container = document.getElementById('chat-term-container');
if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); }
}
renderChatShellTabs();
}
function closeChatTermShell(id, ev) {
if (ev) ev.stopPropagation();
const idx = chatTermShells.findIndex(s => s.id === id);
if (idx === -1 || chatTermShells.length <= 1) return;
termWsSend({ type:'pty_kill', shellId:id });
const shell = chatTermShells[idx]; if (shell.term) shell.term.dispose();
xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove();
chatTermShells.splice(idx, 1);
if (activeChatTermShellId === id) switchChatTermShell(chatTermShells[Math.min(idx,chatTermShells.length-1)].id);
else renderChatShellTabs();
}
document.addEventListener('DOMContentLoaded', function() {
const handle = document.getElementById('chat-term-resize-handle'); if (!handle) return;
let startY=0, startH=0;
handle.addEventListener('mousedown', function(e) {
startY=e.clientY; startH=chatTermHeight;
const onMove = e2 => { const dy=startY-e2.clientY; chatTermHeight=Math.max(120,Math.min(window.innerHeight*.7,startH+dy)); const p=document.getElementById('chat-terminal-panel'); if(p) p.style.height=chatTermHeight+'px'; const s=chatTermShells.find(s=>s.id===activeChatTermShellId); if(s?.fitAddon) s.fitAddon.fit(); };
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault();
});
});
// ── Code editor terminal ──────────────────────────────────────────────────
let codeTermShells = [];
let activeCodeTermShellId = null;
let codeTermOpen = false;
function toggleCodeTerminal() {
const panel = document.getElementById('code-term-panel');
const btn = document.getElementById('code-term-toggle-btn');
if (!panel) return;
codeTermOpen = !codeTermOpen;
if (codeTermOpen) {
panel.style.display = 'flex';
if (btn) btn.style.color = '#22c55e';
if (!codeTermShells.length) { addCodeTermShell(); }
else { setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); }
} else {
panel.style.display = 'none';
if (btn) btn.style.color = '#6fcf8a';
}
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
function renderCodeTermTabs() {
const bar = document.getElementById('code-term-tabs'); if (!bar) return;
bar.innerHTML = codeTermShells.map(s => {
const a = s.id === activeCodeTermShellId;
const label = s.local ? '🖥️ 로컬' : 'bash';
return `<div style="display:flex;align-items:center;gap:5px;padding:0 10px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:12px;color:${a?'#c8e6c0':'#3d6b4f'};background:${a?'#0c0c0c':'transparent'};border-top:${a?'2px solid #22c55e':'2px solid transparent'};white-space:nowrap;user-select:none" onclick="switchCodeTermShell('${s.id}')">
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.7"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>
${label}${codeTermShells.length>1?` <span onclick="closeCodeTermShell('${s.id}',event)" style="color:#3d6b4f;margin-left:3px">&times;</span>`:''}
</div>`;
}).join('');
}
function addLocalShell() {
if (!companionConnected) {
alert('companion.py가 실행되지 않았습니다.\n\n터미널에서: python companion.py');
return;
}
const id = 'cdt_' + Math.random().toString(36).slice(2, 8);
codeTermShells.push({ id, name: '로컬', local: true, ptyReady: false, term: null, fitAddon: null });
localShellIds.add(id);
if (companionShellId) companionKill();
companionShellId = id;
if (!codeTermOpen) toggleCodeTerminal();
switchCodeTermShell(id);
setTimeout(() => {
const shell = codeTermShells.find(s => s.id === id);
if (companionConnected && companionWs && shell?.term) {
companionWs.send(JSON.stringify({ type: 'shell', rows: shell.term.rows, cols: shell.term.cols }));
}
}, 300);
}
function addCodeTermShell() {
const id = 'cdt_' + Math.random().toString(36).slice(2,8);
codeTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null });
switchCodeTermShell(id);
if (localTermPath) {
setTimeout(() => {
const s = codeTermShells.find(s => s.id === id);
if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` });
else setTimeout(() => {
const s2 = codeTermShells.find(s => s.id === id);
if (s2 && s2.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` });
}, 800);
}, 400);
}
}
function csbTermPathSave() {
const inp = document.getElementById('csb-termpath');
localTermPath = (inp?.value || '').trim();
localStorage.setItem('code_local_term_path', localTermPath);
codeAiAppend('system', localTermPath ? `✅ 터미널 경로 설정: ${localTermPath}` : '터미널 경로 초기화됨');
}
function switchCodeTermShell(id) {
const cur = codeTermShells.find(s => s.id === activeCodeTermShellId);
if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; }
activeCodeTermShellId = id;
const shell = codeTermShells.find(s => s.id === id); if (!shell) return;
if (shell.term) {
const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display='';
if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus();
} else {
const container = document.getElementById('code-term-container');
if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); }
}
renderCodeTermTabs();
}
function closeCodeTermShell(id, ev) {
if (ev) ev.stopPropagation();
const idx = codeTermShells.findIndex(s => s.id === id);
if (idx === -1 || codeTermShells.length <= 1) return;
if (localShellIds.has(id)) {
localShellIds.delete(id);
if (companionShellId === id) { companionKill(); companionShellId = null; }
} else {
termWsSend({ type:'pty_kill', shellId:id });
}
const shell = codeTermShells[idx]; if (shell.term) shell.term.dispose();
xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove();
codeTermShells.splice(idx, 1);
if (activeCodeTermShellId === id) switchCodeTermShell(codeTermShells[Math.min(idx,codeTermShells.length-1)].id);
else renderCodeTermTabs();
}
// Resize drag (top edge → pull up/down)
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('code-term-drag');
const panel = document.getElementById('code-term-panel');
if (!drag || !panel) return;
let startY=0, startH=0;
drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const onMove = e2 => {
const dy = startY - e2.clientY;
panel.style.height = Math.max(80, Math.min(window.innerHeight*.6, startH+dy)) + 'px';
const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit();
};
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
// Restore log panel collapsed state
(function() {
if (localStorage.getItem('logPanelCollapsed') === '1') {
const panel = document.getElementById('sidebar-log-panel');
if (panel) panel.classList.add('collapsed');
}
})();
// Ctrl+` shortcut
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === '`') { e.preventDefault(); if (document.getElementById('code-view')?.classList.contains('active')) toggleCodeTerminal(); }
});
// ── Code Run: multi-mode execution ──────────────────────────────────────────
const CODE_RUN_MODES = {
html: [
{ id: 'browser', icon: '🌐', label: '브라우저', desc: '새 탭에서 열기' },
{ id: 'terminal', icon: '⌨', label: '터미널', desc: '웹 서버로 실행' },
],
htm: 'html',
py: [
{ id: 'companion', icon: '🖥️', label: '로컬 실행', desc: 'companion.py (pygame 지원)' },
{ id: 'pyodide', icon: '🐍', label: 'Pyodide', desc: '브라우저에서 실행' },
{ id: 'terminal', icon: '⌨', label: 'python3', desc: '서버 터미널에서 실행' },
],
js: [
{ id: 'terminal', icon: '⌨', label: 'node', desc: '터미널에서 실행' },
],
ts: [
{ id: 'terminal', icon: '⌨', label: 'ts-node', desc: '터미널에서 실행' },
],
go: [
{ id: 'terminal', icon: '⌨', label: 'go run', desc: '터미널에서 실행' },
],
rs: [
{ id: 'terminal', icon: '⌨', label: 'rustc', desc: '터미널에서 실행' },
],
cpp: [
{ id: 'terminal', icon: '⌨', label: 'g++', desc: '터미널에서 실행' },
],
c: [
{ id: 'terminal', icon: '⌨', label: 'gcc', desc: '터미널에서 실행' },
],
sh: [
{ id: 'terminal', icon: '⌨', label: 'bash', desc: '터미널에서 실행' },
],
};
function codeGetRunModes(ext) {
const modes = CODE_RUN_MODES[ext];
if (!modes) return [{ id: 'terminal', icon: '⌨', label: '터미널', desc: '터미널에서 실행' }];
if (typeof modes === 'string') return CODE_RUN_MODES[modes];
return modes;
}
let _codeRunMenuOpen = false;
function codeToggleRunMenu() {
const menu = document.getElementById('code-run-menu');
if (_codeRunMenuOpen) {
menu.style.display = 'none';
_codeRunMenuOpen = false;
return;
}
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
menu.innerHTML = modes.map(m =>
`<div onclick="codeRunWith('${m.id}')" style="display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;transition:background .1s" onmouseover="this.style.background='#2d4a2d'" onmouseout="this.style.background=''">` +
`<span style="font-size:14px">${m.icon}</span>` +
`<div style="flex:1"><div style="font-size:12px;font-weight:600;color:#e5e7eb">${m.label}</div><div style="font-size:10px;color:#9ca3af">${m.desc}</div></div>` +
`</div>`
).join('');
menu.style.display = 'block';
_codeRunMenuOpen = true;
const close = (e) => {
if (!menu.contains(e.target) && !e.target.closest('#code-run-dropdown')) {
menu.style.display = 'none';
_codeRunMenuOpen = false;
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
function codeUpdateRunButton() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
const iconEl = document.getElementById('code-run-icon');
const labelEl = document.getElementById('code-run-label');
if (!file || !iconEl) return;
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
const primary = modes[0];
iconEl.textContent = primary.icon;
labelEl.textContent = primary.label;
}
function codeRunDefault() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
file.content = monacoEditor.getValue();
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
codeRunWith(modes[0].id);
}
function codeRunWith(mode) {
const menu = document.getElementById('code-run-menu');
if (menu) menu.style.display = 'none';
_codeRunMenuOpen = false;
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
// Don't run while an AI diff is pending — the model holds undecided changes.
if (codeDiffState[activeCodeFileId]) {
codeAiAppend('system', '⚠️ AI 제안 변경이 검토 대기 중입니다. 먼저 ✅/❌로 적용하거나 취소한 뒤 실행하세요.');
return;
}
file.content = monacoEditor.getValue();
const fpath = `${codeDirLabel}/${file.name}`;
if (mode === 'browser') {
const ext = file.name.split('.').pop().toLowerCase();
const mime = ext === 'html' || ext === 'htm' ? 'text/html' : ext === 'css' ? 'text/css' : 'text/plain';
const blob = new Blob([file.content], { type: mime });
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
setTimeout(() => URL.revokeObjectURL(url), 60000);
return;
}
if (mode === 'companion') {
if (!companionConnected) {
codeAiAppend('system', '⚠️ companion.py가 실행되지 않았습니다. 로컬에서 python companion.py 를 실행하세요.');
return;
}
if (!codeTermOpen) toggleCodeTerminal();
setTimeout(() => {
companionWs.send(JSON.stringify({ type: 'run', code: file.content, filename: file.name }));
}, codeTermOpen ? 0 : 500);
return;
}
if (mode === 'pyodide') {
sessionStorage.setItem('py_run_code', file.content);
sessionStorage.setItem('py_run_name', file.name);
const w = window.open('/python-runner.html', '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
return;
}
// terminal modes
const ext = file.name.split('.').pop().toLowerCase();
let cmd;
if (mode === 'terminal') {
cmd = ext === 'py' ? `python3 ${fpath}` :
ext === 'js' ? `node ${fpath}` :
ext === 'ts' ? `npx ts-node ${fpath}` :
ext === 'go' ? `go run ${fpath}` :
ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'sh' ? `bash ${fpath}` : `./${fpath}`;
} else {
cmd = mode === 'python3' ? `python3 ${fpath}` :
mode === 'node' ? `node ${fpath}` : `${mode} ${fpath}`;
}
if (!codeTermOpen) toggleCodeTerminal();
setTimeout(() => {
const s = codeTermShells.find(s => s.id === activeCodeTermShellId);
if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: s.id, data: cmd + '\r' });
}, codeTermOpen ? 0 : 500);
}
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();
}
// ─── V2 Chat Live Code Streaming ────────────────────────────────────────────────
// Global state for streaming code into the editor during the main v2 chat.
const _v2LiveState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null };
let codePendingQueue = []; // [{fname, content, lang}] — queued when Monaco not ready
// Throttled Monaco model update — avoids freezing UI by limiting setValue calls to ~80ms intervals
let _monacoThrottleTimer = null;
let _monacoThrottleModel = null;
let _monacoThrottleValue = '';
function codeMonacoThrottle(model, value) {
_monacoThrottleModel = model;
_monacoThrottleValue = value;
if (_monacoThrottleTimer) return; // already scheduled
_monacoThrottleTimer = setTimeout(() => {
_monacoThrottleTimer = null;
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
const _lines = _monacoThrottleValue.split('\n').length;
_DBG && console.log(`[codeMonacoThrottle] setValue: ${_lines} lines`);
_monacoThrottleModel.setValue(_monacoThrottleValue);
_monacoThrottleModel = null;
}
}, 80);
}
function codeMonacoThrottleFlush() {
if (_monacoThrottleTimer) { clearTimeout(_monacoThrottleTimer); _monacoThrottleTimer = null; }
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
_monacoThrottleModel.setValue(_monacoThrottleValue);
}
_monacoThrottleModel = null;
}
function codeFlushPendingQueue() {
if (!monacoReady || !monacoEditor || !codePendingQueue.length) return;
for (const item of codePendingQueue) {
const existing = codeFiles.find(f => f.name === item.fname);
if (existing) {
const oldContent = existing.model ? existing.model.getValue() : existing.content;
if (oldContent !== item.content) {
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, item.content);
}
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname));
codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model });
if (!_v2LiveState.fileId) _v2LiveState.fileId = id;
}
}
codePendingQueue = [];
if (_v2LiveState.fileId) switchCodeFile(_v2LiveState.fileId);
}
// ── Shared live-streaming parser ────────────────────────────────────────────
// Detects the last unclosed ```code block in a streaming reply and mirrors it
// into the editor: creates/reuses a tab, throttles model updates, and renames
// the tab once a real filename surfaces. `state` is a mutable holder
// { inBlock, lang, fileId } so each caller keeps its own streaming context.
function codeLiveStreamUpdate(fullReply, state) {
if (typeof codeIsOpenable !== 'function') return;
// Parse ALL code blocks using toggle: opening ```lang\n starts a block,
// bare ``` closes it. This correctly pairs fences and excludes explanation
// text between blocks. The model may split one file across multiple blocks
// (e.g. 37 lines + explanation + continuation) — we concatenate all blocks
// so the editor shows the complete program.
//
// CRITICAL: Only match ``` at the beginning of a line (with optional leading
// whitespace). Without this check, ``` inside code (e.g. in comments like
// `# ``` or strings) would be misinterpreted as a fence, causing the code
// block to end prematurely and the editor to clear mid-stream.
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
const blocks = [];
let inBlock = false, blockLang = '', blockStart = 0, m;
while ((m = fenceRe.exec(fullReply)) !== null) {
// Require ``` at start of line (only whitespace before it) — prevents
// false matches inside code (comments, strings, etc.)
const lineStart = m.index === 0 ? 0 : fullReply.lastIndexOf('\n', m.index - 1) + 1;
const prefix = fullReply.slice(lineStart, m.index);
if (prefix.trim() !== '') continue; // ``` is mid-line, skip
if (!inBlock) {
blockLang = m[1] || 'text';
blockStart = m.index + m[0].length;
inBlock = true;
} else {
blocks.push({ lang: blockLang, code: fullReply.slice(blockStart, m.index) });
inBlock = false;
}
}
if (inBlock) {
// Unclosed block — still streaming
blocks.push({ lang: blockLang, code: fullReply.slice(blockStart), streaming: true });
}
if (!blocks.length) { state.inBlock = false; return; }
// Concatenate all code blocks (same or different language)
let code = '';
let lang = blocks[blocks.length - 1].lang || 'text';
for (const block of blocks) {
if (code) code += '\n';
code += block.code;
if (block.lang && block.lang !== 'text') lang = block.lang;
}
code = code.replace(/\n+$/, '');
_DBG && console.log(`[codeLiveStream] blocks=${blocks.length} lines=${code.split('\n').length} inBlock=${state.inBlock} fileId=${state.fileId} lang=${lang}`);
// ── Diff block handling ──────────────────────────────────────────────
// When the language is 'diff', the block contains a unified diff that
// should be applied to an existing file (not replace it entirely).
if (lang === 'diff') {
if (!state.inBlock) {
state.inBlock = true;
state.lang = lang;
state.isDiff = true;
// We'll accumulate the diff content and apply after streaming.
// Try to find the filename from the diff header or filename comment.
// A diff must target an existing file — prefer the file we sent as context
// (the active tab), falling back to a name match. Deriving a name from the
// diff body text picked the wrong/new file, so the diff never hit the open
// file ("diff가 동일 파일에서 일어나지 않음").
const fname = codeDeriveName(code, lang);
const existing = (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId))
|| codeFiles.find(f => f.name === fname)
|| codeFiles.find(f => f.id === activeCodeFileId);
if (existing) {
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
state.fileId = existing.id;
state.diffAccumulated = code;
switchCodeFile(existing.id);
} else {
// No matching file found — create a placeholder
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monacoReady ? monaco.editor.createModel(code, 'diff') : null;
codeFiles.push({ id, name: fname || 'diff.patch', content: code, language: 'diff', model });
state.fileId = id;
state.originalContent = '';
state.diffAccumulated = code;
switchCodeFile(id);
}
} else if (state.isDiff && state.fileId) {
// Continue accumulating diff content — keep the original file in the editor;
// the diff will be applied when streaming completes (see codeLiveStreamUpdate end-of-stream)
state.diffAccumulated = code;
}
return;
}
// ── Full code block handling ────────────────────────────────────────────
// Detect if the model accidentally wrapped a diff in a non-diff code fence
// (e.g. ```python with +prefix/-prefix lines) — treat it as a diff block instead.
// Reuses _isDiffCode (the same +/-/@@ ratio heuristic).
if (lang !== 'diff' && _isDiffCode(lang, code)) {
// Model output diff content inside a non-diff code fence — redirect to diff handling
state.inBlock = true;
state.lang = 'diff';
state.isDiff = true;
// Diff content wrapped in a non-diff fence — target the active/context file too.
const fname = codeDeriveName(code, lang);
const existing = (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId))
|| codeFiles.find(f => f.name === fname)
|| codeFiles.find(f => f.id === activeCodeFileId);
if (existing) {
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
state.fileId = existing.id;
state.diffAccumulated = code;
switchCodeFile(existing.id);
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fname)) : null;
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
state.fileId = id;
state.originalContent = '';
state.diffAccumulated = code;
switchCodeFile(id);
}
return;
}
if (!state.inBlock) {
// Wait until the block looks like a real file (not a short run-command snippet)
if (!codeIsOpenable(code, lang)) return;
state.inBlock = true;
state.lang = lang;
const fname = codeDeriveName(code, lang);
_DBG && console.log(`[codeLiveStream] NEW BLOCK: fname=${fname} lines=${code.split('\n').length}`);
if (monacoReady && monacoEditor) {
// Reuse existing tab with same name instead of creating a duplicate
const existing = codeFiles.find(f => f.name === fname);
if (existing) {
// Save original content so we can show a diff after streaming completes
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
state.fileId = existing.id;
// If editing existing file: keep original in editor, show streamed changes as decorations
if (state.originalContent && state.originalContent.trim()) {
state.streamingModifiedText = code;
_updateStreamingDiffDecorations(state.fileId, state.originalContent, code);
} else {
switchCodeFile(existing.id);
}
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(code, codeDetectLang(fname));
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
state.fileId = id;
switchCodeFile(id);
}
} else {
// Monaco not ready yet — queue and trigger init
codePendingQueue = [{ fname, content: code, lang }];
initCodeView();
}
} else if (state.fileId && !state.isDiff) {
_DBG && console.log(`[codeLiveStream] UPDATE: fileId=${state.fileId} lines=${code.split('\n').length} origContent=${!!state.originalContent} diff=${state.isDiff}`);
const file = codeFiles.find(f => f.id === state.fileId);
if (file?.model) {
// If editing existing file with original content: stream as diff decorations
if (state.originalContent && state.originalContent.trim()) {
state.streamingModifiedText = code;
// Keep the original text in the editor — update streaming diff decorations
_updateStreamingDiffDecorations(state.fileId, state.originalContent, code);
// Don't call codeMonacoThrottle here — we keep original in editor
} else {
codeMonacoThrottle(file.model, code);
}
// Rename tab if a filename comment appears later in the stream
const betterName = codeDeriveName(code, state.lang);
if (betterName && !betterName.startsWith('ai_') && file.name.startsWith('ai_')) {
file.name = betterName;
file.language = codeDetectLang(betterName);
renderCodeFileTabs();
}
}
} else if (codePendingQueue.length) {
// Monaco still loading — update queued content
codePendingQueue[codePendingQueue.length - 1].content = code;
}
}
// ── Real-time streaming diff decorations ──────────────────────────────────
// While the model streams code, compare original vs streamed content and
// highlight changed/added lines in green — without replacing the editor content.
// This is the VSCode Copilot approach: keep original, show changes as overlay.
// Throttled streaming diff to avoid excessive decoration updates
let _streamingDiffTimer = null;
let _streamingDiffFileId = null;
let _streamingDiffOriginal = '';
let _streamingDiffModified = '';
function _updateStreamingDiffDecorations(fileId, originalText, modifiedText) {
_streamingDiffFileId = fileId;
_streamingDiffOriginal = originalText;
_streamingDiffModified = modifiedText;
if (_streamingDiffTimer) return; // already scheduled
_streamingDiffTimer = setTimeout(() => {
_streamingDiffTimer = null;
_applyStreamingDiffDecorations(_streamingDiffFileId, _streamingDiffOriginal, _streamingDiffModified);
}, 120);
}
function _applyStreamingDiffDecorations(fileId, originalText, modifiedText) {
if (!monacoReady || !monacoEditor || !window.monaco) return;
const file = codeFiles.find(f => f.id === fileId);
if (!file || file.id !== activeCodeFileId) return;
// Make sure editor still shows original content
const currentContent = file.model ? file.model.getValue() : file.content;
if (currentContent !== originalText) {
// Editor was changed externally — don't override
return;
}
// Highlight changed regions while keeping the original text in the editor.
// (We can't reliably place added/deleted lines until streaming completes, so we
// only color the affected lines.)
const origLines = originalText.split('\n');
const hunks = computeDiffHunks(originalText, modifiedText);
if (!hunks.length) {
// No changes yet — remove any streaming decorations
if (file.model && file._streamingDecos) {
file.model.deltaDecorations(file._streamingDecos, []);
file._streamingDecos = null;
}
return;
}
// We're keeping original text in editor. Show decorations on lines that have changes.
// For "change" hunks: highlight the original lines that will be modified
// For "add" hunks: highlight the line before where additions will appear
// For "delete" hunks: highlight lines that will be removed with strikethrough-like style
const streamingDecos = [];
for (const hunk of hunks) {
if (hunk.type === 'delete') {
// Lines that will be removed
for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) {
streamingDecos.push({
range: new monaco.Range(line, 1, line, 1),
options: {
className: 'inline-diff-streaming-delete',
glyphMarginClassName: 'inline-diff-glyph-delete',
isWholeLine: true,
minimap: { color: '#dc354580', position: monaco.editor.MinimapPosition.Inline },
overviewRuler: { color: '#dc354560', position: monaco.editor.OverviewRulerLane.Full },
}
});
}
} else if (hunk.type === 'add') {
// New lines will appear after oldStart — highlight the line before
const insertAfter = Math.max(1, hunk.oldStart - 1);
streamingDecos.push({
range: new monaco.Range(insertAfter, 1, insertAfter, 1),
options: {
className: 'inline-diff-streaming-add-marker',
isWholeLine: true,
}
});
} else {
// Change: old lines will be replaced
for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) {
streamingDecos.push({
range: new monaco.Range(line, 1, line, 1),
options: {
className: 'inline-diff-streaming-change',
glyphMarginClassName: 'inline-diff-glyph-change',
isWholeLine: true,
minimap: { color: '#ffc10780', position: monaco.editor.MinimapPosition.Inline },
overviewRuler: { color: '#ffc10760', position: monaco.editor.OverviewRulerLane.Full },
}
});
}
}
}
// Apply streaming decorations
const oldDecos = file._streamingDecos || [];
file._streamingDecos = file.model.deltaDecorations(oldDecos, streamingDecos);
}
// Apply a unified diff to original content and return the new content.
// Parses @@ -a,b +c,d @@ hunks and applies additions/removals.
function applyUnifiedDiff(originalContent, diffText) {
const origLines = originalContent.split('\n');
const diffLines = diffText.split('\n');
const result = [];
let origPos = 0; // 0-based cursor into origLines
// Whitespace-insensitive compare. Models frequently emit @@ line numbers that
// are a few lines off, or drop the leading marker space on context lines —
// both desync origPos and make context lines pull the WRONG original line,
// which shows up as corrupted indentation. We re-anchor on line content (not
// position), so the authoritative original text (with its real indentation)
// is always what gets emitted for unchanged/context lines.
const norm = (s) => s.trim();
// Try to move origPos to the original line matching `content`, scanning a small
// window ahead. Any original lines skipped on the way were unchanged, so emit
// them as context. Returns true when aligned.
const realign = (content) => {
if (origPos >= origLines.length) return false;
if (norm(origLines[origPos]) === norm(content)) return true;
for (let k = 1; k <= 5 && origPos + k < origLines.length; k++) {
if (norm(origLines[origPos + k]) === norm(content)) {
for (let j = 0; j < k; j++) result.push(origLines[origPos++]);
return true;
}
}
return false;
};
// Skip to first hunk header
let i = 0;
while (i < diffLines.length && !diffLines[i].startsWith('@@')) i++;
if (i >= diffLines.length) {
// No hunks found — try simple +/- line format (no @@ headers)
return applySimpleDiff(originalContent, diffText);
}
// Walk each hunk in lock-step with the original: copy untouched lines up to the
// hunk, then preserve context lines, drop '-' lines, and insert '+' lines in
// order. Tracking origPos keeps multi-hunk patches aligned (the old splice
// approach lost context lines and drifted on the 2nd hunk → corrupted output).
while (i < diffLines.length) {
if (!diffLines[i].startsWith('@@')) { i++; continue; }
const hunkMatch = diffLines[i].match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (!hunkMatch) { i++; continue; }
const oldStart = parseInt(hunkMatch[1], 10);
i++; // Move past @@ line
// Position origPos at the start of this hunk. The @@ line number is only a
// hint — models routinely get it wrong. Prefer anchoring on the hunk's first
// context/deletion line: find it in the original (forward from where we are)
// and emit the skipped lines as unchanged. Only fall back to the @@ number for
// pure-insertion hunks, which have no anchor line to search for.
let anchor = null;
for (let p = i; p < diffLines.length && !diffLines[p].startsWith('@@'); p++) {
const L = diffLines[p];
if (L.startsWith('\\') || L.startsWith('+++') || L.startsWith('---')) continue;
const t = L[0];
if (t === '+') continue;
anchor = (t === ' ' || t === '-') ? L.slice(1) : L; // context or deletion (or bare context)
break;
}
if (anchor !== null) {
let found = -1;
for (let q = origPos; q < origLines.length; q++) {
if (origLines[q].trim() === anchor.trim()) { found = q; break; }
}
if (found >= 0) { while (origPos < found) result.push(origLines[origPos++]); }
else { const hs = Math.max(0, oldStart - 1); while (origPos < hs && origPos < origLines.length) result.push(origLines[origPos++]); }
} else {
const hs = Math.max(0, oldStart - 1);
while (origPos < hs && origPos < origLines.length) result.push(origLines[origPos++]);
}
// Apply hunk body
while (i < diffLines.length && !diffLines[i].startsWith('@@')) {
const line = diffLines[i];
if (line.startsWith('\\')) { i++; continue; } // "\ No newline at end of file"
if (line.startsWith('+++') || line.startsWith('---')) { i++; continue; } // file headers
const tag = line[0];
const body = line.slice(1); // line minus its 1-char marker
if (tag === '+') {
result.push(body); // added line — verbatim, keep its indentation
} else if (tag === '-') {
realign(body); // anchor to the line being removed
origPos++; // removed — consume original
} else if (tag === ' ') {
realign(body); // anchor to the matching original line
if (origPos < origLines.length) result.push(origLines[origPos++]); // context — keep ORIGINAL text (true indentation)
else result.push(body);
} else {
// No-prefix line: treat as context when it matches the original
// (whitespace-insensitively — models often drop the leading space), else literal
if (realign(line)) result.push(origLines[origPos++]);
else result.push(line);
}
i++;
}
}
// Copy any remaining original lines after the last hunk
while (origPos < origLines.length) result.push(origLines[origPos++]);
return result.join('\n');
}
// Fallback: simple +/- diff without @@ headers (e.g. model just outputs +added/-removed lines)
// Format: lines starting with '+ ' are additions, '- ' are deletions, ' ' (single space) are context.
// Lines with no prefix at all are ignored (filename comments, blank separators, etc.)
function applySimpleDiff(originalContent, diffText) {
const origLines = originalContent.split('\n');
const diffLines = diffText.split('\n');
const resultLines = [];
let origIdx = 0;
for (const line of diffLines) {
const tag = line[0];
// Strip exactly ONE marker char. Stripping the conventional "+ " as two
// chars ate a leading space off indented additions → off-by-one indentation.
if (tag === '+') {
resultLines.push(line.slice(1));
} else if (tag === '-') {
origIdx++; // Skip original line
} else if (tag === ' ') {
// Context line. Accept any space-prefixed line, but only when it actually
// matches the next original (whitespace-insensitively) — guards against
// bare indented code being mistaken for context. Emit the ORIGINAL text so
// the real indentation is preserved.
const content = line.slice(1);
if (origIdx < origLines.length && origLines[origIdx].trim() === content.trim()) {
resultLines.push(origLines[origIdx]);
origIdx++;
}
// else: skip — indented code mistaken for context
}
// All other lines (no prefix, headers, comments) are ignored
}
// Append any remaining original lines
while (origIdx < origLines.length) {
resultLines.push(origLines[origIdx++]);
}
return resultLines.length > 0 ? resultLines.join('\n') : originalContent;
}
// Main v2-chat live streaming (used in the full app) — thin wrapper over the
// shared parser with its own module-level state.
function v2LiveCodeUpdate(fullReply) {
codeLiveStreamUpdate(fullReply, _v2LiveState);
}
// ── /new-project 2-phase project creation ────────────────────────────────
// State variables
let _projMode = false; // 파일 생성 진행 중
let _projEl = null; // 진행 표시용 DOM 요소
let _projFileCount = 0; // 생성된 파일 수
let _projPhase = null; // 'plan' | null
let _projHistory = []; // 계획 대화 히스토리
let _projSessionId = null; // 계획 세션 ID
const _PROJ_PLAN_SYSTEM = `당신은 소프트웨어 프로젝트 설계 도우미입니다.
사용자가 원하는 프로젝트를 분석하고 아래 마크다운 형식으로 답하세요.
🔧 기술 스택: 언어, 라이브러리, 프레임워크 나열
📁 파일 구조:
- 파일명: 역할 설명
(5~10개)
✨ 핵심 기능:
1. 기능 설명
(3~5가지)
중요: JSON 출력 절대 금지. 코드 블록 출력 금지. 코드 생성 금지.
반드시 위의 마크다운 텍스트 형식만 사용하세요.
답변 마지막에 반드시 이 한 줄을 추가하세요:
진행하려면 "만들어줘" 라고 입력하거나, 수정 사항을 알려주세요.`;
const _PROJ_GEN_SYSTEM = `당신은 새 프로젝트 파일을 생성하는 어시스턴트입니다.
도구를 사용하지 말고, 반드시 아래 형식으로 각 파일을 출력하세요. 다른 텍스트 없음.
@@FILE: 프로젝트명/파일명.확장자
파일 내용 (이스케이프 불필요, 있는 그대로)
@@ENDFILE
예시:
@@FILE: tetris-game/index.html
<!DOCTYPE html>
<html lang="ko">
<head><title>테트리스</title></head>
<body>...</body>
</html>
@@ENDFILE
@@FILE: tetris-game/README.md
## 실행 방법
index.html을 브라우저에서 열어주세요.
@@ENDFILE
규칙:
- path: 영문 슬러그 폴더명/파일명 (예: tetris-game/index.html)
- 실행 진입점 필수 (main.py / index.html / app.py 등)
- README.md 필수 (마지막)
- 파일 내용에 @@FILE: 또는 @@ENDFILE 문자열 사용 금지
- 각 파일에 완전한 동작 코드
- 웹 게임(테트리스, 뱀, 퍼즐 등)은 index.html 단일 파일로 (모든 CSS/JS를 <style>/<script>에 인라인). 별도 .js/.css 파일 금지`;
function _isProjConfirm(msg) {
return /^(만들어|만들어줘|만들어주세요|진행|진행해|진행해줘|좋아|좋아요|ok|ㅇㅋ|ㅇㅇ|응|예|네|yes|그래|그래줘|go|시작|시작해|시작해줘)[\s!.]*$/i.test(msg.trim());
}
function _isNewProjectIntent(msg) {
if (!msg || msg.length > 200) return false;
const m = msg;
const hasCreate = /만들(어|자|어줘|어주|고\s*싶|어\s*주|어보자|어봐|어봐줘)|짜(줘|자|봐|봐줘)|개발(해|하자|해줘|해봐)|작성(해|하자|해줘)|생성(해|하자|해줘)|구현(해|하자|해줘)|코딩(해|해줘)|제작(해|하자|해줘)/.test(m);
const hasProject = /게임|앱|app\b|프로그램|프로젝트|웹사이트|웹\s*앱|서버|봇|bot\b|스크립트|도구|툴|플러그인|시스템|대시보드|크롤러|계산기|타이머|채팅|클론|api\s*서버/.test(m);
return hasCreate && hasProject;
}
function _projStartGenerate() {
_projMode = true;
_projFileCount = 0;
const el = codeAiAppend('assistant', '');
el.classList.remove('streaming');
_projEl = el;
const hdr = document.createElement('div');
hdr.style.cssText = 'font-weight:600;color:var(--brand,#7c3aed);margin-bottom:8px;font-size:13px;';
hdr.textContent = '🔨 파일 생성 중...';
el.appendChild(hdr);
el._hdr = hdr;
const list = document.createElement('div');
list.style.cssText = 'font-size:11px;font-family:monospace;';
el.appendChild(list);
el._list = list;
codeAiScrollBottom();
}
async function _projFileCreated(filename, content) {
if (!_projEl) return;
_projFileCount++;
const row = document.createElement('div');
row.style.cssText = 'padding:2px 0;display:flex;align-items:center;gap:6px;';
const statusSpan = document.createElement('span');
statusSpan.textContent = '✅';
statusSpan.style.flexShrink = '0';
const pathSpan = document.createElement('span');
pathSpan.style.cssText = 'color:var(--text,#ccc);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
pathSpan.textContent = filename;
row.appendChild(statusSpan);
row.appendChild(pathSpan);
if (content?.trim()) {
const toggleBtn = document.createElement('button');
toggleBtn.style.cssText = 'background:transparent;border:1px solid var(--border,#333);color:var(--muted,#888);border-radius:3px;padding:0 6px;font-size:10px;cursor:pointer;flex-shrink:0;line-height:18px;';
toggleBtn.textContent = '보기';
row.appendChild(toggleBtn);
const preview = document.createElement('div');
preview.style.cssText = 'display:none;background:var(--panel-2,#1e1e2e);border:1px solid var(--border,#333);border-radius:5px;padding:8px;margin:3px 0 3px 22px;max-height:180px;overflow-y:auto;';
const pre = document.createElement('pre');
pre.style.cssText = 'margin:0;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text,#ccc);';
pre.textContent = content;
preview.appendChild(pre);
toggleBtn.onclick = () => {
const hidden = preview.style.display === 'none';
preview.style.display = hidden ? 'block' : 'none';
toggleBtn.textContent = hidden ? '닫기' : '보기';
};
_projEl._list.appendChild(row);
_projEl._list.appendChild(preview);
} else {
_projEl._list.appendChild(row);
}
// 로컬 폴더에 저장
if (localDirHandle) await localWriteFile(filename, content ?? '');
// 에디터 탭에 추가
_npAddTab(filename, content ?? '');
renderCodeFileTabs();
codeAiScrollBottom();
}
function _projDone() {
_projMode = false;
if (!_projEl) return;
if (_projEl._hdr) _projEl._hdr.textContent = `📁 프로젝트 생성 완료`;
const doneDiv = document.createElement('div');
doneDiv.style.cssText = 'margin-top:10px;padding-top:10px;border-top:1px solid var(--border,#333);display:flex;gap:8px;align-items:center;flex-wrap:wrap;';
const info = document.createElement('span');
info.style.cssText = 'font-size:12px;color:var(--green,#4ade80);font-weight:600;';
info.textContent = `✅ ${_projFileCount}개 파일 생성 완료!`;
doneDiv.appendChild(info);
if (localDirHandle) {
const sbBtn = document.createElement('button');
sbBtn.style.cssText = 'background:var(--brand,#7c3aed);color:#fff;border:none;border-radius:5px;padding:4px 12px;font-size:11px;font-weight:600;cursor:pointer;';
sbBtn.textContent = '📂 파일 탐색기';
sbBtn.onclick = () => { const b = document.getElementById('code-folder-btn'); if (b) b.click(); };
doneDiv.appendChild(sbBtn);
}
_projEl.appendChild(doneDiv);
_projEl = null;
codeSbRefresh();
codeAiScrollBottom();
}
async function _projStreamSSE(sessionId, message, systemPrompt, history, onChunk) {
const res = await fetch('/api/chat', {
method: 'POST',
signal: codeAiAbort.signal,
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
body: JSON.stringify({
message,
pinnedMessages: [{ role: 'system', content: systemPrompt }],
history: history || [],
sessionId,
direct: true,
...(codeAiModel ? { model: codeAiModel } : {}),
}),
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '', fullText = '';
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 raw = line.slice(5).trim();
if (raw === '[DONE]') continue;
try {
const ev = JSON.parse(raw);
let chunk = '';
if (ev.type === 'token') chunk = ev.content || ev.text || '';
else if (ev.type === 'text_delta') chunk = ev.delta || '';
else if (ev.type === 'final' || ev.type === 'done') {
const reply = ev.text || ev.content || ev.reply || '';
if (reply.length > fullText.length) fullText = reply;
}
if (chunk) { fullText += chunk; onChunk?.(chunk); }
} catch {}
}
}
return fullText;
}
async function _projPlanChat(message) {
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
try {
// 스트리밍 말풍선 생성
const bubble = codeAiAppend('assistant', '');
bubble.classList.add('streaming');
const mdDiv = document.createElement('div');
mdDiv.className = 'md-content';
bubble.appendChild(mdDiv);
codeAiScrollBottom();
let accumulated = '';
const fullText = await _projStreamSSE(
_projSessionId, message, _PROJ_PLAN_SYSTEM, _projHistory,
chunk => {
accumulated += chunk;
mdDiv.innerHTML = _renderInlineMarkdown(accumulated);
codeAiScrollBottom();
}
);
bubble.classList.remove('streaming');
const text = fullText || accumulated;
mdDiv.innerHTML = _renderInlineMarkdown(text);
// 히스토리에 추가
_projHistory.push({ role: 'user', content: message });
_projHistory.push({ role: 'assistant', content: text });
// 진행 버튼
const btnRow = document.createElement('div');
btnRow.style.cssText = 'margin-top:10px;display:flex;gap:6px;';
const goBtn = document.createElement('button');
goBtn.style.cssText = 'background:var(--brand,#7c3aed);color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:12px;font-weight:700;cursor:pointer;';
goBtn.textContent = '🔨 진행';
goBtn.onclick = () => { btnRow.remove(); _projGenerate(); };
btnRow.appendChild(goBtn);
bubble.appendChild(btnRow);
codeAiScrollBottom();
} catch (e) {
if (e.name !== 'AbortError')
codeAiAppend('system', '❌ 오류: ' + (e.message || e));
}
}
async function _projGenerate() {
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
_projPhase = null;
_projStartGenerate();
try {
if (_projEl?._hdr) _projEl._hdr.textContent = '🤔 코드 생성 중...';
// 계획 세션 재사용 — 서버가 history 파라미터를 무시하고 세션 ID로 히스토리를 로드하므로
// 새 세션을 쓰면 "위 계획대로"가 뭔지 모델이 알 수 없음
const genSessionId = _projSessionId || ('proj-gen-' + Date.now());
let genChars = 0;
const fullText = await _projStreamSSE(
genSessionId, '위 계획대로 각 파일을 @@FILE / @@ENDFILE 형식으로 생성해주세요.',
_PROJ_GEN_SYSTEM, _projHistory,
chunk => {
genChars += chunk.length;
if (_projEl?._hdr) _projEl._hdr.textContent = `🤔 코드 생성 중... (${genChars}자)`;
}
);
// @@FILE / @@ENDFILE 델리미터 파싱 (JSON 이스케이프 문제 없음)
const fileRe = /@@FILE:\s*(.+?)\r?\n([\s\S]*?)@@ENDFILE/g;
const files = [];
let fm;
while ((fm = fileRe.exec(fullText)) !== null) {
files.push({ path: fm[1].trim(), content: fm[2] });
}
if (files.length === 0) {
const preview = fullText ? `\n\n모델 출력 (앞 400자):\n${fullText.slice(0, 400)}` : ' (응답 없음)';
codeAiAppend('system', '❌ 파일 블록을 찾지 못했습니다. 다시 시도해주세요.' + preview);
return;
}
if (_projEl?._hdr) _projEl._hdr.textContent = '🔨 파일 저장 중...';
for (const f of files) {
if (f.path && f.content !== undefined)
await _projFileCreated(f.path, String(f.content));
}
const allNames = files.map(f => f.path);
const slug = (allNames[0] || '').split('/')[0];
const mainPath = _npMainFile(files.map(f => ({ path: f.path.replace(slug + '/', '') })), slug);
if (mainPath) switchCodeFile(codeFiles.find(f => f.name === mainPath)?.id);
} catch (e) {
if (e.name !== 'AbortError')
codeAiAppend('system', '❌ 프로젝트 생성 오류: ' + (e.message || e));
} finally {
_projHistory = [];
_projSessionId = null;
_projDone();
}
}
// 탭 추가만, switchCodeFile 없이 (나중에 메인 파일로 한 번만 포커스)
function _npAddTab(name, content) {
const existing = codeFiles.find(f => f.name === name);
if (existing) {
if (existing.model) existing.model.setValue(content);
else existing.content = content;
return existing.id;
}
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const lang = codeDetectLang(name);
const model = monacoReady ? monaco.editor.createModel(content, lang) : null;
codeFiles.push({ id, name, content, language: lang, model });
return id;
}
// 메인 진입 파일 추정 (main > app > index > server > 첫 번째)
function _npMainFile(files, slug) {
const priority = /^(main|app|index|server)\.(py|js|ts|jsx|tsx|go|rs|java|rb|php)$/i;
const f = files.find(f => priority.test(f.path.split('/').pop()))
|| files.find(f => !/\.(md|txt|json|yaml|yml|toml|cfg|ini|env)$/i.test(f.path))
|| files[0];
return f ? `${slug}/${f.path}`.replace(/\/+/g, '/') : null;
}
async function codeAiSend() {
const input = document.getElementById('code-ai-input');
const message = input.value.trim();
if (!message) return;
input.value = ''; input.style.height = '';
// 프로젝트 계획 단계 중 → 확인 메시지면 생성, 아니면 계획 계속
if (_projPhase === 'plan') {
codeAiAppend('user', message);
if (_isProjConfirm(message)) {
await _projGenerate();
} else {
await _projPlanChat(message);
}
return;
}
// 새 프로젝트 의도 감지 → 계획 단계 시작
if (_isNewProjectIntent(message)) {
codeAiAppend('user', message);
_projPhase = 'plan';
_projHistory = [];
_projSessionId = 'proj-plan-' + Date.now();
await _projPlanChat(message);
return;
}
// Include current file context so the model knows what to edit (Code mode only)
const currentFile = codeFiles.find(f => f.id === activeCodeFileId);
let contextMsg = message;
if (codeAiMode === 'code' && currentFile && monacoEditor) {
const content = monacoEditor.getValue();
// Diff editing only makes sense against REAL existing code. A brand-new tab
// seeded with a placeholder comment (e.g. "# 여기에 코드를 작성하세요") is not
// editable code — asking for a diff there makes the model emit a whole program
// as +lines, which then got saved verbatim ("+가 붙어 나옴"). Treat a file with
// no substantive (non-comment, non-blank) lines as new → request full code.
const hasRealCode = content.split('\n').some(l => {
const t = l.trim();
return t && !t.startsWith('#') && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*') && !t.startsWith('<!--');
});
if (content.trim() && currentFile.name !== 'untitled.py' && hasRealCode) {
const ext = currentFile.name.split('.').pop() || '';
const langHint = ext || currentFile.language || '';
const lines = content.split('\n');
// For long files (>80 lines), send focused context around the error/cursor
// plus structural overview (class/function defs) so the model understands the file
if (lines.length > 80) {
// Find relevant region: cursor line, or error line from message, or top
const errorMatch = message.match(/line\s+(\d+)/i) || message.match(/라인\s*(\d+)/) || message.match(/(\d+)\s*줄/) || message.match(/Line\s+(\d+)/i);
let focusLine = monacoEditor.getPosition ? monacoEditor.getPosition().lineNumber : 1;
if (errorMatch) focusLine = parseInt(errorMatch[1]);
const contextLines = 30; // lines before and after focus
const startLine = Math.max(1, focusLine - contextLines);
const endLine = Math.min(lines.length, focusLine + contextLines);
const truncated = lines.slice(startLine - 1, endLine).map((l, i) => `${startLine + i}: ${l}`).join('\n');
// Also send structural overview: class/function/def lines so model knows the file structure
const structureLines = [];
const structureRegex = /^(class |def |async def |function |public |private |const |let |var |export |import |from |#[#]* )/i;
for (let i = 0; i < lines.length; i++) {
if (structureRegex.test(lines[i].trimStart())) {
structureLines.push(`${i + 1}: ${lines[i].trim()}`);
}
}
const structureOverview = structureLines.length > 0 ? `\n\n[파일 구조]\n${structureLines.join('\n')}\n` : '';
const omittedBefore = startLine - 1;
const omittedAfter = lines.length - endLine;
contextMsg = `[현재 파일: ${currentFile.name} (${lines.length}줄${omittedBefore ? `, 위 ${omittedBefore}줄 생략` : ''}${omittedAfter ? `, 아래 ${omittedAfter}줄 생략` : ''})]${structureOverview}\n\`\`\`${langHint}\n${truncated}\n\`\`\`\n⚠️ 이 파일을 수정하려면 전체 코드를 다시 출력하지 말고 \`\`\`diff 블록으로 변경된 줄만 출력하세요. - 로 삭제된 줄, + 로 추가된 줄, @@ 라인번호 @@ 헤더 포함. 들여쓰기가 맞는지 확인하세요.\n\n${message}`;
} else {
contextMsg = `[현재 파일: ${currentFile.name}]\n\`\`\`${langHint}\n${content}\n\`\`\`\n⚠️ 이 파일을 수정하려면 전체 코드를 다시 출력하지 말고 \`\`\`diff 블록으로 변경된 줄만 출력하세요.\n\n${message}`;
}
}
}
codeAiAppend('user', message);
codeAiHistory.push({ role: 'user', content: contextMsg });
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: contextMsg, history: historyForAPI, useTools: false, direct: true, sessionId: codeAiSessionId, codeAiMode: codeAiMode, ...(codeAiModel ? { model: codeAiModel } : {}) }),
});
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';
// Live streaming state (mirrored into the editor via codeLiveStreamUpdate)
// Pin the diff target to the file that was active when the user hit send —
// that's the file we attached as "[현재 파일: …]" context, so any returned
// diff/edit must land there rather than spawning a new tab.
const liveState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
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);
let token = null;
if (ev.type === 'token' && (ev.content || ev.text)) token = ev.content || ev.text;
else if (ev.type === 'text_delta' && ev.delta) token = ev.delta;
else if (ev.type === 'final' && (ev.text || ev.content)) { reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
// Handle tool_call / tool_result events for live editor update
else if (ev.type === 'tool_call' && ev.action === 'create_file') {
const cf = ev.args?.filename || ev.args?.name || '';
const cc = ev.args?.content || '';
if (cf && cc) codeEditorWriteFile(cf.replace(/^code\//, ''), cc);
}
else if (ev.type === 'tool_result' && ev.action === 'create_file' && !ev.error) {
codeSbRefresh();
}
if (token) { reply += token; thinkEl.textContent = reply; codeAiScrollBottom(); try { codeLiveStreamUpdate(reply, liveState); } catch(e) { console.error('[codeLiveStream] error:', e); thinkEl.textContent += '\n⚠️ 에디터 스트리밍 오류: ' + (e.message || e); } }
} catch(e) { console.error('[codeAiStream] parse error:', e.message || e, 'data:', data?.slice(0, 80)); }
}
}
liveState.inBlock = false;
codeMonacoThrottleFlush();
// Save streaming state before resetting
const streamFileId = liveState.fileId;
const streamOriginalContent = liveState.originalContent;
const streamIsDiff = liveState.isDiff;
const streamDiffAccumulated = liveState.diffAccumulated;
const streamModifiedText = liveState.streamingModifiedText || null;
liveState.fileId = null;
liveState.isDiff = false;
liveState.diffAccumulated = null;
liveState.streamingModifiedText = null;
// Clear streaming diff decorations
if (streamFileId) {
const file = codeFiles.find(f => f.id === streamFileId);
if (file?._streamingDecos) {
try { file.model.deltaDecorations(file._streamingDecos, []); } catch {}
file._streamingDecos = null;
}
}
if (_streamingDiffTimer) { clearTimeout(_streamingDiffTimer); _streamingDiffTimer = null; }
// After streaming completes: apply diff or show inline diff (Code mode only)
if (codeAiMode === 'code' && streamFileId) {
const file = codeFiles.find(f => f.id === streamFileId);
if (file) {
if (streamIsDiff && streamDiffAccumulated && streamOriginalContent != null) {
// Diff block: apply the unified diff to the original content
const newContent = applyUnifiedDiff(streamOriginalContent, streamDiffAccumulated);
if (streamOriginalContent !== newContent) {
if (file.model) file.model.setValue(streamOriginalContent);
else file.content = streamOriginalContent;
showCodeDiff(file.id, streamOriginalContent, newContent);
}
} else if (streamOriginalContent != null && streamOriginalContent.trim()) {
// Full code block: compare original vs streamed modified text
// Use streamingModifiedText (what the model output) instead of editor content
// (which may still show original if we used streaming diff decorations)
const newContent = streamModifiedText || (file.model ? file.model.getValue() : file.content);
if (streamOriginalContent !== newContent) {
// Restore original in editor, then show inline diff
if (file.model) file.model.setValue(streamOriginalContent);
else file.content = streamOriginalContent;
showCodeDiff(file.id, streamOriginalContent, newContent);
}
} else {
// New file created by AI — show approval bar
_showNewFileApproval(file.id, file.name);
}
}
}
codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
thinkEl.classList.remove('streaming'); // Stop cursor animation
if (!reply) { thinkEl.textContent = '(응답 없음)'; }
else codeAiRenderMarkdown(thinkEl, reply);
} 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;
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;
if (role === 'thinking') el.textContent = text;
else el.textContent = text;
// Add streaming class during live token output
if (role === 'thinking' || role === 'assistant') el.classList.add('streaming');
msgs.appendChild(el);
codeAiScrollBottom();
return el;
}
// ── Inline markdown rendering (bold, italic, inline code, links) ──────────
function _renderInlineMarkdown(text) {
// Escape HTML first
let html = escHtml(text);
// **bold**
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// *italic*
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// `inline code`
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--panel-2);padding:1px 4px;border-radius:3px;font-family:IBM Plex Mono,monospace;font-size:11px;">$1</code>');
// [link](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--brand);">$1</a>');
// Line breaks
html = html.replace(/\n/g, '<br>');
return html;
}
function _isDiffCode(lang, code) {
if (lang === 'diff') return true;
if (['text', 'sh', 'bash', 'shell', 'console'].includes(lang)) return false;
const lines = code.split('\n');
const nonEmpty = lines.filter(l => l.trim()).length;
if (nonEmpty === 0) return false;
const diffLines = lines.filter(l => /^[+\-] /.test(l) || l.startsWith('@@')).length;
return diffLines / nonEmpty > 0.3;
}
// Render AI message with markdown code blocks parsed out
// Uses the same line-start fence detection as codeLiveStreamUpdate to avoid
// false matches on ``` inside code (comments, strings, etc.)
function codeAiRenderMarkdown(el, text) {
el.innerHTML = '';
// Parse code blocks using line-start toggle parser (same as streaming)
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
const parts = []; // alternating: text, {lang, code}, text, {lang, code}, ...
let lastEnd = 0, inBlock = false, blockLang = '', blockStart = 0, m;
while ((m = fenceRe.exec(text)) !== null) {
// Require ``` at start of line (only whitespace before it)
const lineStart = m.index === 0 ? 0 : text.lastIndexOf('\n', m.index - 1) + 1;
const prefix = text.slice(lineStart, m.index);
if (prefix.trim() !== '') continue; // mid-line ```, skip
if (!inBlock) {
// Text before this opening fence
if (m.index > lastEnd) parts.push({ type: 'text', content: text.slice(lastEnd, m.index) });
blockLang = m[1] || 'text';
blockStart = m.index + m[0].length;
inBlock = true;
} else {
// Closing fence — emit the code block
parts.push({ type: 'code', lang: blockLang, code: text.slice(blockStart, m.index) });
lastEnd = m.index + m[0].length;
inBlock = false;
}
}
// Handle unclosed block (streaming or malformed)
if (inBlock) {
parts.push({ type: 'code', lang: blockLang, code: text.slice(blockStart) });
lastEnd = text.length;
}
// Trailing text after last block
if (lastEnd < text.length) parts.push({ type: 'text', content: text.slice(lastEnd) });
let codeCount = 0;
const codeBlocks = [];
_DBG && console.log(`[codeAiRenderMarkdown] parts=${parts.length} types=${parts.map(p=>p.type).join(',')}`);
for (const part of parts) {
if (part.type === 'code') {
const lang = part.lang || 'text';
const code = part.code;
codeBlocks.push({ lang, code });
const isDiff = _isDiffCode(lang, code);
// Extract filename from '# filename: ...' comment if present
let diffFilename = null;
if (isDiff) {
const m = code.match(/^#\s*filename:\s*(.+)/m);
if (m) diffFilename = m[1].trim().split('/').pop();
}
const wrapper = document.createElement('div');
wrapper.className = 'code-ai-code-block';
const header = document.createElement('div');
header.className = 'code-ai-code-header';
const langLabel = document.createElement('span');
langLabel.textContent = isDiff ? ('diff' + (diffFilename ? ' ' + diffFilename : '')) : (lang || 'code');
header.appendChild(langLabel);
let contentEl;
if (isDiff) {
contentEl = document.createElement('div');
contentEl.className = 'diff-block';
for (const line of code.split('\n')) {
const span = document.createElement('span');
span.className = 'diff-line' + (
line.startsWith('+') ? ' diff-line-add' :
line.startsWith('-') ? ' diff-line-del' :
line.startsWith('@@') ? ' diff-line-hunk' :
(line.startsWith('---') || line.startsWith('+++') || line.startsWith('#')) ? ' diff-line-file' : ''
);
span.textContent = line;
contentEl.appendChild(span);
}
} else {
contentEl = document.createElement('pre');
contentEl.textContent = code;
}
wrapper.appendChild(header);
wrapper.appendChild(contentEl);
if (isDiff) {
// Auto-apply diff to editor; no button needed
setTimeout(() => codeAiApplyDiff(code), 0);
} else {
const openBtn = document.createElement('button');
openBtn.className = 'code-ai-open-btn';
openBtn.textContent = '📂 에디터에서 열기';
openBtn.onclick = () => codeAiOpenInEditor(code, lang);
wrapper.appendChild(openBtn);
}
el.appendChild(wrapper);
codeCount++;
} else {
const span = document.createElement('span');
span.style.whiteSpace = 'pre-wrap';
span.innerHTML = _renderInlineMarkdown(part.content || '');
el.appendChild(span);
}
}
// Code blocks were already streamed into the editor by codeLiveStreamUpdate,
// so no auto-open here — just render the chat UI. The "📂 에디터에서 열기"
// button above lets users manually re-open a block if needed.
}
const CODE_EXT_MAP = {
// JavaScript / Node
javascript:'js', js:'js', node:'js', nodejs:'js',
typescript:'ts', ts:'ts', jsx:'jsx', tsx:'tsx',
// Python
python:'py', py:'py', python3:'py',
// C family
c:'c', 'c++':'cpp', cpp:'cpp', 'c#':'cs', csharp:'cs', cs:'cs',
// JVM
java:'java', kotlin:'kt', kt:'kt', scala:'scala', groovy:'groovy',
// Systems / compiled
rust:'rs', rs:'rs', go:'go', golang:'go', zig:'zig',
// Web
html:'html', css:'css', scss:'scss', less:'less', vue:'vue', svelte:'svelte',
// Data / Config
json:'json', yaml:'yml', yml:'yml', toml:'toml', xml:'xml', csv:'csv',
// Shell
shell:'sh', bash:'sh', sh:'sh', zsh:'sh', fish:'fish', powershell:'ps1', ps1:'ps1',
// Database
sql:'sql', sqlite:'sql', postgresql:'sql', mysql:'sql',
// Mobile / Other
swift:'swift', dart:'dart', lua:'lua', perl:'pl', r:'r',
ruby:'rb', rb:'rb', php:'php', elixir:'ex', erlang:'erl',
haskell:'hs', ocaml:'ml', fsharp:'fs',
// Docs
markdown:'md', md:'md', tex:'tex', latex:'tex',
};
// Decide whether a markdown code block is a real file worth opening in the editor.
// Filters out shell run-instructions (e.g. ```python code/tetris.py```) and tiny snippets.
function codeIsOpenable(code, lang) {
const trimmed = (code || '').trim();
if (!trimmed) return false;
const lines = trimmed.split('\n').filter(l => l.trim());
const l = (lang || '').toLowerCase();
const isShellLang = ['', 'text', 'console', 'sh', 'bash', 'shell', 'zsh', 'powershell', 'ps1'].includes(l);
const cmdRe = /^\s*[$#>]?\s*(python3?|pip3?|npm|npx|node|yarn|pnpm|deno|bun|cd|ls|dir|cat|echo|curl|wget|git|chmod|mkdir|rm|cp|mv|source|sudo|export|set|run|\.\/)\b/i;
// Single line: skip shell langs and short command-looking lines
if (lines.length <= 1) {
if (isShellLang) return false;
if (cmdRe.test(trimmed)) return false;
if (trimmed.length < 40) return false;
}
// Short shell block where every line is a command → run instructions, not a file
if (isShellLang && lines.length <= 3 && lines.every(ln => cmdRe.test(ln))) return false;
return true;
}
// Derive a filename from an embedded `# filename:` hint, or infer from code content.
const GENERIC_NAMES = new Set(['init','main','app','index','test','run','start','setup','util','helper','config','handler','utils','helpers','main']);
function codeDeriveName(code, lang) {
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i)
|| (code || '').match(/^(?:#|\/\/)\s*(\w+\.\w+)/m);
if (m) return m[1].replace(/[*\/>]+$/, '');
// Infer filename from code content when no explicit hint
const l = (lang || '').toLowerCase();
const ext = CODE_EXT_MAP[l] || ((l && l.length <= 6) ? l : 'txt');
// Try multiple strategies to find a meaningful name
// 1. HTML <title> tag
const htmlTitleMatch = code.match(/<title>([^<]+)<\/title>/i);
// 2. Class/struct/interface name
const classMatch = code.match(/(?:class|struct|interface)\s+([A-Z]\w+)/);
// 3. First function name that's not too generic
const funcMatches = [...code.matchAll(/(?:def|function|func|fn)\s+([a-zA-Z_]\w*)/g)].map(m => m[1]);
const specificFunc = funcMatches.find(f => !GENERIC_NAMES.has(f.toLowerCase()));
// 4. Title/game/app comment
const titleMatch = code.match(/(?:title|게임|프로그램|app|application)[:\s]+["']?([a-zA-Z0-9_가-힣]+)["']?/i);
// 5. <h1> heading
const h1Match = code.match(/<h[12][^>]*>([^<]{2,30})<\/h[12]>/i);
const candidates = [
htmlTitleMatch && htmlTitleMatch[1].replace(/[가-힣]/g, '').trim(),
classMatch && classMatch[1],
specificFunc,
titleMatch && titleMatch[1].replace(/[가-힣]/g, ''),
h1Match && h1Match[1].replace(/[가-힣]/g, '').trim(),
].filter(Boolean);
// Pick the first specific (non-generic) name
for (const name of candidates) {
const cleaned = name.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
if (cleaned.length >= 3 && cleaned.length <= 30 && !GENERIC_NAMES.has(cleaned)) {
return cleaned + '.' + ext;
}
}
// Fallback: use any candidate even if generic, or random
if (candidates.length > 0) {
const fallback = candidates[0].replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
if (fallback.length >= 2) return fallback + '.' + ext;
}
return `ai_${Math.random().toString(36).slice(2,7)}.${ext}`;
}
// Write an exact (filename, content) pair into the Monaco editor immediately,
// creating or updating the tab. Used to mirror the model's create_file in real time.
function codeEditorWriteFile(fname, content) {
if (!monacoReady || !monacoEditor) {
codePendingQueue.push({ fname, content, lang: codeDetectLang(fname) });
initCodeView();
return;
}
const existing = codeFiles.find(f => f.name === fname);
if (existing) {
const oldContent = existing.model ? existing.model.getValue() : existing.content;
if (oldContent !== content) {
// Show diff for modifications to existing files
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, content);
}
// The diff bar's accept/reject (acceptAllHunks/rejectAllHunks) commits the model
} else {
codeNewFile(fname, content);
}
}
function codeAiApplyDiff(diffCode) {
if (!monacoReady || !monacoEditor) return;
// Extract target filename from diff headers
let targetName = null;
const filenameM = diffCode.match(/^#\s*filename:\s*(.+)/m);
if (filenameM) targetName = filenameM[1].trim();
if (!targetName) {
const m = diffCode.match(/^diff --git a\/(\S+)/m) || diffCode.match(/^--- a\/(\S+)/m);
if (m && !m[1].startsWith('/dev/null')) targetName = m[1];
}
let targetFile = null;
if (targetName) {
const basename = targetName.split('/').pop();
targetFile = codeFiles.find(f => f.name === targetName) || codeFiles.find(f => f.name === basename);
if (!targetFile) return; // 에디터에 없으면 적용 안 함
} else {
targetFile = codeFiles.find(f => f.id === activeCodeFileId);
}
if (!targetFile) return;
// Skip if streaming already applied a diff for this file
if (codeDiffState[targetFile.id]) return;
const originalContent = targetFile.model ? targetFile.model.getValue() : (targetFile.content || '');
const newContent = applyUnifiedDiff(originalContent, diffCode);
if (originalContent === newContent) return;
switchCodeFile(targetFile.id);
showCodeDiff(targetFile.id, originalContent, newContent);
}
function codeAiOpenInEditor(code, lang) {
_DBG && console.log(`[codeAiOpenInEditor] lang=${lang} lines=${code?.split('\n').length} openable=${codeIsOpenable(code, lang)} monacoReady=${monacoReady}`);
if (!codeIsOpenable(code, lang)) { _DBG && console.log('[codeAiOpenInEditor] NOT openable, returning'); return; }
if (!monacoReady || !monacoEditor) {
const fname = codeDeriveName(code, lang);
_DBG && console.log(`[codeAiOpenInEditor] Monaco not ready, queueing fname=${fname}`);
codePendingQueue.push({ fname, content: code, lang });
initCodeView();
return;
}
const fname = codeDeriveName(code, lang);
const existing = codeFiles.find(f => f.name === fname);
if (existing) {
const oldContent = existing.model ? existing.model.getValue() : existing.content;
if (oldContent !== code && code.length > 0) {
// Content differs — show an inline diff for review (line/block level) rather
// than replacing wholesale, consistent with the streaming edit path.
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, code);
} else {
// Same content — just switch to the tab
switchCodeFile(existing.id);
}
} else {
codeNewFile(fname, code);
}
}
// ── Code Diff View (Inline decorations, VSCode Copilot style) ────────────
// Compute diff hunks between original and modified text using LCS algorithm.
// Returns an array of hunks: [{ oldStart, oldEnd, newStart, newEnd, type }]
function computeDiffHunks(originalText, modifiedText) {
const oldLines = (originalText || '').split('\n');
const newLines = (modifiedText || '').split('\n');
const m = oldLines.length, n = newLines.length;
// The LCS table below is O(m*n) memory. Only collapse to a single coarse,
// whole-file hunk when it would genuinely blow up — ~5000×5000 lines is still
// only ~50MB of Uint16, so normal large files keep granular line-level hunks.
// Beyond that we fall back rather than allocating gigabytes / freezing the tab.
if (m * n > 25_000_000) {
if (originalText === modifiedText) return [];
const type = m === 0 ? 'add' : n === 0 ? 'delete' : 'change';
return [{ oldStart: 1, oldEnd: m, newStart: 1, newEnd: n, type }];
}
// Build LCS table (full m×n; rows are Uint16Array to keep it compact)
const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1));
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
dp[i][j] = oldLines[i] === newLines[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
// Walk back to find matching lines
const matchedOld = new Set(), matchedNew = new Set();
let oi = 0, ni = 0;
while (oi < m && ni < n) {
if (oldLines[oi] === newLines[ni]) { matchedOld.add(oi); matchedNew.add(ni); oi++; ni++; }
else if (dp[oi + 1][ni] >= dp[oi][ni + 1]) oi++;
else ni++;
}
// Group consecutive changes into hunks (merge hunks within 3 lines)
const hunks = [];
oi = 0; ni = 0;
while (oi < m || ni < n) {
// Skip matching lines
while (oi < m && ni < n && matchedOld.has(oi) && matchedNew.has(ni) && oldLines[oi] === newLines[ni]) { oi++; ni++; }
if (oi >= m && ni >= n) break;
const hOldStart = oi, hNewStart = ni;
let hOldEnd = oi, hNewEnd = ni;
// Collect changed lines, extending through nearby matches (within 3 lines)
while ((oi < m && !matchedOld.has(oi)) || (ni < n && !matchedNew.has(ni))) {
if (oi < m && !matchedOld.has(oi)) { hOldEnd = oi + 1; oi++; }
else if (ni < n && !matchedNew.has(ni)) { hNewEnd = ni + 1; ni++; }
else break;
// Extend through matching lines if next change is within 3 lines
let extended = true;
while (extended) {
extended = false;
let matchCount = 0;
while (oi < m && ni < n && matchedOld.has(oi) && matchedNew.has(ni) && oldLines[oi] === newLines[ni]) {
hOldEnd = oi + 1; hNewEnd = ni + 1; oi++; ni++; matchCount++;
// Check if next change is within 3 lines
let peekO = oi, peekN = ni, hasNearChange = false;
for (let k = 0; k < 3 && peekO < m && peekN < n; k++) {
if ((peekO < m && !matchedOld.has(peekO)) || (peekN < n && !matchedNew.has(peekN))) { hasNearChange = true; break; }
peekO++; peekN++;
}
if (hasNearChange) extended = true;
else if (matchCount >= 3) break;
}
}
}
if (hOldEnd > hOldStart || hNewEnd > hNewStart) {
const type = hOldEnd === hOldStart ? 'add' : hNewEnd === hNewStart ? 'delete' : 'change';
hunks.push({ oldStart: hOldStart + 1, oldEnd: hOldEnd, newStart: hNewStart + 1, newEnd: hNewEnd, type });
}
}
return hunks;
}
// Show inline diff decorations in the regular editor (VSCode Copilot style)
function showCodeDiff(fileId, originalText, modifiedText, onReject) {
if (!monacoReady || !window.monaco || !monacoEditor) return;
const file = codeFiles.find(f => f.id === fileId);
if (!file) return;
// Clean up any existing diff for this file
cleanupCodeDiff(fileId);
// Compute diff hunks
const hunks = computeDiffHunks(originalText, modifiedText);
if (!hunks.length) return; // No changes
// Store diff state. `hunks` is computed ONCE here and stays stable for the
// lifetime of the review — accept/reject only flip per-hunk decisions:
// resolved = hunks the user has decided on (accepted OR rejected)
// rejected = subset of resolved that was rejected (restore original lines)
// The editor content is rebuilt from these decisions (see _rebuildDiffContent),
// so an accepted hunk never reappears when a later hunk is rejected.
codeDiffState[fileId] = {
originalText, modifiedText, hunks,
decorationIds: [], viewZoneIds: [],
resolved: new Set(), rejected: new Set(), onReject: onReject || null
};
// Set model to modified text (the "new" version)
if (file.model) file.model.setValue(modifiedText);
else file.content = modifiedText;
// Switch to this file and apply decorations
switchCodeFile(fileId);
_applyInlineDiffDecorations(fileId);
_showDiffBar(fileId);
renderCodeFileTabs();
}
// Rebuild the editor content from the current accept/reject decisions.
// Walks the stable hunks in order: unchanged lines come straight from the
// original, rejected hunks restore the original lines, accepted/pending hunks
// use the modified lines. Returns the assembled text plus, for each still-pending
// hunk, where it lands in that text (so decorations can be placed accurately).
function _rebuildDiffContent(ds) {
const oldLines = ds.originalText.split('\n');
const newLines = ds.modifiedText.split('\n');
const out = [];
const pendingRanges = [];
let cursor = 0; // 0-based cursor into oldLines
const ordered = ds.hunks.map((h, i) => ({ h, i })).sort((a, b) => a.h.oldStart - b.h.oldStart);
for (const { h, i } of ordered) {
// Unchanged original lines before this hunk
while (cursor < h.oldStart - 1 && cursor < oldLines.length) out.push(oldLines[cursor++]);
if (ds.rejected.has(i)) {
// Rejected → keep the original lines
for (let l = h.oldStart - 1; l < h.oldEnd && l < oldLines.length; l++) out.push(oldLines[l]);
} else {
// Accepted or pending → use the modified lines
const phantomAfter = out.length; // rebuilt line number just above the new block
for (let l = h.newStart - 1; l < h.newEnd && l < newLines.length; l++) out.push(newLines[l]);
if (!ds.resolved.has(i)) {
pendingRanges.push({ hunkIndex: i, hunk: h, greenStart: phantomAfter + 1, greenEnd: out.length, phantomAfter });
}
}
cursor = h.oldEnd;
}
while (cursor < oldLines.length) out.push(oldLines[cursor++]);
return { content: out.join('\n'), pendingRanges };
}
// Apply inline diff decorations to the editor for a given file
function _applyInlineDiffDecorations(fileId) {
const ds = codeDiffState[fileId];
if (!ds || !monacoEditor) return;
const file = codeFiles.find(f => f.id === fileId);
if (!file || file.id !== activeCodeFileId) return;
// Remove old decorations, view zones, and widgets
_removeInlineDiffDecorations(fileId);
// Rebuild content reflecting current decisions, then decorate the pending hunks
// at their rebuilt positions.
const { content, pendingRanges } = _rebuildDiffContent(ds);
if (file.model) { if (file.model.getValue() !== content) file.model.setValue(content); }
else file.content = content;
const contentLines = content.split('\n');
const oldLines = ds.originalText.split('\n');
const decorations = [];
for (const pr of pendingRanges) {
// Green highlight for added/changed lines
for (let line = pr.greenStart; line <= pr.greenEnd && line <= contentLines.length; line++) {
decorations.push({
range: new monaco.Range(line, 1, line, 1),
options: {
className: 'inline-diff-added',
glyphMarginClassName: 'inline-diff-glyph-added',
isWholeLine: true,
minimap: { color: '#28a74580', position: monaco.editor.MinimapPosition.Inline },
overviewRuler: { color: '#28a74560', position: monaco.editor.OverviewRulerLane.Full },
}
});
}
// View zone for deleted lines (red "phantom" lines), placed directly ABOVE
// the green block (phantomAfter is the line number just before it).
if (pr.hunk.type === 'delete' || pr.hunk.type === 'change') {
const deletedLines = oldLines.slice(pr.hunk.oldStart - 1, pr.hunk.oldEnd);
if (deletedLines.length && monacoEditor.changeViewZones) {
monacoEditor.changeViewZones(ca => {
const div = document.createElement('div');
div.className = 'inline-diff-deleted-zone';
div.innerHTML = deletedLines.map(line =>
'<div class="inline-diff-deleted-line">' + escHtml(line) + '</div>'
).join('');
ds.viewZoneIds.push(ca.addZone({
afterLineNumber: pr.phantomAfter,
afterColumn: 1,
heightInLines: Math.max(deletedLines.length, 1),
domNode: div,
}));
});
}
}
}
// Apply decorations
ds.decorationIds = monacoEditor.deltaDecorations([], decorations);
// Add hunk action button overlays (anchor to the green block, or the gap for pure deletes)
for (const pr of pendingRanges) {
const anchor = pr.greenStart <= pr.greenEnd ? pr.greenStart : pr.phantomAfter + 1;
const line = Math.min(Math.max(1, anchor), contentLines.length);
_addHunkActionsWidget(fileId, pr.hunkIndex, line);
}
// Update diff bar info
_updateDiffBarInfo(fileId);
// Scroll to first pending hunk
if (pendingRanges.length) {
const pr = pendingRanges[0];
const anchor = pr.greenStart <= pr.greenEnd ? pr.greenStart : pr.phantomAfter + 1;
monacoEditor.revealLineInCenter(Math.min(Math.max(1, anchor), contentLines.length));
}
}
// Remove all inline diff decorations, view zones, and widgets for a file
function _removeInlineDiffDecorations(fileId) {
const ds = codeDiffState[fileId];
if (!ds) return;
// Remove decorations
if (ds.decorationIds.length && monacoEditor) {
monacoEditor.deltaDecorations(ds.decorationIds, []);
ds.decorationIds = [];
}
// Remove view zones
if (ds.viewZoneIds.length && monacoEditor.changeViewZones) {
monacoEditor.changeViewZones(ca => {
for (const id of ds.viewZoneIds) ca.removeZone(id);
});
ds.viewZoneIds = [];
}
// Remove hunk action button overlays from DOM
document.querySelectorAll('.inline-diff-hunk-actions[data-file-id="' + fileId + '"]').forEach(el => el.remove());
// Dispose disposables
for (const key of Object.keys(ds)) {
if (key.startsWith('_hunkDisposables_')) {
const disposables = ds[key];
if (Array.isArray(disposables)) disposables.forEach(d => { try { d.dispose(); } catch {} });
}
}
}
// Add hunk action buttons as a positioned overlay
function _addHunkActionsWidget(fileId, hunkIndex, line) {
const ds = codeDiffState[fileId];
if (!ds || !monacoEditor) return;
const actions = document.createElement('div');
actions.className = 'inline-diff-hunk-actions';
actions.dataset.fileId = fileId;
actions.dataset.hunkIndex = hunkIndex;
actions.innerHTML = '<button class="inline-diff-accept-btn" onclick="acceptHunk(\'' + fileId + '\',' + hunkIndex + ')" title="이 변경 적용">✅</button>' +
'<button class="inline-diff-reject-btn" onclick="rejectHunk(\'' + fileId + '\',' + hunkIndex + ')" title="이 변경 취소">❌</button>';
const editorContainer = document.getElementById('code-editor-container');
if (editorContainer) editorContainer.appendChild(actions);
// Position the widget at the line
const updatePosition = () => {
if (!monacoEditor || !document.body.contains(actions)) return;
try {
const lineTop = monacoEditor.getTopForLineNumber(line);
const layoutInfo = monacoEditor.getLayoutInfo();
const contentLeft = layoutInfo.contentLeft;
const scrollTop = monacoEditor.getScrollTop();
actions.style.top = (lineTop - scrollTop) + 'px';
actions.style.left = contentLeft + 'px';
actions.style.transform = 'translateY(-100%)';
} catch {}
};
updatePosition();
const scrollDisp = monacoEditor.onDidScrollChange(updatePosition);
const layoutDisp = monacoEditor.onDidLayoutChange(updatePosition);
ds['_hunkDisposables_' + hunkIndex] = [scrollDisp, layoutDisp];
}
// Show the diff action bar at the top
function _showDiffBar(fileId) {
const bar = document.getElementById('code-diff-bar');
const label = document.getElementById('code-diff-label');
if (!bar || !label) return;
label.textContent = '🔀 AI 제안 변경 사항';
const acceptBtn = document.getElementById('code-diff-accept');
const rejectBtn = document.getElementById('code-diff-reject');
if (acceptBtn) { acceptBtn.textContent = '✅ 모두 적용'; acceptBtn.onclick = () => acceptAllHunks(); }
if (rejectBtn) { rejectBtn.textContent = '❌ 모두 취소'; rejectBtn.onclick = () => rejectAllHunks(); }
bar.style.display = 'flex';
// Editor stays visible (no DiffEditor overlay)
const editorEl = document.getElementById('code-editor-container');
if (editorEl) editorEl.style.display = '';
}
// Update the diff bar with hunk count info
function _updateDiffBarInfo(fileId) {
const ds = codeDiffState[fileId];
if (!ds) return;
const unresolved = ds.hunks.filter((_, i) => !ds.resolved.has(i)).length;
const infoEl = document.getElementById('code-diff-hunk-info');
if (infoEl) infoEl.textContent = unresolved > 0 ? (unresolved + '개 변경') : '모두 해결됨';
}
// Accept a single hunk — keep its modified lines. Decisions are just flags;
// the editor content is rebuilt from them, so accepting one hunk never disturbs
// the others.
function acceptHunk(fileId, hunkIndex) {
const ds = codeDiffState[fileId];
if (!ds || ds.resolved.has(hunkIndex)) return;
ds.resolved.add(hunkIndex);
ds.rejected.delete(hunkIndex);
_applyInlineDiffDecorations(fileId);
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
}
// Reject a single hunk — restore its original lines. Other hunks (including ones
// already accepted) keep their decisions because the rebuild is decision-driven.
function rejectHunk(fileId, hunkIndex) {
const ds = codeDiffState[fileId];
if (!ds || ds.resolved.has(hunkIndex)) return;
ds.resolved.add(hunkIndex);
ds.rejected.add(hunkIndex);
_applyInlineDiffDecorations(fileId);
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
}
// Accept all hunks — keep the modified text as-is
function acceptAllHunks() {
const fileId = activeCodeFileId;
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
_applyInlineDiffDecorations(fileId); // rebuild model to the fully-accepted content
finalizeInlineDiff(fileId, true);
}
// Reject all hunks — restore the original text
function rejectAllHunks() {
const fileId = activeCodeFileId;
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
_applyInlineDiffDecorations(fileId); // rebuild model back to the original content
if (ds.onReject) ds.onReject();
finalizeInlineDiff(fileId, true);
}
// Persist a specific file to workspace/code/ on disk. Accepting a diff only
// updated the Monaco model in memory — without this the .py file kept the old
// code, so re-running showed no change ("diff가 안 됨").
async function codeSaveFileById(fileId) {
const file = codeFiles.find(f => f.id === fileId);
if (!file) return;
const content = (file.model ? file.model.getValue() : file.content) || '';
file.content = content;
if (!localDirHandle) {
codeAiAppend('system', `⚠️ 폴더 미선택 — ${file.name} 저장 생략. 🖥️ 버튼으로 로컬 폴더를 선택해주세요.`);
return;
}
const ok = await localWriteFile(file.name, content);
if (ok) codeAiAppend('system', `💾 저장: ${file.name}`);
else codeAiAppend('system', `⚠️ 저장 실패: ${file.name}`);
}
// Finalize inline diff — clean up all decorations and state.
// `save` persists the accepted result to disk (true for accepts, false for rejects).
function finalizeInlineDiff(fileId, save) {
const ds = codeDiffState[fileId];
if (!ds) return;
_removeInlineDiffDecorations(fileId);
delete codeDiffState[fileId];
// Auto-save accepted changes so the on-disk file matches the editor
if (save) codeSaveFileById(fileId);
// Hide diff bar
const bar = document.getElementById('code-diff-bar');
if (bar) bar.style.display = 'none';
// Restore normal editor focus
const file = codeFiles.find(f => f.id === fileId);
if (file && monacoEditor && file.id === activeCodeFileId) {
monacoEditor.setModel(file.model);
monacoEditor.focus();
}
renderCodeFileTabs();
}
function cleanupCodeDiff(fileId) {
finalizeInlineDiff(fileId);
}
// ── New file approval bar ────────────────────────────────────────────────
function _showNewFileApproval(fileId, fileName) {
const file = codeFiles.find(f => f.id === fileId);
if (!file) return;
switchCodeFile(fileId);
const bar = document.getElementById('code-diff-bar');
const label = document.getElementById('code-diff-label');
if (!bar || !label) return;
label.textContent = '📝 새 파일: ' + fileName;
const acceptBtn = document.getElementById('code-diff-accept');
const rejectBtn = document.getElementById('code-diff-reject');
if (acceptBtn) {
acceptBtn.textContent = '✅ 만들기';
acceptBtn.onclick = () => { bar.style.display = 'none'; codeSaveFileById(fileId); };
}
if (rejectBtn) {
rejectBtn.textContent = '❌ 취소';
rejectBtn.onclick = () => {
const idx = codeFiles.findIndex(f => f.id === fileId);
if (idx >= 0) {
if (codeFiles[idx].model) codeFiles[idx].model.dispose();
codeFiles.splice(idx, 1);
}
if (codeDiffState[fileId]) cleanupCodeDiff(fileId);
bar.style.display = 'none';
if (codeFiles.length > 0) switchCodeFile(codeFiles[0].id);
renderCodeFileTabs();
};
}
bar.style.display = 'flex';
}
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 = [];
}
// ── Code sidebar panel ────────────────────────────────────────────────────
let codeSbCurrentTab = 'files';
function setCodeSbTab(tab) {
codeSbCurrentTab = tab;
['files','model','settings'].forEach(t => {
document.getElementById('csb-tab-' + t).classList.toggle('active', t === tab);
const el = document.getElementById('csb-' + t);
if (el) el.style.display = t === tab ? 'flex' : 'none';
});
if (tab === 'model') csbLoadModels();
}
async function codeSbRefresh() {
const tree = document.getElementById('csb-file-tree');
if (!tree) return;
tree.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">로딩 중...</div>';
if (!localDirHandle) {
tree.innerHTML = `<div style="padding:14px 10px;font-size:11px;color:var(--muted);text-align:center">
<div style="font-size:22px;margin-bottom:6px">🖥️</div>
<div style="margin-bottom:10px">로컬 폴더를 선택하세요</div>
<button onclick="openLocalFolder()" style="background:var(--brand);color:#fff;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-size:11px;font-weight:700">📁 폴더 열기</button>
</div>`;
return;
}
try {
const files = await localListFiles(localDirHandle, '');
if (!files.length) {
tree.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">파일 없음<br><small>+ 버튼으로 추가</small></div>';
return;
}
const extIcon = { py:'🐍', js:'🟨', ts:'🔷', cpp:'⚙️', c:'⚙️', cs:'💜',
go:'🔵', rs:'🦀', java:'☕', html:'🌐', css:'🎨', sh:'💻', bash:'💻',
kt:'🎯', swift:'🍎', rb:'💎', php:'🐘', sql:'🗃️', md:'📝', json:'📋',
yaml:'📋', yml:'📋', txt:'📄' };
// 트리 노드 구조 빌드
const root = {};
for (const f of files) {
const parts = f.split('/');
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
if (!node[parts[i]]) node[parts[i]] = {};
node = node[parts[i]];
}
node[parts[parts.length - 1]] = f; // leaf = full path string
}
const openDirs = csbGetOpenDirs();
function renderNode(node, depth, parentPath) {
const indent = depth * 14;
let html = '';
// 디렉토리 먼저, 파일 나중
const dirs = [], leafs = [];
for (const [k, v] of Object.entries(node)) {
if (typeof v === 'object') dirs.push([k, v]);
else leafs.push([k, v]);
}
dirs.sort((a,b) => a[0].localeCompare(b[0]));
leafs.sort((a,b) => a[0].localeCompare(b[0]));
for (const [name, children] of dirs) {
const dirId = 'csbdir_' + (parentPath+'/'+name).replace(/[^a-z0-9]/gi,'_');
const isOpen = openDirs.has(dirId);
const dirPath = (parentPath+'/'+name).replace(/^\//,'');
const safeDirPath = dirPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
html += `<div class="csb-file-item" style="padding-left:${indent+8}px;font-weight:600;color:var(--muted)" onclick="csbToggleDir('${dirId}')">
<span id="${dirId}_arrow" style="font-size:9px;transition:.15s;flex-shrink:0">${isOpen ? '▼' : '▶'}</span><span style="flex-shrink:0">📂</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escHtml(name)}/</span>
<button class="csb-del" onclick="csbDeleteDir('${safeDirPath}',event)" title="디렉토리 삭제">✕</button>
</div>
<div id="${dirId}" style="display:${isOpen ? 'block' : 'none'}">
${renderNode(children, depth+1, parentPath+'/'+name)}
</div>`;
}
for (const [name, fullPath] of leafs) {
const ext = name.split('.').pop().toLowerCase();
const icon = extIcon[ext] || '📄';
const safe = fullPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
html += `<div class="csb-file-item" style="padding-left:${indent+8}px" onclick="csbOpenFile('${safe}')">
<span style="flex-shrink:0">${icon}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${escHtml(fullPath)}">${escHtml(name)}</span>
<button class="csb-del" onclick="csbDeleteFile('${safe}',event)" title="삭제">✕</button>
</div>`;
}
return html;
}
tree.innerHTML = renderNode(root, 0, '');
} catch {
tree.innerHTML = '<div style="padding:12px;color:#f87171;font-size:11px;text-align:center">불러오기 실패</div>';
}
}
function csbGetOpenDirs() {
try { return new Set(JSON.parse(localStorage.getItem('csb_open_dirs') || '[]')); } catch { return new Set(); }
}
function csbSaveOpenDirs(set) {
try { localStorage.setItem('csb_open_dirs', JSON.stringify([...set])); } catch {}
}
function csbToggleDir(id) {
const el = document.getElementById(id);
const arrow = document.getElementById(id + '_arrow');
if (!el) return;
const collapsed = el.style.display === 'none';
el.style.display = collapsed ? 'block' : 'none';
if (arrow) arrow.textContent = collapsed ? '▼' : '▶';
const dirs = csbGetOpenDirs();
if (collapsed) dirs.add(id); else dirs.delete(id);
csbSaveOpenDirs(dirs);
}
async function csbOpenFile(filename) {
if (!localDirHandle) return;
try {
const content = await localReadFile(filename);
if (content === null) return;
const existing = codeFiles.find(f => f.name === filename);
if (existing) switchCodeFile(existing.id);
else codeNewFile(filename, content);
} catch {}
}
async function csbDeleteFile(filename, ev) {
ev.stopPropagation();
if (!localDirHandle || !confirm(`"${filename}" 삭제할까요?`)) return;
try {
const ok = await localDeleteFile(filename);
if (ok) {
const existing = codeFiles.find(f => f.name === filename);
if (existing) closeCodeFile(existing.id);
codeSbRefresh();
}
} catch {}
}
async function csbDeleteDir(dirPath, ev) {
ev.stopPropagation();
if (!localDirHandle || !confirm(`"${dirPath}" 디렉토리와 내부 파일을 모두 삭제할까요?`)) return;
try {
const parts = dirPath.split('/').filter(Boolean);
let parent = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) parent = await parent.getDirectoryHandle(parts[i]);
await parent.removeEntry(parts[parts.length - 1], { recursive: true });
// 해당 디렉토리 아래 열려 있는 탭 모두 닫기
const prefix = dirPath.endsWith('/') ? dirPath : dirPath + '/';
for (const f of [...codeFiles]) {
if (f.name === dirPath || f.name.startsWith(prefix)) closeCodeFile(f.id);
}
codeSbRefresh();
} catch (e) {
alert('삭제 실패: ' + e.message);
}
}
async function csbLoadModels() {
const sel = document.getElementById('csb-model-select');
if (!sel) return;
try {
const [settingRes, ollamaRes] = await Promise.all([
fetch('/api/settings/model', { headers: authHeaders() }),
fetch('/api/ollama/models', { headers: authHeaders() }),
]);
const setting = settingRes.ok ? await settingRes.json() : {};
const ollamaData = ollamaRes.ok ? await ollamaRes.json() : {};
const primaryModel = setting.primary || '설정 모델';
const ollamaModels = (ollamaData.models || []).map(m => m.name);
sel.innerHTML = `<option value="">기본 (${primaryModel})</option>`;
const allModels = [...ollamaModels];
if (codeAiModel && !allModels.includes(codeAiModel)) allModels.unshift(codeAiModel);
for (const m of allModels) {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m;
if (m === codeAiModel) opt.selected = true;
sel.appendChild(opt);
}
if (!codeAiModel) sel.value = '';
updateCodeAiLabel();
} catch {}
}
function csbModelChanged() {
const sel = document.getElementById('csb-model-select');
codeAiModel = sel ? sel.value : '';
localStorage.setItem('codeAiModel', codeAiModel);
updateCodeAiLabel();
}
function csbFontSize(val) {
document.getElementById('csb-fontsize-label').textContent = val;
if (monacoEditor) monacoEditor.updateOptions({ fontSize: parseInt(val) });
localStorage.setItem('codeEditorFontSize', val);
}
function csbWordWrap(checked) {
if (monacoEditor) monacoEditor.updateOptions({ wordWrap: checked ? 'on' : 'off' });
localStorage.setItem('codeEditorWordWrap', checked ? '1' : '0');
}
const CODE_THEME_GROUPS = [
{ icon: '🌙', label: '다크', themes: [
{ v: 'vs-dark', label: 'VS Dark', bg: '#1e1e1e' },
{ v: 'one-dark', label: 'One Dark', bg: '#282c34' },
{ v: 'nord', label: 'Nord', bg: '#2e3440' },
{ v: 'github-dark', label: 'GitHub Dark', bg: '#0d1117' },
{ v: 'tomorrow-night', label: 'Tomorrow Night',bg: '#1d1f21' },
{ v: 'hc-black', label: 'High Contrast', bg: '#000000' },
]},
{ icon: '☀️', label: '라이트', themes: [
{ v: 'vs', label: 'VS Light', bg: '#ffffff' },
{ v: 'github-light', label: 'GitHub Light',bg: '#f6f8fa' },
]},
{ icon: '🌿', label: '웜', themes: [
{ v: 'monokai', label: 'Monokai', bg: '#272822' },
{ v: 'dracula', label: 'Dracula', bg: '#282a36' },
{ v: 'solarized-dark', label: 'Solarized Dark', bg: '#002b36' },
{ v: 'solarized-light', label: 'Solarized Light',bg: '#fdf6e3' },
]},
];
const CODE_THEMES = CODE_THEME_GROUPS.flatMap(g => g.themes);
function defineCodeThemes() {
const d = monaco.editor.defineTheme.bind(monaco.editor);
d('monokai', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'f92672'},{token:'string',foreground:'e6db74'},
{token:'comment',foreground:'75715e',fontStyle:'italic'},{token:'number',foreground:'ae81ff'},
{token:'type',foreground:'66d9ef'},{token:'function',foreground:'a6e22e'},
], colors:{'editor.background':'#272822','editor.foreground':'#f8f8f2','editor.selectionBackground':'#49483e','editorCursor.foreground':'#f8f8f0'}});
d('dracula', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'ff79c6'},{token:'string',foreground:'f1fa8c'},
{token:'comment',foreground:'6272a4',fontStyle:'italic'},{token:'number',foreground:'bd93f9'},
{token:'type',foreground:'8be9fd'},{token:'function',foreground:'50fa7b'},
], colors:{'editor.background':'#282a36','editor.foreground':'#f8f8f2','editor.selectionBackground':'#44475a','editorCursor.foreground':'#f8f8f2','editor.lineHighlightBackground':'#44475a'}});
d('one-dark', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'c678dd'},{token:'string',foreground:'98c379'},
{token:'comment',foreground:'5c6370',fontStyle:'italic'},{token:'number',foreground:'d19a66'},
{token:'type',foreground:'e5c07b'},{token:'function',foreground:'61afef'},
], colors:{'editor.background':'#282c34','editor.foreground':'#abb2bf','editor.selectionBackground':'#3e4451','editorCursor.foreground':'#528bff'}});
d('nord', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'81a1c1'},{token:'string',foreground:'a3be8c'},
{token:'comment',foreground:'4c566a',fontStyle:'italic'},{token:'number',foreground:'b48ead'},
{token:'type',foreground:'8fbcbb'},{token:'function',foreground:'88c0d0'},
], colors:{'editor.background':'#2e3440','editor.foreground':'#d8dee9','editor.selectionBackground':'#3b4252','editorCursor.foreground':'#d8dee9','editor.lineHighlightBackground':'#3b4252'}});
d('github-dark', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'ff7b72'},{token:'string',foreground:'a5d6ff'},
{token:'comment',foreground:'8b949e',fontStyle:'italic'},{token:'number',foreground:'79c0ff'},
{token:'type',foreground:'ffa657'},{token:'function',foreground:'d2a8ff'},
], colors:{'editor.background':'#0d1117','editor.foreground':'#c9d1d9','editor.selectionBackground':'#163b55','editorCursor.foreground':'#e6edf3'}});
d('tomorrow-night', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'b294bb'},{token:'string',foreground:'b5bd68'},
{token:'comment',foreground:'969896',fontStyle:'italic'},{token:'number',foreground:'de935f'},
{token:'type',foreground:'81a2be'},{token:'function',foreground:'81a2be'},
], colors:{'editor.background':'#1d1f21','editor.foreground':'#c5c8c6','editor.selectionBackground':'#373b41','editorCursor.foreground':'#aeafad'}});
d('solarized-dark', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'859900'},{token:'string',foreground:'2aa198'},
{token:'comment',foreground:'657b83',fontStyle:'italic'},{token:'number',foreground:'d33682'},
{token:'type',foreground:'b58900'},{token:'function',foreground:'268bd2'},
], colors:{'editor.background':'#002b36','editor.foreground':'#839496','editor.selectionBackground':'#073642','editorCursor.foreground':'#839496'}});
d('github-light', { base:'vs', inherit:true, rules:[
{token:'keyword',foreground:'d73a49'},{token:'string',foreground:'032f62'},
{token:'comment',foreground:'6a737d',fontStyle:'italic'},{token:'number',foreground:'005cc5'},
{token:'type',foreground:'e36209'},{token:'function',foreground:'6f42c1'},
], colors:{'editor.background':'#f6f8fa','editor.foreground':'#24292e','editor.selectionBackground':'#c8c8fa','editorCursor.foreground':'#24292e'}});
d('solarized-light', { base:'vs', inherit:true, rules:[
{token:'keyword',foreground:'859900'},{token:'string',foreground:'2aa198'},
{token:'comment',foreground:'93a1a1',fontStyle:'italic'},{token:'number',foreground:'d33682'},
{token:'type',foreground:'b58900'},{token:'function',foreground:'268bd2'},
], colors:{'editor.background':'#fdf6e3','editor.foreground':'#657b83','editor.selectionBackground':'#eee8d5','editorCursor.foreground':'#657b83'}});
}
function renderThemeGrid() {
const grid = document.getElementById('csb-theme-grid');
if (!grid) return;
const cur = localStorage.getItem('codeEditorTheme') ||
(document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs');
grid.className = 'code-theme-groups';
grid.innerHTML = CODE_THEME_GROUPS.map(g =>
`<div class="code-theme-row">
<span class="code-theme-row-lbl">${g.icon} ${g.label}</span>
${g.themes.map(t =>
`<div class="code-skin-sw${t.v===cur?' active':''}" data-v="${t.v}" title="${t.label}"
style="background:${t.bg}" onclick="csbTheme('${t.v}')"></div>`
).join('')}
</div>`
).join('');
}
function setCodePageTheme(name) {
document.documentElement.setAttribute('data-theme', name);
localStorage.setItem('code-page-theme', name);
document.querySelectorAll('.code-theme-btn').forEach(b =>
b.classList.toggle('active', b.id === 'ctbtn-' + name));
// Monaco 에디터 테마도 연동
if (window.monacoEditor && !localStorage.getItem('codeEditorTheme')) {
const autoTheme = name === 'light' ? 'vs' : name === 'warm' ? 'solarized-light' : 'vs-dark';
if (window.monaco) monaco.editor.setTheme(autoTheme);
}
}
(function() {
const t = localStorage.getItem('code-page-theme') || localStorage.getItem('cherryclaw_theme') || 'dark';
setCodePageTheme(t);
})();
function csbTheme(theme) {
if (window.monaco) {
if (!['vs','vs-dark','hc-black','hc-light'].includes(theme)) defineCodeThemes();
monaco.editor.setTheme(theme);
}
localStorage.setItem('codeEditorTheme', theme);
const lbl = CODE_THEMES.find(t => t.v === theme);
const el = document.getElementById('csb-theme-lbl');
if (el) el.textContent = lbl ? lbl.label : theme;
document.querySelectorAll('#csb-theme-grid .code-skin-sw').forEach(sw =>
sw.classList.toggle('active', sw.dataset.v === theme));
}
function csbTabSize(val) {
if (monacoEditor) monacoEditor.updateOptions({ tabSize: parseInt(val) });
localStorage.setItem('codeEditorTabSize', val);
}
function csbMinimap(checked) {
if (monacoEditor) monacoEditor.updateOptions({ minimap: { enabled: checked } });
localStorage.setItem('codeEditorMinimap', checked ? '1' : '0');
}
// Restore editor settings from localStorage
function csbRestoreSettings() {
const fs = localStorage.getItem('codeEditorFontSize');
const ww = localStorage.getItem('codeEditorWordWrap');
const th = localStorage.getItem('codeEditorTheme');
const ts = localStorage.getItem('codeEditorTabSize');
const mm = localStorage.getItem('codeEditorMinimap');
if (fs) { document.getElementById('csb-fontsize').value = fs; document.getElementById('csb-fontsize-label').textContent = fs; }
if (ww !== null) document.getElementById('csb-wordwrap').checked = ww === '1';
if (ts) document.getElementById('csb-tabsize').value = ts;
if (mm !== null) document.getElementById('csb-minimap').checked = mm === '1';
if (monacoEditor) monacoEditor.updateOptions({
fontSize: fs ? parseInt(fs) : 12,
wordWrap: ww === '0' ? 'off' : 'on',
tabSize: ts ? parseInt(ts) : 2,
minimap: { enabled: mm !== '0' },
});
const activeTheme = th || (document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs');
if (window.monaco) { defineCodeThemes(); monaco.editor.setTheme(activeTheme); }
renderThemeGrid();
const lbl = CODE_THEMES.find(t => t.v === activeTheme);
const lblEl = document.getElementById('csb-theme-lbl');
if (lblEl) lblEl.textContent = lbl ? lbl.label : activeTheme;
}
// ── Code folder browser ───────────────────────────────────────────────────
let codeFolderOpen = false;
function toggleCodeFolderBrowser(e) {
const dd = document.getElementById('code-folder-dropdown');
const btn = document.getElementById('code-folder-btn');
if (!dd || !btn) return;
codeFolderOpen = !codeFolderOpen;
if (codeFolderOpen) {
const r = btn.getBoundingClientRect();
dd.style.top = (r.bottom + 4) + 'px';
dd.style.left = r.left + 'px';
dd.style.display = 'block';
codeRefreshFolder();
} else {
dd.style.display = 'none';
}
if (e) e.stopPropagation();
}
async function codeRefreshFolder() {
const list = document.getElementById('code-folder-list');
if (!list) return;
list.innerHTML = '<div style="padding:8px 12px;color:var(--muted);font-size:11px">로딩 중...</div>';
try {
const files = localDirHandle ? await localListFiles(localDirHandle, '') : [];
if (!files.length) {
list.innerHTML = '<div style="padding:8px 12px;color:var(--muted);font-size:11px">파일 없음</div>';
return;
}
list.innerHTML = files.map(f => {
const ext = f.split('.').pop().toLowerCase();
const icon = { py:'🐍', js:'🟨', ts:'🔷', cpp:'⚙️', c:'⚙️', cs:'💜',
go:'🔵', rs:'🦀', java:'☕', html:'🌐', css:'🎨', sh:'💻',
kt:'🎯', swift:'🍎', rb:'💎', php:'🐘', sql:'🗃️', md:'📝',
}[ext] || '📄';
const safe = f.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `<div class="code-folder-item">
<span style="flex:1;display:flex;align-items:center;gap:7px;overflow:hidden;min-width:0"
onclick="codeOpenFromFolder('${safe}')">
${icon} <span style="overflow:hidden;text-overflow:ellipsis">${escHtml(f)}</span>
</span>
<button class="code-folder-del" onclick="codeDeleteFromFolder('${safe}',event)" title="삭제">✕</button>
</div>`;
}).join('');
} catch {
list.innerHTML = '<div style="padding:8px 12px;color:#f00;font-size:11px">불러오기 실패</div>';
}
}
async function codeOpenFromFolder(filename) {
if (!localDirHandle) return;
try {
const content = await localReadFile(filename);
if (content === null) return;
const existing = codeFiles.find(f => f.name === filename);
if (existing) switchCodeFile(existing.id);
else codeNewFile(filename, content);
} catch {}
const dd = document.getElementById('code-folder-dropdown');
if (dd) dd.style.display = 'none';
codeFolderOpen = false;
}
async function codeDeleteFromFolder(filename, ev) {
ev.stopPropagation();
if (!localDirHandle || !confirm(`"${filename}" 파일을 삭제할까요?`)) return;
try {
const ok = await localDeleteFile(filename);
if (ok) {
const existing = codeFiles.find(f => f.name === filename);
if (existing) closeCodeFile(existing.id);
codeRefreshFolder();
}
} catch(e) {
alert('삭제 실패: ' + e.message);
}
}
// Close folder dropdown when clicking outside
document.addEventListener('click', e => {
if (!codeFolderOpen) return;
const dd = document.getElementById('code-folder-dropdown');
const btn = document.getElementById('code-folder-btn');
if (dd && !dd.contains(e.target) && btn && !btn.contains(e.target)) {
dd.style.display = 'none'; codeFolderOpen = false;
}
});
// ── Vertical resize handle between editor and AI panel ────────────────────
function setupCodeEditorDrop() {
const container = document.getElementById('code-editor-container');
if (!container) return;
container.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.style.outline = '2px solid var(--brand)'; });
container.addEventListener('dragleave', () => { container.style.outline = ''; });
container.addEventListener('drop', e => {
e.preventDefault();
container.style.outline = '';
const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
files.forEach(file => {
const reader = new FileReader();
reader.onload = ev => {
const content = ev.target.result;
const existing = codeFiles.find(f => f.name === file.name);
if (existing) { existing.model.setValue(content); switchCodeFile(existing.id); }
else codeNewFile(file.name, content);
};
reader.readAsText(file);
});
});
}
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);
}
}
// ── Standalone bootstrap ────────────────────────────────────────────────────
// In the main app these are triggered by setMode('code') / setSidebarTab('code').
// Here we run them once the DOM is ready since this page IS the code view.
(function bootCodePage() {
async function boot() {
try { setCodeSbTab('files'); } catch (e) {}
try { initCodeView(); } catch (e) { console.error('[code] initCodeView failed', e); }
try { csbLoadModels(); } catch (e) {}
try { csbLoadCodeDir(); } catch (e) {}
try { updateLocalModeUI(); } catch (e) {}
try { const inp = document.getElementById('csb-termpath'); if (inp && localTermPath) inp.value = localTermPath; } catch (e) {}
try { await restoreLocalDirHandle(); } catch (e) {}
try { codeSbRefresh(); } catch (e) {}
try { companionConnect(); } catch (e) {}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();