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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -2,7 +2,7 @@ package version
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.2.1"
|
||||
Version = "0.3.0"
|
||||
Author = "La Légion de Muyue"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user