Compare commits

...

8 Commits

Author SHA1 Message Date
CI Bot
1830c18c7a chore: update CHANGELOG for v0.2.1
All checks were successful
Beta Release / beta (push) Successful in 28s
2026-04-20 20:17:33 +00:00
Augustin ROUX
cb8e3d0d26 feat: complete TUI redesign with cyberpunk theme (#1)
Some checks failed
Stable Release / stable (push) Failing after 22s
- Dark theme with red accents (cyberpunk aesthetic)
- Epuré cyberpunk style: clean dark backgrounds, sharp red highlights
- Full cyberpunk animations: glitch effect, scan line, typewriter
- Mixed Unicode + ASCII icons
- Rounded borders (╭ ╮ ╯ ╰) on cards and panels
- ASCII art block titles (■) with red styling
- Header: MUYUE branding, status indicators, live clock
- Footer: shortcuts, version, update indicator
- Tab transitions: glitch → scan → typewriter sequence
- Extracted header.go, footer.go, animations.go as new files

Controls unchanged: ctrl+t tabs, ctrl+s sidebar, ctrl+a AI panel

file changes:
- styles.go: new color palette (cyberRed, bgVoid, dimRed), block titles
- types.go: added transition state, clock tick, glitch/scan/done messages
- animations.go: new file with glitch, scan, typewriter, hex stream effects
- header.go: new file with logo, tabs, status dots, live clock
- footer.go: new file with shortcuts, version, update indicator
- model.go: integrated transition state machine, clock updates
- dashboard.go, studio.go, terminal.go, config_tab.go: updated icons/styles

Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>

Co-authored-by: Augustin <muyue@legion-muyue.fr>
Reviewed-on: #1
2026-04-20 20:17:06 +00:00
CI Bot
8ea7418684 chore: update CHANGELOG for v0.2.1 2026-04-20 19:22:23 +00:00
Augustin
ec33ff4e4d Merge branch 'main' of https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace
All checks were successful
Stable Release / stable (push) Successful in 28s
Beta Release / beta (push) Successful in 26s
2026-04-20 21:21:32 +02:00
Augustin
22fb2823ce chore: bump version to 0.2.1, update README for TUI redesign
All checks were successful
Beta Release / beta (push) Successful in 32s
- Document 4-tab layout (Dashboard, Studio, Shell, Config)
- Add keyboard shortcuts table for new tabs
- Update version references from 0.2.0 to 0.2.1

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 21:21:03 +02:00
CI Bot
6dad84067d chore: update CHANGELOG for v0.2.0 2026-04-20 19:08:34 +00:00
Augustin
b475348816 Merge branch 'feat/redesign-tui' into develop
All checks were successful
Beta Release / beta (push) Successful in 37s
Stable Release / stable (push) Successful in 28s
2026-04-20 21:03:57 +02:00
Augustin
035e923e6c refactor: redesign TUI with 4 tabs, red/rose theme, split layouts
- Dashboard: tools, agents status, updates, quick actions
- Studio: central chat + agents/workflows sidebar (Ctrl+S toggle)
- Shell: terminal + AI assistant panel side-by-side (Ctrl+A toggle)
- Config: profile, API keys, terminal/starship settings in 2 columns
- New red/rose color scheme (#E8364F → #FF6B8A → #FFB3C6)
- Animated header with visual tab bar and pulse loading
- Remove old chat.go, agents.go, workflow_tab.go (merged into studio.go)
- All tests pass, build clean

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 21:03:49 +02:00
19 changed files with 1538 additions and 802 deletions

View File

@@ -4,6 +4,158 @@ 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.2.1
### Changes since v0.2.1
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.1
### Changes since v0.2.0
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.0
### Changes since start
- refactor: redesign TUI with 4 tabs, red/rose theme, split layouts (035e923)
- feat: GitFlow workflow with beta/stable CI pipelines (bbdac6c)
- feat: security hardening, tests, doctor command, CI update, CHANGELOG (3494f6b)
- refactor: modularize TUI, improve error handling, add CI caching and tests (4469122)
- fix: remove tab switching, filter AI thinking from responses (5a33dfc)
- fix: enable text selection, dashboard multi-column layout (82b2816)
- feat: Ctrl+T tab switcher, minimal header, integrated terminal (2d6fc64)
- feat: Ctrl+M tab switcher overlay menu (bb3b303)
- fix: docker version check, uv PATH, install progress bar (e6fdec4)
- feat: smart setup wizard - sort choices by system detection (1be4fc0)
- fix: use Alt+1-5 for tab navigation to free number keys for input (825b429)
- ci: add install instructions for all platforms in release body (ac35ff2)
- ci: add build + release steps with push-only conditions (bcb9aa0)
- ci: restore exact working ci.yml from e58e00d for testing (0a91cef)
- fix: rename workflow back to CI (slash in name breaks Gitea 1.25) (461122a)
- ci: trigger workflow run (ea59c2c)
- fix: remove workflow_dispatch + add push-only conditions on release steps (9cd583f)
- ci: single job - build + vet + release latest in one pass (92275be)
- ci: merge CI and Release into single workflow (f2c0996)
- fix: release workflow - delete old release before creating new one (5eb237f)
- feat: redesign TUI + Ctrl+C quit confirm + version logic + sudo handling (e3cd618)
- feat: add mouse support + install pnpm, uv, docker, gh (e58e00d)
- fix: use GITEATOKEN secret name (no underscores in Gitea 1.25) (8e3f8b8)
- fix: make release delete step resilient + check GITEA_TOKEN (69ca5c6)
- fix: remove redundant newline in profiler.go (go vet) (2d421fe)
- fix: export PATH in every step for Gitea runner compatibility (3f8e01f)
- ci: restore actions/checkout + simplify workflows (4db69e4)
- fix: add missing cmd/muyue/main.go and fix .gitignore (f650988)
- ci: fix Gitea Actions - native checkout + auto-release on push (78c7239)
- ci: migrate workflows to Gitea Actions with self-hosted runner (811a9aa)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [0.2.0] - 2026-04-20
### Added

View File

@@ -76,25 +76,49 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill
```
## TUI Controls
## TUI — 4 Tabs
| Key | Action |
|-----|--------|
| `Ctrl+T` | Open tab switcher |
| `Tab` / `Shift+Tab` | Cycle tabs |
| `Ctrl+C` | Quit confirmation |
| `i` (Dashboard) | Install missing tools |
| `u` (Dashboard) | Check for updates |
| `s` (Dashboard) | Rescan system |
| `a` (Workflow) | Approve plan |
| `r` (Workflow) | Reject plan |
| `g` (Workflow) | Generate plan |
| `n` (Workflow) | Next step |
| `x` (Workflow) | Cancel workflow |
The TUI is organized into 4 tabs with a red/rose theme (`#E8364F``#FF6B8A`):
### Chat Commands
### ◉ Dashboard
- `/plan <goal>` — Start a structured Plan→Execute workflow
System overview: installed tools with status, active agents, updates, LSP/MCP/daemon status, and quick actions (install, update, scan).
### ◈ Studio
Central AI chat with a collapsible sidebar (`Ctrl+S`) containing 3 panels:
| Panel | Shortcut | Description |
|-------|----------|-------------|
| **Chat** | `1` | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | `2` | Start/stop Crush and Claude Code agents |
| **Workflows** | `3` | Plan→Execute workflow controls (approve, reject, next step) |
### ▶ Shell
Split-view terminal with an AI assistant panel (`Ctrl+A` to toggle). The AI knows your system and suggests commands you can easily copy into the terminal.
### ⚙ Config
Profile, API providers, terminal/starship settings, BMAD, and skills — displayed in a two-column layout.
### Keyboard Shortcuts
| Key | Context | Action |
|-----|---------|--------|
| `Ctrl+T` | Global | Open tab switcher |
| `Ctrl+S` | Studio | Toggle sidebar |
| `Ctrl+A` | Shell | Toggle AI assistant panel |
| `Ctrl+C` | Global | Quit confirmation |
| `i` | Dashboard | Install missing tools |
| `u` | Dashboard | Check for updates |
| `s` | Dashboard | Rescan system |
| `1` `2` `3` | Studio sidebar | Switch panels (Chat/Agents/Workflows) |
| `a` | Workflow | Approve plan |
| `r` | Workflow | Reject plan |
| `g` | Workflow | Generate plan |
| `n` | Workflow | Next step |
| `x` | Workflow | Cancel workflow |
## Configuration
@@ -179,7 +203,7 @@ git push -u origin feature/my-feature
```bash
# 1. Bump the version in internal/version/version.go
# Change: Version = "0.2.0" → Version = "0.3.0"
# Change: Version = "0.2.1" → Version = "0.3.0"
# Commit on develop:
git checkout develop
# (edit internal/version/version.go)
@@ -222,7 +246,7 @@ git push
```go
const (
Version = "0.2.0" // ← bump this before a release
Version = "0.2.1" // ← bump this before a release
)
```
@@ -236,11 +260,11 @@ Binary version is injected at build time via `-ldflags`:
```bash
# Beta build (automatic in CI)
go build -ldflags="-X github.com/muyue/muyue/internal/version.Prerelease=beta.3" ./cmd/muyue/
# → muyue v0.2.0-beta.3
# → muyue v0.2.1-beta.3
# Stable build (automatic in CI)
go build -ldflags="-s -w" ./cmd/muyue/
# → muyue v0.2.0
# → muyue v0.2.1
```
### Conventional commits

View File

@@ -1,87 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/proxy"
)
func (m Model) renderAgents() string {
var b strings.Builder
b.WriteString(sectionStyle.Render("Background Agents"))
b.WriteString("\n\n")
agents := []struct {
name string
agentType proxy.AgentType
tool string
}{
{"Crush", proxy.AgentCrush, "Z.AI GLM"},
{"Claude Code", proxy.AgentClaude, "Anthropic Claude"},
}
for _, a := range agents {
status, logs := m.proxyMgr.Status(a.agentType)
available := m.proxyMgr.IsAvailable(a.agentType)
var statusStr string
switch status {
case proxy.StatusRunning:
statusStr = itemWarnStyle.Render(" running")
case proxy.StatusStopped:
statusStr = itemMissingStyle.Render(" stopped")
case proxy.StatusError:
statusStr = itemMissingStyle.Render(" error")
default:
if available {
statusStr = itemOKStyle.Render(" available")
} else {
statusStr = itemMissingStyle.Render(" not installed")
}
}
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Bold(true)
b.WriteString(fmt.Sprintf(" %s %s %s\n", nameStyle.Render(a.name), statusStr,
lipgloss.NewStyle().Foreground(mutedColor).Render("("+a.tool+")")))
if logs != nil && len(logs) > 0 {
lastLogs := logs
if len(logs) > 5 {
lastLogs = logs[len(logs)-5:]
}
for _, l := range lastLogs {
b.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(dimColor).Render(l.Timestamp.Format("15:04:05")),
l.Message))
}
}
}
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Actions"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s Start Crush\n", lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("[c]")))
b.WriteString(fmt.Sprintf(" %s Start Claude Code\n", lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("[l]")))
return b.String()
}
func (m Model) handleAgentsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "c":
if m.proxyMgr.IsAvailable(proxy.AgentCrush) {
m.proxyMgr.Start(proxy.AgentCrush)
}
m.viewport.SetContent(m.renderContent())
case "l":
if m.proxyMgr.IsAvailable(proxy.AgentClaude) {
m.proxyMgr.Start(proxy.AgentClaude)
}
m.viewport.SetContent(m.renderContent())
}
return m, nil
}

115
internal/tui/animations.go Normal file
View File

@@ -0,0 +1,115 @@
package tui
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
var glitchChars = "!@#$%^&*()_+-=[]{}|;':,./<>?~`"
func init() {
rand.Seed(time.Now().UnixNano())
}
func randomGlitchChar() string {
return string(glitchChars[rand.Intn(len(glitchChars))])
}
func glitchText(text string, intensity int) string {
runes := []rune(text)
for i := 0; i < intensity; i++ {
pos := rand.Intn(len(runes))
if runes[pos] != ' ' && runes[pos] != '\n' {
runes[pos] = []rune(randomGlitchChar())[0]
}
}
return string(runes)
}
func generateScanLine(width int, frame int) string {
pos := frame % width
line := strings.Repeat(" ", pos) +
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(strings.Repeat("━", min(20, width-pos)))
if pos+20 < width {
line += strings.Repeat(" ", width-pos-20)
}
return line[:min(width, len(line))]
}
func typewriterRender(text string, pos int) string {
if pos >= len(text) {
return text
}
if pos <= 0 {
return ""
}
shown := text[:pos]
cursor := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("█")
return shown + cursor
}
func renderGlitchEffect(width, height, frame int) string {
var b strings.Builder
for y := 0; y < height; y++ {
line := ""
for x := 0; x < width; x++ {
if rand.Float64() < 0.15 {
c := randomGlitchChar()
style := lipgloss.NewStyle()
r := rand.Float64()
if r < 0.4 {
style = style.Foreground(cyberRed)
} else if r < 0.7 {
style = style.Foreground(cyberPink)
} else {
style = style.Foreground(textMuted)
}
line += style.Render(c)
} else {
line += " "
}
}
b.WriteString(line)
if y < height-1 {
b.WriteString("\n")
}
}
return b.String()
}
func renderScanEffect(width, height, frame int) string {
var b strings.Builder
scanY := frame % (height + 10)
for y := 0; y < height; y++ {
if y == scanY || y == scanY+1 {
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Render(strings.Repeat("━", width)))
} else if y == scanY-1 || y == scanY+2 {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(strings.Repeat("─", width)))
} else if y < scanY {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf("%*s", width, "")))
} else {
b.WriteString(strings.Repeat(" ", width))
}
if y < height-1 {
b.WriteString("\n")
}
}
return b.String()
}
func generateHexStream(width, lines int) string {
var b strings.Builder
for y := 0; y < lines; y++ {
for x := 0; x < width/3; x++ {
b.WriteString(fmt.Sprintf("%02X", rand.Intn(256)))
}
if y < lines-1 {
b.WriteString("\n")
}
}
return lipgloss.NewStyle().Foreground(dimRed).Render(b.String())
}

View File

@@ -1,45 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderChat() string {
var b strings.Builder
header := sectionStyle.Render("Chat")
header += " "
header += lipgloss.NewStyle().Foreground(mutedColor).Render("(" + m.config.Profile.Preferences.DefaultAI + ")")
if m.chatLoading {
header += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warningColor).Render("thinking...")
}
b.WriteString(header)
b.WriteString("\n\n")
separator := lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-4, 10)))
b.WriteString(separator)
b.WriteString("\n\n")
for _, msg := range m.chatLog {
b.WriteString(msg)
b.WriteString("\n\n")
}
if m.previewURL != "" {
b.WriteString(itemOKStyle.Render(fmt.Sprintf("Preview: %s", m.previewURL)))
b.WriteString("\n\n")
}
return b.String()
}
func (m Model) renderChatInput() string {
if m.chatLoading {
return inputStyle.Render("> ") + m.spinner.View() + lipgloss.NewStyle().Foreground(mutedColor).Render(" waiting for response...")
}
cursor := lipgloss.NewStyle().Foreground(baseColor).Render("")
return inputStyle.Render("> ") + m.chatInput + cursor
}

View File

@@ -2,7 +2,6 @@ package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config"
@@ -107,23 +106,3 @@ func continueWorkflowCmd(orch *orchestrator.Orchestrator, output string) tea.Cmd
return aiResponseMsg{content: resp}
})
}
func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
input := m.chatInput
m.chatLog = append(m.chatLog, userMsgStyle.Render("you: "+input))
m.chatInput = ""
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
if strings.HasPrefix(input, "/plan ") {
goal := strings.TrimPrefix(input, "/plan ")
return m, startWorkflowCmd(m.orch, goal)
}
if m.orch != nil && m.orch.Workflow != nil && m.orch.Workflow.Phase != workflow.PhaseIdle {
return m, workflowChatCmd(m.orch, input)
}
return m, sendAIMessage(m.orch, input)
}

View File

@@ -2,23 +2,21 @@ package tui
import (
"fmt"
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
)
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func extractVersion(s string) string {
return versionRegex.FindString(s)
}
func (m Model) renderConfig() string {
var b strings.Builder
colWidth := m.width / 2
if colWidth < 30 {
colWidth = 30
}
b.WriteString(sectionStyle.Render("Profile"))
b.WriteString("\n")
var left, right strings.Builder
left.WriteString(renderSectionHeader("PROFILE", "[@]"))
left.WriteString("\n")
if m.config != nil {
fields := []struct {
label string
@@ -33,73 +31,84 @@ func (m Model) renderConfig() string {
{"Default AI", m.config.Profile.Preferences.DefaultAI},
}
for _, f := range fields {
labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14)
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0"))
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render(f.label+":"), valueStyle.Render(f.value)))
left.WriteString(fmt.Sprintf(" %s %s\n",
labelStyle.Render(f.label+":"),
valueStyle.Render(f.value)))
}
if len(m.config.Profile.Languages) > 0 {
labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14)
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0"))
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Languages:"), valueStyle.Render(strings.Join(m.config.Profile.Languages, ", "))))
left.WriteString(fmt.Sprintf(" %s %s\n",
labelStyle.Render("Languages:"),
valueStyle.Render(strings.Join(m.config.Profile.Languages, ", "))))
}
}
b.WriteString("\n")
left.WriteString("\n")
b.WriteString(sectionStyle.Render("AI Providers"))
b.WriteString("\n")
left.WriteString(renderSectionHeader("AI PROVIDERS", "[AI]"))
left.WriteString("\n")
if m.config != nil {
for _, p := range m.config.AI.Providers {
active := ""
if p.Active {
active = itemOKStyle.Render(" active")
active = lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" >>")
}
keyStatus := itemMissingStyle.Render("no key")
if p.APIKey != "" {
keyStatus = itemOKStyle.Render("configured")
}
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Bold(true)
b.WriteString(fmt.Sprintf(" %s model=%s key=%s%s\n",
nameStyle.Render(p.Name), p.Model, keyStatus, active))
nameStyle := lipgloss.NewStyle().Foreground(textBright).Bold(true)
left.WriteString(fmt.Sprintf(" %s %s key=%s%s\n",
nameStyle.Render(p.Name),
lipgloss.NewStyle().Foreground(dimRed).Render("model="+p.Model),
keyStatus, active))
}
}
b.WriteString("\n")
left.WriteString("\n")
b.WriteString(sectionStyle.Render("BMAD Method"))
b.WriteString("\n")
right.WriteString(renderSectionHeader("TERMINAL", "[$]"))
right.WriteString("\n")
if m.config != nil {
installed := itemMissingStyle.Render("no")
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Custom Prompt:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Terminal.CustomPrompt))))
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Prompt Theme:"), valueStyle.Render(m.config.Terminal.PromptTheme)))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Auto Update:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.AutoUpdate))))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Check on Start:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.CheckOnStart))))
}
right.WriteString("\n")
right.WriteString(renderSectionHeader("BMAD METHOD", "[B]"))
right.WriteString("\n")
if m.config != nil {
installed := itemMissingStyle.Render("[--] no")
if m.config.BMAD.Installed {
installed = itemOKStyle.Render("yes")
installed = itemOKStyle.Render("[OK] yes")
}
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Installed:"), installed))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Global:"), valueStyle.Render(fmt.Sprintf("%v", m.config.BMAD.Global))))
if m.config.BMAD.Version != "" {
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Version:"), valueStyle.Render(m.config.BMAD.Version)))
}
b.WriteString(fmt.Sprintf(" Installed: %s\n", installed))
b.WriteString(fmt.Sprintf(" Global: %v\n", m.config.BMAD.Global))
}
b.WriteString("\n")
right.WriteString("\n")
b.WriteString(sectionStyle.Render("Terminal"))
b.WriteString("\n")
if m.config != nil {
b.WriteString(fmt.Sprintf(" Custom Prompt: %v\n", m.config.Terminal.CustomPrompt))
b.WriteString(fmt.Sprintf(" Prompt Theme: %s\n", m.config.Terminal.PromptTheme))
}
b.WriteString("\n")
b.WriteString(sectionStyle.Render(fmt.Sprintf("Skills (%d)", len(m.skillList))))
b.WriteString("\n")
right.WriteString(renderSectionHeader(fmt.Sprintf("SKILLS (%d)", len(m.skillList)), "[!]"))
right.WriteString("\n")
if len(m.skillList) > 0 {
for _, s := range m.skillList {
target := s.Target
if target == "" {
target = "both"
}
b.WriteString(fmt.Sprintf(" %-20s %s %s\n",
lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Name),
lipgloss.NewStyle().Foreground(aiColor).Render("["+target+"]"),
s.Description))
right.WriteString(fmt.Sprintf(" %s %s %s\n",
lipgloss.NewStyle().Foreground(textMain).Render(s.Name),
lipgloss.NewStyle().Foreground(cyberRed).Render("["+target+"]"),
lipgloss.NewStyle().Foreground(dimRed).Render(s.Description)))
}
} else {
b.WriteString(" No skills. Run `muyue skills init` to install built-ins.\n")
right.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(" No skills. Run `muyue skills init`."))
right.WriteString("\n")
}
return b.String()
leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
}

View File

@@ -15,16 +15,16 @@ func (m Model) renderDashboard() string {
var left, right strings.Builder
left.WriteString(sectionStyle.Render("System"))
left.WriteString(renderSectionHeader("SYSTEM", "[*]"))
left.WriteString("\n")
if m.scanResult != nil {
sysInfo := m.scanResult.System.String()
left.WriteString(" ")
left.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(sysInfo))
left.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(sysInfo))
}
left.WriteString("\n\n")
left.WriteString(sectionStyle.Render("Tools"))
left.WriteString(renderSectionHeader("INSTALLED TOOLS", "[+]"))
left.WriteString("\n")
if m.scanResult != nil {
installed := 0
@@ -33,27 +33,32 @@ func (m Model) renderDashboard() string {
if t.Installed {
installed++
left.WriteString(" ")
left.WriteString(itemOKStyle.Render(" "))
left.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, extractVersion(t.Version)))
left.WriteString(itemOKStyle.Render("[OK] "))
left.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(t.Name))
left.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %s", extractVersion(t.Version))))
left.WriteString("\n")
} else {
left.WriteString(" ")
left.WriteString(itemMissingStyle.Render(" "))
left.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, itemPendingStyle.Render("(not installed)")))
left.WriteString(itemMissingStyle.Render("[--] "))
left.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(t.Name))
left.WriteString(itemPendingStyle.Render(" (missing)"))
left.WriteString("\n")
}
}
barWidth := 20
pct := 0
if total > 0 {
pct = (installed * barWidth) / total
}
bar := lipgloss.NewStyle().Foreground(successColor).Render(strings.Repeat("█", pct)) +
lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("░", barWidth-pct))
bar := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(strings.Repeat("█", pct)) +
lipgloss.NewStyle().Foreground(dimRed).Render(strings.Repeat("░", barWidth-pct))
left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total))
}
left.WriteString("\n")
if m.installing {
left.WriteString(sectionStyle.Render("Installing..."))
left.WriteString(renderSectionHeader("INSTALLING", "[~]"))
left.WriteString("\n")
progBar := m.progressBar.View()
label := ""
@@ -67,7 +72,7 @@ func (m Model) renderDashboard() string {
}
if len(m.installLog) > 0 {
left.WriteString(sectionStyle.Render("Install Log"))
left.WriteString(renderSectionHeader("INSTALL LOG", "[#]"))
left.WriteString("\n")
for _, l := range m.installLog {
left.WriteString(l + "\n")
@@ -75,85 +80,95 @@ func (m Model) renderDashboard() string {
left.WriteString("\n")
}
right.WriteString(sectionStyle.Render("Quick Actions"))
right.WriteString(renderSectionHeader("QUICK ACTIONS", "[!]"))
right.WriteString("\n")
actions := []struct {
key string
desc string
key string
desc string
color lipgloss.Color
}{
{"i", "Install missing tools"},
{"u", "Check for updates"},
{"s", "Rescan system"},
{"l", "Scan LSP servers"},
{"m", "Configure MCP servers"},
{"i", "Install missing tools", cyberRed},
{"u", "Check for updates", neonRed},
{"s", "Rescan system", cyberPink},
{"l", "Scan LSP servers", cyberRose},
{"m", "Configure MCP servers", brightRed},
}
for _, a := range actions {
right.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("["+a.key+"]"),
a.desc))
lipgloss.NewStyle().Foreground(a.color).Bold(true).Render("["+a.key+"]"),
lipgloss.NewStyle().Foreground(textMain).Render(a.desc)))
}
right.WriteString("\n")
right.WriteString(renderSectionHeader("ACTIVE AGENTS", "[*]"))
right.WriteString("\n")
agents := []struct {
name string
}{
{"Crush"},
{"Claude Code"},
}
for _, a := range agents {
right.WriteString(" ")
right.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(">> "))
right.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(a.name + " "))
right.WriteString(itemPendingStyle.Render("[stopped]"))
right.WriteString("\n")
}
right.WriteString("\n")
if len(m.updateStatus) > 0 {
right.WriteString(sectionStyle.Render("Updates"))
right.WriteString(renderSectionHeader("UPDATES", "[^]"))
right.WriteString("\n")
for _, s := range m.updateStatus {
if s.NeedsUpdate {
right.WriteString(" ")
right.WriteString(itemWarnStyle.Render(" "))
right.WriteString(fmt.Sprintf(" %s: %s -> %s\n", s.Tool, s.Current, s.Latest))
right.WriteString(itemWarnStyle.Render("[!!] "))
right.WriteString(fmt.Sprintf("%s: %s -> %s\n", s.Tool, s.Current, s.Latest))
} else if s.Error == "" {
right.WriteString(" ")
right.WriteString(itemOKStyle.Render(" "))
right.WriteString(fmt.Sprintf(" %s: up to date\n", s.Tool))
right.WriteString(itemOKStyle.Render("[OK] "))
right.WriteString(fmt.Sprintf("%s: up to date\n", s.Tool))
}
}
right.WriteString("\n")
}
if len(m.lspServers) > 0 {
right.WriteString(sectionStyle.Render("LSP Servers"))
right.WriteString(renderSectionHeader("LSP SERVERS", "[L]"))
right.WriteString("\n")
lspInstalled := 0
for _, s := range m.lspServers {
if s.Installed {
lspInstalled++
right.WriteString(" ")
right.WriteString(itemOKStyle.Render(" "))
right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language))
right.WriteString(itemOKStyle.Render("[OK] "))
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
} else {
right.WriteString(" ")
right.WriteString(itemPendingStyle.Render(" "))
right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language))
right.WriteString(itemPendingStyle.Render("[ ] "))
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
}
}
right.WriteString(fmt.Sprintf("\n Installed: %d/%d\n", lspInstalled, len(m.lspServers)))
right.WriteString(fmt.Sprintf("\n %d/%d available\n", lspInstalled, len(m.lspServers)))
right.WriteString("\n")
}
mcpStatus := itemPendingStyle.Render("[ ] not configured")
if m.mcpConfigured {
mcpStatus = itemOKStyle.Render("[OK] configured")
}
right.WriteString(fmt.Sprintf(" MCP: %s\n", mcpStatus))
if m.daemon != nil {
right.WriteString(sectionStyle.Render("Daemon"))
right.WriteString("\n")
daemonStatus := itemPendingStyle.Render("[ ] stopped")
if m.daemon.IsRunning() {
right.WriteString(" ")
right.WriteString(itemOKStyle.Render("running"))
lastCheck := m.daemon.LastCheck()
if !lastCheck.IsZero() {
right.WriteString(fmt.Sprintf(" last: %s", lastCheck.Format("15:04:05")))
}
} else {
right.WriteString(" ")
right.WriteString(itemPendingStyle.Render("stopped"))
daemonStatus = itemOKStyle.Render("[OK] running")
}
right.WriteString("\n\n")
right.WriteString(fmt.Sprintf(" Daemon: %s\n", daemonStatus))
}
mcpStatus := itemPendingStyle.Render("not configured")
if m.mcpConfigured {
mcpStatus = itemOKStyle.Render("configured")
}
right.WriteString(fmt.Sprintf("MCP: %s\n", mcpStatus))
leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())

74
internal/tui/footer.go Normal file
View File

@@ -0,0 +1,74 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/version"
)
func (m Model) renderFooter() string {
profile := "unknown"
if m.config != nil && m.config.Profile.Pseudo != "" {
profile = m.config.Profile.Pseudo
}
left := fmt.Sprintf(" %s@%s",
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(profile),
lipgloss.NewStyle().Foreground(dimRed).Render(version.Name))
leftR := statusBarStyle.Render(left)
var helpText string
switch m.activeTab {
case tabDashboard:
helpText = "[i] install [u] update [s] scan [ctrl+t] tabs"
case tabStudio:
helpText = "[enter] send [ctrl+s] sidebar [ctrl+t] tabs"
case tabShell:
helpText = "[enter] run [ctrl+a] AI panel [ctrl+c] kill"
case tabConfig:
helpText = "[up/down] sections [ctrl+t] tabs"
default:
helpText = "[ctrl+t] tabs [ctrl+c] quit"
}
rightR := statusBarStyle.Render(helpText)
updateIndicator := ""
if len(m.updateStatus) > 0 {
needsUpdate := false
for _, s := range m.updateStatus {
if s.NeedsUpdate {
needsUpdate = true
break
}
}
if needsUpdate {
updateIndicator = lipgloss.NewStyle().Foreground(warnAmber).Render(" [UPD] ")
} else {
updateIndicator = lipgloss.NewStyle().Foreground(successGreen).Render(" [OK] ")
}
}
verStr := lipgloss.NewStyle().Foreground(dimRed).Render("v" + version.Version)
midContent := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
updateIndicator + verStr,
)
gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR) - lipgloss.Width(midContent)
if gap < 0 {
gap = 0
}
statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
leftR,
strings.Repeat(" ", gap),
midContent,
rightR,
)
helpLine := lipgloss.NewStyle().Background(bgSurface).Foreground(textMuted).Render(
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys)))
return lipgloss.JoinVertical(lipgloss.Left, statusLine, helpLine)
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
@@ -12,6 +13,7 @@ import (
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/workflow"
)
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -22,8 +24,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleTabMenu(msg)
}
if m.activeTab == tabTerminal {
return m.handleTerminalKey(msg)
if m.activeTab == tabShell {
return m.handleShellKey(msg)
}
switch msg.String() {
@@ -43,17 +45,22 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tabMenuCursor = int(m.activeTab)
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+s":
if m.activeTab == tabStudio {
m.studioSidebarOpen = !m.studioSidebarOpen
m.viewport.SetContent(m.renderContent())
}
case "enter":
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && m.chatInput != "" && !m.chatLoading {
if m.activeTab == tabStudio && m.chatInput != "" && !m.chatLoading {
return m.handleChatSubmit()
}
case "backspace":
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(m.chatInput) > 0 {
if m.activeTab == tabStudio && len(m.chatInput) > 0 {
m.chatInput = m.chatInput[:len(m.chatInput)-1]
m.viewport.SetContent(m.renderContent())
}
default:
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(msg.String()) == 1 && !m.chatLoading {
if m.activeTab == tabStudio && len(msg.String()) == 1 && !m.chatLoading {
m.chatInput += msg.String()
m.viewport.SetContent(m.renderContent())
}
@@ -62,11 +69,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.activeTab == tabDashboard {
return m.handleDashboardKey(msg)
}
if m.activeTab == tabAgents {
return m.handleAgentsKey(msg)
}
if m.activeTab == tabWorkflow {
return m.handleWorkflowKey(msg)
if m.activeTab == tabStudio {
return m.handleStudioKey(msg)
}
return m, nil
@@ -138,16 +142,14 @@ func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, nil
case "enter":
m.activeTab = tab(m.tabMenuCursor)
m.switchTab(tab(m.tabMenuCursor))
m.showingTabMenu = false
m.resizeViewport()
return m, nil
default:
for i := 0; i < int(tabCount); i++ {
if msg.String() == fmt.Sprintf("%d", i+1) {
m.activeTab = tab(i)
m.switchTab(tab(i))
m.showingTabMenu = false
m.resizeViewport()
return m, nil
}
}
@@ -155,6 +157,17 @@ func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *Model) switchTab(t tab) {
if t == m.activeTab {
return
}
m.prevTab = m.activeTab
m.activeTab = t
m.transition = transitionGlitch
m.transitionTick = 0
m.resizeViewport()
}
func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "i":
@@ -170,13 +183,13 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
if len(missing) == 0 {
m.installLog = append(m.installLog, itemOKStyle.Render("All tools already installed!"))
m.installLog = append(m.installLog, itemOKStyle.Render("[OK] All tools already installed!"))
m.viewport.SetContent(m.renderContent())
return m, nil
}
needsSudo := checkNeedsSudo(m.scanResult)
if needsSudo && !hasSudo() {
m.installLog = append(m.installLog, errMsgStyle.Render("Some tools require sudo. Run: sudo muyue install"))
m.installLog = append(m.installLog, errMsgStyle.Render("[!!] Some tools require sudo. Run: sudo muyue install"))
m.viewport.SetContent(m.renderContent())
return m, nil
}
@@ -210,6 +223,94 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) handleStudioKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if !m.studioSidebarOpen {
return m, nil
}
switch msg.String() {
case "1":
m.studioPanel = panelChat
m.viewport.SetContent(m.renderContent())
case "2":
m.studioPanel = panelAgents
m.viewport.SetContent(m.renderContent())
case "3":
m.studioPanel = panelWorkflows
m.viewport.SetContent(m.renderContent())
}
if m.studioPanel == panelAgents {
return m.handleAgentsKey(msg)
}
if m.studioPanel == panelWorkflows {
return m.handleWorkflowKey(msg)
}
return m, nil
}
func (m Model) handleAgentsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "c":
if m.proxyMgr.IsAvailable(proxy.AgentCrush) {
m.proxyMgr.Start(proxy.AgentCrush)
}
m.viewport.SetContent(m.renderContent())
case "l":
if m.proxyMgr.IsAvailable(proxy.AgentClaude) {
m.proxyMgr.Start(proxy.AgentClaude)
}
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.orch == nil || m.orch.Workflow == nil {
return m, nil
}
wf := m.orch.Workflow
switch msg.String() {
case "a":
if wf.Phase == workflow.PhaseReviewing {
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> [Plan approved]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, reviewPlanCmd(m.orch, true, "")
}
case "r":
if wf.Phase == workflow.PhaseReviewing {
m.chatInput = ""
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Type your rejection feedback below:"))
m.viewport.SetContent(m.renderContent())
}
case "g":
if wf.Phase == workflow.PhaseGathering && len(wf.Plan.Questions) > 0 && len(wf.Plan.Answers) >= len(wf.Plan.Questions) {
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> [Generate plan]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, generatePlanCmd(m.orch)
}
case "n":
if wf.Phase == workflow.PhaseExecuting {
current := wf.CurrentStep()
if current != nil {
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, continueWorkflowCmd(m.orch, "proceeding")
}
}
case "x":
wf.Reset()
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Workflow reset."))
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func checkNeedsSudo(scan *scanner.ScanResult) bool {
if scan == nil {
return false
@@ -237,3 +338,23 @@ func hasSudo() bool {
}
return false
}
func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
input := m.chatInput
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> "+input))
m.chatInput = ""
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
if strings.HasPrefix(input, "/plan ") {
goal := strings.TrimPrefix(input, "/plan ")
return m, startWorkflowCmd(m.orch, goal)
}
if m.orch != nil && m.orch.Workflow != nil && m.orch.Workflow.Phase != workflow.PhaseIdle {
return m, workflowChatCmd(m.orch, input)
}
return m, sendAIMessage(m.orch, input)
}

178
internal/tui/header.go Normal file
View File

@@ -0,0 +1,178 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/version"
)
func (m Model) renderHeader() string {
var tabs []string
for i, name := range tabNames {
icon := tabIcons[i]
if tab(i) == m.activeTab {
tabStyle := lipgloss.NewStyle().
Background(cyberRed).
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
} else {
tabStyle := lipgloss.NewStyle().
Background(bgSurface).
Foreground(textDim).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
}
}
tabLine := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...))
timeStr := ""
if !m.currentTime.IsZero() {
timeStr = m.currentTime.Format("15:04:05")
}
dateStr := ""
if !m.currentTime.IsZero() {
dateStr = m.currentTime.Format("02/01/2006")
}
rightInfo := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center,
lipgloss.NewStyle().Foreground(textDim).Render(dateStr+" "),
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(timeStr),
lipgloss.NewStyle().Foreground(textMuted).Render(" "+getAnimFrame(m.animationFrame)),
),
)
statusDots := ""
if m.config != nil {
hasAI := false
for _, p := range m.config.AI.Providers {
if p.Active && p.APIKey != "" {
hasAI = true
break
}
}
if hasAI {
statusDots += lipgloss.NewStyle().Foreground(successGreen).Render("●")
} else {
statusDots += lipgloss.NewStyle().Foreground(errorRed).Render("●")
}
} else {
statusDots += lipgloss.NewStyle().Foreground(warnAmber).Render("●")
}
statusDots += lipgloss.NewStyle().Foreground(textMuted).Render(" ")
if m.mcpConfigured {
statusDots += lipgloss.NewStyle().Foreground(successGreen).Render("●")
} else {
statusDots += lipgloss.NewStyle().Foreground(warnAmber).Render("●")
}
statusInfo := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center,
lipgloss.NewStyle().Foreground(textDim).Render("SYS "),
statusDots,
),
)
badge := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("MUYUE")
versionBadge := lipgloss.NewStyle().Foreground(dimRed).Render("v" + version.Version)
logoLine := lipgloss.NewStyle().Background(bgVoid).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center, badge, " ", versionBadge),
)
topLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
logoLine,
strings.Repeat(" ", max(0, m.width-lipgloss.Width(logoLine)-lipgloss.Width(rightInfo)-lipgloss.Width(statusInfo))),
statusInfo,
rightInfo,
)
return lipgloss.JoinVertical(lipgloss.Left, topLine, tabLine)
}
func (m Model) renderTabMenuOverlay() string {
menuStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Background(bgCard).
Padding(1, 3)
tabItemStyle := lipgloss.NewStyle().
Foreground(textDim).
Padding(0, 2)
tabItemActiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(cyberRed).
Bold(true).
Padding(0, 2)
descs := []string{
"tools, updates & system status",
"chat, agents & workflows",
"terminal + AI assistant",
"profile, API keys & settings",
}
var items []string
for i, name := range tabNames {
num := lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %d.", i+1))
icon := tabIcons[i] + " "
if i == m.tabMenuCursor {
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(cyberRose).Render(descs[i]))
items = append(items, tabItemActiveStyle.Render(">"+item))
} else {
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(textMuted).Render(descs[i]))
items = append(items, tabItemStyle.Render(" "+item))
}
}
header := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("SWITCH TAB")
content := header + "\n\n" +
strings.Join(items, "\n") +
"\n\n" +
lipgloss.NewStyle().Foreground(textMuted).Render("up/down navigate | enter select | esc cancel")
box := menuStyle.Render(content)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
box,
lipgloss.WithWhitespaceBackground(bgVoid),
lipgloss.WithWhitespaceForeground(textMuted),
)
}
func (m Model) renderQuitOverlay() string {
yesStyle := confirmNoStyle
noStyle := confirmYesStyle
if m.confirmCursor == 0 {
yesStyle = confirmYesStyle
noStyle = confirmNoStyle
}
frame := lipgloss.NewStyle().Foreground(cyberRed).Render(getAnimFrame(m.animationFrame))
box := fmt.Sprintf("\n\n %s Quit muyue?\n\n %s %s",
frame,
yesStyle.Render("[ Yes ]"),
noStyle.Render("[ No ]"),
)
content := confirmBoxStyle.Render(box)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
content,
lipgloss.WithWhitespaceBackground(bgVoid),
lipgloss.WithWhitespaceForeground(textMuted),
)
}

View File

@@ -1,9 +1,17 @@
package tui
import (
"regexp"
"github.com/muyue/muyue/internal/workflow"
)
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func extractVersion(s string) string {
return versionRegex.FindString(s)
}
type previewFile = workflow.PreviewFile
func parsePreviewFiles(response string) []previewFile {

View File

@@ -22,7 +22,6 @@ import (
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/version"
)
func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
@@ -31,7 +30,6 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
d := daemon.NewDaemon(cfg, 1*time.Hour)
lspServers := lsp.ScanServers()
skillList, _ := skills.List()
mcpConfigured := false
@@ -45,19 +43,19 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(baseColor)
sp.Style = lipgloss.NewStyle().Foreground(cyberRed)
prog := progress.New(progress.WithGradient("#FF6B9D", "#A0D2FF"))
prog := progress.New(progress.WithGradient("#FF0033", "#FF1A5E"))
cwd, _ := os.Getwd()
return Model{
config: cfg,
scanResult: scan,
activeTab: tabDashboard,
config: cfg,
scanResult: scan,
activeTab: tabDashboard,
chatLog: []string{
aiMsgStyle.Render("muyue: Welcome! I'm your AI development environment assistant."),
aiMsgStyle.Render("muyue: Type /plan <goal> to start a structured workflow, or just chat."),
aiMsgStyle.Render(" >> Welcome to Studio! Chat with your AI assistant here."),
aiMsgStyle.Render(" >> Configure agents and workflows from the sidebar. Type /plan <goal> to start."),
},
orch: orch,
proxyMgr: proxyMgr,
@@ -75,11 +73,34 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
showingTabMenu: false,
tabMenuCursor: 0,
termCwd: cwd,
studioPanel: panelChat,
studioSidebarOpen: true,
termAIChat: []string{
aiMsgStyle.Render(" >> I know your system inside out. Ask me anything."),
},
termAIShow: true,
configSection: configProfile,
configField: 0,
animationFrame: 0,
currentTime: time.Now(),
transition: transitionNone,
}
}
func animTick() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return animTickMsg{time: t}
})
}
func clockTick() tea.Cmd {
return tea.Tick(1*time.Second, func(t time.Time) tea.Msg {
return clockTickMsg{time: t}
})
}
func (m Model) Init() tea.Cmd {
return tea.Batch(spinner.Tick, tea.EnterAltScreen)
return tea.Batch(spinner.Tick, animTick(), clockTick(), tea.EnterAltScreen)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -90,43 +111,85 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case animTickMsg:
m.animationFrame++
if m.transition == transitionGlitch {
m.transitionTick++
if m.transitionTick > 5 {
m.transition = transitionScan
m.transitionTick = 0
}
} else if m.transition == transitionScan {
m.transitionTick++
if m.transitionTick > 8 {
m.transition = transitionTypewriter
m.transitionTick = 0
m.typewriterBuf = m.renderContent()
m.typewriterPos = 0
}
} else if m.transition == transitionTypewriter {
m.typewriterPos += 3
if m.typewriterPos >= len(m.typewriterBuf) {
m.transition = transitionNone
}
}
return m, animTick()
case clockTickMsg:
m.currentTime = msg.time
return m, clockTick()
case progress.FrameMsg:
pm, cmd := m.progressBar.Update(msg)
m.progressBar = pm.(progress.Model)
return m, cmd
case termOutputMsg:
m.termLog = append(m.termLog, msg.line)
if m.activeTab == tabTerminal {
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
return m, nil
case termExitMsg:
m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(mutedColor).Render("(process exited)"))
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render("(process exited)"))
m.termCmd = nil
if m.activeTab == tabTerminal {
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
}
return m, nil
case aiResponseMsg:
m.chatLoading = false
m.termAILoading = false
content := msg.content
m.chatLog = append(m.chatLog, aiMsgStyle.Render("muyue: "+content))
if m.orch != nil && m.orch.Workflow != nil {
previewFiles := parsePreviewFiles(content)
if len(previewFiles) > 0 {
m.handlePreview(previewFiles)
if m.activeTab == tabShell && m.termAIShow {
m.termAIChat = append(m.termAIChat, aiMsgStyle.Render(" "+content))
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
} else {
m.chatLog = append(m.chatLog, aiMsgStyle.Render(" "+content))
if m.orch != nil && m.orch.Workflow != nil {
previewFiles := parsePreviewFiles(content)
if len(previewFiles) > 0 {
m.handlePreview(previewFiles)
}
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
case aiErrMsg:
m.chatLoading = false
m.chatLog = append(m.chatLog, errMsgStyle.Render("error: "+msg.err.Error()))
m.termAILoading = false
errText := errMsgStyle.Render(" [ERROR] " + msg.err.Error())
if m.activeTab == tabShell && m.termAIShow {
m.termAIChat = append(m.termAIChat, errText)
} else {
m.chatLog = append(m.chatLog, errText)
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
@@ -139,7 +202,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
for _, r := range msg.results {
status := itemOKStyle.Render("[OK]")
if !r.Success {
status = itemMissingStyle.Render("[FAIL]")
status = itemMissingStyle.Render("[--]")
}
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message))
}
@@ -159,7 +222,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case installBatchMsg:
status := itemOKStyle.Render("[OK]")
if !msg.result.Success {
status = itemMissingStyle.Render("[FAIL]")
status = itemMissingStyle.Render("[--]")
}
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message))
m.installCurrent = msg.index + 1
@@ -200,10 +263,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.helpModel.Width = msg.Width
headerH := 1
headerH := 2
footerH := 2
inputH := 0
if m.activeTab == tabChat || m.activeTab == tabWorkflow || m.activeTab == tabTerminal {
if m.activeTab == tabStudio || m.activeTab == tabShell {
inputH = 2
}
contentH := msg.Height - headerH - footerH - inputH
@@ -223,7 +286,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) View() string {
if !m.ready {
return "Loading..."
return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("Initializing muyue...")
}
if m.showingQuit {
@@ -234,17 +297,43 @@ func (m Model) View() string {
return m.renderTabMenuOverlay()
}
if m.transition == transitionGlitch {
return renderGlitchEffect(m.width, m.height, m.transitionTick)
}
if m.transition == transitionScan {
return renderScanEffect(m.width, m.height, m.transitionTick)
}
if m.transition == transitionTypewriter {
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(typewriterRender(m.typewriterBuf, m.typewriterPos))
if m.activeTab == tabStudio {
b.WriteString("\n")
b.WriteString(m.renderStudioInput())
}
if m.activeTab == tabShell {
b.WriteString("\n")
b.WriteString(m.renderShellInput())
}
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(m.viewport.View())
if m.activeTab == tabChat || m.activeTab == tabWorkflow {
if m.activeTab == tabStudio {
b.WriteString("\n")
b.WriteString(m.renderChatInput())
b.WriteString(m.renderStudioInput())
}
if m.activeTab == tabTerminal {
if m.activeTab == tabShell {
b.WriteString("\n")
b.WriteString(m.renderTermInput())
b.WriteString(m.renderShellInput())
}
b.WriteString("\n")
b.WriteString(m.renderFooter())
@@ -252,40 +341,14 @@ func (m Model) View() string {
return b.String()
}
func (m Model) renderHeader() string {
logoStyle := lipgloss.NewStyle().Foreground(baseColor).Bold(true)
badgeStyle := lipgloss.NewStyle().
Background(baseColor).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1)
logo := logoStyle.Render("muyue")
badge := badgeStyle.Render("v" + version.Version)
activeTabName := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render(tabNames[m.activeTab])
separator := lipgloss.NewStyle().Foreground(dimColor).Render(" · ")
rightPart := separator + activeTabName
line := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", badge, rightPart),
)
return line
}
func (m Model) renderContent() string {
switch m.activeTab {
case tabDashboard:
return m.renderDashboard()
case tabChat:
return m.renderChat()
case tabWorkflow:
return m.renderWorkflow()
case tabTerminal:
return m.renderTerminal()
case tabAgents:
return m.renderAgents()
case tabStudio:
return m.renderStudio()
case tabShell:
return m.renderShell()
case tabConfig:
return m.renderConfig()
default:
@@ -294,10 +357,10 @@ func (m Model) renderContent() string {
}
func (m *Model) resizeViewport() {
headerH := 1
headerH := 2
footerH := 2
inputH := 0
if m.activeTab == tabChat || m.activeTab == tabWorkflow || m.activeTab == tabTerminal {
if m.activeTab == tabStudio || m.activeTab == tabShell {
inputH = 2
}
contentH := m.height - headerH - footerH - inputH
@@ -310,120 +373,6 @@ func (m *Model) resizeViewport() {
m.viewport.SetContent(m.renderContent())
}
func (m Model) renderFooter() string {
profile := "unknown"
if m.config != nil && m.config.Profile.Pseudo != "" {
profile = m.config.Profile.Pseudo
}
left := fmt.Sprintf(" %s@%s", profile, version.Name)
leftR := statusBarStyle.Render(left)
var helpText string
switch m.activeTab {
case tabDashboard:
helpText = "[i] install [u] update [s] scan"
case tabChat, tabWorkflow:
helpText = "[ctrl+t] switch tab [ctrl+c] quit"
case tabTerminal:
helpText = "[enter] run [ctrl+c] kill [clear] clear"
case tabAgents:
helpText = "[c] crush [l] claude"
default:
helpText = "[ctrl+t] switch tab [ctrl+c] quit"
}
rightR := statusBarStyle.Render(helpText)
gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR)
if gap < 0 {
gap = 0
}
statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
leftR,
strings.Repeat(" ", gap),
rightR,
)
return lipgloss.JoinVertical(lipgloss.Left, statusLine,
lipgloss.NewStyle().Foreground(dimColor).Render(
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys))))
}
func (m Model) renderQuitOverlay() string {
yesStyle := confirmNoStyle
noStyle := confirmYesStyle
if m.confirmCursor == 0 {
yesStyle = confirmYesStyle
noStyle = confirmNoStyle
}
box := fmt.Sprintf("\n\n Quit muyue?\n\n %s %s",
yesStyle.Render("[ Yes ]"),
noStyle.Render("[ No ]"),
)
content := confirmBoxStyle.Render(box)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
content,
lipgloss.WithWhitespaceBackground(bgDark),
lipgloss.WithWhitespaceForeground(dimColor),
)
}
func (m Model) renderTabMenuOverlay() string {
tabMenuStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(baseColor).
Background(bgCard).
Padding(1, 3)
tabItemStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#A0A0B0")).
Padding(0, 2)
tabItemActiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(baseColor).
Bold(true).
Padding(0, 2)
tabNumStyle := lipgloss.NewStyle().
Foreground(dimColor).
Width(4)
var items []string
descs := []string{"system overview & tools", "AI chat & conversation", "plan & execute workflows", "integrated shell", "background AI agents", "profile & settings"}
for i, name := range tabNames {
num := tabNumStyle.Render(fmt.Sprintf(" %d.", i+1))
if i == m.tabMenuCursor {
item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(mutedColor).Render(descs[i]))
items = append(items, tabItemActiveStyle.Render(">"+item))
} else {
item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i]))
items = append(items, tabItemStyle.Render(" "+item))
}
}
content := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("Switch Tab") +
"\n\n" +
strings.Join(items, "\n") +
"\n\n" +
lipgloss.NewStyle().Foreground(dimColor).Render("up/down navigate enter/select esc cancel")
box := tabMenuStyle.Render(content)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
box,
lipgloss.WithWhitespaceBackground(bgPanel),
lipgloss.WithWhitespaceForeground(dimColor),
)
}
func (m *Model) handlePreview(files []previewFile) {
dir := filepath.Join(os.TempDir(), "muyue-preview")
os.RemoveAll(dir)
@@ -438,9 +387,9 @@ func (m *Model) handlePreview(files []previewFile) {
}
m.previewSrv = preview.NewPreviewServer(dir)
if err := m.previewSrv.Start(8765); err != nil {
m.chatLog = append(m.chatLog, errMsgStyle.Render("preview error: "+err.Error()))
m.chatLog = append(m.chatLog, errMsgStyle.Render(" preview error: "+err.Error()))
} else {
m.previewURL = "http://127.0.0.1:8765"
m.chatLog = append(m.chatLog, itemOKStyle.Render("Preview opened in browser: http://127.0.0.1:8765"))
m.chatLog = append(m.chatLog, itemOKStyle.Render(" Preview: http://127.0.0.1:8765"))
}
}

253
internal/tui/studio.go Normal file
View File

@@ -0,0 +1,253 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/workflow"
)
func (m Model) renderStudio() string {
if m.studioSidebarOpen {
sidebarWidth := 28
chatWidth := m.width - sidebarWidth - 2
if chatWidth < 20 {
chatWidth = 20
sidebarWidth = m.width - chatWidth - 2
}
sidebar := m.renderStudioSidebar(sidebarWidth)
chat := m.renderStudioChat(chatWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, chat)
}
return m.renderStudioChat(m.width)
}
func (m Model) renderStudioSidebar(width int) string {
var b strings.Builder
b.WriteString(renderSectionHeader("STUDIO", "[<>]"))
b.WriteString("\n\n")
panels := []struct {
name string
panel studioPanel
icon string
}{
{"Chat", panelChat, "[#]"},
{"Agents", panelAgents, "[*]"},
{"Workflows", panelWorkflows, "[~]"},
}
for _, p := range panels {
if m.studioPanel == p.panel {
activeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(cyberRed).
Bold(true).
Padding(0, 1)
b.WriteString(activeStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
} else {
inactiveStyle := lipgloss.NewStyle().
Foreground(textDim).
Padding(0, 1)
b.WriteString(inactiveStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", width-4)))
b.WriteString("\n\n")
switch m.studioPanel {
case panelAgents:
m.renderAgentsSidebar(&b, width)
case panelWorkflows:
m.renderWorkflowSidebar(&b, width)
default:
m.renderChatSidebar(&b, width)
}
return sidebarStyle.Width(width).Render(b.String())
}
func (m Model) renderChatSidebar(b *strings.Builder, width int) {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Active Provider"))
b.WriteString("\n")
provider := "none"
if m.config != nil {
provider = m.config.Profile.Preferences.DefaultAI
}
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" " + provider))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Commands"))
b.WriteString("\n")
cmds := []string{"/plan <goal>", "/help"}
for _, c := range cmds {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(" " + c))
b.WriteString("\n")
}
if m.previewURL != "" {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Preview"))
b.WriteString("\n")
b.WriteString(itemOKStyle.Render(" " + m.previewURL))
b.WriteString("\n")
}
}
func (m Model) renderAgentsSidebar(b *strings.Builder, width int) {
agents := []struct {
name string
agentType proxy.AgentType
tool string
}{
{"Crush", proxy.AgentCrush, "GLM"},
{"Claude Code", proxy.AgentClaude, "Anthropic"},
}
for _, a := range agents {
status, _ := m.proxyMgr.Status(a.agentType)
available := m.proxyMgr.IsAvailable(a.agentType)
var statusIcon string
switch status {
case proxy.StatusRunning:
statusIcon = lipgloss.NewStyle().Foreground(neonRed).Render("[>> running]")
case proxy.StatusStopped:
statusIcon = lipgloss.NewStyle().Foreground(textMuted).Render("[|| stopped]")
case proxy.StatusError:
statusIcon = lipgloss.NewStyle().Foreground(errorRed).Render("[!! error]")
default:
if available {
statusIcon = lipgloss.NewStyle().Foreground(successGreen).Render("[OK available]")
} else {
statusIcon = lipgloss.NewStyle().Foreground(textMuted).Render("[-- not installed]")
}
}
b.WriteString(lipgloss.NewStyle().Foreground(textBright).Bold(true).Render(a.name))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", statusIcon))
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %s\n", a.tool)))
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Actions"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" [c]"))
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(" Start Crush"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" [l]"))
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(" Start Claude"))
b.WriteString("\n")
}
func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
if m.orch == nil || m.orch.Workflow == nil {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("No active workflow."))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render("Use /plan <goal> in chat"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render("to start a workflow."))
b.WriteString("\n")
return
}
wf := m.orch.Workflow
phaseColors := map[workflow.Phase]lipgloss.Style{
workflow.PhaseIdle: lipgloss.NewStyle().Foreground(textMuted),
workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warnAmber).Bold(true),
workflow.PhasePlanning: lipgloss.NewStyle().Foreground(cyberPink).Bold(true),
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(cyberRose).Bold(true),
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(cyberRed).Bold(true),
workflow.PhaseDone: lipgloss.NewStyle().Foreground(successGreen).Bold(true),
workflow.PhaseError: lipgloss.NewStyle().Foreground(errorRed).Bold(true),
}
if style, ok := phaseColors[wf.Phase]; ok {
b.WriteString(style.Render(string(wf.Phase)))
}
b.WriteString("\n\n")
if wf.Plan.Goal != "" {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Goal"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(wf.Plan.Goal))
b.WriteString("\n\n")
}
if wf.Phase == workflow.PhaseExecuting {
done, total := wf.Progress()
m.progressBar.SetPercent(float64(done) / float64(max(total, 1)))
b.WriteString(m.progressBar.View())
b.WriteString(fmt.Sprintf(" %d/%d", done, total))
b.WriteString("\n\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Controls"))
b.WriteString("\n")
controls := []struct {
key string
desc string
}{
{"[a]", "Approve plan"},
{"[r]", "Reject plan"},
{"[g]", "Generate plan"},
{"[n]", "Next step"},
{"[x]", "Cancel"},
}
for _, c := range controls {
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" "+c.key))
b.WriteString(lipgloss.NewStyle().Foreground(textDim).Render(" "+c.desc))
b.WriteString("\n")
}
}
func (m Model) renderStudioChat(width int) string {
var b strings.Builder
chatHeader := renderSectionHeader("CHAT", "[#]")
if m.chatLoading {
chatHeader += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warnAmber).Render("thinking...")
}
b.WriteString(chatHeader)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n\n")
for _, msg := range m.chatLog {
b.WriteString(msg)
b.WriteString("\n\n")
}
return b.String()
}
func (m Model) renderStudioInput() string {
if m.chatLoading {
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render(">> ") + m.spinner.View() + lipgloss.NewStyle().Foreground(textMuted).Render(" thinking..."),
)
}
cursor := lipgloss.NewStyle().Foreground(cyberRed).Render("▎")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render(">> ") + m.chatInput + cursor,
)
}
func (m Model) handleStudioPanelSwitch(panel studioPanel) {
m.studioPanel = panel
m.viewport.SetContent(m.renderContent())
}

View File

@@ -5,76 +5,192 @@ import (
)
var (
baseColor = lipgloss.Color("#FF6B9D")
accentColor = lipgloss.Color("#A0D2FF")
aiColor = lipgloss.Color("#C4B5FD")
successColor = lipgloss.Color("#4ADE80")
warningColor = lipgloss.Color("#FBBF24")
errorColor = lipgloss.Color("#FF6B6B")
mutedColor = lipgloss.Color("#666680")
dimColor = lipgloss.Color("#444460")
bgDark = lipgloss.Color("#1A1A2E")
bgPanel = lipgloss.Color("#16213E")
bgCard = lipgloss.Color("#1F2937")
cyberRed = lipgloss.Color("#FF0033")
cyberRedDark = lipgloss.Color("#8B0020")
cyberRedDeep = lipgloss.Color("#5C0015")
cyberPink = lipgloss.Color("#FF1A5E")
cyberRose = lipgloss.Color("#FF4D6D")
neonRed = lipgloss.Color("#FF1744")
brightRed = lipgloss.Color("#FF5252")
dimRed = lipgloss.Color("#6B2033")
mutedRed = lipgloss.Color("#4A1525")
sectionStyle = lipgloss.NewStyle().
Foreground(accentColor).
textBright = lipgloss.Color("#EAE0E2")
textMain = lipgloss.Color("#D4C4C8")
textDim = lipgloss.Color("#8A7A7E")
textMuted = lipgloss.Color("#5A4F52")
successGreen = lipgloss.Color("#00E676")
warnAmber = lipgloss.Color("#FFD740")
errorRed = lipgloss.Color("#FF1744")
bgVoid = lipgloss.Color("#0A0A0C")
bgBase = lipgloss.Color("#0F0D10")
bgSurface = lipgloss.Color("#161218")
bgPanel = lipgloss.Color("#1C1719")
bgCard = lipgloss.Color("#221B1E")
bgInput = lipgloss.Color("#2A2225")
borderDim = lipgloss.Color("#2A1F22")
borderRed = lipgloss.Color("#FF003344")
borderRedFull = lipgloss.Color("#FF0033")
)
var (
baseStyle = lipgloss.NewStyle()
titleBlockStyle = lipgloss.NewStyle().
Foreground(cyberRed).
Bold(true)
itemOKStyle = lipgloss.NewStyle().
Foreground(successColor)
sectionTitleStyle = lipgloss.NewStyle().
Foreground(cyberRed).
Bold(true)
itemMissingStyle = lipgloss.NewStyle().
Foreground(errorColor)
labelStyle = lipgloss.NewStyle().
Foreground(textDim).
Width(14)
itemWarnStyle = lipgloss.NewStyle().
Foreground(warningColor)
valueStyle = lipgloss.NewStyle().
Foreground(textMain)
itemPendingStyle = lipgloss.NewStyle().
Foreground(mutedColor)
cardStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(borderDim).
Padding(0, 1)
userMsgStyle = lipgloss.NewStyle().
Foreground(accentColor)
cardActiveStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Padding(0, 1)
aiMsgStyle = lipgloss.NewStyle().
Foreground(aiColor)
errMsgStyle = lipgloss.NewStyle().
Foreground(errorColor)
inputStyle = lipgloss.NewStyle().
Foreground(baseColor)
stepDoneStyle = lipgloss.NewStyle().
Foreground(successColor)
stepPendingStyle = lipgloss.NewStyle().
Foreground(mutedColor)
stepCurrentStyle = lipgloss.NewStyle().
Foreground(baseColor).
Bold(true)
stepErrorStyle = lipgloss.NewStyle().
Foreground(errorColor)
sidebarStyle = lipgloss.NewStyle().
Background(bgSurface).
Border(lipgloss.Border{Right: "│"}).
BorderForeground(borderDim).
Padding(0, 1)
statusBarStyle = lipgloss.NewStyle().
Background(bgDark).
Foreground(lipgloss.Color("#A0A0B0")).
Background(bgSurface).
Foreground(textDim).
Padding(0, 1)
inputStyle = lipgloss.NewStyle().
Foreground(cyberRed)
userMsgStyle = lipgloss.NewStyle().
Foreground(cyberRose)
aiMsgStyle = lipgloss.NewStyle().
Foreground(textMain)
errMsgStyle = lipgloss.NewStyle().
Foreground(errorRed)
itemOKStyle = lipgloss.NewStyle().Foreground(successGreen)
itemMissingStyle = lipgloss.NewStyle().Foreground(errorRed)
itemWarnStyle = lipgloss.NewStyle().Foreground(warnAmber)
itemPendingStyle = lipgloss.NewStyle().Foreground(textMuted)
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(baseColor).
BorderForeground(cyberRed).
Background(bgCard).
Foreground(lipgloss.Color("#FFFFFF")).
Foreground(textBright).
Padding(1, 3).
Bold(true)
confirmYesStyle = lipgloss.NewStyle().
Foreground(successColor).
confirmYesStyle = lipgloss.NewStyle().Foreground(successGreen).Bold(true)
confirmNoStyle = lipgloss.NewStyle().Foreground(textMuted)
badgeStyle = lipgloss.NewStyle().
Background(cyberRed).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Bold(true)
confirmNoStyle = lipgloss.NewStyle().
Foreground(mutedColor)
tabBarStyle = lipgloss.NewStyle().Background(bgSurface)
stepDoneStyle = lipgloss.NewStyle().Foreground(successGreen)
stepPendingStyle = lipgloss.NewStyle().Foreground(textMuted)
stepCurrentStyle = lipgloss.NewStyle().Foreground(cyberRed).Bold(true)
stepErrorStyle = lipgloss.NewStyle().Foreground(errorRed)
)
var logoLines = []string{
"███╗ ███╗██╗ ██╗ █████╗ ███╗ ██╗███████╗",
"████╗ ████║╚██╗ ██╔╝██╔══██╗████╗ ██║██╔════╝",
"██╔████╔██║ ╚████╔╝ ███████║██╔██╗ ██║███████╗",
"██║╚██╔╝██║ ╚██╔╝ ██╔══██║██║╚██╗██║╚════██║",
"██║ ╚═╝ ██║ ██║ ██║ ██║██║ ╚████║███████║",
"╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝",
}
var scanFrames = []string{
"─────────────────────────── ───",
" ─────────────────────────── ─── ",
"── ──────────────────────────── ",
"─ ─── ────────────────────────────",
"─── ─────────────────────────── ─",
" ──── ─────────────────────────────",
}
func getAnimFrame(frame int) string {
frames := []string{
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
}
return frames[frame%len(frames)]
}
func getScanFrame(frame int) string {
return scanFrames[frame%len(scanFrames)]
}
func renderLogo() string {
styled := make([]string, len(logoLines))
for i, line := range logoLines {
styled[i] = lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(line)
}
return lipgloss.JoinVertical(lipgloss.Left, styled...)
}
func renderBlockTitle(text string) string {
width := len(text) + 6
top := lipgloss.NewStyle().Foreground(dimRed).Render(
"╭" + repeatStr("─", width) + "╮",
)
content := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
"│ ■ " + text + " ■ │",
)
bottom := lipgloss.NewStyle().Foreground(dimRed).Render(
"╰" + repeatStr("─", width) + "╯",
)
return lipgloss.JoinVertical(lipgloss.Left, top, content, bottom)
}
func renderSectionHeader(title string, icon string) string {
return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
"■ "+icon+" "+title+" ■",
)
}
func renderProgressBar(pct float64, width int) string {
filled := int(float64(width) * pct)
empty := width - filled
bar := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
repeatStr("█", filled),
) + lipgloss.NewStyle().Foreground(dimRed).Render(
repeatStr("░", empty),
)
return bar
}
func repeatStr(s string, n int) string {
result := ""
for i := 0; i < n; i++ {
result += s
}
return result
}

View File

@@ -33,13 +33,91 @@ func isDangerousCommand(input string) bool {
return false
}
func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) renderShell() string {
if m.termAIShow {
aiWidth := 36
termWidth := m.width - aiWidth - 2
if termWidth < 20 {
termWidth = 20
aiWidth = m.width - termWidth - 2
}
termPanel := m.renderTermPanel(termWidth)
aiPanel := m.renderAIPanel(aiWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, termPanel, aiPanel)
}
return m.renderTermPanel(m.width)
}
func (m Model) renderTermPanel(width int) string {
var b strings.Builder
cwdStyle := lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd)
b.WriteString(renderSectionHeader("TERMINAL", "[$]"))
b.WriteString(" ")
b.WriteString(cwdStyle)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n")
for _, line := range m.termLog {
b.WriteString(line + "\n")
}
return b.String()
}
func (m Model) renderAIPanel(width int) string {
var b strings.Builder
b.WriteString(renderSectionHeader("AI ASSISTANT", "[?]"))
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n\n")
for _, msg := range m.termAIChat {
b.WriteString(msg)
b.WriteString("\n\n")
}
if m.termAILoading {
b.WriteString(lipgloss.NewStyle().Foreground(neonRed).Render(" " + getAnimFrame(m.animationFrame) + " thinking..."))
b.WriteString("\n")
}
inputLabel := lipgloss.NewStyle().Foreground(cyberRed).Render(">> ")
b.WriteString(inputLabel)
b.WriteString(m.termAIInput)
return lipgloss.NewStyle().
Background(bgSurface).
Border(lipgloss.Border{Left: "│"}).
BorderForeground(borderDim).
Width(width).
Padding(0, 1).
Render(b.String())
}
func (m Model) renderShellInput() string {
prompt := lipgloss.NewStyle().Foreground(successGreen).Render("> ")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
prompt + m.termInput + lipgloss.NewStyle().Foreground(cyberRed).Render("▎"),
)
}
func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
if m.termCmd != nil && m.termCmd.Process != nil {
m.termCmd.Process.Kill()
m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorColor).Render("^C"))
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorRed).Render("^C"))
m.termCmd = nil
m.viewport.SetContent(m.renderContent())
return m, nil
@@ -58,6 +136,10 @@ func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showingTabMenu = true
m.tabMenuCursor = int(m.activeTab)
return m, nil
case "ctrl+a":
m.termAIShow = !m.termAIShow
m.viewport.SetContent(m.renderContent())
return m, nil
case "enter":
if m.termRunning {
return m, nil
@@ -76,7 +158,7 @@ func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if isDangerousCommand(input) {
m.termLog = append(m.termLog, errMsgStyle.Render("blocked: potentially dangerous command"))
m.termLog = append(m.termLog, errMsgStyle.Render(" [BLOCKED] potentially dangerous command"))
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
@@ -90,15 +172,15 @@ func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
if err := os.Chdir(dir); err == nil {
m.termCwd, _ = os.Getwd()
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input))
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd+" $ ")+input)
} else {
m.termLog = append(m.termLog, errMsgStyle.Render("cd: "+err.Error()))
m.termLog = append(m.termLog, errMsgStyle.Render(" cd: "+err.Error()))
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
}
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ ")+input)
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd+" $ ")+input)
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, m.runTermCommand(input)
@@ -132,23 +214,3 @@ func (m Model) runTermCommand(input string) tea.Cmd {
return termOutputMsg{line: string(out)}
})
}
func (m Model) renderTerminal() string {
var b strings.Builder
b.WriteString(sectionStyle.Render("Terminal"))
b.WriteString(" ")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd))
b.WriteString("\n\n")
for _, line := range m.termLog {
b.WriteString(line + "\n")
}
return b.String()
}
func (m Model) renderTermInput() string {
prompt := lipgloss.NewStyle().Foreground(successColor).Render("$ ")
return prompt + m.termInput + lipgloss.NewStyle().Foreground(baseColor).Render("")
}

View File

@@ -26,15 +26,14 @@ type tab int
const (
tabDashboard tab = iota
tabChat
tabWorkflow
tabTerminal
tabAgents
tabStudio
tabShell
tabConfig
tabCount
)
var tabNames = []string{"Dashboard", "Chat", "Workflow", "Terminal", "Agents", "Config"}
var tabNames = []string{"DASH", "STUDIO", "SHELL", "CONFIG"}
var tabIcons = []string{"[■]", "[<>]", "[>$]", "[//]"}
type aiResponseMsg struct{ content string }
type aiErrMsg struct{ err error }
@@ -61,20 +60,53 @@ type skillsListMsg struct{ skills []skills.Skill }
type spinnerTickMsg struct{ time time.Time }
type termOutputMsg struct{ line string }
type termExitMsg struct{}
type animTickMsg struct{ time time.Time }
type clockTickMsg struct{ time time.Time }
type glitchDoneMsg struct{}
type scanDoneMsg struct{}
type studioPanel int
const (
panelChat studioPanel = iota
panelAgents
panelWorkflows
)
type configSection int
const (
configProfile configSection = iota
configProviders
configTerminal
configSkills
)
type transitionState int
const (
transitionNone transitionState = iota
transitionGlitch
transitionScan
transitionTypewriter
)
type Model struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
activeTab tab
width int
height int
viewport viewport.Model
ready bool
chatInput string
chatLog []string
chatLoading bool
orch *orchestrator.Orchestrator
proxyMgr *proxy.Manager
config *config.MuyueConfig
scanResult *scanner.ScanResult
activeTab tab
prevTab tab
width int
height int
viewport viewport.Model
ready bool
chatInput string
chatLog []string
chatLoading bool
orch *orchestrator.Orchestrator
proxyMgr *proxy.Manager
updateStatus []updater.UpdateStatus
installLog []string
previewURL string
@@ -106,18 +138,32 @@ type Model struct {
termLog []string
termRunning bool
termCwd string
studioPanel studioPanel
studioSidebarOpen bool
termAIChat []string
termAIInput string
termAILoading bool
termAIShow bool
configSection configSection
configField int
animationFrame int
transition transitionState
transitionTick int
typewriterBuf string
typewriterPos int
currentTime time.Time
}
type keyMap struct {
Tab key.Binding
Prev key.Binding
Quit key.Binding
Confirm key.Binding
Cancel key.Binding
TabMenu key.Binding
Install key.Binding
Update key.Binding
Scan key.Binding
Enter key.Binding
Backspace key.Binding
}
@@ -125,39 +171,19 @@ type keyMap struct {
var keys = keyMap{
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "indent"),
key.WithHelp("tab", "next"),
),
Prev: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "unindent"),
key.WithHelp("shift+tab", "prev"),
),
Quit: key.NewBinding(
key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"),
),
Confirm: key.NewBinding(
key.WithKeys("y"),
key.WithHelp("y", "yes"),
),
Cancel: key.NewBinding(
key.WithKeys("n", "esc"),
key.WithHelp("n/esc", "no"),
),
TabMenu: key.NewBinding(
key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "switch tab"),
),
Install: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "install"),
),
Update: key.NewBinding(
key.WithKeys("u"),
key.WithHelp("u", "update"),
),
Scan: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "scan"),
key.WithHelp("ctrl+t", "tabs"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),

View File

@@ -1,213 +0,0 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/workflow"
)
func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.orch == nil || m.orch.Workflow == nil {
return m, nil
}
wf := m.orch.Workflow
switch msg.String() {
case "a":
if wf.Phase == workflow.PhaseReviewing {
m.chatLog = append(m.chatLog, userMsgStyle.Render("you: [Plan approved]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, reviewPlanCmd(m.orch, true, "")
}
case "r":
if wf.Phase == workflow.PhaseReviewing {
m.chatInput = ""
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Type your rejection feedback below:"))
m.viewport.SetContent(m.renderContent())
}
case "g":
if wf.Phase == workflow.PhaseGathering && len(wf.Plan.Questions) > 0 && len(wf.Plan.Answers) >= len(wf.Plan.Questions) {
m.chatLog = append(m.chatLog, userMsgStyle.Render("you: [Generate plan]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, generatePlanCmd(m.orch)
}
case "n":
if wf.Phase == workflow.PhaseExecuting {
current := wf.CurrentStep()
if current != nil {
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, continueWorkflowCmd(m.orch, "proceeding")
}
}
case "x":
wf.Reset()
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Workflow reset."))
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func (m Model) renderWorkflow() string {
var b strings.Builder
if m.orch == nil || m.orch.Workflow == nil {
b.WriteString("Workflow engine not available.")
return b.String()
}
wf := m.orch.Workflow
b.WriteString(sectionStyle.Render("Workflow"))
b.WriteString(" ")
phaseColors := map[workflow.Phase]lipgloss.Style{
workflow.PhaseIdle: lipgloss.NewStyle().Foreground(mutedColor),
workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warningColor).Bold(true),
workflow.PhasePlanning: lipgloss.NewStyle().Foreground(accentColor).Bold(true),
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(aiColor).Bold(true),
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(baseColor).Bold(true),
workflow.PhaseDone: lipgloss.NewStyle().Foreground(successColor).Bold(true),
workflow.PhaseError: lipgloss.NewStyle().Foreground(errorColor).Bold(true),
}
if style, ok := phaseColors[wf.Phase]; ok {
b.WriteString(style.Render(string(wf.Phase)))
}
b.WriteString("\n\n")
if wf.Plan.Goal != "" {
b.WriteString(fmt.Sprintf("Goal: %s\n\n", wf.Plan.Goal))
}
switch wf.Phase {
case workflow.PhaseIdle:
b.WriteString("No active workflow.\n")
b.WriteString("Type /plan <goal> to start a structured workflow.\n")
b.WriteString("Example: /plan Create a REST API in Go\n")
case workflow.PhaseGathering:
b.WriteString(sectionStyle.Render("Gathering Requirements"))
b.WriteString("\n")
for i, q := range wf.Plan.Questions {
icon := itemPendingStyle.Render(" ")
if i < len(wf.Plan.Answers) {
icon = itemOKStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q))
b.WriteString(fmt.Sprintf(" A: %s\n", wf.Plan.Answers[i]))
} else {
b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q))
}
}
if len(wf.Plan.Answers) >= len(wf.Plan.Questions) && len(wf.Plan.Questions) > 0 {
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[g] Generate plan"))
b.WriteString("\n")
}
case workflow.PhasePlanning:
b.WriteString(m.spinner.View())
b.WriteString(" ")
b.WriteString(itemWarnStyle.Render("Generating plan..."))
b.WriteString("\n")
case workflow.PhaseReviewing:
b.WriteString(sectionStyle.Render("Plan (review before execution)"))
b.WriteString("\n\n")
for i, s := range wf.Plan.Steps {
numStyle := lipgloss.NewStyle().Foreground(accentColor).Bold(true)
icon := stepPendingStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s %s %s\n", icon, numStyle.Render("#"+s.ID+":"), s.Title))
b.WriteString(fmt.Sprintf(" %s\n", lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Description)))
agentStyle := lipgloss.NewStyle().Foreground(aiColor).Render(s.Agent)
b.WriteString(fmt.Sprintf(" Agent: %s\n", agentStyle))
if i < len(wf.Plan.Steps)-1 {
b.WriteString("\n")
}
}
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[a] Approve plan"))
b.WriteString(" ")
b.WriteString(itemMissingStyle.Render("[r] Reject with feedback"))
b.WriteString("\n")
if len(wf.Plan.PreviewFiles) > 0 {
b.WriteString("\n ")
b.WriteString(itemWarnStyle.Render("Preview files available (opened in browser)"))
b.WriteString("\n")
}
case workflow.PhaseExecuting:
b.WriteString(sectionStyle.Render("Executing Plan"))
b.WriteString("\n\n")
done, total := wf.Progress()
m.progressBar.SetPercent(float64(done) / float64(max(total, 1)))
fmt.Fprintf(&b, " %s %d/%d\n\n", m.progressBar.View(), done, total)
for _, s := range wf.Plan.Steps {
var icon string
switch s.Status {
case "done":
icon = stepDoneStyle.Render(" ")
case "error":
icon = stepErrorStyle.Render(" ")
default:
if wf.Plan.Steps[wf.Plan.StepIndex].ID == s.ID {
icon = stepCurrentStyle.Render(">")
} else {
icon = stepPendingStyle.Render(" ")
}
}
b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title))
if s.Output != "" {
output := s.Output
if len(output) > 80 {
output = output[:80] + "..."
}
b.WriteString(fmt.Sprintf(" %s\n", output))
}
}
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[n] Next step"))
b.WriteString(" ")
b.WriteString(itemMissingStyle.Render("[x] Cancel workflow"))
b.WriteString("\n")
case workflow.PhaseDone:
b.WriteString(itemOKStyle.Render("Workflow completed!"))
b.WriteString("\n\n")
for _, s := range wf.Plan.Steps {
icon := stepDoneStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title))
}
b.WriteString("\n [x] Reset workflow\n")
case workflow.PhaseError:
b.WriteString(itemMissingStyle.Render("Workflow encountered an error."))
b.WriteString("\n [x] Reset workflow\n")
}
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-4, 10))))
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Chat"))
b.WriteString("\n")
for _, msg := range m.chatLog {
lines := strings.Split(msg, "\n")
for _, line := range lines {
if len(line) > m.width-4 {
line = line[:m.width-7] + "..."
}
b.WriteString(" " + line + "\n")
}
}
return b.String()
}

View File

@@ -2,7 +2,7 @@ package version
const (
Name = "muyue"
Version = "0.2.0"
Version = "0.2.1"
Author = "La Légion de Muyue"
License = "MIT"
)