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:
@@ -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">×</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)">×</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();
|
||||
|
||||
Reference in New Issue
Block a user