Files
MuyueWorkspace/internal/tui/terminal.go
Augustin ROUX cb8e3d0d26
Some checks failed
Stable Release / stable (push) Failing after 22s
feat: complete TUI redesign with cyberpunk theme (#1)
- 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

217 lines
5.5 KiB
Go

package tui
import (
"os"
"os/exec"
"regexp"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var dangerousPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|/)`),
regexp.MustCompile(`(?i)\bmkfs\b`),
regexp.MustCompile(`(?i)\bdd\s+if=`),
regexp.MustCompile(`(?i)\b(format\s+[A-Za-z]:)\b`),
regexp.MustCompile(`(?i):\(\)\{.*\}`),
regexp.MustCompile(`(?i)>(/dev/|/etc/|/boot/)`),
regexp.MustCompile(`(?i)\bshutdown\b`),
regexp.MustCompile(`(?i)\breboot\b`),
regexp.MustCompile(`(?i)\bhalt\b`),
regexp.MustCompile(`(?i)\bpoweroff\b`),
}
func isDangerousCommand(input string) bool {
for _, pat := range dangerousPatterns {
if pat.MatchString(input) {
return true
}
}
return false
}
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(errorRed).Render("^C"))
m.termCmd = nil
m.viewport.SetContent(m.renderContent())
return m, nil
}
now := time.Now()
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
return m, tea.Quit
}
m.ctrlCCount++
m.lastCtrlC = now
m.showingQuit = true
m.confirmCursor = 1
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+t":
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
}
input := strings.TrimSpace(m.termInput)
m.termInput = ""
if input == "" {
return m, nil
}
if input == "exit" || input == "quit" {
return m, nil
}
if input == "clear" {
m.termLog = nil
m.viewport.SetContent(m.renderContent())
return m, nil
}
if isDangerousCommand(input) {
m.termLog = append(m.termLog, errMsgStyle.Render(" [BLOCKED] potentially dangerous command"))
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
}
if strings.HasPrefix(input, "cd ") {
dir := strings.TrimPrefix(input, "cd ")
dir = strings.TrimSpace(dir)
if dir == "~" {
home, _ := os.UserHomeDir()
dir = home
}
if err := os.Chdir(dir); err == nil {
m.termCwd, _ = os.Getwd()
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.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
}
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)
case "backspace":
if len(m.termInput) > 0 {
m.termInput = m.termInput[:len(m.termInput)-1]
m.viewport.SetContent(m.renderContent())
}
return m, nil
default:
if len(msg.String()) == 1 {
m.termInput += msg.String()
m.viewport.SetContent(m.renderContent())
}
}
return m, nil
}
func (m Model) runTermCommand(input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
cmd := exec.Command(shell, "-c", input)
cmd.Dir = m.termCwd
out, err := cmd.CombinedOutput()
if err != nil {
return termOutputMsg{line: string(out) + errMsgStyle.Render(err.Error())}
}
return termOutputMsg{line: string(out)}
})
}