// ============================================================================
// 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 =>
``
).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('