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>
+2 -1
View File
@@ -429,7 +429,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
{ConfirmDialogComponent}
<BatchCommandDialog
isOpen={batchCommandOpen}
sessions={batchCommandSessions}
sessions={sessions}
initialSelectedIds={batchCommandSessions.map((session) => session.id)}
onClose={() => setBatchCommandOpen(false)}
/>
</>
+2 -1
View File
@@ -5,6 +5,7 @@ import { usePoolStore } from '../../stores/connection/poolStore'
import type { ConnectionState } from '../../stores/connection'
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
import { useSessionStore } from '../../stores'
import packageJson from '../../../package.json'
// ============== Status Display Config ==============
@@ -84,7 +85,7 @@ function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionSta
export default function StatusBar() {
const togglePanel = useConnectionPanelStore((s) => s.toggle)
const [version, setVersion] = useState('1.0.0')
const [version, setVersion] = useState(packageJson.version)
const [currentTime, setCurrentTime] = useState(new Date())
// Tab store
+186 -60
View File
@@ -1,5 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertCircle, CheckCircle2, Clock3, Loader2, Play, TerminalSquare } from 'lucide-react'
import {
AlertCircle,
Check,
CheckCircle2,
Clock3,
Loader2,
Play,
Search,
Server,
TerminalSquare,
} from 'lucide-react'
import type { Session } from '../../stores/sessionStore'
import { getSessionConfig } from '../../stores/connection'
import { useSettingsStore } from '../../stores/settingsStore'
@@ -19,21 +29,51 @@ interface BatchCommandResult {
interface BatchCommandDialogProps {
isOpen: boolean
sessions: Session[]
initialSelectedIds?: string[]
onClose: () => void
}
const MAX_CONCURRENCY = 5
const EMPTY_SELECTION: string[] = []
export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchCommandDialogProps) {
export default function BatchCommandDialog({
isOpen,
sessions,
initialSelectedIds = EMPTY_SELECTION,
onClose,
}: BatchCommandDialogProps) {
const [command, setCommand] = useState('')
const [query, setQuery] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [running, setRunning] = useState(false)
const [results, setResults] = useState<BatchCommandResult[]>([])
const timeout = useSettingsStore((state) => state.settings.timeout)
useEffect(() => {
if (!isOpen) return
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
}, [isOpen, sessions])
const available = new Set(sessions.map((session) => session.id))
const requested = initialSelectedIds.filter((id) => available.has(id))
const initial = requested.length > 0 ? requested : sessions.map((session) => session.id)
setSelectedIds(new Set(initial))
setResults(initial.map((sessionId) => ({ sessionId, status: 'idle' })))
setCommand('')
setQuery('')
}, [initialSelectedIds, isOpen, sessions])
const filteredSessions = useMemo(() => {
const normalized = query.trim().toLowerCase()
if (!normalized) return sessions
return sessions.filter((session) =>
[session.name, session.host, session.username, session.group, ...(session.tags || [])]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalized))
)
}, [query, sessions])
const selectedSessions = useMemo(
() => sessions.filter((session) => selectedIds.has(session.id)),
[selectedIds, sessions]
)
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
setResults((current) => current.map((result) =>
@@ -75,23 +115,22 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
durationMs: Date.now() - startedAt,
})
} finally {
if (connectionId) {
await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
}
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
}
}, [timeout, updateResult])
const execute = useCallback(async () => {
const commandText = command.trim()
if (!commandText || running || sessions.length === 0) return
if (!commandText || running || selectedSessions.length === 0) return
const targets = [...selectedSessions]
setRunning(true)
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
setResults(targets.map((session) => ({ sessionId: session.id, status: 'idle' })))
let nextIndex = 0
const worker = async () => {
while (nextIndex < sessions.length) {
const session = sessions[nextIndex]
while (nextIndex < targets.length) {
const session = targets[nextIndex]
nextIndex += 1
await executeForSession(session, commandText)
}
@@ -99,40 +138,66 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
try {
await Promise.all(
Array.from({ length: Math.min(MAX_CONCURRENCY, sessions.length) }, () => worker())
Array.from({ length: Math.min(MAX_CONCURRENCY, targets.length) }, () => worker())
)
} finally {
setRunning(false)
}
}, [command, executeForSession, running, sessions])
}, [command, executeForSession, running, selectedSessions])
const toggleSession = (sessionId: string) => {
if (running) return
setSelectedIds((current) => {
const next = new Set(current)
if (next.has(sessionId)) next.delete(sessionId)
else next.add(sessionId)
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
return next
})
}
const allFilteredSelected = filteredSessions.length > 0 && filteredSessions.every((session) => selectedIds.has(session.id))
const toggleFiltered = () => {
if (running) return
setSelectedIds((current) => {
const next = new Set(current)
for (const session of filteredSessions) {
if (allFilteredSelected) next.delete(session.id)
else next.add(session.id)
}
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
return next
})
}
const summary = useMemo(() => ({
success: results.filter((result) => result.status === 'success').length,
error: results.filter((result) => result.status === 'error').length,
running: results.filter((result) => result.status === 'running').length,
}), [results])
const handleClose = () => {
if (!running) onClose()
}
const completed = summary.success + summary.error
const progress = selectedSessions.length > 0 ? Math.round((completed / selectedSessions.length) * 100) : 0
const handleClose = () => { if (!running) onClose() }
return (
<Modal
isOpen={isOpen}
title="批量命令"
description={`${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
description={`已选 ${selectedSessions.length}/${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
icon={<TerminalSquare className="h-5 w-5" />}
onClose={handleClose}
showFooter
showConfirm={false}
disableBackdropClose={running}
size="full"
className="max-w-5xl"
footer={
<div className="flex w-full items-center justify-between gap-3">
<span className="text-xs text-txt-muted">
{summary.success > 0 && <span className="text-accent-green">成功 {summary.success}</span>}
{summary.success > 0 && summary.error > 0 && ' · '}
{summary.error > 0 && <span className="text-red-400">失败 {summary.error}</span>}
</span>
<div className="min-w-0 text-xs text-txt-muted">
{running ? `执行中 ${completed}/${selectedSessions.length}` : `目标 ${selectedSessions.length} 台`}
{summary.success > 0 && <span className="ml-2 text-accent-green">成功 {summary.success}</span>}
{summary.error > 0 && <span className="ml-2 text-red-400">失败 {summary.error}</span>}
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>关闭</Button>
<Button
@@ -140,22 +205,21 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
size="sm"
icon={<Play className="h-3.5 w-3.5" />}
onClick={() => void execute()}
disabled={!command.trim()}
disabled={!command.trim() || selectedSessions.length === 0}
loading={running}
>
{results.some((result) => result.status === 'success' || result.status === 'error')
? '再次执行'
: '执行命令'}
{completed > 0 ? '再次执行' : `执行到 ${selectedSessions.length} 台`}
</Button>
</div>
</div>
}
>
<div className="space-y-4">
<div>
<label htmlFor="batch-command" className="mb-1.5 block text-xs font-medium text-content-secondary">
远程命令
</label>
<section className="rounded-xl border border-dark-700/70 bg-dark-900/55 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<label htmlFor="batch-command" className="text-xs font-medium text-content-secondary">远程命令</label>
<span className="text-[10px] text-txt-faint">Ctrl/⌘ + Enter 执行</span>
</div>
<textarea
id="batch-command"
value={command}
@@ -171,39 +235,101 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
autoFocus
spellCheck={false}
placeholder="例如:date"
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-950 px-3 py-2.5 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
/>
</div>
{(running || completed > 0) && (
<div className="mt-2 h-1 overflow-hidden rounded-full bg-dark-700">
<div className="h-full rounded-full bg-accent-blue transition-[width]" style={{ width: `${progress}%` }} />
</div>
)}
</section>
<div className="max-h-[48vh] space-y-2 overflow-y-auto pr-1">
{sessions.map((session) => {
const result = results.find((item) => item.sessionId === session.id)
const status = result?.status ?? 'idle'
return (
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-900/65">
<div className="flex min-h-10 items-center gap-2 border-b border-dark-700/70 px-3 py-2">
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content-primary">{session.name}</span>
<span className="truncate font-mono text-[11px] text-txt-muted">
{session.username}@{session.host}:{session.port || 22}
</span>
{result?.durationMs !== undefined && (
<span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>
)}
<div className="grid min-h-[390px] grid-cols-[300px_minmax(0,1fr)] gap-3">
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-900/45">
<div className="space-y-2 border-b border-dark-700/60 p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
<Server className="h-3.5 w-3.5" /> 目标主机
</div>
{(result?.output || result?.error) && (
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 text-xs leading-relaxed ${
result.error ? 'text-red-300' : 'text-content-secondary'
}`}>
{result.error || result.output}
</pre>
)}
<button type="button" disabled={running || filteredSessions.length === 0} onClick={toggleFiltered} className="text-[11px] text-accent-blue disabled:opacity-40">
{allFilteredSelected ? '取消当前结果' : '全选当前结果'}
</button>
</div>
)
})}
<label className="relative block">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-faint" />
<input
aria-label="筛选主机"
value={query}
onChange={(event) => setQuery(event.target.value)}
disabled={running}
placeholder="名称、IP、分组或标签"
className="ui-input-sm w-full pl-8"
/>
</label>
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{filteredSessions.map((session) => {
const selected = selectedIds.has(session.id)
return (
<button
key={session.id}
type="button"
role="checkbox"
aria-checked={selected}
disabled={running}
onClick={() => toggleSession(session.id)}
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors ${
selected
? 'border-accent-blue/40 bg-accent-blue/10'
: 'border-transparent hover:border-dark-700 hover:bg-dark-800'
}`}
>
<span className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${selected ? 'border-accent-blue bg-accent-blue text-white' : 'border-dark-500 bg-dark-950'}`}>
{selected && <Check className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-content-primary">{session.name}</span>
<span className="block truncate font-mono text-[10px] text-txt-faint">{session.username}@{session.host}:{session.port || 22}</span>
</span>
<span className="max-w-20 truncate rounded bg-dark-700/70 px-1.5 py-0.5 text-[9px] text-txt-muted">{session.group || '默认'}</span>
</button>
)
})}
{filteredSessions.length === 0 && <div className="py-12 text-center text-xs text-txt-faint">没有匹配的主机</div>}
</div>
</section>
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-900/45">
<div className="flex items-center justify-between border-b border-dark-700/60 px-3 py-2.5">
<span className="text-xs font-medium text-content-secondary">执行结果</span>
<span className="text-[10px] text-txt-faint">输出仅保留在当前弹框内</span>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2.5">
{selectedSessions.map((session) => {
const result = results.find((item) => item.sessionId === session.id)
const status = result?.status ?? 'idle'
return (
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-950/70">
<div className="flex min-h-10 items-center gap-2 px-3 py-2">
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
<span className="min-w-0 flex-1 truncate text-xs font-medium text-content-primary">{session.name}</span>
<span className="truncate font-mono text-[10px] text-txt-muted">{session.host}</span>
{result?.durationMs !== undefined && <span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>}
</div>
{(result?.output || result?.error) && (
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words border-t border-dark-700/70 px-3 py-2 text-[11px] leading-relaxed ${result.error ? 'text-red-300' : 'text-content-secondary'}`}>
{result.error || result.output}
</pre>
)}
</div>
)
})}
{selectedSessions.length === 0 && <div className="flex h-full min-h-64 items-center justify-center text-xs text-txt-faint">请从左侧选择目标主机</div>}
</div>
</section>
</div>
</div>
</Modal>
@@ -69,10 +69,11 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (isOpen && initialTarget) {
setTarget(initialTarget)
setError(null)
}
if (!isOpen) return
setTarget(initialTarget)
setPassword('')
setError(null)
setIsConnecting(false)
}, [initialTarget, isOpen])
const handleSubmit = async () => {
@@ -0,0 +1,126 @@
import { useEffect, type ComponentType } from 'react'
import {
Clipboard,
ClipboardPaste,
Eraser,
Maximize2,
Minimize2,
MousePointer2,
SeparatorHorizontal,
SplitSquareHorizontal,
X,
} from 'lucide-react'
interface MenuAction {
label: string
icon: ComponentType<{ className?: string }>
onSelect: () => void
disabled?: boolean
danger?: boolean
dividerBefore?: boolean
}
interface TerminalContextMenuProps {
position: { x: number; y: number }
hasSelection: boolean
canSplit: boolean
canClosePane: boolean
canCloseTerminal: boolean
closeLabel: string
maximized: boolean
onClose: () => void
onCopy: () => void
onPaste: () => void
onSelectAll: () => void
onClear: () => void
onSplitRight: () => void
onSplitDown: () => void
onToggleMaximize: () => void
onClosePane: () => void
}
export default function TerminalContextMenu({
position,
hasSelection,
canSplit,
canClosePane,
canCloseTerminal,
closeLabel,
maximized,
onClose,
onCopy,
onPaste,
onSelectAll,
onClear,
onSplitRight,
onSplitDown,
onToggleMaximize,
onClosePane,
}: TerminalContextMenuProps) {
useEffect(() => {
const dismiss = () => onClose()
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
window.addEventListener('mousedown', dismiss)
window.addEventListener('blur', dismiss)
window.addEventListener('resize', dismiss)
window.addEventListener('keydown', escape)
return () => {
window.removeEventListener('mousedown', dismiss)
window.removeEventListener('blur', dismiss)
window.removeEventListener('resize', dismiss)
window.removeEventListener('keydown', escape)
}
}, [onClose])
const actions: MenuAction[] = [
{ label: '复制', icon: Clipboard, onSelect: onCopy, disabled: !hasSelection },
{ label: '粘贴', icon: ClipboardPaste, onSelect: onPaste },
{ label: '全选终端内容', icon: MousePointer2, onSelect: onSelectAll },
{ label: '清空显示', icon: Eraser, onSelect: onClear },
{ label: '向右分屏', icon: SplitSquareHorizontal, onSelect: onSplitRight, disabled: !canSplit, dividerBefore: true },
{ label: '向下分屏', icon: SeparatorHorizontal, onSelect: onSplitDown, disabled: !canSplit },
{
label: maximized ? '恢复分屏布局' : '最大化当前分屏',
icon: maximized ? Minimize2 : Maximize2,
onSelect: onToggleMaximize,
disabled: !canClosePane,
},
{ label: closeLabel, icon: X, onSelect: onClosePane, disabled: !canCloseTerminal, danger: true, dividerBefore: true },
]
const left = Math.min(position.x, Math.max(8, window.innerWidth - 220))
const top = Math.min(position.y, Math.max(8, window.innerHeight - 330))
return (
<div
role="menu"
aria-label="终端操作"
style={{ left, top }}
onMouseDown={(event) => event.stopPropagation()}
className="fixed z-[140] w-52 overflow-hidden rounded-lg border border-dark-600/90 bg-dark-800 py-1.5 shadow-2xl shadow-black/45"
>
{actions.map((action) => (
<div key={action.label}>
{action.dividerBefore && <div className="my-1 border-t border-dark-700/70" />}
<button
type="button"
role="menuitem"
disabled={action.disabled}
onClick={() => {
onClose()
action.onSelect()
}}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-35 ${
action.danger
? 'text-red-300 hover:bg-red-500/10'
: 'text-content-secondary hover:bg-dark-700 hover:text-content-primary'
}`}
>
<action.icon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-1">{action.label}</span>
</button>
</div>
))}
</div>
)
}
+193 -35
View File
@@ -24,6 +24,11 @@ import { themes } from '../../themes/terminal'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { getConnectionStore } from '../../stores/connection'
import {
getTerminalBroadcastTargets,
terminalBroadcastTargetCount,
useTerminalBroadcastStore,
} from '../../stores/terminalBroadcastStore'
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
import { shellPayloadByteLength } from '../../utils/shellData'
@@ -35,9 +40,10 @@ import { createLogger } from '../../utils/logger'
import type { TerminalViewProps } from './types'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
import { resolveTerminalInputTargets } from '../../utils/terminalPanes'
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
import TerminalContextMenu from './TerminalContextMenu'
const logger = createLogger('TerminalWorkspace')
const MAX_PANES = 4
@@ -55,6 +61,10 @@ interface PaneState {
interface PaneRuntime {
capture: () => TerminalAiContext
getSize: () => { cols: number; rows: number }
copySelection: () => void
pasteClipboard: () => void
selectAll: () => void
clear: () => void
}
function captureTerminal(term: Terminal): TerminalAiContext {
@@ -83,6 +93,7 @@ interface PaneTerminalProps {
isActive: boolean
onFocus: () => void
onInput: (paneId: string, data: string) => void
onContextMenu: (paneId: string, position: { x: number; y: number }, hasSelection: boolean) => void
onShellReady: (paneId: string, shellId: string) => void
registerShellHandler: (paneId: string, handler: (shellId: string, data: unknown) => void) => void
unregisterShellHandler: (paneId: string) => void
@@ -101,6 +112,7 @@ function PaneTerminal({
isActive,
onFocus,
onInput,
onContextMenu,
onShellReady,
registerShellHandler,
unregisterShellHandler,
@@ -112,12 +124,14 @@ function PaneTerminal({
const fitAddonRef = useRef<FitAddon | null>(null)
const shellIdRef = useRef(shellId)
const onInputRef = useRef(onInput)
const onContextMenuRef = useRef(onContextMenu)
const creatingRef = useRef(false)
const { settings } = useSettingsStore()
const settingsRef = useRef(settings)
settingsRef.current = settings
shellIdRef.current = shellId
onInputRef.current = onInput
onContextMenuRef.current = onContextMenu
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
@@ -176,16 +190,19 @@ function PaneTerminal({
}
}
const copySelection = () => {
const selection = term.getSelection()
if (!selection) return
void writeClipboardText(selection).catch((error) => {
logger.warn('写入剪贴板失败', error)
})
}
term.attachCustomKeyEventHandler((event) => {
if (event.type !== 'keydown') return true
if (isTerminalCopyShortcut(event)) {
event.preventDefault()
const selection = term.getSelection()
if (selection) {
void writeClipboardText(selection).catch((error) => {
logger.warn('写入剪贴板失败', error)
})
}
copySelection()
return false
}
if (isTerminalPasteShortcut(event)) {
@@ -198,14 +215,12 @@ function PaneTerminal({
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault()
const selection = term.getSelection()
if (selection) {
void writeClipboardText(selection).catch((error) => {
logger.warn('写入剪贴板失败', error)
})
} else {
void pasteClipboard()
}
event.stopPropagation()
onContextMenuRef.current(
paneId,
{ x: event.clientX, y: event.clientY },
term.hasSelection()
)
}
container.addEventListener('contextmenu', handleContextMenu)
@@ -245,6 +260,10 @@ function PaneTerminal({
registerRuntime(paneId, {
capture: () => captureTerminal(term),
getSize: () => ({ cols: term.cols, rows: term.rows }),
copySelection,
pasteClipboard: () => { void pasteClipboard() },
selectAll: () => term.selectAll(),
clear: () => term.clear(),
})
return () => {
@@ -324,7 +343,7 @@ function PaneTerminal({
export default function TerminalWorkspace({ tabId, isActive = true }: TerminalViewProps) {
const { loadSettings } = useSettingsStore()
const { getTabById, updateTab, updateTabConnectionInstance } = useTabStore()
const { getTabById, updateTab, updateTabConnectionInstance, closeTab, tabs } = useTabStore()
const snapshotStore = useTerminalSnapshotStore()
const currentTab = getTabById(tabId)
const savedLayout = currentTab?.splitLayout
@@ -342,8 +361,17 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(savedLayout?.direction || 'horizontal')
const [ratio, setRatio] = useState(savedLayout?.ratio ?? 0.5)
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null)
const [broadcastInput, setBroadcastInput] = useState(false)
const broadcastInput = useTerminalBroadcastStore((state) => state.enabled)
const setBroadcastInput = useTerminalBroadcastStore((state) => state.setEnabled)
const registerBroadcastWorkspace = useTerminalBroadcastStore((state) => state.registerWorkspace)
const unregisterBroadcastWorkspace = useTerminalBroadcastStore((state) => state.unregisterWorkspace)
const broadcastTargetCount = useTerminalBroadcastStore(terminalBroadcastTargetCount)
const [aiPanelOpen, setAiPanelOpen] = useState(false)
const [contextMenu, setContextMenu] = useState<{
paneId: string
position: { x: number; y: number }
hasSelection: boolean
} | null>(null)
const draggingRef = useRef(false)
const layoutHydrated = useRef(false)
@@ -392,6 +420,30 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
}, [connectionState, shellIdRef])
// A single global broadcast scope covers every ready SSH shell in every
// currently open terminal tab. Local host shells are intentionally excluded.
useEffect(() => {
const connectionId = connectionIdRef.current
const targets = connectionState === 'connected' && connectionId && connectionId !== 'local' && !currentTab?.isLocal
? panes.flatMap((pane) => pane.shellId
? [{
tabId,
paneId: pane.id,
shellId: pane.shellId,
connectionId,
label: currentTab?.title || 'SSH 终端',
}]
: [])
: []
registerBroadcastWorkspace({ tabId, targets })
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
useEffect(() => {
if (broadcastInput && broadcastTargetCount < 2) setBroadcastInput(false)
}, [broadcastInput, broadcastTargetCount, setBroadcastInput])
// Fan-out shell data to panes (SSH + local)
useEffect(() => {
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
@@ -571,17 +623,31 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const handlePaneInput = useCallback(
(sourcePaneId: string, data: string) => {
const targets = resolveTerminalInputTargets(panesRef.current, sourcePaneId, broadcastInput)
for (const targetShellId of targets) {
const shouldBroadcast = shouldBroadcastTerminalInput(
broadcastInput,
currentTab?.isLocal,
connectionIdRef.current
)
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets() : []
const targets = shouldBroadcast
? broadcastTargets.map((target) => target.shellId)
: resolveTerminalInputTargets(panesRef.current, sourcePaneId, false)
for (const targetShellId of new Set(targets)) {
void shellWrite(targetShellId, data)
}
const connectionId = connectionIdRef.current
if (targets.length > 0 && connectionId && connectionId !== 'local') {
getConnectionStore().updateActivity(connectionId)
if (shouldBroadcast) {
for (const connectionId of new Set(broadcastTargets.map((target) => target.connectionId))) {
getConnectionStore().updateActivity(connectionId)
}
} else {
const connectionId = connectionIdRef.current
if (targets.length > 0 && connectionId && connectionId !== 'local') {
getConnectionStore().updateActivity(connectionId)
}
}
},
[broadcastInput, connectionIdRef]
[broadcastInput, connectionIdRef, currentTab?.isLocal]
)
const split = useCallback(
@@ -614,27 +680,92 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
}, [isActive, split])
const closePane = (paneId: string) => {
if (panes.length <= 1) return
const pane = panes.find((item) => item.id === paneId)
if (currentTab?.isLocal && pane?.shellId) void shellKill(pane.shellId)
const closePane = useCallback((paneId: string) => {
if (panesRef.current.length <= 1) return
const pane = panesRef.current.find((item) => item.id === paneId)
const connectionId = connectionIdRef.current
if (pane?.shellId) {
const closingShellId = pane.shellId
void shellKill(closingShellId)
.then((result) => {
if (!result.success) {
logger.warn('关闭分屏 Shell 失败', {
shellId: closingShellId,
error: result.error || 'unknown',
})
}
})
.catch((error) => logger.warn('关闭分屏 Shell 失败', { shellId: closingShellId, error }))
if (connectionId && connectionId !== 'local') {
getConnectionStore().removeShell(connectionId, pane.shellId)
}
}
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
setPanes((prev) => {
const next = prev.filter((p) => p.id !== paneId)
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
setFocusedPaneId(next[0].id)
if (next[0].shellId) shellIdRef.current = next[0].shellId
if (next[0].shellId) {
shellIdRef.current = next[0].shellId
if (connectionId) updateTabConnectionInstance(tabId, { connectionId, shellId: next[0].shellId })
}
}
return next
})
}
}, [connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance])
const closePaneOrTab = useCallback((paneId: string) => {
if (panesRef.current.length > 1) {
closePane(paneId)
return
}
if (tabs.length <= 1) return
const connectionId = connectionIdRef.current
const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : [])
void (async () => {
for (const paneShellId of paneShells) {
const result = await shellKill(paneShellId).catch((error) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
}))
if (!result.success) {
logger.warn('关闭标签 Shell 失败', {
shellId: paneShellId,
error: result.error || 'unknown',
})
}
if (connectionId && connectionId !== 'local') getConnectionStore().removeShell(connectionId, paneShellId)
}
if (connectionId && connectionId !== 'local') {
await getConnectionStore().releaseConnection(connectionId, tabId)
}
closeTab(tabId)
})()
}, [closePane, closeTab, connectionIdRef, tabId, tabs.length])
const openTerminalContextMenu = useCallback((
paneId: string,
position: { x: number; y: number },
hasSelection: boolean
) => {
setFocusedPaneId(paneId)
const pane = panesRef.current.find((item) => item.id === paneId)
if (pane?.shellId && connectionIdRef.current) {
shellIdRef.current = pane.shellId
updateTabConnectionInstance(tabId, {
connectionId: connectionIdRef.current,
shellId: pane.shellId,
})
}
setContextMenu({ paneId, position, hasSelection })
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
useEffect(() => {
if (panes.length <= 1 && broadcastInput) setBroadcastInput(false)
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
setMaximizedPaneId(null)
}
}, [broadcastInput, maximizedPaneId, panes])
}, [maximizedPaneId, panes])
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
@@ -745,11 +876,13 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
<div className="bg-dark-600/70 mx-1 h-4 w-px" />
<button
type="button"
title={broadcastInput ? '广播输入已开启:键盘输入将发送到所有分屏' : '广播输入到所有分屏'}
title={broadcastInput
? `广播已开启:键盘输入将发送到全部 ${broadcastTargetCount} 个已连接 SSH 终端`
: `广播输入到所有打开的 SSH 终端(${broadcastTargetCount})`}
aria-label="广播输入"
aria-pressed={broadcastInput}
disabled={!multi || connectionState !== 'connected'}
onClick={() => setBroadcastInput((enabled) => !enabled)}
disabled={isLocal || connectionState !== 'connected' || broadcastTargetCount < 2}
onClick={() => setBroadcastInput(!broadcastInput)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
broadcastInput
? 'bg-accent-orange/15 text-accent-orange'
@@ -771,7 +904,11 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
>
<Sparkles className="h-3.5 w-3.5" /> AI 助手
</button>
{broadcastInput && <div className="ui-chip border-accent-orange/30 text-accent-orange">广播中</div>}
{broadcastInput && (
<div className="ui-chip border-accent-orange/30 text-accent-orange">
广播中 · {broadcastTargetCount}
</div>
)}
<div className="ui-chip tabular-nums">
<Square className="h-2.5 w-2.5" />
{panes.length}/{MAX_PANES}
@@ -801,6 +938,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
isFocused={focusedPaneId === pane.id}
isActive={isActive}
onInput={handlePaneInput}
onContextMenu={openTerminalContextMenu}
onFocus={() => {
setFocusedPaneId(pane.id)
if (pane.shellId && connectionIdRef.current) {
@@ -865,6 +1003,26 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
/>
)}
</div>
{contextMenu && (
<TerminalContextMenu
position={contextMenu.position}
hasSelection={contextMenu.hasSelection}
canSplit={connectionState === 'connected' && panes.length < MAX_PANES}
canClosePane={panes.length > 1}
canCloseTerminal={panes.length > 1 || tabs.length > 1}
closeLabel={panes.length > 1 ? '关闭当前分屏' : '关闭当前终端标签'}
maximized={maximizedPaneId === contextMenu.paneId}
onClose={() => setContextMenu(null)}
onCopy={() => paneRuntimesRef.current.get(contextMenu.paneId)?.copySelection()}
onPaste={() => paneRuntimesRef.current.get(contextMenu.paneId)?.pasteClipboard()}
onSelectAll={() => paneRuntimesRef.current.get(contextMenu.paneId)?.selectAll()}
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
onSplitRight={() => split('horizontal')}
onSplitDown={() => split('vertical')}
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
/>
)}
</div>
)
}
+1
View File
@@ -28,6 +28,7 @@ export interface DesktopApiContract {
) => Promise<{ success: boolean; shellId?: string; error?: string }>
sshShellWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
sshCloseShell: (shellId: string) => Promise<{ success: boolean; error?: string }>
// Local terminal (host shell)
localListShells: () => Promise<{
success: boolean
+1
View File
@@ -90,6 +90,7 @@ export const desktopApi: DesktopApi = {
}),
sshShellWrite: (shellId, data) => call('ssh_shell_write', { shellId, data: bytes(data) }),
sshShellResize: (shellId, cols, rows) => call('ssh_shell_resize', { shellId, cols, rows }),
sshCloseShell: (shellId) => call('ssh_close_shell', { shellId }),
localListShells: async () => ({ success: true, shells: await call('local_list_shells') }),
localCreateShell: async (options, shellId) => {
+6
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_KEYWORD_HIGHLIGHT_RULES,
type KeywordHighlightRule,
} from '../utils/keywordHighlight'
import type { AgentSafetyMode } from '../utils/agentCommandRisk'
const logger = createLogger('SettingsStore')
@@ -34,6 +35,7 @@ export interface AppSettings {
scrollback: number
keywordHighlightEnabled: boolean
keywordHighlightRules: KeywordHighlightRule[]
agentSafetyMode: AgentSafetyMode
}
const defaultSettings: AppSettings = {
@@ -56,6 +58,7 @@ const defaultSettings: AppSettings = {
scrollback: 10000,
keywordHighlightEnabled: true,
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
agentSafetyMode: 'balanced',
}
export function normalizeAppSettings(value: unknown): AppSettings {
@@ -69,6 +72,9 @@ export function normalizeAppSettings(value: unknown): AppSettings {
const fallback = defaultSettings[key]
normalized[key] = typeof candidate === typeof fallback ? candidate : fallback
}
if (!['strict', 'balanced', 'permissive'].includes(String(source.agentSafetyMode))) {
normalized.agentSafetyMode = defaultSettings.agentSafetyMode
}
return normalized as unknown as AppSettings
}
+62
View File
@@ -0,0 +1,62 @@
import { create } from 'zustand'
export interface TerminalBroadcastTarget {
tabId: string
paneId: string
shellId: string
connectionId: string
label: string
}
interface TerminalBroadcastWorkspace {
tabId: string
targets: TerminalBroadcastTarget[]
}
interface TerminalBroadcastState {
enabled: boolean
workspaces: Record<string, TerminalBroadcastWorkspace>
setEnabled: (enabled: boolean) => void
registerWorkspace: (workspace: TerminalBroadcastWorkspace) => void
unregisterWorkspace: (tabId: string) => void
reset: () => void
}
export const useTerminalBroadcastStore = create<TerminalBroadcastState>((set) => ({
enabled: false,
workspaces: {},
setEnabled: (enabled) => set({ enabled }),
registerWorkspace: (workspace) =>
set((state) => ({
workspaces: { ...state.workspaces, [workspace.tabId]: workspace },
})),
unregisterWorkspace: (tabId) =>
set((state) => {
const workspaces = { ...state.workspaces }
delete workspaces[tabId]
return { workspaces }
}),
reset: () => set({ enabled: false, workspaces: {} }),
}))
/** Every ready remote shell across all currently open terminal tabs and panes. */
export function getTerminalBroadcastTargets(): TerminalBroadcastTarget[] {
const seen = new Set<string>()
const targets: TerminalBroadcastTarget[] = []
for (const workspace of Object.values(useTerminalBroadcastStore.getState().workspaces)) {
for (const target of workspace.targets) {
if (seen.has(target.shellId)) continue
seen.add(target.shellId)
targets.push(target)
}
}
return targets
}
export function terminalBroadcastTargetCount(state: TerminalBroadcastState): number {
const shellIds = new Set<string>()
for (const workspace of Object.values(state.workspaces)) {
for (const target of workspace.targets) shellIds.add(target.shellId)
}
return shellIds.size
}
+34 -2
View File
@@ -1,4 +1,5 @@
export type AgentCommandRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical'
export type AgentSafetyMode = 'strict' | 'balanced' | 'permissive'
const RISK_WEIGHT: Record<AgentCommandRisk, number> = {
safe: 0,
@@ -97,6 +98,37 @@ export const AGENT_RISK_LABEL: Record<AgentCommandRisk, string> = {
critical: '禁止执行',
}
export const AGENT_SAFETY_MODES: Record<
AgentSafetyMode,
{ label: string; short: string; description: string }
> = {
strict: {
label: '严格',
short: '中风险起确认',
description: '文件修改、权限调整及更高风险命令需要确认,适合生产或陌生主机。',
},
balanced: {
label: '均衡',
short: '仅高风险确认',
description: '查看和普通修改自动执行;删除、重启、安装等高风险操作需要确认。',
},
permissive: {
label: '宽松',
short: '仅硬红线拦截',
description: '除 critical 破坏性命令外自动执行,仅适合隔离测试环境。',
},
}
export function agentCommandNeedsConfirmation(
risk: AgentCommandRisk,
mode: AgentSafetyMode
): boolean {
if (risk === 'critical') return false
if (mode === 'strict') return RISK_WEIGHT[risk] >= RISK_WEIGHT.medium
if (mode === 'balanced') return RISK_WEIGHT[risk] >= RISK_WEIGHT.high
return false
}
function maxRisk(left: AgentCommandRisk, right: AgentCommandRisk): AgentCommandRisk {
return RISK_WEIGHT[left] >= RISK_WEIGHT[right] ? left : right
}
@@ -162,8 +194,8 @@ function assessSegment(value: string): AgentCommandRisk {
/**
* Five-level best-effort risk assessment for AI-proposed one-line commands.
* Every non-critical command still requires explicit user confirmation; this
* classifier controls hard denial and communicates risk, never authorization.
* Confirmation is decided separately by the selected Agent safety mode.
* Critical commands are always denied and can never be approved by a mode.
*/
export function assessAgentCommandRisk(command: string): AgentCommandRisk {
const normalized = command.replace(/\s+/g, ' ').trim()
+6 -1
View File
@@ -117,7 +117,12 @@ export async function shellKill(
shellId: string
): Promise<{ success: boolean; error?: string }> {
if (!isLocalShellId(shellId)) {
return { success: false, error: 'only local shell termination is supported' }
return (
(await window.opsxApi?.sshCloseShell?.(shellId)) ?? {
success: false,
error: 'ssh close shell API unavailable',
}
)
}
if (isTauriRuntime()) {
try {
+12
View File
@@ -3,6 +3,18 @@ export interface TerminalPaneTarget {
shellId: string | null
}
/**
* Global broadcast is a remote-SSH feature. A local terminal must never fan
* its input out to remote shells even while another workspace enabled it.
*/
export function shouldBroadcastTerminalInput(
enabled: boolean,
isLocal: boolean | undefined,
connectionId: string | null | undefined
): boolean {
return enabled && !isLocal && !!connectionId && connectionId !== 'local'
}
/** Resolve ready shells for an input event, preserving pane order. */
export function resolveTerminalInputTargets(
panes: TerminalPaneTarget[],