chore: bump version to 0.3.0
Some checks failed
Beta Release / beta (push) Has been cancelled

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:
Augustin
2026-04-21 22:17:24 +02:00
parent fc7981037f
commit b0b0e1d308
14 changed files with 812 additions and 209 deletions

2
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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})
}

View File

@@ -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
View 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,
})
}
}
}
}

View File

@@ -41,11 +41,12 @@ type ChatResponse struct {
}
type Orchestrator struct {
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
config *config.MuyueConfig
provider *config.AIProvider
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()

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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

View File

@@ -1,65 +1,142 @@
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
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 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 }))
}
} catch (err) {
setHistory(prev => [...prev, { type: 'err', text: err.message }])
}
}
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.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 = () => {
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
@@ -78,60 +155,37 @@ 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="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">&rsaquo;</span>
<input
className="terminal-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
</div>
<div className="shell-xterm-wrapper" ref={containerRef} />
</div>
{showAi && (
<div className="ai-panel">
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-messages">
{aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}>
{msg.content}
</div>
))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div>
<div className="ai-panel-input">
<input
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder={t('shell.askAi')}
/>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
</div>
<div className="shell-ai-col">
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}>
{msg.content}
</div>
))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div>
)}
<div className="ai-panel-input">
<input
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder={t('shell.askAi')}
/>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
</div>
</div>
</div>
)
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('studio.placeholder')}
disabled={loading}
/>
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
{t('studio.send')}
</button>
<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.placeholderNew')}
disabled={loading}
rows={1}
/>
<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 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>
{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>
{showContext && (
<ContextPanel
messages={messages}
selectedPlan={selectedPlan}
onSelectPlan={setSelectedPlan}
/>
)}
</div>
</div>

View File

@@ -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: {

View File

@@ -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; }