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:
1 parent
494ef6b9d0
commit
9ce71795b8
38 files changed
+1437
-186
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user