diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 9793049..b24f706 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -325,3 +325,142 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
}
writeJSON(w, map[string]string{"content": result})
}
+
+func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "PUT" {
+ writeError(w, "PUT only", http.StatusMethodNotAllowed)
+ return
+ }
+ if s.config == nil {
+ writeError(w, "no config", http.StatusNotFound)
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ Pseudo string `json:"pseudo"`
+ Email string `json:"email"`
+ Editor string `json:"editor"`
+ Shell string `json:"shell"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if body.Name != "" {
+ s.config.Profile.Name = body.Name
+ }
+ if body.Pseudo != "" {
+ s.config.Profile.Pseudo = body.Pseudo
+ }
+ if body.Email != "" {
+ s.config.Profile.Email = body.Email
+ }
+ if body.Editor != "" {
+ s.config.Profile.Preferences.Editor = body.Editor
+ }
+ if body.Shell != "" {
+ s.config.Profile.Preferences.Shell = body.Shell
+ }
+ if err := config.Save(s.config); err != nil {
+ writeError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, map[string]string{"status": "ok"})
+}
+
+func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "PUT" {
+ writeError(w, "PUT only", http.StatusMethodNotAllowed)
+ return
+ }
+ if s.config == nil {
+ writeError(w, "no config", http.StatusNotFound)
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ APIKey string `json:"api_key"`
+ Model string `json:"model"`
+ BaseURL string `json:"base_url"`
+ Active *bool `json:"active"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if body.Name == "" {
+ writeError(w, "name required", http.StatusBadRequest)
+ return
+ }
+ found := false
+ for i := range s.config.AI.Providers {
+ if s.config.AI.Providers[i].Name == body.Name {
+ if body.APIKey != "" {
+ s.config.AI.Providers[i].APIKey = body.APIKey
+ }
+ if body.Model != "" {
+ s.config.AI.Providers[i].Model = body.Model
+ }
+ if body.BaseURL != "" {
+ s.config.AI.Providers[i].BaseURL = body.BaseURL
+ }
+ if body.Active != nil {
+ if *body.Active {
+ for j := range s.config.AI.Providers {
+ s.config.AI.Providers[j].Active = false
+ }
+ }
+ s.config.AI.Providers[i].Active = *body.Active
+ }
+ found = true
+ break
+ }
+ }
+ if !found {
+ writeError(w, "provider not found", http.StatusNotFound)
+ return
+ }
+ if err := config.Save(s.config); err != nil {
+ writeError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, map[string]string{"status": "ok"})
+}
+
+func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ writeError(w, "POST only", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Tool string `json:"tool"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+
+ result := scanner.ScanSystem()
+ statuses := updater.CheckUpdates(result)
+
+ if body.Tool != "" {
+ for _, u := range statuses {
+ if u.Tool == body.Tool && u.NeedsUpdate {
+ updater.RunAutoUpdate([]updater.UpdateStatus{u})
+ }
+ }
+ writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
+ return
+ }
+
+ needsUpdate := make([]updater.UpdateStatus, 0)
+ for _, u := range statuses {
+ if u.NeedsUpdate {
+ needsUpdate = append(needsUpdate, u)
+ }
+ }
+ if len(needsUpdate) > 0 {
+ updater.RunAutoUpdate(needsUpdate)
+ }
+ writeJSON(w, map[string]interface{}{
+ "status": "ok",
+ "updated": len(needsUpdate),
+ })
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 9150623..7035208 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -39,7 +39,12 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
+ s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
+ s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
+ s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
+ s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
+ s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/api/terminal.go b/internal/api/terminal.go
index 0834256..35d898f 100644
--- a/internal/api/terminal.go
+++ b/internal/api/terminal.go
@@ -2,15 +2,19 @@ package api
import (
"encoding/json"
+ "fmt"
"log"
"net/http"
"os"
"os/exec"
+ "runtime"
+ "strings"
"sync"
"time"
"github.com/creack/pty/v2"
"github.com/gorilla/websocket"
+ "github.com/muyue/muyue/internal/config"
)
var upgrader = websocket.Upgrader{
@@ -32,12 +36,63 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
defer conn.Close()
- shell := "/bin/sh"
- if s, err := exec.LookPath("bash"); err == nil {
- shell = s
+ var initMsg wsMessage
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
+ return
+ }
+ if err := json.Unmarshal(raw, &initMsg); err != nil {
+ conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
+ return
+ }
+
+ var cmd *exec.Cmd
+
+ if initMsg.Type == "ssh" && initMsg.Data != "" {
+ var sshConf struct {
+ Host string `json:"host"`
+ Port int `json:"port"`
+ User string `json:"user"`
+ KeyPath string `json:"key_path"`
+ }
+ if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
+ conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
+ return
+ }
+ if sshConf.Port == 0 {
+ sshConf.Port = 22
+ }
+
+ sshArgs := []string{
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "LogLevel=ERROR",
+ }
+ if sshConf.KeyPath != "" {
+ sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
+ }
+ if sshConf.Port != 22 {
+ sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
+ }
+ sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
+
+ cmd = exec.Command("ssh", sshArgs...)
+ } else {
+ shell := initMsg.Data
+ if shell == "" {
+ shell = detectShell()
+ }
+
+ if strings.Contains(shell, "wsl") {
+ cmd = exec.Command("wsl", "--shell-type", "login")
+ } else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
+ cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
+ } else {
+ cmd = exec.Command(shell, "--login")
+ }
}
- cmd := exec.Command(shell)
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.Start(cmd)
@@ -65,7 +120,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
})
}
- // PTY -> WebSocket
go func() {
buf := make([]byte, 4096)
for {
@@ -86,8 +140,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
}()
- // WebSocket -> PTY
- conn.SetReadLimit(1 << 20) // 1MB
+ conn.SetReadLimit(1 << 20)
conn.SetReadDeadline(time.Time{})
for {
@@ -118,3 +171,131 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
}
}
+
+func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "GET" {
+ writeJSON(w, map[string]interface{}{
+ "ssh": s.config.Terminal.SSH,
+ "system": detectSystemTerminals(),
+ })
+ return
+ }
+ if r.Method != "POST" {
+ writeError(w, "POST only", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+ User string `json:"user"`
+ Password string `json:"password"`
+ KeyPath string `json:"key_path"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if body.Name == "" || body.Host == "" {
+ writeError(w, "name and host required", http.StatusBadRequest)
+ return
+ }
+ if body.Port == 0 {
+ body.Port = 22
+ }
+
+ conn := config.SSHConnection{
+ Name: body.Name,
+ Host: body.Host,
+ Port: body.Port,
+ User: body.User,
+ KeyPath: body.KeyPath,
+ }
+ if s.config.Terminal.SSH == nil {
+ s.config.Terminal.SSH = []config.SSHConnection{}
+ }
+ s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
+ if err := config.Save(s.config); err != nil {
+ writeError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, map[string]string{"status": "ok"})
+}
+
+func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
+ if name == "" {
+ writeError(w, "name required", http.StatusBadRequest)
+ return
+ }
+ found := false
+ for i, c := range s.config.Terminal.SSH {
+ if c.Name == name {
+ s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
+ found = true
+ break
+ }
+ }
+ if !found {
+ writeError(w, "not found", http.StatusNotFound)
+ return
+ }
+ if err := config.Save(s.config); err != nil {
+ writeError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, map[string]string{"status": "ok"})
+}
+
+func detectShell() string {
+ shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
+ for _, s := range shells {
+ if _, err := exec.LookPath(s); err == nil {
+ return s
+ }
+ }
+ return "/bin/sh"
+}
+
+func detectSystemTerminals() []map[string]string {
+ var terminals []map[string]string
+
+ terminals = append(terminals, map[string]string{
+ "type": "local",
+ "name": "Default Shell",
+ "shell": detectShell(),
+ })
+
+ if runtime.GOOS == "windows" {
+ if _, err := exec.LookPath("wsl"); err == nil {
+ terminals = append(terminals, map[string]string{
+ "type": "local",
+ "name": "WSL",
+ "shell": "wsl",
+ })
+ }
+ if _, err := exec.LookPath("powershell"); err == nil {
+ terminals = append(terminals, map[string]string{
+ "type": "local",
+ "name": "PowerShell",
+ "shell": "powershell",
+ })
+ }
+ if _, err := exec.LookPath("pwsh"); err == nil {
+ terminals = append(terminals, map[string]string{
+ "type": "local",
+ "name": "PowerShell Core",
+ "shell": "pwsh",
+ })
+ }
+ if _, err := exec.LookPath("cmd"); err == nil {
+ terminals = append(terminals, map[string]string{
+ "type": "local",
+ "name": "Command Prompt",
+ "shell": "cmd",
+ })
+ }
+ }
+
+ return terminals
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 1ebd73d..236b0f3 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -41,6 +41,15 @@ type ToolConfig struct {
AutoUpdate bool `yaml:"auto_update"`
}
+type SSHConnection struct {
+ Name string `yaml:"name"`
+ Host string `yaml:"host"`
+ Port int `yaml:"port"`
+ User string `yaml:"user"`
+ Password string `yaml:"password,omitempty"`
+ KeyPath string `yaml:"key_path,omitempty"`
+}
+
type MuyueConfig struct {
Version string `yaml:"version"`
Profile Profile `yaml:"profile"`
@@ -54,8 +63,9 @@ type MuyueConfig struct {
Global bool `yaml:"global"`
} `yaml:"bmad"`
Terminal struct {
- CustomPrompt bool `yaml:"custom_prompt"`
- PromptTheme string `yaml:"prompt_theme"`
+ CustomPrompt bool `yaml:"custom_prompt"`
+ PromptTheme string `yaml:"prompt_theme"`
+ SSH []SSHConnection `yaml:"ssh"`
} `yaml:"terminal"`
}
diff --git a/web/package-lock.json b/web/package-lock.json
index 40cbe43..c95235a 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,6 +9,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
+ "lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
@@ -736,6 +737,15 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
+ "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
diff --git a/web/package.json b/web/package.json
index e974527..814fd30 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
+ "lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
diff --git a/web/src/api/client.js b/web/src/api/client.js
index fc6b267..1172f4b 100644
--- a/web/src/api/client.js
+++ b/web/src/api/client.js
@@ -26,7 +26,13 @@ const api = {
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
+ saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
+ saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
+ runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
+ getTerminalSessions: () => request('/terminal/sessions'),
+ addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
+ deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
sendChat: (message, stream = true) => {
if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx
index ef0f674..e82d62c 100644
--- a/web/src/components/App.jsx
+++ b/web/src/components/App.jsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
+import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
-import { getTheme, getThemeNames, applyTheme } from '../themes'
+import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard'
import Studio from './Studio'
@@ -16,10 +17,10 @@ export default function App() {
const { t, layout } = useI18n()
const TABS = useMemo(() => [
- { id: 'dash', label: t('tabs.dashboard'), icon: '\u25A0' },
- { id: 'studio', label: t('tabs.studio'), icon: '\u27E8\u27E9' },
- { id: 'shell', label: t('tabs.shell'), icon: '$' },
- { id: 'config', label: t('tabs.config'), icon: '\u2699' },
+ { id: 'dash', label: t('tabs.dashboard'), icon: },
+ { id: 'studio', label: t('tabs.studio'), icon: },
+ { id: 'shell', label: t('tabs.shell'), icon: },
+ { id: 'config', label: t('tabs.config'), icon: },
], [t])
useEffect(() => {
diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx
index 8e38a1a..df96d9c 100644
--- a/web/src/components/Config.jsx
+++ b/web/src/components/Config.jsx
@@ -1,58 +1,251 @@
-import { useState, useEffect } from 'react'
-import { getThemeNames, applyTheme, getTheme } from '../themes'
+import { useState, useEffect, useCallback } from 'react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
export default function Config({ api }) {
- const { t, language, keyboard, setLanguage, setKeyboard, layout } = useI18n()
+ const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
- const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
+ const [updates, setUpdates] = useState([])
+ const [tools, setTools] = useState([])
+ const [checking, setChecking] = useState(false)
+ const [updating, setUpdating] = useState(null)
+ const [editProfile, setEditProfile] = useState(false)
+ const [editProvider, setEditProvider] = useState(null)
+ const [profileForm, setProfileForm] = useState({})
+ const [providerForm, setProviderForm] = useState({})
+ const [toast, setToast] = useState(null)
- useEffect(() => {
- api.getConfig().then(d => setConfig(d)).catch(() => {})
- api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
- api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
- }, [])
-
- const themes = getThemeNames()
const layouts = getLayoutList()
- const handleThemeChange = (themeId) => {
- applyTheme(getTheme(themeId))
- setCurrentTheme(themeId)
+ const loadData = useCallback(() => {
+ api.getConfig().then(d => {
+ setConfig(d)
+ setProfileForm({
+ name: d.profile?.name || '',
+ pseudo: d.profile?.pseudo || '',
+ email: d.profile?.email || '',
+ editor: d.profile?.preferences?.editor || '',
+ shell: d.profile?.preferences?.shell || '',
+ })
+ }).catch(() => {})
+ api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
+ api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
+ api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
+ api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
+ }, [api])
+
+ useEffect(() => { loadData() }, [loadData])
+
+ const showToast = (msg) => {
+ setToast(msg)
+ setTimeout(() => setToast(null), 2500)
}
- const themeColors = {
- 'cyberpunk-red': '#FF0033',
- 'cyberpunk-pink': '#FF1A8C',
- 'midnight-blue': '#0088FF',
- 'matrix-green': '#00FF41',
+ const handleCheckUpdates = async () => {
+ setChecking(true)
+ try {
+ await api.runScan()
+ const d = await api.getUpdates()
+ setUpdates(d.updates || [])
+ const td = await api.getTools()
+ setTools(td.tools || [])
+ showToast(t('config.upToDate'))
+ } catch (err) {
+ showToast(`${t('config.error')}: ${err.message}`)
+ }
+ setChecking(false)
}
+ const handleUpdateTool = async (tool) => {
+ setUpdating(tool)
+ try {
+ await api.runUpdate(tool)
+ await handleCheckUpdates()
+ showToast(`${tool} ✓`)
+ } catch (err) {
+ showToast(`${t('config.error')}: ${err.message}`)
+ }
+ setUpdating(null)
+ }
+
+ const handleUpdateAll = async () => {
+ setUpdating('__all__')
+ try {
+ await api.runUpdate('')
+ await handleCheckUpdates()
+ showToast(t('config.saved'))
+ } catch (err) {
+ showToast(`${t('config.error')}: ${err.message}`)
+ }
+ setUpdating(null)
+ }
+
+ const handleSaveProfile = async () => {
+ try {
+ await api.saveProfile(profileForm)
+ setEditProfile(false)
+ loadData()
+ showToast(t('config.saved'))
+ } catch (err) {
+ showToast(`${t('config.error')}: ${err.message}`)
+ }
+ }
+
+ const handleSaveProvider = async () => {
+ try {
+ await api.saveProvider(providerForm)
+ setEditProvider(null)
+ loadData()
+ showToast(t('config.saved'))
+ } catch (err) {
+ showToast(`${t('config.error')}: ${err.message}`)
+ }
+ }
+
+ const openProviderEdit = (p) => {
+ setProviderForm({
+ name: p.name,
+ api_key: p.apiKey || '',
+ model: p.model || '',
+ base_url: p.baseURL || '',
+ })
+ setEditProvider(p.name)
+ }
+
+ const needsUpdateCount = updates.filter(u => u.needsUpdate).length
+ const installedCount = tools.filter(t => t.installed).length
+ const missingCount = tools.filter(t => !t.installed).length
+
return (
+ {toast &&
{toast}
}
+
-
{t('config.profile')}
- {config?.profile ? (
+
{t('config.systemUpdates')}
+
+
+ {needsUpdateCount > 0 && (
+
+ )}
+
+
+ {installedCount} {t('config.installed')}
+ {missingCount > 0 && {missingCount} {t('config.missing')}}
+ {needsUpdateCount > 0 && {needsUpdateCount} {t('config.needsUpdate')}}
+
+
+ {updates.length === 0 ? (
+
{t('config.noUpdates')}
+ ) : (
+
+ {updates.map((u, i) => (
+
+
+ {u.tool}
+
+ {u.needsUpdate ? (
+ <>{u.current} → {u.latest}>
+ ) : (
+ {u.current}
+ )}
+
+
+ {u.needsUpdate && (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+ {t('config.profile')}
+
+
+ {config?.profile && !editProfile ? (
-
+ ) : editProfile ? (
+
+
setProfileForm(p => ({ ...p, name: v }))} />
+ setProfileForm(p => ({ ...p, pseudo: v }))} />
+ setProfileForm(p => ({ ...p, email: v }))} />
+ setProfileForm(p => ({ ...p, editor: v }))} />
+ setProfileForm(p => ({ ...p, shell: v }))} />
+
+
+
+
+
) : (
{t('config.loadingProfile')}
)}
+
+
{t('config.aiProviders')}
+ {providers.map((p, i) => (
+
+
+
+ {p.name}
+ {p.active && {t('config.active')}}
+
+ {editProvider !== p.name ? (
+
+ {p.model}
+
+ {p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
+
+
+ {!p.active && (
+
+ )}
+
+ ) : (
+
+
setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
+ setProviderForm(f => ({ ...f, model: v }))} />
+ setProviderForm(f => ({ ...f, base_url: v }))} />
+
+
+
+
+
+ )}
+
+
+ ))}
+
+
{t('config.language')}
-
+
{LANGUAGES.map(lang => (
{t('config.keyboardLayout')}
-
+
{layouts.map(l => (
-
-
{t('config.aiProviders')}
- {providers.map((p, i) => (
-
-
-
- {p.name}
- {p.active && {t('config.active')}}
-
-
- {p.model}
-
- {p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
-
-
-
-
- ))}
-
-
-
-
{t('config.theme')}
-
- {themes.map(th => (
-
handleThemeChange(th.id)}
- title={th.name}
- />
- ))}
-
-
-
{t('config.skills')} ({skillList.length})
{skillList.length === 0 ? (
@@ -124,10 +282,10 @@ export default function Config({ api }) {
) : (
skillList.map((s, i) => (
-
-
{s.name}
+
+ {s.name}
{s.target || 'both'}
- {s.description}
+ {s.description}
))
)}
@@ -144,3 +302,17 @@ function FieldRow({ label, value }) {
)
}
+
+function FormInput({ label, value, onChange, type = 'text' }) {
+ return (
+
+ {label}
+ onChange(e.target.value)}
+ />
+
+ )
+}
diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx
index c8b35ea..14591c1 100644
--- a/web/src/components/Dashboard.jsx
+++ b/web/src/components/Dashboard.jsx
@@ -3,7 +3,6 @@ import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) {
const { t, layout } = useI18n()
- const [activeSection, setActiveSection] = useState('tools')
const [notifications, setNotifications] = useState([])
const installed = tools.filter(tool => tool.installed).length
@@ -13,35 +12,17 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
}
- const sections = [
- { id: 'tools', label: t('dashboard.systemOverview') },
- { id: 'notifications', label: t('dashboard.activityLog') },
- { id: 'workflows', label: t('studio.workflows') },
- ]
-
return (
-
- {sections.map(s => (
-
setActiveSection(s.id)}
- >
- {s.label}
- {s.id === 'tools' && total > 0 && (
- {installed}/{total}
- )}
- {s.id === 'notifications' && notifications.length > 0 && (
- {notifications.length}
- )}
-
- ))}
-
-
- {activeSection === 'tools' && (
-
+
+
+
+
{t('dashboard.systemOverview')}
+ {total > 0 && (
+
{installed}/{total}
+ )}
+
{tools.length === 0 ? (
{t('dashboard.noUpdateData')}
) : (
@@ -63,41 +44,50 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
)}
- )}
- {activeSection === 'notifications' && (
-
+
+
+
{t('studio.workflows')}
+
+
+
+
{t('studio.workflows')}
+
+ {t('studio.noWorkflow')}
+
+
+
+
{t('studio.activeAgents')}
+
+ {t('studio.noWorkflow')}
+
+
+
+
+
+
+
+
{t('dashboard.activityLog')}
+ {notifications.length > 0 && (
+
{notifications.length}
+ )}
+
{notifications.length === 0 ? (
{t('dashboard.noUpdateData')}
) : (
- notifications.map(n => (
-
-
- {n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
-
- {n.text}
-
- ))
+
+ {notifications.map(n => (
+
+
+ {n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
+
+ {n.text}
+
+ ))}
+
)}
- )}
-
- {activeSection === 'workflows' && (
-
-
-
{t('studio.workflows')}
-
- {t('studio.noWorkflow')}
-
-
-
-
{t('studio.activeAgents')}
-
- {t('studio.noWorkflow')}
-
-
-
- )}
+
)
diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx
index ca16c22..3556cfd 100644
--- a/web/src/components/Shell.jsx
+++ b/web/src/components/Shell.jsx
@@ -1,142 +1,311 @@
import { useState, useRef, useEffect, useCallback } from 'react'
-import { Terminal } from '@xterm/xterm'
+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 XTERM_THEME = {
+ 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',
+}
+
+function createTerminal(container) {
+ const term = new XTerm({
+ cursorBlink: true,
+ fontSize: 14,
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
+ theme: XTERM_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.onopen = () => {
+ ws.send(JSON.stringify(initPayload))
+ const dims = fitAddon.proposeDimensions()
+ if (dims) {
+ ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
+ }
+ }
+
+ ws.onmessage = (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.onclose = () => {
+ term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
+ }
+
+ ws.onerror = () => {
+ 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 termRef = useRef(null)
- const fitAddonRef = useRef(null)
- const wsRef = useRef(null)
- const containerRef = useRef(null)
+ 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 [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 [connected, setConnected] = useState(false)
const aiMessagesRef = useRef(null)
useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
- const getWsUrl = useCallback(() => {
- const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
- return `${proto}//${window.location.host}/api/ws/terminal`
+ useEffect(() => {
+ api.getTerminalSessions().then(d => {
+ setSshConnections(d.ssh || [])
+ setSystemTerminals(d.system || [])
+ }).catch(() => {})
}, [])
- useEffect(() => {
- if (!containerRef.current) return
+ const initTerminal = useCallback((tabId, tab) => {
+ if (tabsRef.current[tabId]) return
- const term = new Terminal({
- cursorBlink: true,
- fontSize: 14,
- fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
- theme: {
- 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',
- },
- allowTransparency: false,
- scrollback: 5000,
- })
+ const container = document.getElementById(`terminal-${tabId}`)
+ if (!container) return
- const fitAddon = new FitAddon()
- const webLinksAddon = new WebLinksAddon()
- term.loadAddon(fitAddon)
- term.loadAddon(webLinksAddon)
- term.open(containerRef.current)
- fitAddon.fit()
+ const { term, fitAddon } = createTerminal(container)
- termRef.current = term
- fitAddonRef.current = fitAddon
-
- const ws = new WebSocket(getWsUrl())
- wsRef.current = ws
-
- ws.onopen = () => {
- setConnected(true)
- const dims = fitAddon.proposeDimensions()
- if (dims) {
- ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
+ 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 || '',
}
}
- ws.onmessage = (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)
- }
+ const ws = connectWebSocket(term, fitAddon, initPayload)
+
+ ws.onopen = () => {
+ setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
}
ws.onclose = () => {
- setConnected(false)
- term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
+ setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
ws.onerror = () => {
- setConnected(false)
- term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
+ setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
- 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 }))
- }
- })
-
const onResize = () => {
- if (containerRef.current?.offsetParent !== null) {
+ const el = document.getElementById(`terminal-${tabId}`)
+ if (el && el.offsetParent !== null) {
fitAddon.fit()
}
}
const resizeObserver = new ResizeObserver(onResize)
- resizeObserver.observe(containerRef.current)
+ resizeObserver.observe(container)
window.addEventListener('resize', onResize)
- return () => {
+ tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
+ }, [])
+
+ useEffect(() => {
+ const tab = tabs.find(t => t.id === activeTab)
+ if (tab && !tabsRef.current[tab.id]) {
+ const timer = setTimeout(() => initTerminal(tab.id, tab), 50)
+ return () => clearTimeout(timer)
+ } else if (tab && tabsRef.current[tab.id]) {
+ const timer = setTimeout(() => {
+ const { fitAddon } = tabsRef.current[tab.id]
+ fitAddon.fit()
+ }, 50)
+ return () => clearTimeout(timer)
+ }
+ }, [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]
}
- }, [getWsUrl])
+
+ setTabs(prev => {
+ const next = prev.filter(t => t.id !== tabId)
+ if (activeTab === tabId) {
+ 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
@@ -144,7 +313,6 @@ export default function Shell({ api }) {
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiInput('')
setAiLoading(true)
-
try {
const res = await api.runCommand(`echo "AI: ${text}"`, '')
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
@@ -157,13 +325,115 @@ export default function Shell({ api }) {
return (
-
-
- {t('shell.terminal')}
-
-
+
+
+ {tabs.map((tab, i) => (
+
setActiveTab(tab.id)}
+ onDoubleClick={(e) => startRename(tab.id, e)}
+ >
+
+ {tab.type === 'ssh' && }
+ {tab.type === 'local' && }
+ {editingTab === tab.id ? (
+ setEditName(e.target.value)}
+ onBlur={finishRename}
+ onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
+ autoFocus
+ onClick={e => e.stopPropagation()}
+ />
+ ) : (
+ {tab.name}
+ )}
+ {i + 1}
+ {tabs.length > 1 && (
+
+ )}
+
+ ))}
+
+
+
+ {tabs.length < MAX_TABS && (
+
+
+ {showMenu && (
+ <>
+
setShowMenu(false)} />
+
+
{t('shell.systemTerminals')}
+ {systemTerminals.map(st => (
+
+ ))}
+
+
{t('shell.savedConnections')}
+ {sshConnections.length === 0 && (
+
{t('shell.noConnections')}
+ )}
+ {sshConnections.map(conn => (
+
+
+
+
+ ))}
+
+
+
+ >
+ )}
+
+ )}
+
+
+
+
+ {tabs.map(tab => (
+
+ ))}
-
@@ -186,6 +456,56 @@ export default function Shell({ api }) {
+
+ {showSshModal && (
+
setShowSshModal(false)}>
+
e.stopPropagation()}>
+
{t('shell.addConnection')}
+
+
+
+
+
+
+
+ )}
)
}
diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js
index edfb481..4aac671 100644
--- a/web/src/i18n/en.js
+++ b/web/src/i18n/en.js
@@ -87,6 +87,29 @@ const en = {
send: 'Send',
noResponse: 'No response',
error: 'Error',
+ newTab: 'New tab',
+ closeTab: 'Close tab',
+ maxTabsReached: 'Maximum 7 terminals reached',
+ renameTab: 'Rename',
+ local: 'Local',
+ ssh: 'SSH',
+ connections: 'Connections',
+ addConnection: 'Add SSH connection',
+ editConnection: 'Edit connection',
+ deleteConnection: 'Delete',
+ connectionName: 'Name',
+ host: 'Host',
+ port: 'Port',
+ user: 'User',
+ keyPath: 'SSH key path',
+ connect: 'Connect',
+ save: 'Save',
+ cancel: 'Cancel',
+ savedConnections: 'Saved connections',
+ noConnections: 'No saved SSH connections.',
+ systemTerminals: 'System terminals',
+ switchTerminal: 'Switch terminal',
+ localShell: 'Local Shell',
},
config: {
@@ -102,15 +125,39 @@ const en = {
notSet: 'Not set',
aiProviders: 'AI Providers',
active: 'Active',
+ activate: 'Activate',
keyConfigured: 'Key configured',
noKey: 'No key',
- theme: 'Theme',
+ apiKey: 'API Key',
+ model: 'Model',
+ baseUrl: 'Base URL',
+ save: 'Save',
+ saved: 'Saved!',
+ error: 'Error',
skills: 'Skills',
noSkills: 'No skills installed.',
runSkillsInit: 'Run muyue skills init',
language: 'Language',
keyboardLayout: 'Keyboard Layout',
target: 'Target',
+ updates: 'Updates',
+ systemUpdates: 'System Updates',
+ checkUpdates: 'Check for updates',
+ updateAll: 'Update all',
+ updateTool: 'Update',
+ checking: 'Checking...',
+ updating: 'Updating...',
+ upToDate: 'Up to date',
+ needsUpdate: 'Update available',
+ current: 'Current',
+ latest: 'Latest',
+ noUpdates: 'All tools are up to date.',
+ version: 'Version',
+ installed: 'Installed',
+ missing: 'Missing',
+ editProfile: 'Edit profile',
+ cancel: 'Cancel',
+ editProvider: 'Configure',
},
}
diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js
index d2bc20a..44abdf6 100644
--- a/web/src/i18n/fr.js
+++ b/web/src/i18n/fr.js
@@ -87,6 +87,29 @@ const fr = {
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
+ newTab: 'Nouvel onglet',
+ closeTab: 'Fermer l\u2019onglet',
+ maxTabsReached: 'Maximum 7 terminaux atteint',
+ renameTab: 'Renommer',
+ local: 'Local',
+ ssh: 'SSH',
+ connections: 'Connexions',
+ addConnection: 'Ajouter une connexion SSH',
+ editConnection: 'Modifier la connexion',
+ deleteConnection: 'Supprimer',
+ connectionName: 'Nom',
+ host: 'H\u00f4te',
+ port: 'Port',
+ user: 'Utilisateur',
+ keyPath: 'Chemin cl\u00e9 SSH',
+ connect: 'Se connecter',
+ save: 'Enregistrer',
+ cancel: 'Annuler',
+ savedConnections: 'Connexions enregistr\u00e9es',
+ noConnections: 'Aucune connexion SSH enregistr\u00e9e.',
+ systemTerminals: 'Terminaux syst\u00e8me',
+ switchTerminal: 'Changer de terminal',
+ localShell: 'Shell local',
},
config: {
@@ -102,15 +125,39 @@ const fr = {
notSet: 'Non d\u00e9fini',
aiProviders: 'Fournisseurs IA',
active: 'Actif',
+ activate: 'Activer',
keyConfigured: 'Cl\u00e9 configur\u00e9e',
noKey: 'Pas de cl\u00e9',
- theme: 'Th\u00e8me',
+ apiKey: 'Cl\u00e9 API',
+ model: 'Mod\u00e8le',
+ baseUrl: 'URL de base',
+ save: 'Enregistrer',
+ saved: 'Enregistr\u00e9 !',
+ error: 'Erreur',
skills: 'Comp\u00e9tences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
keyboardLayout: 'Disposition du clavier',
target: 'Cible',
+ updates: 'Mises \u00e0 jour',
+ systemUpdates: 'Mises \u00e0 jour syst\u00e8me',
+ checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
+ updateAll: 'Tout mettre \u00e0 jour',
+ updateTool: 'Mettre \u00e0 jour',
+ checking: 'V\u00e9rification...',
+ updating: 'Mise \u00e0 jour...',
+ upToDate: '\u00c0 jour',
+ needsUpdate: 'Mise \u00e0 jour disponible',
+ current: 'Actuel',
+ latest: 'Dernier',
+ noUpdates: 'Tous les outils sont \u00e0 jour.',
+ version: 'Version',
+ installed: 'Install\u00e9',
+ missing: 'Manquant',
+ editProfile: 'Modifier le profil',
+ editProvider: 'Configurer',
+ cancel: 'Annuler',
},
}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css
index 36e978b..98a95d6 100644
--- a/web/src/styles/global.css
+++ b/web/src/styles/global.css
@@ -141,6 +141,7 @@ input::placeholder { color: var(--text-disabled); }
}
.nav-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.nav-tab.active { color: #fff; background: var(--accent); }
+.tab-icon { display: flex; align-items: center; }
.header-spacer { flex: 1; }
@@ -269,24 +270,177 @@ input::placeholder { color: var(--text-disabled); }
.shell-layout { display: flex; height: 100%; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
-.shell-xterm-wrapper { flex: 1; padding: 8px; background: var(--bg); overflow: hidden; }
-.shell-xterm-wrapper .xterm { height: 100%; padding: 4px; }
+
+.shell-tabs-bar {
+ display: flex; align-items: center; background: var(--bg-surface);
+ border-bottom: 1px solid var(--border); flex-shrink: 0;
+ height: 36px; padding: 0 8px; gap: 4px;
+}
+.shell-tabs {
+ display: flex; align-items: center; gap: 2px; flex: 1; overflow-x: auto;
+ scrollbar-width: none;
+}
+.shell-tabs::-webkit-scrollbar { display: none; }
+
+.shell-tab {
+ display: flex; align-items: center; gap: 6px;
+ padding: 4px 10px; border-radius: var(--radius) var(--radius) 0 0;
+ font-size: 12px; font-weight: 500; color: var(--text-tertiary);
+ cursor: pointer; transition: all 0.15s; user-select: none;
+ border: 1px solid transparent; border-bottom: none;
+ white-space: nowrap; max-width: 180px; position: relative;
+ background: transparent;
+}
+.shell-tab:hover { color: var(--text-primary); background: var(--bg-card); }
+.shell-tab.active {
+ color: var(--text-primary); background: var(--bg);
+ border-color: var(--border); border-bottom-color: var(--bg);
+ margin-bottom: -1px;
+}
+.shell-tab-name {
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ max-width: 120px; font-size: 12px;
+}
+.shell-tab-index {
+ font-size: 9px; color: var(--text-disabled); font-family: var(--font-mono);
+ padding: 0 3px; background: var(--bg-input); border-radius: 3px; line-height: 1.4;
+}
+.shell-tab-close {
+ display: flex; align-items: center; justify-content: center;
+ width: 16px; height: 16px; border-radius: 3px; border: none;
+ background: transparent; color: var(--text-disabled); cursor: pointer;
+ padding: 0; transition: all 0.1s; flex-shrink: 0;
+}
+.shell-tab-close:hover { background: var(--accent-bg); color: var(--accent); }
+
+.shell-tab-rename {
+ width: 80px; font-size: 12px; padding: 1px 4px; border-radius: 3px;
+ background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--accent);
+ outline: none; font-family: var(--font-sans);
+}
+
+.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
+
+.shell-new-tab-wrapper { position: relative; }
+.shell-new-tab-btn {
+ display: flex; align-items: center; gap: 2px;
+ padding: 4px 8px; border-radius: var(--radius);
+ background: transparent; border: 1px solid var(--border);
+ color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
+ font-size: 12px;
+}
+.shell-new-tab-btn:hover { color: var(--text-primary); background: var(--bg-card); border-color: var(--accent-dark); }
+
+.shell-menu-overlay {
+ position: fixed; inset: 0; z-index: 998;
+}
+.shell-new-tab-menu {
+ position: absolute; top: 100%; right: 0; z-index: 999;
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); padding: 6px;
+ min-width: 260px; max-height: 400px; overflow-y: auto;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+}
+.shell-menu-label {
+ font-size: 10px; font-weight: 700; color: var(--text-disabled);
+ text-transform: uppercase; letter-spacing: 0.5px;
+ padding: 6px 10px 4px;
+}
+.shell-menu-item {
+ display: flex; align-items: center; gap: 8px;
+ width: 100%; padding: 7px 10px; border-radius: var(--radius);
+ background: transparent; border: none; color: var(--text-secondary);
+ cursor: pointer; transition: all 0.1s; font-size: 12px;
+ text-align: left; font-family: var(--font-sans);
+}
+.shell-menu-item:hover { background: var(--bg-hover); color: var(--text-primary); }
+.shell-menu-item.accent { color: var(--accent); }
+.shell-menu-item.accent:hover { background: var(--accent-bg); }
+.shell-menu-item-sub {
+ font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono);
+ margin-left: auto;
+}
+.shell-menu-item-row { display: flex; align-items: center; }
+.shell-menu-item-icon {
+ display: flex; align-items: center; justify-content: center;
+ width: 24px; height: 24px; border-radius: var(--radius);
+ background: transparent; border: none; color: var(--text-disabled);
+ cursor: pointer; transition: all 0.1s; flex-shrink: 0;
+}
+.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
+.shell-menu-empty {
+ font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
+ font-style: italic;
+}
+.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
+
+.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
+.shell-xterm-instance {
+ position: absolute; inset: 0; padding: 4px;
+}
+.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
+
.shell-ai-col { width: 340px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
-.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-left: 8px; }
+.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
.connection-dot.off { background: var(--error); }
-.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; }
+.shell-modal-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.6);
+ display: flex; align-items: center; justify-content: center; z-index: 1000;
+}
+.shell-modal {
+ background: var(--bg-elevated); border: 1px solid var(--border);
+ border-radius: var(--radius-lg); min-width: 380px; max-width: 480px;
+ box-shadow: 0 16px 48px rgba(0,0,0,0.5);
+}
+.shell-modal-header {
+ padding: 16px 20px; font-size: 14px; font-weight: 700;
+ color: var(--text-primary); border-bottom: 1px solid var(--border);
+}
+.shell-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
+.shell-modal-label { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 2px; }
+.shell-modal-row { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; }
+.shell-modal-field { display: flex; flex-direction: column; }
+.shell-modal-footer {
+ padding: 12px 20px; border-top: 1px solid var(--border);
+ display: flex; justify-content: flex-end; gap: 8px;
+}
+
+.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; position: relative; }
.config-section { margin-bottom: 28px; }
.config-section-title {
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; justify-content: space-between;
}
.field-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; }
.field-row:last-child { border-bottom: none; }
.field-label { width: 140px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
.field-value { color: var(--text-primary); font-size: 14px; flex: 1; }
.field-value.empty { color: var(--text-disabled); font-style: italic; }
+.config-input { flex: 1; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; color: var(--text-primary); font-size: 13px; outline: none; font-family: var(--font-mono); }
+.config-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
+.config-form-actions { display: flex; gap: 8px; padding: 12px 0 0 152px; }
+.config-actions-row { display: flex; gap: 8px; margin-bottom: 12px; }
+.config-stats { display: flex; gap: 8px; margin-bottom: 12px; }
+.config-update-list { display: flex; flex-direction: column; gap: 2px; }
+.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); }
+.config-update-row:hover { background: var(--bg-card); }
+.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
+.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
+.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
+.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
+.config-skill-row:last-child { border-bottom: none; }
+.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
+.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
+.config-toast {
+ position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
+ background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
+ font-size: 13px; font-weight: 600; z-index: 100; animation: fadeIn 0.2s ease-out;
+ box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
+}
.provider-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
@@ -298,17 +452,7 @@ input::placeholder { color: var(--text-disabled); }
.provider-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); }
-.theme-picker { display: flex; gap: 8px; flex-wrap: wrap; }
-.theme-swatch {
- width: 48px; height: 48px; border-radius: var(--radius); border: 2px solid var(--border);
- cursor: pointer; transition: all 0.15s; position: relative;
-}
-.theme-swatch:hover { transform: scale(1.1); border-color: var(--accent-dim); }
-.theme-swatch.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
-.theme-swatch.active::after {
- content: '\2713'; position: absolute; inset: 0; display: flex; align-items: center;
- justify-content: center; color: #fff; font-size: 18px; font-weight: 700; text-shadow: 0 1px 3px rgba(0,0,0,0.5);
-}
+
.section-title { font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
.actions-stack { display: flex; flex-direction: column; gap: 6px; }
@@ -332,27 +476,29 @@ input::placeholder { color: var(--text-disabled); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
-.dashboard-tabs {
- display: flex; gap: 0; border-bottom: 1px solid var(--border);
- background: var(--bg-surface); flex-shrink: 0;
-}
-.dashboard-tab {
- padding: 10px 24px; font-size: 13px; font-weight: 600;
- color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
- display: flex; align-items: center; gap: 8px; border-bottom: 2px solid transparent;
- user-select: none;
-}
-.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-card); }
-.dashboard-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
-.tab-count {
- font-size: 10px; padding: 1px 6px; border-radius: 99px;
- background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono);
-}
-.tab-count.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
-
.dashboard-content { flex: 1; overflow-y: auto; }
+.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
-.dashboard-tools { padding: 16px 24px; }
+.dashboard-section {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ transition: border-color 0.2s;
+}
+.dashboard-section:hover { border-color: var(--accent-dim); }
+.dashboard-section.full-width { grid-column: 1 / -1; }
+.dashboard-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
+.dashboard-section-title {
+ font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
+
+.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
+
+.dashboard-tools { padding: 0; }
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
.tool-compact-row {
display: flex; align-items: center; gap: 10px;
@@ -365,7 +511,7 @@ input::placeholder { color: var(--text-disabled); }
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
-.dashboard-notifications { padding: 16px 24px; }
+.dashboard-notifications { padding: 0; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
@@ -378,7 +524,7 @@ input::placeholder { color: var(--text-disabled); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
-.dashboard-workflows { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; }
+.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;