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>
121 lines
2.1 KiB
Go
121 lines
2.1 KiB
Go
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,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|