From 4e9875efe5b1fc7d927fcecdd25ff4ddf988c96e Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 21 May 2026 14:22:22 +0530 Subject: [PATCH 1/6] feat(tui): redesign chrome, color system, picker + theme typography - New pkg/ui package: Palette (dark+light), Typography styles, Card primitive, HeaderStrip, Breadcrumbs, status bar, chroma SQL/PromQL highlight, ASCII chart, PB ASCII wordmark, icon set (PB_ICONS=ascii|nerd toggle). - Theme loader: PB_THEME=dark|light|auto via COLORFGBG sniff. - pkg/ui/views/: greenfield View interface + picker (fuzzy dataset list, pinning, selection rail) + EmptyView placeholders for remaining screens. - cmd/tui.go: new `pb tui` cobra command (greenfield entry), leaves existing `pb query -i` untouched. - Migrate yellow ANSI 226/220 references across cmd/style.go, pkg/model/{credential,defaultprofile,login,role}/*.go to brand indigo via ui.Adaptive. - pkg/model/query.go + promql.go: theme-aware textarea styles, hidden vim tildes, uniform editor bg, dropped vertical column dividers, header in Faint, data in Body, null indicator near-invisible, timestamps trimmed to HH:MM:SS, selection on SelRow+Accent. - pkg/model/status.go: segmented MODE/CLUSTER/ENV/LIVE/t/help bar. - pkg/model/timeinput.go + timerange.go: redesigned modal with preset list + manual fields + now badge + summary chip. - Esc in time modal closes without applying; Alt+Enter runs query (Cmd+Enter via terminal Meta config). - Migrated 15 ad-hoc Foreground styles in pkg/model/* to ui.Type(). Co-Authored-By: Claude Opus 4.7 --- cmd/style.go | 29 +- cmd/tui.go | 67 ++++ go.mod | 3 + go.sum | 6 + main.go | 1 + pkg/model/credential/credential.go | 11 +- pkg/model/defaultprofile/profile.go | 17 +- pkg/model/login/login.go | 13 +- pkg/model/promql.go | 448 ++++++++++++++------- pkg/model/query.go | 594 +++++++++++++++++++++------- pkg/model/role/role.go | 13 +- pkg/model/savedQueries.go | 19 +- pkg/model/status.go | 138 ++++--- pkg/model/timeinput.go | 203 ++++++++-- pkg/model/timerange.go | 73 ++-- pkg/ui/app.go | 299 ++++++++++++++ pkg/ui/chart.go | 58 +++ pkg/ui/chrome.go | 416 +++++++++++++++++++ pkg/ui/highlight.go | 110 ++++++ pkg/ui/icons.go | 98 +++++ pkg/ui/logo.go | 40 ++ pkg/ui/theme.go | 329 +++++++++++++++ pkg/ui/views/empty.go | 38 ++ pkg/ui/views/picker.go | 419 ++++++++++++++++++++ pkg/ui/views/views.go | 56 +++ 25 files changed, 3094 insertions(+), 404 deletions(-) create mode 100644 cmd/tui.go create mode 100644 pkg/ui/app.go create mode 100644 pkg/ui/chart.go create mode 100644 pkg/ui/chrome.go create mode 100644 pkg/ui/highlight.go create mode 100644 pkg/ui/icons.go create mode 100644 pkg/ui/logo.go create mode 100644 pkg/ui/theme.go create mode 100644 pkg/ui/views/empty.go create mode 100644 pkg/ui/views/picker.go create mode 100644 pkg/ui/views/views.go diff --git a/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..7028789 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,12 +42,12 @@ 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 }) + 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 }) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(primaryColor) selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(primaryColor) diff --git a/pkg/model/promql.go b/pkg/model/promql.go index 7f02d16..c6c0e2b 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" @@ -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) @@ -292,13 +293,13 @@ func NewPromqlModel(profile config.Profile, expr string, startTime, endTime time 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{ @@ -752,6 +753,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 +810,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 = "" @@ -921,124 +928,83 @@ func (m PromqlModel) View() string { return "" } - // ── 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, - ) + // ── Top chrome: KV context · keybinds · PB logo ── + chromeView := buildPromqlHeaderStrip(m) + chromeHeight := lipgloss.Height(chromeView) - 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 + // ── Inline editor (no border boxes). Dataset/Time/Step/Mode all + // live in the top HeaderStrip KV block; navigation still cycles + // across the same logical panes for editing. + // Time pane lives on the right (matches mock); editor on the left. + timeCardW := 30 + if m.width < 80 { + timeCardW = 26 } - 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) + editorW := m.width - timeCardW + if editorW < 30 { + editorW = 30 } - stepModePane := lipgloss.JoinVertical(lipgloss.Left, - stepRow, - fmt.Sprintf("%s %s", baseBoldUnderlinedStyle.Render(" mode "), modeLabel), - ) + m.query.SetWidth(editorW - 4) - 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) - } + toolbar := buildPromqlToolbar(m, editorW) - // 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) + editorTitle := buildPaneTitle("EDITOR · PromQL", m.currentFocus() == "query", editorW) - // ── 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") - } - toggleRow := lipgloss.NewStyle(). - Width(innerW). - Align(lipgloss.Right). - Render(codeLabel + inactiveTabStyle.Render(" | ") + builderLabel) - - var queryPanelContent string + var editorBody string if m.queryMode == "builder" { expr := m.query.Value() - var exprDisplay string + body := expr if expr == "" { - exprDisplay = lipgloss.NewStyle(). - Foreground(StandardSecondary).Width(innerW). - Render("press Enter to open builder...") + body = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Faint })). + Italic(true). + Render("press Enter to open builder…") } else { - exprDisplay = lipgloss.NewStyle(). - Foreground(FocusPrimary).Bold(true).Width(innerW). + body = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Bold(true). Render(expr) } - queryPanelContent = lipgloss.JoinVertical(lipgloss.Left, toggleRow, exprDisplay) + editorBody = lipgloss.NewStyle(). + Width(editorW). + Padding(0, 2). + Background(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.EditorBg })). + Render(body) } else { - queryPanelContent = lipgloss.JoinVertical(lipgloss.Left, toggleRow, m.query.View()) - } - - header := lipgloss.JoinHorizontal(lipgloss.Top, - dsRendered, - queryOuter.Render(queryPanelContent), - timeRendered, - stepRendered, + editorBody = lipgloss.NewStyle(). + Width(editorW). + Padding(0, 2). + Background(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.EditorBg })). + Render(m.query.View()) + } + + leftPane := lipgloss.JoinVertical(lipgloss.Left, editorTitle, toolbar, editorBody) + timeBody := buildTimeBody( + m.timeRange.start.Value(), + m.timeRange.end.Value(), + timeCardW-4, + ) + rightPane := ui.Card("TIME RANGE", timeCardW, + lipgloss.Height(leftPane)-2, + m.currentFocus() == "time", + timeBody, ) + header := lipgloss.JoinHorizontal(lipgloss.Top, leftPane, rightPane) + tableOuter := lipgloss.NewStyle() + if m.currentFocus() == "table" { + tableOuter = tableOuter.Border(lipgloss.ThickBorder(), false, false, false, true). + BorderForeground(FocusPrimary) + } headerHeight := lipgloss.Height(header) if m.loading { m.status.Info = "" m.status.Error = "" } + m.status.SetMode("PromQL") + if len(m.dataRows) > 0 { + m.status.Info = fmt.Sprintf("series %d", len(m.dataRows)) + } statusView := m.status.View() statusHeight := lipgloss.Height(statusView) @@ -1110,11 +1076,20 @@ func (m PromqlModel) View() string { }, } } - helpView := m.help.FullHelpView(helpKeys) + // Modal overlays still need their footer hint row; main views push + // keybinds into the HeaderStrip and drop the body help line. + var helpView string + switch m.overlay { + case overlayInputs, overlayDataset, overlayBuilder: + helpView = m.help.FullHelpView(helpKeys) + default: + helpView = "" + _ = helpKeys + } helpHeight := lipgloss.Height(helpView) // ── result area ────────────────────────────────────────────────────────── - tableAvail := m.height - headerHeight - helpHeight - statusHeight + tableAvail := m.height - chromeHeight - headerHeight - helpHeight - statusHeight pageSize := tableAvail - 6 if pageSize < 1 { pageSize = 1 @@ -1136,30 +1111,34 @@ func (m PromqlModel) View() string { var resultPane string if !m.hasQueried { + // Quiet empty state — mirrors mock ViewEmpty in terminal/page.tsx. logoStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(FocusPrimary). - Border(lipgloss.DoubleBorder()). - BorderForeground(FocusSecondary). - Padding(0, 2) + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Ghost })) hintStyle := lipgloss.NewStyle(). - Foreground(StandardSecondary). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Faint })). MarginTop(1) - keyStyle := lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) - logo := logoStyle.Render("P A R S E A B L E") + keyStyle := ui.Type().Accent.Bold(true) + exampleBox := lipgloss.NewStyle(). + MarginTop(2). + Padding(0, 2). + Border(lipgloss.NormalBorder()). + BorderForeground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.BorderSoft })). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Body })) + 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) + example := exampleBox.Render(`rate(http_requests_total{service="checkout"}[5m])`) + content := lipgloss.JoinVertical(lipgloss.Center, logo, hint, example) placed := lipgloss.Place(availW, availH, lipgloss.Center, lipgloss.Center, content) resultPane = tableOuter.Render(placed) } else if m.loading { - spinStyle := lipgloss.NewStyle().Foreground(FocusPrimary) + spinStyle := ui.Type().Accent 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 != "" { errStyle := lipgloss.NewStyle(). Padding(1, 2). - Foreground(lipgloss.AdaptiveColor{Light: "#9B2226", Dark: "#FF6B6B"}). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Err })). Width(m.width) rendered := errStyle.Render(m.fetchErrMsg) lines := strings.Split(rendered, "\n") @@ -1214,10 +1193,202 @@ func (m PromqlModel) View() string { mainView = mainView + strings.Repeat("\n", padLines) } - render := lipgloss.JoinVertical(lipgloss.Left, mainView, helpView, statusView) + _ = helpView + breadcrumbs := buildPromqlBreadcrumbs(m) + render := lipgloss.JoinVertical(lipgloss.Left, + chromeView, + mainView, + breadcrumbs, + statusView, + ) return lipgloss.NewStyle().Width(m.width).Render(render) } +// 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) + + 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. func (m PromqlModel) renderSpotlight() string { innerW := spotlightWidth - 2 @@ -1261,23 +1432,32 @@ func (m PromqlModel) renderSpotlight() string { if m.datasetSelectedIdx >= spotlightMaxItems { start = m.datasetSelectedIdx - spotlightMaxItems + 1 } + // Selection treatment matches the mock's picker: 1-cell accent + // rail, subtle SelRow background, ▸ cursor in accent. No yellow + // fill, no inverted text. + selBg := ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.SelRow }) + selFg := ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Text }) + selCursor := ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent }) + rail := lipgloss.NewStyle(). + Background(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Render(" ") + blankRail := " " 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). + rowBody := lipgloss.NewStyle(). + Background(selBg). + Foreground(selFg). + Width(innerW - 1). Padding(0, 1). - Bold(true). - Render("▸ " + ds) - listLines = append(listLines, row) + Render(lipgloss.NewStyle().Foreground(selCursor).Render("▸ ") + ds) + listLines = append(listLines, rail+rowBody) } else { - row := lipgloss.NewStyle(). - Width(innerW). + rowBody := lipgloss.NewStyle(). + Width(innerW - 1). Padding(0, 1). Render(" " + ds) - listLines = append(listLines, row) + listLines = append(listLines, blankRail+rowBody) } } if len(m.filteredDatasets) > spotlightMaxItems { @@ -1633,11 +1813,13 @@ func renderBuilderCol(title string, items []string, selectedIdx int, loading, fo item = item[:maxLen-3] + "..." } if i == selectedIdx { + selBg := ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.SelRow }) + selFg := ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Text }) + cur := ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent }) rows = append(rows, lipgloss.NewStyle(). - Background(FocusPrimary). - Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). - Width(innerW).Padding(0, 1).Bold(true). - Render("▸ "+item)) + Background(selBg).Foreground(selFg). + Width(innerW).Padding(0, 1). + Render(lipgloss.NewStyle().Foreground(cur).Render("▸ ")+item)) } else { rows = append(rows, lipgloss.NewStyle(). Width(innerW).Padding(0, 1). @@ -1681,8 +1863,8 @@ func (m PromqlModel) renderBuilder() string { colsW := lipgloss.Width(columns) expr := buildPromqlExpr(m.builderCurrentMetric(), m.builderCurrentLabel(), m.builderCurrentValue()) - dimStyle := lipgloss.NewStyle().Foreground(StandardSecondary) - exprStyle := lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) + dimStyle := ui.Type().Mute + exprStyle := ui.Type().Accent.Bold(true) exprLine := dimStyle.Render("Built: ") + exprStyle.Render(expr) searchStyle := lipgloss.NewStyle(). diff --git a/pkg/model/query.go b/pkg/model/query.go index 2cad7d2..30fa1f5 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,78 @@ 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) + // Table header — Faint color, uppercase, no bold. Sits visually + // below the data rows so real values pop. + headerStyle = lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Faint })). + Padding(0, 1) + // Data rows in Body — clear, scannable. + 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 — k9s pattern: no outer box, no vertical column + // dividers, single horizontal hairline under the header. Lets the + // data breathe and stops the grid from competing with content. customBorder = table.Border{ - Top: "─", - Left: "│", - Right: "│", - Bottom: "─", - - TopRight: "╮", - TopLeft: "╭", - BottomRight: "╯", - BottomLeft: "╰", - - TopJunction: "╥", - LeftJunction: "├", - RightJunction: "┤", - BottomJunction: "╨", - InnerJunction: "╫", - - InnerDivider: "║", + Top: "", + Left: "", + Right: "", + Bottom: "", + + TopRight: "", + TopLeft: "", + BottomRight: "", + BottomLeft: "", + + TopJunction: "", + LeftJunction: "", + RightJunction: "", + BottomJunction: "", + InnerJunction: "", + + InnerDivider: " ", } additionalKeyBinds = []key.Binding{runQueryKey} @@ -181,30 +205,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 = "" 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 +312,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 +341,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 +359,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 = "" @@ -362,70 +408,64 @@ func (m QueryModel) View() string { return "" } - // 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()), - ) + // ── Chrome: top HeaderStrip (KV context · keybinds · PB logo) ── + chromeView := buildQueryHeaderStrip(m) + chromeHeight := lipgloss.Height(chromeView) - 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) + // ── Top pane row: EDITOR (left) + TIME (right) ── + // Polish only — same structure as before. Both panes use the + // CardWithMeta primitive so titles + subtitles render consistently. + timeCardW := 32 + if m.width < 80 { + timeCardW = 26 } - - // 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 + gutter := 1 + editorW := m.width - timeCardW - gutter + if editorW < 36 { + editorW = 36 } - m.query.SetWidth(queryW - 2) // -2 for query panel border - header := lipgloss.JoinHorizontal(lipgloss.Top, - queryOuter.Render(m.query.View()), - timeRendered, + m.query.SetWidth(editorW - 6) + + editorRows := 12 + editorBody := m.query.View() + editorMeta := strings.ToUpper(extractDataset(m.query.Value())) + " · SQL" + editorCard := ui.CardWithMeta("EDITOR", editorMeta, editorW, editorRows, + m.currentFocus() == "query", editorBody) + + timeBody := buildTimeBody( + m.timeRange.start.Value(), + m.timeRange.end.Value(), + timeCardW-4, ) + timeCard := ui.CardWithMeta("TIME RANGE", "enter to edit", + timeCardW, editorRows, + m.currentFocus() == "time", timeBody) + + pad := lipgloss.NewStyle(). + Width(gutter). + Background(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Bg })). + Render("") + header := lipgloss.JoinHorizontal(lipgloss.Top, editorCard, pad, timeCard) headerHeight := lipgloss.Height(header) if m.loading { m.status.Info = "" m.status.Error = "" } + m.status.SetMode("SQL") + if len(m.dataRows) > 0 { + m.status.Info = fmt.Sprintf("rows %d", len(m.dataRows)) + } 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) - } - case overlayInputs: - helpKeys = m.timeRange.FullHelp() - helpKeys = append(helpKeys, additionalKeyBinds) - } - helpView := m.help.FullHelpView(helpKeys) - helpHeight := lipgloss.Height(helpView) + // Help keybinds now live in the top HeaderStrip — no in-body help. + // Modal overlay shows its own footer hints (timeRange.FullHelp()). + helpView := "" + helpHeight := 0 // Step 3: calculate exact table page size so everything fits. - tableAvail := m.height - headerHeight - helpHeight - statusHeight + tableAvail := m.height - chromeHeight - headerHeight - statusHeight pageSize := tableAvail - 6 if pageSize < 1 { pageSize = 1 @@ -436,75 +476,67 @@ func (m QueryModel) View() string { 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) + m.table = m.table.WithPageSize(pageSize).WithRows(displayRows).WithMaxTotalWidth(m.width - 4) // Step 4: compose main view. - availW := m.width + availW := m.width - 2 if availW < 0 { availW = 0 } - availH := tableAvail - 2 + availH := tableAvail - 4 if availH < 0 { - availH = 0 + availH = 1 } - var resultPane string - if !m.hasQueried { - // Welcome / empty state — no query has been run yet. - logoStyle := lipgloss.NewStyle(). + // Pick the right body for the RESULTS card. + var inner string + switch { + case !m.hasQueried: + // Empty state — block ASCII wordmark in brand accent + hint. + wordmark := lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return 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) + Render(parseableAsciiArt) + hintKey := lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Accent })). + Bold(true). + Render("ctrl+r") + hint := lipgloss.NewStyle(). + MarginTop(1). + Render(hintKey + lipgloss.NewStyle(). + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Faint })). + Render(" run query")) + content := lipgloss.JoinVertical(lipgloss.Center, wordmark, hint) + inner = lipgloss.Place(availW, availH, lipgloss.Center, lipgloss.Center, content) + case m.loading: + spinStyle := ui.Type().Accent 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. + inner = lipgloss.Place(availW, availH, lipgloss.Center, lipgloss.Center, content) + case m.fetchErrMsg != "": errStyle := lipgloss.NewStyle(). Padding(1, 2). - Foreground(lipgloss.AdaptiveColor{Light: "#9B2226", Dark: "#FF6B6B"}). - Width(m.width) + Foreground(ui.Adaptive(func(p ui.Palette) lipgloss.Color { return p.Err })). + Width(availW) rendered := errStyle.Render(m.fetchErrMsg) lines := strings.Split(rendered, "\n") - maxLines := tableAvail - 2 - if maxLines < 1 { - maxLines = 1 + if len(lines) > availH { + lines = lines[:availH] } - if len(lines) > maxLines { - lines = lines[:maxLines] - } - resultPane = tableOuter.Render(strings.Join(lines, "\n")) - } else { - resultPane = tableOuter.Render(m.table.View()) + inner = strings.Join(lines, "\n") + default: + inner = m.table.View() } + resultPane := ui.Card("RESULTS", m.width, availH, + m.currentFocus() == "table", inner) + var mainView string switch m.overlay { case overlayNone: mainView = lipgloss.JoinVertical(lipgloss.Left, header, resultPane) case overlayInputs: timeView := m.timeRange.View() - mainView = lipgloss.Place(m.width, m.height-helpHeight-statusHeight, + mainView = lipgloss.Place(m.width, m.height-chromeHeight-helpHeight-statusHeight, lipgloss.Center, lipgloss.Center, timeView, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceForeground(StandardSecondary), @@ -519,10 +551,296 @@ func (m QueryModel) View() string { mainView = mainView + strings.Repeat("\n", padLines) } - render := lipgloss.JoinVertical(lipgloss.Left, mainView, helpView, statusView) + _ = helpView + breadcrumbs := buildQueryBreadcrumbs(m) + render := lipgloss.JoinVertical(lipgloss.Left, + chromeView, + mainView, + breadcrumbs, + statusView, + ) return lipgloss.NewStyle().Width(m.width).Render(render) } +// 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