Files
homeclaw/web-ui/js/code-editor/code.js
T
kimandClaude Opus 5 435c8f4e9c feat: 코드앱에서 ESP 보드 무선(OTA) 업로드
인버터 옆처럼 USB를 다시 꽂기 어려운 곳에 보드를 설치하면 시리얼 경로가 무용지물이
된다. 코드앱 업로드 패널은 지금까지 USB 전용이었다 — 플래싱을 브라우저에서
Web Serial(esptool-js/STK500/STM32 UART)로 직접 하는 구조라 무선 경로가 없었다.

OTA는 브라우저가 할 수 없는 일이다. 페이지에서 보드로 espota 핸드셰이크를 열 방법이
없어서, 이 업로드 경로만 서버를 경유한다.

- 백엔드: /api/arduino/upload의 port가 IP면 OTA로 분기 — 컴파일해서 .bin을 만들고
  코어에 들어있는 espota.py로 밀어넣는다. espota.py는 arduino-cli가 아니라 ESP 코어에
  딸려오고 코어를 올릴 때마다 버전 디렉토리가 바뀌므로 호출 시점에 최신 버전부터
  찾는다. 부트로더/파티션 이미지는 걸러내고 애플리케이션 바이너리만 보낸다.
  ESP32는 3232, ESP8266은 8266 포트.
- 암호는 exec가 아니라 execFile로 넘긴다 — 사용자가 넣는 값이라 셸에 닿으면 안 된다.
- 프런트: 패널에 📡 OTA 토글과 보드 IP/암호 입력줄 추가. OTA 모드에서는 시리얼 포트를
  안 골라도 업로드 버튼이 살아있어야 하므로 disabled 조건을 분리했다.

에러 힌트는 espota.py의 logging.error() 문구를 직접 읽고 맞췄다. 처음엔 "No response
from device"를 기준으로 짐작해서 썼는데, 실제로 응답 없는 보드는 **"No Answer"**를
뱉어서 힌트가 한 번도 안 떴다(실측 확인). 안 뜨는 힌트는 없느니만 못하다.
인증 실패/무응답/Listen 실패를 각각 다른 안내로 매핑.

실측: 실제 rems_solar_esp8266.ino로 엔드포인트 호출 → 컴파일 성공, .bin 탐색,
espota 실행, 응답 없는 IP에 대해 3단계 확인 순서 힌트까지 정상 출력.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:42:01 +09:00

6956 lines
330 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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='/html/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 = [];
// Strip code blocks from a history entry before storage.
// File content is re-sent via contextMsg on every request, so repeating it in history
// is pure waste and the main source of payload bloat. Keep only prose/summary text.
function _histEntry(text) {
if (!text) return text;
const stripped = text
.replace(/```[\s\S]*?```/g, '[코드]') // fenced code blocks → placeholder
.replace(/<think>[\s\S]*?<\/think>/gi, '') // thinking tags
.trim();
return stripped.slice(0, 3000) || text.slice(0, 200); // prose cap; fallback if all stripped
}
// Returns history entries (excluding last/current user msg) up to maxMsgs count AND maxBytes total
function _histForAPI(hist, maxMsgs = 20, maxBytes = 150 * 1024) {
if (hist.length <= 1) return [];
const src = hist.slice(0, -1);
let bytes = 0;
const result = [];
for (let i = src.length - 1; i >= 0 && result.length < maxMsgs; i--) {
const size = (src[i].content || '').length;
if (bytes + size > maxBytes) break;
bytes += size;
result.unshift({ role: src[i].role, content: src[i].content });
}
return result;
}
let codeAiAbort = null;
let _codeAiAutoAccept = false; // when true: skip diff review, write files directly
let _autoAcceptTurn = false; // true during an auto-accept execute phase → triggers completion message
let _prevTurnDoneTools = []; // legacy: kept for session-clear compatibility
let _readFileCache = {}; // { bareFilename: fullContent } — persists across turns so model remembers Read files
let _historySummary = ''; // compressed summary of old turns injected into system prompt
let _karpathyMode = localStorage.getItem('codeKarpathyMode') === '1'; // Andrej Karpathy coding guidelines
let _pendingImages = []; // { dataUrl: string, mimeType: string }[]
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 } }
// Claude Code-style tool definitions sent directly to Ollama by the client loop
const CLIENT_TOOL_DEFS = [
{ type: 'function', function: { name: 'Read', description: '파일 내용을 줄번호와 함께 읽습니다. offset/limit으로 부분 읽기 가능. 컨텍스트에 이미 있는 파일에는 사용하지 마세요.', parameters: { type: 'object', properties: { file: { type: 'string', description: '파일 경로' }, offset: { type: 'number', description: '시작 줄 번호 (1-based, 선택)' }, limit: { type: 'number', description: '읽을 줄 수 (선택)' } }, required: ['file'] } } },
{ type: 'function', function: { name: 'Write', description: '새 파일을 생성하거나 전체 내용을 교체합니다. 기존 파일 수정에는 Edit을 사용하세요.', parameters: { type: 'object', properties: { file: { type: 'string' }, content: { type: 'string', description: '파일 전체 내용' } }, required: ['file', 'content'] } } },
{ type: 'function', function: { name: 'Edit', description: '파일 내 정확한 문자열을 교체합니다. old_string은 파일에 정확히 존재해야 합니다 (공백, 들여쓰기, 줄바꿈 포함).', parameters: { type: 'object', properties: { file: { type: 'string' }, old_string: { type: 'string', description: '교체할 기존 텍스트 (정확히 일치해야 함)' }, new_string: { type: 'string', description: '새 텍스트' } }, required: ['file', 'old_string', 'new_string'] } } },
{ type: 'function', function: { name: 'Glob', description: '파일 패턴으로 파일 목록을 검색합니다. 예: "**/*.py", "src/*.ts"', parameters: { type: 'object', properties: { pattern: { type: 'string', description: '글로브 패턴' } }, required: ['pattern'] } } },
{ type: 'function', function: { name: 'Grep', description: '파일에서 텍스트를 검색합니다. 결과는 "파일:줄번호: 내용" 형식입니다.', parameters: { type: 'object', properties: { pattern: { type: 'string', description: '검색 패턴 (정규식 가능)' }, path: { type: 'string', description: '검색 범위 파일/디렉토리 (선택, 기본: 전체 프로젝트)' }, case_insensitive: { type: 'boolean', description: '대소문자 무시 (기본: false)' } }, required: ['pattern'] } } },
{ type: 'function', function: { name: 'Bash', description: '쉘 명령을 실행합니다. companion.py 연결 시에만 작동합니다.', parameters: { type: 'object', properties: { command: { type: 'string', description: '실행할 명령어' } }, required: ['command'] } } },
];
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 input = document.getElementById('code-ai-input');
if (input) input.placeholder = label + '에게 질문... (예: 게임 만들어줘)';
}
function setCodeAiTech(tech) {
codeAiTech = tech;
localStorage.setItem('codeAiTech', tech);
}
function initCodeView() {
updateCodeAiLabel();
setCodeAiTech(codeAiTech);
if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
if (typeof require === 'undefined') return;
require.config({ paths: { vs: '/vendor/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: "'D2Coding', 'D2 Coding', monospace",
minimap: { enabled: true },
scrollBeyondLastLine: false,
wordWrap: 'on',
renderWhitespace: 'selection',
smoothScrolling: true,
cursorBlinking: 'smooth',
mouseWheelZoom: true,
tabSize: 2,
});
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile);
// Broadcast .ino edits to emulator in real-time
let _syncDebounce = null;
monacoEditor.onDidChangeModelContent(() => {
if (_esp32SyncBusy) return;
clearTimeout(_syncDebounce);
_syncDebounce = setTimeout(() => {
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !file.name.endsWith('.ino')) return;
_esp32CodeChannel.postMessage({ type: 'code', code: monacoEditor.getValue() });
}, 300);
});
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();
if (_hideBlankLines) setTimeout(_applyHideBlankLines, 50);
if (_blockFlipFlop) setTimeout(_applyBlockFlipFlop, 60);
}
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 `<div class="code-file-tab${active ? ' active' : ''}${diff ? ' diff-active' : ''}" onclick="switchCodeFile('${f.id}')">
${unresolved > 0 ? `<span class="code-tab-dot">${unresolved}</span>` : ''}
<span>${escHtml(f.name)}</span>
<span class="code-file-tab-close" onclick="closeCodeFile('${f.id}',event)">&times;</span>
</div>`;
}).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}` : '📁 폴더 없음';
}
const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git', 'dist', 'build', '.venv', 'venv', '.cache', '.next', 'out', 'coverage', 'target', '.idea', '.vs']);
// Per-file byte cap for AI context. A minified bundle / large data file can be few
// lines but tens of MB — line-count windowing misses it, so cap by bytes too.
const MAX_AI_FILE_BYTES = 256 * 1024; // 256KB
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') {
if (SKIP_DIRS.has(name)) continue;
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);
// Inject file manifest into empty AI history so AI knows existing files
if (codeFiles.length > 0 && codeAiHistory.length === 0) {
const manifest = codeFiles.map(f => f.name).join(', ');
codeAiHistory.push({ role: 'user', content: `[기존 프로젝트 파일: ${manifest}]\n이 파일들이 이미 존재합니다. 새로 만들지 마세요.` });
codeAiHistory.push({ role: 'assistant', content: '기존 프로젝트 파일 목록을 확인했습니다.' });
}
}
// ── 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;
function _stripTerminal(raw) {
return raw
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title 등)
.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, '') // CSI (색상, 커서, private mode)
.replace(/\x1b[^[\]]/g, '') // 기타 ESC 시퀀스
.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '') // 제어문자 (\n 제외)
.replace(/\r/g, '');
}
function _termBufferAppend(shellId, raw) {
if (!shellId) return;
const text = _stripTerminal(raw);
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 = [
// Python
/Traceback \(most recent call last\)/,
/(?:Syntax|Name|Type|Attribute|Import|Indentation|Value|Key|Index|Zero|Overflow|Runtime|OS|IO|File)Error:/,
/^\s*\^\s*$/,
// JavaScript / Node
/Uncaught (?:TypeError|ReferenceError|SyntaxError|Error):/,
/Error: .+\n?\s+at /,
/\[Error\]/i,
// GCC / Arduino 컴파일 에러
/\berror:\s/,
/undefined reference to\b/,
/multiple definition of\b/,
/expected\s.+before\s/,
/invalid conversion from\b/,
/cannot convert\b/,
/sketch too big/i,
/\w+\.ino:\d+:\d+:.*error/,
/\w+\.cpp:\d+:\d+:.*error/,
/recipe for target.*failed/i,
/make\[1\].*Error/,
];
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) {
const btn = document.getElementById('code-term-ai-error-btn');
if (btn) { btn.style.display = ''; btn.style.background = '#dc2626'; btn.style.color = '#fff'; btn.dataset.shellId = shellId; }
}
function _sendTerminalErrorToAI(errorText) {
const input = document.getElementById('code-ai-input');
if (!input) return;
// Filter noise lines
const filtered = errorText.split('\n').filter(l => {
const t = l.trim();
if (!t) return false;
if (/^[A-Za-z]:\\.+>/.test(t)) return false; // Windows 프롬프트 (명령어 포함)
if (/^[~/][^\s]*[$#]\s*/.test(t)) return false; // Unix 프롬프트
if (/^Hello from the pygame community/.test(t)) return false;
if (/^https?:\/\/www\.pygame\.org/.test(t)) return false;
return true;
});
// 마지막 Traceback 블록만 추출
let blockStart = 0;
for (let i = filtered.length - 1; i >= 0; i--) {
if (/Traceback \(most recent call last\)/.test(filtered[i])) {
blockStart = i; break;
}
}
const trimmed = filtered.slice(blockStart).slice(-30).join('\n');
if (!trimmed) return;
input.value = '다음 에러를 고쳐줘. Read로 파일을 읽은 뒤 Edit 또는 Write 도구로 파일을 직접 수정해:\n```\n' + trimmed + '\n```';
input.style.height = '';
input.dispatchEvent(new Event('input'));
codeAiSend();
}
function _getXtermVisibleText(term, maxLines = 80) {
const buf = term.buffer.active;
const lines = [];
const endRow = buf.baseY + buf.cursorY;
const startRow = Math.max(0, endRow - maxLines);
for (let i = startRow; i <= endRow; i++) {
const line = buf.getLine(i);
if (!line) continue;
const t = line.translateToString(true);
if (t.trim()) lines.push(t);
}
return lines.join('\n');
}
function _sendActiveTermErrorToAI() {
const btn = document.getElementById('code-term-ai-error-btn');
const shellId = btn?.dataset.shellId || _lastErrorShellId || activeCodeTermShellId;
const shell = codeTermShells.find(s => s.id === shellId) || codeTermShells.find(s => s.id === activeCodeTermShellId);
const text = (shell?.term ? _getXtermVisibleText(shell.term) : '')
|| (shellId && _termBuffers[shellId] || Object.values(_termBuffers).at(-1))?.slice(-40).join('\n')
|| _lastErrorText;
if (text) _sendTerminalErrorToAI(text);
if (btn) { btn.style.background = '#1a3a28'; btn.style.color = '#22c55e'; }
}
// 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.style.display = 'none';
}
// ── Companion WebSocket (local Python execution) ───────────────────────────
let companionWs = null;
let companionConnected = false;
let companionEverConnected = false; // 한 번이라도 성공한 적 있는지
const COMPANION_URL = 'ws://127.0.0.1:9000';
let companionPlatform = 'unix'; // 'windows' | 'unix', set on shell_ready
let companionHome = ''; // home directory from companion
const localShellIds = new Set(); // shell IDs routed to companion PTY (tab id = shell id)
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; companionEverConnected = true; updateCompanionStatus(true); };
companionWs.onclose = () => {
companionConnected = false; companionWs = null;
updateCompanionStatus(false);
// 한 번이라도 연결됐던 경우에만 자동 재연결 (미연결 상태서 콘솔 에러 spam 방지)
if (companionEverConnected) 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';
}
// reqId로 상관관계를 맺는 companion.py 요청/응답 헬퍼 (claude-app.js의 동일 함수 포팅)
function companionRequest(type, payload, responseType, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
if (!companionWs || companionWs.readyState !== WebSocket.OPEN) { reject(new Error('companion.py가 연결되지 않았습니다')); return; }
const reqId = type + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
const timeout = setTimeout(() => { companionWs.removeEventListener('message', handler); reject(new Error('시간 초과')); }, timeoutMs);
const handler = (evt) => {
let d; try { d = JSON.parse(evt.data); } catch { return; }
if (d.reqId === reqId && d.type === responseType) {
clearTimeout(timeout);
companionWs.removeEventListener('message', handler);
resolve(d);
}
};
companionWs.addEventListener('message', handler);
companionWs.send(JSON.stringify({ type, reqId, ...payload }));
});
}
// ── 📱 USB 실기기 (companion.py를 통한 로컬 PC의 adb/scrcpy) ──────────────────
let _androidUsbOpen = false;
let _androidUsbDevices = []; // [{serial, state, model}]
function _androidUsbLog(text) {
const log = document.getElementById('android-usb-log');
if (!log) return;
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 30;
log.textContent += (log.textContent ? '\n' : '') + text;
if (atBottom) log.scrollTop = log.scrollHeight;
}
function _androidUsbSetStatus(text, colorBg, colorFg) {
const el = document.getElementById('android-usb-status');
if (!el) return;
el.textContent = text;
if (colorBg) el.style.background = colorBg;
if (colorFg) el.style.color = colorFg;
}
function _androidUsbSetHint(text) {
const el = document.getElementById('android-usb-hint');
if (!el) return;
if (!text) { el.style.display = 'none'; el.textContent = ''; return; }
el.style.display = '';
el.textContent = text;
}
function _androidUsbSetButtonsEnabled(enabled) {
['android-usb-install-btn', 'android-usb-logcat-btn', 'android-usb-mirror-btn'].forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
});
}
function _androidUsbSelectedSerial() {
const sel = document.getElementById('android-usb-device-sel');
return sel && sel.value ? sel.value : (_androidUsbDevices[0]?.serial || null);
}
// companion.py가 이번 기능을 지원하지 않는 구버전일 때(browse_file/scrcpy_start 타임아웃)
// exec 계열의 일반 타임아웃과 구분되는 안내 문구를 보여준다.
function _androidUsbOutdatedCompanionMessage() {
return 'companion.py가 이 기능을 지원하지 않는 것 같습니다. 최신 버전으로 다시 다운로드해주세요 '
+ '(⬇ 다운로드 버튼, 또는 자동시작 사용자는 install_autostart.py 재실행).';
}
function toggleAndroidUsbPanel() {
const panel = document.getElementById('android-usb-panel');
const btn = document.getElementById('code-usb-android-btn');
if (!panel) return;
_androidUsbOpen = !_androidUsbOpen;
panel.style.display = _androidUsbOpen ? 'flex' : 'none';
if (btn) btn.classList.toggle('active', _androidUsbOpen);
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
if (_androidUsbOpen) androidUsbRefreshDevices();
}
async function androidUsbRefreshDevices() {
if (!companionConnected) {
_androidUsbSetStatus('companion 미연결', '#3a2a1a', '#e8a05a');
_androidUsbSetHint('companion.py가 실행 중이 아닙니다. 상단의 🐍 표시를 클릭해서 연결하세요.');
_androidUsbSetButtonsEnabled(false);
return;
}
_androidUsbSetStatus('확인 중...', '#1a3448', '#5aa8e8');
try {
const res = await companionRequest('exec', { command: 'adb devices -l' }, 'exec_result', 31000);
const out = res.output || '';
_androidUsbLog('$ adb devices -l\n' + out);
if (res.error) {
if (/not recognized|command not found|no such file/i.test(out)) {
_androidUsbSetStatus('adb 없음', '#3a1a1a', '#e85a5a');
_androidUsbSetHint('adb를 찾을 수 없습니다. Android SDK Platform-Tools를 설치하고 PATH에 추가해주세요.');
} else {
_androidUsbSetStatus('오류', '#3a1a1a', '#e85a5a');
_androidUsbSetHint(out);
}
_androidUsbDevices = [];
_androidUsbSetButtonsEnabled(false);
return;
}
const lines = out.split('\n').map(l => l.trim());
const startIdx = lines.findIndex(l => l.startsWith('List of devices attached'));
const deviceLines = (startIdx >= 0 ? lines.slice(startIdx + 1) : lines).filter(l => l);
_androidUsbDevices = deviceLines.map(l => {
const parts = l.split(/\s+/);
const serial = parts[0];
const state = parts[1] || 'unknown';
const modelTok = parts.find(p => p.startsWith('model:'));
const model = modelTok ? modelTok.slice(6) : '';
return { serial, state, model };
}).filter(d => d.serial);
const sel = document.getElementById('android-usb-device-sel');
if (sel) {
sel.innerHTML = _androidUsbDevices.map(d => `<option value="${d.serial}">${d.model || d.serial}${d.state !== 'device' ? ' (' + d.state + ')' : ''}</option>`).join('');
sel.style.display = _androidUsbDevices.length > 1 ? '' : 'none';
}
if (_androidUsbDevices.length === 0) {
_androidUsbSetStatus('기기 없음', '#1a3448', '#5aa8e8');
_androidUsbSetHint('USB로 연결하고 휴대폰의 설정 > 개발자 옵션에서 \'USB 디버깅\'을 켜주세요.');
_androidUsbSetButtonsEnabled(false);
} else if (_androidUsbDevices.every(d => d.state !== 'device')) {
_androidUsbSetStatus('⚠️ 인증 대기', '#3a2a1a', '#e8a05a');
_androidUsbSetHint('휴대폰 화면에 뜬 \'USB 디버깅을 허용하시겠습니까?\' 팝업에서 허용을 눌러주세요.');
_androidUsbSetButtonsEnabled(false);
} else {
_androidUsbSetStatus(`${_androidUsbDevices.length}대 연결됨`, '#1a3a2a', '#4ade80');
_androidUsbSetHint('');
_androidUsbSetButtonsEnabled(true);
}
} catch (e) {
_androidUsbSetStatus('오류', '#3a1a1a', '#e85a5a');
_androidUsbSetHint(e.message === '시간 초과' ? '시간 초과 (adb devices가 응답하지 않습니다)' : e.message);
_androidUsbSetButtonsEnabled(false);
}
}
async function androidUsbInstallApk() {
const serial = _androidUsbSelectedSerial();
try {
const picked = await companionRequest('browse_file', {
title: '설치할 APK 선택',
filetypes: [['APK 파일', '*.apk'], ['모든 파일', '*.*']],
}, 'browse_file_result', 120000);
if (picked.error) { _androidUsbLog('⚠️ ' + picked.error); return; }
if (!picked.path) { _androidUsbLog('(설치 취소됨)'); return; }
const cmd = `adb ${serial ? '-s ' + serial + ' ' : ''}install -r "${picked.path}"`;
_androidUsbLog('$ ' + cmd);
const res = await companionRequest('exec', { command: cmd }, 'exec_result', 31000);
_androidUsbLog(res.output || '');
if (/INSTALL_FAILED_/i.test(res.output || '')) _androidUsbLog('⚠️ 설치 실패 — 서명/버전 불일치일 수 있습니다.');
} catch (e) {
_androidUsbLog('⚠️ ' + (e.message === '시간 초과' ? _androidUsbOutdatedCompanionMessage() : e.message));
}
}
function androidUsbOpenLogcat() {
if (!companionConnected) { _androidUsbLog('⚠️ companion.py 미연결'); return; }
const serial = _androidUsbSelectedSerial();
const pkg = (document.getElementById('android-usb-pkg-filter')?.value || '').trim();
const id = addLocalShell('📱 Logcat');
const sFlag = serial ? `-s ${serial} ` : '';
let cmd = `adb ${sFlag}logcat -c && adb ${sFlag}logcat -v time`;
if (pkg) cmd += companionPlatform === 'windows' ? ` | findstr /I "${pkg}"` : ` | grep -i "${pkg}"`;
setTimeout(() => {
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data: cmd + '\r' }));
}, 1300);
}
async function androidUsbStartMirror() {
const serial = _androidUsbSelectedSerial();
_androidUsbLog('🪞 화면 미러링 시작 중...');
try {
const res = await companionRequest('scrcpy_start', { serial }, 'scrcpy_result', 8000);
if (res.ok) _androidUsbLog('🪞 scrcpy 실행됨 (기기 화면 창이 곧 나타납니다)');
else _androidUsbLog('⚠️ ' + res.error);
} catch (e) {
_androidUsbLog('⚠️ ' + (e.message === '시간 초과' ? _androidUsbOutdatedCompanionMessage() : e.message));
}
}
function handleCompanionMessage(msg) {
// 순수 요청/응답 타입(companionRequest로 처리됨) — 터미널 패널을 강제로 열지 않는다
if (msg.type === 'browse_file_result' || msg.type === 'scrcpy_result') return;
// shell_* messages → route by msg.shellId; run messages → active code terminal
const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit';
const targetId = isShellMsg && msg.shellId ? msg.shellId : 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') {
if (msg.platform) companionPlatform = msg.platform;
if (msg.home) companionHome = msg.home;
_companionCdProject(msg.shellId);
}
else if (msg.type === 'shell_output') { s.term.write(msg.data); _termBufferAppend(s.id, msg.data); }
else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[셸 종료 ${msg.code}]\x1b[0m\r\n`); }
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(/(?<!\r)\n/g, '\r\n')); _termBufferAppend(s.id, msg.data); }
else if (msg.type === 'exit') { s.term.write(`\r\n\x1b[33m[exit ${msg.code}]\x1b[0m\r\n`); if (msg.code === 0) _termOnRunSuccess(s.id); }
else if (msg.type === 'error') { s.term.write(`\r\n\x1b[31m오류: ${msg.data}\x1b[0m\r\n`); _termBufferAppend(s.id, msg.data); }
else if (msg.type === 'killed') s.term.write(`\r\n\x1b[31m[강제 종료]\x1b[0m\r\n`);
}, codeTermOpen ? 0 : 500);
}
// ⏹ 버튼: 활성 터미널에서 실행 중인 프로세스를 중단
function codeTermKill() {
const s = codeTermShells.find(s => s.id === activeCodeTermShellId);
if (!s) return;
if (localShellIds.has(s.id)) {
// 로컬(companion) PTY 셸: Ctrl+C만 전송 (PTY 자체는 유지)
if (companionConnected && companionWs)
companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: '\x03' }));
} else {
// 서버 셸: PTY에 Ctrl+C 전송
if (termWsConnected) termWsSend({ type: 'pty_input', shellId: s.id, data: '\x03' });
}
if (s.term) s.term.focus();
}
function _companionCdProject(shellId) {
if (!companionConnected || !companionWs) return;
const sid = shellId || activeCodeTermShellId;
const isWin = companionPlatform === 'windows';
const sep = isWin ? '\\' : '/';
const base = (localTermPath || companionHome || '').replace(/[/\\]$/, '');
if (!base) return;
const target = activeProjectName ? base + sep + activeProjectName : base;
const cmd = isWin
? `cd /d "${target}"\r`
: `cd ${JSON.stringify(target)}\r`;
companionWs.send(JSON.stringify({ type: 'input', shellId: sid, data: cmd }));
}
function csbLoadCodeDir() {}
async function codeSaveFile() {
const file = codeFiles.find(f => 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:'#4B1D45', 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();
// Intercept Ctrl+` inside xterm to toggle the terminal panel.
// Return false = xterm won't process this key; return true = xterm handles it normally.
term.attachCustomKeyEventHandler(e => {
if (e.type === 'keydown' && e.ctrlKey && (e.key === '`' || e.code === 'Backquote')) {
toggleCodeTerminal();
return false;
}
if (e.type === 'keydown' && e.key === 'Escape' && codeTermFullscreen) {
toggleCodeTermFullscreen();
return false;
}
return true;
});
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', shellId: id, 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', shellId: id, 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 `<div class="ct-tab${a?' active':''}" onclick="switchChatTermShell('${s.id}')"><svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg><span>${escHtml(s.name)}</span>${chatTermShells.length>1?`<span class="ct-tab-close" onclick="closeChatTermShell('${s.id}',event)">&times;</span>`:''}</div>`;
}).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.classList.add('active');
const { min, max } = _bottomPanelHeightBounds();
const cur = panel.offsetHeight;
if (cur < min || cur > max) panel.style.height = Math.round((min + max) / 2) + 'px';
if (!codeTermShells.length) { setTimeout(() => { if (companionConnected) addLocalShell(); else addCodeTermShell(); }, 60); }
else { setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) { s.term.focus(); const inst = xtermInstances.get(s.id); if (inst?.ptyReady) { if (localShellIds.has(s.id)) { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: companionPlatform === 'windows' ? 'cls\r' : 'clear\r' })); } else { termWsSend({ type: 'pty_input', shellId: s.id, data: 'clear\r' }); } } } }, 50); }
} else {
panel.style.display = 'none';
if (btn) btn.classList.remove('active');
}
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
let codeTermFullscreen = false;
function toggleCodeTermFullscreen() {
const panel = document.getElementById('code-term-panel');
const btn = document.getElementById('code-term-fullscreen-btn');
if (!panel) return;
codeTermFullscreen = !codeTermFullscreen;
panel.classList.toggle('code-term-fullscreen', codeTermFullscreen);
if (btn) { btn.classList.toggle('active', codeTermFullscreen); btn.title = codeTermFullscreen ? '전체화면 종료 (Esc)' : '전체화면'; }
setTimeout(() => {
const s = codeTermShells.find(s => s.id === activeCodeTermShellId);
if (s?.fitAddon) s.fitAddon.fit();
if (s?.term) s.term.focus();
}, 60);
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && codeTermFullscreen) toggleCodeTermFullscreen();
});
// ── Browser Preview Panel ────────────────────────────────────────────────────
let codePreviewOpen = false;
function toggleCodePreview() {
const panel = document.getElementById('code-preview-panel');
const btn = document.getElementById('code-preview-toggle-btn');
if (!panel) return;
codePreviewOpen = !codePreviewOpen;
if (codePreviewOpen) {
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
const urlInput = document.getElementById('code-preview-url');
const saved = localStorage.getItem('code_preview_url');
if (saved && urlInput) urlInput.value = saved;
const iframe = document.getElementById('code-preview-iframe');
if (iframe && !iframe.getAttribute('src')) iframe.src = (urlInput ? urlInput.value : 'http://127.0.0.1:8000');
} else {
panel.style.display = 'none';
if (btn) btn.classList.remove('active');
}
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
let _emuPanelOpen = false;
let _emuPanelUrl = '';
function toggleEmuPanel() {
const panel = document.getElementById('code-emu-panel');
const btn = document.getElementById('code-emu-btn');
if (!panel) return;
_emuPanelOpen = !_emuPanelOpen;
panel.style.display = _emuPanelOpen ? 'flex' : 'none';
if (btn) btn.classList.toggle('active', _emuPanelOpen);
if (!_emuPanelOpen) {
const iframe = document.getElementById('code-emu-iframe');
if (iframe) iframe.src = '';
_emuPanelUrl = '';
}
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
function _emuPanelLoad(url, title) {
const panel = document.getElementById('code-emu-panel');
const iframe = document.getElementById('code-emu-iframe');
const lbl = document.getElementById('emu-panel-title');
const btn = document.getElementById('code-emu-btn');
if (!panel || !iframe) return;
_emuPanelUrl = url;
iframe.src = url;
if (lbl) lbl.textContent = title || '에뮬레이터';
// Always force the panel visible instead of gating on _emuPanelOpen — a
// back-forward-cached page restore (or any path where this JS-level flag
// drifts from the DOM's actual display state) left `panel.style.display`
// stuck at its closed default forever after, since this only ever ran the
// one time the flag flipped false->true.
_emuPanelOpen = true;
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
function emuPanelNewTab() {
if (_emuPanelUrl) window.open(_emuPanelUrl, '_blank');
}
// 하단 패널(클로드 앱, 터미널 등) 높이는 항상 전체 높이의 1/2~7/10 사이로 제한한다.
function _bottomPanelHeightBounds() {
const total = document.getElementById('code-view')?.clientHeight || window.innerHeight;
return { min: total * 0.5, max: total * 0.7 };
}
let _claudePanelOpen = false;
function toggleClaudeAppPanel() {
const panel = document.getElementById('code-claude-panel');
const btn = document.getElementById('code-claude-btn');
if (!panel) return;
_claudePanelOpen = !_claudePanelOpen;
panel.style.display = _claudePanelOpen ? 'flex' : 'none';
if (btn) btn.classList.toggle('active', _claudePanelOpen);
if (_claudePanelOpen) {
const iframe = document.getElementById('code-claude-iframe');
if (iframe && !iframe.getAttribute('src')) iframe.src = '/claude-app.html';
const { min, max } = _bottomPanelHeightBounds();
const cur = panel.offsetHeight;
if (cur < min || cur > max) panel.style.height = Math.round((min + max) / 2) + 'px';
}
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
// Claude App panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('code-claude-drag');
const panel = document.getElementById('code-claude-panel');
if (!drag || !panel) return;
let startY = 0, startH = 0;
drag.addEventListener('mouseover', () => drag.style.background = 'rgba(189,147,249,.4)');
drag.addEventListener('mousedown', e => {
startY = e.clientY; startH = panel.offsetHeight; e.preventDefault();
const { min, max } = _bottomPanelHeightBounds();
// 드래그 중 마우스가 iframe 위로 지나가면 부모 document가 mousemove/mouseup을 못 받아
// 드래그가 안 풀리므로, 드래그하는 동안은 iframe이 마우스 이벤트를 가로채지 않게 한다.
const iframe = document.getElementById('code-claude-iframe');
if (iframe) iframe.style.pointerEvents = 'none';
const onMove = e2 => {
const dy = startY - e2.clientY;
panel.style.height = Math.max(min, Math.min(max, startH + dy)) + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
drag.style.background = '';
if (iframe) iframe.style.pointerEvents = '';
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
function openArduinoEmulator() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
const btn = document.getElementById('code-emu-btn');
if (!file || !file.name.endsWith('.ino')) {
_emuPanelLoad('/arduino-emulator.html', '에뮬레이터');
return;
}
const content = file.model ? file.model.getValue() : (file.content || '');
const fqbn = _csbSelBoard.fqbn;
const fqbnArch = fqbn.split(':')[1] || '';
if (fqbnArch === 'esp32') {
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
_emuPanelLoad('/arduino-emulator.html?loading=1', `ESP32 — ${file.name}`);
codeAiAppend('system', `🖥️ ESP32 에뮬레이터 컴파일 중... [${_csbSelBoard.name}]`);
try { localStorage.setItem('esp32_sketch', content); } catch {}
api('/api/arduino/qemu/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) })
.then(d => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
if (d.error) {
const isUnsupported = d.error.includes('미지원') || d.error.includes('RISC-V');
if (isUnsupported) codeAiAppend('system', '⚠️ ' + d.error);
else sendCompileErrorToAI(d.error, file.name, content);
toggleEmuPanel();
return;
}
const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
_emuPanelLoad(url, `ESP32 — ${file.name}`);
codeAiAppend('system', `✅ ESP32 에뮬레이터 실행 중.`);
}).catch(e => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
codeAiAppend('system', '⚠️ ' + e.message);
toggleEmuPanel();
});
} else if (fqbnArch === 'esp8266') {
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
_emuPanelLoad('/arduino-emulator.html?loading=1', `ESP8266 — ${file.name}`);
codeAiAppend('system', `🖥️ ESP8266 에뮬레이터 컴파일 중... [${_csbSelBoard.name}]`);
try { localStorage.setItem('esp8266_sketch', content); } catch {}
api('/api/arduino/esp8266/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) })
.then(d => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
if (d.error) {
sendCompileErrorToAI(d.error, file.name, content);
toggleEmuPanel();
return;
}
const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
_emuPanelLoad(url, `ESP8266 — ${file.name}`);
codeAiAppend('system', `✅ ESP8266 에뮬레이터 실행 중.`);
}).catch(e => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
codeAiAppend('system', '⚠️ ' + e.message);
toggleEmuPanel();
});
} else if (fqbn.startsWith('STMicroelectronics:stm32')) {
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
_emuPanelLoad('/arduino-emulator.html?loading=1', `STM32 — ${file.name}`);
codeAiAppend('system', `🖥️ STM32 Renode 에뮬레이터 컴파일 중... [${_csbSelBoard.name}]`);
try { localStorage.setItem('stm32_sketch', content); } catch {}
api('/api/arduino/renode/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) })
.then(d => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
if (d.error) {
sendCompileErrorToAI(d.error, file.name, content);
toggleEmuPanel();
return;
}
const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`;
_emuPanelLoad(url, `STM32 — ${file.name}`);
codeAiAppend('system', `✅ STM32 Renode 에뮬레이터 실행 중.`);
}).catch(e => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
codeAiAppend('system', '⚠️ ' + e.message);
toggleEmuPanel();
});
} else {
// AVR 및 기타
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
fetch('/api/arduino/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders({}) },
body: JSON.stringify({ code: content, board: fqbn, fqbn }),
}).then(r => r.json()).then(data => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
if (data.autoInstalled?.length) codeAiAppend('system', `📦 라이브러리 자동 설치: ${data.autoInstalled.join(', ')}`);
if (data.error) { sendCompileErrorToAI(data.error, file.name, content); return; }
if (data.hex) {
sessionStorage.setItem('arduino_hex', data.hex);
sessionStorage.setItem('arduino_sketch', content);
_emuPanelLoad('/arduino-emulator.html', `AVR — ${file.name}`);
} else if (data.boardType === 'rp2040') {
sessionStorage.setItem('arduino_uf2', data.bin);
sessionStorage.setItem('arduino_sketch', content);
_emuPanelLoad('/arduino-emulator.html', `RP2040 — ${file.name}`);
} else if (data.bin) {
const ext = data.boardType === 'uf2' ? '.uf2' : '.bin';
const blob = new Blob([Uint8Array.from(atob(data.bin), c => c.charCodeAt(0))], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = file.name.replace(/\.ino$/, '') + ext; a.click();
URL.revokeObjectURL(url);
codeAiAppend('system', `✅ 컴파일 완료 (${(data.size/1024).toFixed(1)} KB) — ${ext.slice(1).toUpperCase()} 다운로드됨.\n보드에 올리려면 ⚡ 업로드 버튼을 사용하세요.`);
}
}).catch(e => {
if (btn) { btn.textContent = '🖥️ 에뮬레이터'; btn.disabled = false; }
codeAiAppend('system', '⚠️ 서버 오류: ' + e.message);
});
}
}
function openAndroidEmulator(_isRetry) {
const btn = document.getElementById('code-android-btn');
if (btn) { btn.textContent = '⏳ 확인 중…'; btn.disabled = true; }
// Idle sessions no longer auto-die, so one from before a page reload may
// still be running — reconnect to it instead of calling /start (which
// would just 429 "already running" and close the panel).
api('/api/android/mine')
.then(d => {
if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; }
_emuPanelLoad('/android-emulator.html?session=' + d.id, 'Android');
codeAiAppend('system', '✅ 기존 Android 에뮬레이터 세션에 재연결됨.');
})
.catch(e => {
// A bare "Failed to fetch" (network-layer failure, not our JSON 404
// for "no session") happens reliably on the very first click right
// after this page finishes loading — likely the browser's per-origin
// connection pool still busy with the editor/terminal/etc. assets.
// One silent retry after a beat clears it without bothering the user;
// real 404s ("no session yet") resolve immediately either way.
if (!_isRetry && /Failed to fetch/i.test(e.message || '')) {
setTimeout(() => openAndroidEmulator(true), 600);
return;
}
_startNewAndroidEmulator(btn);
});
}
function _startNewAndroidEmulator(btn, _isRetry) {
if (btn) { btn.textContent = '⏳ 부팅 중…'; btn.disabled = true; }
_emuPanelLoad('/android-emulator.html?loading=1', 'Android');
if (!_isRetry) codeAiAppend('system', '🤖 Android 에뮬레이터 부팅 중... (최대 90초)');
api('/api/android/start', { method: 'POST' })
.then(d => {
if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; }
if (d.error) {
codeAiAppend('system', '⚠️ ' + d.error);
toggleEmuPanel();
return;
}
_emuPanelLoad('/android-emulator.html?session=' + d.id, 'Android');
codeAiAppend('system', '✅ Android 에뮬레이터 실행 중.');
}).catch(e => {
// Same first-click connection-pool hiccup as in openAndroidEmulator()
// above — retry once before bothering the user with it.
if (!_isRetry && /Failed to fetch/i.test(e.message || '')) {
setTimeout(() => _startNewAndroidEmulator(btn, true), 600);
return;
}
if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; }
codeAiAppend('system', '⚠️ ' + e.message);
toggleEmuPanel();
});
}
function resetAndroidEmulator() {
const btn = document.getElementById('code-android-reset-btn');
if (btn) { btn.disabled = true; }
api('/api/android/reset', { method: 'POST' })
.then(d => {
if (btn) { btn.disabled = false; }
codeAiAppend('system', `🔄 Android 세션 ${d.killed}개 강제 종료됨. 다시 열어주세요.`);
if (_emuPanelOpen) toggleEmuPanel();
}).catch(e => {
if (btn) { btn.disabled = false; }
codeAiAppend('system', '⚠️ ' + e.message);
});
}
// ── Arduino 업로드 페이지 열기 ────────────────────────────────────────────────
function csbUploadToBoard() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !file.name.endsWith('.ino')) {
codeAiAppend('system', '⚠️ .ino 파일을 선택해주세요'); return;
}
cupOpenWith(file);
}
function codePreviewRefresh() {
const urlInput = document.getElementById('code-preview-url');
const iframe = document.getElementById('code-preview-iframe');
if (!iframe) return;
const url = urlInput ? urlInput.value.trim() : 'http://127.0.0.1:8000';
if (url) { localStorage.setItem('code_preview_url', url); iframe.src = url; }
}
// Preview panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('code-preview-drag');
const panel = document.getElementById('code-preview-panel');
if (!drag || !panel) return;
let startY=0, startH=0;
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(96,165,250,.4)');
drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const iframe = document.getElementById('code-preview-iframe');
if (iframe) iframe.style.pointerEvents = 'none';
const onMove = e2 => {
const dy = startY - e2.clientY;
panel.style.height = Math.max(100, Math.min(window.innerHeight*.7, startH+dy)) + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp);
drag.style.background='';
if (iframe) iframe.style.pointerEvents = '';
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
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 ? (s.name || '🖥️ 로컬') : 'bash';
return `<div style="display:flex;align-items:center;gap:5px;padding:0 10px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:12px;color:${a?'#c8e6c0':'#3d6b4f'};background:${a?'#0c0c0c':'transparent'};border-top:${a?'2px solid #22c55e':'2px solid transparent'};white-space:nowrap;user-select:none" onclick="switchCodeTermShell('${s.id}')">
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.7"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>
${label}${codeTermShells.length>1?` <span onclick="closeCodeTermShell('${s.id}',event)" style="color:#3d6b4f;margin-left:3px">&times;</span>`:''}
</div>`;
}).join('');
}
function addLocalShell(label) {
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: label || '🖥️ 로컬', local: true, ptyReady: false, term: null, fitAddon: null });
localShellIds.add(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', shellId: id, rows, cols }));
setTimeout(() => { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data: companionPlatform === 'windows' ? 'cls\r' : 'clear\r' })); }, 800);
}
}, 300);
return id;
}
function addCodeTermShell() {
const id = 'cdt_' + Math.random().toString(36).slice(2,8);
codeTermShells.push({ id, name:'☁️서버', ptyReady:false, term:null, fitAddon:null });
switchCodeTermShell(id);
const _termClear = (delay) => setTimeout(() => {
const inst = xtermInstances.get(id);
if (inst?.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: 'clear\r' });
else setTimeout(() => { const i2 = xtermInstances.get(id); if (i2?.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: 'clear\r' }); }, 800);
}, delay);
if (localTermPath) {
setTimeout(() => {
const inst = xtermInstances.get(id);
if (inst?.ptyReady) { termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` }); _termClear(200); }
else setTimeout(() => { const i2 = xtermInstances.get(id); if (i2?.ptyReady) { termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` }); _termClear(200); } }, 800);
}, 400);
} else {
_termClear(400);
}
}
function csbTermPathSave() {
const inp = document.getElementById('csb-termpath');
localTermPath = (inp?.value || '').trim();
localStorage.setItem('code_local_term_path', localTermPath);
// Show confirmation inline (not in chat window)
const status = document.getElementById('csb-termpath-status');
if (status) {
status.textContent = localTermPath ? `✅ 저장됨: ${localTermPath}` : '경로 초기화됨';
status.style.color = 'var(--brand)';
setTimeout(() => { status.textContent = '새 터미널 탭 열면 이 경로로 자동 이동'; status.style.color = ''; }, 2500);
}
// Apply cd to all currently open terminals immediately
if (localTermPath) {
const isWin = companionPlatform === 'windows';
for (const s of codeTermShells) {
if (localShellIds.has(s.id)) {
if (companionConnected && companionWs) {
const cmd = isWin
? `cd /d "${localTermPath}"\r`
: `cd ${JSON.stringify(localTermPath)}\r`;
companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: cmd }));
}
} else if (s.ptyReady) {
termWsSend({ type: 'pty_input', shellId: s.id, data: `cd ${JSON.stringify(localTermPath)}\r` });
}
}
}
}
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 (companionConnected && companionWs)
companionWs.send(JSON.stringify({ type: 'shell_close', shellId: id }));
} 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('click', e => { if (!e.target.closest('#csb-ctx')) csbCtxClose(); });
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('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const { min, max } = _bottomPanelHeightBounds();
const onMove = e2 => {
const dy = startY - e2.clientY;
panel.style.height = Math.max(min, Math.min(max, 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=''; };
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');
}
// Restore preview URL input
const savedUrl = localStorage.getItem('code_preview_url');
if (savedUrl) { const el = document.getElementById('code-preview-url'); if (el) el.value = savedUrl; }
})();
// Ctrl+` shortcut — for Monaco / non-xterm focus; xterm handles it via attachCustomKeyEventHandler
document.addEventListener('keydown', e => {
if (e.ctrlKey && (e.key === '`' || e.code === 'Backquote')) {
e.preventDefault();
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: 'companion', icon: '🖥️', label: 'python3 로컬', desc: '로컬 셸에서 실행' },
],
js: [
{ id: 'companion', icon: '🖥️', label: 'node 로컬', desc: '로컬 셸에서 실행' },
{ id: 'terminal', icon: '☁️', label: 'node 서버', desc: '서버 터미널에서 실행' },
],
ts: [
{ id: 'companion', icon: '🖥️', label: 'ts-node 로컬', desc: '로컬 셸에서 실행' },
{ 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: '터미널에서 실행' },
],
ino: [
{ id: 'arduino', icon: '🔌', label: '에뮬레이터', desc: 'AVR/STM32/ESP 에뮬레이터 (보드에 따라 자동 선택)' },
{ id: 'esp32', icon: '⚡', label: 'ESP32 플래시', desc: 'Web Serial로 ESP32에 업로드' },
{ id: 'terminal', icon: '⌨', label: 'arduino-cli', 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 =>
`<div onclick="codeRunWith('${m.id}')" style="display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;transition:background .1s" onmouseover="this.style.background='#2d4a2d'" onmouseout="this.style.background=''">` +
`<span style="font-size:14px">${m.icon}</span>` +
`<div style="flex:1"><div style="font-size:12px;font-weight:600;color:#e5e7eb">${m.label}</div><div style="font-size:10px;color:#9ca3af">${m.desc}</div></div>` +
`</div>`
).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 <link href="x.css"> and <script src="x.js"> from local filesystem,
// inline them into the HTML, then return a blob URL for window.open.
async function _openHtmlWithInlinedAssets(file) {
let html = file.content;
const dir = file.name.includes('/') ? file.name.split('/').slice(0, -1).join('/') : '';
// Inline <link rel="stylesheet" href="...">
const linkRe = /<link\b[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*\/?>/gi;
const cssMatches = [...html.matchAll(linkRe)];
for (const m of cssMatches) {
const href = m[1];
if (href.startsWith('http') || href.startsWith('//')) continue;
const fpath = dir ? `${dir}/${href}` : href;
const css = await localReadFile(_clientProjectPath(fpath)) ?? await localReadFile(fpath);
if (css !== null) {
html = html.replace(m[0], `<style>/* ${href} */\n${css}\n</style>`);
}
}
// Inline <script src="..."> (skip external URLs)
const scriptRe = /<script\b([^>]*)\bsrc=["']([^"']+)["'][^>]*><\/script>/gi;
const scriptMatches = [...html.matchAll(scriptRe)];
for (const m of scriptMatches) {
const src = m[2];
if (src.startsWith('http') || src.startsWith('//')) continue;
const fpath = dir ? `${dir}/${src}` : src;
const js = await localReadFile(_clientProjectPath(fpath)) ?? await localReadFile(fpath);
if (js !== null) {
html = html.replace(m[0], `<script${m[1]}>\n/* ${src} */\n${js}\n</script>`);
}
}
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 (localDirHandle) {
// Local folder is open → run via companion shell PTY so output stays in the local tab.
// Ensure local shell tab exists and is front.
const existingLocalId = [...localShellIds].find(id => codeTermShells.find(s => s.id === id));
const needsShell = !existingLocalId;
if (needsShell) addLocalShell();
else { if (!codeTermOpen) toggleCodeTerminal(); switchCodeTermShell(existingLocalId); }
const ext = file.name.split('.').pop().toLowerCase();
const bare = _clientBareFileName(file.name);
const base = (localTermPath || companionHome || '').replace(/[/\\]$/, '');
// Detect Windows from platform flag OR from drive-letter prefix (C:\...) in base path
const isWin = companionPlatform === 'windows' || /^[A-Za-z]:/.test(base);
const sep = isWin ? '\\' : '/';
const proj = activeProjectName ? sep + activeProjectName : '';
// Normalise all separators in the assembled path to match the platform
const rawPath = base ? base + proj + sep + bare : bare;
const normPath = isWin ? rawPath.replace(/\//g, '\\') : rawPath.replace(/\\/g, '/');
const localPath = `"${normPath}"`;
const pyCmd = isWin ? 'python' : 'python3';
const cmd = (ext === 'js' ? `node ${localPath}` :
ext === 'ts' ? `npx ts-node ${localPath}` :
`${pyCmd} ${localPath}`) + '\r';
setTimeout(() => {
const targetShellId = [...localShellIds].find(id => codeTermShells.find(s => s.id === id));
if (companionConnected && companionWs && targetShellId)
companionWs.send(JSON.stringify({ type: 'input', shellId: targetShellId, data: cmd }));
}, needsShell ? 900 : 0);
} else {
// No local folder → send code directly (one-shot run, output to active terminal)
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('/html/python-runner.html', '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
return;
}
if (mode === 'esp32') {
_emuPanelLoad('/arduino-emulator.html?loading=1', `ESP32 — ${file.name}`);
codeAiAppend('system', `🖥️ ESP32 에뮬레이터 컴파일 중... (${file.name})`);
try { localStorage.setItem('esp32_sketch', file.content); } catch {}
api('/api/arduino/qemu/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: file.content, fqbn: 'esp32:esp32:esp32' }) })
.then(d => {
if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); toggleEmuPanel(); return; }
const _proj2 = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
_emuPanelLoad(`/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=esp32%3Aesp32%3Aesp32${_proj2}`, `ESP32 — ${file.name}`);
codeAiAppend('system', `✅ ESP32 에뮬레이터 실행 중.`);
}).catch(e => { codeAiAppend('system', '⚠️ ' + e.message); toggleEmuPanel(); });
return;
}
if (mode === 'esp8266') {
_emuPanelLoad('/arduino-emulator.html?loading=1', `ESP8266 — ${file.name}`);
codeAiAppend('system', `🖥️ ESP8266 에뮬레이터 컴파일 중... (${file.name})`);
try { localStorage.setItem('esp8266_sketch', file.content); } catch {}
const _esp8266Fqbn = (_csbSelBoard.fqbn.startsWith('esp8266:')) ? _csbSelBoard.fqbn : 'esp8266:esp8266:nodemcuv2';
api('/api/arduino/esp8266/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: file.content, fqbn: _esp8266Fqbn }) })
.then(d => {
if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); toggleEmuPanel(); return; }
const _proj2 = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : '';
_emuPanelLoad(`/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(_esp8266Fqbn)}${_proj2}`, `ESP8266 — ${file.name}`);
codeAiAppend('system', `✅ ESP8266 에뮬레이터 실행 중.`);
}).catch(e => { codeAiAppend('system', '⚠️ ' + e.message); toggleEmuPanel(); });
return;
}
if (mode === 'arduino') {
const _isEsp32Board = (_csbSelBoard.fqbn.split(':')[1] || '') === 'esp32';
const _isEsp8266Board = (_csbSelBoard.fqbn.split(':')[0] || '') === 'esp8266';
const _isStm32Board = _csbSelBoard.fqbn.startsWith('STMicroelectronics:stm32');
if (_isEsp32Board || _isEsp8266Board || _isStm32Board) {
// ESP32/ESP8266/STM32 계열 — openArduinoEmulator()로 에뮬레이터 실행
openArduinoEmulator(); return;
}
codeAiAppend('system', `🔌 컴파일 중... (${file.name}) [${_csbSelBoard.name}]`);
fetch('/api/arduino/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders({}) },
body: JSON.stringify({ code: file.content, board: _csbSelBoard.fqbn, fqbn: _csbSelBoard.fqbn }),
}).then(r => r.json()).then(data => {
if (data.autoInstalled?.length) codeAiAppend('system', `📦 라이브러리 자동 설치: ${data.autoInstalled.join(', ')}`);
if (data.error) {
const extra = data.install ? `\n\n설치 필요: ${data.install}` : '';
sendCompileErrorToAI(data.error + extra, file.name, file.content);
return;
}
sessionStorage.setItem('arduino_filename', file.name);
sessionStorage.setItem('arduino_project', activeProjectName || '');
sessionStorage.setItem('arduino_board', _csbSelBoard.fqbn);
// boardType에 따라 결과 처리
if (data.merged) {
// (이미 위에서 처리됨, fallback)
codeAiAppend('system', `✅ 컴파일 완료.`);
} else if (data.hex) {
sessionStorage.setItem('arduino_hex', data.hex);
sessionStorage.setItem('arduino_sketch', file.content);
_emuPanelLoad('/arduino-emulator.html', `AVR — ${file.name}`);
} else if (data.boardType === 'rp2040') {
sessionStorage.setItem('arduino_uf2', data.bin);
sessionStorage.setItem('arduino_sketch', file.content);
_emuPanelLoad('/arduino-emulator.html', `RP2040 — ${file.name}`);
} else if (data.bin) {
const ext = data.boardType === 'uf2' ? '.uf2' : '.bin';
const blob = new Blob([Uint8Array.from(atob(data.bin), c => c.charCodeAt(0))], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = file.name.replace(/\.ino$/, '') + ext; a.click();
URL.revokeObjectURL(url);
const kb = (data.size / 1024).toFixed(1);
codeAiAppend('system', `✅ 컴파일 완료 (${kb} KB) — ${ext.slice(1).toUpperCase()} 다운로드됨.\n보드에 올리려면 ⚡ 업로드 버튼을 사용하세요. [${_csbSelBoard.name}]`);
}
}).catch(e => codeAiAppend('system', '⚠️ 서버 오류: ' + e.message));
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 _boardSpecificHints(code = '') {
const fqbn = _csbSelBoard.fqbn || '';
const arch = fqbn.split(':')[1] || '';
const usesWifi = /WiFi|ESP8266WiFi|HTTPClient|WebServer/i.test(code);
// 언어 직접 선택 (하드웨어 보드 아님)
if (fqbn === 'lang:python') {
return '\n' + [
'언어: Python',
'- 순수 Python 코드로 작성하세요. Arduino/C++ 문법 사용 금지',
'- 스크립트는 표준 Python 환경에서 실행됩니다',
].join('\n');
}
if (fqbn === 'lang:cpp') {
return '\n' + [
'언어: C++ (일반)',
'- 표준 C++17 코드로 작성하세요. Arduino 전용 API 사용 금지',
].join('\n');
}
if (fqbn === 'lang:other') return '';
// 하드웨어 보드: 공통 Arduino 규칙 + arch별 추가 규칙
if (!arch) return '';
const base = ['Arduino (C++) 코딩 규칙:', '- 언어: C++ (Arduino 스케치). PC 전용 앱이 필요하면 해당 언어 병행 가능'];
const extra = [];
if (arch === 'esp32') {
extra.push('- analogWrite() 없음 → ledcAttach(pin, freq, res) + ledcWrite(pin, duty)');
extra.push('- loop() 장시간 blocking → watchdog 리셋. delay() 또는 yield() 필수');
extra.push('- SPIFFS 대신 LittleFS 권장 (esp32 3.x 코어)');
if (usesWifi) extra.push(
'- WiFi 내장 — #include <WiFi.h> 하나로 충분. SPI.h 추가 금지',
'- WiFi.firmwareVersion() 없음 (WiFiNINA 전용)');
} else if (arch === 'esp8266') {
extra.push('- analogWrite() 있음 (PWM, 0-1023)');
extra.push('- loop()에서 yield() 또는 delay() 필수 (watchdog 방지)');
extra.push('- ledcAttach 등 ESP32 전용 API 사용 금지');
if (usesWifi) extra.push(
'- WiFi: #include <ESP8266WiFi.h>. #include <WiFi.h> 사용 금지',
'- 암호화 상수: ENC_TYPE_NONE / ENC_TYPE_CCMP / ENC_TYPE_TKIP');
} else if (arch === 'stm32') {
extra.push('- 핀 이름: PB_3·PA_0 등 STM32 PinName 상수 사용');
extra.push('- Serial = USART1 (PA9/PA10), Serial.begin(115200) 필수');
extra.push('- analogWrite() 없음 → HardwareTimer + PWM 채널 사용');
extra.push('- Wire(I2C1): SDA=PB7, SCL=PB6 / SPI1: MOSI=PA7, MISO=PA6, SCK=PA5');
} else if (arch === 'avr') {
if (usesWifi) extra.push('- WiFi 실드: #include <SPI.h> + #include <WiFiNINA.h>');
}
// sam, samd, megaavr 등은 base만 적용
return '\n' + [...base, ...extra].join('\n');
}
async function csbCompileOnly() {
if (!_csbSelBoard) { codeAiAppend('system', '⚠ 보드를 먼저 선택하세요.'); return; }
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !file.name.endsWith('.ino')) { codeAiAppend('system', '⚠ .ino 파일을 선택하세요.'); return; }
const code = file.model?.getValue() || file.content || '';
const btn = document.getElementById('code-compile-btn');
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
codeAiAppend('system', `🔨 컴파일 중... [${_csbSelBoard.name}]`);
try {
const r = await fetch('/api/arduino/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ code, fqbn: _csbSelBoard.fqbn }),
});
const data = await r.json();
if (data.error) {
sendCompileErrorToAI(data.error, file.name, code);
} else {
const kb = data.size ? (data.size / 1024).toFixed(1) : null;
const extra = data.autoInstalled?.length ? `\n자동 설치된 라이브러리: ${data.autoInstalled.join(', ')}` : '';
codeAiAppend('system', `✅ 컴파일 성공${kb ? ` (${kb} KB)` : ''} — 오류 없음.${extra}`);
// 업로드 패널에서 재컴파일 없이 바로 업로드할 수 있도록 캐시
_cupCompiledData = data;
_cupCompiledForCode = code;
_cupCompiledForFqbn = _csbSelBoard.fqbn;
_cupCode = code; _cupFqbn = _csbSelBoard.fqbn;
_cupBoardName = _csbSelBoard.name; _cupFname = file.name;
}
} catch (e) {
codeAiAppend('system', `컴파일 오류: ${e.message}`);
} finally {
if (btn) { btn.textContent = '🔨 컴파일'; btn.disabled = false; }
}
}
function sendCompileErrorToAI(error, filename, code) {
const inp = document.getElementById('code-ai-input');
if (!inp) return;
const boardInfo = `대상 보드: ${_csbSelBoard.name} (FQBN: ${_csbSelBoard.fqbn})`;
const hints = _boardSpecificHints(code);
// Library conflict (redefinition) — not fixable by editing the .ino file
if (/redefinition of/.test(error)) {
const m = error.match(/\/sketch\/(\S+?\.h)/);
const conflictHeader = m ? m[1] : 'unknown.h';
inp.value = `아두이노 컴파일 오류: **라이브러리 충돌**입니다. ${boardInfo}${hints}\n\n원인: \`${conflictHeader}\`가 코어 내장 헤더와 중복 정의(redefinition) 충돌을 일으키고 있습니다. ${filename} 코드의 \`#include\` 방향을 조정하거나 외부 라이브러리를 교체해야 합니다.\n\n오류:\n\`\`\`\n${error}\n\`\`\`\n\n해결 방법을 설명해주세요. 파일 검색은 하지 않아도 됩니다.`;
} else {
inp.value = `아두이노 컴파일 오류가 발생했습니다. ${boardInfo}${hints}\n\nRead로 ${filename}을 읽은 뒤 Edit 또는 Write 도구로 파일을 직접 수정해줘.\n\n오류:\n\`\`\`\n${error}\n\`\`\``;
}
codeAiSend();
}
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 _codeAiSending = false;
// Public entry. Guards against overlapping sends and doubles as a Stop control:
// while a generation is in flight, the 전송 button turns into ⏹ 중지 and a second
// click aborts the current request instead of starting a racing one.
async function codeAiSend() {
if (_codeAiSending) {
if (codeAiAbort) codeAiAbort.abort();
return;
}
const inp = document.getElementById('code-ai-input');
if (!inp || !inp.value.trim()) return;
_codeAiSending = true;
_setSendBtnBusy(true);
try {
await _codeAiSendImpl();
} finally {
_codeAiSending = false;
_setSendBtnBusy(false);
}
}
function _setSendBtnBusy(busy) {
const btn = document.getElementById('code-ai-send-btn');
if (!btn) return;
btn.textContent = busy ? '⏹ 중지' : '전송';
btn.classList.toggle('busy', busy);
}
async function _codeAiSendImpl() {
const input = document.getElementById('code-ai-input');
const message = input.value.trim();
const attachedImages = [..._pendingImages];
if (!message && !attachedImages.length) return;
input.value = ''; input.style.height = '';
_pendingImages = [];
_codeAiRenderImagePreview();
// @파일명 참조 처리 — 메시지에서 @xxx.js 패턴을 찾아 파일 내용 삽입
let resolvedMessage = message;
const atInlinedFiles = new Set(); // track files already inlined via @ref
if (localDirHandle) {
const atRefs = [...message.matchAll(/@([\w./-]+\.\w+)/g)];
for (const m of atRefs) {
const fname = activeProjectName ? `${activeProjectName}/${m[1]}` : m[1];
const content = await localReadFile(fname) ?? await localReadFile(m[1]);
if (content !== null) {
const ext = m[1].split('.').pop() || '';
resolvedMessage = resolvedMessage.replace(m[0],
`\n[파일: ${m[1]}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`);
atInlinedFiles.add(m[1]); // bare name
atInlinedFiles.add(fname); // project-prefixed name
}
}
}
// 프로젝트 내 모든 파일 내용을 컨텍스트에 포함 (현재 파일 제외, 별도 첨부)
let otherFilesCtx = '';
{
const currentFileName = codeFiles.find(f => f.id === activeCodeFileId)?.name;
const seen = new Set();
const CTX_TOTAL_BYTES = 500 * 1024; // 500KB total cap for other-files section
const SKIP_NAMES = /^(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.DS_Store|thumbs\.db)$/i;
const addFile = (rel, content) => {
if (otherFilesCtx.length >= CTX_TOTAL_BYTES) return;
const ext = rel.split('.').pop() || '';
const chunk = `[파일: ${rel}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`;
if (otherFilesCtx.length + chunk.length > CTX_TOTAL_BYTES) {
otherFilesCtx += `[나머지 파일 생략 — 컨텍스트 크기 초과]\n`;
} else {
otherFilesCtx += chunk;
}
};
// 1. All other open tabs (no project prefix required — covers both prefixed and bare filenames)
for (const f of codeFiles) {
if (f.name === currentFileName) continue;
// If activeProjectName set, only include files from same project.
// 로컬 모드(localDirHandle 있음)에서는 탭이 베어 경로("src/main.py")로 저장될 수 있으므로
// 첫 세그먼트가 알려진 다른 프로젝트 이름인 경우에만 제외한다.
if (activeProjectName && f.name.includes('/')) {
const isCurrentProjPrefix = f.name.startsWith(activeProjectName + '/');
if (!isCurrentProjPrefix) {
if (!localDirHandle) { continue; } // 서버 모드: 프로젝트 prefix 없으면 다른 프로젝트
// 로컬 모드: 첫 세그먼트가 알려진 다른 프로젝트 이름이면 제외
const firstSeg = f.name.split('/')[0];
const knownProjects = _knownProjectsGet();
if (knownProjects.includes(firstSeg) && firstSeg !== activeProjectName) continue;
}
}
if (!isCodeFile(f.name)) continue; // skip .md, .txt etc.
const bare = f.name.split('/').pop() || '';
if (SKIP_NAMES.test(bare)) continue;
seen.add(f.name);
const rel = activeProjectName ? f.name.replace(activeProjectName + '/', '') : f.name;
const content = f.model ? f.model.getValue() : f.content;
if (!content) continue;
addFile(rel, content);
}
// 1.5. Files Read by model in prior turns — inject full content (cross-turn context memory)
for (const [cachedName, cachedContent] of Object.entries(_readFileCache)) {
const currentBare = currentFileName ? _clientBareFileName(currentFileName) : '';
if (cachedName === currentBare) continue;
if (seen.has(cachedName) || seen.has(activeProjectName + '/' + cachedName)) continue;
seen.add(cachedName);
addFile(cachedName, cachedContent);
}
// 2. Files on disk not yet opened as tabs — manifest only (Claude Code 방식: 목록만 주고 필요 시 coder_read_file로 요청)
if (localDirHandle) {
try {
const allFiles = await localListFiles(localDirHandle, '');
const projFiles = activeProjectName
? allFiles.filter(f => f.startsWith(activeProjectName + '/'))
: allFiles;
const manifest = projFiles
.filter(f => {
const bare = activeProjectName ? f.slice(activeProjectName.length + 1) : f;
return !seen.has(f) && !seen.has(bare) && f !== currentFileName
&& isCodeFile(f) && !SKIP_NAMES.test(f.split('/').pop() || '');
})
.map(f => activeProjectName ? f.slice(activeProjectName.length + 1) : f);
if (manifest.length)
otherFilesCtx += `[로컬 파일 (Read 툴로 요청 가능): ${manifest.join(', ')}]\n\n`;
} catch {}
}
}
// Include current file context (ask/code 모드 모두 포함)
const currentFile = codeFiles.find(f => f.id === activeCodeFileId);
// otherFilesCtx는 currentFile 유무와 무관하게 항상 contextMsg에 포함 (현재 파일 없어도 프로젝트 파일 보이게)
let contextMsg = otherFilesCtx ? otherFilesCtx + resolvedMessage : resolvedMessage;
// Add tech hint for the model
const techHints = { html: '웹 (HTML/CSS/JS)', python: 'Python', node: 'Node.js/JavaScript', auto: '' };
const techHint = techHints[codeAiTech] || '';
if (techHint && !contextMsg.includes('[tech]')) {
contextMsg = `[tech: ${techHint}] ${contextMsg}`;
}
if (currentFile && monacoEditor) {
const content = monacoEditor.getValue();
// Diff editing only makes sense against REAL existing code. A brand-new tab
// seeded with a placeholder comment (e.g. "# 여기에 코드를 작성하세요") is not
// editable code — asking for a diff there makes the model emit a whole program
// as +lines, which then got saved verbatim ("+가 붙어 나옴"). Treat a file with
// no substantive (non-comment, non-blank) lines as new → request full code.
const hasRealCode = content.split('\n').some(l => {
const t = l.trim();
return t && !t.startsWith('#') && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*') && !t.startsWith('<!--');
});
if (content.trim() && currentFile.name !== 'untitled.py' && hasRealCode) {
const ext = currentFile.name.split('.').pop() || '';
const langHint = ext || currentFile.language || '';
const lines = content.split('\n');
let fileCtx, omitNote = '';
if (lines.length <= 3000) {
fileCtx = lines.map((l, i) => `${i + 1}: ${l}`).join('\n');
} else {
// Large file: always include header (first 80 lines) + cursor window + footer
const errorMatch = message.match(/line\s+(\d+)/i) || message.match(/라인\s*(\d+)/) || message.match(/(\d+)\s*줄/) || message.match(/Line\s+(\d+)/i);
let focusLine = (monacoEditor?.getPosition?.()?.lineNumber) || 1;
if (errorMatch) focusLine = parseInt(errorMatch[1]);
const headerEnd = Math.min(80, lines.length);
const ctx = 80;
const s = Math.max(headerEnd + 1, focusLine - ctx);
const e = Math.min(lines.length, focusLine + ctx);
const headerLines = lines.slice(0, headerEnd).map((l, i) => `${i + 1}: ${l}`).join('\n');
const windowLines = s <= e ? lines.slice(s - 1, e).map((l, i) => `${s + i}: ${l}`).join('\n') : '';
const structLines = [];
for (let i = 0; i < lines.length; i++) {
if (/^(class |def |async def |function |public |private |const |let |var |export )/i.test(lines[i].trimStart()))
structLines.push(`${i + 1}: ${lines[i].trim()}`);
}
const overview = structLines.length ? `\n[구조]\n${structLines.join('\n')}\n` : '';
const windowPart = windowLines && s > headerEnd + 1 ? `\n...(${s - headerEnd - 1}줄 생략)...\n${windowLines}` : (windowLines ? '\n' + windowLines : '');
fileCtx = headerLines + windowPart;
omitNote = ` (${lines.length}줄 중 앞 ${headerEnd}줄 + 커서 주변 표시)${overview}`;
}
// Byte safety net: a few-line minified/data file passes the line check but can be
// tens of MB. Hard-cap the assembled context so /api/chat never gets a giant body.
if (fileCtx.length > MAX_AI_FILE_BYTES) {
fileCtx = fileCtx.slice(0, MAX_AI_FILE_BYTES) + `\n…[파일이 너무 커서 잘림 — 전체 ${Math.round(content.length / 1024)}KB]`;
omitNote = omitNote || ` (대용량 ${Math.round(content.length / 1024)}KB — 앞부분만 표시)`;
}
// Use bare filename so model never sees the client project prefix
const displayName = _clientBareFileName(currentFile.name);
contextMsg = `${otherFilesCtx}[현재 파일: ${displayName}${omitNote}]\n\`\`\`${langHint}\n${fileCtx}\n\`\`\`\n\n${resolvedMessage}`;
}
}
// 이미지 첨부 시 채팅 버블에 썸네일 표시
const userBubble = codeAiAppend('user', message || '🖼️');
if (attachedImages.length) {
attachedImages.forEach(img => {
const im = document.createElement('img');
im.src = img.dataUrl;
im.style.cssText = 'max-width:120px;max-height:90px;border-radius:4px;display:block;margin-top:4px';
userBubble.appendChild(im);
});
}
const historyEntry = currentFile ? `[파일: ${_clientBareFileName(currentFile.name)}] ${message}` : message;
codeAiHistory.push({ role: 'user', content: historyEntry });
try { updateSessionPanel(); } catch (_) {}
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
_codeAiAutoAccept = true;
_autoAcceptTurn = true;
const thinkEl = codeAiAppend('thinking', '생각 중');
let _execSecs = 0;
const _execTimer = setInterval(() => {
if (!thinkEl.classList.contains('thinking')) { clearInterval(_execTimer); return; }
_execSecs++;
const base = thinkEl.dataset.status || '생각 중';
const warn = _execSecs >= 60 ? ' ⚠️ 모델이 길게 생각 중' : '';
thinkEl.textContent = `${base} ${_execSecs}s${warn}`;
}, 1000);
_showLogBubble();
// Full-file context (Edit tool needs exact text to match)
let execContextMsg = contextMsg;
if (currentFile && monacoEditor && localDirHandle) {
const fullContent = monacoEditor.getValue();
const ext = currentFile.name.split('.').pop() || '';
const displayName = _clientBareFileName(currentFile.name);
let numberedFull = fullContent.split('\n').map((l, i) => `${i + 1}: ${l}`).join('\n');
let bigNote = '';
if (numberedFull.length > MAX_AI_FILE_BYTES) {
numberedFull = numberedFull.slice(0, MAX_AI_FILE_BYTES) + `\n…[파일이 너무 커서 잘림 — 전체 ${Math.round(fullContent.length / 1024)}KB]`;
bigNote = ` — 대용량, 앞부분만`;
}
execContextMsg = `${otherFilesCtx}[현재 파일: ${displayName} (전체 ${fullContent.split('\n').length}줄${bigNote})]\n\`\`\`${ext}\n${numberedFull}\n\`\`\`\n\n${resolvedMessage}`;
}
try {
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
// Build messages for client-owned loop (Claude Code style)
const _statusMove = (text) => { thinkEl.dataset.status = text; thinkEl.textContent = `${text} ${_execSecs}s`; thinkEl.style.display = ''; const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); codeAiScrollBottom(); };
const sysPrompt = _buildCodeSystemPrompt();
// 이미지 첨부 시 content를 배열로 구성 (vision API 포맷)
const userContent = attachedImages.length
? [
{ type: 'text', text: execContextMsg },
...attachedImages.map(img => ({ type: 'image_url', image_url: { url: img.dataUrl } })),
]
: execContextMsg;
let messages = [
{ role: 'system', content: sysPrompt },
..._histForAPI(codeAiHistory),
{ role: 'user', content: userContent },
];
let finalReply = '', _firstToolSeen = false;
let liveTextEl = null, segReply = '';
_statusMove('분석 중');
// Client-owned conversation loop (Claude Code style: Read/Write/Edit/Glob/Grep/Bash)
while (true) {
const { reply, toolCalls, usage } = await _codeAiOneRound(messages, (tok) => {
segReply += tok;
if (!liveTextEl) { liveTextEl = codeAiAppend('assistant', ''); liveTextEl.classList.add('streaming'); }
liveTextEl.textContent = segReply; codeAiScrollBottom();
});
finalReply = reply || segReply;
if (usage) _codeAiOnUsage(usage);
if (!toolCalls.length) break;
// Finalize pre-tool text bubble
if (liveTextEl) {
if (!segReply.trim()) liveTextEl.remove();
else { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
liveTextEl = null; segReply = '';
}
_firstToolSeen = true;
// Execute each tool client-side
const ollamaToolCalls = [], toolResultContents = [];
for (const tc of toolCalls) {
const { id, name, args } = tc;
const shortFname = (args.file || args.filename || args.pattern || args.command || '').split('/').pop() || '';
_statusMove(`${_TOOL_ICONS[name] || '🔧'} ${name}${shortFname ? ': ' + shortFname : ''} ...`);
_showLogBubble();
_codeAiToolAction(name, args, null, null);
const { result, error } = await _executeToolLocal(name, args);
_codeAiToolAction(name, null, result, error);
if (!error && ['Write', 'Edit'].includes(name)) setTimeout(() => codeSbRefresh(), 200);
ollamaToolCalls.push({ id: id || `call_${name}_${Date.now()}`, function: { name, arguments: args } });
toolResultContents.push(result);
}
_statusMove('💭 다음 단계 분석 중...');
// Append tool round to messages (Ollama native format)
messages.push({ role: 'assistant', content: finalReply || '', tool_calls: ollamaToolCalls });
for (const r of toolResultContents) messages.push({ role: 'tool', content: r });
}
// Finalize history and UI
codeAiHistory.push({ role: 'assistant', content: _histEntry(finalReply) || '(응답 없음)' });
_compressHistory(); // fire-and-forget: runs in background, saves after completion
_autoSaveChatSession();
if (activeProjectName) saveProjectSession(activeProjectName);
try { updateSessionPanel(); } catch (_) {}
thinkEl.classList.remove('streaming');
if (_firstToolSeen) {
thinkEl.remove();
_hideLogBubble();
if (liveTextEl) { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
const doneEl = codeAiAppend('system', '✅ 완료');
doneEl.style.cssText = 'font-weight:600;color:#4ade80;';
setTimeout(() => codeSbRefresh(), 400);
} else {
_hideLogBubble();
thinkEl.className = 'code-ai-msg assistant';
if (!finalReply) { thinkEl.textContent = '(응답 없음)'; thinkEl.style.display = ''; }
else { codeAiRenderMarkdown(thinkEl, finalReply); thinkEl.style.display = ''; }
}
} catch(e) {
_hideLogBubble();
if (!thinkEl.isConnected) { const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); }
thinkEl.style.display = '';
thinkEl.className = 'code-ai-msg thinking';
const errMsg = (e.name === 'AbortError' || (e.message || '').includes('aborted'))
? '요청이 취소되었습니다. 다시 시도해주세요.'
: (e.message || e);
thinkEl.textContent = '오류: ' + errMsg;
_autoAcceptTurn = false;
} finally {
clearInterval(_execTimer);
_codeAiAutoAccept = false;
}
}
// ── Live server log bubble ────────────────────────────────────────────────
let _logBubble = null;
let _logPollTimer = null;
let _logBubbleStartCount = 0; // totalCount watermark — only show logs from after bubble appeared
function _showLogBubble() {
if (_logBubble) return;
const msgs = document.getElementById('code-ai-messages');
if (!msgs) return;
_logBubble = document.createElement('div');
_logBubble.className = 'code-ai-log-bubble';
_logBubble.innerHTML = '<span class="log-live-dot"></span><span class="log-lines"></span>';
msgs.appendChild(_logBubble);
codeAiScrollBottom();
// Record current totalCount so we only show logs that arrive during AI processing
fetch('/api/logs', { headers: authHeaders({}) })
.then(r => r.json()).then(d => { _logBubbleStartCount = d.totalCount || 0; })
.catch(() => {})
.finally(() => { _logPollTimer = setInterval(_pollLogBubble, 800); });
}
async function _pollLogBubble() {
if (!_logBubble) return;
try {
const r = await fetch('/api/logs', { headers: authHeaders({}) });
if (!r.ok) return;
const data = await r.json();
const allEntries = data.entries || [];
const newCount = Math.max(0, (data.totalCount || 0) - _logBubbleStartCount);
const newEntries = newCount > 0 ? allEntries.slice(-Math.min(newCount, allEntries.length)) : [];
const lines = newEntries.filter(e => !/^\[v\d\]|^\[task\]|^\[orch\]/.test(e.msg)).slice(-4).map(e => e.msg.slice(0, 90));
const linesEl = _logBubble.querySelector('.log-lines');
if (linesEl) linesEl.textContent = lines.join('\n');
codeAiScrollBottom();
} catch {}
}
function _hideLogBubble() {
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
const b = _logBubble; _logBubble = null;
if (b) { b.style.opacity = '0'; setTimeout(() => b.remove(), 400); }
}
// ── Tool action cards for Cline-style visibility ──────────────────────────
const _TOOL_ICONS = {
Read: '📖', Write: '📄', Edit: '✏️', Glob: '🔍', Grep: '🔎', Bash: '▶️',
};
const _TOOL_COLORS = {
Read: '#3b82f6', Write: '#22c55e', Edit: '#f59e0b', Glob: '#3b82f6', Grep: '#8b5cf6', Bash: '#f59e0b',
};
const _FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
function _codeAiToolAction(toolName, toolArgs, toolResult, toolError) {
const msgs = document.getElementById('code-ai-messages');
if (!msgs) return;
const icon = _TOOL_ICONS[toolName] || '🔧';
const color = _TOOL_COLORS[toolName] || '#6b7280';
if (toolArgs !== null) {
// Tool call event — show action card
const card = document.createElement('div');
card.className = 'code-ai-tool-card';
card.dataset.tool = toolName;
const _looksLikeFilename = (s) => s && typeof s === 'string' && s.length < 200 && !s.includes('\n') && !s.includes('\t');
const fname = (_looksLikeFilename(toolArgs.file) ? toolArgs.file : _looksLikeFilename(toolArgs.filename) ? toolArgs.filename : toolArgs.name || '');
const shortName = fname.replace(/^code\//, '').split('/').pop() || '';
const isFile = _FILE_TOOLS.has(toolName);
let detail;
if (toolName === 'Write') {
const lines = toolArgs.content ? toolArgs.content.split('\n').length : 0;
detail = escHtml((shortName || fname) + (lines ? ` (${lines}줄)` : ''));
} else if (isFile) {
detail = escHtml(shortName || toolArgs.pattern || fname);
} else {
detail = escHtml((toolArgs.command || toolArgs.query || toolArgs.code || '').slice(0, 60));
}
card.innerHTML = `<span class="code-ai-tool-icon" style="color:${color}">${icon}</span> <span class="code-ai-tool-name">${escHtml(toolName)}</span> <span class="code-ai-tool-detail" style="color:var(--muted);font-size:11px">${detail}</span>`;
msgs.appendChild(card);
codeAiScrollBottom();
} else if (toolResult !== null) {
// Tool result event — update last matching card or append result
const cards = msgs.querySelectorAll(`.code-ai-tool-card[data-tool="${toolName}"]`);
const card = cards[cards.length - 1];
if (card && !card.dataset.done) {
card.dataset.done = '1';
const resultIcon = toolError ? '❌' : '✅';
const resultText = toolError ? escHtml((toolResult || 'Error').slice(0, 120)) : escHtml((toolResult || '').slice(0, 80));
const resultEl = document.createElement('span');
resultEl.className = 'code-ai-tool-result';
resultEl.innerHTML = resultText ? `${resultIcon} ${resultText}` : resultIcon;
card.appendChild(resultEl);
if (toolError) card.style.borderLeftColor = '#f87171';
}
codeAiScrollBottom();
}
}
// ── Client-owned tool execution (Read/Write/Edit/Glob/Grep/Bash) ──────────────
function _globMatch(pattern, filepath) {
// Convert glob pattern to regex: ** → any path, * → non-slash chars, ? → one non-slash
const re = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '__DOUBLESTAR__')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]')
.replace(/__DOUBLESTAR__/g, '.*');
return new RegExp('^' + re + '$').test(filepath);
}
async function _executeToolLocal(name, args) {
try {
switch (name) {
case 'Read': {
const fname = args.file || '';
if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
const raw = await localReadFile(_clientProjectPath(_clientBareFileName(fname)))
?? await localReadFile(_clientBareFileName(fname));
if (raw === null) {
const tab = codeFiles.find(f => f.name === fname || f.name.endsWith('/' + fname));
if (tab) {
const tc = tab.model ? tab.model.getValue() : tab.content;
_readFileCache[_clientBareFileName(fname)] = tc;
const lines = tc.split('\n');
const offset = args.offset ? Math.max(0, (args.offset|0) - 1) : 0;
const limit = args.limit ? (args.limit|0) : lines.length;
return { result: lines.slice(offset, offset + limit).map((l, i) => `${offset + i + 1}: ${l}`).join('\n'), error: false };
}
return { result: `오류: ${fname} 파일을 찾을 수 없습니다`, error: true };
}
_readFileCache[_clientBareFileName(fname)] = raw;
const lines = raw.split('\n');
const offset = args.offset ? Math.max(0, (args.offset|0) - 1) : 0;
const limit = args.limit ? (args.limit|0) : lines.length;
const sliced = lines.slice(offset, offset + limit);
return { result: sliced.map((l, i) => `${offset + i + 1}: ${l}`).join('\n'), error: false };
}
case 'Write': {
const fname = args.file || '';
const newContent = args.content ?? '';
if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
if (fname.startsWith('#!') || /^\//.test(fname)) return { result: `오류: 유효하지 않은 파일명: ${fname.slice(0, 60)}`, error: true };
const bare = _clientBareFileName(fname);
const diskPath = _clientProjectPath(bare);
if (localDirHandle) await localWriteFile(diskPath, newContent);
codeEditorWriteFile(bare, newContent);
_readFileCache[bare] = newContent;
return { result: `${bare} 저장 완료 (${newContent.split('\n').length}줄)`, error: false };
}
case 'Edit': {
const fname = args.file || '';
const oldStr = args.old_string ?? '';
const newStr = args.new_string ?? '';
if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
if (fname.startsWith('#!') || /^\//.test(fname)) return { result: `오류: 유효하지 않은 파일명: ${fname.slice(0, 60)}`, error: true };
const bare = _clientBareFileName(fname);
const existing = await _codeAiReadLocalFile(bare);
if (existing === null) return { result: `오류: ${bare}을(를) 찾을 수 없습니다`, error: true };
if (!existing.includes(oldStr)) {
const preview = oldStr.slice(0, 60).replace(/\n/g, '↵');
return { result: `오류: old_string을 파일에서 찾을 수 없습니다\n검색: "${preview}…"`, error: true };
}
const updated = existing.replace(oldStr, newStr);
const diskPath = _clientProjectPath(bare);
if (localDirHandle) await localWriteFile(diskPath, updated);
codeEditorWriteFile(bare, updated);
_readFileCache[bare] = updated;
return { result: `${bare} 수정 완료`, error: false };
}
case 'Glob': {
const pattern = args.pattern || '**/*';
if (!localDirHandle) return { result: '(로컬 폴더가 연결되지 않았습니다)', error: false };
const allFiles = await localListFiles(localDirHandle, '');
const matches = allFiles.filter(f => _globMatch(pattern, f));
return { result: matches.length ? matches.join('\n') : '(매칭 파일 없음)', error: false };
}
case 'Grep': {
const pattern = args.pattern || '';
const searchPath = args.path || null;
const ci = !!args.case_insensitive;
if (!localDirHandle && !searchPath) return { result: '(로컬 폴더가 연결되지 않았습니다)', error: false };
let files;
if (searchPath) {
files = [_clientBareFileName(searchPath)];
} else {
files = await localListFiles(localDirHandle, '');
const CODE_EXTS_RE = /\.(js|ts|jsx|tsx|py|java|c|cpp|h|cs|go|rs|php|rb|swift|kt|html|css|json|yaml|yml|xml|md|sh|sql)$/i;
files = files.filter(f => CODE_EXTS_RE.test(f));
}
let regex;
try { regex = new RegExp(pattern, ci ? 'gi' : 'g'); } catch { regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ci ? 'gi' : 'g'); }
const results = [];
for (const f of files) {
if (results.length >= 200) break;
const fc = await localReadFile(_clientProjectPath(f)) ?? await localReadFile(f);
if (!fc) continue;
fc.split('\n').forEach((line, idx) => {
if (regex.test(line)) results.push(`${f}:${idx + 1}: ${line.trim()}`);
regex.lastIndex = 0;
});
}
return { result: results.slice(0, 200).join('\n') || '(결과 없음)', error: false };
}
case 'Bash': {
const cmd = args.command || '';
if (!cmd) return { result: '오류: command 인수가 필요합니다', error: true };
if (!localDirHandle) return { result: '⚠️ Bash를 사용하려면 로컬 폴더를 먼저 연결해주세요 (🖥️ 버튼)', error: true };
if (!companionWs || companionWs.readyState !== WebSocket.OPEN) {
return { result: '⚠️ companion.py가 연결되지 않았습니다. Bash 명령을 실행하려면 companion.py를 먼저 실행하세요.', error: true };
}
return await new Promise((resolve) => {
const reqId = 'bash_' + Date.now();
const timeout = setTimeout(() => resolve({ result: '오류: 명령 실행 시간 초과 (30s)', error: true }), 30000);
const handler = (evt) => {
try {
const d = JSON.parse(evt.data);
if (d.reqId === reqId) {
clearTimeout(timeout);
companionWs.removeEventListener('message', handler);
resolve({ result: d.output ?? d.result ?? '', error: !!d.error });
}
} catch {}
};
companionWs.addEventListener('message', handler);
companionWs.send(JSON.stringify({ type: 'exec', command: cmd, reqId }));
});
}
default:
return { result: `알 수 없는 툴: ${name}`, error: true };
}
} catch (e) {
return { result: `오류: ${e?.message || e}`, error: true };
}
}
async function _compressHistory() {
const COMPRESS_AT = 24;
const KEEP_RECENT = 8;
if (codeAiHistory.length <= COMPRESS_AT) return;
const compressEntries = codeAiHistory.slice(0, codeAiHistory.length - KEEP_RECENT);
const keepEntries = codeAiHistory.slice(codeAiHistory.length - KEEP_RECENT);
const prevSummary = _historySummary ? `[이전 요약]\n${_historySummary}\n\n` : '';
const histText = compressEntries.map(m => {
const role = m.role === 'user' ? '사용자' : 'AI';
return `${role}: ${(m.content || '').slice(0, 600)}`;
}).join('\n\n');
const summaryMessages = [
{ role: 'system', content: '당신은 코딩 세션 요약 전문가입니다.' },
{ role: 'user', content: `${prevSummary}다음 코딩 대화를 요약하세요. 어떤 파일을 다뤘는지, 어떤 수정을 했는지, 해결된 문제와 현재 상태를 5~8문장으로 간결하게:\n\n${histText}` },
];
try {
const resp = await fetch('/api/code/round', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ messages: summaryMessages, tools: [], sessionId: codeAiSessionId }),
});
if (!resp.ok) return;
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '', summary = '';
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;
try {
const ev = JSON.parse(line.slice(5).trim());
if (ev.type === 'done') summary = ev.reply || summary;
else if (ev.type === 'token') summary += ev.text;
} catch {}
}
}
if (!summary.trim()) return;
_historySummary = summary.trim();
codeAiHistory = keepEntries;
try { _autoSaveChatSession(); if (activeProjectName) saveProjectSession(activeProjectName); } catch {}
} catch {}
}
async function _codeAiOneRound(messages, onToken) {
const resp = await fetch('/api/code/round', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
signal: codeAiAbort?.signal,
body: JSON.stringify({
messages,
tools: CLIENT_TOOL_DEFS,
sessionId: codeAiSessionId,
...(codeAiModel ? { model: codeAiModel } : {}),
...(_csbSelBoard?.fqbn ? { boardContext: `${_csbSelBoard.name} (${_csbSelBoard.fqbn})` } : {}),
}),
});
if (!resp.ok) throw new Error(`/api/code/round ${resp.status}: ${await resp.text()}`);
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '', reply = '', usage = null, toolCalls = [];
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 raw = line.slice(5).trim();
if (raw === '[DONE]') continue;
try {
const ev = JSON.parse(raw);
if (ev.type === 'token' && ev.text) { onToken(ev.text); reply += ev.text; }
else if (ev.type === 'usage') { usage = ev; }
else if (ev.type === 'done') {
reply = ev.reply || reply;
toolCalls = ev.toolCalls || [];
usage = ev.usage || usage;
} else if (ev.type === 'error') { throw new Error(ev.message || 'Server error'); }
} catch (parseErr) {
if (parseErr.message?.includes('Server error') || parseErr.message?.includes('/api/code/round')) throw parseErr;
}
}
}
return { reply, toolCalls, usage };
}
function _buildCodeSystemPrompt() {
// 현재 에디터에 열린 파일 목록
const openFiles = codeFiles.map(f => f.name).filter(Boolean);
const activeFile = (() => { try { return codeFiles.find(f => f.id === activeCodeFileId)?.name || ''; } catch { return ''; } })();
const summaryBlock = _historySummary ? `\n[이전 대화 요약]\n${_historySummary}\n` : '';
const _activeCode = (() => { try { const f = codeFiles.find(f => f.id === activeCodeFileId); return f?.model?.getValue() || f?.content || ''; } catch { return ''; } })();
const boardHints = _boardSpecificHints(_activeCode);
const isLangOnly = _csbSelBoard?.fqbn?.startsWith('lang:');
const libBlock = (!isLangOnly && _csbBoardLibs.length)
? `\n설치된 라이브러리: ${_csbBoardLibs.join(', ')}`
: '';
const boardBlock = _csbSelBoard?.fqbn
? `\n## ${isLangOnly ? '코딩 언어' : '대상 보드'}: ${_csbSelBoard.name}${isLangOnly ? '' : ` (${_csbSelBoard.fqbn})`}${boardHints}${libBlock}\n`
: '';
// 에디터 파일 컨텍스트 — 로컬 폴더와 분리하여 강조
let editorNote = '';
if (openFiles.length) {
editorNote = `\n## 에디터 파일 (작업 대상)
현재 에디터에 열린 파일: ${openFiles.join(', ')}${activeFile ? `\n현재 활성 파일: **${activeFile}**` : ''}
⚠️ 코드 작업은 이 파일들을 대상으로 하세요. 로컬 폴더를 Glob/list해서 프로젝트를 파악하려 하지 마세요.\n`;
}
// 로컬 폴더 노트 (파일 저장 경로)
const localNote = localDirHandle
? `로컬 폴더 "${localDirHandle.name}"이 파일 저장소로 연결되어 있습니다.`
: '로컬 폴더가 연결되지 않았습니다.';
const karpathyBlock = _karpathyMode ? `
## Karpathy 코딩 가이드라인 (필수)
1. Think Before Coding — 가정을 명시하고, 모호하면 질문하세요. 여러 해석 가능 시 제시 후 선택하세요.
2. Simplicity First — 최소한의 코드로 문제를 해결하세요. 요청받지 않은 기능, 단일 사용 추상화, 불필요한 에러 핸들링을 넣지 마세요.
3. Surgical Changes — 사용자 요청과 직접 관련 없는 코드는 건드리지 마세요. 기존 스타일을 그대로 따르세요. 내 변경으로 인한 고아 코드만 제거하세요.
4. Goal-Driven Execution — 작업을 검증 가능한 목표로 변환하세요. "버그 수정" → "재현 테스트 작성 → 통과".
` : '';
return `당신은 전문 코드 어시스턴트입니다. ${localNote}${boardBlock}${editorNote}${summaryBlock}${karpathyBlock}
## 툴 사용 지침
- 파일을 수정하기 전에 반드시 Read로 현재 내용을 확인하세요.
- **Edit**: 1~2곳의 정밀 교체에만 사용. old_string은 파일 내용과 정확히 일치해야 합니다 (공백·들여쓰기 포함).
- **Write**: 수정이 3곳 이상이거나 전체 리팩토링·일괄 변경 시 사용. Read로 읽은 내용을 수정한 뒤 Write로 교체하세요. Edit 반복보다 훨씬 빠릅니다.
- Glob으로 파일 목록 검색, Grep으로 코드 내용 검색이 가능합니다.
- Bash는 companion.py 연결 시 터미널 명령을 실행합니다.
- **파일을 2번 검색해도 없으면 즉시 멈추세요.** 같은 패턴으로 반복 검색하지 말고, 파일이 없다는 사실을 사용자에게 설명한 뒤 다른 해결책을 제안하세요.
## 파일 경로 규칙 (중요)
- 파일 경로에 절대경로·Windows 경로를 절대 사용하지 마세요: Documents/, Desktop/, C:\\, /home/ 등 금지.
- 프로젝트 루트 기준 상대 경로만 사용하세요. 예: \`main.ino\`, \`src/utils.cpp\`, \`lib/helper.h\`
- 새 프로젝트를 만들면 해당 폴더가 루트입니다. 별도의 상위 폴더를 만들지 마세요.
## 회로도 파일 규칙 (중요)
- \`circuit.json\` (회로도)은 사용자가 **명시적으로 회로도 편집을 요청**할 때만 읽거나 수정하세요.
- "TFT 연결해줘", "회로도에 부품 추가해줘" 같이 회로도 편집을 직접 언급한 경우에만 해당.
- 코드 작성·분석·업로드 등 일반 작업 중에는 circuit.json을 열지 마세요.
## 응답 원칙
- **코드 변경은 반드시 Edit 또는 Write 도구로만** 하세요. 텍스트로 "수정했습니다", "수정 완료", 마크다운 코드 블록 출력으로 파일이 바뀌지 않습니다.
- 변경 사항은 도구 호출 후 한 줄로 간결하게 설명하세요.
- 여러 파일을 수정해야 한다면 순서대로 각각 처리하세요.`;
}
// Read a file from editor tabs (preferred) or local folder
async function _codeAiReadLocalFile(fname) {
// Normalise: strip code/ prefix, then strip project prefix → bare name
const bare = _clientBareFileName(fname.replace(/^code\//, ''));
// Try editor tabs first (most up-to-date); tabs store bare names in client-local mode
const tab = codeFiles.find(f => f.name === bare || f.name === fname || f.name.endsWith('/' + bare));
if (tab) return tab.model ? tab.model.getValue() : tab.content;
// Try client-local disk (scoped to project subfolder when applicable)
if (localDirHandle) {
const content = await localReadFile(_clientProjectPath(bare));
if (content !== null) return content;
// Fallback: bare path (covers root-level files when no activeProjectName)
if (_clientProjectPath(bare) !== bare) {
const c2 = await localReadFile(bare);
if (c2 !== null) return c2;
}
}
return null;
}
// Strip "123: " line-number prefixes the model copies from context (e.g. "42: def foo():")
function _stripLineNumbers(text) {
// Only strip if EVERY non-empty line starts with a number prefix — avoids stripping real code
const lines = text.split('\n');
if (lines.every(l => !l.trim() || /^\d+: /.test(l))) {
return lines.map(l => l.replace(/^\d+: /, '')).join('\n');
}
return text;
}
// Apply SEARCH/REPLACE blocks to content (client-side mirror of server coder_patch_file)
function _applySearchReplace(content, edits) {
if (!edits || !content) return null;
// Normalize line endings in both content and edits (\r\n / \r → \n)
const normNl = s => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
content = normNl(content);
edits = normNl(edits);
const srRegex = /------- SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n\+\+\+\+\+\+\+ REPLACE/g;
let match;
let result = content;
let editCount = 0;
while ((match = srRegex.exec(edits)) !== null) {
// Strip line-number prefixes the model might have copied from numbered context
const searchText = _stripLineNumbers(match[1]);
const replaceText = _stripLineNumbers(match[2]);
if (result.includes(searchText)) {
result = result.replace(searchText, replaceText);
editCount++;
} else {
// Fallback 1: trim trailing whitespace per line
const trimEnd = s => s.split('\n').map(l => l.trimEnd()).join('\n');
const trimmedResult = trimEnd(result);
const trimmedSearch = trimEnd(searchText);
if (trimmedResult.includes(trimmedSearch)) {
const lines = result.split('\n');
const searchLines = searchText.split('\n');
const normLines = lines.map(l => l.trimEnd());
const normSearch = searchLines.map(l => l.trimEnd());
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
const originalText = lines.slice(i, i + searchLines.length).join('\n');
result = result.replace(originalText, replaceText);
editCount++;
break;
}
}
} else {
// Fallback 2: ignore leading indentation (model sometimes guesses wrong indent)
const stripIndent = s => s.split('\n').map(l => l.trimStart()).join('\n');
const stripped = stripIndent(result);
const strippedSearch = stripIndent(searchText);
if (stripped.includes(strippedSearch)) {
const lines = result.split('\n');
const searchLines = searchText.split('\n');
const normLines = lines.map(l => l.trimStart());
const normSearch = searchLines.map(l => l.trimStart());
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
// Detect the actual indentation from the first matching line and re-indent replaceText
const actualIndent = lines[i].match(/^(\s*)/)?.[1] || '';
const searchIndent = searchLines[0].match(/^(\s*)/)?.[1] || '';
const reindented = replaceText.split('\n').map(l => {
const trimmed = l.startsWith(searchIndent) ? l.slice(searchIndent.length) : l.trimStart();
return actualIndent + trimmed;
}).join('\n');
const originalText = lines.slice(i, i + searchLines.length).join('\n');
result = result.replace(originalText, reindented);
editCount++;
break;
}
}
}
}
}
}
return editCount > 0 ? result : null;
}
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;
// Add streaming class during live token output
if (role === 'thinking' || role === 'assistant') el.classList.add('streaming');
msgs.appendChild(el);
codeAiScrollBottom();
return el;
}
// ── Inline markdown rendering (bold, italic, inline code, links) ──────────
function _renderInlineMarkdown(text) {
// Escape HTML first
let html = escHtml(text);
// **bold**
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// *italic*
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// `inline code`
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--panel-2);padding:1px 4px;border-radius:3px;font-family:IBM Plex Mono,monospace;font-size:11px;">$1</code>');
// [link](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--brand);">$1</a>');
// Line breaks
html = html.replace(/\n/g, '<br>');
return html;
}
function _isDiffCode(lang, code) {
if (lang === 'diff') return true;
if (['text', 'sh', 'bash', 'shell', 'console'].includes(lang)) return false;
const lines = code.split('\n');
if (!lines.some(l => l.trim())) return false;
// An @@ hunk header is an unambiguous diff signal.
if (lines.some(l => l.startsWith('@@'))) return true;
// Count single-char +/- markers. The old test required a space after the
// marker (/^[+\-] /), which BOTH missed standard `+code`/`-code` diffs (no
// space → markers leaked into the file as literal text) AND misfired on
// markdown "- item" lists. Exclude +++/--- file headers and ++/-- runs.
const plus = lines.filter(l => /^\+(?!\+\+)/.test(l)).length;
const minus = lines.filter(l => /^-(?!--)/.test(l)).length;
if (plus === 0 && minus === 0) return false;
// A two-sided change (both adds and removes) is unambiguously a diff.
if (plus > 0 && minus > 0) return true;
// Single-sided: only a diff if accompanied by real context lines. A diff
// context line is " <code>" — a marker space then content that is NOT itself a
// bullet ("- "/"* "/"+ "). This separates a unified-diff body from a plain
// YAML/markdown list, whose items are bullets at any indent.
const strongContext = lines.filter(l =>
/^ /.test(l) && l.trim() && !/^[-*+]\s/.test(l.trim())).length;
if (strongContext > 0) return true;
// Note: we do NOT use filename comments as a diff indicator here.
// `# filename:` comments appear in both diff blocks AND new-file blocks,
// so using them to detect diffs causes false positives that break multi-file
// extraction (new files get misclassified as diffs and silently dropped).
return false;
}
// Render AI message with markdown code blocks parsed out
// Uses the same line-start fence detection as codeLiveStreamUpdate to avoid
// false matches on ``` inside code (comments, strings, etc.)
function codeAiRenderMarkdown(el, text) {
el.innerHTML = '';
// Detect explicit plan blocks: 📋 marker OR 3+ consecutive numbered items (1. / 1) )
// Narrow enough to avoid false-positives on normal prose starting with → or ▶
const planMarkerRe = /^📋\s*.+/m;
const numberedListRe = /(?:^\d+[).]\s+.+\n){3,}/m;
const isPlan = planMarkerRe.test(text) || numberedListRe.test(text);
let remaining = text;
if (isPlan) {
const lines = text.split('\n');
const planLines = [];
let inPlan = false;
for (const line of lines) {
if (/^📋/.test(line) || /^\d+[).]\s+/.test(line)) { inPlan = true; planLines.push(line); }
else if (inPlan && line.trim() === '') break;
else if (inPlan) planLines.push(line);
}
if (planLines.length >= 2) {
const planDiv = document.createElement('div');
planDiv.className = 'code-ai-plan';
// Only prepend 📋 계획 header if the first line isn't already a header/emoji title
const firstLine = planLines[0].trim();
const hasTitle = /^📋/.test(firstLine);
const header = hasTitle ? '' : '<strong>📋 계획</strong><br>';
planDiv.innerHTML = header + planLines.map(l => escHtml(l)).join('<br>');
el.appendChild(planDiv);
const lastPlanLine = planLines[planLines.length - 1];
const planEnd = text.indexOf('\n', text.indexOf(lastPlanLine) + lastPlanLine.length);
remaining = planEnd > 0 ? text.slice(planEnd + 1) : '';
}
}
// Parse code blocks using line-start toggle parser (same as streaming)
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
const parts = []; // alternating: text, {lang, code}, text, {lang, code}, ...
let lastEnd = 0, inBlock = false, blockLang = '', blockStart = 0, m;
while ((m = fenceRe.exec(remaining)) !== null) {
// Require ``` at start of line (only whitespace before it)
const lineStart = m.index === 0 ? 0 : remaining.lastIndexOf('\n', m.index - 1) + 1;
const prefix = remaining.slice(lineStart, m.index);
if (prefix.trim() !== '') continue; // mid-line ```, skip
if (!inBlock) {
// Text before this opening fence
if (m.index > lastEnd) parts.push({ type: 'text', content: remaining.slice(lastEnd, m.index) });
blockLang = m[1] || 'text';
blockStart = m.index + m[0].length;
inBlock = true;
} else {
// Closing fence — emit the code block
parts.push({ type: 'code', lang: blockLang, code: remaining.slice(blockStart, m.index) });
lastEnd = m.index + m[0].length;
inBlock = false;
}
}
// Handle unclosed block (streaming or malformed).
if (inBlock) {
const rawCode = remaining.slice(blockStart).replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
parts.push({ type: 'code', lang: blockLang, code: rawCode });
lastEnd = remaining.length;
}
// Trailing text after last block
if (lastEnd < remaining.length) parts.push({ type: 'text', content: remaining.slice(lastEnd) });
let codeCount = 0;
const codeBlocks = [];
_DBG && console.log(`[codeAiRenderMarkdown] parts=${parts.length} types=${parts.map(p=>p.type).join(',')}`);
for (const part of parts) {
if (part.type === 'code') {
const lang = part.lang || 'text';
const code = part.code;
codeBlocks.push({ lang, code });
const isDiff = _isDiffCode(lang, code);
// Extract filename from '# filename: ...' comment if present
let diffFilename = null;
if (isDiff) {
const m = code.match(/^#\s*filename:\s*(.+)/m);
if (m) diffFilename = m[1].trim().split('/').pop();
}
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 = isDiff ? ('diff' + (diffFilename ? ' ' + diffFilename : '')) : (lang || 'code');
header.appendChild(langLabel);
let contentEl;
if (isDiff) {
contentEl = document.createElement('div');
contentEl.className = 'diff-block';
for (const line of code.split('\n')) {
const span = document.createElement('span');
span.className = 'diff-line' + (
line.startsWith('+') ? ' diff-line-add' :
line.startsWith('-') ? ' diff-line-del' :
line.startsWith('@@') ? ' diff-line-hunk' :
(line.startsWith('---') || line.startsWith('+++') || line.startsWith('#')) ? ' diff-line-file' : ''
);
span.textContent = line;
contentEl.appendChild(span);
}
} else {
contentEl = document.createElement('pre');
contentEl.textContent = code;
}
wrapper.appendChild(header);
wrapper.appendChild(contentEl);
const actionBtn = document.createElement('button');
actionBtn.className = 'code-ai-open-btn';
if (isDiff) {
actionBtn.textContent = '🔀 diff 적용';
actionBtn.onclick = () => { codeAiApplyDiff(code); actionBtn.textContent = '✅ 적용됨'; actionBtn.disabled = true; };
} else {
actionBtn.textContent = '📂 에디터에서 열기';
actionBtn.onclick = () => codeAiOpenInEditor(code, lang);
}
wrapper.appendChild(actionBtn);
el.appendChild(wrapper);
codeCount++;
} else {
const span = document.createElement('span');
span.style.whiteSpace = 'pre-wrap';
span.innerHTML = _renderInlineMarkdown(part.content || '');
el.appendChild(span);
}
}
// Code blocks were already streamed into the editor by codeLiveStreamUpdate,
// so no auto-open here — just render the chat UI. The "📂 에디터에서 열기"
// button above lets users manually re-open a block if needed.
}
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, or infer from code content.
const GENERIC_NAMES = new Set(['init','main','app','index','test','run','start','setup','util','helper','config','handler','utils','helpers','main']);
function codeDeriveName(code, lang) {
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i)
|| (code || '').match(/^(?:#|\/\/)\s*(\w+\.\w+)/m);
if (m) return m[1].replace(/[*\/>]+$/, '');
// Infer filename from code content when no explicit hint
const l = (lang || '').toLowerCase();
const ext = CODE_EXT_MAP[l] || ((l && l.length <= 6) ? l : 'txt');
// Try multiple strategies to find a meaningful name
// 1. HTML <title> tag
const htmlTitleMatch = code.match(/<title>([^<]+)<\/title>/i);
// 2. Class/struct/interface name
const classMatch = code.match(/(?:class|struct|interface)\s+([A-Z]\w+)/);
// 3. First function name that's not too generic
const funcMatches = [...code.matchAll(/(?:def|function|func|fn)\s+([a-zA-Z_]\w*)/g)].map(m => m[1]);
const specificFunc = funcMatches.find(f => !GENERIC_NAMES.has(f.toLowerCase()));
// 4. Title/game/app comment
const titleMatch = code.match(/(?:title|게임|프로그램|app|application)[:\s]+["']?([a-zA-Z0-9_가-힣]+)["']?/i);
// 5. <h1> heading
const h1Match = code.match(/<h[12][^>]*>([^<]{2,30})<\/h[12]>/i);
const candidates = [
htmlTitleMatch && htmlTitleMatch[1].replace(/[가-힣]/g, '').trim(),
classMatch && classMatch[1],
specificFunc,
titleMatch && titleMatch[1].replace(/[가-힣]/g, ''),
h1Match && h1Match[1].replace(/[가-힣]/g, '').trim(),
].filter(Boolean);
// Pick the first specific (non-generic) name
for (const name of candidates) {
const cleaned = name.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
if (cleaned.length >= 3 && cleaned.length <= 30 && !GENERIC_NAMES.has(cleaned)) {
return cleaned + '.' + ext;
}
}
// Fallback: use any candidate even if generic, or random
if (candidates.length > 0) {
const fallback = candidates[0].replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
if (fallback.length >= 2) return fallback + '.' + ext;
}
return `ai_${Math.random().toString(36).slice(2,7)}.${ext}`;
}
// Write an exact (filename, content) pair into the Monaco editor immediately,
// creating or updating the tab. Used to mirror the model's coder_write_file in real time.
function codeEditorWriteFile(fname, content) {
if (!monacoReady || !monacoEditor) {
codePendingQueue.push({ fname, content, lang: codeDetectLang(fname) });
initCodeView();
return;
}
const existing = codeFiles.find(f => f.name === fname);
if (existing) {
const oldContent = existing.model ? existing.model.getValue() : existing.content;
if (oldContent !== content) {
if (_codeAiAutoAccept) {
// Auto-accept mode: show diff briefly with scroll preview, then auto-save
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, content);
_scrollAndAcceptDiff(existing.id);
} else {
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, content);
}
}
} else {
codeNewFile(fname, content);
}
}
function codeAiApplyDiff(diffCode) {
if (!monacoReady || !monacoEditor) return;
// Extract target filename from diff headers
let targetName = null;
const filenameM = diffCode.match(/^#\s*filename:\s*(.+)/m);
if (filenameM) targetName = filenameM[1].trim();
if (!targetName) {
const m = diffCode.match(/^diff --git a\/(\S+)/m) || diffCode.match(/^--- a\/(\S+)/m);
if (m && !m[1].startsWith('/dev/null')) targetName = m[1];
}
let targetFile = null;
if (targetName) {
const basename = targetName.split('/').pop();
targetFile = codeFiles.find(f => f.name === targetName) || codeFiles.find(f => f.name === basename);
if (!targetFile) return; // 에디터에 없으면 적용 안 함
} else {
targetFile = codeFiles.find(f => f.id === activeCodeFileId);
}
if (!targetFile) return;
// Skip if streaming already applied a diff for this file
if (codeDiffState[targetFile.id]) return;
const originalContent = targetFile.model ? targetFile.model.getValue() : (targetFile.content || '');
const newContent = applyUnifiedDiff(originalContent, diffCode);
if (originalContent === newContent) return;
switchCodeFile(targetFile.id);
showCodeDiff(targetFile.id, originalContent, newContent);
}
function codeAiOpenInEditor(code, lang) {
_DBG && console.log(`[codeAiOpenInEditor] lang=${lang} lines=${code?.split('\n').length} openable=${codeIsOpenable(code, lang)} monacoReady=${monacoReady}`);
if (!codeIsOpenable(code, lang)) { _DBG && console.log('[codeAiOpenInEditor] NOT openable, returning'); return; }
if (!monacoReady || !monacoEditor) {
const fname = codeDeriveName(code, lang);
_DBG && console.log(`[codeAiOpenInEditor] Monaco not ready, queueing fname=${fname}`);
codePendingQueue.push({ fname, content: code, lang });
initCodeView();
return;
}
const fname = codeDeriveName(code, lang);
const existing = codeFiles.find(f => f.name === fname);
if (existing) {
const oldContent = existing.model ? existing.model.getValue() : existing.content;
if (oldContent !== code && code.length > 0) {
// Content differs — show an inline diff for review (line/block level) rather
// than replacing wholesale, consistent with the streaming edit path.
switchCodeFile(existing.id);
showCodeDiff(existing.id, oldContent, code);
} else {
// Same content — just switch to the tab
switchCodeFile(existing.id);
}
} else {
codeNewFile(fname, code);
}
}
// ── Code Diff View (Inline decorations, VSCode Copilot style) ────────────
// Compute diff hunks between original and modified text using LCS algorithm.
// Returns an array of hunks: [{ oldStart, oldEnd, newStart, newEnd, type }]
function computeDiffHunks(originalText, modifiedText) {
const oldLines = (originalText || '').split('\n');
const newLines = (modifiedText || '').split('\n');
const m = oldLines.length, n = newLines.length;
// The LCS table below is O(m*n) memory. Only collapse to a single coarse,
// whole-file hunk when it would genuinely blow up — ~5000×5000 lines is still
// only ~50MB of Uint16, so normal large files keep granular line-level hunks.
// Beyond that we fall back rather than allocating gigabytes / freezing the tab.
if (m * n > 25_000_000) {
if (originalText === modifiedText) return [];
const type = m === 0 ? 'add' : n === 0 ? 'delete' : 'change';
return [{ oldStart: 1, oldEnd: m, newStart: 1, newEnd: n, type }];
}
// Build LCS table (full m×n; rows are Uint16Array to keep it compact)
const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1));
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
dp[i][j] = oldLines[i] === newLines[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
// Walk back to find matching lines
const matchedOld = new Set(), matchedNew = new Set();
let oi = 0, ni = 0;
while (oi < m && ni < n) {
if (oldLines[oi] === newLines[ni]) { matchedOld.add(oi); matchedNew.add(ni); oi++; ni++; }
else if (dp[oi + 1][ni] >= dp[oi][ni + 1]) oi++;
else ni++;
}
// Group consecutive changes into hunks (merge hunks within 3 lines)
const hunks = [];
oi = 0; ni = 0;
while (oi < m || ni < n) {
// Skip matching lines
while (oi < m && ni < n && matchedOld.has(oi) && matchedNew.has(ni) && oldLines[oi] === newLines[ni]) { oi++; ni++; }
if (oi >= m && ni >= n) break;
const hOldStart = oi, hNewStart = ni;
let hOldEnd = oi, hNewEnd = ni;
// Collect changed lines, extending through nearby matches (within 3 lines)
while ((oi < m && !matchedOld.has(oi)) || (ni < n && !matchedNew.has(ni))) {
if (oi < m && !matchedOld.has(oi)) { hOldEnd = oi + 1; oi++; }
else if (ni < n && !matchedNew.has(ni)) { hNewEnd = ni + 1; ni++; }
else break;
// Extend through matching lines if next change is within 3 lines
let extended = true;
while (extended) {
extended = false;
let matchCount = 0;
while (oi < m && ni < n && matchedOld.has(oi) && matchedNew.has(ni) && oldLines[oi] === newLines[ni]) {
hOldEnd = oi + 1; hNewEnd = ni + 1; oi++; ni++; matchCount++;
// Check if next change is within 3 lines
let peekO = oi, peekN = ni, hasNearChange = false;
for (let k = 0; k < 3 && peekO < m && peekN < n; k++) {
if ((peekO < m && !matchedOld.has(peekO)) || (peekN < n && !matchedNew.has(peekN))) { hasNearChange = true; break; }
peekO++; peekN++;
}
if (hasNearChange) extended = true;
else if (matchCount >= 3) break;
}
}
}
if (hOldEnd > hOldStart || hNewEnd > hNewStart) {
const type = hOldEnd === hOldStart ? 'add' : hNewEnd === hNewStart ? 'delete' : 'change';
hunks.push({ oldStart: hOldStart + 1, oldEnd: hOldEnd, newStart: hNewStart + 1, newEnd: hNewEnd, type });
}
}
return hunks;
}
// Show inline diff decorations in the regular editor (VSCode Copilot style)
function showCodeDiff(fileId, originalText, modifiedText, onReject) {
if (!monacoReady || !window.monaco || !monacoEditor) return;
const file = codeFiles.find(f => f.id === fileId);
if (!file) return;
// If a pending (un-reviewed) diff already exists for this file, keep the user's
// original baseline so that a sequence of AI edits collapses into one review.
// e.g. coder_patch_file called 3× → user sees one combined diff, accepts once, saves once.
const existingDs = codeDiffState[fileId];
if (existingDs) {
originalText = existingDs.originalText;
}
// Clean up any existing diff for this file
cleanupCodeDiff(fileId);
// Compute diff hunks
const hunks = computeDiffHunks(originalText, modifiedText);
if (!hunks.length) return; // No changes
// Store diff state. `hunks` is computed ONCE here and stays stable for the
// lifetime of the review — accept/reject only flip per-hunk decisions:
// resolved = hunks the user has decided on (accepted OR rejected)
// rejected = subset of resolved that was rejected (restore original lines)
// The editor content is rebuilt from these decisions (see _rebuildDiffContent),
// so an accepted hunk never reappears when a later hunk is rejected.
codeDiffState[fileId] = {
originalText, modifiedText, hunks,
decorationIds: [], viewZoneIds: [],
resolved: new Set(), rejected: new Set(), onReject: onReject || null
};
// Set model to modified text (the "new" version)
if (file.model) file.model.setValue(modifiedText);
else file.content = modifiedText;
// Switch to this file and apply decorations
switchCodeFile(fileId);
_applyInlineDiffDecorations(fileId);
_showDiffBar(fileId);
renderCodeFileTabs();
}
// Rebuild the editor content from the current accept/reject decisions.
// Walks the stable hunks in order: unchanged lines come straight from the
// original, rejected hunks restore the original lines, accepted/pending hunks
// use the modified lines. Returns the assembled text plus, for each still-pending
// hunk, where it lands in that text (so decorations can be placed accurately).
function _rebuildDiffContent(ds) {
const oldLines = ds.originalText.split('\n');
const newLines = ds.modifiedText.split('\n');
const out = [];
const pendingRanges = [];
let cursor = 0; // 0-based cursor into oldLines
const ordered = ds.hunks.map((h, i) => ({ h, i })).sort((a, b) => a.h.oldStart - b.h.oldStart);
for (const { h, i } of ordered) {
// Unchanged original lines before this hunk
while (cursor < h.oldStart - 1 && cursor < oldLines.length) out.push(oldLines[cursor++]);
if (ds.rejected.has(i)) {
// Rejected → keep the original lines
for (let l = h.oldStart - 1; l < h.oldEnd && l < oldLines.length; l++) out.push(oldLines[l]);
} else {
// Accepted or pending → use the modified lines
const phantomAfter = out.length; // rebuilt line number just above the new block
for (let l = h.newStart - 1; l < h.newEnd && l < newLines.length; l++) out.push(newLines[l]);
if (!ds.resolved.has(i)) {
pendingRanges.push({ hunkIndex: i, hunk: h, greenStart: phantomAfter + 1, greenEnd: out.length, phantomAfter });
}
}
cursor = h.oldEnd;
}
while (cursor < oldLines.length) out.push(oldLines[cursor++]);
return { content: out.join('\n'), pendingRanges };
}
// Apply inline diff decorations to the editor for a given file
function _applyInlineDiffDecorations(fileId) {
const ds = codeDiffState[fileId];
if (!ds || !monacoEditor) return;
const file = codeFiles.find(f => f.id === fileId);
if (!file || file.id !== activeCodeFileId) return;
// Remove old decorations, view zones, and widgets
_removeInlineDiffDecorations(fileId);
// Rebuild content reflecting current decisions, then decorate the pending hunks
// at their rebuilt positions.
const { content, pendingRanges } = _rebuildDiffContent(ds);
if (file.model) { if (file.model.getValue() !== content) file.model.setValue(content); }
else file.content = content;
const contentLines = content.split('\n');
const oldLines = ds.originalText.split('\n');
const decorations = [];
for (const pr of pendingRanges) {
// Green highlight for added/changed lines
for (let line = pr.greenStart; line <= pr.greenEnd && line <= contentLines.length; line++) {
decorations.push({
range: new monaco.Range(line, 1, line, 1),
options: {
className: 'inline-diff-added',
glyphMarginClassName: 'inline-diff-glyph-added',
isWholeLine: true,
minimap: { color: '#28a74580', position: monaco.editor.MinimapPosition.Inline },
overviewRuler: { color: '#28a74560', position: monaco.editor.OverviewRulerLane.Full },
}
});
}
// View zone for deleted lines (red "phantom" lines), placed directly ABOVE
// the green block (phantomAfter is the line number just before it).
if (pr.hunk.type === 'delete' || pr.hunk.type === 'change') {
const deletedLines = oldLines.slice(pr.hunk.oldStart - 1, pr.hunk.oldEnd);
if (deletedLines.length && monacoEditor.changeViewZones) {
monacoEditor.changeViewZones(ca => {
const div = document.createElement('div');
div.className = 'inline-diff-deleted-zone';
div.innerHTML = deletedLines.map(line =>
'<div class="inline-diff-deleted-line">' + escHtml(line) + '</div>'
).join('');
ds.viewZoneIds.push(ca.addZone({
afterLineNumber: pr.phantomAfter,
afterColumn: 1,
heightInLines: Math.max(deletedLines.length, 1),
domNode: div,
}));
});
}
}
}
// Apply decorations
ds.decorationIds = monacoEditor.deltaDecorations([], decorations);
// Add hunk action button overlays (anchor to the green block, or the gap for pure deletes)
for (const pr of pendingRanges) {
const anchor = pr.greenStart <= pr.greenEnd ? pr.greenStart : pr.phantomAfter + 1;
const line = Math.min(Math.max(1, anchor), contentLines.length);
_addHunkActionsWidget(fileId, pr.hunkIndex, line);
}
// Update diff bar info
_updateDiffBarInfo(fileId);
// Scroll to first pending hunk
if (pendingRanges.length) {
const pr = pendingRanges[0];
const anchor = pr.greenStart <= pr.greenEnd ? pr.greenStart : pr.phantomAfter + 1;
monacoEditor.revealLineInCenter(Math.min(Math.max(1, anchor), contentLines.length));
}
}
// Remove all inline diff decorations, view zones, and widgets for a file
function _removeInlineDiffDecorations(fileId) {
const ds = codeDiffState[fileId];
if (!ds) return;
// Remove decorations
if (ds.decorationIds.length && monacoEditor) {
monacoEditor.deltaDecorations(ds.decorationIds, []);
ds.decorationIds = [];
}
// Remove view zones
if (ds.viewZoneIds.length && monacoEditor.changeViewZones) {
monacoEditor.changeViewZones(ca => {
for (const id of ds.viewZoneIds) ca.removeZone(id);
});
ds.viewZoneIds = [];
}
// Remove hunk action button overlays from DOM
document.querySelectorAll('.inline-diff-hunk-actions[data-file-id="' + fileId + '"]').forEach(el => el.remove());
// Dispose disposables
for (const key of Object.keys(ds)) {
if (key.startsWith('_hunkDisposables_')) {
const disposables = ds[key];
if (Array.isArray(disposables)) disposables.forEach(d => { try { d.dispose(); } catch {} });
}
}
}
// Add hunk action buttons as a positioned overlay
function _addHunkActionsWidget(fileId, hunkIndex, line) {
const ds = codeDiffState[fileId];
if (!ds || !monacoEditor) return;
const actions = document.createElement('div');
actions.className = 'inline-diff-hunk-actions';
actions.dataset.fileId = fileId;
actions.dataset.hunkIndex = hunkIndex;
// Per-hunk buttons accept/reject ALL hunks for the file (one-click UX)
actions.innerHTML = '<button class="inline-diff-accept-btn" onclick="_acceptAllDiff(\'' + fileId + '\')" title="모두 적용">✅</button>' +
'<button class="inline-diff-reject-btn" onclick="_rejectAllDiff(\'' + fileId + '\')" title="모두 취소">❌</button>';
const editorContainer = document.getElementById('code-editor-container');
if (editorContainer) editorContainer.appendChild(actions);
// Position the widget at the line
const updatePosition = () => {
if (!monacoEditor || !document.body.contains(actions)) return;
try {
const lineTop = monacoEditor.getTopForLineNumber(line);
const layoutInfo = monacoEditor.getLayoutInfo();
const contentLeft = layoutInfo.contentLeft;
const scrollTop = monacoEditor.getScrollTop();
actions.style.top = (lineTop - scrollTop) + 'px';
actions.style.left = contentLeft + 'px';
actions.style.transform = 'translateY(-100%)';
} catch {}
};
updatePosition();
const scrollDisp = monacoEditor.onDidScrollChange(updatePosition);
const layoutDisp = monacoEditor.onDidLayoutChange(updatePosition);
ds['_hunkDisposables_' + hunkIndex] = [scrollDisp, layoutDisp];
}
// Show the diff action bar at the top
function _showDiffBar(fileId) {
const bar = document.getElementById('code-diff-bar');
const label = document.getElementById('code-diff-label');
if (!bar || !label) return;
const pendingCount = Object.keys(codeDiffState).length;
const countNote = pendingCount > 1 ? ` (${pendingCount}개 파일 남음)` : '';
label.textContent = '🔀 AI 제안 변경 사항' + countNote;
const acceptBtn = document.getElementById('code-diff-accept');
const rejectBtn = document.getElementById('code-diff-reject');
// Capture fileId in the closure so clicking works regardless of activeCodeFileId
if (acceptBtn) {
acceptBtn.textContent = pendingCount > 1 ? '✅ 적용 →' : '✅ 모두 적용';
acceptBtn.onclick = () => {
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
_applyInlineDiffDecorations(fileId);
finalizeInlineDiff(fileId, true);
};
}
if (rejectBtn) {
rejectBtn.textContent = '❌ 모두 취소';
rejectBtn.onclick = () => {
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
_applyInlineDiffDecorations(fileId);
if (ds.onReject) ds.onReject();
finalizeInlineDiff(fileId, true);
};
}
bar.style.display = 'flex';
// Editor stays visible (no DiffEditor overlay)
const editorEl = document.getElementById('code-editor-container');
if (editorEl) editorEl.style.display = '';
}
// Update the diff bar with hunk count info
function _updateDiffBarInfo(fileId) {
const ds = codeDiffState[fileId];
if (!ds) return;
const unresolved = ds.hunks.filter((_, i) => !ds.resolved.has(i)).length;
const infoEl = document.getElementById('code-diff-hunk-info');
if (infoEl) infoEl.textContent = unresolved > 0 ? (unresolved + '개 변경') : '모두 해결됨';
}
// Accept a single hunk — keep its modified lines. Decisions are just flags;
// the editor content is rebuilt from them, so accepting one hunk never disturbs
// the others.
function acceptHunk(fileId, hunkIndex) {
const ds = codeDiffState[fileId];
if (!ds || ds.resolved.has(hunkIndex)) return;
ds.resolved.add(hunkIndex);
ds.rejected.delete(hunkIndex);
_applyInlineDiffDecorations(fileId);
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
}
// Reject a single hunk — restore its original lines. Other hunks (including ones
// already accepted) keep their decisions because the rebuild is decision-driven.
function rejectHunk(fileId, hunkIndex) {
const ds = codeDiffState[fileId];
if (!ds || ds.resolved.has(hunkIndex)) return;
ds.resolved.add(hunkIndex);
ds.rejected.add(hunkIndex);
_applyInlineDiffDecorations(fileId);
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
}
// Auto-accept diff with scroll preview: reveal each changed hunk in sequence, then save.
async function _scrollAndAcceptDiff(fileId) {
const ds = codeDiffState[fileId];
if (!ds || !monacoEditor) { _acceptAllDiff(fileId); return; }
const hunks = ds.hunks;
if (!hunks.length) { _acceptAllDiff(fileId); return; }
// Show preview label in the diff bar
const label = document.getElementById('code-diff-label');
if (label) label.textContent = `⏩ 편집 미리보기 (${hunks.length}개 변경)...`;
const acceptBtn = document.getElementById('code-diff-accept');
if (acceptBtn) acceptBtn.style.display = 'none'; // hide during preview
// Time budget: 500ms per hunk, capped at 5 s total
const perHunk = Math.min(500, Math.max(150, 5000 / hunks.length));
for (let i = 0; i < hunks.length; i++) {
if (!codeDiffState[fileId]) return; // diff cancelled by user or replaced
const h = hunks[i];
if (label) label.textContent = `⏩ 편집 미리보기 (${i + 1}/${hunks.length})...`;
const targetLine = h.newStart || h.oldStart || 1;
try { monacoEditor.revealLineInCenter(targetLine, 0 /* Smooth */); } catch {}
await new Promise(r => setTimeout(r, perHunk));
}
if (!codeDiffState[fileId]) return;
// Brief pause at the last hunk before auto-saving
await new Promise(r => setTimeout(r, 200));
_acceptAllDiff(fileId);
}
// Accept all hunks for a specific file (called by both per-hunk widgets and the bar button)
function _acceptAllDiff(fileId) {
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
_applyInlineDiffDecorations(fileId);
finalizeInlineDiff(fileId, true);
}
// Reject all hunks for a specific file
function _rejectAllDiff(fileId) {
const ds = codeDiffState[fileId];
if (!ds) return;
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
_applyInlineDiffDecorations(fileId);
if (ds.onReject) ds.onReject();
finalizeInlineDiff(fileId, true);
}
// Fallback wrappers using activeCodeFileId (kept for HTML onclick= attributes)
function acceptAllHunks() {
const fileId = activeCodeFileId || Object.keys(codeDiffState)[0];
if (fileId) _acceptAllDiff(fileId);
}
function rejectAllHunks() {
const fileId = activeCodeFileId || Object.keys(codeDiffState)[0];
if (fileId) _rejectAllDiff(fileId);
}
// Show inline re-login form inside the AI chat when session expires (401)
function _codeAiShowRelogin() {
const id = 'relogin_' + Date.now();
const el = codeAiAppend('system', '');
el.innerHTML = `<div style="display:flex;flex-direction:column;gap:8px;padding:4px 0">
<span style="color:#f87">⚠️ 세션 만료 — 재로그인</span>
<input id="${id}_u" placeholder="아이디" style="background:#1e1e1e;border:1px solid #444;color:#eee;padding:6px 8px;border-radius:4px;font-size:13px" />
<input id="${id}_p" type="password" placeholder="비밀번호" style="background:#1e1e1e;border:1px solid #444;color:#eee;padding:6px 8px;border-radius:4px;font-size:13px" />
<button onclick="_codeAiDoRelogin('${id}')" style="background:#2d6a2d;color:#eee;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:13px">로그인</button>
</div>`;
setTimeout(() => document.getElementById(id + '_u')?.focus(), 50);
}
async function _codeAiDoRelogin(id) {
const u = document.getElementById(id + '_u')?.value?.trim();
const p = document.getElementById(id + '_p')?.value;
if (!u || !p) return;
try {
const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) });
const d = await r.json();
if (d.token) {
setAuthToken(d.token);
const el = document.getElementById(id + '_u')?.closest('.code-ai-msg');
if (el) el.remove();
codeAiAppend('system', '✅ 로그인 완료 — 다시 메시지를 보내세요');
} else {
codeAiAppend('system', '❌ 로그인 실패: ' + (d.error || '아이디/비밀번호 확인'));
}
} catch (e) { codeAiAppend('system', '❌ 로그인 오류: ' + e.message); }
}
// Persist a specific file to workspace/code/ on disk. Accepting a diff only
// updated the Monaco model in memory — without this the .py file kept the old
// code, so re-running showed no change ("diff가 안 됨").
async function codeSaveFileById(fileId, hunkCount) {
const file = codeFiles.find(f => f.id === fileId);
if (!file) return;
const content = (file.model ? file.model.getValue() : file.content) || '';
file.content = content;
if (!localDirHandle) {
codeAiAppend('system', `⚠️ 폴더 미선택 — ${file.name} 저장 생략. 🖥️ 버튼으로 로컬 폴더를 선택해주세요.`);
return;
}
const ok = await localWriteFile(_clientProjectPath(file.name), content);
const bareName = _clientBareFileName(file.name);
const countNote = hunkCount > 0 ? ` (${hunkCount}곳)` : '';
if (ok) { codeAiAppend('system', `💾 저장: ${bareName}${countNote}`); codeSbRefresh(); }
else codeAiAppend('system', `⚠️ 저장 실패: ${bareName}`);
}
// Finalize inline diff — clean up all decorations and state.
// `save` persists the accepted result to disk (true for accepts, false for rejects).
function finalizeInlineDiff(fileId, save) {
const ds = codeDiffState[fileId];
if (!ds) return;
const hunkCount = ds.hunks.length;
_removeInlineDiffDecorations(fileId);
delete codeDiffState[fileId];
// Auto-save accepted changes so the on-disk file matches the editor
if (save) codeSaveFileById(fileId, hunkCount);
renderCodeFileTabs();
// If more files have pending diffs, switch to next one automatically
const nextId = Object.keys(codeDiffState)[0];
if (nextId) {
switchCodeFile(nextId); // switchCodeFile calls _showDiffBar if codeDiffState[nextId] exists
return;
}
// No more pending diffs — show completion message if this was an auto-accept turn
if (save && _autoAcceptTurn) {
_autoAcceptTurn = false;
setTimeout(() => codeAiAppend('system', '✅ 완료'), 300);
}
// Hide bar and restore focus
const bar = document.getElementById('code-diff-bar');
if (bar) bar.style.display = 'none';
const file = codeFiles.find(f => f.id === fileId);
if (file && monacoEditor && file.id === activeCodeFileId) {
monacoEditor.setModel(file.model);
monacoEditor.focus();
}
}
function cleanupCodeDiff(fileId) {
finalizeInlineDiff(fileId);
}
// ── New file approval bar + queue (supports multiple files at once) ──────
let _fileApprovalQueue = []; // [{fileId, fileName}]
function _enqueueFileApproval(fileId, fileName) {
_fileApprovalQueue.push({ fileId, fileName });
if (_fileApprovalQueue.length === 1) _showNextApproval();
}
function _showNextApproval() {
if (!_fileApprovalQueue.length) return;
const { fileId, fileName } = _fileApprovalQueue[0];
_showNewFileApproval(fileId, fileName);
}
function _showNewFileApproval(fileId, fileName) {
const file = codeFiles.find(f => f.id === fileId);
if (!file) { _fileApprovalQueue.shift(); _showNextApproval(); return; }
// In local mode: auto-save directly, no approval popup needed
if (localDirHandle) {
const content = file.model ? file.model.getValue() : (file.content || '');
localWriteFile(_clientProjectPath(file.name), content).then(ok => {
if (ok) { codeSbRefresh(); codeAiAppend('system', `💾 저장: ${file.name}`); }
else codeAiAppend('system', `⚠️ 저장 실패: ${file.name}`);
});
_fileApprovalQueue.shift();
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
return;
}
switchCodeFile(fileId);
const bar = document.getElementById('code-diff-bar');
const label = document.getElementById('code-diff-label');
if (!bar || !label) return;
const remaining = _fileApprovalQueue.length;
const queueNote = remaining > 1 ? ` (${remaining}개 중 1번째)` : '';
label.textContent = '📝 새 파일: ' + fileName + queueNote;
const acceptBtn = document.getElementById('code-diff-accept');
const rejectBtn = document.getElementById('code-diff-reject');
if (acceptBtn) {
acceptBtn.textContent = remaining > 1 ? '✅ 만들기 →' : '✅ 만들기';
acceptBtn.onclick = () => {
bar.style.display = 'none';
codeSaveFileById(fileId);
_fileApprovalQueue.shift();
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
};
}
if (rejectBtn) {
rejectBtn.textContent = '❌ 취소';
rejectBtn.onclick = () => {
const idx = codeFiles.findIndex(f => f.id === fileId);
if (idx >= 0) {
if (codeFiles[idx].model) codeFiles[idx].model.dispose();
codeFiles.splice(idx, 1);
}
if (codeDiffState[fileId]) cleanupCodeDiff(fileId);
bar.style.display = 'none';
if (codeFiles.length > 0) switchCodeFile(codeFiles[0].id);
renderCodeFileTabs();
_fileApprovalQueue.shift();
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
};
}
bar.style.display = 'flex';
}
// Extract blocks that have an explicit `# filename:` / `// filename:` comment
function _getExplicitFilename(code) {
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i);
return m ? m[1].replace(/[*\/>]+$/, '') : null;
}
function _extractExplicitFileBlocks(text) {
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
const result = [];
let inBlock = false, blockLang = '', blockStart = 0, m;
while ((m = fenceRe.exec(text)) !== null) {
const lineStart = m.index === 0 ? 0 : text.lastIndexOf('\n', m.index - 1) + 1;
if (text.slice(lineStart, m.index).trim() !== '') continue;
if (!inBlock) { blockLang = m[1] || 'text'; blockStart = m.index + m[0].length; inBlock = true; }
else {
const code = text.slice(blockStart, m.index);
const fname = _getExplicitFilename(code);
// Include BOTH diff and non-diff blocks with filenames.
// Previously we excluded diffs here, which caused new files to be lost
// when the AI outputs a diff (modify existing) + new-file blocks (create new).
if (fname) result.push({ fname, code, lang: blockLang, isDiff: _isDiffCode(blockLang, code) });
inBlock = false;
}
}
if (inBlock) {
const code = text.slice(blockStart);
const fname = _getExplicitFilename(code);
if (fname) result.push({ fname, code, lang: blockLang, isDiff: _isDiffCode(blockLang, code) });
}
return result;
}
function codeAiScrollBottom() {
const msgs = document.getElementById('code-ai-messages');
if (msgs) msgs.scrollTop = msgs.scrollHeight;
}
// ── Project session management ────────────────────────────────────────────
function saveProjectSession(projectName) {
if (!projectName) return;
localStorage.setItem('code_proj_session_' + projectName, codeAiSessionId);
try { localStorage.setItem('code_proj_history_' + projectName, JSON.stringify(codeAiHistory.slice(-40))); } catch {}
}
function _knownProjectsGet() {
try { return JSON.parse(localStorage.getItem('code_known_projects') || '[]'); } catch { return []; }
}
function _knownProjectsAdd(name) {
if (!name) return;
const list = _knownProjectsGet();
if (!list.includes(name)) { list.push(name); localStorage.setItem('code_known_projects', JSON.stringify(list)); }
}
function _knownProjectsRemove(name) {
if (!name) return;
const list = _knownProjectsGet().filter(p => p !== name);
localStorage.setItem('code_known_projects', JSON.stringify(list));
}
async function deleteActiveProject() {
if (!activeProjectName) return;
if (!confirm(`"${activeProjectName}" 프로젝트를 삭제할까요?\n(폴더와 파일이 모두 삭제됩니다)`)) return;
const name = activeProjectName;
// localStorage 정리
_knownProjectsRemove(name);
localStorage.removeItem('code_proj_session_' + name);
localStorage.removeItem('code_proj_history_' + name);
// 로컬 폴더 삭제
if (localDirHandle) {
try { await localDirHandle.removeEntry(name, { recursive: true }); } catch (e) { console.warn('[deleteProject]', e); }
}
// 탭 닫기 및 프로젝트 초기화
codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} });
codeFiles.length = 0; activeCodeFileId = null; codeDiffState = {};
renderCodeFileTabs();
activeProjectName = null;
localStorage.setItem('code_active_project', '');
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
_codeAiLastUsage = null;
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
updateLocalModeUI();
updateSessionPanel();
codeSbRefresh();
_renderSessionsList();
}
function switchProject(name) {
_autoSaveChatSession(); // null 프로젝트 상태의 히스토리도 세션 목록에 보존
if (activeProjectName) saveProjectSession(activeProjectName);
activeProjectName = name || null;
localStorage.setItem('code_active_project', activeProjectName || '');
if (activeProjectName) {
_knownProjectsAdd(activeProjectName);
const savedId = localStorage.getItem('code_proj_session_' + activeProjectName);
const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName);
codeAiSessionId = savedId || ('code_ai_' + Math.random().toString(36).slice(2, 10));
try { codeAiHistory = savedHist ? JSON.parse(savedHist) : []; } catch { codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = ''; }
_readFileCache = {}; _historySummary = '';
saveProjectSession(activeProjectName);
} else {
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
}
updateLocalModeUI();
updateSessionPanel();
codeSbRefresh();
localShellIds.forEach(shellId => _companionCdProject(shellId));
// 채팅 패널 갱신: 히스토리가 있으면 그대로, 없으면 세션 목록
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();
}
}
// In client-local mode: close previous project's tabs and load new project files
if (localDirHandle) _clientLoadProjectFiles();
}
function csbRenderProjectSelector(projects) {
const sel = document.getElementById('csb-project-sel');
const row = document.getElementById('csb-project-row');
if (!sel || !row) return;
if (!projects.length) { row.style.display = 'none'; return; }
row.style.display = 'flex';
const options = '<option value="">(전체)</option>' +
projects.map(p => `<option value="${escHtml(p)}"${activeProjectName === p ? ' selected' : ''}>${escHtml(p)}</option>`).join('');
sel.innerHTML = options;
sel.value = activeProjectName || '';
}
// ── Session list (Cline style) ─────────────────────────────────────────────
const _SESS_KEY = 'code_chat_sessions_v2';
const _SESS_MAX = 30;
function _loadSessions() {
try { return JSON.parse(localStorage.getItem(_SESS_KEY) || '[]'); } catch { return []; }
}
function _saveSessions(list) {
try { localStorage.setItem(_SESS_KEY, JSON.stringify(list)); } catch {}
}
function _autoSaveChatSession() {
if (!codeAiHistory.length) return;
const list = _loadSessions();
const firstUser = codeAiHistory.find(m => m.role === 'user')?.content || '';
const title = firstUser.replace(/^\[파일:[^\]]+\]\s*/, '').slice(0, 50) || '(제목 없음)';
const existing = list.findIndex(s => s.sessionId === codeAiSessionId);
const entry = {
id: existing >= 0 ? list[existing].id : ('sess_' + Math.random().toString(36).slice(2, 9)),
sessionId: codeAiSessionId,
title,
project: activeProjectName || '',
ts: Date.now(),
msgCount: codeAiHistory.length,
history: codeAiHistory.slice(-60),
};
if (existing >= 0) list[existing] = entry;
else list.unshift(entry);
if (list.length > _SESS_MAX) list.length = _SESS_MAX;
_saveSessions(list);
}
function _backToSessionList() {
_autoSaveChatSession();
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
_codeAiLastUsage = null;
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
if (msgs) msgs.innerHTML = '';
_renderSessionsList();
}
function _renderSessionsList() {
const msgs = document.getElementById('code-ai-messages');
if (!msgs) return;
const list = _loadSessions();
const proj = activeProjectName || '';
const filtered = proj ? list.filter(s => s.project === proj || !s.project) : list;
if (!filtered.length) {
msgs.innerHTML = '<div class="code-ai-msg thinking" style="text-align:center;padding:24px 8px;color:var(--muted)">저장된 세션이 없습니다.<br>아래에 질문을 입력하면 새 세션이 시작됩니다.</div>';
return;
}
const relTime = ts => {
const d = (Date.now() - ts) / 1000;
if (d < 60) return '방금';
if (d < 3600) return Math.floor(d/60) + '분 전';
if (d < 86400) return Math.floor(d/3600) + '시간 전';
return Math.floor(d/86400) + '일 전';
};
msgs.innerHTML = `
<div style="padding:8px 10px 4px;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase">최근 세션</span>
<button onclick="_deleteAllSessions(event)" style="font-size:10px;padding:1px 6px;border:1px solid #374151;background:none;color:#6b7280;border-radius:3px;cursor:pointer" onmouseover="this.style.borderColor='#f87171';this.style.color='#f87171'" onmouseout="this.style.borderColor='#374151';this.style.color='#6b7280'">전체 삭제</button>
</div>
<div style="padding:0 6px 6px;display:flex;flex-direction:column;gap:3px">
${filtered.map(s => `
<div style="display:flex;align-items:center;gap:0;border-radius:5px;overflow:hidden;border:1px solid var(--line);background:var(--panel-2);transition:border-color .15s"
onmouseover="this.style.borderColor='#3d6b4f'" onmouseout="this.style.borderColor='var(--line)'">
<div onclick="_resumeSession('${s.id}')" style="flex:1;min-width:0;padding:5px 8px;cursor:pointer;border-left:2px solid #22c55e">
<div style="display:flex;align-items:center;gap:5px;margin-bottom:2px">
${s.project ? `<span style="font-size:9px;padding:1px 5px;background:#0d2b1a;color:#22c55e;border-radius:3px;letter-spacing:.03em;flex-shrink:0">${escHtml(s.project)}</span>` : ''}
<span style="font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(s.title)}</span>
</div>
<span style="font-size:10px;color:var(--muted)">${s.msgCount}개 · ${relTime(s.ts)}</span>
</div>
<button onclick="_deleteSession('${s.id}', event)" title="세션 삭제" style="flex-shrink:0;width:24px;height:100%;background:none;border:none;border-left:1px solid var(--line);color:#4b5563;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;align-self:stretch;padding:0" onmouseover="this.style.color='#f87171';this.style.background='rgba(248,113,113,0.08)'" onmouseout="this.style.color='#4b5563';this.style.background='none'">✕</button>
</div>`).join('')}
</div>`;
}
function _resumeSession(id) {
const list = _loadSessions();
const sess = list.find(s => s.id === id);
if (!sess) return;
_autoSaveChatSession(); // save current before switching
codeAiHistory = sess.history || [];
codeAiSessionId = sess.sessionId;
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
if (msgs) {
msgs.innerHTML = '';
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
}
updateSessionPanel();
}
function _deleteSession(id, event) {
if (event) { event.stopPropagation(); event.preventDefault(); }
let list = _loadSessions();
const sess = list.find(s => s.id === id);
// If deleting current session, clear it
if (sess && sess.sessionId === codeAiSessionId) {
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
if (msgs) msgs.innerHTML = '';
}
list = list.filter(s => s.id !== id);
_saveSessions(list);
// Also clear server-side history for this session
if (sess) {
fetch('/api/clear-history', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sessionId: sess.sessionId }),
}).catch(() => {});
}
_renderSessionsList();
updateSessionPanel();
}
function _deleteAllSessions(event) {
if (event) { event.stopPropagation(); event.preventDefault(); }
const list = _loadSessions();
// Clear server-side history for all sessions
for (const s of list) {
fetch('/api/clear-history', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sessionId: s.sessionId }),
}).catch(() => {});
}
_saveSessions([]);
// Also clear project-specific session storage
Object.keys(localStorage).filter(k => k.startsWith('code_proj_session_') || k.startsWith('code_proj_history_')).forEach(k => localStorage.removeItem(k));
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
if (msgs) msgs.innerHTML = '';
_renderSessionsList();
updateSessionPanel();
}
async function codeAiClear() {
_autoSaveChatSession(); // 현재 세션 자동 저장
if (activeProjectName) saveProjectSession(activeProjectName); // per-project store도 지우기 전에 저장
fetch('/api/clear-history', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sessionId: codeAiSessionId }),
}).catch(() => {});
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
// In client-local mode: ask for a project name and create the folder
if (localDirHandle) {
const raw = prompt('새 프로젝트 이름:', '');
const projName = (raw || '').trim().replace(/[^a-zA-Z0-9가-힣_-]/g, '_').replace(/^_+|_+$/g, '');
if (projName) {
await localDirHandle.getDirectoryHandle(projName, { create: true }).catch(() => {});
switchProject(projName);
codeAiAppend('system', `📁 프로젝트 "${projName}" 생성됨 (빈 폴더). 파일은 Write 툴로 만드세요. Bash는 companion 연결 시에만 사용 가능합니다. 어떤 걸 만들지 설명해주세요.`);
return;
}
}
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
updateSessionPanel();
_renderSessionsList(); // 세션 목록으로 돌아가기
}
// ── Code sidebar panel ────────────────────────────────────────────────────
let codeSbCurrentTab = 'files';
function setCodeSbTab(tab) {
codeSbCurrentTab = tab;
['files','settings','libraries'].forEach(t => {
const btn = document.getElementById('csb-tab-' + t);
if (btn) btn.classList.toggle('active', t === tab);
const el = document.getElementById('csb-' + t);
if (el) el.style.display = t === tab ? 'flex' : 'none';
});
if (tab === 'files') csbLoadModels();
if (tab === 'libraries') {
if (_csbArdSection === 'lib') csbLibLoad();
if (_csbArdSection === 'board') csbBoardLoad();
}
}
// ── 라이브러리/보드 매니저 (code editor sidebar) ──────────────────────────────
let _csbLibCurrentTab = 'installed';
let _csbLibFilterMode = 'all'; // 'all' | 'compat' | 'incompat'
let _csbLibViewMode = 'flat'; // 'flat' | 'category'
let _csbLibArchFilter = ''; // '' = 전체, 'esp32' | 'esp8266' | 'avr' ...
let _csbLibCache = [];
let _csbArdSection = 'lib';
const _CAT_KO = {
'Communication': '통신',
'Data Processing': '데이터 처리',
'Data Storage': '데이터 저장',
'Device Control': '장치 제어',
'Display': '디스플레이',
'Encryption': '암호화',
'Imaging': '이미징',
'Other': '기타',
'Sensors': '센서',
'Signal Input/Output':'신호 입출력',
'Timing': '타이밍',
'Uncategorized': '미분류',
};
function csbArdSection(sec) {
_csbArdSection = sec;
['lib','board'].forEach(s => {
const btn = document.getElementById('csb-ardsec-' + s);
if (btn) btn.classList.toggle('active', s === sec);
const el = document.getElementById('csb-ard-' + s);
if (el) el.style.display = s === sec ? 'flex' : 'none';
});
if (sec === 'lib') csbLibLoad();
if (sec === 'board') csbBoardLoad();
}
function csbLibTab(tab) {
_csbLibCurrentTab = tab;
['installed','search'].forEach(t => {
const btn = document.getElementById('csb-libtab-' + t);
if (btn) btn.classList.toggle('active', t === tab);
});
const sr = document.getElementById('csb-lib-search-row');
if (sr) sr.style.display = tab === 'search' ? 'flex' : 'none';
const fr = document.getElementById('csb-lib-filter-row');
if (fr) fr.style.display = tab === 'installed' ? 'flex' : 'none';
document.getElementById('csb-lib-status').textContent = '';
if (tab === 'installed') csbLibLoad();
else _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">키워드를 입력하세요.</div>';
}
function csbLibFilter(mode) {
_csbLibFilterMode = mode;
['all','compat','incompat'].forEach(m => {
const btn = document.getElementById('csb-libf-' + m);
if (!btn) return;
const active = m === mode;
btn.style.background = active ? 'var(--brand)' : 'var(--panel-2)';
btn.style.color = active ? '#fff' : 'var(--muted)';
btn.style.borderColor = active ? 'var(--brand)' : 'var(--line)';
});
_csbLibRenderInstalled(_csbLibCache);
}
function csbLibView(mode) {
_csbLibViewMode = mode;
['flat','category'].forEach(m => {
const btn = document.getElementById('csb-libv-' + m);
if (!btn) return;
const active = m === mode;
btn.style.background = active ? 'var(--brand)' : 'var(--panel-2)';
btn.style.color = active ? '#fff' : 'var(--muted)';
btn.style.borderColor = active ? 'var(--brand)' : 'var(--line)';
});
_csbLibRenderInstalled(_csbLibCache);
}
function _csbLibSetArch(arch) {
_csbLibArchFilter = arch;
_csbLibUpdateArchChips(_csbLibCache);
_csbLibRenderInstalled(_csbLibCache);
}
function _csbLibUpdateArchChips(libs) {
const archRow = document.getElementById('csb-lib-arch-row');
if (!archRow) return;
// Count non-wildcard archs
const archCount = {};
for (const l of libs) {
for (const a of (l.architectures || [])) {
if (a !== '*') archCount[a] = (archCount[a] || 0) + 1;
}
}
// Show archs with ≥2 libs, sorted by count desc
const major = Object.entries(archCount).filter(([,c]) => c >= 2).sort((a,b) => b[1]-a[1]).map(([a]) => a);
if (!major.length) { archRow.style.display = 'none'; return; }
const all = ['', ...major];
archRow.innerHTML = all.map(a => {
const label = a ? a.toUpperCase() : '전체';
const cnt = a ? (archCount[a] || 0) : libs.length;
const active = _csbLibArchFilter === a;
return `<button onclick="_csbLibSetArch('${a}')" style="font-size:9px;padding:1px 6px;border-radius:3px;border:1px solid ${active?'var(--brand)':'var(--line)'};background:${active?'var(--brand)':'var(--panel-2)'};color:${active?'#fff':'var(--muted)'};cursor:pointer;font-family:inherit" title="${cnt}개">${label}</button>`;
}).join('');
archRow.style.display = 'flex';
}
function _csbLibListEl() { return document.getElementById('csb-lib-list'); }
function _csbLibStatus(msg) { const el = document.getElementById('csb-lib-status'); if (el) el.textContent = msg; }
function _csbEsc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
async function csbLibLoad() {
_csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
try {
const fqbn = _csbSelBoard?.fqbn ? '&fqbn=' + encodeURIComponent(_csbSelBoard.fqbn) : '';
const r = await fetch('/api/arduino/libs?all=1' + fqbn, { headers: authHeaders() });
const data = await r.json();
_csbLibCache = data.libs || [];
_csbLibArchFilter = '';
_csbLibUpdateArchChips(_csbLibCache);
_csbLibRenderInstalled(_csbLibCache);
} catch { _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
}
function _csbArchBadge(architectures) {
const arch = _csbSelBoard.fqbn.split(':')[1] || '';
const archs = architectures || [];
const isAll = archs.includes('*') || archs.length === 0;
const compat = isAll || archs.some(a => a === arch || a.startsWith(arch) || arch.startsWith(a));
if (isAll) return `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#1a2a1a;color:#6ee7b7;border:1px solid #2d4a2d">모든 보드</span>`;
const label = archs.slice(0,3).join(', ') + (archs.length>3?'…':'');
const color = compat ? '#6ee7b7' : '#f87171';
const bg = compat ? '#1a2a1a' : '#2a1a1a';
const bd = compat ? '#2d4a2d' : '#4a2d2d';
const icon = compat ? '✓' : '✗';
return `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:${bg};color:${color};border:1px solid ${bd}" title="호환 아키텍처: ${archs.join(', ')}">${icon} ${_csbEsc(label)}</span>`;
}
function _csbIsCompat(l) {
const arch = _csbSelBoard.fqbn.split(':')[1] || '';
const archs = l.architectures || [];
return archs.includes('*') || archs.length === 0 || archs.some(a => a === arch || a.startsWith(arch) || arch.startsWith(a));
}
function _csbLibRenderInstalled(libs) {
const el = _csbLibListEl();
if (!libs.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">설치된 라이브러리 없음</div>'; return; }
// Apply board compat filter
let filtered = libs;
if (_csbLibFilterMode === 'compat') filtered = libs.filter(l => _csbIsCompat(l));
if (_csbLibFilterMode === 'incompat') filtered = libs.filter(l => !_csbIsCompat(l));
// Apply architecture filter
if (_csbLibArchFilter) {
filtered = filtered.filter(l => {
const archs = l.architectures || [];
return archs.includes('*') || archs.length === 0 || archs.includes(_csbLibArchFilter) ||
archs.some(a => a.startsWith(_csbLibArchFilter) || _csbLibArchFilter.startsWith(a));
});
}
// Dedup by name when a specific arch or compat filter is active
// Priority: user-installed > platform, specific arch > wildcard *, newer version
if (_csbLibArchFilter || _csbLibFilterMode === 'compat') {
const byName = new Map();
for (const l of filtered) {
if (!byName.has(l.name)) { byName.set(l.name, []); }
byName.get(l.name).push(l);
}
filtered = [...byName.values()].map(group => group.reduce((best, l) => {
if (l.location === 'user' && best.location !== 'user') return l;
if (best.location === 'user' && l.location !== 'user') return best;
const lWild = !(l.architectures||[]).length || (l.architectures||[]).includes('*');
const bWild = !(best.architectures||[]).length || (best.architectures||[]).includes('*');
if (!lWild && bWild) return l;
if (lWild && !bWild) return best;
return (l.version||'') >= (best.version||'') ? l : best;
}));
}
// Sort: platform/core first, then compatible, then name
filtered = [...filtered].sort((a, b) => {
const pa = a.location === 'platform', pb = b.location === 'platform';
if (pa && !pb) return -1;
if (!pa && pb) return 1;
const ca = _csbIsCompat(a), cb = _csbIsCompat(b);
if (ca && !cb) return -1;
if (!ca && cb) return 1;
return a.name.localeCompare(b.name);
});
if (!filtered.length) {
const msg = _csbLibFilterMode === 'compat' ? `${_csbSelBoard.name}과 호환되는 라이브러리 없음`
: _csbLibArchFilter ? `${_csbLibArchFilter.toUpperCase()} 라이브러리 없음`
: '비호환 라이브러리 없음';
el.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">${msg}</div>`;
return;
}
if (_csbLibViewMode === 'category') { _csbLibRenderGrouped(filtered); return; }
const fqbn = _csbSelBoard?.fqbn || '';
el.innerHTML = _csbBuiltinCard() + filtered.map(l => _csbLibCard(l, fqbn)).join('');
}
function _csbLibCard(l, fqbn) {
const sid = 'csbex_' + l.name.replace(/\W/g,'_');
const archs = l.architectures || [];
const compat = _csbIsCompat(l);
const isPlatform = l.location === 'platform';
const incompatWarn = !compat ? `<div style="font-size:10px;color:#f87171;margin-top:3px">⚠ 현재 보드(${_csbEsc(_csbSelBoard.name)})와 호환되지 않을 수 있음</div>` : '';
const platformBadge = isPlatform ? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#1a2a3a;color:#60a5fa;border:1px solid #2d4a6a">코어</span>` : '';
const exRows = (l.examples||[]).map(ex =>
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
<span style="font-size:10px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">📄 ${_csbEsc(ex)}</span>
<button onclick="csbLibOpenExample('${_csbEsc(l.name)}','${_csbEsc(ex)}',${compat},'${_csbEsc(fqbn)}')"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:${compat?'var(--brand)':'#f87171'};border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap"
title="${compat?'':'⚠ 비호환 예제 — 열면 AI가 자동으로 변환해드립니다'}">${compat?'열기':'열기 ⚠'}</button>
</div>`).join('');
const hasEx = l.examples?.length > 0;
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line)${!compat?';border-left:2px solid #7f1d1d':''}">
<div style="display:flex;align-items:flex-start;gap:6px">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</span>
${platformBadge}${_csbArchBadge(archs)}
</div>
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}${l.category?' · '+_csbEsc(l.category):''}</div>
${l.sentence?`<div style="font-size:10px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${_csbEsc(l.sentence)}</div>`:''}
${incompatWarn}
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
${hasEx?`<button onclick="document.getElementById('${sid}').style.display=document.getElementById('${sid}').style.display==='none'?'block':'none'"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--muted);border-radius:4px;cursor:pointer;font-family:inherit">예제</button>`:''}
${isPlatform?'':`<button onclick="csbLibUninstall('${_csbEsc(l.name)}')"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:#f87171;border-radius:4px;cursor:pointer;font-family:inherit">삭제</button>`}
</div>
</div>
${hasEx?`<div id="${sid}" style="display:none;margin-top:5px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${exRows}</div>`:''}
</div>`;
}
function _csbLibRenderGrouped(libs) {
const el = _csbLibListEl();
const fqbn = _csbSelBoard?.fqbn || '';
// Group by category
const groups = {};
for (const l of libs) {
const cat = l.category || 'Uncategorized';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(l);
}
// Sort categories: put Uncategorized/Other last
const last = new Set(['Uncategorized', 'Other', '']);
const sorted = Object.keys(groups).sort((a,b) => {
if (last.has(a) && !last.has(b)) return 1;
if (!last.has(a) && last.has(b)) return -1;
return a.localeCompare(b);
});
el.innerHTML = _csbBuiltinCard() + sorted.map(cat => {
const ko = _CAT_KO[cat] || cat;
const gid = 'csbg_' + cat.replace(/\W/g,'_');
const cards = groups[cat].map(l => _csbLibCard(l, fqbn)).join('');
const cnt = groups[cat].length;
return `<div>
<div onclick="const b=document.getElementById('${gid}');b.style.display=b.style.display==='none'?'block':'none'"
style="display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;background:var(--panel-2);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:1">
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(ko)}</span>
${cat !== ko ? `<span style="font-size:9px;color:var(--muted)">${_csbEsc(cat)}</span>` : ''}
<span style="font-size:10px;color:var(--muted);margin-left:auto">${cnt}개</span>
</div>
<div id="${gid}">${cards}</div>
</div>`;
}).join('');
}
const _BUILTIN_EX = [
{ name:'Blink.ino', desc:'LED 깜빡이기', code:
`void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
digitalWrite(LED_BUILTIN, LOW);
delay(1000);
}` },
{ name:'Button.ino', desc:'버튼으로 LED 제어', code:
`const int BTN = 0;
const int LED = LED_BUILTIN;
void setup() {
pinMode(BTN, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
digitalWrite(LED, digitalRead(BTN) == LOW ? HIGH : LOW);
}` },
{ name:'AnalogRead.ino', desc:'아날로그 읽기', code:
`void setup() {
Serial.begin(115200);
}
void loop() {
int val = analogRead(A0);
Serial.println(val);
delay(200);
}` },
{ name:'Fade.ino', desc:'LED 밝기 조절 (PWM)', code:
`const int LED = LED_BUILTIN;
int brightness = 0, step = 5;
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
analogWrite(LED, brightness);
brightness += step;
if (brightness <= 0 || brightness >= 255) step = -step;
delay(15);
}` },
{ name:'BlinkWithoutDelay.ino', desc:'딜레이 없는 깜빡이기', code:
`const int LED = LED_BUILTIN;
bool state = false;
unsigned long prev = 0;
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
unsigned long now = millis();
if (now - prev >= 1000) {
prev = now;
state = !state;
digitalWrite(LED, state);
}
}` },
{ name:'SerialHello.ino', desc:'시리얼 출력', code:
`void setup() {
Serial.begin(115200);
Serial.println("Hello, World!");
}
void loop() {
if (Serial.available()) {
String s = Serial.readStringUntil('\\n');
Serial.print("받음: ");
Serial.println(s);
}
}` },
];
function csbLibOpenBuiltin(idx) {
const ex = _BUILTIN_EX[idx];
if (!ex) return;
codeNewFile(ex.name, ex.code);
setCodeSbTab('files');
}
function _csbBuiltinCard() {
const rows = _BUILTIN_EX.map((ex, i) =>
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
<span style="font-size:10px;color:var(--muted);flex:1">📄 ${_csbEsc(ex.desc)}</span>
<button onclick="csbLibOpenBuiltin(${i})"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--brand);border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap">열기</button>
</div>`).join('');
return `<div style="padding:8px 10px;border-bottom:2px solid var(--line);border-left:3px solid #facc15">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span style="font-size:11px;font-weight:700;color:var(--text)">Arduino 기본 예제</span>
<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#2a2010;color:#facc15;border:1px solid #4a3a10">기본</span>
</div>
<div style="margin-top:4px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${rows}</div>
</div>`;
}
async function csbLibOpenExample(libName, exName, isCompat = true, fqbn = '') {
_csbLibStatus(`예제 로딩: ${exName}...`);
try {
const fqbnParam = (fqbn || _csbSelBoard?.fqbn || '');
const r = await fetch(`/api/arduino/libs/example?lib=${encodeURIComponent(libName)}&example=${encodeURIComponent(exName)}&fqbn=${encodeURIComponent(fqbnParam)}`, { headers: authHeaders() });
const data = await r.json();
if (data.error) { _csbLibStatus('오류: ' + data.error); return; }
_csbLibStatus('');
codeNewFile(data.name, data.code);
setCodeSbTab('files');
if (!isCompat) {
// Auto-send to AI for board adaptation
const hints = _boardSpecificHints(data.code);
const inp = document.getElementById('code-ai-input');
if (inp) {
inp.value = `이 예제는 ${libName} 라이브러리용이지만 현재 보드(${_csbSelBoard.name}, FQBN: ${_csbSelBoard.fqbn})와 호환되지 않을 수 있어.\n${hints}\n\nRead로 ${data.name}을 읽은 뒤 이 보드에서 동작하도록 코드를 수정해줘. 호환되지 않는 API는 이 보드에 맞는 방식으로 교체해.`;
codeAiSend();
}
}
} catch(e) { _csbLibStatus('오류: ' + e.message); }
}
async function csbLibSearch() {
const q = (document.getElementById('csb-lib-q')?.value || '').trim();
if (!q) return;
_csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 중...</div>';
_csbLibStatus('');
try {
const r = await fetch('/api/arduino/libs/search?q=' + encodeURIComponent(q), { headers: authHeaders() });
const data = await r.json();
_csbLibRenderSearch(data.libs || []);
} catch { _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
}
function _csbLibRenderSearch(libs) {
const el = _csbLibListEl();
if (!libs.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 결과 없음</div>'; return; }
el.innerHTML = libs.map(l => `
<div style="padding:8px 10px;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:6px">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</span>
${l.category?`<span style="font-size:9px;padding:1px 5px;border:1px solid var(--line);border-radius:3px;color:var(--muted)">${_csbEsc(l.category)}</span>`:''}
${_csbArchBadge(l.architectures||[])}
</div>
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}</div>
${l.sentence?`<div style="font-size:10px;color:var(--muted);margin-top:2px">${_csbEsc(l.sentence)}</div>`:''}
</div>
<div style="flex-shrink:0;padding-top:2px">
${l.installed
? `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`
: `<button onclick="csbLibInstall('${_csbEsc(l.name)}',this)"
style="font-size:10px;padding:3px 8px;background:var(--brand);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:inherit">설치</button>`}
</div>
</div>`).join('');
}
async function csbLibInstall(name, btn) {
_csbLibStatus(`설치 중: ${name}...`);
if (btn) { btn.disabled = true; btn.textContent = '...'; }
try {
const r = await fetch('/api/arduino/libs/install', {
method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await r.json();
if (data.error) { _csbLibStatus('오류: ' + data.error.slice(0,100)); if(btn){btn.disabled=false;btn.textContent='설치';} return; }
_csbLibStatus(`✅ ${name} 설치 완료`);
if (btn) btn.outerHTML = `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`;
} catch(e) { _csbLibStatus('오류: ' + e.message); if(btn){btn.disabled=false;btn.textContent='설치';} }
}
async function csbLibUninstall(name) {
if (!confirm(`"${name}" 을 삭제할까요?`)) return;
_csbLibStatus(`삭제 중: ${name}...`);
try {
const r = await fetch('/api/arduino/libs/uninstall', {
method: 'DELETE', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await r.json();
if (data.error) { _csbLibStatus('오류: ' + data.error.slice(0,100)); return; }
_csbLibStatus(`✅ ${name} 삭제 완료`);
csbLibLoad();
} catch(e) { _csbLibStatus('오류: ' + e.message); }
}
// ── 보드 매니저 ───────────────────────────────────────────────────────────────
// ── 보드 선택기 ───────────────────────────────────────────────────────────────
const CSB_BOARD_DEFAULT = { fqbn: 'arduino:avr:uno', name: 'Arduino UNO' };
let _csbSelBoard = (() => {
try { return JSON.parse(localStorage.getItem('csb_selected_board') || 'null') || CSB_BOARD_DEFAULT; }
catch { return CSB_BOARD_DEFAULT; }
})();
let _csbSelAllBoards = [];
let _csbBoardLibs = []; // 현재 보드의 설치된 라이브러리 캐시
let _csbBoardLibsFqbn = ''; // 캐시된 fqbn
async function _csbFetchLibs(fqbn) {
if (!fqbn || fqbn.startsWith('lang:')) { _csbBoardLibs = []; _csbBoardLibsFqbn = fqbn; return; }
if (fqbn === _csbBoardLibsFqbn) return;
try {
const data = await api('/api/arduino/libs?fqbn=' + encodeURIComponent(fqbn));
_csbBoardLibs = (data.libs || []).map(l => l.name).filter(Boolean);
_csbBoardLibsFqbn = fqbn;
} catch { _csbBoardLibs = []; }
}
// 초기 로드 시 현재 보드 라이브러리 가져오기
_csbFetchLibs(_csbSelBoard.fqbn);
function _csbBoardSelSave(board) {
_csbSelBoard = board;
try { localStorage.setItem('csb_selected_board', JSON.stringify(board)); } catch {}
_csbFetchLibs(board.fqbn);
const el = document.getElementById('csb-board-sel-name');
if (el) el.textContent = board.name;
const badge = document.getElementById('csb-board-qemu-badge');
if (badge) {
if (board.qemu === 'esp8266-native') {
badge.textContent = '🖥 Native'; badge.style.display = 'inline';
badge.style.background = '#0c3a4a'; badge.style.color = '#67e8f9'; badge.style.borderColor = '#0e7490';
} else if (board.qemu === 'renode') {
badge.textContent = '🖥 Renode'; badge.style.display = 'inline';
badge.style.background = '#1a0a3a'; badge.style.color = '#c084fc'; badge.style.borderColor = '#7c3aed';
} else if (board.qemu) {
badge.textContent = '🖥 QEMU'; badge.style.display = 'inline';
badge.style.background = board.qemu === 'esp32s3' ? '#1a3a5a' : '#1a3a1a';
badge.style.color = board.qemu === 'esp32s3' ? '#60a5fa' : '#22c55e';
badge.style.borderColor = board.qemu === 'esp32s3' ? '#2a5a8a' : '#2a5a2a';
} else {
badge.style.display = 'none';
}
}
}
function csbBoardSelOpen() {
const dd = document.getElementById('csb-board-sel-dropdown');
if (!dd) return;
const open = dd.style.display !== 'none';
if (open) { dd.style.display = 'none'; return; }
// position:fixed + 여기서 좌표 계산 — 부모 패널이 overflow:hidden/overflow-y:auto라
// position:absolute로는 그 경계 밖으로 못 나가고 잘림(2026-07-21, "2칸만 보임" 리포트).
// 버튼 기준 뷰포트 좌표로 직접 배치해 어떤 조상의 overflow에도 안 걸리게 한다.
const btn = document.getElementById('csb-board-sel-display');
const rect = btn ? btn.getBoundingClientRect() : { left: 20, top: window.innerHeight - 40 };
const margin = 20;
dd.style.left = margin + 'px';
dd.style.width = `calc(100vw - ${margin * 2}px)`;
dd.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
dd.style.maxHeight = 'min(70vh, 620px)';
dd.style.display = 'flex';
const list = document.getElementById('csb-board-sel-list');
if (list) list.style.maxHeight = 'min(60vh, 560px)';
const q = document.getElementById('csb-board-sel-q');
if (q) { q.value = ''; q.focus(); }
csbBoardSelSearch();
}
function csbBoardSelClose() {
const dd = document.getElementById('csb-board-sel-dropdown');
if (dd) dd.style.display = 'none';
}
const _LANG_BOARDS = [
{ fqbn: 'lang:python', name: '🐍 Python', desc: 'Python 스크립트' },
{ fqbn: 'lang:cpp', name: '⚡ C++ (일반)', desc: '표준 C++, Arduino 아님' },
{ fqbn: 'lang:other', name: '📝 기타 언어', desc: 'AI가 언어를 판단' },
];
async function csbBoardSelSearch() {
const q = (document.getElementById('csb-board-sel-q')?.value || '').trim();
const list = document.getElementById('csb-board-sel-list');
if (!list) return;
list.innerHTML = '<div style="grid-column:1/-1;padding:10px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
try {
// 언어 옵션 고정 섹션 — 그리드 레이아웃에서도 헤더/구분선은 전체 폭을 차지해야 하므로 grid-column:1/-1
const langFiltered = q ? _LANG_BOARDS.filter(b => b.name.includes(q) || b.desc.includes(q)) : _LANG_BOARDS;
const langHtml = langFiltered.length ? [
`<div style="grid-column:1/-1;padding:4px 10px 2px;font-size:9px;color:var(--muted);font-weight:600;letter-spacing:.05em;text-transform:uppercase">언어</div>`,
...langFiltered.map(b => {
const sel = b.fqbn === _csbSelBoard.fqbn;
return `<div data-fqbn="${_csbEsc(b.fqbn)}" data-name="${_csbEsc(b.name)}" data-qemu="" data-chip=""
style="padding:6px 10px;cursor:pointer;font-size:11px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:1px;${sel?'background:var(--brand);color:#fff':''}">
<span style="font-weight:600;${sel?'color:#fff':'color:var(--text)'}">${_csbEsc(b.name)}</span>
<span style="font-size:10px;${sel?'color:rgba(255,255,255,.7)':'color:var(--muted)'}">${b.desc}</span>
</div>`;
}),
`<div style="grid-column:1/-1;padding:4px 10px 2px;font-size:9px;color:var(--muted);font-weight:600;letter-spacing:.05em;text-transform:uppercase;border-top:1px solid var(--line)">보드</div>`,
].join('') : '';
const data = await api('/api/arduino/boards/listall?q=' + encodeURIComponent(q));
const boards = data.boards || [];
if (!boards.length && !langFiltered.length) { list.innerHTML = '<div style="grid-column:1/-1;padding:10px;color:var(--muted);font-size:11px;text-align:center">검색 결과 없음</div>'; return; }
list.innerHTML = langHtml + boards.map(b => {
const sel = b.fqbn === _csbSelBoard.fqbn;
const qemuBadge = b.qemu === 'esp8266-native'
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:#0c3a4a;color:#67e8f9;border:1px solid #0e7490">🖥 Native</span>`
: b.qemu === 'renode'
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:#1a0a3a;color:#c084fc;border:1px solid #7c3aed">🖥 Renode</span>`
: b.qemu
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;
background:${b.qemu==='esp32s3'?'#1a3a5a':'#1a3a1a'};
color:${b.qemu==='esp32s3'?'#60a5fa':'#22c55e'};
border:1px solid ${b.qemu==='esp32s3'?'#2a5a8a':'#2a5a2a'}">🖥 QEMU</span>`
: '';
const varBadge = (b.variants > 1)
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:var(--panel-2);color:var(--muted);border:1px solid var(--line)">+${b.variants-1}</span>`
: '';
return `<div data-fqbn="${_csbEsc(b.fqbn)}" data-name="${_csbEsc(b.name)}"
data-qemu="${b.qemu||''}" data-chip="${b.chip||''}"
style="padding:7px 10px;cursor:pointer;font-size:11px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:2px;${sel?'background:var(--brand);color:#fff':''}">
<span style="display:flex;align-items:center;flex-wrap:wrap;gap:0">
<span style="font-weight:600;${sel?'color:#fff':'color:var(--text)'}">${_csbEsc(b.name)}</span>
${sel?'':qemuBadge}${sel?'':varBadge}
</span>
<span style="font-size:10px;${sel?'color:rgba(255,255,255,.75)':'color:var(--muted)'}">${_csbEsc(b.fqbn)}</span>
</div>`;
}).join('');
list.querySelectorAll('[data-fqbn]').forEach(el => {
el.addEventListener('click', () => {
_csbBoardSelSave({ fqbn: el.dataset.fqbn, name: el.dataset.name,
qemu: el.dataset.qemu || false, chip: el.dataset.chip || '' });
csbBoardSelClose();
});
el.addEventListener('mouseenter', () => { if (el.dataset.fqbn !== _csbSelBoard.fqbn) el.style.background = 'var(--bg)'; });
el.addEventListener('mouseleave', () => { if (el.dataset.fqbn !== _csbSelBoard.fqbn) el.style.background = ''; });
});
} catch(e) { list.innerHTML = `<div style="grid-column:1/-1;padding:10px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
}
function csbBoardSelKey(e) {
if (e.key === 'Escape') csbBoardSelClose();
}
// 드롭다운 외부 클릭 시 닫기
document.addEventListener('click', e => {
const display = document.getElementById('csb-board-sel-display');
const dd = document.getElementById('csb-board-sel-dropdown');
if (dd && display && !display.contains(e.target) && !dd.contains(e.target))
dd.style.display = 'none';
});
// 초기 표시 업데이트
(function() {
const el = document.getElementById('csb-board-sel-name');
if (el) el.textContent = _csbSelBoard.name;
// 저장된 보드에 qemu 정보가 있으면 배지 표시
if (_csbSelBoard.qemu) _csbBoardSelSave(_csbSelBoard);
})();
let _csbBoardCurrentTab = 'installed';
function csbBoardTab(tab) {
_csbBoardCurrentTab = tab;
['installed','search'].forEach(t => {
const btn = document.getElementById('csb-boardtab-' + t);
if (btn) btn.classList.toggle('active', t === tab);
});
const sr = document.getElementById('csb-board-search-row');
if (sr) sr.style.display = tab === 'search' ? 'flex' : 'none';
document.getElementById('csb-board-status').textContent = '';
if (tab === 'installed') csbBoardLoad();
else csbBoardSearch();
}
function _csbBoardStatus(msg) { const el = document.getElementById('csb-board-status'); if (el) el.textContent = msg; }
async function csbBoardLoad() {
document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
try {
const data = await api('/api/arduino/cores');
_csbBoardRenderInstalled(data.cores || []);
} catch(e) { document.getElementById('csb-board-list').innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
}
function _csbBoardRenderInstalled(cores) {
const el = document.getElementById('csb-board-list');
if (!cores.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">설치된 보드 없음</div>'; return; }
el.innerHTML = cores.map(c => {
const boardList = c.boards.slice(0,6).map(b => `<span style="font-size:10px;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);border-radius:3px;padding:1px 5px">${_csbEsc(b)}</span>`).join(' ');
const more = c.boards.length > 6 ? `<span style="font-size:10px;color:var(--muted)">+${c.boards.length-6}개</span>` : '';
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line)">
<div style="display:flex;align-items:flex-start;gap:6px">
<div style="flex:1;min-width:0">
<div style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(c.name)}</div>
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(c.id)} · v${_csbEsc(c.version)} · ${_csbEsc(c.maintainer)}</div>
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:5px">${boardList}${more}</div>
</div>
<button onclick="csbBoardUninstall('${_csbEsc(c.id)}')"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:#f87171;border-radius:4px;cursor:pointer;font-family:inherit;flex-shrink:0">삭제</button>
</div>
</div>`;
}).join('');
}
async function csbBoardSearch() {
const q = (document.getElementById('csb-board-q')?.value || '').trim();
document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 중...</div>';
_csbBoardStatus('');
try {
const data = await api('/api/arduino/cores/search?q=' + encodeURIComponent(q));
_csbBoardRenderSearch(data.cores || []);
} catch(e) { document.getElementById('csb-board-list').innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
}
function _csbBoardRenderSearch(cores) {
const el = document.getElementById('csb-board-list');
if (!cores.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 결과 없음</div>'; return; }
el.innerHTML = cores.map(c => {
const boardList = c.boards.slice(0,4).map(b => `<span style="font-size:9px;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);border-radius:3px;padding:1px 4px">${_csbEsc(b)}</span>`).join(' ');
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:6px">
<div style="flex:1;min-width:0">
<div style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(c.name)}</div>
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(c.id)} · v${_csbEsc(c.version)}</div>
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:4px">${boardList}${c.boards.length>4?`<span style="font-size:9px;color:var(--muted)">+${c.boards.length-4}개</span>`:''}</div>
</div>
<div style="flex-shrink:0;padding-top:2px">
${c.installed
? `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`
: `<button onclick="csbBoardInstall('${_csbEsc(c.id)}',this)"
style="font-size:10px;padding:3px 8px;background:var(--brand);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:inherit">설치</button>`}
</div>
</div>`;
}).join('');
}
async function csbBoardInstall(id, btn) {
_csbBoardStatus(`설치 중: ${id} (수 분 소요)...`);
if (btn) { btn.disabled = true; btn.textContent = '...'; }
try {
const data = await api('/api/arduino/cores/install', { method: 'POST', body: JSON.stringify({ id }) });
if (data.error) { _csbBoardStatus('오류: ' + data.error.slice(0,150)); if(btn){btn.disabled=false;btn.textContent='설치';} return; }
_csbBoardStatus(`✅ ${id} 설치 완료`);
if (btn) btn.outerHTML = `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`;
} catch(e) { _csbBoardStatus('오류: ' + e.message); if(btn){btn.disabled=false;btn.textContent='설치';} }
}
async function csbBoardUninstall(id) {
if (!confirm(`"${id}" 보드를 삭제할까요?`)) return;
_csbBoardStatus(`삭제 중: ${id}...`);
try {
const data = await api('/api/arduino/cores/uninstall', { method: 'DELETE', body: JSON.stringify({ id }) });
if (data.error) { _csbBoardStatus('오류: ' + data.error.slice(0,100)); return; }
_csbBoardStatus(`✅ ${id} 삭제 완료`);
csbBoardLoad();
} catch(e) { _csbBoardStatus('오류: ' + e.message); }
}
let _codeAiCtxMaxCached = 200000; // default until server responds
let _codeAiCtxMaxFetchedFor = ''; // model name the cache is valid for
// Real token usage from the last turn (server 'usage' SSE). When present, the gauge
// shows actual prompt tokens / real context window instead of the char/4 estimate.
let _codeAiLastUsage = null; // { promptTokens, ctxWindow }
function _codeAiCtxMax() { return _codeAiCtxMaxCached; }
// Called from streaming loops when a 'usage' SSE arrives.
function _codeAiOnUsage(ev) {
if (!ev || typeof ev.promptTokens !== 'number') return;
_codeAiLastUsage = { promptTokens: ev.promptTokens, ctxWindow: ev.ctxWindow || _codeAiCtxMax() };
try { updateSessionPanel(); } catch (_) {}
}
async function _fetchModelCtxMax(model) {
const key = model || '';
if (key === _codeAiCtxMaxFetchedFor) return; // already fetched for this model
_codeAiCtxMaxFetchedFor = key;
try {
const url = '/api/model-context' + (key ? `?model=${encodeURIComponent(key)}` : '');
const r = await fetch(url, { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
if (d.provisional) {
// Guess, not a measurement — undo the once-per-model latch so the next call re-asks.
_codeAiCtxMaxFetchedFor = null;
return;
}
if (d.contextWindow) {
_codeAiCtxMaxCached = d.contextWindow;
updateSessionPanel();
}
} catch (_) {}
}
function updateSessionPanel() {
const barProj = document.getElementById('code-ai-session-bar-project');
const barCount = document.getElementById('code-ai-session-bar-count');
const backBtn = document.getElementById('code-ai-back-btn');
if (barProj) barProj.textContent = activeProjectName || '(전체)';
if (barCount) barCount.textContent = codeAiHistory.length ? `${codeAiHistory.length}개` : '';
if (backBtn) backBtn.style.display = codeAiHistory.length ? '' : 'none';
// Context usage estimate
const ctxWrap = document.getElementById('code-ai-ctx-wrap');
const ctxLabel = document.getElementById('code-ai-ctx-label');
const ctxFill = document.getElementById('code-ai-ctx-fill');
if (ctxWrap && ctxLabel && ctxFill) {
// Fill = real prompt tokens from the last turn (fallback: char/4 estimate).
// Max = the model's NATIVE context_length (true ceiling), not the per-turn num_ctx.
const totalChars = codeAiHistory.reduce((s, m) => s + (m.content || '').length, 0);
const estTokens = _codeAiLastUsage ? _codeAiLastUsage.promptTokens : Math.round(totalChars / 4);
const maxTokens = _codeAiCtxMax();
const pct = Math.min(100, Math.round(estTokens / maxTokens * 100));
const kStr = estTokens >= 1000 ? `~${(estTokens/1000).toFixed(1)}k` : `${estTokens}`;
const maxKStr = maxTokens >= 1000 ? `${(maxTokens/1000).toFixed(0)}k` : `${maxTokens}`;
ctxLabel.textContent = `${kStr} / ${maxKStr}`;
ctxFill.style.width = pct + '%';
ctxFill.style.background = pct >= 80 ? '#ef4444' : pct >= 50 ? '#f59e0b' : '#22c55e';
ctxWrap.style.display = 'flex';
}
}
async function codeSbRefresh() {
const tree = document.getElementById('csb-file-tree');
if (!tree) return;
tree.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">로딩 중...</div>';
if (!localDirHandle) {
tree.innerHTML = `<div style="padding:14px 10px;font-size:11px;color:var(--muted);text-align:center">
<div style="font-size:22px;margin-bottom:6px">🖥️</div>
<div style="margin-bottom:10px">로컬 폴더를 선택하세요</div>
<button onclick="openLocalFolder()" style="background:var(--brand);color:#fff;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-size:11px;font-weight:700">📁 폴더 열기</button>
</div>`;
return;
}
try {
const allFiles = await localListFiles(localDirHandle, '');
// Build project list: dirs that contain code files + all known projects (persisted in localStorage)
const projectSet = new Set(allFiles.filter(f => f.includes('/')).map(f => f.split('/')[0]));
_knownProjectsGet().forEach(p => projectSet.add(p));
if (activeProjectName) projectSet.add(activeProjectName);
const projects = [...projectSet].sort();
csbRenderProjectSelector(projects);
const files = activeProjectName
? allFiles.filter(f => f.startsWith(activeProjectName + '/'))
: allFiles;
if (!files.length) {
const emptyMsg = activeProjectName
? `<div style="padding:10px;font-size:11px;color:var(--muted)">
<div style="font-weight:600;color:var(--text);margin-bottom:4px">📁 ${escHtml(activeProjectName)}/</div>
<div style="padding-left:12px;color:var(--muted)">비어 있음 — + 버튼으로 파일 추가</div>
</div>`
: '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">파일 없음<br><small>+ 버튼으로 추가</small></div>';
tree.innerHTML = emptyMsg;
return;
}
const extIcon = { py:'🐍', js:'⚡', ts:'🔷', cpp:'⚙️', c:'⚙️', cs:'💜',
go:'🔵', rs:'🦀', java:'☕', html:'🌐', css:'🎨', sh:'💻', bash:'💻',
kt:'🎯', swift:'🍎', rb:'💎', php:'🐘', sql:'🗃️', md:'📝', json:'📋',
yaml:'📋', yml:'📋', txt:'📄' };
// 트리 노드 구조 빌드 (leaf = 항상 전체 경로)
const root = {};
for (const f of files) {
const parts = f.split('/');
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
if (!node[parts[i]]) node[parts[i]] = {};
node = node[parts[i]];
}
node[parts[parts.length - 1]] = f; // leaf = full path string
}
// 프로젝트 선택 시: 해당 서브트리를 루트로, parentPath를 프로젝트명으로 시작
const displayRoot = (activeProjectName && root[activeProjectName]) ? root[activeProjectName] : root;
const startPath = activeProjectName || '';
const openDirs = csbGetOpenDirs();
function renderNode(node, depth, parentPath) {
const indent = depth * 14;
let html = '';
// 디렉토리 먼저, 파일 나중
const dirs = [], leafs = [];
for (const [k, v] of Object.entries(node)) {
if (typeof v === 'object') dirs.push([k, v]);
else leafs.push([k, v]);
}
dirs.sort((a,b) => a[0].localeCompare(b[0]));
leafs.sort((a,b) => a[0].localeCompare(b[0]));
for (const [name, children] of dirs) {
const dirId = 'csbdir_' + (parentPath+'/'+name).replace(/[^a-z0-9]/gi,'_');
const isOpen = openDirs.has(dirId);
const dirPath = (parentPath+'/'+name).replace(/^\//,'');
const safeDirPath = dirPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
html += `<div class="csb-file-item" data-dirpath="${escHtml(dirPath)}" style="padding-left:${indent+8}px;font-weight:600;color:var(--muted)"
onclick="csbToggleDir('${dirId}')"
oncontextmenu="csbCtxMenu(event,this,true)"
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('csb-drop-target')"
ondragleave="this.classList.remove('csb-drop-target')"
ondrop="csbDrop(event,this)">
<span id="${dirId}_arrow" style="font-size:9px;transition:.15s;flex-shrink:0">${isOpen ? '▼' : '▶'}</span><span style="flex-shrink:0">📂</span>
<span class="csb-item-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escHtml(name)}/</span>
<button class="csb-add-btn" onclick="csbNewInDir(this.closest('[data-dirpath]').dataset.dirpath,event)" title="새 파일">+</button>
<button class="csb-del" onclick="csbDeleteDir('${safeDirPath}',event)" title="디렉토리 삭제">✕</button>
</div>
<div id="${dirId}" style="display:${isOpen ? 'block' : 'none'}">
${renderNode(children, depth+1, parentPath+'/'+name)}
</div>`;
}
for (const [name, fullPath] of leafs) {
const ext = name.split('.').pop().toLowerCase();
const icon = extIcon[ext] || '📄';
const safe = fullPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
html += `<div class="csb-file-item" data-path="${escHtml(fullPath)}" style="padding-left:${indent+8}px"
draggable="true"
ondragstart="event.dataTransfer.setData('text/plain',this.dataset.path);event.dataTransfer.effectAllowed='move'"
oncontextmenu="csbCtxMenu(event,this,false)"
onclick="csbOpenFile('${safe}')">
<span style="flex-shrink:0">${icon}</span>
<span class="csb-item-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${escHtml(fullPath)}">${escHtml(name)}</span>
<button class="csb-del" onclick="csbDeleteFile('${safe}',event)" title="삭제">✕</button>
</div>`;
}
return html;
}
tree.innerHTML = renderNode(displayRoot, 0, startPath);
} catch {
tree.innerHTML = '<div style="padding:12px;color:#f87171;font-size:11px;text-align:center">불러오기 실패</div>';
}
}
function csbGetOpenDirs() {
try { return new Set(JSON.parse(localStorage.getItem('csb_open_dirs') || '[]')); } catch { return new Set(); }
}
function csbSaveOpenDirs(set) {
try { localStorage.setItem('csb_open_dirs', JSON.stringify([...set])); } catch {}
}
function csbToggleDir(id) {
const el = document.getElementById(id);
const arrow = document.getElementById(id + '_arrow');
if (!el) return;
const collapsed = el.style.display === 'none';
el.style.display = collapsed ? 'block' : 'none';
if (arrow) arrow.textContent = collapsed ? '▼' : '▶';
const dirs = csbGetOpenDirs();
if (collapsed) dirs.add(id); else dirs.delete(id);
csbSaveOpenDirs(dirs);
}
async function csbOpenFile(filename) {
if (!localDirHandle) return;
try {
const content = await localReadFile(filename);
if (content === null) return;
// In client-local+project mode, tabs use bare names; sidebar paths include the project prefix
const bare = _clientBareFileName(filename);
const existing = codeFiles.find(f => f.name === bare);
if (existing) switchCodeFile(existing.id);
else codeNewFile(bare, content);
} catch {}
}
async function csbDeleteFile(filename, ev) {
ev.stopPropagation();
if (!localDirHandle || !confirm(`"${filename}" 삭제할까요?`)) return;
try {
const ok = await localDeleteFile(filename);
if (ok) {
const bare = _clientBareFileName(filename);
const existing = codeFiles.find(f => f.name === bare);
if (existing) closeCodeFile(existing.id);
codeSbRefresh();
}
} catch {}
}
async function csbDeleteDir(dirPath, ev) {
ev.stopPropagation();
if (!localDirHandle || !confirm(`"${dirPath}" 디렉토리와 내부 파일을 모두 삭제할까요?`)) return;
try {
const parts = dirPath.split('/').filter(Boolean);
let parent = localDirHandle;
for (let i = 0; i < parts.length - 1; i++) parent = await parent.getDirectoryHandle(parts[i]);
await parent.removeEntry(parts[parts.length - 1], { recursive: true });
// 해당 디렉토리 아래 열려 있는 탭 모두 닫기
const prefix = dirPath.endsWith('/') ? dirPath : dirPath + '/';
for (const f of [...codeFiles]) {
if (f.name === dirPath || f.name.startsWith(prefix)) closeCodeFile(f.id);
}
codeSbRefresh();
} catch (e) {
alert('삭제 실패: ' + e.message);
}
}
// ── Context menu ─────────────────────────────────────────────────────────────
let _csbCtxTarget = null;
function csbCtxMenu(ev, el, isDir) {
ev.preventDefault(); ev.stopPropagation();
_csbCtxTarget = { path: el.dataset.path || el.dataset.dirpath, isDir };
const menu = document.getElementById('csb-ctx');
if (!menu) return;
const items = isDir ? [
{ label: '📄 새 파일', action: 'newfile' },
{ label: '📁 새 폴더', action: 'newfolder' },
{ label: '✏️ 이름 바꾸기', action: 'rename' },
{ label: '🗑️ 삭제', action: 'delete' },
] : [
{ label: '✏️ 이름 바꾸기', action: 'rename' },
{ label: '🗑️ 삭제', action: 'delete' },
];
menu.innerHTML = items.map(i => `<div class="csb-ctx-item" onclick="csbCtxAction('${i.action}')">${i.label}</div>`).join('');
menu.style.display = 'block';
const x = Math.min(ev.clientX, window.innerWidth - 160);
const y = Math.min(ev.clientY, window.innerHeight - (items.length * 34) - 16);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
}
function csbCtxClose() {
const menu = document.getElementById('csb-ctx');
if (menu) menu.style.display = 'none';
_csbCtxTarget = null;
}
function csbCtxAction(action) {
const target = _csbCtxTarget;
csbCtxClose();
if (!target) return;
const { path, isDir } = target;
if (action === 'rename') csbInlineRename(path, isDir);
else if (action === 'delete') isDir ? csbDeleteDir(path, { stopPropagation: () => {} }) : csbDeleteFile(path, { stopPropagation: () => {} });
else if (action === 'newfile') csbNewInDir(path, null);
else if (action === 'newfolder') csbNewFolderAt(path);
}
// ── Inline rename ─────────────────────────────────────────────────────────────
function csbInlineRename(path, isDir) {
const tree = document.getElementById('csb-file-tree');
if (!tree) return;
const attr = isDir ? 'data-dirpath' : 'data-path';
const item = tree.querySelector(`[${attr}="${path.replace(/\\/g,'\\\\').replace(/"/g,'\\"')}"]`);
if (!item) return;
const nameEl = item.querySelector('.csb-item-name');
if (!nameEl) return;
const oldBase = path.split('/').pop();
const inp = document.createElement('input');
inp.value = oldBase;
inp.style.cssText = 'flex:1;min-width:0;font-size:11px;padding:1px 4px;background:var(--panel);color:var(--text);border:1px solid var(--brand);border-radius:3px;outline:none;font-family:inherit';
nameEl.replaceWith(inp);
inp.focus(); inp.select();
let done = false;
const commit = async () => {
if (done) return; done = true;
const newName = inp.value.trim();
if (newName && newName !== oldBase) await csbRenameExec(path, newName, isDir);
else await codeSbRefresh();
};
inp.onblur = commit;
inp.onkeydown = e => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') { done = true; codeSbRefresh(); }
};
}
async function csbRenameExec(oldPath, newName, isDir) {
if (!localDirHandle) return;
const parts = oldPath.split('/');
parts[parts.length - 1] = newName;
const newPath = parts.join('/');
try {
if (!isDir) {
const content = await localReadFile(oldPath);
if (content === null) throw new Error('읽기 실패');
await localWriteFile(newPath, content);
await localDeleteFile(oldPath);
const bare = _clientBareFileName(oldPath);
const tab = codeFiles.find(f => f.name === bare);
if (tab) { tab.name = _clientBareFileName(newPath); renderCodeFileTabs(); }
} else {
const allFiles = await localListFiles(localDirHandle, '');
const prefix = oldPath + '/';
for (const f of allFiles.filter(f => f.startsWith(prefix))) {
const content = await localReadFile(f);
if (content !== null) await localWriteFile(newPath + '/' + f.slice(prefix.length), content);
}
const ps = oldPath.split('/');
let parent = localDirHandle;
for (let i = 0; i < ps.length - 1; i++) parent = await parent.getDirectoryHandle(ps[i]);
await parent.removeEntry(ps[ps.length - 1], { recursive: true });
for (const f of codeFiles) {
const disk = _clientProjectPath(f.name);
if (disk.startsWith(prefix)) f.name = _clientBareFileName(newPath + '/' + disk.slice(prefix.length));
}
renderCodeFileTabs();
}
await codeSbRefresh();
} catch (e) { alert('이름 바꾸기 실패: ' + (e.message || e)); await codeSbRefresh(); }
}
// ── New file/folder helpers ───────────────────────────────────────────────────
async function csbNewInDir(dirPath, ev) {
if (ev) ev.stopPropagation();
if (!localDirHandle) return;
const name = prompt(`새 파일 이름 (${dirPath.split('/').pop()}/ 에 생성):`);
if (!name?.trim()) return;
const fullPath = dirPath + '/' + name.trim();
await localWriteFile(fullPath, '');
codeNewFile(_clientBareFileName(fullPath), '');
codeSbRefresh();
}
async function csbNewFolderAt(parentPath, ev) {
if (ev) ev.stopPropagation();
if (!localDirHandle) return;
const name = prompt(`새 폴더 이름 (${parentPath.split('/').pop()}/ 안에 생성):`);
if (!name?.trim()) return;
await localWriteFile(parentPath + '/' + name.trim() + '/.gitkeep', '');
codeSbRefresh();
}
async function csbNewFolderRoot() {
if (!localDirHandle) return;
const base = activeProjectName ? activeProjectName + '/' : '';
const name = prompt('새 폴더 이름:');
if (!name?.trim()) return;
await localWriteFile(base + name.trim() + '/.gitkeep', '');
codeSbRefresh();
}
// ── Drag and drop ─────────────────────────────────────────────────────────────
function csbDrop(ev, el) {
ev.preventDefault(); ev.stopPropagation();
el.classList.remove('csb-drop-target');
const srcPath = ev.dataTransfer.getData('text/plain');
const dirPath = el.dataset.dirpath;
if (!srcPath || !dirPath) return;
const dstPath = dirPath + '/' + srcPath.split('/').pop();
if (srcPath === dstPath) return;
csbMoveFile(srcPath, dstPath);
}
async function csbMoveFile(srcPath, dstPath) {
if (!localDirHandle) return;
try {
const content = await localReadFile(srcPath);
if (content === null) { alert(`읽기 실패: ${srcPath}`); return; }
await localWriteFile(dstPath, content);
await localDeleteFile(srcPath);
const tab = codeFiles.find(f => f.name === _clientBareFileName(srcPath));
if (tab) { tab.name = _clientBareFileName(dstPath); renderCodeFileTabs(); }
await codeSbRefresh();
} catch (e) { alert('이동 실패: ' + (e.message || e)); }
}
async function csbLoadModels() {
const sel = document.getElementById('csb-model-select');
if (!sel) return;
try {
const [settingRes, ollamaRes] = await Promise.all([
fetch('/api/settings/model', { headers: authHeaders() }),
fetch('/api/ollama/models', { headers: authHeaders() }),
]);
const setting = settingRes.ok ? await settingRes.json() : {};
const ollamaData = ollamaRes.ok ? await ollamaRes.json() : {};
const primaryModel = setting.primary || '설정 모델';
const ollamaModels = (ollamaData.models || []).map(m => m.name);
sel.innerHTML = `<option value="">기본 (${primaryModel})</option>`;
const allModels = [...ollamaModels];
if (codeAiModel && !allModels.includes(codeAiModel)) allModels.unshift(codeAiModel);
for (const m of allModels) {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m;
if (m === codeAiModel) opt.selected = true;
sel.appendChild(opt);
}
if (!codeAiModel) sel.value = '';
updateCodeAiLabel();
} catch {}
}
function csbModelChanged() {
const sel = document.getElementById('csb-model-select');
codeAiModel = sel ? sel.value : '';
localStorage.setItem('codeAiModel', codeAiModel);
updateCodeAiLabel();
_codeAiCtxMaxFetchedFor = ''; // invalidate cache so next fetch runs
_fetchModelCtxMax(codeAiModel);
}
function csbFontSize(val) {
document.getElementById('csb-fontsize-label').textContent = val;
if (monacoEditor) monacoEditor.updateOptions({ fontSize: parseInt(val) });
localStorage.setItem('codeEditorFontSize', val);
}
function csbWordWrap(checked) {
if (monacoEditor) monacoEditor.updateOptions({ wordWrap: checked ? 'on' : 'off' });
localStorage.setItem('codeEditorWordWrap', checked ? '1' : '0');
}
function csbFontWeight(val) {
localStorage.setItem('codeEditorFontWeight', val);
if (monacoEditor) monacoEditor.updateOptions({ fontWeight: val });
}
function csbKarpathy(checked) {
_karpathyMode = !!checked;
localStorage.setItem('codeKarpathyMode', _karpathyMode ? '1' : '0');
const btn = document.getElementById('code-opt-karpathy');
if (btn) btn.classList.toggle('active', _karpathyMode);
}
// ── Chat image attach (paste / drag-drop) ────────────────────────────────────
function _codeAiAddImage(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
_pendingImages.push({ dataUrl: e.target.result, mimeType: file.type });
_codeAiRenderImagePreview();
};
reader.readAsDataURL(file);
}
function _codeAiPasteImage(e) {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
e.preventDefault();
_codeAiAddImage(item.getAsFile());
}
}
}
function _codeAiDropImage(e) {
e.preventDefault();
document.getElementById('code-ai-input-row')?.classList.remove('drag-over');
for (const file of (e.dataTransfer?.files || [])) _codeAiAddImage(file);
}
function _codeAiRenderImagePreview() {
const wrap = document.getElementById('code-ai-img-preview');
if (!wrap) return;
if (!_pendingImages.length) { wrap.style.display = 'none'; wrap.innerHTML = ''; return; }
wrap.style.display = 'flex';
wrap.innerHTML = '';
_pendingImages.forEach((img, i) => {
const thumb = document.createElement('div');
thumb.className = 'code-ai-img-thumb';
thumb.innerHTML = `<img src="${img.dataUrl}" style="max-width:80px;max-height:60px;border-radius:4px;display:block"><button onclick="_codeAiRemoveImage(${i})" title="제거" style="position:absolute;top:-4px;right:-4px;background:#ef4444;color:#fff;border:none;border-radius:50%;width:16px;height:16px;font-size:10px;cursor:pointer;line-height:1;padding:0">×</button>`;
wrap.appendChild(thumb);
});
}
function _codeAiRemoveImage(i) {
_pendingImages.splice(i, 1);
_codeAiRenderImagePreview();
}
let _hideBlankLines = false;
function csbHideBlankLines(checked) {
_hideBlankLines = !!checked;
localStorage.setItem('codeEditorHideBlank', _hideBlankLines ? '1' : '0');
const btn = document.getElementById('code-opt-hideblank');
if (btn) btn.classList.toggle('active', _hideBlankLines);
_applyHideBlankLines();
}
function _applyHideBlankLines() {
if (!monacoEditor) return;
if (!_hideBlankLines) { try { monacoEditor.setHiddenAreas([]); } catch {} return; }
const model = monacoEditor.getModel();
if (!model) return;
const n = model.getLineCount();
const ranges = [];
let start = -1;
let inBlock = false, blockEnd = '';
for (let i = 1; i <= n; i++) {
const l = model.getLineContent(i).trim();
let hide = false;
if (inBlock) {
hide = true;
if (l.includes(blockEnd)) inBlock = false;
} else if (l === '') {
hide = true;
} else if (/^(\/\/|--|;)/.test(l) || /^#(?!include|define|ifdef|ifndef|endif|else|elif|pragma|undef|error|warning)/.test(l)) {
hide = true;
} else if (l.startsWith('/*')) {
hide = true;
if (!l.slice(2).includes('*/')) { inBlock = true; blockEnd = '*/'; }
} else if (l.startsWith('<!--')) {
hide = true;
if (!l.slice(4).includes('-->')) { inBlock = true; blockEnd = '-->'; }
} else if (l.startsWith('"""') || l.startsWith("'''")) {
const d = l.slice(0, 3);
hide = true;
if (!l.slice(3).includes(d)) { inBlock = true; blockEnd = d; }
}
if (hide) { if (start < 0) start = i; }
else { if (start >= 0) { ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: i - 1, endColumn: 1 }); start = -1; } }
}
if (start >= 0) ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: n, endColumn: 1 });
try { monacoEditor.setHiddenAreas(ranges); } catch {}
}
let _blockFlipFlop = false;
let _blockFFDecoIds = [];
function csbBlockFlipFlop(checked) {
_blockFlipFlop = !!checked;
localStorage.setItem('codeEditorBlockFF', _blockFlipFlop ? '1' : '0');
const btn = document.getElementById('code-opt-blockff');
if (btn) btn.classList.toggle('active', _blockFlipFlop);
_applyBlockFlipFlop();
}
function _applyBlockFlipFlop() {
if (!monacoEditor) return;
const model = monacoEditor.getModel();
if (!model) return;
_blockFFDecoIds = monacoEditor.deltaDecorations(_blockFFDecoIds, []);
if (!_blockFlipFlop) return;
const n = model.getLineCount();
const decos = [];
// Detect top-level function/class/def declarations
const declRe = /^(export\s+)?(default\s+)?(async\s+)?(function\b|class\b|def\b|async\s+def\b)|^(const|let|var)\s+\w+\s*=\s*(async\s+)?(function|\(|[A-Za-z_$]\w*\s*=>)/;
const boundaries = [];
for (let i = 1; i <= n; i++) {
const line = model.getLineContent(i);
if (line.match(/^(\s*)/)[1].length === 0 && declRe.test(line.trim())) {
boundaries.push(i);
}
}
const ffCls = (idx) => idx % 2 === 0 ? 'ff-block-even' : 'ff-block-odd';
const ffRange = (from, to, idx) => ({ range: { startLineNumber: from, startColumn: 1, endLineNumber: to, endColumn: 1 }, options: { isWholeLine: true, className: ffCls(idx) } });
if (boundaries.length >= 2) {
// Declaration-based: alternate between declaration boundaries
for (let b = 0; b < boundaries.length; b++) {
const from = boundaries[b];
const to = b + 1 < boundaries.length ? boundaries[b + 1] - 1 : n;
decos.push(ffRange(from, to, b));
}
} else {
// Fallback: blank-line-separated groups
let phase = 0, blockStart = -1;
for (let i = 1; i <= n; i++) {
const blank = model.getLineContent(i).trim() === '';
if (!blank && blockStart < 0) { blockStart = i; }
else if (blank && blockStart >= 0) {
decos.push(ffRange(blockStart, i - 1, phase));
blockStart = -1; phase++;
}
}
if (blockStart >= 0) decos.push(ffRange(blockStart, n, phase));
}
_blockFFDecoIds = monacoEditor.deltaDecorations([], decos);
}
const CODE_THEME_GROUPS = [
{ icon: '🌙', label: '다크', themes: [
{ v: 'github-dark', label: 'GitHub Dark', bg: '#0d1117' },
{ v: 'one-dark', label: 'One Dark', bg: '#282c34' },
{ v: 'dracula', label: 'Dracula', bg: '#282a36' },
{ v: 'nord', label: 'Nord', bg: '#2e3440' },
{ v: 'vs-dark', label: 'VS Dark', bg: '#1e1e1e' },
{ v: 'monokai', label: 'Monokai', bg: '#272822' },
]},
{ icon: '☀️', label: '라이트', themes: [
{ v: 'github-light', label: 'GitHub Light', bg: '#f6f8fa' },
{ v: 'vs', label: 'VS Light', bg: '#ffffff' },
]},
];
const CODE_THEMES = CODE_THEME_GROUPS.flatMap(g => g.themes);
function defineCodeThemes() {
const d = monaco.editor.defineTheme.bind(monaco.editor);
d('monokai', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'f92672'},{token:'string',foreground:'e6db74'},
{token:'comment',foreground:'75715e',fontStyle:'italic'},{token:'number',foreground:'ae81ff'},
{token:'type',foreground:'66d9ef'},{token:'function',foreground:'a6e22e'},
], colors:{'editor.background':'#272822','editor.foreground':'#f8f8f2','editor.selectionBackground':'#49483e','editorCursor.foreground':'#f8f8f0'}});
d('dracula', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'ff79c6'},{token:'string',foreground:'f1fa8c'},
{token:'comment',foreground:'6272a4',fontStyle:'italic'},{token:'number',foreground:'bd93f9'},
{token:'type',foreground:'8be9fd'},{token:'function',foreground:'50fa7b'},
], colors:{'editor.background':'#282a36','editor.foreground':'#f8f8f2','editor.selectionBackground':'#44475a','editorCursor.foreground':'#f8f8f2','editor.lineHighlightBackground':'#44475a'}});
d('one-dark', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'c678dd'},{token:'string',foreground:'98c379'},
{token:'comment',foreground:'5c6370',fontStyle:'italic'},{token:'number',foreground:'d19a66'},
{token:'type',foreground:'e5c07b'},{token:'function',foreground:'61afef'},
], colors:{'editor.background':'#282c34','editor.foreground':'#abb2bf','editor.selectionBackground':'#3e4451','editorCursor.foreground':'#528bff'}});
d('nord', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'81a1c1'},{token:'string',foreground:'a3be8c'},
{token:'comment',foreground:'4c566a',fontStyle:'italic'},{token:'number',foreground:'b48ead'},
{token:'type',foreground:'8fbcbb'},{token:'function',foreground:'88c0d0'},
], colors:{'editor.background':'#2e3440','editor.foreground':'#d8dee9','editor.selectionBackground':'#3b4252','editorCursor.foreground':'#d8dee9','editor.lineHighlightBackground':'#3b4252'}});
d('github-dark', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'ff7b72'},{token:'string',foreground:'a5d6ff'},
{token:'comment',foreground:'8b949e',fontStyle:'italic'},{token:'number',foreground:'79c0ff'},
{token:'type',foreground:'ffa657'},{token:'function',foreground:'d2a8ff'},
], colors:{'editor.background':'#0d1117','editor.foreground':'#c9d1d9','editor.selectionBackground':'#163b55','editorCursor.foreground':'#e6edf3'}});
d('tomorrow-night', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'b294bb'},{token:'string',foreground:'b5bd68'},
{token:'comment',foreground:'969896',fontStyle:'italic'},{token:'number',foreground:'de935f'},
{token:'type',foreground:'81a2be'},{token:'function',foreground:'81a2be'},
], colors:{'editor.background':'#1d1f21','editor.foreground':'#c5c8c6','editor.selectionBackground':'#373b41','editorCursor.foreground':'#aeafad'}});
d('solarized-dark', { base:'vs-dark', inherit:true, rules:[
{token:'keyword',foreground:'859900'},{token:'string',foreground:'2aa198'},
{token:'comment',foreground:'657b83',fontStyle:'italic'},{token:'number',foreground:'d33682'},
{token:'type',foreground:'b58900'},{token:'function',foreground:'268bd2'},
], colors:{'editor.background':'#002b36','editor.foreground':'#839496','editor.selectionBackground':'#073642','editorCursor.foreground':'#839496'}});
d('github-light', { base:'vs', inherit:true, rules:[
{token:'keyword',foreground:'d73a49'},{token:'string',foreground:'032f62'},
{token:'comment',foreground:'6a737d',fontStyle:'italic'},{token:'number',foreground:'005cc5'},
{token:'type',foreground:'e36209'},{token:'function',foreground:'6f42c1'},
], colors:{'editor.background':'#f6f8fa','editor.foreground':'#24292e','editor.selectionBackground':'#c8c8fa','editorCursor.foreground':'#24292e'}});
d('solarized-light', { base:'vs', inherit:true, rules:[
{token:'keyword',foreground:'859900'},{token:'string',foreground:'2aa198'},
{token:'comment',foreground:'93a1a1',fontStyle:'italic'},{token:'number',foreground:'d33682'},
{token:'type',foreground:'b58900'},{token:'function',foreground:'268bd2'},
], colors:{'editor.background':'#fdf6e3','editor.foreground':'#657b83','editor.selectionBackground':'#eee8d5','editorCursor.foreground':'#657b83'}});
}
function renderThemeGrid() {
const grid = document.getElementById('csb-theme-grid');
if (!grid) return;
const cur = localStorage.getItem('codeEditorTheme') ||
(document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs');
grid.className = 'code-theme-groups';
grid.innerHTML = CODE_THEME_GROUPS.map(g =>
`<div class="code-theme-row">
<span class="code-theme-row-lbl">${g.icon} ${g.label}</span>
${g.themes.map(t =>
`<div class="code-skin-sw${t.v===cur?' active':''}" data-v="${t.v}" title="${t.label}"
style="background:${t.bg}" onclick="csbTheme('${t.v}')"></div>`
).join('')}
</div>`
).join('');
}
function setCodePageTheme(name) {
document.documentElement.setAttribute('data-theme', name);
localStorage.setItem('code-page-theme', name);
document.querySelectorAll('.code-theme-btn').forEach(b =>
b.classList.toggle('active', b.id === 'ctbtn-' + name));
// Monaco 에디터 테마도 연동
if (window.monacoEditor && !localStorage.getItem('codeEditorTheme')) {
const autoTheme = name === 'light' ? 'vs' : name === 'warm' ? 'solarized-light' : 'vs-dark';
if (window.monaco) monaco.editor.setTheme(autoTheme);
}
}
(function() {
const t = localStorage.getItem('code-page-theme') || localStorage.getItem('homeclaw_theme') || 'dark';
setCodePageTheme(t);
})();
function csbTheme(theme) {
if (window.monaco) {
if (!['vs','vs-dark','hc-black','hc-light'].includes(theme)) defineCodeThemes();
monaco.editor.setTheme(theme);
}
localStorage.setItem('codeEditorTheme', theme);
const lbl = CODE_THEMES.find(t => t.v === theme);
const el = document.getElementById('csb-theme-lbl');
if (el) el.textContent = lbl ? lbl.label : theme;
document.querySelectorAll('#csb-theme-grid .code-skin-sw').forEach(sw =>
sw.classList.toggle('active', sw.dataset.v === theme));
}
function csbTabSize(val) {
if (monacoEditor) monacoEditor.updateOptions({ tabSize: parseInt(val) });
localStorage.setItem('codeEditorTabSize', val);
}
function csbMinimap(checked) {
if (monacoEditor) monacoEditor.updateOptions({ minimap: { enabled: checked } });
localStorage.setItem('codeEditorMinimap', checked ? '1' : '0');
}
function csbFontFamily(val) {
localStorage.setItem('codeEditorFontFamily', val);
if (monacoEditor) monacoEditor.updateOptions({ fontFamily: val === 'monospace' ? 'monospace' : `'${val}', monospace` });
}
// Restore editor settings from localStorage
function csbRestoreSettings() {
const fs = localStorage.getItem('codeEditorFontSize');
const ww = localStorage.getItem('codeEditorWordWrap');
const th = localStorage.getItem('codeEditorTheme');
const ts = localStorage.getItem('codeEditorTabSize');
const mm = localStorage.getItem('codeEditorMinimap');
const ff = localStorage.getItem('codeEditorFontFamily') || 'D2Coding';
const fw = localStorage.getItem('codeEditorFontWeight') || '400';
const hb = localStorage.getItem('codeEditorHideBlank');
const bff = localStorage.getItem('codeEditorBlockFF');
if (fs) { document.getElementById('csb-fontsize').value = fs; document.getElementById('csb-fontsize-label').textContent = fs; }
if (ww !== null) document.getElementById('csb-wordwrap').checked = ww === '1';
if (ts) document.getElementById('csb-tabsize').value = ts;
if (mm !== null) document.getElementById('csb-minimap').checked = mm === '1';
const ffEl = document.getElementById('csb-fontfamily'); if (ffEl) ffEl.value = ff;
const fwEl = document.getElementById('csb-fontweight'); if (fwEl) fwEl.value = fw;
_hideBlankLines = hb === '1';
_blockFlipFlop = bff === '1';
const kp = localStorage.getItem('codeKarpathyMode');
_karpathyMode = kp === '1';
const hbBtn = document.getElementById('code-opt-hideblank'); if (hbBtn) hbBtn.classList.toggle('active', _hideBlankLines);
const bffBtn = document.getElementById('code-opt-blockff'); if (bffBtn) bffBtn.classList.toggle('active', _blockFlipFlop);
const kpBtn = document.getElementById('code-opt-karpathy'); if (kpBtn) kpBtn.classList.toggle('active', _karpathyMode);
if (monacoEditor) monacoEditor.updateOptions({
fontSize: fs ? parseInt(fs) : 14,
wordWrap: ww === '0' ? 'off' : 'on',
tabSize: ts ? parseInt(ts) : 2,
minimap: { enabled: mm !== '0' },
fontFamily: ff === 'monospace' ? 'monospace' : `'${ff}', monospace`,
fontWeight: fw,
});
if (_hideBlankLines) _applyHideBlankLines();
if (_blockFlipFlop) _applyBlockFlipFlop();
const activeTheme = th || (document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs');
if (window.monaco) { defineCodeThemes(); monaco.editor.setTheme(activeTheme); }
renderThemeGrid();
const lbl = CODE_THEMES.find(t => t.v === activeTheme);
const lblEl = document.getElementById('csb-theme-lbl');
if (lblEl) lblEl.textContent = lbl ? lbl.label : activeTheme;
}
// ── Code folder browser ───────────────────────────────────────────────────
let codeFolderOpen = false;
function toggleCodeFolderBrowser(e) {
const dd = document.getElementById('code-folder-dropdown');
const btn = document.getElementById('code-folder-btn');
if (!dd || !btn) return;
codeFolderOpen = !codeFolderOpen;
if (codeFolderOpen) {
const r = btn.getBoundingClientRect();
dd.style.top = (r.bottom + 4) + 'px';
dd.style.left = r.left + 'px';
dd.style.display = 'block';
codeRefreshFolder();
} else {
dd.style.display = 'none';
}
if (e) e.stopPropagation();
}
async function codeRefreshFolder() {
const list = document.getElementById('code-folder-list');
if (!list) return;
list.innerHTML = '<div style="padding:8px 12px;color:var(--muted);font-size:11px">로딩 중...</div>';
try {
const files = localDirHandle ? await localListFiles(localDirHandle, '') : [];
if (!files.length) {
list.innerHTML = '<div style="padding:8px 12px;color:var(--muted);font-size:11px">파일 없음</div>';
return;
}
list.innerHTML = files.map(f => {
const ext = f.split('.').pop().toLowerCase();
const icon = { py:'🐍', js:'⚡', ts:'🔷', cpp:'⚙️', c:'⚙️', cs:'💜',
go:'🔵', rs:'🦀', java:'☕', html:'🌐', css:'🎨', sh:'💻',
kt:'🎯', swift:'🍎', rb:'💎', php:'🐘', sql:'🗃️', md:'📝',
}[ext] || '📄';
const safe = f.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `<div class="code-folder-item">
<span style="flex:1;display:flex;align-items:center;gap:7px;overflow:hidden;min-width:0"
onclick="codeOpenFromFolder('${safe}')">
${icon} <span style="overflow:hidden;text-overflow:ellipsis">${escHtml(f)}</span>
</span>
<button class="code-folder-del" onclick="codeDeleteFromFolder('${safe}',event)" title="삭제">✕</button>
</div>`;
}).join('');
} catch {
list.innerHTML = '<div style="padding:8px 12px;color:#f00;font-size:11px">불러오기 실패</div>';
}
}
async function codeOpenFromFolder(filename) {
if (!localDirHandle) return;
try {
const content = await localReadFile(filename);
if (content === null) return;
const bare = _clientBareFileName(filename);
const existing = codeFiles.find(f => f.name === bare);
if (existing) switchCodeFile(existing.id);
else codeNewFile(bare, content);
} catch {}
const dd = document.getElementById('code-folder-dropdown');
if (dd) dd.style.display = 'none';
codeFolderOpen = false;
}
async function codeDeleteFromFolder(filename, ev) {
ev.stopPropagation();
if (!localDirHandle || !confirm(`"${filename}" 파일을 삭제할까요?`)) return;
try {
const ok = await localDeleteFile(filename);
if (ok) {
const bare = _clientBareFileName(filename);
const existing = codeFiles.find(f => f.name === bare);
if (existing) closeCodeFile(existing.id);
codeRefreshFolder();
}
} catch(e) {
alert('삭제 실패: ' + e.message);
}
}
// Close folder dropdown when clicking outside
document.addEventListener('click', e => {
if (!codeFolderOpen) return;
const dd = document.getElementById('code-folder-dropdown');
const btn = document.getElementById('code-folder-btn');
if (dd && !dd.contains(e.target) && btn && !btn.contains(e.target)) {
dd.style.display = 'none'; codeFolderOpen = false;
}
});
// ── Vertical resize handle between editor and AI panel ────────────────────
function setupCodeEditorDrop() {
const container = document.getElementById('code-editor-container');
if (!container) return;
container.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.style.outline = '2px solid var(--brand)'; });
container.addEventListener('dragleave', () => { container.style.outline = ''; });
container.addEventListener('drop', e => {
e.preventDefault();
container.style.outline = '';
const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
files.forEach(file => {
const reader = new FileReader();
reader.onload = ev => {
const content = ev.target.result;
const existing = codeFiles.find(f => f.name === file.name);
if (existing) { existing.model.setValue(content); switchCodeFile(existing.id); }
else codeNewFile(file.name, content);
};
reader.readAsText(file);
});
});
}
function setupCodeVResize() {
const handle = document.getElementById('code-vresize');
const codeView = document.getElementById('code-view');
const aiPanel = document.getElementById('code-ai-panel');
if (!handle || !aiPanel || !codeView) return;
let startX = 0, startW = 0;
handle.addEventListener('mousedown', function(e) {
startX = e.clientX; startW = aiPanel.offsetWidth;
document.addEventListener('mousemove', onVMove);
document.addEventListener('mouseup', onVUp);
e.preventDefault();
});
function onVMove(e) {
const dx = startX - e.clientX;
const newW = Math.max(220, Math.min(window.innerWidth * 0.6, startW + dx));
codeView.style.setProperty('--ai-panel-w', newW + 'px');
if (monacoEditor) monacoEditor.layout();
}
function onVUp() {
document.removeEventListener('mousemove', onVMove);
document.removeEventListener('mouseup', onVUp);
}
}
// ── Standalone bootstrap ────────────────────────────────────────────────────
// In the main app these are triggered by setMode('code') / setSidebarTab('code').
// BroadcastChannel: real-time code sync between code editor and emulator
const _esp32CodeChannel = new BroadcastChannel('esp32_code_sync');
let _esp32SyncBusy = false;
_esp32CodeChannel.onmessage = (e) => {
if (e.data?.type !== 'code') return;
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !file.name.endsWith('.ino') || !monacoEditor) return;
_esp32SyncBusy = true;
const pos = monacoEditor.getPosition();
if (file.model) file.model.setValue(e.data.code);
if (pos) monacoEditor.setPosition(pos);
_esp32SyncBusy = false;
};
// ── Upload Panel (⚡) ─────────────────────────────────────────────────────────
let _cupOpen = false;
let _cupPort = null;
let _cupBusy = false;
let _cupCode = '', _cupFqbn = '', _cupBoardName = '', _cupFname = '';
let _cupCompiledData = null;
let _cupCompiledForCode = '';
let _cupCompiledForFqbn = '';
let _cupBoardOptions = [];
let _cupSelOptions = {};
let _cupMonActive = false;
let _cupMonReader = null;
let _cupMonWriter = null;
let _cupMonBuf = '';
const _cupDecoder = new TextDecoder();
function toggleUploadPanel() {
const panel = document.getElementById('code-upload-panel');
if (!panel) return;
_cupOpen = !_cupOpen;
panel.style.display = _cupOpen ? 'flex' : 'none';
const btn = document.getElementById('code-upload-btn');
if (btn) btn.classList.toggle('active', _cupOpen);
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
function cupOpenWith(file) {
_cupCode = file.model ? file.model.getValue() : (file.content || '');
_cupFqbn = _csbSelBoard?.fqbn || 'arduino:avr:uno';
_cupBoardName = _csbSelBoard?.name || _cupFqbn;
_cupFname = file.name;
_cupSelOptions = {};
const panel = document.getElementById('code-upload-panel');
if (!_cupOpen) {
_cupOpen = true;
panel.style.display = 'flex';
const btn = document.getElementById('code-upload-btn');
if (btn) btn.classList.add('active');
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
cupTab('log');
document.getElementById('cup-tab-log').innerHTML = '';
const method = _cupGetUploadMethod(_cupFqbn);
_cupUpdateInfoRows();
document.getElementById('cup-stm32-hint').style.display = method === 'stm32-uart' ? '' : 'none';
document.getElementById('cup-uf2-hint').style.display = method === 'download-uf2' ? '' : 'none';
document.getElementById('cup-port-name').textContent = _cupPort ? '✓ 포트 선택됨' : '포트 없음';
document.getElementById('cup-btn-upload').disabled = !_cupPort;
document.getElementById('cup-btn-download').disabled = !_cupPort;
cupLog(`보드: ${_cupBoardName}`, 'info');
cupLog(`방식: ${_cupMethodLabel(method)}`, 'info');
if (method === 'stm32-uart') cupLog('STM32: BOOT0=HIGH 후 RESET → ⚡ 업로드', 'warn');
else if (method === 'avr-stk500') cupLog('AVR: 포트 선택 후 ⚡ 업로드 (자동 DTR 리셋)', 'info');
else if (method === 'rp2040-emulator') cupLog('RP2040: ⚡ 업로드 버튼으로 에뮬레이터 실행 / ⬇ 다운로드로 UF2 파일 저장', 'info');
else if (method === 'download-uf2' || method === 'download-bin') cupLog('이 보드는 ⬇ 다운로드 후 수동 복사 방식입니다.', 'warn');
else if (method === 'esp32-flash') cupLog('ESP32/ESP8266: 🔌 포트 선택 후 ⚡ 업로드\n 컴파일: 서버 | 플래싱: 브라우저 esptool-js', 'info');
if (!navigator.serial) cupLog('⚠ WebSerial 미지원 브라우저입니다. Chrome/Edge를 사용하세요.', 'warn');
_cupLoadBoardOptions();
}
function cupTab(name) {
const tabs = { log: 'block', opts: 'block', mon: 'flex' };
Object.keys(tabs).forEach(t => {
const p = document.getElementById('cup-tab-' + t);
const b = document.getElementById('cup-tbtn-' + t);
if (p) p.style.display = t === name ? tabs[t] : 'none';
if (b) { b.style.color = t === name ? '#e89040' : '#8a7050'; b.style.borderBottom = t === name ? '2px solid #e89040' : '2px solid transparent'; }
});
}
function cupLog(msg, cls = 'info') {
const out = document.getElementById('cup-tab-log');
if (!out) return;
const colors = { info: '#9aacb8', ok: '#5dc48a', err: '#e07070', prog: '#80a8e0', warn: '#e8c860' };
const span = document.createElement('span');
span.style.color = colors[cls] || '#6a7880';
span.style.display = 'block';
span.textContent = msg;
out.appendChild(span);
out.scrollTop = out.scrollHeight;
}
function _cupSetStatus(cls, text) {
const el = document.getElementById('cup-status');
if (!el) return;
const styles = { idle:{ bg:'#2a1806', color:'#6a5030' }, ready:{ bg:'#1a3020', color:'#4d9e74' }, compiling:{ bg:'#2a1e08', color:'#ccaa50' }, uploading:{ bg:'#2a1208', color:'#c87d30' }, done:{ bg:'#1a3020', color:'#4d9e74' }, error:{ bg:'#2a1010', color:'#c46060' } };
const s = styles[cls] || styles.idle;
el.style.background = s.bg; el.style.color = s.color; el.textContent = text;
}
function _cupSetProgress(pct) {
const bar = document.getElementById('cup-progress');
if (!bar) return;
if (pct == null) { bar.style.display = 'none'; return; }
bar.style.display = 'block'; bar.value = pct;
}
function _cupGetUploadMethod(fqbn) {
const arch = fqbn.split(':')[1] || '', vendor = fqbn.split(':')[0] || '';
if (arch === 'avr') return 'avr-stk500';
if (vendor === 'STMicroelectronics') return 'stm32-uart';
if (arch === 'samd' || arch === 'sam') return 'download-bin';
if (arch === 'mbed_rp2040' || arch === 'rp2040') return 'rp2040-emulator';
if (arch === 'esp32' || arch === 'esp8266') return 'esp32-flash';
return 'download-bin';
}
function _cupMethodLabel(m) {
return { 'avr-stk500':'STK500 (WebSerial)', 'stm32-uart':'STM32 UART', 'download-bin':'.bin 다운로드', 'download-uf2':'UF2 드래그&드롭', 'rp2040-emulator':'RP2040 에뮬레이터', 'esp32-flash':'esptool-js (브라우저)' }[m] || m;
}
function _cupGetEffectiveFqbn() {
const keys = Object.keys(_cupSelOptions);
if (!keys.length) return _cupFqbn;
return _cupFqbn + ':' + keys.map(k => k + '=' + _cupSelOptions[k]).join(',');
}
function _cupUpdateInfoRows() {
const el = document.getElementById('cup-info-rows');
if (!el) return;
const method = _cupGetUploadMethod(_cupFqbn);
el.innerHTML = [['방식', _cupMethodLabel(method)], ['보드', _cupBoardName], ['FQBN', _cupGetEffectiveFqbn()], ['파일', _cupFname]]
.map(([k, v]) => `<div style="display:flex;gap:10px;font-size:13px"><span style="color:#907050;min-width:40px">${k}</span><span style="color:#c8a060;word-break:break-all">${v}</span></div>`).join('');
}
async function _cupLoadBoardOptions() {
const baseFqbn = _cupFqbn.split(':').slice(0, 3).join(':');
try {
const r = await fetch(`/api/arduino/board/options?fqbn=${encodeURIComponent(baseFqbn)}`, { credentials: 'include', headers: authHeaders() });
const d = await r.json();
_cupBoardOptions = d.options || []; _cupSelOptions = {};
_cupRenderBoardOptions();
} catch {}
}
function _cupRenderBoardOptions() {
const list = document.getElementById('cup-board-opts-list');
if (!list) return;
if (!_cupBoardOptions.length) { list.innerHTML = ''; return; }
list.innerHTML = '<div style="font-size:12px;font-weight:700;color:#907050;margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em">보드 옵션</div>' +
_cupBoardOptions.map(o => {
const sel = _cupSelOptions[o.id] ?? (o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? '');
const opts = o.values.map(v => `<option value="${v.value}"${v.value === sel ? ' selected' : ''}>${v.label}</option>`).join('');
const optId = JSON.stringify(o.id), defVal = o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? '';
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="color:#907050;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px" title="${o.label}">${o.label}</span><select style="background:#3a2818;border:1px solid #5a3820;color:#c8a060;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace;flex:1.4" onchange="if(this.value!==this.dataset.def)_cupSelOptions[${optId}]=this.value;else delete _cupSelOptions[${optId}];_cupUpdateInfoRows()" data-def="${defVal}">${opts}</select></div>`;
}).join('');
}
async function cupSelectPort() {
if (!navigator.serial) { cupLog('❌ WebSerial 미지원. Chrome/Edge를 사용하세요.', 'err'); return; }
try {
_cupPort = await navigator.serial.requestPort();
const info = _cupPort.getInfo();
const vid = info.usbVendorId ? `VID:0x${info.usbVendorId.toString(16).padStart(4,'0')}` : '';
const pid = info.usbProductId ? `PID:0x${info.usbProductId.toString(16).padStart(4,'0')}` : '';
const label = [vid, pid].filter(Boolean).join(' ') || '선택됨';
document.getElementById('cup-port-name').textContent = '✓ ' + label;
document.getElementById('cup-btn-upload').disabled = false;
document.getElementById('cup-btn-download').disabled = false;
cupLog(`포트 선택됨: ${label}`, 'ok');
_cupSetStatus('ready', '준비됨');
} catch(e) { if (e.name !== 'NotFoundError') cupLog('포트 선택 오류: ' + e.message, 'err'); }
}
async function _cupCompile() {
const arch = _cupFqbn.split(':')[1] || '';
if (/ESP8266WiFi|ESP8266WebServer|ESP8266HTTPClient/i.test(_cupCode) && arch !== 'esp8266') {
_cupFqbn = 'esp8266:esp8266:nodemcuv2'; _cupSelOptions = {};
cupLog(`⚠ ESP8266 감지 → FQBN 자동 변경: ${_cupFqbn}`, 'warn'); _cupUpdateInfoRows(); _cupLoadBoardOptions();
} else if (/^#include\s+<WiFi\.h>/m.test(_cupCode) && arch !== 'esp32' && arch !== 'esp8266') {
_cupFqbn = 'esp32:esp32:esp32'; _cupSelOptions = {};
cupLog(`⚠ ESP32 WiFi 감지 → FQBN 자동 변경: ${_cupFqbn}`, 'warn'); _cupUpdateInfoRows(); _cupLoadBoardOptions();
}
cupLog('서버에서 컴파일 중...', 'prog'); _cupSetStatus('compiling', '컴파일 중...');
const r = await fetch('/api/arduino/compile', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ code: _cupCode, fqbn: _cupGetEffectiveFqbn() }),
});
const data = await r.json();
if (data.error) { cupLog('컴파일 오류:\n' + data.error, 'err'); _cupSetStatus('error', '컴파일 실패'); return null; }
const kb = data.size ? ` (${(data.size/1024).toFixed(1)} KB)` : '';
cupLog(`✅ 컴파일 완료${kb}`, 'ok');
_cupCompiledForCode = _cupCode;
_cupCompiledForFqbn = _cupGetEffectiveFqbn();
return data;
}
function _cupCacheHit() {
return _cupCompiledData && _cupCompiledForCode === _cupCode && _cupCompiledForFqbn === _cupGetEffectiveFqbn();
}
async function cupDoDownload() {
cupTab('log');
let data;
if (_cupCacheHit()) { data = _cupCompiledData; cupLog('캐시된 컴파일 결과 사용', 'info'); }
else { data = await _cupCompile(); if (!data) return; }
_cupCompiledData = data;
const isHex = !!data.hex;
const content = isHex ? data.hex : _cupBase64ToBytes(data.bin || data.merged);
const ext = (data.boardType === 'uf2' || data.boardType === 'rp2040') ? '.uf2' : (isHex ? '.hex' : '.bin');
const blob = new Blob([content], { type: isHex ? 'text/plain' : 'application/octet-stream' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = _cupFname.replace(/\.[^.]+$/, '') + ext;
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
cupLog(`다운로드: ${a.download}`, 'ok'); _cupSetStatus('done', '다운로드됨');
}
/**
* Toggle the OTA target bar.
*
* OTA cannot run in the browser the way the serial paths do — esptool-js talks over Web Serial,
* but pushing firmware to a board over WiFi is an espota handshake to the device, which a page
* has no way to open. So this one upload path goes through the server.
*/
function cupToggleOta() {
const bar = document.getElementById('cup-ota-bar');
const on = bar.style.display === 'none' || !bar.style.display;
bar.style.display = on ? 'flex' : 'none';
const btn = document.getElementById('cup-btn-ota');
btn.style.background = on ? '#e89040' : '#3a2818';
btn.style.color = on ? '#241a0e' : '#e89040';
// Serial upload needs a chosen port; OTA needs an IP instead, so the button must not stay
// disabled just because no serial port was picked.
if (on) document.getElementById('cup-btn-upload').disabled = false;
else document.getElementById('cup-btn-upload').disabled = !_cupPort;
cupLog(on ? '📡 OTA 모드 — 보드 IP와 암호를 넣고 ⚡ 업로드' : '📡 OTA 모드 해제', 'info');
}
function _cupOtaTarget() {
const bar = document.getElementById('cup-ota-bar');
if (!bar || bar.style.display === 'none' || !bar.style.display) return null;
const ip = (document.getElementById('cup-ota-ip').value || '').trim();
return ip ? { ip, password: document.getElementById('cup-ota-pw').value || '' } : null;
}
/** Compile + push over WiFi, both on the server (see /api/arduino/upload). */
async function _cupUploadOTA(target) {
_cupBusy = true;
document.getElementById('cup-btn-upload').disabled = true;
document.getElementById('cup-btn-download').disabled = true;
document.getElementById('cup-tab-log').innerHTML = '';
// espota reports progress on the server's stdout, which we only see when it finishes, so the
// bar is indeterminate rather than faked.
_cupSetProgress(null);
_cupSetStatus('uploading', 'OTA 업로드 중...');
cupLog(`📡 OTA 업로드 [${_cupBoardName}] → ${target.ip}`, 'prog');
cupLog('서버에서 컴파일 후 무선 전송합니다. 30초 이상 걸릴 수 있습니다.', 'info');
try {
const r = await fetch('/api/arduino/upload', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({
code: _cupCode, fqbn: _cupGetEffectiveFqbn(),
port: target.ip, otaPassword: target.password,
}),
});
const data = await r.json();
if (data.error) { cupLog('❌ ' + data.error, 'err'); _cupSetStatus('error', 'OTA 실패'); }
else { cupLog(data.output || '완료', 'ok'); _cupSetStatus('done', '완료 ✓'); }
} catch (e) {
cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', 'OTA 실패');
} finally {
_cupSetProgress(null);
_cupBusy = false;
document.getElementById('cup-btn-upload').disabled = false;
document.getElementById('cup-btn-download').disabled = false;
}
}
async function cupDoUpload() {
if (_cupBusy) return;
const ota = _cupOtaTarget();
if (ota) { cupTab('log'); await _cupUploadOTA(ota); return; }
if (document.getElementById('cup-ota-bar')?.style.display === 'flex') {
cupLog('보드 IP를 입력하세요 (예: 192.168.0.42)', 'err'); return;
}
if (!_cupPort) { cupLog('포트를 먼저 선택하세요 (🔌 포트 버튼)', 'err'); return; }
cupTab('log');
const method = _cupGetUploadMethod(_cupGetEffectiveFqbn());
if (method === 'esp32-flash') { await _cupUploadESP32(); return; }
if (method === 'rp2040-emulator') {
let data;
if (_cupCacheHit()) { data = _cupCompiledData; cupLog('캐시된 컴파일 결과 사용', 'info'); }
else { data = await _cupCompile(); if (!data) return; _cupCompiledData = data; }
if (!data) return;
sessionStorage.setItem('arduino_uf2', data.bin);
sessionStorage.setItem('arduino_sketch', _cupCode || '');
_emuPanelLoad('/arduino-emulator.html', `RP2040 — ${_cupFname}`);
return;
}
if (method === 'download-uf2' || method === 'download-bin') { await cupDoDownload(); return; }
_cupBusy = true;
document.getElementById('cup-btn-upload').disabled = true;
document.getElementById('cup-btn-download').disabled = true;
document.getElementById('cup-tab-log').innerHTML = '';
_cupSetProgress(0);
try {
let data;
if (_cupCacheHit()) { data = _cupCompiledData; cupLog('캐시된 컴파일 결과 사용', 'info'); }
else { data = await _cupCompile(); if (!data) return; _cupCompiledData = data; }
cupLog(`\n--- 업로드 시작 [${_cupBoardName}] ---`, 'prog'); _cupSetStatus('uploading', '업로드 중...'); _cupSetProgress(5);
if (method === 'avr-stk500') await _cupUploadAVR(data.hex);
else if (method === 'stm32-uart') await _cupUploadSTM32(_cupBase64ToBytes(data.bin));
} catch(e) { cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', '업로드 실패'); _cupSetProgress(null); }
finally { _cupBusy = false; document.getElementById('cup-btn-upload').disabled = false; document.getElementById('cup-btn-download').disabled = false; }
}
async function _cupUploadESP32() {
_cupBusy = true;
document.getElementById('cup-btn-upload').disabled = true;
document.getElementById('cup-btn-download').disabled = true;
document.getElementById('cup-tab-log').innerHTML = '';
_cupSetProgress(0);
let transport = null, success = false;
try {
let data;
if (_cupCacheHit()) { data = _cupCompiledData; cupLog('캐시된 컴파일 결과 사용', 'info'); }
else { data = await _cupCompile(); if (!data) return; _cupCompiledData = data; }
const b64 = data.merged || data.bin;
if (!b64) throw new Error('컴파일 결과에 바이너리가 없습니다');
cupLog('esptool-js 로딩 중...', 'prog');
const { ESPLoader, Transport, HardReset } = await import('/js/code-editor/esptool-bundle.js');
cupLog('보드 연결 중 (자동 리셋)...', 'prog'); _cupSetStatus('uploading', '연결 중...');
transport = new Transport(_cupPort, true);
const terminal = { clean:()=>{}, writeLine:m=>{ if(m.trim()) cupLog(m,'info'); }, write:m=>{ if(m.trim()) cupLog(m,'info'); } };
const esploader = new ESPLoader({ transport, baudrate:115200, romBaudrate:115200, enableTracing:false, terminal });
let chip;
try { chip = await esploader.main(); }
catch(e) { cupLog('⚠ 자동 리셋 실패 — BOOT 누른채 RST → BOOT 놓기 후 재시도.', 'warn'); throw e; }
cupLog(`칩 감지: ${chip}`, 'ok'); _cupSetProgress(10); _cupSetStatus('uploading', '업로드 중...');
const binData = _cupBase64ToBytes(b64);
let _t0 = Date.now(), _w0 = 0;
await esploader.writeFlash({
fileArray: [{ data: binData, address: 0x0 }],
flashSize:'keep', flashMode:'keep', flashFreq:'keep', eraseAll:false, compress:true,
reportProgress: (_fi, written, total) => {
const pct = Math.round(written/total*100); _cupSetProgress(10 + pct*0.88);
const now = Date.now();
if (_w0===0 && written>0) { _t0=now; _w0=written; }
const kbps = (now-_t0)>200 ? ((written-_w0)/1024/((now-_t0)/1000)).toFixed(1) : '...';
document.getElementById('cup-port-name').textContent = `플래싱 ${(written/1024).toFixed(1)}/${(total/1024).toFixed(1)} KB (${pct}%) · ${kbps} KB/s`;
},
});
try { await new HardReset(transport).reset(); } catch {}
cupLog('✅ 업로드 완료! 보드가 재시작됩니다.', 'ok'); _cupSetStatus('done', '완료'); _cupSetProgress(100); success = true;
} catch(e) { cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', '업로드 실패'); _cupSetProgress(null); }
finally {
try { if (transport) await transport.disconnect(); } catch {}
_cupBusy = false;
document.getElementById('cup-btn-upload').disabled = false;
document.getElementById('cup-btn-download').disabled = false;
document.getElementById('cup-port-name').textContent = _cupPort ? '✓ 포트 선택됨' : '포트 없음';
setTimeout(() => _cupSetProgress(null), 3000);
}
if (success) {
cupTab('mon'); _cupMonAppendSys('[업로드 완료 — 시리얼 모니터 자동 연결 중...]');
await new Promise(r => setTimeout(r, 1500));
await cupMonOpen();
}
}
function _cupBase64ToBytes(b64) {
const raw = atob(b64), out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function _cupParseIntelHex(hexText) {
const pages = new Map(); let extAddr = 0;
for (const line of hexText.split('\n')) {
const t = line.trim();
if (!t.startsWith(':') || t.length < 11) continue;
const bytes = []; for (let i=1; i<t.length-1; i+=2) bytes.push(parseInt(t.substr(i,2),16));
const len=bytes[0], addr=(bytes[1]<<8)|bytes[2], type=bytes[3];
if (type===1) break; if (type===4) { extAddr=((bytes[4]<<8)|bytes[5])<<16; continue; } if (type!==0) continue;
const fullAddr = extAddr+addr;
for (let i=0; i<len; i++) { const a=fullAddr+i, pg=a>>7, off=a&0x7f; if (!pages.has(pg)) pages.set(pg,new Uint8Array(128).fill(0xff)); pages.get(pg)[off]=bytes[4+i]; }
}
return pages;
}
async function _cupWrite(writer, data) { await writer.write(data instanceof Uint8Array ? data : new Uint8Array(Array.isArray(data) ? data : [data])); }
async function _cupRead(reader, n, ms=5000) {
const buf=new Uint8Array(n); let pos=0; const deadline=Date.now()+ms;
while (pos<n) {
if (Date.now()>deadline) throw new Error(`시리얼 타임아웃 (${n}B 중 ${pos}B)`);
const {value,done} = await reader.read(); if (done) throw new Error('포트가 닫혔습니다');
const take=Math.min(value.length,n-pos); buf.set(value.slice(0,take),pos); pos+=take;
}
return buf;
}
async function _cupUploadAVR(hexText) {
const INSYNC=0x14,OK=0x10,EOP=0x20,CMD_SYNC=0x30,CMD_SET_DEV=0x42,CMD_ENTER=0x50,CMD_LEAVE=0x51,CMD_ADDR=0x55,CMD_PROG=0x64;
await _cupPort.open({ baudRate:115200, dataBits:8, stopBits:1, parity:'none' });
const reader=_cupPort.readable.getReader(), writer=_cupPort.writable.getWriter();
const cmdOk = async (bytes,extra=0,ms=3000) => {
await _cupWrite(writer, new Uint8Array([...bytes,EOP]));
const r=await _cupRead(reader,2+extra,ms);
if (r[0]!==INSYNC) throw new Error(`STK500: INSYNC 없음 (0x${r[0].toString(16)})`);
if (r[1+extra]!==OK) throw new Error(`STK500: OK 없음 (0x${r[1+extra].toString(16)})`);
return r.slice(1,1+extra);
};
try {
if (_cupPort.setSignals) {
await _cupPort.setSignals({ dataTerminalReady:false, requestToSend:false });
await new Promise(r=>setTimeout(r,100));
await _cupPort.setSignals({ dataTerminalReady:true, requestToSend:true });
await new Promise(r=>setTimeout(r,50));
await _cupPort.setSignals({ dataTerminalReady:false });
}
await new Promise(r=>setTimeout(r,250));
cupLog('동기화 중...', 'prog');
let synced=false;
for (let i=0; i<12&&!synced; i++) { try { await _cupWrite(writer,[CMD_SYNC,EOP]); const r=await _cupRead(reader,2,300); if(r[0]===INSYNC&&r[1]===OK) synced=true; } catch {} }
if (!synced) throw new Error('보드에 연결할 수 없습니다. 케이블/DTR 리셋을 확인하세요.');
cupLog('✓ 동기화됨', 'ok');
await cmdOk([CMD_SET_DEV,0x86,0x00,0x07,0x7f,0x83,0xff,0x00,0x01,0x01,0x01,0x01,0x03,0xff,0x00,0x80,0x04,0x00,0x00,0x00,0x80,0x00]);
await cmdOk([CMD_ENTER]); cupLog('✓ 프로그래밍 모드 진입', 'ok');
const pages=_cupParseIntelHex(hexText), sorted=[...pages.entries()].sort((a,b)=>a[0]-b[0]);
let done=0;
for (const [pg, pgData] of sorted) {
const waddr=pg<<6; await cmdOk([CMD_ADDR,waddr&0xff,(waddr>>8)&0xff]); await cmdOk([CMD_PROG,0x00,0x80,0x46,...pgData]);
done++; _cupSetProgress(5+Math.round(done/sorted.length*90));
document.getElementById('cup-port-name').textContent = `업로드 중... ${done}/${sorted.length} 페이지`;
}
await cmdOk([CMD_LEAVE]); _cupSetProgress(100); cupLog(`✅ 업로드 완료! (${sorted.length} 페이지)`, 'ok');
_cupSetStatus('done', '완료 ✓'); document.getElementById('cup-port-name').textContent = '✓ 포트 선택됨';
await new Promise(r=>setTimeout(r,500)); _cupSetProgress(null);
} finally { try{reader.releaseLock();}catch{} try{writer.releaseLock();}catch{} try{await _cupPort.close();}catch{} }
}
async function _cupUploadSTM32(binData) {
const ACK=0x79;
await _cupPort.open({ baudRate:115200, dataBits:8, stopBits:1, parity:'even' });
const reader=_cupPort.readable.getReader(), writer=_cupPort.writable.getWriter();
const w = async d=>_cupWrite(writer,d), readByte=async(ms=6000)=>(await _cupRead(reader,1,ms))[0];
const waitAck = async(ms=12000)=>{ const b=await readByte(ms); if(b===ACK)return; if(b===0x1F)throw new Error('STM32 NACK'); throw new Error(`응답: 0x${b.toString(16)}`); };
const xor4=a=>a[0]^a[1]^a[2]^a[3], addrPkt=addr=>{ const b=[(addr>>24)&0xff,(addr>>16)&0xff,(addr>>8)&0xff,addr&0xff]; return new Uint8Array([...b,xor4(b)]); };
try {
cupLog('STM32 동기화 중...', 'prog'); cupLog('(BOOT0=HIGH 상태에서 RESET 확인)', 'warn');
let synced=false;
for (let i=0; i<5&&!synced; i++) { try{await w([0x7F]);const b=await readByte(1200);if(b===ACK||b===0x1F)synced=true;}catch{} }
if (!synced) throw new Error('동기화 실패 — BOOT0=HIGH 후 RESET 하세요.');
cupLog('✓ 동기화됨', 'ok');
await w([0x00,0xFF]); await waitAck(3000); const n=await readByte(); await _cupRead(reader,n+1,3000); await waitAck(3000);
cupLog('플래시 지우는 중... (최대 30초)', 'prog'); _cupSetProgress(10);
await w([0x44,0xBB]); await waitAck(5000); await w([0xFF,0xFF,0x00]); await waitAck(30000);
cupLog('✓ 플래시 지움', 'ok'); _cupSetProgress(20);
const START=0x08000000, CHUNK=256, total=Math.ceil(binData.length/CHUNK);
cupLog(`${binData.length} 바이트 쓰는 중 (${total} 블록)...`, 'prog');
for (let i=0; i<binData.length; i+=CHUNK) {
const raw=binData.slice(i,i+CHUNK), padded=new Uint8Array(CHUNK).fill(0xff); padded.set(raw);
await w([0x31,0xCE]); await waitAck(5000); await w(addrPkt(START+i)); await waitAck(5000);
let ck=CHUNK-1; for(const b of padded)ck^=b;
const pkt=new Uint8Array(CHUNK+2); pkt[0]=CHUNK-1; pkt.set(padded,1); pkt[CHUNK+1]=ck;
await w(pkt); await waitAck(10000);
const blk=Math.floor(i/CHUNK)+1; _cupSetProgress(20+Math.round(blk/total*75));
document.getElementById('cup-port-name').textContent = `업로드 중... ${blk}/${total} 블록`;
}
_cupSetProgress(97); cupLog('실행 중...', 'prog');
await w([0x21,0xDE]); await waitAck(5000); await w(addrPkt(START)); try{await waitAck(2000);}catch{}
_cupSetProgress(100); cupLog('✅ 업로드 완료! BOOT0=GND 후 RESET 하세요.', 'ok');
_cupSetStatus('done', '완료 ✓'); document.getElementById('cup-port-name').textContent = '✓ 포트 선택됨';
await new Promise(r=>setTimeout(r,500)); _cupSetProgress(null);
} finally { try{reader.releaseLock();}catch{} try{writer.releaseLock();}catch{} try{await _cupPort.close();}catch{} }
}
// ── Serial monitor ───────────────────────────────────────────────────────────
function _cupMonAppendSys(msg) {
const out = document.getElementById('cup-mon-output'); if (!out) return;
const div = document.createElement('div');
div.style.color = '#907050'; div.style.fontStyle = 'italic'; div.textContent = msg;
out.appendChild(div);
if (document.getElementById('cup-mon-auto')?.checked) out.scrollTop = out.scrollHeight;
}
function cupMonAppend(text) {
const out = document.getElementById('cup-mon-output'); if (!out) return;
const showTs = document.getElementById('cup-mon-ts')?.checked;
const autoScroll = document.getElementById('cup-mon-auto')?.checked;
_cupMonBuf += text;
const parts = _cupMonBuf.split('\n'); _cupMonBuf = parts.pop();
for (const raw of parts) {
const div = document.createElement('div'); div.style.color = '#c8e8c0';
if (showTs) {
const d=new Date(), ts=`${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}.${d.getMilliseconds().toString().padStart(3,'0')}`;
const s=document.createElement('span'); s.style.cssText='color:#907050;font-size:11px;margin-right:6px;opacity:.7;user-select:none'; s.textContent=ts;
div.appendChild(s); div.appendChild(document.createTextNode(raw.replace(/\r/g,'')));
} else { div.textContent = raw.replace(/\r/g,''); }
out.appendChild(div);
}
if (autoScroll) out.scrollTop = out.scrollHeight;
}
async function _cupMonReadLoop() {
while (_cupMonActive && _cupMonReader) {
try { const {value,done}=await _cupMonReader.read(); if(done)break; cupMonAppend(_cupDecoder.decode(value,{stream:true})); }
catch(e) { if (_cupMonActive) _cupMonAppendSys(`[오류: ${e.message}]`); break; }
}
if (_cupMonActive) cupMonClose();
}
async function cupMonToggle() { if (_cupMonActive) await cupMonClose(); else await cupMonOpen(); }
async function cupMonOpen() {
if (!_cupPort) { _cupMonAppendSys('[포트를 먼저 선택하세요]'); return; }
if (_cupBusy) { _cupMonAppendSys('[업로드 중입니다. 완료 후 연결하세요]'); return; }
const baud = parseInt(document.getElementById('cup-mon-baud')?.value) || 115200;
try {
await _cupPort.open({ baudRate: baud });
_cupMonReader = _cupPort.readable.getReader();
_cupMonWriter = _cupPort.writable.getWriter();
_cupMonActive = true;
const btn = document.getElementById('cup-mon-btn');
if (btn) { btn.textContent='■ 연결 해제'; btn.style.color='#e87070'; btn.style.borderColor='color-mix(in srgb,#e87070 35%,#241a0e)'; }
const inp = document.getElementById('cup-mon-input'); if (inp) inp.disabled = false;
_cupMonAppendSys(`[연결됨 — ${baud} baud]`); _cupMonReadLoop();
} catch(e) { _cupMonAppendSys(`[연결 실패: ${e.message}]`); }
}
async function cupMonClose() {
_cupMonActive = false;
try{await _cupMonReader?.cancel();}catch{} try{_cupMonReader?.releaseLock();}catch{}
try{_cupMonWriter?.releaseLock();}catch{} try{await _cupPort?.close();}catch{}
_cupMonReader=null; _cupMonWriter=null;
const btn=document.getElementById('cup-mon-btn');
if (btn) { btn.textContent='▶ 연결'; btn.style.color='#4ade80'; btn.style.borderColor='color-mix(in srgb,#4ade80 35%,#241a0e)'; }
const inp=document.getElementById('cup-mon-input'); if(inp) inp.disabled=true;
_cupMonAppendSys('[연결 해제됨]');
}
async function cupMonSend() {
if (!_cupMonWriter) return;
const input=document.getElementById('cup-mon-input'); if(!input) return;
const endings={none:'',nl:'\n',cr:'\r',crnl:'\r\n'};
const suffix=endings[document.getElementById('cup-mon-line-end')?.value]??'\r\n';
const text=input.value+suffix; input.value='';
try {
await _cupMonWriter.write(new TextEncoder().encode(text));
const div=document.createElement('div'); div.style.color='#94b0d8'; div.textContent=text.replace(/[\r\n]/g,'');
document.getElementById('cup-mon-output')?.appendChild(div);
} catch(e) { _cupMonAppendSys(`[전송 오류: ${e.message}]`); }
}
function cupMonClear() { const el=document.getElementById('cup-mon-output'); if(el) el.innerHTML=''; _cupMonBuf=''; }
// Emulator panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('code-emu-drag');
const panel = document.getElementById('code-emu-panel');
if (!drag || !panel) return;
let startY=0, startH=0;
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const iframe = document.getElementById('code-emu-iframe');
if (iframe) iframe.style.pointerEvents = 'none';
const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(120,Math.min(window.innerHeight*.75,startH+dy))+'px'; };
const onUp = () => {
document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp);
drag.style.background='';
if (iframe) iframe.style.pointerEvents = '';
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
// Upload panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('code-upload-drag');
const panel = document.getElementById('code-upload-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 onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(200,125,48,.4)');
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
// USB 실기기 panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('android-usb-drag');
const panel = document.getElementById('android-usb-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 onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(90,168,232,.4)');
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
// Here we run them once the DOM is ready since this page IS the code view.
(function bootCodePage() {
async function boot() {
try { setCodeSbTab('files'); } catch (e) {}
try { initCodeView(); } catch (e) { console.error('[code] initCodeView failed', e); }
try { csbLoadModels(); } catch (e) {}
try { csbLoadCodeDir(); } catch (e) {}
try { updateLocalModeUI(); } catch (e) {}
try { const inp = document.getElementById('csb-termpath'); if (inp && localTermPath) inp.value = localTermPath; } catch (e) {}
try { await restoreLocalDirHandle(); } catch (e) {}
try { updateSessionPanel(); } catch (e) {}
try { _fetchModelCtxMax(codeAiModel); } catch (e) {}
try { codeSbRefresh(); } catch (e) {}
try { companionConnect(); } catch (e) {}
// 히스토리 없으면 세션 목록 표시
try { if (!codeAiHistory.length) _renderSessionsList(); } catch (e) {}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();