feat: claude-app 로컬 LLM 코딩 에이전트 추가 + 코드 에디터 임베드

- Ollama/LM Studio 기반 터미널형 코딩 에이전트(claude-app.html/js): 테마 11종,
  Plan/Confirm/Auto/Yolo 실행 모드, companion.py 연동
- companion.py에 browse_folder/fs_list/fs_read/fs_write 추가 — 네이티브 폴더
  선택이 곧 Bash 작업 경로가 되도록 통합해 Write/Bash가 서로 다른 폴더를 보던
  문제 해결
- code.html에 클로드 앱 하단 패널 추가, 우측 AI 채팅 패널은 그대로 유지되도록
  그리드 레이아웃 조정, 패널 높이를 전체 높이의 50~70%로 제한
- iframe 관련 버그 수정: src="" 판정 오류로 패널이 로드되지 않던 문제,
  드래그 리사이즈 중 마우스가 iframe 위를 지나면 릴리즈가 안 되던 문제
  (미리보기/에뮬레이터 패널도 동일 수정)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-03 23:41:07 +09:00
co-authored by Claude Sonnet 5
parent d953f69de4
commit 5fdf99fb73
7 changed files with 1379 additions and 6 deletions
+209
View File
@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🤖 Claude App</title>
<link rel="stylesheet" href="/vendor/xterm.css">
<script src="/vendor/xterm.js"></script>
<script src="/vendor/addon-fit.js"></script>
<script src="/vendor/addon-web-links.js"></script>
<style>
:root {
/* Default: Ubuntu — overridden by applyTheme() in JS */
--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;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: var(--bg-main); font-family: 'IBM Plex Mono', 'D2Coding', 'NanumGothicCoding', monospace; }
#app { display: flex; flex-direction: column; height: 100%; }
#topbar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 8px 12px; background: var(--bg-panel); border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#topbar .title { color: var(--accent); font-weight: 700; font-size: 13px; flex-shrink: 0; margin-right: 4px; }
#topbar select, #topbar input {
background: var(--bg-input); border: 1px solid var(--border); color: var(--text);
border-radius: 4px; padding: 4px 6px; font-size: 11px; font-family: inherit;
}
#topbar input[type=text] { width: 150px; }
#topbar label { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .04em; }
#topbar button {
background: var(--accent-bg); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px;
padding: 4px 10px; font-size: 11px; cursor: pointer; font-family: inherit; white-space: nowrap;
}
#topbar button:hover { background: var(--accent-bg-hover); }
.field { display: flex; align-items: center; gap: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dot-off); flex-shrink: 0; }
.dot.on { background: var(--dot-on); }
#folder-status { color: var(--text-muted); font-size: 11px; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#mode-strip {
display: flex; align-items: center; gap: 6px;
padding: 4px 12px; background: var(--bg-panel); border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.mode-label { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .04em; margin-right: 2px; }
.mode-btn {
background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted);
border-radius: 12px; padding: 2px 10px; font-size: 11px; cursor: pointer; font-family: inherit;
transition: all .15s;
}
.mode-btn:hover { border-color: var(--accent); color: var(--text); }
.mode-btn.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); font-weight: 600; }
#main-row { flex: 1; min-height: 0; display: flex; }
#file-panel {
width: 260px; flex-shrink: 0; display: flex; flex-direction: column;
background: var(--bg-panel); border-right: 1px solid var(--border); overflow: hidden;
}
#file-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px; border-bottom: 1px solid var(--border); flex-shrink: 0;
}
#file-panel-header span { color: var(--text-muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
#file-panel-header button {
background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 12px; padding: 0 4px;
}
#file-tree { flex: 1; min-height: 60px; overflow-y: auto; padding: 4px 0; font-size: 12px; }
.ft-item { padding: 3px 8px; color: var(--text); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ft-item:hover { background: var(--accent-bg); }
.ft-item.ft-active { background: var(--accent-bg-hover); color: var(--accent); }
.ft-empty { padding: 10px; color: var(--text-muted); font-size: 11px; }
#file-editor { display: none; flex-direction: column; flex-shrink: 0; height: 45%; border-top: 1px solid var(--border); }
#file-editor-header {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 8px; background: var(--bg-panel); border-bottom: 1px solid var(--border); flex-shrink: 0;
}
#file-editor-name { color: var(--text-muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#file-editor-header button { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 13px; padding: 0 4px; }
#file-editor-textarea {
flex: 1; min-height: 0; resize: none; border: none; outline: none;
background: var(--bg-input); color: var(--text); font-family: inherit; font-size: 12px; padding: 8px;
}
#file-editor-actions { display: flex; gap: 6px; padding: 6px 8px; border-top: 1px solid var(--border); flex-shrink: 0; }
#file-editor-actions button {
background: var(--accent-bg); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px;
padding: 4px 8px; font-size: 11px; cursor: pointer; font-family: inherit;
}
#file-editor-actions button:hover { background: var(--accent-bg-hover); }
#file-editor-status { color: var(--text-muted); font-size: 10px; align-self: center; }
#term-wrap { flex: 1; min-height: 0; position: relative; padding: 6px; background: var(--bg-main); }
#term-container { position: absolute; inset: 6px; }
</style>
</head>
<body>
<div id="app">
<div id="topbar">
<span class="title">🤖 Claude App</span>
<div class="field">
<label>백엔드</label>
<select id="backend-select">
<option value="ollama">Ollama</option>
<option value="lm_studio">LM Studio</option>
</select>
</div>
<div class="field">
<label>모델</label>
<select id="model-select"><option value="">(불러오는 중...)</option></select>
<button id="refresh-models-btn" title="모델 목록 새로고침">↻</button>
</div>
<div class="field">
<label>엔드포인트</label>
<input type="text" id="endpoint-input">
</div>
<div class="field" title="Ollama/LM Studio가 이 homeclaw 서버와 같은 컴퓨터에서 돌고 있을 때 사용. 브라우저에서 직접 접속하지 않고 서버를 통해 우회하므로 CORS 설정이 필요 없습니다.">
<input type="checkbox" id="proxy-toggle">
<label for="proxy-toggle">서버 프록시</label>
</div>
<div class="field">
<button id="folder-btn">📁 폴더 연결</button>
<span id="folder-status">(폴더 없음)</span>
</div>
<div class="field" title="Bash 명령이 실행될 작업 디렉토리. companion.py가 연결된 컴퓨터의 절대 경로를 입력하세요.">
<label>Bash 경로</label>
<input type="text" id="local-path-input" placeholder="예: C:\Users\name\project 또는 /home/user/project">
</div>
<div class="field" title="companion.py 연결 상태">
<span id="companion-dot" class="dot"></span>
<span style="color:var(--text-muted);font-size:10px">companion</span>
</div>
<div class="field">
<label>🎨 테마</label>
<select id="theme-select">
<optgroup label="다크">
<option value="ubuntu">Ubuntu</option>
<option value="dracula">Dracula</option>
<option value="nord">Nord</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="catppuccin">Catppuccin</option>
<option value="tokyo">Tokyo Night</option>
</optgroup>
<optgroup label="라이트">
<option value="solarized-light">Solarized Light</option>
<option value="catppuccin-latte">Catppuccin Latte</option>
<option value="github-light">GitHub Light</option>
<option value="paper">Paper</option>
</optgroup>
</select>
</div>
</div>
<div id="mode-strip">
<span class="mode-label">실행 모드</span>
<button class="mode-btn" data-mode="plan">📋 Plan</button>
<button class="mode-btn" data-mode="confirm">🔒 Confirm</button>
<button class="mode-btn active" data-mode="auto">✏️ Auto</button>
<button class="mode-btn" data-mode="yolo">🤖 Yolo</button>
</div>
<div id="main-row">
<div id="file-panel">
<div id="file-panel-header">
<span>📁 파일</span>
<button id="file-refresh-btn" title="새로고침">↻</button>
</div>
<div id="file-tree"><div class="ft-empty">폴더를 연결하세요</div></div>
<div id="file-editor">
<div id="file-editor-header">
<span id="file-editor-name"></span>
<button id="file-editor-close-btn" title="닫기">✕</button>
</div>
<textarea id="file-editor-textarea" spellcheck="false"></textarea>
<div id="file-editor-actions">
<button id="file-editor-save-btn">💾 저장</button>
<button id="file-editor-mention-btn">@ 터미널에 참조 추가</button>
<span id="file-editor-status"></span>
</div>
</div>
</div>
<div id="term-wrap"><div id="term-container"></div></div>
</div>
</div>
<script src="claude-app.js"></script>
</body>
</html>
+956
View File
@@ -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 += `<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 {
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`);
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(); });
+15
View File
@@ -331,6 +331,10 @@
<button id="code-upload-btn" class="csb-term-btn" onclick="csbUploadToBoard()" title="보드에 업로드 (USB 연결 필요)" style="background:var(--brand);color:#fff;border-color:var(--brand)">⚡ 업로드</button> <button id="code-upload-btn" class="csb-term-btn" onclick="csbUploadToBoard()" title="보드에 업로드 (USB 연결 필요)" style="background:var(--brand);color:#fff;border-color:var(--brand)">⚡ 업로드</button>
</div> </div>
<!-- Claude App (로컬 LLM 코딩 에이전트) -->
<div class="csb-term-row">
<button id="code-claude-btn" class="csb-term-btn" onclick="toggleClaudeAppPanel()" title="Claude App(로컬 LLM 코딩 에이전트) 패널 토글">🤖 클로드 앱</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -568,6 +572,17 @@
</div> </div>
<iframe id="code-emu-iframe" src="" style="flex:1;border:none;min-height:0" allow="serial;usb"></iframe> <iframe id="code-emu-iframe" src="" style="flex:1;border:none;min-height:0" allow="serial;usb"></iframe>
</div> </div>
<!-- 🤖 Claude App panel -->
<div id="code-claude-panel" style="display:none;flex-direction:column;flex-shrink:0;height:560px;min-height:160px;border-top:2px solid #4a2d6b;background:#140f1c;position:relative">
<div id="code-claude-drag" style="position:absolute;top:0;left:0;right:0;height:5px;cursor:ns-resize;z-index:2;transition:background .15s"></div>
<div style="display:flex;align-items:center;height:30px;background:#1c1424;border-bottom:1px solid #4a2d6b;flex-shrink:0;padding:0 8px;gap:6px">
<span style="font-size:13px;flex-shrink:0">🤖</span>
<span style="font-size:12px;font-weight:700;color:#c9a9e8;font-family:'IBM Plex Mono',monospace;flex:1">Claude App</span>
<button onclick="window.open('/claude-app.html','_blank')" title="새 탭에서 열기" style="background:none;border:none;color:#8a6c9e;cursor:pointer;font-size:13px;padding:0 5px;flex-shrink:0">⧉</button>
<button onclick="toggleClaudeAppPanel()" title="닫기" style="background:none;border:none;color:#6b4d7e;cursor:pointer;font-size:16px;padding:0 6px;line-height:1;flex-shrink:0">&times;</button>
</div>
<iframe id="code-claude-iframe" src="" style="flex:1;border:none;min-height:0" allow="local-network-access"></iframe>
</div>
<!-- ⚡ Upload panel --> <!-- ⚡ Upload panel -->
<div id="code-upload-panel" style="display:none;flex-direction:column;flex-shrink:0;height:300px;min-height:100px;border-top:2px solid #7a4e20;background:#1e1810;position:relative"> <div id="code-upload-panel" style="display:none;flex-direction:column;flex-shrink:0;height:300px;min-height:100px;border-top:2px solid #7a4e20;background:#1e1810;position:relative">
<div id="code-upload-drag" style="position:absolute;top:0;left:0;right:0;height:5px;cursor:ns-resize;z-index:2;transition:background .15s"></div> <div id="code-upload-drag" style="position:absolute;top:0;left:0;right:0;height:5px;cursor:ns-resize;z-index:2;transition:background .15s"></div>
+68 -5
View File
@@ -904,7 +904,7 @@ function toggleCodePreview() {
const saved = localStorage.getItem('code_preview_url'); const saved = localStorage.getItem('code_preview_url');
if (saved && urlInput) urlInput.value = saved; if (saved && urlInput) urlInput.value = saved;
const iframe = document.getElementById('code-preview-iframe'); const iframe = document.getElementById('code-preview-iframe');
if (iframe && !iframe.src) iframe.src = (urlInput ? urlInput.value : 'http://127.0.0.1:8000'); if (iframe && !iframe.getAttribute('src')) iframe.src = (urlInput ? urlInput.value : 'http://127.0.0.1:8000');
} else { } else {
panel.style.display = 'none'; panel.style.display = 'none';
if (btn) btn.classList.remove('active'); if (btn) btn.classList.remove('active');
@@ -950,6 +950,57 @@ function emuPanelNewTab() {
if (_emuPanelUrl) window.open(_emuPanelUrl, '_blank'); if (_emuPanelUrl) window.open(_emuPanelUrl, '_blank');
} }
// 클로드 앱 패널 높이는 항상 전체 높이의 1/2~7/10 사이로 제한한다.
function _claudePanelBounds() {
const total = document.getElementById('code-view')?.clientHeight || window.innerHeight;
return { min: total * 0.5, max: total * 0.7 };
}
let _claudePanelOpen = false;
function toggleClaudeAppPanel() {
const panel = document.getElementById('code-claude-panel');
const btn = document.getElementById('code-claude-btn');
if (!panel) return;
_claudePanelOpen = !_claudePanelOpen;
panel.style.display = _claudePanelOpen ? 'flex' : 'none';
if (btn) btn.classList.toggle('active', _claudePanelOpen);
if (_claudePanelOpen) {
const iframe = document.getElementById('code-claude-iframe');
if (iframe && !iframe.getAttribute('src')) iframe.src = '/claude-app.html';
const { min, max } = _claudePanelBounds();
const cur = panel.offsetHeight;
if (cur < min || cur > max) panel.style.height = Math.round((min + max) / 2) + 'px';
}
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
// Claude App panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('code-claude-drag');
const panel = document.getElementById('code-claude-panel');
if (!drag || !panel) return;
let startY = 0, startH = 0;
drag.addEventListener('mouseover', () => drag.style.background = 'rgba(189,147,249,.4)');
drag.addEventListener('mousedown', e => {
startY = e.clientY; startH = panel.offsetHeight; e.preventDefault();
const { min, max } = _claudePanelBounds();
// 드래그 중 마우스가 iframe 위로 지나가면 부모 document가 mousemove/mouseup을 못 받아
// 드래그가 안 풀리므로, 드래그하는 동안은 iframe이 마우스 이벤트를 가로채지 않게 한다.
const iframe = document.getElementById('code-claude-iframe');
if (iframe) iframe.style.pointerEvents = 'none';
const onMove = e2 => {
const dy = startY - e2.clientY;
panel.style.height = Math.max(min, Math.min(max, startH + dy)) + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
drag.style.background = '';
if (iframe) iframe.style.pointerEvents = '';
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
function openArduinoEmulator() { function openArduinoEmulator() {
const file = codeFiles.find(f => f.id === activeCodeFileId); const file = codeFiles.find(f => f.id === activeCodeFileId);
const btn = document.getElementById('code-emu-btn'); const btn = document.getElementById('code-emu-btn');
@@ -1090,14 +1141,20 @@ document.addEventListener('DOMContentLoaded', function() {
const panel = document.getElementById('code-preview-panel'); const panel = document.getElementById('code-preview-panel');
if (!drag || !panel) return; if (!drag || !panel) return;
let startY=0, startH=0; let startY=0, startH=0;
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(96,165,250,.4)');
drag.addEventListener('mousedown', e => { drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault(); startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const iframe = document.getElementById('code-preview-iframe');
if (iframe) iframe.style.pointerEvents = 'none';
const onMove = e2 => { const onMove = e2 => {
const dy = startY - e2.clientY; const dy = startY - e2.clientY;
panel.style.height = Math.max(100, Math.min(window.innerHeight*.7, startH+dy)) + 'px'; panel.style.height = Math.max(100, Math.min(window.innerHeight*.7, startH+dy)) + 'px';
}; };
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; }; const onUp = () => {
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(96,165,250,.4)'); document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp);
drag.style.background='';
if (iframe) iframe.style.pointerEvents = '';
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
}); });
}); });
@@ -6466,11 +6523,17 @@ document.addEventListener('DOMContentLoaded', function() {
const panel = document.getElementById('code-emu-panel'); const panel = document.getElementById('code-emu-panel');
if (!drag || !panel) return; if (!drag || !panel) return;
let startY=0, startH=0; let startY=0, startH=0;
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
drag.addEventListener('mousedown', e => { drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault(); startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const iframe = document.getElementById('code-emu-iframe');
if (iframe) iframe.style.pointerEvents = 'none';
const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(120,Math.min(window.innerHeight*.75,startH+dy))+'px'; }; const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(120,Math.min(window.innerHeight*.75,startH+dy))+'px'; };
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; }; const onUp = () => {
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)'); document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp);
drag.style.background='';
if (iframe) iframe.style.pointerEvents = '';
};
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
}); });
}); });
+129
View File
@@ -14,6 +14,7 @@ pygame 등 GUI 라이브러리도 정상 동작합니다.
import asyncio import asyncio
import io import io
import json import json
import locale
import os import os
import sys import sys
import tempfile import tempfile
@@ -364,6 +365,45 @@ class Runner:
pass pass
# ── 파일 작업(Read/Write/Edit/Glob/Grep)용 헬퍼 ──────────────────────────
# claude-app의 Bash 경로와 동일한 root를 사용해 파일 작업을 처리한다.
# 이렇게 하면 Bash cwd와 Read/Write 대상 폴더가 항상 같은 곳을 가리킨다.
FS_IGNORE_DIRS = {'node_modules', '.git', 'dist', 'build', '__pycache__', 'venv', '.venv', '.next', 'target', '.cache', 'out'}
def _pick_folder():
"""네이티브 OS 폴더 선택 창을 열고 절대경로를 반환한다 (동기, executor에서 실행)."""
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True)
try:
path = filedialog.askdirectory(title='claude-app 프로젝트 폴더 선택')
finally:
root.destroy()
return path or None
def _safe_join(root_path: str, rel_path: str) -> str:
root_abs = os.path.abspath(root_path)
full = os.path.abspath(os.path.join(root_abs, rel_path))
if full != root_abs and not full.startswith(root_abs + os.sep):
raise ValueError('경로가 작업 폴더를 벗어났습니다')
return full
def _fs_walk(root_path: str):
root_abs = os.path.abspath(root_path)
results = []
for dirpath, dirnames, filenames in os.walk(root_abs):
dirnames[:] = [d for d in dirnames if d not in FS_IGNORE_DIRS and not d.startswith('.')]
for fn in filenames:
rel = os.path.relpath(os.path.join(dirpath, fn), root_abs).replace(os.sep, '/')
results.append(rel)
return results
async def handle(ws): async def handle(ws):
shells = {} # shell_id -> ShellSession shells = {} # shell_id -> ShellSession
runner = Runner() runner = Runner()
@@ -477,6 +517,95 @@ async def handle(ws):
print('[강제 종료]') print('[강제 종료]')
await ws.send(json.dumps({'type': 'killed'})) await ws.send(json.dumps({'type': 'killed'}))
elif t == 'exec':
# 단발성 명령 실행 (에디터의 Bash 툴 / claude-app 에이전트 루프용).
# 인터랙티브 셸(ShellSession)과 무관하게 reqId로 결과를 상관시킨다.
req_id = msg.get('reqId')
command = msg.get('command', '')
cwd = msg.get('cwd') or None
# Windows(cmd.exe)는 시스템 로컬 인코딩(CP949 등)을 사용하므로
# UTF-8 디코딩 실패 시 시스템 기본 인코딩으로 폴백한다.
_refresh_env()
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd if cwd and os.path.isdir(cwd) else None,
env={**os.environ},
)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
# Try UTF-8 first; on Windows fall back to system locale encoding
try:
text = out.decode('utf-8')
except UnicodeDecodeError:
text = out.decode(locale.getpreferredencoding(False), errors='replace')
if len(text) > 200_000:
text = text[:200_000] + '\n…(출력 잘림)'
await ws.send(json.dumps({
'type': 'exec_result', 'reqId': req_id,
'output': text, 'error': proc.returncode != 0,
}))
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
await ws.send(json.dumps({
'type': 'exec_result', 'reqId': req_id,
'output': '오류: 명령 실행 시간 초과 (30s)', 'error': True,
}))
except Exception as e:
await ws.send(json.dumps({
'type': 'exec_result', 'reqId': req_id,
'output': f'오류: {e}', 'error': True,
}))
elif t == 'browse_folder':
req_id = msg.get('reqId')
try:
path = await asyncio.get_event_loop().run_in_executor(None, _pick_folder)
await ws.send(json.dumps({'type': 'browse_result', 'reqId': req_id, 'path': path}))
except Exception as e:
await ws.send(json.dumps({
'type': 'browse_result', 'reqId': req_id, 'path': None,
'error': f'폴더 선택 창을 열 수 없습니다: {e}',
}))
elif t == 'fs_list':
req_id = msg.get('reqId')
root_path = msg.get('cwd') or ''
try:
paths = _fs_walk(root_path) if root_path and os.path.isdir(root_path) else []
await ws.send(json.dumps({'type': 'fs_list_result', 'reqId': req_id, 'paths': paths}))
except Exception as e:
await ws.send(json.dumps({'type': 'fs_list_result', 'reqId': req_id, 'paths': [], 'error': str(e)}))
elif t == 'fs_read':
req_id = msg.get('reqId')
root_path = msg.get('cwd') or ''
rel = msg.get('path') or ''
try:
full = _safe_join(root_path, rel)
with open(full, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
await ws.send(json.dumps({'type': 'fs_read_result', 'reqId': req_id, 'content': content}))
except Exception as e:
await ws.send(json.dumps({'type': 'fs_read_result', 'reqId': req_id, 'content': None, 'error': str(e)}))
elif t == 'fs_write':
req_id = msg.get('reqId')
root_path = msg.get('cwd') or ''
rel = msg.get('path') or ''
content = msg.get('content', '')
try:
full = _safe_join(root_path, rel)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, 'w', encoding='utf-8') as f:
f.write(content)
await ws.send(json.dumps({'type': 'fs_write_result', 'reqId': req_id, 'ok': True}))
except Exception as e:
await ws.send(json.dumps({'type': 'fs_write_result', 'reqId': req_id, 'ok': False, 'error': str(e)}))
elif t == 'ping': elif t == 'ping':
await ws.send(json.dumps({'type': 'pong'})) await ws.send(json.dumps({'type': 'pong'}))
+1
View File
@@ -55,6 +55,7 @@
<div class="mode-toggle" style="margin-left:8px"> <div class="mode-toggle" style="margin-left:8px">
<button class="mode-btn" onclick="window.open('/code.html','_blank')" title="코드 에디터">💻 코드</button> <button class="mode-btn" onclick="window.open('/code.html','_blank')" title="코드 에디터">💻 코드</button>
<button class="mode-btn" onclick="window.open('/claude-app.html','_blank')" title="Claude App (Ollama/LM Studio 코딩 에이전트)">🤖 클로드</button>
<button class="mode-btn" onclick="window.open('/pptx-wizard.html','_blank')" title="슬라이드 제작">📊 슬라이드</button> <button class="mode-btn" onclick="window.open('/pptx-wizard.html','_blank')" title="슬라이드 제작">📊 슬라이드</button>
<button class="mode-btn" onclick="window.open('/language-app.html','_blank')" title="우즈베크어 학습">🇺🇿 언어</button> <button class="mode-btn" onclick="window.open('/language-app.html','_blank')" title="우즈베크어 학습">🇺🇿 언어</button>
<button class="mode-btn" onclick="window.open('/music-app.html','_blank')" title="음악 스튜디오">🎵 음악</button> <button class="mode-btn" onclick="window.open('/music-app.html','_blank')" title="음악 스튜디오">🎵 음악</button>
+1 -1
View File
@@ -2453,7 +2453,7 @@
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
grid-template-areas: grid-template-areas:
"editor vresize ai" "editor vresize ai"
"term term term"; "term term ai";
} }
.code-tabs-bar { .code-tabs-bar {
display: flex; display: flex;