feat: release OpsXShell 2.1.1
This commit is contained in:
1 parent
a3dba0c6ed
commit
494ef6b9d0
37 files changed
+2039
-370
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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'}`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 会话'}
|
||||
|
||||
Reference in new issue
Block a user