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/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..2a5aed7 --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,67 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "pb/pkg/ui" + "pb/pkg/ui/views" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +// TuiCmd is the greenfield TUI entrypoint. It does NOT replace the +// existing `pb query -i` interactive mode; both coexist while the new +// pkg/ui/views/* implementations reach parity. +// +// pb tui → start on picker +// PB_THEME=light pb tui → light palette +var TuiCmd = &cobra.Command{ + Use: "tui", + Short: "Open the redesigned interactive TUI (greenfield)", + Long: "Open the redesigned TUI (pkg/ui/*). Picker is the entry view; use 1-7 or breadcrumbs to switch.", + PersistentPreRunE: PreRunDefaultProfile, + RunE: runTUI, +} + +func runTUI(_ *cobra.Command, _ []string) error { + // Active profile + theme. PreRunDefaultProfile already populated + // the package-level DefaultProfile var. + profile := DefaultProfile + if profile.URL == "" { + return fmt.Errorf("no default profile — run `pb profile add` and `pb profile default ` first") + } + ui.SetActive(ui.LoadTheme()) + ui.SetActiveIcons(ui.LoadIcons()) + + // Register one view per ViewID. Picker is real; others are empty + // placeholders until they get ported in subsequent PRs. + vmap := map[ui.ViewID]ui.View{ + ui.ViewQuery: views.EmptyView{Title: "QUERY", Hint: "SQL editor — coming next. Use `pb query -i` for now."}, + ui.ViewResults: views.EmptyView{Title: "RESULTS", Hint: "Run a query to see results here."}, + ui.ViewMetrics: views.EmptyView{Title: "METRICS", Hint: "PromQL editor — coming next. Use `pb query --promql -i` for now."}, + ui.ViewPicker: views.NewPicker(), + ui.ViewTime: views.EmptyView{Title: "TIME RANGE", Hint: "Time picker modal — coming next."}, + ui.ViewSaved: views.EmptyView{Title: "SAVED", Hint: "Saved queries — coming next."}, + ui.ViewHelp: views.EmptyView{Title: "HELP", Hint: "Press 1-7 to switch views. Esc returns to query. Ctrl-c quits."}, + } + app := ui.NewApp(profile, vmap) + + _, err := tea.NewProgram(app, tea.WithAltScreen()).Run() + return err +} + diff --git a/go.mod b/go.mod index 8da8ebf..c334b63 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/alecthomas/chroma/v2 v2.24.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -55,6 +56,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 @@ -90,6 +92,7 @@ require ( github.com/gorilla/websocket v1.5.0 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/guptarohit/asciigraph v0.9.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/huandu/xstrings v1.5.0 // 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/main.go b/main.go index 45af08a..5fcd5b7 100644 --- a/main.go +++ b/main.go @@ -296,6 +296,7 @@ func main() { cli.AddCommand(pb.LoginCmd) cli.AddCommand(pb.LogoutCmd) cli.AddCommand(pb.StatusCmd) + cli.AddCommand(pb.TuiCmd) // Set as command pb.VersionCmd.Run = func(_ *cobra.Command, _ []string) { 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..7ffebd8 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. @@ -379,20 +384,40 @@ func breadcrumb(trail string) string { return dimStyle.Render(" "+trail+" ›") + " " } -// View renders the current wizard step. +// 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) +} + +// rowIdle — 4-space prefix + Body label, matches the arrow indent. +func rowIdle(label string) string { + return " " + normalStyle.Render(label) +} + +// 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 +425,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 +505,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..07e6e3a 100644 --- a/pkg/model/promql.go +++ b/pkg/model/promql.go @@ -25,6 +25,7 @@ import ( "net/url" "os" "pb/pkg/config" + "pb/pkg/ui" "sort" "strings" "time" @@ -46,7 +47,7 @@ const ( promqlTimestampKey = "timestamp" promqlMetricKey = "metric" promqlValueKey = "value" - promqlTimestampWidth = 20 + promqlTimestampWidth = 10 // matches SQL dateTimeWidth (HH:MM:SS + slack) // header panel outer widths (inner = outer - 2 for borders) datasetPanelOuter = 30 @@ -235,9 +236,9 @@ 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 @@ -256,7 +257,7 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time WithPageSize(pageSize). WithBaseStyle(tableStyle). WithMissingDataIndicatorStyled(table.StyledCell{ - Style: lipgloss.NewStyle().Foreground(StandardSecondary), + Style: ui.Type().Mute, Data: "╌", }).WithTargetWidth(w) @@ -269,10 +270,12 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time q.MaxWidth = 0 q.SetHeight(1) q.SetWidth(qw) - q.ShowLineNumbers = false + q.ShowLineNumbers = true + q.EndOfBufferCharacter = ' ' q.SetValue(expr) - q.Placeholder = "write your PromQL expression here..." + q.Placeholder = "Write your queries here" q.KeyMap = textAreaKeyMap + applyEditorStyles(&q) q.Focus() si := textinput.New() @@ -282,23 +285,37 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time 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 hasQuery := strings.TrimSpace(expr) != "" return PromqlModel{ @@ -373,7 +390,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 @@ -752,6 +778,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() @@ -804,8 +835,9 @@ 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 { m.overlay = overlayNone m.status.Error = "" m.status.Info = "" @@ -920,390 +952,694 @@ 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 - } - datasetPane := lipgloss.JoinVertical(lipgloss.Left, - baseBoldUnderlinedStyle.Render(" dataset "), - dsNameRendered, - ) - - 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 - } - 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) - } - 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") + // ── Status + help (precompute heights) ────────────────────────── + if m.loading { + m.status.Info = "" + m.status.Error = "" } - toggleRow := lipgloss.NewStyle(). - Width(innerW). - Align(lipgloss.Right). - Render(codeLabel + inactiveTabStyle.Render(" | ") + builderLabel) - - var queryPanelContent string + m.status.SetMode("PromQL") + if len(m.dataRows) > 0 { + m.status.Info = fmt.Sprintf("series %d", len(m.dataRows)) + } + bottomView := buildPromqlBottomBar(m, m.width) + bottomHeight := lipgloss.Height(bottomView) + + // Top row: editor (wide) + sidebar (narrow). Sidebar is split + // into TWO stacked bordered boxes — controls (DATASET / STEP / + // MODE) on top, DATE (FROM / TO) below. Total sidebar height + // must match editor pane height for symmetry; topH=14 is the + // minimum that fits both boxes plus a 1-row gap. + topH := 14 + sidebarW := 30 + if m.width >= 140 { + sidebarW = 34 + } + if m.width < 100 { + sidebarW = 26 + } + // 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) + + 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" + } + sidebarFocused := m.currentFocus() == "time" || + m.currentFocus() == "step" || + m.currentFocus() == "dataset" + timeHi := m.currentFocus() == "time" + stepHi := m.currentFocus() == "step" + dsHi := m.currentFocus() == "dataset" + dataset := m.dataset + if dataset == "" { + dataset = "select-dataset" + } + _ = sidebarFocused + // Two stacked sidebar boxes. Borders touch — same zero-gap join + // used between the top section and the results pane. + // Controls (8 rows) + Date (6 rows) = 14 = topH. + controlsBox := renderPromqlControlsBox( + dataset, m.step, rangeMode, + sidebarW, 8, + dsHi, stepHi, + ) + dateBox := renderPromqlDateBox( + m.timeRange.start.Value(), m.timeRange.end.Value(), + sidebarW, 6, + timeHi, m.instant, ) - headerHeight := lipgloss.Height(header) + sidebarPane := lipgloss.JoinVertical(lipgloss.Left, controlsBox, dateBox) - if m.loading { - m.status.Info = "" - m.status.Error = "" - } - statusView := m.status.View() - statusHeight := lipgloss.Height(statusView) + gap := lipgloss.NewStyle().Width(1).Height(topH).Render("") + topSection := lipgloss.JoinHorizontal(lipgloss.Top, editorPane, gap, sidebarPane) - // ── 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 - if pageSize < 1 { - pageSize = 1 + // ── Results pane ───────────────────────────────────────────────── + availH := m.height - topH - bottomHeight + if availH < 6 { + availH = 6 } - - 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 + resultsInnerH := availH - 3 + if resultsInnerH < 3 { + resultsInnerH = 3 } - availH := tableAvail - 2 - if availH < 0 { - availH = 0 + resultsInnerW := m.width - 4 + if resultsInnerW < 10 { + resultsInnerW = 10 + } + pageSize := resultsInnerH - 1 + if pageSize < 1 { + pageSize = 1 } - tableOuter = tableOuter.Width(m.width) + m.table = m.table.WithPageSize(pageSize).WithRows(m.dataRows).WithTargetWidth(resultsInnerW) - 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) > resultsInnerH { + lines = lines[:resultsInnerH] } - if len(lines) > maxLines { - lines = lines[:maxLines] + inner = strings.Join(lines, "\n") + default: + inner = m.table.View() + } + { + lines := strings.Split(inner, "\n") + if len(lines) > resultsInnerH { + lines = lines[:resultsInnerH] } - resultPane = tableOuter.Render(strings.Join(lines, "\n")) - } else { - resultPane = tableOuter.Render(m.table.View()) + inner = strings.Join(lines, "\n") } + resultsPane := renderResultsPane(inner, m.width, availH, len(m.dataRows), m.currentFocus() == "table") - // ── compose main or overlay view ───────────────────────────────────────── + // ── 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) +} + +// renderPromqlEditorPane mirrors the SQL editor pane — flat NormalBorder +// rectangle, label row at top-left, body below. Right side of the title +// row carries the code|builder mode toggle: active mode is Accent bold, +// inactive is Faint. +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") + + // Code | Builder — active mode uses Active (sky blue), same + // selection-state color used by sidebar focus. ctrl-b shortcut + // lives in the bottom bar. + 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") + } + + 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) +} - render := lipgloss.JoinVertical(lipgloss.Left, mainView, helpView, statusView) - return lipgloss.NewStyle().Width(m.width).Render(render) +// 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 } -// renderSpotlight builds the dataset picker modal. -func (m PromqlModel) renderSpotlight() string { - innerW := spotlightWidth - 2 +// renderPromqlControlsBox draws the top sidebar card: DATASET + +// STEP + MODE. Active sub-section (datasetHi / stepHi) gets the +// Active rail + bold label on every row of that group. +func renderPromqlControlsBox(dataset, step, mode string, width, height int, datasetHi, stepHi 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 + } + lines := []string{ + prefix(datasetHi) + dLabel.Render("DATASET"), + prefix(datasetHi) + val.Render(dataset), + "", + prefix(stepHi) + sLabel.Render("STEP ") + val.Render(step), + prefix(stepHi) + 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) +} - titleStyle := lipgloss.NewStyle(). - Foreground(FocusPrimary). - Bold(true). +// renderPromqlDateBox draws the bottom sidebar card: FROM + TO. +// `instant` hides FROM since instant queries evaluate at one point. +// timeHi lights the Active rail + bold label. +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). - Align(lipgloss.Center) - title := titleStyle.Render("Select Dataset") + Height(innerH). + Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Render(body) +} + +// buildPromqlBottomBar — single combined help+status row for PromQL. +// Left: focus-aware key hints. Right: info · MODE · LIVE. +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) + + 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)), + ) + } + left := strings.Join(keyParts, " ") + + sep := sepStyle.Render(" │ ") + var rightParts []string + if m.status.Error != "" { + rightParts = append(rightParts, + labelStyle.Render("ERR"), + " ", + lipgloss.NewStyle().Foreground(p.Err).Bold(true).Render(m.status.Error), + sep, + ) + } else if m.status.Info != "" { + rightParts = append(rightParts, + lipgloss.NewStyle().Foreground(p.Body).Render(m.status.Info), + sep, + ) + } + rightParts = append(rightParts, + labelStyle.Render("MODE"), + " ", + lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render(strings.ToUpper(m.status.title)), + sep, + labelStyle.Render("LIVE"), + " ", + lipgloss.NewStyle().Foreground(p.Ok).Bold(true).Render("●"), + ) + right := lipgloss.JoinHorizontal(lipgloss.Bottom, rightParts...) + + innerW := width - 2 + if innerW < 1 { + innerW = 1 + } + contentW := innerW - 2 + if contentW < 1 { + contentW = 1 + } + gap := contentW - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + row := left + strings.Repeat(" ", gap) + right + inner := lipgloss.NewStyle().Width(innerW).Padding(0, 1).Render(row) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Render(inner) +} - searchStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(FocusSecondary). - Width(innerW-2). +// buildPromqlToolbar renders the row above the editor: range/instant +// pills + step chip + code/builder pills. +func buildPromqlToolbar(m PromqlModel, width int) string { + p := ui.Active + dim := ui.Type().Dim + chipStyle := lipgloss.NewStyle(). + Foreground(p.Body). + Background(p.PanelAlt). Padding(0, 1) - searchBar := searchStyle.Render(m.spotlightFilter.View()) + left := lipgloss.JoinHorizontal( + lipgloss.Top, + ui.Pill("range", !m.instant), + " ", + ui.Pill("instant", m.instant), + " ", + dim.Render("step "), + chipStyle.Render(m.step), + ) + + right := lipgloss.JoinHorizontal( + lipgloss.Top, + ui.Pill("code", m.queryMode != "builder"), + " ", + ui.Pill("builder", m.queryMode == "builder"), + ) + + gap := width - lipgloss.Width(left) - lipgloss.Width(right) - 4 + if gap < 1 { + gap = 1 + } + row := lipgloss.JoinHorizontal(lipgloss.Top, + left, + strings.Repeat(" ", gap), + right, + ) + return lipgloss.NewStyle(). + Width(width). + Padding(0, 2). + Background(p.Panel). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(p.Border). + Render(row) +} + +// buildPromqlBreadcrumbs surfaces current overlay/focus as the bottom tab row. +func buildPromqlBreadcrumbs(m PromqlModel) string { + active := "query" + switch m.overlay { + case overlayDataset: + active = "picker" + case overlayInputs: + active = "time" + case overlayBuilder: + active = "builder" + default: + switch m.currentFocus() { + case "table": + active = "results" + case "time": + active = "time" + case "dataset": + active = "picker" + case "step": + active = "step" + default: + active = "query" + } + } + items := []ui.Breadcrumb{ + {ID: "query", Label: "query", Active: active == "query"}, + {ID: "picker", Label: "picker", Active: active == "picker"}, + {ID: "time", Label: "time", Active: active == "time"}, + {ID: "results", Label: "results", Active: active == "results"}, + {ID: "help", Label: "help", Active: active == "help"}, + } + return ui.Breadcrumbs(m.width, items) +} + +// buildPromqlHeaderStrip renders the top chrome bar for the PromQL view. +func buildPromqlHeaderStrip(m PromqlModel) string { + dataset := m.dataset + if dataset == "" { + dataset = "—" + } + rangeMode := "range" + if m.instant { + rangeMode = "instant" + } + rowsVal := "—" + if len(m.dataRows) > 0 { + rowsVal = fmt.Sprintf("%d series", len(m.dataRows)) + } + latencyVal := "—" + if m.loading { + latencyVal = "…" + } + user := m.profile.Username + if user == "" { + user = "—" + } + + ctx := []ui.KV{ + {Key: "Cluster", Value: m.profile.URL, Variant: ui.KVMute}, + {Key: "User", Value: user}, + {Key: "Dataset", Value: dataset, Variant: ui.KVAccent}, + {Key: "Mode", Value: rangeMode, Variant: ui.KVMute}, + {Key: "Step", Value: m.step, Variant: ui.KVMute}, + {Key: "Series", Value: rowsVal, Variant: ui.KVMute}, + {Key: "Latency", Value: latencyVal, Variant: ui.KVMute}, + } + keys := promqlKeysForFocus(m) + return ui.HeaderStrip(m.width, ctx, keys) +} + +// 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: "Run"}, + {Key: "", Label: "Next pane"}, + {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": + if m.queryMode == "builder" { + return append([]ui.KeyHint{ + {Key: "", Label: "Open builder"}, + {Key: "", Label: "Code mode"}, + }, common...) + } + return append([]ui.KeyHint{ + {Key: "", Label: "Builder mode"}, + {Key: "", Label: "Datasets"}, + }, common...) + case "time": + return append([]ui.KeyHint{ + {Key: "", Label: "Open picker"}, + }, common...) + case "step": + return append([]ui.KeyHint{ + {Key: "type", Label: "Edit (15s, 5m)"}, + {Key: "", Label: "Range/instant"}, + }, common...) + case "table": + return append([]ui.KeyHint{ + {Key: "<↑/↓>", Label: "Row"}, + {Key: "", Label: "Filter"}, + {Key: "", Label: "Top/End"}, + }, common...) + } + return common +} + +// 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 { + p := ui.Active + // content area inside border(2) + h-padding(4) + innerW := spotlightWidth - 6 + if innerW < 20 { + innerW = 20 + } + + // Header: title left, count right + titleLeft := lipgloss.NewStyle(). + Foreground(p.Accent). + Bold(true). + 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)) + + // 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). + // Selected: 1-cell Active (sky blue) bg rail + bold + // Active name — same selection convention as the + // main view sidebar and other lists. + 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 +1649,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 +1762,14 @@ 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) + // Minimum value column width: 14 cells. Big counters (e.g. 9-digit + // uint64) plus a sign or decimal fit without truncation, and the + // header "VALUE" never wraps regardless of the actual data range. + 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 +1780,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 +1939,35 @@ 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. +// renderBuilderCol renders a single column (METRICS / LABELS / VALUES) +// for the builder overlay. Flat NormalBorder, UPPERCASE title, plain +// selection treatment. 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 +1978,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 +1986,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) - - searchStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(FocusSecondary). - Width(colsW-4). - Padding(0, 1) - searchBar := searchStyle.Render(m.builderFilter.View()) + exprLine := lipgloss.NewStyle().Foreground(p.Faint).Render("built ") + + lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render(expr) - titleStyle := lipgloss.NewStyle(). - Foreground(FocusPrimary).Bold(true). - Width(colsW).Align(lipgloss.Center) - title := titleStyle.Render("PromQL Query Builder") + searchBar := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Width(colsW - 4). + Padding(0, 1). + Render(m.builderFilter.View()) - body := lipgloss.JoinVertical(lipgloss.Left, title, columns, exprLine, searchBar) + title := lipgloss.NewStyle(). + Foreground(p.Accent). + Bold(true). + Width(colsW). + Align(lipgloss.Center). + Render("PROMQL QUERY BUILDER") - modal := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(FocusPrimary). - Padding(0, 1). - Render(body) + body := lipgloss.JoinVertical(lipgloss.Left, + title, + "", + columns, + "", + exprLine, + "", + searchBar, + ) - return modal + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Padding(1, 2). + Render(body) } // ─── builder async commands ─────────────────────────────────────────────────── diff --git a/pkg/model/query.go b/pkg/model/query.go index 2cad7d2..1174573 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -27,6 +27,7 @@ import ( "os" "pb/pkg/config" "pb/pkg/iterator" + "pb/pkg/ui" "strings" "time" @@ -42,55 +43,85 @@ 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) + baseBoldUnderlinedStyle = lipgloss.NewStyle().BorderForeground(chromeBorder).Bold(true) + // 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} @@ -181,30 +212,41 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t WithKeyMap(tableKeyBinds). WithPageSize(pageSize). WithBaseStyle(tableStyle). + HighlightStyle(highlightStyle). WithMissingDataIndicatorStyled(table.StyledCell{ - Style: lipgloss.NewStyle().Foreground(StandardSecondary), - Data: "╌", + // Near-invisible nulls — sits at Border, lets real data pop. + Style: lipgloss.NewStyle().Foreground(chromeBorder), + Data: "—", }).WithMaxTotalWidth(w) 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 hasQuery := strings.TrimSpace(queryStr) != "" model := QueryModel{ @@ -277,6 +319,7 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Is it a key press? case tea.KeyMsg: + // special behavior on main page if m.overlay == overlayNone { if msg.Type == tea.KeyEnter && m.currentFocus() == "time" { @@ -305,6 +348,13 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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() @@ -316,8 +366,11 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // 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 = "" @@ -361,166 +414,791 @@ 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) - - // 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) + m.status.SetMode("SQL") + if len(m.dataRows) > 0 { + page := m.table.CurrentPage() + total := m.table.MaxPages() + if total < 1 { + total = 1 } - case overlayInputs: - helpKeys = m.timeRange.FullHelp() - helpKeys = append(helpKeys, additionalKeyBinds) + m.status.Info = fmt.Sprintf("rows %d · %d/%d", len(m.dataRows), page, total) } - 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 + 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. + topH := 11 + // Sidebar width matches PromQL so the two views read symmetric. + sidebarW := 30 + if m.width >= 140 { + sidebarW = 34 } + if m.width < 100 { + sidebarW = 26 + } + // editorW reserves 1 col for the horizontal gap between editor + // and date pane, 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) + // Editor body height must match the pane's inner content area — + // innerH(topH-2) minus the title row (1). If textarea is taller + // than the pane, it overflows and pushes the top borders/labels + // off-screen. + 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") + dataset := extractDataset(m.query.Value()) + if dataset == "—" || dataset == "" { + dataset = "—" + } + // Two stacked sidebar boxes — DATASET (read-only) on top, DATE + // (FROM/TO) below. Borders touch (same zero-gap join used + // between the top section and the results pane). + // Controls(4) + Date(7) = 11 = topH. + datasetBox := renderSQLDatasetBox(dataset, sidebarW, 4) + dateBox := renderSQLDateBox( + m.timeRange.start.Value(), + m.timeRange.end.Value(), + sidebarW, 7, + m.currentFocus() == "time", + ) + sidebarPane := lipgloss.JoinVertical(lipgloss.Left, datasetBox, dateBox) - // 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) - - m.table = m.table.WithPageSize(pageSize).WithRows(displayRows).WithMaxTotalWidth(m.width) - tableOuter = tableOuter.Width(m.width) + gap := lipgloss.NewStyle().Width(1).Height(topH).Render("") + topSection := lipgloss.JoinHorizontal(lipgloss.Top, editorPane, gap, sidebarPane) - // Step 4: compose main view. - availW := m.width - if availW < 0 { - availW = 0 + // ── 4. Results / table area ─────────────────────────────────────── + availH := m.height - crumbsHeight - topH - bottomHeight + if availH < 6 { + availH = 6 + } + // Results pane border (2) + label row (1) = 3 rows of chrome. + resultsInnerH := availH - 3 + if resultsInnerH < 3 { + resultsInnerH = 3 } - availH := tableAvail - 2 - if availH < 0 { - availH = 0 + resultsInnerW := m.width - 4 // border(2) + h-padding(2) + if resultsInnerW < 10 { + resultsInnerW = 10 } + pageSize := resultsInnerH - 1 + if pageSize < 1 { + pageSize = 1 + } + m.table = m.table.WithPageSize(pageSize).WithRows(m.dataRows).WithMaxTotalWidth(resultsInnerW) - var resultPane string - if !m.hasQueried { - // Welcome / empty state — no query has been run yet. - 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 your SQL query 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 { - // Query dispatched — show spinner centered in the result area. - 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 with width constraint so the long error string wraps, - // then clip to tableAvail lines so the header stays in place. + 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) > resultsInnerH { + lines = lines[:resultsInnerH] } - if len(lines) > maxLines { - lines = lines[:maxLines] + inner = strings.Join(lines, "\n") + default: + inner = m.table.View() + } + { + lines := strings.Split(inner, "\n") + if len(lines) > resultsInnerH { + lines = lines[:resultsInnerH] } - resultPane = tableOuter.Render(strings.Join(lines, "\n")) - } else { - resultPane = tableOuter.Render(m.table.View()) + inner = strings.Join(lines, "\n") } + resultsPane := renderResultsPane(inner, m.width, availH, len(m.dataRows), m.currentFocus() == "table") + // ── 5. 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-crumbsHeight-bottomHeight, lipgloss.Center, lipgloss.Center, timeView, lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(StandardSecondary), ) } - // 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) + render := lipgloss.JoinVertical(lipgloss.Left, + mainView, + bottomView, + ) + return lipgloss.NewStyle().Width(m.width).Render(render) +} + +// renderEditorPane draws a flat rectangle with a single label row +// (Faint, top-left) and body below. NormalBorder per design — matches +// the wireframe "plain rectangle with label inside" idiom. +func renderEditorPane(body string, width, height 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 + } + title := lipgloss.NewStyle().Foreground(titleFg).Bold(focused).Render("EDITOR") + titleRow := lipgloss.NewStyle().Width(innerW).Padding(0, 1).Render(title) + 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) +} - render := lipgloss.JoinVertical(lipgloss.Left, mainView, helpView, statusView) - return lipgloss.NewStyle().Width(m.width).Render(render) +// renderSQLDatasetBox draws the top SQL sidebar card: read-only +// DATASET label + value parsed from the SQL FROM clause. SQL doesn't +// have a separate dataset focus, so this card never lights. +func renderSQLDatasetBox(dataset string, width, height int) 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) + lines := []string{ + " " + dim.Render("DATASET"), + " " + val.Render(dataset), + } + body := lipgloss.NewStyle(). + Width(innerW). + Height(innerH). + Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Render(body) +} + +// 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) +} + +// renderTopSection (legacy: combined pane) — kept only to avoid +// breaking callers; not used by the main View() any more. +func renderTopSection(editorBody, start, end string, editorW, sidebarW, height int, focusEditor, focusTime bool) string { + p := ui.Active + borderColor := p.Border + if focusEditor || focusTime { + borderColor = p.BorderHi + } + + innerH := height - 2 // outer border top+bottom + if innerH < 4 { + innerH = 4 + } + + // ── Left half: editor ──────────────────────────────────────────── + leftInner := editorW - 2 + if leftInner < 4 { + leftInner = 4 + } + editorTitleFg := p.Ghost + if focusEditor { + editorTitleFg = p.Accent + } + editorTitleText := lipgloss.NewStyle(). + Foreground(editorTitleFg). + Bold(focusEditor). + Render("EDITOR") + editorRail := " " + if focusEditor { + editorRail = lipgloss.NewStyle().Background(p.Accent).Render(" ") + } + editorTitleRow := lipgloss.NewStyle(). + Width(leftInner). + Padding(0, 1). + Render(editorRail + " " + editorTitleText) + editorRule := lipgloss.NewStyle(). + Foreground(p.BorderSoft). + Render(strings.Repeat("─", leftInner)) + editorBodyPane := lipgloss.NewStyle(). + Width(leftInner). + Height(innerH - 2). + Padding(0, 1). + Render(editorBody) + leftStack := lipgloss.JoinVertical(lipgloss.Left, editorTitleRow, editorRule, editorBodyPane) + + // ── Vertical inner divider ─────────────────────────────────────── + vDivLine := strings.TrimRight(strings.Repeat("│\n", innerH), "\n") + vDiv := lipgloss.NewStyle(). + Foreground(p.BorderSoft). + Render(vDivLine) + + // ── Right half: time sidebar ───────────────────────────────────── + rightInner := sidebarW - 3 + if rightInner < 4 { + rightInner = 4 + } + timeTitleFg := p.Ghost + if focusTime { + timeTitleFg = p.Accent + } + timeTitleText := lipgloss.NewStyle(). + Foreground(timeTitleFg). + Bold(focusTime). + Render("TIME") + timeRail := " " + if focusTime { + timeRail = lipgloss.NewStyle().Background(p.Accent).Render(" ") + } + timeTitleRow := lipgloss.NewStyle(). + Width(rightInner). + Padding(0, 1). + Render(timeRail + " " + timeTitleText) + timeRule := lipgloss.NewStyle(). + Foreground(p.BorderSoft). + Render(strings.Repeat("─", rightInner)) + + label := lipgloss.NewStyle().Foreground(p.Ghost).Bold(true) + tsVal := lipgloss.NewStyle().Foreground(p.Accent) + hintKey := lipgloss.NewStyle().Foreground(p.Accent).Bold(true) + hintLabel := lipgloss.NewStyle().Foreground(p.Ghost) + hint := hintKey.Render("enter") + hintLabel.Render(" edit range") + timeContent := lipgloss.JoinVertical(lipgloss.Left, + label.Render("START"), + tsVal.Render(start), + "", + label.Render("END"), + tsVal.Render(end), + ) + padRows := (innerH - 2) - lipgloss.Height(timeContent) - lipgloss.Height(hint) + if padRows < 0 { + padRows = 0 + } + timeStack := timeContent + strings.Repeat("\n", padRows+1) + hint + timeBodyPane := lipgloss.NewStyle(). + Width(rightInner). + Height(innerH - 2). + Padding(0, 1). + Render(timeStack) + rightStack := lipgloss.JoinVertical(lipgloss.Left, timeTitleRow, timeRule, timeBodyPane) + + row := lipgloss.JoinHorizontal(lipgloss.Top, leftStack, vDiv, rightStack) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Render(row) +} + +// buildBreadcrumbs renders the top tab row with FILLED active tab — +// active = Accent bg + InvertText fg + bold; idle = Mute fg on +// PanelAlt bg (raised vs canvas so the row reads as distinct chrome). +// Bottom hairline in Border separates from body. +func buildBreadcrumbs(active string, rowCount, savedCount, width int) string { + p := ui.Active + items := []struct { + id, label string + count int + }{ + {"query", "query", 0}, + {"time", "time", 0}, + {"results", "results", rowCount}, + {"metrics", "metrics", 0}, + {"saved", "saved", savedCount}, + {"help", "help", 0}, + } + + var tabs []string + for _, it := range items { + isActive := it.id == active + st := lipgloss.NewStyle().Padding(0, 2) + if isActive { + st = st.Background(p.Accent).Foreground(p.InvertText).Bold(true) + } else { + st = st.Background(p.PanelAlt).Foreground(p.Mute) + } + text := it.label + if it.count > 0 { + text = fmt.Sprintf("%s %d", it.label, it.count) + } + tabs = append(tabs, st.Render(text)) + } + + row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + used := lipgloss.Width(row) + if used < width { + row = row + lipgloss.NewStyle().Width(width-used).Background(p.PanelAlt).Render("") + } + return lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(p.Border). + Render(row) +} + +// 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) + + // ── Left: key hints ──────────────────────────────────────────── + 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)), + ) + } + left := strings.Join(keyParts, " ") + + // ── Right: info · MODE · LIVE ────────────────────────────────── + sep := sepStyle.Render(" │ ") + var rightParts []string + if m.status.Error != "" { + rightParts = append(rightParts, + labelStyle.Render("ERR"), + " ", + lipgloss.NewStyle().Foreground(p.Err).Bold(true).Render(m.status.Error), + sep, + ) + } else if m.status.Info != "" { + rightParts = append(rightParts, + lipgloss.NewStyle().Foreground(p.Body).Render(m.status.Info), + sep, + ) + } + rightParts = append(rightParts, + labelStyle.Render("MODE"), + " ", + lipgloss.NewStyle().Foreground(p.Accent).Bold(true).Render(strings.ToUpper(m.status.title)), + sep, + labelStyle.Render("LIVE"), + " ", + lipgloss.NewStyle().Foreground(p.Ok).Bold(true).Render("●"), + ) + right := lipgloss.JoinHorizontal(lipgloss.Bottom, rightParts...) + + innerW := width - 2 + if innerW < 1 { + innerW = 1 + } + contentW := innerW - 2 + if contentW < 1 { + contentW = 1 + } + gap := contentW - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + row := left + strings.Repeat(" ", gap) + right + inner := lipgloss.NewStyle().Width(innerW).Padding(0, 1).Render(row) + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(p.Border). + Render(inner) +} + + +// buildQueryBreadcrumbs surfaces the current mode/overlay as a tab row. +// Active crumb fills with accent; idle crumbs read on bg. +func buildQueryBreadcrumbs(m QueryModel) string { + active := "query" + switch m.overlay { + case overlayInputs: + active = "time" + default: + switch m.currentFocus() { + case "table": + active = "results" + case "time": + active = "time" + default: + active = "query" + } + } + items := []ui.Breadcrumb{ + {ID: "query", Label: "query", Active: active == "query"}, + {ID: "time", Label: "time", Active: active == "time"}, + {ID: "results", Label: "results", Active: active == "results"}, + {ID: "saved", Label: "saved"}, + {ID: "help", Label: "help", Active: active == "help"}, + } + return ui.Breadcrumbs(m.width, items) +} + +// buildQueryHeaderStrip renders the top chrome bar for the SQL view. KV +// block left, keybind grid middle, PB logo right (logo only at >=92 cols). +func buildQueryHeaderStrip(m QueryModel) string { + dataset := "" + q := m.query.Value() + // best-effort: pull "FROM " from the SQL — purely cosmetic. + if i := strings.Index(strings.ToLower(q), " from "); i >= 0 { + rest := strings.TrimSpace(q[i+6:]) + if sp := strings.IndexAny(rest, " ,;\n\t"); sp > 0 { + dataset = rest[:sp] + } else { + dataset = rest + } + } + if dataset == "" { + dataset = "—" + } + + rowsVal := "—" + if len(m.dataRows) > 0 { + rowsVal = fmt.Sprintf("%d", len(m.dataRows)) + } + latencyVal := "—" + if m.loading { + latencyVal = "…" + } + + user := m.profile.Username + if user == "" { + user = "—" + } + + ctx := []ui.KV{ + {Key: "Cluster", Value: m.profile.URL, Variant: ui.KVMute}, + {Key: "User", Value: user}, + {Key: "Dataset", Value: dataset, Variant: ui.KVAccent}, + {Key: "Rows", Value: rowsVal, Variant: ui.KVMute}, + {Key: "Latency", Value: latencyVal, Variant: ui.KVMute}, + } + + keys := queryKeysForFocus(m) + return ui.HeaderStrip(m.width, ctx, keys) +} + +// 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: "Run"}, + {Key: "", Label: "Next pane"}, + {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 "query": + return append([]ui.KeyHint{ + {Key: "", Label: "Comment"}, + {Key: "", Label: "Dup line"}, + {Key: "", Label: "Line"}, + }, common...) + case "time": + return append([]ui.KeyHint{ + {Key: "", Label: "Open picker"}, + }, common...) + case "table": + return append([]ui.KeyHint{ + {Key: "<↑/↓>", Label: "Row"}, + {Key: "", Label: "Filter"}, + {Key: "", Label: "Top/End"}, + {Key: "", Label: "Prev page"}, + }, common...) + } + return common +} + +// trimTimestampToHMS extracts the HH:MM:SS portion of an RFC3339-ish +// timestamp (or any string containing `T