// ============================================================================
// 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
// 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';
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 = 'Coder-' + label + 'μκ² μ§λ¬Έ...';
}
function initCodeView() {
updateCodeAiLabel();
if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
if (typeof require === 'undefined') return;
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
require(['vs/editor/editor.main'], function() {
monacoReady = true;
const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
monacoEditor = monaco.editor.create(document.getElementById('code-editor-container'), {
theme: isDark ? 'vs-dark' : 'vs',
language: 'plaintext',
automaticLayout: true,
fontSize: 14,
lineHeight: 22,
fontFamily: "'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) codeNewFile('main.py', '# μ¬κΈ°μ μ½λλ₯Ό μμ±νμΈμ\n');
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();
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);
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;
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;
return `
${escHtml(f.name)}
×
`;
}).join('');
}
const CODE_DIR = 'code';
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 = `${CODE_DIR}/${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/${CODE_DIR}/${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.
let v2LiveFileId = null, v2LiveLang = '', v2LiveInBlock = false;
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) {
_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) {
if (existing.model) existing.model.setValue(item.content);
else existing.content = 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 (!v2LiveFileId) v2LiveFileId = id;
}
}
codePendingQueue = [];
if (v2LiveFileId) switchCodeFile(v2LiveFileId);
}
function v2LiveCodeUpdate(fullReply) {
if (typeof codeIsOpenable !== 'function') return;
const fenceRe = /```([\w\-]*)\s*\n/g;
let lastOpen = null, m;
while ((m = fenceRe.exec(fullReply)) !== null) {
if (lastOpen === null) {
lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length };
} else {
lastOpen = null;
}
}
if (!lastOpen) { v2LiveInBlock = false; return; }
const code = fullReply.slice(lastOpen.start);
const lang = lastOpen.lang;
if (!v2LiveInBlock) {
if (!codeIsOpenable(code, lang)) return;
v2LiveInBlock = true;
v2LiveLang = lang;
const fname = codeDeriveName(code, lang);
if (monacoReady && monacoEditor) {
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 });
v2LiveFileId = id;
switchCodeFile(id);
} else {
// Monaco not ready yet β queue and trigger init
codePendingQueue = [{ fname, content: code, lang }];
initCodeView();
}
} else if (v2LiveFileId) {
const file = codeFiles.find(f => f.id === v2LiveFileId);
if (file?.model) {
codeMonacoThrottle(file.model, code);
}
} else if (codePendingQueue.length) {
// Monaco still loading β update queued content
codePendingQueue[codePendingQueue.length - 1].content = code;
}
}
async function codeAiSend() {
const input = document.getElementById('code-ai-input');
const message = input.value.trim();
if (!message) return;
input.value = ''; input.style.height = '';
codeAiAppend('user', message);
codeAiHistory.push({ role: 'user', content: message });
const thinkEl = codeAiAppend('thinking', 'μκ° μ€...');
try {
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
const historyForAPI = codeAiHistory.slice(-10, -1).map(m => ({ role: m.role, content: m.content }));
const res = await fetch('/api/chat', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
signal: codeAiAbort.signal,
body: JSON.stringify({ message, history: historyForAPI, useTools: true, sessionId: codeAiSessionId, ...(codeAiModel ? { model: codeAiModel } : {}) }),
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '', reply = '';
thinkEl.textContent = '';
thinkEl.className = 'code-ai-msg assistant';
// Live streaming state
let liveFileId = null, liveLang = '', liveCodeBuf = '', liveInBlock = false;
function codeAiLiveUpdate(fullReply) {
// Find the last unclosed code block
const fenceRe = /```([\w\-]*)\s*\n/g;
let lastOpen = null, lastClose = -1, m;
let pos = 0;
while ((m = fenceRe.exec(fullReply)) !== null) {
if (lastOpen === null) {
lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length };
} else {
lastClose = m.index;
lastOpen = null;
}
}
if (!lastOpen) { liveInBlock = false; return; }
// We're inside an open code block
const code = fullReply.slice(lastOpen.start);
const lang = lastOpen.lang;
if (!liveInBlock) {
// Wait until the block looks like a real file (not a short run-command snippet)
if (!codeIsOpenable(code, lang)) return;
liveInBlock = true;
liveLang = lang;
const fname = codeDeriveName(code, lang);
if (monacoReady && monacoEditor) {
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 });
liveFileId = id;
switchCodeFile(id);
} else {
codePendingQueue = [{ fname, content: code, lang }];
initCodeView();
}
} else if (liveFileId) {
const file = codeFiles.find(f => f.id === liveFileId);
if (file?.model) {
liveCodeBuf = code;
codeMonacoThrottle(file.model, code);
}
} else if (codePendingQueue.length) {
codePendingQueue[codePendingQueue.length - 1].content = code;
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') continue;
try {
const ev = JSON.parse(data);
let token = null;
if (ev.type === 'token' && (ev.content || ev.text)) token = ev.content || ev.text;
else if (ev.type === 'text_delta' && ev.delta) token = ev.delta;
else if (ev.type === 'final' && (ev.text || ev.content)) { reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
// Handle tool_call / tool_result events for live editor update
else if (ev.type === 'tool_call' && ev.action === 'create_file') {
const cf = ev.args?.filename || ev.args?.name || '';
const cc = ev.args?.content || '';
if (cf && cc) codeEditorWriteFile(cf.replace(/^code\//, ''), cc);
}
else if (ev.type === 'tool_result' && ev.action === 'create_file' && !ev.error) {
codeSbRefresh();
}
if (token) { reply += token; thinkEl.textContent = reply; codeAiScrollBottom(); codeAiLiveUpdate(reply); }
} catch {}
}
}
liveInBlock = false; liveFileId = null;
codeMonacoThrottleFlush();
codeAiHistory.push({ role: 'assistant', content: reply || '(μλ΅ μμ)' });
if (!reply) { thinkEl.textContent = '(μλ΅ μμ)'; }
else codeAiRenderMarkdown(thinkEl, reply);
} catch(e) {
thinkEl.className = 'code-ai-msg thinking';
thinkEl.textContent = 'μ€λ₯: ' + (e.message || e);
}
}
function codeAiAppend(role, text) {
const msgs = document.getElementById('code-ai-messages');
if (!msgs) return null;
const placeholder = msgs.querySelector('.code-ai-msg.thinking');
if (placeholder && placeholder.textContent.includes('AIμκ² μ μ‘')) placeholder.remove();
const el = document.createElement('div');
el.className = 'code-ai-msg ' + role;
el.textContent = text;
msgs.appendChild(el);
codeAiScrollBottom();
return el;
}
// Render AI message with markdown code blocks parsed out
function codeAiRenderMarkdown(el, text) {
el.innerHTML = '';
const parts = text.split(/(```[\w\-]*[ \t]*\r?\n[\s\S]*?```)/g);
let codeCount = 0;
const codeBlocks = [];
for (const part of parts) {
const m = part.match(/^```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```\s*$/);
if (m) {
const lang = m[1] || 'text';
const code = m[2];
codeBlocks.push({ lang, code });
const wrapper = document.createElement('div');
wrapper.className = 'code-ai-code-block';
const header = document.createElement('div');
header.className = 'code-ai-code-header';
const langLabel = document.createElement('span');
langLabel.textContent = lang || 'code';
const openBtn = document.createElement('button');
openBtn.className = 'code-ai-open-btn';
openBtn.textContent = 'π μλν°μμ μ΄κΈ°';
openBtn.onclick = () => codeAiOpenInEditor(code, lang);
header.appendChild(langLabel);
header.appendChild(openBtn);
const pre = document.createElement('pre');
pre.textContent = code;
wrapper.appendChild(header);
wrapper.appendChild(pre);
el.appendChild(wrapper);
codeCount++;
} else if (part) {
const span = document.createElement('span');
span.style.whiteSpace = 'pre-wrap';
span.textContent = part;
el.appendChild(span);
}
}
// Auto-open all code blocks in editor
if (codeBlocks.length) codeBlocks.forEach(b => codeAiOpenInEditor(b.code, b.lang));
}
const CODE_EXT_MAP = {
// JavaScript / Node
javascript:'js', js:'js', node:'js', nodejs:'js',
typescript:'ts', ts:'ts', jsx:'jsx', tsx:'tsx',
// Python
python:'py', py:'py', python3:'py',
// C family
c:'c', 'c++':'cpp', cpp:'cpp', 'c#':'cs', csharp:'cs', cs:'cs',
// JVM
java:'java', kotlin:'kt', kt:'kt', scala:'scala', groovy:'groovy',
// Systems / compiled
rust:'rs', rs:'rs', go:'go', golang:'go', zig:'zig',
// Web
html:'html', css:'css', scss:'scss', less:'less', vue:'vue', svelte:'svelte',
// Data / Config
json:'json', yaml:'yml', yml:'yml', toml:'toml', xml:'xml', csv:'csv',
// Shell
shell:'sh', bash:'sh', sh:'sh', zsh:'sh', fish:'fish', powershell:'ps1', ps1:'ps1',
// Database
sql:'sql', sqlite:'sql', postgresql:'sql', mysql:'sql',
// Mobile / Other
swift:'swift', dart:'dart', lua:'lua', perl:'pl', r:'r',
ruby:'rb', rb:'rb', php:'php', elixir:'ex', erlang:'erl',
haskell:'hs', ocaml:'ml', fsharp:'fs',
// Docs
markdown:'md', md:'md', tex:'tex', latex:'tex',
};
// Decide whether a markdown code block is a real file worth opening in the editor.
// Filters out shell run-instructions (e.g. ```python code/tetris.py```) and tiny snippets.
function codeIsOpenable(code, lang) {
const trimmed = (code || '').trim();
if (!trimmed) return false;
const lines = trimmed.split('\n').filter(l => l.trim());
const l = (lang || '').toLowerCase();
const isShellLang = ['', 'text', 'console', 'sh', 'bash', 'shell', 'zsh', 'powershell', 'ps1'].includes(l);
const cmdRe = /^\s*[$#>]?\s*(python3?|pip3?|npm|npx|node|yarn|pnpm|deno|bun|cd|ls|dir|cat|echo|curl|wget|git|chmod|mkdir|rm|cp|mv|source|sudo|export|set|run|\.\/)\b/i;
// Single line: skip shell langs and short command-looking lines
if (lines.length <= 1) {
if (isShellLang) return false;
if (cmdRe.test(trimmed)) return false;
if (trimmed.length < 40) return false;
}
// Short shell block where every line is a command β run instructions, not a file
if (isShellLang && lines.length <= 3 && lines.every(ln => cmdRe.test(ln))) return false;
return true;
}
// Derive a filename from an embedded `# filename:` hint, else a random one.
function codeDeriveName(code, lang) {
const m = (code || '').match(/(?:#|\/\/|--|\/\*|