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