Files
homeclaw/web-ui/code.js
T
kimandClaude Sonnet 4.6 2f07dbf1fa Release 2.9.9: 오케스트레이터 버그 수정 + 로컬 터미널 프로젝트 동기화
- 오케스트레이터 auto-trigger 경로에 !isCodeAiSession/!isProjSession 누락 수정
  (code_ai_ 세션에서 tool failure 쌓이면 memory_read 등 엉뚱한 도구 호출로 멈추던 문제)
- 로컬 셸 시작/프로젝트 전환 시 프로젝트 폴더로 자동 cd
- companion.py: shell_ready에 platform/home 정보 포함
- Windows/Unix 경로 처리: cd /d "경로" vs cd "경로"
- localTermPath 미설정 시 companionHome fallback, 둘 다 없으면 cd 안 함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 13:52:37 +09:00

4429 lines
202 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') || localStorage.getItem('smallclaw_token') || ''; } catch { return ''; } }
function setAuthToken(t) { try { if (t) { sessionStorage.setItem('smallclaw_token', t); localStorage.setItem('smallclaw_token', t); } else { 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 activeProjectName = localStorage.getItem('code_active_project') || null;
let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud';
let codeAiTech = localStorage.getItem('codeAiTech') ?? 'auto'; // 'auto' | 'html' | 'python' | 'node'
let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, resolved, rejected, onReject } }
const CODE_EXTS = new Set(['js','ts','jsx','tsx','py','html','htm','css','scss','java','c','cpp','cs','go','rs','php','rb','swift','kt','vue','svelte','json','yaml','yml','sh','sql']);
const isCodeFile = name => CODE_EXTS.has((name.split('.').pop() || '').toLowerCase());
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 = label + '에게 질문... (예: 게임 만들어줘)';
}
function setCodeAiTech(tech) {
codeAiTech = tech;
localStorage.setItem('codeAiTech', tech);
document.querySelectorAll('.code-ai-tech-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tech === tech);
});
updateCodeAiLabel();
}
function initCodeView() {
updateCodeAiLabel();
setCodeAiTech(codeAiTech); // Initialize tech 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) {
// In client-local + project mode: tabs always store bare filenames; the project
// subfolder prefix is handled transparently by _clientProjectPath at disk I/O time.
// In server mode with activeProjectName: prefix IS part of the tab name (existing behaviour).
let fname = name;
if (!fname) {
const raw = prompt('파일 이름:', 'untitled.py');
if (!raw || !raw.trim()) return;
fname = (localDirHandle && activeProjectName)
? raw.trim()
: (activeProjectName ? `${activeProjectName}/${raw.trim()}` : raw.trim());
} else if (localDirHandle && activeProjectName) {
// Strip project prefix so tab names are always bare in client-local mode
fname = _clientBareFileName(fname.trim());
}
fname = fname.trim();
if (!fname) 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, 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();
// Restore per-project AI session
if (activeProjectName) {
const savedId = localStorage.getItem('code_proj_session_' + activeProjectName);
const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName);
if (savedId) codeAiSessionId = savedId;
try { if (savedHist) codeAiHistory = JSON.parse(savedHist); } catch {}
// 채팅 패널 렌더링 (switchProject와 동일)
const _msgs = document.getElementById('code-ai-messages');
if (_msgs) {
_msgs.innerHTML = '';
if (codeAiHistory.length) {
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
} else {
_renderSessionsList();
}
}
}
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();
// Auto-load files if a project was already remembered
if (activeProjectName) _clientLoadProjectFiles();
} 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 ? (activeProjectName || 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('.') || name.endsWith('.crswap')) 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; }
}
// ── Client-local project path helpers ────────────────────────────────────
// "client-local" = browser File System Access API (localDirHandle).
// Distinguished from "server-local" = server's own workspace on disk.
// When client-local root + activeProjectName are both set, all disk I/O
// is scoped to the project subfolder. The model always sees bare filenames.
function _clientProjectPath(fname) {
if (!localDirHandle || !activeProjectName) return fname;
const bare = fname.startsWith(activeProjectName + '/')
? fname.slice(activeProjectName.length + 1) : fname;
return activeProjectName + '/' + bare;
}
function _clientBareFileName(fname) {
if (!activeProjectName) return fname;
return fname.startsWith(activeProjectName + '/')
? fname.slice(activeProjectName.length + 1) : fname;
}
// Close all open tabs that belong to the client-local project scope,
// then load every code file from the new activeProjectName subfolder.
async function _clientLoadProjectFiles() {
if (!localDirHandle || !activeProjectName) return;
// Close all existing tabs (they belonged to the previous project)
if (codeFiles.length) {
codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} });
codeFiles.length = 0;
activeCodeFileId = null;
codeDiffState = {};
renderCodeFileTabs();
}
// Ensure the session's directory exists on disk (new session → no folder yet)
await localDirHandle.getDirectoryHandle(activeProjectName, { create: true }).catch(() => {});
const allFiles = await localListFiles(localDirHandle, '');
const projFiles = allFiles.filter(f =>
f.startsWith(activeProjectName + '/') && isCodeFile(f)
);
for (const fpath of projFiles) {
const bare = _clientBareFileName(fpath);
const content = await localReadFile(fpath);
if (content === null || content.length > 300_000) continue; // skip missing/huge
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const lang = codeDetectLang(bare);
const model = monacoReady ? monaco.editor.createModel(content, lang) : null;
codeFiles.push({ id, name: bare, content, language: lang, model });
}
renderCodeFileTabs();
// Switch to first file if any
if (codeFiles.length) switchCodeFile(codeFiles[0].id);
}
// ── Terminal output capture (for AI error feedback loop) ─────────────────
// Keeps a rolling plaintext buffer per shell (ANSI stripped). Used to detect
// errors after a run and offer one-click "AI에게 전송" from the terminal.
const _termBuffers = {}; // shellId → string[] (last 300 lines)
const _TERM_BUF_MAX = 300;
const _ANSI_RE = /\x1b\[[0-9;]*[mGKHFABCDJnsuhl]/g;
function _termBufferAppend(shellId, raw) {
if (!shellId) return;
const text = raw.replace(_ANSI_RE, '').replace(/\r/g, '');
if (!text) return;
const lines = text.split('\n');
if (!_termBuffers[shellId]) _termBuffers[shellId] = [];
const buf = _termBuffers[shellId];
for (const l of lines) { if (l) buf.push(l); }
while (buf.length > _TERM_BUF_MAX) buf.shift();
_termDetectAndShowError(shellId);
}
const _ERROR_PATTERNS = [
/Traceback \(most recent call last\)/,
/(?:Syntax|Name|Type|Attribute|Import|Indentation|Value|Key|Index|Zero|Overflow|Runtime|OS|IO|File)Error:/,
/^\s*\^\s*$/, // Python caret indicator
/Uncaught (?:TypeError|ReferenceError|SyntaxError|Error):/,
/Error: .+\n?\s+at /,
/\[Error\]/i,
];
let _lastErrorShellId = null;
let _lastErrorText = '';
function _termDetectAndShowError(shellId) {
const buf = _termBuffers[shellId];
if (!buf || buf.length < 2) return;
const recent = buf.slice(-40).join('\n');
const hasError = _ERROR_PATTERNS.some(re => re.test(recent));
if (!hasError) return;
// Deduplicate — don't re-show if same error already triggered
if (_lastErrorShellId === shellId && _lastErrorText === recent) return;
_lastErrorShellId = shellId;
_lastErrorText = recent;
_showTerminalErrorButton(shellId, recent);
}
function _showTerminalErrorButton(shellId, errorText) {
// Remove old button if present
const old = document.getElementById('code-term-ai-error-btn');
if (old) old.remove();
const footer = document.querySelector('#code-terminal-panel .term-footer, #code-terminal-panel');
if (!footer) return;
const btn = document.createElement('button');
btn.id = 'code-term-ai-error-btn';
btn.textContent = '🤖 에러 AI에게 전송';
btn.style.cssText = 'position:absolute;bottom:4px;right:90px;z-index:10;background:#dc2626;color:#fff;border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;font-weight:600;animation:pulse-red 1.5s infinite';
btn.onclick = () => { _sendTerminalErrorToAI(errorText); btn.remove(); };
footer.style.position = 'relative';
footer.appendChild(btn);
}
function _sendTerminalErrorToAI(errorText) {
const input = document.getElementById('code-ai-input');
if (!input) return;
const trimmed = errorText.split('\n').filter(l => l.trim()).slice(-25).join('\n');
input.value = '다음 에러를 고쳐줘. edit_file 도구로 파일을 직접 수정해:\n```\n' + trimmed + '\n```';
input.style.height = '';
input.dispatchEvent(new Event('input'));
// Auto-send
codeAiSend();
}
// Called when a run completes successfully (exit code 0) — clear error button
function _termOnRunSuccess(shellId) {
_lastErrorShellId = null;
_lastErrorText = '';
const btn = document.getElementById('code-term-ai-error-btn');
if (btn) btn.remove();
}
// ── 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
let companionPlatform = 'unix'; // 'windows' | 'unix', set on shell_ready
let companionHome = ''; // home directory from companion
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') {
if (msg.platform) companionPlatform = msg.platform;
if (msg.home) companionHome = msg.home;
s.term.write('\x1b[32m🖥️ 로컬 셸\x1b[0m\r\n');
_companionCdProject();
}
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')); _termBufferAppend(s.id, msg.data); }
else if (msg.type === 'exit') { s.term.write(`\r\n\x1b[33m[exit ${msg.code}]\x1b[0m\r\n`); if (msg.code === 0) _termOnRunSuccess(s.id); }
else if (msg.type === 'error') { s.term.write(`\r\n\x1b[31m오류: ${msg.data}\x1b[0m\r\n`); _termBufferAppend(s.id, msg.data); }
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 _companionCdProject() {
if (!companionConnected || !companionWs || !activeProjectName) return;
const isWin = companionPlatform === 'windows';
const sep = isWin ? '\\' : '/';
const base = (localTermPath || companionHome || '').replace(/[/\\]$/, '');
if (!base) return; // 경로 정보 없으면 cd 안 함
const target = base + sep + activeProjectName;
const cmd = isWin ? `cd /d "${target}"\r` : `cd ${JSON.stringify(target)}\r`;
companionWs.send(JSON.stringify({ type: 'input', data: cmd }));
}
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(_clientProjectPath(file.name), file.content);
codeAiAppend('system', ok ? `💾 저장: ${file.name}` : '⚠️ 저장 실패');
if (ok) codeSbRefresh();
}
// ── 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); _termBufferAppend(msg.shellId, 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'); if (msg.code === 0) _termOnRunSuccess(msg.shellId); } }
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) {
if (shell.fitAddon) shell.fitAddon.fit();
const rows = shell.term.rows > 0 ? shell.term.rows : 24;
const cols = shell.term.cols > 0 ? shell.term.cols : 80;
companionWs.send(JSON.stringify({ type: 'shell', rows, 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);
}
// Resolve <link href="x.css"> and <script src="x.js"> from local filesystem,
// inline them into the HTML, then return a blob URL for window.open.
async function _openHtmlWithInlinedAssets(file) {
let html = file.content;
const dir = file.name.includes('/') ? file.name.split('/').slice(0, -1).join('/') : '';
// Inline <link rel="stylesheet" href="...">
const linkRe = /<link\b[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*\/?>/gi;
const cssMatches = [...html.matchAll(linkRe)];
for (const m of cssMatches) {
const href = m[1];
if (href.startsWith('http') || href.startsWith('//')) continue;
const fpath = dir ? `${dir}/${href}` : href;
const css = await localReadFile(_clientProjectPath(fpath)) ?? await localReadFile(fpath);
if (css !== null) {
html = html.replace(m[0], `<style>/* ${href} */\n${css}\n</style>`);
}
}
// Inline <script src="..."> (skip external URLs)
const scriptRe = /<script\b([^>]*)\bsrc=["']([^"']+)["'][^>]*><\/script>/gi;
const scriptMatches = [...html.matchAll(scriptRe)];
for (const m of scriptMatches) {
const src = m[2];
if (src.startsWith('http') || src.startsWith('//')) continue;
const fpath = dir ? `${dir}/${src}` : src;
const js = await localReadFile(_clientProjectPath(fpath)) ?? await localReadFile(fpath);
if (js !== null) {
html = html.replace(m[0], `<script${m[1]}>\n/* ${src} */\n${js}\n</script>`);
}
}
const blob = new Blob([html], { type: 'text/html' });
return URL.createObjectURL(blob);
}
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();
if ((ext === 'html' || ext === 'htm') && localDirHandle) {
// Inline linked CSS and JS so the blob URL can resolve them
_openHtmlWithInlinedAssets(file).then(url => {
const w = window.open(url, '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
setTimeout(() => URL.revokeObjectURL(url), 120000);
});
} else {
const mime = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : '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);
// Don't null _monacoThrottleModel here — keep it alive for codeMonacoThrottleFlush.
// The timer callback applies the latest throttled value, but a later flush
// (called when streaming ends) must still be able to apply any final value
// that arrived after this timer fired.
}
}, 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.
// Priority: explicit filename in diff > active tab.
// The model names the file it's editing in the diff header or filename comment —
// that explicit name wins so the right tab gets focused automatically.
const fname = codeDeriveName(code, lang)
|| code.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)(?:\t|$)/m)?.[1]?.trim();
const fnameFile = fname ? (
codeFiles.find(f => f.name === fname) ||
codeFiles.find(f => f.name.endsWith('/' + fname))
) : null;
const existing = fnameFile
|| (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId))
|| 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
if (/^`{3}/.test(line)) { i++; continue; } // stray closing fence (no trailing \n on last stream token)
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 result = [];
let origPos = 0; // 0-based cursor into origLines
const norm = (s) => s.trim();
// Advance origPos to the first original line at/after origPos matching
// `content` (whitespace-insensitive), emitting any skipped originals as
// unchanged context. Returns that index (origPos left there), or -1 unmoved.
// This anchors '-'/context/bare lines to the REAL original position instead of
// assuming the diff starts at line 0 — without it a leading deletion consumed
// the wrong line and insertions floated to the top (context mismatch).
const seekAndEmit = (content) => {
for (let q = origPos; q < origLines.length; q++) {
if (norm(origLines[q]) === norm(content)) {
while (origPos < q) result.push(origLines[origPos++]);
return q;
}
}
return -1;
};
for (const line of diffLines) {
if (line === '') continue; // blank separator noise
if (line.startsWith('@@') || line.startsWith('\\')) continue; // hunk header / "no newline"
if (line.startsWith('+++') || line.startsWith('---')) continue; // file headers
const tag = line[0];
const body = line.slice(1); // strip exactly ONE marker char
if (tag === '+') {
result.push(body); // addition — verbatim, keep indentation
} else if (tag === '-') {
if (seekAndEmit(body) >= 0) origPos++; // delete the matched original
// not found → stale/duplicate deletion, drop silently
} else if (tag === ' ') {
if (seekAndEmit(body) >= 0) result.push(origLines[origPos++]); // context — emit ORIGINAL (true indentation)
// not found → context drifted; skip rather than corrupt
} else {
// Bare line: the model frequently drops the leading space on context lines.
// Treat it as context ONLY when it matches an upcoming original — otherwise
// it is a stray comment / "# filename:" / prose line and is ignored.
if (seekAndEmit(line) >= 0) result.push(origLines[origPos++]);
}
}
while (origPos < origLines.length) result.push(origLines[origPos++]);
return result.length > 0 ? result.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 = `당신은 소프트웨어 프로젝트 설계 도우미입니다.
사용자가 원하는 프로젝트를 분석하고 아래 마크다운 형식으로 답하세요.
🔧 기술 스택: 언어, 라이브러리, 프레임워크 나열
📁 파일별 구현 계획:
각 파일에 대해 아래 형식으로 작성하세요:
**파일명** — 역할 한 줄 요약
- 주요 함수/클래스/컴포넌트: 이름과 역할
- 핵심 로직: 어떻게 동작하는지 1~2줄
(파일이 여러 개면 모두 나열)
✨ 데이터 흐름 / 구조:
- 파일/모듈 간 연결 관계 (어디서 어디를 호출하는지)
- 상태 관리나 데이터 저장 방식
⚠️ 구현 시 주의사항:
- 예상 난이도 포인트나 엣지케이스
중요: 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 _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) {
const ok = await localWriteFile(filename, content ?? '');
if (!ok) {
statusSpan.textContent = '⚠️';
pathSpan.style.color = '#f87171';
pathSpan.title = '디스크 저장 실패';
}
}
// 에디터 탭에 추가
_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);
} else {
const warn = document.createElement('span');
warn.style.cssText = 'font-size:11px;color:#f87171;';
warn.textContent = '⚠️ 로컬 폴더 미연결 — 에디터에만 저장됨';
doneDiv.appendChild(warn);
}
_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,
max_tokens: 16000,
...(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 (!localDirHandle) {
codeAiAppend('system', '📁 프로젝트를 저장할 로컬 폴더를 먼저 선택해주세요.');
await openLocalFolder();
if (!localDirHandle) {
codeAiAppend('system', '⚠️ 폴더 선택이 취소되었습니다. 폴더를 선택한 뒤 다시 시도해주세요.');
_projDone();
return;
}
}
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 파싱 — @@ENDFILE 없는 마지막 블록도 허용
const files = [];
const headerRe = /@@FILE:\s*(.+?)[ \t]*\r?\n/g;
const headers = [];
let hm;
while ((hm = headerRe.exec(fullText)) !== null) {
headers.push({ path: hm[1].trim(), contentStart: hm.index + hm[0].length });
}
for (let i = 0; i < headers.length; i++) {
const { path, contentStart } = headers[i];
const nextHeaderPos = i + 1 < headers.length
? fullText.lastIndexOf('@@FILE:', headers[i + 1].contentStart)
: fullText.length;
const endfileIdx = fullText.indexOf('@@ENDFILE', contentStart);
const contentEnd = (endfileIdx !== -1 && endfileIdx < nextHeaderPos) ? endfileIdx : nextHeaderPos;
files.push({ path, content: fullText.slice(contentStart, contentEnd) });
}
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 = '🔨 파일 저장 중...';
// 파일 쓰기 전에 slug 확정: AI 출력 slug를 그대로 쓰면 'space-invaDERS' 같은
// 잘못된 케이스 폴더가 생긴다. activeProjectName 우선, 없으면 소문자 정규화.
const allNames = files.map(f => f.path);
const hasPrefix = allNames.some(n => n.includes('/'));
const rawSlug = hasPrefix ? (allNames[0] || '').split('/')[0] : '';
const slug = activeProjectName
|| (rawSlug ? rawSlug.toLowerCase().replace(/[^a-z0-9가-힣_-]/g, '-').replace(/^-+|-+$/g, '') : '');
// AI가 출력한 경로의 slug prefix → 정규화된 slug로 교체
const normalizedFiles = files.map(f => ({
...f,
path: rawSlug && rawSlug !== slug ? f.path.replace(rawSlug + '/', slug + '/') : f.path,
}));
for (const f of normalizedFiles) {
if (f.path && f.content !== undefined)
await _projFileCreated(f.path, String(f.content));
}
const mainPath = _npMainFile(
normalizedFiles.map(f => ({ path: slug ? f.path.replace(slug + '/', '') : f.path })),
slug
);
if (mainPath) switchCodeFile(codeFiles.find(f => f.name === mainPath)?.id);
// 새 프로젝트로 자동 전환 (AI 세션 분리)
if (slug && slug !== activeProjectName) switchProject(slug);
} 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];
if (!f) return null;
return slug ? `${slug}/${f.path}`.replace(/\/+/g, '/') : f.path;
}
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;
}
// @파일명 참조 처리 — 메시지에서 @xxx.js 패턴을 찾아 파일 내용 삽입
let resolvedMessage = message;
if (localDirHandle) {
const atRefs = [...message.matchAll(/@([\w./-]+\.\w+)/g)];
for (const m of atRefs) {
const fname = activeProjectName ? `${activeProjectName}/${m[1]}` : m[1];
const content = await localReadFile(fname) ?? await localReadFile(m[1]);
if (content !== null) {
const ext = m[1].split('.').pop() || '';
resolvedMessage = resolvedMessage.replace(m[0],
`\n[파일: ${m[1]}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`);
}
}
}
// 프로젝트 내 모든 파일 내용을 컨텍스트에 포함 (현재 파일 제외, 별도 첨부)
let otherFilesCtx = '';
{
const currentFileName = codeFiles.find(f => f.id === activeCodeFileId)?.name;
const seen = new Set();
// 1. All other open tabs (no project prefix required — covers both prefixed and bare filenames)
for (const f of codeFiles) {
if (f.name === currentFileName) continue;
// If activeProjectName set, only include files from same project
if (activeProjectName && !f.name.startsWith(activeProjectName + '/') && f.name.includes('/')) continue;
if (!isCodeFile(f.name)) continue; // skip .md, .txt etc.
seen.add(f.name);
const rel = activeProjectName ? f.name.replace(activeProjectName + '/', '') : f.name;
const content = f.model ? f.model.getValue() : f.content;
if (!content) continue;
const ext = rel.split('.').pop() || '';
otherFilesCtx += `[파일: ${rel}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`;
}
// 2. Files on disk not yet opened as tabs
if (localDirHandle && activeProjectName) {
try {
const allFiles = await localListFiles(localDirHandle, '');
const projFiles = allFiles.filter(f => f.startsWith(activeProjectName + '/') && !seen.has(f) && f !== currentFileName && isCodeFile(f));
for (const fpath of projFiles) {
const rel = fpath.replace(activeProjectName + '/', '');
const content = await localReadFile(fpath);
if (content) {
const ext = rel.split('.').pop() || '';
otherFilesCtx += `[파일: ${rel}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`;
}
}
} catch {}
}
}
// Include current file context (ask/code 모드 모두 포함)
const currentFile = codeFiles.find(f => f.id === activeCodeFileId);
let contextMsg = resolvedMessage;
// Add tech hint for the model
const techHints = { html: '웹 (HTML/CSS/JS)', python: 'Python', node: 'Node.js/JavaScript', auto: '' };
const techHint = techHints[codeAiTech] || '';
if (techHint && !contextMsg.includes('[tech]')) {
contextMsg = `[tech: ${techHint}] ${contextMsg}`;
}
if (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');
let fileCtx, omitNote = '';
if (lines.length <= 3000) {
fileCtx = lines.map((l, i) => `${i + 1}: ${l}`).join('\n');
} else {
// Large file: always include header (first 80 lines) + cursor window + footer
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?.()?.lineNumber) || 1;
if (errorMatch) focusLine = parseInt(errorMatch[1]);
const headerEnd = Math.min(80, lines.length);
const ctx = 80;
const s = Math.max(headerEnd + 1, focusLine - ctx);
const e = Math.min(lines.length, focusLine + ctx);
const headerLines = lines.slice(0, headerEnd).map((l, i) => `${i + 1}: ${l}`).join('\n');
const windowLines = s <= e ? lines.slice(s - 1, e).map((l, i) => `${s + i}: ${l}`).join('\n') : '';
const structLines = [];
for (let i = 0; i < lines.length; i++) {
if (/^(class |def |async def |function |public |private |const |let |var |export )/i.test(lines[i].trimStart()))
structLines.push(`${i + 1}: ${lines[i].trim()}`);
}
const overview = structLines.length ? `\n[구조]\n${structLines.join('\n')}\n` : '';
const windowPart = windowLines && s > headerEnd + 1 ? `\n...(${s - headerEnd - 1}줄 생략)...\n${windowLines}` : (windowLines ? '\n' + windowLines : '');
fileCtx = headerLines + windowPart;
omitNote = ` (${lines.length}줄 중 앞 ${headerEnd}줄 + 커서 주변 표시)${overview}`;
}
// Use bare filename so model never sees the client project prefix
const displayName = _clientBareFileName(currentFile.name);
contextMsg = `${otherFilesCtx}[현재 파일: ${displayName}${omitNote}]\n\`\`\`${langHint}\n${fileCtx}\n\`\`\`\n\n${resolvedMessage}`;
}
}
codeAiAppend('user', message);
// 히스토리: 파일명 스텁만 포함 (전체 내용 제외).
// 모델이 "기존 파일이 있다"는 맥락은 유지하되 매 턴 파일 전체를 반복하지 않음.
const historyEntry = currentFile ? `[파일: ${_clientBareFileName(currentFile.name)}] ${message}` : message;
codeAiHistory.push({ role: 'user', content: historyEntry });
const thinkEl = codeAiAppend('thinking', '생각 중...');
try {
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
const historyForAPI = codeAiHistory.slice(-20, -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: true, sessionId: codeAiSessionId, codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
});
if (res.status === 401) { setAuthToken(''); _codeAiShowRelogin(); return; }
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)) { if (!reply) reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { if (!reply) reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
// Handle tool events for live editor update + action cards
else if (ev.type === 'tool_call') {
const toolName = ev.action || ev.name || '';
const toolArgs = ev.args || {};
liveState.toolCallCount = (liveState.toolCallCount || 0) + 1;
_codeAiToolAction(toolName, toolArgs, null, null);
// ── Client-side file intercept: apply tool changes to local folder ──
// The server returns synthetic success for local sessions, so we surface
// any client-side apply failure here — otherwise the model (and user)
// would think the edit landed when nothing actually changed.
_codeAiApplyToolLocally(toolName, toolArgs).then(r => {
if (r && r.ok === false) {
_codeAiToolAction(toolName, null, `⚠️ 로컬 적용 실패: ${r.reason}`, true);
codeAiAppend('assistant', `⚠️ ${toolName} 로컬 적용 실패: ${r.reason}. 모델에게 다시 시도하도록 요청하세요.`);
}
});
}
else if (ev.type === 'tool_result') {
const toolName = ev.action || '';
const toolResult = ev.result || '';
const toolError = ev.error || false;
_codeAiToolAction(toolName, null, toolResult, toolError);
// Refresh file list after successful file-modifying tools
if (!toolError && ['create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'edit_file'].includes(toolName)) {
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)
// First check if this is a multi-file response (model split into CSS/JS/HTML etc.)
const explicitBlocks = _extractExplicitFileBlocks(reply);
const hasExplicitBlocks = explicitBlocks.length > 0;
if (hasExplicitBlocks) {
// Multi-file or mixed diff+new-file response: restore the streamed tab
// first, then process each block individually.
if (streamFileId) {
const streamedFile = codeFiles.find(f => f.id === streamFileId);
if (streamedFile && streamOriginalContent != null && streamOriginalContent.trim()) {
if (streamedFile.model) streamedFile.model.setValue(streamOriginalContent);
else streamedFile.content = streamOriginalContent;
}
}
_fileApprovalQueue = [];
// Separate diff blocks (modify existing files) from new-file blocks
const diffBlocks = explicitBlocks.filter(b => b.isDiff);
const newFileBlocks = explicitBlocks.filter(b => !b.isDiff);
// Process diff blocks: apply each diff to the corresponding file
for (const { fname, code } of diffBlocks) {
const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
// Clean trailing fence
const cleanDiff = code.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
// Find the target file — try exact match, then project-prefixed, then open tabs
let targetFile = codeFiles.find(f => f.name === fullName || f.name === fname);
if (!targetFile) targetFile = codeFiles.find(f => f.name.endsWith('/' + fname));
if (!targetFile && localDirHandle) {
// Try loading from disk
const diskContent = await localReadFile(fullName) ?? await localReadFile(fname);
if (diskContent !== null) {
codeNewFile(fullName, diskContent);
targetFile = codeFiles.find(f => f.name === fullName || f.name === fname);
}
}
if (targetFile) {
const oldContent = targetFile.model ? targetFile.model.getValue() : targetFile.content;
const newContent = applyUnifiedDiff(oldContent, cleanDiff);
if (oldContent !== newContent) {
if (targetFile.model) targetFile.model.setValue(oldContent);
else targetFile.content = oldContent;
showCodeDiff(targetFile.id, oldContent, newContent);
}
}
// If target file not found, the diff block is silently skipped.
// The user can see the diff in the chat text and apply manually.
}
// Process new-file blocks: create tabs or update existing files
for (const { fname, code } of newFileBlocks) {
const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
const existing = codeFiles.find(f => f.name === fullName || f.name === fname);
if (existing) {
const oldContent = existing.model ? existing.model.getValue() : existing.content;
if (existing.model) existing.model.setValue(code);
else existing.content = code;
if (oldContent.trim() && oldContent !== code) {
if (existing.model) existing.model.setValue(oldContent);
showCodeDiff(existing.id, oldContent, code);
} else {
_enqueueFileApproval(existing.id, existing.name);
}
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fullName)) : null;
codeFiles.push({ id, name: fullName, content: code, language: codeDetectLang(fullName), model });
_enqueueFileApproval(id, fullName);
}
}
renderCodeFileTabs();
if (_fileApprovalQueue.length) _showNextApproval();
else if (explicitBlocks.length) {
const firstBlock = explicitBlocks[0];
const firstFname = (activeProjectName && !firstBlock.fname.includes('/')) ? `${activeProjectName}/${firstBlock.fname}` : firstBlock.fname;
switchCodeFile(codeFiles.find(f => f.name === firstFname || f.name === firstBlock.fname)?.id || activeCodeFileId);
}
} else if (streamFileId) {
let file = codeFiles.find(f => f.id === streamFileId);
let resolvedOriginal = streamOriginalContent;
// Re-resolve diff target: streaming may have landed on the active tab even though
// the diff names a different file. Extract the real filename and switch to it.
if (file && streamIsDiff && streamDiffAccumulated) {
const diffFname = codeDeriveName(streamDiffAccumulated, 'diff')
|| streamDiffAccumulated.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)(?:\t|$)/m)?.[1]?.trim();
if (diffFname && !file.name.endsWith(diffFname) && file.name !== diffFname) {
// Active tab doesn't match diff target — find the right file
const rightFile = codeFiles.find(f => f.name === diffFname || f.name.endsWith('/' + diffFname));
if (rightFile) {
// Restore wrongly-targeted tab and switch to correct file
if (file.model && streamOriginalContent != null) file.model.setValue(streamOriginalContent);
resolvedOriginal = rightFile.model ? rightFile.model.getValue() : rightFile.content;
file = rightFile;
} else if (localDirHandle) {
// File not open — load from disk, open as new tab
const fullFname = (activeProjectName && !diffFname.includes('/'))
? `${activeProjectName}/${diffFname}` : diffFname;
const diskContent = await localReadFile(fullFname) ?? await localReadFile(diffFname);
if (diskContent !== null) {
if (file.model && streamOriginalContent != null) file.model.setValue(streamOriginalContent);
codeNewFile(fullFname, diskContent);
const loaded = codeFiles.find(f => f.name === fullFname || f.name === diffFname);
if (loaded) { resolvedOriginal = diskContent; file = loaded; }
}
}
}
}
if (file) {
if (streamIsDiff && streamDiffAccumulated && resolvedOriginal != null) {
// Diff block: apply the unified diff to the original content.
// Strip trailing closing fence that leaks in when the model emits ``` with no newline at end.
const cleanDiff = streamDiffAccumulated.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
const newContent = applyUnifiedDiff(resolvedOriginal, cleanDiff);
if (resolvedOriginal !== newContent) {
if (file.model) file.model.setValue(resolvedOriginal);
else file.content = resolvedOriginal;
showCodeDiff(file.id, resolvedOriginal, newContent);
}
} else if (resolvedOriginal != null && resolvedOriginal.trim()) {
// Full code block: compare original vs streamed modified text
const newContent = streamModifiedText || (file.model ? file.model.getValue() : file.content);
if (resolvedOriginal !== newContent) {
if (file.model) file.model.setValue(resolvedOriginal);
else file.content = resolvedOriginal;
showCodeDiff(file.id, resolvedOriginal, newContent);
}
} else {
// New file created by AI — show approval bar
_showNewFileApproval(file.id, file.name);
}
}
}
// Auto-retry: if in code mode, had a file context, but the response contains
// no diff/code blocks (analysis-only), automatically re-prompt for actual code.
const hasCodeBlock = /```[\w-]*\n[\s\S]*?\n```/.test(reply || '');
const hasDiffBlock = /```diff\n[\s\S]*?\n```/.test(reply || '');
const toolsWereUsed = (liveState.toolCallCount || 0) > 0;
const looksLikeAnalysis = /^(Diagnosis|Plan|Analysis|진단|분석|해결 방법|수정 사항|변경 사항|Steps|Step\s+\d|Incomplete |Fix |The (?:file|code|error|issue))/im.test(reply || '');
if (!hasCodeBlock && !hasDiffBlock && !toolsWereUsed && looksLikeAnalysis && codeAiHistory.length > 0) {
const retryMsg = '위 분석만 하지 말고 edit_file 또는 create_file 도구를 사용해서 실제로 파일을 수정해줘.';
codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
codeAiHistory.push({ role: 'user', content: retryMsg });
thinkEl.classList.add('streaming');
thinkEl.textContent = '🔄 분석만 감지 — 도구로 파일 수정 재시도 중...';
const retryAbort = new AbortController();
// Abort retry if user sends a new message
const origAbort = codeAiAbort;
const origOnNewSend = () => retryAbort.abort();
try {
const retryHistory = codeAiHistory.slice(-20).map(m => ({ role: m.role, content: m.content }));
const retryRes = await fetch('/api/chat', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
signal: retryAbort.signal,
body: JSON.stringify({ message: retryMsg, history: retryHistory.slice(0, -1), useTools: true, sessionId: codeAiSessionId + '_retry', codeAiTech, codeAiLocal: !!localDirHandle }),
});
if (retryRes.ok) {
const retryReader = retryRes.body.getReader();
const retryDec = new TextDecoder();
let retryBuf = '', retryReply = '';
const retryState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
while (true) {
const { done: rDone, value: rVal } = await retryReader.read();
if (rDone) break;
retryBuf += retryDec.decode(rVal, { stream: true });
const rLines = retryBuf.split('\n');
retryBuf = rLines.pop();
for (const rl of rLines) {
if (!rl.startsWith('data:')) continue;
const rd = rl.slice(5).trim();
if (rd === '[DONE]') continue;
try {
const rev = JSON.parse(rd);
let rToken = null;
if (rev.type === 'token' && (rev.content || rev.text)) rToken = rev.content || rev.text;
else if (rev.type === 'text_delta' && rev.delta) rToken = rev.delta;
else if (rev.type === 'final' && (rev.text || rev.content)) { if (!retryReply) retryReply = rev.text || rev.content; continue; }
else if (rev.type === 'done' && rev.reply) { if (!retryReply) retryReply = rev.reply; continue; }
if (rToken) { retryReply += rToken; thinkEl.textContent = retryReply; codeAiScrollBottom(); try { codeLiveStreamUpdate(retryReply, retryState); } catch(e) { console.error('[codeLiveStream] retry error:', e); } }
} catch(e) {}
}
}
retryState.inBlock = false;
codeMonacoThrottleFlush();
// Clear streaming decorations for retry
if (retryState.fileId) {
const rf = codeFiles.find(f => f.id === retryState.fileId);
if (rf?._streamingDecos) { try { rf.model.deltaDecorations(rf._streamingDecos, []); } catch {} rf._streamingDecos = null; }
}
if (_streamingDiffTimer) { clearTimeout(_streamingDiffTimer); _streamingDiffTimer = null; }
// If retry produced code, replace the analysis-only response
if (retryReply && /```[\w-]*\n[\s\S]*?\n```/.test(retryReply)) {
reply = retryReply;
// Apply retry result (same logic as main flow)
const retryBlocks = _extractExplicitFileBlocks(retryReply);
const retryIsMulti = retryBlocks.length > 1;
if (retryIsMulti) {
// Restore original if streaming modified it
const rStreamFile = codeFiles.find(f => f.id === retryState.fileId);
if (rStreamFile && retryState.originalContent != null && retryState.originalContent.trim()) {
if (rStreamFile.model) rStreamFile.model.setValue(retryState.originalContent);
else rStreamFile.content = retryState.originalContent;
}
_fileApprovalQueue = [];
for (const { fname, code, lang } of retryBlocks) {
const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
const existing = codeFiles.find(f => f.name === fullName || f.name === fname);
if (existing) {
const oldC = existing.model ? existing.model.getValue() : existing.content;
if (existing.model) existing.model.setValue(code);
else existing.content = code;
if (oldC.trim() && oldC !== code) {
if (existing.model) existing.model.setValue(oldC);
showCodeDiff(existing.id, oldC, code);
} else { _enqueueFileApproval(existing.id, existing.name); }
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fullName)) : null;
codeFiles.push({ id, name: fullName, content: code, language: codeDetectLang(fullName), model });
_enqueueFileApproval(id, fullName);
}
}
renderCodeFileTabs();
if (_fileApprovalQueue.length) _showNextApproval();
} else if (retryState.isDiff && retryState.diffAccumulated) {
const rFile = codeFiles.find(f => f.id === retryState.fileId);
if (rFile) {
const cleanD = retryState.diffAccumulated.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
const newC = applyUnifiedDiff(retryState.originalContent, cleanD);
if (retryState.originalContent !== newC) {
if (rFile.model) rFile.model.setValue(retryState.originalContent);
else rFile.content = retryState.originalContent;
showCodeDiff(rFile.id, retryState.originalContent, newC);
}
}
}
// Update history with actual fix
codeAiHistory[codeAiHistory.length - 1] = { role: 'assistant', content: reply };
}
}
} catch(retryErr) {
if (retryErr.name === 'AbortError') {
// User sent a new message during retry — that's fine, just log
console.log('[codeAiRetry] aborted — user sent new message');
} else {
console.error('[codeAiRetry] failed:', retryErr);
}
}
}
codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
_autoSaveChatSession();
if (activeProjectName) saveProjectSession(activeProjectName);
thinkEl.classList.remove('streaming'); // Stop cursor animation
if (!reply) { thinkEl.textContent = '(응답 없음)'; }
else codeAiRenderMarkdown(thinkEl, reply);
} catch(e) {
thinkEl.className = 'code-ai-msg thinking';
const errMsg = (e.name === 'AbortError' || (e.message || '').includes('aborted'))
? '요청이 취소되었습니다. 다시 시도해주세요.'
: (e.message || e);
thinkEl.textContent = '오류: ' + errMsg;
}
}
// ── Tool action cards for Cline-style visibility ──────────────────────────
const _TOOL_ICONS = {
read_file: '📖', list_files: '📂', search_files: '🔍',
edit_file: '✏️', create_file: '📄', replace_lines: '✏️',
insert_after: '➕', delete_lines: '🗑️', find_replace: '🔄',
delete_file: '🗑️', shell: '▶️', python_eval: '🐍', web_search: '🌐',
};
const _TOOL_COLORS = {
read_file: '#3b82f6', list_files: '#3b82f6', search_files: '#3b82f6',
edit_file: '#22c55e', create_file: '#22c55e', replace_lines: '#22c55e',
insert_after: '#22c55e', delete_lines: '#f87171', find_replace: '#22c55e',
delete_file: '#f87171', shell: '#f59e0b', python_eval: '#f59e0b', web_search: '#8b5cf6',
};
const _FILE_TOOLS = new Set(['read_file', 'create_file', 'edit_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'delete_file']);
function _codeAiToolAction(toolName, toolArgs, toolResult, toolError) {
const msgs = document.getElementById('code-ai-messages');
if (!msgs) return;
const icon = _TOOL_ICONS[toolName] || '🔧';
const color = _TOOL_COLORS[toolName] || '#6b7280';
if (toolArgs !== null) {
// Tool call event — show action card
const card = document.createElement('div');
card.className = 'code-ai-tool-card';
card.dataset.tool = toolName;
const fname = toolArgs.filename || toolArgs.name || '';
const shortName = fname.replace(/^code\//, '').split('/').pop() || '';
const isFile = _FILE_TOOLS.has(toolName);
const detail = isFile ? escHtml(shortName || fname) : (toolArgs.command || toolArgs.query || toolArgs.code || '').slice(0, 60);
card.innerHTML = `<span class="code-ai-tool-icon" style="color:${color}">${icon}</span> <span class="code-ai-tool-name">${escHtml(toolName)}</span> <span class="code-ai-tool-detail" style="color:var(--muted);font-size:11px">${detail}</span>`;
msgs.appendChild(card);
codeAiScrollBottom();
} else if (toolResult !== null) {
// Tool result event — update last matching card or append result
const cards = msgs.querySelectorAll(`.code-ai-tool-card[data-tool="${toolName}"]`);
const card = cards[cards.length - 1];
if (card && !card.dataset.done) {
card.dataset.done = '1';
const resultIcon = toolError ? '❌' : '✅';
const resultText = toolError ? escHtml((toolResult || 'Error').slice(0, 120)) : escHtml((toolResult || 'OK').slice(0, 80));
const resultEl = document.createElement('span');
resultEl.className = 'code-ai-tool-result';
resultEl.innerHTML = `${resultIcon} ${resultText}`;
card.appendChild(resultEl);
if (toolError) card.style.borderLeftColor = '#f87171';
}
codeAiScrollBottom();
}
}
// ── Client-side file intercept: apply tool changes to local folder ──────────
// When the AI uses file-modifying tools, we also apply them locally so
// the user's local folder stays in sync with the editor.
// Applies a model tool_call to the local folder / editor. The server short-circuits
// file tools for local sessions (synthetic success), so the CLIENT is the source of
// truth — meaning a silent failure here would let the model believe an edit landed
// when nothing changed. Returns { ok, reason } so the caller can warn the user.
async function _codeAiApplyToolLocally(toolName, args) {
if (!args) return { ok: true };
// Normalise to bare filename: strip code/ prefix, then strip client project prefix.
// Model always sends bare names; _clientProjectPath resolves to the disk path.
const fname = _clientBareFileName((args.filename || args.name || '').replace(/^code\//, ''));
const diskPath = _clientProjectPath(fname);
const _FILE_TOOLS = new Set(['create_file', 'edit_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'delete_file']);
if (!_FILE_TOOLS.has(toolName)) return { ok: true };
if (!fname) return { ok: false, reason: '파일명이 없습니다' };
try {
switch (toolName) {
case 'create_file': {
const content = args.content || '';
if (localDirHandle) await localWriteFile(diskPath, content);
codeEditorWriteFile(fname, content);
return { ok: true };
}
case 'edit_file': {
// Read current content from editor or client-local disk
const existing = await _codeAiReadLocalFile(fname);
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
const newContent = _applySearchReplace(existing, args.edits || '');
if (newContent === null) return { ok: false, reason: `${fname}: SEARCH 블록이 파일 내용과 일치하지 않습니다` };
if (localDirHandle) await localWriteFile(diskPath, newContent);
codeEditorWriteFile(fname, newContent);
return { ok: true };
}
case 'replace_lines': {
const startLine = parseInt(args.start_line) || 1;
const endLine = parseInt(args.end_line) || startLine;
const newContent = args.new_content || '';
const existing = await _codeAiReadLocalFile(fname);
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
const lines = existing.split('\n');
const before = lines.slice(0, startLine - 1).join('\n');
const after = lines.slice(endLine).join('\n');
const result = endLine < lines.length ? before + '\n' + newContent + '\n' + after : before + '\n' + newContent;
if (localDirHandle) await localWriteFile(diskPath, result);
codeEditorWriteFile(fname, result);
return { ok: true };
}
case 'insert_after': {
const afterLine = parseInt(args.after_line) || 0;
const content = args.content || '';
const existing = await _codeAiReadLocalFile(fname);
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
const lines = existing.split('\n');
const insertIdx = afterLine >= 0 ? afterLine : 0;
lines.splice(insertIdx, 0, ...content.split('\n'));
const result = lines.join('\n');
if (localDirHandle) await localWriteFile(diskPath, result);
codeEditorWriteFile(fname, result);
return { ok: true };
}
case 'delete_lines': {
const startLine = parseInt(args.start_line) || 1;
const endLine = parseInt(args.end_line) || startLine;
const existing = await _codeAiReadLocalFile(fname);
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
const lines = existing.split('\n');
lines.splice(startLine - 1, endLine - startLine + 1);
const result = lines.join('\n');
if (localDirHandle) await localWriteFile(diskPath, result);
codeEditorWriteFile(fname, result);
return { ok: true };
}
case 'find_replace': {
const find = args.find || '';
const replace = args.replace || '';
const existing = await _codeAiReadLocalFile(fname);
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
if (!existing.includes(find)) return { ok: false, reason: `${fname}: "${find.slice(0, 30)}…" 텍스트를 찾을 수 없습니다` };
const result = existing.replace(find, replace);
if (localDirHandle) await localWriteFile(diskPath, result);
codeEditorWriteFile(fname, result);
return { ok: true };
}
case 'delete_file': {
if (localDirHandle) await localDeleteFile(diskPath);
const fileId = codeFiles.find(f => f.name === fname)?.id;
if (fileId) { codeFiles.splice(codeFiles.findIndex(f => f.id === fileId), 1); renderCodeFileTabs(); }
return { ok: true };
}
}
return { ok: true };
} catch (e) {
console.warn('[codeAiApplyToolLocally] error:', e);
return { ok: false, reason: (e && e.message) || String(e) };
}
}
// Read a file from editor tabs (preferred) or local folder
async function _codeAiReadLocalFile(fname) {
// Normalise: strip code/ prefix, then strip project prefix → bare name
const bare = _clientBareFileName(fname.replace(/^code\//, ''));
// Try editor tabs first (most up-to-date); tabs store bare names in client-local mode
const tab = codeFiles.find(f => f.name === bare || f.name === fname || f.name.endsWith('/' + bare));
if (tab) return tab.model ? tab.model.getValue() : tab.content;
// Try client-local disk (scoped to project subfolder when applicable)
if (localDirHandle) {
const content = await localReadFile(_clientProjectPath(bare));
if (content !== null) return content;
// Fallback: bare path (covers root-level files when no activeProjectName)
if (_clientProjectPath(bare) !== bare) {
const c2 = await localReadFile(bare);
if (c2 !== null) return c2;
}
}
return null;
}
// Apply SEARCH/REPLACE blocks to content (client-side mirror of server edit_file)
function _applySearchReplace(content, edits) {
if (!edits || !content) return null;
const srRegex = /------- SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n\+\+\+\+\+\+\+ REPLACE/g;
let match;
let result = content;
let editCount = 0;
while ((match = srRegex.exec(edits)) !== null) {
const searchText = match[1];
const replaceText = match[2];
if (result.includes(searchText)) {
result = result.replace(searchText, replaceText);
editCount++;
} else {
// Fallback: try with normalized whitespace
const normalize = s => s.split('\n').map(l => l.trimEnd()).join('\n');
if (normalize(result).includes(normalize(searchText))) {
const lines = result.split('\n');
const searchLines = searchText.split('\n');
const normLines = lines.map(l => l.trimEnd());
const normSearch = searchLines.map(l => l.trimEnd());
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
const originalText = lines.slice(i, i + searchLines.length).join('\n');
result = result.replace(originalText, replaceText);
editCount++;
break;
}
}
}
}
}
return editCount > 0 ? result : null;
}
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');
if (!lines.some(l => l.trim())) return false;
// An @@ hunk header is an unambiguous diff signal.
if (lines.some(l => l.startsWith('@@'))) return true;
// Count single-char +/- markers. The old test required a space after the
// marker (/^[+\-] /), which BOTH missed standard `+code`/`-code` diffs (no
// space → markers leaked into the file as literal text) AND misfired on
// markdown "- item" lists. Exclude +++/--- file headers and ++/-- runs.
const plus = lines.filter(l => /^\+(?!\+\+)/.test(l)).length;
const minus = lines.filter(l => /^-(?!--)/.test(l)).length;
if (plus === 0 && minus === 0) return false;
// A two-sided change (both adds and removes) is unambiguously a diff.
if (plus > 0 && minus > 0) return true;
// Single-sided: only a diff if accompanied by real context lines. A diff
// context line is " <code>" — a marker space then content that is NOT itself a
// bullet ("- "/"* "/"+ "). This separates a unified-diff body from a plain
// YAML/markdown list, whose items are bullets at any indent.
const strongContext = lines.filter(l =>
/^ /.test(l) && l.trim() && !/^[-*+]\s/.test(l.trim())).length;
if (strongContext > 0) return true;
// Note: we do NOT use filename comments as a diff indicator here.
// `# filename:` comments appear in both diff blocks AND new-file blocks,
// so using them to detect diffs causes false positives that break multi-file
// extraction (new files get misclassified as diffs and silently dropped).
return false;
}
// 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 = '';
// Detect and render plan lines (📋 or numbered steps like "1) ... -> 2) ...")
// before the code blocks
const planRe = /^[📋🔹▸▶→➤]\s*.*$/m;
const numberedPlanRe = /^\d+[).]\s+.*(?:\s*[-→>]\s*\d+[).]\s+.*)+/m;
const isPlan = planRe.test(text) || numberedPlanRe.test(text);
if (isPlan) {
// Extract plan lines and render them in a plan box
const lines = text.split('\n');
const planLines = [];
let inPlan = false;
for (const line of lines) {
if (/^[📋🔹▸▶→➤]\s*/.test(line) || /^\d+[).]\s+/.test(line)) {
inPlan = true;
planLines.push(line);
} else if (inPlan && line.trim() === '') {
break;
} else if (inPlan) {
planLines.push(line);
}
}
if (planLines.length > 0) {
const planDiv = document.createElement('div');
planDiv.className = 'code-ai-plan';
planDiv.innerHTML = '<strong>📋 계획</strong><br>' + planLines.map(l => escHtml(l)).join('<br>').replace(/[-→>]\s*(\d+[).]\s+)/g, '<br>$1');
el.appendChild(planDiv);
// Remove plan lines from text before further processing
const planEnd = text.indexOf('\n', text.indexOf(planLines[planLines.length - 1]) + planLines[planLines.length - 1].length);
if (planEnd > 0) text = text.slice(planEnd + 1);
}
}
// 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).
// Strip trailing closing fence that has no newline after it — the fence regex
// requires \n so a final ``` without newline isn't matched, leaving it in the code.
if (inBlock) {
const rawCode = text.slice(blockStart).replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
parts.push({ type: 'code', lang: blockLang, code: rawCode });
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);
}
// Show inline re-login form inside the AI chat when session expires (401)
function _codeAiShowRelogin() {
const id = 'relogin_' + Date.now();
const el = codeAiAppend('system', '');
el.innerHTML = `<div style="display:flex;flex-direction:column;gap:8px;padding:4px 0">
<span style="color:#f87">⚠️ 세션 만료 — 재로그인</span>
<input id="${id}_u" placeholder="아이디" style="background:#1e1e1e;border:1px solid #444;color:#eee;padding:6px 8px;border-radius:4px;font-size:13px" />
<input id="${id}_p" type="password" placeholder="비밀번호" style="background:#1e1e1e;border:1px solid #444;color:#eee;padding:6px 8px;border-radius:4px;font-size:13px" />
<button onclick="_codeAiDoRelogin('${id}')" style="background:#2d6a2d;color:#eee;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:13px">로그인</button>
</div>`;
setTimeout(() => document.getElementById(id + '_u')?.focus(), 50);
}
async function _codeAiDoRelogin(id) {
const u = document.getElementById(id + '_u')?.value?.trim();
const p = document.getElementById(id + '_p')?.value;
if (!u || !p) return;
try {
const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) });
const d = await r.json();
if (d.token) {
setAuthToken(d.token);
const el = document.getElementById(id + '_u')?.closest('.code-ai-msg');
if (el) el.remove();
codeAiAppend('system', '✅ 로그인 완료 — 다시 메시지를 보내세요');
} else {
codeAiAppend('system', '❌ 로그인 실패: ' + (d.error || '아이디/비밀번호 확인'));
}
} catch (e) { codeAiAppend('system', '❌ 로그인 오류: ' + e.message); }
}
// 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(_clientProjectPath(file.name), content);
if (ok) { codeAiAppend('system', `💾 저장: ${file.name}`); codeSbRefresh(); }
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 + queue (supports multiple files at once) ──────
let _fileApprovalQueue = []; // [{fileId, fileName}]
function _enqueueFileApproval(fileId, fileName) {
_fileApprovalQueue.push({ fileId, fileName });
if (_fileApprovalQueue.length === 1) _showNextApproval();
}
function _showNextApproval() {
if (!_fileApprovalQueue.length) return;
const { fileId, fileName } = _fileApprovalQueue[0];
_showNewFileApproval(fileId, fileName);
}
function _showNewFileApproval(fileId, fileName) {
const file = codeFiles.find(f => f.id === fileId);
if (!file) { _fileApprovalQueue.shift(); _showNextApproval(); return; }
// In local mode: auto-save directly, no approval popup needed
if (localDirHandle) {
const content = file.model ? file.model.getValue() : (file.content || '');
localWriteFile(_clientProjectPath(file.name), content).then(ok => {
if (ok) { codeSbRefresh(); codeAiAppend('system', `💾 저장: ${file.name}`); }
else codeAiAppend('system', `⚠️ 저장 실패: ${file.name}`);
});
_fileApprovalQueue.shift();
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
return;
}
switchCodeFile(fileId);
const bar = document.getElementById('code-diff-bar');
const label = document.getElementById('code-diff-label');
if (!bar || !label) return;
const remaining = _fileApprovalQueue.length;
const queueNote = remaining > 1 ? ` (${remaining}개 중 1번째)` : '';
label.textContent = '📝 새 파일: ' + fileName + queueNote;
const acceptBtn = document.getElementById('code-diff-accept');
const rejectBtn = document.getElementById('code-diff-reject');
if (acceptBtn) {
acceptBtn.textContent = remaining > 1 ? '✅ 만들기 →' : '✅ 만들기';
acceptBtn.onclick = () => {
bar.style.display = 'none';
codeSaveFileById(fileId);
_fileApprovalQueue.shift();
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
};
}
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();
_fileApprovalQueue.shift();
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
};
}
bar.style.display = 'flex';
}
// Extract blocks that have an explicit `# filename:` / `// filename:` comment
function _getExplicitFilename(code) {
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i);
return m ? m[1].replace(/[*\/>]+$/, '') : null;
}
function _extractExplicitFileBlocks(text) {
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
const result = [];
let inBlock = false, blockLang = '', blockStart = 0, m;
while ((m = fenceRe.exec(text)) !== null) {
const lineStart = m.index === 0 ? 0 : text.lastIndexOf('\n', m.index - 1) + 1;
if (text.slice(lineStart, m.index).trim() !== '') continue;
if (!inBlock) { blockLang = m[1] || 'text'; blockStart = m.index + m[0].length; inBlock = true; }
else {
const code = text.slice(blockStart, m.index);
const fname = _getExplicitFilename(code);
// Include BOTH diff and non-diff blocks with filenames.
// Previously we excluded diffs here, which caused new files to be lost
// when the AI outputs a diff (modify existing) + new-file blocks (create new).
if (fname) result.push({ fname, code, lang: blockLang, isDiff: _isDiffCode(blockLang, code) });
inBlock = false;
}
}
if (inBlock) {
const code = text.slice(blockStart);
const fname = _getExplicitFilename(code);
if (fname) result.push({ fname, code, lang: blockLang, isDiff: _isDiffCode(blockLang, code) });
}
return result;
}
function codeAiScrollBottom() {
const msgs = document.getElementById('code-ai-messages');
if (msgs) msgs.scrollTop = msgs.scrollHeight;
}
// ── Project session management ────────────────────────────────────────────
function saveProjectSession(projectName) {
if (!projectName) return;
localStorage.setItem('code_proj_session_' + projectName, codeAiSessionId);
try { localStorage.setItem('code_proj_history_' + projectName, JSON.stringify(codeAiHistory.slice(-40))); } catch {}
}
function _knownProjectsGet() {
try { return JSON.parse(localStorage.getItem('code_known_projects') || '[]'); } catch { return []; }
}
function _knownProjectsAdd(name) {
if (!name) return;
const list = _knownProjectsGet();
if (!list.includes(name)) { list.push(name); localStorage.setItem('code_known_projects', JSON.stringify(list)); }
}
function _knownProjectsRemove(name) {
if (!name) return;
const list = _knownProjectsGet().filter(p => p !== name);
localStorage.setItem('code_known_projects', JSON.stringify(list));
}
async function deleteActiveProject() {
if (!activeProjectName) return;
if (!confirm(`"${activeProjectName}" 프로젝트를 삭제할까요?\n(폴더와 파일이 모두 삭제됩니다)`)) return;
const name = activeProjectName;
// localStorage 정리
_knownProjectsRemove(name);
localStorage.removeItem('code_proj_session_' + name);
localStorage.removeItem('code_proj_history_' + name);
// 로컬 폴더 삭제
if (localDirHandle) {
try { await localDirHandle.removeEntry(name, { recursive: true }); } catch (e) { console.warn('[deleteProject]', e); }
}
// 탭 닫기 및 프로젝트 초기화
codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} });
codeFiles.length = 0; activeCodeFileId = null; codeDiffState = {};
renderCodeFileTabs();
activeProjectName = null;
localStorage.setItem('code_active_project', '');
codeAiHistory = [];
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
updateLocalModeUI();
updateSessionPanel();
codeSbRefresh();
_renderSessionsList();
}
function switchProject(name) {
if (activeProjectName) saveProjectSession(activeProjectName);
activeProjectName = name || null;
localStorage.setItem('code_active_project', activeProjectName || '');
if (activeProjectName) {
_knownProjectsAdd(activeProjectName);
const savedId = localStorage.getItem('code_proj_session_' + activeProjectName);
const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName);
codeAiSessionId = savedId || ('code_ai_' + Math.random().toString(36).slice(2, 10));
try { codeAiHistory = savedHist ? JSON.parse(savedHist) : []; } catch { codeAiHistory = []; }
saveProjectSession(activeProjectName);
} else {
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
codeAiHistory = [];
}
updateLocalModeUI();
updateSessionPanel();
codeSbRefresh();
if (companionShellId) _companionCdProject();
// 채팅 패널 갱신: 히스토리가 있으면 그대로, 없으면 세션 목록
const _msgs = document.getElementById('code-ai-messages');
if (_msgs) {
_msgs.innerHTML = '';
if (codeAiHistory.length) {
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
} else {
_renderSessionsList();
}
}
// In client-local mode: close previous project's tabs and load new project files
if (localDirHandle) _clientLoadProjectFiles();
}
function csbRenderProjectSelector(projects) {
const sel = document.getElementById('csb-project-sel');
const row = document.getElementById('csb-project-row');
if (!sel || !row) return;
if (!projects.length) { row.style.display = 'none'; return; }
row.style.display = 'flex';
const options = '<option value="">(전체)</option>' +
projects.map(p => `<option value="${escHtml(p)}"${activeProjectName === p ? ' selected' : ''}>${escHtml(p)}</option>`).join('');
sel.innerHTML = options;
sel.value = activeProjectName || '';
// Also sync the session panel project selector
const sessSel = document.getElementById('csb-session-project-sel');
if (sessSel) {
sessSel.innerHTML = options;
sessSel.value = activeProjectName || '';
}
}
// ── Session list (Cline style) ─────────────────────────────────────────────
const _SESS_KEY = 'code_chat_sessions_v2';
const _SESS_MAX = 30;
function _loadSessions() {
try { return JSON.parse(localStorage.getItem(_SESS_KEY) || '[]'); } catch { return []; }
}
function _saveSessions(list) {
try { localStorage.setItem(_SESS_KEY, JSON.stringify(list)); } catch {}
}
function _autoSaveChatSession() {
if (!codeAiHistory.length) return;
const list = _loadSessions();
const firstUser = codeAiHistory.find(m => m.role === 'user')?.content || '';
const title = firstUser.replace(/^\[파일:[^\]]+\]\s*/, '').slice(0, 50) || '(제목 없음)';
const existing = list.findIndex(s => s.sessionId === codeAiSessionId);
const entry = {
id: existing >= 0 ? list[existing].id : ('sess_' + Math.random().toString(36).slice(2, 9)),
sessionId: codeAiSessionId,
title,
project: activeProjectName || '',
ts: Date.now(),
msgCount: codeAiHistory.length,
history: codeAiHistory.slice(-60),
};
if (existing >= 0) list[existing] = entry;
else list.unshift(entry);
if (list.length > _SESS_MAX) list.length = _SESS_MAX;
_saveSessions(list);
}
function _renderSessionsList() {
const msgs = document.getElementById('code-ai-messages');
if (!msgs) return;
const list = _loadSessions();
const proj = activeProjectName || '';
const filtered = proj ? list.filter(s => s.project === proj || !s.project) : list;
if (!filtered.length) {
msgs.innerHTML = '<div class="code-ai-msg thinking" style="text-align:center;padding:24px 8px;color:var(--muted)">저장된 세션이 없습니다.<br>아래에 질문을 입력하면 새 세션이 시작됩니다.</div>';
return;
}
const relTime = ts => {
const d = (Date.now() - ts) / 1000;
if (d < 60) return '방금';
if (d < 3600) return Math.floor(d/60) + '분 전';
if (d < 86400) return Math.floor(d/3600) + '시간 전';
return Math.floor(d/86400) + '일 전';
};
msgs.innerHTML = `
<div style="padding:12px 10px 4px;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:11px;color:var(--muted);letter-spacing:.05em">최근 세션</span>
${filtered.length > 0 ? `<button onclick="_deleteAllSessions(event)" style="font-size:10px;padding:2px 8px;border:1px solid #f87171;background:none;color:#f87171;border-radius:4px;cursor:pointer">전체 삭제</button>` : ''}
</div>
${filtered.map(s => `
<div style="margin:0 8px 6px;padding:10px 12px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;cursor:pointer;transition:border-color .15s"
onmouseover="this.style.borderColor='#3d6b4f'" onmouseout="this.style.borderColor='var(--line)'">
<div style="display:flex;justify-content:space-between;align-items:start">
<div onclick="_resumeSession('${s.id}')" style="flex:1;min-width:0">
<div style="font-size:13px;color:var(--text);margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(s.title)}</div>
<div style="display:flex;gap:6px;align-items:center">
<span style="font-size:11px;color:var(--muted)">${s.project ? escHtml(s.project) + ' · ' : ''}${s.msgCount}개</span>
<span style="font-size:11px;color:var(--muted)">${relTime(s.ts)}</span>
</div>
</div>
<button onclick="_deleteSession('${s.id}', event)" title="세션 삭제" style="flex-shrink:0;margin-left:6px;background:none;border:none;color:var(--muted);cursor:pointer;font-size:14px;padding:2px 4px;border-radius:4px;line-height:1" onmouseover="this.style.color='#f87171';this.style.background='rgba(248,113,113,0.1)'" onmouseout="this.style.color='var(--muted)';this.style.background='none'">✕</button>
</div>
</div>`).join('')}`;
}
function _resumeSession(id) {
const list = _loadSessions();
const sess = list.find(s => s.id === id);
if (!sess) return;
_autoSaveChatSession(); // save current before switching
codeAiHistory = sess.history || [];
codeAiSessionId = sess.sessionId;
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
if (msgs) {
msgs.innerHTML = '';
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
}
updateSessionPanel();
}
function _deleteSession(id, event) {
if (event) { event.stopPropagation(); event.preventDefault(); }
let list = _loadSessions();
const sess = list.find(s => s.id === id);
// If deleting current session, clear it
if (sess && sess.sessionId === codeAiSessionId) {
codeAiHistory = [];
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
if (msgs) msgs.innerHTML = '';
}
list = list.filter(s => s.id !== id);
_saveSessions(list);
// Also clear server-side history for this session
if (sess) {
fetch('/api/clear-history', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sessionId: sess.sessionId }),
}).catch(() => {});
}
_renderSessionsList();
updateSessionPanel();
}
function _deleteAllSessions(event) {
if (event) { event.stopPropagation(); event.preventDefault(); }
const list = _loadSessions();
// Clear server-side history for all sessions
for (const s of list) {
fetch('/api/clear-history', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sessionId: s.sessionId }),
}).catch(() => {});
}
_saveSessions([]);
// Also clear project-specific session storage
Object.keys(localStorage).filter(k => k.startsWith('code_proj_session_') || k.startsWith('code_proj_history_')).forEach(k => localStorage.removeItem(k));
codeAiHistory = [];
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
if (msgs) msgs.innerHTML = '';
_renderSessionsList();
updateSessionPanel();
}
async function codeAiClear() {
_autoSaveChatSession(); // 현재 세션 자동 저장
if (activeProjectName) saveProjectSession(activeProjectName); // per-project store도 지우기 전에 저장
fetch('/api/clear-history', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sessionId: codeAiSessionId }),
}).catch(() => {});
codeAiHistory = [];
// In client-local mode: ask for a project name and create the folder
if (localDirHandle) {
const raw = prompt('새 프로젝트 이름:', '');
const projName = (raw || '').trim().replace(/[^a-zA-Z0-9가-힣_-]/g, '_').replace(/^_+|_+$/g, '');
if (projName) {
await localDirHandle.getDirectoryHandle(projName, { create: true }).catch(() => {});
switchProject(projName);
// 새 프로젝트 → 마법사 계획 단계 자동 진입
_projPhase = 'plan';
_projHistory = [];
_projSessionId = 'proj-plan-' + Date.now();
codeAiAppend('system', `📁 프로젝트 "${projName}" 생성됨. 어떤 걸 만들지 설명해주세요.`);
return;
}
}
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
updateSessionPanel();
_renderSessionsList(); // 세션 목록으로 돌아가기
}
// ── Code sidebar panel ────────────────────────────────────────────────────
let codeSbCurrentTab = 'files';
function setCodeSbTab(tab) {
codeSbCurrentTab = tab;
['files','settings'].forEach(t => {
const btn = document.getElementById('csb-tab-' + t);
if (btn) btn.classList.toggle('active', t === tab);
const el = document.getElementById('csb-' + t);
if (el) el.style.display = t === tab ? 'flex' : 'none';
});
if (tab === 'files') csbLoadModels();
}
function updateSessionPanel() {
const barProj = document.getElementById('code-ai-session-bar-project');
const barCount = document.getElementById('code-ai-session-bar-count');
if (barProj) barProj.textContent = activeProjectName || '(전체)';
if (barCount) barCount.textContent = codeAiHistory.length ? `${codeAiHistory.length}개` : '';
// 프로젝트 선택기 (항상 업데이트)
const sel = document.getElementById('csb-session-project-sel');
const projRow = document.getElementById('csb-session-project-row');
if (sel && localDirHandle) {
localListFiles(localDirHandle, '').then(files => {
const projects = [...new Set(files.filter(f => f.includes('/')).map(f => f.split('/')[0]))].sort();
const options = '<option value="">(전체)</option>' +
projects.map(p => `<option value="${escHtml(p)}"${activeProjectName === p ? ' selected' : ''}>${escHtml(p)}</option>`).join('');
sel.innerHTML = options;
sel.value = activeProjectName || '';
if (projRow) projRow.style.display = projects.length ? '' : 'none';
}).catch(() => { if (projRow) projRow.style.display = 'none'; });
} else if (projRow) {
projRow.style.display = 'none';
}
}
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 allFiles = await localListFiles(localDirHandle, '');
// Build project list: dirs that contain code files + all known projects (persisted in localStorage)
const projectSet = new Set(allFiles.filter(f => f.includes('/')).map(f => f.split('/')[0]));
_knownProjectsGet().forEach(p => projectSet.add(p));
if (activeProjectName) projectSet.add(activeProjectName);
const projects = [...projectSet].sort();
csbRenderProjectSelector(projects);
const files = activeProjectName
? allFiles.filter(f => f.startsWith(activeProjectName + '/'))
: allFiles;
if (!files.length) {
const emptyMsg = activeProjectName
? `<div style="padding:10px;font-size:11px;color:var(--muted)">
<div style="font-weight:600;color:var(--text);margin-bottom:4px">📁 ${escHtml(activeProjectName)}/</div>
<div style="padding-left:12px;color:var(--muted)">비어 있음 — + 버튼으로 파일 추가</div>
</div>`
: '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">파일 없음<br><small>+ 버튼으로 추가</small></div>';
tree.innerHTML = emptyMsg;
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:'📄' };
// 트리 노드 구조 빌드 (leaf = 항상 전체 경로)
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
}
// 프로젝트 선택 시: 해당 서브트리를 루트로, parentPath를 프로젝트명으로 시작
const displayRoot = (activeProjectName && root[activeProjectName]) ? root[activeProjectName] : root;
const startPath = activeProjectName || '';
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(displayRoot, 0, startPath);
} 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;
// In client-local+project mode, tabs use bare names; sidebar paths include the project prefix
const bare = _clientBareFileName(filename);
const existing = codeFiles.find(f => f.name === bare);
if (existing) switchCodeFile(existing.id);
else codeNewFile(bare, content);
} catch {}
}
async function csbDeleteFile(filename, ev) {
ev.stopPropagation();
if (!localDirHandle || !confirm(`"${filename}" 삭제할까요?`)) return;
try {
const ok = await localDeleteFile(filename);
if (ok) {
const bare = _clientBareFileName(filename);
const existing = codeFiles.find(f => f.name === bare);
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 bare = _clientBareFileName(filename);
const existing = codeFiles.find(f => f.name === bare);
if (existing) switchCodeFile(existing.id);
else codeNewFile(bare, 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 bare = _clientBareFileName(filename);
const existing = codeFiles.find(f => f.name === bare);
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 { updateSessionPanel(); } catch (e) {}
try { codeSbRefresh(); } catch (e) {}
try { companionConnect(); } catch (e) {}
// 히스토리 없으면 세션 목록 표시
try { if (!codeAiHistory.length) _renderSessionsList(); } catch (e) {}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();