From fc7a5b9d876b991f13e14d5d8db7368e8ddbb057 Mon Sep 17 00:00:00 2001 From: Muyue Date: Mon, 27 Apr 2026 11:56:40 +0200 Subject: [PATCH 1/2] fix(terminal/windows): fallback to pipes when PTY unsupported (v0.7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal tab was unusable on Windows: creack/pty has no native Windows ConPTY support, so pty.Start() returned "operating system not supported" and the WebSocket closed immediately on any tab click — even though the menu detection (wsl --list --quiet, pwsh, cmd) worked. Introduce a termSession interface with two implementations selected at runtime: - ptySession (unix): unchanged behaviour, real PTY via creack/pty, resize works, vim/top behave normally. - pipeSession (windows): plain stdin + merged stdout/stderr pipes, forwarded to the WebSocket. Resize is a no-op (no SIGWINCH without a TTY), so full-screen TUIs misbehave in this mode — but launching wsl.exe, pwsh, or cmd works for line-based interaction, which is what the menu shortcuts target. handleTerminalWS now goes through startTermSession(cmd); the unix path is unchanged, the windows fallback kicks in only when pty.Start would have failed. Bump v0.7.0 → v0.7.1; CHANGELOG entry added. --- CHANGELOG.md | 10 ++ internal/api/terminal.go | 35 +++--- internal/api/terminal_session.go | 198 +++++++++++++++++++++++++++++++ internal/version/version.go | 2 +- 4 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 internal/api/terminal_session.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b1799..d92c906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## v0.7.1 + +### Fix + +- **fix(terminal/windows): "unsupported" / connection closed** — `creack/pty` n'a pas de support Windows natif et `pty.Start()` retourne immédiatement une erreur ("operating system not supported"), fermant le WebSocket avant même la bannière. L'utilisateur voyait le menu des terminaux peuplé (détection OK : `wsl --list --quiet` fonctionne) mais chaque clic se soldait par "unsupported" ou une connexion fermée. +- Introduction de l'abstraction `termSession` (`internal/api/terminal_session.go`) avec deux implémentations sélectionnées au runtime : + - **`ptySession`** (Linux / macOS / BSDs) : conserve le comportement existant (TTY complet via `creack/pty`, resize, apps interactives type vim/top). + - **`pipeSession`** (Windows) : pipes natifs `stdin` + `stdout` + `stderr` mergés, lus en goroutines, forwardés au WebSocket. Suffisant pour `wsl.exe`, `pwsh`, `cmd` en mode ligne — la plupart des cas d'usage (lancer une commande, voir la sortie, taper la suivante). Resize est un no-op (pas de SIGWINCH sans TTY) ; les TUIs en plein écran ne fonctionnent pas dans ce mode. +- Refactor minimal de `handleTerminalWS` : utilise `startTermSession(cmd)` au lieu de `pty.Start(cmd)` direct ; même chemin code pour les deux OS. + ## v0.7.0 ### Changes since v0.4.0 diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 566eb64..445a657 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -13,7 +13,6 @@ import ( "sync" "time" - "github.com/creack/pty/v2" "github.com/gorilla/websocket" "github.com/muyue/muyue/internal/config" ) @@ -154,7 +153,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { } cmd.Env = append(cmd.Env, "TERM=xterm-256color") - ptmx, err := pty.Start(cmd) + session, err := startTermSession(cmd) if err != nil { conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()}) return @@ -163,11 +162,8 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { var once sync.Once cleanup := func() { once.Do(func() { - ptmx.Close() - if cmd.Process != nil { - cmd.Process.Kill() - cmd.Wait() - } + session.Close() + session.Wait() }) } defer cleanup() @@ -175,15 +171,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { go func() { buf := make([]byte, 4096) for { - n, err := ptmx.Read(buf) - if err != nil { - cleanup() - return + n, err := session.Read(buf) + if n > 0 { + if err := conn.WriteJSON(wsMessage{ + Type: "output", + Data: string(buf[:n]), + }); err != nil { + cleanup() + return + } } - if err := conn.WriteJSON(wsMessage{ - Type: "output", - Data: string(buf[:n]), - }); err != nil { + if err != nil { cleanup() return } @@ -207,16 +205,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { switch msg.Type { case "input": - if _, err := ptmx.Write([]byte(msg.Data)); err != nil { + if _, err := session.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, - }) + session.Resize(msg.Rows, msg.Cols) } } } diff --git a/internal/api/terminal_session.go b/internal/api/terminal_session.go new file mode 100644 index 0000000..0dcdc68 --- /dev/null +++ b/internal/api/terminal_session.go @@ -0,0 +1,198 @@ +package api + +// Cross-platform terminal session abstraction. +// +// On Linux / macOS we have a real PTY via creack/pty: full TTY semantics, +// resize support, interactive apps (vim, top…) work. On Windows the same +// package returns "operating system not supported" at pty.Start time, so we +// fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't +// give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`, +// and most CLI tools emit usable line-buffered output, which is what the +// user actually clicks for. + +import ( + "io" + "os" + "os/exec" + "runtime" + "sync" + + "github.com/creack/pty/v2" +) + +// termSession is the read/write/resize/close surface used by handleTerminalWS. +type termSession interface { + Read([]byte) (int, error) + Write([]byte) (int, error) + Resize(rows, cols uint16) error + Close() error + Wait() error + Pid() int +} + +// startTermSession tries a real PTY first; on Windows or any pty.Start failure +// it falls back to a pipe-based session. +func startTermSession(cmd *exec.Cmd) (termSession, error) { + if runtime.GOOS != "windows" { + ptmx, err := pty.Start(cmd) + if err == nil { + return &ptySession{ptmx: ptmx, cmd: cmd}, nil + } + // On unix, a pty.Start error is fatal — pipes won't help interactive + // shells without a TTY, and the unix build is the supported path. + return nil, err + } + return startPipeSession(cmd) +} + +// ptySession wraps creack/pty's *os.File-backed PTY. +type ptySession struct { + ptmx *os.File + cmd *exec.Cmd +} + +func (s *ptySession) Read(p []byte) (int, error) { return s.ptmx.Read(p) } +func (s *ptySession) Write(p []byte) (int, error) { return s.ptmx.Write(p) } +func (s *ptySession) Resize(rows, cols uint16) error { + return pty.Setsize(s.ptmx, &pty.Winsize{Rows: rows, Cols: cols}) +} +func (s *ptySession) Close() error { + err := s.ptmx.Close() + if s.cmd.Process != nil { + s.cmd.Process.Kill() + } + return err +} +func (s *ptySession) Wait() error { + if s.cmd.Process == nil { + return nil + } + return s.cmd.Wait() +} +func (s *ptySession) Pid() int { + if s.cmd.Process == nil { + return 0 + } + return s.cmd.Process.Pid +} + +// pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe, +// running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to). +type pipeSession struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + mu sync.Mutex + merged chan []byte + closed bool + closeCh chan struct{} +} + +func startPipeSession(cmd *exec.Cmd) (termSession, error) { + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + stdin.Close() + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + stdin.Close() + stdout.Close() + return nil, err + } + if err := cmd.Start(); err != nil { + stdin.Close() + stdout.Close() + stderr.Close() + return nil, err + } + s := &pipeSession{ + cmd: cmd, + stdin: stdin, + stdout: stdout, + stderr: stderr, + merged: make(chan []byte, 32), + closeCh: make(chan struct{}), + } + go s.pump(stdout) + go s.pump(stderr) + return s, nil +} + +func (s *pipeSession) pump(r io.ReadCloser) { + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + select { + case s.merged <- chunk: + case <-s.closeCh: + return + } + } + if err != nil { + return + } + } +} + +func (s *pipeSession) Read(p []byte) (int, error) { + select { + case chunk, ok := <-s.merged: + if !ok { + return 0, io.EOF + } + n := copy(p, chunk) + return n, nil + case <-s.closeCh: + return 0, io.EOF + } +} + +func (s *pipeSession) Write(p []byte) (int, error) { + return s.stdin.Write(p) +} + +func (s *pipeSession) Resize(rows, cols uint16) error { + // No real TTY → resize is a no-op; the child won't get SIGWINCH. + return nil +} + +func (s *pipeSession) Close() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + close(s.closeCh) + s.mu.Unlock() + s.stdin.Close() + s.stdout.Close() + s.stderr.Close() + if s.cmd.Process != nil { + s.cmd.Process.Kill() + } + return nil +} + +func (s *pipeSession) Wait() error { + if s.cmd.Process == nil { + return nil + } + return s.cmd.Wait() +} + +func (s *pipeSession) Pid() int { + if s.cmd.Process == nil { + return 0 + } + return s.cmd.Process.Pid +} diff --git a/internal/version/version.go b/internal/version/version.go index f56c286..e79fe01 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.7.0" + Version = "0.7.1" Author = "La Légion de Muyue" ) From a7d4b31a0d7a0f9f9b4764800310f30081291cc9 Mon Sep 17 00:00:00 2001 From: Muyue Date: Mon, 27 Apr 2026 12:02:04 +0200 Subject: [PATCH 2/2] feat(studio): force advanced reflection during browser-test sessions (v0.7.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When at least one browser_test session is connected, every chat message in Studio now auto-enables advanced reflection regardless of the user toggle. The intent: during AI-driven UI testing, having a second model produce a preliminary [RAPPORT PRÉALABLE] materially improves which clicks the active model decides to perform and the quality of the final ✓/✗ report. - handlers_chat: derive wantReflection from body.AdvancedReflection OR (browserTestStore has any active session). The user toggle still works for normal conversations; tests just override it. - Silent fallback when no inactive provider is configured (no error, no behaviour change for single-provider setups). - Tests.jsx: add a hint explaining the auto-on behaviour so the user understands why the Studio toggle appears bypassed. - Version 0.7.1 → 0.7.2 + CHANGELOG entry. --- CHANGELOG.md | 8 ++++++++ internal/api/handlers_chat.go | 12 +++++++++++- internal/version/version.go | 2 +- web/src/components/Tests.jsx | 3 +++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d92c906..8bf28d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## v0.7.2 + +### Amélioration + +- **feat(studio): réflexion avancée forcée automatiquement pendant les tests** — quand au moins une session `browser_test` est connectée, chaque message à Studio active automatiquement la réflexion avancée (un second modèle, si configuré, produit un rapport préalable injecté dans le prompt actif). Le toggle UI est ignoré tant qu'une session de test est active. Justification : pendant un test piloté par l'IA, avoir une analyse complémentaire d'un autre modèle améliore matériellement la qualité des décisions de clic et la couverture du rapport final. +- Si aucun second provider n'est configuré, le comportement reste silencieux (fallback chat normal — pas d'erreur visible côté utilisateur). +- Hint UI ajouté dans l'onglet Tests pour expliquer le comportement. + ## v0.7.1 ### Fix diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index 641263c..e16011b 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -213,7 +213,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { orb.SetSystemPrompt(studioPrompt.String()) orb.SetTools(s.agentToolsJSON) - if body.AdvancedReflection { + // Auto-force advanced reflection while a browser-test session is active: + // the user is doing AI-driven UI testing, where having a second model + // produce a preliminary report (when one is configured) materially + // improves which clicks the active model decides to perform. The toggle + // remains user-controllable for non-test conversations. + wantReflection := body.AdvancedReflection + if !wantReflection && s.browserTestStore != nil && len(s.browserTestStore.List()) > 0 { + wantReflection = true + } + + if wantReflection { if report, ok := s.runReflectionReport(enrichedMessage); ok { enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]" } diff --git a/internal/version/version.go b/internal/version/version.go index e79fe01..600f85d 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.7.1" + Version = "0.7.2" Author = "La Légion de Muyue" ) diff --git a/web/src/components/Tests.jsx b/web/src/components/Tests.jsx index d7a0927..c8881a2 100644 --- a/web/src/components/Tests.jsx +++ b/web/src/components/Tests.jsx @@ -148,6 +148,9 @@ lesquels déclenchent une erreur console.`}

L'IA dispose de l'outil browser_test avec les actions list_clickables, click, console, eval, type, current_url, wait, summary.

+

+ Réflexion avancée auto : tant qu'au moins une session de test est connectée, chaque message dans Studio utilise automatiquement la réflexion avancée — un second modèle (s'il est configuré) produit un rapport d'analyse préalable injecté dans le prompt actif. Le toggle Studio est ignoré pendant la session. +