diff --git a/.cursor/rules/caveman.md b/.cursor/rules/caveman.md new file mode 100644 index 0000000..7cd917e --- /dev/null +++ b/.cursor/rules/caveman.md @@ -0,0 +1,21 @@ +--- +description: "Caveman mode — terse communication, ~75% fewer tokens, full technical accuracy" +alwaysApply: true +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +Rules: + +- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging +- Fragments OK. Short synonyms. Technical terms exact. Code unchanged. +- Pattern: [thing] [action] [reason]. [next step]. +- Not: "Sure! I'd be happy to help you with that." +- Yes: "Bug in auth middleware. Fix:" + +Switch level: /caveman lite|full|ultra|wenyan +Stop: "stop caveman" or "normal mode" + +Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after. + +Boundaries: code/commits/PRs written normal. diff --git a/.cursor/skills/caveman/SKILL.md b/.cursor/skills/caveman/SKILL.md new file mode 100644 index 0000000..33b4457 --- /dev/null +++ b/.cursor/skills/caveman/SKILL.md @@ -0,0 +1,72 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman | +| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" + +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop → new ref → re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。" +- wenyan-ultra: "新參照→重繪。useMemo Wrap。" + +Example — "Explain database connection pooling." + +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool = reuse DB conn. Skip handshake → fast under load." +- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。" +- wenyan-ultra: "池reuse conn。skip handshake → fast。" + +## Auto-Clarity + +Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done. + +Example — destructive op: + +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> +> ```sql +> DROP TABLE users; +> ``` +> +> Caveman resume. Verify backup exist first. + +## Boundaries + +Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. diff --git a/README.md b/README.md index 35fa59e..4b88370 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ repos: - owner/repo-b poll_interval_seconds: 20 group_by_repo: false +dashboard_view: auto # auto, full, compact theme: selected: auto # auto, neon-dark, neon-light, or a user theme id directory: ~/.config/github-butler/themes @@ -119,6 +120,7 @@ theme: | `repos` | list | `[]` | List of `owner/repo` slugs to track | | `poll_interval_seconds` | int | `20` | How often the app polls GitHub (min `2`, max `3600`) | | `group_by_repo` | bool | `false` | When `true`, PRs are grouped under per-repo section headers instead of one flat updated list | +| `dashboard_view` | str | `auto` | Dashboard layout: `auto`, `full`, or `compact` | | `theme.selected` | str | `auto` | Theme id to use (`auto`, `neon-dark`, `neon-light`, or a user theme filename without suffix) | | `theme.directory` | str | XDG dir | Directory scanned for `*.theme.yaml` user themes | | `theme.colors` | map | `{}` | Optional config-level color overrides applied after the selected theme | @@ -127,6 +129,12 @@ Repositories and settings are editable from inside the app (`m` → **Settings** On first launch with no config, the app opens to an empty dashboard; press `m` → Repositories → `a` to add one. +### Dashboard layouts + +`dashboard_view: auto` keeps the full table on wide terminals and switches to a compact reduced-column table on narrower screens or split panes. Use **Settings** → **Dashboard view** to pick `auto`, `full`, or `compact`. + +Compact mode stays one row per PR and keeps only `REPO`, `#`, `TITLE`, `TAGS`, and a dense `STATUS` summary. `STATUS` combines CI (`PASS`, `FAIL×2`, `RUN×1`), review/required state (`OK`, `CHG`, `REQ 1/2`, `PEND`), and unresolved threads (`UNRES 3` when non-zero). + ### Themes `auto` uses Lipgloss/termenv to detect whether your terminal background is dark or light, then chooses the built-in `neon-dark` or `neon-light` theme. Detection depends on terminal support, so you can force either built-in theme from **Settings** → **Theme**. diff --git a/internal/config/config.go b/internal/config/config.go index 4824efa..691fd33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,10 @@ const ( DefaultPollInterval = 20 * time.Second MinPollIntervalSeconds = 2 MaxPollIntervalSeconds = 3600 + + DashboardViewAuto = "auto" + DashboardViewFull = "full" + DashboardViewCompact = "compact" ) // Config is the user-facing configuration persisted as YAML. @@ -20,6 +24,7 @@ type Config struct { Repos []string `yaml:"repos"` PollIntervalSeconds int `yaml:"poll_interval_seconds"` GroupByRepo bool `yaml:"group_by_repo"` + DashboardView string `yaml:"dashboard_view"` Theme Theme `yaml:"theme"` // Path is the file this config was loaded from (or should be saved to). @@ -50,6 +55,7 @@ func Default() Config { return Config{ Repos: []string{}, PollIntervalSeconds: int(DefaultPollInterval / time.Second), + DashboardView: DashboardViewAuto, Theme: Theme{ Selected: "auto", Directory: themeDir, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5c5a940..f52670b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,12 +23,34 @@ func TestLoadDefaultsThemeConfig(t *testing.T) { if cfg.Theme.Selected != "auto" { t.Fatalf("Theme.Selected = %q, want auto", cfg.Theme.Selected) } + if cfg.DashboardView != DashboardViewAuto { + t.Fatalf("DashboardView = %q, want auto", cfg.DashboardView) + } wantDir := filepath.Join(xdg, "github-butler", "themes") if cfg.Theme.Directory != wantDir { t.Fatalf("Theme.Directory = %q, want %q", cfg.Theme.Directory, wantDir) } } +func TestValidateDashboardView(t *testing.T) { + cfg := Default() + for _, view := range []string{DashboardViewAuto, DashboardViewFull, DashboardViewCompact} { + cfg.DashboardView = view + if err := Validate(cfg); err != nil { + t.Fatalf("Validate(%q) error: %v", view, err) + } + } + + cfg.DashboardView = "tiny" + err := Validate(cfg) + if err == nil { + t.Fatal("Validate succeeded, want error") + } + if !strings.Contains(err.Error(), "dashboard_view") { + t.Fatalf("Validate error = %q, want dashboard_view path", err) + } +} + func TestValidateThemeColors(t *testing.T) { cfg := Default() cfg.Theme.Colors = map[string]string{"accent": "not-a-color"} diff --git a/internal/config/load.go b/internal/config/load.go index 3a6d5fe..4abb815 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -41,6 +41,9 @@ func Load(path string) (Config, error) { if cfg.Repos == nil { cfg.Repos = []string{} } + if cfg.DashboardView == "" { + cfg.DashboardView = DashboardViewAuto + } if cfg.Theme.Selected == "" { cfg.Theme.Selected = "auto" } diff --git a/internal/config/validate.go b/internal/config/validate.go index 9ef8918..67aafbb 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -34,6 +34,12 @@ func Validate(cfg Config) error { } seen[key] = struct{}{} } + switch cfg.DashboardView { + case "", DashboardViewAuto, DashboardViewFull, DashboardViewCompact: + default: + return fmt.Errorf("dashboard_view must be one of %q, %q, or %q (got %q)", + DashboardViewAuto, DashboardViewFull, DashboardViewCompact, cfg.DashboardView) + } if strings.TrimSpace(cfg.Theme.Selected) == "" { return fmt.Errorf("theme.selected must not be empty") } diff --git a/internal/ui/app.go b/internal/ui/app.go index ff9dfa6..ada8b90 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -23,6 +23,7 @@ const ( screenConfirmRemove screenSettings screenEditInterval + screenDashboardViews screenThemes ) @@ -50,6 +51,7 @@ type Model struct { menuCursor int reposCursor int settingsCursor int + viewCursor int themeCursor int themeChoices []theme.Choice @@ -182,6 +184,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSettings(msg) case screenEditInterval: return m.updateEditInterval(msg) + case screenDashboardViews: + return m.updateDashboardViews(msg) case screenThemes: return m.updateThemes(msg) } @@ -202,7 +206,7 @@ func (m Model) View() string { body = m.viewMenu() case screenRepos, screenAddRepo, screenConfirmRemove: body = m.viewRepos() - case screenSettings, screenEditInterval, screenThemes: + case screenSettings, screenEditInterval, screenDashboardViews, screenThemes: body = m.viewSettings() } diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index f200129..c77e86c 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/bluegardenproject/github-butler/internal/config" "github.com/bluegardenproject/github-butler/internal/github" "github.com/bluegardenproject/github-butler/internal/ui/components" "github.com/bluegardenproject/github-butler/internal/ui/theme" @@ -28,6 +29,9 @@ const ( colUnresW = 6 colAgeW = 8 colActW = 8 + + compactAutoWidthThreshold = 150 + compactMinContentWidth = 60 ) func (m Model) updateDashboard(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -100,6 +104,10 @@ func (m Model) viewDashboard() string { } func (m Model) renderTable() string { + if m.useCompactTable() { + return m.renderCompactTable() + } + header := renderHeaderRow() rows := make([]string, 0, len(m.prs)+1) rows = append(rows, header) @@ -115,6 +123,46 @@ func (m Model) renderTable() string { return theme.Panel.Render(strings.Join(rows, "\n")) } +func (m Model) useCompactTable() bool { + switch m.cfg.DashboardView { + case config.DashboardViewCompact: + return true + case config.DashboardViewFull: + return false + default: + return m.width > 0 && m.width < compactAutoWidthThreshold + } +} + +func (m Model) renderCompactTable() string { + contentWidth := m.tableContentWidth() + rows := make([]string, 0, len(m.prs)+1) + rows = append(rows, renderCompactHeaderRow(contentWidth)) + + var prevRepo string + for i, pr := range m.prs { + if m.cfg.GroupByRepo && pr.Repo != prevRepo { + rows = append(rows, renderGroupHeader(pr.Repo)) + prevRepo = pr.Repo + } + rows = append(rows, m.renderCompactRow(pr, i == m.selected, contentWidth)) + } + return theme.Panel.Render(strings.Join(rows, "\n")) +} + +func (m Model) tableContentWidth() int { + // Panel adds a rounded border and horizontal padding, so keep the inner + // layout a few columns narrower than the terminal width. + if m.width <= 0 { + return 100 + } + w := m.width - 4 + if w < compactMinContentWidth { + return compactMinContentWidth + } + return w +} + // renderGroupHeader renders a single separator row above each repo group // when group-by-repo is enabled. Kept visually light so it doesn't // compete with the gradient table header. @@ -186,6 +234,18 @@ func renderHeaderRow() string { return theme.Gradient(strings.Join(cells, " "), theme.HeaderStops...) } +func renderCompactHeaderRow(width int) string { + repoW, numW, titleW, tagsW, statusW := compactColumnWidths(width) + cells := []string{ + pad("REPO", repoW), + pad("#", numW), + pad("TITLE", titleW), + pad("TAGS", tagsW), + pad("STATUS", statusW), + } + return theme.Gradient(strings.Join(cells, " "), theme.HeaderStops...) +} + func (m Model) renderRow(pr github.PR, selected bool) string { repo := pad(shortRepo(pr.Repo), colRepoW) num := pad(fmt.Sprintf("#%d", pr.Number), colNumW) @@ -213,6 +273,93 @@ func (m Model) renderRow(pr github.PR, selected bool) string { return leading + " " + trailing } +func (m Model) renderCompactRow(pr github.PR, selected bool, width int) string { + repoW, numW, titleW, tagsW, statusW := compactColumnWidths(width) + repo := pad(shortRepo(pr.Repo), repoW) + num := pad(fmt.Sprintf("#%d", pr.Number), numW) + title := pad(truncate(pr.Title, titleW), titleW) + tags := padVisible(renderTags(pr), tagsW) + status := padVisible(compactStatus(pr), statusW) + + leading := strings.Join([]string{repo, num, title}, " ") + if selected { + leading = theme.SelectedRow.Render(leading) + } + return strings.Join([]string{leading, tags, status}, " ") +} + +func compactColumnWidths(width int) (repoW, numW, titleW, tagsW, statusW int) { + repoW, numW, tagsW, statusW = 18, 7, 12, 24 + titleW = width - repoW - numW - tagsW - statusW - 4 + if titleW >= 18 { + return repoW, numW, titleW, tagsW, statusW + } + + for titleW < 18 && statusW > 18 { + statusW-- + titleW++ + } + for titleW < 18 && tagsW > 8 { + tagsW-- + titleW++ + } + for titleW < 18 && repoW > 12 { + repoW-- + titleW++ + } + if titleW < 12 { + titleW = 12 + } + return repoW, numW, titleW, tagsW, statusW +} + +func compactStatus(pr github.PR) string { + parts := []string{compactCIStatus(pr), compactReviewStatus(pr)} + if pr.UnresolvedCount > 0 { + parts = append(parts, theme.Pending.Render(fmt.Sprintf("UNRES %d", pr.UnresolvedCount))) + } + return strings.Join(parts, " ") +} + +func compactCIStatus(pr github.PR) string { + switch { + case pr.TotalChecks == 0: + return theme.Dimmed.Render("—") + case len(pr.FailingChecks) > 0: + return theme.Fail.Render(fmt.Sprintf("FAIL×%d", len(pr.FailingChecks))) + case pr.PendingChecks > 0: + return theme.Pending.Render(fmt.Sprintf("RUN×%d", pr.PendingChecks)) + default: + return theme.OK.Render("PASS") + } +} + +func compactReviewStatus(pr github.PR) string { + total := len(pr.CodeOwnerReviews()) + if total > 0 { + approved := pr.CodeOwnerApprovalCount() + switch { + case pr.CodeOwnerHasChangesRequested(): + return theme.Fail.Render("CHG") + case approved == total: + return theme.OK.Render("OK") + default: + return theme.Info.Render(fmt.Sprintf("REQ %d/%d", approved, total)) + } + } + + switch pr.ReviewDecision { + case "APPROVED": + return theme.OK.Render("OK") + case "CHANGES_REQUESTED": + return theme.Fail.Render("CHG") + case "REVIEW_REQUIRED": + return theme.Info.Render("PEND") + default: + return theme.Dimmed.Render("—") + } +} + // renderTags joins the DRAFT/STALE chips for a PR into a single styled // string. Empty when the PR has neither flag; the row renderer pads the // result out to colTagsW so every column to the right stays aligned. diff --git a/internal/ui/dashboard_test.go b/internal/ui/dashboard_test.go new file mode 100644 index 0000000..46e513c --- /dev/null +++ b/internal/ui/dashboard_test.go @@ -0,0 +1,104 @@ +package ui + +import ( + "strings" + "testing" + "time" + + "github.com/bluegardenproject/github-butler/internal/config" + "github.com/bluegardenproject/github-butler/internal/github" + "github.com/charmbracelet/lipgloss" +) + +func TestUseCompactTable(t *testing.T) { + m := Model{cfg: config.Config{DashboardView: config.DashboardViewAuto}, width: 120} + if !m.useCompactTable() { + t.Fatal("auto view should use compact table below the width threshold") + } + + m.width = 180 + if m.useCompactTable() { + t.Fatal("auto view should use full table above the width threshold") + } + + m.cfg.DashboardView = config.DashboardViewCompact + m.width = 220 + if !m.useCompactTable() { + t.Fatal("compact view should always use compact table") + } + + m.cfg.DashboardView = config.DashboardViewFull + m.width = 80 + if m.useCompactTable() { + t.Fatal("full view should never use compact table") + } +} + +func TestRenderCompactRowUsesSingleDenseStatusLine(t *testing.T) { + m := Model{} + pr := github.PR{ + Repo: "ledger/github-butler", + Number: 42, + Title: "Add compact dashboard layout for split terminal panes", + HeadRef: "feature/compact-dashboard", + CreatedAt: time.Now().Add(-48 * time.Hour), + UpdatedAt: time.Now().Add(-2 * time.Hour), + ReviewDecision: "REVIEW_REQUIRED", + TotalChecks: 3, + PendingChecks: 1, + UnresolvedCount: 2, + RequiredReviews: []github.RequiredReview{{State: github.ReviewStatePending, RequiredByCodeOwner: true}}, + } + + row := m.renderCompactRow(pr, true, 96) + if !strings.Contains(row, "#42") || !strings.Contains(row, "Add compact dashboard") { + t.Fatalf("row missing PR identity: %q", row) + } + if lipgloss.Width(row) > 96 { + t.Fatalf("row width = %d, want <= 96", lipgloss.Width(row)) + } + for _, token := range []string{"RUN×1", "REQ 0/1", "UNRES 2"} { + if !strings.Contains(row, token) { + t.Fatalf("row missing status token %s: %q", token, row) + } + } +} + +func TestCompactStatus(t *testing.T) { + cases := []struct { + name string + pr github.PR + want []string + }{ + { + name: "passing approved", + pr: github.PR{ + TotalChecks: 1, + ReviewDecision: "APPROVED", + RequiredReviews: []github.RequiredReview{{State: github.ReviewStateApproved, RequiredByCodeOwner: true}}, + }, + want: []string{"PASS", "OK"}, + }, + { + name: "failing changes unresolved", + pr: github.PR{ + TotalChecks: 2, + FailingChecks: []github.Check{{Name: "test"}}, + UnresolvedCount: 3, + RequiredReviews: []github.RequiredReview{{State: github.ReviewStateChangesRequested, RequiredByCodeOwner: true}}, + }, + want: []string{"FAIL×1", "CHG", "UNRES 3"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := compactStatus(tc.pr) + for _, want := range tc.want { + if !strings.Contains(got, want) { + t.Fatalf("compactStatus() = %q, want token %q", got, want) + } + } + }) + } +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index d75c91c..3580a38 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -34,6 +34,11 @@ var settingItems = []settingItem{ value: func(cfg config.Config) string { return onOff(cfg.GroupByRepo) }, activate: toggleGroupByRepo, }, + { + label: "Dashboard view", + value: func(cfg config.Config) string { return cfg.DashboardView }, + activate: openDashboardViewPicker, + }, { label: "Theme", value: func(cfg config.Config) string { return cfg.Theme.Selected }, @@ -68,6 +73,29 @@ func toggleGroupByRepo(m Model) (Model, tea.Cmd) { return m, saveConfigCmd(m.cfg) } +type dashboardViewChoice struct { + id string + name string +} + +var dashboardViewChoices = []dashboardViewChoice{ + {id: config.DashboardViewAuto, name: "Auto"}, + {id: config.DashboardViewFull, name: "Full"}, + {id: config.DashboardViewCompact, name: "Compact"}, +} + +func openDashboardViewPicker(m Model) (Model, tea.Cmd) { + m.screen = screenDashboardViews + m.viewCursor = 0 + for i, choice := range dashboardViewChoices { + if choice.id == m.cfg.DashboardView { + m.viewCursor = i + break + } + } + return m, nil +} + func openThemePicker(m Model) (Model, tea.Cmd) { m.screen = screenThemes m.themeCursor = 0 @@ -144,6 +172,37 @@ func (m Model) updateEditInterval(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m Model) updateDashboardViews(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch { + case key.Matches(km, keys.Quit): + return m, tea.Quit + case key.Matches(km, keys.Back): + m.screen = screenSettings + case key.Matches(km, keys.Menu): + m.screen = screenDashboard + case key.Matches(km, keys.Up): + if m.viewCursor > 0 { + m.viewCursor-- + } + case key.Matches(km, keys.Down): + if m.viewCursor < len(dashboardViewChoices)-1 { + m.viewCursor++ + } + case key.Matches(km, keys.Select): + if m.viewCursor < 0 || m.viewCursor >= len(dashboardViewChoices) { + return m, nil + } + m.cfg.DashboardView = dashboardViewChoices[m.viewCursor].id + m.screen = screenSettings + return m, saveConfigCmd(m.cfg) + } + return m, nil +} + func (m Model) updateThemes(msg tea.Msg) (tea.Model, tea.Cmd) { km, ok := msg.(tea.KeyMsg) if !ok { @@ -201,6 +260,8 @@ func (m Model) viewSettings() string { var extra string if m.screen == screenEditInterval { extra = m.renderEditInterval() + } else if m.screen == screenDashboardViews { + extra = m.renderDashboardViewPicker() } else if m.screen == screenThemes { extra = m.renderThemePicker() } @@ -215,6 +276,28 @@ func (m Model) viewSettings() string { return lipgloss.JoinVertical(lipgloss.Left, parts...) } +func (m Model) renderDashboardViewPicker() string { + label := theme.PanelTitle.Render("Select dashboard view") + var rows []string + for i, choice := range dashboardViewChoices { + line := " " + choice.name + if i == m.viewCursor { + line = theme.SelectedRow.Render(" ▸ " + choice.name + " ") + } + if choice.id == m.cfg.DashboardView { + line += theme.Accent.Render(" current") + } + rows = append(rows, line) + } + rows = append(rows, theme.Dimmed.Render("[enter] select [esc] cancel")) + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.NeonPink). + Padding(0, 1). + Render(lipgloss.JoinVertical(lipgloss.Left, append([]string{label}, rows...)...)) +} + func (m Model) renderThemePicker() string { label := theme.PanelTitle.Render("Select theme") var rows []string