From 8513110fd88b30dc9cd13a27b38b121bb96c3430 Mon Sep 17 00:00:00 2001 From: Andreaseszhang Date: Fri, 26 Jun 2026 21:38:41 +0800 Subject: [PATCH] feat(agent): add right panel terminal --- apps/electron/electron-builder.yml | 13 + apps/electron/package.json | 8 +- .../scripts/fix-node-pty-permissions.cjs | 137 ++++ apps/electron/src/main/ipc.ts | 87 ++- .../src/main/lib/agent-session-manager.ts | 4 + .../electron/src/main/lib/terminal-service.ts | 391 ++++++++++++ apps/electron/src/preload/index.ts | 83 ++- .../renderer/components/agent/SidePanel.tsx | 107 ++++ .../components/agent/TerminalPanel.tsx | 589 ++++++++++++++++++ .../components/app-shell/AppShell.tsx | 2 +- .../components/diff/DiffTabContent.tsx | 9 +- apps/electron/src/renderer/styles/globals.css | 24 + bun.lock | 13 +- packages/shared/src/types/index.ts | 3 + packages/shared/src/types/terminal.ts | 100 +++ 15 files changed, 1560 insertions(+), 10 deletions(-) create mode 100644 apps/electron/scripts/fix-node-pty-permissions.cjs create mode 100644 apps/electron/src/main/lib/terminal-service.ts create mode 100644 apps/electron/src/renderer/components/agent/TerminalPanel.tsx create mode 100644 packages/shared/src/types/terminal.ts diff --git a/apps/electron/electron-builder.yml b/apps/electron/electron-builder.yml index e65ba4b96..83231db20 100644 --- a/apps/electron/electron-builder.yml +++ b/apps/electron/electron-builder.yml @@ -21,10 +21,14 @@ compression: normal asar: true asarUnpack: - "node_modules/@anthropic-ai/**" + - "node_modules/node-pty/**" # 跳过 npm install(代码已被 esbuild/Vite 打包,npm 不识别 workspace:* 协议) npmRebuild: false +# node-pty 的 spawn-helper 必须在签名前具备可执行权限。 +afterPack: scripts/fix-node-pty-permissions.cjs + # 通用文件配置 files: - dist/**/* @@ -44,6 +48,15 @@ files: - node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64/**/* # PDF 内联预览使用本地 PDF.js 资源,避免运行时依赖 CDN。 - node_modules/pdfjs-dist/**/* + # 右侧面板终端使用 node-pty native module,必须与 dist/main.cjs 同级保留。 + - from: ../../node_modules/node-pty + to: node_modules/node-pty + filter: + - "**/*" + - from: ../../node_modules/node-addon-api + to: node_modules/node-addon-api + filter: + - "**/*" # 排除 workspace 包的 symlink(代码已被 esbuild/Vite 打包到 dist/ 中) - "!node_modules/@proma/**" diff --git a/apps/electron/package.json b/apps/electron/package.json index 0863d2db0..8e07f09e6 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -15,9 +15,10 @@ "dev:vite": "vite dev", "dev:kill": "bun run scripts/dev-kill.ts", "dev:electron": "bun run dev:kill && bun run build:main && bun run build:preload && bun run build:resources && bun run scripts/sleep.ts 2 && concurrently -k -n main,preload,app -c yellow,magenta,cyan \"bun run watch:main\" \"bun run watch:preload\" \"bunx electronmon .\"", - "build:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk", + "fix:node-pty": "node scripts/fix-node-pty-permissions.cjs", + "build:main": "bun run fix:node-pty && esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --external:node-pty", "build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload.cjs --external:electron", - "watch:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --watch=forever", + "watch:main": "bun run fix:node-pty && esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --external:node-pty --watch=forever", "watch:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload.cjs --external:electron --watch=forever", "build:renderer": "vite build", "build:resources": "cp -r resources dist/ 2>/dev/null || true", @@ -42,10 +43,13 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@pierre/diffs": "^1.1.20", "@xmldom/xmldom": "^0.8.11", + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", "adm-zip": "^0.5.17", "dompurify": "^3.4.5", "iconv-lite": "^0.6.3", "mammoth": "^1.12.0", + "node-pty": "1.1.0", "pdfjs-dist": "^4.10.38" }, "optionalDependencies": { diff --git a/apps/electron/scripts/fix-node-pty-permissions.cjs b/apps/electron/scripts/fix-node-pty-permissions.cjs new file mode 100644 index 000000000..b8a5ce731 --- /dev/null +++ b/apps/electron/scripts/fix-node-pty-permissions.cjs @@ -0,0 +1,137 @@ +const { chmodSync, existsSync, readdirSync, statSync } = require('node:fs') +const { dirname, join, resolve } = require('node:path') + +const SCRIPT_DIR = __dirname +const APP_DIR = resolve(SCRIPT_DIR, '..') +const REPO_ROOT = resolve(APP_DIR, '../..') + +function resolveNodePtyRoot() { + const packagePath = require.resolve('node-pty/package.json', { + paths: [APP_DIR, REPO_ROOT], + }) + return dirname(packagePath) +} + +function isExecutable(filePath) { + if (process.platform === 'win32') return true + return (statSync(filePath).mode & 0o111) !== 0 +} + +function chmodExecutable(filePath) { + if (process.platform === 'win32') return + if (!existsSync(filePath)) return + chmodSync(filePath, 0o755) + console.log(`[node-pty] 已设置可执行权限: ${filePath}`) +} + +function currentPlatformArch() { + return `${process.platform}-${process.arch}` +} + +function validateNodePtyRoot(nodePtyRoot, platformArch = currentPlatformArch()) { + const prebuildRoot = join(nodePtyRoot, 'prebuilds', platformArch) + const buildRoot = join(nodePtyRoot, 'build', 'Release') + const nativeCandidates = [ + join(prebuildRoot, 'pty.node'), + join(buildRoot, 'pty.node'), + ] + const nativeModule = nativeCandidates.find((candidate) => existsSync(candidate)) + + if (!nativeModule) { + throw new Error(`[node-pty] 缺少当前平台 native module: ${nativeCandidates.join(', ')}`) + } + + if (!platformArch.startsWith('win32-')) { + const helperCandidates = [ + join(prebuildRoot, 'spawn-helper'), + join(buildRoot, 'spawn-helper'), + ] + const helper = helperCandidates.find((candidate) => existsSync(candidate)) + if (!helper) { + throw new Error(`[node-pty] 缺少 spawn-helper: ${helperCandidates.join(', ')}`) + } + chmodExecutable(helper) + if (!isExecutable(helper)) { + throw new Error(`[node-pty] spawn-helper 仍不可执行: ${helper}`) + } + } + + console.log(`[node-pty] native 产物校验通过: ${nativeModule}`) +} + +function findAppResourceDirs(appOutDir) { + const resourceDirs = [] + const resourcesDir = join(appOutDir, 'resources') + if (existsSync(resourcesDir)) resourceDirs.push(resourcesDir) + + if (existsSync(appOutDir)) { + for (const entry of readdirSync(appOutDir)) { + if (!entry.endsWith('.app')) continue + const macResourcesDir = join(appOutDir, entry, 'Contents', 'Resources') + if (existsSync(macResourcesDir)) resourceDirs.push(macResourcesDir) + } + } + + return resourceDirs +} + +function collectPackagedNodePtyRoots(appOutDir) { + const roots = new Set() + for (const resourcesDir of findAppResourceDirs(appOutDir)) { + for (const relativeRoot of [ + 'app.asar.unpacked/node_modules/node-pty', + 'app/node_modules/node-pty', + ]) { + const nodePtyRoot = join(resourcesDir, relativeRoot) + if (existsSync(nodePtyRoot)) roots.add(nodePtyRoot) + } + } + return Array.from(roots) +} + +function platformArchFromContext(context) { + const platformName = context.electronPlatformName || context.packager?.platform?.nodeName || process.platform + const archName = normalizeArch(context.arch || context.packager?.arch || process.arch) + return `${platformName}-${archName}` +} + +function normalizeArch(arch) { + if (typeof arch === 'string') return arch + if (typeof arch !== 'number') return process.arch + const archNames = { + 0: 'ia32', + 1: 'x64', + 2: 'armv7l', + 3: 'arm64', + 4: 'universal', + } + return archNames[arch] || process.arch +} + +async function afterPack(context) { + const roots = collectPackagedNodePtyRoots(context.appOutDir) + if (roots.length === 0) { + throw new Error(`[node-pty] 打包产物中未找到 node-pty: ${context.appOutDir}`) + } + + const platformArch = platformArchFromContext(context) + for (const nodePtyRoot of roots) { + validateNodePtyRoot(nodePtyRoot, platformArch) + } +} + +function runLocal() { + const nodePtyRoot = resolveNodePtyRoot() + validateNodePtyRoot(nodePtyRoot) +} + +module.exports = afterPack + +if (require.main === module) { + try { + runLocal() + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) + } +} diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 023a6fc24..1b8de9d99 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -9,7 +9,7 @@ import { join, resolve, sep, dirname } from 'node:path' import { existsSync, realpathSync, rmSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs' import { writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, INSTALLER_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, CHAT_TOOL_IPC_CHANNELS, FEISHU_IPC_CHANNELS, DINGTALK_IPC_CHANNELS, WECHAT_IPC_CHANNELS, AUTOMATION_IPC_CHANNELS, isPromaPermissionMode, normalizePathForCompare } from '@proma/shared' +import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, INSTALLER_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, CHAT_TOOL_IPC_CHANNELS, FEISHU_IPC_CHANNELS, DINGTALK_IPC_CHANNELS, WECHAT_IPC_CHANNELS, AUTOMATION_IPC_CHANNELS, TERMINAL_IPC_CHANNELS, isPromaPermissionMode, normalizePathForCompare } from '@proma/shared' import { USER_PROFILE_IPC_CHANNELS, SETTINGS_IPC_CHANNELS, SCRATCH_PAD_IPC_CHANNELS, QUICK_TASK_IPC_CHANNELS, VOICE_DICTATION_IPC_CHANNELS, APP_ICON_IPC_CHANNELS, DOCK_BADGE_IPC_CHANNELS, STORAGE_IPC_CHANNELS } from '../types' import type { QuickTaskSubmitInput, @@ -110,6 +110,16 @@ import type { Automation, CreateAutomationInput, UpdateAutomationInput, + TerminalSession, + TerminalStartInput, + TerminalListInput, + TerminalListResult, + TerminalWriteInput, + TerminalStopInput, + TerminalResizeInput, + TerminalSetActiveSessionInput, + TerminalBusyInput, + TerminalBusyResult, } from '@proma/shared' import type { UserProfile, AppSettings } from '../types' import { getRuntimeStatus, getGitRepoStatus, reinitializeRuntime } from './lib/runtime-init' @@ -261,6 +271,7 @@ import { getDingTalkConfig, saveDingTalkConfig, getDecryptedClientSecret, getDin import { dingtalkBridgeManager } from './lib/dingtalk-bridge-manager' import { getWeChatConfig } from './lib/wechat-config' import { wechatBridge } from './lib/wechat-bridge' +import { getTerminalBusyState, listTerminalsForSession, resizeTerminal, setActiveTerminalSession, startTerminal, stopAllTerminals, stopTerminal, writeTerminal } from './lib/terminal-service' /** 文件浏览器中需要隐藏的系统文件 */ const HIDDEN_FS_ENTRIES = new Set(['.DS_Store', 'Thumbs.db']) @@ -798,6 +809,7 @@ export function resolveAppIconPath(variantId: string): string | null { export function registerIpcHandlers(): void { console.log('[IPC] 正在注册 IPC 处理器...') + app.once('before-quit', () => stopAllTerminals()) // ===== 运行时相关 ===== @@ -830,6 +842,79 @@ export function registerIpcHandlers(): void { } ) + // ===== 终端相关 ===== + + ipcMain.handle( + TERMINAL_IPC_CHANNELS.START, + async (event, input: TerminalStartInput): Promise => { + if (!input || typeof input !== 'object') throw new Error('input 必须是对象') + if (!input.sessionId || typeof input.sessionId !== 'string') throw new Error('sessionId 必填') + if (!input.cwd || typeof input.cwd !== 'string') throw new Error('cwd 必填') + const cwd = resolve(input.cwd) + if (!ensurePathAllowed(cwd, { sessionId: input.sessionId })) { + throw new Error('终端工作目录不在当前会话允许范围内') + } + setActiveTerminalSession(event.sender.id, input.sessionId) + return startTerminal({ sessionId: input.sessionId, cwd, cols: input.cols, rows: input.rows }, event.sender) + } + ) + + ipcMain.handle( + TERMINAL_IPC_CHANNELS.LIST, + async (event, input: TerminalListInput): Promise => { + if (!input || typeof input !== 'object') throw new Error('input 必须是对象') + if (!input.sessionId || typeof input.sessionId !== 'string') throw new Error('sessionId 必填') + return listTerminalsForSession(input.sessionId, event.sender.id) + } + ) + + ipcMain.handle( + TERMINAL_IPC_CHANNELS.WRITE, + async (event, input: TerminalWriteInput): Promise => { + if (!input || typeof input !== 'object') throw new Error('input 必须是对象') + if (!input.terminalId || typeof input.terminalId !== 'string') throw new Error('terminalId 必填') + if (typeof input.data !== 'string') throw new Error('data 必须是字符串') + writeTerminal(input.terminalId, input.data, event.sender.id) + } + ) + + ipcMain.handle( + TERMINAL_IPC_CHANNELS.RESIZE, + async (event, input: TerminalResizeInput): Promise => { + if (!input || typeof input !== 'object') throw new Error('input 必须是对象') + if (!input.terminalId || typeof input.terminalId !== 'string') throw new Error('terminalId 必填') + if (!Number.isFinite(input.cols) || !Number.isFinite(input.rows)) throw new Error('cols/rows 必须是数字') + resizeTerminal(input, event.sender.id) + } + ) + + ipcMain.handle( + TERMINAL_IPC_CHANNELS.STOP, + async (event, input: TerminalStopInput): Promise => { + if (!input || typeof input !== 'object') throw new Error('input 必须是对象') + if (!input.terminalId || typeof input.terminalId !== 'string') throw new Error('terminalId 必填') + stopTerminal(input.terminalId, event.sender.id) + } + ) + + ipcMain.handle( + TERMINAL_IPC_CHANNELS.SET_ACTIVE_SESSION, + async (event, input: TerminalSetActiveSessionInput): Promise => { + if (!input || typeof input !== 'object') throw new Error('input 必须是对象') + if (input.sessionId !== null && typeof input.sessionId !== 'string') throw new Error('sessionId 必须是字符串或 null') + setActiveTerminalSession(event.sender.id, input.sessionId) + } + ) + + ipcMain.handle( + TERMINAL_IPC_CHANNELS.IS_BUSY, + async (event, input: TerminalBusyInput): Promise => { + if (!input || typeof input !== 'object') throw new Error('input 必须是对象') + if (!input.terminalId || typeof input.terminalId !== 'string') throw new Error('terminalId 必填') + return getTerminalBusyState(input.terminalId, event.sender.id) + } + ) + // 获取未暂存的变更文件列表 ipcMain.handle( IPC_CHANNELS.GET_UNSTAGED_CHANGES, diff --git a/apps/electron/src/main/lib/agent-session-manager.ts b/apps/electron/src/main/lib/agent-session-manager.ts index b207c8620..aa9a311a7 100644 --- a/apps/electron/src/main/lib/agent-session-manager.ts +++ b/apps/electron/src/main/lib/agent-session-manager.ts @@ -22,6 +22,7 @@ import { getSdkConfigDir, } from './config-paths' import { getAgentWorkspace } from './agent-workspace-manager' +import { stopTerminalsForSession } from './terminal-service' // 在模块加载时一次性设置 SDK 配置目录,避免在 forkSession 等异步调用中临时修改/恢复 // process.env 导致的并发安全问题(异步操作的 await 间隙其他代码可能读到错误值) @@ -393,6 +394,9 @@ export function deleteAgentSession(id: string): void { // 清理 Nano Banana 生图历史 clearNanoBananaAgentHistory(id) + // 停止该会话的全部终端,避免后台 PTY 进程泄漏(覆盖单会话删除与删工作区批量删除两条路径) + stopTerminalsForSession(id) + // 清理 SDK 关联数据(file-history 和 projects 下的 session JSONL) const sdkSessionIds = [removed.sdkSessionId, removed.forkSourceSdkSessionId].filter(Boolean) as string[] if (sdkSessionIds.length > 0) { diff --git a/apps/electron/src/main/lib/terminal-service.ts b/apps/electron/src/main/lib/terminal-service.ts new file mode 100644 index 000000000..114ebf4e3 --- /dev/null +++ b/apps/electron/src/main/lib/terminal-service.ts @@ -0,0 +1,391 @@ +/** + * 右侧面板终端进程管理。 + * + * 使用 node-pty 创建真实 PTY,渲染端由 xterm.js 负责键盘输入和 ANSI 渲染。 + */ + +import type { WebContents } from 'electron' +import * as pty from 'node-pty' +import { existsSync } from 'node:fs' +import { basename } from 'node:path' +import type { + TerminalBusyResult, + TerminalDataEvent, + TerminalExitEvent, + TerminalListResult, + TerminalResizeInput, + TerminalSession, + TerminalStartInput, +} from '@proma/shared' +import { TERMINAL_IPC_CHANNELS } from '@proma/shared' +import { detectGitBash } from './git-bash-detector' + +interface TerminalProcess { + meta: TerminalSession + ptyProcess: pty.IPty + webContents: WebContents + webContentsId: number + outputBuffer: string + pendingOutput: string + flushTimer: ReturnType | null + droppedOutputChars: number + backpressureNoticeQueued: boolean +} + +interface ShellCommand { + shell: string + args: string[] +} + +const terminals = new Map() +const terminalIdsByWebContents = new Map>() +const activeSessionByWebContents = new Map() +const watchedWebContentsIds = new Set() +const MAX_TERMINALS_PER_SESSION = 4 +const MAX_TERMINALS_TOTAL = 16 +const ACTIVE_TERMINAL_FLUSH_MS = 33 +const BACKGROUND_TERMINAL_FLUSH_MS = 250 +const MAX_PENDING_OUTPUT_CHARS = 200_000 +const MAX_OUTPUT_BUFFER_CHARS = 500_000 +const TERMINAL_BACKPRESSURE_NOTICE = '\r\n[Proma: 终端输出过快,已丢弃部分输出以保持应用响应]\r\n' + +function createTerminalId(): string { + return `terminal-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +async function resolveShellCommand(): Promise { + if (process.platform === 'darwin') { + return { shell: '/bin/zsh', args: ['-l'] } + } + + if (process.platform === 'win32') { + const gitBash = await detectGitBash() + if (gitBash.available && gitBash.path) { + return { shell: gitBash.path, args: ['--login'] } + } + throw new Error(gitBash.error ?? '未找到可用的 Bash,请先安装 Git for Windows') + } + + return { shell: process.env.SHELL || '/bin/bash', args: ['-l'] } +} + +function buildTerminalEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + PROMA_TERMINAL: '1', + } +} + +function clampSize(value: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) return fallback + return Math.max(min, Math.min(max, Math.floor(value as number))) +} + +function trackTerminalForWebContents(terminalId: string, webContents: WebContents): number { + const webContentsId = webContents.id + const terminalIds = terminalIdsByWebContents.get(webContentsId) ?? new Set() + terminalIds.add(terminalId) + terminalIdsByWebContents.set(webContentsId, terminalIds) + + if (!watchedWebContentsIds.has(webContentsId)) { + watchedWebContentsIds.add(webContentsId) + webContents.once('destroyed', () => { + stopTerminalsForWebContents(webContentsId) + terminalIdsByWebContents.delete(webContentsId) + watchedWebContentsIds.delete(webContentsId) + }) + } + + return webContentsId +} + +function untrackTerminal(terminalId: string, webContentsId: number): void { + const terminalIds = terminalIdsByWebContents.get(webContentsId) + if (!terminalIds) return + terminalIds.delete(terminalId) + if (terminalIds.size === 0) { + terminalIdsByWebContents.delete(webContentsId) + activeSessionByWebContents.delete(webContentsId) + } +} + +function removeTerminal(terminalId: string): TerminalProcess | null { + const terminal = terminals.get(terminalId) + if (!terminal) return null + if (terminal.flushTimer) { + clearTimeout(terminal.flushTimer) + terminal.flushTimer = null + } + terminals.delete(terminalId) + untrackTerminal(terminalId, terminal.webContentsId) + return terminal +} + +function getOwnedTerminal(terminalId: string, webContentsId: number): TerminalProcess | null { + const terminal = terminals.get(terminalId) + if (!terminal) return null + if (terminal.webContentsId !== webContentsId) { + console.warn(`[TerminalService] 拒绝跨窗口操作终端: terminal=${terminalId}, owner=${terminal.webContentsId}, requester=${webContentsId}`) + return null + } + return terminal +} + +function appendOutputBuffer(terminal: TerminalProcess, data: string): void { + const next = terminal.outputBuffer + data + terminal.outputBuffer = next.length > MAX_OUTPUT_BUFFER_CHARS + ? next.slice(-MAX_OUTPUT_BUFFER_CHARS) + : next +} + +function countTerminalsForSession(sessionId: string): number { + let count = 0 + for (const terminal of terminals.values()) { + if (terminal.meta.sessionId === sessionId) count += 1 + } + return count +} + +function getFlushDelay(terminal: TerminalProcess): number { + const activeSessionId = activeSessionByWebContents.get(terminal.webContentsId) + return activeSessionId === terminal.meta.sessionId ? ACTIVE_TERMINAL_FLUSH_MS : BACKGROUND_TERMINAL_FLUSH_MS +} + +function flushTerminalData(terminalId: string): void { + const terminal = terminals.get(terminalId) + if (!terminal) return + if (terminal.flushTimer) { + clearTimeout(terminal.flushTimer) + terminal.flushTimer = null + } + if (!terminal.pendingOutput) return + + const event: TerminalDataEvent = { terminalId, data: terminal.pendingOutput } + terminal.pendingOutput = '' + terminal.droppedOutputChars = 0 + terminal.backpressureNoticeQueued = false + if (!terminal.webContents.isDestroyed()) { + terminal.webContents.send(TERMINAL_IPC_CHANNELS.DATA, event) + } +} + +function scheduleTerminalFlush(terminal: TerminalProcess): void { + if (terminal.flushTimer) return + terminal.flushTimer = setTimeout(() => { + flushTerminalData(terminal.meta.id) + }, getFlushDelay(terminal)) +} + +function enqueueTerminalData(terminalId: string, data: string): void { + const terminal = terminals.get(terminalId) + if (!terminal) return + + appendOutputBuffer(terminal, data) + terminal.pendingOutput += data + if (terminal.pendingOutput.length > MAX_PENDING_OUTPUT_CHARS) { + const overflow = terminal.pendingOutput.length - MAX_PENDING_OUTPUT_CHARS + terminal.droppedOutputChars += overflow + const shouldQueueNotice = !terminal.backpressureNoticeQueued + if (shouldQueueNotice) { + terminal.backpressureNoticeQueued = true + console.warn(`[TerminalService] 终端 ${terminalId} 输出过快,已丢弃 ${overflow} 个字符`) + } + const notice = shouldQueueNotice || terminal.pendingOutput.startsWith(TERMINAL_BACKPRESSURE_NOTICE) + ? TERMINAL_BACKPRESSURE_NOTICE + : '' + const outputWithoutNotice = terminal.pendingOutput.startsWith(TERMINAL_BACKPRESSURE_NOTICE) + ? terminal.pendingOutput.slice(TERMINAL_BACKPRESSURE_NOTICE.length) + : terminal.pendingOutput + const availableChars = Math.max(MAX_PENDING_OUTPUT_CHARS - notice.length, 0) + terminal.pendingOutput = `${notice}${outputWithoutNotice.slice(-availableChars)}` + } + + scheduleTerminalFlush(terminal) +} + +function rescheduleTerminalFlushesForWebContents(webContentsId: number): void { + const terminalIds = terminalIdsByWebContents.get(webContentsId) + if (!terminalIds) return + for (const terminalId of terminalIds) { + const terminal = terminals.get(terminalId) + if (!terminal || !terminal.pendingOutput || !terminal.flushTimer) continue + clearTimeout(terminal.flushTimer) + terminal.flushTimer = null + scheduleTerminalFlush(terminal) + } +} + +export async function startTerminal(input: TerminalStartInput, webContents: WebContents): Promise { + if (terminals.size >= MAX_TERMINALS_TOTAL) { + throw new Error(`Proma 最多只能同时打开 ${MAX_TERMINALS_TOTAL} 个终端`) + } + if (countTerminalsForSession(input.sessionId) >= MAX_TERMINALS_PER_SESSION) { + throw new Error(`当前会话最多只能同时打开 ${MAX_TERMINALS_PER_SESSION} 个终端`) + } + + if (!existsSync(input.cwd)) { + throw new Error('终端工作目录不存在') + } + const cwd = input.cwd + const command = await resolveShellCommand() + const cols = clampSize(input.cols, 80, 10, 500) + const rows = clampSize(input.rows, 24, 4, 200) + const ptyProcess = pty.spawn(command.shell, command.args, { + name: 'xterm-256color', + cols, + rows, + cwd, + env: buildTerminalEnv(), + }) + + const meta: TerminalSession = { + id: createTerminalId(), + sessionId: input.sessionId, + cwd, + shell: basename(command.shell), + pid: ptyProcess.pid, + title: basename(cwd) || 'Terminal', + createdAt: Date.now(), + cols, + rows, + } + + const webContentsId = trackTerminalForWebContents(meta.id, webContents) + terminals.set(meta.id, { + meta, + ptyProcess, + webContents, + webContentsId, + outputBuffer: '', + pendingOutput: '', + flushTimer: null, + droppedOutputChars: 0, + backpressureNoticeQueued: false, + }) + + ptyProcess.onData((data) => { + enqueueTerminalData(meta.id, data) + }) + + ptyProcess.onExit(({ exitCode, signal }) => { + flushTerminalData(meta.id) + meta.exitCode = exitCode + removeTerminal(meta.id) + const event: TerminalExitEvent = { terminalId: meta.id, exitCode, signal: signal?.toString() ?? null } + if (!webContents.isDestroyed()) webContents.send(TERMINAL_IPC_CHANNELS.EXIT, event) + }) + + return meta +} + +export function listTerminalsForSession(sessionId: string, webContentsId: number): TerminalListResult { + const sessionTerminals = Array.from(terminals.values()) + .filter((terminal) => terminal.webContentsId === webContentsId && terminal.meta.sessionId === sessionId) + .sort((a, b) => a.meta.createdAt - b.meta.createdAt) + const outputByTerminalId: Record = {} + for (const terminal of sessionTerminals) { + outputByTerminalId[terminal.meta.id] = terminal.outputBuffer + } + return { + terminals: sessionTerminals.map((terminal) => ({ ...terminal.meta })), + outputByTerminalId, + } +} + +export function writeTerminal(terminalId: string, data: string, webContentsId: number): void { + const terminal = getOwnedTerminal(terminalId, webContentsId) + if (!terminal) return + terminal.ptyProcess.write(data) +} + +export function resizeTerminal(input: TerminalResizeInput, webContentsId: number): void { + const terminal = getOwnedTerminal(input.terminalId, webContentsId) + if (!terminal) return + const cols = clampSize(input.cols, terminal.meta.cols, 10, 500) + const rows = clampSize(input.rows, terminal.meta.rows, 4, 200) + terminal.meta.cols = cols + terminal.meta.rows = rows + terminal.ptyProcess.resize(cols, rows) +} + +export function stopTerminal(terminalId: string, webContentsId: number): void { + const ownedTerminal = getOwnedTerminal(terminalId, webContentsId) + if (!ownedTerminal) return + flushTerminalData(terminalId) + const terminal = removeTerminal(terminalId) + if (!terminal) return + terminal.ptyProcess.kill() +} + +export function stopTerminalsForWebContents(webContentsId: number): void { + const terminalIds = Array.from(terminalIdsByWebContents.get(webContentsId) ?? []) + for (const terminalId of terminalIds) { + flushTerminalData(terminalId) + const terminal = removeTerminal(terminalId) + if (!terminal) continue + terminal.ptyProcess.kill() + } + activeSessionByWebContents.delete(webContentsId) +} + +/** + * 停止指定会话的全部终端。会话被删除时调用,避免后台 PTY 进程泄漏并持续占用全局名额。 + * 注意:仅在会话真正删除时使用,组件临时卸载(切换 Tab)不应调用,否则会破坏重连体验。 + */ +export function stopTerminalsForSession(sessionId: string): void { + const terminalIds = Array.from(terminals.values()) + .filter((terminal) => terminal.meta.sessionId === sessionId) + .map((terminal) => terminal.meta.id) + for (const terminalId of terminalIds) { + flushTerminalData(terminalId) + const terminal = removeTerminal(terminalId) + if (!terminal) continue + terminal.ptyProcess.kill() + } +} + +export function stopAllTerminals(): void { + for (const id of Array.from(terminals.keys())) { + flushTerminalData(id) + const terminal = removeTerminal(id) + if (!terminal) continue + terminal.ptyProcess.kill() + } + activeSessionByWebContents.clear() +} + +export function setActiveTerminalSession(webContentsId: number, sessionId: string | null): void { + if (sessionId) { + activeSessionByWebContents.set(webContentsId, sessionId) + } else { + activeSessionByWebContents.delete(webContentsId) + } + rescheduleTerminalFlushesForWebContents(webContentsId) +} + +/** + * 查询终端是否有前台进程在运行。 + * + * unix 下 node-pty 的 `.process` 动态反映前台进程组 leader 名:空闲时等于启动 shell 名 + * (如 zsh/bash),运行命令时为该命令名(如 sleep/vim)。据此判断关闭是否会终止用户进程。 + * + * Windows 下 `.process` 是创建时固定的 shell 名,无法反映前台进程,故保守视为忙 + * (foregroundProcess=null,busy=true),保持与原有「总是确认」一致的行为。 + */ +export function getTerminalBusyState(terminalId: string, webContentsId: number): TerminalBusyResult { + const terminal = getOwnedTerminal(terminalId, webContentsId) + if (!terminal) return { busy: false, foregroundProcess: null } + if (process.platform === 'win32') { + return { busy: true, foregroundProcess: null } + } + let foreground: string + try { + foreground = terminal.ptyProcess.process + } catch { + return { busy: true, foregroundProcess: null } + } + const busy = Boolean(foreground) && foreground !== terminal.meta.shell + return { busy, foregroundProcess: busy ? foreground : null } +} diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 2c7004a26..05380fd01 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -6,7 +6,7 @@ */ import { contextBridge, ipcRenderer, webUtils } from 'electron' -import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, INSTALLER_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, CHAT_TOOL_IPC_CHANNELS, FEISHU_IPC_CHANNELS, DINGTALK_IPC_CHANNELS, WECHAT_IPC_CHANNELS, AUTOMATION_IPC_CHANNELS } from '@proma/shared' +import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, INSTALLER_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, CHAT_TOOL_IPC_CHANNELS, FEISHU_IPC_CHANNELS, DINGTALK_IPC_CHANNELS, WECHAT_IPC_CHANNELS, AUTOMATION_IPC_CHANNELS, TERMINAL_IPC_CHANNELS } from '@proma/shared' import { USER_PROFILE_IPC_CHANNELS, SETTINGS_IPC_CHANNELS, SCRATCH_PAD_IPC_CHANNELS, APP_ICON_IPC_CHANNELS, DOCK_BADGE_IPC_CHANNELS, STORAGE_IPC_CHANNELS } from '../types' import type { RuntimeStatus, @@ -104,6 +104,18 @@ import type { Automation, CreateAutomationInput, UpdateAutomationInput, + TerminalDataEvent, + TerminalExitEvent, + TerminalListInput, + TerminalListResult, + TerminalSession, + TerminalStartInput, + TerminalWriteInput, + TerminalStopInput, + TerminalResizeInput, + TerminalSetActiveSessionInput, + TerminalBusyInput, + TerminalBusyResult, } from '@proma/shared' import type { UserProfile, @@ -759,6 +771,35 @@ export interface ElectronAPI { /** 搜索工作区文件(用于 @ 引用,支持附加目录) */ searchWorkspaceFiles: (rootPath: string, query: string, limit?: number, additionalPaths?: string[], sessionPaths?: string[]) => Promise + // ===== 终端 ===== + + /** 启动会话终端 */ + startTerminal: (input: TerminalStartInput) => Promise + + /** 列出当前会话已存在的终端 */ + listTerminals: (input: TerminalListInput) => Promise + + /** 写入终端 stdin */ + writeTerminal: (input: TerminalWriteInput) => Promise + + /** 停止终端 */ + stopTerminal: (input: TerminalStopInput) => Promise + + /** 调整终端尺寸 */ + resizeTerminal: (input: TerminalResizeInput) => Promise + + /** 设置当前可见的终端会话 */ + setActiveTerminalSession: (input: TerminalSetActiveSessionInput) => Promise + + /** 查询终端是否有前台进程在运行 */ + isTerminalBusy: (input: TerminalBusyInput) => Promise + + /** 订阅终端输出 */ + onTerminalData: (callback: (event: TerminalDataEvent) => void) => () => void + + /** 订阅终端退出 */ + onTerminalExit: (callback: (event: TerminalExitEvent) => void) => () => void + // ===== 系统提示词管理 ===== /** 获取系统提示词配置 */ @@ -1899,6 +1940,46 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.SEARCH_WORKSPACE_FILES, rootPath, query, limit, additionalPaths, sessionPaths) }, + startTerminal: (input: TerminalStartInput) => { + return ipcRenderer.invoke(TERMINAL_IPC_CHANNELS.START, input) + }, + + listTerminals: (input: TerminalListInput) => { + return ipcRenderer.invoke(TERMINAL_IPC_CHANNELS.LIST, input) + }, + + writeTerminal: (input: TerminalWriteInput) => { + return ipcRenderer.invoke(TERMINAL_IPC_CHANNELS.WRITE, input) + }, + + stopTerminal: (input: TerminalStopInput) => { + return ipcRenderer.invoke(TERMINAL_IPC_CHANNELS.STOP, input) + }, + + resizeTerminal: (input: TerminalResizeInput) => { + return ipcRenderer.invoke(TERMINAL_IPC_CHANNELS.RESIZE, input) + }, + + setActiveTerminalSession: (input: TerminalSetActiveSessionInput) => { + return ipcRenderer.invoke(TERMINAL_IPC_CHANNELS.SET_ACTIVE_SESSION, input) + }, + + isTerminalBusy: (input: TerminalBusyInput) => { + return ipcRenderer.invoke(TERMINAL_IPC_CHANNELS.IS_BUSY, input) + }, + + onTerminalData: (callback: (event: TerminalDataEvent) => void) => { + const listener = (_: unknown, event: TerminalDataEvent): void => callback(event) + ipcRenderer.on(TERMINAL_IPC_CHANNELS.DATA, listener) + return () => { ipcRenderer.removeListener(TERMINAL_IPC_CHANNELS.DATA, listener) } + }, + + onTerminalExit: (callback: (event: TerminalExitEvent) => void) => { + const listener = (_: unknown, event: TerminalExitEvent): void => callback(event) + ipcRenderer.on(TERMINAL_IPC_CHANNELS.EXIT, listener) + return () => { ipcRenderer.removeListener(TERMINAL_IPC_CHANNELS.EXIT, listener) } + }, + // 系统提示词管理 getSystemPromptConfig: () => { return ipcRenderer.invoke(SYSTEM_PROMPT_IPC_CHANNELS.GET_CONFIG) diff --git a/apps/electron/src/renderer/components/agent/SidePanel.tsx b/apps/electron/src/renderer/components/agent/SidePanel.tsx index e4965852c..e4d661af3 100644 --- a/apps/electron/src/renderer/components/agent/SidePanel.tsx +++ b/apps/electron/src/renderer/components/agent/SidePanel.tsx @@ -20,6 +20,7 @@ import { cn } from '@/lib/utils' import { FileBrowser, FileDropZone, FileTypeIcon, FileSearchBar, computeRevealAncestors, isPathUnderRoot, computeTreeRowLayout, AncestorGuides, STICKY_ROW_BASE_CLASS, canBeSticky } from '@/components/file-browser' import { DiffPanelTabBar } from '@/components/diff/DiffPanelTabBar' import { DiffChangesList } from '@/components/diff/DiffChangesList' +import { TerminalPanel } from './TerminalPanel' import { agentSidePanelOpenAtom, workspaceFilesVersionAtom, @@ -61,6 +62,15 @@ interface SidePanelProps { } const filePanelActionButtonClass = 'h-6 w-6 flex-shrink-0 rounded-md text-muted-foreground/75 hover:bg-accent/70 hover:text-foreground [&_svg]:size-3.5' +const MIN_TERMINAL_HEIGHT = 180 +const MIN_FILE_PANEL_HEIGHT = 180 +const TERMINAL_RESIZE_HANDLE_HEIGHT = 6 +const terminalHeightBySession = new Map() + +function clampTerminalHeight(height: number, availableHeight: number): number { + const maxHeight = Math.max(MIN_TERMINAL_HEIGHT, availableHeight - MIN_FILE_PANEL_HEIGHT - TERMINAL_RESIZE_HANDLE_HEIGHT) + return Math.max(MIN_TERMINAL_HEIGHT, Math.min(maxHeight, Math.round(height))) +} export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, width = 280 }: SidePanelProps): React.ReactElement { // per-session 侧面板状态(默认打开) @@ -396,6 +406,77 @@ export function SidePanel({ sessionId, sessionPath, activeTab, onTabChange, widt const hasWorkspaceAttachedItems = wsAttachedDirs.length > 0 || wsAttachedFiles.length > 0 const interfaceVariant = useAtomValue(interfaceVariantAtom) const isClassic = interfaceVariant === 'classic' + const splitContainerRef = React.useRef(null) + const terminalPanelRef = React.useRef(null) + const [terminalHeight, setTerminalHeight] = React.useState(null) + const [terminalCollapsed, setTerminalCollapsed] = React.useState(false) + const [terminalResizing, setTerminalResizing] = React.useState(false) + const showTerminal = activeTab === 'changes' + + React.useEffect(() => { + setTerminalHeight(terminalHeightBySession.get(sessionId) ?? null) + setTerminalResizing(false) + }, [sessionId]) + + React.useEffect(() => { + const container = splitContainerRef.current + if (!container) return + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + const availableHeight = entry.contentRect.height + setTerminalHeight((height) => { + if (height == null) return height + const nextHeight = clampTerminalHeight(height, availableHeight) + terminalHeightBySession.set(sessionId, nextHeight) + return nextHeight + }) + }) + observer.observe(container) + return () => observer.disconnect() + }, [sessionId]) + + const handleTerminalResizeStart = React.useCallback((event: React.PointerEvent) => { + if (terminalCollapsed) return + event.preventDefault() + + const container = splitContainerRef.current + if (!container) return + const availableHeight = container.getBoundingClientRect().height + const currentHeight = terminalHeight + ?? terminalPanelRef.current?.getBoundingClientRect().height + ?? availableHeight / 2 + const startY = event.clientY + const startHeight = clampTerminalHeight(currentHeight, availableHeight) + const targetSessionId = sessionId + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + setTerminalResizing(true) + + const handlePointerMove = (moveEvent: PointerEvent): void => { + moveEvent.preventDefault() + const delta = startY - moveEvent.clientY + const nextHeight = clampTerminalHeight(startHeight + delta, availableHeight) + terminalHeightBySession.set(targetSessionId, nextHeight) + setTerminalHeight(nextHeight) + } + + const stopResize = (): void => { + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + setTerminalResizing(false) + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', stopResize) + window.removeEventListener('pointercancel', stopResize) + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', stopResize) + window.addEventListener('pointercancel', stopResize) + }, [sessionId, terminalCollapsed, terminalHeight]) return (
setIsOpen(false)} isWindows={isWindows} /> +
+
{activeTab === 'changes' ? ( sessionPath ? (
)} +
+ {showTerminal && !terminalCollapsed && ( +
+ )} + {showTerminal && ( + + )} +
) diff --git a/apps/electron/src/renderer/components/agent/TerminalPanel.tsx b/apps/electron/src/renderer/components/agent/TerminalPanel.tsx new file mode 100644 index 000000000..eee5f46b3 --- /dev/null +++ b/apps/electron/src/renderer/components/agent/TerminalPanel.tsx @@ -0,0 +1,589 @@ +/** + * TerminalPanel — Agent 右侧面板底部终端。 + */ + +import * as React from 'react' +import { FitAddon } from '@xterm/addon-fit' +import { Terminal as XTerm } from '@xterm/xterm' +import type { ITheme } from '@xterm/xterm' +import '@xterm/xterm/css/xterm.css' +import { ChevronDown, ChevronUp, Plus, Terminal as TerminalIcon, X } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import type { TerminalExitEvent, TerminalSession } from '@proma/shared' + +interface TerminalPanelProps { + sessionId: string + cwd: string | null + height?: number | null + isResizing?: boolean + onCollapsedChange?: (collapsed: boolean) => void +} + +interface TerminalRuntime { + terminal: XTerm + fitAddon: FitAddon + disposables: Array<{ dispose: () => void }> +} + +interface CachedTerminalState { + terminals: TerminalSession[] + activeTerminalId: string | null + isCollapsed: boolean + starting: boolean + outputByTerminalId: Map +} + +interface TerminalTabStyle extends React.CSSProperties { + '--terminal-tab-indicator-width'?: string +} + +const TERMINAL_OUTPUT_LIMIT = 500_000 +const MAX_TERMINALS_PER_SESSION = 4 +const SHORT_TERMINAL_LABEL_THRESHOLD = 16 +const TERMINAL_TAB_LABEL_CHAR_WIDTH = 8 +const TERMINAL_TAB_CONTENT_CHROME_WIDTH = 34 +const SHORT_TERMINAL_INDICATOR_EXTRA = 80 +const MAX_TERMINAL_INDICATOR_WIDTH = 520 +const terminalStateBySession = new Map() +const sessionIdByTerminalId = new Map() + +function getTerminalTabIndicatorWidth(label: string): number { + const contentWidth = label.length * TERMINAL_TAB_LABEL_CHAR_WIDTH + TERMINAL_TAB_CONTENT_CHROME_WIDTH + const extraWidth = label.length <= SHORT_TERMINAL_LABEL_THRESHOLD ? SHORT_TERMINAL_INDICATOR_EXTRA : 0 + return Math.min(MAX_TERMINAL_INDICATOR_WIDTH, Math.max(120, Math.round(contentWidth + extraWidth))) +} + +function getCachedTerminalState(sessionId: string): CachedTerminalState { + let state = terminalStateBySession.get(sessionId) + if (!state) { + state = { + terminals: [], + activeTerminalId: null, + isCollapsed: false, + starting: false, + outputByTerminalId: new Map(), + } + terminalStateBySession.set(sessionId, state) + } + return state +} + +function appendTerminalOutput(state: CachedTerminalState, terminalId: string, data: string): void { + const previous = state.outputByTerminalId.get(terminalId) ?? '' + const next = previous.length + data.length > TERMINAL_OUTPUT_LIMIT + ? (previous + data).slice(-TERMINAL_OUTPUT_LIMIT) + : previous + data + state.outputByTerminalId.set(terminalId, next) +} + +function cssHslVar(styles: CSSStyleDeclaration, name: string, fallback: string): string { + const value = styles.getPropertyValue(name).trim() + return value ? `hsl(${value})` : fallback +} + +function createTerminalTheme(container: HTMLElement): ITheme { + const styles = getComputedStyle(container) + const background = cssHslVar(styles, '--content-area', '#11100f') + const foreground = cssHslVar(styles, '--foreground', '#e8e0d9') + const muted = cssHslVar(styles, '--muted-foreground', '#8b8580') + const primary = cssHslVar(styles, '--primary', '#78e08f') + const accent = cssHslVar(styles, '--accent-foreground', '#64dce5') + return { + background, + foreground, + cursor: foreground, + cursorAccent: background, + selectionBackground: cssHslVar(styles, '--primary', '#5a4a42'), + black: background, + red: '#ff8a80', + green: primary, + yellow: '#f4d35e', + blue: '#82aaff', + magenta: '#f5a3c7', + cyan: accent, + white: foreground, + brightBlack: muted, + brightRed: '#ff9f95', + brightGreen: primary, + brightYellow: '#ffe28a', + brightBlue: '#9bbcff', + brightMagenta: '#ffc1db', + brightCyan: accent, + brightWhite: foreground, + } +} + +export const TerminalPanel = React.forwardRef(function TerminalPanel( + { sessionId, cwd, height, isResizing = false, onCollapsedChange }, + ref +): React.ReactElement { + const [terminals, setTerminals] = React.useState([]) + const [activeTerminalId, setActiveTerminalId] = React.useState(null) + const [isCollapsed, setIsCollapsed] = React.useState(false) + const [startupError, setStartupError] = React.useState(null) + const terminalsRef = React.useRef([]) + const runtimesRef = React.useRef>(new Map()) + const containerRefs = React.useRef>(new Map()) + const currentSessionIdRef = React.useRef(sessionId) + + React.useEffect(() => { + terminalsRef.current = terminals + }, [terminals]) + + React.useEffect(() => { + currentSessionIdRef.current = sessionId + }, [sessionId]) + + React.useEffect(() => { + getCachedTerminalState(sessionId).isCollapsed = isCollapsed + onCollapsedChange?.(isCollapsed) + }, [isCollapsed, onCollapsedChange, sessionId]) + + React.useEffect(() => { + getCachedTerminalState(sessionId).activeTerminalId = activeTerminalId + }, [activeTerminalId, sessionId]) + + const activeTerminal = terminals.find((terminal) => terminal.id === activeTerminalId) ?? terminals[0] ?? null + const hasReachedSessionLimit = terminals.length >= MAX_TERMINALS_PER_SESSION + const createTerminalDisabled = !cwd || hasReachedSessionLimit + const createTerminalTooltip = !cwd + ? '等待会话初始化' + : hasReachedSessionLimit + ? `当前会话最多 ${MAX_TERMINALS_PER_SESSION} 个终端` + : '新建终端' + + const resizeTerminal = React.useCallback((terminalId: string) => { + const runtime = runtimesRef.current.get(terminalId) + if (!runtime) return + try { + runtime.fitAddon.fit() + runtime.terminal.scrollToBottom() + window.electronAPI.resizeTerminal({ + terminalId, + cols: runtime.terminal.cols, + rows: runtime.terminal.rows, + }).catch(console.error) + } catch (error) { + console.error('[TerminalPanel] 调整终端尺寸失败:', error) + } + }, []) + + const focusTerminal = React.useCallback((terminalId: string | null) => { + if (!terminalId) return + const runtime = runtimesRef.current.get(terminalId) + runtime?.terminal.focus() + }, []) + + const refreshTerminalThemes = React.useCallback(() => { + for (const [terminalId, runtime] of runtimesRef.current) { + const container = containerRefs.current.get(terminalId) + if (!container) continue + runtime.terminal.options.theme = createTerminalTheme(container) + } + }, []) + + const setupTerminal = React.useCallback((meta: TerminalSession) => { + if (runtimesRef.current.has(meta.id)) return + const container = containerRefs.current.get(meta.id) + if (!container) return + + const terminal = new XTerm({ + allowProposedApi: false, + convertEol: true, + cursorBlink: true, + cursorStyle: 'block', + disableStdin: meta.exitCode !== undefined, + fontFamily: 'Menlo, Monaco, "Cascadia Mono", "SFMono-Regular", monospace', + fontSize: 12, + fontWeight: 500, + lineHeight: 1.35, + scrollback: 8000, + theme: createTerminalTheme(container), + }) + const fitAddon = new FitAddon() + terminal.loadAddon(fitAddon) + terminal.open(container) + + const dataDisposable = terminal.onData((data) => { + window.electronAPI.writeTerminal({ terminalId: meta.id, data }).catch(console.error) + }) + + const runtime: TerminalRuntime = { + terminal, + fitAddon, + disposables: [dataDisposable], + } + runtimesRef.current.set(meta.id, runtime) + + const owningSessionId = sessionIdByTerminalId.get(meta.id) ?? sessionId + const cachedOutput = getCachedTerminalState(owningSessionId).outputByTerminalId.get(meta.id) + if (cachedOutput) terminal.write(cachedOutput, () => terminal.scrollToBottom()) + + requestAnimationFrame(() => { + resizeTerminal(meta.id) + if (meta.id === activeTerminalId) terminal.focus() + }) + }, [activeTerminalId, resizeTerminal]) + + const startNewTerminal = React.useCallback(async () => { + if (!cwd) return + const targetSessionId = sessionId + const cachedState = getCachedTerminalState(targetSessionId) + if (cachedState.starting) return + if (cachedState.terminals.length >= MAX_TERMINALS_PER_SESSION) return + cachedState.starting = true + setStartupError(null) + try { + const meta = await window.electronAPI.startTerminal({ sessionId: targetSessionId, cwd, cols: 80, rows: 24 }) + const state = getCachedTerminalState(targetSessionId) + const title = state.terminals.length === 0 ? 'Terminal 1' : `Terminal ${state.terminals.length + 1}` + const terminal = { ...meta, title } + state.terminals = [...state.terminals, terminal] + state.activeTerminalId = meta.id + state.starting = false + sessionIdByTerminalId.set(meta.id, targetSessionId) + if (currentSessionIdRef.current === targetSessionId) { + setTerminals(state.terminals) + setActiveTerminalId(meta.id) + } + } catch (error) { + getCachedTerminalState(targetSessionId).starting = false + if (currentSessionIdRef.current === targetSessionId) { + setStartupError(error instanceof Error ? error.message : '启动终端失败') + } + console.error('[TerminalPanel] 启动终端失败:', error) + } + }, [cwd, sessionId]) + + const disposeRendererTerminals = React.useCallback(() => { + for (const runtime of runtimesRef.current.values()) { + runtime.disposables.forEach((disposable) => disposable.dispose()) + runtime.terminal.dispose() + } + runtimesRef.current.clear() + containerRefs.current.clear() + }, []) + + React.useEffect(() => { + window.electronAPI.setActiveTerminalSession({ sessionId }).catch(console.error) + return () => { + window.electronAPI.setActiveTerminalSession({ sessionId: null }).catch(console.error) + } + }, [sessionId]) + + React.useEffect(() => { + disposeRendererTerminals() + const state = getCachedTerminalState(sessionId) + setIsCollapsed(state.isCollapsed) + setStartupError(null) + terminalsRef.current = [] + + let cancelled = false + async function loadExistingTerminals(): Promise { + try { + const snapshot = await window.electronAPI.listTerminals({ sessionId }) + if (cancelled || currentSessionIdRef.current !== sessionId) return + + const nextState = getCachedTerminalState(sessionId) + // 主进程的 title 退化为 cwd 的 basename(即会话 UUID),重连时按渲染端「Terminal N」约定重新编号 + const renumbered = snapshot.terminals.map((terminal, index) => ({ ...terminal, title: `Terminal ${index + 1}` })) + nextState.terminals = renumbered + nextState.outputByTerminalId = new Map(Object.entries(snapshot.outputByTerminalId)) + const terminalIds = new Set(renumbered.map((terminal) => terminal.id)) + if (!nextState.activeTerminalId || !terminalIds.has(nextState.activeTerminalId)) { + nextState.activeTerminalId = renumbered[0]?.id ?? null + } + for (const terminal of renumbered) { + sessionIdByTerminalId.set(terminal.id, sessionId) + } + setTerminals(nextState.terminals) + setActiveTerminalId(nextState.activeTerminalId) + terminalsRef.current = nextState.terminals + if (nextState.terminals.length === 0 && cwd && !nextState.starting) { + void startNewTerminal() + } + } catch (error) { + console.error('[TerminalPanel] 加载已有终端失败:', error) + const fallbackState = getCachedTerminalState(sessionId) + setTerminals(fallbackState.terminals) + setActiveTerminalId(fallbackState.activeTerminalId ?? fallbackState.terminals[0]?.id ?? null) + terminalsRef.current = fallbackState.terminals + if (fallbackState.terminals.length === 0 && cwd && !fallbackState.starting) void startNewTerminal() + } + } + + void loadExistingTerminals() + + return () => { + cancelled = true + disposeRendererTerminals() + terminalsRef.current = [] + } + }, [sessionId, cwd, disposeRendererTerminals, startNewTerminal]) + + React.useEffect(() => { + const offData = window.electronAPI.onTerminalData((event) => { + const owningSessionId = sessionIdByTerminalId.get(event.terminalId) + if (owningSessionId) { + appendTerminalOutput(getCachedTerminalState(owningSessionId), event.terminalId, event.data) + } + const runtime = runtimesRef.current.get(event.terminalId) + if (runtime) { + runtime.terminal.write(event.data, () => runtime.terminal.scrollToBottom()) + } + }) + + const offExit = window.electronAPI.onTerminalExit((event: TerminalExitEvent) => { + const message = `\r\n[进程已退出,code=${event.exitCode ?? 'null'}${event.signal ? `, signal=${event.signal}` : ''}]\r\n` + const owningSessionId = sessionIdByTerminalId.get(event.terminalId) + const state = owningSessionId ? getCachedTerminalState(owningSessionId) : null + if (state) { + appendTerminalOutput(state, event.terminalId, message) + state.terminals = state.terminals.map((terminal) => ( + terminal.id === event.terminalId ? { ...terminal, exitCode: event.exitCode } : terminal + )) + } + const runtime = runtimesRef.current.get(event.terminalId) + if (runtime) { + runtime.terminal.write(message, () => runtime.terminal.scrollToBottom()) + } + if (owningSessionId && currentSessionIdRef.current === owningSessionId && state) { + setTerminals(state.terminals) + } + }) + + return () => { + offData() + offExit() + } + }, []) + + React.useEffect(() => { + for (const meta of terminals) setupTerminal(meta) + }, [setupTerminal, terminals]) + + React.useEffect(() => { + const container = activeTerminalId ? containerRefs.current.get(activeTerminalId) : null + if (!container || !activeTerminalId || isCollapsed) return + const observer = new ResizeObserver(() => resizeTerminal(activeTerminalId)) + observer.observe(container) + resizeTerminal(activeTerminalId) + focusTerminal(activeTerminalId) + return () => observer.disconnect() + }, [activeTerminalId, focusTerminal, isCollapsed, resizeTerminal]) + + React.useEffect(() => { + if (isCollapsed || !activeTerminalId) return + requestAnimationFrame(() => { + resizeTerminal(activeTerminalId) + focusTerminal(activeTerminalId) + }) + }, [activeTerminalId, focusTerminal, isCollapsed, resizeTerminal]) + + React.useEffect(() => { + const html = document.documentElement + let frame = 0 + const scheduleRefresh = (): void => { + if (frame) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = 0 + refreshTerminalThemes() + }) + } + const observer = new MutationObserver(scheduleRefresh) + observer.observe(html, { attributes: true, attributeFilter: ['class', 'style'] }) + return () => { + if (frame) cancelAnimationFrame(frame) + observer.disconnect() + } + }, [refreshTerminalThemes]) + + const removeTerminalFromView = React.useCallback((terminalId: string) => { + const runtime = runtimesRef.current.get(terminalId) + if (runtime) { + runtime.disposables.forEach((disposable) => disposable.dispose()) + runtime.terminal.dispose() + runtimesRef.current.delete(terminalId) + } + const state = getCachedTerminalState(sessionId) + state.outputByTerminalId.delete(terminalId) + sessionIdByTerminalId.delete(terminalId) + setTerminals((prev) => { + const next = prev.filter((terminal) => terminal.id !== terminalId) + state.terminals = next + if (activeTerminalId === terminalId) { + const nextActiveId = next[0]?.id ?? null + state.activeTerminalId = nextActiveId + setActiveTerminalId(nextActiveId) + } + return next + }) + }, [activeTerminalId, sessionId]) + + const closeTerminal = React.useCallback(async (terminalId: string) => { + if (terminals.length <= 1) return + const targetTerminal = terminals.find((terminal) => terminal.id === terminalId) + // 仅当终端内确有未退出的前台进程时才二次确认,空闲 shell 直接关闭,避免无谓打扰 + if (targetTerminal && targetTerminal.exitCode === undefined) { + let busy = true + let foregroundProcess: string | null = null + try { + const state = await window.electronAPI.isTerminalBusy({ terminalId }) + busy = state.busy + foregroundProcess = state.foregroundProcess + } catch (error) { + // 查询失败时保守确认,避免误杀正在运行的进程 + console.error('[TerminalPanel] 查询终端忙碌状态失败:', error) + } + if (busy) { + const runningHint = foregroundProcess ? `正在运行 ${foregroundProcess},` : '' + const confirmed = window.confirm(`终端 "${targetTerminal.title}" ${runningHint}关闭会终止其中的进程,确定要关闭吗?`) + if (!confirmed) return + } + } + window.electronAPI.stopTerminal({ terminalId }) + .then(() => removeTerminalFromView(terminalId)) + .catch((error) => { + console.error('[TerminalPanel] 关闭终端失败:', error) + setStartupError(error instanceof Error ? error.message : '关闭终端失败') + }) + }, [removeTerminalFromView, terminals]) + + return ( +
+
+ + + + + {isCollapsed ? '显示终端' : '隐藏终端'} + +
+ {terminals.map((terminal, index) => { + const active = terminal.id === activeTerminalId + const label = terminal.title || `Terminal ${index + 1}` + const tabStyle: TerminalTabStyle | undefined = active + ? { '--terminal-tab-indicator-width': `${getTerminalTabIndicatorWidth(label)}px` } + : undefined + return ( +
+ + {terminal.exitCode !== undefined && } + {terminals.length > 1 && ( + + )} +
+ ) + })} +
+ + + + + {createTerminalTooltip} + +
+ +
focusTerminal(activeTerminal?.id ?? null)} + > + {terminals.length === 0 && ( +
+ {startupError ?? (cwd ? '正在启动终端...' : '等待会话初始化...')} +
+ )} + {terminals.map((terminal) => ( +
+
{ + if (node) { + containerRefs.current.set(terminal.id, node) + setupTerminal(terminal) + } else { + containerRefs.current.delete(terminal.id) + } + }} + className="h-full w-full min-h-0 overflow-hidden" + /> +
+ ))} +
+
+ ) +}) diff --git a/apps/electron/src/renderer/components/app-shell/AppShell.tsx b/apps/electron/src/renderer/components/app-shell/AppShell.tsx index 941b5dae8..9262c1fce 100644 --- a/apps/electron/src/renderer/components/app-shell/AppShell.tsx +++ b/apps/electron/src/renderer/components/app-shell/AppShell.tsx @@ -22,7 +22,7 @@ import { detectIsWindows, WINDOW_CONTROLS_INSET_RIGHT } from '@/lib/platform' import { cn } from '@/lib/utils' const MIN_RIGHT_PANEL_WIDTH = 300 -const MAX_RIGHT_PANEL_WIDTH = 420 +const MAX_RIGHT_PANEL_WIDTH = 840 function clampRightPanelWidth(width: number): number { return Math.max(MIN_RIGHT_PANEL_WIDTH, Math.min(MAX_RIGHT_PANEL_WIDTH, width)) diff --git a/apps/electron/src/renderer/components/diff/DiffTabContent.tsx b/apps/electron/src/renderer/components/diff/DiffTabContent.tsx index a38d82279..465970932 100644 --- a/apps/electron/src/renderer/components/diff/DiffTabContent.tsx +++ b/apps/electron/src/renderer/components/diff/DiffTabContent.tsx @@ -438,8 +438,9 @@ export function DiffTabContent({ filePath, dirPath, sessionId, gitRoot, previewO const contentCacheScope = React.useMemo(() => JSON.stringify({ dirPath, gitRoot: gitRoot ?? '', + baseRef: baseRef ?? '', basePaths: basePaths ?? [], - }), [basePaths, dirPath, gitRoot]) + }), [basePaths, baseRef, dirPath, gitRoot]) const getContentCacheKey = React.useCallback((mode: 'preview' | 'diff', version: number) => ( `${sessionId}:${mode}:${filePath}@v${version}:${contentCacheScope}` @@ -655,7 +656,7 @@ export function DiffTabContent({ filePath, dirPath, sessionId, gitRoot, previewO load() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filePath, dirPath, gitRoot, previewOnly, previewContentVersion, fileAccess, isPdf, isDocx, isOfficePreview, isLegacyOffice, isImage, sessionId, ext, getContentCacheKey]) + }, [filePath, dirPath, gitRoot, previewOnly, previewContentVersion, fileAccess, isPdf, isDocx, isOfficePreview, isLegacyOffice, isImage, sessionId, ext, getContentCacheKey, baseRef]) // refreshVersion 触发的静默刷新:仅 diff 模式、内容有变化时才更新 state const prevRefreshRef = React.useRef(-1) @@ -672,7 +673,7 @@ export function DiffTabContent({ filePath, dirPath, sessionId, gitRoot, previewO let cancelled = false async function refresh() { try { - const result = await window.electronAPI.getDiffContents({ dirPath, filePath, gitRoot, sessionId }) + const result = await window.electronAPI.getDiffContents({ dirPath, filePath, gitRoot, sessionId, baseRef }) if (cancelled || !result) return const newC = result.newContent ?? '' const oldC = result.oldContent ?? '' @@ -689,7 +690,7 @@ export function DiffTabContent({ filePath, dirPath, sessionId, gitRoot, previewO } refresh() return () => { cancelled = true } - }, [refreshVersion, previewOnly, filePath, dirPath, gitRoot, sessionId, getContentCacheKey]) + }, [refreshVersion, previewOnly, filePath, dirPath, gitRoot, sessionId, getContentCacheKey, baseRef]) // diff 模式:内容加载完成后若新旧一致(无差异),通知父组件关闭预览面板 const emptyDiffFiredRef = React.useRef(false) diff --git a/apps/electron/src/renderer/styles/globals.css b/apps/electron/src/renderer/styles/globals.css index 862f59875..87d18387d 100644 --- a/apps/electron/src/renderer/styles/globals.css +++ b/apps/electron/src/renderer/styles/globals.css @@ -975,6 +975,30 @@ background-color: hsl(var(--tab-surface) / 0.42); } +.terminal-tabbar .terminal-tab-active { + background-color: transparent; +} + +.terminal-tabbar .terminal-tab-active::after { + content: ""; + position: absolute; + left: 5px; + right: auto; + bottom: 0; + width: var(--terminal-tab-indicator-width, 200px); + max-width: calc(100% - 10px); + height: 3px; + border-radius: 0; + background-color: hsl(var(--primary)); + box-shadow: 0 0 10px hsl(var(--primary) / 0.28); + pointer-events: none; + z-index: 2; +} + +.terminal-tabbar .terminal-tab-inactive:hover { + background-color: hsl(var(--tab-surface) / 0.42); +} + [data-input-mode] > div:first-child { background-color: hsl(var(--input-surface)) !important; } diff --git a/bun.lock b/bun.lock index db9ff53e4..d4dd57cf0 100644 --- a/bun.lock +++ b/bun.lock @@ -44,10 +44,13 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@pierre/diffs": "^1.1.20", "@xmldom/xmldom": "^0.8.11", + "@xterm/addon-fit": "0.11.0", + "@xterm/xterm": "6.0.0", "adm-zip": "^0.5.17", "dompurify": "^3.4.5", "iconv-lite": "^0.6.3", "mammoth": "^1.12.0", + "node-pty": "1.1.0", "pdfjs-dist": "^4.10.38", }, "devDependencies": { @@ -861,6 +864,10 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -1791,7 +1798,7 @@ "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], - "node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], @@ -1799,6 +1806,8 @@ "node-gyp": ["node-gyp@9.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.0.3", "nopt": "^6.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "^1.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="], @@ -2547,6 +2556,8 @@ "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "make-dir/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e4f2b5e12..2f45a0ef8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -53,3 +53,6 @@ export * from './wechat' // 定时任务(Automation)相关类型 export * from './automation' + +// 终端相关类型 +export * from './terminal' diff --git a/packages/shared/src/types/terminal.ts b/packages/shared/src/types/terminal.ts new file mode 100644 index 000000000..841d51809 --- /dev/null +++ b/packages/shared/src/types/terminal.ts @@ -0,0 +1,100 @@ +/** + * 右侧面板终端相关类型与 IPC 通道。 + */ + +/** 终端会话元数据 */ +export interface TerminalSession { + id: string + sessionId: string + cwd: string + shell: string + pid: number | null + title: string + createdAt: number + cols: number + rows: number + exitCode?: number | null +} + +/** 创建终端输入 */ +export interface TerminalStartInput { + sessionId: string + cwd: string + cols?: number + rows?: number +} + +/** 列出当前会话已存在的终端输入 */ +export interface TerminalListInput { + sessionId: string +} + +/** 当前会话终端快照,用于渲染进程重载后重新接回主进程 PTY */ +export interface TerminalListResult { + terminals: TerminalSession[] + outputByTerminalId: Record +} + +/** 写入终端输入 */ +export interface TerminalWriteInput { + terminalId: string + data: string +} + +/** 停止终端输入 */ +export interface TerminalStopInput { + terminalId: string +} + +/** 调整终端尺寸输入 */ +export interface TerminalResizeInput { + terminalId: string + cols: number + rows: number +} + +/** 设置当前可见的终端会话,用于主进程降低后台会话输出刷新频率 */ +export interface TerminalSetActiveSessionInput { + sessionId: string | null +} + +/** 查询终端是否有前台进程在运行的输入 */ +export interface TerminalBusyInput { + terminalId: string +} + +/** + * 终端忙碌状态查询结果。 + * busy=true 表示终端内有非 shell 的前台进程在运行(关闭会终止它)。 + * foregroundProcess 为检测到的前台进程名;无法检测(如 Windows)时为 null,此时 busy 保守置为 true。 + */ +export interface TerminalBusyResult { + busy: boolean + foregroundProcess: string | null +} + +/** 终端输出事件 */ +export interface TerminalDataEvent { + terminalId: string + data: string +} + +/** 终端退出事件 */ +export interface TerminalExitEvent { + terminalId: string + exitCode: number | null + signal: string | null +} + +/** 终端 IPC 通道 */ +export const TERMINAL_IPC_CHANNELS = { + START: 'terminal:start', + LIST: 'terminal:list', + WRITE: 'terminal:write', + RESIZE: 'terminal:resize', + STOP: 'terminal:stop', + SET_ACTIVE_SESSION: 'terminal:set-active-session', + IS_BUSY: 'terminal:is-busy', + DATA: 'terminal:data', + EXIT: 'terminal:exit', +} as const