Add chat inline terminal panel (xterm.js + PTY)

채팅 입력창 옆 ⌨ 버튼으로 슬라이드업 터미널 패널 토글.
- 상단 드래그 핸들로 높이 조절 (120px~70vh)
- + 버튼으로 bash 탭 추가, × 로 탭 닫기
- 기존 xterm.js / PTY WebSocket 인프라 재사용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-21 23:44:14 +09:00
co-authored by Claude Sonnet 4.6
parent 6df1bcf5b3
commit 9fca14e8db
+173
View File
@@ -2377,6 +2377,51 @@
flex: 1;
}
.midi-router-status { font-size: 11px; color: var(--muted); }
/* ── Chat inline terminal ─────────────────────────────────────── */
#chat-terminal-panel {
display: none;
flex-direction: column;
background: #0c0c0c;
border-top: 2px solid #222;
flex-shrink: 0;
position: relative;
height: 280px;
min-height: 120px;
max-height: 70vh;
}
#chat-term-resize-handle {
position: absolute;
top: 0; left: 0; right: 0;
height: 6px;
cursor: ns-resize;
z-index: 10;
transition: background 0.15s;
}
#chat-term-resize-handle:hover { background: rgba(59,120,255,0.35); }
#chat-terminal-panel .ct-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 8px 3px 4px;
background: #161616;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
min-height: 28px;
}
#chat-shell-tabs { display: flex; align-items: stretch; overflow-x: auto; flex: 1; }
.ct-tab {
display: flex; align-items: center; gap: 5px;
padding: 2px 10px; cursor: pointer;
font-family: 'IBM Plex Mono', 'Courier New', monospace; font-size: 12px;
color: #767676; background: transparent;
border-top: 2px solid transparent;
white-space: nowrap; user-select: none; flex-shrink: 0;
}
.ct-tab.active { color: #cccccc; background: #0c0c0c; border-top-color: #3b78ff; }
.ct-tab-close { font-size: 13px; color: #555; margin-left: 3px; line-height: 1; }
.ct-tab-close:hover { color: #e74856; }
#chat-term-container { position: relative; flex: 1; min-height: 0; overflow: hidden; }
#chat-term-btn.active { color: var(--brand) !important; }
</style>
<script src="https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js"></script>
<script src="https://cdn.jsdelivr.net/combine/npm/tone@14,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.6.0" defer></script>
@@ -2503,6 +2548,19 @@
</div>
</div>
<!-- Chat inline terminal -->
<div id="chat-terminal-panel">
<div id="chat-term-resize-handle"></div>
<div class="ct-bar">
<div id="chat-shell-tabs"></div>
<div style="display:flex;gap:2px;flex-shrink:0">
<button onclick="addChatTermShell()" title="새 터미널" style="background:none;border:none;cursor:pointer;color:#767676;font-size:18px;padding:0 6px;line-height:1;font-family:monospace">+</button>
<button onclick="toggleChatTerminal()" title="닫기" style="background:none;border:none;cursor:pointer;color:#767676;font-size:14px;padding:0 6px;line-height:1">&times;</button>
</div>
</div>
<div id="chat-term-container"></div>
</div>
<div class="chat-input-area">
<div class="agent-toggle">
<span class="agent-toggle-label">모드:</span>
@@ -2585,6 +2643,7 @@
<svg id="send-icon" width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="22 2 15 22 11 13 2 9"/></svg>
<svg id="stop-icon" width="13" height="13" viewBox="0 0 14 14" fill="currentColor" style="display:none"><rect x="2" y="2" width="10" height="10" rx="1.5"/></svg>
</button>
<button class="send-btn" id="chat-term-btn" onclick="toggleChatTerminal()" title="터미널" style="font-size:15px;width:44px;height:44px;flex-shrink:0">⌨</button>
</div>
<div class="chat-hint">
<span id="chat-model-name">모델</span>과 대화 중 — <span id="chat-provider-name">CherryClaw</span> 경유.
@@ -11049,6 +11108,120 @@ function closeTermShell(id, ev) {
}
}
// ── Chat inline terminal ─────────────────────────────────────────────────────
let chatTermShells = [];
let activeChatTermShellId = null;
let chatTermOpen = false;
let chatTermHeight = 280;
function toggleChatTerminal() {
const panel = document.getElementById('chat-terminal-panel');
const btn = document.getElementById('chat-term-btn');
if (!panel) return;
chatTermOpen = !chatTermOpen;
if (chatTermOpen) {
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
if (!chatTermShells.length) {
addChatTermShell();
} else {
setTimeout(() => {
const s = chatTermShells.find(s => s.id === activeChatTermShellId);
if (s && s.fitAddon) { s.fitAddon.fit(); }
if (s && s.term) s.term.focus();
}, 50);
}
} else {
panel.style.display = 'none';
if (btn) btn.classList.remove('active');
}
}
function renderChatShellTabs() {
const bar = document.getElementById('chat-shell-tabs');
if (!bar) return;
bar.innerHTML = chatTermShells.map(s => {
const active = s.id === activeChatTermShellId;
return `<div class="ct-tab${active ? ' active' : ''}" onclick="switchChatTermShell('${s.id}')">` +
`<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6;flex-shrink:0"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>` +
`<span>${escHtml(s.name)}</span>` +
(chatTermShells.length > 1 ? `<span class="ct-tab-close" onclick="closeChatTermShell('${s.id}',event)">&times;</span>` : '') +
`</div>`;
}).join('');
}
function addChatTermShell() {
const id = 'ct_' + Math.random().toString(36).slice(2, 8);
chatTermShells.push({ id, name: 'bash', ptyReady: false, term: null, fitAddon: null });
switchChatTermShell(id);
}
function switchChatTermShell(id) {
const cur = chatTermShells.find(s => s.id === activeChatTermShellId);
if (cur) { const sub = document.getElementById('xterm-' + cur.id); if (sub) sub.style.display = 'none'; }
activeChatTermShellId = id;
const shell = chatTermShells.find(s => s.id === id);
if (!shell) return;
if (shell.term) {
const sub = document.getElementById('xterm-' + shell.id);
if (sub) sub.style.display = '';
if (shell.fitAddon) shell.fitAddon.fit();
shell.term.focus();
} else {
const container = document.getElementById('chat-term-container');
if (container) {
const result = createXtermShell(container, shell.id);
shell.term = result.term;
shell.fitAddon = result.fitAddon;
shell.ptyReady = result.inst.ptyReady;
setTimeout(() => { result.fitAddon.fit(); result.term.focus(); }, 50);
}
}
renderChatShellTabs();
}
function closeChatTermShell(id, ev) {
if (ev) ev.stopPropagation();
const idx = chatTermShells.findIndex(s => s.id === id);
if (idx === -1 || chatTermShells.length <= 1) return;
termWsSend({ type: 'pty_kill', shellId: id });
const shell = chatTermShells[idx];
if (shell.term) shell.term.dispose();
xtermInstances.delete(id);
const sub = document.getElementById('xterm-' + id);
if (sub) sub.remove();
chatTermShells.splice(idx, 1);
if (activeChatTermShellId === id)
switchChatTermShell(chatTermShells[Math.min(idx, chatTermShells.length - 1)].id);
else
renderChatShellTabs();
}
// Resize drag: drag handle (top edge) pulls panel height up/down
document.addEventListener('DOMContentLoaded', function() {
const handle = document.getElementById('chat-term-resize-handle');
if (!handle) return;
let startY = 0, startH = 0;
handle.addEventListener('mousedown', function(e) {
startY = e.clientY; startH = chatTermHeight;
document.addEventListener('mousemove', onTermResizeMove);
document.addEventListener('mouseup', onTermResizeUp);
e.preventDefault();
});
function onTermResizeMove(e) {
const dy = startY - e.clientY;
chatTermHeight = Math.max(120, Math.min(window.innerHeight * 0.7, startH + dy));
const panel = document.getElementById('chat-terminal-panel');
if (panel) panel.style.height = chatTermHeight + 'px';
const s = chatTermShells.find(s => s.id === activeChatTermShellId);
if (s && s.fitAddon) s.fitAddon.fit();
}
function onTermResizeUp() {
document.removeEventListener('mousemove', onTermResizeMove);
document.removeEventListener('mouseup', onTermResizeUp);
}
});
// Fallback: HTTP POST for canvasRun and non-PTY scenarios
async function termExec(command) {
const shell = getActiveShell();