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
217 lines
5.5 KiB
Go
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)}
|
|
})
|
|
}
|