diff --git a/package.json b/package.json
index 18cd59d..9cfe85f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "smallclaw",
- "version": "2.8.2",
+ "version": "2.9.0",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js",
"bin": {
diff --git a/web-ui/code.html b/web-ui/code.html
new file mode 100644
index 0000000..8c093de
--- /dev/null
+++ b/web-ui/code.html
@@ -0,0 +1,222 @@
+
+
+
+
+
+π» μ½λ μλν°
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
μ½λλ₯Ό μ ννκ³ "AIμκ² μ μ‘"μ λλ₯΄κ±°λ, μλμ μ§λ¬Έμ μ
λ ₯νμΈμ.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ π workspace/code/
+ βΊ
+
+
+
+
+
+
+
diff --git a/web-ui/code.js b/web-ui/code.js
new file mode 100644
index 0000000..c3a8e0a
--- /dev/null
+++ b/web-ui/code.js
@@ -0,0 +1,1253 @@
+// ============================================================================
+// 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 =>
+ `` +
+ `
${m.icon}` +
+ `
` +
+ `
`
+ ).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(/(?:#|\/\/|--|\/\*|