Files
DeepSeek-Reasonix/internal/frontend/serve/terminals.go
T
yhh 83bdeb81f0 feat(studio): the workbench gets a terminal of your own, as the Terminal board draws it
A fourth kind of tab opens a real shell (ConPTY on Windows, a pty elsewhere) in
the project folder, outside the model's permissions and sandbox and with the
host's provider keys left out of its environment. The kernel keeps each shell's
output, so a tab left or a window reloaded comes back as it was; a tab is named
for the command submitted while the shell is busy and carries a blue dot, and
closing a busy one asks first.

Also: the composer's note stack no longer clips to a few pixels when it holds
two notes, which left the second one overlapping the input.
2026-11-06 04:44:41 -08:00

227 lines
6.0 KiB
Go

package serve
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"reasonix/internal/platform/terminal"
)
const (
codeTerminalNotFound = "terminal.not_found"
codeTerminalShell = "terminal.shell_missing"
codeTerminalStart = "terminal.start_failed"
codeTerminalExited = "terminal.exited"
codeTerminalLimit = "terminal.limit"
terminalInputLimit = 1 << 16
)
// registerWorkbenchRoutes is what the panes beside the conversation call on.
func (s *Server) registerWorkbenchRoutes(mux *http.ServeMux) {
s.registerBrowserRoutes(mux)
s.registerTerminalRoutes(mux)
}
func (s *Server) registerTerminalRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /terminals/shells", s.terminalShells)
mux.HandleFunc("GET /terminals", s.terminalList)
mux.HandleFunc("POST /terminals", s.terminalOpen)
mux.HandleFunc("DELETE /terminals/{id}", s.terminalClose)
mux.HandleFunc("POST /terminals/{id}/restart", s.terminalRestart)
mux.HandleFunc("POST /terminals/{id}/resize", s.terminalResize)
mux.HandleFunc("POST /terminals/{id}/input", s.terminalInput)
mux.HandleFunc("GET /terminals/{id}/stream", s.terminalStream)
}
// refuseTerminal names a terminal failure by the code its producer gave it.
func refuseTerminal(w http.ResponseWriter, err error) {
switch terminal.CodeOf(err) {
case terminal.CodeNotFound:
refuse(w, http.StatusNotFound, codeTerminalNotFound, err.Error(), nil)
case terminal.CodeShellMissing:
refuse(w, http.StatusBadRequest, codeTerminalShell, err.Error(), nil)
case terminal.CodeExited:
refuse(w, http.StatusConflict, codeTerminalExited, err.Error(), nil)
case terminal.CodeLimit:
refuse(w, http.StatusConflict, codeTerminalLimit, err.Error(), nil)
default:
refuse(w, http.StatusInternalServerError, codeTerminalStart, err.Error(), nil)
}
}
func (s *Server) terminalShells(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
writeJSON(w, terminal.Shells(os.Getenv))
}
func (s *Server) terminalList(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
writeJSON(w, s.terminals.List())
}
// terminalOpen starts the shell the person picked, or their best one, in the
// workspace folder.
func (s *Server) terminalOpen(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
var req struct {
Shell string `json:"shell"`
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, terminalInputLimit)).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
badBody(w)
return
}
var sh *terminal.Shell
for _, c := range terminal.Shells(os.Getenv) {
if req.Shell == "" || c.Kind == req.Shell {
sh = &c
break
}
}
if sh == nil {
refuse(w, http.StatusBadRequest, codeTerminalShell, "that shell is not on this machine", nil)
return
}
sess, err := s.terminals.Open(terminal.Options{
Shell: sh.Path, Args: sh.Args, Name: sh.Name,
Dir: s.ctl().WorkspaceRoot(), Env: terminal.PersonEnv(os.Environ()),
Cols: req.Cols, Rows: req.Rows,
})
if err != nil {
refuseTerminal(w, err)
return
}
writeJSON(w, sess.Info())
}
func (s *Server) terminalClose(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
if err := s.terminals.Close(r.PathValue("id")); err != nil {
refuseTerminal(w, err)
return
}
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) terminalRestart(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
sess, err := s.terminals.Get(r.PathValue("id"))
if err == nil {
err = sess.Restart()
}
if err != nil {
refuseTerminal(w, err)
return
}
writeJSON(w, sess.Info())
}
func (s *Server) terminalResize(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
var req struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil {
badBody(w)
return
}
sess, err := s.terminals.Get(r.PathValue("id"))
if err == nil {
err = sess.Resize(req.Cols, req.Rows)
}
if err != nil {
refuseTerminal(w, err)
return
}
writeJSON(w, map[string]bool{"ok": true})
}
// terminalInput carries the person's keystrokes as they typed them.
func (s *Server) terminalInput(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
var req struct {
Data string `json:"data"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, terminalInputLimit)).Decode(&req); err != nil {
badBody(w)
return
}
sess, err := s.terminals.Get(r.PathValue("id"))
if err == nil {
err = sess.Write([]byte(req.Data))
}
if err != nil {
refuseTerminal(w, err)
return
}
writeJSON(w, map[string]bool{"ok": true})
}
type terminalFrame struct {
Kind string `json:"kind"` // replay | out | exit
Data string `json:"data,omitempty"`
Code *int `json:"code,omitempty"`
}
// terminalStream sends what the shell held, then what it writes, as base64 so
// bytes that are not text cross intact, and ends with the exit code.
func (s *Server) terminalStream(w http.ResponseWriter, r *http.Request) {
if refuseDevice(w, r) {
return
}
flusher, ok := w.(http.Flusher)
if !ok {
refuse(w, http.StatusInternalServerError, "stream.unsupported", "this transport cannot stream", nil)
return
}
sess, err := s.terminals.Get(r.PathValue("id"))
if err != nil {
refuseTerminal(w, err)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
send := func(f terminalFrame) {
b, _ := json.Marshal(f)
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
sub := sess.Subscribe()
defer sub.Cancel()
send(terminalFrame{Kind: "replay", Data: base64.StdEncoding.EncodeToString(sub.Replay)})
for {
select {
case c, open := <-sub.Output:
if !open {
<-sess.Done()
send(terminalFrame{Kind: "exit", Code: sess.Info().ExitCode})
return
}
send(terminalFrame{Kind: "out", Data: base64.StdEncoding.EncodeToString(c)})
case <-r.Context().Done():
return
}
}
}