From 131334b826e84cf488bf72e3e8db4296c90ed825 Mon Sep 17 00:00:00 2001 From: Piotr Grabowski Date: Fri, 5 Sep 2025 15:18:28 +0200 Subject: [PATCH 1/6] app/ui: more descriptive error when API key not set Instead of just saying "No API key provided", the application now prints what environment variables are missing: > Set the API key with OPENAI_API_KEY, OPENROUTER_API_KEY, or ANTHROPIC_API_KEY. --- app/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui.go b/app/ui.go index f2e5f2a..6038b3a 100644 --- a/app/ui.go +++ b/app/ui.go @@ -196,7 +196,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.summarizing && m.summary == "" { // 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 From 37764e2e51c6a1018e40ce4d3453394dc1e7b4a3 Mon Sep 17 00:00:00 2001 From: Piotr Grabowski Date: Fri, 5 Sep 2025 15:19:17 +0200 Subject: [PATCH 2/6] app/ui: UI tweaks Change the footer to a lighter color, change the banner to smaller one (better for small terminals), adjust gradient speed to slower one, adjust the footer text. --- app/ui.go | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/app/ui.go b/app/ui.go index 6038b3a..f192de8 100644 --- a/app/ui.go +++ b/app/ui.go @@ -20,7 +20,7 @@ var ( 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) ) // commandStatus represents the execution status of a diagnostic command. @@ -352,7 +352,7 @@ func (m *model) generateContent() string { // Assemble full UI var b strings.Builder b.WriteString(generateBanner(time.Since(m.startTime).Seconds())) - b.WriteString("\n\n") + b.WriteString("\n") b.WriteString(commandsBox) if m.summarizing { @@ -375,7 +375,7 @@ func (m *model) generateContent() string { } b.WriteString("\n\n") - b.WriteString(footerStyle.Render("Tab: toggle details q: quit")) + b.WriteString(footerStyle.Render("Tab: toggle details; q: quit; up/down or mouse: scroll")) b.WriteString("\n") return b.String() @@ -392,24 +392,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 +431,7 @@ 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 := math.Mod(float64(i)/100+t*0.07, 1.0) hue := progress * 360.0 // lower brightness for less intense colors r, g, c := hsvToRGB(hue, 0.8, 0.5) @@ -459,7 +449,7 @@ 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 := math.Mod(float64(i)/float64(len(line))+t*0.07+0.5, 1.0) hue := progress * 360.0 r, g, cc := hsvToRGB(hue, 1.0, 1.0) colorStr := fmt.Sprintf("#%02X%02X%02X", r, g, cc) From 2d54ef3b048a00c10adc2a26da1ff9ca3caf0c2f Mon Sep 17 00:00:00 2001 From: Piotr Grabowski Date: Fri, 5 Sep 2025 15:28:33 +0200 Subject: [PATCH 3/6] app/main: make PLAYBOOK_NAME optional Previously PLAYBOOK_NAME was always required. Now, if the user doesn't provide that, it defaults to 60-second-linux (or 60-second-darwin on macOS). --- app/main.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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) From 1ec12b87c18f804e8c255d3c4e85ea7ee337ab96 Mon Sep 17 00:00:00 2001 From: Piotr Grabowski Date: Fri, 5 Sep 2025 15:29:26 +0200 Subject: [PATCH 4/6] app/ui: show both the command and description Previously, the UI showed only the description of the command, not the command itself. Now, it will print both the command and the description. Make the descriptions shorter to better fit in the new UI. --- app/ui.go | 12 +++++++++++- playbook/60-second-darwin.yaml | 20 ++++++++++---------- playbook/60-second-linux.yaml | 16 ++++++++-------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/app/ui.go b/app/ui.go index f192de8..22a127e 100644 --- a/app/ui.go +++ b/app/ui.go @@ -21,6 +21,7 @@ var ( successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) 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. @@ -318,7 +319,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 { 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 From ac27f96989f7fd8f9ad4508e8f023e5b202d5e31 Mon Sep 17 00:00:00 2001 From: Piotr Grabowski Date: Fri, 5 Sep 2025 15:54:40 +0200 Subject: [PATCH 5/6] app/ui: UI tweaks Make it scroll after all commands have finished executing - several users on small displays didn't notice that the commands have finished executing, because the summary was at the bottom (not visible), scrolling should fix that. Additionally, add explicit message "Executing commands finished" with running time. Additionally, add a spinner to the "60-second Linux analysis" header and remove the animated gradient from it (since it was too distracting). --- app/ui.go | 46 +++++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/app/ui.go b/app/ui.go index 22a127e..eb4c5b9 100644 --- a/app/ui.go +++ b/app/ui.go @@ -14,8 +14,7 @@ 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")) @@ -81,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 @@ -195,6 +200,8 @@ 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.\nSet the API key with OPENAI_API_KEY, OPENROUTER_API_KEY, or ANTHROPIC_API_KEY." @@ -277,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() } @@ -348,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()))) @@ -384,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; up/down or mouse: scroll")) - b.WriteString("\n") - return b.String() } From 3d4dcd021a4c8563d1af4d9108a4539a43540855 Mon Sep 17 00:00:00 2001 From: Piotr Grabowski Date: Fri, 5 Sep 2025 15:58:01 +0200 Subject: [PATCH 6/6] app/UI: make the gradient move in the opposite way Make the gradient move to the right, instead of to the left - it looks more pleasing. Unfortunately, it required a few more computations related to modular arithmetic on negative numbers. --- app/ui.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/ui.go b/app/ui.go index eb4c5b9..d0bec45 100644 --- a/app/ui.go +++ b/app/ui.go @@ -461,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.07, 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) @@ -479,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.07+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)