diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cc971..7f2358f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ 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.6 + +### Trois fixes Windows + une amélioration agent + +#### Métriques dashboard à 0 sur Windows + +Symptôme : CPU / RAM / Réseau toujours à 0 dans le panneau Dashboard sous Windows. Cause : `handleSystemMetrics` lisait exclusivement `/proc/stat`, `/proc/meminfo`, `/proc/net/dev` — fichiers absents sur Windows, donc `os.ReadFile` échouait silencieusement et la struct restait à zéro. + +Split en fichiers `_unix.go` / `_windows.go` : +- **`metrics_unix.go`** (`!windows`) : reprend tel quel le code `/proc/...` existant. +- **`metrics_windows.go`** : appelle `kernel32!GetSystemTimes` (CPU, ratio idle/total entre deux samples) et `kernel32!GlobalMemoryStatusEx` (RAM totale + dispo). Pas de spawn PowerShell, ~50 µs par appel. Réseau à zéro pour l'instant — `MIB_IF_ROW2` est trop sensible aux versions de Windows pour faire ça à la main proprement (TODO à part). +- `handleSystemMetrics` réduit à un appel à `collectSystemMetrics()`. + +#### Terminal écran noir sur Windows + +Symptôme : sous Windows native, le tab terminal ouvre la connexion mais l'écran reste noir, aucune sortie. Cause : `creack/pty/v2` retourne *"operating system not supported"* → fallback aux pipes. Pipes ne portent pas les signaux TTY, donc `cmd.exe` / `pwsh` / `wsl.exe` détectent l'absence de TTY et passent en mode silencieux ou attendent indéfiniment. + +Implémentation **ConPTY** native via `kernel32!CreatePseudoConsole` (`internal/api/terminal_conpty_windows.go`) : +- Probe runtime `canUseConPTY()` (cache la disponibilité — Windows 10 1809+ requis). +- Crée un pseudo-console + 2 pipes anonymes, les passe au child via `STARTUPINFOEX` + `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` (utilise `windows.NewProcThreadAttributeList`). +- `CreateProcessW` lance le shell avec le PC attaché → ANSI / cursor / line discipline marchent comme sur un vrai TTY. +- `ResizePseudoConsole` câblé sur les events de redimensionnement xterm. +- Fallback `pipeSession` conservé si `canUseConPTY()` est false (Windows < 1809) ou si `startConptySession` échoue. +- Restructure des fichiers : `terminal_session.go` (interface + structs), `terminal_session_unix.go` (creack/pty), `terminal_session_windows.go` (ConPTY → pipe fallback), `terminal_conpty_windows.go` (impl). + +#### Limite d'itérations d'outils agent + +Symptôme : *"l'IA semble s'arrêter après 15 exécutions d'outils, je veux qu'elle puisse en faire 100, voire 1000"*. Cause : `MaxToolIterations = 15` dans `chat_engine.go`. + +Bump : 15 → 500. Cap reste pour éviter les boucles infinies en cas de bug modèle, mais 500 itérations couvre largement les cas réels (refactor multi-fichiers, debug exploratoire). Documentation inline ajoutée pour expliquer pourquoi le cap existe et quand il faudrait s'inquiéter de le toucher. + ## v0.7.5 ### Fix Windows : commande `muyue` reconnue après install diff --git a/internal/api/chat_engine.go b/internal/api/chat_engine.go index 93b8d89..e36a91e 100644 --- a/internal/api/chat_engine.go +++ b/internal/api/chat_engine.go @@ -10,9 +10,13 @@ import ( "github.com/muyue/muyue/internal/orchestrator" ) -const ( - MaxToolIterations = 15 -) +// MaxToolIterations bounds the inner tool-call loop in RunWithTools / +// RunNonStream. The cap exists only to avoid an infinite loop when a model +// keeps calling tools forever; the value is intentionally generous so a +// realistic agent run (multi-file refactor, exploratory debugging…) never +// hits it. If you find yourself raising this to absurd values, look for a +// loop bug in the model output instead. +const MaxToolIterations = 500 // ToolLimiter checks if a tool call is allowed and returns a release function. type ToolLimiter func(toolName string) (release func(), err error) diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 0fa1519..03659b1 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -756,93 +756,6 @@ var ( ) func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) { - m := sysMetrics{} - - // CPU from /proc/stat - if data, err := os.ReadFile("/proc/stat"); err == nil { - line := strings.Split(string(data), "\n")[0] - fields := strings.Fields(line) - if len(fields) >= 5 { - var idle, total float64 - for i := 1; i < len(fields) && i <= 4; i++ { - var v float64 - fmt.Sscanf(fields[i], "%f", &v) - total += v - if i == 4 { - idle = v - } - } - if lastCPUSet { - dIdle := idle - lastCPU[0] - dTotal := total - lastCPU[1] - if dTotal > 0 { - m.CPUPercent = (1 - dIdle/dTotal) * 100 - } - } - lastCPU = [2]float64{idle, total} - lastCPUSet = true - } - } - - // Memory from /proc/meminfo - if data, err := os.ReadFile("/proc/meminfo"); err == nil { - var memTotal, memAvailable float64 - for _, line := range strings.Split(string(data), "\n") { - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - var v float64 - fmt.Sscanf(fields[1], "%f", &v) - switch fields[0] { - case "MemTotal:": - memTotal = v - case "MemAvailable:": - memAvailable = v - } - } - if memTotal > 0 { - m.MemTotalMB = memTotal / 1024 - m.MemUsedMB = (memTotal - memAvailable) / 1024 - m.MemPercent = (memTotal - memAvailable) / memTotal * 100 - } - } - - // Network from /proc/net/dev - if data, err := os.ReadFile("/proc/net/dev"); err == nil { - var rxBytes, txBytes float64 - for _, line := range strings.Split(string(data), "\n")[2:] { - fields := strings.Fields(line) - if len(fields) < 10 { - continue - } - iface := strings.TrimSuffix(fields[0], ":") - if iface == "lo" { - continue - } - var rx, tx float64 - fmt.Sscanf(fields[1], "%f", &rx) - fmt.Sscanf(fields[9], "%f", &tx) - rxBytes += rx - txBytes += tx - } - now := time.Now() - if !lastNetTs.IsZero() { - elapsed := now.Sub(lastNetTs).Seconds() - if elapsed > 0 { - m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed - m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed - if m.NetRxKBs < 0 { - m.NetRxKBs = 0 - } - if m.NetTxKBs < 0 { - m.NetTxKBs = 0 - } - } - } - lastNet = [2]float64{rxBytes, txBytes} - lastNetTs = now - } - + m := collectSystemMetrics() writeJSON(w, m) } diff --git a/internal/api/metrics_unix.go b/internal/api/metrics_unix.go new file mode 100644 index 0000000..1834c00 --- /dev/null +++ b/internal/api/metrics_unix.go @@ -0,0 +1,106 @@ +//go:build !windows + +package api + +import ( + "fmt" + "os" + "strings" + "time" +) + +// collectSystemMetrics reads /proc on Linux. On macOS / BSD this returns +// zeroes for files that don't exist — the dashboard panel renders blanks +// rather than crashing. macOS-specific metrics could be added later via +// `vm_stat` / `iostat` parsing. +func collectSystemMetrics() sysMetrics { + m := sysMetrics{} + + // CPU from /proc/stat + if data, err := os.ReadFile("/proc/stat"); err == nil { + line := strings.Split(string(data), "\n")[0] + fields := strings.Fields(line) + if len(fields) >= 5 { + var idle, total float64 + for i := 1; i < len(fields) && i <= 4; i++ { + var v float64 + fmt.Sscanf(fields[i], "%f", &v) + total += v + if i == 4 { + idle = v + } + } + if lastCPUSet { + dIdle := idle - lastCPU[0] + dTotal := total - lastCPU[1] + if dTotal > 0 { + m.CPUPercent = (1 - dIdle/dTotal) * 100 + } + } + lastCPU = [2]float64{idle, total} + lastCPUSet = true + } + } + + // Memory from /proc/meminfo + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + var memTotal, memAvailable float64 + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + var v float64 + fmt.Sscanf(fields[1], "%f", &v) + switch fields[0] { + case "MemTotal:": + memTotal = v + case "MemAvailable:": + memAvailable = v + } + } + if memTotal > 0 { + m.MemTotalMB = memTotal / 1024 + m.MemUsedMB = (memTotal - memAvailable) / 1024 + m.MemPercent = (memTotal - memAvailable) / memTotal * 100 + } + } + + // Network from /proc/net/dev + if data, err := os.ReadFile("/proc/net/dev"); err == nil { + var rxBytes, txBytes float64 + for _, line := range strings.Split(string(data), "\n")[2:] { + fields := strings.Fields(line) + if len(fields) < 10 { + continue + } + iface := strings.TrimSuffix(fields[0], ":") + if iface == "lo" { + continue + } + var rx, tx float64 + fmt.Sscanf(fields[1], "%f", &rx) + fmt.Sscanf(fields[9], "%f", &tx) + rxBytes += rx + txBytes += tx + } + now := time.Now() + if !lastNetTs.IsZero() { + elapsed := now.Sub(lastNetTs).Seconds() + if elapsed > 0 { + m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed + m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed + if m.NetRxKBs < 0 { + m.NetRxKBs = 0 + } + if m.NetTxKBs < 0 { + m.NetTxKBs = 0 + } + } + } + lastNet = [2]float64{rxBytes, txBytes} + lastNetTs = now + } + + return m +} diff --git a/internal/api/metrics_windows.go b/internal/api/metrics_windows.go new file mode 100644 index 0000000..d6f391f --- /dev/null +++ b/internal/api/metrics_windows.go @@ -0,0 +1,129 @@ +//go:build windows + +package api + +import ( + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// collectSystemMetrics reads CPU% and memory from kernel32 directly. +// Network throughput on Windows is left at zero for now — the iphlpapi +// MIB_IF_ROW2 layout is large and version-sensitive; reliable net stats +// would warrant a separate, well-tested implementation. CPU + RAM are +// enough for the dashboard's main signal. +func collectSystemMetrics() sysMetrics { + m := sysMetrics{} + + if cpu, ok := readWindowsCPUPercent(); ok { + m.CPUPercent = cpu + } + if memTotalMB, memUsedMB, memPct, ok := readWindowsMemory(); ok { + m.MemTotalMB = memTotalMB + m.MemUsedMB = memUsedMB + m.MemPercent = memPct + } + // Net: zero (TODO). + return m +} + +// --- CPU --------------------------------------------------------------- + +var ( + cpuOnce sync.Once + getSystemTimes *syscall.LazyProc + lastWinCPUIdle uint64 + lastWinCPUTotal uint64 + lastWinCPUSet bool + winCPUMu sync.Mutex +) + +func loadCPUFns() { + cpuOnce.Do(func() { + k := syscall.NewLazyDLL("kernel32.dll") + getSystemTimes = k.NewProc("GetSystemTimes") + }) +} + +func filetimeToUint64(low, high uint32) uint64 { + return uint64(high)<<32 | uint64(low) +} + +// readWindowsCPUPercent samples GetSystemTimes twice and computes the busy +// ratio as 1 - dIdle / (dKernel + dUser). The first call returns 0% and +// stores the baseline; subsequent calls return the delta-based percentage. +func readWindowsCPUPercent() (float64, bool) { + loadCPUFns() + if getSystemTimes == nil { + return 0, false + } + var idle, kernel, user windows.Filetime + r1, _, _ := getSystemTimes.Call( + uintptr(unsafe.Pointer(&idle)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ) + if r1 == 0 { + return 0, false + } + idleT := filetimeToUint64(idle.LowDateTime, idle.HighDateTime) + totalT := filetimeToUint64(kernel.LowDateTime, kernel.HighDateTime) + + filetimeToUint64(user.LowDateTime, user.HighDateTime) + winCPUMu.Lock() + defer winCPUMu.Unlock() + if !lastWinCPUSet { + lastWinCPUIdle = idleT + lastWinCPUTotal = totalT + lastWinCPUSet = true + return 0, true + } + dIdle := idleT - lastWinCPUIdle + dTotal := totalT - lastWinCPUTotal + lastWinCPUIdle = idleT + lastWinCPUTotal = totalT + if dTotal == 0 { + return 0, true + } + pct := (1 - float64(dIdle)/float64(dTotal)) * 100 + if pct < 0 { + pct = 0 + } else if pct > 100 { + pct = 100 + } + return pct, true +} + +// --- Memory ------------------------------------------------------------ + +type memoryStatusEx struct { + Length uint32 + MemoryLoad uint32 + TotalPhys uint64 + AvailPhys uint64 + TotalPageFile uint64 + AvailPageFile uint64 + TotalVirtual uint64 + AvailVirtual uint64 + AvailExtendedVirtual uint64 +} + +var globalMemoryStatusEx = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx") + +func readWindowsMemory() (totalMB, usedMB, percent float64, ok bool) { + var ms memoryStatusEx + ms.Length = uint32(unsafe.Sizeof(ms)) + r1, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms))) + if r1 == 0 { + return 0, 0, 0, false + } + const mb = 1024 * 1024 + totalMB = float64(ms.TotalPhys) / mb + usedMB = float64(ms.TotalPhys-ms.AvailPhys) / mb + if ms.TotalPhys > 0 { + percent = float64(ms.TotalPhys-ms.AvailPhys) * 100 / float64(ms.TotalPhys) + } + return totalMB, usedMB, percent, true +} diff --git a/internal/api/terminal_conpty_windows.go b/internal/api/terminal_conpty_windows.go new file mode 100644 index 0000000..24aa9bb --- /dev/null +++ b/internal/api/terminal_conpty_windows.go @@ -0,0 +1,265 @@ +//go:build windows + +package api + +// Windows ConPTY (Pseudo Console) backend for the terminal tab. +// +// creack/pty/v2 returns "operating system not supported" on Windows, so the +// previous fallback was plain stdin/stdout pipes (terminal_session.go:: +// pipeSession). Pipes don't carry TTY signals, so cmd.exe / pwsh / wsl +// detect "no TTY" and either go silent or wait forever — the user sees a +// black screen. This file implements a real pseudo console using the +// kernel32 ConPTY API, so the spawned shell behaves as if it were attached +// to a real terminal: prompts render, ANSI escapes are honoured, resize +// events propagate. +// +// Requires Windows 10 v1809 (build 17763) or newer. On older hosts +// CreatePseudoConsole returns an error and startTermSession_windows falls +// back to pipeSession. + +import ( + "fmt" + "io" + "os/exec" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + procThreadAttributePseudoconsole = 0x00020016 + extendedStartupinfoPresent = 0x00080000 + createUnicodeEnvironment = 0x00000400 +) + +// conptySession drives a Windows pseudo console. +type conptySession struct { + hPC windows.Handle + inWrite windows.Handle + outRead windows.Handle + procInfo windows.ProcessInformation + closed bool + mu sync.Mutex +} + +// startConptySession spins up the pseudo console, plumbs the pipes, and +// CreateProcessW's the child with the PC attached via STARTUPINFOEX. +func startConptySession(cmd *exec.Cmd) (termSession, error) { + // 1. Two pipe pairs: in (we write → child stdin) and out (child stdout → we read). + var inRead, inWrite, outRead, outWrite windows.Handle + if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil { + return nil, fmt.Errorf("create stdin pipe: %w", err) + } + if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil { + windows.CloseHandle(inRead) + windows.CloseHandle(inWrite) + return nil, fmt.Errorf("create stdout pipe: %w", err) + } + + // 2. Create the pseudo console. After this call ConPTY effectively owns + // the child-facing pipe ends (inRead, outWrite); we close our copy. + var hPC windows.Handle + sz := windows.Coord{X: 120, Y: 30} + if err := windows.CreatePseudoConsole(sz, inRead, outWrite, 0, &hPC); err != nil { + windows.CloseHandle(inRead) + windows.CloseHandle(inWrite) + windows.CloseHandle(outRead) + windows.CloseHandle(outWrite) + return nil, fmt.Errorf("CreatePseudoConsole: %w", err) + } + windows.CloseHandle(inRead) + windows.CloseHandle(outWrite) + + // 3. Allocate an attribute list with one slot for the PC attribute. + attrList, err := windows.NewProcThreadAttributeList(1) + if err != nil { + windows.ClosePseudoConsole(hPC) + windows.CloseHandle(inWrite) + windows.CloseHandle(outRead) + return nil, fmt.Errorf("NewProcThreadAttributeList: %w", err) + } + if err := attrList.Update( + procThreadAttributePseudoconsole, + unsafe.Pointer(&hPC), + unsafe.Sizeof(hPC), + ); err != nil { + attrList.Delete() + windows.ClosePseudoConsole(hPC) + windows.CloseHandle(inWrite) + windows.CloseHandle(outRead) + return nil, fmt.Errorf("attrList.Update: %w", err) + } + + // 4. Build command line. + cmdLine, err := buildCommandLine(cmd) + if err != nil { + attrList.Delete() + windows.ClosePseudoConsole(hPC) + windows.CloseHandle(inWrite) + windows.CloseHandle(outRead) + return nil, err + } + cmdLineUTF16, err := windows.UTF16PtrFromString(cmdLine) + if err != nil { + attrList.Delete() + windows.ClosePseudoConsole(hPC) + windows.CloseHandle(inWrite) + windows.CloseHandle(outRead) + return nil, err + } + + // 5. Build the env block (key=value\0...\0\0). + var envBlock *uint16 + if cmd.Env != nil { + eb, err := makeEnvBlock(cmd.Env) + if err != nil { + attrList.Delete() + windows.ClosePseudoConsole(hPC) + windows.CloseHandle(inWrite) + windows.CloseHandle(outRead) + return nil, err + } + envBlock = eb + } + + si := windows.StartupInfoEx{} + si.StartupInfo.Cb = uint32(unsafe.Sizeof(si)) + si.ProcThreadAttributeList = attrList.List() + + flags := uint32(extendedStartupinfoPresent) + if envBlock != nil { + flags |= createUnicodeEnvironment + } + + var pi windows.ProcessInformation + err = windows.CreateProcess( + nil, // application name (null = parse from cmdline) + cmdLineUTF16, + nil, // process security attrs + nil, // thread security attrs + false, // inherit handles (ConPTY hands handles via attribute list) + flags, + envBlock, + nil, // working dir + &si.StartupInfo, + &pi, + ) + attrList.Delete() + if err != nil { + windows.ClosePseudoConsole(hPC) + windows.CloseHandle(inWrite) + windows.CloseHandle(outRead) + return nil, fmt.Errorf("CreateProcess: %w", err) + } + + return &conptySession{ + hPC: hPC, + inWrite: inWrite, + outRead: outRead, + procInfo: pi, + }, nil +} + +func (s *conptySession) Read(p []byte) (int, error) { + var n uint32 + err := windows.ReadFile(s.outRead, p, &n, nil) + if err != nil { + if n > 0 { + return int(n), nil + } + return 0, io.EOF + } + return int(n), nil +} + +func (s *conptySession) Write(p []byte) (int, error) { + var n uint32 + err := windows.WriteFile(s.inWrite, p, &n, nil) + if err != nil { + return int(n), err + } + return int(n), nil +} + +func (s *conptySession) Resize(rows, cols uint16) error { + return windows.ResizePseudoConsole(s.hPC, windows.Coord{X: int16(cols), Y: int16(rows)}) +} + +func (s *conptySession) Close() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + s.mu.Unlock() + + // Order matters: close the pseudo console first so the child sees EOF, + // then close our pipe ends, then terminate / close handles. + windows.ClosePseudoConsole(s.hPC) + windows.CloseHandle(s.inWrite) + windows.CloseHandle(s.outRead) + if s.procInfo.Process != 0 { + windows.TerminateProcess(s.procInfo.Process, 0) + windows.CloseHandle(s.procInfo.Process) + } + if s.procInfo.Thread != 0 { + windows.CloseHandle(s.procInfo.Thread) + } + return nil +} + +func (s *conptySession) Wait() error { + if s.procInfo.Process == 0 { + return nil + } + _, err := windows.WaitForSingleObject(s.procInfo.Process, windows.INFINITE) + return err +} + +func (s *conptySession) Pid() int { + return int(s.procInfo.ProcessId) +} + +// --- helpers ----------------------------------------------------------- + +// buildCommandLine produces the Windows command-line string for an +// *exec.Cmd, mirroring what os/exec uses internally (escaping spaces and +// quotes per Windows convention). +func buildCommandLine(cmd *exec.Cmd) (string, error) { + if cmd.Path == "" { + return "", fmt.Errorf("empty cmd.Path") + } + parts := []string{cmd.Path} + if len(cmd.Args) > 1 { + parts = append(parts, cmd.Args[1:]...) + } + out := syscall.EscapeArg(parts[0]) + for _, a := range parts[1:] { + out += " " + syscall.EscapeArg(a) + } + return out, nil +} + +// makeEnvBlock packs a Go environ slice into the Windows UTF-16 env block +// format: key=value\0key=value\0\0. +func makeEnvBlock(env []string) (*uint16, error) { + var buf []uint16 + for _, kv := range env { + s, err := syscall.UTF16FromString(kv) + if err != nil { + return nil, err + } + buf = append(buf, s...) // includes trailing NUL + } + buf = append(buf, 0) // final terminator + if len(buf) == 0 { + return nil, nil + } + return &buf[0], nil +} + +// Compile-time interface assertion. +var _ termSession = (*conptySession)(nil) diff --git a/internal/api/terminal_session.go b/internal/api/terminal_session.go index 0dcdc68..19e8704 100644 --- a/internal/api/terminal_session.go +++ b/internal/api/terminal_session.go @@ -2,19 +2,22 @@ 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. +// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires +// startTermSession to creack/pty for a real PTY: full TTY semantics, +// resize support, interactive apps (vim, top…) work. +// +// On Windows the windows-tagged file (terminal_session_windows.go) tries +// the kernel32 ConPTY API first, with a pipe-based fallback for older +// hosts. pipeSession does NOT carry TTY signals, so most shells go silent +// — it's only kept as a last resort. +// +// Both platforms share the termSession interface, the ptySession type +// (used by unix), and the pipeSession type (used by the Windows fallback). import ( "io" "os" "os/exec" - "runtime" "sync" "github.com/creack/pty/v2" @@ -30,22 +33,7 @@ type termSession interface { 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. +// ptySession wraps creack/pty's *os.File-backed PTY (unix path). type ptySession struct { ptmx *os.File cmd *exec.Cmd @@ -76,8 +64,10 @@ func (s *ptySession) Pid() int { 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). +// pipeSession is the Windows last-resort fallback when ConPTY is not +// available: stdin pipe + merged stdout/stderr, no TTY signals. Most +// interactive shells go silent in this mode, so it should rarely be hit on +// modern Windows (10 1809+). type pipeSession struct { cmd *exec.Cmd stdin io.WriteCloser @@ -115,7 +105,7 @@ func startPipeSession(cmd *exec.Cmd) (termSession, error) { cmd: cmd, stdin: stdin, stdout: stdout, - stderr: stderr, + stderr: stderr, merged: make(chan []byte, 32), closeCh: make(chan struct{}), } diff --git a/internal/api/terminal_session_unix.go b/internal/api/terminal_session_unix.go new file mode 100644 index 0000000..e580024 --- /dev/null +++ b/internal/api/terminal_session_unix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package api + +import ( + "os/exec" + + "github.com/creack/pty/v2" +) + +// startTermSession (unix) opens a real PTY via creack/pty. Fatal on error +// — the unix build assumes PTY availability. +func startTermSession(cmd *exec.Cmd) (termSession, error) { + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, err + } + return &ptySession{ptmx: ptmx, cmd: cmd}, nil +} diff --git a/internal/api/terminal_session_windows.go b/internal/api/terminal_session_windows.go new file mode 100644 index 0000000..8cf566d --- /dev/null +++ b/internal/api/terminal_session_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package api + +import ( + "os/exec" +) + +// startTermSession (windows) tries the kernel32 ConPTY API first. ConPTY +// gives a real pseudo terminal, so wsl.exe / pwsh / cmd render their +// prompt and the user can interact normally. If ConPTY is unavailable +// (Windows < 10 1809) or the call fails for any reason, we fall back to +// the line-buffered pipe session — degraded but functional for non-TUI +// commands. +func startTermSession(cmd *exec.Cmd) (termSession, error) { + if sess, err := startConptySession(cmd); err == nil { + return sess, nil + } + return startPipeSession(cmd) +} diff --git a/internal/version/version.go b/internal/version/version.go index 2632125..e51f8c4 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.7.5" + Version = "0.7.6" Author = "La Légion de Muyue" )