]> acesimba.cloud Git - codebuddy-web.git/commitdiff
粘贴改为右上角弹窗模式(避免发送自动提交问题)
authorCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 08:39:34 +0000 (16:39 +0800)
committerCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 08:39:34 +0000 (16:39 +0800)
变更:
- 移除下方独立输入框,恢复 xterm 原生输入(两条绿线之间)
- 右上角"列表"后加回"📋 粘贴"按钮
- 点击弹出 modal:textarea + 📎 + ✕ + 发送,样式复用豆包式输入框
- modal 支持文字/图片粘贴、图片拖拽、📎 选文件
- Ctrl+Enter 或点发送:内容进 xterm 输入行(不自动提交),modal 自动关闭
- ESC 关闭 modal
- 发送逻辑只发内容字符流,不带 \r,用户自行回车提交

原因: 之前自动提交方案(\n / \r / bracketed paste 包裹)均无法让 codebuddy
CLI 正确触发提交。改为"内容进输入行 + 用户回车"最可靠,符合终端交互本质。

frontend/index.html

index 4a3eac05e7864ba0d48ca51f374f47f1e7652f9a..9f30e5c202387a6cb7c94d740743cd61ea71d585 100644 (file)
@@ -59,29 +59,11 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
 }
 .top-bar .action-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
 
-/* 终端页布局:xterm 输出区 + 独立输入区 */
+/* 终端页布局 */
 #terminal-page { display: none; height: 100vh; flex-direction: column; }
 #terminal-container { flex: 1; overflow: hidden; padding: 4px; min-height: 0; }
 
-/* 输入区(豆包式) */
-#input-bar {
-  flex-shrink: 0; border-top: 1px solid rgba(255,255,255,0.12);
-  background: rgba(0,0,0,0.35); padding: 10px 14px;
-}
-#input-bar .input-row {
-  display: flex; gap: 8px; align-items: flex-end;
-  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15);
-  border-radius: 12px; padding: 8px 10px;
-}
-#input-bar .input-row:focus-within { border-color: #4ecca3; }
-#msg-input {
-  flex: 1; background: transparent; border: none; outline: none; color: #e0e0e0;
-  font-family: 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace;
-  font-size: 14px; line-height: 1.5; resize: none; max-height: 160px; min-height: 24px;
-  padding: 4px 4px;
-}
-#msg-input::placeholder { color: #666; }
-#input-bar .input-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
+/* 通用按钮样式(粘贴 modal 复用) */
 .icon-btn {
   width: 32px; height: 32px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15);
   background: rgba(255,255,255,0.05); color: #ccc; font-size: 16px; cursor: pointer;
@@ -94,16 +76,13 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
   font-size: 14px; font-weight: 600; cursor: pointer;
 }
 .send-btn:hover { opacity: 0.9; }
-.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
-#input-bar .input-hint { font-size: 11px; color: #666; margin-top: 4px; text-align: right; }
-#input-bar .img-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
-#input-bar .img-chip {
+.img-chip {
   display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px;
   background: rgba(78,204,163,0.12); border: 1px solid rgba(78,204,163,0.3);
   border-radius: 6px; font-size: 12px; color: #4ecca3;
 }
-#input-bar .img-chip .remove { cursor: pointer; opacity: 0.6; }
-#input-bar .img-chip .remove:hover { opacity: 1; }
+.img-chip .remove { cursor: pointer; opacity: 0.6; }
+.img-chip .remove:hover { opacity: 1; }
 
 /* 任务列表页 */
 #picker-page { display: none; flex-direction: column; height: 100vh; }
@@ -184,22 +163,27 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     <div class="actions">
       <button class="action-btn" id="btn-restart" title="重启会话(续上次历史)">↻ 重启会话</button>
       <button class="action-btn" id="btn-list" title="打开任务列表">≡ 列表</button>
+      <button class="action-btn" id="btn-paste" title="粘贴文字或图片">📋 粘贴</button>
       <button class="action-btn" id="btn-close" title="关闭窗口">✕ 关闭</button>
     </div>
   </div>
   <div id="terminal-container"></div>
-  <div id="input-bar">
-    <div class="img-chips" id="img-chips" style="display:none;"></div>
-    <div class="input-row">
-      <textarea id="msg-input" rows="1" placeholder="输入消息,Ctrl+Enter 发送;可粘贴文字或截图"></textarea>
-      <div class="input-actions">
-        <button class="icon-btn" id="btn-attach" title="选择图片文件">📎</button>
-        <button class="icon-btn" id="btn-clear" title="清空输入">✕</button>
-        <button class="send-btn" id="btn-send">发送</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 style="color:#4ecca3; font-size:15px; margin-bottom:10px;">📋 粘贴到终端</div>
+      <div class="img-chips" id="paste-img-chips" style="display:none;"></div>
+      <div class="input-row" style="display:flex; gap:8px; align-items:flex-end; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:12px; padding:8px 10px;">
+        <textarea id="paste-input" rows="3" style="flex:1; background:transparent; border:none; outline:none; color:#e0e0e0; font-family:'Cascadia Code','Fira Code',Menlo,monospace; font-size:14px; line-height:1.5; resize:vertical; max-height:240px; min-height:60px;" placeholder="在此粘贴文字或截图,Ctrl+Enter 发送到终端输入行"></textarea>
+        <div style="display:flex; gap:6px; align-items:center; flex-shrink:0;">
+          <button class="icon-btn" id="paste-attach" title="选择图片">📎</button>
+          <button class="icon-btn" id="paste-clear" title="清空">✕</button>
+          <button class="send-btn" id="paste-send">发送</button>
+        </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>
-    <div class="input-hint">Ctrl+Enter 发送 · 粘贴截图自动上传 · <span id="ws-hint">未连接</span></div>
-    <input type="file" id="file-input" accept="image/*" style="display:none;">
   </div>
 </div>
 
@@ -386,24 +370,17 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: text }));
   }
 
-  // ========== 输入区(方案 B:独立 textarea 输入框) ==========
-  const msgInput = document.getElementById('msg-input');
-  const btnSend = document.getElementById('btn-send');
-  const btnAttach = document.getElementById('btn-attach');
-  const btnClear = document.getElementById('btn-clear');
-  const fileInput = document.getElementById('file-input');
-  const imgChips = document.getElementById('img-chips');
-  const wsHint = document.getElementById('ws-hint');
+  // ========== 粘贴 modal(右上角 📋 粘贴按钮触发) ==========
+  const pasteModal = document.getElementById('paste-modal');
+  const pasteInput = document.getElementById('paste-input');
+  const pasteSend = document.getElementById('paste-send');
+  const pasteAttach = document.getElementById('paste-attach');
+  const pasteClear = document.getElementById('paste-clear');
+  const pasteFileInput = document.getElementById('paste-file-input');
+  const pasteImgChips = document.getElementById('paste-img-chips');
   // 待发送的图片列表:[{path, name}]
   let pendingImages = [];
 
-  // textarea 自适应高度
-  function autoResize() {
-    msgInput.style.height = 'auto';
-    msgInput.style.height = Math.min(160, msgInput.scrollHeight) + 'px';
-  }
-  msgInput.addEventListener('input', autoResize);
-
   // 上传图片到后端,返回 {path, name} 或抛错
   async function uploadImage(blob) {
     const sizeKB = Math.max(1, Math.round(blob.size / 1024));
@@ -433,21 +410,34 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
   }
 
   // 渲染图片 chip 列表
-  function renderImgChips() {
-    if (!pendingImages.length) { imgChips.style.display = 'none'; imgChips.innerHTML = ''; return; }
-    imgChips.style.display = 'flex';
-    imgChips.innerHTML = pendingImages.map((img, i) =>
+  function renderPasteImgChips() {
+    if (!pendingImages.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('');
-    imgChips.querySelectorAll('.remove').forEach(el => {
-      el.onclick = () => { pendingImages.splice(parseInt(el.dataset.i), 1); renderImgChips(); };
+    pasteImgChips.querySelectorAll('.remove').forEach(el => {
+      el.onclick = () => { pendingImages.splice(parseInt(el.dataset.i), 1); renderPasteImgChips(); };
     });
   }
 
+  // 打开/关闭 modal
+  function openPasteModal() {
+    pasteInput.value = '';
+    pendingImages = [];
+    renderPasteImgChips();
+    pasteModal.style.display = 'flex';
+    setTimeout(() => pasteInput.focus(), 0);
+  }
+  function closePasteModal() {
+    pasteModal.style.display = 'none';
+    if (term && term.focus) term.focus();
+  }
+  document.getElementById('btn-paste').onclick = openPasteModal;
+
   // textarea 的 paste 事件:文字直接插入,图片上传后加 chip
-  msgInput.addEventListener('paste', async (e) => {
+  pasteInput.addEventListener('paste', async (e) => {
     if (!e.clipboardData) return;
-    // 优先检测图片
     let imgBlob = null;
     if (e.clipboardData.items) {
       for (const item of e.clipboardData.items) {
@@ -463,95 +453,75 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     }
     if (imgBlob) {
       e.preventDefault();
-      try {
-        const img = await uploadImage(imgBlob);
-        pendingImages.push(img);
-        renderImgChips();
-      } catch (err) {
-        term.writeln('\x1b[31m✗ 图片上传失败: ' + err.message + '\x1b[0m');
-      }
+      try { const img = await uploadImage(imgBlob); pendingImages.push(img); renderPasteImgChips(); }
+      catch (err) { term.writeln('\x1b[31m✗ 图片上传失败: ' + err.message + '\x1b[0m'); }
       return;
     }
-    // 文字:让浏览器默认插入 textarea(不拦截)
+    // 文字:浏览器默认插入
   });
 
-  // 拖拽图片到输入区
-  msgInput.addEventListener('dragover', (e) => { e.preventDefault(); });
-  msgInput.addEventListener('drop', async (e) => {
+  // 拖拽图片到 modal
+  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/'));
     if (!files.length) return;
     e.preventDefault();
     for (const f of files) {
-      try {
-        const img = await uploadImage(f);
-        pendingImages.push(img);
-      } catch (err) {
-        term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m');
-      }
+      try { const img = await uploadImage(f); pendingImages.push(img); }
+      catch (err) { term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m'); }
     }
-    renderImgChips();
+    renderPasteImgChips();
   });
 
-  // 📎 选择图片文件
-  btnAttach.onclick = () => fileInput.click();
-  fileInput.onchange = async () => {
-    if (!fileInput.files || !fileInput.files.length) return;
-    for (const f of fileInput.files) {
+  // 📎 选择图片
+  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);
-      } catch (err) {
-        term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m');
-      }
+      try { const img = await uploadImage(f); pendingImages.push(img); }
+      catch (err) { term.writeln('\x1b[31m✗ ' + f.name + ' 上传失败: ' + err.message + '\x1b[0m'); }
     }
-    fileInput.value = '';
-    renderImgChips();
+    pasteFileInput.value = '';
+    renderPasteImgChips();
   };
 
-  // ✕ 清空输入
-  btnClear.onclick = () => {
-    msgInput.value = '';
+  // ✕ 清空
+  pasteClear.onclick = () => {
+    pasteInput.value = '';
     pendingImages = [];
-    renderImgChips();
-    autoResize();
-    msgInput.focus();
+    renderPasteImgChips();
+    pasteInput.focus();
   };
 
-  // 发送:文字 + 图片路径标签
-  function sendMessage() {
+  // 发送:把内容字符流发给 PTY(不加 \r),内容显示在 xterm 输入行,
+  // 用户自己按回车提交。多行内容用 \n 分隔,最后一行留空让用户继续输入。
+  function sendPasteContent() {
     if (!ws || ws.readyState !== WebSocket.OPEN) { term.writeln('\x1b[31m✗ 未连接,无法发送\x1b[0m'); return; }
-    const text = msgInput.value;
-    let payload = '';
-    // 先发图片标签
+    const text = pasteInput.value;
+    let parts = [];
     for (const img of pendingImages) {
-      payload += '<image_local_path>' + img.path + '</image_local_path>\n';
+      parts.push('<image_local_path>' + img.path + '</image_local_path>');
     }
-    // 再发文字
-    if (text) payload += text;
-    if (!payload) return;
-    payload += '\n';  // 回车确认提交
-    sendInput(payload);
-    // 清空
-    msgInput.value = '';
-    pendingImages = [];
-    renderImgChips();
-    autoResize();
-    msgInput.focus();
+    if (text) parts.push(text);
+    if (!parts.length) return;
+    const body = parts.join(' ');
+    // 只发内容,不带回车:内容进 xterm 输入行,用户自行回车提交
+    sendInput(body);
+    closePasteModal();
   }
-  btnSend.onclick = sendMessage;
-  msgInput.addEventListener('keydown', (e) => {
+  pasteSend.onclick = sendPasteContent;
+  pasteInput.addEventListener('keydown', (e) => {
     if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      sendMessage();
+      sendPasteContent();
+    } else if (e.key === 'Escape') {
+      e.preventDefault();
+      closePasteModal();
     }
   });
 
-  function setWsHint(connected) {
-    wsHint.textContent = connected ? '已连接' : '未连接';
-  }
-
   function initTerminal() {
     if (term) return;
     term = new Terminal({
@@ -585,8 +555,7 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
   function setConnected(v) {
     statusDot.className = 'dot ' + (v ? 'connected' : 'disconnected');
     statusText.textContent = v ? '已连接' : '未连接';
-    setWsHint(v);
-    if (v) { try { msgInput.focus(); } catch(e){} }
+    if (v && term && term.focus) { try { term.focus(); } catch(e){} }
   }
 
   function connectWS() {