feat: release OpsXShell 2.1.1

This commit is contained in:
Logan.Li committed 2026-07-31 01:37:01 +08:00
1 parent a3dba0c6ed
commit 494ef6b9d0
37 files changed
+2039 -370

No files matched your search

+268 -196
View File
@@ -1,9 +1,21 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, Clipboard, CornerDownLeft, Loader2, Play, Server, ShieldAlert, Sparkles, X } from 'lucide-react'
import type { AiAssistantAction } from '../../../shared/types/ai.types'
import { useEffect, useRef, useState } from 'react'
import {
Check,
CheckCircle2,
Clipboard,
Loader2,
Play,
Server,
ShieldAlert,
Sparkles,
TerminalSquare,
X,
} from 'lucide-react'
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 { useConfirmDialog } from '../common/ConfirmDialog'
export interface TerminalAiContext {
@@ -13,44 +25,91 @@ export interface TerminalAiContext {
interface AiTerminalPanelProps {
capture: () => TerminalAiContext
insert: (command: string) => void
onClose: () => void
connectionId?: string | null
targetLabel?: string
isLocal?: boolean
}
const ACTIONS: Array<{ id: AiAssistantAction; label: string }> = [
{ id: 'explain', label: '解释' },
{ id: 'command', label: '生成命令' },
{ id: 'diagnose', label: '排障' },
{ id: 'execute', label: '执行任务' },
]
interface AgentStep {
round: number
command: string
output?: string
exitCode?: number
risk: AgentCommandRisk
status: 'awaiting' | 'running' | 'done' | 'error'
}
function commandMayChangeSystem(command: string): boolean {
return /(^|[;&|]\s*)(rm|mv|cp|chmod|chown|kill|pkill|reboot|shutdown|mkfs|dd|systemctl|service|apt|yum|dnf|apk|brew)\b|\bsed\s+-i\b|(^|[^<])>{1,2}[^=]|\bkubectl\s+(delete|apply|patch|replace|scale|rollout)\b/i.test(command)
const MAX_AGENT_STEPS = 8
const MAX_TOOL_OUTPUT_CHARS = 12_000
const MAX_AGENT_CONTEXT_CHARS = 48_000
function truncateMiddle(value: string, limit: number): string {
if (value.length <= limit) return value
const head = Math.floor(limit * 0.65)
const tail = limit - head
return `${value.slice(0, head)}\n\n… [输出过长,已截断 ${value.length - limit} 个字符] …\n\n${value.slice(-tail)}`
}
function agentPrompt(
task: string,
targetLabel: string,
round: number,
consecutiveFailures: number,
protocolRetry: boolean
): string {
const checkpoint =
consecutiveFailures >= 2
? `最近已连续失败 ${consecutiveFailures} 次,必须更换排查路径,不得只换参数重试相同操作。`
: round === 4
? '请检查已有证据是否足够;如果足够应立即收敛,不要为了调用工具而调用。'
: round === MAX_AGENT_STEPS
? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。'
: ''
return [
`用户任务:${task}`,
`执行目标:当前已连接 SSH 主机「${targetLabel}」。`,
`当前为 Agent 第 ${round}/${MAX_AGENT_STEPS} 轮。`,
'你只有 ssh-exec 一个工具,可执行当前远端 shell 中的单行命令。',
'如果仍需获取信息或执行操作:只返回一个 bash 代码块,块内只能有一行命令。',
'如果任务已完成、已无需命令,或仅根据已有上下文就能回答:以“FINAL:”开头给出简短结论,不要包含代码块。',
'不得声称执行过尚未出现在真实工具结果中的命令。',
protocolRetry ? '上一次回答没有遵循工具协议;这一次必须二选一:唯一单行 bash 代码块,或以 FINAL: 开头的结论。' : '',
checkpoint,
]
.filter(Boolean)
.join('\n')
}
function agentContext(initialSelection: string, steps: AgentStep[]): string {
const parts: string[] = []
if (initialSelection) parts.push(`【用户选中的初始终端内容】\n${initialSelection}`)
for (const step of steps) {
parts.push(
`【ssh-exec 第 ${step.round} 步真实结果】\n$ ${step.command}\n${step.output || '命令无输出'}${
step.exitCode === undefined ? '' : `\n[exit_code=${step.exitCode}]`
}`
)
}
return truncateMiddle(parts.join('\n\n'), MAX_AGENT_CONTEXT_CHARS)
}
export default function AiTerminalPanel({
capture,
insert,
onClose,
connectionId = null,
targetLabel = '当前 SSH 会话',
isLocal = false,
}: AiTerminalPanelProps) {
const store = useAiProviderStore()
const [action, setAction] = useState<AiAssistantAction>('diagnose')
const [prompt, setPrompt] = useState('')
const [includeViewport, setIncludeViewport] = useState(false)
const [result, setResult] = useState('')
const [contextNote, setContextNote] = useState('不会自动发送终端内容')
const [contextNote, setContextNote] = useState('仅在开始时读取当前选中内容')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [running, setRunning] = useState(false)
const [copied, setCopied] = useState(false)
const [toolOutput, setToolOutput] = useState('')
const [pendingCommand, setPendingCommand] = useState('')
const [executing, setExecuting] = useState(false)
const [steps, setSteps] = useState<AgentStep[]>([])
const runIdRef = useRef(0)
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
useEffect(() => {
@@ -59,12 +118,11 @@ export default function AiTerminalPanel({
const provider = store.providers.find((item) => item.id === store.activeProviderId && item.enabled)
const model = provider?.models.find((item) => item.id === provider.defaultModelId)
const blocks = useMemo(() => extractBashBlocks(result), [result])
const insertable = blocks.length === 1 ? singleLineTerminalInsert(blocks[0]) : null
const submit = async () => {
const runAgent = async () => {
setError('')
setResult('')
setSteps([])
if (!provider || !model) {
setError('请先在“设置 → 大模型”添加 Provider 并选择默认模型')
return
@@ -73,92 +131,125 @@ export default function AiTerminalPanel({
setError(`Provider「${provider.name}」尚未保存 API Key`)
return
}
if (!prompt.trim()) {
setError('请输入问题或任务')
const task = prompt.trim()
if (!task) {
setError('请输入要执行的任务')
return
}
if (action === 'execute' && (!connectionId || isLocal)) {
setError('ssh-exec 只作用于当前已连接的远程 SSH 会话')
if (!connectionId || isLocal) {
setError('Agent CLI 只作用于当前已连接的远程 SSH 会话')
return
}
const captured = capture()
const selection = captured.selection.trim()
const context = selection || (includeViewport ? captured.viewport.trim() : '')
const runId = runIdRef.current + 1
runIdRef.current = runId
const selection = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
setContextNote(
selection
? `已发送选中内容 · ${terminalContextLineCount(context)} 行`
: context
? `已发送当前屏幕 · ${terminalContextLineCount(context)} 行`
: '本次未发送终端内容'
? `已附带选中内容 · ${terminalContextLineCount(selection)} 行`
: '未附带终端内容 · Agent 将通过 ssh-exec 自主检查'
)
setLoading(true)
setToolOutput('')
setPendingCommand('')
setRunning(true)
const completedSteps: AgentStep[] = []
const commandCounts = new Map<string, number>()
let redactedCount = 0
let consecutiveFailures = 0
let protocolRetry = false
try {
const executionScope = action === 'execute'
? `\n\n执行目标:当前 SSH 会话「${targetLabel}」。只生成远程 shell 内要执行的命令。远程已有的 kubectl、helm、docker 等工具可像普通命令一样使用。`
: ''
const response = await window.opsxApi.aiComplete({
provider,
model,
action,
prompt: `${prompt.trim()}${executionScope}`,
context,
})
if (!response.success || !response.text) throw new Error(response.error || '模型未返回内容')
setResult(response.text)
if (action === 'execute') {
const commands = extractBashBlocks(response.text)
const command = commands.length === 1 ? singleLineTerminalInsert(commands[0]) : null
for (let round = 1; round <= MAX_AGENT_STEPS; round += 1) {
const completion = await window.opsxApi.aiComplete({
provider,
model,
action: 'execute',
prompt: agentPrompt(task, targetLabel, round, consecutiveFailures, protocolRetry),
context: agentContext(selection, completedSteps),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
throw new Error(completion.error || '模型未返回内容')
}
redactedCount += completion.redactedCount || 0
const blocks = extractBashBlocks(completion.text)
if (blocks.length === 0) {
if (!/^\s*FINAL\s*:/i.test(completion.text)) {
if (!protocolRetry) {
protocolRetry = true
continue
}
throw new Error('模型连续两次未遵循 Agent 工具协议,已停止以避免误判')
}
const finalAnswer = completion.text.replace(/^\s*FINAL\s*:\s*/i, '').trim()
if (!finalAnswer) throw new Error('模型未返回可用的最终结论')
setResult(finalAnswer)
return
}
if (blocks.length !== 1) {
throw new Error('模型一次返回了多个命令,已拒绝执行')
}
const command = singleLineTerminalInsert(blocks[0])
if (!command) {
setError('模型没有返回唯一的单行命令,已拒绝进入执行流程')
} else {
setPendingCommand(command)
throw new Error('模型未返回唯一的单行命令,已拒绝执行')
}
protocolRetry = false
const repeated = (commandCounts.get(command) || 0) + 1
commandCounts.set(command, repeated)
if (repeated > 2) {
throw new Error(`检测到重复命令循环,已在执行前停止:${command}`)
}
const risk = assessAgentCommandRisk(command)
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
}
setSteps([...completedSteps, { ...awaiting, status: 'running' }])
const executed = await window.opsxApi.sshExec(connectionId, command)
if (runIdRef.current !== runId) return
const rawOutput = executed.success
? executed.result?.trim() || '命令执行完成,无输出'
: `ssh-exec 失败:${executed.error || '未知错误'}`
const output =
rawOutput.length > MAX_TOOL_OUTPUT_CHARS / 2
? truncateMiddle(analyzeAgentOutput(rawOutput).text, MAX_TOOL_OUTPUT_CHARS)
: rawOutput
const step: AgentStep = {
round,
command,
output,
exitCode: executed.exitCode,
risk,
status: executed.success && (executed.exitCode === undefined || executed.exitCode === 0) ? 'done' : 'error',
}
completedSteps.push(step)
consecutiveFailures = step.status === 'error' ? consecutiveFailures + 1 : 0
setSteps([...completedSteps])
}
throw new Error(`已达到 ${MAX_AGENT_STEPS} 步上限,任务尚未收敛,请缩小任务范围后重试`)
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
} finally {
if (runIdRef.current === runId) {
setRunning(false)
if (redactedCount > 0) {
setContextNote((current) => `${current} · 发送前本地脱敏 ${redactedCount} 处`)
}
}
if (response.redactedCount) setContextNote((current) => `${current} · 本地脱敏 ${response.redactedCount} 处`)
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
} finally {
setLoading(false)
}
}
const executeOnce = async () => {
if (!provider || !model || !pendingCommand || action !== 'execute') return
const target = `SSH · ${targetLabel}`
const highRisk = commandMayChangeSystem(pendingCommand)
const approved = await confirm({
title: highRisk ? '确认执行可能修改系统的命令' : '确认执行一次',
message: `目标:${target}\n\n命令:${pendingCommand}\n\n该操作只执行一次,不会保存到命令历史。`,
danger: highRisk,
confirmText: highRisk ? '确认风险并执行' : '执行一次',
})
if (!approved) return
setExecuting(true)
setError('')
setToolOutput('')
try {
const response = await window.opsxApi.sshExec(connectionId || '', pendingCommand)
if (!response.success) throw new Error(response.error || '工具执行失败')
const output = response.result?.trim() || '命令执行成功,无输出'
setToolOutput(output)
const executedCommand = pendingCommand
setPendingCommand('')
const analysis = await window.opsxApi.aiComplete({
provider,
model,
action: 'diagnose',
prompt: `用户原任务:${prompt.trim()}\n已通过 ssh-exec 执行一次命令。根据真实结果给出简短结论;不要声称执行了其他操作。`,
context: `$ ${executedCommand}\n${output}`,
})
if (analysis.success && analysis.text) setResult(analysis.text)
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
} finally {
setExecuting(false)
}
}
@@ -169,13 +260,13 @@ export default function AiTerminalPanel({
}
return (
<aside className="border-dark-700/50 bg-dark-850 flex w-[370px] flex-shrink-0 flex-col border-l">
<aside className="border-dark-700/50 bg-dark-850 flex w-[390px] 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">
<div className="text-sm font-medium text-content-primary">终端助手</div>
<div className="text-sm font-medium text-content-primary">Agent CLI</div>
<div className="truncate text-[10px] text-txt-faint">
{provider && model ? `${provider.name} · ${model.label}` : '尚未配置模型'}
{provider && model ? `LLM: ${provider.name} · ${model.label}` : '尚未配置模型'}
</div>
</div>
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
@@ -184,67 +275,45 @@ export default function AiTerminalPanel({
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-3">
<div className="bg-dark-900/60 flex gap-1 rounded-lg p-1">
{ACTIONS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
setAction(item.id)
setResult('')
setToolOutput('')
setPendingCommand('')
setError('')
}}
className={`flex-1 rounded-md px-2 py-1.5 text-xs ${action === item.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-muted hover:text-content-secondary'}`}
>
{item.label}
</button>
))}
</div>
{action === 'execute' && (
<div className="border-dark-700/40 bg-dark-900/30 space-y-2 rounded-lg border p-2.5">
<div className="flex items-center gap-1.5 truncate text-[11px] text-content-secondary">
<Server className="h-3.5 w-3.5 flex-shrink-0" /> 目标:{targetLabel}
</div>
<p className="text-[10px] leading-relaxed text-txt-faint">AI 只准备一条远程命令;固定复用当前 SSH 连接,每次真实执行都需要你确认。</p>
<div className="border-accent-blue/20 bg-accent-blue/5 space-y-2 rounded-lg border p-3">
<div className="text-xs font-medium text-content-primary">自然语言驱动 SSH 自主排障与部署</div>
<div className="flex items-center gap-1.5 text-[11px] text-content-secondary">
<Server className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate">已连接主机:{connectionId && !isLocal ? targetLabel : '尚未连接远程 SSH'}</span>
</div>
)}
<p className="text-[10px] leading-relaxed text-txt-faint">
Agent 通过当前连接执行、观察再决定下一步;每条真实命令都需要你确认。
</p>
</div>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={4}
placeholder="例如:分析这段报错,并给出最少的只读检查命令"
className="ui-input-sm resize-none leading-relaxed"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
if (!running) void runAgent()
}
}}
rows={5}
disabled={running}
placeholder="输入任务描述,回车发送(Shift+回车换行)"
className="ui-input-sm min-h-24 resize-none leading-relaxed disabled:opacity-70"
/>
<label className="border-dark-700/40 bg-dark-900/30 flex items-start gap-2 rounded-lg border p-2.5 text-xs text-txt-muted">
<input
type="checkbox"
checked={includeViewport}
onChange={(event) => setIncludeViewport(event.target.checked)}
className="mt-0.5"
/>
<span>
<span className="block text-content-secondary">无选区时附带当前屏幕</span>
<span className="mt-0.5 block text-[10px] text-txt-faint">
有选中内容时始终只发送选区;默认不读取屏幕。
</span>
</span>
</label>
<button
type="button"
onClick={() => void submit()}
disabled={loading}
className="ui-btn-primary flex w-full items-center justify-center gap-1.5"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}{' '}
{loading ? '处理中...' : action === 'execute' ? '准备执行方案' : '生成建议'}
</button>
<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>
</div>
<div className="text-[10px] text-txt-faint">{contextNote}</div>
{error && (
<div
role="alert"
@@ -254,58 +323,61 @@ export default function AiTerminalPanel({
</div>
)}
{result && (
<div className="space-y-2">
<div className="border-dark-700/50 bg-dark-900/55 whitespace-pre-wrap break-words rounded-lg border p-3 font-mono text-xs leading-relaxed text-content-secondary">
{result}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void copyResult()}
className="ui-btn-secondary flex items-center gap-1.5"
{steps.length > 0 && (
<div className="space-y-2" aria-label="Agent 执行步骤">
{steps.map((step) => (
<div
key={`${step.round}-${step.command}`}
className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Clipboard className="h-3.5 w-3.5" />}{' '}
{copied ? '已复制' : '复制结果'}
</button>
{action !== 'execute' && insertable && (
<button
type="button"
onClick={() => insert(insertable)}
className="ui-btn-primary flex items-center gap-1.5"
>
<CornerDownLeft className="h-3.5 w-3.5" />
填入终端
</button>
)}
</div>
{action !== 'execute' && blocks.length > 0 && !insertable && (
<p className="text-[10px] text-amber-300">多行脚本仅允许复制,不提供填入按钮。</p>
)}
{action === 'execute' && pendingCommand && (
<div className={`rounded-lg border p-2.5 ${commandMayChangeSystem(pendingCommand) ? 'border-amber-500/30 bg-amber-500/10' : 'border-dark-700/50 bg-dark-900/40'}`}>
<div className="mb-1 text-[10px] text-txt-faint">待执行 · SSH {targetLabel}</div>
<code className="block break-all text-xs text-content-secondary">{pendingCommand}</code>
<button type="button" onClick={() => void executeOnce()} disabled={executing} className="ui-btn-primary mt-2 flex items-center gap-1.5">
{executing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
{executing ? '执行并分析中...' : '执行一次'}
</button>
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-txt-faint">
{step.status === 'running' || step.status === 'awaiting' ? (
<Loader2 className={`h-3.5 w-3.5 ${step.status === 'running' ? 'animate-spin' : ''}`} />
) : step.status === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : (
<TerminalSquare className="h-3.5 w-3.5 text-amber-300" />
)}
第 {step.round} 步 ·{' '}
{step.status === 'awaiting' ? '等待确认' : step.status === 'running' ? '正在执行' : '已观察'}
<span className="ml-auto">{AGENT_RISK_LABEL[step.risk]}</span>
</div>
<code className="block break-all text-xs text-content-secondary">$ {step.command}</code>
{step.output && (
<pre className="border-dark-700/40 mt-2 max-h-44 overflow-auto whitespace-pre-wrap break-words border-t pt-2 text-[11px] text-txt-muted">
{step.output}
</pre>
)}
</div>
)}
))}
</div>
)}
{toolOutput && (
<div className="space-y-1">
<div className="text-[10px] text-emerald-400">真实工具输出</div>
<pre className="border-dark-700/50 bg-black/25 max-h-52 overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3 text-[11px] text-content-secondary">{toolOutput}</pre>
{result && (
<div className="space-y-2">
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" /> Agent 结论
</div>
<div className="whitespace-pre-wrap break-words text-xs leading-relaxed text-content-secondary">
{result}
</div>
</div>
<button
type="button"
onClick={() => void copyResult()}
className="ui-btn-secondary flex items-center gap-1.5"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Clipboard className="h-3.5 w-3.5" />}
{copied ? '已复制' : '复制结论'}
</button>
</div>
)}
</div>
<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" />
ssh-exec 只复用当前连接;每次执行均需确认,关闭面板即清空内容,不保存命令历史。
最多 {MAX_AGENT_STEPS} 步,重复命令自动拦截;命令、AI 对话和工具输出只存在于当前面板内存。
</footer>
{ConfirmDialogComponent}
</aside>
+4 -3
View File
@@ -45,7 +45,7 @@ const TAB_LIST: TabMeta[] = [
{ id: 'general', label: '工作区', description: '布局与资源', icon: Monitor },
{ id: 'appearance', label: '外观', description: '主题与终端配色', icon: Palette },
{ id: 'terminal', label: '终端', description: '字体与配色', icon: Terminal },
{ id: 'ai', label: '大模型', description: 'Provider 与模型', icon: Sparkles },
{ id: 'ai', label: '大模型', description: 'Provider、模型与测试', icon: Sparkles },
{ id: 'shortcuts', label: '快捷键', description: '键盘快捷键', icon: Keyboard },
{ id: 'ssh', label: 'SSH', description: '连接(高级)', icon: Shield },
{ id: 'security', label: '安全', description: '加密与隐私', icon: Database },
@@ -682,10 +682,11 @@ export default function SettingsView() {
return (
<button
key={tab.id}
data-testid={`settings-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
>
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{tab.label}</div>
<div className={`truncate text-[11px] ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
+67 -35
View File
@@ -1,7 +1,44 @@
import { memo } from 'react'
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle, Clock3 } from 'lucide-react'
import type { ConnectionState } from '../../stores/connection'
export interface ShellClosedOverlayProps {
reason: string
idleTimeout: boolean
restarting?: boolean
onReopen: () => void
}
export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, onReopen }: ShellClosedOverlayProps) {
return (
<div className="bg-dark-900/92 absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-5 text-center">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border">
{idleTimeout ? (
<Clock3 className="h-6 w-6 text-accent-blue" />
) : (
<TerminalIcon className="h-6 w-6 text-accent-blue" />
)}
</div>
<div className="mb-1.5 text-sm font-medium text-content-primary">
{idleTimeout ? '空闲超时,Shell 已退出' : 'Shell 已退出'}
</div>
<div className="mb-2 text-xs leading-relaxed text-txt-muted">{reason}</div>
<div className="mb-4 text-[11px] text-emerald-400">SSH 连接仍可用,其他分屏、SFTP 和隧道不受影响</div>
<button
type="button"
disabled={restarting}
onClick={onReopen}
className="inline-flex items-center gap-2 rounded-lg bg-accent-blue px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-accent-blue-hover disabled:opacity-60"
>
<RefreshCw className={`h-3.5 w-3.5 ${restarting ? 'animate-spin' : ''}`} />
{restarting ? '正在打开…' : '重新打开 Shell'}
</button>
</div>
</div>
)
}
export interface TerminalOverlayProps {
connectionState: ConnectionState
statusMessage: string
@@ -21,29 +58,26 @@ function TerminalOverlay({
onReconnect,
onResume,
}: TerminalOverlayProps) {
const showDisconnectBanner =
connectionState === 'disconnected' && needsReconnect
const showDisconnectBanner = connectionState === 'disconnected' && needsReconnect
return (
<>
{showDisconnectBanner && (
<div className="absolute inset-0 bg-dark-900/92 backdrop-blur-[2px] flex items-center justify-center z-10">
<div className="text-center max-w-sm px-6">
<div className="w-14 h-14 bg-accent-blue/15 border border-accent-blue/25 rounded-2xl flex items-center justify-center mx-auto mb-4">
<WifiOff className="w-7 h-7 text-accent-blue" />
<div className="bg-dark-900/92 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-6 text-center">
<div className="bg-accent-blue/15 border-accent-blue/25 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<WifiOff className="h-7 w-7 text-accent-blue" />
</div>
<div className="text-content-primary mb-1.5 text-base font-medium">连接已断开</div>
<div className="text-txt-muted text-sm mb-6 leading-relaxed">
{statusMessage?.trim()
? statusMessage
: 'SSH 会话已结束或网络中断。点击下方一键重新连接。'}
<div className="mb-1.5 text-base font-medium text-content-primary">连接已断开</div>
<div className="mb-6 text-sm leading-relaxed text-txt-muted">
{statusMessage?.trim() ? statusMessage : 'SSH 会话已结束或网络中断。点击下方一键重新连接。'}
</div>
<button
type="button"
onClick={onReconnect}
className="inline-flex items-center gap-2 px-6 py-2.5 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-xl transition-colors text-sm font-medium shadow-sm shadow-accent-blue/20"
className="shadow-accent-blue/20 inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-blue-hover"
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className="h-4 w-4" />
重新连接
</button>
</div>
@@ -51,24 +85,22 @@ function TerminalOverlay({
)}
{showSuspendedOverlay && (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-10">
<div className="text-center max-w-sm px-6">
<div className="text-yellow-400 mb-2 text-base font-medium">连接已挂起</div>
<div className="text-txt-muted text-sm mb-5">
长时间未操作,连接已进入挂起以节省资源
</div>
<div className="bg-dark-900/90 absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-sm px-6 text-center">
<div className="mb-2 text-base font-medium text-yellow-400">连接已挂起</div>
<div className="mb-5 text-sm text-txt-muted">长时间未操作,连接已进入挂起以节省资源</div>
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={onResume}
className="px-4 py-2 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-lg text-sm transition-colors"
className="rounded-lg bg-accent-blue px-4 py-2 text-sm text-white transition-colors hover:bg-accent-blue-hover"
>
恢复连接
</button>
<button
type="button"
onClick={onReconnect}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-content-primary rounded-lg text-sm transition-colors"
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-content-primary transition-colors hover:bg-dark-600"
>
重新连接
</button>
@@ -78,21 +110,21 @@ function TerminalOverlay({
)}
{connectionState === 'error' && (
<div className="absolute inset-0 bg-dark-900/88 flex items-center justify-center z-10">
<div className="text-center max-w-md px-6">
<div className="w-12 h-12 rounded-2xl bg-red-500/15 border border-red-500/25 flex items-center justify-center mx-auto mb-3">
<AlertTriangle className="w-6 h-6 text-red-400" />
<div className="bg-dark-900/88 absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-md px-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/25 bg-red-500/15">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<div className="text-red-300 mb-1.5 font-medium">连接失败</div>
<div className="text-txt-muted text-sm mb-5 leading-relaxed">
<div className="mb-1.5 font-medium text-red-300">连接失败</div>
<div className="mb-5 text-sm leading-relaxed text-txt-muted">
{statusMessage || '请检查主机、端口、用户名与认证方式'}
</div>
<button
type="button"
onClick={onReconnect}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-accent-blue hover:bg-accent-blue-hover text-white rounded-xl text-sm font-medium transition-colors"
className="inline-flex items-center gap-2 rounded-xl bg-accent-blue px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-blue-hover"
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className="h-4 w-4" />
重试连接
</button>
</div>
@@ -100,16 +132,16 @@ function TerminalOverlay({
)}
{(connectionState === 'connecting' || connectionState === 'reconnecting') && (
<div className="absolute inset-0 bg-dark-900/45 flex items-center justify-center pointer-events-none z-10">
<div className="text-center px-4">
<div className="inline-flex items-center gap-2 text-accent-blue animate-pulse text-sm font-medium">
<TerminalIcon className="w-4 h-4" />
<div className="bg-dark-900/45 pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<div className="px-4 text-center">
<div className="inline-flex animate-pulse items-center gap-2 text-sm font-medium text-accent-blue">
<TerminalIcon className="h-4 w-4" />
{connectionState === 'reconnecting'
? `自动重连中${reconnectAttempt ? ` (${reconnectAttempt})` : ''}…`
: '正在连接…'}
</div>
{statusMessage && connectionState === 'reconnecting' && (
<div className="text-txt-muted text-xs mt-2 max-w-sm">{statusMessage}</div>
<div className="mt-2 max-w-sm text-xs text-txt-muted">{statusMessage}</div>
)}
</div>
</div>
+109 -11
View File
@@ -30,7 +30,7 @@ import { shellPayloadByteLength } from '../../utils/shellData'
import { shellWrite, shellResize, shellCreate, shellKill } from '../../utils/shellIO'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import TerminalOverlay from './TerminalOverlay'
import TerminalOverlay, { ShellClosedOverlay } from './TerminalOverlay'
import { createLogger } from '../../utils/logger'
import type { TerminalViewProps } from './types'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
@@ -38,7 +38,6 @@ import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcu
import { resolveTerminalInputTargets } from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
import { singleLineTerminalInsert } from '../../utils/aiResponse'
const logger = createLogger('TerminalWorkspace')
const MAX_PANES = 4
@@ -46,11 +45,16 @@ const MAX_PANES = 4
interface PaneState {
id: string
shellId: string | null
closed?: {
reason: string
idleTimeout: boolean
restarting?: boolean
}
}
interface PaneRuntime {
capture: () => TerminalAiContext
insert: (command: string) => void
getSize: () => { cols: number; rows: number }
}
function captureTerminal(term: Terminal): TerminalAiContext {
@@ -240,10 +244,7 @@ function PaneTerminal({
})
registerRuntime(paneId, {
capture: () => captureTerminal(term),
insert: (value) => {
const command = singleLineTerminalInsert(value)
if (command) term.paste(command)
},
getSize: () => ({ cols: term.cols, rows: term.rows }),
})
return () => {
@@ -411,6 +412,52 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
}, [connectionIdRef])
// A remote shell can exit while its pooled SSH connection remains healthy
// (for example Bash TMOUT, logout or Ctrl+D). Only mark the matching pane as
// closed; sibling panes, SFTP and tunnels continue using the connection.
useEffect(() => {
const handler = (
_event: unknown,
connectionId: string,
closedShellId: string,
details: { reason: string; idleTimeout: boolean }
) => {
if (!connectionIdRef.current || connectionId !== connectionIdRef.current) return
const pane = panesRef.current.find((item) => item.shellId === closedShellId)
if (!pane) return
getConnectionStore().removeShell(connectionId, closedShellId)
setPanes((current) =>
current.map((item) =>
item.shellId === closedShellId
? {
...item,
shellId: null,
closed: {
reason: details.reason,
idleTimeout: details.idleTimeout,
},
}
: item
)
)
if (shellIdRef.current === closedShellId) {
const fallback = panesRef.current.find((item) => item.shellId && item.shellId !== closedShellId)
shellIdRef.current = fallback?.shellId || null
updateTabConnectionInstance(tabId, {
connectionId,
shellId: fallback?.shellId || undefined,
})
}
}
window.opsxApi?.onShellClosed?.(handler)
return () => {
window.opsxApi?.removeListener?.('ssh:shell-closed', handler as (...args: unknown[]) => void)
}
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
useEffect(() => {
return () => {
if (!currentTab?.isLocal) return
@@ -480,6 +527,48 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
[connectionIdRef, tabId, updateTabConnectionInstance]
)
const reopenPaneShell = useCallback(
async (paneId: string) => {
const connectionId = connectionIdRef.current
if (!connectionId || connectionId === 'local' || connectionState !== 'connected') return
setPanes((current) =>
current.map((pane) =>
pane.id === paneId && pane.closed ? { ...pane, closed: { ...pane.closed, restarting: true } } : pane
)
)
const size = paneRuntimesRef.current.get(paneId)?.getSize() || { cols: 80, rows: 24 }
const preferredId = `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
try {
const result = await shellCreate(false, connectionId, size, preferredId)
if (!result.success || !result.shellId) {
throw new Error(result.error || '创建远端 Shell 失败')
}
getConnectionStore().addShell(connectionId, result.shellId)
setPanes((current) =>
current.map((pane) =>
pane.id === paneId ? { ...pane, shellId: result.shellId || null, closed: undefined } : pane
)
)
if (focusedPaneId === paneId || !shellIdRef.current) {
shellIdRef.current = result.shellId
updateTabConnectionInstance(tabId, { connectionId, shellId: result.shellId })
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
setPanes((current) =>
current.map((pane) =>
pane.id === paneId && pane.closed
? { ...pane, closed: { ...pane.closed, restarting: false, reason } }
: pane
)
)
}
},
[connectionIdRef, connectionState, focusedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
)
const handlePaneInput = useCallback(
(sourcePaneId: string, data: string) => {
const targets = resolveTerminalInputTargets(panesRef.current, sourcePaneId, broadcastInput)
@@ -674,11 +763,13 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
<button
type="button"
title="终端 AI 助手"
aria-label={aiPanelOpen ? '关闭终端 AI 助手' : '打开终端 AI 助手'}
aria-pressed={aiPanelOpen}
data-testid="terminal-ai-button"
onClick={() => setAiPanelOpen((open) => !open)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors ${aiPanelOpen ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'}`}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
>
<Sparkles className="h-3.5 w-3.5" /> AI
<Sparkles className="h-3.5 w-3.5" /> AI 助手
</button>
{broadcastInput && <div className="ui-chip border-accent-orange/30 text-accent-orange">广播中</div>}
<div className="ui-chip tabular-nums">
@@ -706,7 +797,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
tabId={tabId}
isLocal={isLocal}
autoCreateShell={index > 0}
autoCreateShell={index > 0 && !pane.closed}
isFocused={focusedPaneId === pane.id}
isActive={isActive}
onInput={handlePaneInput}
@@ -726,6 +817,14 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
registerRuntime={registerRuntime}
unregisterRuntime={unregisterRuntime}
/>
{pane.closed && (
<ShellClosedOverlay
reason={pane.closed.reason}
idleTimeout={pane.closed.idleTimeout}
restarting={pane.closed.restarting}
onReopen={() => void reopenPaneShell(pane.id)}
/>
)}
{multi && panes.length === 2 && index === 0 && (
<div
onMouseDown={onDividerMouseDown}
@@ -759,7 +858,6 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
{aiPanelOpen && (
<AiTerminalPanel
capture={() => paneRuntimesRef.current.get(focusedPaneId)?.capture() || { selection: '', viewport: '' }}
insert={(command) => paneRuntimesRef.current.get(focusedPaneId)?.insert(command)}
onClose={() => setAiPanelOpen(false)}
connectionId={connectionState === 'connected' && !isLocal ? connectionIdRef.current : null}
targetLabel={currentTab?.title || '当前 SSH 会话'}