feat: add terminal broadcast, context menu, and shell close

Complete multi-pane broadcast, standard terminal right-click actions, batch host selection with progress, and native ssh_close_shell so closing a pane tears down the remote shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-05 19:53:39 +08:00
1 parent 494ef6b9d0
commit 9ce71795b8
38 files changed
+1437 -186

No files matched your search

+85 -26
View File
@@ -7,7 +7,9 @@ import {
Play,
Server,
ShieldAlert,
ShieldCheck,
Sparkles,
Square,
TerminalSquare,
X,
} from 'lucide-react'
@@ -15,8 +17,16 @@ import { useAiProviderStore } from '../../stores/aiProviderStore'
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../utils/aiResponse'
import { writeClipboardText } from '../../platform/clipboard'
import { analyzeAgentOutput } from '../../utils/agentOutput'
import { AGENT_RISK_LABEL, assessAgentCommandRisk, type AgentCommandRisk } from '../../utils/agentCommandRisk'
import {
AGENT_RISK_LABEL,
AGENT_SAFETY_MODES,
agentCommandNeedsConfirmation,
assessAgentCommandRisk,
type AgentCommandRisk,
type AgentSafetyMode,
} from '../../utils/agentCommandRisk'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { useSettingsStore } from '../../stores/settingsStore'
export interface TerminalAiContext {
selection: string
@@ -43,6 +53,7 @@ interface AgentStep {
const MAX_AGENT_STEPS = 8
const MAX_TOOL_OUTPUT_CHARS = 12_000
const MAX_AGENT_CONTEXT_CHARS = 48_000
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'balanced', 'permissive']
function truncateMiddle(value: string, limit: number): string {
if (value.length <= limit) return value
@@ -102,6 +113,8 @@ export default function AiTerminalPanel({
isLocal = false,
}: AiTerminalPanelProps) {
const store = useAiProviderStore()
const safetyMode = useSettingsStore((state) => state.settings.agentSafetyMode)
const setSetting = useSettingsStore((state) => state.setSetting)
const [prompt, setPrompt] = useState('')
const [result, setResult] = useState('')
const [contextNote, setContextNote] = useState('仅在开始时读取当前选中内容')
@@ -150,6 +163,7 @@ export default function AiTerminalPanel({
: '未附带终端内容 · Agent 将通过 ssh-exec 自主检查'
)
setRunning(true)
const runSafetyMode = safetyMode
const completedSteps: AgentStep[] = []
const commandCounts = new Map<string, number>()
@@ -204,21 +218,24 @@ export default function AiTerminalPanel({
if (risk === 'critical') {
throw new Error(`命令命中破坏性操作硬红线,不提供确认放行:${command}`)
}
const awaiting: AgentStep = { round, command, risk, status: 'awaiting' }
setSteps([...completedSteps, awaiting])
const approved = await confirm({
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
message: `目标:SSH · ${targetLabel}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
danger: risk === 'high',
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
})
if (!approved) {
setSteps([...completedSteps])
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
return
const proposed: AgentStep = { round, command, risk, status: 'running' }
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
const awaiting = { ...proposed, status: 'awaiting' as const }
setSteps([...completedSteps, awaiting])
const approved = await confirm({
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
message: `目标:SSH · ${targetLabel}\n安全模式:${AGENT_SAFETY_MODES[runSafetyMode].label}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
danger: risk === 'high',
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
})
if (!approved) {
setSteps([...completedSteps])
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
return
}
}
setSteps([...completedSteps, { ...awaiting, status: 'running' }])
setSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command)
if (runIdRef.current !== runId) return
const rawOutput = executed.success
@@ -259,8 +276,14 @@ export default function AiTerminalPanel({
window.setTimeout(() => setCopied(false), 1200)
}
const stopAgent = () => {
runIdRef.current += 1
setRunning(false)
setResult('任务已停止;正在执行的单条 SSH 命令不会被强制中断,但不会再继续下一步。')
}
return (
<aside className="border-dark-700/50 bg-dark-850 flex w-[390px] flex-shrink-0 flex-col border-l">
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
<header className="border-dark-700/50 flex items-center gap-2 border-b px-3 py-2.5">
<Sparkles className="h-4 w-4 text-accent-blue" />
<div className="min-w-0 flex-1">
@@ -269,6 +292,9 @@ export default function AiTerminalPanel({
{provider && model ? `LLM: ${provider.name} · ${model.label}` : '尚未配置模型'}
</div>
</div>
<span className="rounded-md border border-dark-600 bg-dark-900/70 px-2 py-1 text-[10px] text-content-secondary">
{AGENT_SAFETY_MODES[safetyMode].label}
</span>
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
</button>
@@ -282,7 +308,40 @@ export default function AiTerminalPanel({
<span className="truncate">已连接主机:{connectionId && !isLocal ? targetLabel : '尚未连接远程 SSH'}</span>
</div>
<p className="text-[10px] leading-relaxed text-txt-faint">
Agent 通过当前连接执行、观察再决定下一步;每条真实命令都需要你确认。
Agent 只使用当前 SSH 连接执行、观察并继续处理,不切换目标主机。
</p>
</div>
<div className="rounded-lg border border-dark-700/60 bg-dark-900/45 p-2.5">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium text-content-secondary">
<ShieldCheck className="h-3.5 w-3.5 text-accent-green" /> 执行安全模式
</div>
<div className="grid grid-cols-3 gap-1" role="radiogroup" aria-label="Agent 安全模式">
{SAFETY_MODE_ORDER.map((mode) => {
const option = AGENT_SAFETY_MODES[mode]
const selected = safetyMode === mode
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={selected}
disabled={running}
title={`${option.short}:${option.description}`}
onClick={() => void setSetting('agentSafetyMode', mode)}
className={`rounded-md border px-2 py-1.5 text-[11px] transition-colors disabled:opacity-50 ${
selected
? 'border-accent-blue/50 bg-accent-blue/15 text-accent-blue'
: 'border-dark-700 bg-dark-800 text-txt-muted hover:text-content-primary'
}`}
>
{option.label}
</button>
)
})}
</div>
<p className="mt-2 text-[10px] leading-relaxed text-txt-faint">
{AGENT_SAFETY_MODES[safetyMode].short} · {AGENT_SAFETY_MODES[safetyMode].description}
</p>
</div>
@@ -302,15 +361,15 @@ export default function AiTerminalPanel({
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-txt-faint">Shift+回车换行 · 回车发送</span>
<button
type="button"
onClick={() => void runAgent()}
disabled={running}
className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
{running ? '执行中…' : '执行'}
</button>
{running ? (
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex min-w-20 items-center justify-center gap-1.5">
<Square className="h-3.5 w-3.5" /> 停止
</button>
) : (
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5">
<Play className="h-3.5 w-3.5" /> 执行
</button>
)}
</div>
<div className="text-[10px] text-txt-faint">{contextNote}</div>
@@ -377,7 +436,7 @@ export default function AiTerminalPanel({
<footer className="border-dark-700/40 flex items-start gap-2 border-t px-3 py-2 text-[10px] leading-relaxed text-txt-faint">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
最多 {MAX_AGENT_STEPS} 步,重复命令自动拦截;命令、AI 对话和工具输出只存在于当前面板内存。
默认均衡模式仅高风险确认;critical 永久拒绝。最多 {MAX_AGENT_STEPS} 步,命令、对话和输出不落盘。
</footer>
{ConfirmDialogComponent}
</aside>