From 3cdcb22068563b68e83576ac6603e2f858216b57 Mon Sep 17 00:00:00 2001 From: Augustin Date: Tue, 21 Apr 2026 22:35:49 +0200 Subject: [PATCH] feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Terminal: multi-tab sessions, SSH connections, shell detection (zsh/bash/fish/wsl/powershell) - Config: inline profile & provider editing, system update management - Dashboard: grid layout with inline tools/notifications/workflows sections - Add lucide-react icons, i18n keys (FR/EN), and new CSS components 💾 Generated with Crush Assisted-by: GLM-5-Turbo via Crush --- internal/api/handlers.go | 139 +++++++++ internal/api/server.go | 5 + internal/api/terminal.go | 195 +++++++++++- internal/config/config.go | 14 +- web/package-lock.json | 10 + web/package.json | 1 + web/src/api/client.js | 6 + web/src/components/App.jsx | 11 +- web/src/components/Config.jsx | 296 ++++++++++++++---- web/src/components/Dashboard.jsx | 102 +++--- web/src/components/Shell.jsx | 514 +++++++++++++++++++++++++------ web/src/i18n/en.js | 49 ++- web/src/i18n/fr.js | 49 ++- web/src/styles/global.css | 218 ++++++++++--- 14 files changed, 1342 insertions(+), 267 deletions(-) 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')}
+
+ + setSshForm(f => ({ ...f, name: e.target.value }))} + placeholder="prod-server" + /> + + setSshForm(f => ({ ...f, host: e.target.value }))} + placeholder="192.168.1.100" + /> +
+
+ + setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))} + /> +
+
+ + setSshForm(f => ({ ...f, user: e.target.value }))} + placeholder="root" + /> +
+
+ + setSshForm(f => ({ ...f, key_path: e.target.value }))} + placeholder="~/.ssh/id_rsa" + /> +
+
+ + +
+
+
+ )}
) } 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;