feat(shell): real terminal with xterm.js + PTY over WebSocket Replace fake shell input with a full PTY-backed terminal using xterm.js. Apps like btop, vim, htop now work. AI chat panel is always visible. Backend: - Add WebSocket handler /api/ws/terminal with creack/pty - Allocate real pseudo-terminal with TERM=xterm-256color - Bidirectional I/O + dynamic resize via pty.Setsize - Skip JSON headers on /api/ws/* paths for WebSocket upgrade Frontend: - Integrate xterm.js with FitAddon and WebLinksAddon - Cyberpunk color theme matching app design - ResizeObserver for automatic terminal resizing - AI assistant panel always visible (340px, no toggle) - Connection status indicator (green/red dot) Dependencies: - Go: github.com/gorilla/websocket, github.com/creack/pty/v2 - npm: @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
2
go.mod
2
go.mod
@@ -4,6 +4,8 @@ go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/creack/pty/v2 v2.0.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
4
go.sum
4
go.sum
@@ -44,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
|
||||
github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/skills"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
@@ -244,3 +245,83 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Message == "" {
|
||||
writeError(w, "no message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(`You are Muyue Studio's AI orchestrator. You help the user with software development tasks. You can:
|
||||
- Create and manage development plans with step-by-step workflows
|
||||
- Propose agents (tools like Crush, Claude Code, etc.) to execute specific tasks
|
||||
- Track progress across multi-step tasks
|
||||
- Suggest file modifications, code reviews, and architecture decisions
|
||||
|
||||
Be concise, actionable, and structured. When proposing a plan, use clear numbered steps. When referencing files, use relative paths. You are embedded in the Muyue desktop app.`)
|
||||
|
||||
if body.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
chunkSize := 8
|
||||
result, err := orb.Send(body.Message)
|
||||
if err != nil {
|
||||
data, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
runes := []rune(result)
|
||||
for i := 0; i < len(runes); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
chunk := string(runes[i:end])
|
||||
data, _ := json.Marshal(map[string]string{"content": chunk})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"done": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
result, err := orb.Send(body.Message)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"content": result})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
@@ -37,10 +38,15 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/scan", s.handleScan)
|
||||
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/mcp/configure", s.handleMCPConfigure)
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
|
||||
120
internal/api/terminal.go
Normal file
120
internal/api/terminal.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
type wsMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
shell := "/bin/sh"
|
||||
if s, err := exec.LookPath("bash"); err == nil {
|
||||
shell = s
|
||||
}
|
||||
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Printf("pty start: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
once.Do(func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// PTY -> WebSocket
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
Type: "output",
|
||||
Data: string(buf[:n]),
|
||||
}); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// WebSocket -> PTY
|
||||
conn.SetReadLimit(1 << 20) // 1MB
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
var msg wsMessage
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
case "resize":
|
||||
if msg.Rows > 0 && msg.Cols > 0 {
|
||||
pty.Setsize(ptmx, &pty.Winsize{
|
||||
Rows: msg.Rows,
|
||||
Cols: msg.Cols,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type Orchestrator struct {
|
||||
client *http.Client
|
||||
history []Message
|
||||
histMu sync.Mutex
|
||||
systemPrompt string
|
||||
}
|
||||
|
||||
var sharedHTTPClient = &http.Client{
|
||||
@@ -77,6 +78,10 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
||||
o.systemPrompt = prompt
|
||||
}
|
||||
|
||||
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
@@ -88,9 +93,15 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
o.history = o.history[len(o.history)-maxHistorySize:]
|
||||
}
|
||||
|
||||
messages := make([]Message, 0, len(o.history)+1)
|
||||
if o.systemPrompt != "" {
|
||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
||||
}
|
||||
messages = append(messages, o.history...)
|
||||
|
||||
reqBody := ChatRequest{
|
||||
Model: o.provider.Model,
|
||||
Messages: o.history,
|
||||
Messages: messages,
|
||||
Stream: false,
|
||||
}
|
||||
o.histMu.Unlock()
|
||||
|
||||
@@ -2,7 +2,7 @@ package version
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.2.1"
|
||||
Version = "0.3.0"
|
||||
Author = "La Légion de Muyue"
|
||||
)
|
||||
|
||||
|
||||
24
web/package-lock.json
generated
24
web/package-lock.json
generated
@@ -6,6 +6,9 @@
|
||||
"": {
|
||||
"name": "muyue-web",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
@@ -396,6 +399,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
|
||||
@@ -27,6 +27,42 @@ const api = {
|
||||
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
||||
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
||||
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
||||
sendChat: (message, stream = true) => {
|
||||
if (!stream) {
|
||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${API_BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, stream: true }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
reject(new Error(err.error || res.statusText))
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let full = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const text = decoder.decode(value, { stream: true })
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.error) { reject(new Error(data.error)); return }
|
||||
if (data.done) { resolve(full); return }
|
||||
if (data.content) full += data.content
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
resolve(full)
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -1,66 +1,143 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
export default function Shell({ api }) {
|
||||
const { t } = useI18n()
|
||||
const [history, setHistory] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [cwd, setCwd] = useState('~')
|
||||
const [showAi, setShowAi] = useState(false)
|
||||
const termRef = useRef(null)
|
||||
const fitAddonRef = useRef(null)
|
||||
const wsRef = useRef(null)
|
||||
const containerRef = useRef(null)
|
||||
|
||||
const [aiMessages, setAiMessages] = useState([
|
||||
{ role: 'ai', content: t('shell.aiWelcome') }
|
||||
])
|
||||
const [aiInput, setAiInput] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [cmdHistory, setCmdHistory] = useState([])
|
||||
const [histIdx, setHistIdx] = useState(-1)
|
||||
const outputRef = useRef(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const aiMessagesRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
outputRef.current?.scrollTo(0, outputRef.current.scrollHeight)
|
||||
}, [history])
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
const handleCommand = async (cmd) => {
|
||||
if (!cmd.trim()) return
|
||||
if (cmd === 'clear') { setHistory([]); return }
|
||||
const getWsUrl = useCallback(() => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}/api/ws/terminal`
|
||||
}, [])
|
||||
|
||||
setCmdHistory(prev => [...prev, cmd])
|
||||
setHistIdx(-1)
|
||||
setHistory(prev => [...prev, { type: 'cmd', text: `${cwd} $ ${cmd}` }])
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) 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 fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(containerRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
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 }))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const res = await api.runCommand(cmd, cwd === '~' ? '' : cwd)
|
||||
if (res.output) setHistory(prev => [...prev, { type: 'out', text: res.output }])
|
||||
if (res.error) setHistory(prev => [...prev, { type: 'err', text: res.error }])
|
||||
if (cmd.startsWith('cd ')) {
|
||||
const dir = cmd.slice(3).trim()
|
||||
setCwd(dir === '~' ? '~' : dir)
|
||||
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 (err) {
|
||||
setHistory(prev => [...prev, { type: 'err', text: err.message }])
|
||||
} catch {
|
||||
term.write(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleCommand(input)
|
||||
setInput('')
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (cmdHistory.length === 0) return
|
||||
const newIdx = histIdx === -1 ? cmdHistory.length - 1 : Math.max(0, histIdx - 1)
|
||||
setHistIdx(newIdx)
|
||||
setInput(cmdHistory[newIdx])
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (histIdx === -1) return
|
||||
const newIdx = histIdx + 1
|
||||
if (newIdx >= cmdHistory.length) { setHistIdx(-1); setInput('') }
|
||||
else { setHistIdx(newIdx); setInput(cmdHistory[newIdx]) }
|
||||
ws.onclose = () => {
|
||||
setConnected(false)
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnected(false)
|
||||
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 }))
|
||||
}
|
||||
})
|
||||
|
||||
const onResize = () => {
|
||||
if (containerRef.current?.offsetParent !== null) {
|
||||
fitAddon.fit()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(onResize)
|
||||
resizeObserver.observe(containerRef.current)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
resizeObserver.disconnect()
|
||||
ws.close()
|
||||
term.dispose()
|
||||
}
|
||||
}, [getWsUrl])
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading) return
|
||||
const text = aiInput.trim()
|
||||
@@ -78,42 +155,20 @@ export default function Shell({ api }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-horizontal" style={{ height: '100%' }}>
|
||||
<div className="terminal" style={{ flex: 1 }}>
|
||||
<div className="shell-layout">
|
||||
<div className="shell-terminal-col">
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
{t('shell.terminal')}
|
||||
<span className="panel-subtitle">{cwd}</span>
|
||||
<span className={`connection-dot ${connected ? 'on' : 'off'}`} />
|
||||
</span>
|
||||
<button className="ghost sm" onClick={() => setShowAi(!showAi)}>
|
||||
{showAi ? t('shell.hideAi') : t('shell.aiAssistant')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="shell-xterm-wrapper" ref={containerRef} />
|
||||
</div>
|
||||
|
||||
<div className="terminal-output" ref={outputRef}>
|
||||
{history.map((line, i) => (
|
||||
<div key={i} className={`terminal-line ${line.type}`}>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-input-bar">
|
||||
<span className="terminal-prompt">›</span>
|
||||
<input
|
||||
className="terminal-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAi && (
|
||||
<div className="ai-panel">
|
||||
<div className="shell-ai-col">
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-messages">
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
@@ -131,7 +186,6 @@ export default function Shell({ api }) {
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,320 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const MSG_ID = () => Math.random().toString(36).slice(2, 10)
|
||||
|
||||
function parsePlanBlocks(text) {
|
||||
const plans = []
|
||||
const regex = /(?:^|\n)(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN[^\]]*\]?:?\s*(.*?)(?=\n(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN|\n## |\n### |\n$)/gis
|
||||
const matches = text.matchAll(regex)
|
||||
for (const m of matches) {
|
||||
plans.push({ id: MSG_ID(), title: m[1].trim(), content: m[0].trim() })
|
||||
}
|
||||
if (plans.length === 0 && /plan|workflow/i.test(text)) {
|
||||
const lines = text.split('\n').filter(l => /^\s*[-*]\s|^\s*\d+\.\s/.test(l))
|
||||
if (lines.length > 0) {
|
||||
plans.push({ id: MSG_ID(), title: text.split('\n')[0].slice(0, 80), content: text.trim() })
|
||||
}
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
function parseAgentMentions(text) {
|
||||
const agents = new Set()
|
||||
const names = ['crush', 'claude', 'claude code', 'ollama', 'copilot', 'cursor', 'agent']
|
||||
for (const name of names) {
|
||||
if (new RegExp('\\b' + name + '\\b', 'i').test(text)) {
|
||||
agents.add(name)
|
||||
}
|
||||
}
|
||||
return [...agents]
|
||||
}
|
||||
|
||||
function parseSteps(text) {
|
||||
const steps = []
|
||||
const lines = text.split('\n')
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(\d+)[.)]\s+(.+)/)
|
||||
if (match) {
|
||||
steps.push({ num: match[1], text: match[2].trim() })
|
||||
}
|
||||
const bulletMatch = line.match(/^\s*[-*]\s+(.+)/)
|
||||
if (bulletMatch) {
|
||||
steps.push({ num: String(steps.length + 1), text: bulletMatch[1].trim() })
|
||||
}
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
function renderContent(text) {
|
||||
const parts = []
|
||||
let i = 0
|
||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||
let match
|
||||
let lastIndex = 0
|
||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
||||
}
|
||||
const full = match[1]
|
||||
const firstNewline = full.indexOf('\n')
|
||||
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
||||
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
||||
parts.push({ type: 'code', lang, content: code })
|
||||
lastIndex = match.index + full.length
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex) })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
|
||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
||||
return html
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const plans = msg.role === 'ai' ? parsePlanBlocks(msg.content) : []
|
||||
const steps = msg.role === 'ai' ? parseSteps(msg.content) : []
|
||||
const agents = msg.role === 'ai' ? parseAgentMentions(msg.content) : []
|
||||
|
||||
return (
|
||||
<div className={`studio-msg ${msg.role}`}>
|
||||
{msg.role === 'ai' && (
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-msg-content">
|
||||
{renderContent(msg.content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'ai' && (plans.length > 0 || agents.length > 0) && (
|
||||
<div className="studio-msg-meta">
|
||||
{plans.map(plan => (
|
||||
<div key={plan.id} className="studio-plan-chip" onClick={() => setExpanded(expanded === plan.id ? null : plan.id)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
{plan.title.slice(0, 60)}
|
||||
<span className="studio-expand-icon">{expanded === plan.id ? '\u25B2' : '\u25BC'}</span>
|
||||
</div>
|
||||
))}
|
||||
{agents.map(agent => (
|
||||
<span key={agent} className="studio-agent-tag">
|
||||
{agent}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && plans.find(p => p.id === expanded) && (
|
||||
<div className="studio-plan-detail">
|
||||
<div className="studio-plan-detail-header">{t('studio.planDetail')}</div>
|
||||
{steps.length > 0 && (
|
||||
<div className="studio-steps">
|
||||
{steps.map(step => (
|
||||
<div key={step.num} className="studio-step">
|
||||
<span className="studio-step-num">{step.num}</span>
|
||||
<span className="studio-step-text">{step.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-plan-raw">
|
||||
<pre>{plans.find(p => p.id === expanded).content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingMessage({ content }) {
|
||||
return (
|
||||
<div className="studio-msg ai">
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-msg-content">
|
||||
{renderContent(content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
|
||||
const { t } = useI18n()
|
||||
const [tab, setTab] = useState('plans')
|
||||
|
||||
const allPlans = []
|
||||
const allAgents = new Set()
|
||||
const activities = []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role === 'ai') {
|
||||
const plans = parsePlanBlocks(msg.content)
|
||||
for (const plan of plans) {
|
||||
if (!allPlans.find(p => p.title === plan.title)) {
|
||||
allPlans.push({ ...plan, msgIndex: i })
|
||||
}
|
||||
}
|
||||
parseAgentMentions(msg.content).forEach(a => allAgents.add(a))
|
||||
}
|
||||
activities.push({ role: msg.role, content: msg.content.slice(0, 100), time: msg.time })
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'plans', label: t('studio.plans'), count: allPlans.length },
|
||||
{ id: 'agents', label: t('studio.agents'), count: allAgents.size },
|
||||
{ id: 'activity', label: t('studio.activity'), count: activities.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="studio-context">
|
||||
<div className="studio-context-tabs">
|
||||
{tabs.map(t2 => (
|
||||
<div
|
||||
key={t2.id}
|
||||
className={`studio-context-tab ${tab === t2.id ? 'active' : ''}`}
|
||||
onClick={() => setTab(t2.id)}
|
||||
>
|
||||
{t2.label}
|
||||
{t2.count > 0 && <span className="studio-tab-count">{t2.count}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="studio-context-body">
|
||||
{tab === 'plans' && (
|
||||
allPlans.length > 0 ? (
|
||||
<div className="studio-plan-list">
|
||||
{allPlans.map(plan => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`studio-plan-item ${selectedPlan === plan.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectPlan(selectedPlan === plan.id ? null : plan.id)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<div className="studio-plan-item-text">{plan.title}</div>
|
||||
<span className="studio-plan-item-badge">{parseSteps(plan.content).length} {t('studio.steps')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-empty">{t('studio.noPlansYet')}</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'agents' && (
|
||||
allAgents.size > 0 ? (
|
||||
<div className="studio-agent-list">
|
||||
{[...allAgents].map(agent => (
|
||||
<div key={agent} className="studio-agent-item">
|
||||
<div className="studio-agent-dot" />
|
||||
<span className="studio-agent-name">{agent}</span>
|
||||
<span className="badge info">{t('studio.mentioned')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-empty">{t('studio.noAgentsYet')}</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'activity' && (
|
||||
<div className="studio-activity-list">
|
||||
{activities.map((act, i) => (
|
||||
<div key={i} className="studio-activity-item">
|
||||
<div className={`studio-activity-dot ${act.role}`} />
|
||||
<div className="studio-activity-text">
|
||||
{act.role === 'user' ? t('studio.you') + ': ' : 'AI: '}
|
||||
{act.content}{act.content.length >= 100 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Studio({ api }) {
|
||||
const { t, layout } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const [messages, setMessages] = useState([
|
||||
{ role: 'ai', content: t('studio.welcome') },
|
||||
{ role: 'ai', content: t('studio.configureHint') },
|
||||
{ id: MSG_ID(), role: 'ai', content: t('studio.welcomeNew'), time: new Date() },
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [sidebarPanel, setSidebarPanel] = useState('chat')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [selectedPlan, setSelectedPlan] = useState(null)
|
||||
const [showContext, setShowContext] = useState(true)
|
||||
const messagesEnd = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
}, [messages, streaming])
|
||||
|
||||
const handleSend = () => {
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
|
||||
}
|
||||
}, [input])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || loading) return
|
||||
const text = input.trim()
|
||||
setMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setInput('')
|
||||
const userMsg = { id: MSG_ID(), role: 'user', content: text, time: new Date() }
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setLoading(true)
|
||||
setStreaming('')
|
||||
|
||||
api.runCommand(`echo "AI response simulation for: ${text}"`, '')
|
||||
.then(res => {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || t('studio.noResponse') }])
|
||||
})
|
||||
.catch(err => {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: `${t('studio.error')}: ${err.message}` }])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
try {
|
||||
let accumulated = ''
|
||||
await api.sendChat(text, true).then(full => {
|
||||
accumulated = full
|
||||
}).catch(() => {})
|
||||
|
||||
const finalContent = accumulated || t('studio.noResponse')
|
||||
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: finalContent, time: new Date() }])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: `${t('studio.error')}: ${err.message}`, time: new Date() }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setStreaming('')
|
||||
}
|
||||
}, [input, loading, api, t])
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -40,99 +323,69 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarItems = [
|
||||
{ id: 'chat', label: t('studio.chat'), icon: '#' },
|
||||
{ id: 'agents', label: t('studio.agents'), icon: '*' },
|
||||
{ id: 'workflows', label: t('studio.workflows'), icon: '~' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="split-horizontal">
|
||||
<div className="chat-layout" style={{ flex: 1, borderRight: '1px solid var(--border)' }}>
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
{t('studio.chat')}
|
||||
{loading && <span className="spinner" />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`message ${msg.role}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
<div className="studio-layout">
|
||||
<div className="studio-chat-area">
|
||||
<div className="studio-messages">
|
||||
{messages.map(msg => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
{streaming && <StreamingMessage content={streaming} />}
|
||||
{loading && !streaming && (
|
||||
<div className="studio-msg ai">
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-bar">
|
||||
<input
|
||||
<div className="studio-input-area">
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('studio.placeholder')}
|
||||
placeholder={t('studio.placeholderNew')}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
/>
|
||||
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
|
||||
{t('studio.send')}
|
||||
<button
|
||||
className="studio-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="split-right">
|
||||
<div className="sidebar-nav">
|
||||
{sidebarItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sidebar-tab ${sidebarPanel === item.id ? 'active' : ''}`}
|
||||
onClick={() => setSidebarPanel(item.id)}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', width: 16 }}>{item.icon}</span>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sidebarPanel === 'chat' && (
|
||||
<div>
|
||||
<div className="section-title">{t('studio.commands')}</div>
|
||||
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}>
|
||||
{t('studio.planGoal')}<br />
|
||||
{t('studio.help')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'agents' && (
|
||||
<div>
|
||||
<div className="section-title">{t('studio.activeAgents')}</div>
|
||||
<div className="agent-card">
|
||||
<div className="agent-avatar">C</div>
|
||||
<div>
|
||||
<div className="agent-name">{t('studio.crush')}</div>
|
||||
<div className="agent-status">{t('studio.stopped')}</div>
|
||||
</div>
|
||||
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
|
||||
</div>
|
||||
<div className="agent-card">
|
||||
<div className="agent-avatar">CC</div>
|
||||
<div>
|
||||
<div className="agent-name">{t('studio.claudeCode')}</div>
|
||||
<div className="agent-status">{t('studio.stopped')}</div>
|
||||
</div>
|
||||
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'workflows' && (
|
||||
<div>
|
||||
<div className="section-title">{t('studio.workflows')}</div>
|
||||
<div className="empty-state">
|
||||
{t('studio.noWorkflow')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('studio.usePlan')}</span>
|
||||
</div>
|
||||
<div className={`studio-sidebar ${showContext ? 'open' : ''}`}>
|
||||
<div className="studio-sidebar-header">
|
||||
<span>{t('studio.context')}</span>
|
||||
<button className="ghost sm studio-sidebar-toggle" onClick={() => setShowContext(!showContext)}>
|
||||
{showContext ? '\u203A' : '\u2039'}
|
||||
</button>
|
||||
</div>
|
||||
{showContext && (
|
||||
<ContextPanel
|
||||
messages={messages}
|
||||
selectedPlan={selectedPlan}
|
||||
onSelectPlan={setSelectedPlan}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,11 +46,13 @@ const fr = {
|
||||
|
||||
studio: {
|
||||
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
|
||||
welcomeNew: 'Bienvenue dans Muyue Studio. Je suis votre orchestrateur IA. D\u00e9crivez votre projet et je cr\u00e9erai un plan, proposerai des agents, et suivrai chaque \u00e9tape.',
|
||||
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
|
||||
chat: 'Chat',
|
||||
agents: 'Agents',
|
||||
workflows: 'Workflows',
|
||||
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
|
||||
placeholderNew: 'D\u00e9crivez votre projet ou posez une question...',
|
||||
send: 'Envoyer',
|
||||
commands: 'Commandes',
|
||||
planGoal: '/plan <objectif>',
|
||||
@@ -64,6 +66,16 @@ const fr = {
|
||||
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
|
||||
noResponse: 'Pas de r\u00e9ponse',
|
||||
error: 'Erreur',
|
||||
inputHint: 'Entr\u00e9e pour envoyer, Shift+Entr\u00e9e pour un retour \u00e0 la ligne',
|
||||
context: 'Contexte',
|
||||
plans: 'Plans',
|
||||
activity: 'Activit\u00e9',
|
||||
noPlansYet: 'Aucun plan d\u00e9tect\u00e9. Demandez \u00e0 l\u2019IA de cr\u00e9er un plan.',
|
||||
noAgentsYet: 'Aucun agent mentionn\u00e9.',
|
||||
planDetail: 'D\u00e9tail du plan',
|
||||
steps: '\u00e9tapes',
|
||||
you: 'Vous',
|
||||
mentioned: 'mentionn\u00e9',
|
||||
},
|
||||
|
||||
shell: {
|
||||
|
||||
@@ -267,16 +267,14 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
.terminal { display: flex; flex-direction: column; height: 100%; background: var(--bg); }
|
||||
.terminal-output { flex: 1; padding: 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.6; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
|
||||
.terminal-line { margin-bottom: 2px; }
|
||||
.terminal-line.cmd { color: var(--accent-dim); }
|
||||
.terminal-line.out { color: var(--text-primary); }
|
||||
.terminal-line.err { color: var(--error); }
|
||||
.terminal-input-bar { display: flex; align-items: center; padding: 10px 16px; background: var(--bg-surface); border-top: 1px solid var(--border); gap: 8px; }
|
||||
.terminal-prompt { color: var(--success); font-family: var(--font-mono); font-weight: 700; font-size: 14px; flex-shrink: 0; }
|
||||
.terminal-input { flex: 1; background: transparent; border: none; outline: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 13px; padding: 0; }
|
||||
.terminal-input:focus { box-shadow: none; border-color: transparent; }
|
||||
.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-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.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%; }
|
||||
.config-section { margin-bottom: 28px; }
|
||||
@@ -323,7 +321,6 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; }
|
||||
.agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
|
||||
|
||||
.ai-panel { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; }
|
||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||
|
||||
Reference in New Issue
Block a user