diff --git a/web-ui/claude-app.html b/web-ui/claude-app.html new file mode 100644 index 0000000..af97a69 --- /dev/null +++ b/web-ui/claude-app.html @@ -0,0 +1,209 @@ + + + + + +πŸ€– Claude App + + + + + + + +
+
+ πŸ€– Claude App + +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + (폴더 μ—†μŒ) +
+ +
+ + +
+ +
+ + companion +
+ +
+ + +
+
+ +
+ μ‹€ν–‰ λͺ¨λ“œ + + + + +
+ +
+
+
+ πŸ“ 파일 + +
+
폴더λ₯Ό μ—°κ²°ν•˜μ„Έμš”
+
+
+ + +
+ +
+ + + +
+
+
+
+
+
+ + + + diff --git a/web-ui/claude-app.js b/web-ui/claude-app.js new file mode 100644 index 0000000..7ac3d73 --- /dev/null +++ b/web-ui/claude-app.js @@ -0,0 +1,956 @@ +// claude-app.js β€” local-model coding agent rendered as a real terminal. +// Backends: Ollama (/api/chat) and LM Studio (/v1/chat/completions, OpenAI-compatible). +// 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' }; +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, +}; +const models = { + ollama: localStorage.getItem('claude_app_model_ollama') || '', + lm_studio: localStorage.getItem('claude_app_model_lmstudio') || '', +}; +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. +const currentEndpoint = () => useServerProxy ? PROXY_BASES[backend] : endpoints[backend]; +const currentModel = () => models[backend]; +const modelStorageKey = () => 'claude_app_model_' + (backend === 'ollama' ? 'ollama' : 'lmstudio'); + +// ── 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 += `
πŸ“„ ${escAttr(entry.name)}
`; + } else { + const isOpen = fileTreeExpanded.has(fullPath); + html += `
${isOpen ? 'πŸ“‚' : 'πŸ“'} ${escAttr(entry.name)}
`; + if (isOpen) html += renderTreeNode(entry, fullPath, depth + 1); + } + } + return html; +} +async function refreshFileTree() { + const treeEl = document.getElementById('file-tree'); + if (!useCompanionFs() && !dirHandle) { treeEl.innerHTML = '
폴더λ₯Ό μ—°κ²°ν•˜μ„Έμš”
'; return; } + try { + const paths = await fsListPaths(); + const html = renderTreeNode(buildTree(paths), '', 0); + treeEl.innerHTML = html || '
(빈 폴더)
'; + } catch (e) { + treeEl.innerHTML = `
였λ₯˜: ${escAttr(e.message || e)}
`; + } +} +async function openFileInEditor(relPath) { + const content = await fsReadFile(relPath); + if (content === null) { twriteln(`\r\n\x1b[31m였λ₯˜: ${relPath} 읽기 μ‹€νŒ¨\x1b[0m`); printPrompt(); return; } + currentEditorFile = relPath; + document.getElementById('file-editor-name').textContent = relPath; + document.getElementById('file-editor-textarea').value = content; + document.getElementById('file-editor-status').textContent = ''; + document.getElementById('file-editor').style.display = 'flex'; + refreshFileTree(); +} +function closeFileEditor() { + currentEditorFile = null; + document.getElementById('file-editor').style.display = 'none'; + refreshFileTree(); +} +async function saveFileEditor() { + if (!currentEditorFile) return; + const statusEl = document.getElementById('file-editor-status'); + try { + await fsWriteFile(currentEditorFile, document.getElementById('file-editor-textarea').value); + statusEl.textContent = 'μ €μž₯됨'; + statusEl.style.color = '#4ade80'; + setTimeout(() => { if (statusEl.textContent === 'μ €μž₯됨') statusEl.textContent = ''; }, 1500); + } catch (e) { + statusEl.textContent = 'μ €μž₯ μ‹€νŒ¨: ' + (e.message || e); + statusEl.style.color = '#f87171'; + } +} +function insertMentionToTerminal() { + if (!currentEditorFile || !term) return; + const text = `@${currentEditorFile} `; + lineBuf += text; + term.write(text); + term.focus(); +} + +function globToRegex(pattern) { + let re = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { re += '.*'; i++; if (pattern[i + 1] === '/') i++; } + else re += '[^/]*'; + } else if (c === '?') re += '[^/]'; + else if ('.+^$()|[]{}\\'.includes(c)) re += '\\' + c; + else re += c; + } + return new RegExp('^' + re + '$'); +} + +// ── Tool execution ─────────────────────────────────────────────────────── +async function executeTool(name, args) { + try { + switch (name) { + case 'Read': { + const file = args.file || ''; + if (!file) return { result: '였λ₯˜: file μΈμˆ˜κ°€ ν•„μš”ν•©λ‹ˆλ‹€', error: true }; + const raw = await fsReadFile(file); + if (raw === null) return { result: `였λ₯˜: ${file} νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€`, error: true }; + 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; + return { result: lines.slice(offset, offset + limit).map((l, i) => `${offset + i + 1}: ${l}`).join('\n'), error: false }; + } + case 'Write': { + const file = args.file || '', content = args.content ?? ''; + if (!file) return { result: '였λ₯˜: file μΈμˆ˜κ°€ ν•„μš”ν•©λ‹ˆλ‹€', error: true }; + await fsWriteFile(file, content); + refreshFileTree(); + if (file === currentEditorFile) document.getElementById('file-editor-textarea').value = content; + return { result: `${file} μ €μž₯ μ™„λ£Œ (${content.split('\n').length}쀄)`, error: false }; + } + case 'Edit': { + const file = args.file || '', oldStr = args.old_string ?? '', newStr = args.new_string ?? ''; + if (!file) return { result: '였λ₯˜: file μΈμˆ˜κ°€ ν•„μš”ν•©λ‹ˆλ‹€', error: true }; + const existing = await fsReadFile(file); + if (existing === null) return { result: `였λ₯˜: ${file}을(λ₯Ό) 찾을 수 μ—†μŠ΅λ‹ˆλ‹€`, error: true }; + if (!existing.includes(oldStr)) return { result: '였λ₯˜: old_string을 νŒŒμΌμ—μ„œ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€', error: true }; + const updated = existing.replace(oldStr, newStr); + await fsWriteFile(file, updated); + if (file === currentEditorFile) document.getElementById('file-editor-textarea').value = updated; + return { result: `${file} μˆ˜μ • μ™„λ£Œ`, error: false }; + } + case 'Glob': { + if (!useCompanionFs() && !dirHandle) return { result: '(폴더가 μ—°κ²°λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€)', error: false }; + const pattern = args.pattern || '**/*'; + const re = globToRegex(pattern); + const matches = (await fsListPaths()).filter(f => re.test(f)); + return { result: matches.length ? matches.join('\n') : '(λ§€μΉ­ 파일 μ—†μŒ)', error: false }; + } + case 'Grep': { + if (!useCompanionFs() && !dirHandle) return { result: '(폴더가 μ—°κ²°λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€)', error: false }; + const pattern = args.pattern || '', ci = !!args.case_insensitive; + let files; + if (args.path) files = [args.path]; + else { + const CODE_EXT = /\.(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 = (await fsListPaths()).filter(f => CODE_EXT.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 content = await fsReadFile(f); + if (!content) continue; + content.split('\n').forEach((line, idx) => { + regex.lastIndex = 0; + if (regex.test(line)) results.push(`${f}:${idx + 1}: ${line.trim()}`); + }); + } + return { result: results.slice(0, 200).join('\n') || '(κ²°κ³Ό μ—†μŒ)', error: false }; + } + case 'Bash': return await execBash(args.command || ''); + default: return { result: `μ•Œ 수 μ—†λŠ” 툴: ${name}`, error: true }; + } + } catch (e) { + return { result: `였λ₯˜: ${e?.message || e}`, error: true }; + } +} + +// ── Model backends ─────────────────────────────────────────────────────── +function normalizeArgs(raw) { + if (raw == null) return {}; + if (typeof raw === 'object') return raw; + try { return JSON.parse(raw); } catch { return {}; } +} +function escAttr(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/ m.name); + } else { + const r = await fetch(currentEndpoint() + '/v1/models'); + if (!r.ok) throw new Error('HTTP ' + r.status); + names = ((await r.json()).data || []).map(m => m.id); + } + sel.innerHTML = names.length ? names.map(n => ``).join('') : ''; + if (currentModel() && names.includes(currentModel())) sel.value = currentModel(); + else if (names.length) { sel.value = names[0]; setModel(names[0]); } + } catch (e) { + sel.innerHTML = ''; + if (term) { + twriteln(`\r\n\x1b[31m⚠️ ${backend} λͺ¨λΈ λͺ©λ‘μ„ κ°€μ Έμ˜€μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€: ${e.message}\x1b[0m`); + twriteln(`\x1b[90m CORS 문제일 수 μžˆμŠ΅λ‹ˆλ‹€ β€” ${backend === 'ollama' ? 'OLLAMA_ORIGINS=* ollama serve 둜 μž¬μ‹œμž‘' : 'LM Studio μ„œλ²„ μ„€μ •μ—μ„œ CORS ν™œμ„±ν™”'} λ₯Ό ν™•μΈν•˜μ„Έμš”.\x1b[0m`); + } + } +} +function setModel(name) { models[backend] = name; localStorage.setItem(modelStorageKey(), name); } + +async function callModel(messages, tools, signal) { + if (backend === 'ollama') { + const r = await fetch(currentEndpoint() + '/api/chat', { + method: 'POST', signal, headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: currentModel(), messages, tools, stream: false }), + }); + if (!r.ok) throw new Error(`Ollama HTTP ${r.status}: ${await r.text()}`); + const msg = (await r.json()).message || {}; + const toolCalls = (msg.tool_calls || []).map((tc, i) => ({ + id: tc.id || `call_${(tc.function?.name || 'tool')}_${i}_${Date.now()}`, + name: tc.function?.name || '', args: normalizeArgs(tc.function?.arguments), + })); + return { content: msg.content || '', toolCalls }; + } + const r = await fetch(currentEndpoint() + '/v1/chat/completions', { + method: 'POST', signal, headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: currentModel(), messages, tools, stream: false }), + }); + if (!r.ok) throw new Error(`LM Studio HTTP ${r.status}: ${await r.text()}`); + const msg = (await r.json()).choices?.[0]?.message || {}; + const toolCalls = (msg.tool_calls || []).map((tc, i) => ({ + id: tc.id || `call_${i}_${Date.now()}`, + name: tc.function?.name || '', args: normalizeArgs(tc.function?.arguments), + })); + return { content: msg.content || '', toolCalls }; +} + +// ── Agent loop ─────────────────────────────────────────────────────────── +function buildSystemPrompt() { + let modeHint = ''; + if (agentMode === 'plan') modeHint = '\n- [Plan λͺ¨λ“œ] λ¨Όμ € μ–΄λ–€ μž‘μ—…μ„ ν• μ§€ κ³„νšμ„ μ„Έμš°κ³  νˆ΄μ„ ν˜ΈμΆœν•˜μ„Έμš”. μ‚¬μš©μžκ°€ μŠΉμΈν•œ 후에 μ‹€ν–‰λ©λ‹ˆλ‹€.'; + else if (agentMode === 'yolo') modeHint = '\n- [Yolo λͺ¨λ“œ] κ°„κ²°ν•˜κ²Œ λ‹΅λ³€ν•˜μ„Έμš”. 툴 κ²°κ³ΌλŠ” μš”μ•½λ§Œ ν•„μš”ν•©λ‹ˆλ‹€.'; + const root = getWorkRoot(); + const workDesc = useCompanionFs() + ? `Read/Write/Edit/Glob/Grepκ³Ό Bash λͺ¨λ‘ 같은 폴더 "${root}" κΈ°μ€€μœΌλ‘œ λ™μž‘ν•©λ‹ˆλ‹€ (μƒλŒ€ 경둜 μ‚¬μš©).` + : dirHandle + ? `Read/Write/Edit/Glob/Grep은 μ—°κ²°λœ 폴더 "${dirHandle.name}" κΈ°μ€€ μƒλŒ€ 경둜둜 λ™μž‘ν•˜μ§€λ§Œ, companion.pyκ°€ μ—°κ²°λ˜μ§€ μ•Šμ•„ BashλŠ” λ³„λ„λ‘œ λ™μž‘ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.` + : '폴더가 μ—°κ²°λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€ β€” μ‚¬μš©μžμ—κ²Œ 상단 "πŸ“ 폴더 μ—°κ²°" λ²„νŠΌμ„ λˆŒλŸ¬λ‹¬λΌκ³  μ•ˆλ‚΄ν•˜μ„Έμš”.'; + return `당신은 둜컬 μ½”λ”© μ—μ΄μ „νŠΈμž…λ‹ˆλ‹€ (Claude Code μŠ€νƒ€μΌ). Read/Write/Edit/Glob/Grep/Bash νˆ΄μ„ μ‚¬μš©ν•΄ μ‚¬μš©μžμ˜ μš”μ²­μ„ μˆ˜ν–‰ν•˜μ„Έμš”. +- μž‘μ—… 폴더: ${workDesc} +- BashλŠ” μ‚¬μš©μžμ˜ 둜컬 OS κΈ°λ³Έ μ…Έμ—μ„œ μ‹€ν–‰λ©λ‹ˆλ‹€ β€” WindowsλŠ” cmd.exe(ls/grep λ“± μœ λ‹‰μŠ€ λͺ…λ Ή μ—†μŒ, dir/type/findstr λ“± μ‚¬μš©), macOS/LinuxλŠ” bash/shμž…λ‹ˆλ‹€. μ–΄λ–€ OS인지 λͺ¨λ₯΄λ©΄ λ¨Όμ € κ°€λ²Όμš΄ λͺ…λ Ή(예: "echo %OS%" λŠ” Windowsμ—μ„œλ§Œ 좜λ ₯됨)으둜 νŒλ³„ν•œ λ’€ 그에 λ§žλŠ” λͺ…령을 μ‚¬μš©ν•˜μ„Έμš”. +- λ³€κ²½ 전에 νŒŒμΌμ„ Read둜 λ¨Όμ € ν™•μΈν•˜μ„Έμš”. +- 닡변은 κ°„κ²°ν•˜κ²Œ, ν•œκ΅­μ–΄λ‘œ ν•˜μ„Έμš”.${modeHint}`; +} + +async function submitPrompt(text) { + if (!currentModel()) { twriteln('\x1b[31m⚠️ λͺ¨λΈμ„ λ¨Όμ € μ„ νƒν•˜μ„Έμš”.\x1b[0m'); printPrompt(); return; } + busy = true; + history.push({ role: 'user', content: text }); + abortCtrl = new AbortController(); + let messages = [{ role: 'system', content: buildSystemPrompt() }, ...history]; + _spinTimer = startSpinner(); + try { + for (let round = 0; round < MAX_ROUNDS; round++) { + let resp; + try { + resp = await callModel(messages, TOOL_DEFS, abortCtrl.signal); + } finally { stopSpinner(_spinTimer); _spinTimer = null; } + + if (resp.content) twriteln(resp.content); + + if (!resp.toolCalls.length) { + history.push({ role: 'assistant', content: resp.content || '' }); + break; + } + + const assistantToolCalls = [], toolMsgs = []; + + // ── Plan mode: show all planned tool calls and wait for approval ── + if (agentMode === 'plan' && resp.toolCalls.length > 0) { + twriteln('\x1b[1;35mπŸ“‹ μ‹€ν–‰ κ³„νš:\x1b[0m'); + for (const tc of resp.toolCalls) { + const s = String(tc.args.file || tc.args.pattern || tc.args.command || '').slice(0, 80); + twriteln(` \x1b[36m● ${tc.name}\x1b[0m${s ? `(${s})` : ''}`); + } + twriteln('\x1b[90m[Enter] μ‹€ν–‰ [Esc] μ·¨μ†Œ\x1b[0m'); + stopSpinner(_spinTimer); _spinTimer = null; + const planAnswer = await waitForApproval('plan'); + if (planAnswer === 'cancel' || planAnswer === 'no') { + twriteln('\x1b[33m(κ³„νš μ·¨μ†Œλ¨)\x1b[0m'); + history.push({ role: 'assistant', content: resp.content || '(κ³„νš μ·¨μ†Œλ¨)' }); + break; + } + _spinTimer = startSpinner(); + } + + let skipRest = false; // set to true when user presses 'A' in confirm mode + for (const tc of resp.toolCalls) { + const argSummary = String(tc.args.file || tc.args.pattern || tc.args.command || '').slice(0, 80); + + // ── Confirm mode: ask before each tool call ── + if (agentMode === 'confirm' && !skipRest) { + stopSpinner(_spinTimer); _spinTimer = null; + twriteln(`\x1b[1;33mπŸ”’ ${tc.name}\x1b[0m${argSummary ? `(${argSummary})` : ''}`); + twriteln('\x1b[90m[Y] μ‹€ν–‰ [N] κ±΄λ„ˆλ›°κΈ° [A] λͺ¨λ‘ μžλ™μ‹€ν–‰ [Esc] 전체 μ·¨μ†Œ\x1b[0m'); + const answer = await waitForApproval('confirm'); + if (answer === 'cancel') { + twriteln('\x1b[33m(전체 μ·¨μ†Œλ¨)\x1b[0m'); + break; + } + if (answer === 'no') { + // Skip this tool β€” feed a "user declined" result + const skipResult = `μ‚¬μš©μžκ°€ ${tc.name} 싀행을 κ±΄λ„ˆλ›°μ—ˆμŠ΅λ‹ˆλ‹€.`; + twriteln(`\x1b[33m ⏭ ${tc.name} κ±΄λ„ˆλœ€\x1b[0m`); + const toolCallArgs = backend === 'ollama' ? tc.args : JSON.stringify(tc.args); + assistantToolCalls.push({ id: tc.id, type: 'function', function: { name: tc.name, arguments: toolCallArgs } }); + toolMsgs.push({ role: 'tool', tool_call_id: tc.id, content: skipResult }); + _spinTimer = startSpinner(); + continue; + } + if (answer === 'all') { + skipRest = true; // switch to auto for the rest of this round + } + _spinTimer = startSpinner(); + } + + // ── Execute the tool ── + if (agentMode === 'yolo') { + // Yolo: minimal output β€” just tool name + βœ“/βœ— + const { result, error } = await executeTool(tc.name, tc.args); + twriteln(`\x1b[36m● ${tc.name}\x1b[0m${argSummary ? `(${argSummary})` : ''} ${error ? '\x1b[31mβœ—\x1b[0m' : '\x1b[32mβœ“\x1b[0m'}`); + const toolCallArgs = backend === 'ollama' ? tc.args : JSON.stringify(tc.args); + assistantToolCalls.push({ id: tc.id, type: 'function', function: { name: tc.name, arguments: toolCallArgs } }); + toolMsgs.push({ role: 'tool', tool_call_id: tc.id, content: result }); + } else { + // Auto / Plan / Confirm (approved): full output + twriteln(`\x1b[36m● ${tc.name}\x1b[0m${argSummary ? `(${argSummary})` : ''}`); + const { result, error } = await executeTool(tc.name, tc.args); + const resultLines = String(result).split('\n'); + const preview = resultLines.slice(0, 15).map(l => '\x1b[90m ' + l + '\x1b[0m').join('\n'); + twriteln(preview); + if (resultLines.length > 15) twriteln('\x1b[90m …(μƒλž΅)\x1b[0m'); + const toolCallArgs = backend === 'ollama' ? tc.args : JSON.stringify(tc.args); + assistantToolCalls.push({ id: tc.id, type: 'function', function: { name: tc.name, arguments: toolCallArgs } }); + toolMsgs.push({ role: 'tool', tool_call_id: tc.id, content: result }); + } + } + messages.push({ role: 'assistant', content: resp.content || '', tool_calls: assistantToolCalls }); + messages.push(...toolMsgs); + history.push({ role: 'assistant', content: resp.content || '', tool_calls: assistantToolCalls }); + history.push(...toolMsgs); + _spinTimer = startSpinner(); + } + } catch (e) { + stopSpinner(_spinTimer); _spinTimer = null; + if (e.name === 'AbortError') twriteln('\x1b[33m(쀑단됨)\x1b[0m'); + else twriteln(`\x1b[31m였λ₯˜: ${e.message || e}\x1b[0m`); + } finally { + busy = false; abortCtrl = null; + printPrompt(); + } +} + +// ── Wiring ─────────────────────────────────────────────────────────────── +window.addEventListener('load', async () => { + initTerm(); + + const backendSel = document.getElementById('backend-select'); + const endpointInput = document.getElementById('endpoint-input'); + const localPathInput = document.getElementById('local-path-input'); + const proxyToggle = document.getElementById('proxy-toggle'); + + backendSel.value = backend; + endpointInput.value = currentEndpoint(); + localPathInput.value = localStorage.getItem('claude_app_local_path') || ''; + proxyToggle.checked = useServerProxy; + endpointInput.disabled = useServerProxy; + + backendSel.addEventListener('change', async (e) => { + backend = e.target.value; + localStorage.setItem('claude_app_backend', backend); + endpointInput.value = currentEndpoint(); + await fetchModels(); + }); + endpointInput.addEventListener('change', (e) => { + endpoints[backend] = e.target.value.trim() || DEFAULT_ENDPOINTS[backend]; + localStorage.setItem('claude_app_ep_' + (backend === 'ollama' ? 'ollama' : 'lmstudio'), endpoints[backend]); + fetchModels(); + }); + proxyToggle.addEventListener('change', async (e) => { + useServerProxy = e.target.checked; + localStorage.setItem('claude_app_use_proxy', useServerProxy ? '1' : '0'); + endpointInput.disabled = useServerProxy; + endpointInput.value = currentEndpoint(); + await fetchModels(); + }); + document.getElementById('model-select').addEventListener('change', (e) => setModel(e.target.value)); + document.getElementById('refresh-models-btn').addEventListener('click', fetchModels); + document.getElementById('folder-btn').addEventListener('click', connectFolder); + localPathInput.addEventListener('change', (e) => localStorage.setItem('claude_app_local_path', e.target.value)); + + document.getElementById('file-refresh-btn').addEventListener('click', refreshFileTree); + document.getElementById('file-tree').addEventListener('click', (e) => { + const item = e.target.closest('.ft-item'); + if (!item) return; + const p = item.dataset.path; + if (item.classList.contains('ft-dir')) { + if (fileTreeExpanded.has(p)) fileTreeExpanded.delete(p); else fileTreeExpanded.add(p); + refreshFileTree(); + } else { + openFileInEditor(p); + } + }); + document.getElementById('file-editor-close-btn').addEventListener('click', closeFileEditor); + document.getElementById('file-editor-save-btn').addEventListener('click', saveFileEditor); + document.getElementById('file-editor-mention-btn').addEventListener('click', insertMentionToTerminal); + + companionConnect(); + + // Mode strip + document.querySelectorAll('#mode-strip .mode-btn').forEach(btn => { + if (btn.dataset.mode === agentMode) btn.classList.add('active'); + else btn.classList.remove('active'); + btn.addEventListener('click', () => { + agentMode = btn.dataset.mode; + localStorage.setItem('claude_app_mode', agentMode); + document.querySelectorAll('#mode-strip .mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === agentMode)); + }); + }); + + // Theme + const savedTheme = localStorage.getItem('claude_app_theme') || 'ubuntu'; + document.getElementById('theme-select').value = savedTheme; + applyTheme(savedTheme); + document.getElementById('theme-select').addEventListener('change', (e) => applyTheme(e.target.value)); + + await fetchModels(); + + twriteln('\x1b[1;32mClaude App\x1b[0m β€” 둜컬 λͺ¨λΈ(Ollama/LM Studio) 기반 μ½”λ”© μ—μ΄μ „νŠΈ'); + twriteln('\x1b[90mμƒλ‹¨μ—μ„œ 폴더λ₯Ό μ—°κ²°ν•˜κ³  λͺ¨λΈμ„ μ„ νƒν•œ λ’€, μ•„λž˜μ— μš”μ²­μ„ μž…λ ₯ν•˜μ„Έμš”. (Ctrl+C둜 쀑단)\x1b[0m'); + printPrompt(); +}); +window.addEventListener('resize', () => { if (fitAddon) fitAddon.fit(); }); diff --git a/web-ui/code.html b/web-ui/code.html index aa90dbe..03bc254 100644 --- a/web-ui/code.html +++ b/web-ui/code.html @@ -331,6 +331,10 @@ + +
+ +
@@ -568,6 +572,17 @@ + +