From d56e62c58914d63290e2c3170a2a9d505c1c4bef Mon Sep 17 00:00:00 2001 From: Pratik Date: Sat, 23 May 2026 14:02:23 +0530 Subject: [PATCH 1/4] feat: SQL dataset/column spotlight and UX polish --- .gitignore | 2 +- cmd/queryList.go | 74 +- cmd/style.go | 29 +- go.mod | 3 + go.sum | 6 + pkg/model/credential/credential.go | 11 +- pkg/model/defaultprofile/profile.go | 17 +- pkg/model/login/login.go | 131 +-- pkg/model/promql.go | 1218 +++++++++++++++-------- pkg/model/query.go | 1420 +++++++++++++++++++++++---- pkg/model/role/role.go | 13 +- pkg/model/savedQueries.go | 207 ++-- pkg/model/status.go | 127 ++- pkg/model/timeinput.go | 5 +- pkg/model/timerange.go | 61 +- pkg/ui/app.go | 300 ++++++ pkg/ui/chart.go | 58 ++ pkg/ui/chrome.go | 415 ++++++++ pkg/ui/highlight.go | 110 +++ pkg/ui/icons.go | 98 ++ pkg/ui/logo.go | 40 + pkg/ui/theme.go | 340 +++++++ pkg/ui/views/empty.go | 38 + pkg/ui/views/picker.go | 419 ++++++++ pkg/ui/views/views.go | 56 ++ 25 files changed, 4242 insertions(+), 956 deletions(-) create mode 100644 pkg/ui/app.go create mode 100644 pkg/ui/chart.go create mode 100644 pkg/ui/chrome.go create mode 100644 pkg/ui/highlight.go create mode 100644 pkg/ui/icons.go create mode 100644 pkg/ui/logo.go create mode 100644 pkg/ui/theme.go create mode 100644 pkg/ui/views/empty.go create mode 100644 pkg/ui/views/picker.go create mode 100644 pkg/ui/views/views.go diff --git a/.gitignore b/.gitignore index 9469aed..b2a56c5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ *.out # Dependency directories (remove the comment below to include it) -# vendor/ +vendor/ # Go workspace file go.work diff --git a/cmd/queryList.go b/cmd/queryList.go index a81b196..d8d5a3e 100644 --- a/cmd/queryList.go +++ b/cmd/queryList.go @@ -137,73 +137,27 @@ func deleteSavedQuery(client *internalHTTP.HTTPClient, savedQueryID, title strin } } -// Convert a saved query to executable pb query -func savedQueryToPbQuery(query string, start string, end string) { - var timeStamps string - if start == "" || end == "" { - timeStamps = `` - } else { - startFormatted := formatToRFC3339(start) - endFormatted := formatToRFC3339(end) - timeStamps = ` --from=` + startFormatted + ` --to=` + endFormatted +// Convert a saved query to executable pb query and print results to terminal +func savedQueryToPbQuery(sqlQuery string, start string, end string) { + if start == "" { + start = "1h" } - _ = `pb query run ` + query + timeStamps -} - -// Parses all UTC time format from string to time interface -func parseTimeToFormat(input string) (time.Time, error) { - // List of possible formats - formats := []string{ - time.RFC3339, - "2006-01-02 15:04:05", - "2006-01-02", - "01/02/2006 15:04:05", - "02-Jan-2006 15:04:05 MST", - "2006-01-02T15:04:05Z", - "02-Jan-2006", + if end == "" { + end = "now" } - var err error - var t time.Time - - for _, format := range formats { - t, err = time.Parse(format, input) - if err == nil { - return t, nil - } - } + sqlQuery = quoteStreamNames(sqlQuery) + sqlQuery = quoteFieldsWithDots(sqlQuery) - return t, fmt.Errorf("unable to parse time: %s", input) -} + fmt.Printf("Query: %s\n", sqlQuery) -// Converts to RFC3339 -func convertTime(input string) (string, error) { - t, err := parseTimeToFormat(input) + client := internalHTTP.DefaultClient(&DefaultProfile) + stopSpinner := startSpinner() + err := fetchData(&client, sqlQuery, start, end, "") + stopSpinner() if err != nil { - return "", err - } - - return t.Format(time.RFC3339), nil -} - -// Converts User inputted time to string type RFC3339 time -func formatToRFC3339(time string) string { - var formattedTime string - if len(strings.Fields(time)) > 1 { - newTime := strings.Fields(time)[0:2] - rfc39990time, err := convertTime(strings.Join(newTime, " ")) - if err != nil { - fmt.Println("error formatting time") - } - formattedTime = rfc39990time - } else { - rfc39990time, err := convertTime(time) - if err != nil { - fmt.Println("error formatting time") - } - formattedTime = rfc39990time + fmt.Fprintf(os.Stderr, "error: %v\n", err) } - return formattedTime } func init() { diff --git a/cmd/style.go b/cmd/style.go index cebfddf..b152f32 100644 --- a/cmd/style.go +++ b/cmd/style.go @@ -17,23 +17,38 @@ package cmd import ( + "pb/pkg/ui" + "github.com/charmbracelet/lipgloss" ) -// styling for cli outputs +// Styles for the cobra command CLI outputs (prompts, error messages, list +// items rendered outside the bubbletea TUI). Sourced from the shared +// ui.Palette so any palette change auto-propagates here. +// +// Names kept stable for backwards compatibility with existing call sites +// across cmd/ and pkg/model/. var ( - FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} - FocusSecondary = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} + // FocusPrimary / FocusSecondary used to be yellow (ANSI 226/220). Now + // brand indigo — same role (selected / active item highlight) but + // matches the rest of the design system. + FocusPrimary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent }) + FocusSecondary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent2 }) + + StandardPrimary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Body }) + StandardSecondary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Mute }) - StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} - StandardSecondary = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} StandardStyle = lipgloss.NewStyle().Foreground(StandardPrimary) StandardStyleBold = lipgloss.NewStyle().Foreground(StandardPrimary).Bold(true) StandardStyleAlt = lipgloss.NewStyle().Foreground(StandardSecondary) SelectedStyle = lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) SelectedStyleAlt = lipgloss.NewStyle().Foreground(FocusSecondary) - SelectedItemOuter = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderLeft(true).PaddingLeft(1).BorderForeground(FocusPrimary) - ItemOuter = lipgloss.NewStyle().PaddingLeft(1) + SelectedItemOuter = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderLeft(true). + PaddingLeft(1). + BorderForeground(FocusPrimary) + ItemOuter = lipgloss.NewStyle().PaddingLeft(1) StyleBold = lipgloss.NewStyle().Bold(true) ) diff --git a/go.mod b/go.mod index 8da8ebf..8c9ca52 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 toolchain go1.25.4 require ( + github.com/alecthomas/chroma/v2 v2.24.1 github.com/apache/arrow/go/v13 v13.0.0 github.com/briandowns/spinner v1.23.1 github.com/charmbracelet/bubbles v0.18.0 @@ -12,6 +13,7 @@ require ( github.com/charmbracelet/lipgloss v0.12.1 github.com/dustin/go-humanize v1.0.1 github.com/gofrs/flock v0.12.1 + github.com/guptarohit/asciigraph v0.9.0 github.com/manifoldco/promptui v0.9.0 github.com/oklog/ulid/v2 v2.1.0 github.com/olekukonko/tablewriter v0.0.5 @@ -55,6 +57,7 @@ require ( github.com/cyphar/filepath-securejoin v0.3.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect github.com/docker/cli v25.0.1+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v25.0.6+incompatible // indirect diff --git a/go.sum b/go.sum index 1d77164..85b218f 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/Microsoft/hcsshim v0.11.7 h1:vl/nj3Bar/CvJSYo7gIQPyRWc9f3c6IeSNavBTSZ github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= +github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/apache/arrow/go/v13 v13.0.0 h1:kELrvDQuKZo8csdWYqBQfyi431x6Zs/YJTEgUuSVcWk= @@ -108,6 +110,8 @@ github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aB github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v25.0.1+incompatible h1:mFpqnrS6Hsm3v1k7Wa/BO23oz0k121MTbTO1lpcGSkU= github.com/docker/cli v25.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -233,6 +237,8 @@ github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/guptarohit/asciigraph v0.9.0 h1:MvCSRRVkT2XvU1IO6n92o7l7zqx1DiFaoszOUZQztbY= +github.com/guptarohit/asciigraph v0.9.0/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/pkg/model/credential/credential.go b/pkg/model/credential/credential.go index 7b190d5..6b61d47 100644 --- a/pkg/model/credential/credential.go +++ b/pkg/model/credential/credential.go @@ -18,6 +18,7 @@ package credential import ( "pb/pkg/model/button" + "pb/pkg/ui" "strings" "github.com/charmbracelet/bubbles/textinput" @@ -25,13 +26,13 @@ import ( "github.com/charmbracelet/lipgloss" ) -// Default Style for this widget +// Default Style for this widget — theme-derived; yellow no more. var ( - FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} - FocusSecondary = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} + FocusPrimary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent }) + FocusSecondary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent2 }) - StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} - StandardSecondary = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} + StandardPrimary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Body }) + StandardSecondary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Mute }) focusedStyle = lipgloss.NewStyle().Foreground(FocusPrimary) blurredStyle = lipgloss.NewStyle().Foreground(StandardSecondary) diff --git a/pkg/model/defaultprofile/profile.go b/pkg/model/defaultprofile/profile.go index 9077054..cef4221 100644 --- a/pkg/model/defaultprofile/profile.go +++ b/pkg/model/defaultprofile/profile.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "pb/pkg/config" + "pb/pkg/ui" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -26,14 +27,14 @@ import ( ) var ( - // FocusPrimary is the primary focus color - FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} - // FocusSecondry is the secondry focus color - FocusSecondry = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} - // StandardPrimary is the primary standard color - StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} - // StandardSecondary is the secondary standard color - StandardSecondary = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} + // FocusPrimary is the primary focus color (brand indigo). + FocusPrimary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent }) + // FocusSecondry is the secondary focus color. + FocusSecondry = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent2 }) + // StandardPrimary is the primary standard color. + StandardPrimary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Body }) + // StandardSecondary is the secondary standard color. + StandardSecondary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Mute }) focusTitleStyle = lipgloss.NewStyle().Foreground(FocusPrimary) focusDescStyle = lipgloss.NewStyle().Foreground(FocusSecondry) diff --git a/pkg/model/login/login.go b/pkg/model/login/login.go index 2b89012..45a7559 100644 --- a/pkg/model/login/login.go +++ b/pkg/model/login/login.go @@ -19,6 +19,7 @@ import ( "strings" "pb/pkg/config" + "pb/pkg/ui" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -41,21 +42,25 @@ const ( ) var ( - primaryColor = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} - normalColor = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} - dimColor = lipgloss.AdaptiveColor{Light: "244", Dark: "240"} - successColor = lipgloss.AdaptiveColor{Light: "28", Dark: "82"} - errorColor = lipgloss.AdaptiveColor{Light: "196", Dark: "196"} - subtitleColor = lipgloss.AdaptiveColor{Light: "238", Dark: "248"} + primaryColor = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent }) + activeColor = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Active }) + normalColor = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Body }) + dimColor = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Faint }) + successColor = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Ok }) + errorColor = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Err }) + subtitleColor = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Mute }) + borderColor = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Border }) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(primaryColor) - selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(primaryColor) + selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(activeColor) normalStyle = lipgloss.NewStyle().Foreground(normalColor) dimStyle = lipgloss.NewStyle().Foreground(dimColor) successStyle = lipgloss.NewStyle().Bold(true).Foreground(successColor) hintStyle = lipgloss.NewStyle().Foreground(dimColor) errorStyle = lipgloss.NewStyle().Foreground(errorColor) - labelStyle = lipgloss.NewStyle().Foreground(subtitleColor) + labelStyle = lipgloss.NewStyle().Foreground(subtitleColor).Bold(true) + keyStyle = lipgloss.NewStyle().Bold(true).Foreground(primaryColor) + railStyle = lipgloss.NewStyle().Background(activeColor) ) // Model is the BubbleTea model for the interactive login wizard. @@ -371,28 +376,40 @@ func (m Model) finalize(name string) (tea.Model, tea.Cmd) { return m, tea.Quit } -func sep() string { - return dimStyle.Render(strings.Repeat("─", 44)) +// rowSelected — Active sky-blue rail + ❯ cursor + bold Active label. +// The arrow makes the active row unambiguous on monochrome terminals +// where bg fills may not render. +func rowSelected(label string) string { + return railStyle.Render(" ") + " " + selectedStyle.Render("❯ "+label) } -func breadcrumb(trail string) string { - return dimStyle.Render(" "+trail+" ›") + " " +// rowIdle — 4-space prefix + Body label, matches the arrow indent. +func rowIdle(label string) string { + return " " + normalStyle.Render(label) } -// View renders the current wizard step. +// hint — render " action action" with consistent styling. +func hint(pairs ...[2]string) string { + parts := make([]string, 0, len(pairs)) + for _, kv := range pairs { + parts = append(parts, keyStyle.Render("<"+kv[0]+">")+hintStyle.Render(" "+kv[1])) + } + return " " + strings.Join(parts, hintStyle.Render(" ")) +} + +// View renders the current wizard step inside a flat NormalBorder +// card with a fixed UPPERCASE title strip. Each step writes its own +// label row + body + hint row, joined into the card. func (m Model) View() string { var b strings.Builder - b.WriteString("\n") - b.WriteString(titleStyle.Render(" Parseable Login")) - b.WriteString("\n") - b.WriteString(sep()) + b.WriteString(titleStyle.Render("PARSEABLE LOGIN")) b.WriteString("\n\n") switch m.step { case stepChooseType: - b.WriteString(dimStyle.Render(" How would you like to connect?")) + b.WriteString(labelStyle.Render("CONNECT TO")) b.WriteString("\n\n") entries := []struct{ label, badge string }{ {"Self-hosted", ""}, @@ -400,85 +417,79 @@ func (m Model) View() string { } for i, e := range entries { if i == m.typeIndex { - b.WriteString(selectedStyle.Render(" ❯ " + e.label)) - b.WriteString(dimStyle.Render(e.badge)) + b.WriteString(rowSelected(e.label)) } else { - b.WriteString(normalStyle.Render(" " + e.label)) - b.WriteString(dimStyle.Render(e.badge)) + b.WriteString(rowIdle(e.label)) } + b.WriteString(dimStyle.Render(e.badge)) b.WriteString("\n") } b.WriteString("\n") - b.WriteString(hintStyle.Render(" ↑↓ navigate · Enter select · Ctrl+C quit")) + b.WriteString(hint([2]string{"↑↓", "navigate"}, [2]string{"enter", "select"}, [2]string{"ctrl-c", "quit"})) case stepCloudSoon: - b.WriteString(selectedStyle.Render(" Parseable Cloud")) + b.WriteString(labelStyle.Render("PARSEABLE CLOUD")) b.WriteString("\n\n") - b.WriteString(normalStyle.Render(" We're working on it!")) + b.WriteString(normalStyle.Render(" We're working on it.")) b.WriteString("\n") - b.WriteString(dimStyle.Render(" Cloud login is coming soon. Stay tuned for updates.")) + b.WriteString(dimStyle.Render(" Cloud login is coming soon.")) b.WriteString("\n\n") - b.WriteString(hintStyle.Render(" Press any key to go back")) + b.WriteString(hint([2]string{"any key", "back"})) case stepEnterURL: - b.WriteString(breadcrumb("Self-hosted")) - b.WriteString(labelStyle.Render("Server URL")) + b.WriteString(labelStyle.Render("SERVER URL")) b.WriteString("\n\n ") b.WriteString(m.urlInput.View()) b.WriteString("\n\n") b.WriteString(renderErr(m.errMsg)) - b.WriteString(hintStyle.Render(" Esc back · Enter continue")) + b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "continue"})) case stepChooseAuth: - b.WriteString(breadcrumb("Self-hosted")) - b.WriteString(labelStyle.Render("Authentication")) + b.WriteString(labelStyle.Render("AUTHENTICATION")) b.WriteString("\n\n") authEntries := []string{"Username & Password", "API key"} for i, entry := range authEntries { if i == m.authIndex { - b.WriteString(selectedStyle.Render(" ❯ " + entry)) + b.WriteString(rowSelected(entry)) } else { - b.WriteString(normalStyle.Render(" " + entry)) + b.WriteString(rowIdle(entry)) } b.WriteString("\n") } b.WriteString("\n") - b.WriteString(hintStyle.Render(" Esc back · ↑↓ navigate · Enter select")) + b.WriteString(hint([2]string{"esc", "back"}, [2]string{"↑↓", "navigate"}, [2]string{"enter", "select"})) case stepEnterUsername: - b.WriteString(breadcrumb("Self-hosted")) - b.WriteString(labelStyle.Render("Username")) + b.WriteString(labelStyle.Render("USERNAME")) b.WriteString("\n\n ") b.WriteString(m.usernameInput.View()) b.WriteString("\n\n") b.WriteString(renderErr(m.errMsg)) - b.WriteString(hintStyle.Render(" Esc back · Enter continue")) + b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "continue"})) case stepEnterPassword: - b.WriteString(breadcrumb("Self-hosted")) - b.WriteString(labelStyle.Render("Password")) + b.WriteString(labelStyle.Render("PASSWORD")) b.WriteString("\n\n ") b.WriteString(m.passwordInput.View()) b.WriteString("\n\n") b.WriteString(renderErr(m.errMsg)) - b.WriteString(hintStyle.Render(" Esc back · Enter continue")) + b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "continue"})) case stepEnterToken: - b.WriteString(breadcrumb("Self-hosted")) - b.WriteString(labelStyle.Render("API key")) + b.WriteString(labelStyle.Render("API KEY")) b.WriteString("\n\n ") b.WriteString(m.tokenInput.View()) b.WriteString("\n\n") b.WriteString(renderErr(m.errMsg)) - b.WriteString(hintStyle.Render(" Esc back · Enter continue")) + b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "continue"})) case stepEnterProfileName: - b.WriteString(labelStyle.Render(" Profile name")) + b.WriteString(labelStyle.Render("PROFILE NAME")) b.WriteString("\n\n ") b.WriteString(m.profileNameInput.View()) b.WriteString("\n\n") b.WriteString(renderErr(m.errMsg)) - b.WriteString(hintStyle.Render(" Esc back · Enter save")) + b.WriteString(hint([2]string{"esc", "back"}, [2]string{"enter", "save"})) case stepConfirmReplace: b.WriteString(errorStyle.Render(" Profile '" + m.Name + "' already exists")) @@ -486,39 +497,43 @@ func (m Model) View() string { entries := []string{"Replace it", "Change name"} for i, e := range entries { if i == m.replaceIndex { - b.WriteString(selectedStyle.Render(" ❯ " + e)) + b.WriteString(rowSelected(e)) } else { - b.WriteString(normalStyle.Render(" " + e)) + b.WriteString(rowIdle(e)) } b.WriteString("\n") } b.WriteString("\n") - b.WriteString(hintStyle.Render(" Esc back · ↑↓ navigate · Enter select")) + b.WriteString(hint([2]string{"esc", "back"}, [2]string{"↑↓", "navigate"}, [2]string{"enter", "select"})) case stepDone: - b.WriteString(successStyle.Render(" ✓ Profile '" + m.Name + "' saved")) + b.WriteString(successStyle.Render("✓ profile '" + m.Name + "' saved")) b.WriteString("\n\n") - b.WriteString(labelStyle.Render(" URL: ")) + b.WriteString(" " + labelStyle.Render("URL ")) b.WriteString(normalStyle.Render(m.Profile.URL)) b.WriteString("\n") if m.Profile.Username != "" { - b.WriteString(labelStyle.Render(" User: ")) + b.WriteString(" " + labelStyle.Render("USER ")) b.WriteString(normalStyle.Render(m.Profile.Username)) b.WriteString("\n") } if m.Profile.Token != "" { - b.WriteString(labelStyle.Render(" Auth: ")) + b.WriteString(" " + labelStyle.Render("AUTH ")) b.WriteString(normalStyle.Render("API key (stored)")) b.WriteString("\n") } b.WriteString("\n") - b.WriteString(dimStyle.Render(" To add more profiles:")) - b.WriteString("\n") - b.WriteString(hintStyle.Render(" pb profile add [user] [pass]")) + b.WriteString(dimStyle.Render(" add more profiles:")) + b.WriteString("\n ") + b.WriteString(hintStyle.Render("pb profile add [user] [pass]")) } - b.WriteString("\n\n") - return b.String() + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(borderColor). + Padding(1, 2). + Width(60). + Render(b.String()) + "\n" } func renderErr(msg string) string { diff --git a/pkg/model/promql.go b/pkg/model/promql.go index 7f02d16..0706bd1 100644 --- a/pkg/model/promql.go +++ b/pkg/model/promql.go @@ -25,12 +25,12 @@ import ( "net/url" "os" "pb/pkg/config" + "pb/pkg/ui" "sort" "strings" "time" "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" @@ -46,12 +46,7 @@ const ( promqlTimestampKey = "timestamp" promqlMetricKey = "metric" promqlValueKey = "value" - promqlTimestampWidth = 20 - - // header panel outer widths (inner = outer - 2 for borders) - datasetPanelOuter = 30 - timePanelOuter = 38 - stepModePanelOuter = 14 + promqlTimestampWidth = 10 // matches SQL dateTimeWidth (HH:MM:SS + slack) // spotlight modal width spotlightWidth = 58 @@ -64,9 +59,7 @@ const ( const overlayDataset uint = 2 const overlayBuilder uint = 3 -var PromqlNavigationMap = []string{"dataset", "query", "time", "step", "table"} - -var promqlAdditionalKeyBinds = []key.Binding{runQueryKey} +var PromqlNavigationMap = []string{"query", "dataset", "step", "time", "table"} // ─── response types ────────────────────────────────────────────────────────── @@ -176,9 +169,9 @@ type PromqlModel struct { // query builder — 3-column panel (metrics | labels | values) builderCol int - builderMetric string // currently highlighted metric (drives label/value fetch) - builderLabel string // currently selected label for preview - builderValue string // currently selected value for preview + builderMetric string + builderLabel string + builderValue string builderMetrics []string builderLabels []string builderValues []string @@ -192,10 +185,9 @@ type PromqlModel struct { builderLabelsLoading bool builderValuesLoading bool builderFilter textinput.Model - cancelLabels context.CancelFunc // aborts in-flight labels request; nil when idle - cancelValues context.CancelFunc // aborts in-flight values request; nil when idle + cancelLabels context.CancelFunc + cancelValues context.CancelFunc - // query panel mode toggle: "code" (raw textarea) or "builder" (expression breadcrumb + overlay) queryMode string } @@ -219,13 +211,54 @@ func (m *PromqlModel) currentFocus() string { } func (m *PromqlModel) queryWidth() int { - w := m.width - datasetPanelOuter - timePanelOuter - stepModePanelOuter - 2 - if w < 30 { - w = 30 + lw, rw := promqlPanelWidths(m.width) + editorW := m.width - lw - rw - 2 + if editorW < 30 { + editorW = 30 + } + w := editorW - 6 + if w < 24 { + w = 24 } return w } +// promqlPageSize computes the table page size for the given terminal height and +// bottom-bar height. bottomH=3 is the normal value (1 content line + 2 border +// lines from NormalBorder). Both View() and the WindowSizeMsg handler use this +// so navigation and rendering always agree on the page size. +// +// The table's built-in footer is disabled; a custom footer line (pages left, +// rows right) is rendered as the last line inside the results pane body. +// Table overhead = header(3) + last-row-bottom(1) + inside-footer(1) = 5 lines. +func promqlPageSize(totalH, bottomH int) int { + const topH = 13 + av := totalH - topH - bottomH + if av < 6 { + av = 6 + } + rih := av - 3 // results pane outer border (2) + title row (1) + if rih < 3 { + rih = 3 + } + ps := rih - 5 // table overhead: header(3) + last-row-bottom(1) + inside-footer(1) + if ps < 1 { + ps = 1 + } + return ps +} + +func promqlPanelWidths(totalW int) (leftW, rightW int) { + leftW, rightW = 20, 28 + if totalW >= 140 { + leftW, rightW = 22, 30 + } + if totalW < 100 { + leftW, rightW = 18, 26 + } + return +} + // ─── constructor ───────────────────────────────────────────────────────────── func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time.Time, step, dataset string, instant bool) PromqlModel { @@ -235,12 +268,12 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time inputs.SetInstant(instant) columns := []table.Column{ - table.NewColumn(promqlTimestampKey, "timestamp", promqlTimestampWidth), - table.NewFlexColumn(promqlMetricKey, "metric", 1), - table.NewColumn(promqlValueKey, "value", 10), + table.NewColumn(promqlTimestampKey, "TIMESTAMP", promqlTimestampWidth), + table.NewFlexColumn(promqlMetricKey, "METRIC", 1), + table.NewColumn(promqlValueKey, "VALUE", 14), } - pageSize := h - 14 + pageSize := promqlPageSize(h, 3) // 3 = bottom bar height (1 content + 2 border lines) if pageSize < 5 { pageSize = 5 } @@ -255,50 +288,83 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time WithKeyMap(tableKeyBinds). WithPageSize(pageSize). WithBaseStyle(tableStyle). + HighlightStyle(highlightStyle). WithMissingDataIndicatorStyled(table.StyledCell{ - Style: lipgloss.NewStyle().Foreground(StandardSecondary), + Style: ui.Type().Mute, Data: "╌", - }).WithTargetWidth(w) + }).WithTargetWidth(w). + WithFooterVisibility(false) // custom page-info line is pinned to results pane bottom - qw := w - datasetPanelOuter - timePanelOuter - stepModePanelOuter - 2 - if qw < 30 { - qw = 30 + lw, rw := promqlPanelWidths(w) + editorInitW := w - lw - rw - 2 + if editorInitW < 30 { + editorInitW = 30 + } + qw := editorInitW - 6 + if qw < 24 { + qw = 24 } q := textarea.New() q.MaxHeight = 0 q.MaxWidth = 0 q.SetHeight(1) - q.SetWidth(qw) q.ShowLineNumbers = false + q.EndOfBufferCharacter = ' ' q.SetValue(expr) - q.Placeholder = "write your PromQL expression here..." + q.Placeholder = "Write your queries here" q.KeyMap = textAreaKeyMap + applyEditorStyles(&q) + q.SetWidth(qw) + q.SetValue(expr) q.Focus() si := textinput.New() si.Prompt = "" si.SetValue(step) - si.Width = stepModePanelOuter - 10 + si.Width = 4 si.Blur() sf := textinput.New() - sf.Placeholder = "search datasets..." - sf.Width = spotlightWidth - 6 + sf.Placeholder = "filter datasets" + sf.Prompt = "> " + sf.PromptStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Bold(true) + sf.PlaceholderStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Ghost })). + Italic(true) + sf.Width = spotlightWidth - 8 sf.Blur() bf := textinput.New() - bf.Placeholder = "search..." + bf.Placeholder = "filter" + bf.Prompt = "> " + bf.PromptStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Bold(true) + bf.PlaceholderStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Ghost })). + Italic(true) bf.Width = 30 bf.Blur() hlp := help.New() - hlp.Styles.FullDesc = lipgloss.NewStyle().Foreground(FocusSecondary) + hlp.Styles.FullDesc = ui.Type().Dim stat := NewStatusBar(profile.URL, w) sp := spinner.New() sp.Spinner = spinner.Line - sp.Style = lipgloss.NewStyle().Foreground(FocusPrimary) + sp.Style = ui.Type().Accent + + // Start with focus on the query editor so the cursor is ready to type. + queryFocusIdx := 0 + for i, name := range PromqlNavigationMap { + if name == "query" { + queryFocusIdx = i + break + } + } hasQuery := strings.TrimSpace(expr) != "" return PromqlModel{ @@ -317,6 +383,7 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time dataset: dataset, step: step, instant: instant, + focused: queryFocusIdx, stepInput: si, spotlightFilter: sf, @@ -336,6 +403,8 @@ func (m PromqlModel) Init() tea.Cmd { if m.dataset != "" { cmds = append(cmds, fetchCacheMetrics(m.profile, m.dataset)) } + // Fetch dataset list on init so the first dataset is auto-selected if none was provided. + cmds = append(cmds, fetchMetricDatasets(m.profile)) return tea.Batch(cmds...) } @@ -358,11 +427,14 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.help.Width = m.width m.status.width = m.width m.query.SetWidth(m.queryWidth()) - m.stepInput.Width = stepModePanelOuter - 10 + m.query.SetHeight(13 - 4) // topH(13) - border(2)+title(1)+spacer(1) = 9 editable lines + m.stepInput.Width = 4 m.spotlightFilter.Width = spotlightWidth - 6 colW := builderColWidth(m.width) m.builderFilter.Width = colW*3 + 8 - m.updateTableColumns(0, 0) // reflow columns to new terminal width + m.updateTableColumns(0, 0) + bh := lipgloss.Height(buildPromqlBottomBar(m, m.width)) + m.table = m.table.WithPageSize(promqlPageSize(m.height, bh)) return m, nil case datasetListMsg: @@ -373,7 +445,16 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.allDatasets = msg.datasets m.filteredDatasets = msg.datasets m.datasetSelectedIdx = 0 - // pre-select current dataset + // No dataset chosen yet: pick the first available so the + // sidebar shows a real value out of the box instead of + // the "select-dataset" placeholder. Kick off the metrics + // cache fetch the same way an explicit selection does. + if (m.dataset == "" || m.dataset == "select-dataset") && len(msg.datasets) > 0 { + m.dataset = msg.datasets[0] + m.cacheDataset = "" + m.cacheMetrics = nil + return m, fetchCacheMetrics(m.profile, m.dataset) + } for i, ds := range m.filteredDatasets { if ds == m.dataset { m.datasetSelectedIdx = i @@ -480,13 +561,16 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dataRows = msg.rows m.lastResultType = msg.resultType m.seriesCount = msg.seriesCount - mode := "range" - if m.instant { - mode = "instant" - } - m.status.Info = fmt.Sprintf("%d rows %d series %s step=%s ds=%s", - len(m.dataRows), m.seriesCount, mode, m.step, m.dataset) + m.status.Info = "" m.updateTableColumns(msg.metricWidth, msg.valueWidth) + // Auto-focus results table after successful query. + for i, p := range PromqlNavigationMap { + if p == "table" { + m.focused = i + break + } + } + m.focusSelected() } else { m.dataRows = []table.Row{} m.table = m.table.WithRows([]table.Row{}) @@ -554,7 +638,7 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // On the final column (Values) it also runs the query. case tea.KeyEnter: switch m.builderCol { - case 0: // confirm metric → fetch labels → move to Labels column + case 0: metric := m.builderCurrentMetric() if metric == "" { return m, nil @@ -565,7 +649,6 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.builderLabelsIdx, m.builderValuesIdx = 0, 0 m.builderCol = 1 m.builderFilter.SetValue("") - // cancel any previous in-flight labels request if m.cancelLabels != nil { m.cancelLabels() } @@ -589,7 +672,6 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.builderLabel = label m.builderFilter.SetValue("") if label == "" || label == "(any)" { - // no label filter — build expr and run expr := buildPromqlExpr(m.builderCurrentMetric(), "", "") newM, cmd := m.runQueryFromBuilder(expr) return newM, cmd @@ -597,7 +679,6 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.builderValues, m.builderValuesFiltered = nil, nil m.builderValuesIdx = 0 m.builderCol = 2 - // cancel any previous in-flight values request if m.cancelValues != nil { m.cancelValues() } @@ -620,7 +701,7 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancelValues = cancel2 return m, fetchBuilderValuesCtx(ctx2, m.profile, m.dataset, m.builderCurrentMetric(), label, m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) - case 2: // confirm value → build expression + run query + close + case 2: expr := buildPromqlExpr(m.builderCurrentMetric(), m.builderCurrentLabel(), m.builderCurrentValue()) newM, cmd := m.runQueryFromBuilder(expr) return newM, cmd @@ -706,25 +787,20 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEnter: if len(m.filteredDatasets) > 0 { newDS := m.filteredDatasets[m.datasetSelectedIdx] - if newDS != m.dataset { - m.dataset = newDS - // clear stale cache and warm fresh one in background - m.cacheDataset = "" - m.cacheMetrics = nil - m.cacheLabels = nil - m.cacheValues = nil - m.overlay = overlayNone - m.spotlightFilter.SetValue("") - m.spotlightFilter.Blur() - m.focusSelected() - return m, fetchCacheMetrics(m.profile, newDS) - } + m.dataset = newDS + m.query.SetValue("") + // clear stale cache and warm fresh one in background + m.cacheDataset = "" + m.cacheMetrics = nil + m.cacheLabels = nil + m.cacheValues = nil } m.overlay = overlayNone m.spotlightFilter.SetValue("") m.spotlightFilter.Blur() + m.focused = 0 // focus query editor m.focusSelected() - return m, nil + return m, fetchCacheMetrics(m.profile, m.dataset) case tea.KeyUp: if m.datasetSelectedIdx > 0 { @@ -752,6 +828,11 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // ── time overlay ───────────────────────────────────────────────────── if m.overlay == overlayInputs { + if msg.Type == tea.KeyEsc { + m.overlay = overlayNone + m.focusSelected() + return m, nil + } if msg.Type == tea.KeyEnter { m.overlay = overlayNone m.focusSelected() @@ -785,6 +866,29 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Up/Down navigate within the sidebar (dataset ↔ step) as an + // alternative to Tab so the user can stay in the sidebar area. + if msg.Type == tea.KeyDown && m.currentFocus() == "dataset" { + for i, p := range PromqlNavigationMap { + if p == "step" { + m.focused = i + break + } + } + m.focusSelected() + return m, nil + } + if msg.Type == tea.KeyUp && m.currentFocus() == "step" { + for i, p := range PromqlNavigationMap { + if p == "dataset" { + m.focused = i + break + } + } + m.focusSelected() + return m, nil + } + // Enter on dataset → open spotlight if msg.Type == tea.KeyEnter && m.currentFocus() == "dataset" { m.overlay = overlayDataset @@ -804,8 +908,16 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.openBuilderOverlay() } - // Ctrl+R → run query - if msg.Type == tea.KeyCtrlR { + // Ctrl+R or Alt+Enter (≈ Cmd+Enter with meta config) → run query + isAltEnter := msg.Alt && msg.Type == tea.KeyEnter + if msg.Type == tea.KeyCtrlR || isAltEnter { + if m.dataset == "" { + m.overlay = overlayDataset + m.spotlightFilter.Focus() + m.datasetsLoading = true + m.status.Error = "select a dataset first" + return m, fetchMetricDatasets(m.profile) + } m.overlay = overlayNone m.status.Error = "" m.status.Info = "" @@ -854,16 +966,18 @@ func (m PromqlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -// runQueryFromBuilder sets the expression, closes the builder overlay, and fires a query. -// The query panel stays in builder mode so the expression is shown as a breadcrumb. +// runQueryFromBuilder sets the expression, switches to code mode, closes the +// builder overlay, and fires the query. func (m *PromqlModel) runQueryFromBuilder(expr string) (PromqlModel, tea.Cmd) { if expr != "" { m.query.SetValue(expr) + m.query.CursorEnd() } + m.queryMode = "code" m.overlay = overlayNone m.builderFilter.SetValue("") m.builderFilter.Blur() - // return focus to query panel, stay in builder mode + // return focus to query panel for i, p := range PromqlNavigationMap { if p == "query" { m.focused = i @@ -883,7 +997,6 @@ func (m *PromqlModel) runQueryFromBuilder(expr string) (PromqlModel, tea.Cmd) { m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc(), m.instant)) } -// openBuilderOverlay transitions to the builder overlay, seeding state from the cache. func (m *PromqlModel) openBuilderOverlay() tea.Cmd { m.overlay = overlayBuilder m.builderCol = 0 @@ -920,390 +1033,606 @@ func (m PromqlModel) View() string { if m.width == 0 || m.height == 0 { return "" } + p := ui.Active - // ── header panels ──────────────────────────────────────────────────────── - dsName := m.dataset - var dsNameRendered string - if dsName == "" { - dsNameRendered = lipgloss.NewStyle().Foreground(StandardSecondary).Render("select dataset") - } else { - if len(dsName) > datasetPanelOuter-4 { - dsName = dsName[:datasetPanelOuter-7] + "..." - } - dsNameRendered = dsName + // ── Status + help (precompute heights) ────────────────────────── + if m.loading { + m.status.Info = "" + m.status.Error = "" } - datasetPane := lipgloss.JoinVertical(lipgloss.Left, - baseBoldUnderlinedStyle.Render(" dataset "), - dsNameRendered, - ) + m.status.SetMode("PromQL") + bottomView := buildPromqlBottomBar(m, m.width) + bottomHeight := lipgloss.Height(bottomView) - mode := "range" - modeColor := lipgloss.AdaptiveColor{Light: "28", Dark: "82"} // green = range - if m.instant { - mode = "instant" - modeColor = lipgloss.AdaptiveColor{Light: "208", Dark: "214"} // orange = instant + topH := 13 + sidebarW := 30 + if m.width >= 140 { + sidebarW = 34 } - modeLabel := lipgloss.NewStyle().Foreground(modeColor).Bold(true).Render(mode) - - var stepRow string - if m.instant { - dimmed := lipgloss.NewStyle().Foreground(StandardSecondary).Render("--") - stepRow = fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" step "), dimmed) - } else if m.currentFocus() == "step" { - stepRow = fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" step "), m.stepInput.View()) - } else { - stepRow = fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" step "), m.step) + if m.width < 100 { + sidebarW = 26 } - stepModePane := lipgloss.JoinVertical(lipgloss.Left, - stepRow, - fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" mode "), modeLabel), - ) - - timePane := lipgloss.JoinVertical(lipgloss.Left, - fmt.Sprintf("%s %s ", baseBoldUnderlinedStyle.Render(" start "), m.timeRange.start.Value()), - fmt.Sprintf("%s %s ", baseBoldUnderlinedStyle.Render(" end "), m.timeRange.end.Value()), - ) - - // pick border styles based on focused panel - dsOuter, queryOuter, timeOuter, stepOuter := &borderedStyle, &borderedStyle, &borderedStyle, &borderedStyle - tableOuter := lipgloss.NewStyle() - switch m.currentFocus() { - case "dataset": - dsOuter = &borderedFocusStyle - case "query": - queryOuter = &borderedFocusStyle - case "time": - timeOuter = &borderedFocusStyle - case "step": - stepOuter = &borderedFocusStyle - case "table": - tableOuter = tableOuter.Border(lipgloss.DoubleBorder(), false, false, false, true). - BorderForeground(FocusPrimary) - } - - // render fixed panels first so we can measure their real widths - dsRendered := dsOuter.Render(datasetPane) - timeRendered := timeOuter.Render(timePane) - stepRendered := stepOuter.Render(stepModePane) - fixedW := lipgloss.Width(dsRendered) + lipgloss.Width(timeRendered) + lipgloss.Width(stepRendered) - queryW := m.width - fixedW - if queryW < 30 { - queryW = 30 - } - innerW := queryW - 2 // subtract border - m.query.SetWidth(innerW) - - // ── query panel: toggle row + mode-aware content ────────────────────────── - activeTabStyle := lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) - inactiveTabStyle := lipgloss.NewStyle().Foreground(StandardSecondary) - var codeLabel, builderLabel string - if m.queryMode == "builder" { - codeLabel = inactiveTabStyle.Render("Code") - builderLabel = activeTabStyle.Render("Builder") - } else { - codeLabel = activeTabStyle.Render("Code") - builderLabel = inactiveTabStyle.Render("Builder") + // editorW reserves 1 col for the horizontal gap between editor + // and sidebar so the two `│` borders aren't flush against each + // other. + editorW := m.width - sidebarW - 1 + if editorW < 30 { + editorW = 30 } - toggleRow := lipgloss.NewStyle(). - Width(innerW). - Align(lipgloss.Right). - Render(codeLabel + inactiveTabStyle.Render(" | ") + builderLabel) + m.query.SetWidth(editorW - 6) + editorBodyH := topH - 4 // border(2) + title(1) + spacer(1) + if editorBodyH < 1 { + editorBodyH = 1 + } + m.query.SetHeight(editorBodyH) - var queryPanelContent string + var editorBody string if m.queryMode == "builder" { expr := m.query.Value() - var exprDisplay string if expr == "" { - exprDisplay = lipgloss.NewStyle(). - Foreground(StandardSecondary).Width(innerW). - Render("press Enter to open builder...") + editorBody = lipgloss.NewStyle(). + Foreground(p.Faint). + Italic(true). + Render("Press Enter to open builder…") } else { - exprDisplay = lipgloss.NewStyle(). - Foreground(FocusPrimary).Bold(true).Width(innerW). + editorBody = lipgloss.NewStyle(). + Foreground(p.Accent). + Bold(true). Render(expr) } - queryPanelContent = lipgloss.JoinVertical(lipgloss.Left, toggleRow, exprDisplay) } else { - queryPanelContent = lipgloss.JoinVertical(lipgloss.Left, toggleRow, m.query.View()) + editorBody = m.query.View() } - header := lipgloss.JoinHorizontal(lipgloss.Top, - dsRendered, - queryOuter.Render(queryPanelContent), - timeRendered, - stepRendered, + editorFocused := m.currentFocus() == "query" + editorPane := renderPromqlEditorPane(editorBody, editorW, topH, editorFocused, m.queryMode == "builder") + + rangeMode := "range" + if m.instant { + rangeMode = "instant" + } + stepHi := m.currentFocus() == "step" + dsHi := m.currentFocus() == "dataset" + dataset := m.dataset + if dataset == "" { + dataset = "select-dataset" + } + timeHi := m.currentFocus() == "time" + // Two stacked sidebar boxes. Borders touch — same zero-gap join + // used between the top section and the results pane. + // Controls (7 rows) + Date (6 rows) = 13 = topH. + // When step is focused, pass the live textinput View() so its cursor is visible. + stepDisplay := m.step + if stepHi { + stepDisplay = m.stepInput.View() + } + controlsBox := renderPromqlControlsBox( + dataset, stepDisplay, rangeMode, + sidebarW, 7, + dsHi, stepHi, m.instant, ) - headerHeight := lipgloss.Height(header) + dateBox := renderPromqlDateBox( + m.timeRange.start.Value(), m.timeRange.end.Value(), + sidebarW, 6, + timeHi, m.instant, + ) + sidebarPane := lipgloss.JoinVertical(lipgloss.Left, controlsBox, dateBox) - if m.loading { - m.status.Info = "" - m.status.Error = "" + gap := lipgloss.NewStyle().Width(1).Height(topH).Render("") + topSection := lipgloss.JoinHorizontal(lipgloss.Top, editorPane, gap, sidebarPane) + + // ── Results pane ───────────────────────────────────────────────── + availH := m.height - topH - bottomHeight + if availH < 6 { + availH = 6 + } + resultsInnerH := availH - 3 + if resultsInnerH < 3 { + resultsInnerH = 3 + } + resultsInnerW := m.width - 4 + if resultsInnerW < 10 { + resultsInnerW = 10 } - statusView := m.status.View() - statusHeight := lipgloss.Height(statusView) - // ── help ───────────────────────────────────────────────────────────────── - var helpKeys [][]key.Binding - switch m.overlay { - case overlayNone: - switch m.currentFocus() { - case "dataset": - helpKeys = [][]key.Binding{ - {key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "pick dataset"))}, - {promqlAdditionalKeyBinds[0]}, - } - case "query": - if m.queryMode == "builder" { - helpKeys = [][]key.Binding{ - { - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open builder")), - key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "switch to code mode")), - }, - {promqlAdditionalKeyBinds[0]}, - } - } else { - helpKeys = append(TextAreaHelpKeys{}.FullHelp(), - []key.Binding{key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "switch to builder mode"))}, - ) - } - case "time": - timeHint := "edit time range" - if m.instant { - timeHint = "set evaluation time (instant)" - } - helpKeys = [][]key.Binding{ - {key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", timeHint))}, - {promqlAdditionalKeyBinds[0]}, - } - case "step": - helpKeys = [][]key.Binding{ - { - key.NewBinding(key.WithKeys("type"), key.WithHelp("type", "edit step (e.g. 15s, 5m)")), - key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle range/instant")), - }, - { - promqlAdditionalKeyBinds[0], - }, - } - case "table": - helpKeys = tableHelpBinds.FullHelp() - helpKeys = append(helpKeys, promqlAdditionalKeyBinds) - } - case overlayInputs: - helpKeys = m.timeRange.FullHelp() - helpKeys = append(helpKeys, promqlAdditionalKeyBinds) - case overlayDataset: - helpKeys = [][]key.Binding{{ - key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")), - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), - key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), - }} - case overlayBuilder: - helpKeys = [][]key.Binding{ - { - key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")), - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select → next / run")), - }, - { - key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r", "run with current")), - key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), - }, - } - } - helpView := m.help.FullHelpView(helpKeys) - helpHeight := lipgloss.Height(helpView) - - // ── result area ────────────────────────────────────────────────────────── - tableAvail := m.height - headerHeight - helpHeight - statusHeight - pageSize := tableAvail - 6 + pageSize := resultsInnerH - 5 if pageSize < 1 { pageSize = 1 } + m.table = m.table.WithPageSize(pageSize).WithRows(m.dataRows).WithTargetWidth(resultsInnerW) - displayRows := make([]table.Row, pageSize) - copy(displayRows, m.dataRows) - m.table = m.table.WithPageSize(pageSize).WithRows(displayRows).WithTargetWidth(m.width) - - availW := m.width - if availW < 0 { - availW = 0 - } - availH := tableAvail - 2 - if availH < 0 { - availH = 0 - } - tableOuter = tableOuter.Width(m.width) - - var resultPane string - if !m.hasQueried { - logoStyle := lipgloss.NewStyle(). + var inner string + switch { + case !m.hasQueried: + wordmark := lipgloss.NewStyle(). + Foreground(p.Accent). Bold(true). - Foreground(FocusPrimary). - Border(lipgloss.DoubleBorder()). - BorderForeground(FocusSecondary). - Padding(0, 2) - hintStyle := lipgloss.NewStyle(). - Foreground(StandardSecondary). - MarginTop(1) - keyStyle := lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) - logo := logoStyle.Render("P A R S E A B L E") - hint := hintStyle.Render("write a PromQL expression above and press " + keyStyle.Render("ctrl+r") + " to run") - content := lipgloss.JoinVertical(lipgloss.Center, logo, hint) - placed := lipgloss.Place(availW, availH, lipgloss.Center, lipgloss.Center, content) - resultPane = tableOuter.Render(placed) - } else if m.loading { - spinStyle := lipgloss.NewStyle().Foreground(FocusPrimary) - content := spinStyle.Render(m.spinner.View() + " fetching...") - placed := lipgloss.Place(availW, availH, lipgloss.Center, lipgloss.Center, content) - resultPane = tableOuter.Render(placed) - } else if m.fetchErrMsg != "" { + Render(parseableASCIIArt) + inner = lipgloss.Place(resultsInnerW, resultsInnerH, lipgloss.Center, lipgloss.Center, wordmark, + lipgloss.WithWhitespaceChars(" ")) + case m.loading: + content := ui.Type().Accent.Render(m.spinner.View() + " fetching...") + inner = lipgloss.Place(resultsInnerW, resultsInnerH, lipgloss.Center, lipgloss.Center, content, + lipgloss.WithWhitespaceChars(" ")) + case m.fetchErrMsg != "": errStyle := lipgloss.NewStyle(). Padding(1, 2). - Foreground(lipgloss.AdaptiveColor{Light: "#9B2226", Dark: "#FF6B6B"}). - Width(m.width) + Foreground(p.Err). + Width(resultsInnerW) rendered := errStyle.Render(m.fetchErrMsg) lines := strings.Split(rendered, "\n") - maxLines := tableAvail - 2 - if maxLines < 1 { - maxLines = 1 - } - if len(lines) > maxLines { - lines = lines[:maxLines] - } - resultPane = tableOuter.Render(strings.Join(lines, "\n")) - } else { - resultPane = tableOuter.Render(m.table.View()) - } - - // ── compose main or overlay view ───────────────────────────────────────── + if len(lines) > resultsInnerH { + lines = lines[:resultsInnerH] + } + inner = strings.Join(lines, "\n") + case len(m.dataRows) == 0: + msg := lipgloss.NewStyle().Foreground(p.Faint).Render("no results for this query") + inner = lipgloss.Place(resultsInnerW, resultsInnerH, lipgloss.Center, lipgloss.Center, msg, + lipgloss.WithWhitespaceChars(" ")) + default: + tableStr := m.table.View() + tableLines := strings.Split(tableStr, "\n") + // strip any trailing blank line the table renderer may emit + for len(tableLines) > 0 && tableLines[len(tableLines)-1] == "" { + tableLines = tableLines[:len(tableLines)-1] + } + + var bottomRule string + if len(tableLines) > 0 { + bottomRule = tableLines[len(tableLines)-1] + tableLines = tableLines[:len(tableLines)-1] + } + tableBodyH := len(tableLines) + 1 // +1 for the bottom rule line + paddingH := resultsInnerH - 1 - tableBodyH + if paddingH < 0 { + paddingH = 0 + } + // placeholder row — matches table row indent (no left border in customBorder) + dashRow := lipgloss.NewStyle(). + Foreground(p.Ghost). + Width(resultsInnerW). + Render(" --") + var leftPart, rightPart string + if m.table.MaxPages() > 1 { + leftPart = fmt.Sprintf("%d/%d", m.table.CurrentPage(), m.table.MaxPages()) + } + if len(m.dataRows) > 0 { + curRow := m.table.GetHighlightedRowIndex() + 1 + rightPart = fmt.Sprintf("%d | %d rows", curRow, len(m.dataRows)) + } + faint := lipgloss.NewStyle().Foreground(p.Faint) + leftR, rightR := faint.Render(leftPart), faint.Render(rightPart) + gap := resultsInnerW - 2 - lipgloss.Width(leftR) - lipgloss.Width(rightR) + if gap < 1 { + gap = 1 + } + footerLine := lipgloss.NewStyle().Width(resultsInnerW).Padding(0, 1). + Render(leftR + strings.Repeat(" ", gap) + rightR) + // assemble: body rows → "--" placeholders → closing rule → footer + parts := make([]string, 0, len(tableLines)+paddingH+3) + parts = append(parts, strings.Join(tableLines, "\n")) + for i := 0; i < paddingH; i++ { + parts = append(parts, dashRow) + } + parts = append(parts, bottomRule) + parts = append(parts, footerLine) + inner = strings.Join(parts, "\n") + } + { + lines := strings.Split(inner, "\n") + if len(lines) > resultsInnerH { + lines = lines[:resultsInnerH] + } + inner = strings.Join(lines, "\n") + } + resultsPane := renderResultsPane(inner, m.width, availH, 0, m.currentFocus() == "table") + + // ── Compose body or overlay ────────────────────────────────────── + body := lipgloss.JoinVertical(lipgloss.Left, topSection, resultsPane) var mainView string switch m.overlay { case overlayNone: - mainView = lipgloss.JoinVertical(lipgloss.Left, header, resultPane) + mainView = body case overlayInputs: timeView := m.timeRange.View() - mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, + mainView = lipgloss.Place(m.width, m.height-bottomHeight, lipgloss.Center, lipgloss.Center, timeView, lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(StandardSecondary), ) case overlayDataset: - behind := lipgloss.JoinVertical(lipgloss.Left, header, resultPane) spotlight := m.renderSpotlight() - mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, + mainView = lipgloss.Place(m.width, m.height-bottomHeight, lipgloss.Center, lipgloss.Center, spotlight, lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(StandardSecondary), ) - _ = behind case overlayBuilder: - behind := lipgloss.JoinVertical(lipgloss.Left, header, resultPane) builder := m.renderBuilder() - mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, + mainView = lipgloss.Place(m.width, m.height-bottomHeight, lipgloss.Center, lipgloss.Center, builder, lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(StandardSecondary), ) - _ = behind } - mainHeight := lipgloss.Height(mainView) - bottomHeight := helpHeight + statusHeight - padLines := m.height - mainHeight - bottomHeight - if padLines > 0 { - mainView = mainView + strings.Repeat("\n", padLines) + render := lipgloss.JoinVertical(lipgloss.Left, + mainView, + bottomView, + ) + return lipgloss.NewStyle().Width(m.width).Render(render) +} + +func renderPromqlEditorPane(body string, width, height int, focused, builder bool) string { + p := ui.Active + borderColor := p.Border + titleFg := p.Faint + if focused { + borderColor = p.BorderHi + titleFg = p.Accent + } + innerW := width - 2 + if innerW < 4 { + innerW = 4 + } + innerH := height - 2 + if innerH < 3 { + innerH = 3 + } + + left := lipgloss.NewStyle().Foreground(titleFg).Bold(focused).Render("EDITOR") + + activeStyle := lipgloss.NewStyle().Foreground(p.Active).Bold(true) + idle := lipgloss.NewStyle().Foreground(p.Faint) + sepStyle := lipgloss.NewStyle().Foreground(p.Faint) + sep := sepStyle.Render(" | ") + var right string + if builder { + right = idle.Render("Code") + sep + activeStyle.Render("Builder") + } else { + right = activeStyle.Render("Code") + sep + idle.Render("Builder") } - render := lipgloss.JoinVertical(lipgloss.Left, mainView, helpView, statusView) - return lipgloss.NewStyle().Width(m.width).Render(render) + gap := innerW - lipgloss.Width(left) - lipgloss.Width(right) - 2 + if gap < 1 { + gap = 1 + } + titleRow := lipgloss.NewStyle().Width(innerW).Padding(0, 1).Render( + left + strings.Repeat(" ", gap) + right, + ) + spacer := lipgloss.NewStyle().Width(innerW).Render("") + bodyPane := lipgloss.NewStyle(). + Width(innerW). + Height(innerH-2). + Padding(0, 1). + Render(body) + stack := lipgloss.JoinVertical(lipgloss.Left, titleRow, spacer, bodyPane) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(borderColor). + Render(stack) +} + +// sidebarStyles returns the shared label/value/rail styles used by +// the controls and date sidebar boxes. +func sidebarStyles() (dim, val, hi lipgloss.Style, rail string) { + p := ui.Active + dim = lipgloss.NewStyle().Foreground(p.Faint) + val = lipgloss.NewStyle().Foreground(p.Body) + hi = lipgloss.NewStyle().Foreground(p.Active).Bold(true) + rail = lipgloss.NewStyle().Background(p.Active).Render(" ") + return +} + +func renderPromqlControlsBox(dataset, step, mode string, width, height int, datasetHi, stepHi, instant bool) string { + p := ui.Active + innerW := width - 2 + if innerW < 4 { + innerW = 4 + } + innerH := height - 2 + if innerH < 1 { + innerH = 1 + } + dim, val, hi, rail := sidebarStyles() + prefix := func(active bool) string { + if active { + return rail + " " + } + return " " + } + dLabel := dim + if datasetHi { + dLabel = hi + } + sLabel := dim + if stepHi { + sLabel = hi + } + // When step is focused, `step` is already the textinput View() string + // (cursor included); render it directly to avoid stripping the cursor. + stepVal := val.Render(step) + if stepHi { + stepVal = step + } + // In instant mode step is irrelevant — show it greyed out with a dash. + if instant { + sLabel = lipgloss.NewStyle().Foreground(p.Ghost) + stepVal = lipgloss.NewStyle().Foreground(p.Ghost).Render("—") + } + lines := []string{ + prefix(datasetHi) + dLabel.Render("DATASET"), + prefix(datasetHi) + val.Render(dataset), + "", + prefix(stepHi && !instant) + sLabel.Render("STEP ") + stepVal, + prefix(stepHi && !instant) + sLabel.Render("MODE ") + val.Render(mode), + } + body := lipgloss.NewStyle(). + Width(innerW). + Height(innerH). + Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Render(body) +} + +func renderPromqlDateBox(start, end string, width, height int, timeHi, instant bool) string { + p := ui.Active + innerW := width - 2 + if innerW < 4 { + innerW = 4 + } + innerH := height - 2 + if innerH < 1 { + innerH = 1 + } + dim, val, hi, rail := sidebarStyles() + prefix := " " + if timeHi { + prefix = rail + " " + } + label := dim + if timeHi { + label = hi + } + lines := []string{} + if !instant { + lines = append(lines, + prefix+label.Render("FROM"), + prefix+val.Render(start), + ) + } + lines = append(lines, + prefix+label.Render("TO"), + prefix+val.Render(end), + ) + body := lipgloss.NewStyle(). + Width(innerW). + Height(innerH). + Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Render(body) +} + +// buildPromqlBottomBar — two-line footer matching SQL view design. +// Line 1: shortcuts. Line 2: hairline. Line 3: Parseable left · MODE right. +func buildPromqlBottomBar(m PromqlModel, width int) string { + p := ui.Active + + keyStyle := lipgloss.NewStyle().Foreground(p.Accent).Bold(true) + labelStyle := lipgloss.NewStyle().Foreground(p.Faint) + sepStyle := lipgloss.NewStyle().Foreground(p.BorderSoft) + + const pad = 2 + innerW := width - pad*2 + if innerW < 1 { + innerW = 1 + } + padding := strings.Repeat(" ", pad) + + // ── Line 1: shortcuts ───────────────────────────────────────── + hints := promqlKeysForFocus(m) + var keyParts []string + for _, h := range hints { + k := strings.TrimSuffix(strings.TrimPrefix(h.Key, "<"), ">") + keyParts = append(keyParts, + keyStyle.Render("<"+k+">")+labelStyle.Render(" "+strings.ToLower(h.Label)), + ) + } + shortcutsLine := padding + strings.Join(keyParts, " ") + + // ── Line 2: hairline ────────────────────────────────────────── + divider := sepStyle.Render(strings.Repeat("─", width)) + + // ── Line 3: Parseable left · status+MODE right ───────── + connLeft := lipgloss.JoinHorizontal(lipgloss.Bottom, + lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render("Parseable"), + " ", + labelStyle.Render(m.profile.URL), + ) + var rightParts []string + if m.status.Error != "" { + rightParts = append(rightParts, + lipgloss.NewStyle().Foreground(p.Err).Bold(true).Render(m.status.Error), + sepStyle.Render(" │ "), + ) + } + rightParts = append(rightParts, + labelStyle.Render("MODE"), + " ", + lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render(strings.ToUpper(m.status.title)), + ) + connRight := lipgloss.JoinHorizontal(lipgloss.Bottom, rightParts...) + gap := innerW - lipgloss.Width(connLeft) - lipgloss.Width(connRight) + if gap < 1 { + gap = 1 + } + statusLine := padding + connLeft + strings.Repeat(" ", gap) + connRight + padding + + return lipgloss.JoinVertical(lipgloss.Left, shortcutsLine, divider, statusLine) +} + +// promqlKeysForFocus returns context-aware keybind hints for the +// HeaderStrip. Each focused pane / overlay surfaces its real keys. +func promqlKeysForFocus(m PromqlModel) []ui.KeyHint { + common := []ui.KeyHint{ + {Key: "", Label: "Next pane"}, + {Key: "", Label: "Prev pane"}, + {Key: "", Label: "Run"}, + {Key: "", Label: "Quit"}, + } + switch m.overlay { + case overlayDataset: + return []ui.KeyHint{ + {Key: "<↑/↓>", Label: "Navigate"}, + {Key: "", Label: "Select"}, + {Key: "", Label: "Cancel"}, + {Key: "type", Label: "Filter"}, + } + case overlayBuilder: + return []ui.KeyHint{ + {Key: "<↑/↓>", Label: "Navigate"}, + {Key: "", Label: "Next col"}, + {Key: "", Label: "Cycle col"}, + {Key: "", Label: "Run"}, + {Key: "", Label: "Cancel"}, + } + case overlayInputs: + return []ui.KeyHint{ + {Key: "<↑/↓>", Label: "Preset"}, + {Key: "", Label: "Field"}, + {Key: "", Label: "End → now"}, + {Key: "", Label: "Apply"}, + {Key: "", Label: "Cancel"}, + } + } + switch m.currentFocus() { + case "dataset": + return append([]ui.KeyHint{ + {Key: "", Label: "Open picker"}, + {Key: "", Label: "Datasets"}, + }, common...) + case "query": + hints := []ui.KeyHint{ + {Key: "", Label: "Toggle builder"}, + } + if m.queryMode == "builder" { + hints = append(hints, ui.KeyHint{Key: "", Label: "Open builder"}) + } + return append(hints, common...) + case "time": + return append([]ui.KeyHint{ + {Key: "", Label: "Open picker"}, + }, common...) + case "step": + return append([]ui.KeyHint{ + {Key: "type", Label: "Edit (15s, 5m, 1h)"}, + {Key: "", Label: "Toggle range/instant"}, + }, common...) + case "table": + return append([]ui.KeyHint{ + {Key: "<↑/↓>", Label: "Row"}, + {Key: "", Label: "Filter"}, + }, common...) + } + return common } -// renderSpotlight builds the dataset picker modal. +// renderSpotlight builds the dataset picker modal — flat NormalBorder +// frame, UPPERCASE title + count on top row, inline prompt (no inner +// box), and clean list rows. Matches the main view's chrome. func (m PromqlModel) renderSpotlight() string { - innerW := spotlightWidth - 2 + p := ui.Active + // content area inside border(2) + h-padding(4) + innerW := spotlightWidth - 6 + if innerW < 20 { + innerW = 20 + } - titleStyle := lipgloss.NewStyle(). - Foreground(FocusPrimary). + // Header: title left, count right + titleLeft := lipgloss.NewStyle(). + Foreground(p.Accent). Bold(true). - Width(innerW). - Align(lipgloss.Center) - title := titleStyle.Render("Select Dataset") + Render("SELECT DATASET") + countTxt := "" + if !m.datasetsLoading { + countTxt = fmt.Sprintf("%d datasets", len(m.filteredDatasets)) + } + titleRight := lipgloss.NewStyle().Foreground(p.Faint).Render(countTxt) + gap := innerW - lipgloss.Width(titleLeft) - lipgloss.Width(titleRight) + if gap < 1 { + gap = 1 + } + header := titleLeft + strings.Repeat(" ", gap) + titleRight + rule := lipgloss.NewStyle().Foreground(p.BorderSoft).Render(strings.Repeat("─", innerW)) - searchStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(FocusSecondary). - Width(innerW-2). - Padding(0, 1) - searchBar := searchStyle.Render(m.spotlightFilter.View()) + // Inline filter prompt — no inner border. The textinput renders + // its own prompt + cursor; we just place the row inside the body. + searchRow := lipgloss.NewStyle().Width(innerW).Render(m.spotlightFilter.View()) + // List var listLines []string - if m.datasetsLoading { - loadStyle := lipgloss.NewStyle(). - Foreground(StandardSecondary). + switch { + case m.datasetsLoading: + listLines = append(listLines, lipgloss.NewStyle(). + Foreground(p.Faint). Width(innerW). - Align(lipgloss.Center). - Padding(1, 0) - listLines = append(listLines, loadStyle.Render(m.spinner.View()+" loading…")) - } else if len(m.filteredDatasets) == 0 { - emptyStyle := lipgloss.NewStyle(). - Foreground(StandardSecondary). + Padding(1, 0). + Render(" "+m.spinner.View()+" loading…")) + case len(m.filteredDatasets) == 0: + listLines = append(listLines, lipgloss.NewStyle(). + Foreground(p.Faint). Width(innerW). - Align(lipgloss.Center). - Padding(1, 0) - listLines = append(listLines, emptyStyle.Render("no datasets found")) - } else { + Padding(1, 0). + Render(" no datasets found")) + default: limit := len(m.filteredDatasets) if limit > spotlightMaxItems { limit = spotlightMaxItems } - // scroll window around selected index start := 0 if m.datasetSelectedIdx >= spotlightMaxItems { start = m.datasetSelectedIdx - spotlightMaxItems + 1 } + rail := lipgloss.NewStyle().Background(p.Active).Render(" ") for i := start; i < start+limit && i < len(m.filteredDatasets); i++ { ds := m.filteredDatasets[i] if i == m.datasetSelectedIdx { - row := lipgloss.NewStyle(). - Background(FocusPrimary). - Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). - Width(innerW). - Padding(0, 1). + name := lipgloss.NewStyle(). + Foreground(p.Active). Bold(true). - Render("▸ " + ds) - listLines = append(listLines, row) + Render(ds) + listLines = append(listLines, rail+" "+name) } else { - row := lipgloss.NewStyle(). - Width(innerW). - Padding(0, 1). - Render(" " + ds) - listLines = append(listLines, row) + name := lipgloss.NewStyle().Foreground(p.Body).Render(ds) + listLines = append(listLines, " "+name) } } if len(m.filteredDatasets) > spotlightMaxItems { more := lipgloss.NewStyle(). - Foreground(StandardSecondary). + Foreground(p.Faint). Width(innerW). Align(lipgloss.Right). - Render(fmt.Sprintf(" +%d more", len(m.filteredDatasets)-spotlightMaxItems)) + Render(fmt.Sprintf("+%d more", len(m.filteredDatasets)-spotlightMaxItems)) listLines = append(listLines, more) } } body := lipgloss.JoinVertical(lipgloss.Left, - title, - searchBar, + header, + rule, + "", + searchRow, + "", strings.Join(listLines, "\n"), ) - modal := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(FocusPrimary). - Padding(0, 1). + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Padding(1, 2). Width(spotlightWidth). Render(body) - - return modal } // updateTableColumns rebuilds table columns. valueWidth is inferred from data; @@ -1313,9 +1642,9 @@ func (m *PromqlModel) updateTableColumns(_, valueWidth int) { valueWidth = len(promqlValueKey) } columns := []table.Column{ - table.NewColumn(promqlTimestampKey, "timestamp", promqlTimestampWidth), - table.NewFlexColumn(promqlMetricKey, "metric", 1).WithFiltered(true), - table.NewColumn(promqlValueKey, "value", valueWidth).WithFiltered(true), + table.NewColumn(promqlTimestampKey, "TIMESTAMP", promqlTimestampWidth), + table.NewFlexColumn(promqlMetricKey, "METRIC", 1).WithFiltered(true), + table.NewColumn(promqlValueKey, "VALUE", valueWidth).WithFiltered(true), } m.table = m.table.WithColumns(columns).WithTargetWidth(m.width).WithRows(m.dataRows) } @@ -1426,7 +1755,11 @@ func promqlModelFetch(profile config.Profile, path string, params url.Values) ([ func promqlResultToRows(result promqlRespModel) (rows []table.Row, seriesCount, metricWidth, valueWidth int) { metricWidth = len(promqlMetricKey) - valueWidth = len(promqlValueKey) + valueWidth = 14 + + addRow := func(rowData table.RowData) { + rows = append(rows, table.NewRow(rowData)) + } for _, series := range result.Data.Result { metricStr := promqlModelFormatLabels(series.Metric) @@ -1437,30 +1770,30 @@ func promqlResultToRows(result promqlRespModel) (rows []table.Row, seriesCount, switch result.Data.ResultType { case "vector": if len(series.Value) == 2 { - ts := promqlModelFormatTS(series.Value[0]) + ts := trimTimestampToHMS(promqlModelFormatTS(series.Value[0])) val := fmt.Sprintf("%v", series.Value[1]) if len(val) > valueWidth { valueWidth = len(val) } - rows = append(rows, table.NewRow(table.RowData{ + addRow(table.RowData{ promqlTimestampKey: ts, promqlMetricKey: metricStr, promqlValueKey: val, - })) + }) } case "matrix": for _, pt := range series.Values { if len(pt) == 2 { - ts := promqlModelFormatTS(pt[0]) + ts := trimTimestampToHMS(promqlModelFormatTS(pt[0])) val := fmt.Sprintf("%v", pt[1]) if len(val) > valueWidth { valueWidth = len(val) } - rows = append(rows, table.NewRow(table.RowData{ + addRow(table.RowData{ promqlTimestampKey: ts, promqlMetricKey: metricStr, promqlValueKey: val, - })) + }) } } } @@ -1596,26 +1929,32 @@ func buildPromqlExpr(metric, label, value string) string { return fmt.Sprintf(`%s{%s="%s"}`, metric, label, value) } -// renderBuilderCol renders a single column (Metrics / Labels / Values) for the builder overlay. func renderBuilderCol(title string, items []string, selectedIdx int, loading, focused bool, colW int) string { + p := ui.Active innerW := colW - 2 - titleStyle := lipgloss.NewStyle().Bold(true).Width(innerW) + borderColor := p.Border + titleFg := p.Faint if focused { - titleStyle = titleStyle.Foreground(FocusPrimary) - } else { - titleStyle = titleStyle.Foreground(StandardSecondary) + borderColor = p.BorderHi + titleFg = p.Accent } + titleRow := lipgloss.NewStyle(). + Foreground(titleFg). + Bold(true). + Width(innerW). + Padding(0, 1). + Render(strings.ToUpper(title)) var rows []string switch { case loading: rows = append(rows, lipgloss.NewStyle(). - Foreground(StandardSecondary).Width(innerW). + Foreground(p.Faint).Width(innerW).Padding(0, 1). Render("loading...")) case len(items) == 0: rows = append(rows, lipgloss.NewStyle(). - Foreground(StandardSecondary).Width(innerW). + Foreground(p.Faint).Width(innerW).Padding(0, 1). Render("(empty)")) default: start := 0 @@ -1626,6 +1965,7 @@ func renderBuilderCol(title string, items []string, selectedIdx int, loading, fo if end > len(items) { end = len(items) } + rail := lipgloss.NewStyle().Background(p.Active).Render(" ") for i := start; i < end; i++ { item := items[i] maxLen := innerW - 4 @@ -1633,79 +1973,83 @@ func renderBuilderCol(title string, items []string, selectedIdx int, loading, fo item = item[:maxLen-3] + "..." } if i == selectedIdx { - rows = append(rows, lipgloss.NewStyle(). - Background(FocusPrimary). - Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). - Width(innerW).Padding(0, 1).Bold(true). - Render("▸ "+item)) + name := lipgloss.NewStyle(). + Foreground(p.Active). + Bold(true). + Render(item) + rows = append(rows, " "+rail+" "+name) } else { - rows = append(rows, lipgloss.NewStyle(). - Width(innerW).Padding(0, 1). - Render(" "+item)) + name := lipgloss.NewStyle().Foreground(p.Body).Render(item) + rows = append(rows, " "+name) } } } content := lipgloss.JoinVertical(lipgloss.Left, - titleStyle.Render(title), + titleRow, strings.Join(rows, "\n"), ) - borderStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - Width(colW) - if focused { - borderStyle = borderStyle.BorderForeground(FocusPrimary) - } else { - borderStyle = borderStyle.BorderForeground(StandardSecondary) - } - return borderStyle.Render(content) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(borderColor). + Width(colW). + Render(content) } -// renderBuilder builds the 3-column query builder overlay. +// renderBuilder builds the 3-column query builder overlay — flat +// NormalBorder, UPPERCASE title, plain bg. Matches the main view. func (m PromqlModel) renderBuilder() string { + p := ui.Active colW := builderColWidth(m.width) metricsItems := m.builderMetricsFiltered if m.dataset == "" { metricsItems = []string{"── select a dataset first ──"} } - col0 := renderBuilderCol("Metrics", metricsItems, m.builderMetricsIdx, + col0 := renderBuilderCol("metrics", metricsItems, m.builderMetricsIdx, m.builderMetricsLoading, m.builderCol == 0, colW) - col1 := renderBuilderCol("Labels", m.builderLabelsFiltered, m.builderLabelsIdx, + col1 := renderBuilderCol("labels", m.builderLabelsFiltered, m.builderLabelsIdx, m.builderLabelsLoading, m.builderCol == 1, colW) - col2 := renderBuilderCol("Values", m.builderValuesFiltered, m.builderValuesIdx, + col2 := renderBuilderCol("values", m.builderValuesFiltered, m.builderValuesIdx, m.builderValuesLoading, m.builderCol == 2, colW) columns := lipgloss.JoinHorizontal(lipgloss.Top, col0, col1, col2) colsW := lipgloss.Width(columns) expr := buildPromqlExpr(m.builderCurrentMetric(), m.builderCurrentLabel(), m.builderCurrentValue()) - dimStyle := lipgloss.NewStyle().Foreground(StandardSecondary) - exprStyle := lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) - exprLine := dimStyle.Render("Built: ") + exprStyle.Render(expr) + exprLine := lipgloss.NewStyle().Foreground(p.Faint).Render("built ") + + lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render(expr) - searchStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(FocusSecondary). + searchBar := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). Width(colsW-4). - Padding(0, 1) - searchBar := searchStyle.Render(m.builderFilter.View()) + Padding(0, 1). + Render(m.builderFilter.View()) - titleStyle := lipgloss.NewStyle(). - Foreground(FocusPrimary).Bold(true). - Width(colsW).Align(lipgloss.Center) - title := titleStyle.Render("PromQL Query Builder") + title := lipgloss.NewStyle(). + Foreground(p.Accent). + Bold(true). + Width(colsW). + Align(lipgloss.Center). + Render("PROMQL QUERY BUILDER") - body := lipgloss.JoinVertical(lipgloss.Left, title, columns, exprLine, searchBar) + body := lipgloss.JoinVertical(lipgloss.Left, + title, + "", + columns, + "", + exprLine, + "", + searchBar, + ) - modal := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(FocusPrimary). - Padding(0, 1). + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Padding(1, 2). Render(body) - - return modal } // ─── builder async commands ─────────────────────────────────────────────────── @@ -1736,20 +2080,37 @@ func fetchMetricDatasets(profile config.Profile) tea.Cmd { body, _ := io.ReadAll(resp.Body) var items []struct { - Name string `json:"name"` + Name string `json:"name"` + StreamType string `json:"stream_type"` } if err := json.Unmarshal(body, &items); err != nil { return datasetListMsg{errMsg: err.Error()} } - var all, matched []string + // Prefer server-side stream_type (matches exactly what the UI shows). + // Fall back to name-contains-"metrics" only for servers that don't + // return stream_type — never return everything unfiltered. + hasType := false + for _, item := range items { + if item.StreamType != "" { + hasType = true + break + } + } + var all, typed []string for _, item := range items { all = append(all, item.Name) - if strings.Contains(strings.ToLower(item.Name), "metrics") { - matched = append(matched, item.Name) + if hasType { + if item.StreamType == "Metrics" { + typed = append(typed, item.Name) + } + } else { + if strings.Contains(strings.ToLower(item.Name), "metrics") { + typed = append(typed, item.Name) + } } } - datasets := matched + datasets := typed if len(datasets) == 0 { datasets = all } @@ -1764,9 +2125,6 @@ type promqlLabelListResp struct { Error string `json:"error,omitempty"` } -// builderHTTPGetCtx performs an authenticated GET with context for cancellation. -// URLs are built manually so that match[] stays as literal brackets — -// url.Values.Encode percent-encodes them to match%5B%5D, which Parseable ignores. func builderHTTPGetCtx(ctx context.Context, profile config.Profile, rawURL string) ([]byte, error) { client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) diff --git a/pkg/model/query.go b/pkg/model/query.go index 2cad7d2..e735780 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -27,13 +27,15 @@ import ( "os" "pb/pkg/config" "pb/pkg/iterator" + "pb/pkg/ui" + "sort" "strings" "time" "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" table "github.com/evertras/bubble-table/table" @@ -42,62 +44,106 @@ import ( ) const ( - dateTimeWidth = 26 + // Trimmed display width — HH:MM:SS = 8 cells + slack. + dateTimeWidth = 10 dateTimeKey = "p_timestamp" tagKey = "p_tags" metadataKey = "p_metadata" ) -// Style for this widget +// Theme-derived styles. All palette atoms come from pkg/ui — to swap a +// color, edit ui.Dark / ui.Light, not these vars. var ( - FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} - FocusSecondary = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} + FocusPrimary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent }) + FocusSecondary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent2 }) - StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} - StandardSecondary = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} + StandardPrimary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Body }) + StandardSecondary = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Mute }) + + chromeBorder = ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Border }) borderedStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder(), true). - BorderForeground(StandardPrimary). + BorderForeground(chromeBorder). Padding(0) + // Focused pane: single rounded border in brand accent. No double + // border (read as "alert" in TUI) — accent color carries the focus + // signal on its own. borderedFocusStyle = lipgloss.NewStyle(). - Border(lipgloss.DoubleBorder(), true). + Border(lipgloss.RoundedBorder(), true). BorderForeground(FocusPrimary). Padding(0) - baseStyle = lipgloss.NewStyle().BorderForeground(StandardPrimary) - baseBoldUnderlinedStyle = lipgloss.NewStyle().BorderForeground(StandardPrimary).Bold(true) - headerStyle = lipgloss.NewStyle().Inherit(baseStyle).Foreground(FocusSecondary).Bold(true) - tableStyle = lipgloss.NewStyle().Inherit(baseStyle).Align(lipgloss.Left) + baseStyle = lipgloss.NewStyle().BorderForeground(chromeBorder) + // Header: bold + Accent fg, no background fill. Background tints + // fight the terminal theme (especially when the user switches + // light/dark) so we rely on weight + color contrast alone. + headerStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Bold(true). + Padding(0, 1) + // Data rows: Body fg, generous horizontal padding so columns + // breathe and the divider glyphs don't sit flush against text. + tableStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Body })). + Align(lipgloss.Left). + Padding(0, 1) + // Highlight: SelRow bg + bold + Accent text on cursor row. + highlightStyle = lipgloss.NewStyle(). + Background(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.SelRow })). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Bold(true) ) var ( + // customBorder — outer box is drawn by renderResultsPane. Header + // row gets an underline (`Bottom = "─"`) so it reads as a real + // header strip; the top edge stays blank (a single space row) + // because bubble-table forces BorderTop on header cells and any + // non-blank Top char would draw a visible top rule we don't want. + // Empty strings here render as phantom rows in lipgloss, which is + // what caused the value column header to wrap to the line below. customBorder = table.Border{ - Top: "─", - Left: "│", - Right: "│", + Top: " ", Bottom: "─", + Left: "", + Right: "", - TopRight: "╮", - TopLeft: "╭", - BottomRight: "╯", - BottomLeft: "╰", + TopLeft: " ", + TopRight: " ", + BottomLeft: "─", + BottomRight: "─", - TopJunction: "╥", - LeftJunction: "├", - RightJunction: "┤", - BottomJunction: "╨", - InnerJunction: "╫", + TopJunction: " ", + BottomJunction: "─", + LeftJunction: " ", + RightJunction: " ", + InnerJunction: "─", - InnerDivider: "║", + InnerDivider: "│", } - additionalKeyBinds = []key.Binding{runQueryKey} - - QueryNavigationMap = []string{"query", "time", "table"} + QueryNavigationMap = []string{"query", "dataset", "column", "time", "table"} ) +func sqlPageSize(totalH, bottomH int) int { + const topH = 14 + av := totalH - topH - bottomH + if av < 6 { + av = 6 + } + rih := av - 3 // results pane border(2) + title(1) + if rih < 3 { + rih = 3 + } + ps := rih - 5 // table overhead: header(3) + bottom-rule(1) + footer-line(1) + if ps < 1 { + ps = 1 + } + return ps +} + type ( Mode int FetchResult int @@ -110,6 +156,11 @@ type FetchData struct { errMsg string } +type schemaMsg struct { + columns []string + errMsg string +} + const ( fetchOk FetchResult = iota fetchErr @@ -120,6 +171,8 @@ const ( overlayInputs ) +const overlayColumn uint = 3 + type QueryModel struct { width int height int @@ -137,11 +190,31 @@ type QueryModel struct { focused int dataRows []table.Row // actual data rows (without padding) fetchErrMsg string // last fetch error, shown in the result area + + // dataset spotlight + dataset string + spotlightFilter textinput.Model + allDatasets []string + filteredDatasets []string + datasetSelectedIdx int + datasetsLoading bool + + // column spotlight — populated when a dataset is selected + selectedColumn string + columnFilter textinput.Model + allColumns []string + filteredColumns []string + columnSelectedIdx int + columnsLoading bool + + schema []string } func (m *QueryModel) focusSelected() { m.query.Blur() m.table = m.table.Focused(false) + m.spotlightFilter.Blur() + m.columnFilter.Blur() switch m.currentFocus() { case "query": @@ -166,7 +239,7 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t rows := make([]table.Row, 0) - pageSize := h - 14 // header(4) + help(4) + status(1) + table-overhead(6) = 15; -1 buffer + pageSize := sqlPageSize(h, 3) if pageSize < 5 { pageSize = 5 } @@ -177,62 +250,98 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t HeaderStyle(headerStyle). SelectableRows(false). Border(customBorder). - Focused(true). + Focused(false). WithKeyMap(tableKeyBinds). WithPageSize(pageSize). WithBaseStyle(tableStyle). + HighlightStyle(highlightStyle). WithMissingDataIndicatorStyled(table.StyledCell{ - Style: lipgloss.NewStyle().Foreground(StandardSecondary), - Data: "╌", - }).WithMaxTotalWidth(w) + Style: lipgloss.NewStyle().Foreground(chromeBorder), + Data: "—", + }). + WithMaxTotalWidth(w). + WithFooterVisibility(false) query := textarea.New() query.MaxHeight = 0 query.MaxWidth = 0 - query.SetHeight(2) + query.SetHeight(10) query.SetWidth(70) query.ShowLineNumbers = true + // Hide vim-style `~` tildes — they're the textarea default end-of- + // buffer glyph and read as "this UI is broken". Render a space so + // the gutter stays aligned but produces no visual noise. + query.EndOfBufferCharacter = ' ' query.SetValue(queryStr) - query.Placeholder = "write your SQL query here..." + query.Placeholder = "Write your queries here" query.KeyMap = textAreaKeyMap + + // Theme-aware editor styles. Active-line gets a subtle bg shift + // (EditorActive) so the cursor row stands out; line numbers in + // Faint, prompt mark in Accent. Mirrors the mock editor look. + applyEditorStyles(&query) query.Focus() help := help.New() - help.Styles.FullDesc = lipgloss.NewStyle().Foreground(FocusSecondary) + help.Styles.FullDesc = ui.Type().Dim status := NewStatusBar(profile.URL, w) sp := spinner.New() sp.Spinner = spinner.Line - sp.Style = lipgloss.NewStyle().Foreground(FocusPrimary) + sp.Style = ui.Type().Accent + + sf := textinput.New() + sf.Placeholder = "filter datasets" + sf.Prompt = "> " + sf.PromptStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Bold(true) + sf.PlaceholderStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Ghost })). + Italic(true) + sf.Width = spotlightWidth - 8 + sf.Blur() + + cf := textinput.New() + cf.Placeholder = "filter columns" + cf.Prompt = "> " + cf.PromptStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Bold(true) + cf.PlaceholderStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Ghost })). + Italic(true) + cf.Width = spotlightWidth - 8 + cf.Blur() hasQuery := strings.TrimSpace(queryStr) != "" model := QueryModel{ - width: w, - height: h, - table: table, - query: query, - timeRange: inputs, - overlay: overlayNone, - profile: profile, - help: help, - spinner: sp, - loading: hasQuery, - hasQueried: hasQuery, - queryIterator: nil, - status: status, + width: w, + height: h, + table: table, + query: query, + timeRange: inputs, + overlay: overlayNone, + profile: profile, + help: help, + spinner: sp, + loading: hasQuery, + hasQueried: hasQuery, + queryIterator: nil, + status: status, + spotlightFilter: sf, + columnFilter: cf, } return model } func (m QueryModel) Init() tea.Cmd { - if strings.TrimSpace(m.query.Value()) == "" { - return m.spinner.Tick + cmds := []tea.Cmd{m.spinner.Tick, fetchAllStreams(m.profile)} + if strings.TrimSpace(m.query.Value()) != "" { + cmds = append(cmds, NewFetchTask(m.profile, resolveColumnPlaceholder(resolveDatasetPlaceholder(m.query.Value(), m.dataset), m.selectedColumn), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) } - return tea.Batch( - m.spinner.Tick, - NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()), - ) + return tea.Batch(cmds...) } func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -253,7 +362,37 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.help.Width = m.width m.status.width = m.width - m.table = m.table.WithMaxTotalWidth(m.width) + bh := lipgloss.Height(buildBottomBar(m, m.width)) + m.table = m.table.WithMaxTotalWidth(m.width).WithPageSize(sqlPageSize(m.height, bh)) + return m, nil + + case schemaMsg: + m.columnsLoading = false + if msg.errMsg == "" && len(msg.columns) > 0 { + m.allColumns = msg.columns + m.filteredColumns = msg.columns + m.columnSelectedIdx = 0 + } + return m, nil + + case datasetListMsg: + m.datasetsLoading = false + if msg.errMsg != "" { + m.status.Error = "could not load datasets: " + msg.errMsg + } else { + m.allDatasets = msg.datasets + m.filteredDatasets = msg.datasets + m.datasetSelectedIdx = 0 + if m.dataset == "" && len(msg.datasets) > 0 { + m.dataset = msg.datasets[0] + } + for i, ds := range m.filteredDatasets { + if ds == m.dataset { + m.datasetSelectedIdx = i + break + } + } + } return m, nil case FetchData: @@ -263,7 +402,15 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.fetchErrMsg = "" m.UpdateTable(msg) m.status.Error = "" - m.status.Info = fmt.Sprintf("%d rows", len(m.dataRows)) + m.status.Info = "" + // Recompute page size now that status.Info changed — buildBottomBar + // height may shift, causing stored pageSize to drift from View()'s + // computed value, which breaks cursor-to-page mapping in navigation. + bh := lipgloss.Height(buildBottomBar(m, m.width)) + m.table = m.table.WithPageSize(sqlPageSize(m.height, bh)) + // Move focus to results table after a successful fetch. + m.focused = 4 + m.focusSelected() } else { m.dataRows = []table.Row{} m.table = m.table.WithRows([]table.Row{}) @@ -277,8 +424,133 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Is it a key press? case tea.KeyMsg: + + // ── dataset spotlight overlay ──────────────────────────────────── + if m.overlay == overlayDataset { + switch msg.Type { + case tea.KeyEsc: + m.overlay = overlayNone + m.spotlightFilter.SetValue("") + m.spotlightFilter.Blur() + m.focusSelected() + return m, nil + + case tea.KeyEnter: + if len(m.filteredDatasets) > 0 { + selected := m.filteredDatasets[m.datasetSelectedIdx] + if selected != m.dataset { + // dataset changed — reset column and fetch fresh schema + m.dataset = selected + m.selectedColumn = "" + m.allColumns = []string{} + m.filteredColumns = []string{} + m.columnsLoading = true + cmds = append(cmds, fetchStreamSchema(m.profile, selected)) + } + if strings.TrimSpace(m.query.Value()) == "" { + m.query.SetValue("SELECT * FROM dataset LIMIT 100") + m.query.CursorEnd() + } + } + m.overlay = overlayNone + m.spotlightFilter.SetValue("") + m.spotlightFilter.Blur() + m.focused = 0 // focus query editor after dataset select + m.focusSelected() + return m, tea.Batch(cmds...) + + case tea.KeyUp: + if m.datasetSelectedIdx > 0 { + m.datasetSelectedIdx-- + } + return m, nil + + case tea.KeyDown: + if m.datasetSelectedIdx < len(m.filteredDatasets)-1 { + m.datasetSelectedIdx++ + } + return m, nil + + default: + prev := m.spotlightFilter.Value() + m.spotlightFilter, cmd = m.spotlightFilter.Update(msg) + cmds = append(cmds, cmd) + if m.spotlightFilter.Value() != prev { + m.filteredDatasets = filterDatasets(m.allDatasets, m.spotlightFilter.Value()) + m.datasetSelectedIdx = 0 + } + return m, tea.Batch(cmds...) + } + } + + // ── column spotlight overlay ───────────────────────────────────── + if m.overlay == overlayColumn { + switch msg.Type { + case tea.KeyEsc: + m.overlay = overlayNone + m.columnFilter.SetValue("") + m.columnFilter.Blur() + m.focusSelected() + return m, nil + + case tea.KeyEnter: + if len(m.filteredColumns) > 0 { + m.selectedColumn = m.filteredColumns[m.columnSelectedIdx] + m.query.InsertString("column ") + } + m.overlay = overlayNone + m.columnFilter.SetValue("") + m.columnFilter.Blur() + m.focused = 0 + m.focusSelected() + return m, nil + + case tea.KeyUp: + if m.columnSelectedIdx > 0 { + m.columnSelectedIdx-- + } + return m, nil + + case tea.KeyDown: + if m.columnSelectedIdx < len(m.filteredColumns)-1 { + m.columnSelectedIdx++ + } + return m, nil + + default: + prev := m.columnFilter.Value() + m.columnFilter, cmd = m.columnFilter.Update(msg) + cmds = append(cmds, cmd) + if m.columnFilter.Value() != prev { + m.filteredColumns = filterDatasets(m.allColumns, m.columnFilter.Value()) + m.columnSelectedIdx = 0 + } + return m, tea.Batch(cmds...) + } + } + // special behavior on main page if m.overlay == overlayNone { + if msg.Type == tea.KeyCtrlD { + m.overlay = overlayDataset + m.spotlightFilter.Focus() + m.datasetsLoading = true + return m, fetchAllStreams(m.profile) + } + + if msg.Type == tea.KeyEnter && m.currentFocus() == "dataset" { + m.overlay = overlayDataset + m.spotlightFilter.Focus() + m.datasetsLoading = true + return m, fetchAllStreams(m.profile) + } + + if msg.Type == tea.KeyEnter && m.currentFocus() == "column" { + m.overlay = overlayColumn + m.columnFilter.Focus() + return m, nil + } + if msg.Type == tea.KeyEnter && m.currentFocus() == "time" { m.overlay = overlayInputs return m, nil @@ -301,10 +573,29 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusSelected() return m, nil } + + // up/down arrows navigate between dataset and column rows + if m.currentFocus() == "dataset" && msg.Type == tea.KeyDown { + m.focused = 2 // column + m.focusSelected() + return m, nil + } + if m.currentFocus() == "column" && msg.Type == tea.KeyUp { + m.focused = 1 // dataset + m.focusSelected() + return m, nil + } } // special behavior on time input page if m.overlay == overlayInputs { + // Esc: close modal without applying. Returns to main view + // with previous start/end intact. + if msg.Type == tea.KeyEsc { + m.overlay = overlayNone + m.focusSelected() + return m, nil + } if msg.Type == tea.KeyEnter { m.overlay = overlayNone m.focusSelected() @@ -312,18 +603,21 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status.Info = "" m.loading = true m.hasQueried = true - return m, tea.Batch(m.spinner.Tick, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) + return m, tea.Batch(m.spinner.Tick, NewFetchTask(m.profile, resolveColumnPlaceholder(resolveDatasetPlaceholder(m.query.Value(), m.dataset), m.selectedColumn), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) } } - // common keybind - if msg.Type == tea.KeyCtrlR { + // common keybind — Ctrl+R, Alt+Enter (Cmd+Enter on macOS once + // the terminal is configured to send Meta on Cmd) all run the + // current query. + isAltEnter := msg.Alt && msg.Type == tea.KeyEnter + if msg.Type == tea.KeyCtrlR || isAltEnter { m.overlay = overlayNone m.status.Error = "" m.status.Info = "" m.loading = true m.hasQueried = true - return m, tea.Batch(m.spinner.Tick, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) + return m, tea.Batch(m.spinner.Tick, NewFetchTask(m.profile, resolveColumnPlaceholder(resolveDatasetPlaceholder(m.query.Value(), m.dataset), m.selectedColumn), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) } if msg.Type == tea.KeyCtrlB { @@ -361,166 +655,604 @@ func (m QueryModel) View() string { if m.width == 0 || m.height == 0 { return "" } + p := ui.Active - // Step 1: build the fixed-height components and measure them. - timePane := lipgloss.JoinVertical( - lipgloss.Left, - fmt.Sprintf("%s %s ", baseBoldUnderlinedStyle.Render(" start "), m.timeRange.start.Value()), - fmt.Sprintf("%s %s ", baseBoldUnderlinedStyle.Render(" end "), m.timeRange.end.Value()), - ) - - queryOuter, timeOuter := &borderedStyle, &borderedStyle - tableOuter := lipgloss.NewStyle() - switch m.currentFocus() { - case "query": - queryOuter = &borderedFocusStyle - case "time": - timeOuter = &borderedFocusStyle - case "table": - tableOuter = tableOuter.Border(lipgloss.DoubleBorder(), false, false, false, true). - BorderForeground(FocusPrimary) - } - - // render time first so query gets exactly the remaining width - timeRendered := timeOuter.Render(timePane) - queryW := m.width - lipgloss.Width(timeRendered) - if queryW < 30 { - queryW = 30 - } - m.query.SetWidth(queryW - 2) // -2 for query panel border - header := lipgloss.JoinHorizontal(lipgloss.Top, - queryOuter.Render(m.query.View()), - timeRendered, - ) - headerHeight := lipgloss.Height(header) + // No breadcrumbs — minimal layout: editor + time on top, table, + // helper, status. Per scope: 5 zones only. + crumbsHeight := 0 + // ── 2. Status bar / help (precompute heights) ───────────────────── if m.loading { m.status.Info = "" m.status.Error = "" } - statusView := m.status.View() - statusHeight := lipgloss.Height(statusView) + m.status.SetMode("SQL") + bottomView := buildBottomBar(m, m.width) + bottomHeight := lipgloss.Height(bottomView) + + // ── 3. TOP row: editor (wide) + date (narrow). Plain rectangles, + // label-only chrome. Date pane stays compact per mock. + // Sidebar holds DATASET + FROM + TO = 8 content rows; topH must + // stay >= 11 (innerH = 9 fits 8 + spare) or the sidebar overflows + // and pushes the top border off-screen. + // Sidebar width matches PromQL so the two views read symmetric. + sidebarW := 30 + if m.width >= 140 { + sidebarW = 34 + } + if m.width < 100 { + sidebarW = 26 + } - // Step 2: build help view and measure it. - var helpKeys [][]key.Binding - switch m.overlay { - case overlayNone: - switch m.currentFocus() { - case "query": - helpKeys = TextAreaHelpKeys{}.FullHelp() - case "time": - helpKeys = [][]key.Binding{ - {key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select timeRange"))}, - } - helpKeys = append(helpKeys, additionalKeyBinds) - case "table": - helpKeys = tableHelpBinds.FullHelp() - helpKeys = append(helpKeys, additionalKeyBinds) + // topH = dateBox(7) + combined dataset+column box(7) = 14 + const topH = 14 + + // editorW reserves 1 col for the horizontal gap between editor + // and sidebar, so the two │ borders aren't flush against each other. + editorW := m.width - sidebarW - 1 + if editorW < 30 { + editorW = 30 + sidebarW = m.width - editorW - 1 + } + m.query.SetWidth(editorW - 6) + editorBodyH := topH - 4 // border(2) + title(1) + spacer(1) + if editorBodyH < 1 { + editorBodyH = 1 + } + m.query.SetHeight(editorBodyH) + editorPane := renderEditorPane(m.query.View(), editorW, topH, m.currentFocus() == "query") + + // Prefer the explicitly selected dataset; fall back to parsing the FROM clause. + dataset := m.dataset + if dataset == "" { + if extracted := extractDataset(m.query.Value()); extracted != "—" && extracted != "" { + dataset = extracted } - case overlayInputs: - helpKeys = m.timeRange.FullHelp() - helpKeys = append(helpKeys, additionalKeyBinds) } - helpView := m.help.FullHelpView(helpKeys) - helpHeight := lipgloss.Height(helpView) - - // Step 3: calculate exact table page size so everything fits. - tableAvail := m.height - headerHeight - helpHeight - statusHeight - pageSize := tableAvail - 6 - if pageSize < 1 { - pageSize = 1 + if dataset == "" { + dataset = "select-dataset" } - // Pad rows to pageSize so the table always fills its allocated height. - // Empty rows render as blank lines inside the table border. - displayRows := make([]table.Row, pageSize) - copy(displayRows, m.dataRows) + // Two stacked sidebar boxes — DATE on top, combined DATASET+COLUMN below. + dateBox := renderSQLDateBox( + m.timeRange.start.Value(), + m.timeRange.end.Value(), + sidebarW, 7, + m.currentFocus() == "time", + ) + colLabel := m.selectedColumn + if colLabel == "" { + colLabel = "" { + colValStyle = ghost + } + + maxVal := innerW - lipgloss.Width(dsPrefix) + if maxVal < 4 { + maxVal = 4 + } + lines := []string{ + dsPrefix + dsLabel.Render("DATASET"), + dsPrefix + body.Render(ui.Truncate(dataset, maxVal)), + "", + colPrefix + colLabel.Render("COLUMN"), + colPrefix + colValStyle.Render(ui.Truncate(colDisplay, maxVal)), + } + content := lipgloss.NewStyle(). + Width(innerW). + Height(innerH). + Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + + borderColor := p.Border + if focusedOn == "dataset" || focusedOn == "column" { + borderColor = p.BorderHi + } + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(borderColor). + Render(content) +} + +// renderSQLDateBox draws the bottom SQL sidebar card: FROM + TO. +// Focused state uses the Active (sky-blue) rail + bold label +// convention shared with PromQL. +func renderSQLDateBox(start, end string, width, height int, focused bool) string { + p := ui.Active + innerW := width - 2 + if innerW < 4 { + innerW = 4 + } + innerH := height - 2 + if innerH < 1 { + innerH = 1 + } + dim := lipgloss.NewStyle().Foreground(p.Faint) + val := lipgloss.NewStyle().Foreground(p.Body) + label := dim + prefix := " " + if focused { + label = lipgloss.NewStyle().Foreground(p.Active).Bold(true) + prefix = lipgloss.NewStyle().Background(p.Active).Render(" ") + " " + } + lines := []string{ + prefix + label.Render("FROM"), + prefix + val.Render(start), + "", + prefix + label.Render("TO"), + prefix + val.Render(end), + } + body := lipgloss.NewStyle(). + Width(innerW). + Height(innerH). + Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Render(body) +} + +// renderResultsPane wraps the table (or empty-state / loading / error +// body) in a flat rectangle with a single label row. Row count appears +// dim-right of the label when there is data. +func renderResultsPane(body string, width, height, rowCount int, focused bool) string { + p := ui.Active + borderColor := p.Border + titleFg := p.Faint + if focused { + borderColor = p.BorderHi + titleFg = p.Accent + } + innerW := width - 2 + if innerW < 4 { + innerW = 4 + } + innerH := height - 2 + if innerH < 3 { + innerH = 3 + } + left := lipgloss.NewStyle().Foreground(titleFg).Bold(focused).Render("RESULTS") + var right string + if rowCount > 0 { + right = lipgloss.NewStyle(). + Foreground(p.Faint). + Render(fmt.Sprintf("%d rows", rowCount)) + } + gap := innerW - lipgloss.Width(left) - lipgloss.Width(right) - 2 + if gap < 1 { + gap = 1 + } + titleRow := lipgloss.NewStyle().Width(innerW).Padding(0, 1).Render( + left + strings.Repeat(" ", gap) + right, + ) + bodyPane := lipgloss.NewStyle(). + Width(innerW). + Height(innerH-1). + Padding(0, 1). + Render(body) + stack := lipgloss.JoinVertical(lipgloss.Left, titleRow, bodyPane) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(borderColor). + Render(stack) +} + +// buildBottomBar — single combined help+status row. Left side carries +// the focus-aware key hints; right side carries the meta block (info +// from results, then MODE, then LIVE). Replaces the previous two +// separate bordered strips. +func buildBottomBar(m QueryModel, width int) string { + p := ui.Active + + keyStyle := lipgloss.NewStyle().Foreground(p.Accent).Bold(true) + labelStyle := lipgloss.NewStyle().Foreground(p.Faint) + sepStyle := lipgloss.NewStyle().Foreground(p.BorderSoft) + + // ── Line 1: shortcuts ───────────────────────────────────────── + hints := queryKeysForFocus(m) + var keyParts []string + for _, h := range hints { + k := strings.TrimSuffix(strings.TrimPrefix(h.Key, "<"), ">") + keyParts = append(keyParts, + keyStyle.Render("<"+k+">")+labelStyle.Render(" "+strings.ToLower(h.Label)), + ) + } + const pad = 1 + innerW := width - pad*2 + if innerW < 1 { + innerW = 1 + } + padding := strings.Repeat(" ", pad) + + shortcutsLine := padding + strings.Join(keyParts, " ") + + // ── Line 2: hairline ────────────────────────────────────────── + divider := sepStyle.Render(strings.Repeat("─", width)) + + // ── Line 3: Parseable left · status+MODE right ───────── + connLeft := lipgloss.JoinHorizontal(lipgloss.Bottom, + lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render("Parseable"), + " ", + labelStyle.Render(m.profile.URL), + ) + var rightParts []string + if m.status.Error != "" { + rightParts = append(rightParts, + lipgloss.NewStyle().Foreground(p.Err).Bold(true).Render(m.status.Error), + sepStyle.Render(" │ "), ) } + rightParts = append(rightParts, + labelStyle.Render("MODE"), + " ", + lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render(strings.ToUpper(m.status.title)), + ) + connRight := lipgloss.JoinHorizontal(lipgloss.Bottom, rightParts...) + gap := innerW - lipgloss.Width(connLeft) - lipgloss.Width(connRight) + if gap < 1 { + gap = 1 + } + statusLine := padding + connLeft + strings.Repeat(" ", gap) + connRight + padding + + return lipgloss.JoinVertical(lipgloss.Left, shortcutsLine, divider, statusLine) +} - // Pin help+status to the bottom by padding the main view to fill remaining height. - mainHeight := lipgloss.Height(mainView) - bottomHeight := helpHeight + statusHeight - padLines := m.height - mainHeight - bottomHeight - if padLines > 0 { - mainView = mainView + strings.Repeat("\n", padLines) +// queryKeysForFocus returns the keybind hints shown in the HeaderStrip +// based on which pane is focused. Mirrors what bubbles help did before +// the chrome refactor — context-aware help is back. +func queryKeysForFocus(m QueryModel) []ui.KeyHint { + common := []ui.KeyHint{ + {Key: "", Label: "Next pane"}, + {Key: "", Label: "Prev pane"}, + {Key: "", Label: "Run"}, + {Key: "", Label: "Quit"}, + } + switch m.overlay { + case overlayInputs: + return []ui.KeyHint{ + {Key: "<↑/↓>", Label: "Preset"}, + {Key: "", Label: "Field"}, + {Key: "", Label: "End → now"}, + {Key: "", Label: "Apply"}, + {Key: "", Label: "Cancel"}, + } } + switch m.currentFocus() { + case "dataset": + return append([]ui.KeyHint{ + {Key: "", Label: "Open selector"}, + {Key: "", Label: "Open selector"}, + }, common...) + case "column": + return append([]ui.KeyHint{ + {Key: "", Label: "Open selector"}, + {Key: "", Label: "Dataset"}, + }, common...) + case "time": + return append([]ui.KeyHint{ + {Key: "", Label: "Open picker"}, + }, common...) + case "table": + return append([]ui.KeyHint{ + {Key: "<↑/↓>", Label: "Row"}, + {Key: "", Label: "Filter"}, + }, common...) + } + return common +} - render := lipgloss.JoinVertical(lipgloss.Left, mainView, helpView, statusView) - return lipgloss.NewStyle().Width(m.width).Render(render) +// trimTimestampToHMS extracts the HH:MM:SS portion of an RFC3339-ish +// timestamp (or any string containing `T