diff --git a/web-ui/claude-app.html b/web-ui/claude-app.html
new file mode 100644
index 0000000..af97a69
--- /dev/null
+++ b/web-ui/claude-app.html
@@ -0,0 +1,209 @@
+
+
+
+
diff --git a/web-ui/code.js b/web-ui/code.js
index a643ea3..07e68bf 100644
--- a/web-ui/code.js
+++ b/web-ui/code.js
@@ -904,7 +904,7 @@ function toggleCodePreview() {
const saved = localStorage.getItem('code_preview_url');
if (saved && urlInput) urlInput.value = saved;
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 {
panel.style.display = 'none';
if (btn) btn.classList.remove('active');
@@ -950,6 +950,57 @@ function emuPanelNewTab() {
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() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
const btn = document.getElementById('code-emu-btn');
@@ -1090,14 +1141,20 @@ document.addEventListener('DOMContentLoaded', function() {
const panel = document.getElementById('code-preview-panel');
if (!drag || !panel) return;
let startY=0, startH=0;
+ drag.addEventListener('mouseover', ()=>drag.style.background='rgba(96,165,250,.4)');
drag.addEventListener('mousedown', e => {
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 dy = startY - e2.clientY;
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=''; };
- drag.addEventListener('mouseover', ()=>drag.style.background='rgba(96,165,250,.4)');
+ 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);
});
});
@@ -6466,11 +6523,17 @@ document.addEventListener('DOMContentLoaded', function() {
const panel = document.getElementById('code-emu-panel');
if (!drag || !panel) return;
let startY=0, startH=0;
+ drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
drag.addEventListener('mousedown', e => {
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 onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
- drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
+ 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);
});
});
diff --git a/web-ui/companion.py b/web-ui/companion.py
index aff2391..38dc998 100644
--- a/web-ui/companion.py
+++ b/web-ui/companion.py
@@ -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'}))
diff --git a/web-ui/index.html b/web-ui/index.html
index 8d4f5d2..b7d6acc 100644
--- a/web-ui/index.html
+++ b/web-ui/index.html
@@ -55,6 +55,7 @@
+
diff --git a/web-ui/styles.css b/web-ui/styles.css
index 1d08683..223a088 100644
--- a/web-ui/styles.css
+++ b/web-ui/styles.css
@@ -2453,7 +2453,7 @@
grid-template-rows: 1fr auto;
grid-template-areas:
"editor vresize ai"
- "term term term";
+ "term term ai";
}
.code-tabs-bar {
display: flex;