All checks were successful
Beta Release / beta (push) Successful in 2m24s
Major changes: - Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version) - Add LSP registry with health checks, auto-install, and editor config generation - Add MCP registry with editor detection, status tracking, and per-editor configuration - Add workflow engine with planner and step execution for automated task chains - Add conversation search, export (Markdown/JSON), and detailed token counting - Add streaming shell chat handler with tool call/result events - Add skill validation, dry-run testing, and export endpoints - Enrich dashboard with Tools/Activity/Status tabs and tool cards grid - Add PRD documentation - Complete i18n for both EN and FR 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
614 lines
22 KiB
JavaScript
614 lines
22 KiB
JavaScript
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { Terminal as XTerm } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react'
|
|
import '@xterm/xterm/css/xterm.css'
|
|
import { useI18n } from '../i18n'
|
|
|
|
const MAX_TABS = 7
|
|
|
|
const THEMES = {
|
|
default: {
|
|
background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033',
|
|
cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff',
|
|
black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740',
|
|
blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2',
|
|
brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE',
|
|
brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB',
|
|
brightCyan: '#84FFFF', brightWhite: '#FFFFFF',
|
|
},
|
|
monokai: {
|
|
background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0',
|
|
cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
|
black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74',
|
|
blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2',
|
|
brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E',
|
|
brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF',
|
|
brightCyan: '#A1EFE4', brightWhite: '#F8F8F2',
|
|
},
|
|
gruvbox: {
|
|
background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934',
|
|
cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff',
|
|
black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921',
|
|
blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2',
|
|
brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26',
|
|
brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B',
|
|
brightCyan: '#8EC07C', brightWhite: '#EBDBB2',
|
|
},
|
|
nord: {
|
|
background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9',
|
|
cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff',
|
|
black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B',
|
|
blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9',
|
|
brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C',
|
|
brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD',
|
|
brightCyan: '#8FBCBB', brightWhite: '#ECEFF4',
|
|
},
|
|
'solarized-dark': {
|
|
background: '#002B36', foreground: '#839496', cursor: '#D33682',
|
|
cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff',
|
|
black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900',
|
|
blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3',
|
|
brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75',
|
|
brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4',
|
|
brightCyan: '#93A1A1', brightWhite: '#FDF6E3',
|
|
},
|
|
dracula: {
|
|
background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2',
|
|
cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
|
black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C',
|
|
blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2',
|
|
brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94',
|
|
brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF',
|
|
brightCyan: '#A4FFFF', brightWhite: '#FFFFFF',
|
|
},
|
|
}
|
|
|
|
function getTheme(themeName) {
|
|
return THEMES[themeName] || THEMES.default
|
|
}
|
|
|
|
function createTerminal(container, settings = {}) {
|
|
const theme = getTheme(settings.theme || 'default')
|
|
const term = new XTerm({
|
|
cursorBlink: true,
|
|
fontSize: settings.fontSize || 14,
|
|
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
|
theme,
|
|
allowTransparency: false,
|
|
scrollback: 5000,
|
|
})
|
|
|
|
const fitAddon = new FitAddon()
|
|
const webLinksAddon = new WebLinksAddon()
|
|
term.loadAddon(fitAddon)
|
|
term.loadAddon(webLinksAddon)
|
|
term.open(container)
|
|
fitAddon.fit()
|
|
|
|
return { term, fitAddon }
|
|
}
|
|
|
|
function connectWebSocket(term, fitAddon, initPayload) {
|
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
|
|
|
ws.addEventListener('open', () => {
|
|
ws.send(JSON.stringify(initPayload))
|
|
const dims = fitAddon.proposeDimensions()
|
|
if (dims) {
|
|
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
|
}
|
|
})
|
|
|
|
ws.addEventListener('message', (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data)
|
|
if (msg.type === 'output') {
|
|
term.write(msg.data)
|
|
} else if (msg.type === 'error') {
|
|
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
|
|
}
|
|
} catch {
|
|
term.write(event.data)
|
|
}
|
|
})
|
|
|
|
ws.addEventListener('close', () => {
|
|
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
|
})
|
|
|
|
ws.addEventListener('error', () => {
|
|
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
|
})
|
|
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'input', data }))
|
|
}
|
|
})
|
|
|
|
term.onResize(({ rows, cols }) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
|
|
}
|
|
})
|
|
|
|
return ws
|
|
}
|
|
|
|
export default function Shell({ api }) {
|
|
const { t } = useI18n()
|
|
const tabsRef = useRef({})
|
|
const nextIdRef = useRef(1)
|
|
|
|
const [tabs, setTabs] = useState([
|
|
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
|
])
|
|
const [activeTab, setActiveTab] = useState(1)
|
|
const [sshConnections, setSshConnections] = useState([])
|
|
const [systemTerminals, setSystemTerminals] = useState([])
|
|
const [showMenu, setShowMenu] = useState(false)
|
|
const [showSshModal, setShowSshModal] = useState(false)
|
|
const [editingTab, setEditingTab] = useState(null)
|
|
const [editName, setEditName] = useState('')
|
|
const [terminalSettings, setTerminalSettings] = useState({
|
|
fontSize: 14,
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
|
theme: 'default',
|
|
})
|
|
|
|
const [sshForm, setSshForm] = useState({
|
|
name: '', host: '', port: 22, user: '', key_path: '',
|
|
})
|
|
|
|
const [aiMessages, setAiMessages] = useState([
|
|
{ role: 'ai', content: t('shell.aiWelcome') }
|
|
])
|
|
const [aiInput, setAiInput] = useState('')
|
|
const [aiLoading, setAiLoading] = useState(false)
|
|
const aiMessagesRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
|
}, [aiMessages])
|
|
|
|
useEffect(() => {
|
|
api.getTerminalSessions().then(d => {
|
|
setSshConnections(d.ssh || [])
|
|
setSystemTerminals(d.system || [])
|
|
}).catch(() => {})
|
|
api.getConfig().then(d => {
|
|
if (d.terminal) {
|
|
setTerminalSettings({
|
|
fontSize: d.terminal.font_size || 14,
|
|
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
|
theme: d.terminal.theme || 'default',
|
|
})
|
|
}
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
const initTerminal = useCallback((tabId, tab) => {
|
|
if (tabsRef.current[tabId]) return
|
|
|
|
const container = document.getElementById(`terminal-${tabId}`)
|
|
if (!container) return
|
|
|
|
const { term, fitAddon } = createTerminal(container, {
|
|
fontSize: terminalSettings.fontSize,
|
|
fontFamily: terminalSettings.fontFamily,
|
|
theme: terminalSettings.theme,
|
|
})
|
|
|
|
let initPayload
|
|
if (tab.type === 'ssh') {
|
|
initPayload = {
|
|
type: 'ssh',
|
|
data: JSON.stringify({
|
|
host: tab.host,
|
|
port: tab.port || 22,
|
|
user: tab.user || 'root',
|
|
key_path: tab.key_path || '',
|
|
}),
|
|
}
|
|
} else {
|
|
initPayload = {
|
|
type: 'shell',
|
|
data: tab.shell || '',
|
|
}
|
|
}
|
|
|
|
const ws = connectWebSocket(term, fitAddon, initPayload)
|
|
|
|
ws.onopen = () => {
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
|
}
|
|
|
|
const onResize = () => {
|
|
const el = document.getElementById(`terminal-${tabId}`)
|
|
if (el && el.offsetParent !== null) {
|
|
fitAddon.fit()
|
|
}
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(onResize)
|
|
resizeObserver.observe(container)
|
|
window.addEventListener('resize', onResize)
|
|
|
|
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const tab = tabs.find(t => t.id === activeTab)
|
|
if (!tab) return
|
|
|
|
const container = document.getElementById(`terminal-${tab.id}`)
|
|
if (!container) return
|
|
|
|
if (!tabsRef.current[tab.id]) {
|
|
const timer = setTimeout(() => {
|
|
initTerminal(tab.id, tab)
|
|
requestAnimationFrame(() => {
|
|
const entry = tabsRef.current[tab.id]
|
|
if (entry) entry.fitAddon.fit()
|
|
})
|
|
}, 100)
|
|
return () => clearTimeout(timer)
|
|
} else {
|
|
requestAnimationFrame(() => {
|
|
const entry = tabsRef.current[tab.id]
|
|
if (entry) entry.fitAddon.fit()
|
|
})
|
|
}
|
|
}, [activeTab, tabs, initTerminal])
|
|
|
|
useEffect(() => {
|
|
const onKey = (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
|
if (!e.altKey) return
|
|
|
|
const num = parseInt(e.key)
|
|
if (num >= 1 && num <= tabs.length) {
|
|
e.preventDefault()
|
|
setActiveTab(tabs[num - 1].id)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [tabs])
|
|
|
|
const addLocalTab = (shell, name) => {
|
|
if (tabs.length >= MAX_TABS) return
|
|
const id = nextIdRef.current++
|
|
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
|
|
setTabs(prev => [...prev, newTab])
|
|
setActiveTab(id)
|
|
setShowMenu(false)
|
|
}
|
|
|
|
const addSSHTab = (conn) => {
|
|
if (tabs.length >= MAX_TABS) return
|
|
const id = nextIdRef.current++
|
|
const newTab = {
|
|
id,
|
|
name: conn.name || `${conn.user}@${conn.host}`,
|
|
type: 'ssh',
|
|
host: conn.host,
|
|
port: conn.port || 22,
|
|
user: conn.user || 'root',
|
|
key_path: conn.key_path || '',
|
|
connected: false,
|
|
}
|
|
setTabs(prev => [...prev, newTab])
|
|
setActiveTab(id)
|
|
setShowMenu(false)
|
|
}
|
|
|
|
const closeTab = (tabId, e) => {
|
|
if (e) e.stopPropagation()
|
|
if (tabs.length <= 1) return
|
|
|
|
if (tabsRef.current[tabId]) {
|
|
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
|
|
window.removeEventListener('resize', onResize)
|
|
resizeObserver.disconnect()
|
|
ws.close()
|
|
term.dispose()
|
|
delete tabsRef.current[tabId]
|
|
}
|
|
|
|
setTabs(prev => {
|
|
const next = prev.filter(t => t.id !== tabId)
|
|
if (activeTab === tabId && next.length > 0) {
|
|
setActiveTab(next[next.length - 1].id)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const startRename = (tabId, e) => {
|
|
if (e) e.stopPropagation()
|
|
const tab = tabs.find(t => t.id === tabId)
|
|
setEditingTab(tabId)
|
|
setEditName(tab.name)
|
|
}
|
|
|
|
const finishRename = () => {
|
|
if (editName.trim() && editingTab) {
|
|
setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t))
|
|
}
|
|
setEditingTab(null)
|
|
setEditName('')
|
|
}
|
|
|
|
const saveSSHConnection = async () => {
|
|
if (!sshForm.name.trim() || !sshForm.host.trim()) return
|
|
try {
|
|
await api.addSSHConnection(sshForm)
|
|
setSshConnections(prev => [...prev, { ...sshForm }])
|
|
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
|
|
setShowSshModal(false)
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
const deleteSSHConnection = async (name) => {
|
|
try {
|
|
await api.deleteSSHConnection(name)
|
|
setSshConnections(prev => prev.filter(c => c.name !== name))
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
const handleAiSend = async () => {
|
|
if (!aiInput.trim() || aiLoading) return
|
|
const text = aiInput.trim()
|
|
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
|
setAiInput('')
|
|
setAiLoading(true)
|
|
|
|
const currentTab = tabs.find(t => t.id === activeTab)
|
|
const context = {
|
|
cwd: currentTab?.cwd || '',
|
|
platform: navigator.platform || '',
|
|
}
|
|
|
|
try {
|
|
let accumulated = ''
|
|
await api.sendShellChat(text, context, true, (partial, event) => {
|
|
if (event && event.tool_call) {
|
|
setAiMessages(prev => [...prev, {
|
|
role: 'tool',
|
|
content: `${t('shell.toolLaunched')}: ${event.tool_call.name || 'tool'}`,
|
|
args: event.tool_call.args ? JSON.stringify(event.tool_call.args).slice(0, 100) : '',
|
|
}])
|
|
return
|
|
}
|
|
if (event && event.tool_result) {
|
|
const resultText = event.tool_result.result?.content || event.tool_result.error || 'completed'
|
|
setAiMessages(prev => [...prev, {
|
|
role: 'tool_result',
|
|
content: resultText,
|
|
isError: event.tool_result.result?.is_error,
|
|
}])
|
|
return
|
|
}
|
|
if (event && event.done) return
|
|
accumulated = partial
|
|
setAiMessages(prev => {
|
|
const filtered = prev.filter(m => !m._streaming)
|
|
return [...filtered, { role: 'ai', content: partial, _streaming: true }]
|
|
})
|
|
})
|
|
|
|
setAiMessages(prev => prev.filter(m => !m._streaming))
|
|
if (accumulated) {
|
|
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: accumulated }])
|
|
}
|
|
} catch (err) {
|
|
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
|
}
|
|
setAiLoading(false)
|
|
}
|
|
|
|
return (
|
|
<div className="shell-layout">
|
|
<div className="shell-terminal-col">
|
|
<div className="shell-tabs-bar">
|
|
<div className="shell-tabs">
|
|
{tabs.map((tab, i) => (
|
|
<div
|
|
key={tab.id}
|
|
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
onDoubleClick={(e) => startRename(tab.id, e)}
|
|
>
|
|
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
|
|
{tab.type === 'ssh' && <Globe size={12} />}
|
|
{tab.type === 'local' && <Monitor size={12} />}
|
|
{editingTab === tab.id ? (
|
|
<input
|
|
className="shell-tab-rename"
|
|
value={editName}
|
|
onChange={e => setEditName(e.target.value)}
|
|
onBlur={finishRename}
|
|
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
|
|
autoFocus
|
|
onClick={e => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<span className="shell-tab-name">{tab.name}</span>
|
|
)}
|
|
<span className="shell-tab-index">{i + 1}</span>
|
|
{tabs.length > 1 && (
|
|
<button
|
|
className="shell-tab-close"
|
|
onClick={(e) => closeTab(tab.id, e)}
|
|
title={t('shell.closeTab')}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="shell-tab-actions">
|
|
{tabs.length < MAX_TABS && (
|
|
<div className="shell-new-tab-wrapper">
|
|
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
|
<Plus size={16} />
|
|
<ChevronDown size={12} />
|
|
</button>
|
|
{showMenu && (
|
|
<>
|
|
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
|
|
<div className="shell-new-tab-menu">
|
|
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
|
|
{systemTerminals.map(st => (
|
|
<button
|
|
key={st.name}
|
|
className="shell-menu-item"
|
|
onClick={() => addLocalTab(st.shell, st.name)}
|
|
>
|
|
<Monitor size={14} />
|
|
<span>{st.name}</span>
|
|
<span className="shell-menu-item-sub">{st.shell}</span>
|
|
</button>
|
|
))}
|
|
<div className="shell-menu-divider" />
|
|
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
|
|
{sshConnections.length === 0 && (
|
|
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
|
|
)}
|
|
{sshConnections.map(conn => (
|
|
<div key={conn.name} className="shell-menu-item-row">
|
|
<button
|
|
className="shell-menu-item"
|
|
onClick={() => addSSHTab(conn)}
|
|
>
|
|
<Globe size={14} />
|
|
<span>{conn.name}</span>
|
|
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
|
|
</button>
|
|
<button
|
|
className="shell-menu-item-icon"
|
|
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
|
|
title={t('shell.deleteConnection')}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="shell-menu-divider" />
|
|
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
|
|
<Plus size={14} />
|
|
<span>{t('shell.addConnection')}</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shell-xterm-wrapper">
|
|
{tabs.map(tab => (
|
|
<div
|
|
key={tab.id}
|
|
id={`terminal-${tab.id}`}
|
|
className="shell-xterm-instance"
|
|
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shell-ai-col">
|
|
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
|
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
|
{aiMessages.map((msg, i) => (
|
|
<div key={i} className={`ai-message ${msg.role}`}>
|
|
{msg.content}
|
|
{msg.args && <div className="tool-args">{msg.args}</div>}
|
|
</div>
|
|
))}
|
|
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
|
</div>
|
|
<div className="ai-panel-input">
|
|
<input
|
|
value={aiInput}
|
|
onChange={e => setAiInput(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
|
placeholder={t('shell.askAi')}
|
|
/>
|
|
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showSshModal && (
|
|
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="shell-modal-header">{t('shell.addConnection')}</div>
|
|
<div className="shell-modal-body">
|
|
<label className="shell-modal-label">{t('shell.connectionName')}</label>
|
|
<input
|
|
value={sshForm.name}
|
|
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
|
|
placeholder="prod-server"
|
|
/>
|
|
<label className="shell-modal-label">{t('shell.host')}</label>
|
|
<input
|
|
value={sshForm.host}
|
|
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
|
|
placeholder="192.168.1.100"
|
|
/>
|
|
<div className="shell-modal-row">
|
|
<div className="shell-modal-field">
|
|
<label className="shell-modal-label">{t('shell.port')}</label>
|
|
<input
|
|
type="number"
|
|
value={sshForm.port}
|
|
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
|
|
/>
|
|
</div>
|
|
<div className="shell-modal-field">
|
|
<label className="shell-modal-label">{t('shell.user')}</label>
|
|
<input
|
|
value={sshForm.user}
|
|
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
|
|
placeholder="root"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
|
|
<input
|
|
value={sshForm.key_path}
|
|
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
|
|
placeholder="~/.ssh/id_rsa"
|
|
/>
|
|
</div>
|
|
<div className="shell-modal-footer">
|
|
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
|
|
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|