From d150b531169b5332890f3c986c3e212addd49f59 Mon Sep 17 00:00:00 2001 From: thzxx Date: Wed, 8 Apr 2026 12:57:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B7=A5=E4=BD=9C=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=E5=8E=BB=E6=8E=89=E6=89=8B=E5=8A=A8=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=BE=93=E5=85=A5=EF=BC=8C=E7=BB=88=E6=AD=A2=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8F=8D=E9=A6=88=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除工作空间终端的命令输入框和执行按钮 - 用户只能通过终止按钮中断卡住的 AI 命令 - 终止后自动通过 killTool IPC 终止进程,结果自动回传 AI - 新增 workspace:killTool IPC 和 terminateCurrentToolCommand - 新增终端底部状态提示(等待/执行中) - 修复工作空间面板顶部对齐(90px → 92px) --- src/main/ipc.ts | 9 +- src/main/preload.ts | 2 + src/main/workspace.ts | 17 +++ src/renderer/components/workspace-panel.ts | 125 +++++++++------------ src/renderer/index.html | 41 ++++--- src/renderer/styles/style.css | 44 +++++--- src/renderer/types.d.ts | 2 + 7 files changed, 130 insertions(+), 110 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b6ad987..f4eba75 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -22,7 +22,7 @@ import { handleRunCommand } from './tool-handlers.js'; import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js'; -import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir, execWorkspaceCommand } from './workspace.js'; +import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir, execWorkspaceCommand, terminateCurrentToolCommand } from './workspace.js'; export function setupIPC(): void { ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => { @@ -177,4 +177,11 @@ export function setupIPC(): void { sendLog('info', `🔧 workspace:execTool`, `${command.slice(0, 200)} | cwd: ${cwd || '默认'}`); return execWorkspaceCommand(command, cwd); }); + + // 终止当前 AI 工具命令 + ipcMain.handle('workspace:killTool', async () => { + const killed = terminateCurrentToolCommand(); + sendLog('info', `🔧 workspace:killTool`, killed ? '已终止' : '无正在运行的工具命令'); + return { killed }; + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 1c6293a..67fc4bd 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -61,6 +61,8 @@ contextBridge.exposeInMainWorld('metonaDesktop', { }, /** 无超时命令执行,用于 AI Tool Calling */ execTool: (command: string, cwd?: string) => ipcRenderer.invoke('workspace:execTool', command, cwd), + /** 终止当前 AI 工具命令 */ + killTool: () => ipcRenderer.invoke('workspace:killTool'), /** AI Tool Calling 命令输出 → 同步到工作空间终端 */ onToolOutput: (callback: (data: { command: string; stdout: string; stderr: string; exitCode: number | null }) => void) => { ipcRenderer.on('workspace:toolOutput', (_: unknown, data: { command: string; stdout: string; stderr: string; exitCode: number | null }) => callback(data)); diff --git a/src/main/workspace.ts b/src/main/workspace.ts index f33f262..3578964 100644 --- a/src/main/workspace.ts +++ b/src/main/workspace.ts @@ -20,6 +20,9 @@ export const DEFAULT_WORKSPACE_DIR = path.join(app.getPath('userData'), 'workspa /** 活跃进程 Map */ const activeProcesses = new Map(); +/** 当前 AI 工具命令进程 ID(用于用户手动终止) */ +let _currentToolProcessId: string | null = null; + /** 确保工作空间目录存在 */ export function ensureWorkspaceDir(): void { if (!fs.existsSync(DEFAULT_WORKSPACE_DIR)) { @@ -253,6 +256,7 @@ export function execWorkspaceCommand( }); activeProcesses.set(cmdId, proc); + _currentToolProcessId = cmdId; proc.stdout.on('data', (data: Buffer) => { if (stdoutBuf.length < MAX_OUTPUT) { @@ -280,6 +284,7 @@ export function execWorkspaceCommand( proc.on('close', (code) => { activeProcesses.delete(cmdId); + if (_currentToolProcessId === cmdId) _currentToolProcessId = null; const duration = Date.now() - startTime; sendLog( code === 0 ? 'success' : 'warn', @@ -299,6 +304,7 @@ export function execWorkspaceCommand( proc.on('error', (err) => { activeProcesses.delete(cmdId); + if (_currentToolProcessId === cmdId) _currentToolProcessId = null; const duration = Date.now() - startTime; sendLog('error', `🔧 execWorkspaceCommand 失败`, `${cmdId} | ${err.message}`); resolve({ @@ -334,3 +340,14 @@ export function execWorkspaceCommand( export function terminateWorkspaceCommand(commandId: string): boolean { return killProcess(commandId); } + +/** + * 终止当前正在运行的 AI 工具命令 + * @returns 是否成功终止 + */ +export function terminateCurrentToolCommand(): boolean { + if (!_currentToolProcessId) return false; + const id = _currentToolProcessId; + _currentToolProcessId = null; + return killProcess(id); +} diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts index 6162681..697a3d9 100644 --- a/src/renderer/components/workspace-panel.ts +++ b/src/renderer/components/workspace-panel.ts @@ -39,6 +39,17 @@ let fileTree: FileNode[] = []; let previewFile: { name: string; content: string; path: string } | null = null; let _counter = 0; +/** 当前正在运行的 AI 命令(用于用户手动终止时通知 AI) */ +let currentAiCommand: string | null = null; + +/** 终止通知回调(由外部设置) */ +let onToolTerminated: ((command: string) => void) | null = null; + +/** 设置终止通知回调 */ +export function setOnToolTerminated(cb: (command: string) => void): void { + onToolTerminated = cb; +} + function genId(): string { return `ws_${Date.now()}_${++_counter}`; } @@ -191,28 +202,6 @@ function bindEvents(): void { document.querySelector('#wsTabTerminal')?.addEventListener('click', () => switchTab('terminal')); document.querySelector('#wsTabFiles')?.addEventListener('click', () => switchTab('files')); - // 终端输入 - const termInput = document.querySelector('#wsTermInput') as HTMLInputElement; - termInput?.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const cmd = termInput.value.trim(); - if (cmd) { - executeCommand(cmd); - termInput.value = ''; - } - } - }); - - // 执行按钮 - document.querySelector('#wsExecBtn')?.addEventListener('click', () => { - const cmd = termInput?.value.trim(); - if (cmd) { - executeCommand(cmd); - termInput.value = ''; - } - }); - // 停止按钮 document.querySelector('#wsStopBtn')?.addEventListener('click', killCurrentProcess); @@ -274,36 +263,16 @@ function getActiveSession(): TerminalSession | undefined { return terminalSessions.find(s => s.id === activeTerminalId); } -function executeCommand(command: string): void { - const session = getActiveSession(); - if (!session) { - logError('工作空间执行失败', '无活跃终端会话'); - return; - } - - const bridge = window.metonaDesktop; - if (!bridge?.isDesktop) { - logError('工作空间执行失败', '非桌面环境'); - return; - } - - // 显示命令 - session.lines.push({ type: 'system', text: `$ ${command}` }); - session.running = true; - session.autoScroll = true; - - renderTerminal(); - - const cwd = currentFileDir || workspaceDir; - logInfo('工作空间执行命令', `ID: ${session.id} | ${command.slice(0, 100)} | cwd: ${cwd}`); - bridge.workspace.exec({ id: session.id, command, cwd }); -} - /** AI Tool Calling 命令输出 → 追加到活跃终端 */ function appendToolOutput(command: string, stdout: string, stderr: string, exitCode: number | null): void { const session = getActiveSession(); if (!session) return; + // 记录当前 AI 命令 + currentAiCommand = command; + session.running = true; + updateHint(); + // 显示执行的命令 session.lines.push({ type: 'system', text: `$ ${command} (AI)` }); @@ -325,12 +294,18 @@ function appendToolOutput(command: string, stdout: string, stderr: string, exitC const exitMsg = exitCode === 0 ? '✓ AI 命令执行成功' : `✗ AI 命令退出 (code: ${exitCode})`; session.lines.push({ type: 'system', text: exitMsg }); + // 命令结束 + session.running = false; + currentAiCommand = null; + // 限制最大行数 if (session.lines.length > 3000) { session.lines = session.lines.slice(-2000); } renderTerminal(); + updateStopBtnState(); + updateHint(); } function appendTerminalOutput(sessionId: string, type: 'stdout' | 'stderr', data: string): void { @@ -389,14 +364,32 @@ function handleProcessExit(sessionId: string, code: number | null): void { function killCurrentProcess(): void { const session = getActiveSession(); - if (!session || !session.running) return; + if (!session) return; const bridge = window.metonaDesktop; if (!bridge?.isDesktop) return; - bridge.workspace.kill(session.id); - session.lines.push({ type: 'system', text: '■ 已发送终止信号' }); + // 终止用户终端进程(如果有) + if (session.running) { + bridge.workspace.kill(session.id); + } + + // 终止当前 AI 工具命令 + bridge.workspace.killTool(); + + const cmd = currentAiCommand; + session.lines.push({ type: 'system', text: '■ 命令已手动终止' }); + session.running = false; + currentAiCommand = null; + renderTerminal(); + updateStopBtnState(); + updateHint(); + + // 通知 AI 命令被用户终止 + if (cmd && onToolTerminated) { + onToolTerminated(cmd); + } } function clearTerminal(): void { @@ -649,12 +642,23 @@ function renderPreview(): void { function updateStopBtnState(): void { const stopBtn = document.querySelector('#wsStopBtn') as HTMLButtonElement; if (!stopBtn) return; - const session = getActiveSession(); - const running = session?.running || false; + const running = currentAiCommand !== null; stopBtn.disabled = !running; stopBtn.classList.toggle('active', running); } +function updateHint(): void { + const hint = document.querySelector('#wsTermHint') as HTMLElement; + if (!hint) return; + if (currentAiCommand) { + hint.textContent = `⏳ AI 正在执行: ${currentAiCommand.slice(0, 40)}${currentAiCommand.length > 40 ? '...' : ''}`; + hint.classList.add('running'); + } else { + hint.textContent = '等待 AI 执行命令...'; + hint.classList.remove('running'); + } +} + // ── 工具函数 ── function getFileIcon(name: string): string { const ext = name.split('.').pop()?.toLowerCase() || ''; @@ -676,23 +680,6 @@ function formatSize(bytes: number): string { // ── 外部调用接口 ── -/** 从聊天区域调用:在工作空间执行命令 */ -export function execInWorkspace(command: string): void { - // 切换到终端 tab - activeTab = 'terminal'; - - // 使用当前活跃终端或创建新的 - const session = getActiveSession(); - if (session?.running) { - // 当前终端有进程在跑,新建一个 - const num = terminalSessions.length + 1; - createTerminalSession(`终端 ${num}`); - } - - renderPanel(); - executeCommand(command); -} - /** 获取工作空间目录 */ export function getWorkspaceDirPath(): string { return workspaceDir; diff --git a/src/renderer/index.html b/src/renderer/index.html index 95c689a..57c41b8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -182,28 +182,25 @@
- - - - - + 等待 AI 执行命令... +
+ + + +
diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index 11ec21a..4a9368c 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -2667,7 +2667,7 @@ html, body { .workspace-panel { position: fixed; - top: 90px; /* header(44px) + model-bar(46px) */ + top: 92px; /* header(44px) + border(1px) + model-bar(46px) + border(1px) */ right: 0; bottom: 0; width: 480px; @@ -2838,31 +2838,39 @@ html, body { .ws-term-toolbar { display: flex; align-items: center; + justify-content: space-between; gap: 4px; padding: 6px 8px; border-top: 1px solid var(--border-default, rgba(255,255,255,0.08)); background: var(--bg-layer-alt, #2a2a2a); } -.ws-term-input { +.ws-term-hint { flex: 1; - background: var(--bg-layer, #383838); - border: 1px solid var(--border-default, rgba(255,255,255,0.08)); - border-radius: 4px; - color: var(--text-primary, #fff); - padding: 6px 10px; - font-size: 12px; - font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace; - outline: none; - transition: border-color 0.15s; -} - -.ws-term-input:focus { - border-color: var(--accent, #60CDFF); -} - -.ws-term-input::placeholder { + font-size: 11px; color: var(--text-tertiary, #666); + font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 4px; +} + +.ws-term-hint.running { + color: var(--accent, #60CDFF); + animation: hint-pulse 1.5s ease-in-out infinite; +} + +@keyframes hint-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.ws-term-toolbar-btns { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; } .ws-toolbar-btn { diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index 2bf89ec..d2a1926 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -243,6 +243,8 @@ export interface MetonaDesktopAPI { execTool: (command: string, cwd?: string) => Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null; duration: number; userTerminated: boolean; truncated: boolean }>; /** AI Tool Calling 命令输出 → 同步到工作空间终端 */ onToolOutput: (callback: (data: { command: string; stdout: string; stderr: string; exitCode: number | null }) => void) => void; + /** 终止当前 AI 工具命令 */ + killTool: () => Promise<{ killed: boolean }>; }; }