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
+129
View File
@@ -14,6 +14,7 @@ pygame 등 GUI 라이브러리도 정상 동작합니다.
import asyncio
import io
import json
import locale
import os
import sys
import tempfile
@@ -364,6 +365,45 @@ class Runner:
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):
shells = {} # shell_id -> ShellSession
runner = Runner()
@@ -477,6 +517,95 @@ async def handle(ws):
print('[강제 종료]')
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':
await ws.send(json.dumps({'type': 'pong'}))