// claude-app.js — local-model coding agent rendered as a real terminal. // Backends: Ollama (/api/chat), LM Studio (/v1/chat/completions, OpenAI-compatible), // and Anthropic (/v1/messages, called directly from the browser with a user-supplied API key). // Tools (Read/Write/Edit/Glob/Grep) run against a folder picked via the File System // Access API; Bash runs through the same companion.py bridge code.html uses. const DEFAULT_ENDPOINTS = { ollama: 'http://localhost:11434', lm_studio: 'http://localhost:1234', anthropic: 'https://api.anthropic.com' }; const COMPANION_URL = 'ws://127.0.0.1:9000'; const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__', 'venv', '.venv', '.next', 'target', '.cache', 'out']); const MAX_ROUNDS = 25; const 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' }, new_string: { type: 'string' } }, required: ['file', 'old_string', 'new_string'] } } }, { type: 'function', function: { name: 'Glob', description: '파일 패턴으로 파일 목록을 검색합니다. 예: "**/*.py", "src/*.ts"', parameters: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] } } }, { type: 'function', function: { name: 'Grep', description: '파일에서 텍스트를 검색합니다. 결과는 "파일:줄번호: 내용" 형식입니다.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, case_insensitive: { type: 'boolean' } }, required: ['pattern'] } } }, { type: 'function', function: { name: 'Bash', description: '쉘 명령을 실행합니다. companion.py 연결 시에만 작동합니다.', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } } }, ]; // ── Themes ──────────────────────────────────────────────────────────────── const THEMES = { ubuntu: { label: 'Ubuntu', css: { '--bg-main': '#4B1D45', '--bg-panel': '#3A1636', '--bg-input': '#2C0F29', '--border': '#6b2f63', '--accent': '#E95420', '--accent-bg': 'rgba(233,84,32,0.16)', '--accent-bg-hover': 'rgba(233,84,32,0.30)', '--text': '#EEEEEC', '--text-muted': '#c9a9c4', '--dot-on': '#8AE234', '--dot-off': '#8a6c86', }, term: { background: '#4B1D45', foreground: '#EEEEEC', cursor: '#E95420', selectionBackground: 'rgba(233,84,32,0.35)', black: '#2E3436', red: '#CC0000', green: '#4E9A06', yellow: '#C4A000', blue: '#3465A4', magenta: '#75507B', cyan: '#06989A', white: '#D3D7CF', brightBlack: '#8a6c86', brightRed: '#EF2929', brightGreen: '#8AE234', brightYellow: '#FCE94F', brightBlue: '#729FCF', brightMagenta: '#AD7FA8', brightCyan: '#34E2E2', brightWhite: '#EEEEEC', }, }, dracula: { label: 'Dracula', css: { '--bg-main': '#282a36', '--bg-panel': '#21222c', '--bg-input': '#1a1b24', '--border': '#44475a', '--accent': '#bd93f9', '--accent-bg': 'rgba(189,147,249,0.15)', '--accent-bg-hover': 'rgba(189,147,249,0.28)', '--text': '#f8f8f2', '--text-muted': '#6272a4', '--dot-on': '#50fa7b', '--dot-off': '#44475a', }, term: { background: '#282a36', foreground: '#f8f8f2', cursor: '#f8f8f2', selectionBackground: 'rgba(189,147,249,0.35)', black: '#21222c', red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c', blue: '#bd93f9', magenta: '#ff79c6', cyan: '#8be9fd', white: '#f8f8f2', brightBlack: '#6272a4', brightRed: '#ff6e6e', brightGreen: '#69ff94', brightYellow: '#ffffa5', brightBlue: '#d6acff', brightMagenta: '#ff92df', brightCyan: '#a4ffff', brightWhite: '#ffffff', }, }, nord: { label: 'Nord', css: { '--bg-main': '#2e3440', '--bg-panel': '#272c36', '--bg-input': '#242933', '--border': '#3b4252', '--accent': '#88c0d0', '--accent-bg': 'rgba(136,192,208,0.12)', '--accent-bg-hover': 'rgba(136,192,208,0.24)', '--text': '#eceff4', '--text-muted': '#d8dee9', '--dot-on': '#a3be8c', '--dot-off': '#4c566a', }, term: { background: '#2e3440', foreground: '#d8dee9', cursor: '#88c0d0', selectionBackground: 'rgba(136,192,208,0.3)', black: '#3b4252', red: '#bf616a', green: '#a3be8c', yellow: '#ebcb8b', blue: '#81a1c1', magenta: '#b48ead', cyan: '#88c0d0', white: '#e5e9f0', brightBlack: '#4c566a', brightRed: '#bf616a', brightGreen: '#a3be8c', brightYellow: '#ebcb8b', brightBlue: '#81a1c1', brightMagenta: '#b48ead', brightCyan: '#8fbcbb', brightWhite: '#eceff4', }, }, solarized: { label: 'Solarized Dark', css: { '--bg-main': '#002b36', '--bg-panel': '#073642', '--bg-input': '#002029', '--border': '#073642', '--accent': '#268bd2', '--accent-bg': 'rgba(38,139,210,0.14)', '--accent-bg-hover': 'rgba(38,139,210,0.28)', '--text': '#839496', '--text-muted': '#586e75', '--dot-on': '#859900', '--dot-off': '#586e75', }, term: { background: '#002b36', foreground: '#839496', cursor: '#268bd2', selectionBackground: 'rgba(38,139,210,0.35)', black: '#073642', red: '#dc322f', green: '#859900', yellow: '#b58900', blue: '#268bd2', magenta: '#d33682', cyan: '#2aa198', white: '#eee8d5', brightBlack: '#586e75', brightRed: '#cb4b16', brightGreen: '#586e75', brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4', brightCyan: '#93a1a1', brightWhite: '#fdf6e3', }, }, monokai: { label: 'Monokai', css: { '--bg-main': '#272822', '--bg-panel': '#1e1f1b', '--bg-input': '#1a1b17', '--border': '#3e3d32', '--accent': '#f92672', '--accent-bg': 'rgba(249,38,114,0.14)', '--accent-bg-hover': 'rgba(249,38,114,0.28)', '--text': '#f8f8f2', '--text-muted': '#75715e', '--dot-on': '#a6e22e', '--dot-off': '#75715e', }, term: { background: '#272822', foreground: '#f8f8f2', cursor: '#f92672', selectionBackground: 'rgba(249,38,114,0.3)', black: '#272822', red: '#f92672', green: '#a6e22e', yellow: '#f4bf75', blue: '#66d9ef', magenta: '#ae81ff', cyan: '#a1efe4', white: '#f8f8f2', brightBlack: '#75715e', brightRed: '#fd971f', brightGreen: '#a6e22e', brightYellow: '#e6db74', brightBlue: '#66d9ef', brightMagenta: '#ae81ff', brightCyan: '#a1efe4', brightWhite: '#f9f8f5', }, }, catppuccin: { label: 'Catppuccin', css: { '--bg-main': '#1e1e2e', '--bg-panel': '#181825', '--bg-input': '#151520', '--border': '#313244', '--accent': '#cba6f7', '--accent-bg': 'rgba(203,166,247,0.12)', '--accent-bg-hover': 'rgba(203,166,247,0.24)', '--text': '#cdd6f4', '--text-muted': '#a6adc8', '--dot-on': '#a6e3a1', '--dot-off': '#585b70', }, term: { background: '#1e1e2e', foreground: '#cdd6f4', cursor: '#f5e0dc', selectionBackground: 'rgba(203,166,247,0.3)', black: '#45475a', red: '#f38ba8', green: '#a6e3a1', yellow: '#f9e2af', blue: '#89b4fa', magenta: '#cba6f7', cyan: '#94e2d5', white: '#bac2de', brightBlack: '#585b70', brightRed: '#f38ba8', brightGreen: '#a6e3a1', brightYellow: '#f9e2af', brightBlue: '#89b4fa', brightMagenta: '#cba6f7', brightCyan: '#94e2d5', brightWhite: '#a6adc8', }, }, tokyo: { label: 'Tokyo Night', css: { '--bg-main': '#1a1b26', '--bg-panel': '#16161e', '--bg-input': '#13131c', '--border': '#292e42', '--accent': '#7aa2f7', '--accent-bg': 'rgba(122,162,247,0.12)', '--accent-bg-hover': 'rgba(122,162,247,0.24)', '--text': '#c0caf5', '--text-muted': '#565f89', '--dot-on': '#9ece6a', '--dot-off': '#292e42', }, term: { background: '#1a1b26', foreground: '#c0caf5', cursor: '#c0caf5', selectionBackground: 'rgba(122,162,247,0.3)', black: '#15161e', red: '#f7768e', green: '#9ece6a', yellow: '#e0af68', blue: '#7aa2f7', magenta: '#bb9af7', cyan: '#7dcfff', white: '#a9b1d6', brightBlack: '#414868', brightRed: '#f7768e', brightGreen: '#9ece6a', brightYellow: '#e0af68', brightBlue: '#7aa2f7', brightMagenta: '#bb9af7', brightCyan: '#7dcfff', brightWhite: '#c0caf5', }, }, // ── Light themes ──────────────────────────────────────────────────────── 'solarized-light': { label: 'Solarized Light', css: { '--bg-main': '#fdf6e3', '--bg-panel': '#eee8d5', '--bg-input': '#faf5e4', '--border': '#d0c8b8', '--accent': '#268bd2', '--accent-bg': 'rgba(38,139,210,0.10)', '--accent-bg-hover': 'rgba(38,139,210,0.20)', '--text': '#073642', '--text-muted': '#586e75', '--dot-on': '#859900', '--dot-off': '#93a1a1', }, term: { background: '#fdf6e3', foreground: '#073642', cursor: '#268bd2', selectionBackground: 'rgba(38,139,210,0.25)', black: '#073642', red: '#dc322f', green: '#859900', yellow: '#b58900', blue: '#268bd2', magenta: '#d33682', cyan: '#2aa198', white: '#eee8d5', brightBlack: '#586e75', brightRed: '#cb4b16', brightGreen: '#859900', brightYellow: '#b58900', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#fdf6e3', }, }, 'catppuccin-latte': { label: 'Catppuccin Latte', css: { '--bg-main': '#eff1f5', '--bg-panel': '#e6e9ef', '--bg-input': '#e0e3eb', '--border': '#ccd0da', '--accent': '#8839ef', '--accent-bg': 'rgba(136,57,239,0.10)', '--accent-bg-hover': 'rgba(136,57,239,0.20)', '--text': '#4c4f69', '--text-muted': '#7c7f93', '--dot-on': '#40a02b', '--dot-off': '#bcc0cc', }, term: { background: '#eff1f5', foreground: '#4c4f69', cursor: '#8839ef', selectionBackground: 'rgba(136,57,239,0.2)', black: '#5c5f77', red: '#d20f39', green: '#40a02b', yellow: '#df8e1d', blue: '#1e66f5', magenta: '#8839ef', cyan: '#179299', white: '#4c4f69', brightBlack: '#6c6f85', brightRed: '#d20f39', brightGreen: '#40a02b', brightYellow: '#df8e1d', brightBlue: '#1e66f5', brightMagenta: '#8839ef', brightCyan: '#179299', brightWhite: '#4c4f69', }, }, 'github-light': { label: 'GitHub Light', css: { '--bg-main': '#ffffff', '--bg-panel': '#f6f8fa', '--bg-input': '#f0f0f0', '--border': '#d0d7de', '--accent': '#0969da', '--accent-bg': 'rgba(9,105,218,0.08)', '--accent-bg-hover': 'rgba(9,105,218,0.16)', '--text': '#1f2328', '--text-muted': '#656d76', '--dot-on': '#1a7f37', '--dot-off': '#d0d7de', }, term: { background: '#ffffff', foreground: '#1f2328', cursor: '#0969da', selectionBackground: 'rgba(9,105,218,0.2)', black: '#1f2328', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#0550ae', white: '#6e7781', brightBlack: '#656d76', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#8a6914', brightBlue: '#0550ae', brightMagenta: '#6e40c9', brightCyan: '#0550ae', brightWhite: '#1f2328', }, }, paper: { label: 'Paper', css: { '--bg-main': '#f4f0e8', '--bg-panel': '#ebe7df', '--bg-input': '#e8e4dc', '--border': '#c9c3b8', '--accent': '#b4592a', '--accent-bg': 'rgba(180,89,42,0.10)', '--accent-bg-hover': 'rgba(180,89,42,0.20)', '--text': '#3c3226', '--text-muted': '#8a7e6e', '--dot-on': '#5c8a3c', '--dot-off': '#c9c3b8', }, term: { background: '#f4f0e8', foreground: '#3c3226', cursor: '#b4592a', selectionBackground: 'rgba(180,89,42,0.2)', black: '#3c3226', red: '#b13e26', green: '#5c8a3c', yellow: '#a6872a', blue: '#3d6a9e', magenta: '#8e4a8e', cyan: '#3a8a8a', white: '#ebe7df', brightBlack: '#8a7e6e', brightRed: '#cc4a30', brightGreen: '#6da04e', brightYellow: '#c9a630', brightBlue: '#5080b8', brightMagenta: '#a85ca8', brightCyan: '#48a8a8', brightWhite: '#3c3226', }, }, }; function applyTheme(name) { const t = THEMES[name]; if (!t) return; const root = document.documentElement; for (const [k, v] of Object.entries(t.css)) root.style.setProperty(k, v); if (term) term.options.theme = t.term; localStorage.setItem('claude_app_theme', name); } // ── State ──────────────────────────────────────────────────────────────── let backend = localStorage.getItem('claude_app_backend') || 'ollama'; const endpoints = { ollama: localStorage.getItem('claude_app_ep_ollama') || DEFAULT_ENDPOINTS.ollama, lm_studio: localStorage.getItem('claude_app_ep_lmstudio') || DEFAULT_ENDPOINTS.lm_studio, anthropic: localStorage.getItem('claude_app_ep_anthropic') || DEFAULT_ENDPOINTS.anthropic, }; const models = { ollama: localStorage.getItem('claude_app_model_ollama') || '', lm_studio: localStorage.getItem('claude_app_model_lmstudio') || '', anthropic: localStorage.getItem('claude_app_model_anthropic') || '', }; let dirHandle = null; let companionWs = null, companionConnected = false; let term = null, fitAddon = null; let lineBuf = '', busy = false, abortCtrl = null; let history = []; let agentMode = localStorage.getItem('claude_app_mode') || 'auto'; let pendingApproval = null; // { resolve, type } — set when waiting for user approval in plan/confirm mode let useServerProxy = localStorage.getItem('claude_app_use_proxy') === '1'; const PROXY_BASES = { ollama: '/api/localllm/ollama', lm_studio: '/api/localllm/lmstudio' }; // When useServerProxy is on, requests go through this homeclaw server (same-origin, no CORS // needed) which forwards to Ollama/LM Studio on the server's own machine. Off = the browser // hits localhost directly, which only works when the local model runs on the browsing device. // Anthropic has no proxy target (it's called directly — see callModel) so this always falls // back to endpoints.anthropic regardless of the toggle. const currentEndpoint = () => (useServerProxy && PROXY_BASES[backend]) ? PROXY_BASES[backend] : endpoints[backend]; const currentModel = () => models[backend]; const backendStorageSuffix = () => backend === 'ollama' ? 'ollama' : backend === 'lm_studio' ? 'lmstudio' : 'anthropic'; const modelStorageKey = () => 'claude_app_model_' + backendStorageSuffix(); const endpointStorageKey = () => 'claude_app_ep_' + backendStorageSuffix(); const getAnthropicKey = () => (document.getElementById('anthropic-key-input')?.value || '').trim(); // ── Terminal ───────────────────────────────────────────────────────────── function initTerm() { const savedTheme = localStorage.getItem('claude_app_theme') || 'ubuntu'; const t = THEMES[savedTheme] || THEMES.ubuntu; // Apply CSS vars immediately so the page paints with the right theme before load for (const [k, v] of Object.entries(t.css)) document.documentElement.style.setProperty(k, v); term = new Terminal({ theme: t.term, fontFamily: "'IBM Plex Mono','D2Coding',monospace", fontSize: 14, lineHeight: 1.4, cursorBlink: true, scrollback: 5000, }); fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.loadAddon(new WebLinksAddon.WebLinksAddon()); term.open(document.getElementById('term-container')); fitAddon.fit(); term.onData(handleTermInput); } function twrite(s) { term.write(String(s).replace(/\r?\n/g, '\r\n')); } function twriteln(s) { twrite((s ?? '') + '\n'); } const MODE_PROMPTS = { plan: '\x1b[1;35m❯📋\x1b[0m ', confirm: '\x1b[1;33m❯🔒\x1b[0m ', auto: '\x1b[1;32m❯\x1b[0m ', yolo: '\x1b[1;31m❯🤖\x1b[0m ', }; function printPrompt() { twrite('\r\n' + (MODE_PROMPTS[agentMode] || MODE_PROMPTS.auto)); } // ── Approval mechanism for plan/confirm modes ───────────────────────────── // When pendingApproval is set, handleTermInput routes keystrokes here instead // of the normal command buffer. function waitForApproval(type) { return new Promise(resolve => { pendingApproval = { resolve, type }; }); } function handleTermInput(data) { // Approval input takes priority — user is answering a plan/confirm prompt if (pendingApproval) { if (data === '\r') { const answer = lineBuf.trim().toLowerCase(); lineBuf = ''; twrite('\r\n'); if (!answer || answer === 'y' || answer === 'yes' || answer === '실행') { pendingApproval.resolve('yes'); } else if (answer === 'a' || answer === 'all' || answer === '모두') { pendingApproval.resolve('all'); } else if (answer === 'n' || answer === 'no' || answer === '취소' || answer === '건너뛰기') { pendingApproval.resolve('no'); } else { pendingApproval.resolve('yes'); // default = approve } pendingApproval = null; return; } if (data === '\x03' || data === '\x1b') { // Ctrl+C or Esc → cancel lineBuf = ''; twrite('\r\n'); pendingApproval.resolve('cancel'); pendingApproval = null; return; } if (data === '\x7f' || data === '\b') { if (lineBuf.length) { lineBuf = lineBuf.slice(0, -1); term.write('\b \b'); } return; } if (data.charCodeAt(0) < 32) return; lineBuf += data; term.write(data); return; } if (busy) { if (data === '\x03' && abortCtrl) abortCtrl.abort(); return; } if (data === '\r') { const text = lineBuf.trim(); lineBuf = ''; twrite('\r\n'); if (text) submitPrompt(text); else printPrompt(); return; } if (data === '\x7f' || data === '\b') { if (lineBuf.length) { lineBuf = lineBuf.slice(0, -1); term.write('\b \b'); } return; } if (data === '\x03') { lineBuf = ''; twrite('^C'); printPrompt(); return; } if (data === '\x0c') { term.clear(); return; } if (data.charCodeAt(0) < 32 && data !== '\t') return; lineBuf += data; term.write(data); } let _spinTimer = null; function startSpinner() { const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']; let i = 0; return setInterval(() => { term.write('\r\x1b[2K' + frames[i = (i + 1) % frames.length] + ' \x1b[90m생각 중...\x1b[0m'); }, 80); } function stopSpinner(timer) { if (timer) clearInterval(timer); term.write('\r\x1b[2K'); } // ── Companion (local Bash execution) ──────────────────────────────────── function companionConnect() { if (companionWs && (companionWs.readyState === WebSocket.OPEN || companionWs.readyState === WebSocket.CONNECTING)) return; try { companionWs = new WebSocket(COMPANION_URL); } catch { setTimeout(companionConnect, 5000); return; } companionWs.onopen = () => { companionConnected = true; setCompanionDot(true); if (getWorkRoot()) refreshFileTree(); }; companionWs.onclose = () => { companionConnected = false; companionWs = null; setCompanionDot(false); setTimeout(companionConnect, 5000); }; companionWs.onerror = () => { companionConnected = false; }; } function setCompanionDot(on) { const d = document.getElementById('companion-dot'); if (d) d.classList.toggle('on', on); } function execBash(command) { return new Promise((resolve) => { if (!command) { resolve({ result: '오류: command 인수가 필요합니다', error: true }); return; } if (!companionWs || companionWs.readyState !== WebSocket.OPEN) { resolve({ result: '⚠️ companion.py가 연결되지 않았습니다 (ws://127.0.0.1:9000). companion.py를 먼저 실행하세요.', error: true }); return; } const reqId = 'exec_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); const timeout = setTimeout(() => { companionWs.removeEventListener('message', handler); resolve({ result: '오류: 명령 실행 시간 초과 (30s)', error: true }); }, 31000); const handler = (evt) => { let d; try { d = JSON.parse(evt.data); } catch { return; } if (d.reqId === reqId) { clearTimeout(timeout); companionWs.removeEventListener('message', handler); resolve({ result: d.output ?? '', error: !!d.error }); } }; companionWs.addEventListener('message', handler); const cwd = document.getElementById('local-path-input').value.trim() || undefined; companionWs.send(JSON.stringify({ type: 'exec', command, reqId, cwd })); }); } // ── Companion-backed filesystem (Bash 경로와 동일한 root 사용) ─────────── // companion.py가 연결되어 있고 "Bash 경로"가 채워져 있으면, Read/Write/Edit/Glob/Grep도 // 그 경로를 root로 사용해 companion.py를 통해 처리한다. 이렇게 하면 Bash와 파일 작업이 // 항상 같은 폴더를 가리킨다. companion 미연결 시에는 File System Access API로 폴백한다. function getWorkRoot() { return document.getElementById('local-path-input').value.trim(); } function useCompanionFs() { return companionConnected && !!getWorkRoot(); } 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 })); }); } async function connectFolder() { if (companionConnected) { try { twriteln('\r\n\x1b[90m(companion.py에서 폴더 선택 창을 여는 중...)\x1b[0m'); const d = await companionRequest('browse_folder', {}, 'browse_result', 120000); if (!d.path) { twriteln(d.error ? `\x1b[31m⚠️ ${d.error}\x1b[0m` : '\x1b[33m(폴더 선택 취소됨)\x1b[0m'); printPrompt(); return; } dirHandle = null; // companion 경로가 우선이므로 브라우저 핸들은 해제 document.getElementById('local-path-input').value = d.path; localStorage.setItem('claude_app_local_path', d.path); document.getElementById('folder-status').textContent = '연결됨: ' + d.path; twriteln(`\x1b[32m📁 폴더 연결됨(Bash 경로도 함께 설정됨): ${d.path}\x1b[0m`); printPrompt(); await refreshFileTree(); } catch (e) { twriteln(`\r\n\x1b[31m⚠️ 폴더 선택 실패: ${e.message || e}\x1b[0m`); printPrompt(); } return; } // companion.py 미연결 시: 브라우저 File System Access API로 폴백 (Bash는 별도 companion 연결 필요) if (!window.showDirectoryPicker) { twriteln('\r\n\x1b[31m⚠️ companion.py가 연결되지 않았고, 이 브라우저는 파일 시스템 접근 API도 지원하지 않습니다.\x1b[0m'); twriteln('\x1b[90m companion.py를 먼저 실행하면 폴더 선택 시 Bash 경로도 함께 설정됩니다.\x1b[0m'); printPrompt(); return; } try { dirHandle = await window.showDirectoryPicker(); document.getElementById('folder-status').textContent = '연결됨(브라우저 전용): ' + dirHandle.name; twriteln(`\r\n\x1b[33m📁 폴더 연결됨(브라우저 전용): ${dirHandle.name}\x1b[0m`); twriteln('\x1b[90m companion.py가 연결되지 않아 Bash는 이 폴더와 별개로 동작합니다.\x1b[0m'); printPrompt(); await refreshFileTree(); } catch (e) { if (e.name === 'AbortError') return; // user closed the picker without choosing twriteln(`\r\n\x1b[31m⚠️ 폴더 연결 실패: ${e.message || e}\x1b[0m`); printPrompt(); } } async function getDirHandleForPath(root, segments, create) { let h = root; for (const seg of segments) h = await h.getDirectoryHandle(seg, { create }); return h; } async function getFileHandleForPath(root, relPath, create) { const parts = relPath.split('/').filter(Boolean); const fileName = parts.pop(); const dirH = parts.length ? await getDirHandleForPath(root, parts, create) : root; return dirH.getFileHandle(fileName, { create }); } async function fsReadFile(relPath) { if (useCompanionFs()) { try { const d = await companionRequest('fs_read', { cwd: getWorkRoot(), path: relPath }, 'fs_read_result'); return d.content ?? null; } catch { return null; } } if (!dirHandle) return null; try { const fh = await getFileHandleForPath(dirHandle, relPath, false); return await (await fh.getFile()).text(); } catch { return null; } } async function fsWriteFile(relPath, content) { if (useCompanionFs()) { const d = await companionRequest('fs_write', { cwd: getWorkRoot(), path: relPath, content }, 'fs_write_result'); if (!d.ok) throw new Error(d.error || '쓰기 실패'); return; } if (!dirHandle) throw new Error('폴더가 연결되지 않았습니다 (📁 폴더 연결)'); const fh = await getFileHandleForPath(dirHandle, relPath, true); const w = await fh.createWritable(); await w.write(content); await w.close(); } async function fsListPaths() { if (useCompanionFs()) { try { const d = await companionRequest('fs_list', { cwd: getWorkRoot() }, 'fs_list_result'); return d.paths || []; } catch { return []; } } if (!dirHandle) return []; return await fsWalk(dirHandle); } async function fsWalk(dirH, prefix = '') { const results = []; for await (const [name, handle] of dirH.entries()) { if (handle.kind === 'directory') { if (IGNORE_DIRS.has(name) || name.startsWith('.')) continue; results.push(...await fsWalk(handle, prefix ? `${prefix}/${name}` : name)); } else { results.push(prefix ? `${prefix}/${name}` : name); } } return results; } // ── File panel: tree browser + simple text editor ─────────────────────── let fileTreeExpanded = new Set(); let currentEditorFile = null; function buildTree(paths) { const root = { children: {} }; for (const p of paths) { const parts = p.split('/'); let node = root; parts.forEach((part, i) => { const isLast = i === parts.length - 1; if (!node.children[part]) node.children[part] = { name: part, isFile: isLast, children: {} }; node = node.children[part]; }); } return root; } function renderTreeNode(node, prefix, depth) { const entries = Object.values(node.children).sort((a, b) => { if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; return a.name.localeCompare(b.name); }); let html = ''; for (const entry of entries) { const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name; const indent = 8 + depth * 14; if (entry.isFile) { const active = fullPath === currentEditorFile ? ' ft-active' : ''; html += `