// ============================================================================ // 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,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"'); } 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(/[\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: 12, lineHeight: 19, fontFamily: "'IBM Plex Mono', 'Fira Code', 'Courier New', monospace", minimap: { enabled: true }, scrollBeyondLastLine: false, wordWrap: 'on', renderWhitespace: 'selection', smoothScrolling: true, cursorBlinking: 'smooth', tabSize: 2, }); monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile); codeFlushPendingQueue(); if (!codeFiles.length && !codePendingQueue.length) { // Don't create a placeholder file β€” the model will create files as needed renderCodeFileTabs(); } else renderCodeFileTabs(); setupCodeVResize(); csbRestoreSettings(); setupCodeEditorDrop(); }); } function codeNewFile(name, content) { // In client-local + project mode: tabs always store bare filenames; the project // subfolder prefix is handled transparently by _clientProjectPath at disk I/O time. // In server mode with activeProjectName: prefix IS part of the tab name (existing behaviour). let fname = name; if (!fname) { const raw = prompt('파일 이름:', 'untitled.py'); if (!raw || !raw.trim()) return; fname = (localDirHandle && activeProjectName) ? raw.trim() : (activeProjectName ? `${activeProjectName}/${raw.trim()}` : raw.trim()); } else if (localDirHandle && activeProjectName) { // Strip project prefix so tab names are always bare in client-local mode fname = _clientBareFileName(fname.trim()); } fname = fname.trim(); if (!fname) return; const id = 'cf_' + Math.random().toString(36).slice(2, 8); const lang = codeDetectLang(fname); const model = monacoReady ? monaco.editor.createModel(content || '', lang) : null; codeFiles.push({ id, name: fname, content: content || '', language: lang, model }); switchCodeFile(id); } function switchCodeFile(id) { const prev = codeFiles.find(f => f.id === activeCodeFileId); if (prev && monacoEditor) prev.content = monacoEditor.getValue(); _DBG && console.log(`[switchCodeFile] ${activeCodeFileId} β†’ ${id} file=${(codeFiles.find(f => f.id === id) || {}).name || '?'}`); activeCodeFileId = id; const file = codeFiles.find(f => f.id === id); if (!file || !monacoEditor) { renderCodeFileTabs(); return; } if (!file.model) file.model = monaco.editor.createModel(file.content, file.language); // If this file has an active inline diff, re-apply decorations if (codeDiffState[id]) { monacoEditor.setModel(file.model); monacoEditor.focus(); _showDiffBar(id); _applyInlineDiffDecorations(id); } else { monacoEditor.setModel(file.model); monacoEditor.focus(); } renderCodeFileTabs(); codeUpdateRunButton(); } function closeCodeFile(id, ev) { if (ev) ev.stopPropagation(); const idx = codeFiles.findIndex(f => f.id === id); if (idx === -1) return; // Clean up diff view if active if (codeDiffState[id]) cleanupCodeDiff(id); const file = codeFiles[idx]; if (file.model) file.model.dispose(); codeFiles.splice(idx, 1); if (!codeFiles.length) { codeNewFile('untitled.py', ''); return; } if (activeCodeFileId === id) switchCodeFile(codeFiles[Math.min(idx, codeFiles.length - 1)].id); else renderCodeFileTabs(); } function renderCodeFileTabs() { const bar = document.getElementById('code-file-tabs'); if (!bar) return; bar.innerHTML = codeFiles.map(f => { const active = f.id === activeCodeFileId; const ds = codeDiffState[f.id]; const diff = !!ds; const unresolved = diff ? ds.hunks.filter((_, i) => !ds.resolved.has(i)).length : 0; return `
${unresolved > 0 ? `${unresolved}` : ''} ${escHtml(f.name)} ×
`; }).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; const _ANSI_RE = /\x1b\[[0-9;]*[mGKHFABCDJnsuhl]/g; function _termBufferAppend(shellId, raw) { if (!shellId) return; const text = raw.replace(_ANSI_RE, '').replace(/\r/g, ''); if (!text) return; const lines = text.split('\n'); if (!_termBuffers[shellId]) _termBuffers[shellId] = []; const buf = _termBuffers[shellId]; for (const l of lines) { if (l) buf.push(l); } while (buf.length > _TERM_BUF_MAX) buf.shift(); _termDetectAndShowError(shellId); } const _ERROR_PATTERNS = [ /Traceback \(most recent call last\)/, /(?:Syntax|Name|Type|Attribute|Import|Indentation|Value|Key|Index|Zero|Overflow|Runtime|OS|IO|File)Error:/, /^\s*\^\s*$/, // Python caret indicator /Uncaught (?:TypeError|ReferenceError|SyntaxError|Error):/, /Error: .+\n?\s+at /, /\[Error\]/i, ]; let _lastErrorShellId = null; let _lastErrorText = ''; function _termDetectAndShowError(shellId) { const buf = _termBuffers[shellId]; if (!buf || buf.length < 2) return; const recent = buf.slice(-40).join('\n'); const hasError = _ERROR_PATTERNS.some(re => re.test(recent)); if (!hasError) return; // Deduplicate β€” don't re-show if same error already triggered if (_lastErrorShellId === shellId && _lastErrorText === recent) return; _lastErrorShellId = shellId; _lastErrorText = recent; _showTerminalErrorButton(shellId, recent); } function _showTerminalErrorButton(shellId, errorText) { const btn = document.getElementById('code-term-ai-error-btn'); if (btn) { 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; const trimmed = errorText.split('\n').filter(l => l.trim()).slice(-25).join('\n'); input.value = 'λ‹€μŒ μ—λŸ¬λ₯Ό 고쳐쀘. edit_file λ„κ΅¬λ‘œ νŒŒμΌμ„ 직접 μˆ˜μ •ν•΄:\n```\n' + trimmed + '\n```'; input.style.height = ''; input.dispatchEvent(new Event('input')); // Auto-send codeAiSend(); } function _sendActiveTermErrorToAI() { const btn = document.getElementById('code-term-ai-error-btn'); const shellId = btn?.dataset.shellId || _lastErrorShellId; const buf = (shellId && _termBuffers[shellId]) || Object.values(_termBuffers).at(-1); const text = buf ? buf.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; s.term.write('\x1b[32mπŸ–₯️ 둜컬 μ…Έ\x1b[0m\r\n'); _companionCdProject(msg.shellId); } else if (msg.type === 'shell_output') s.term.write(msg.data); else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[μ…Έ μ’…λ£Œ ${msg.code}]\x1b[0m\r\n`); } 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(/(? 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 ? `cd /d "${target}"\r` : `cd ${JSON.stringify(target)}\r`; companionWs.send(JSON.stringify({ type: 'input', shellId: sid, data: cmd })); // After cd completes, clear via xterm API so the prompt is always visible setTimeout(() => { const shell = codeTermShells.find(s => s.id === sid); if (shell?.term) shell.term.clear(); if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: sid, data: '\r' })); }, 800); } 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 `
${escHtml(s.name)}${chatTermShells.length>1?`×`:''}
`; }).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) { 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 `
${label}${codeTermShells.length>1?` ×`:''}
`; }).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 => `
` + `${m.icon}` + `
${m.label}
${m.desc}
` + `
` ).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 and `); } } 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 ν…ŒνŠΈλ¦¬μŠ€ ... @@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λ₯Ό