// ============================================================================
// code.js β Standalone Code Editor page (code.html)
//
// This is a self-contained extract of the code-editor view that also lives
// inside the main app (web-ui/app.js). It is loaded ONLY by code.html so the
const _DBG = false; // set true to enable streaming/editor debug logs
// editor can run in its own browser tab (opened from the π» μ½λ sidebar tab),
// mirroring how the slide wizard opens pptx-wizard.html.
//
// The main app keeps its own in-page code mode (for live code streaming from
// the chat agent), so this file intentionally does not depend on app.js.
// If you change code-view behaviour, update both this file and app.js.
// ============================================================================
// ββ Shared helpers (self-contained copies of app.js utilities) ββββββββββββββ
function getAuthToken() { try { return sessionStorage.getItem('smallclaw_token') || localStorage.getItem('smallclaw_token') || ''; } catch { return ''; } }
function setAuthToken(t) { try { if (t) { sessionStorage.setItem('smallclaw_token', t); localStorage.setItem('smallclaw_token', t); } else { sessionStorage.removeItem('smallclaw_token'); } } catch {} }
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra || {}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"');
}
async function api(path, opts = {}) {
const API = window.location.origin || 'http://127.0.0.1:18789';
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
const r = await fetch(API + path, { headers, ...restOpts });
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
const ct = String(r.headers.get('content-type') || '').toLowerCase();
const bodyText = await r.text();
if (!ct.includes('application/json')) {
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`);
}
let data = null;
try { data = bodyText ? JSON.parse(bodyText) : null; }
catch { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`); }
if (!r.ok) { const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`; throw new Error(msg); }
return data;
}
// ββ Code Editor View βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
let monacoEditor = null;
let monacoReady = false;
let codeFiles = []; // [{id, name, content, language, model}]
let activeCodeFileId = null;
let codeAiHistory = [];
let codeAiAbort = null;
let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
let activeProjectName = localStorage.getItem('code_active_project') || null;
let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud';
let codeAiTech = localStorage.getItem('codeAiTech') ?? 'auto'; // 'auto' | 'html' | 'python' | 'node'
let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, resolved, rejected, onReject } }
const CODE_EXTS = new Set(['js','ts','jsx','tsx','py','html','htm','css','scss','java','c','cpp','cs','go','rs','php','rb','swift','kt','vue','svelte','json','yaml','yml','sh','sql']);
const isCodeFile = name => CODE_EXTS.has((name.split('.').pop() || '').toLowerCase());
const CODE_LANG_MAP = {
js:'javascript', ts:'typescript', jsx:'javascript', tsx:'typescript',
py:'python', rb:'ruby', go:'go', rs:'rust', java:'java', cs:'csharp',
cpp:'cpp', c:'c', php:'php', html:'html', css:'css', scss:'scss',
json:'json', yaml:'yaml', yml:'yaml', md:'markdown', sh:'shell',
bash:'shell', sql:'sql', xml:'xml', toml:'ini', vue:'html',
};
function codeDetectLang(name) {
const ext = (name || '').split('.').pop().toLowerCase();
return CODE_LANG_MAP[ext] || 'plaintext';
}
function updateCodeAiLabel() {
const label = codeAiModel || 'AI';
const title = document.getElementById('code-ai-title');
if (title) title.textContent = 'π» Coder-' + label;
const input = document.getElementById('code-ai-input');
if (input) input.placeholder = label + 'μκ² μ§λ¬Έ... (μ: κ²μ λ§λ€μ΄μ€)';
}
function setCodeAiTech(tech) {
codeAiTech = tech;
localStorage.setItem('codeAiTech', tech);
document.querySelectorAll('.code-ai-tech-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tech === tech);
});
updateCodeAiLabel();
}
function initCodeView() {
updateCodeAiLabel();
setCodeAiTech(codeAiTech); // Initialize tech 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) {
// In client-local + project mode: tabs always store bare filenames; the project
// subfolder prefix is handled transparently by _clientProjectPath at disk I/O time.
// In server mode with activeProjectName: prefix IS part of the tab name (existing behaviour).
let fname = name;
if (!fname) {
const raw = prompt('νμΌ μ΄λ¦:', 'untitled.py');
if (!raw || !raw.trim()) return;
fname = (localDirHandle && activeProjectName)
? raw.trim()
: (activeProjectName ? `${activeProjectName}/${raw.trim()}` : raw.trim());
} else if (localDirHandle && activeProjectName) {
// Strip project prefix so tab names are always bare in client-local mode
fname = _clientBareFileName(fname.trim());
}
fname = fname.trim();
if (!fname) return;
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const lang = codeDetectLang(fname);
const model = monacoReady ? monaco.editor.createModel(content || '', lang) : null;
codeFiles.push({ id, name: fname, content: content || '', language: lang, model });
switchCodeFile(id);
}
function switchCodeFile(id) {
const prev = codeFiles.find(f => f.id === activeCodeFileId);
if (prev && monacoEditor) prev.content = monacoEditor.getValue();
_DBG && console.log(`[switchCodeFile] ${activeCodeFileId} β ${id} file=${(codeFiles.find(f => f.id === id) || {}).name || '?'}`);
activeCodeFileId = id;
const file = codeFiles.find(f => f.id === id);
if (!file || !monacoEditor) { renderCodeFileTabs(); return; }
if (!file.model) file.model = monaco.editor.createModel(file.content, file.language);
// If this file has an active inline diff, re-apply decorations
if (codeDiffState[id]) {
monacoEditor.setModel(file.model);
monacoEditor.focus();
_showDiffBar(id);
_applyInlineDiffDecorations(id);
} else {
monacoEditor.setModel(file.model);
monacoEditor.focus();
}
renderCodeFileTabs();
codeUpdateRunButton();
}
function closeCodeFile(id, ev) {
if (ev) ev.stopPropagation();
const idx = codeFiles.findIndex(f => f.id === id);
if (idx === -1) return;
// Clean up diff view if active
if (codeDiffState[id]) cleanupCodeDiff(id);
const file = codeFiles[idx];
if (file.model) file.model.dispose();
codeFiles.splice(idx, 1);
if (!codeFiles.length) { codeNewFile('untitled.py', ''); return; }
if (activeCodeFileId === id)
switchCodeFile(codeFiles[Math.min(idx, codeFiles.length - 1)].id);
else
renderCodeFileTabs();
}
function renderCodeFileTabs() {
const bar = document.getElementById('code-file-tabs');
if (!bar) return;
bar.innerHTML = codeFiles.map(f => {
const active = f.id === activeCodeFileId;
const ds = codeDiffState[f.id];
const diff = !!ds;
const unresolved = diff ? ds.hunks.filter((_, i) => !ds.resolved.has(i)).length : 0;
return `
${unresolved > 0 ? `${unresolved}` : ''}
${escHtml(f.name)}
×
`;
}).join('');
}
let codeDirLabel = 'code';
// ββ Local file system (File System Access API) ββββββββββββββββββββββββββββ
let localDirHandle = null;
let localTermPath = localStorage.getItem('code_local_term_path') || '';
async function idbHandleGet() {
return new Promise(resolve => {
const req = indexedDB.open('code_editor_v1', 1);
req.onupgradeneeded = e => e.target.result.createObjectStore('handles');
req.onsuccess = e => {
const r = e.target.result.transaction('handles', 'readonly').objectStore('handles').get('dir');
r.onsuccess = () => resolve(r.result || null);
r.onerror = () => resolve(null);
};
req.onerror = () => resolve(null);
});
}
async function idbHandleSet(handle) {
return new Promise(resolve => {
const req = indexedDB.open('code_editor_v1', 1);
req.onupgradeneeded = e => e.target.result.createObjectStore('handles');
req.onsuccess = e => {
const tx = e.target.result.transaction('handles', 'readwrite');
tx.objectStore('handles').put(handle, 'dir');
tx.oncomplete = resolve;
tx.onerror = resolve;
};
req.onerror = resolve;
});
}
async function restoreLocalDirHandle() {
try {
const handle = await idbHandleGet();
if (!handle) return;
const perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm === 'granted') {
localDirHandle = handle;
updateLocalModeUI();
// Restore per-project AI session
if (activeProjectName) {
const savedId = localStorage.getItem('code_proj_session_' + activeProjectName);
const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName);
if (savedId) codeAiSessionId = savedId;
try { if (savedHist) codeAiHistory = JSON.parse(savedHist); } catch {}
// μ±ν
ν¨λ λ λλ§ (switchProjectμ λμΌ)
const _msgs = document.getElementById('code-ai-messages');
if (_msgs) {
_msgs.innerHTML = '';
if (codeAiHistory.length) {
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
} else {
_renderSessionsList();
}
}
}
codeSbRefresh();
}
} catch {}
}
async function openLocalFolder() {
if (!window.showDirectoryPicker) {
alert('μ΄ λΈλΌμ°μ λ File System Access APIλ₯Ό μ§μνμ§ μμ΅λλ€.\nChrome λλ Edgeλ₯Ό μ¬μ©ν΄μ£ΌμΈμ.');
return;
}
try {
localDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
await idbHandleSet(localDirHandle);
if (!localTermPath) {
const inp = document.getElementById('csb-termpath');
if (inp && !inp.value) inp.value = '';
}
updateLocalModeUI();
codeSbRefresh();
// Auto-load files if a project was already remembered
if (activeProjectName) _clientLoadProjectFiles();
} catch (e) {
if (e.name !== 'AbortError') codeAiAppend('system', 'β οΈ ν΄λ μ΄κΈ° μ€ν¨: ' + (e.message || e));
}
}
function updateLocalModeUI() {
const btn = document.getElementById('code-local-btn');
const lbl = document.getElementById('csb-folder-label');
const folderBtn = document.getElementById('code-folder-btn');
const folderName = localDirHandle ? (localDirHandle.name || 'λ‘컬') : 'ν΄λ μμ';
if (btn) {
btn.title = localDirHandle ? `π ${folderName} β ν΄λ¦ν΄μ λ³κ²½` : 'λ‘컬 ν΄λ μ΄κΈ°';
btn.style.color = localDirHandle ? '#22c55e' : '';
btn.textContent = localDirHandle ? 'π' : 'π₯οΈ';
btn.onclick = openLocalFolder;
}
if (lbl) lbl.textContent = localDirHandle ? folderName : 'ν΄λ μμ';
if (folderBtn) { const s = folderBtn.querySelector('span'); if (s) s.textContent = localDirHandle ? folderName : 'ν΄λ μμ'; }
const hdr = document.getElementById('code-header-dir');
if (hdr) hdr.textContent = localDirHandle ? (activeProjectName || folderName) : 'ν΄λ μμ';
const dd = document.getElementById('code-dropdown-dir');
if (dd) dd.textContent = localDirHandle ? `π ${folderName}` : 'π ν΄λ μμ';
}
async function localListFiles(dirHandle, prefix) {
const files = [];
try {
for await (const [name, handle] of dirHandle.entries()) {
if (name.startsWith('.') || name.endsWith('.crswap')) continue;
const rel = prefix ? `${prefix}/${name}` : name;
if (handle.kind === 'directory') files.push(...await localListFiles(handle, rel));
else files.push(rel);
}
} catch {}
return files.sort();
}
async function localReadFile(filename) {
if (!localDirHandle) return null;
try {
const parts = filename.split('/');
let dir = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i]);
const fh = await dir.getFileHandle(parts[parts.length - 1]);
return await (await fh.getFile()).text();
} catch { return null; }
}
async function localWriteFile(filename, content) {
if (!localDirHandle) return false;
try {
const parts = filename.split('/');
let dir = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i], { create: true });
const fh = await dir.getFileHandle(parts[parts.length - 1], { create: true });
const w = await fh.createWritable();
await w.write(content);
await w.close();
return true;
} catch (e) { console.error('[localWriteFile]', e); return false; }
}
async function localDeleteFile(filename) {
if (!localDirHandle) return false;
try {
const parts = filename.split('/');
let dir = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i]);
await dir.removeEntry(parts[parts.length - 1]);
return true;
} catch { return false; }
}
// ββ Client-local project path helpers ββββββββββββββββββββββββββββββββββββ
// "client-local" = browser File System Access API (localDirHandle).
// Distinguished from "server-local" = server's own workspace on disk.
// When client-local root + activeProjectName are both set, all disk I/O
// is scoped to the project subfolder. The model always sees bare filenames.
function _clientProjectPath(fname) {
if (!localDirHandle || !activeProjectName) return fname;
const bare = fname.startsWith(activeProjectName + '/')
? fname.slice(activeProjectName.length + 1) : fname;
return activeProjectName + '/' + bare;
}
function _clientBareFileName(fname) {
if (!activeProjectName) return fname;
return fname.startsWith(activeProjectName + '/')
? fname.slice(activeProjectName.length + 1) : fname;
}
// Close all open tabs that belong to the client-local project scope,
// then load every code file from the new activeProjectName subfolder.
async function _clientLoadProjectFiles() {
if (!localDirHandle || !activeProjectName) return;
// Close all existing tabs (they belonged to the previous project)
if (codeFiles.length) {
codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} });
codeFiles.length = 0;
activeCodeFileId = null;
codeDiffState = {};
renderCodeFileTabs();
}
// Ensure the session's directory exists on disk (new session β no folder yet)
await localDirHandle.getDirectoryHandle(activeProjectName, { create: true }).catch(() => {});
const allFiles = await localListFiles(localDirHandle, '');
const projFiles = allFiles.filter(f =>
f.startsWith(activeProjectName + '/') && isCodeFile(f)
);
for (const fpath of projFiles) {
const bare = _clientBareFileName(fpath);
const content = await localReadFile(fpath);
if (content === null || content.length > 300_000) continue; // skip missing/huge
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const lang = codeDetectLang(bare);
const model = monacoReady ? monaco.editor.createModel(content, lang) : null;
codeFiles.push({ id, name: bare, content, language: lang, model });
}
renderCodeFileTabs();
// Switch to first file if any
if (codeFiles.length) switchCodeFile(codeFiles[0].id);
}
// ββ Terminal output capture (for AI error feedback loop) βββββββββββββββββ
// Keeps a rolling plaintext buffer per shell (ANSI stripped). Used to detect
// errors after a run and offer one-click "AIμκ² μ μ‘" from the terminal.
const _termBuffers = {}; // shellId β string[] (last 300 lines)
const _TERM_BUF_MAX = 300;
const _ANSI_RE = /\x1b\[[0-9;]*[mGKHFABCDJnsuhl]/g;
function _termBufferAppend(shellId, raw) {
if (!shellId) return;
const text = raw.replace(_ANSI_RE, '').replace(/\r/g, '');
if (!text) return;
const lines = text.split('\n');
if (!_termBuffers[shellId]) _termBuffers[shellId] = [];
const buf = _termBuffers[shellId];
for (const l of lines) { if (l) buf.push(l); }
while (buf.length > _TERM_BUF_MAX) buf.shift();
_termDetectAndShowError(shellId);
}
const _ERROR_PATTERNS = [
/Traceback \(most recent call last\)/,
/(?:Syntax|Name|Type|Attribute|Import|Indentation|Value|Key|Index|Zero|Overflow|Runtime|OS|IO|File)Error:/,
/^\s*\^\s*$/, // Python caret indicator
/Uncaught (?:TypeError|ReferenceError|SyntaxError|Error):/,
/Error: .+\n?\s+at /,
/\[Error\]/i,
];
let _lastErrorShellId = null;
let _lastErrorText = '';
function _termDetectAndShowError(shellId) {
const buf = _termBuffers[shellId];
if (!buf || buf.length < 2) return;
const recent = buf.slice(-40).join('\n');
const hasError = _ERROR_PATTERNS.some(re => re.test(recent));
if (!hasError) return;
// Deduplicate β don't re-show if same error already triggered
if (_lastErrorShellId === shellId && _lastErrorText === recent) return;
_lastErrorShellId = shellId;
_lastErrorText = recent;
_showTerminalErrorButton(shellId, recent);
}
function _showTerminalErrorButton(shellId, errorText) {
// Remove old button if present
const old = document.getElementById('code-term-ai-error-btn');
if (old) old.remove();
const footer = document.querySelector('#code-terminal-panel .term-footer, #code-terminal-panel');
if (!footer) return;
const btn = document.createElement('button');
btn.id = 'code-term-ai-error-btn';
btn.textContent = 'π€ μλ¬ AIμκ² μ μ‘';
btn.style.cssText = 'position:absolute;bottom:4px;right:90px;z-index:10;background:#dc2626;color:#fff;border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;font-weight:600;animation:pulse-red 1.5s infinite';
btn.onclick = () => { _sendTerminalErrorToAI(errorText); btn.remove(); };
footer.style.position = 'relative';
footer.appendChild(btn);
}
function _sendTerminalErrorToAI(errorText) {
const input = document.getElementById('code-ai-input');
if (!input) return;
const trimmed = errorText.split('\n').filter(l => l.trim()).slice(-25).join('\n');
input.value = 'λ€μ μλ¬λ₯Ό κ³ μ³μ€. edit_file λκ΅¬λ‘ νμΌμ μ§μ μμ ν΄:\n```\n' + trimmed + '\n```';
input.style.height = '';
input.dispatchEvent(new Event('input'));
// Auto-send
codeAiSend();
}
// Called when a run completes successfully (exit code 0) β clear error button
function _termOnRunSuccess(shellId) {
_lastErrorShellId = null;
_lastErrorText = '';
const btn = document.getElementById('code-term-ai-error-btn');
if (btn) btn.remove();
}
// ββ Companion WebSocket (local Python execution) βββββββββββββββββββββββββββ
let companionWs = null;
let companionConnected = false;
const COMPANION_URL = 'ws://127.0.0.1:9000';
let companionShellId = null; // code terminal shell ID backed by companion PTY
const localShellIds = new Set(); // shell IDs routed to companion instead of server PTY
function companionConnect() {
if (companionWs && (companionWs.readyState === WebSocket.OPEN || companionWs.readyState === WebSocket.CONNECTING)) return;
try { companionWs = new WebSocket(COMPANION_URL); } catch { return; }
companionWs.onopen = () => { companionConnected = true; updateCompanionStatus(true); };
companionWs.onclose = () => {
companionConnected = false; companionWs = null;
companionShellId = null; // μ¬μ°κ²° μ μ΄μ μ
Έ μν μ΄κΈ°ν
updateCompanionStatus(false);
setTimeout(companionConnect, 5000);
};
companionWs.onerror = () => { companionConnected = false; };
companionWs.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
handleCompanionMessage(msg);
};
}
function updateCompanionStatus(connected) {
const dot = document.getElementById('companion-dot');
if (!dot) return;
dot.style.background = connected ? '#22c55e' : '#6b7280';
dot.title = connected ? 'π Python companion μ°κ²°λ¨' : 'π Python companion λ―Έμ°κ²° (companion.py μ€ν νμ) β ν΄λ¦ν΄μ μ¬μ°κ²°';
const lsBtn = document.getElementById('local-shell-btn');
if (lsBtn) lsBtn.style.color = connected ? '#22c55e' : '#6b7280';
}
function handleCompanionMessage(msg) {
// shell_* messages β local shell tab; run messages β active code terminal
const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit';
const targetId = isShellMsg && companionShellId ? companionShellId : activeCodeTermShellId;
if (!codeTermOpen) toggleCodeTerminal();
setTimeout(() => {
const s = codeTermShells.find(s => s.id === targetId) || codeTermShells.find(s => s.id === activeCodeTermShellId);
if (!s || !s.term) return;
if (msg.type === 'shell_ready') s.term.write('\x1b[32mπ₯οΈ λ‘컬 μ
Έ\x1b[0m\r\n');
else if (msg.type === 'shell_output') s.term.write(msg.data);
else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[μ
Έ μ’
λ£ ${msg.code}]\x1b[0m\r\n`); companionShellId = null; }
else if (msg.type === 'started') s.term.write(`\r\n\x1b[32mβΆ ${msg.filename}\x1b[0m\r\n`);
else if (msg.type === 'output') { s.term.write(msg.data.replace(/(? f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
// A pending AI diff keeps the FULL modified text live in the model, so saving
// now would persist hunks the user hasn't decided on yet. Make them resolve first.
if (codeDiffState[activeCodeFileId]) {
codeAiAppend('system', 'β οΈ AI μ μ λ³κ²½μ΄ κ²ν λκΈ° μ€μ
λλ€. λ¨Όμ β
/βλ‘ μ μ©νκ±°λ μ·¨μν λ€ μ μ₯νμΈμ.');
return;
}
file.content = monacoEditor.getValue();
if (!localDirHandle) { codeAiAppend('system', 'β οΈ ν΄λλ₯Ό λ¨Όμ μ νν΄μ£ΌμΈμ (π₯οΈ λ²νΌ)'); return; }
const ok = await localWriteFile(_clientProjectPath(file.name), file.content);
codeAiAppend('system', ok ? `πΎ μ μ₯: ${file.name}` : 'β οΈ μ μ₯ μ€ν¨');
if (ok) codeSbRefresh();
}
// ββ Terminal infrastructure (PTY over WebSocket) βββββββββββββββββββββββββ
let termWs = null;
let termWsConnected = false;
function termWsConnect() {
if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let url = `${proto}//${location.host}/ws`;
const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session');
if (token) url += `?token=${encodeURIComponent(token)}`;
try { termWs = new WebSocket(url); } catch { return; }
termWs.onopen = () => {
termWsConnected = true;
for (const [shellId, inst] of xtermInstances) {
if (!inst.ptyReady && !localShellIds.has(shellId)) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); }
}
};
termWs.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) { i.term.write(msg.data); _termBufferAppend(msg.shellId, msg.data); } }
else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); if (msg.code === 0) _termOnRunSuccess(msg.shellId); } }
else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); }
};
termWs.onclose = () => {
termWsConnected = false; termWs = null;
for (const [,i] of xtermInstances) i.ptyReady = false;
setTimeout(termWsConnect, 3000);
};
termWs.onerror = () => { termWsConnected = false; };
}
function termWsSend(msg) {
if (termWs && termWs.readyState === WebSocket.OPEN) { termWs.send(JSON.stringify(msg)); return true; }
return false;
}
const xtermInstances = new Map();
function createXtermShell(container, shellId) {
const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8);
const sub = document.createElement('div');
sub.id = 'xterm-' + id;
sub.style.cssText = 'position:absolute;inset:0';
container.appendChild(sub);
const term = new Terminal({
theme: { background:'#0C0C0C', foreground:'#CCCCCC', cursor:'#22c55e',
selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C',
yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC',
brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' },
fontFamily: "'IBM Plex Mono', 'D2Coding', 'NanumGothicCoding', 'Malgun Gothic', 'AppleGothic', monospace",
fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000,
});
const fitAddon = new FitAddon.FitAddon();
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(fitAddon); term.loadAddon(webLinksAddon);
term.open(sub); fitAddon.fit();
const inst = { term, fitAddon, ptyReady: false };
xtermInstances.set(id, inst);
term.onData(data => {
if (localShellIds.has(id)) {
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', data }));
} else if (termWsConnected && inst.ptyReady) {
termWsSend({ type: 'pty_input', shellId: id, data });
}
});
term.onResize(({ cols, rows }) => {
if (localShellIds.has(id)) {
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'resize', cols, rows }));
} else if (termWsConnected && inst.ptyReady) {
termWsSend({ type: 'pty_resize', shellId: id, cols, rows });
}
});
if (!localShellIds.has(id) && termWsConnected) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows }); }
return { id, term, fitAddon, inst };
}
// Init WebSocket connection on load
fetch('/api/status').then(r => r.json()).catch(() => ({})).finally(termWsConnect);
// ββ Chat terminal (for chat view) βββββββββββββββββββββββββββββββββββββββββ
let chatTermShells = [];
let activeChatTermShellId = null;
let chatTermOpen = false;
let chatTermHeight = 280;
function toggleChatTerminal() {
const panel = document.getElementById('chat-terminal-panel');
const btn = document.getElementById('chat-term-btn');
if (!panel) return;
chatTermOpen = !chatTermOpen;
if (chatTermOpen) {
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
if (!chatTermShells.length) { addChatTermShell(); }
else { setTimeout(() => { const s = chatTermShells.find(s => s.id === activeChatTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); }
} else {
panel.style.display = 'none';
if (btn) btn.classList.remove('active');
}
}
function renderChatShellTabs() {
const bar = document.getElementById('chat-shell-tabs'); if (!bar) return;
bar.innerHTML = chatTermShells.map(s => {
const a = s.id === activeChatTermShellId;
return `${escHtml(s.name)}${chatTermShells.length>1?`
×`:''}
`;
}).join('');
}
function addChatTermShell() {
const id = 'ct_' + Math.random().toString(36).slice(2,8);
chatTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null });
switchChatTermShell(id);
}
function switchChatTermShell(id) {
const cur = chatTermShells.find(s => s.id === activeChatTermShellId);
if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; }
activeChatTermShellId = id;
const shell = chatTermShells.find(s => s.id === id); if (!shell) return;
if (shell.term) {
const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display='';
if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus();
} else {
const container = document.getElementById('chat-term-container');
if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); }
}
renderChatShellTabs();
}
function closeChatTermShell(id, ev) {
if (ev) ev.stopPropagation();
const idx = chatTermShells.findIndex(s => s.id === id);
if (idx === -1 || chatTermShells.length <= 1) return;
termWsSend({ type:'pty_kill', shellId:id });
const shell = chatTermShells[idx]; if (shell.term) shell.term.dispose();
xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove();
chatTermShells.splice(idx, 1);
if (activeChatTermShellId === id) switchChatTermShell(chatTermShells[Math.min(idx,chatTermShells.length-1)].id);
else renderChatShellTabs();
}
document.addEventListener('DOMContentLoaded', function() {
const handle = document.getElementById('chat-term-resize-handle'); if (!handle) return;
let startY=0, startH=0;
handle.addEventListener('mousedown', function(e) {
startY=e.clientY; startH=chatTermHeight;
const onMove = e2 => { const dy=startY-e2.clientY; chatTermHeight=Math.max(120,Math.min(window.innerHeight*.7,startH+dy)); const p=document.getElementById('chat-terminal-panel'); if(p) p.style.height=chatTermHeight+'px'; const s=chatTermShells.find(s=>s.id===activeChatTermShellId); if(s?.fitAddon) s.fitAddon.fit(); };
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault();
});
});
// ββ Code editor terminal ββββββββββββββββββββββββββββββββββββββββββββββββββ
let codeTermShells = [];
let activeCodeTermShellId = null;
let codeTermOpen = false;
function toggleCodeTerminal() {
const panel = document.getElementById('code-term-panel');
const btn = document.getElementById('code-term-toggle-btn');
if (!panel) return;
codeTermOpen = !codeTermOpen;
if (codeTermOpen) {
panel.style.display = 'flex';
if (btn) btn.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;
const label = s.local ? 'π₯οΈ λ‘컬' : 'bash';
return `
${label}${codeTermShells.length>1?`
×`:''}
`;
}).join('');
}
function addLocalShell() {
if (!companionConnected) {
alert('companion.pyκ° μ€νλμ§ μμμ΅λλ€.\n\nν°λ―Έλμμ: python companion.py');
return;
}
const id = 'cdt_' + Math.random().toString(36).slice(2, 8);
codeTermShells.push({ id, name: 'λ‘컬', local: true, ptyReady: false, term: null, fitAddon: null });
localShellIds.add(id);
if (companionShellId) companionKill();
companionShellId = id;
if (!codeTermOpen) toggleCodeTerminal();
switchCodeTermShell(id);
setTimeout(() => {
const shell = codeTermShells.find(s => s.id === id);
if (companionConnected && companionWs && shell?.term) {
if (shell.fitAddon) shell.fitAddon.fit();
const rows = shell.term.rows > 0 ? shell.term.rows : 24;
const cols = shell.term.cols > 0 ? shell.term.cols : 80;
companionWs.send(JSON.stringify({ type: 'shell', rows, cols }));
}
}, 300);
}
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);
if (localTermPath) {
setTimeout(() => {
const s = codeTermShells.find(s => s.id === id);
if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` });
else setTimeout(() => {
const s2 = codeTermShells.find(s => s.id === id);
if (s2 && s2.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` });
}, 800);
}, 400);
}
}
function csbTermPathSave() {
const inp = document.getElementById('csb-termpath');
localTermPath = (inp?.value || '').trim();
localStorage.setItem('code_local_term_path', localTermPath);
codeAiAppend('system', localTermPath ? `β
ν°λ―Έλ κ²½λ‘ μ€μ : ${localTermPath}` : 'ν°λ―Έλ κ²½λ‘ μ΄κΈ°νλ¨');
}
function switchCodeTermShell(id) {
const cur = codeTermShells.find(s => s.id === activeCodeTermShellId);
if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; }
activeCodeTermShellId = id;
const shell = codeTermShells.find(s => s.id === id); if (!shell) return;
if (shell.term) {
const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display='';
if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus();
} else {
const container = document.getElementById('code-term-container');
if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); }
}
renderCodeTermTabs();
}
function closeCodeTermShell(id, ev) {
if (ev) ev.stopPropagation();
const idx = codeTermShells.findIndex(s => s.id === id);
if (idx === -1 || codeTermShells.length <= 1) return;
if (localShellIds.has(id)) {
localShellIds.delete(id);
if (companionShellId === id) { companionKill(); companionShellId = null; }
} else {
termWsSend({ type:'pty_kill', shellId:id });
}
const shell = codeTermShells[idx]; if (shell.term) shell.term.dispose();
xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove();
codeTermShells.splice(idx, 1);
if (activeCodeTermShellId === id) switchCodeTermShell(codeTermShells[Math.min(idx,codeTermShells.length-1)].id);
else renderCodeTermTabs();
}
// Resize drag (top edge β pull up/down)
document.addEventListener('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: 'companion', icon: 'π₯οΈ', label: 'λ‘컬 μ€ν', desc: 'companion.py (pygame μ§μ)' },
{ 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);
}
// Resolve and `);
}
}
const blob = new Blob([html], { type: 'text/html' });
return URL.createObjectURL(blob);
}
function codeRunWith(mode) {
const menu = document.getElementById('code-run-menu');
if (menu) menu.style.display = 'none';
_codeRunMenuOpen = false;
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
// Don't run while an AI diff is pending β the model holds undecided changes.
if (codeDiffState[activeCodeFileId]) {
codeAiAppend('system', 'β οΈ AI μ μ λ³κ²½μ΄ κ²ν λκΈ° μ€μ
λλ€. λ¨Όμ β
/βλ‘ μ μ©νκ±°λ μ·¨μν λ€ μ€ννμΈμ.');
return;
}
file.content = monacoEditor.getValue();
const fpath = `${codeDirLabel}/${file.name}`;
if (mode === 'browser') {
const ext = file.name.split('.').pop().toLowerCase();
if ((ext === 'html' || ext === 'htm') && localDirHandle) {
// Inline linked CSS and JS so the blob URL can resolve them
_openHtmlWithInlinedAssets(file).then(url => {
const w = window.open(url, '_blank');
if (!w) codeAiAppend('system', 'β οΈ νμ
μ°¨λ¨λ¨ β λΈλΌμ°μ μμ νμ
μ νμ©ν΄μ£ΌμΈμ');
setTimeout(() => URL.revokeObjectURL(url), 120000);
});
} else {
const mime = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : 'text/plain';
const blob = new Blob([file.content], { type: mime });
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank');
if (!w) codeAiAppend('system', 'β οΈ νμ
μ°¨λ¨λ¨ β λΈλΌμ°μ μμ νμ
μ νμ©ν΄μ£ΌμΈμ');
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
return;
}
if (mode === 'companion') {
if (!companionConnected) {
codeAiAppend('system', 'β οΈ companion.pyκ° μ€νλμ§ μμμ΅λλ€. λ‘컬μμ python companion.py λ₯Ό μ€ννμΈμ.');
return;
}
if (!codeTermOpen) toggleCodeTerminal();
setTimeout(() => {
companionWs.send(JSON.stringify({ type: 'run', code: file.content, filename: file.name }));
}, codeTermOpen ? 0 : 500);
return;
}
if (mode === 'pyodide') {
sessionStorage.setItem('py_run_code', file.content);
sessionStorage.setItem('py_run_name', file.name);
const w = window.open('/python-runner.html', '_blank');
if (!w) codeAiAppend('system', 'β οΈ νμ
μ°¨λ¨λ¨ β λΈλΌμ°μ μμ νμ
μ νμ©ν΄μ£ΌμΈμ');
return;
}
// terminal modes
const ext = file.name.split('.').pop().toLowerCase();
let cmd;
if (mode === 'terminal') {
cmd = ext === 'py' ? `python3 ${fpath}` :
ext === 'js' ? `node ${fpath}` :
ext === 'ts' ? `npx ts-node ${fpath}` :
ext === 'go' ? `go run ${fpath}` :
ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'sh' ? `bash ${fpath}` : `./${fpath}`;
} else {
cmd = mode === 'python3' ? `python3 ${fpath}` :
mode === 'node' ? `node ${fpath}` : `${mode} ${fpath}`;
}
if (!codeTermOpen) toggleCodeTerminal();
setTimeout(() => {
const s = codeTermShells.find(s => s.id === activeCodeTermShellId);
if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: s.id, data: cmd + '\r' });
}, codeTermOpen ? 0 : 500);
}
function codeSendSelectionToAI() {
if (!monacoEditor) return;
const sel = monacoEditor.getSelection();
const selected = sel && !sel.isEmpty() ? monacoEditor.getModel().getValueInRange(sel) : monacoEditor.getValue();
const file = codeFiles.find(f => f.id === activeCodeFileId);
const lang = file ? file.language : 'code';
const prompt = `λ€μ ${lang} μ½λλ₯Ό λΆμν΄μ€:\n\`\`\`${lang}\n${selected}\n\`\`\``;
document.getElementById('code-ai-input').value = prompt;
codeAiSend();
}
// βββ V2 Chat Live Code Streaming ββββββββββββββββββββββββββββββββββββββββββββββββ
// Global state for streaming code into the editor during the main v2 chat.
const _v2LiveState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null };
let codePendingQueue = []; // [{fname, content, lang}] β queued when Monaco not ready
// Throttled Monaco model update β avoids freezing UI by limiting setValue calls to ~80ms intervals
let _monacoThrottleTimer = null;
let _monacoThrottleModel = null;
let _monacoThrottleValue = '';
function codeMonacoThrottle(model, value) {
_monacoThrottleModel = model;
_monacoThrottleValue = value;
if (_monacoThrottleTimer) return; // already scheduled
_monacoThrottleTimer = setTimeout(() => {
_monacoThrottleTimer = null;
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
const _lines = _monacoThrottleValue.split('\n').length;
_DBG && console.log(`[codeMonacoThrottle] setValue: ${_lines} lines`);
_monacoThrottleModel.setValue(_monacoThrottleValue);
// Don't null _monacoThrottleModel here β keep it alive for codeMonacoThrottleFlush.
// The timer callback applies the latest throttled value, but a later flush
// (called when streaming ends) must still be able to apply any final value
// that arrived after this timer fired.
}
}, 80);
}
function codeMonacoThrottleFlush() {
if (_monacoThrottleTimer) { clearTimeout(_monacoThrottleTimer); _monacoThrottleTimer = null; }
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
_monacoThrottleModel.setValue(_monacoThrottleValue);
}
_monacoThrottleModel = null;
}
function codeFlushPendingQueue() {
if (!monacoReady || !monacoEditor || !codePendingQueue.length) return;
for (const item of codePendingQueue) {
const existing = codeFiles.find(f => f.name === item.fname);
if (existing) {
const oldContent = existing.model ? existing.model.getValue() : existing.content;
if (oldContent !== item.content) {
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, item.content);
}
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname));
codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model });
if (!_v2LiveState.fileId) _v2LiveState.fileId = id;
}
}
codePendingQueue = [];
if (_v2LiveState.fileId) switchCodeFile(_v2LiveState.fileId);
}
// ββ Shared live-streaming parser ββββββββββββββββββββββββββββββββββββββββββββ
// Detects the last unclosed ```code block in a streaming reply and mirrors it
// into the editor: creates/reuses a tab, throttles model updates, and renames
// the tab once a real filename surfaces. `state` is a mutable holder
// { inBlock, lang, fileId } so each caller keeps its own streaming context.
function codeLiveStreamUpdate(fullReply, state) {
if (typeof codeIsOpenable !== 'function') return;
// Parse ALL code blocks using toggle: opening ```lang\n starts a block,
// bare ``` closes it. This correctly pairs fences and excludes explanation
// text between blocks. The model may split one file across multiple blocks
// (e.g. 37 lines + explanation + continuation) β we concatenate all blocks
// so the editor shows the complete program.
//
// CRITICAL: Only match ``` at the beginning of a line (with optional leading
// whitespace). Without this check, ``` inside code (e.g. in comments like
// `# ``` or strings) would be misinterpreted as a fence, causing the code
// block to end prematurely and the editor to clear mid-stream.
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
const blocks = [];
let inBlock = false, blockLang = '', blockStart = 0, m;
while ((m = fenceRe.exec(fullReply)) !== null) {
// Require ``` at start of line (only whitespace before it) β prevents
// false matches inside code (comments, strings, etc.)
const lineStart = m.index === 0 ? 0 : fullReply.lastIndexOf('\n', m.index - 1) + 1;
const prefix = fullReply.slice(lineStart, m.index);
if (prefix.trim() !== '') continue; // ``` is mid-line, skip
if (!inBlock) {
blockLang = m[1] || 'text';
blockStart = m.index + m[0].length;
inBlock = true;
} else {
blocks.push({ lang: blockLang, code: fullReply.slice(blockStart, m.index) });
inBlock = false;
}
}
if (inBlock) {
// Unclosed block β still streaming
blocks.push({ lang: blockLang, code: fullReply.slice(blockStart), streaming: true });
}
if (!blocks.length) { state.inBlock = false; return; }
// Concatenate all code blocks (same or different language)
let code = '';
let lang = blocks[blocks.length - 1].lang || 'text';
for (const block of blocks) {
if (code) code += '\n';
code += block.code;
if (block.lang && block.lang !== 'text') lang = block.lang;
}
code = code.replace(/\n+$/, '');
_DBG && console.log(`[codeLiveStream] blocks=${blocks.length} lines=${code.split('\n').length} inBlock=${state.inBlock} fileId=${state.fileId} lang=${lang}`);
// ββ Diff block handling ββββββββββββββββββββββββββββββββββββββββββββββ
// When the language is 'diff', the block contains a unified diff that
// should be applied to an existing file (not replace it entirely).
if (lang === 'diff') {
if (!state.inBlock) {
state.inBlock = true;
state.lang = lang;
state.isDiff = true;
// We'll accumulate the diff content and apply after streaming.
// Priority: explicit filename in diff > active tab.
// The model names the file it's editing in the diff header or filename comment β
// that explicit name wins so the right tab gets focused automatically.
const fname = codeDeriveName(code, lang)
|| code.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)(?:\t|$)/m)?.[1]?.trim();
const fnameFile = fname ? (
codeFiles.find(f => f.name === fname) ||
codeFiles.find(f => f.name.endsWith('/' + fname))
) : null;
const existing = fnameFile
|| (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId))
|| codeFiles.find(f => f.id === activeCodeFileId);
if (existing) {
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
state.fileId = existing.id;
state.diffAccumulated = code;
switchCodeFile(existing.id);
} else {
// No matching file found β create a placeholder
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monacoReady ? monaco.editor.createModel(code, 'diff') : null;
codeFiles.push({ id, name: fname || 'diff.patch', content: code, language: 'diff', model });
state.fileId = id;
state.originalContent = '';
state.diffAccumulated = code;
switchCodeFile(id);
}
} else if (state.isDiff && state.fileId) {
// Continue accumulating diff content β keep the original file in the editor;
// the diff will be applied when streaming completes (see codeLiveStreamUpdate end-of-stream)
state.diffAccumulated = code;
}
return;
}
// ββ Full code block handling ββββββββββββββββββββββββββββββββββββββββββββ
// Detect if the model accidentally wrapped a diff in a non-diff code fence
// (e.g. ```python with +prefix/-prefix lines) β treat it as a diff block instead.
// Reuses _isDiffCode (the same +/-/@@ ratio heuristic).
if (lang !== 'diff' && _isDiffCode(lang, code)) {
// Model output diff content inside a non-diff code fence β redirect to diff handling
state.inBlock = true;
state.lang = 'diff';
state.isDiff = true;
// Diff content wrapped in a non-diff fence β target the active/context file too.
const fname = codeDeriveName(code, lang);
const existing = (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId))
|| codeFiles.find(f => f.name === fname)
|| codeFiles.find(f => f.id === activeCodeFileId);
if (existing) {
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
state.fileId = existing.id;
state.diffAccumulated = code;
switchCodeFile(existing.id);
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fname)) : null;
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
state.fileId = id;
state.originalContent = '';
state.diffAccumulated = code;
switchCodeFile(id);
}
return;
}
if (!state.inBlock) {
// Wait until the block looks like a real file (not a short run-command snippet)
if (!codeIsOpenable(code, lang)) return;
state.inBlock = true;
state.lang = lang;
const fname = codeDeriveName(code, lang);
_DBG && console.log(`[codeLiveStream] NEW BLOCK: fname=${fname} lines=${code.split('\n').length}`);
if (monacoReady && monacoEditor) {
// Reuse existing tab with same name instead of creating a duplicate
const existing = codeFiles.find(f => f.name === fname);
if (existing) {
// Save original content so we can show a diff after streaming completes
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
state.fileId = existing.id;
// If editing existing file: keep original in editor, show streamed changes as decorations
if (state.originalContent && state.originalContent.trim()) {
state.streamingModifiedText = code;
_updateStreamingDiffDecorations(state.fileId, state.originalContent, code);
} else {
switchCodeFile(existing.id);
}
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(code, codeDetectLang(fname));
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
state.fileId = id;
switchCodeFile(id);
}
} else {
// Monaco not ready yet β queue and trigger init
codePendingQueue = [{ fname, content: code, lang }];
initCodeView();
}
} else if (state.fileId && !state.isDiff) {
_DBG && console.log(`[codeLiveStream] UPDATE: fileId=${state.fileId} lines=${code.split('\n').length} origContent=${!!state.originalContent} diff=${state.isDiff}`);
const file = codeFiles.find(f => f.id === state.fileId);
if (file?.model) {
// If editing existing file with original content: stream as diff decorations
if (state.originalContent && state.originalContent.trim()) {
state.streamingModifiedText = code;
// Keep the original text in the editor β update streaming diff decorations
_updateStreamingDiffDecorations(state.fileId, state.originalContent, code);
// Don't call codeMonacoThrottle here β we keep original in editor
} else {
codeMonacoThrottle(file.model, code);
}
// Rename tab if a filename comment appears later in the stream
const betterName = codeDeriveName(code, state.lang);
if (betterName && !betterName.startsWith('ai_') && file.name.startsWith('ai_')) {
file.name = betterName;
file.language = codeDetectLang(betterName);
renderCodeFileTabs();
}
}
} else if (codePendingQueue.length) {
// Monaco still loading β update queued content
codePendingQueue[codePendingQueue.length - 1].content = code;
}
}
// ββ Real-time streaming diff decorations ββββββββββββββββββββββββββββββββββ
// While the model streams code, compare original vs streamed content and
// highlight changed/added lines in green β without replacing the editor content.
// This is the VSCode Copilot approach: keep original, show changes as overlay.
// Throttled streaming diff to avoid excessive decoration updates
let _streamingDiffTimer = null;
let _streamingDiffFileId = null;
let _streamingDiffOriginal = '';
let _streamingDiffModified = '';
function _updateStreamingDiffDecorations(fileId, originalText, modifiedText) {
_streamingDiffFileId = fileId;
_streamingDiffOriginal = originalText;
_streamingDiffModified = modifiedText;
if (_streamingDiffTimer) return; // already scheduled
_streamingDiffTimer = setTimeout(() => {
_streamingDiffTimer = null;
_applyStreamingDiffDecorations(_streamingDiffFileId, _streamingDiffOriginal, _streamingDiffModified);
}, 120);
}
function _applyStreamingDiffDecorations(fileId, originalText, modifiedText) {
if (!monacoReady || !monacoEditor || !window.monaco) return;
const file = codeFiles.find(f => f.id === fileId);
if (!file || file.id !== activeCodeFileId) return;
// Make sure editor still shows original content
const currentContent = file.model ? file.model.getValue() : file.content;
if (currentContent !== originalText) {
// Editor was changed externally β don't override
return;
}
// Highlight changed regions while keeping the original text in the editor.
// (We can't reliably place added/deleted lines until streaming completes, so we
// only color the affected lines.)
const origLines = originalText.split('\n');
const hunks = computeDiffHunks(originalText, modifiedText);
if (!hunks.length) {
// No changes yet β remove any streaming decorations
if (file.model && file._streamingDecos) {
file.model.deltaDecorations(file._streamingDecos, []);
file._streamingDecos = null;
}
return;
}
// We're keeping original text in editor. Show decorations on lines that have changes.
// For "change" hunks: highlight the original lines that will be modified
// For "add" hunks: highlight the line before where additions will appear
// For "delete" hunks: highlight lines that will be removed with strikethrough-like style
const streamingDecos = [];
for (const hunk of hunks) {
if (hunk.type === 'delete') {
// Lines that will be removed
for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) {
streamingDecos.push({
range: new monaco.Range(line, 1, line, 1),
options: {
className: 'inline-diff-streaming-delete',
glyphMarginClassName: 'inline-diff-glyph-delete',
isWholeLine: true,
minimap: { color: '#dc354580', position: monaco.editor.MinimapPosition.Inline },
overviewRuler: { color: '#dc354560', position: monaco.editor.OverviewRulerLane.Full },
}
});
}
} else if (hunk.type === 'add') {
// New lines will appear after oldStart β highlight the line before
const insertAfter = Math.max(1, hunk.oldStart - 1);
streamingDecos.push({
range: new monaco.Range(insertAfter, 1, insertAfter, 1),
options: {
className: 'inline-diff-streaming-add-marker',
isWholeLine: true,
}
});
} else {
// Change: old lines will be replaced
for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) {
streamingDecos.push({
range: new monaco.Range(line, 1, line, 1),
options: {
className: 'inline-diff-streaming-change',
glyphMarginClassName: 'inline-diff-glyph-change',
isWholeLine: true,
minimap: { color: '#ffc10780', position: monaco.editor.MinimapPosition.Inline },
overviewRuler: { color: '#ffc10760', position: monaco.editor.OverviewRulerLane.Full },
}
});
}
}
}
// Apply streaming decorations
const oldDecos = file._streamingDecos || [];
file._streamingDecos = file.model.deltaDecorations(oldDecos, streamingDecos);
}
// Apply a unified diff to original content and return the new content.
// Parses @@ -a,b +c,d @@ hunks and applies additions/removals.
function applyUnifiedDiff(originalContent, diffText) {
const origLines = originalContent.split('\n');
const diffLines = diffText.split('\n');
const result = [];
let origPos = 0; // 0-based cursor into origLines
// Whitespace-insensitive compare. Models frequently emit @@ line numbers that
// are a few lines off, or drop the leading marker space on context lines β
// both desync origPos and make context lines pull the WRONG original line,
// which shows up as corrupted indentation. We re-anchor on line content (not
// position), so the authoritative original text (with its real indentation)
// is always what gets emitted for unchanged/context lines.
const norm = (s) => s.trim();
// Try to move origPos to the original line matching `content`, scanning a small
// window ahead. Any original lines skipped on the way were unchanged, so emit
// them as context. Returns true when aligned.
const realign = (content) => {
if (origPos >= origLines.length) return false;
if (norm(origLines[origPos]) === norm(content)) return true;
for (let k = 1; k <= 5 && origPos + k < origLines.length; k++) {
if (norm(origLines[origPos + k]) === norm(content)) {
for (let j = 0; j < k; j++) result.push(origLines[origPos++]);
return true;
}
}
return false;
};
// Skip to first hunk header
let i = 0;
while (i < diffLines.length && !diffLines[i].startsWith('@@')) i++;
if (i >= diffLines.length) {
// No hunks found β try simple +/- line format (no @@ headers)
return applySimpleDiff(originalContent, diffText);
}
// Walk each hunk in lock-step with the original: copy untouched lines up to the
// hunk, then preserve context lines, drop '-' lines, and insert '+' lines in
// order. Tracking origPos keeps multi-hunk patches aligned (the old splice
// approach lost context lines and drifted on the 2nd hunk β corrupted output).
while (i < diffLines.length) {
if (!diffLines[i].startsWith('@@')) { i++; continue; }
const hunkMatch = diffLines[i].match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (!hunkMatch) { i++; continue; }
const oldStart = parseInt(hunkMatch[1], 10);
i++; // Move past @@ line
// Position origPos at the start of this hunk. The @@ line number is only a
// hint β models routinely get it wrong. Prefer anchoring on the hunk's first
// context/deletion line: find it in the original (forward from where we are)
// and emit the skipped lines as unchanged. Only fall back to the @@ number for
// pure-insertion hunks, which have no anchor line to search for.
let anchor = null;
for (let p = i; p < diffLines.length && !diffLines[p].startsWith('@@'); p++) {
const L = diffLines[p];
if (L.startsWith('\\') || L.startsWith('+++') || L.startsWith('---')) continue;
const t = L[0];
if (t === '+') continue;
anchor = (t === ' ' || t === '-') ? L.slice(1) : L; // context or deletion (or bare context)
break;
}
if (anchor !== null) {
let found = -1;
for (let q = origPos; q < origLines.length; q++) {
if (origLines[q].trim() === anchor.trim()) { found = q; break; }
}
if (found >= 0) { while (origPos < found) result.push(origLines[origPos++]); }
else { const hs = Math.max(0, oldStart - 1); while (origPos < hs && origPos < origLines.length) result.push(origLines[origPos++]); }
} else {
const hs = Math.max(0, oldStart - 1);
while (origPos < hs && origPos < origLines.length) result.push(origLines[origPos++]);
}
// Apply hunk body
while (i < diffLines.length && !diffLines[i].startsWith('@@')) {
const line = diffLines[i];
if (line.startsWith('\\')) { i++; continue; } // "\ No newline at end of file"
if (line.startsWith('+++') || line.startsWith('---')) { i++; continue; } // file headers
if (/^`{3}/.test(line)) { i++; continue; } // stray closing fence (no trailing \n on last stream token)
const tag = line[0];
const body = line.slice(1); // line minus its 1-char marker
if (tag === '+') {
result.push(body); // added line β verbatim, keep its indentation
} else if (tag === '-') {
realign(body); // anchor to the line being removed
origPos++; // removed β consume original
} else if (tag === ' ') {
realign(body); // anchor to the matching original line
if (origPos < origLines.length) result.push(origLines[origPos++]); // context β keep ORIGINAL text (true indentation)
else result.push(body);
} else {
// No-prefix line: treat as context when it matches the original
// (whitespace-insensitively β models often drop the leading space), else literal
if (realign(line)) result.push(origLines[origPos++]);
else result.push(line);
}
i++;
}
}
// Copy any remaining original lines after the last hunk
while (origPos < origLines.length) result.push(origLines[origPos++]);
return result.join('\n');
}
// Fallback: simple +/- diff without @@ headers (e.g. model just outputs +added/-removed lines)
// Format: lines starting with '+ ' are additions, '- ' are deletions, ' ' (single space) are context.
// Lines with no prefix at all are ignored (filename comments, blank separators, etc.)
function applySimpleDiff(originalContent, diffText) {
const origLines = originalContent.split('\n');
const diffLines = diffText.split('\n');
const result = [];
let origPos = 0; // 0-based cursor into origLines
const norm = (s) => s.trim();
// Advance origPos to the first original line at/after origPos matching
// `content` (whitespace-insensitive), emitting any skipped originals as
// unchanged context. Returns that index (origPos left there), or -1 unmoved.
// This anchors '-'/context/bare lines to the REAL original position instead of
// assuming the diff starts at line 0 β without it a leading deletion consumed
// the wrong line and insertions floated to the top (context mismatch).
const seekAndEmit = (content) => {
for (let q = origPos; q < origLines.length; q++) {
if (norm(origLines[q]) === norm(content)) {
while (origPos < q) result.push(origLines[origPos++]);
return q;
}
}
return -1;
};
for (const line of diffLines) {
if (line === '') continue; // blank separator noise
if (line.startsWith('@@') || line.startsWith('\\')) continue; // hunk header / "no newline"
if (line.startsWith('+++') || line.startsWith('---')) continue; // file headers
const tag = line[0];
const body = line.slice(1); // strip exactly ONE marker char
if (tag === '+') {
result.push(body); // addition β verbatim, keep indentation
} else if (tag === '-') {
if (seekAndEmit(body) >= 0) origPos++; // delete the matched original
// not found β stale/duplicate deletion, drop silently
} else if (tag === ' ') {
if (seekAndEmit(body) >= 0) result.push(origLines[origPos++]); // context β emit ORIGINAL (true indentation)
// not found β context drifted; skip rather than corrupt
} else {
// Bare line: the model frequently drops the leading space on context lines.
// Treat it as context ONLY when it matches an upcoming original β otherwise
// it is a stray comment / "# filename:" / prose line and is ignored.
if (seekAndEmit(line) >= 0) result.push(origLines[origPos++]);
}
}
while (origPos < origLines.length) result.push(origLines[origPos++]);
return result.length > 0 ? result.join('\n') : originalContent;
}
// Main v2-chat live streaming (used in the full app) β thin wrapper over the
// shared parser with its own module-level state.
function v2LiveCodeUpdate(fullReply) {
codeLiveStreamUpdate(fullReply, _v2LiveState);
}
// ββ /new-project 2-phase project creation ββββββββββββββββββββββββββββββββ
// State variables
let _projMode = false; // νμΌ μμ± μ§ν μ€
let _projEl = null; // μ§ν νμμ© DOM μμ
let _projFileCount = 0; // μμ±λ νμΌ μ
let _projPhase = null; // 'plan' | null
let _projHistory = []; // κ³ν λν νμ€ν 리
let _projSessionId = null; // κ³ν μΈμ
ID
const _PROJ_PLAN_SYSTEM = `λΉμ μ μννΈμ¨μ΄ νλ‘μ νΈ μ€κ³ λμ°λ―Έμ
λλ€.
μ¬μ©μκ° μνλ νλ‘μ νΈλ₯Ό λΆμνκ³ μλ λ§ν¬λ€μ΄ νμμΌλ‘ λ΅νμΈμ.
π§ κΈ°μ μ€ν: μΈμ΄, λΌμ΄λΈλ¬λ¦¬, νλ μμν¬ λμ΄
π νμΌλ³ ꡬν κ³ν:
κ° νμΌμ λν΄ μλ νμμΌλ‘ μμ±νμΈμ:
**νμΌλͺ
** β μν ν μ€ μμ½
- μ£Όμ ν¨μ/ν΄λμ€/μ»΄ν¬λνΈ: μ΄λ¦κ³Ό μν
- ν΅μ¬ λ‘μ§: μ΄λ»κ² λμνλμ§ 1~2μ€
(νμΌμ΄ μ¬λ¬ κ°λ©΄ λͺ¨λ λμ΄)
β¨ λ°μ΄ν° νλ¦ / ꡬ쑰:
- νμΌ/λͺ¨λ κ° μ°κ²° κ΄κ³ (μ΄λμ μ΄λλ₯Ό νΈμΆνλμ§)
- μν κ΄λ¦¬λ λ°μ΄ν° μ μ₯ λ°©μ
β οΈ κ΅¬ν μ μ£Όμμ¬ν:
- μμ λμ΄λ ν¬μΈνΈλ μ£μ§μΌμ΄μ€
μ€μ: JSON μΆλ ₯ μ λ κΈμ§. μ½λ λΈλ‘ μΆλ ₯ κΈμ§. μ½λ μμ± κΈμ§.
λ°λμ μμ λ§ν¬λ€μ΄ ν
μ€νΈ νμλ§ μ¬μ©νμΈμ.
λ΅λ³ λ§μ§λ§μ λ°λμ μ΄ ν μ€μ μΆκ°νμΈμ:
μ§ννλ €λ©΄ "λ§λ€μ΄μ€" λΌκ³ μ
λ ₯νκ±°λ, μμ μ¬νμ μλ €μ£ΌμΈμ.`;
const _PROJ_GEN_SYSTEM = `λΉμ μ μ νλ‘μ νΈ νμΌμ μμ±νλ μ΄μμ€ν΄νΈμ
λλ€.
λꡬλ₯Ό μ¬μ©νμ§ λ§κ³ , λ°λμ μλ νμμΌλ‘ κ° νμΌμ μΆλ ₯νμΈμ. λ€λ₯Έ ν
μ€νΈ μμ.
@@FILE: νλ‘μ νΈλͺ
/νμΌλͺ
.νμ₯μ
νμΌ λ΄μ© (μ΄μ€μΌμ΄ν λΆνμ, μλ κ·Έλλ‘)
@@ENDFILE
μμ:
@@FILE: tetris-game/index.html
ν
νΈλ¦¬μ€
...
@@ENDFILE
@@FILE: tetris-game/README.md
## μ€ν λ°©λ²
index.htmlμ λΈλΌμ°μ μμ μ΄μ΄μ£ΌμΈμ.
@@ENDFILE
κ·μΉ:
- path: μλ¬Έ μ¬λ¬κ·Έ ν΄λλͺ
/νμΌλͺ
(μ: tetris-game/index.html)
- μ€ν μ§μ
μ νμ (main.py / index.html / app.py λ±)
- README.md νμ (λ§μ§λ§)
- νμΌ λ΄μ©μ @@FILE: λλ @@ENDFILE λ¬Έμμ΄ μ¬μ© κΈμ§
- κ° νμΌμ μμ ν λμ μ½λ
- μΉ κ²μ(ν
νΈλ¦¬μ€, λ±, νΌμ¦ λ±)μ index.html λ¨μΌ νμΌλ‘ (λͺ¨λ CSS/JSλ₯Ό