// ============================================================================ // code.js β€” Standalone Code Editor page (code.html) // // This is a self-contained extract of the code-editor view that also lives // inside the main app (web-ui/app.js). It is loaded ONLY by code.html so the const _DBG = false; // set true to enable streaming/editor debug logs // editor can run in its own browser tab (opened from the πŸ’» μ½”λ“œ sidebar tab), // mirroring how the slide wizard opens pptx-wizard.html. // // The main app keeps its own in-page code mode (for live code streaming from // the chat agent), so this file intentionally does not depend on app.js. // If you change code-view behaviour, update both this file and app.js. // ============================================================================ // ── Shared helpers (self-contained copies of app.js utilities) ────────────── function getAuthToken() { try { return sessionStorage.getItem('smallclaw_token') || ''; } catch { return ''; } } function setAuthToken(t) { try { t ? sessionStorage.setItem('smallclaw_token', t) : sessionStorage.removeItem('smallclaw_token'); } catch {} } function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra || {}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; } function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return ''; } function escHtml(s) { return String(s || '').replace(/&/g,'&').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 = []; let codeAiAbort = null; let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10); let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud'; let codeAiMode = localStorage.getItem('codeAiMode') ?? 'code'; // 'code' or 'ask' let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, widgetIds, resolved, onReject } } const CODE_LANG_MAP = { js:'javascript', ts:'typescript', jsx:'javascript', tsx:'typescript', py:'python', rb:'ruby', go:'go', rs:'rust', java:'java', cs:'csharp', cpp:'cpp', c:'c', php:'php', html:'html', css:'css', scss:'scss', json:'json', yaml:'yaml', yml:'yaml', md:'markdown', sh:'shell', bash:'shell', sql:'sql', xml:'xml', toml:'ini', vue:'html', }; function codeDetectLang(name) { const ext = (name || '').split('.').pop().toLowerCase(); return CODE_LANG_MAP[ext] || 'plaintext'; } function updateCodeAiLabel() { const label = codeAiModel || 'AI'; const title = document.getElementById('code-ai-title'); if (title) title.textContent = 'πŸ’» Coder-' + label; const input = document.getElementById('code-ai-input'); if (input) input.placeholder = (codeAiMode === 'ask' ? 'Ask-' : 'Coder-') + label + 'μ—κ²Œ 질문...'; } function setCodeAiMode(mode) { codeAiMode = mode; localStorage.setItem('codeAiMode', mode); const codeBtn = document.getElementById('code-ai-mode-code'); const askBtn = document.getElementById('code-ai-mode-ask'); if (codeBtn) codeBtn.classList.toggle('active', mode === 'code'); if (askBtn) askBtn.classList.toggle('active', mode === 'ask'); updateCodeAiLabel(); } function initCodeView() { updateCodeAiLabel(); setCodeAiMode(codeAiMode); // Initialize mode selector UI if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; } if (typeof require === 'undefined') return; require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } }); require(['vs/editor/editor.main'], function() { monacoReady = true; const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; monacoEditor = monaco.editor.create(document.getElementById('code-editor-container'), { theme: isDark ? 'vs-dark' : 'vs', language: 'plaintext', automaticLayout: true, fontSize: 12, lineHeight: 19, fontFamily: "'IBM Plex Mono', 'Fira Code', 'Courier New', monospace", minimap: { enabled: true }, scrollBeyondLastLine: false, wordWrap: 'on', renderWhitespace: 'selection', smoothScrolling: true, cursorBlinking: 'smooth', tabSize: 2, }); monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile); codeFlushPendingQueue(); if (!codeFiles.length && !codePendingQueue.length) { // Don't create a placeholder file β€” the model will create files as needed renderCodeFileTabs(); } else renderCodeFileTabs(); setupCodeVResize(); csbRestoreSettings(); setupCodeEditorDrop(); }); } function codeNewFile(name, content) { const fname = name || prompt('파일 이름:', 'untitled.py'); if (!fname || !fname.trim()) return; const id = 'cf_' + Math.random().toString(36).slice(2, 8); const lang = codeDetectLang(fname); const model = monacoReady ? monaco.editor.createModel(content || '', lang) : null; codeFiles.push({ id, name: fname.trim(), content: content || '', language: lang, model }); switchCodeFile(id); } function switchCodeFile(id) { const prev = codeFiles.find(f => f.id === activeCodeFileId); if (prev && monacoEditor) prev.content = monacoEditor.getValue(); _DBG && console.log(`[switchCodeFile] ${activeCodeFileId} β†’ ${id} file=${(codeFiles.find(f => f.id === id) || {}).name || '?'}`); activeCodeFileId = id; const file = codeFiles.find(f => f.id === id); if (!file || !monacoEditor) { renderCodeFileTabs(); return; } if (!file.model) file.model = monaco.editor.createModel(file.content, file.language); // If this file has an active inline diff, re-apply decorations if (codeDiffState[id]) { monacoEditor.setModel(file.model); monacoEditor.focus(); _showDiffBar(id); _applyInlineDiffDecorations(id); } else { monacoEditor.setModel(file.model); monacoEditor.focus(); } renderCodeFileTabs(); codeUpdateRunButton(); } function closeCodeFile(id, ev) { if (ev) ev.stopPropagation(); const idx = codeFiles.findIndex(f => f.id === id); if (idx === -1) return; // Clean up diff view if active if (codeDiffState[id]) cleanupCodeDiff(id); const file = codeFiles[idx]; if (file.model) file.model.dispose(); codeFiles.splice(idx, 1); if (!codeFiles.length) { codeNewFile('untitled.py', ''); return; } if (activeCodeFileId === id) switchCodeFile(codeFiles[Math.min(idx, codeFiles.length - 1)].id); else renderCodeFileTabs(); } function renderCodeFileTabs() { const bar = document.getElementById('code-file-tabs'); if (!bar) return; bar.innerHTML = codeFiles.map(f => { const active = f.id === activeCodeFileId; const ds = codeDiffState[f.id]; const diff = !!ds; const unresolved = diff ? ds.hunks.filter((_, i) => !ds.resolved.has(i)).length : 0; return `
${unresolved > 0 ? `${unresolved}` : ''} ${escHtml(f.name)} ×
`; }).join(''); } let codeDirLabel = 'code'; function csbCodeDirChanged(val) { /* preview only, saved on button click */ } async function csbCodeDirSave() { const input = document.getElementById('csb-codedir'); const val = (input?.value || '').trim() || 'code'; try { await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ code_dir: val }) }); codeDirLabel = val; const folderBtn = document.getElementById('code-folder-btn'); if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${val}/`; const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]'); if (headerLabel) headerLabel.textContent = `workspace/${val}/`; codeSbRefresh(); } catch {} } async function csbLoadCodeDir() { try { const data = await api('/api/settings/paths'); const dir = data.code_dir || 'code'; codeDirLabel = dir; const input = document.getElementById('csb-codedir'); if (input) input.value = dir; const folderBtn = document.getElementById('code-folder-btn'); if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${dir}/`; const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]'); if (headerLabel) headerLabel.textContent = `workspace/${dir}/`; } catch {} } function codeSaveFile() { const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !monacoEditor) return; file.content = monacoEditor.getValue(); api('/api/code/save', { method: 'POST', body: JSON.stringify({ filename: file.name, content: file.content }), }) .then(r => { if (r.success) codeAiAppend('system', `πŸ’Ύ μ €μž₯됨: ${r.path}`); else throw new Error(r.error); }) .catch(e => codeAiAppend('system', `⚠️ μ €μž₯ μ‹€νŒ¨: ${e.message || e}`)); } // ── 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) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); } } }; termWs.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write(msg.data); } else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); } } else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); } }; termWs.onclose = () => { termWsConnected = false; termWs = null; for (const [,i] of xtermInstances) i.ptyReady = false; setTimeout(termWsConnect, 3000); }; termWs.onerror = () => { termWsConnected = false; }; } function termWsSend(msg) { if (termWs && termWs.readyState === WebSocket.OPEN) { termWs.send(JSON.stringify(msg)); return true; } return false; } const xtermInstances = new Map(); function createXtermShell(container, shellId) { const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8); const sub = document.createElement('div'); sub.id = 'xterm-' + id; sub.style.cssText = 'position:absolute;inset:0'; container.appendChild(sub); const term = new Terminal({ theme: { background:'#0C0C0C', foreground:'#CCCCCC', cursor:'#22c55e', selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C', yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC', brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' }, fontFamily: "'IBM Plex Mono','Courier New',monospace", fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000, }); const fitAddon = new FitAddon.FitAddon(); const webLinksAddon = new WebLinksAddon.WebLinksAddon(); term.loadAddon(fitAddon); term.loadAddon(webLinksAddon); term.open(sub); fitAddon.fit(); const inst = { term, fitAddon, ptyReady: false }; xtermInstances.set(id, inst); term.onData(data => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data }); }); term.onResize(({ cols, rows }) => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_resize', shellId: id, cols, rows }); }); if (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.style.color = '#22c55e'; if (!codeTermShells.length) { addCodeTermShell(); } else { setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); } } else { panel.style.display = 'none'; if (btn) btn.style.color = '#6fcf8a'; } if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } function renderCodeTermTabs() { const bar = document.getElementById('code-term-tabs'); if (!bar) return; bar.innerHTML = codeTermShells.map(s => { const a = s.id === activeCodeTermShellId; return `
bash${codeTermShells.length>1?` ×`:''}
`; }).join(''); } function addCodeTermShell() { const id = 'cdt_' + Math.random().toString(36).slice(2,8); codeTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null }); switchCodeTermShell(id); } 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; termWsSend({ type:'pty_kill', shellId:id }); const shell = codeTermShells[idx]; if (shell.term) shell.term.dispose(); xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove(); codeTermShells.splice(idx, 1); if (activeCodeTermShellId === id) switchCodeTermShell(codeTermShells[Math.min(idx,codeTermShells.length-1)].id); else renderCodeTermTabs(); } // Resize drag (top edge β†’ pull up/down) document.addEventListener('DOMContentLoaded', function() { const drag = document.getElementById('code-term-drag'); const panel = document.getElementById('code-term-panel'); if (!drag || !panel) return; let startY=0, startH=0; drag.addEventListener('mousedown', e => { startY=e.clientY; startH=panel.offsetHeight; e.preventDefault(); const onMove = e2 => { const dy = startY - e2.clientY; panel.style.height = Math.max(80, Math.min(window.innerHeight*.6, startH+dy)) + 'px'; const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; }; drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)'); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); // Restore log panel collapsed state (function() { if (localStorage.getItem('logPanelCollapsed') === '1') { const panel = document.getElementById('sidebar-log-panel'); if (panel) panel.classList.add('collapsed'); } })(); // Ctrl+` shortcut document.addEventListener('keydown', e => { if (e.ctrlKey && e.key === '`') { e.preventDefault(); if (document.getElementById('code-view')?.classList.contains('active')) toggleCodeTerminal(); } }); // ── Code Run: multi-mode execution ────────────────────────────────────────── const CODE_RUN_MODES = { html: [ { id: 'browser', icon: '🌐', label: 'λΈŒλΌμš°μ €', desc: 'μƒˆ νƒ­μ—μ„œ μ—΄κΈ°' }, { id: 'terminal', icon: '⌨', label: '터미널', desc: 'μ›Ή μ„œλ²„λ‘œ μ‹€ν–‰' }, ], htm: 'html', py: [ { id: 'pyodide', icon: '🐍', label: 'Pyodide', desc: 'λΈŒλΌμš°μ €μ—μ„œ μ‹€ν–‰' }, { id: 'terminal', icon: '⌨', label: 'python3', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], js: [ { id: 'terminal', icon: '⌨', label: 'node', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], ts: [ { id: 'terminal', icon: '⌨', label: 'ts-node', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], go: [ { id: 'terminal', icon: '⌨', label: 'go run', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], rs: [ { id: 'terminal', icon: '⌨', label: 'rustc', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], cpp: [ { id: 'terminal', icon: '⌨', label: 'g++', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], c: [ { id: 'terminal', icon: '⌨', label: 'gcc', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], sh: [ { id: 'terminal', icon: '⌨', label: 'bash', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }, ], }; function codeGetRunModes(ext) { const modes = CODE_RUN_MODES[ext]; if (!modes) return [{ id: 'terminal', icon: '⌨', label: '터미널', desc: 'ν„°λ―Έλ„μ—μ„œ μ‹€ν–‰' }]; if (typeof modes === 'string') return CODE_RUN_MODES[modes]; return modes; } let _codeRunMenuOpen = false; function codeToggleRunMenu() { const menu = document.getElementById('code-run-menu'); if (_codeRunMenuOpen) { menu.style.display = 'none'; _codeRunMenuOpen = false; return; } const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file) return; const ext = file.name.split('.').pop().toLowerCase(); const modes = codeGetRunModes(ext); menu.innerHTML = modes.map(m => `
` + `${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); } 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; file.content = monacoEditor.getValue(); const fpath = `${codeDirLabel}/${file.name}`; if (mode === 'browser') { api('/api/code/save', { method: 'POST', body: JSON.stringify({ filename: file.name, content: file.content }) }) .then(r => { if (r.success) { const w = window.open(`${location.origin}/code-serve/${codeDirLabel}/${file.name}`, '_blank'); if (!w) codeAiAppend('system', '⚠️ νŒμ—… 차단됨 β€” λΈŒλΌμš°μ €μ—μ„œ νŒμ—…μ„ ν—ˆμš©ν•΄μ£Όμ„Έμš”'); } }); return; } if (mode === 'pyodide') { sessionStorage.setItem('py_run_code', file.content); sessionStorage.setItem('py_run_name', file.name); const w = window.open('/python-runner.html', '_blank'); if (!w) codeAiAppend('system', '⚠️ νŒμ—… 차단됨 β€” λΈŒλΌμš°μ €μ—μ„œ νŒμ—…μ„ ν—ˆμš©ν•΄μ£Όμ„Έμš”'); return; } // terminal modes const ext = file.name.split('.').pop().toLowerCase(); let cmd; if (mode === 'terminal') { cmd = ext === 'py' ? `python3 ${fpath}` : ext === 'js' ? `node ${fpath}` : ext === 'ts' ? `npx ts-node ${fpath}` : ext === 'go' ? `go run ${fpath}` : ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'sh' ? `bash ${fpath}` : `./${fpath}`; } else { cmd = mode === 'python3' ? `python3 ${fpath}` : mode === 'node' ? `node ${fpath}` : `${mode} ${fpath}`; } if (!codeTermOpen) toggleCodeTerminal(); setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: s.id, data: cmd + '\r' }); }, codeTermOpen ? 0 : 500); } function codeSendSelectionToAI() { if (!monacoEditor) return; const sel = monacoEditor.getSelection(); const selected = sel && !sel.isEmpty() ? monacoEditor.getModel().getValueInRange(sel) : monacoEditor.getValue(); const file = codeFiles.find(f => f.id === activeCodeFileId); const lang = file ? file.language : 'code'; const prompt = `λ‹€μŒ ${lang} μ½”λ“œλ₯Ό λΆ„μ„ν•΄μ€˜:\n\`\`\`${lang}\n${selected}\n\`\`\``; document.getElementById('code-ai-input').value = prompt; codeAiSend(); } // ─── V2 Chat Live Code Streaming ──────────────────────────────────────────────── // Global state for streaming code into the editor during the main v2 chat. const _v2LiveState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null }; let codePendingQueue = []; // [{fname, content, lang}] β€” queued when Monaco not ready // Throttled Monaco model update β€” avoids freezing UI by limiting setValue calls to ~80ms intervals let _monacoThrottleTimer = null; let _monacoThrottleModel = null; let _monacoThrottleValue = ''; function codeMonacoThrottle(model, value) { _monacoThrottleModel = model; _monacoThrottleValue = value; if (_monacoThrottleTimer) return; // already scheduled _monacoThrottleTimer = setTimeout(() => { _monacoThrottleTimer = null; if (_monacoThrottleModel && _monacoThrottleValue !== undefined) { const _lines = _monacoThrottleValue.split('\n').length; _DBG && console.log(`[codeMonacoThrottle] setValue: ${_lines} lines`); _monacoThrottleModel.setValue(_monacoThrottleValue); _monacoThrottleModel = null; } }, 80); } function codeMonacoThrottleFlush() { if (_monacoThrottleTimer) { clearTimeout(_monacoThrottleTimer); _monacoThrottleTimer = null; } if (_monacoThrottleModel && _monacoThrottleValue !== undefined) { _monacoThrottleModel.setValue(_monacoThrottleValue); } _monacoThrottleModel = null; } function codeFlushPendingQueue() { if (!monacoReady || !monacoEditor || !codePendingQueue.length) return; for (const item of codePendingQueue) { const existing = codeFiles.find(f => f.name === item.fname); if (existing) { const oldContent = existing.model ? existing.model.getValue() : existing.content; if (oldContent !== item.content) { switchCodeFile(existing.id); showCodeDiff(existing.id, oldContent, item.content); } } else { const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname)); codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model }); if (!_v2LiveState.fileId) _v2LiveState.fileId = id; } } codePendingQueue = []; if (_v2LiveState.fileId) switchCodeFile(_v2LiveState.fileId); } // ── Shared live-streaming parser ──────────────────────────────────────────── // Detects the last unclosed ```code block in a streaming reply and mirrors it // into the editor: creates/reuses a tab, throttles model updates, and renames // the tab once a real filename surfaces. `state` is a mutable holder // { inBlock, lang, fileId } so each caller keeps its own streaming context. function codeLiveStreamUpdate(fullReply, state) { if (typeof codeIsOpenable !== 'function') return; // Parse ALL code blocks using toggle: opening ```lang\n starts a block, // bare ``` closes it. This correctly pairs fences and excludes explanation // text between blocks. The model may split one file across multiple blocks // (e.g. 37 lines + explanation + continuation) β€” we concatenate all blocks // so the editor shows the complete program. // // CRITICAL: Only match ``` at the beginning of a line (with optional leading // whitespace). Without this check, ``` inside code (e.g. in comments like // `# ``` or strings) would be misinterpreted as a fence, causing the code // block to end prematurely and the editor to clear mid-stream. const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g; const blocks = []; let inBlock = false, blockLang = '', blockStart = 0, m; while ((m = fenceRe.exec(fullReply)) !== null) { // Require ``` at start of line (only whitespace before it) β€” prevents // false matches inside code (comments, strings, etc.) const lineStart = m.index === 0 ? 0 : fullReply.lastIndexOf('\n', m.index - 1) + 1; const prefix = fullReply.slice(lineStart, m.index); if (prefix.trim() !== '') continue; // ``` is mid-line, skip if (!inBlock) { blockLang = m[1] || 'text'; blockStart = m.index + m[0].length; inBlock = true; } else { blocks.push({ lang: blockLang, code: fullReply.slice(blockStart, m.index) }); inBlock = false; } } if (inBlock) { // Unclosed block β€” still streaming blocks.push({ lang: blockLang, code: fullReply.slice(blockStart), streaming: true }); } if (!blocks.length) { state.inBlock = false; return; } // Concatenate all code blocks (same or different language) let code = ''; let lang = blocks[blocks.length - 1].lang || 'text'; for (const block of blocks) { if (code) code += '\n'; code += block.code; if (block.lang && block.lang !== 'text') lang = block.lang; } code = code.replace(/\n+$/, ''); _DBG && console.log(`[codeLiveStream] blocks=${blocks.length} lines=${code.split('\n').length} inBlock=${state.inBlock} fileId=${state.fileId} lang=${lang}`); // ── Diff block handling ────────────────────────────────────────────── // When the language is 'diff', the block contains a unified diff that // should be applied to an existing file (not replace it entirely). if (lang === 'diff') { if (!state.inBlock) { state.inBlock = true; state.lang = lang; state.isDiff = true; // We'll accumulate the diff content and apply after streaming. // Try to find the filename from the diff header or filename comment. // A diff must target an existing file β€” prefer the file we sent as context // (the active tab), falling back to a name match. Deriving a name from the // diff body text picked the wrong/new file, so the diff never hit the open // file ("diffκ°€ 동일 νŒŒμΌμ—μ„œ μΌμ–΄λ‚˜μ§€ μ•ŠμŒ"). const fname = codeDeriveName(code, lang); const existing = (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId)) || codeFiles.find(f => f.name === fname) || codeFiles.find(f => f.id === activeCodeFileId); if (existing) { state.originalContent = existing.model ? existing.model.getValue() : existing.content; state.fileId = existing.id; state.diffAccumulated = code; switchCodeFile(existing.id); } else { // No matching file found β€” create a placeholder const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monacoReady ? monaco.editor.createModel(code, 'diff') : null; codeFiles.push({ id, name: fname || 'diff.patch', content: code, language: 'diff', model }); state.fileId = id; state.originalContent = ''; state.diffAccumulated = code; switchCodeFile(id); } } else if (state.isDiff && state.fileId) { // Continue accumulating diff content β€” keep the original file in the editor; // the diff will be applied when streaming completes (see codeLiveStreamUpdate end-of-stream) state.diffAccumulated = code; } return; } // ── Full code block handling ──────────────────────────────────────────── // Detect if the model accidentally wrapped a diff in a non-diff code fence // (e.g. ```python with +prefix/-prefix lines) β€” treat it as a diff block instead const _looksLikeDiff = (code) => { const lines = code.split('\n'); let diffLineCount = 0; for (const line of lines) { // Only count + and - lines (not space-prefixed context lines β€” those match Python indentation) if (/^[+\-] /.test(line) || line.startsWith('@@')) diffLineCount++; } // If more than 30% of non-empty lines look like diff markers, it's a diff const nonEmpty = lines.filter(l => l.trim()).length; return nonEmpty > 0 && diffLineCount / nonEmpty > 0.3; }; if (lang !== 'diff' && _looksLikeDiff(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; } // Compute simple line-level diff: find which lines in original are changed const origLines = originalText.split('\n'); const modLines = modifiedText.split('\n'); // Use a fast LCS-like comparison to find added/changed lines // While streaming, we only show "added/changed" lines in green. // We can't show deleted lines until streaming completes (we don't know // if the model will output them later or if they're truly deleted). const decorations = []; // Simple line-by-line diff: for each line in modified that differs from original let oi = 0, mi = 0; while (oi < origLines.length && mi < modLines.length) { if (origLines[oi] === modLines[mi]) { // Lines match β€” unchanged oi++; mi++; } else { // Check if this is an insertion (next orig line matches current mod line) let matchAhead = false; for (let look = 1; look <= 3 && oi + look < origLines.length; look++) { if (origLines[oi + look] === modLines[mi]) { // Lines ahead match β€” lines oi..oi+look-1 are deleted, modLines[mi] is context // Mark deleted lines (we'll skip this in streaming since we keep original) // Just advance pointers for (let k = 0; k < look; k++) { // Original lines that were removed β€” we can't show this during streaming // because the model might still output them } oi += look; matchAhead = true; break; } } if (!matchAhead) { // Check if this is a modification (mod line is new/changed) let matchBehind = false; for (let look = 1; look <= 3 && mi + look < modLines.length; look++) { if (origLines[oi] === modLines[mi + look]) { // Lines mi..mi+look-1 are new additions for (let k = 0; k < look; k++) { // We can't show added lines in the original editor during streaming // because we're keeping the original content } mi += look; matchBehind = true; break; } } if (!matchBehind) { // Both lines differ β€” this is a change oi++; mi++; } } } } // The approach above is too complex for real-time. Instead, use a simpler method: // Show a subtle "AI is editing..." indicator on lines that are different. // We do this by computing a quick diff and highlighting changed regions. // Quick diff: find the longest common subsequence alignment 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 // 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 // Copy untouched original lines up to where this hunk begins const hunkStart = Math.max(0, oldStart - 1); while (origPos < hunkStart && origPos < origLines.length) result.push(origLines[origPos++]); // Apply hunk body while (i < diffLines.length && !diffLines[i].startsWith('@@')) { const line = diffLines[i]; if (line.startsWith('\\')) { i++; continue; } // "\ No newline at end of file" if (line.startsWith('+++') || line.startsWith('---')) { i++; continue; } // file headers const tag = line[0]; if (tag === '+') { result.push(line.slice(1)); // added line } else if (tag === '-') { origPos++; // removed β€” consume original } else if (tag === ' ') { if (origPos < origLines.length) result.push(origLines[origPos]); else result.push(line.slice(1)); origPos++; // context β€” keep original text } else { // No-prefix line: treat as context when it matches the original, else literal if (origPos < origLines.length && origLines[origPos] === line) { result.push(origLines[origPos]); 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) function applySimpleDiff(originalContent, diffText) { const origLines = originalContent.split('\n'); const diffLines = diffText.split('\n'); const resultLines = []; let origIdx = 0; for (const line of diffLines) { if (line.startsWith('+') && !line.startsWith('+++')) { resultLines.push(line.slice(1)); } else if (line.startsWith('-') && !line.startsWith('---')) { origIdx++; // Skip original line } else if (line.startsWith(' ')) { // Context line resultLines.push(line.slice(1)); origIdx++; } else { // Plain line (no prefix) or header β€” skip diff headers if (!line.startsWith('diff ') && !line.startsWith('index ') && !line.startsWith('---') && !line.startsWith('+++')) { resultLines.push(line); } } } // Append any remaining original lines while (origIdx < origLines.length) { resultLines.push(origLines[origIdx]); origIdx++; } return resultLines.length > 0 ? resultLines.join('\n') : originalContent; } // Main v2-chat live streaming (used in the full app) β€” thin wrapper over the // shared parser with its own module-level state. function v2LiveCodeUpdate(fullReply) { codeLiveStreamUpdate(fullReply, _v2LiveState); } async function codeAiSend() { const input = document.getElementById('code-ai-input'); const message = input.value.trim(); if (!message) return; input.value = ''; input.style.height = ''; // Include current file context so the model knows what to edit (Code mode only) const currentFile = codeFiles.find(f => f.id === activeCodeFileId); let contextMsg = message; if (codeAiMode === 'code' && currentFile && monacoEditor) { const content = monacoEditor.getValue(); // Diff editing only makes sense against REAL existing code. A brand-new tab // seeded with a placeholder comment (e.g. "# 여기에 μ½”λ“œλ₯Ό μž‘μ„±ν•˜μ„Έμš”") is not // editable code β€” asking for a diff there makes the model emit a whole program // as +lines, which then got saved verbatim ("+κ°€ λΆ™μ–΄ λ‚˜μ˜΄"). Treat a file with // no substantive (non-comment, non-blank) lines as new β†’ request full code. const hasRealCode = content.split('\n').some(l => { const t = l.trim(); return t && !t.startsWith('#') && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*') && !t.startsWith('