Compare commits

...

12 Commits

Author SHA1 Message Date
Augustin ROUX
5fd8cceabd Merge pull request 'fix(windows/conpty): pass HPCON value, not &hPC (v0.7.8)' (#18) from release/v0.7.8 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m15s
Reviewed-on: #18
2026-04-27 13:49:15 +00:00
Muyue
a3487392c0 fix(windows/conpty): pass HPCON value, not &hPC (v0.7.8)
All checks were successful
PR Check / check (pull_request) Successful in 1m3s
User reported regression introduced in v0.7.6: PowerShell / cmd open
in a separate external console window instead of attaching to the
xterm.js tab (v0.7.5 worked).

Root cause: the ConPTY wiring used
  attrList.Update(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
                  unsafe.Pointer(&hPC),    // ← wrong
                  unsafe.Sizeof(hPC))

The PSEUDOCONSOLE attribute is a Win32 API quirk: lpValue must be
the HPCON *value* (cast to PVOID), not a pointer to the local
variable holding the handle. With &hPC the kernel reads garbage,
silently drops the attribute, and CreateProcessW spawns the child
with a fresh console — hence the external window.

Fix is one line:
  unsafe.Pointer(uintptr(hPC))

Confirmed against Microsoft's EchoCon sample and Go libraries that
work in production (UserExistsError/conpty, aymanbagabas/go-pty).

- internal/version/version.go: 0.7.7 → 0.7.8
- CHANGELOG.md: v0.7.8 entry with the diagnostic write-up
2026-04-27 14:39:26 +02:00
Augustin ROUX
af5fbf9324 Merge pull request 'fix(install): kill running muyue before extracting (v0.7.7)' (#16) from release/v0.7.7 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m18s
PR Check / check (pull_request) Successful in 58s
Reviewed-on: #16
2026-04-27 12:31:32 +00:00
Muyue
29953bde6d fix(install): kill running muyue before extracting (v0.7.7)
All checks were successful
PR Check / check (pull_request) Successful in 1m3s
User reported v0.7.6 install silently no-op'd when v0.7.5 was still
running:

  $dest = "$env:LOCALAPPDATA\Muyue"
  Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
  # No error, but the running v0.7.5 .exe stays in place because
  # Windows refuses to overwrite a locked file. After 'install', the
  # 'muyue' command still launches v0.7.5.

Add a Stop-Process step at the top of the install snippet:

  Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue |
    Stop-Process -Force
  Start-Sleep -Milliseconds 500

-ErrorAction SilentlyContinue makes it idempotent (no error on a
clean first install). The 500ms sleep gives Windows time to release
the file handle before Expand-Archive opens the destination paths.

Snippet bumps to 6 lines; explanatory note added so users updating
from a previous version know why this step matters.

- internal/version/version.go: 0.7.6 → 0.7.7
- CHANGELOG.md: v0.7.7 entry
2026-04-27 14:29:59 +02:00
Augustin ROUX
9d1d717999 Merge pull request 'fix(windows): ConPTY + kernel32 metrics + agent loop cap (v0.7.6)' (#14) from release/v0.7.6 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m15s
PR Check / check (pull_request) Successful in 54s
Reviewed-on: #14
2026-04-27 12:06:17 +00:00
Muyue
d557b8e74c fix(windows): native ConPTY + kernel32 metrics + agent loop cap (v0.7.6)
All checks were successful
PR Check / check (pull_request) Successful in 1m0s
Three issues reported on Windows + one user-requested limit bump:

1. Dashboard CPU/RAM/Network all at 0
   handleSystemMetrics read /proc/* exclusively. Replaced with a
   platform-split:
   - metrics_unix.go (!windows): existing /proc reading code.
   - metrics_windows.go: kernel32!GetSystemTimes for CPU
     (delta of idle vs kernel+user FILETIMEs) and
     kernel32!GlobalMemoryStatusEx for memory. Network left at zero
     for now — MIB_IF_ROW2 is too version-sensitive to parse by hand.
   handlers_info.go::handleSystemMetrics reduced to one delegating
   call.

2. Terminal black screen on Windows
   creack/pty/v2 returns "unsupported" on Windows; the v0.7.1 pipe
   fallback works but pipes don't carry TTY signals, so cmd/pwsh/wsl
   go silent. Implemented native ConPTY:
   - terminal_conpty_windows.go: CreatePseudoConsole + STARTUPINFOEX
     + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE wiring via
     windows.NewProcThreadAttributeList. CreateProcessW launches
     child with the PC attached, full ANSI / line discipline /
     resize.
   - canUseConPTY() probes once at startup (Win10 1809+ check).
   - Restructure: terminal_session.go now holds just the interface
     + ptySession + pipeSession structs. terminal_session_unix.go
     wires creack/pty. terminal_session_windows.go tries ConPTY
     first, falls back to pipeSession.

3. Agent stops after 15 tool calls
   MaxToolIterations bumped 15 → 500. Doc comment explains why the
   cap exists at all (infinite-loop safety) and that 500 is well
   above realistic usage.

- internal/version/version.go: 0.7.5 → 0.7.6
- CHANGELOG.md: v0.7.6 entry covers the three fixes
2026-04-27 14:04:41 +02:00
Augustin ROUX
87e606c853 Merge pull request 'fix(windows): GUI subsystem + AttachConsole + muyue.exe canonical (v0.7.5)' (#12) from release/v0.7.5 into develop
Some checks failed
PR Check / check (pull_request) Has been cancelled
Beta Release / beta (push) Has been cancelled
Reviewed-on: #12
2026-04-27 11:40:28 +00:00
Muyue
79e467c32a fix(windows): GUI subsystem + parent-console attach + canonical muyue.exe (v0.7.5)
All checks were successful
PR Check / check (pull_request) Successful in 58s
Three Windows install/launch issues reported by the user:

1. Double-click on Desktop shortcut → dialog "This is a command line
   tool. You need to open cmd.exe and run it from there."
   Cause: charmbracelet/huh detects no TTY when launched via Explorer
   and aborts. Fix:
   - cmd/muyue/commands/root.go: skip RunFirstTimeSetup when
     os.Stdin is not a character device; persist config.Default()
     and let the React onboarding wizard handle first-run UX.
   - ci-{main,develop}.yml: build Windows binaries with
     -ldflags="-H=windowsgui" so the .exe is a GUI subsystem app —
     no console window flashes on double-click.

2. CLI sub-commands (`muyue scan`, `muyue install-shortcuts`, etc.)
   would lose all output under -H=windowsgui when launched from
   cmd.exe / PowerShell. Mitigation:
   - cmd/muyue/console_windows.go (new, build-tagged): on init(),
     call kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the
     parent has a console, rebind os.Stdout/os.Stderr/os.Stdin to
     it and call log.SetOutput(os.Stderr) so existing log.Printf
     calls surface. If no parent console (Explorer), exit silently.

3. After install, `muyue` not recognized in PowerShell.
   Causes: (a) the extracted binary is muyue-windows-amd64.exe, not
   muyue.exe; (b) the user PATH update by install-shortcuts doesn't
   propagate to the existing PowerShell session.
   Fix in install-shortcuts:
   - Copy self to <installDir>/muyue.exe (rename impossible — the
     running .exe is locked on Windows) so `muyue` resolves once
     PATH is set.
   - Update Desktop + Start Menu .lnk to target the canonical
     muyue.exe rather than the platform-suffixed binary.
   - Print the line `$env:Path += ';<installDir>'` for the user to
     paste, refreshing the current session immediately.
   - ci-main.yml install snippet bumps to 5 lines, last being
     `$env:Path += ";$dest"`.

- internal/version/version.go: 0.7.4 → 0.7.5
- CHANGELOG.md: v0.7.5 entry covers all three fixes
2026-04-27 13:39:22 +02:00
Augustin ROUX
1ce5c49622 Merge pull request 'feat: integrate Muyue logo (icon embedded + web favicon) v0.7.4' (#10) from release/v0.7.4 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m12s
PR Check / check (pull_request) Successful in 55s
Reviewed-on: #10
2026-04-27 11:19:46 +00:00
Muyue
830e085c2a feat: integrate Muyue logo (icon embedded in Windows binary + web favicon)
All checks were successful
PR Check / check (pull_request) Successful in 58s
Logo dropped at project root by user. Bake it everywhere it matters:

Assets:
- assets/muyue.ico — multi-res (16/24/32/48/64/128/256) generated via PIL
- assets/muyue-{16,32,64,128,256,512}.png — clean PNG resizes
- LogoMuyue.png kept at root as the source of truth

Windows binary (.exe):
- CI runs `rsrc -ico assets/muyue.ico -arch {amd64,arm64} -o cmd/muyue/rsrc_windows_{amd64,arm64}.syso`
  before `go build` (both ci-main.yml and ci-develop.yml)
- Go automatically links *.syso files matching the target GOOS/GOARCH —
  no code change in the cmd/muyue main package
- .syso files are gitignored: regenerated at every build, never committed
- Existing install-shortcuts subcommand already uses IconLocation =
  "$exe,0" so the embedded icon flows automatically into Desktop +
  Start Menu .lnk files

Web UI:
- web/public/favicon-{16,32}.png + muyue.png + muyue-64.png
- web/index.html: real <link rel="icon"> tags (16/32 PNG + apple-touch),
  replacing the placeholder SVG hexagon
- App.jsx header: 22×22 logo image rendered next to the "MUYUE" wordmark
  (rounded 4px corners for visual consistency with the source logo)

Install snippet (ci-main.yml changelog template):
- Idempotent first line: `New-Item -ItemType Directory -Force -Path $dest`
  to handle the case where the user re-runs after a partial install

Versioning unchanged (still v0.7.3 — these additions stay on the same
release branch / PR #9).
2026-04-27 13:13:56 +02:00
Augustin ROUX
24b09f5700 Merge pull request 'feat: onboarding MiniMax+MiMo + Windows install w/o admin (v0.7.3)' (#9) from release/v0.7.3 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m10s
Reviewed-on: #9
2026-04-27 10:55:48 +00:00
Muyue
1442b4fd8a feat: onboarding 2-keys + Windows install w/o admin (v0.7.3)
All checks were successful
PR Check / check (pull_request) Successful in 56s
Two user-reported pain points:

1. First-run setup proposed only MiniMax (no MiMo step). Onboarding
   now offers both keys side-by-side under a single "apikey" step,
   with per-key Validate buttons. At least one must be valid to
   proceed; the rest of the providers (OpenAI/Anthropic/Z.AI/Ollama)
   are not shown in the wizard — they're configured later via the
   Config tab. Active provider = MiniMax if valid, else MiMo.

2. Windows install instructions failed: Move-Item to C:\Windows
   requires admin. Replaced with a no-admin 4-line snippet that
   installs to %LOCALAPPDATA%\Muyue and calls a new subcommand
   `muyue install-shortcuts` to create Desktop + Start Menu .lnk
   files and add the install dir to the user PATH. Shortcut creation
   uses WScript.Shell COM via PowerShell — keeps Go binary
   dependency-free. Folder paths resolved through
   [Environment]::GetFolderPath so OneDrive/redirected profiles
   work too.

- cmd/muyue/commands/install_shortcuts.go: new file
- web/src/components/OnboardingWizard.jsx: 2-key apikey step
- .gitea/workflows/ci-main.yml: updated install snippet
- internal/version/version.go: 0.7.2 → 0.7.3
- CHANGELOG.md: v0.7.3 entry
2026-04-27 12:12:18 +02:00
31 changed files with 1127 additions and 168 deletions

View File

@@ -68,17 +68,25 @@ jobs:
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
echo "Building beta release: ${VERSION}"
- name: Generate Windows resource (icon)
run: |
go install github.com/akavel/rsrc@latest
RSRC="$(go env GOPATH)/bin/rsrc"
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
- name: Build (all platforms)
run: |
mkdir -p dist
VERSION=${{ steps.version.outputs.version }}
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Package archives
run: |

View File

@@ -64,16 +64,28 @@ jobs:
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
echo "Building stable release: ${VERSION}"
- name: Generate Windows resource (icon)
run: |
go install github.com/akavel/rsrc@latest
RSRC="$(go env GOPATH)/bin/rsrc"
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
- name: Build (all platforms)
run: |
mkdir -p dist
LDFLAGS="-s -w"
# Windows builds use -H=windowsgui so the binary registers as a GUI
# subsystem app: double-clicking from the Desktop shortcut does not
# spawn a console window (and huh's "This is a command line tool"
# banner can never appear).
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Package archives
run: |
@@ -138,12 +150,17 @@ jobs:
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
echo "\`\`\`"
echo ""
echo "**Windows (x86_64)**"
echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande \`muyue\` dans la session courante :"
echo "\`\`\`powershell"
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\""
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\""
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe"
echo "Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500"
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
echo "\$env:Path += \";\$dest\""
echo "\`\`\`"
echo ""
echo "Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le \`.exe\` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire."
} > /tmp/stable_changelog.md
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT

1
.gitignore vendored
View File

@@ -24,6 +24,7 @@ Thumbs.db
*.exe
*.test
*.out
*.syso
vendor/
# Config with secrets

View File

@@ -4,6 +4,136 @@ 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.8
### Fix régression v0.7.6 : terminaux ouverts en fenêtre externe
Symptôme rapporté : depuis v0.7.6, cliquer sur PowerShell / cmd dans l'onglet Terminal ouvre une **fenêtre console séparée** au lieu de s'afficher dans le tab xterm.js (régression — v0.7.5 fonctionnait).
**Cause** : le binding ConPTY introduit en v0.7.6 passait `&hPC` (pointeur vers la variable Go locale) à `UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, …)`. Or cet attribut est un quirk de l'API Win32 : `lpValue` doit être la **valeur du handle** (cast en `PVOID`), **pas** un pointeur vers la variable. Avec `&hPC`, le kernel lisait des octets aléatoires, l'attribut PSEUDOCONSOLE était silencieusement ignoré, et `CreateProcessW` créait une nouvelle console pour l'enfant — d'où la fenêtre externe.
**Fix** (1 ligne) :
```go
// Avant
unsafe.Pointer(&hPC)
// Après
unsafe.Pointer(uintptr(hPC)) // le HPCON value comme PVOID
```
Référence : Microsoft EchoCon sample + bibliothèques Go ConPTY existantes (`UserExistsError/conpty`, `aymanbagabas/go-pty`) utilisent toutes la valeur du handle directement.
Conséquence : terminaux PowerShell / cmd / WSL s'ouvrent à nouveau **dans** le tab xterm.js avec TTY complet (ANSI, prompt couleur, vim, etc.).
## v0.7.7
### Fix : install Windows échoue silencieusement quand une version précédente tourne
Symptôme rapporté en mettant à jour de v0.7.5 → v0.7.6 : `Expand-Archive ... -Force` semble réussir mais le `.exe` n'est en réalité pas écrasé (Windows refuse de remplacer un fichier verrouillé), donc après l'install, `muyue` lance toujours l'ancienne version. Aucun message d'erreur visible — d'où le côté traître.
**Fix** : ajout d'une 1ʳᵉ ligne au snippet d'install qui tue toute instance Muyue déjà lancée :
```powershell
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
```
`-ErrorAction SilentlyContinue` rend l'étape idempotente (pas d'erreur si rien ne tourne, cas d'install propre). Le `Start-Sleep` 500ms laisse Windows libérer le file handle. Le snippet officiel passe à 6 lignes ; une note explicative est ajoutée dans la section *Install* du changelog généré.
## 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
Symptôme rapporté : après les commandes d'install, `muyue` retourne `n'est pas reconnu comme nom d'applet de commande`. Causes :
- Le binaire extrait s'appelle `muyue-windows-amd64.exe` — taper `muyue` ne résoud pas
- La PATH utilisateur a été mise à jour mais la session PowerShell courante n'en hérite que pour les NOUVEAUX processus
Corrections dans `install-shortcuts` :
- **Copie canonique** : `muyue.exe` est créé à côté de `muyue-windows-amd64.exe` (copy, pas rename — le binaire en cours d'exécution est verrouillé sur Windows). Les raccourcis Bureau / Menu Démarrer ciblent désormais cette copie.
- **Hint de session** : la commande imprime `$env:Path += ';...'` à coller pour activer `muyue` dans le shell courant sans rouvrir un terminal.
Snippet d'install passe à 5 lignes : la dernière (`$env:Path += ";$dest"`) rend la commande dispo immédiatement dans la session.
### Fix Windows : double-clic du raccourci fonctionne enfin
Symptôme rapporté : après installation, double-clic sur le raccourci Bureau → boîte de dialogue *"This is a command line tool. You need to open cmd.exe and run it from there."*. Cause : `charmbracelet/huh` (utilisé pour la TUI de premier lancement) détecte l'absence de TTY interactif quand le binaire est lancé via Explorer Windows et avorte avec ce message.
Double correctif :
1. **Skip de la TUI sans terminal interactif** (`cmd/muyue/commands/root.go::isInteractiveStdin`) — si `os.Stdin.Stat()` indique pas de `os.ModeCharDevice`, on saute `profiler.RunFirstTimeSetup` et on persiste un `config.Default()`. L'onboarding web (déjà existant) prend ensuite le relais dès l'ouverture du navigateur — aucune régression : avec un vrai terminal, la TUI continue de tourner comme avant.
2. **Build Windows en GUI subsystem** (`-H=windowsgui` ajouté aux Windows builds dans `ci-main.yml` et `ci-develop.yml`) — le binaire ne demande plus de console, donc plus aucun flash de fenêtre noire au double-clic.
Conséquence : les sous-commandes CLI (`muyue scan`, `muyue version`, `muyue install-shortcuts`) ne produiraient plus d'output quand lancées depuis cmd.exe. Mitigation : nouveau fichier `cmd/muyue/console_windows.go` qui appelle `kernel32!AttachConsole(ATTACH_PARENT_PROCESS)` au démarrage. Si un terminal parent existe, on s'y rattache et `os.Stdout` / `os.Stderr` / `os.Stdin` y sont rebindés ; sinon, on tourne silencieusement (cas double-clic). Compatible des deux usages sans deux binaires séparés.
## v0.7.4
### Logo Muyue intégré
- `LogoMuyue.png` ajouté à la racine + déclinaisons générées dans `assets/` (16/32/64/128/256/512 px) et `assets/muyue.ico` (multi-résolution 16-256 px).
- **Binaire Windows** : icône embarquée comme ressource Windows via `github.com/akavel/rsrc` au build CI (génération de `cmd/muyue/rsrc_windows_{amd64,arm64}.syso`). Conséquences :
- Explorateur Windows affiche l'icône Muyue sur le `.exe`
- Les raccourcis créés par `install-shortcuts` héritent de l'icône (via `IconLocation = "$exe,0"`)
- Aucune dépendance Go à runtime ; les `.syso` sont gitignorés et regénérés à chaque build
- **UI web** : favicon réel (16/32 px), apple-touch-icon (256 px) et logo affiché dans le header à côté de "MUYUE".
- Snippet d'install Windows : 1ʳᵉ ligne idempotente (`New-Item -ItemType Directory -Force`) pour gérer le cas d'une ré-exécution après install partielle.
- Préservation du logo source en pleine résolution (912×950 RGBA) — pas de perte d'information.
## v0.7.3
### Onboarding — focus MiniMax + MiMo
- L'étape `apikey` du wizard de premier lancement propose désormais **les deux clés** (MiniMax + MiMo) côte à côte ; au moins une doit être validée pour continuer.
- Les autres fournisseurs (OpenAI, Anthropic, Z.AI, Ollama) ne sont plus proposés dans le wizard — l'utilisateur les configure ensuite via l'onglet **Configuration** s'il le souhaite. Justification : pour les nouveaux utilisateurs, deux choix simples > six choix qui ralentissent le démarrage.
- Si MiniMax est validé, il devient le provider actif. Sinon, c'est MiMo. Si les deux sont validés, MiniMax reste actif (peut être basculé via `/model change` plus tard).
### Install Windows — pas d'admin + raccourcis automatiques
- **Avant** : la 3ᵉ ligne du snippet d'install (`Move-Item ... C:\Windows\muyue.exe`) échouait avec `UnauthorizedAccessException` sur PowerShell sans élévation.
- **Maintenant** : 4 lignes, toutes exécutables sans admin :
```powershell
$dest = "$env:LOCALAPPDATA\Muyue"
Invoke-WebRequest -Uri ".../muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
& "$dest\muyue-windows-amd64.exe" install-shortcuts
```
- Nouvelle commande `muyue install-shortcuts` (Windows uniquement) :
- crée `Muyue.lnk` sur le Bureau et dans le Menu Démarrer (résolus via `[Environment]::GetFolderPath`, robuste OneDrive / profils non-standards) ;
- utilise WScript.Shell COM via PowerShell pour générer les `.lnk` (pas de dépendance Go ajoutée) ;
- ajoute le dossier d'install au `PATH` utilisateur (scope User, pas de modif système).
- Une icône custom pourra être branchée plus tard en remplaçant la ressource embed du `.exe` ; pour l'instant, l'icône Windows par défaut du binaire est utilisée.
## v0.7.2
### Amélioration

BIN
LogoMuyue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
assets/muyue-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/muyue-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

BIN
assets/muyue-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
assets/muyue-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/muyue-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

BIN
assets/muyue-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
assets/muyue.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -0,0 +1,189 @@
package commands
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/spf13/cobra"
)
// installShortcutsCmd creates desktop + Start Menu shortcuts on Windows so
// non-technical users can launch Muyue without opening a terminal. It also
// adds the install directory to the user's PATH (per-user, no admin).
//
// Implementation note: shortcut (.lnk) creation on Windows is most reliable
// via WScript.Shell COM. We invoke it via PowerShell — keeps the Go binary
// dependency-free and works on any Windows 10+ host.
var installShortcutsCmd = &cobra.Command{
Use: "install-shortcuts",
Short: "Create Desktop + Start Menu shortcuts (Windows only) and add Muyue to PATH",
RunE: func(cmd *cobra.Command, args []string) error {
if runtime.GOOS != "windows" {
fmt.Println("install-shortcuts is a Windows-only command (no-op on this platform)")
return nil
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate executable: %w", err)
}
exe, _ = filepath.Abs(exe)
installDir := filepath.Dir(exe)
fmt.Println("Installing Muyue shortcuts...")
fmt.Printf(" Source : %s\n", exe)
// Provide a clean `muyue.exe` next to the platform-suffixed binary so
// users can type `muyue` once the install dir is on PATH. Copy (not
// rename) because the running .exe is locked on Windows.
canonicalExe := filepath.Join(installDir, "muyue.exe")
if !strings.EqualFold(exe, canonicalExe) {
if err := copyFile(exe, canonicalExe); err != nil {
fmt.Fprintf(os.Stderr, " Copy : warning — could not create muyue.exe: %v\n", err)
canonicalExe = exe
} else {
fmt.Printf(" Canonical : %s\n", canonicalExe)
}
}
desktop, err := userShellFolder("Desktop")
if err != nil {
return fmt.Errorf("locate Desktop folder: %w", err)
}
startMenu, err := userShellFolder("Programs")
if err != nil {
return fmt.Errorf("locate Start Menu Programs folder: %w", err)
}
desktopLnk := filepath.Join(desktop, "Muyue.lnk")
startLnk := filepath.Join(startMenu, "Muyue.lnk")
if err := createWindowsShortcut(desktopLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
return fmt.Errorf("create desktop shortcut: %w", err)
}
fmt.Printf(" Desktop : %s\n", desktopLnk)
if err := createWindowsShortcut(startLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
return fmt.Errorf("create Start Menu shortcut: %w", err)
}
fmt.Printf(" Start Menu : %s\n", startLnk)
if err := addUserPATH(installDir); err != nil {
fmt.Fprintf(os.Stderr, " PATH : warning — could not add %s to user PATH: %v\n", installDir, err)
} else {
fmt.Printf(" PATH : added %s\n", installDir)
}
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
fmt.Println("\nTo use 'muyue' from this PowerShell session right now, run:")
fmt.Printf(" $env:Path += ';%s'\n", installDir)
fmt.Println("(New terminals will pick up the user PATH automatically.)")
return nil
},
}
// copyFile duplicates src to dst, overwriting an existing dst (used to drop a
// `muyue.exe` next to the platform-suffixed binary so the command is callable
// as `muyue` from PATH).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func init() {
rootCmd.AddCommand(installShortcutsCmd)
}
// userShellFolder asks Windows for a user shell folder via PowerShell —
// resilient to OneDrive redirection and non-default profile locations.
// `which` is one of: Desktop, Programs (Start Menu Programs), StartMenu.
func userShellFolder(which string) (string, error) {
ps := fmt.Sprintf(`[Environment]::GetFolderPath('%s')`, which)
out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", ps).Output()
if err != nil {
return "", err
}
path := stripTrailingWhitespace(string(out))
if path == "" {
return "", fmt.Errorf("empty path for %s", which)
}
return path, nil
}
func stripTrailingWhitespace(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}
// createWindowsShortcut generates a .lnk via WScript.Shell COM. The arguments
// are passed through PowerShell variables (not interpolated into the script
// body) to avoid quoting issues with paths containing spaces or special chars.
func createWindowsShortcut(lnkPath, target, workingDir, description string) error {
script := `
$lnk = $env:MUYUE_LNK
$target = $env:MUYUE_TARGET
$workdir = $env:MUYUE_WORKDIR
$desc = $env:MUYUE_DESC
$wsh = New-Object -ComObject WScript.Shell
$sc = $wsh.CreateShortcut($lnk)
$sc.TargetPath = $target
$sc.WorkingDirectory = $workdir
$sc.Description = $desc
$sc.IconLocation = "$target,0"
$sc.Save()
`
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
cmd.Env = append(os.Environ(),
"MUYUE_LNK="+lnkPath,
"MUYUE_TARGET="+target,
"MUYUE_WORKDIR="+workingDir,
"MUYUE_DESC="+description,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("powershell: %v: %s", err, string(out))
}
return nil
}
// addUserPATH appends installDir to the user's PATH if not already present.
// Uses PowerShell to read/write the User-scope environment via .NET API,
// which broadcasts WM_SETTINGCHANGE so new processes pick it up.
func addUserPATH(installDir string) error {
script := `
$dir = $env:MUYUE_INSTALL_DIR
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($current -eq $null) { $current = '' }
$parts = $current -split ';' | Where-Object { $_ -ne '' }
if ($parts -notcontains $dir) {
$new = if ($current -eq '') { $dir } else { "$current;$dir" }
[Environment]::SetEnvironmentVariable('Path', $new, 'User')
}
`
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
cmd.Env = append(os.Environ(), "MUYUE_INSTALL_DIR="+installDir)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("powershell: %v: %s", err, string(out))
}
return nil
}

View File

@@ -24,8 +24,29 @@ func Execute() error {
return rootCmd.Execute()
}
// isInteractiveStdin reports whether os.Stdin is connected to a real terminal.
// Used to decide between the TUI first-time setup (huh forms) and a no-op
// fallback that defers onboarding to the web wizard. Returns false when the
// binary is launched by a double-click on Windows (Explorer attaches a pseudo
// console without a usable TTY) — which is the exact case where huh prints
// "This is a command line tool. You need to open cmd.exe and run it from there."
// and exits.
func isInteractiveStdin() bool {
stat, err := os.Stdin.Stat()
if err != nil {
return false
}
return (stat.Mode() & os.ModeCharDevice) != 0
}
func loadOrSetupConfig() *config.MuyueConfig {
if !config.Exists() {
// No config yet. If we have a real terminal, run the rich TUI setup
// (huh forms). Otherwise — typically when the user double-clicked the
// shortcut on Windows — write defaults silently and let the React
// onboarding wizard handle the real first-run flow once the browser
// opens. This avoids huh aborting with "This is a command line tool".
if isInteractiveStdin() {
fmt.Println("First time setup detected!")
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
@@ -51,6 +72,16 @@ func loadOrSetupConfig() *config.MuyueConfig {
return cfg
}
// Non-interactive — skip the TUI, persist defaults, web onboarding
// will fill in the profile / API keys.
cfg := config.Default()
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
return cfg
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)

View File

@@ -0,0 +1,54 @@
//go:build windows
package main
// Windows-only: with -H=windowsgui the binary is registered as a GUI
// subsystem app, so double-clicking from the Desktop shortcut does NOT
// spawn a console window (good for the desktop UX). The downside is that
// sub-commands like `muyue scan`, `muyue version`, `muyue install-shortcuts`
// produce no output when invoked from cmd.exe.
//
// Workaround: at process start, try to attach to the parent's console via
// kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the parent has a console
// (i.e. we were launched from cmd.exe / PowerShell), stdout/stderr/stdin are
// rebound to it. If not (Explorer double-click), the call fails silently and
// the binary runs without any console — exactly what we want.
import (
"log"
"os"
"syscall"
)
const attachParentProcess = ^uint32(0) // -1 cast to DWORD
func init() {
kernel32, err := syscall.LoadLibrary("kernel32.dll")
if err != nil {
return
}
defer syscall.FreeLibrary(kernel32)
attachConsole, err := syscall.GetProcAddress(kernel32, "AttachConsole")
if err != nil {
return
}
r0, _, _ := syscall.SyscallN(attachConsole, uintptr(attachParentProcess))
if r0 == 0 {
return // parent has no console (Explorer launch) — stay silent
}
// Re-bind the standard streams to the freshly attached console so
// fmt.Println / log output appear in the parent terminal.
if h, err := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE); err == nil && h != 0 {
os.Stdout = os.NewFile(uintptr(h), "stdout")
}
if h, err := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE); err == nil && h != 0 {
os.Stderr = os.NewFile(uintptr(h), "stderr")
}
if h, err := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE); err == nil && h != 0 {
os.Stdin = os.NewFile(uintptr(h), "stdin")
}
// log.Default() captured the original os.Stderr at init time — repoint it
// at the freshly attached console so log.Printf calls (e.g. desktop.Run)
// surface in the parent terminal.
log.SetOutput(os.Stderr)
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,271 @@
//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)
}
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is a quirk of the Win32 API: lpValue
// is the HPCON *value* (cast to PVOID), not a pointer to the handle. If
// we pass &hPC the kernel reads garbage, the PC attribute is silently
// ignored, and cmd/pwsh get their own external console window — which is
// exactly the regression v0.7.6 introduced. The cbSize stays the size of
// the handle (8 bytes on amd64). Reference: Microsoft EchoCon sample.
if err := attrList.Update(
procThreadAttributePseudoconsole,
unsafe.Pointer(uintptr(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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -7,7 +7,7 @@ import (
const (
Name = "muyue"
Version = "0.7.2"
Version = "0.7.8"
Author = "La Légion de Muyue"
)

View File

@@ -4,8 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0A0A0C" />
<title>muyue</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
<title>Muyue</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
<link rel="shortcut icon" href="/muyue.png" />
</head>
<body>
<div id="root"></div>

BIN
web/public/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

BIN
web/public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
web/public/muyue-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
web/public/muyue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -103,6 +103,7 @@ export default function App() {
<div className="app-layout">
<header className="header">
<div className="header-brand">
<img src="/muyue-64.png" alt="Muyue" className="header-logo-img" width="22" height="22" style={{ borderRadius: 4, verticalAlign: 'middle' }} />
<span className="header-logo">MUYUE</span>
<span className="header-version">v{info.version || '...'}</span>
</div>

View File

@@ -23,8 +23,12 @@ export default function OnboardingWizard({ api, onComplete }) {
language: 'fr',
keyboard: 'azerty',
apikey: '',
apikey_mimo: '',
editor: '',
})
const [keyValidMimo, setKeyValidMimo] = useState(false)
const [errorMimo, setErrorMimo] = useState(null)
const [validatingMimo, setValidatingMimo] = useState(false)
const [editorList, setEditorList] = useState(BASE_EDITORS)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
@@ -52,7 +56,7 @@ export default function OnboardingWizard({ api, onComplete }) {
case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard
case 'apikey': return keyValid && !scanning
case 'apikey': return (keyValid || keyValidMimo) && !scanning
case 'editor': return true
case 'done': return true
default: return true
@@ -173,6 +177,33 @@ export default function OnboardingWizard({ api, onComplete }) {
setValidating(false)
}
const handleValidateKeyMimo = async () => {
if (!answers.apikey_mimo.trim()) return
setValidatingMimo(true)
setErrorMimo(null)
try {
await api.validateProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
})
setKeyValidMimo(true)
// Save MiMo. If MiniMax wasn't validated yet, MiMo becomes the active provider.
await api.saveProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
active: !keyValid,
})
} catch (err) {
setErrorMimo(err.message || 'Clé invalide')
setKeyValidMimo(false)
}
setValidatingMimo(false)
}
const handleSave = async () => {
@@ -201,6 +232,15 @@ export default function OnboardingWizard({ api, onComplete }) {
active: true,
})
}
if (answers.apikey_mimo.trim()) {
await api.saveProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
active: !answers.apikey.trim(),
})
}
onComplete()
} catch (err) {
setError(err.message || 'Erreur lors de la sauvegarde')
@@ -283,38 +323,71 @@ export default function OnboardingWizard({ api, onComplete }) {
{current.key === 'apikey' && (
<div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div>
<div className="onboarding-title">Clés API</div>
<div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
Renseignez au moins l'une des deux clés pour activer l'assistant. Les autres fournisseurs (OpenAI, Anthropic, Ollama, Z.AI) se configurent plus tard depuis l'onglet Configuration.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiniMax</label>
<input
className="onboarding-input"
placeholder="sk-xxxxxxxxxxxxxxxx"
placeholder="sk-xxxxxxxxxxxxxxxx (MiniMax)"
type="password"
value={answers.apikey}
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
autoFocus
/>
{error && !keyValid && <div className="onboarding-required">{error}</div>}
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
{scanning && (
<div className="onboarding-scanning">
<Loader size={14} className="spin-icon" />
<span>{scanMessage}</span>
</div>
)}
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="sm primary"
onClick={handleValidateKey}
disabled={validating || !answers.apikey.trim()}
>
{validating ? 'Validation...' : 'Valider la clé'}
{validating ? 'Validation...' : 'Valider MiniMax'}
</button>
{keyValid && <span className="onboarding-valid">✓ MiniMax OK</span>}
{error && !keyValid && <span className="onboarding-required">{error}</span>}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiMo (Xiaomi)</label>
<input
className="onboarding-input"
placeholder="sk-xxxxxxxxxxxxxxxx (MiMo)"
type="password"
value={answers.apikey_mimo}
onChange={e => { setAnswers(a => ({ ...a, apikey_mimo: e.target.value })); setKeyValidMimo(false); setErrorMimo(null) }}
/>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="sm primary"
onClick={handleValidateKeyMimo}
disabled={validatingMimo || !answers.apikey_mimo.trim()}
>
{validatingMimo ? 'Validation...' : 'Valider MiMo'}
</button>
{keyValidMimo && <span className="onboarding-valid">✓ MiMo OK</span>}
{errorMimo && !keyValidMimo && <span className="onboarding-required">{errorMimo}</span>}
</div>
</div>
{scanning && (
<div className="onboarding-scanning" style={{ marginTop: 8 }}>
<Loader size={14} className="spin-icon" />
<span>{scanMessage}</span>
</div>
)}
{requiredError && (
<div className="onboarding-required" style={{ marginTop: 8 }}>
Veuillez valider au moins une clé (MiniMax ou MiMo) pour continuer.
</div>
)}
{(keyValid || keyValidMimo) && !scanning && (
<div className="onboarding-valid" style={{ marginTop: 8 }}>
Au moins une clé est valide — appuyez sur Suivant pour continuer.
</div>
{!keyValid && !error && answers.apikey.trim() && (
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
)}
</div>
)}