From 1548d38db01f650b78a6476cbd0471bfb761c461 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 21:27:18 +0530 Subject: [PATCH 01/18] colorize orca Signed-off-by: Balaji J --- internal/ui/banner.go | 52 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/internal/ui/banner.go b/internal/ui/banner.go index 337d3a3..f29a398 100644 --- a/internal/ui/banner.go +++ b/internal/ui/banner.go @@ -12,10 +12,30 @@ import ( //go:embed ascii.txt var asciiArt string -func orcaStr(out *termenv.Output) string { - oceanBlue := out.Color("#4E7080") - steel := out.Color("#A8BDC8") +// gradientColor computes an interpolated RGB color along a 3-stop gradient: +// Pine (#3e8fb0) → Foam (#9ccfd8) → Iris (#c4a7e7), where t ∈ [0.0, 1.0]. +func gradientColor(t float64) (r, g, b int) { + type rgb = [3]float64 + pine := rgb{62, 143, 176} + foam := rgb{156, 207, 216} + iris := rgb{196, 167, 231} + + lerp := func(a, b, t float64) float64 { return a + t*(b-a) } + + var c1, c2 rgb + var t2 float64 + if t <= 0.5 { + c1, c2, t2 = pine, foam, t*2 + } else { + c1, c2, t2 = foam, iris, (t-0.5)*2 + } + return int(lerp(c1[0], c2[0], t2)), + int(lerp(c1[1], c2[1], t2)), + int(lerp(c1[2], c2[2], t2)) +} + +func orcaStr(out *termenv.Output) string { // Strip leading blank lines and trailing spaces per line. lines := strings.Split(asciiArt, "\n") for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { @@ -28,17 +48,27 @@ func orcaStr(out *termenv.Output) string { lines[i] = strings.TrimRight(line, " ") } + total := len(lines) var sb strings.Builder - for _, r := range strings.Join(lines, "\n") { - switch r { - case ':', ';', '.', ',', '-': - sb.WriteString(out.String(string(r)).Foreground(oceanBlue).String()) - case '▆', '▀': - sb.WriteString(out.String(string(r)).Foreground(steel).String()) - default: - sb.WriteRune(r) + + for i, line := range lines { + if i > 0 { + sb.WriteByte('\n') + } + + // Compute gradient position for this line. + t := 0.0 + if total > 1 { + t = float64(i) / float64(total-1) + } + r, g, b := gradientColor(t) + lineCol := out.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) + + for _, ch := range line { + sb.WriteString(out.String(string(ch)).Foreground(lineCol).String()) } } + return sb.String() } From 479b48342f673c864b7c67b10d729bda8b8de686 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 21:30:50 +0530 Subject: [PATCH 02/18] add borders and style ui. --- internal/app/app.go | 4 +++- internal/app/ui/model.go | 50 ++++++++++++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 241baa2..ecc6747 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -85,7 +85,9 @@ func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { result, quit, err := p.handleSpecialCommand(ctx, metaResult, p.client) if quit { p.logger.Info("REPL exiting via quit command") - return ui.ExecCmdMsg{Cmd: tea.Quit} + return ui.ExecCmdMsg{Cmd: func() tea.Msg { + return ui.QuitRequestMsg{} + }} } if err != nil { diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 5253781..09b6172 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -24,10 +24,11 @@ import ( var chromaFormatter = detectTerminalColorProfile() var ( - userInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#908CAA")) - appOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E0DEF4")) - errorOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - statusBarStyle = lipgloss.NewStyle(). + userInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#908CAA")) + appOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E0DEF4")) + errorOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) + inputSeparatorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7b40a0")) // border for input + statusBarStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#908CAA")). Background(lipgloss.Color("#2A273F")). Padding(0, 1) @@ -39,6 +40,12 @@ type ReadyMsg struct{ Prefix string } // ExecCmdMsg is used to dispatch a batch/sequence of commands. type ExecCmdMsg struct{ Cmd tea.Cmd } +// QuitRequestMsg signals that the app wants to quit. +type QuitRequestMsg struct{} + +// ConfirmQuitMsg is used internally to finalize quitting. +type ConfirmQuitMsg struct{} + type cancel func(ctx context.Context) error type execute func(query string) tea.Cmd @@ -47,6 +54,7 @@ type Model struct { input *editline.Model width, height int executing bool + quitting bool prevUserInput string historyFile string style string @@ -83,30 +91,45 @@ func (m *Model) Init() tea.Cmd { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case QuitRequestMsg: + m.quitting = true + return m, func() tea.Msg { + return ConfirmQuitMsg{} + } + + case ConfirmQuitMsg: + return m, tea.Quit + case ReadyMsg: m.executing = false if msg.Prefix != "" { m.input.Prompt = msg.Prefix } - m.input.Reset() + return m, nil case ExecCmdMsg: return m, msg.Cmd case editline.InputCompleteMsg: + if m.executing { + return m, nil + } return m.handleInput() case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.input.SetSize(msg.Width, msg.Height-4) + m.input.SetSize(msg.Width, msg.Height-6) return m, nil case tea.KeyMsg: switch msg.String() { + case "ctrl+d": + return m, func() tea.Msg { + return QuitRequestMsg{} + } case "ctrl+c": - m.input.Reset() if m.executing { m.executing = false cancelFn := m.cancel @@ -118,6 +141,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + m.input.Reset() return m, nil } } @@ -143,6 +167,7 @@ func (m *Model) handleInput() (tea.Model, tea.Cmd) { m.prevUserInput = input m.executing = true m.input.AddHistoryEntry(input) + m.input.Reset() return m, tea.Sequence( m.printUserInput(userInputStyle.Render(m.input.Prompt), input), @@ -161,13 +186,14 @@ func (m *Model) printUserInput(prefix, input string) tea.Cmd { } func (m *Model) View() tea.View { - statusStyle := statusBarStyle.Width(m.width) - if m.executing { - statusBar := statusStyle.AlignVertical(lipgloss.Bottom).Render("pgxcli") - return tea.NewView(statusBar) + if m.quitting { + return tea.NewView("") } - str := lipgloss.Sprintf("%s\n%s", m.input.View(), statusStyle.Render("pgxcli")) + statusStyle := statusBarStyle.Width(m.width) + separator := inputSeparatorStyle.Render(strings.Repeat("─", m.width)) // Full-width top + bottom borders for input + + str := lipgloss.Sprintf("%s\n%s\n%s\n%s", separator, m.input.View(), separator, statusStyle.Render("pgxcli")) return tea.NewView(str) } From e86c643a5014a2da89832cbc4a5d383ee6bc2cd1 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 22:23:14 +0530 Subject: [PATCH 03/18] fix: unify ui --- internal/app/app.go | 5 +++-- internal/{ => app}/ui/ascii.txt | 1 - internal/{ => app}/ui/banner.go | 14 +++++--------- internal/{ => app}/ui/forms.go | 0 internal/{ => app}/ui/forms_test.go | 0 internal/app/ui/model.go | 9 +++++++-- internal/cli/root.go | 7 ++----- 7 files changed, 17 insertions(+), 19 deletions(-) rename internal/{ => app}/ui/ascii.txt (94%) rename internal/{ => app}/ui/banner.go (76%) rename internal/{ => app}/ui/forms.go (100%) rename internal/{ => app}/ui/forms_test.go (100%) diff --git a/internal/app/app.go b/internal/app/app.go index ecc6747..0387514 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -28,7 +28,7 @@ import ( // Application defines the interface for the main application logic. type Application interface { // Start starts the main repl loop, reading input, executing commands and printing results until the user exits. - Start(ctx context.Context) error + Start(ctx context.Context, version string) error // Close performs saving history before exiting. Close() error @@ -140,7 +140,7 @@ func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { } } -func (p *pgxCLI) Start(ctx context.Context) error { +func (p *pgxCLI) Start(ctx context.Context, version string) error { executeFunc := func(query string) tea.Cmd { return p.execute(ctx, query) } @@ -151,6 +151,7 @@ func (p *pgxCLI) Start(ctx context.Context) error { p.completer.GetKeyWords(), p.config.Main.HistoryFile, string(p.config.Main.Style), + version, executeFunc, p.Cancel, ) diff --git a/internal/ui/ascii.txt b/internal/app/ui/ascii.txt similarity index 94% rename from internal/ui/ascii.txt rename to internal/app/ui/ascii.txt index 4711374..acf1ec2 100644 --- a/internal/ui/ascii.txt +++ b/internal/app/ui/ascii.txt @@ -1,4 +1,3 @@ - ::: :::: . :::: ::::::::::::: :::: :::: ::: diff --git a/internal/ui/banner.go b/internal/app/ui/banner.go similarity index 76% rename from internal/ui/banner.go rename to internal/app/ui/banner.go index f29a398..e5a210e 100644 --- a/internal/ui/banner.go +++ b/internal/app/ui/banner.go @@ -6,14 +6,13 @@ import ( "os" "strings" + tea "charm.land/bubbletea/v2" "github.com/muesli/termenv" ) //go:embed ascii.txt var asciiArt string -// gradientColor computes an interpolated RGB color along a 3-stop gradient: -// Pine (#3e8fb0) → Foam (#9ccfd8) → Iris (#c4a7e7), where t ∈ [0.0, 1.0]. func gradientColor(t float64) (r, g, b int) { type rgb = [3]float64 pine := rgb{62, 143, 176} @@ -36,7 +35,6 @@ func gradientColor(t float64) (r, g, b int) { } func orcaStr(out *termenv.Output) string { - // Strip leading blank lines and trailing spaces per line. lines := strings.Split(asciiArt, "\n") for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { lines = lines[1:] @@ -56,7 +54,6 @@ func orcaStr(out *termenv.Output) string { sb.WriteByte('\n') } - // Compute gradient position for this line. t := 0.0 if total > 1 { t = float64(i) / float64(total-1) @@ -72,19 +69,18 @@ func orcaStr(out *termenv.Output) string { return sb.String() } -// orcaView returns the colored orca art for use in TUI layouts. func orcaView() string { return orcaStr(termenv.NewOutput(os.Stdout)) } -// PrintBanner prints the colored ASCII art banner and a welcome line. -func PrintBanner(version string) { +func BannerCmd(version string) tea.Cmd { out := termenv.NewOutput(os.Stdout) green := out.Color("#02BF87") - fmt.Print(orcaStr(out)) - fmt.Printf("\n %s %s\n\n", + str := fmt.Sprintf("%s\n %s %s\n\n", + orcaStr(out), out.String("pgxcli v"+version).Foreground(green).Bold().String(), out.String("\\q to quit").Foreground(out.Color("240")).String(), ) + return tea.Printf("%s", str) } diff --git a/internal/ui/forms.go b/internal/app/ui/forms.go similarity index 100% rename from internal/ui/forms.go rename to internal/app/ui/forms.go diff --git a/internal/ui/forms_test.go b/internal/app/ui/forms_test.go similarity index 100% rename from internal/ui/forms_test.go rename to internal/app/ui/forms_test.go diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 09b6172..314b8ac 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -58,13 +58,14 @@ type Model struct { prevUserInput string historyFile string style string + version string // execute executes a query passed and return as ExecCmdMsg + ReadyMsg. execute execute cancel cancel } -func New(initialPrefix string, pgKeywords []string, historyFile string, style string, executeFunc execute, cancelFunc cancel) (*Model, error) { +func New(initialPrefix string, pgKeywords []string, historyFile string, style string, version string, executeFunc execute, cancelFunc cancel) (*Model, error) { el := editline.New(0, 0) el.Prompt = initialPrefix if historyFile == "" || historyFile == config.Default { @@ -79,13 +80,17 @@ func New(initialPrefix string, pgKeywords []string, historyFile string, style st input: el, historyFile: historyFile, style: style, + version: version, execute: executeFunc, cancel: cancelFunc, }, nil } func (m *Model) Init() tea.Cmd { - return m.input.Focus() + return tea.Sequence( + BannerCmd(m.version), + m.input.Focus(), + ) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/internal/cli/root.go b/internal/cli/root.go index 71725c7..1fdd60f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,11 +12,11 @@ import ( "github.com/balajz/pgxcli/internal/app" "github.com/balajz/pgxcli/internal/app/renderer" + "github.com/balajz/pgxcli/internal/app/ui" "github.com/balajz/pgxcli/internal/completer" "github.com/balajz/pgxcli/internal/config" "github.com/balajz/pgxcli/internal/database" "github.com/balajz/pgxcli/internal/logger" - "github.com/balajz/pgxcli/internal/ui" "github.com/spf13/cobra" ) @@ -77,10 +77,7 @@ func NewRootCmd(ctx context.Context, cliCtx *CliContext) *cobra.Command { cliCtx.Logger.Error("Application context not initialized") return fmt.Errorf("application context not initialized") } - if !bool(interactiveConnFlag) { - ui.PrintBanner(version) - } - return cliCtx.App.Start(ctx) + return cliCtx.App.Start(ctx, version) }, PersistentPostRunE: func(_ *cobra.Command, _ []string) error { From afa7ec5a0acdb08117933979ba818e41deb2b38e Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 23:52:04 +0530 Subject: [PATCH 04/18] border the banner and add welcome message. --- internal/app/ui/banner.go | 75 +++++++++++++++++++++++++++++++-------- internal/app/ui/forms.go | 2 +- internal/app/ui/model.go | 33 +++++++++++------ 3 files changed, 83 insertions(+), 27 deletions(-) diff --git a/internal/app/ui/banner.go b/internal/app/ui/banner.go index e5a210e..5e0a512 100644 --- a/internal/app/ui/banner.go +++ b/internal/app/ui/banner.go @@ -7,7 +7,30 @@ import ( "strings" tea "charm.land/bubbletea/v2" - "github.com/muesli/termenv" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" +) + +var issueLink = "https://github.com/balajz/pgxcli/issues" + +var ( + primaryStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B5CF6")). + Bold(true) + + secondaryStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")) + + mutedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A1A1AA")) + + accentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + Italic(true) + + linkStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C3AED")). + Underline(true) ) //go:embed ascii.txt @@ -34,7 +57,7 @@ func gradientColor(t float64) (r, g, b int) { int(lerp(c1[2], c2[2], t2)) } -func orcaStr(out *termenv.Output) string { +func orcaStr() string { lines := strings.Split(asciiArt, "\n") for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { lines = lines[1:] @@ -59,28 +82,50 @@ func orcaStr(out *termenv.Output) string { t = float64(i) / float64(total-1) } r, g, b := gradientColor(t) - lineCol := out.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) + hexColor := lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) + style := lipgloss.NewStyle().Foreground(hexColor) for _, ch := range line { - sb.WriteString(out.String(string(ch)).Foreground(lineCol).String()) + sb.WriteString(style.Render(string(ch))) } } return sb.String() } -func orcaView() string { - return orcaStr(termenv.NewOutput(os.Stdout)) -} - func BannerCmd(version string) tea.Cmd { - out := termenv.NewOutput(os.Stdout) - green := out.Color("#02BF87") + leftPane := orcaStr() - str := fmt.Sprintf("%s\n %s %s\n\n", - orcaStr(out), - out.String("pgxcli v"+version).Foreground(green).Bold().String(), - out.String("\\q to quit").Foreground(out.Color("240")).String(), + rightPane := lipgloss.JoinVertical( + lipgloss.Left, + secondaryStyle.Render("welcome to ")+ + primaryStyle.Render("pgxcli ")+ + mutedStyle.Render("v"+version), + + accentStyle.Render("Happy Postgresing!"), + + linkStyle. + Hyperlink(issueLink). + Render("Report Issues ↗"), + ) + + content := lipgloss.JoinHorizontal( + lipgloss.Center, + leftPane, + " ", // gap between art and text + rightPane, ) - return tea.Printf("%s", str) + + banner := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#8B5CF6")). + Padding(1, 4). + Render(content) + + w, _, err := term.GetSize(os.Stdout.Fd()) + if err == nil && w > 0 { + banner = lipgloss.Place(w, lipgloss.Height(banner), lipgloss.Center, lipgloss.Top, banner) + } + + return tea.Printf("%s\n", banner) } diff --git a/internal/app/ui/forms.go b/internal/app/ui/forms.go index 3f6c003..e9d1319 100644 --- a/internal/app/ui/forms.go +++ b/internal/app/ui/forms.go @@ -188,7 +188,7 @@ func (m *model) View() tea.View { return view default: // Orca (left side) - orca := lipgloss.NewStyle().Margin(1, 4, 0, 0).Render(orcaView()) + orca := lipgloss.NewStyle().Margin(1, 4, 0, 0).Render(orcaStr()) // Form card (right side) v := strings.TrimSuffix(m.form.View(), "\n\n") diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 314b8ac..f35d93f 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -24,14 +24,22 @@ import ( var chromaFormatter = detectTerminalColorProfile() var ( - userInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#908CAA")) - appOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E0DEF4")) - errorOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - inputSeparatorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7b40a0")) // border for input - statusBarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#908CAA")). - Background(lipgloss.Color("#2A273F")). - Padding(0, 1) + userInputStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")) + + appOutputStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")) + + errorOutputStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B6B")) + + inputSeparatorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B5CF6")) + + statusBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + Background(lipgloss.Color("#2A273F")). + Padding(0, 1) ) // ReadyMsg signals the ui that execution is done and it should prompt. @@ -66,7 +74,7 @@ type Model struct { } func New(initialPrefix string, pgKeywords []string, historyFile string, style string, version string, executeFunc execute, cancelFunc cancel) (*Model, error) { - el := editline.New(0, 0) + el := editline.New(1, 1) el.Prompt = initialPrefix if historyFile == "" || historyFile == config.Default { historyFile = getHistoryFilePath() @@ -110,8 +118,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Prefix != "" { m.input.Prompt = msg.Prefix } - - return m, nil + return m, m.input.Focus() case ExecCmdMsg: return m, msg.Cmd @@ -194,6 +201,10 @@ func (m *Model) View() tea.View { if m.quitting { return tea.NewView("") } + // Don't render until we know the terminal size. + if m.width == 0 { + return tea.NewView("") + } statusStyle := statusBarStyle.Width(m.width) separator := inputSeparatorStyle.Render(strings.Repeat("─", m.width)) // Full-width top + bottom borders for input From c78f87aa3334026917d3301a4ea6693906034450 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 23:53:42 +0530 Subject: [PATCH 05/18] remove empty line form ascii image --- internal/app/ui/ascii.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/ui/ascii.txt b/internal/app/ui/ascii.txt index acf1ec2..1b5e968 100644 --- a/internal/app/ui/ascii.txt +++ b/internal/app/ui/ascii.txt @@ -11,4 +11,4 @@ :::-▆▆▆▆▆▆▆▆▆;::;;;;;;;:::▆▆▆▆ :::: ▆▆▆▆::::;;;;;::; .::: ;::::: - ;::: + ;::: \ No newline at end of file From 67d49a5a91be4ded2dcc988c732de8ed00f17938 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 09:49:08 +0530 Subject: [PATCH 06/18] customize ui and add spinner while executing Signed-off-by: Balaji J --- internal/app/app.go | 12 ++++++++- internal/app/ui/banner.go | 11 ++++---- internal/app/ui/model.go | 55 +++++++++++++++++++++++++++++++++++---- internal/cli/root.go | 2 +- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 0387514..9490bad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,11 +8,13 @@ import ( "context" "fmt" "log/slog" + "os" "strconv" "strings" "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/balaji01-4d/pgxspecial" "github.com/balajz/pgxcli/internal/app/commands" "github.com/balajz/pgxcli/internal/app/renderer" @@ -47,15 +49,18 @@ type pgxCLI struct { logger *slog.Logger completer *completer.Completer client *database.Client + + version string } -func New(cfg *config.Config, printer cliio.Printer, logger *slog.Logger, completer *completer.Completer, client *database.Client) (Application, error) { +func New(cfg *config.Config, printer cliio.Printer, logger *slog.Logger, completer *completer.Completer, client *database.Client, version string) (Application, error) { return &pgxCLI{ config: cfg, logger: logger, Printer: printer, completer: completer, client: client, + version: version, }, nil } @@ -141,6 +146,7 @@ func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { } func (p *pgxCLI) Start(ctx context.Context, version string) error { + p.printBanner(p.version) executeFunc := func(query string) tea.Cmd { return p.execute(ctx, query) } @@ -242,6 +248,10 @@ func (p *pgxCLI) Cancel(ctx context.Context) error { return p.client.Cancel(ctx) } +func (p *pgxCLI) printBanner(version string) { + lipgloss.Fprint(os.Stdout, ui.Banner(version)+"\n") +} + func (p *pgxCLI) handleQueryResult(r result.Result) (tea.Cmd, error) { res, ok := r.(*result.QueryResult) if !ok { diff --git a/internal/app/ui/banner.go b/internal/app/ui/banner.go index 5e0a512..2c38c59 100644 --- a/internal/app/ui/banner.go +++ b/internal/app/ui/banner.go @@ -6,7 +6,6 @@ import ( "os" "strings" - tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/term" ) @@ -93,16 +92,16 @@ func orcaStr() string { return sb.String() } -func BannerCmd(version string) tea.Cmd { +func Banner(version string) string { leftPane := orcaStr() rightPane := lipgloss.JoinVertical( lipgloss.Left, secondaryStyle.Render("welcome to ")+ primaryStyle.Render("pgxcli ")+ - mutedStyle.Render("v"+version), + mutedStyle.Render("v"+version)+"\n\n", - accentStyle.Render("Happy Postgresing!"), + accentStyle.Render("Happy Postgresing!\n\n"), linkStyle. Hyperlink(issueLink). @@ -110,7 +109,7 @@ func BannerCmd(version string) tea.Cmd { ) content := lipgloss.JoinHorizontal( - lipgloss.Center, + lipgloss.Top, leftPane, " ", // gap between art and text rightPane, @@ -127,5 +126,5 @@ func BannerCmd(version string) tea.Cmd { banner = lipgloss.Place(w, lipgloss.Height(banner), lipgloss.Center, lipgloss.Top, banner) } - return tea.Printf("%s\n", banner) + return banner } diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index f35d93f..cd25b74 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -11,6 +11,7 @@ import ( "strings" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/Balaji01-4D/bubbline/computil" @@ -33,9 +34,22 @@ var ( errorOutputStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF6B6B")) + userQuerySeparatorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#B8A2FF")) + inputSeparatorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#8B5CF6")) + spinnerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + PaddingLeft(2). + Bold(true) + + spinnerCaptionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")). + Italic(true). + Faint(true) + statusBarStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#C4B5FD")). Background(lipgloss.Color("#2A273F")). @@ -60,6 +74,7 @@ type execute func(query string) tea.Cmd type Model struct { input *editline.Model + spinner spinner.Model width, height int executing bool quitting bool @@ -84,8 +99,13 @@ func New(initialPrefix string, pgKeywords []string, historyFile string, style st return nil, fmt.Errorf("applying input config: %w", err) } + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = spinnerStyle + return &Model{ input: el, + spinner: sp, historyFile: historyFile, style: style, version: version, @@ -95,9 +115,9 @@ func New(initialPrefix string, pgKeywords []string, historyFile string, style st } func (m *Model) Init() tea.Cmd { - return tea.Sequence( - BannerCmd(m.version), + return tea.Batch( m.input.Focus(), + m.spinner.Tick, ) } @@ -123,6 +143,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ExecCmdMsg: return m, msg.Cmd + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case editline.InputCompleteMsg: if m.executing { return m, nil @@ -132,7 +157,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.input.SetSize(msg.Width, msg.Height-6) + m.input.SetSize(msg.Width, msg.Height-4) return m, nil case tea.KeyMsg: @@ -193,7 +218,10 @@ func (m *Model) printUserInput(prefix, input string) tea.Cmd { highlightedInput = postgresHighlighter(m.style)(input) } + line := strings.Repeat("─", m.width/2) + userContent := lipgloss.JoinHorizontal(lipgloss.Left, userInputStyle.Render(prefix), highlightedInput) + userContent = lipgloss.JoinVertical(lipgloss.Top, userQuerySeparatorStyle.Render(line), userContent) return tea.Printf("%s", userContent) } @@ -206,11 +234,28 @@ func (m *Model) View() tea.View { return tea.NewView("") } + if m.executing { + spinnerLine := lipgloss.JoinHorizontal( + lipgloss.Left, + m.spinner.View(), + spinnerCaptionStyle.Render("Postgresing..."), + ) + + return tea.NewView(spinnerLine) + } + statusStyle := statusBarStyle.Width(m.width) separator := inputSeparatorStyle.Render(strings.Repeat("─", m.width)) // Full-width top + bottom borders for input - str := lipgloss.Sprintf("%s\n%s\n%s\n%s", separator, m.input.View(), separator, statusStyle.Render("pgxcli")) - return tea.NewView(str) + inputView := lipgloss.JoinVertical( + lipgloss.Top, + separator, + m.input.View(), + separator, + statusStyle.Render("pgxcli "+m.version), + ) + + return tea.NewView(inputView) } func (m *Model) saveHistory() error { diff --git a/internal/cli/root.go b/internal/cli/root.go index 1fdd60f..e0355e0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -355,7 +355,7 @@ func ensureConnected(cliCtx *CliContext) error { // which includes setting up the logger, config and autocompleter with PostgreSQL keywords. func initApplication(cliCtx *CliContext) error { completer := completer.New(cliCtx.Logger.Logger) - pgxCLI, err := app.New(cliCtx.config, cliCtx.Printer, cliCtx.Logger.Logger, completer, cliCtx.Client) + pgxCLI, err := app.New(cliCtx.config, cliCtx.Printer, cliCtx.Logger.Logger, completer, cliCtx.Client, version) if err != nil { cliCtx.Logger.Error("Failed to initialize app", "error", err) return err From 2fd909d5ff11c11cd11a9ffdc4e1ad6327c451e1 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:11:09 +0530 Subject: [PATCH 07/18] extract the childrens form ui Signed-off-by: Balaji J --- internal/app/ui/components/input.go | 180 ++++++++++++++++++++++++++ internal/app/ui/components/spinner.go | 49 +++++++ internal/app/ui/components/status.go | 59 +++++++++ 3 files changed, 288 insertions(+) create mode 100644 internal/app/ui/components/input.go create mode 100644 internal/app/ui/components/spinner.go create mode 100644 internal/app/ui/components/status.go diff --git a/internal/app/ui/components/input.go b/internal/app/ui/components/input.go new file mode 100644 index 0000000..47f8d71 --- /dev/null +++ b/internal/app/ui/components/input.go @@ -0,0 +1,180 @@ +package components + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/Balaji01-4D/bubbline/computil" + "github.com/Balaji01-4D/bubbline/editline" + "github.com/Balaji01-4D/bubbline/history" + "github.com/alecthomas/chroma/v2/quick" + "github.com/muesli/termenv" +) + +var chromaFormatter = detectTerminalColorProfile() + +// InputModel wraps the editline component. +type InputModel struct { + Model *editline.Model + HistoryFile string +} + +// NewInputModel creates and configures the input model. +func NewInputModel(prompt, historyFile string, pgKeywords []string, style string) (*InputModel, error) { + el := editline.New(1, 1) + el.Prompt = prompt + + if historyFile == "" || historyFile == "default" { + historyFile = getHistoryFilePath() + } + + if err := applyEditlineConfig(el, historyFile, pgKeywords, style); err != nil { + return nil, fmt.Errorf("applying input config: %w", err) + } + + return &InputModel{ + Model: el, + HistoryFile: historyFile, + }, nil +} + +func (m *InputModel) Init() tea.Cmd { + return m.Model.Focus() +} + +func (m *InputModel) Update(msg tea.Msg) (InputModel, tea.Cmd) { + newModel, cmd := m.Model.Update(msg) + m.Model = newModel + return *m, cmd +} + +func (m *InputModel) View() string { + return m.Model.View() +} + +func (m *InputModel) SetSize(width, height int) { + m.Model.SetSize(width, height) +} + +func (m *InputModel) Value() string { + return m.Model.Value() +} + +func (m *InputModel) Reset() { + m.Model.Reset() +} + +func (m *InputModel) AddHistoryEntry(entry string) { + m.Model.AddHistoryEntry(entry) +} + +func (m *InputModel) SaveHistory() error { + if m.HistoryFile == "" { + return nil + } + return history.SaveHistory(m.Model.GetHistory(), m.HistoryFile) +} + +func (m *InputModel) SetPrompt(prompt string) { + m.Model.Prompt = prompt +} + +func (m *InputModel) Prompt() string { + return m.Model.Prompt +} + +func postgresHighlighter(style string) func(string) string { + return func(s string) string { + var buf bytes.Buffer + if err := quick.Highlight(&buf, s, "postgresql", chromaFormatter, style); err != nil { + return s + } + return buf.String() + } +} + +func postgresAutocomplete(pgKeywords []string) func(v [][]rune, line, col int) (string, editline.Completions) { + return func(v [][]rune, line, col int) (string, editline.Completions) { + word, wstart, wend := computil.FindWord(v, line, col) + if word == "" { + return "", nil + } + upperWord := strings.ToUpper(word) + var matches []string + for _, kw := range pgKeywords { + if strings.HasPrefix(kw, upperWord) { + matches = append(matches, kw) + } + } + if len(matches) == 0 { + return "", nil + } + return "", editline.SimpleWordsCompletion(matches, "Keywords", col, wstart, wend) + } +} + +func detectTerminalColorProfile() string { + switch termenv.ColorProfile() { + case termenv.TrueColor: + return "terminal16m" + case termenv.ANSI256: + return "terminal256" + case termenv.ANSI: + return "terminal16" + default: + return "noop" + } +} + +func applyEditlineConfig(el *editline.Model, historyFile string, pgKeywords []string, style string) error { + el.SetHelpDisabled(true) + el.SetHighlighter(postgresHighlighter(style)) + el.SetExternalEditorEnabled(true, "sql") + el.KeyMap.ExternalEdit = key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "edit query in external editor"), + ) + el.AutoComplete = postgresAutocomplete(pgKeywords) + + el.CheckInputComplete = func(entireInput [][]rune, line, col int) bool { + var sb strings.Builder + for i, rline := range entireInput { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString(string(rline)) + } + input := strings.TrimSpace(sb.String()) + + if input == "" { + return true + } + + if strings.HasPrefix(input, "\\") { + return true + } + + return strings.HasSuffix(input, ";") + } + + entries, err := history.LoadHistory(historyFile) + if err != nil { + return fmt.Errorf("loading history: %w", err) + } + + el.SetHistory(entries) + return nil +} + +func getHistoryFilePath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".pgxcli_history.jsonl") +} diff --git a/internal/app/ui/components/spinner.go b/internal/app/ui/components/spinner.go new file mode 100644 index 0000000..2b59f89 --- /dev/null +++ b/internal/app/ui/components/spinner.go @@ -0,0 +1,49 @@ +package components + +import ( + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type SpinnerModel struct { + spinner spinner.Model + caption string + + spinnerStyle lipgloss.Style + captionStyle lipgloss.Style +} + +func NewSpinnerModel(spinnerStyle, captionStyle lipgloss.Style) SpinnerModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = spinnerStyle + + return SpinnerModel{ + spinner: sp, + caption: "Postgresing...", + spinnerStyle: spinnerStyle, + captionStyle: captionStyle, + } +} + +func (m SpinnerModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m SpinnerModel) Update(msg tea.Msg) (SpinnerModel, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case spinner.TickMsg: + m.spinner, cmd = m.spinner.Update(msg) + } + return m, cmd +} + +func (m SpinnerModel) View() string { + return lipgloss.JoinHorizontal( + lipgloss.Left, + m.spinner.View(), + m.captionStyle.Render(m.caption), + ) +} diff --git a/internal/app/ui/components/status.go b/internal/app/ui/components/status.go new file mode 100644 index 0000000..8c54385 --- /dev/null +++ b/internal/app/ui/components/status.go @@ -0,0 +1,59 @@ +package components + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// StatusModel manages the status bar and the executing spinner. +type StatusModel struct { + Version string + Width int + SeparatorStyle lipgloss.Style + StatusBarStyle lipgloss.Style +} + +func NewStatusModel(version string) StatusModel { + return StatusModel{ + Version: version, + } +} + +func (m StatusModel) Init() tea.Cmd { + return nil +} + +func (m StatusModel) Update(msg tea.Msg) (StatusModel, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Width = msg.Width + } + return m, cmd +} + +// ViewFooter returns the static footer with separator and status bar. +func (m StatusModel) View() string { + if m.Width == 0 { + return "" + } + separator := m.SeparatorStyle.Render(strings.Repeat("─", m.Width)) + statusBar := m.StatusBarStyle.Width(m.Width).Render("pgxcli " + m.Version) + + return lipgloss.JoinVertical( + lipgloss.Top, + separator, + statusBar, + ) +} + +// StaticHeight returns the height of the footer. +func (m StatusModel) StaticHeight() int { + separator := m.SeparatorStyle.Render(strings.Repeat("─", m.Width)) + statusBar := m.StatusBarStyle.Width(m.Width).Render("pgxcli " + m.Version) + + // Top separator + Bottom separator + Status bar + return lipgloss.Height(separator)*2 + lipgloss.Height(statusBar) +} From 2bdfc31faf47fa8ea6b4c12bef67c09f6d146a44 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:12:49 +0530 Subject: [PATCH 08/18] create seperate style source Signed-off-by: Balaji J --- internal/app/ui/styles.go | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 internal/app/ui/styles.go diff --git a/internal/app/ui/styles.go b/internal/app/ui/styles.go new file mode 100644 index 0000000..0e98912 --- /dev/null +++ b/internal/app/ui/styles.go @@ -0,0 +1,52 @@ +package ui + +import "charm.land/lipgloss/v2" + +type Styles struct { + // UserInput styles the user's input query after enter key is pressed. + UserInput lipgloss.Style + // AppOutput styles the output from the application after executing the query. + AppOutput lipgloss.Style + // ErrorOutput styles the error output from the application after executing the query. + ErrorOutput lipgloss.Style + + // UserInputSepartor styles the separator between user input from previous result. + UserInputSepartor lipgloss.Style + + // InputSeparator is a top and bottom border for editline input area. + InputSeparator lipgloss.Style + + // Spinner styles the spinner animation. + Spinner lipgloss.Style + SpinnerCaption lipgloss.Style + + // StatusBar styles the status bar at the bottom. + StatusBar lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + UserInput: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")), + AppOutput: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")), + ErrorOutput: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B6B")), + UserInputSepartor: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#B8A2FF")), + InputSeparator: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B5CF6")), + Spinner: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + PaddingLeft(2). + Bold(true), + SpinnerCaption: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")). + Italic(true). + Faint(true), + StatusBar: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + Background(lipgloss.Color("#2A273F")). + Padding(0, 1), + } +} From d37668fb4b3823a4cfd0e9bbe6c00652e4bd49d9 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:14:00 +0530 Subject: [PATCH 09/18] add keybinding Signed-off-by: Balaji J --- internal/app/ui/keys.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 internal/app/ui/keys.go diff --git a/internal/app/ui/keys.go b/internal/app/ui/keys.go new file mode 100644 index 0000000..e86dd89 --- /dev/null +++ b/internal/app/ui/keys.go @@ -0,0 +1,23 @@ +package ui + +import "charm.land/bubbles/v2/key" + +// KeyMap defines the keybindings for the application. +type KeyMap struct { + Quit key.Binding + Interrupt key.Binding +} + +// DefaultKeyMap returns the default keybindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Quit: key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("ctrl+d", "quit"), + ), + Interrupt: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "interrupt"), + ), + } +} From 74431e872f8b272e2fa8d6e212aacc0a66e16881 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:15:50 +0530 Subject: [PATCH 10/18] refactor: make the ui more maintainable Signed-off-by: Balaji J --- internal/app/app.go | 14 +- internal/app/commands/clear_screen.go | 13 -- internal/app/ui/model.go | 315 +++++++++++--------------- 3 files changed, 138 insertions(+), 204 deletions(-) delete mode 100644 internal/app/commands/clear_screen.go diff --git a/internal/app/app.go b/internal/app/app.go index 9490bad..dfa0aee 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -16,7 +16,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/balaji01-4d/pgxspecial" - "github.com/balajz/pgxcli/internal/app/commands" "github.com/balajz/pgxcli/internal/app/renderer" "github.com/balajz/pgxcli/internal/app/ui" "github.com/balajz/pgxcli/internal/cliio" @@ -36,8 +35,8 @@ type Application interface { Close() error } -var builtinsCommand = map[string]func(){ - "\\clear": commands.ClearScreen, +var builtinsCommand = map[string]func() tea.Cmd{ + "\\clear": func() tea.Cmd { return tea.ClearScreen }, } // pgxCLI is the main implementation of the Application interface. @@ -74,8 +73,7 @@ func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { if cmd, ok := builtinsCommand[query]; ok { p.logger.Debug("executing builtin command", "command", query) - cmd() - return promptReady + return tea.Sequence(cmd(), promptReady) } return func() tea.Msg { @@ -281,16 +279,16 @@ func (p *pgxCLI) printViaPager(str string) tea.Cmd { if p.Printer.ShouldUsePager(str) { cmd, ok := cliio.PagerCmd(str) if !ok { - return ui.PrintCmd(str) + return ui.PrintCmd(str, ui.DefaultStyles().AppOutput) } return ui.ShowPagerCmd(cmd) } - return ui.PrintCmd(str) + return ui.PrintCmd(str, ui.DefaultStyles().AppOutput) } func (p *pgxCLI) printError(err error) tea.Cmd { - return ui.PrintErrCmd(err) + return ui.PrintErrCmd(err, ui.DefaultStyles().ErrorOutput) } func (p *pgxCLI) Close() error { diff --git a/internal/app/commands/clear_screen.go b/internal/app/commands/clear_screen.go deleted file mode 100644 index a06682e..0000000 --- a/internal/app/commands/clear_screen.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package commands provides implementations for various commands -// that can be executed in the pgxCLI application. -package commands - -import "fmt" - -const escCode = "\033[2J\033[H\033[3J" - -// ClearScreen clears the terminal screen -// by printing the appropriate escape code. -func ClearScreen() { - fmt.Print(escCode) -} diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index cd25b74..d498b7f 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -7,53 +7,26 @@ import ( "fmt" "os" "os/exec" - "path/filepath" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/Balaji01-4D/bubbline/computil" "github.com/Balaji01-4D/bubbline/editline" - "github.com/Balaji01-4D/bubbline/history" "github.com/alecthomas/chroma/v2/quick" - "github.com/balajz/pgxcli/internal/config" + "github.com/balajz/pgxcli/internal/app/ui/components" + "github.com/davecgh/go-spew/spew" "github.com/muesli/termenv" ) var chromaFormatter = detectTerminalColorProfile() -var ( - userInputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A78BFA")) +type State int - appOutputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C4B5FD")) - - errorOutputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B6B")) - - userQuerySeparatorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#B8A2FF")) - - inputSeparatorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#8B5CF6")) - - spinnerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C4B5FD")). - PaddingLeft(2). - Bold(true) - - spinnerCaptionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A78BFA")). - Italic(true). - Faint(true) - - statusBarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C4B5FD")). - Background(lipgloss.Color("#2A273F")). - Padding(0, 1) +const ( + StateInput State = iota + StateExecuting ) // ReadyMsg signals the ui that execution is done and it should prompt. @@ -73,55 +46,90 @@ type cancel func(ctx context.Context) error type execute func(query string) tea.Cmd type Model struct { - input *editline.Model - spinner spinner.Model + input components.InputModel + statusModel components.StatusModel + spinner components.SpinnerModel width, height int - executing bool + state State quitting bool prevUserInput string - historyFile string - style string version string + highlighter func(string) string + + keys KeyMap + styles Styles // execute executes a query passed and return as ExecCmdMsg + ReadyMsg. execute execute cancel cancel + + dump *os.File } func New(initialPrefix string, pgKeywords []string, historyFile string, style string, version string, executeFunc execute, cancelFunc cancel) (*Model, error) { - el := editline.New(1, 1) - el.Prompt = initialPrefix - if historyFile == "" || historyFile == config.Default { - historyFile = getHistoryFilePath() + inputModel, err := components.NewInputModel(initialPrefix, historyFile, pgKeywords, style) + if err != nil { + return nil, fmt.Errorf("creating input model: %w", err) } - if err := applyEditlineConfig(el, historyFile, pgKeywords, style); err != nil { - return nil, fmt.Errorf("applying input config: %w", err) - } + styles := DefaultStyles() - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = spinnerStyle + statusModel := components.NewStatusModel(version) + statusModel.SeparatorStyle = styles.InputSeparator + statusModel.StatusBarStyle = styles.StatusBar + + spinnerModel := components.NewSpinnerModel(styles.Spinner, styles.SpinnerCaption) + + var dump *os.File + if _, ok := os.LookupEnv("PGXCLI_DEBUG"); ok { + dump, err = os.OpenFile("pgxcli_messages.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("opening debug log: %w", err) + } + } return &Model{ - input: el, - spinner: sp, - historyFile: historyFile, - style: style, + input: *inputModel, + statusModel: statusModel, + spinner: spinnerModel, version: version, + state: StateInput, + keys: DefaultKeyMap(), + styles: styles, execute: executeFunc, cancel: cancelFunc, + dump: dump, + highlighter: postgresHighlighter(style), }, nil } func (m *Model) Init() tea.Cmd { return tea.Batch( - m.input.Focus(), - m.spinner.Tick, + m.input.Init(), + m.statusModel.Init(), + m.spinner.Init(), ) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.dump != nil { + spew.Fdump(m.dump, msg) + } + + var cmds []tea.Cmd + + // Send WindowSize to children + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + var smCmd tea.Cmd + m.statusModel, smCmd = m.statusModel.Update(msg) + cmds = append(cmds, smCmd) + m.updateInputSize() + return m, tea.Batch(cmds...) + } + switch msg := msg.(type) { case QuitRequestMsg: @@ -134,45 +142,39 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case ReadyMsg: - m.executing = false + m.state = StateInput if msg.Prefix != "" { - m.input.Prompt = msg.Prefix + m.input.SetPrompt(msg.Prefix) } - return m, m.input.Focus() + return m, nil case ExecCmdMsg: return m, msg.Cmd case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd + var smCmd tea.Cmd + m.spinner, smCmd = m.spinner.Update(msg) + return m, smCmd case editline.InputCompleteMsg: - if m.executing { + if m.state == StateExecuting { return m, nil } return m.handleInput() - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.input.SetSize(msg.Width, msg.Height-4) - return m, nil - case tea.KeyMsg: - switch msg.String() { - case "ctrl+d": + if key.Matches(msg, m.keys.Quit) { return m, func() tea.Msg { return QuitRequestMsg{} } - case "ctrl+c": - if m.executing { - m.executing = false + } + if key.Matches(msg, m.keys.Interrupt) { + if m.state == StateExecuting { + m.state = StateInput cancelFn := m.cancel return m, func() tea.Msg { if err := cancelFn(context.Background()); err != nil { - return ExecCmdMsg{Cmd: PrintErrCmd(err)} + return ExecCmdMsg{Cmd: PrintErrCmd(err, m.styles.ErrorOutput)} } return nil } @@ -183,9 +185,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - var nextCmd tea.Cmd - m.input, nextCmd = m.input.Update(msg) - return m, nextCmd + // Route to input only if in input state, avoiding capturing keystrokes while executing. + if m.state == StateInput { + var nextCmd tea.Cmd + m.input, nextCmd = m.input.Update(msg) + cmds = append(cmds, nextCmd) + } + + return m, tea.Batch(cmds...) } func (m *Model) handleInput() (tea.Model, tea.Cmd) { @@ -194,35 +201,51 @@ func (m *Model) handleInput() (tea.Model, tea.Cmd) { if trimmed == "" { return m, tea.Sequence( - m.printUserInput(userInputStyle.Render(m.input.Prompt), ""), + m.printUserInput(m.styles.UserInput.Render(m.input.Prompt()), ""), func() tea.Msg { - return ReadyMsg{Prefix: m.input.Prompt} + return ReadyMsg{Prefix: m.input.Prompt()} }, ) } m.prevUserInput = input - m.executing = true + m.state = StateExecuting m.input.AddHistoryEntry(input) m.input.Reset() return m, tea.Sequence( - m.printUserInput(userInputStyle.Render(m.input.Prompt), input), + m.printUserInput(m.styles.UserInput.Render(m.input.Prompt()), input), m.execute(trimmed), ) } func (m *Model) printUserInput(prefix, input string) tea.Cmd { - var highlightedInput string - if input != "" { - highlightedInput = postgresHighlighter(m.style)(input) - } + return func() tea.Msg { + var highlightedInput string + if input != "" { + highlightedInput = m.highlighter(input) + } - line := strings.Repeat("─", m.width/2) + // used to seperate previous user input from the current one with half straight line. + line := strings.Repeat("─", m.width/2) + + userContent := lipgloss.JoinHorizontal(lipgloss.Left, prefix, highlightedInput) + userContent = lipgloss.JoinVertical(lipgloss.Top, m.styles.UserInputSepartor.Render(line), userContent) + + return ExecCmdMsg{Cmd: tea.Printf("%s", userContent)} + } +} - userContent := lipgloss.JoinHorizontal(lipgloss.Left, userInputStyle.Render(prefix), highlightedInput) - userContent = lipgloss.JoinVertical(lipgloss.Top, userQuerySeparatorStyle.Render(line), userContent) - return tea.Printf("%s", userContent) +func (m *Model) updateInputSize() { + if m.width == 0 || m.height == 0 { + return + } + // Calculate available height for input + h := m.height - m.statusModel.StaticHeight() + if h < 1 { + h = 1 + } + m.input.SetSize(m.width, h) } func (m *Model) View() tea.View { @@ -234,38 +257,32 @@ func (m *Model) View() tea.View { return tea.NewView("") } - if m.executing { - spinnerLine := lipgloss.JoinHorizontal( - lipgloss.Left, - m.spinner.View(), - spinnerCaptionStyle.Render("Postgresing..."), - ) - - return tea.NewView(spinnerLine) - } + var baseView string - statusStyle := statusBarStyle.Width(m.width) - separator := inputSeparatorStyle.Render(strings.Repeat("─", m.width)) // Full-width top + bottom borders for input + if m.state == StateExecuting { + baseView = m.spinner.View() + } else { + separator := m.statusModel.SeparatorStyle.Render(strings.Repeat("─", m.width)) - inputView := lipgloss.JoinVertical( - lipgloss.Top, - separator, - m.input.View(), - separator, - statusStyle.Render("pgxcli "+m.version), - ) + baseView = lipgloss.JoinVertical( + lipgloss.Top, + separator, + m.input.View(), + m.statusModel.View(), + ) + } - return tea.NewView(inputView) + return tea.NewView(baseView) } func (m *Model) saveHistory() error { - if m.historyFile == "" { - return nil - } - return history.SaveHistory(m.input.GetHistory(), m.historyFile) + return m.input.SaveHistory() } func (m *Model) Close() error { + if m.dump != nil { + _ = m.dump.Close() + } if err := m.saveHistory(); err != nil { return fmt.Errorf("saving history: %w", err) } @@ -273,19 +290,19 @@ func (m *Model) Close() error { } // PrintCmd returns a command that prints formatted text. -func PrintCmd(text string) tea.Cmd { +func PrintCmd(text string, style lipgloss.Style) tea.Cmd { formattedInteraction := lipgloss.Sprintf( "%s\n", - appOutputStyle.Render(text), + style.Render(text), ) return tea.Printf("%s", formattedInteraction) } // PrintErrCmd returns a command that prints a formatted error. -func PrintErrCmd(err error) tea.Cmd { +func PrintErrCmd(err error, style lipgloss.Style) tea.Cmd { formattedError := lipgloss.Sprintf( "%s\n", - errorOutputStyle.Render("✗ "+err.Error()), + style.Render("✗ "+err.Error()), ) return tea.Printf("%s", formattedError) } @@ -310,26 +327,6 @@ func postgresHighlighter(style string) func(string) string { } } -func postgresAutocomplete(pgKeywords []string) func(v [][]rune, line, col int) (string, editline.Completions) { - return func(v [][]rune, line, col int) (string, editline.Completions) { - word, wstart, wend := computil.FindWord(v, line, col) - if word == "" { - return "", nil - } - upperWord := strings.ToUpper(word) - var matches []string - for _, kw := range pgKeywords { - if strings.HasPrefix(kw, upperWord) { - matches = append(matches, kw) - } - } - if len(matches) == 0 { - return "", nil - } - return "", editline.SimpleWordsCompletion(matches, "Keywords", col, wstart, wend) - } -} - func detectTerminalColorProfile() string { switch termenv.ColorProfile() { case termenv.TrueColor: @@ -342,51 +339,3 @@ func detectTerminalColorProfile() string { return "noop" } } - -func applyEditlineConfig(el *editline.Model, historyFile string, pgKeywords []string, style string) error { - el.SetHelpDisabled(true) - el.SetHighlighter(postgresHighlighter(style)) - el.SetExternalEditorEnabled(true, "sql") - el.KeyMap.ExternalEdit = key.NewBinding( - key.WithKeys("ctrl+e"), - key.WithHelp("ctrl+e", "edit query in external editor"), - ) - el.AutoComplete = postgresAutocomplete(pgKeywords) - - el.CheckInputComplete = func(entireInput [][]rune, line, col int) bool { - var sb strings.Builder - for i, rline := range entireInput { - if i > 0 { - sb.WriteByte('\n') - } - sb.WriteString(string(rline)) - } - input := strings.TrimSpace(sb.String()) - - if input == "" { - return true - } - - if strings.HasPrefix(input, "\\") { - return true - } - - return strings.HasSuffix(input, ";") - } - - entries, err := history.LoadHistory(historyFile) - if err != nil { - return fmt.Errorf("loading history: %w", err) - } - - el.SetHistory(entries) - return nil -} - -func getHistoryFilePath() string { - homeDir, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(homeDir, ".pgxcli_history.jsonl") -} From 9426411082579c33e16e83f6513f4d587c7f7f78 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:21:46 +0530 Subject: [PATCH 11/18] fix: lint and formatting Signed-off-by: Balaji J --- internal/app/ui/components/status.go | 2 +- internal/app/ui/model.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/app/ui/components/status.go b/internal/app/ui/components/status.go index 8c54385..4e4542b 100644 --- a/internal/app/ui/components/status.go +++ b/internal/app/ui/components/status.go @@ -53,7 +53,7 @@ func (m StatusModel) View() string { func (m StatusModel) StaticHeight() int { separator := m.SeparatorStyle.Render(strings.Repeat("─", m.Width)) statusBar := m.StatusBarStyle.Width(m.Width).Render("pgxcli " + m.Version) - + // Top separator + Bottom separator + Status bar return lipgloss.Height(separator)*2 + lipgloss.Height(statusBar) } diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index d498b7f..2e782cb 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -111,6 +111,7 @@ func (m *Model) Init() tea.Cmd { ) } +//nolint:gocyclo func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.dump != nil { spew.Fdump(m.dump, msg) @@ -226,7 +227,7 @@ func (m *Model) printUserInput(prefix, input string) tea.Cmd { highlightedInput = m.highlighter(input) } - // used to seperate previous user input from the current one with half straight line. + // used to separate previous user input from the current one with half straight line. line := strings.Repeat("─", m.width/2) userContent := lipgloss.JoinHorizontal(lipgloss.Left, prefix, highlightedInput) From 37e4245a27319b5fa97aec0383afd99005b41f93 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:47:46 +0530 Subject: [PATCH 12/18] fix: spinner unnecessary spins silently Signed-off-by: Balaji J --- internal/app/ui/components/spinner.go | 4 ++++ internal/app/ui/model.go | 13 ++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/internal/app/ui/components/spinner.go b/internal/app/ui/components/spinner.go index 2b59f89..949e581 100644 --- a/internal/app/ui/components/spinner.go +++ b/internal/app/ui/components/spinner.go @@ -31,6 +31,10 @@ func (m SpinnerModel) Init() tea.Cmd { return m.spinner.Tick } +func (m SpinnerModel) Tick() tea.Cmd { + return m.spinner.Tick +} + func (m SpinnerModel) Update(msg tea.Msg) (SpinnerModel, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 2e782cb..7d938f8 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -49,6 +49,7 @@ type Model struct { input components.InputModel statusModel components.StatusModel spinner components.SpinnerModel + isSpinning bool width, height int state State quitting bool @@ -144,6 +145,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ReadyMsg: m.state = StateInput + m.isSpinning = false if msg.Prefix != "" { m.input.SetPrompt(msg.Prefix) } @@ -153,9 +155,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, msg.Cmd case spinner.TickMsg: - var smCmd tea.Cmd - m.spinner, smCmd = m.spinner.Update(msg) - return m, smCmd + if m.isSpinning { + var smCmd tea.Cmd + m.spinner, smCmd = m.spinner.Update(msg) + return m, smCmd + } + return m, nil case editline.InputCompleteMsg: if m.state == StateExecuting { @@ -211,11 +216,13 @@ func (m *Model) handleInput() (tea.Model, tea.Cmd) { m.prevUserInput = input m.state = StateExecuting + m.isSpinning = true m.input.AddHistoryEntry(input) m.input.Reset() return m, tea.Sequence( m.printUserInput(m.styles.UserInput.Render(m.input.Prompt()), input), + m.spinner.Tick(), m.execute(trimmed), ) } From 9b19c090ca5bb91dbe4a4edc26b8074471de545c Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:52:08 +0530 Subject: [PATCH 13/18] fix: duplicate version logic Signed-off-by: Balaji J --- internal/app/app.go | 6 +++--- internal/cli/root.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index dfa0aee..942acf5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -29,7 +29,7 @@ import ( // Application defines the interface for the main application logic. type Application interface { // Start starts the main repl loop, reading input, executing commands and printing results until the user exits. - Start(ctx context.Context, version string) error + Start(ctx context.Context) error // Close performs saving history before exiting. Close() error @@ -143,7 +143,7 @@ func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { } } -func (p *pgxCLI) Start(ctx context.Context, version string) error { +func (p *pgxCLI) Start(ctx context.Context) error { p.printBanner(p.version) executeFunc := func(query string) tea.Cmd { return p.execute(ctx, query) @@ -155,7 +155,7 @@ func (p *pgxCLI) Start(ctx context.Context, version string) error { p.completer.GetKeyWords(), p.config.Main.HistoryFile, string(p.config.Main.Style), - version, + p.version, executeFunc, p.Cancel, ) diff --git a/internal/cli/root.go b/internal/cli/root.go index e0355e0..8c711f0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -21,7 +21,7 @@ import ( ) var ( - version = "0.1.1" + version = "0.2.0" osUser = osUsername() ) @@ -77,7 +77,7 @@ func NewRootCmd(ctx context.Context, cliCtx *CliContext) *cobra.Command { cliCtx.Logger.Error("Application context not initialized") return fmt.Errorf("application context not initialized") } - return cliCtx.App.Start(ctx, version) + return cliCtx.App.Start(ctx) }, PersistentPostRunE: func(_ *cobra.Command, _ []string) error { From 958372dc1a064c0b9d8b455b9f53cad4213f24c3 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:58:57 +0530 Subject: [PATCH 14/18] add input reset on empty string Signed-off-by: Balaji J --- internal/app/ui/model.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 7d938f8..afdb49f 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -206,6 +206,7 @@ func (m *Model) handleInput() (tea.Model, tea.Cmd) { trimmed := strings.TrimSpace(input) if trimmed == "" { + m.input.Reset() return m, tea.Sequence( m.printUserInput(m.styles.UserInput.Render(m.input.Prompt()), ""), func() tea.Msg { From 83edd0b71936fbf3c5536d178e47c10dea2b7c32 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 20:59:29 +0530 Subject: [PATCH 15/18] optimize banner rendering Signed-off-by: Balaji J --- internal/app/ui/banner.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/app/ui/banner.go b/internal/app/ui/banner.go index 2c38c59..f48f54e 100644 --- a/internal/app/ui/banner.go +++ b/internal/app/ui/banner.go @@ -84,9 +84,7 @@ func orcaStr() string { hexColor := lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) style := lipgloss.NewStyle().Foreground(hexColor) - for _, ch := range line { - sb.WriteString(style.Render(string(ch))) - } + sb.WriteString(style.Render(line)) } return sb.String() From e816d7518c6fa11bcf58947197a2b1fbacf7887b Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 22:27:59 +0530 Subject: [PATCH 16/18] add Report Issue hyperlink in status bar --- internal/app/ui/components/status.go | 29 +++++++++++++++++++++++++--- internal/app/ui/model.go | 2 +- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/app/ui/components/status.go b/internal/app/ui/components/status.go index 4e4542b..d6b8eb5 100644 --- a/internal/app/ui/components/status.go +++ b/internal/app/ui/components/status.go @@ -11,13 +11,15 @@ import ( type StatusModel struct { Version string Width int + IssueLink string SeparatorStyle lipgloss.Style StatusBarStyle lipgloss.Style } -func NewStatusModel(version string) StatusModel { +func NewStatusModel(version, issueLink string) StatusModel { return StatusModel{ - Version: version, + Version: version, + IssueLink: issueLink, } } @@ -39,8 +41,29 @@ func (m StatusModel) View() string { if m.Width == 0 { return "" } + + name := "pgxcli " + m.Version + + link := m.StatusBarStyle. + Underline(true). + Hyperlink(m.IssueLink). + Render("Report Issue") + separator := m.SeparatorStyle.Render(strings.Repeat("─", m.Width)) - statusBar := m.StatusBarStyle.Width(m.Width).Render("pgxcli " + m.Version) + + innerWidth := m.Width - m.StatusBarStyle.GetHorizontalPadding() + if innerWidth < 0 { + innerWidth = 0 + } + + usedWidth := lipgloss.Width(name) + lipgloss.Width(link) + paddingWidth := innerWidth - usedWidth + if paddingWidth < 0 { + paddingWidth = 0 + } + padding := strings.Repeat(" ", paddingWidth) + + statusBar := m.StatusBarStyle.Width(m.Width).Render(name + padding + link) return lipgloss.JoinVertical( lipgloss.Top, diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index afdb49f..997975a 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -75,7 +75,7 @@ func New(initialPrefix string, pgKeywords []string, historyFile string, style st styles := DefaultStyles() - statusModel := components.NewStatusModel(version) + statusModel := components.NewStatusModel(version, issueLink) statusModel.SeparatorStyle = styles.InputSeparator statusModel.StatusBarStyle = styles.StatusBar From c5f2a9e9c3270560435abce629f6c71c8c000748 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 22:51:18 +0530 Subject: [PATCH 17/18] fix: show form only when terminal width is small and consitent color palete --- internal/app/ui/forms.go | 56 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/internal/app/ui/forms.go b/internal/app/ui/forms.go index e9d1319..27cf28b 100644 --- a/internal/app/ui/forms.go +++ b/internal/app/ui/forms.go @@ -33,9 +33,9 @@ func newStyles(hasDarkBg bool) *styles { lightDark = lipgloss.LightDark(hasDarkBg) ) - s.Red = lightDark(lipgloss.Color("#FE5F86"), lipgloss.Color("#FE5F86")) - s.Indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) - s.Green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) + s.Red = lightDark(lipgloss.Color("#FF6B6B"), lipgloss.Color("#FF6B6B")) + s.Indigo = lightDark(lipgloss.Color("#8B5CF6"), lipgloss.Color("#A78BFA")) + s.Green = lightDark(lipgloss.Color("#A78BFA"), lipgloss.Color("#C4B5FD")) s.Base = lipgloss.NewStyle(). Padding(1, 4, 0, 1) s.HeaderText = lipgloss.NewStyle(). @@ -51,7 +51,7 @@ func newStyles(hasDarkBg bool) *styles { Foreground(s.Green). Bold(true) s.Highlight = lipgloss.NewStyle(). - Foreground(lipgloss.Color("212")) + Foreground(lipgloss.Color("#B8A2FF")) s.ErrorHeaderText = s.HeaderText. Foreground(s.Red) s.Help = lipgloss.NewStyle(). @@ -127,6 +127,7 @@ func newModel(database, username, host, port string) *model { Value(&m.values.Password), ), ). + WithTheme(pgxcliTheme{}). WithWidth(45). WithShowHelp(false). WithShowErrors(false) @@ -219,7 +220,13 @@ func (m *model) View() tea.View { if len(errors) > 0 { header = m.appErrorBoundaryView(m.errorView()) } - body := lipgloss.JoinHorizontal(lipgloss.Center, orca, rightPanel) + totalWidthReq := lipgloss.Width(orca) + lipgloss.Width(rightPanel) + s.Base.GetHorizontalFrameSize() + var body string + if m.termWidth > 0 && m.termWidth < totalWidthReq { + body = rightPanel + } else { + body = lipgloss.JoinHorizontal(lipgloss.Center, orca, rightPanel) + } footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds())) if len(errors) > 0 { @@ -345,3 +352,42 @@ func validatePort(v string) error { } return nil } + +type pgxcliTheme struct{} + +func (t pgxcliTheme) Theme(hasDarkBg bool) *huh.Styles { + s := huh.ThemeBase(hasDarkBg) + + primary := lipgloss.Color("#A78BFA") + secondary := lipgloss.Color("#C4B5FD") + border := lipgloss.Color("#8B5CF6") + errorFg := lipgloss.Color("#FF6B6B") + + s.Focused.Base = s.Focused.Base.BorderForeground(border) + s.Focused.Title = s.Focused.Title.Foreground(primary).Bold(true) + s.Focused.NoteTitle = s.Focused.NoteTitle.Foreground(primary).Bold(true) + s.Focused.Directory = s.Focused.Directory.Foreground(primary) + s.Focused.Description = s.Focused.Description.Foreground(secondary) + s.Focused.ErrorIndicator = s.Focused.ErrorIndicator.Foreground(errorFg) + s.Focused.ErrorMessage = s.Focused.ErrorMessage.Foreground(errorFg) + s.Focused.SelectSelector = s.Focused.SelectSelector.Foreground(primary) + s.Focused.NextIndicator = s.Focused.NextIndicator.Foreground(primary) + s.Focused.PrevIndicator = s.Focused.PrevIndicator.Foreground(secondary) + s.Focused.Option = s.Focused.Option.Foreground(secondary) + s.Focused.MultiSelectSelector = s.Focused.MultiSelectSelector.Foreground(primary) + s.Focused.SelectedOption = s.Focused.SelectedOption.Foreground(primary) + s.Focused.SelectedPrefix = s.Focused.SelectedPrefix.Foreground(primary) + s.Focused.UnselectedPrefix = s.Focused.UnselectedPrefix.Foreground(secondary) + s.Focused.UnselectedOption = s.Focused.UnselectedOption.Foreground(secondary) + s.Focused.FocusedButton = s.Focused.FocusedButton.Foreground(lipgloss.Color("#2A273F")).Background(primary) + s.Focused.BlurredButton = s.Focused.BlurredButton.Foreground(secondary).Background(lipgloss.Color("#2A273F")) + + s.Focused.TextInput.Cursor = s.Focused.TextInput.Cursor.Foreground(primary) + s.Focused.TextInput.Placeholder = s.Focused.TextInput.Placeholder.Foreground(lipgloss.Color("240")) + s.Focused.TextInput.Prompt = s.Focused.TextInput.Prompt.Foreground(primary) + + s.Blurred = s.Focused + s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + + return s +} From 7e188af1571235108643ba9ea73090611f5aed91 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Fri, 22 May 2026 22:52:24 +0530 Subject: [PATCH 18/18] update changelog --- CHANGELOG.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c051cf..903674e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## [0.2.0] - 2026-05-22 + +### Added +- **Modernized UI**: Completely redesigned REPL interface with styles and better visual hierarchy. +- **Orca Banner**: Colorful ASCII orca banner with gradient styling on startup. +- **Report Issue link**: Clickable link in the status bar to report issues on GitHub and banner. +- **Loading Spinner**: Visual spinner indicator that displays during query execution. + +### Fixed +- **Version update**: Updated version string. +- **fix interactive page**: fix the interactive page to show only form when width is lesser. + +### Refactored +- **UI Component Architecture**: Extracted child components (Input, Status, Spinner) into separate modules for better maintainability. +- **Style Management**: Separated style definitions into dedicated source files for cleaner organization. + ## [0.1.2] - 2026-05-20 ### Added @@ -39,4 +55,4 @@ The first release of pgxcli, a command-line interface for PostgreSQL inspired by - configurable syntax highlighting - Support for multiple table formats - Linux packages: deb, rpm, apk, archlinux -- Windows MSI installer \ No newline at end of file +- Windows MSI installer