- 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>
210 lines
5.3 KiB
Go
210 lines
5.3 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(dimColor).Render(m.termCwd)
|
|
b.WriteString(renderSectionWithIcon("Terminal", "▶"))
|
|
b.WriteString(" ")
|
|
b.WriteString(cwdStyle)
|
|
b.WriteString("\n\n")
|
|
|
|
sep := lipgloss.NewStyle().Foreground(borderColor).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(renderSectionWithIcon("AI Assistant", "◈"))
|
|
b.WriteString("\n\n")
|
|
|
|
sep := lipgloss.NewStyle().Foreground(borderColor).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(warmColor).Render(" " + getAnimFrame(m.animationFrame) + " thinking..."))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
inputLabel := lipgloss.NewStyle().Foreground(roseColor).Render("⟩ ")
|
|
b.WriteString(inputLabel)
|
|
b.WriteString(m.termAIInput)
|
|
|
|
return lipgloss.NewStyle().
|
|
Background(bgPanel).
|
|
Border(lipgloss.Border{Left: "│"}).
|
|
BorderForeground(borderColor).
|
|
Width(width).
|
|
Padding(0, 1).
|
|
Render(b.String())
|
|
}
|
|
|
|
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.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(dimColor).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(dimColor).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)}
|
|
})
|
|
}
|