Files
homeclaw/web-ui/claude-app.js
T
kimandClaude Sonnet 5 830ae12bac feat: claude-app Anthropic 백엔드 추가 + 코드 에디터 하단 패널 리사이즈/테마 통일
- claude-app에 Anthropic 백엔드 추가 (Ollama/LM Studio에 이어 3번째). Messages
  API(system 필드, tool_use/tool_result 블록) 변환 로직 구현, 브라우저 직접
  호출(anthropic-dangerous-direct-browser-access), API 키는 브라우저 로컬
  저장만 하고 서버로는 전송하지 않음
- 코드 에디터 터미널 패널에도 클로드 앱과 동일한 하단 패널 높이 제한(전체
  높이의 1/2~7/10) 적용, 공용 헬퍼(_bottomPanelHeightBounds)로 정리
- 터미널 배경색을 클로드 앱 Ubuntu 테마 색(#4B1D45)으로 통일

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 00:18:10 +09:00

1043 lines
52 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.
// 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 += `<div class="ft-item ft-file${active}" style="padding-left:${indent}px" data-path="${escAttr(fullPath)}" title="${escAttr(fullPath)}">📄 ${escAttr(entry.name)}</div>`;
} else {
const isOpen = fileTreeExpanded.has(fullPath);
html += `<div class="ft-item ft-dir" style="padding-left:${indent - 8}px" data-path="${escAttr(fullPath)}">${isOpen ? '📂' : '📁'} ${escAttr(entry.name)}</div>`;
if (isOpen) html += renderTreeNode(entry, fullPath, depth + 1);
}
}
return html;
}
async function refreshFileTree() {
const treeEl = document.getElementById('file-tree');
if (!useCompanionFs() && !dirHandle) { treeEl.innerHTML = '<div class="ft-empty">폴더를 연결하세요</div>'; return; }
try {
const paths = await fsListPaths();
const html = renderTreeNode(buildTree(paths), '', 0);
treeEl.innerHTML = html || '<div class="ft-empty">(빈 폴더)</div>';
} catch (e) {
treeEl.innerHTML = `<div class="ft-empty" style="color:#f87171">오류: ${escAttr(e.message || e)}</div>`;
}
}
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
async function fetchModels() {
const sel = document.getElementById('model-select');
sel.innerHTML = '<option value="">(불러오는 중...)</option>';
try {
let names = [];
if (backend === 'ollama') {
const r = await fetch(currentEndpoint() + '/api/tags');
if (!r.ok) throw new Error('HTTP ' + r.status);
names = ((await r.json()).models || []).map(m => m.name);
} else if (backend === 'anthropic') {
const apiKey = getAnthropicKey();
if (!apiKey) throw new Error('API 키를 입력하세요');
const r = await fetch(currentEndpoint() + '/v1/models', {
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' },
});
if (!r.ok) throw new Error('HTTP ' + r.status);
names = ((await r.json()).data || []).map(m => m.id);
} 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 => `<option value="${escAttr(n)}">${escAttr(n)}</option>`).join('') : '<option value="">(모델 없음)</option>';
if (currentModel() && names.includes(currentModel())) sel.value = currentModel();
else if (names.length) { sel.value = names[0]; setModel(names[0]); }
} catch (e) {
sel.innerHTML = '<option value="">(연결 실패)</option>';
if (term) {
twriteln(`\r\n\x1b[31m⚠️ ${backend} 모델 목록을 가져오지 못했습니다: ${e.message}\x1b[0m`);
const hint = backend === 'ollama' ? 'CORS 문제일 수 있습니다 — OLLAMA_ORIGINS=* ollama serve 로 재시작을 확인하세요.'
: backend === 'lm_studio' ? 'CORS 문제일 수 있습니다 — LM Studio 서버 설정에서 CORS 활성화를 확인하세요.'
: '상단 "API 키"가 올바른지 확인하세요.';
twriteln(`\x1b[90m ${hint}\x1b[0m`);
}
}
}
function setModel(name) { models[backend] = name; localStorage.setItem(modelStorageKey(), name); }
// Converts the app's internal OpenAI/Ollama-style messages array into Anthropic's Messages API
// shape: system prompt becomes a top-level field, assistant tool_calls become tool_use content
// blocks, and consecutive 'tool' result messages (one per call in a round) merge into a single
// user turn with multiple tool_result blocks — Anthropic expects one user turn per round, not one
// message per tool result.
function toAnthropicMessages(messages) {
const out = [];
let system;
for (const m of messages) {
if (m.role === 'system') { system = system ? system + '\n\n' + (m.content || '') : (m.content || ''); continue; }
if (m.role === 'user') { out.push({ role: 'user', content: m.content || '' }); continue; }
if (m.role === 'assistant') {
const blocks = [];
if (m.content) blocks.push({ type: 'text', text: m.content });
for (const tc of (m.tool_calls || [])) {
blocks.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input: normalizeArgs(tc.function.arguments) });
}
out.push({ role: 'assistant', content: blocks.length ? blocks : (m.content || '(응답 없음)') });
continue;
}
if (m.role === 'tool') {
const block = { type: 'tool_result', tool_use_id: m.tool_call_id, content: String(m.content ?? '') };
const last = out[out.length - 1];
if (last && last.role === 'user' && Array.isArray(last.content) && last.content[0]?.type === 'tool_result') last.content.push(block);
else out.push({ role: 'user', content: [block] });
continue;
}
}
return { system, messages: out };
}
async function callModel(messages, tools, signal) {
if (backend === 'anthropic') {
const apiKey = getAnthropicKey();
if (!apiKey) throw new Error('상단에 Anthropic API 키를 입력하세요');
const { system, messages: aMessages } = toAnthropicMessages(messages);
const anthropicTools = tools.map(t => ({ name: t.function.name, description: t.function.description, input_schema: t.function.parameters }));
const r = await fetch(currentEndpoint() + '/v1/messages', {
method: 'POST', signal,
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({ model: currentModel(), max_tokens: 8192, system, messages: aMessages, tools: anthropicTools }),
});
if (!r.ok) throw new Error(`Anthropic HTTP ${r.status}: ${await r.text()}`);
const blocks = (await r.json()).content || [];
const content = blocks.filter(b => b.type === 'text').map(b => b.text).join('');
const toolCalls = blocks.filter(b => b.type === 'tool_use').map(b => ({ id: b.id, name: b.name, args: b.input || {} }));
return { content, toolCalls };
}
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');
const anthropicKeyInput = document.getElementById('anthropic-key-input');
function updateBackendFieldsVisibility() {
const isAnthropic = backend === 'anthropic';
document.getElementById('anthropic-key-field').style.display = isAnthropic ? '' : 'none';
// 서버 프록시는 Ollama/LM Studio 전용 — Anthropic은 항상 직접 호출된다.
document.getElementById('proxy-toggle-field').style.display = isAnthropic ? 'none' : '';
}
backendSel.value = backend;
endpointInput.value = currentEndpoint();
localPathInput.value = localStorage.getItem('claude_app_local_path') || '';
anthropicKeyInput.value = localStorage.getItem('claude_app_anthropic_key') || '';
proxyToggle.checked = useServerProxy;
endpointInput.disabled = useServerProxy;
updateBackendFieldsVisibility();
backendSel.addEventListener('change', async (e) => {
backend = e.target.value;
localStorage.setItem('claude_app_backend', backend);
endpointInput.value = currentEndpoint();
updateBackendFieldsVisibility();
await fetchModels();
});
endpointInput.addEventListener('change', (e) => {
endpoints[backend] = e.target.value.trim() || DEFAULT_ENDPOINTS[backend];
localStorage.setItem(endpointStorageKey(), endpoints[backend]);
fetchModels();
});
anthropicKeyInput.addEventListener('change', (e) => {
localStorage.setItem('claude_app_anthropic_key', e.target.value.trim());
if (backend === 'anthropic') 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(); });