diff --git a/app/main.go b/app/main.go index 05c2c8c..7798e71 100644 --- a/app/main.go +++ b/app/main.go @@ -2,6 +2,7 @@ package main import ( "log" + "runtime" tea "github.com/charmbracelet/bubbletea/v2" "github.com/spf13/cobra" @@ -13,14 +14,23 @@ var ( func main() { var rootCmd = &cobra.Command{ - Use: "gradient-engineer [flags] PLAYBOOK_NAME", + Use: "gradient-engineer [flags] [PLAYBOOK_NAME]", Short: "Run diagnostic playbooks using gradient engineer toolbox", Long: `Gradient Engineer runs diagnostic playbooks by downloading and executing toolbox commands. The toolbox is automatically downloaded from the specified repository based on your platform (OS and architecture).`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - playbookName := args[0] + var playbookName string + if len(args) > 0 { + playbookName = args[0] + } else { + if runtime.GOOS == "linux" { + playbookName = "60-second-linux" + } else { + playbookName = "60-second-darwin" + } + } // Create a new toolbox instance tb := NewToolbox(toolboxRepo, playbookName) diff --git a/app/ui.go b/app/ui.go index f2e5f2a..d0bec45 100644 --- a/app/ui.go +++ b/app/ui.go @@ -14,13 +14,13 @@ import ( ) var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) - headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")) // foreground black; background will be per-char gradient + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")) pendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) runningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) - footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Italic(true) + footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Italic(true) + descStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) ) // commandStatus represents the execution status of a diagnostic command. @@ -80,6 +80,12 @@ type model struct { done bool summarizer *Summarizer + + // Time it took to execute all commands (seconds), captured when summarization starts + execSeconds float64 + + // Request a one-time scroll to bottom after next SetContent in View + requestScrollToBottom bool } // NewModel constructs a model initialised with all diagnostic commands in a @@ -194,9 +200,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if allDone { if !m.summarizing && m.summary == "" { + m.execSeconds = time.Since(m.startTime).Seconds() + m.requestScrollToBottom = true // If summarizer is disabled (no API key), skip summarization and show a notice. if m.summarizer == nil || m.summarizer.disabled { - m.summaryNotice = "No API key provided; skipping AI summary." + m.summaryNotice = "No API key provided; skipping AI summary.\nSet the API key with OPENAI_API_KEY, OPENROUTER_API_KEY, or ANTHROPIC_API_KEY." return m, nil } m.summarizing = true @@ -276,6 +284,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *model) View() string { // Build the content string and assign it to the viewport. m.vp.SetContent(m.generateContent()) + if m.requestScrollToBottom { + m.vp.GotoBottom() + m.requestScrollToBottom = false + } return m.vp.View() } @@ -318,7 +330,16 @@ func (m *model) generateContent() string { default: lineStyle = pendingStyle } - cmdBuf.WriteString(lineStyle.Render(fmt.Sprintf("%s %s", icon, cmd.Display))) + // Render command and lighter description + cmdText := cmd.Command + if cmd.Spec != nil && strings.TrimSpace(cmd.Spec.Command) != "" { + cmdText = cmd.Spec.Command + } + line := lineStyle.Render(fmt.Sprintf("%s %s", icon, cmdText)) + if strings.TrimSpace(cmd.Display) != "" { + line += " " + descStyle.Render("— "+cmd.Display) + } + cmdBuf.WriteString(line) cmdBuf.WriteString("\n") if m.showDetails { @@ -338,23 +359,36 @@ func (m *model) generateContent() string { } // Strip final \n from cmdBuf if present - cmdStr := cmdBuf.String() - if strings.HasSuffix(cmdStr, "\n") { - cmdStr = cmdStr[:len(cmdStr)-1] - } + cmdStr := strings.TrimSuffix(cmdBuf.String(), "\n") cmdBuf.Reset() cmdBuf.WriteString(cmdStr) - // Border the commands with a title - sectionTitle := renderGradientHeader(" 60-second Linux analysis ", time.Since(m.startTime).Seconds()) - commandsBox := sectionTitle + "\n\n" + cmdBuf.String() + // Border the commands with a title (plain header, with spinner while running or a tick when finished) + headerTitle := titleStyle.Render("60-second Linux analysis") + var header string + if m.execSeconds == 0 { + header = fmt.Sprintf("%s %s", m.spin.View(), headerTitle) + } else { + header = fmt.Sprintf("%s %s %s", successStyle.Render(iconSuccess), headerTitle, descStyle.Render(fmt.Sprintf("(finished in %.1f seconds)", m.execSeconds))) + } + commandsBox := header + "\n\n" + cmdBuf.String() // Assemble full UI var b strings.Builder b.WriteString(generateBanner(time.Since(m.startTime).Seconds())) + + b.WriteString("\n") + b.WriteString(footerStyle.Render("Tab: toggle details; q: quit; up/down or mouse: scroll")) + b.WriteString("\n\n") b.WriteString(commandsBox) + // If we have finished executing commands, show the elapsed time above the summary section + if m.execSeconds > 0 { + b.WriteString("\n\n") + b.WriteString(successStyle.Render(fmt.Sprintf("Executing commands finished in %.1f seconds.", m.execSeconds))) + } + if m.summarizing { b.WriteString("\n\n") b.WriteString(runningStyle.Render(fmt.Sprintf("%s Summarizing results with AI…", m.spin.View()))) @@ -374,10 +408,6 @@ func (m *model) generateContent() string { b.WriteString(errorStyle.Render(fmt.Sprintf("LLM error: %v", m.summaryErr))) } - b.WriteString("\n\n") - b.WriteString(footerStyle.Render("Tab: toggle details q: quit")) - b.WriteString("\n") - return b.String() } @@ -392,24 +422,14 @@ func indent(text, prefix string) string { // ASCII banner lines for Gradient Engineer logo. var bannerLines = []string{ - " ░██ ░██ ░██ ", - " ░██ ░██ ", - " ░████████ ░██░████ ░██████ ░████████ ░██ ░███████ ░████████ ░████████ ", - "░██ ░██ ░███ ░██ ░██ ░██ ░██░██ ░██ ░██ ░██ ░██ ", - "░██ ░██ ░██ ░███████ ░██ ░██ ░██░█████████ ░██ ░██ ░██ ", - "░██ ░███ ░██ ░██ ░██ ░██ ░███ ░██░██ ░██ ░██ ░██ ", - " ░█████░██ ░██ ░█████░██ ░█████░██ ░██ ░███████ ░██ ░██ ░████ ", - " ░██ ", - " ░███████ ", - " ░██ ", - " ", - " ░███████ ░████████ ░████████ ░██░████████ ░███████ ░███████ ░██░████ ", - "░██ ░██ ░██ ░██ ░██ ░██ ░██░██ ░██ ░██ ░██ ░██ ░██ ░███ ", - "░█████████ ░██ ░██ ░██ ░██ ░██░██ ░██ ░█████████ ░█████████ ░██ ", - "░██ ░██ ░██ ░██ ░███ ░██░██ ░██ ░██ ░██ ░██ ", - " ░███████ ░██ ░██ ░█████░██ ░██░██ ░██ ░███████ ░███████ ░██ ", - " ░██ ", - " ░███████ ", + " ▗▄▄▖▗▄▄▖ ▗▄▖ ▗▄▄▄ ▗▄▄▄▖▗▄▄▄▖▗▖ ▗▖▗▄▄▄▖ ", + " ▐▌ ▐▌ ▐▌▐▌ ▐▌▐▌ █ █ ▐▌ ▐▛▚▖▐▌ █ ", + " ▐▌▝▜▌▐▛▀▚▖▐▛▀▜▌▐▌ █ █ ▐▛▀▀▘▐▌ ▝▜▌ █ ", + " ▝▚▄▞▘▐▌ ▐▌▐▌ ▐▌▐▙▄▄▀▗▄█▄▖▐▙▄▄▖▐▌ ▐▌ █ ", + " ▗▄▄▄▖▗▖ ▗▖ ▗▄▄▖▗▄▄▄▖▗▖ ▗▖▗▄▄▄▖▗▄▄▄▖▗▄▄▖ ", + " ▐▌ ▐▛▚▖▐▌▐▌ █ ▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌", + " ▐▛▀▀▘▐▌ ▝▜▌▐▌▝▜▌ █ ▐▌ ▝▜▌▐▛▀▀▘▐▛▀▀▘▐▛▀▚▖", + " ▐▙▄▄▖▐▌ ▐▌▝▚▄▞▘▗▄█▄▖▐▌ ▐▌▐▙▄▄▖▐▙▄▄▖▐▌ ▐▌", } // Convert HSV to RGB (0-360, 0-1, 0-1) output uint8 components. @@ -441,7 +461,9 @@ func hsvToRGB(h, s, v float64) (uint8, uint8, uint8) { func renderGradientHeader(text string, t float64) string { var b strings.Builder for i, ch := range text { - progress := math.Mod(float64(i)/100+t*0.1, 1.0) + progress := float64(i)/100 + t*-0.07 + progress += math.Ceil(math.Abs(progress)) + progress = math.Mod(progress, 1.0) hue := progress * 360.0 // lower brightness for less intense colors r, g, c := hsvToRGB(hue, 0.8, 0.5) @@ -459,7 +481,9 @@ func generateBanner(t float64) string { var b strings.Builder for _, line := range bannerLines { for i, ch := range line { - progress := math.Mod(float64(i)/float64(len(line))+t*0.10+0.5, 1.0) + progress := float64(i)/float64(len(line)) + t*-0.07 + 0.5 + progress += math.Ceil(math.Abs(progress)) + progress = math.Mod(progress, 1.0) hue := progress * 360.0 r, g, cc := hsvToRGB(hue, 1.0, 1.0) colorStr := fmt.Sprintf("#%02X%02X%02X", r, g, cc) diff --git a/playbook/60-second-darwin.yaml b/playbook/60-second-darwin.yaml index bf25165..45d4d51 100644 --- a/playbook/60-second-darwin.yaml +++ b/playbook/60-second-darwin.yaml @@ -12,24 +12,24 @@ system_prompt: | When recommending actions, prioritize practical steps. commands: - command: uptime - description: System uptime and load averages + description: System uptime, load averages - command: log show --style syslog --last 1m - description: Recent system log entries (last 1 minute) + description: Recent system log entries - command: vm_stat 1 - description: Virtual memory paging, 1s updates + description: Virtual memory paging - command: ps -M -o pid,%cpu,comm -r - description: Threads sorted by CPU to spot hot single-thread bottlenecks + description: Threads sorted by CPU - command: top -l 999 -s 1 -o cpu - description: Per-process CPU usage, 1s rolling view + description: Per-process CPU usage - command: iostat -d -w 1 - description: Disk I/O per device, 1s updates (KB/t, tps, MB/s) + description: Disk I/O per device - command: memory_pressure -Q - description: Memory pressure summary (free, purgeable, compressed) + description: Memory pressure summary - command: netstat -w 1 -i - description: Network device statistics for all interfaces, 1s updates + description: Network device statistics - command: netstat -s -p tcp - description: TCP health snapshot (retransmits, connection stats) + description: TCP health snapshot - command: top -l 1 - description: One-shot system snapshot (CPU breakdown, processes, PhysMem) + description: Top processes snapshot diff --git a/playbook/60-second-linux.yaml b/playbook/60-second-linux.yaml index d50c21a..5c7bfa1 100644 --- a/playbook/60-second-linux.yaml +++ b/playbook/60-second-linux.yaml @@ -19,21 +19,21 @@ system_prompt: | When recommending actions, prioritize practical steps. commands: - command: uptime - description: System uptime and load averages + description: System uptime, load averages - command: vmstat 1 - description: Virtual memory statistics, 1s updates + description: Virtual memory statistics - command: mpstat -P ALL 1 - description: CPU utilization per core, 1s + description: CPU utilization per core - command: pidstat 1 - description: Per-process CPU usage, 1s + description: Per-process CPU usage - command: iostat -xz 1 - description: Extended I/O statistics, 1s + description: Extended I/O statistics - command: free -m - description: Memory usage in MiB + description: Memory usage - command: sar -n DEV 1 - description: Network device statistics, 1s + description: Network device statistics - command: sar -n TCP,ETCP 1 - description: TCP counters and errors, 1s + description: TCP counters and errors - command: top -b -n 1 description: Top processes snapshot - command: dmesg