Files
homeclaw/web-ui/code.js
T
kimandClaude Sonnet 4.6 9b558be9f4 Code editor: block flip-flop colors, view option buttons, terminal error improvements, live server log bubble
- Add alternating block background colors (ff-block-even/odd) for functions/classes
- Add ¶빈줄숨김 / ▓블록색상 toggle buttons to file panel sidebar (below file tree)
- Fix terminal error send: filter Windows prompt+command lines, only send last Traceback block, strip pygame header noise
- Show live server log bubble during AI tool calls, fade out on completion

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

5762 lines
267 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 = [];
// Strip code blocks from a history entry before storage.
// File content is re-sent via contextMsg on every request, so repeating it in history
// is pure waste and the main source of payload bloat. Keep only prose/summary text.
function _histEntry(text) {
if (!text) return text;
const stripped = text
.replace(/```[\s\S]*?```/g, '[코드]') // fenced code blocks → placeholder
.replace(/<think>[\s\S]*?<\/think>/gi, '') // thinking tags
.trim();
return stripped.slice(0, 3000) || text.slice(0, 200); // prose cap; fallback if all stripped
}
// Returns history entries (excluding last/current user msg) up to maxMsgs count AND maxBytes total
function _histForAPI(hist, maxMsgs = 20, maxBytes = 150 * 1024) {
if (hist.length <= 1) return [];
const src = hist.slice(0, -1);
let bytes = 0;
const result = [];
for (let i = src.length - 1; i >= 0 && result.length < maxMsgs; i--) {
const size = (src[i].content || '').length;
if (bytes + size > maxBytes) break;
bytes += size;
result.unshift({ role: src[i].role, content: src[i].content });
}
return result;
}
let codeAiAbort = null;
let _codeAiAutoAccept = false; // when true: skip diff review, write files directly
let _autoAcceptTurn = false; // true during an auto-accept execute phase → triggers completion message
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 codeAiMode = localStorage.getItem('codeAiMode') || 'code'; // 'ask' | 'code'
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 input = document.getElementById('code-ai-input');
if (input) input.placeholder = label + '에게 질문... (예: 게임 만들어줘)';
}
function setCodeAiTech(tech) {
codeAiTech = tech;
localStorage.setItem('codeAiTech', tech);
}
function initCodeView() {
updateCodeAiLabel();
setCodeAiTech(codeAiTech);
if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
if (typeof require === 'undefined') return;
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
require(['vs/editor/editor.main'], function() {
monacoReady = true;
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
monacoEditor = monaco.editor.create(document.getElementById('code-editor-container'), {
theme: isDark ? 'vs-dark' : 'vs',
language: 'plaintext',
automaticLayout: true,
fontSize: 14,
lineHeight: 22,
fontFamily: "'D2Coding', 'D2 Coding', monospace",
minimap: { enabled: true },
scrollBeyondLastLine: false,
wordWrap: 'on',
renderWhitespace: 'selection',
smoothScrolling: true,
cursorBlinking: 'smooth',
mouseWheelZoom: true,
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();
if (_hideBlankLines) setTimeout(_applyHideBlankLines, 50);
if (_blockFlipFlop) setTimeout(_applyBlockFlipFlop, 60);
}
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}` : '📁 폴더 없음';
}
const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git', 'dist', 'build', '.venv', 'venv', '.cache', '.next', 'out', 'coverage', 'target', '.idea', '.vs']);
// Per-file byte cap for AI context. A minified bundle / large data file can be few
// lines but tens of MB — line-count windowing misses it, so cap by bytes too.
const MAX_AI_FILE_BYTES = 256 * 1024; // 256KB
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') {
if (SKIP_DIRS.has(name)) continue;
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;
function _stripTerminal(raw) {
return raw
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title 등)
.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, '') // CSI (색상, 커서, private mode)
.replace(/\x1b[^[\]]/g, '') // 기타 ESC 시퀀스
.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '') // 제어문자 (\n 제외)
.replace(/\r/g, '');
}
function _termBufferAppend(shellId, raw) {
if (!shellId) return;
const text = _stripTerminal(raw);
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) {
const btn = document.getElementById('code-term-ai-error-btn');
if (btn) { btn.style.display = ''; btn.style.background = '#dc2626'; btn.style.color = '#fff'; btn.dataset.shellId = shellId; }
}
function _sendTerminalErrorToAI(errorText) {
const input = document.getElementById('code-ai-input');
if (!input) return;
// Filter noise lines
const filtered = errorText.split('\n').filter(l => {
const t = l.trim();
if (!t) return false;
if (/^[A-Za-z]:\\.+>/.test(t)) return false; // Windows 프롬프트 (명령어 포함)
if (/^[~/][^\s]*[$#]\s*/.test(t)) return false; // Unix 프롬프트
if (/^Hello from the pygame community/.test(t)) return false;
if (/^https?:\/\/www\.pygame\.org/.test(t)) return false;
return true;
});
// 마지막 Traceback 블록만 추출
let blockStart = 0;
for (let i = filtered.length - 1; i >= 0; i--) {
if (/Traceback \(most recent call last\)/.test(filtered[i])) {
blockStart = i; break;
}
}
const trimmed = filtered.slice(blockStart).slice(-30).join('\n');
if (!trimmed) return;
input.value = '다음 에러를 고쳐줘. edit_file 도구로 파일을 직접 수정해:\n```\n' + trimmed + '\n```';
input.style.height = '';
input.dispatchEvent(new Event('input'));
codeAiSend();
}
function _getXtermVisibleText(term, maxLines = 80) {
const buf = term.buffer.active;
const lines = [];
const endRow = buf.baseY + buf.cursorY;
const startRow = Math.max(0, endRow - maxLines);
for (let i = startRow; i <= endRow; i++) {
const line = buf.getLine(i);
if (!line) continue;
const t = line.translateToString(true);
if (t.trim()) lines.push(t);
}
return lines.join('\n');
}
function _sendActiveTermErrorToAI() {
const btn = document.getElementById('code-term-ai-error-btn');
const shellId = btn?.dataset.shellId || _lastErrorShellId || activeCodeTermShellId;
const shell = codeTermShells.find(s => s.id === shellId) || codeTermShells.find(s => s.id === activeCodeTermShellId);
const text = (shell?.term ? _getXtermVisibleText(shell.term) : '')
|| (shellId && _termBuffers[shellId] || Object.values(_termBuffers).at(-1))?.slice(-40).join('\n')
|| _lastErrorText;
if (text) _sendTerminalErrorToAI(text);
if (btn) { btn.style.background = '#1a3a28'; btn.style.color = '#22c55e'; }
}
// 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.style.display = 'none';
}
// ── Companion WebSocket (local Python execution) ───────────────────────────
let companionWs = null;
let companionConnected = false;
const COMPANION_URL = 'ws://127.0.0.1:9000';
let companionPlatform = 'unix'; // 'windows' | 'unix', set on shell_ready
let companionHome = ''; // home directory from companion
const localShellIds = new Set(); // shell IDs routed to companion PTY (tab id = shell id)
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;
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 → route by msg.shellId; run messages → active code terminal
const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit';
const targetId = isShellMsg && msg.shellId ? msg.shellId : 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;
_companionCdProject(msg.shellId);
}
else if (msg.type === 'shell_output') { s.term.write(msg.data); _termBufferAppend(s.id, msg.data); }
else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[셸 종료 ${msg.code}]\x1b[0m\r\n`); }
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 codeTermKill() {
const s = codeTermShells.find(s => s.id === activeCodeTermShellId);
if (!s) return;
if (localShellIds.has(s.id)) {
// 로컬(companion) PTY 셸: Ctrl+C만 전송 (PTY 자체는 유지)
if (companionConnected && companionWs)
companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: '\x03' }));
} else {
// 서버 셸: PTY에 Ctrl+C 전송
if (termWsConnected) termWsSend({ type: 'pty_input', shellId: s.id, data: '\x03' });
}
if (s.term) s.term.focus();
}
function _companionCdProject(shellId) {
if (!companionConnected || !companionWs || !activeProjectName) return;
// shellId 없으면 activeCodeTermShellId로 폴백 (구버전 companion 호환)
const sid = shellId || activeCodeTermShellId;
const isWin = companionPlatform === 'windows';
const sep = isWin ? '\\' : '/';
const base = (localTermPath || companionHome || '').replace(/[/\\]$/, '');
if (!base) return;
const target = base + sep + activeProjectName;
const cmd = isWin
? `set "PROMPT=$E[A$P$G" && cd /d "${target}" && cls\r`
: `cd ${JSON.stringify(target)} && clear\r`;
companionWs.send(JSON.stringify({ type: 'input', shellId: sid, data: cmd }));
}
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();
// Intercept Ctrl+` inside xterm to toggle the terminal panel.
// Return false = xterm won't process this key; return true = xterm handles it normally.
term.attachCustomKeyEventHandler(e => {
if (e.type === 'keydown' && e.ctrlKey && (e.key === '`' || e.code === 'Backquote')) {
toggleCodeTerminal();
return false;
}
return true;
});
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', shellId: id, 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', shellId: id, 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.classList.add('active');
if (!codeTermShells.length) { requestAnimationFrame(() => { if (companionConnected) addLocalShell(); else 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.classList.remove('active');
}
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
// ── Browser Preview Panel ────────────────────────────────────────────────────
let codePreviewOpen = false;
function toggleCodePreview() {
const panel = document.getElementById('code-preview-panel');
const btn = document.getElementById('code-preview-toggle-btn');
if (!panel) return;
codePreviewOpen = !codePreviewOpen;
if (codePreviewOpen) {
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
const urlInput = document.getElementById('code-preview-url');
const saved = localStorage.getItem('code_preview_url');
if (saved && urlInput) urlInput.value = saved;
const iframe = document.getElementById('code-preview-iframe');
if (iframe && !iframe.src) iframe.src = (urlInput ? urlInput.value : 'http://127.0.0.1:8000');
} else {
panel.style.display = 'none';
if (btn) btn.classList.remove('active');
}
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
function codePreviewRefresh() {
const urlInput = document.getElementById('code-preview-url');
const iframe = document.getElementById('code-preview-iframe');
if (!iframe) return;
const url = urlInput ? urlInput.value.trim() : 'http://127.0.0.1:8000';
if (url) { localStorage.setItem('code_preview_url', url); iframe.src = url; }
}
// Preview panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('code-preview-drag');
const panel = document.getElementById('code-preview-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(100, Math.min(window.innerHeight*.7, startH+dy)) + 'px';
};
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(96,165,250,.4)');
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
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 (!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', shellId: id, rows, cols }));
}
}, 300);
}
function addCodeTermShell() {
const id = 'cdt_' + Math.random().toString(36).slice(2,8);
codeTermShells.push({ id, name:'☁️서버', 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 (companionConnected && companionWs)
companionWs.send(JSON.stringify({ type: 'shell_close', shellId: id }));
} 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('click', e => { if (!e.target.closest('#csb-ctx')) csbCtxClose(); });
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');
}
// Restore preview URL input
const savedUrl = localStorage.getItem('code_preview_url');
if (savedUrl) { const el = document.getElementById('code-preview-url'); if (el) el.value = savedUrl; }
})();
// Ctrl+` shortcut — for Monaco / non-xterm focus; xterm handles it via attachCustomKeyEventHandler
document.addEventListener('keydown', e => {
if (e.ctrlKey && (e.key === '`' || e.code === 'Backquote')) {
e.preventDefault();
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: 'companion', icon: '🖥️', label: 'python3 로컬', desc: '로컬 셸에서 실행' },
],
js: [
{ id: 'companion', icon: '🖥️', label: 'node 로컬', desc: '로컬 셸에서 실행' },
{ id: 'terminal', icon: '☁️', label: 'node 서버', desc: '서버 터미널에서 실행' },
],
ts: [
{ id: 'companion', icon: '🖥️', label: 'ts-node 로컬', desc: '로컬 셸에서 실행' },
{ 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 (localDirHandle) {
// Local folder is open → run via companion shell PTY so output stays in the local tab.
// Ensure local shell tab exists and is front.
const existingLocalId = [...localShellIds].find(id => codeTermShells.find(s => s.id === id));
const needsShell = !existingLocalId;
if (needsShell) addLocalShell();
else { if (!codeTermOpen) toggleCodeTerminal(); switchCodeTermShell(existingLocalId); }
const ext = file.name.split('.').pop().toLowerCase();
const bare = _clientBareFileName(file.name);
const base = (localTermPath || companionHome || '').replace(/[/\\]$/, '');
// Detect Windows from platform flag OR from drive-letter prefix (C:\...) in base path
const isWin = companionPlatform === 'windows' || /^[A-Za-z]:/.test(base);
const sep = isWin ? '\\' : '/';
const proj = activeProjectName ? sep + activeProjectName : '';
// Normalise all separators in the assembled path to match the platform
const rawPath = base ? base + proj + sep + bare : bare;
const normPath = isWin ? rawPath.replace(/\//g, '\\') : rawPath.replace(/\\/g, '/');
const localPath = `"${normPath}"`;
const pyCmd = isWin ? 'python' : 'python3';
const cmd = (ext === 'js' ? `node ${localPath}` :
ext === 'ts' ? `npx ts-node ${localPath}` :
`${pyCmd} ${localPath}`) + '\r';
setTimeout(() => {
const targetShellId = [...localShellIds].find(id => codeTermShells.find(s => s.id === id));
if (companionConnected && companionWs && targetShellId)
companionWs.send(JSON.stringify({ type: 'input', shellId: targetShellId, data: cmd }));
}, needsShell ? 900 : 0);
} else {
// No local folder → send code directly (one-shot run, output to active terminal)
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;
const live = document.createElement('pre');
live.style.cssText = 'margin:4px 0 8px;padding:8px;background:var(--panel-2,#1e1e2e);border:1px solid var(--border,#333);border-radius:5px;font-size:10px;line-height:1.4;max-height:200px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:var(--text,#ccc);display:none';
el.appendChild(live);
el._live = live;
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 = '디스크 저장 실패';
}
}
// 에디터 탭에 추가 — client-local 모드에서는 프로젝트 slug를 제거한 베어 이름 사용
// (_clientLoadProjectFiles와 일관성 유지: 탭은 항상 베어 상대 경로)
const tabName = localDirHandle && filename.includes('/')
? filename.slice(filename.indexOf('/') + 1)
: filename;
_npAddTab(tabName, 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;
let _liveAccum = '';
let _liveFilePath = null;
const earlyDone = new Set(); // raw paths processed incrementally during streaming
const earlyPromises = []; // promises for early _projFileCreated calls — awaited before switchProject
let _earlyRawSlug = null;
const _earlyNormPath = rawPath => {
if (!rawPath.includes('/')) return rawPath;
const pfx = rawPath.split('/')[0];
if (!_earlyRawSlug) _earlyRawSlug = pfx;
const normPfx = activeProjectName
|| pfx.toLowerCase().replace(/[^a-z0-9가-힣_-]/g, '-').replace(/^-+|-+$/g, '');
return pfx !== normPfx ? rawPath.replace(pfx + '/', normPfx + '/') : rawPath;
};
const fullText = await _projStreamSSE(
genSessionId, '위 계획대로 각 파일을 @@FILE / @@ENDFILE 형식으로 생성해주세요.',
_PROJ_GEN_SYSTEM, _projHistory,
chunk => {
genChars += chunk.length;
_liveAccum += chunk;
// @@FILE: 헤더 스캔 — 현재 작성 중인 파일 + 부분 내용 표시
const hdrRe = /@@FILE:\s*(.+?)[ \t]*\r?\n/g;
const hdrs = [];
let hm2;
while ((hm2 = hdrRe.exec(_liveAccum)) !== null)
hdrs.push({ path: hm2[1].trim(), contentStart: hm2.index + hm2[0].length, idx: hm2.index });
if (!hdrs.length) {
if (_projEl?._hdr) _projEl._hdr.textContent = `🤔 코드 생성 중... (${genChars}자)`;
return;
}
for (let i = 0; i < hdrs.length; i++) {
const { path, contentStart } = hdrs[i];
if (earlyDone.has(path)) continue;
const nextPos = i + 1 < hdrs.length ? hdrs[i + 1].idx : _liveAccum.length;
const endIdx = _liveAccum.indexOf('@@ENDFILE', contentStart);
const isComplete = endIdx !== -1 && endIdx < nextPos;
if (isComplete) {
// 완성된 파일 즉시 체크리스트에 추가 (promise 추적 — switchProject 전 await)
earlyDone.add(path);
earlyPromises.push(_projFileCreated(_earlyNormPath(path), _liveAccum.slice(contentStart, endIdx)));
} else if (i === hdrs.length - 1) {
// 마지막 헤더 = 현재 스트리밍 중인 파일 — 부분 내용 표시
const fname = path.split('/').pop();
if (path !== _liveFilePath) {
_liveFilePath = path;
if (_projEl?._hdr) _projEl._hdr.textContent = `✏️ ${fname} 작성 중...`;
}
const partial = _liveAccum.slice(contentStart);
if (_projEl?._live) {
_projEl._live.style.display = 'block';
_projEl._live.textContent = partial;
_projEl._live.scrollTop = _projEl._live.scrollHeight;
}
}
}
// 마지막 파일도 완료되면 라이브 프리뷰 숨김
if (earlyDone.has(hdrs[hdrs.length - 1].path) && _projEl?._live)
_projEl._live.style.display = 'none';
}
);
if (_projEl?._live) _projEl._live.style.display = 'none';
// @@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,
rawPath: f.path, // earlyDone 체크용 원본 경로
path: rawSlug && rawSlug !== slug ? f.path.replace(rawSlug + '/', slug + '/') : f.path,
}));
for (const f of normalizedFiles) {
if (earlyDone.has(f.rawPath)) continue; // 스트리밍 중 이미 처리됨
if (f.path && f.content !== undefined)
await _projFileCreated(f.path, String(f.content));
}
// earlyDone 파일의 디스크 쓰기가 모두 완료된 후 프로젝트 전환
// (_clientLoadProjectFiles가 디스크에서 파일을 읽으므로 write 완료 전에 전환하면 탭이 비어 컨텍스트 없음)
await Promise.allSettled(earlyPromises);
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);
// switchProject 내부의 _clientLoadProjectFiles()는 await 없이 호출되므로
// codeFiles가 비어있는 상태로 _projGenerate가 반환될 수 있다.
// 여기서 명시적으로 await해서 다음 메시지 전송 시 파일이 로드된 상태를 보장한다.
if (localDirHandle) await _clientLoadProjectFiles();
}
} 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;
}
let _codeAiSending = false;
// Public entry. Guards against overlapping sends and doubles as a Stop control:
// while a generation is in flight, the 전송 button turns into ⏹ 중지 and a second
// click aborts the current request instead of starting a racing one.
async function codeAiSend() {
if (_codeAiSending) {
if (codeAiAbort) codeAiAbort.abort();
return;
}
const inp = document.getElementById('code-ai-input');
if (!inp || !inp.value.trim()) return;
// 승인 대기 중인 계획 결정 카드가 있으면 입력을 가로채서 버튼 클릭으로 처리
const pendingCard = document.querySelector('.code-ai-msg.system button:not([disabled])');
if (pendingCard) {
const txt = inp.value.trim().toLowerCase();
const isYes = /^(응|예|ㅇ|ok|yes|실행|승인|go|ㄱ|ㅇㅇ)$/.test(txt);
const isNo = /^(아니|취소|no|ㄴ|cancel)$/.test(txt);
const numMatch = txt.match(/^(\d+)$/);
if (isNo) { inp.value = ''; document.querySelector('.code-ai-msg.system button[style*="#dc3545"]:not([disabled])')?.click(); return; }
if (numMatch) {
const optBtn = document.querySelector(`.code-ai-msg.system button[data-opt-num="${numMatch[1]}"]:not([disabled])`);
if (optBtn) { inp.value = ''; optBtn.click(); return; }
}
const confirmBtn = document.querySelector('.code-ai-msg.system button[style*="#28a745"]:not([disabled])');
if (isYes && confirmBtn) { inp.value = ''; confirmBtn.click(); return; }
}
_codeAiSending = true;
_setSendBtnBusy(true);
try {
await _codeAiSendImpl();
} finally {
_codeAiSending = false;
_setSendBtnBusy(false);
}
}
function _setSendBtnBusy(busy) {
const btn = document.getElementById('code-ai-send-btn');
if (!btn) return;
btn.textContent = busy ? '⏹ 중지' : '전송';
btn.classList.toggle('busy', busy);
}
async function _codeAiSendImpl() {
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;
const atInlinedFiles = new Set(); // track files already inlined via @ref
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`);
atInlinedFiles.add(m[1]); // bare name
atInlinedFiles.add(fname); // project-prefixed name
}
}
}
// 프로젝트 내 모든 파일 내용을 컨텍스트에 포함 (현재 파일 제외, 별도 첨부)
let otherFilesCtx = '';
{
const currentFileName = codeFiles.find(f => f.id === activeCodeFileId)?.name;
const seen = new Set();
const CTX_TOTAL_BYTES = 500 * 1024; // 500KB total cap for other-files section
const SKIP_NAMES = /^(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.DS_Store|thumbs\.db)$/i;
const addFile = (rel, content) => {
if (otherFilesCtx.length >= CTX_TOTAL_BYTES) return;
const ext = rel.split('.').pop() || '';
const chunk = `[파일: ${rel}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`;
if (otherFilesCtx.length + chunk.length > CTX_TOTAL_BYTES) {
otherFilesCtx += `[나머지 파일 생략 — 컨텍스트 크기 초과]\n`;
} else {
otherFilesCtx += chunk;
}
};
// 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.
// 로컬 모드(localDirHandle 있음)에서는 탭이 베어 경로("src/main.py")로 저장될 수 있으므로
// 첫 세그먼트가 알려진 다른 프로젝트 이름인 경우에만 제외한다.
if (activeProjectName && f.name.includes('/')) {
const isCurrentProjPrefix = f.name.startsWith(activeProjectName + '/');
if (!isCurrentProjPrefix) {
if (!localDirHandle) { continue; } // 서버 모드: 프로젝트 prefix 없으면 다른 프로젝트
// 로컬 모드: 첫 세그먼트가 알려진 다른 프로젝트 이름이면 제외
const firstSeg = f.name.split('/')[0];
const knownProjects = _knownProjectsGet();
if (knownProjects.includes(firstSeg) && firstSeg !== activeProjectName) continue;
}
}
if (!isCodeFile(f.name)) continue; // skip .md, .txt etc.
const bare = f.name.split('/').pop() || '';
if (SKIP_NAMES.test(bare)) continue;
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;
addFile(rel, content);
}
// 2. Files on disk not yet opened as tabs
if (localDirHandle && activeProjectName && otherFilesCtx.length < CTX_TOTAL_BYTES) {
try {
const allFiles = await localListFiles(localDirHandle, '');
const projFiles = allFiles.filter(f => {
if (!f.startsWith(activeProjectName + '/')) return false;
const bare = f.slice(activeProjectName.length + 1);
// seen에는 베어 이름만 있으므로 양쪽 모두 체크해 중복 포함 방지
return !seen.has(f) && !seen.has(bare) && f !== currentFileName && isCodeFile(f) && !SKIP_NAMES.test(f.split('/').pop() || '');
});
for (const fpath of projFiles) {
if (otherFilesCtx.length >= CTX_TOTAL_BYTES) break;
const rel = fpath.replace(activeProjectName + '/', '');
const content = await localReadFile(fpath);
if (content) addFile(rel, content);
}
} catch {}
}
}
// Include current file context (ask/code 모드 모두 포함)
const currentFile = codeFiles.find(f => f.id === activeCodeFileId);
// otherFilesCtx는 currentFile 유무와 무관하게 항상 contextMsg에 포함 (현재 파일 없어도 프로젝트 파일 보이게)
let contextMsg = otherFilesCtx ? otherFilesCtx + resolvedMessage : 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}`;
}
// Byte safety net: a few-line minified/data file passes the line check but can be
// tens of MB. Hard-cap the assembled context so /api/chat never gets a giant body.
if (fileCtx.length > MAX_AI_FILE_BYTES) {
fileCtx = fileCtx.slice(0, MAX_AI_FILE_BYTES) + `\n…[파일이 너무 커서 잘림 — 전체 ${Math.round(content.length / 1024)}KB]`;
omitNote = omitNote || ` (대용량 ${Math.round(content.length / 1024)}KB — 앞부분만 표시)`;
}
// 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 });
try { updateSessionPanel(); } catch (_) {}
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
const historyForAPI = _histForAPI(codeAiHistory);
// ── Ask 모드: 단일 API 호출, 도구 없이 바로 응답 ─────────────────────────
if (codeAiMode === 'ask') {
const askEl = codeAiAppend('thinking', '답변 중...');
let askText = '', askBuf = '';
try {
const askRes = await fetch('/api/chat', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
signal: codeAiAbort.signal,
body: JSON.stringify({ message: contextMsg, history: historyForAPI, useTools: false, sessionId: codeAiSessionId, codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
});
if (askRes.status === 401) { setAuthToken(''); _codeAiShowRelogin(); return; }
if (askRes.status === 413) { askEl.textContent = '❌ 요청 크기 초과. 파일이 너무 크거나 대화 히스토리가 너무 깁니다.'; return; }
if (askRes.ok) {
const askReader = askRes.body.getReader();
const askDec = new TextDecoder();
askEl.textContent = '';
askEl.className = 'code-ai-msg assistant';
while (true) {
const { done, value } = await askReader.read();
if (done) break;
askBuf += askDec.decode(value, { stream: true });
const askLines = askBuf.split('\n'); askBuf = askLines.pop();
for (const ln of askLines) {
if (!ln.startsWith('data:')) continue;
const d = ln.slice(5).trim();
if (d === '[DONE]') continue;
try {
const ev = JSON.parse(d);
if (ev.type === 'usage') { _codeAiOnUsage(ev); continue; }
if (ev.type === 'final' || ev.type === 'done') { if (!askText) askText = ev.text || ev.reply || ev.content || ''; continue; }
const tok = ev.content || ev.text || ev.delta || null;
if (tok) { askText += tok; askEl.textContent = askText.replace(/<\/?think>/gi, ''); codeAiScrollBottom(); }
} catch {}
}
}
}
} catch (e) {
if (e.name === 'AbortError') return;
askText = '오류: ' + (e.message || e);
}
askText = askText.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<\/think>/gi, '').trim();
askEl.textContent = askText || '(응답 없음)';
codeAiRenderMarkdown(askEl, askText || '(응답 없음)');
codeAiHistory.push({ role: 'assistant', content: _histEntry(askText) || '(응답 없음)' });
_autoSaveChatSession();
if (activeProjectName) saveProjectSession(activeProjectName);
try { updateSessionPanel(); } catch (_) {}
return;
}
// ── Code 모드: Phase 1 (계획) → confirm → Phase 2 (실행) ──────────────────
const planInstruction = '\n\n[계획 단계] 실행하지 말고 무엇을 어떻게 바꿀지만 설명해. 질문이면 바로 답해.';
const planThinkEl = codeAiAppend('thinking', '계획 중...');
let planText = '';
try {
const planRes = await fetch('/api/chat', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
signal: codeAiAbort.signal,
body: JSON.stringify({ message: contextMsg + planInstruction, history: historyForAPI, useTools: false, sessionId: codeAiSessionId + '_plan', codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
});
if (planRes.status === 401) { setAuthToken(''); _codeAiShowRelogin(); return; }
if (planRes.ok) {
const planReader = planRes.body.getReader();
const planDec = new TextDecoder();
let planBuf = '';
planThinkEl.textContent = '';
planThinkEl.className = 'code-ai-msg assistant';
while (true) {
const { done, value } = await planReader.read();
if (done) break;
planBuf += planDec.decode(value, { stream: true });
const planLines = planBuf.split('\n'); planBuf = planLines.pop();
for (const ln of planLines) {
if (!ln.startsWith('data:')) continue;
const d = ln.slice(5).trim();
if (d === '[DONE]') continue;
try {
const ev = JSON.parse(d);
if (ev.type === 'usage') { _codeAiOnUsage(ev); continue; }
if (ev.type === 'final' || ev.type === 'done') { if (!planText) planText = ev.text || ev.reply || ev.content || ''; continue; }
const tok = ev.content || ev.text || ev.delta || null;
if (tok) { planText += tok; planThinkEl.textContent = planText.replace(/<\/?think>/gi, ''); codeAiScrollBottom(); }
} catch {}
}
}
}
} catch(planErr) {
if (planErr.name === 'AbortError') return;
planText = '(계획 생성 실패)';
}
// Strip leaked <think>...</think> blocks (some models emit them despite think:false)
planText = planText.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<\/think>/gi, '').trim();
planThinkEl.textContent = planText || '(응답 없음)';
codeAiRenderMarkdown(planThinkEl, planText || '(응답 없음)');
// ── Confirm UI ──────────────────────────────────────────────────────────────
// Detect if the plan describes edits or is a pure Q&A answer.
const _EDIT_VERB_RE = /수정|변경|추가|삭제|편집|교체|생성|작성|만들|구현|replace|modify|add\s|remove|insert|update|creat/i;
const _SEARCH_BLOCK_RE = /------- SEARCH|<<<<<<< SEARCH/;
const _LIST_RE = /^\s*(\d+[.)]|[-*•])\s+\S/m;
// 목록 항목 자체가 질문이면 Q&A로 판단 ("수정해 드릴까요?" 등 오탐 방지)
const _LIST_QUESTION_RE = /^\s*(\d+[.)]|[-*•])\s+.*[??]/m;
// 파일명 + 편집 동사만 있는 경우도 편집 계획으로 판단 (목록 없이 "X에 추가하겠습니다" 등)
const _FILE_MENTION_RE = /`[^`]*\.[a-zA-Z]{1,6}`|\b\w+\.(py|js|ts|tsx|html|css|json|yaml|yml|sh|java|cpp|c|go|rs|md|txt|sql|env|toml|cfg)\b/i;
const isPlanWithEdits =
_SEARCH_BLOCK_RE.test(planText) ||
(_LIST_RE.test(planText) && _EDIT_VERB_RE.test(planText) && !_LIST_QUESTION_RE.test(planText)) ||
(_FILE_MENTION_RE.test(planText) && _EDIT_VERB_RE.test(planText) && !_LIST_QUESTION_RE.test(planText));
if (!isPlanWithEdits) {
// Pure Q&A — plan text is the answer; record it and stop
codeAiHistory.push({ role: 'assistant', content: _histEntry(planText) || '(응답 없음)' });
_autoSaveChatSession();
if (activeProjectName) saveProjectSession(activeProjectName);
try { updateSessionPanel(); } catch (_) {}
return;
}
// Parse numbered options from plan text (e.g. "1. CSS 방식\n2. JS 방식")
// Returns [{num, label}] if 2-5 sequential numbered items found, else []
const _parsedOptions = (() => {
const opts = [];
for (const line of planText.split('\n')) {
const m = line.match(/^(\d+)[.)]\s+(.+)/);
if (!m) continue;
if (parseInt(m[1]) !== opts.length + 1) break;
opts.push({ num: parseInt(m[1]), label: m[2].replace(/\*\*/g, '').trim() });
if (opts.length === 6) break;
}
if (opts.length < 2) return [];
// 파일 작업 키워드가 포함된 항목이 있으면 실행 단계 목록 → 선택지 아님
const fileOpRe = /edit_file|create_file|move_file|delete_file|replace_lines|insert_after|\.[a-zA-Z]{2,4}(?:\s|$|—|:)/;
if (opts.some(o => fileOpRe.test(o.label))) return [];
if (/수정\s*계획|변경\s*계획|개선\s*계획|구현\s*계획/.test(planText)) return [];
const firstPart = planText.slice(0, planText.length * 0.6);
if (opts.every(o => firstPart.includes(o.label))) return [];
return opts;
})();
// Show confirm / cancel / modify buttons and wait for user decision
// Resolves with: true (execute) | false (cancel) | { modify: string } | { option: num, label: string }
const _planDecision = await new Promise(resolve => {
const msgs = document.getElementById('code-ai-messages');
if (!msgs) { resolve(false); return; }
const signal = codeAiAbort?.signal;
if (signal?.aborted) { resolve(false); return; }
const card = document.createElement('div');
card.className = 'code-ai-msg system';
card.style.cssText = 'padding:6px 0;';
const mkBtn = (text, bg, extra = '') => {
const b = document.createElement('button');
b.textContent = text;
b.style.cssText = `background:${bg};color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:12px;font-weight:700;cursor:pointer;${extra}`;
return b;
};
const allBtns = [];
const onAbort = () => done(false, true);
const done = (result, viaAbort) => {
if (signal) signal.removeEventListener('abort', onAbort);
allBtns.forEach(b => { b.disabled = true; b.style.opacity = '0.5'; });
if (modInput) modInput.disabled = true;
if (viaAbort) card.innerHTML = '<span style="font-size:12px;color:var(--muted)">편집이 중지되었습니다.</span>';
resolve(result);
};
let modInput = null;
if (_parsedOptions.length) {
// ── 선택지 모드 ──────────────────────────────────────────────────────────
const optLabel = document.createElement('div');
optLabel.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:6px';
optLabel.textContent = '방법을 선택하세요:';
card.appendChild(optLabel);
const optRow = document.createElement('div');
optRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px';
for (const opt of _parsedOptions) {
const short = opt.label.length > 32 ? opt.label.slice(0, 32) + '…' : opt.label;
const ob = mkBtn(`${opt.num}. ${short}`, '#0d6efd', 'text-align:left;white-space:normal;max-width:200px;line-height:1.4;padding:5px 10px');
ob.title = opt.label;
ob.dataset.optNum = String(opt.num);
ob.onclick = () => done({ option: opt.num, label: opt.label });
allBtns.push(ob);
optRow.appendChild(ob);
}
card.appendChild(optRow);
const cancelBtn = mkBtn('❌ 취소', '#dc3545');
allBtns.push(cancelBtn);
cancelBtn.onclick = () => done(false);
card.appendChild(cancelBtn);
} else {
// ── 일반 모드: 실행 / 수정 / 취소 ──────────────────────────────────────
const hint = document.createElement('div');
hint.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:6px';
hint.textContent = '위 계획대로 실행할까요?';
card.appendChild(hint);
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap';
const confirmBtn = mkBtn('✅ 실행', '#28a745');
const modifyBtn = mkBtn('✏️ 수정', '#6c757d');
const cancelBtn = mkBtn('❌ 취소', '#dc3545');
allBtns.push(confirmBtn, modifyBtn, cancelBtn);
// Modify row (hidden until ✏️ clicked)
const modRow = document.createElement('div');
modRow.style.cssText = 'display:none;margin-top:6px;gap:6px;align-items:flex-start;flex-direction:column';
modInput = document.createElement('textarea');
modInput.placeholder = '수정 요청 입력 (예: 버튼 색상은 파란색으로)';
modInput.style.cssText = 'width:100%;box-sizing:border-box;padding:5px 8px;font-size:12px;border:1px solid var(--border,#444);border-radius:4px;background:var(--panel-2,#1e1e2e);color:var(--text,#ccc);resize:vertical;min-height:54px';
const replanBtn = mkBtn('🔄 다시 계획', '#0d6efd');
allBtns.push(replanBtn);
modRow.appendChild(modInput);
modRow.appendChild(replanBtn);
confirmBtn.onclick = () => done(true);
cancelBtn.onclick = () => done(false);
modifyBtn.onclick = () => { modRow.style.display = 'flex'; modifyBtn.style.display = 'none'; modInput.focus(); };
replanBtn.onclick = () => { const t = modInput.value.trim(); done(t ? { modify: t } : false); };
modInput.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) replanBtn.click(); });
btnRow.appendChild(confirmBtn);
btnRow.appendChild(modifyBtn);
btnRow.appendChild(cancelBtn);
card.appendChild(btnRow);
card.appendChild(modRow);
} // end else (normal mode)
if (signal) signal.addEventListener('abort', onAbort, { once: true });
msgs.appendChild(card);
codeAiScrollBottom();
});
if (!_planDecision) return; // cancelled
// ── Option selected → re-plan with chosen option ────────────────────────────
if (typeof _planDecision === 'object' && _planDecision.option != null) {
await _codeAiSend(`${_planDecision.option}번 방법으로 진행해줘: ${_planDecision.label}`);
return;
}
// ── Re-plan with modification ────────────────────────────────────────────────
if (typeof _planDecision === 'object' && _planDecision.modify) {
await _codeAiSend(`위 계획을 다음 조건으로 수정해서 다시 계획해줘: ${_planDecision.modify}`);
return;
}
const _userConfirmed = _planDecision; // true → proceed to execute
// ── Phase 2: execute (with tools, auto-accept) ─────────────────────────────
_codeAiAutoAccept = true;
_autoAcceptTurn = true;
const thinkEl = codeAiAppend('thinking', '편집 실행 중...');
// Build full-file context for execute phase (model needs exact SEARCH text). Still
// byte-capped: a multi-MB minified/data file would otherwise blow the request size.
let execContextMsg = contextMsg;
if (currentFile && monacoEditor && localDirHandle) {
const fullContent = monacoEditor.getValue();
const ext = currentFile.name.split('.').pop() || '';
const displayName = _clientBareFileName(currentFile.name);
let numberedFull = fullContent.split('\n').map((l, i) => `${i + 1}: ${l}`).join('\n');
let bigNote = '';
if (numberedFull.length > MAX_AI_FILE_BYTES) {
numberedFull = numberedFull.slice(0, MAX_AI_FILE_BYTES) + `\n…[파일이 너무 커서 잘림 — 전체 ${Math.round(fullContent.length / 1024)}KB]`;
bigNote = ` — 대용량, 앞부분만`;
}
execContextMsg = `${otherFilesCtx}[현재 파일: ${displayName} (전체 ${fullContent.split('\n').length}줄${bigNote})]\n\`\`\`${ext}\n${numberedFull}\n\`\`\`\n\n${resolvedMessage}`;
}
// Include plan in execute history so model knows exactly what to do.
// Cap planText: if the plan phase returned huge content (e.g. full file rewrite), sending
// it raw can push the request body over 50MB and cause 413 errors.
const planTextCapped = planText.length > 12000 ? planText.slice(0, 12000) + '\n…[계획 잘림]' : planText;
const execHistory = [
...historyForAPI,
{ role: 'assistant', content: planTextCapped },
{ role: 'user', content: '위 계획대로 도구를 즉시 실행해줘 (edit_file, move_file, create_file 등 계획에 맞는 도구 사용). 계획을 다시 요약하거나 반복하지 마. 첫 번째 메시지부터 바로 도구를 호출해. SEARCH 블록은 파일 내용에서 정확히 복사.' },
];
try {
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
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: execContextMsg, history: execHistory, 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 = '', _preambleBuf = '', _firstToolSeen = false;
let liveTextEl = null, segReply = '';
thinkEl.textContent = '🤔 분석 중...';
thinkEl.className = 'code-ai-msg thinking';
const _statusMove = (text) => { thinkEl.textContent = text; thinkEl.style.display = ''; const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); codeAiScrollBottom(); };
// 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 };
const _localFailedFiles = new Set(); // track files where local apply failed this turn
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 === 'usage') { _codeAiOnUsage(ev); continue; }
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; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { if (!reply) reply = ev.reply; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
// Handle tool events for live editor update + action cards
else if (ev.type === 'tool_call') {
_firstToolSeen = true;
// Close current text bubble before showing tool card
if (liveTextEl) {
if (!segReply.trim()) liveTextEl.remove();
else { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
liveTextEl = null; segReply = '';
}
const toolName = ev.action || ev.name || '';
const toolArgs = ev.args || {};
const _fname = toolArgs.filename || toolArgs.name || toolArgs.path || '';
const _short = _fname ? _fname.split('/').pop() : '';
const _tIcon = _TOOL_ICONS[toolName] || '🔧';
_statusMove(`${_tIcon} ${toolName}${_short ? ': ' + _short : ''} ...`);
_showLogBubble();
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);
const failedFname = toolArgs.filename || toolArgs.name || '';
if (failedFname) _localFailedFiles.add(_clientBareFileName(failedFname.replace(/^code\//, '')));
}
});
}
else if (ev.type === 'tool_result') {
const toolName = ev.action || '';
const toolResult = ev.result || '';
const toolError = ev.error || false;
_codeAiToolAction(toolName, null, toolResult, toolError);
_statusMove('💭 다음 단계 분석 중...');
// Refresh file list after successful file-modifying tools
if (!toolError && ['create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'edit_file', 'move_file', 'delete_file'].includes(toolName)) {
codeSbRefresh();
}
}
if (token) {
reply += token; segReply += token;
if (!liveTextEl) {
liveTextEl = codeAiAppend('assistant', '');
liveTextEl.classList.add('streaming');
if (_firstToolSeen) _statusMove('💬 응답 작성 중...');
}
liveTextEl.textContent = segReply; codeAiScrollBottom();
if (_firstToolSeen) {
try { codeLiveStreamUpdate(segReply, liveState); } catch(e) { console.error('[codeLiveStream] error:', e); liveTextEl.textContent += '\n⚠️ 에디터 스트리밍 오류: ' + (e.message || e); }
}
}
} catch(e) { console.error('[codeAiStream] parse error:', e.message || e, 'data:', data?.slice(0, 80)); }
}
}
liveState.inBlock = false;
codeMonacoThrottleFlush();
// If no tool calls occurred, the buffered preamble IS the reply (pure text response)
if (!_firstToolSeen && _preambleBuf) { reply = _preambleBuf; thinkEl.textContent = reply; codeAiScrollBottom(); }
// 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 || '');
// Skip retry for conversational/question messages — only retry when user clearly wants code changes
const isQuestion = /[??]$/.test(message.trim()) ||
/^(어떻게|어떤|뭐가|뭔|왜|언제|어디|누가|무엇|알려|설명|뭐야|뭐지|어디서|어떤게|차이|차이가|뭘|안녕|하이|hi\b|hello\b|hey\b)/i.test(message.trim()) ||
/^(what|how|why|when|where|who|which|can you explain|tell me|is there|are there|does|do |did )/i.test(message.trim());
if (!hasCodeBlock && !hasDiffBlock && !toolsWereUsed && looksLikeAnalysis && !isQuestion && codeAiHistory.length > 0) {
const retryMsg = '위 분석만 하지 말고 edit_file 또는 create_file 도구를 사용해서 실제로 파일을 수정해줘.';
codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
codeAiHistory.push({ role: 'user', content: retryMsg });
thinkEl.classList.add('streaming');
thinkEl.textContent = '🔄 분석만 감지 — 도구로 파일 수정 재시도 중...';
const retryAbort = new AbortController();
try {
const retryHistory = _histForAPI(codeAiHistory);
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, 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; }
// Retry result always wins — prevents original analysis from being re-rendered on top
if (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);
}
}
}
}
}
} 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);
}
}
// Remove the injected retry turn (assistant analysis + user retry prompt);
// the final `reply` is pushed once at the end. Mirrors the SEARCH-fix path.
if (codeAiHistory[codeAiHistory.length - 1]?.role === 'user') codeAiHistory.pop();
if (codeAiHistory[codeAiHistory.length - 1]?.role === 'assistant') codeAiHistory.pop();
}
// If any edit_file failed locally (SEARCH mismatch), send the actual file
// content back so the model can retry with correct SEARCH blocks.
if (_localFailedFiles.size > 0) {
// Wait a tick so all .then() handlers above have fired
await new Promise(r => setTimeout(r, 50));
const failedParts = [];
for (const fname of _localFailedFiles) {
const actual = await _codeAiReadLocalFile(fname);
if (actual != null) {
failedParts.push(`**${fname}** 현재 내용:\n\`\`\`\n${actual}\n\`\`\``);
}
}
if (failedParts.length) {
const fixMsg = `edit_file SEARCH 블록이 실제 파일 내용과 일치하지 않았습니다. 아래 현재 내용을 기준으로 다시 수정해줘:\n\n${failedParts.join('\n\n')}`;
codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
codeAiHistory.push({ role: 'user', content: fixMsg });
thinkEl.classList.add('streaming');
thinkEl.textContent = '🔄 SEARCH 불일치 — 실제 파일로 재시도 중...';
try {
const fixRes = await fetch('/api/chat', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
signal: codeAiAbort?.signal,
body: JSON.stringify({ message: fixMsg, history: _histForAPI(codeAiHistory), useTools: true, sessionId: codeAiSessionId + '_fix', codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
});
if (fixRes.ok) {
const fixReader = fixRes.body.getReader();
const fixDec = new TextDecoder();
let fixBuf = '', fixReply = '';
const fixState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
while (true) {
const { done, value } = await fixReader.read();
if (done) break;
fixBuf += fixDec.decode(value, { stream: true });
const fixLines = fixBuf.split('\n'); fixBuf = fixLines.pop();
for (const ln of fixLines) {
if (!ln.startsWith('data:')) continue;
const d = ln.slice(5).trim();
if (d === '[DONE]') continue;
try {
const ev = JSON.parse(d);
let tok = null;
if (ev.type === 'token' || ev.type === 'text_delta') tok = ev.content || ev.text || ev.delta || null;
else if (ev.type === 'final' || ev.type === 'done') { if (!fixReply) fixReply = ev.text || ev.reply || ev.content || ''; }
else if (ev.type === 'tool_call') {
_codeAiToolAction(ev.action || ev.name || '', ev.args || {}, null, null);
_codeAiApplyToolLocally(ev.action || ev.name || '', ev.args || {}).then(r2 => {
if (r2 && r2.ok === false) _codeAiToolAction(ev.action || ev.name || '', null, `⚠️ 로컬 적용 실패: ${r2.reason}`, true);
});
} else if (ev.type === 'tool_result') { _codeAiToolAction(ev.action || '', null, ev.result || '', ev.error || false); }
if (tok) { fixReply += tok; thinkEl.textContent = fixReply; codeAiScrollBottom(); try { codeLiveStreamUpdate(fixReply, fixState); } catch {} }
} catch {}
}
}
codeMonacoThrottleFlush();
reply = fixReply || reply;
thinkEl.textContent = reply;
}
} catch(fixErr) {
if (fixErr.name !== 'AbortError') console.error('[codeAiFix] failed:', fixErr);
}
// Remove the injected retry turn from history (will be replaced below)
if (codeAiHistory[codeAiHistory.length - 1]?.role === 'user') codeAiHistory.pop();
if (codeAiHistory[codeAiHistory.length - 1]?.role === 'assistant') codeAiHistory.pop();
}
}
codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
_autoSaveChatSession();
if (activeProjectName) saveProjectSession(activeProjectName);
try { updateSessionPanel(); } catch (_) {}
thinkEl.classList.remove('streaming');
if (_firstToolSeen) {
thinkEl.remove();
_hideLogBubble();
if (liveTextEl) { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
const doneEl = codeAiAppend('system', '✅ 완료');
doneEl.style.cssText = 'font-weight:600;color:#4ade80;';
} else {
_hideLogBubble();
thinkEl.className = 'code-ai-msg assistant';
if (!reply) { thinkEl.textContent = '(응답 없음)'; thinkEl.style.display = ''; }
else { codeAiRenderMarkdown(thinkEl, reply); thinkEl.style.display = ''; }
}
} catch(e) {
_hideLogBubble();
if (!thinkEl.isConnected) { const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); }
thinkEl.style.display = '';
thinkEl.className = 'code-ai-msg thinking';
const errMsg = (e.name === 'AbortError' || (e.message || '').includes('aborted'))
? '요청이 취소되었습니다. 다시 시도해주세요.'
: (e.message || e);
thinkEl.textContent = '오류: ' + errMsg;
// On error no diff will ever be queued, so clear the auto-accept flag —
// otherwise it leaks into the next turn and fires a stale "✅ 완료".
_autoAcceptTurn = false;
} finally {
_codeAiAutoAccept = false;
}
}
// ── Live server log bubble ────────────────────────────────────────────────
let _logBubble = null;
let _logPollTimer = null;
function _showLogBubble() {
if (_logBubble) return;
const msgs = document.getElementById('code-ai-messages');
if (!msgs) return;
_logBubble = document.createElement('div');
_logBubble.className = 'code-ai-log-bubble';
_logBubble.innerHTML = '<span class="log-live-dot"></span><span class="log-lines">서버 연결 중...</span>';
msgs.appendChild(_logBubble);
codeAiScrollBottom();
_pollLogBubble();
_logPollTimer = setInterval(_pollLogBubble, 800);
}
async function _pollLogBubble() {
if (!_logBubble) return;
try {
const r = await fetch('/api/logs', { headers: authHeaders({}) });
if (!r.ok) return;
const data = await r.json();
const lines = (data.entries || []).slice(-4).map(e => e.msg.slice(0, 90));
const linesEl = _logBubble.querySelector('.log-lines');
if (linesEl) linesEl.textContent = lines.join('\n') || '...';
codeAiScrollBottom();
} catch {}
}
function _hideLogBubble() {
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
const b = _logBubble; _logBubble = null;
if (b) { b.style.opacity = '0'; setTimeout(() => b.remove(), 400); }
}
// ── 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: '🗑️', move_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', move_file: '#f59e0b', 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', 'move_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 = toolName === 'move_file'
? escHtml(`${toolArgs.source || ''} → ${toolArgs.destination || ''}`)
: 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 noDetail = !toolError && (toolName === 'move_file' || toolName === 'delete_file');
const resultText = toolError ? escHtml((toolResult || 'Error').slice(0, 120)) : noDetail ? '' : escHtml((toolResult || '').slice(0, 80));
const resultEl = document.createElement('span');
resultEl.className = 'code-ai-tool-result';
resultEl.innerHTML = resultText ? `${resultIcon} ${resultText}` : resultIcon;
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.
let rawFname = (args.filename || args.name || '').replace(/^code\//, '');
// Auto-inject filename from active tab when model omits it (happens ~40% of the time)
if (!rawFname) {
const activeFile = codeFiles.find(f => f.id === activeCodeFileId);
if (activeFile) rawFname = _clientBareFileName(activeFile.name);
}
const fname = _clientBareFileName(rawFname);
const diskPath = _clientProjectPath(fname);
const _FILE_TOOLS = new Set(['create_file', 'edit_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'delete_file', 'move_file']);
if (!_FILE_TOOLS.has(toolName)) return { ok: true };
if (!fname && toolName !== 'move_file') 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 };
}
case 'move_file': {
const srcRaw = _clientBareFileName((args.source || args.from || '').replace(/^code\//, ''));
const dstRaw = _clientBareFileName((args.destination || args.to || '').replace(/^code\//, ''));
const srcDisk = _clientProjectPath(srcRaw);
const dstDisk = _clientProjectPath(dstRaw);
if (!srcRaw || !dstRaw) return { ok: false, reason: 'source/destination가 없습니다' };
const content = await _codeAiReadLocalFile(srcRaw);
if (content === null) return { ok: false, reason: `${srcRaw}을(를) 찾을 수 없습니다 (탭에 파일이 열려있어야 합니다)` };
if (localDirHandle) {
const writeOk = await localWriteFile(dstDisk, content);
if (!writeOk) return { ok: false, reason: `${dstRaw} 쓰기 실패 (폴더 권한 확인)` };
}
codeEditorWriteFile(dstRaw, content);
if (localDirHandle) {
const delOk = await localDeleteFile(srcDisk);
if (!delOk) console.warn('[move_file] 소스 파일 삭제 실패:', srcDisk);
}
const srcId = codeFiles.find(f => f.name === srcRaw)?.id;
if (srcId) { codeFiles.splice(codeFiles.findIndex(f => f.id === srcId), 1); renderCodeFileTabs(); }
// Refresh sidebar after disk I/O is complete
codeSbRefresh();
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;
}
// Strip "123: " line-number prefixes the model copies from context (e.g. "42: def foo():")
function _stripLineNumbers(text) {
// Only strip if EVERY non-empty line starts with a number prefix — avoids stripping real code
const lines = text.split('\n');
if (lines.every(l => !l.trim() || /^\d+: /.test(l))) {
return lines.map(l => l.replace(/^\d+: /, '')).join('\n');
}
return text;
}
// Apply SEARCH/REPLACE blocks to content (client-side mirror of server edit_file)
function _applySearchReplace(content, edits) {
if (!edits || !content) return null;
// Normalize line endings in both content and edits (\r\n / \r → \n)
const normNl = s => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
content = normNl(content);
edits = normNl(edits);
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) {
// Strip line-number prefixes the model might have copied from numbered context
const searchText = _stripLineNumbers(match[1]);
const replaceText = _stripLineNumbers(match[2]);
if (result.includes(searchText)) {
result = result.replace(searchText, replaceText);
editCount++;
} else {
// Fallback 1: trim trailing whitespace per line
const trimEnd = s => s.split('\n').map(l => l.trimEnd()).join('\n');
const trimmedResult = trimEnd(result);
const trimmedSearch = trimEnd(searchText);
if (trimmedResult.includes(trimmedSearch)) {
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;
}
}
} else {
// Fallback 2: ignore leading indentation (model sometimes guesses wrong indent)
const stripIndent = s => s.split('\n').map(l => l.trimStart()).join('\n');
const stripped = stripIndent(result);
const strippedSearch = stripIndent(searchText);
if (stripped.includes(strippedSearch)) {
const lines = result.split('\n');
const searchLines = searchText.split('\n');
const normLines = lines.map(l => l.trimStart());
const normSearch = searchLines.map(l => l.trimStart());
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
// Detect the actual indentation from the first matching line and re-indent replaceText
const actualIndent = lines[i].match(/^(\s*)/)?.[1] || '';
const searchIndent = searchLines[0].match(/^(\s*)/)?.[1] || '';
const reindented = replaceText.split('\n').map(l => {
const trimmed = l.startsWith(searchIndent) ? l.slice(searchIndent.length) : l.trimStart();
return actualIndent + trimmed;
}).join('\n');
const originalText = lines.slice(i, i + searchLines.length).join('\n');
result = result.replace(originalText, reindented);
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) {
if (_codeAiAutoAccept) {
// Auto-accept mode: show diff briefly with scroll preview, then auto-save
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, content);
_scrollAndAcceptDiff(existing.id);
} else {
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, content);
}
}
} 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;
// If a pending (un-reviewed) diff already exists for this file, keep the user's
// original baseline so that a sequence of AI edits collapses into one review.
// e.g. edit_file called 3× → user sees one combined diff, accepts once, saves once.
const existingDs = codeDiffState[fileId];
if (existingDs) {
originalText = existingDs.originalText;
}
// 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;
// Per-hunk buttons accept/reject ALL hunks for the file (one-click UX)
actions.innerHTML = '<button class="inline-diff-accept-btn" onclick="_acceptAllDiff(\'' + fileId + '\')" title="모두 적용">✅</button>' +
'<button class="inline-diff-reject-btn" onclick="_rejectAllDiff(\'' + fileId + '\')" 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;
const pendingCount = Object.keys(codeDiffState).length;
const countNote = pendingCount > 1 ? ` (${pendingCount}개 파일 남음)` : '';
label.textContent = '🔀 AI 제안 변경 사항' + countNote;
const acceptBtn = document.getElementById('code-diff-accept');
const rejectBtn = document.getElementById('code-diff-reject');
// Capture fileId in the closure so clicking works regardless of activeCodeFileId
if (acceptBtn) {
acceptBtn.textContent = pendingCount > 1 ? '✅ 적용 →' : '✅ 모두 적용';
acceptBtn.onclick = () => {
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
_applyInlineDiffDecorations(fileId);
finalizeInlineDiff(fileId, true);
};
}
if (rejectBtn) {
rejectBtn.textContent = '❌ 모두 취소';
rejectBtn.onclick = () => {
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
_applyInlineDiffDecorations(fileId);
if (ds.onReject) ds.onReject();
finalizeInlineDiff(fileId, true);
};
}
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);
}
// Auto-accept diff with scroll preview: reveal each changed hunk in sequence, then save.
async function _scrollAndAcceptDiff(fileId) {
const ds = codeDiffState[fileId];
if (!ds || !monacoEditor) { _acceptAllDiff(fileId); return; }
const hunks = ds.hunks;
if (!hunks.length) { _acceptAllDiff(fileId); return; }
// Show preview label in the diff bar
const label = document.getElementById('code-diff-label');
if (label) label.textContent = `⏩ 편집 미리보기 (${hunks.length}개 변경)...`;
const acceptBtn = document.getElementById('code-diff-accept');
if (acceptBtn) acceptBtn.style.display = 'none'; // hide during preview
// Time budget: 500ms per hunk, capped at 5 s total
const perHunk = Math.min(500, Math.max(150, 5000 / hunks.length));
for (let i = 0; i < hunks.length; i++) {
if (!codeDiffState[fileId]) return; // diff cancelled by user or replaced
const h = hunks[i];
if (label) label.textContent = `⏩ 편집 미리보기 (${i + 1}/${hunks.length})...`;
const targetLine = h.newStart || h.oldStart || 1;
try { monacoEditor.revealLineInCenter(targetLine, 0 /* Smooth */); } catch {}
await new Promise(r => setTimeout(r, perHunk));
}
if (!codeDiffState[fileId]) return;
// Brief pause at the last hunk before auto-saving
await new Promise(r => setTimeout(r, 200));
_acceptAllDiff(fileId);
}
// Accept all hunks for a specific file (called by both per-hunk widgets and the bar button)
function _acceptAllDiff(fileId) {
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
_applyInlineDiffDecorations(fileId);
finalizeInlineDiff(fileId, true);
}
// Reject all hunks for a specific file
function _rejectAllDiff(fileId) {
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
_applyInlineDiffDecorations(fileId);
if (ds.onReject) ds.onReject();
finalizeInlineDiff(fileId, true);
}
// Fallback wrappers using activeCodeFileId (kept for HTML onclick= attributes)
function acceptAllHunks() {
const fileId = activeCodeFileId || Object.keys(codeDiffState)[0];
if (fileId) _acceptAllDiff(fileId);
}
function rejectAllHunks() {
const fileId = activeCodeFileId || Object.keys(codeDiffState)[0];
if (fileId) _rejectAllDiff(fileId);
}
// 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, hunkCount) {
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);
const bareName = _clientBareFileName(file.name);
const countNote = hunkCount > 0 ? ` (${hunkCount}곳)` : '';
if (ok) { codeAiAppend('system', `💾 저장: ${bareName}${countNote}`); codeSbRefresh(); }
else codeAiAppend('system', `⚠️ 저장 실패: ${bareName}`);
}
// 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;
const hunkCount = ds.hunks.length;
_removeInlineDiffDecorations(fileId);
delete codeDiffState[fileId];
// Auto-save accepted changes so the on-disk file matches the editor
if (save) codeSaveFileById(fileId, hunkCount);
renderCodeFileTabs();
// If more files have pending diffs, switch to next one automatically
const nextId = Object.keys(codeDiffState)[0];
if (nextId) {
switchCodeFile(nextId); // switchCodeFile calls _showDiffBar if codeDiffState[nextId] exists
return;
}
// No more pending diffs — show completion message if this was an auto-accept turn
if (save && _autoAcceptTurn) {
_autoAcceptTurn = false;
setTimeout(() => codeAiAppend('system', '✅ 완료'), 300);
}
// Hide bar and restore focus
const bar = document.getElementById('code-diff-bar');
if (bar) bar.style.display = 'none';
const file = codeFiles.find(f => f.id === fileId);
if (file && monacoEditor && file.id === activeCodeFileId) {
monacoEditor.setModel(file.model);
monacoEditor.focus();
}
}
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 = [];
_codeAiLastUsage = null;
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
updateLocalModeUI();
updateSessionPanel();
codeSbRefresh();
_renderSessionsList();
}
function switchProject(name) {
_autoSaveChatSession(); // null 프로젝트 상태의 히스토리도 세션 목록에 보존
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();
localShellIds.forEach(shellId => _companionCdProject(shellId));
// 채팅 패널 갱신: 히스토리가 있으면 그대로, 없으면 세션 목록
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 _backToSessionList() {
_autoSaveChatSession();
codeAiHistory = [];
_codeAiLastUsage = null;
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();
}
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:8px 10px 4px;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase">최근 세션</span>
<button onclick="_deleteAllSessions(event)" style="font-size:10px;padding:1px 6px;border:1px solid #374151;background:none;color:#6b7280;border-radius:3px;cursor:pointer" onmouseover="this.style.borderColor='#f87171';this.style.color='#f87171'" onmouseout="this.style.borderColor='#374151';this.style.color='#6b7280'">전체 삭제</button>
</div>
<div style="padding:0 6px 6px;display:flex;flex-direction:column;gap:3px">
${filtered.map(s => `
<div style="display:flex;align-items:center;gap:0;border-radius:5px;overflow:hidden;border:1px solid var(--line);background:var(--panel-2);transition:border-color .15s"
onmouseover="this.style.borderColor='#3d6b4f'" onmouseout="this.style.borderColor='var(--line)'">
<div onclick="_resumeSession('${s.id}')" style="flex:1;min-width:0;padding:5px 8px;cursor:pointer;border-left:2px solid #22c55e">
<div style="display:flex;align-items:center;gap:5px;margin-bottom:2px">
${s.project ? `<span style="font-size:9px;padding:1px 5px;background:#0d2b1a;color:#22c55e;border-radius:3px;letter-spacing:.03em;flex-shrink:0">${escHtml(s.project)}</span>` : ''}
<span style="font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(s.title)}</span>
</div>
<span style="font-size:10px;color:var(--muted)">${s.msgCount}개 · ${relTime(s.ts)}</span>
</div>
<button onclick="_deleteSession('${s.id}', event)" title="세션 삭제" style="flex-shrink:0;width:24px;height:100%;background:none;border:none;border-left:1px solid var(--line);color:#4b5563;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;align-self:stretch;padding:0" onmouseover="this.style.color='#f87171';this.style.background='rgba(248,113,113,0.08)'" onmouseout="this.style.color='#4b5563';this.style.background='none'">✕</button>
</div>`).join('')}
</div>`;
}
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();
}
let _codeAiCtxMaxCached = 200000; // default until server responds
let _codeAiCtxMaxFetchedFor = ''; // model name the cache is valid for
// Real token usage from the last turn (server 'usage' SSE). When present, the gauge
// shows actual prompt tokens / real context window instead of the char/4 estimate.
let _codeAiLastUsage = null; // { promptTokens, ctxWindow }
function _codeAiCtxMax() { return _codeAiCtxMaxCached; }
// Called from streaming loops when a 'usage' SSE arrives.
function _codeAiOnUsage(ev) {
if (!ev || typeof ev.promptTokens !== 'number') return;
_codeAiLastUsage = { promptTokens: ev.promptTokens, ctxWindow: ev.ctxWindow || _codeAiCtxMax() };
try { updateSessionPanel(); } catch (_) {}
}
async function _fetchModelCtxMax(model) {
const key = model || '';
if (key === _codeAiCtxMaxFetchedFor) return; // already fetched for this model
_codeAiCtxMaxFetchedFor = key;
try {
const url = '/api/model-context' + (key ? `?model=${encodeURIComponent(key)}` : '');
const r = await fetch(url, { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
if (d.contextWindow) {
_codeAiCtxMaxCached = d.contextWindow;
updateSessionPanel();
}
} catch (_) {}
}
function toggleCodeAiMode() {
codeAiMode = codeAiMode === 'ask' ? 'code' : 'ask';
localStorage.setItem('codeAiMode', codeAiMode);
updateCodeAiModeBtn();
}
function updateCodeAiModeBtn() {
const btn = document.getElementById('code-ai-mode-btn');
if (!btn) return;
if (codeAiMode === 'ask') {
btn.textContent = '💬 Ask';
btn.style.color = '#5a9e78';
btn.style.borderColor = '#2a4a38';
} else {
btn.textContent = '✏️ Code';
btn.style.color = '#7aabf0';
btn.style.borderColor = '#2a3a5a';
}
}
function updateSessionPanel() {
const barProj = document.getElementById('code-ai-session-bar-project');
const barCount = document.getElementById('code-ai-session-bar-count');
const backBtn = document.getElementById('code-ai-back-btn');
if (barProj) barProj.textContent = activeProjectName || '(전체)';
if (barCount) barCount.textContent = codeAiHistory.length ? `${codeAiHistory.length}개` : '';
if (backBtn) backBtn.style.display = codeAiHistory.length ? '' : 'none';
// Context usage estimate
const ctxWrap = document.getElementById('code-ai-ctx-wrap');
const ctxLabel = document.getElementById('code-ai-ctx-label');
const ctxFill = document.getElementById('code-ai-ctx-fill');
if (ctxWrap && ctxLabel && ctxFill) {
// Fill = real prompt tokens from the last turn (fallback: char/4 estimate).
// Max = the model's NATIVE context_length (true ceiling), not the per-turn num_ctx.
const totalChars = codeAiHistory.reduce((s, m) => s + (m.content || '').length, 0);
const estTokens = _codeAiLastUsage ? _codeAiLastUsage.promptTokens : Math.round(totalChars / 4);
const maxTokens = _codeAiCtxMax();
const pct = Math.min(100, Math.round(estTokens / maxTokens * 100));
const kStr = estTokens >= 1000 ? `~${(estTokens/1000).toFixed(1)}k` : `${estTokens}`;
const maxKStr = maxTokens >= 1000 ? `${(maxTokens/1000).toFixed(0)}k` : `${maxTokens}`;
ctxLabel.textContent = `${kStr} / ${maxKStr}`;
ctxFill.style.width = pct + '%';
ctxFill.style.background = pct >= 80 ? '#ef4444' : pct >= 50 ? '#f59e0b' : '#22c55e';
ctxWrap.style.display = 'flex';
}
// 프로젝트 선택기 (항상 업데이트)
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" data-dirpath="${escHtml(dirPath)}" style="padding-left:${indent+8}px;font-weight:600;color:var(--muted)"
onclick="csbToggleDir('${dirId}')"
oncontextmenu="csbCtxMenu(event,this,true)"
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('csb-drop-target')"
ondragleave="this.classList.remove('csb-drop-target')"
ondrop="csbDrop(event,this)">
<span id="${dirId}_arrow" style="font-size:9px;transition:.15s;flex-shrink:0">${isOpen ? '▼' : '▶'}</span><span style="flex-shrink:0">📂</span>
<span class="csb-item-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escHtml(name)}/</span>
<button class="csb-add-btn" onclick="csbNewInDir(this.closest('[data-dirpath]').dataset.dirpath,event)" title="새 파일">+</button>
<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" data-path="${escHtml(fullPath)}" style="padding-left:${indent+8}px"
draggable="true"
ondragstart="event.dataTransfer.setData('text/plain',this.dataset.path);event.dataTransfer.effectAllowed='move'"
oncontextmenu="csbCtxMenu(event,this,false)"
onclick="csbOpenFile('${safe}')">
<span style="flex-shrink:0">${icon}</span>
<span class="csb-item-name" 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);
}
}
// ── Context menu ─────────────────────────────────────────────────────────────
let _csbCtxTarget = null;
function csbCtxMenu(ev, el, isDir) {
ev.preventDefault(); ev.stopPropagation();
_csbCtxTarget = { path: el.dataset.path || el.dataset.dirpath, isDir };
const menu = document.getElementById('csb-ctx');
if (!menu) return;
const items = isDir ? [
{ label: '📄 새 파일', action: 'newfile' },
{ label: '📁 새 폴더', action: 'newfolder' },
{ label: '✏️ 이름 바꾸기', action: 'rename' },
{ label: '🗑️ 삭제', action: 'delete' },
] : [
{ label: '✏️ 이름 바꾸기', action: 'rename' },
{ label: '🗑️ 삭제', action: 'delete' },
];
menu.innerHTML = items.map(i => `<div class="csb-ctx-item" onclick="csbCtxAction('${i.action}')">${i.label}</div>`).join('');
menu.style.display = 'block';
const x = Math.min(ev.clientX, window.innerWidth - 160);
const y = Math.min(ev.clientY, window.innerHeight - (items.length * 34) - 16);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
}
function csbCtxClose() {
const menu = document.getElementById('csb-ctx');
if (menu) menu.style.display = 'none';
_csbCtxTarget = null;
}
function csbCtxAction(action) {
const target = _csbCtxTarget;
csbCtxClose();
if (!target) return;
const { path, isDir } = target;
if (action === 'rename') csbInlineRename(path, isDir);
else if (action === 'delete') isDir ? csbDeleteDir(path, { stopPropagation: () => {} }) : csbDeleteFile(path, { stopPropagation: () => {} });
else if (action === 'newfile') csbNewInDir(path, null);
else if (action === 'newfolder') csbNewFolderAt(path);
}
// ── Inline rename ─────────────────────────────────────────────────────────────
function csbInlineRename(path, isDir) {
const tree = document.getElementById('csb-file-tree');
if (!tree) return;
const attr = isDir ? 'data-dirpath' : 'data-path';
const item = tree.querySelector(`[${attr}="${path.replace(/\\/g,'\\\\').replace(/"/g,'\\"')}"]`);
if (!item) return;
const nameEl = item.querySelector('.csb-item-name');
if (!nameEl) return;
const oldBase = path.split('/').pop();
const inp = document.createElement('input');
inp.value = oldBase;
inp.style.cssText = 'flex:1;min-width:0;font-size:11px;padding:1px 4px;background:var(--panel);color:var(--text);border:1px solid var(--brand);border-radius:3px;outline:none;font-family:inherit';
nameEl.replaceWith(inp);
inp.focus(); inp.select();
let done = false;
const commit = async () => {
if (done) return; done = true;
const newName = inp.value.trim();
if (newName && newName !== oldBase) await csbRenameExec(path, newName, isDir);
else await codeSbRefresh();
};
inp.onblur = commit;
inp.onkeydown = e => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') { done = true; codeSbRefresh(); }
};
}
async function csbRenameExec(oldPath, newName, isDir) {
if (!localDirHandle) return;
const parts = oldPath.split('/');
parts[parts.length - 1] = newName;
const newPath = parts.join('/');
try {
if (!isDir) {
const content = await localReadFile(oldPath);
if (content === null) throw new Error('읽기 실패');
await localWriteFile(newPath, content);
await localDeleteFile(oldPath);
const bare = _clientBareFileName(oldPath);
const tab = codeFiles.find(f => f.name === bare);
if (tab) { tab.name = _clientBareFileName(newPath); renderCodeFileTabs(); }
} else {
const allFiles = await localListFiles(localDirHandle, '');
const prefix = oldPath + '/';
for (const f of allFiles.filter(f => f.startsWith(prefix))) {
const content = await localReadFile(f);
if (content !== null) await localWriteFile(newPath + '/' + f.slice(prefix.length), content);
}
const ps = oldPath.split('/');
let parent = localDirHandle;
for (let i = 0; i < ps.length - 1; i++) parent = await parent.getDirectoryHandle(ps[i]);
await parent.removeEntry(ps[ps.length - 1], { recursive: true });
for (const f of codeFiles) {
const disk = _clientProjectPath(f.name);
if (disk.startsWith(prefix)) f.name = _clientBareFileName(newPath + '/' + disk.slice(prefix.length));
}
renderCodeFileTabs();
}
await codeSbRefresh();
} catch (e) { alert('이름 바꾸기 실패: ' + (e.message || e)); await codeSbRefresh(); }
}
// ── New file/folder helpers ───────────────────────────────────────────────────
async function csbNewInDir(dirPath, ev) {
if (ev) ev.stopPropagation();
if (!localDirHandle) return;
const name = prompt(`새 파일 이름 (${dirPath.split('/').pop()}/ 에 생성):`);
if (!name?.trim()) return;
const fullPath = dirPath + '/' + name.trim();
await localWriteFile(fullPath, '');
codeNewFile(_clientBareFileName(fullPath), '');
codeSbRefresh();
}
async function csbNewFolderAt(parentPath, ev) {
if (ev) ev.stopPropagation();
if (!localDirHandle) return;
const name = prompt(`새 폴더 이름 (${parentPath.split('/').pop()}/ 안에 생성):`);
if (!name?.trim()) return;
await localWriteFile(parentPath + '/' + name.trim() + '/.gitkeep', '');
codeSbRefresh();
}
async function csbNewFolderRoot() {
if (!localDirHandle) return;
const base = activeProjectName ? activeProjectName + '/' : '';
const name = prompt('새 폴더 이름:');
if (!name?.trim()) return;
await localWriteFile(base + name.trim() + '/.gitkeep', '');
codeSbRefresh();
}
// ── Drag and drop ─────────────────────────────────────────────────────────────
function csbDrop(ev, el) {
ev.preventDefault(); ev.stopPropagation();
el.classList.remove('csb-drop-target');
const srcPath = ev.dataTransfer.getData('text/plain');
const dirPath = el.dataset.dirpath;
if (!srcPath || !dirPath) return;
const dstPath = dirPath + '/' + srcPath.split('/').pop();
if (srcPath === dstPath) return;
csbMoveFile(srcPath, dstPath);
}
async function csbMoveFile(srcPath, dstPath) {
if (!localDirHandle) return;
try {
const content = await localReadFile(srcPath);
if (content === null) { alert(`읽기 실패: ${srcPath}`); return; }
await localWriteFile(dstPath, content);
await localDeleteFile(srcPath);
const tab = codeFiles.find(f => f.name === _clientBareFileName(srcPath));
if (tab) { tab.name = _clientBareFileName(dstPath); renderCodeFileTabs(); }
await codeSbRefresh();
} catch (e) { alert('이동 실패: ' + (e.message || e)); }
}
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();
_codeAiCtxMaxFetchedFor = ''; // invalidate cache so next fetch runs
_fetchModelCtxMax(codeAiModel);
}
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');
}
function csbFontWeight(val) {
localStorage.setItem('codeEditorFontWeight', val);
if (monacoEditor) monacoEditor.updateOptions({ fontWeight: val });
}
let _hideBlankLines = false;
function csbHideBlankLines(checked) {
_hideBlankLines = !!checked;
localStorage.setItem('codeEditorHideBlank', _hideBlankLines ? '1' : '0');
const btn = document.getElementById('code-opt-hideblank');
if (btn) btn.classList.toggle('active', _hideBlankLines);
_applyHideBlankLines();
}
function _applyHideBlankLines() {
if (!monacoEditor) return;
if (!_hideBlankLines) { try { monacoEditor.setHiddenAreas([]); } catch {} return; }
const model = monacoEditor.getModel();
if (!model) return;
const n = model.getLineCount();
const ranges = [];
let start = -1;
let inBlock = false, blockEnd = '';
for (let i = 1; i <= n; i++) {
const l = model.getLineContent(i).trim();
let hide = false;
if (inBlock) {
hide = true;
if (l.includes(blockEnd)) inBlock = false;
} else if (l === '') {
hide = true;
} else if (/^(\/\/|#|--|;)/.test(l)) {
hide = true;
} else if (l.startsWith('/*')) {
hide = true;
if (!l.slice(2).includes('*/')) { inBlock = true; blockEnd = '*/'; }
} else if (l.startsWith('<!--')) {
hide = true;
if (!l.slice(4).includes('-->')) { inBlock = true; blockEnd = '-->'; }
} else if (l.startsWith('"""') || l.startsWith("'''")) {
const d = l.slice(0, 3);
hide = true;
if (!l.slice(3).includes(d)) { inBlock = true; blockEnd = d; }
}
if (hide) { if (start < 0) start = i; }
else { if (start >= 0) { ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: i - 1, endColumn: 1 }); start = -1; } }
}
if (start >= 0) ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: n, endColumn: 1 });
try { monacoEditor.setHiddenAreas(ranges); } catch {}
}
let _blockFlipFlop = false;
let _blockFFDecoIds = [];
function csbBlockFlipFlop(checked) {
_blockFlipFlop = !!checked;
localStorage.setItem('codeEditorBlockFF', _blockFlipFlop ? '1' : '0');
const btn = document.getElementById('code-opt-blockff');
if (btn) btn.classList.toggle('active', _blockFlipFlop);
_applyBlockFlipFlop();
}
function _applyBlockFlipFlop() {
if (!monacoEditor) return;
const model = monacoEditor.getModel();
if (!model) return;
_blockFFDecoIds = monacoEditor.deltaDecorations(_blockFFDecoIds, []);
if (!_blockFlipFlop) return;
const n = model.getLineCount();
const decos = [];
// Detect top-level function/class/def declarations
const declRe = /^(export\s+)?(default\s+)?(async\s+)?(function\b|class\b|def\b|async\s+def\b)|^(const|let|var)\s+\w+\s*=\s*(async\s+)?(function|\(|[A-Za-z_$]\w*\s*=>)/;
const boundaries = [];
for (let i = 1; i <= n; i++) {
const line = model.getLineContent(i);
if (line.match(/^(\s*)/)[1].length === 0 && declRe.test(line.trim())) {
boundaries.push(i);
}
}
const ffCls = (idx) => idx % 2 === 0 ? 'ff-block-even' : 'ff-block-odd';
const ffRange = (from, to, idx) => ({ range: { startLineNumber: from, startColumn: 1, endLineNumber: to, endColumn: 1 }, options: { isWholeLine: true, className: ffCls(idx) } });
if (boundaries.length >= 2) {
// Declaration-based: alternate between declaration boundaries
for (let b = 0; b < boundaries.length; b++) {
const from = boundaries[b];
const to = b + 1 < boundaries.length ? boundaries[b + 1] - 1 : n;
decos.push(ffRange(from, to, b));
}
} else {
// Fallback: blank-line-separated groups
let phase = 0, blockStart = -1;
for (let i = 1; i <= n; i++) {
const blank = model.getLineContent(i).trim() === '';
if (!blank && blockStart < 0) { blockStart = i; }
else if (blank && blockStart >= 0) {
decos.push(ffRange(blockStart, i - 1, phase));
blockStart = -1; phase++;
}
}
if (blockStart >= 0) decos.push(ffRange(blockStart, n, phase));
}
_blockFFDecoIds = monacoEditor.deltaDecorations([], decos);
}
const CODE_THEME_GROUPS = [
{ icon: '🌙', label: '다크', themes: [
{ v: 'github-dark', label: 'GitHub Dark', bg: '#0d1117' },
{ v: 'one-dark', label: 'One Dark', bg: '#282c34' },
{ v: 'dracula', label: 'Dracula', bg: '#282a36' },
{ v: 'nord', label: 'Nord', bg: '#2e3440' },
{ v: 'vs-dark', label: 'VS Dark', bg: '#1e1e1e' },
{ v: 'monokai', label: 'Monokai', bg: '#272822' },
]},
{ icon: '☀️', label: '라이트', themes: [
{ v: 'github-light', label: 'GitHub Light', bg: '#f6f8fa' },
{ v: 'vs', label: 'VS Light', bg: '#ffffff' },
]},
];
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');
}
function csbFontFamily(val) {
localStorage.setItem('codeEditorFontFamily', val);
if (monacoEditor) monacoEditor.updateOptions({ fontFamily: val === 'monospace' ? 'monospace' : `'${val}', monospace` });
}
// 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');
const ff = localStorage.getItem('codeEditorFontFamily') || 'D2Coding';
const fw = localStorage.getItem('codeEditorFontWeight') || '400';
const hb = localStorage.getItem('codeEditorHideBlank');
const bff = localStorage.getItem('codeEditorBlockFF');
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';
const ffEl = document.getElementById('csb-fontfamily'); if (ffEl) ffEl.value = ff;
const fwEl = document.getElementById('csb-fontweight'); if (fwEl) fwEl.value = fw;
_hideBlankLines = hb === '1';
_blockFlipFlop = bff === '1';
const hbBtn = document.getElementById('code-opt-hideblank'); if (hbBtn) hbBtn.classList.toggle('active', _hideBlankLines);
const bffBtn = document.getElementById('code-opt-blockff'); if (bffBtn) bffBtn.classList.toggle('active', _blockFlipFlop);
if (monacoEditor) monacoEditor.updateOptions({
fontSize: fs ? parseInt(fs) : 14,
wordWrap: ww === '0' ? 'off' : 'on',
tabSize: ts ? parseInt(ts) : 2,
minimap: { enabled: mm !== '0' },
fontFamily: ff === 'monospace' ? 'monospace' : `'${ff}', monospace`,
fontWeight: fw,
});
if (_hideBlankLines) _applyHideBlankLines();
if (_blockFlipFlop) _applyBlockFlipFlop();
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;
const modelCard = document.getElementById('code-ai-model-card');
const syncModelCard = w => { if (modelCard) modelCard.style.width = w + 'px'; };
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';
syncModelCard(newW);
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 { updateCodeAiModeBtn(); } catch (e) {}
try { _fetchModelCtxMax(codeAiModel); } catch (e) {}
try { const p = document.getElementById('code-ai-panel'), c = document.getElementById('code-ai-model-card'); if (p && c) c.style.width = p.offsetWidth + 'px'; } 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();
})();