# ==================== 后端路由 ====================
@app.get("/")
async def index():
- return HTMLResponse(load_html())
+ return HTMLResponse(load_html(), headers={"Cache-Control": "no-store"})
@app.post("/api/login")
# 前端把粘贴的截图 POST 到这里,后端存盘后返回绝对路径
# 前端再把 <image_local_path>路径</image_local_path> 当作输入发到 CLI
PASTES_DIR = Path(__file__).resolve().parent.parent / "data" / "pastes"
-ALLOWED_IMAGE_MIME = {"image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/bmp"}
-MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
+MAX_UPLOAD_SIZE = 5 * 1024 * 1024 # 5MB(粘贴栏上传上限:图片/视频/zip/任意格式)
_MIME_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg",
"image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp"}
except Exception:
return JSONResponse({"error": "invalid json body"}, status_code=400)
mime = (body.get("mime") or "").lower()
+ name = body.get("name") or ""
b64 = body.get("data") or ""
- if mime not in ALLOWED_IMAGE_MIME:
- return JSONResponse({"error": f"unsupported mime: {mime}"}, status_code=400)
import base64
try:
raw = base64.b64decode(b64)
except Exception:
return JSONResponse({"error": "invalid base64 data"}, status_code=400)
if len(raw) == 0:
- return JSONResponse({"error": "empty image"}, status_code=400)
- if len(raw) > MAX_IMAGE_SIZE:
- return JSONResponse({"error": f"image too large (max {MAX_IMAGE_SIZE // 1024 // 1024}MB)"}, status_code=400)
- ext = _MIME_EXT.get(mime, "png")
+ return JSONResponse({"error": "empty file"}, status_code=400)
+ if len(raw) > MAX_UPLOAD_SIZE:
+ return JSONResponse({"error": f"file too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)"}, status_code=400)
+ # 扩展名:优先用原文件名,其次按 mime 推断,否则 bin
+ ext = ""
+ if name and "." in name:
+ ext = name.rsplit(".", 1)[1].lower()[:12]
+ if not ext:
+ ext = _MIME_EXT.get(mime, "bin")
PASTES_DIR.mkdir(parents=True, exist_ok=True)
fname = f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_{secrets.token_hex(4)}.{ext}"
fpath = PASTES_DIR / fname
fpath.write_bytes(raw)
- logger.info(f"图片上传: {fpath} ({len(raw)} bytes, {mime})")
- return {"path": str(fpath)}
+ logger.info(f"文件上传: {fpath} ({len(raw)} bytes, {mime})")
+ return {"path": str(fpath), "name": name or fname}
+
+
+# 已上传文件:列出 / 删除(data/pastes/ 下,供前端“已上传文件”面板使用)
+_IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
+_VIDEO_EXT = {".mp4", ".mov", ".webm", ".mkv", ".avi"}
+_ZIP_EXT = {".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"}
+
+
+@app.get("/api/uploads")
+async def list_uploads(request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ try:
+ files = []
+ if PASTES_DIR.exists():
+ for p in PASTES_DIR.iterdir():
+ if p.is_file():
+ ext = p.suffix.lower()
+ kind = "other"
+ if ext in _IMG_EXT:
+ kind = "image"
+ elif ext in _VIDEO_EXT:
+ kind = "video"
+ elif ext in _ZIP_EXT:
+ kind = "zip"
+ st = p.stat()
+ files.append({
+ "name": p.name, "path": str(p),
+ "size": st.st_size, "mtime": int(st.st_mtime), "kind": kind,
+ })
+ files.sort(key=lambda x: x["mtime"], reverse=True)
+ return {"files": files}
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+@app.delete("/api/uploads/{name}")
+async def delete_upload(name: str, request: Request):
+ if not _auth_ok(request):
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+ # 防路径穿越:只允许纯文件名
+ if not name or "/" in name or "\\" in name or name == ".." or name.startswith("."):
+ return JSONResponse({"error": "invalid name"}, status_code=400)
+ try:
+ target = (PASTES_DIR / name).resolve()
+ if target.parent != PASTES_DIR.resolve():
+ return JSONResponse({"error": "invalid path"}, status_code=400)
+ if not target.exists() or not target.is_file():
+ return JSONResponse({"error": "not found"}, status_code=404)
+ target.unlink()
+ logger.info(f"删除上传文件: {target}")
+ return {"ok": True}
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
# ==================== 自定义 Skill 管理(全局 ~/.codebuddy/skills) ====================
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Codebuddy Web Console</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 15px; height: 100%; }
-body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; overflow: hidden; background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
+body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; height: 100dvh; overflow: hidden; overscroll-behavior: none; background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
/* 登录页 */
#login-page {
/* 顶栏 */
.top-bar {
display: flex; justify-content: space-between; align-items: center;
- padding: 8px 16px; background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.1);
+ padding: calc(8px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) 8px calc(16px + env(safe-area-inset-left));
+ background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.1);
font-size: 15px; flex-shrink: 0; gap: 12px;
}
.top-bar .left { display: flex; align-items: center; gap: 10px; min-width: 0; }
.top-bar .model-select option { background: #1a1a2e; color: #e0e0e0; }
/* 终端页布局 */
-#terminal-page { display: none; height: 100%; flex-direction: column; }
-#terminal-container { flex: 1; overflow: hidden; padding: 4px; min-height: 0; }
+#terminal-page { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; height: 100%; height: 100dvh; flex-direction: column; }
+#terminal-container { flex: 1; overflow: hidden; padding: 4px 4px calc(4px + env(safe-area-inset-bottom)); min-height: 0; }
+/* 回到底部按钮:上滑离开底部时浮现,避开安全区域 */
+#jump-bottom {
+ display: none; position: fixed; right: calc(16px + env(safe-area-inset-right));
+ bottom: calc(16px + env(safe-area-inset-bottom)); z-index: 50;
+ padding: 8px 14px; border-radius: 20px; border: 1px solid rgba(78,204,163,0.5);
+ background: rgba(26,26,46,0.95); color: #4ecca3; font-size: 13px; cursor: pointer;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.4);
+}
/* 通用按钮样式(粘贴 modal 复用) */
.icon-btn {
</div>
</div>
<div id="terminal-container"></div>
+ <button id="jump-bottom" title="回到底部(最新输出)">↓ 回到底部</button>
<!-- 粘贴 modal:支持文字/图片粘贴,发送内容到 xterm 输入行(不自动提交) -->
<div id="paste-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:100; align-items:center; justify-content:center;">
<div id="paste-dialog" style="background:#1a1a2e; border:1px solid #4ecca3; border-radius:12px; padding:16px; width:560px; max-width:90vw;">
</div>
</div>
<div style="font-size:11px; color:#666; margin-top:6px; text-align:right;">Ctrl+Enter 发送到终端输入行(不自动提交,需自行回车)</div>
- <input type="file" id="paste-file-input" accept="image/*" style="display:none;">
+ <div style="margin-top:12px; border-top:1px solid rgba(255,255,255,0.1); padding-top:10px;">
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
+ <span style="color:#74c0fc; font-size:13px;">📂 已上传文件</span>
+ <button class="icon-btn" id="uploads-refresh" title="刷新列表">⟳</button>
+ </div>
+ <div id="uploads-list" style="display:flex; flex-direction:column; gap:6px; max-height:160px; overflow:auto;"></div>
+ </div>
+ <input type="file" id="paste-file-input" accept="*/*" style="display:none;">
</div>
</div>
<!-- 技能管理 modal(全局,首页与终端页共用) -->
let fitAddon = null;
let pid = 0;
let currentTask = null;
+ let followBottom = true; // 终端是否随新输出自动贴底
+ const jumpBtn = document.getElementById('jump-bottom');
+
+ // iPad/iOS 关键修复:把页面壳精确钉到“可视视口”,避开顶部地址栏与软键盘,
+ // 否则 position:fixed;top:0 会被钉到布局视口顶部(地址栏后面),标题栏被遮且整页可滚。
+ function layoutShell() {
+ const vv = window.visualViewport;
+ if (!vv) return;
+ const s = document.body.style;
+ s.position = 'fixed';
+ s.top = vv.offsetTop + 'px';
+ s.left = vv.offsetLeft + 'px';
+ s.width = vv.width + 'px';
+ s.height = vv.height + 'px';
+ if (fitAddon && term) { try { fitAddon.fit(); } catch (e) {} if (followBottom) term.scrollToBottom(); }
+ }
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener('resize', layoutShell);
+ window.visualViewport.addEventListener('scroll', layoutShell);
+ }
+ window.addEventListener('resize', layoutShell);
+ window.addEventListener('orientationchange', () => setTimeout(layoutShell, 300));
+ layoutShell();
const loginPage = document.getElementById('login-page');
const pickerPage = document.getElementById('picker-page');
}
document.getElementById('task-title').textContent = '🗂 ' + currentTask.name;
document.getElementById('task-cwd').textContent = currentTask.cwd;
+ followBottom = true;
+ if (jumpBtn) jumpBtn.style.display = 'none';
updateTitle();
show('terminal');
initTerminal();
const pasteFileInput = document.getElementById('paste-file-input');
const pasteImgChips = document.getElementById('paste-img-chips');
// 待发送的图片列表:[{path, name}]
- let pendingImages = [];
-
- // 上传图片到后端,返回 {path, name} 或抛错
- async function uploadImage(blob) {
+ let pendingFiles = [];
+
+ // 上传任意文件到后端,返回 {path, name} 或抛错(上限 5MB)
+ async function uploadFile(blob) {
+ const MAX = 5 * 1024 * 1024;
+ if (blob.size > MAX) {
+ const mb = Math.round(blob.size / 1024 / 1024 * 10) / 10;
+ throw new Error('文件过大(' + mb + 'MB),上限 5MB');
+ }
const sizeKB = Math.max(1, Math.round(blob.size / 1024));
- const name = blob.name || ('æ\88ªå\9b¾_' + Date.now() + '.' + (blob.type.split('/')[1] || 'png'));
- term.writeln('\x1b[33m⏳ 上传图片 ' + name + ' (' + sizeKB + 'KB)...\x1b[0m');
+ const name = blob.name || ('æ\96\87ä»¶_' + Date.now() + '.' + ((blob.type.split('/')[1]) || 'bin'));
+ term.writeln('\x1b[33m⏳ 上传文件 ' + name + ' (' + sizeKB + 'KB)...\x1b[0m');
const reader = new FileReader();
const b64 = await new Promise((resolve, reject) => {
reader.onload = () => { const i = reader.result.indexOf(','); resolve(i >= 0 ? reader.result.slice(i + 1) : ''); };
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
- if (!b64) throw new Error('图片读取失败');
+ if (!b64) throw new Error('文件读取失败');
const r = await fetch(API + '/api/upload-image', {
method: 'POST',
headers: Object.assign({}, authHeaders(), { 'Content-Type': 'application/json' }),
- body: JSON.stringify({ mime: blob.type, data: b64 })
+ body: JSON.stringify({ mime: blob.type, name: name, data: b64 })
});
if (!r.ok) {
let msg = 'HTTP ' + r.status;
const data = await r.json();
if (data.error) throw new Error(data.error);
term.writeln('\x1b[32m✓ ' + name + ' 已上传\x1b[0m');
- return { path: data.path, name: name };
+ return { path: data.path, name: name, mime: blob.type };
}
- // 渲æ\9f\93å\9b¾ç\89\87 chip å\88\97表
- function renderPasteImgChips() {
- if (!pendingImages.length) { pasteImgChips.style.display = 'none'; pasteImgChips.innerHTML = ''; return; }
+ // 渲æ\9f\93å¾\85å\8f\91é\80\81æ\96\87ä»¶ chip å\88\97表ï¼\88å\9b¾ç\89\87/è§\86é¢\91/zip/å\85¶ä»\96ï¼\89
+ function renderPasteChips() {
+ if (!pendingFiles.length) { pasteImgChips.style.display = 'none'; pasteImgChips.innerHTML = ''; return; }
pasteImgChips.style.display = 'flex';
- pasteImgChips.innerHTML = pendingImages.map((img, i) =>
- '<span class="img-chip">🖼 ' + img.name + ' <span class="remove" data-i="' + i + '">✕</span></span>'
- ).join('');
+ pasteImgChips.innerHTML = pendingFiles.map((f, i) => {
+ let icon = '📄'; const m = (f.mime || ''); const n = (f.name || '').toLowerCase();
+ if (m.startsWith('image/')) icon = '🖼';
+ else if (m.startsWith('video/')) icon = '🎬';
+ else if (m === 'application/zip' || m === 'application/x-zip-compressed' || n.endsWith('.zip') || n.endsWith('.tar') || n.endsWith('.gz')) icon = '📦';
+ return '<span class="img-chip">' + icon + ' ' + f.name + ' <span class="remove" data-i="' + i + '">✕</span></span>';
+ }).join('');
pasteImgChips.querySelectorAll('.remove').forEach(el => {
- el.onclick = () => { pendingImages.splice(parseInt(el.dataset.i), 1); renderPasteImgChips(); };
+ el.onclick = () => { pendingFiles.splice(parseInt(el.dataset.i), 1); renderPasteChips(); };
});
}
// 打开/关闭 modal
function openPasteModal() {
pasteInput.value = '';
- pendingImages = [];
- renderPasteImgChips();
+ pendingFiles = [];
+ renderPasteChips();
pasteModal.style.display = 'flex';
+ refreshUploadsList();
setTimeout(() => pasteInput.focus(), 0);
}
function closePasteModal() {
if (e.target === pasteModal) closePasteModal();
});
- // textarea 的 paste 事件:文字直接插入,图片上传后加 chip
+ // textarea 的 paste 事件:文字直接插入,文件(图片/视频/zip/任意)上传后加 chip
pasteInput.addEventListener('paste', async (e) => {
if (!e.clipboardData) return;
- let imgBlob = null;
+ let fileBlob = null;
if (e.clipboardData.items) {
for (const item of e.clipboardData.items) {
- if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
- imgBlob = item.getAsFile(); if (imgBlob) break;
- }
+ if (item.kind === 'file') { fileBlob = item.getAsFile(); if (fileBlob) break; }
}
}
- if (!imgBlob && e.clipboardData.files && e.clipboardData.files.length) {
- for (const f of e.clipboardData.files) {
- if (f.type && f.type.startsWith('image/')) { imgBlob = f; break; }
- }
+ if (!fileBlob && e.clipboardData.files && e.clipboardData.files.length) {
+ fileBlob = e.clipboardData.files[0];
}
- if (imgBlob) {
+ if (fileBlob) {
e.preventDefault();
- try { const img = await uploadImage(imgBlob); pendingImages.push(img); renderPasteImgChips(); }
- catch (err) { term.writeln('\x1b[31m✗ 图片上传失败: ' + err.message + '\x1b[0m'); }
+ try { const f = await uploadFile(fileBlob); pendingFiles.push(f); renderPasteChips(); }
+ catch (err) { term.writeln('\x1b[31m✗ 文件上传失败: ' + err.message + '\x1b[0m'); }
return;
}
// 文字:浏览器默认插入
});
- // 拖拽图片到 modal
+ // 拖拽文件到 modal(图片/视频/zip/任意)
pasteInput.addEventListener('dragover', (e) => { e.preventDefault(); });
pasteInput.addEventListener('drop', async (e) => {
if (!e.dataTransfer || !e.dataTransfer.files) return;
- const files = Array.from(e.dataTransfer.files).filter(f => f.type && f.type.startsWith('image/'));
+ const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
e.preventDefault();
for (const f of files) {
- try { const img = await uploadImage(f); pendingImages.push(img); }
+ try { const uf = await uploadFile(f); pendingFiles.push(uf); }
catch (err) { term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m'); }
}
- renderPasteImgChips();
+ renderPasteChips();
});
- // 📎 选择图片
+ // 📎 选择文件(图片/视频/zip/任意)
pasteAttach.onclick = () => pasteFileInput.click();
pasteFileInput.onchange = async () => {
if (!pasteFileInput.files || !pasteFileInput.files.length) return;
for (const f of pasteFileInput.files) {
- if (!f.type || !f.type.startsWith('image/')) continue;
- try { const img = await uploadImage(f); pendingImages.push(img); }
+ try { const uf = await uploadFile(f); pendingFiles.push(uf); }
catch (err) { term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m'); }
}
pasteFileInput.value = '';
- renderPasteImgChips();
+ renderPasteChips();
+ refreshUploadsList();
};
+ // ========== 已上传文件列表(列出 + 删除) ==========
+ const uploadsList = document.getElementById('uploads-list');
+ function formatSize(b) {
+ if (b < 1024) return b + 'B';
+ if (b < 1024 * 1024) return (b / 1024).toFixed(1) + 'KB';
+ return (b / 1024 / 1024).toFixed(1) + 'MB';
+ }
+ async function refreshUploadsList() {
+ if (!uploadsList) return;
+ try {
+ const res = await fetch(API + '/api/uploads', { headers: authHeaders() });
+ if (!res.ok) { uploadsList.innerHTML = '<div style="color:#888; font-size:12px;">加载失败</div>'; return; }
+ const data = await res.json();
+ const files = data.files || [];
+ if (!files.length) { uploadsList.innerHTML = '<div style="color:#888; font-size:12px;">暂无已上传文件</div>'; return; }
+ uploadsList.innerHTML = files.map(f => {
+ let icon = '📄'; const n = (f.name || '').toLowerCase();
+ if (f.kind === 'image') icon = '🖼';
+ else if (f.kind === 'video') icon = '🎬';
+ else if (f.kind === 'zip') icon = '📦';
+ return '<div style="display:flex;align-items:center;gap:8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:6px 8px;font-size:13px;">'
+ + '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + icon + ' ' + escHtml(f.name) + ' <span style="color:#888;">(' + formatSize(f.size) + ')</span></span>'
+ + '<span class="up-del" style="cursor:pointer;color:#ff6b6b;flex-shrink:0;">✕</span>'
+ + '</div>';
+ }).join('');
+ uploadsList.querySelectorAll('.up-del').forEach((el, i) => {
+ el.onclick = () => deleteUpload(files[i].name);
+ });
+ } catch (e) {
+ uploadsList.innerHTML = '<div style="color:#888; font-size:12px;">加载失败</div>';
+ }
+ }
+ async function deleteUpload(name) {
+ if (!confirm('确定删除文件 ' + name + '?')) return;
+ try {
+ const res = await fetch(API + '/api/uploads/' + encodeURIComponent(name), { method: 'DELETE', headers: authHeaders() });
+ const d = await res.json().catch(() => ({}));
+ if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status));
+ term.writeln('\x1b[32m✓ 已删除 ' + name + '\x1b[0m');
+ refreshUploadsList();
+ } catch (e) {
+ term.writeln('\x1b[31m✗ 删除失败: ' + e.message + '\x1b[0m');
+ alert('删除失败: ' + e.message);
+ }
+ }
+ const uploadsRefresh = document.getElementById('uploads-refresh');
+ if (uploadsRefresh) uploadsRefresh.onclick = refreshUploadsList;
+
// ========== 技能管理 ==========
const skillsModal = document.getElementById('skills-modal');
const skillsList = document.getElementById('skills-list');
// ✕ 清空
pasteClear.onclick = () => {
pasteInput.value = '';
- pendingImages = [];
- renderPasteImgChips();
+ pendingFiles = [];
+ renderPasteChips();
pasteInput.focus();
};
if (!ws || ws.readyState !== WebSocket.OPEN) { term.writeln('\x1b[31m✗ 未连接,无法发送\x1b[0m'); return; }
const text = pasteInput.value;
let parts = [];
- for (const img of pendingImages) {
- parts.push('<image_local_path>' + img.path + '</image_local_path>');
+ for (const f of pendingFiles) {
+ // 图片用 codebuddy 识别的标记;其他文件(视频/zip/任意)发送本地路径,供命令行引用
+ if ((f.mime || '').startsWith('image/')) {
+ parts.push('<image_local_path>' + f.path + '</image_local_path>');
+ } else {
+ parts.push(f.path);
+ }
}
if (text) parts.push(text);
if (!parts.length) return;
termContainer.addEventListener('click', focusTerm);
termContainer.addEventListener('touchstart', focusTerm, { passive: true });
+ // 自动贴底:根据滚动位置判断是否停在底部,决定是否跟随新输出
+ const updateFollow = () => {
+ const buf = term.buffer.active;
+ const atBottom = buf.viewportY >= buf.baseY - term.rows + 1;
+ followBottom = atBottom;
+ jumpBtn.style.display = atBottom ? 'none' : 'block';
+ };
+ term.onScroll(updateFollow);
+ jumpBtn.onclick = () => { term.scrollToBottom(); followBottom = true; jumpBtn.style.display = 'none'; };
+
+ // iPad 软键盘/工具栏收起时:重算终端尺寸并贴底,避免被遮挡
+ if (window.visualViewport) {
+ const applyVVHeight = () => { terminalPage.style.height = window.visualViewport.height + 'px'; };
+ const syncVV = () => {
+ applyVVHeight();
+ if (fitAddon) fitAddon.fit();
+ if (followBottom) term.scrollToBottom();
+ };
+ window.visualViewport.addEventListener('resize', syncVV);
+ window.visualViewport.addEventListener('scroll', applyVVHeight);
+ }
+
// 复制支持:有选区时 Ctrl+C 复制到剪贴板(不发中断信号)
// Ctrl+Shift+C 也复制(xterm 默认行为)
term.attachCustomKeyEventHandler((e) => {
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
- if (msg.type === 'output' && msg.data) term.write(msg.data);
+ if (msg.type === 'output' && msg.data) {
+ term.write(msg.data, () => { if (followBottom) term.scrollToBottom(); });
+ }
else if (msg.type === 'pid') pid = msg.pid;
else if (msg.type === 'reattach') { pid = msg.pid || pid; renderReattach(msg); }
else if (msg.type === 'error') { term.writeln('\x1b[31m✗ ' + msg.data + '\x1b[0m'); }