diff --git a/cmd/thv/app/tui.go b/cmd/thv/app/tui.go index 7ec2f452bb..ecf256374d 100644 --- a/cmd/thv/app/tui.go +++ b/cmd/thv/app/tui.go @@ -6,12 +6,8 @@ package app import ( "fmt" "log/slog" - "os" - "os/exec" - "os/signal" - "syscall" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" "github.com/stacklok/toolhive/cmd/thv/app/ui" @@ -59,27 +55,6 @@ func tuiCmdFunc(cmd *cobra.Command, _ []string) error { slog.SetDefault(slog.New(ui.NewTUILogHandler(tuiLogCh, slog.LevelWarn))) defer slog.SetDefault(origLogger) - // Ensure the terminal background colour set by the TUI's OSC 11 sequence is - // always reset, even if the program exits via a panic or signal rather than - // a clean quit. On a normal quit, View() emits the reset; this defer covers - // panic paths. The signal handler covers SIGTERM/SIGINT when the defer - // cannot run (e.g. terminal multiplexers sending signals directly). - // "\x1b]111;\x07" is the OSC 111 sequence that restores the terminal's - // default background colour. - const oscReset = "\x1b]111;\x07" - defer func() { _, _ = fmt.Fprint(os.Stdout, oscReset) }() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) - go func() { - <-sigCh - _, _ = fmt.Fprint(os.Stdout, oscReset) - signal.Stop(sigCh) - // Re-raise so the default handler terminates the process. - self, _ := os.FindProcess(os.Getpid()) - _ = self.Signal(syscall.SIGTERM) - }() - manager, err := workloads.NewManager(ctx) if err != nil { return fmt.Errorf("failed to create workload manager: %w", err) @@ -90,18 +65,9 @@ func tuiCmdFunc(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to initialize TUI: %w", err) } - p := tea.NewProgram(model, tea.WithAltScreen()) + p := tea.NewProgram(model) _, runErr := p.Run() - // BubbleTea puts the terminal in raw mode (OPOST/ONLCR disabled) and - // may not fully restore it before the shell regains control. - // Running "stty sane" is the most reliable way to reset all terminal - // flags (OPOST, ONLCR, ECHO, ICANON, …) back to safe defaults. - if stty := exec.Command("stty", "sane"); stty != nil { - stty.Stdin = os.Stdin - _ = stty.Run() - } - if runErr != nil { return fmt.Errorf("TUI error: %w", runErr) } diff --git a/cmd/thv/app/ui/clients_setup.go b/cmd/thv/app/ui/clients_setup.go index 1c9c32513e..75e0cd569c 100644 --- a/cmd/thv/app/ui/clients_setup.go +++ b/cmd/thv/app/ui/clients_setup.go @@ -9,8 +9,8 @@ import ( "sort" "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/stacklok/toolhive/pkg/client" "github.com/stacklok/toolhive/pkg/groups" @@ -48,7 +48,7 @@ type setupModel struct { func (*setupModel) Init() tea.Cmd { return nil } func (m *setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok { switch keyMsg.String() { case "ctrl+c", "q": m.Confirmed = false @@ -84,7 +84,7 @@ func (m *setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Confirmed = true m.Quitting = true return m, tea.Quit - case " ": + case "space": if m.CurrentStep == stepGroupSelection { // Toggle group selection if _, ok := m.SelectedGroups[m.Cursor]; ok { @@ -112,9 +112,9 @@ func (m *setupModel) getMaxCursorPosition() int { return len(m.Clients) } -func (m *setupModel) View() string { +func (m *setupModel) View() tea.View { if m.Quitting { - return "" + return tea.NewView("") } var b strings.Builder @@ -135,7 +135,7 @@ func (m *setupModel) View() string { b.WriteString("\nUse ↑/↓ (or j/k) to move, 'space' to select, 'enter' to confirm, 'q' to quit.\n") } - return docStyle.Render(b.String()) + return tea.NewView(docStyle.Render(b.String())) } // selectedGroups returns the groups corresponding to SelectedGroups indices, diff --git a/cmd/thv/app/ui/clients_setup_test.go b/cmd/thv/app/ui/clients_setup_test.go index b289457184..0d207f6c5d 100644 --- a/cmd/thv/app/ui/clients_setup_test.go +++ b/cmd/thv/app/ui/clients_setup_test.go @@ -6,7 +6,7 @@ package ui import ( "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -88,7 +88,7 @@ func TestSetupModelUpdate_GroupToClientTransition(t *testing.T) { } // Press enter to transition - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) result := updated.(*setupModel) assert.Equal(t, tt.wantStep, result.CurrentStep) @@ -117,19 +117,19 @@ func TestSetupModelUpdate_ClientSelection(t *testing.T) { } // Toggle first client with space - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) result := updated.(*setupModel) _, selected := result.SelectedClients[0] assert.True(t, selected, "first client should be selected after space") // Toggle it off - updated, _ = result.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + updated, _ = result.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) result = updated.(*setupModel) _, selected = result.SelectedClients[0] assert.False(t, selected, "first client should be deselected after second space") // Confirm with enter - updated, cmd := result.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := result.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) result = updated.(*setupModel) assert.True(t, result.Confirmed) assert.True(t, result.Quitting) diff --git a/cmd/thv/app/ui/help.go b/cmd/thv/app/ui/help.go index 2889b3b8f1..573332ff63 100644 --- a/cmd/thv/app/ui/help.go +++ b/cmd/thv/app/ui/help.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -184,7 +184,7 @@ func RenderHelp(cmd *cobra.Command) { fmt.Fprintf(&sb, " %s\n\n", footerHint) - fmt.Print(sb.String()) + _, _ = lipgloss.Print(sb.String()) } // RenderCommandUsage prints a styled usage hint for a command when the user @@ -223,7 +223,7 @@ func RenderCommandUsage(cmd *cobra.Command) { lipgloss.NewStyle().Foreground(ColorDim).Render( "Run thv "+cmd.Name()+" --help for more information.")) - fmt.Print(sb.String()) + _, _ = lipgloss.Print(sb.String()) } // renderParentHelp prints a styled subcommand list for a parent command. @@ -259,5 +259,5 @@ func renderParentHelp(cmd *cobra.Command) { lipgloss.NewStyle().Foreground(ColorDim).Render( "Run thv "+cmd.Name()+" --help for details.")) - fmt.Print(sb.String()) + _, _ = lipgloss.Print(sb.String()) } diff --git a/cmd/thv/app/ui/selected_groups_test.go b/cmd/thv/app/ui/selected_groups_test.go index 77c170f787..b383211c01 100644 --- a/cmd/thv/app/ui/selected_groups_test.go +++ b/cmd/thv/app/ui/selected_groups_test.go @@ -6,7 +6,7 @@ package ui import ( "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/stretchr/testify/assert" "github.com/stacklok/toolhive/pkg/client" @@ -87,7 +87,7 @@ func TestFilterClientsBySelectedGroups_OutOfBoundsIndices(t *testing.T) { } // Press enter to trigger transition which calls filterClientsBySelectedGroups - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) result := updated.(*setupModel) assert.Equal(t, stepClientSelection, result.CurrentStep) diff --git a/cmd/thv/app/ui/spinner.go b/cmd/thv/app/ui/spinner.go index 36b2360121..a655c84d69 100644 --- a/cmd/thv/app/ui/spinner.go +++ b/cmd/thv/app/ui/spinner.go @@ -4,12 +4,11 @@ package ui import ( - "fmt" "os" "sync" "time" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "golang.org/x/term" ) @@ -65,7 +64,7 @@ func (s *Spinner) Start() { s.mu.Lock() label := lipgloss.NewStyle().Foreground(ColorDim2).Render(s.msg) s.mu.Unlock() - fmt.Printf("\r\033[K %s %s", frame, label) + _, _ = lipgloss.Printf("\r\033[K %s %s", frame, label) i++ } } @@ -76,7 +75,7 @@ func (s *Spinner) Start() { func printCheckpoint(doneMsg string) { check := lipgloss.NewStyle().Foreground(ColorGreen).Bold(true).Render("✓") msg := lipgloss.NewStyle().Foreground(ColorDim2).Render(doneMsg) - fmt.Printf("\r\033[K %s %s\n", check, msg) + _, _ = lipgloss.Printf("\r\033[K %s %s\n", check, msg) } // Checkpoint commits the current step as done (prints ✓ doneMsg) and keeps @@ -104,7 +103,7 @@ func (s *Spinner) Stop(successMsg string) { <-s.doneCh check := lipgloss.NewStyle().Foreground(ColorGreen).Bold(true).Render("✓") msg := lipgloss.NewStyle().Foreground(ColorText).Bold(true).Render(successMsg) - fmt.Printf("\r\033[K %s %s\n", check, msg) + _, _ = lipgloss.Printf("\r\033[K %s %s\n", check, msg) } // Fail halts the spinner and prints a final error line. @@ -116,5 +115,5 @@ func (s *Spinner) Fail(errMsg string) { <-s.doneCh cross := lipgloss.NewStyle().Foreground(ColorRed).Bold(true).Render("✗") msg := lipgloss.NewStyle().Foreground(ColorRed).Render(errMsg) - fmt.Printf("\r\033[K %s %s\n", cross, msg) + _, _ = lipgloss.Printf("\r\033[K %s %s\n", cross, msg) } diff --git a/cmd/thv/app/ui/styles.go b/cmd/thv/app/ui/styles.go index 008440096b..1ce05b03b3 100644 --- a/cmd/thv/app/ui/styles.go +++ b/cmd/thv/app/ui/styles.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" rt "github.com/stacklok/toolhive/pkg/container/runtime" ) diff --git a/go.mod b/go.mod index 6117eb190f..148d5a54b0 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.27.0 require ( cel.dev/cel-go v0.32.0 + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 dario.cat/mergo v1.0.2 github.com/1password/onepassword-sdk-go v0.4.1 github.com/Microsoft/go-winio v0.6.2 @@ -15,9 +18,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 github.com/cedar-policy/cedar-go v1.8.0 github.com/cenkalti/backoff/v5 v5.0.3 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.8 github.com/containerd/errdefs v1.0.0 github.com/coreos/go-oidc/v3 v3.21.0 @@ -42,7 +42,6 @@ require ( github.com/moby/moby/api v1.56.0 github.com/moby/moby/client v0.6.0 github.com/modelcontextprotocol/registry v1.8.1 - github.com/muesli/termenv v0.16.0 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 github.com/olekukonko/tablewriter v1.1.4 github.com/onsi/ginkgo/v2 v2.32.2 @@ -127,13 +126,14 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect github.com/aws/smithy-go v1.28.1 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect @@ -161,7 +161,6 @@ require ( github.com/ebitengine/purego v0.10.2 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/extism/go-sdk v1.7.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect @@ -224,13 +223,12 @@ require ( github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.24 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mattn/goveralls v0.0.12 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect @@ -246,7 +244,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect diff --git a/go.sum b/go.sum index 0ad744ca18..6ee6a6d856 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,12 @@ cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= @@ -95,8 +101,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM= github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -112,20 +118,20 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -196,8 +202,6 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -454,8 +458,8 @@ github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiC github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -466,10 +470,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg= github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxFGjZwgMSQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -516,12 +518,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= @@ -899,7 +897,6 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/tui/actions.go b/pkg/tui/actions.go index c5da9a910b..f5c0022f0a 100644 --- a/pkg/tui/actions.go +++ b/pkg/tui/actions.go @@ -9,7 +9,7 @@ import ( "log/slog" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" regtypes "github.com/stacklok/toolhive-core/registry/types" cfg "github.com/stacklok/toolhive/pkg/config" diff --git a/pkg/tui/form_helpers.go b/pkg/tui/form_helpers.go index cd3e39df83..d99f3250f5 100644 --- a/pkg/tui/form_helpers.go +++ b/pkg/tui/form_helpers.go @@ -4,7 +4,7 @@ package tui import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // formNextField advances focus to the next field in a formField slice (wraps around). @@ -43,8 +43,8 @@ func formBlurAll(fields []formField, idx *int) { *idx = -1 } -// formForwardKey forwards a key message to the currently focused field. -func formForwardKey(fields []formField, idx int, msg tea.KeyMsg) tea.Cmd { +// formForwardMessage forwards a message to the currently focused field. +func formForwardMessage(fields []formField, idx int, msg tea.Msg) tea.Cmd { if idx < 0 || idx >= len(fields) { return nil } diff --git a/pkg/tui/form_helpers_test.go b/pkg/tui/form_helpers_test.go index 5fff5db163..22f076b862 100644 --- a/pkg/tui/form_helpers_test.go +++ b/pkg/tui/form_helpers_test.go @@ -6,7 +6,7 @@ package tui import ( "testing" - "github.com/charmbracelet/bubbles/textinput" + "charm.land/bubbles/v2/textinput" "github.com/stretchr/testify/assert" ) diff --git a/pkg/tui/init.go b/pkg/tui/init.go index d5492ffd1b..b2c155c6ef 100644 --- a/pkg/tui/init.go +++ b/pkg/tui/init.go @@ -7,7 +7,7 @@ import ( "context" "fmt" - "github.com/charmbracelet/bubbles/viewport" + "charm.land/bubbles/v2/viewport" "github.com/stacklok/toolhive/pkg/core" "github.com/stacklok/toolhive/pkg/workloads" @@ -23,19 +23,19 @@ func New(ctx context.Context, manager workloads.Manager, logCh <-chan string) (M } core.SortWorkloadsByName(list) - vp := viewport.New(80, 20) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(20)) vp.SetContent("") - pvp := viewport.New(80, 20) + pvp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(20)) pvp.SetContent("") - tvp := viewport.New(80, 20) + tvp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(20)) tvp.SetContent("") - ivp := viewport.New(60, 20) + ivp := viewport.New(viewport.WithWidth(60), viewport.WithHeight(20)) ivp.SetContent("") - lvp := viewport.New(60, 6) + lvp := viewport.New(viewport.WithWidth(60), viewport.WithHeight(6)) lvp.SetContent("") m := Model{ diff --git a/pkg/tui/inspector.go b/pkg/tui/inspector.go index 2b4bc9ce03..4d40a8d078 100644 --- a/pkg/tui/inspector.go +++ b/pkg/tui/inspector.go @@ -12,8 +12,8 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" mcpclient "github.com/stacklok/toolhive-core/mcpcompat/client" "github.com/stacklok/toolhive-core/mcpcompat/mcp" @@ -65,7 +65,7 @@ func buildInspFields(tool mcp.Tool) []formField { ti := textinput.New() ti.Placeholder = fieldType - ti.Width = 40 + ti.SetWidth(40) fields = append(fields, formField{ input: ti, diff --git a/pkg/tui/inspector_test.go b/pkg/tui/inspector_test.go index a249a68406..371c47f1ac 100644 --- a/pkg/tui/inspector_test.go +++ b/pkg/tui/inspector_test.go @@ -6,7 +6,7 @@ package tui import ( "testing" - "github.com/charmbracelet/bubbles/textinput" + "charm.land/bubbles/v2/textinput" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/tui/json_tree.go b/pkg/tui/json_tree.go index 549d261af0..95f278f801 100644 --- a/pkg/tui/json_tree.go +++ b/pkg/tui/json_tree.go @@ -9,7 +9,7 @@ import ( "slices" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/stacklok/toolhive/cmd/thv/app/ui" ) diff --git a/pkg/tui/keys.go b/pkg/tui/keys.go index e3c566fc78..109d71c278 100644 --- a/pkg/tui/keys.go +++ b/pkg/tui/keys.go @@ -3,7 +3,7 @@ package tui -import "github.com/charmbracelet/bubbles/key" +import "charm.land/bubbles/v2/key" // keyMap holds all key bindings for the TUI. type keyMap struct { @@ -98,7 +98,7 @@ var keys = keyMap{ key.WithHelp("→", "scroll right"), ), Space: key.NewBinding( - key.WithKeys(" "), + key.WithKeys("space"), key.WithHelp("space", "toggle collapse"), ), CopyNode: key.NewBinding( diff --git a/pkg/tui/logformat.go b/pkg/tui/logformat.go index 65e11fef31..e6091c93b2 100644 --- a/pkg/tui/logformat.go +++ b/pkg/tui/logformat.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" xansi "github.com/charmbracelet/x/ansi" "github.com/stacklok/toolhive/cmd/thv/app/ui" diff --git a/pkg/tui/main_test.go b/pkg/tui/main_test.go deleted file mode 100644 index a59dcedc7a..0000000000 --- a/pkg/tui/main_test.go +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package tui - -import ( - "os" - "testing" - - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" -) - -func TestMain(m *testing.M) { - // Force ANSI color output so lipgloss renders escape sequences in tests. - // Without this, lipgloss detects a non-TTY environment and strips all - // styling, making it impossible to verify that styled output is produced. - lipgloss.DefaultRenderer().SetColorProfile(termenv.ANSI256) - os.Exit(m.Run()) -} diff --git a/pkg/tui/migration_v2_test.go b/pkg/tui/migration_v2_test.go new file mode 100644 index 0000000000..2deb6a0f39 --- /dev/null +++ b/pkg/tui/migration_v2_test.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "testing" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + + "github.com/stacklok/toolhive/cmd/thv/app/ui" +) + +func TestViewConfiguresBubbleTeaV2(t *testing.T) { + t.Parallel() + + view := (Model{}).View() + assert.Equal(t, "Loading…\n", view.Content) + assert.True(t, view.AltScreen) + assert.Equal(t, ui.ColorBg, view.BackgroundColor) + + view = (Model{quitting: true}).View() + assert.Empty(t, view.Content) + assert.True(t, view.AltScreen) + assert.Equal(t, ui.ColorBg, view.BackgroundColor) +} + +func TestHandleMsgIgnoresKeyRelease(t *testing.T) { + t.Parallel() + + model := Model{} + _, earlyReturn := model.handleMsg(tea.KeyReleaseMsg(tea.Key{Code: 'q', Text: "q"})) + + assert.False(t, earlyReturn) + assert.False(t, model.quitting) +} + +func TestHandlePasteRoutesToActiveInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model Model + assert func(*testing.T, Model) + }{ + { + name: "server filter", + model: Model{filterActive: true}, + assert: func(t *testing.T, model Model) { + t.Helper() + assert.Equal(t, "café", model.filterQuery) + }, + }, + { + name: "log search", + model: Model{logSearchActive: true, logLines: []string{"café"}}, + assert: func(t *testing.T, model Model) { + t.Helper() + assert.Equal(t, "café", model.logSearchQuery) + assert.Equal(t, []int{0}, model.logSearchMatches) + }, + }, + { + name: "registry filter", + model: Model{registry: registryState{open: true}}, + assert: func(t *testing.T, model Model) { + t.Helper() + assert.Equal(t, "café", model.registry.filter) + }, + }, + { + name: "inspector field", + model: func() Model { + input := textinput.New() + input.Focus() + return Model{ + panel: panelInspector, + insp: inspectorState{fieldIdx: 0, fields: []formField{{input: input}}}, + } + }(), + assert: func(t *testing.T, model Model) { + t.Helper() + assert.Equal(t, "café", model.insp.fields[0].input.Value()) + }, + }, + { + name: "run form field", + model: func() Model { + input := textinput.New() + input.Focus() + return Model{ + registry: registryState{open: true}, + runForm: runFormState{open: true, idx: 0, fields: []formField{{input: input}}}, + } + }(), + assert: func(t *testing.T, model Model) { + t.Helper() + assert.Equal(t, "café", model.runForm.fields[0].input.Value()) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + model := test.model + _, earlyReturn := model.handleMsg(tea.PasteMsg{Content: "café"}) + assert.True(t, earlyReturn) + test.assert(t, model) + }) + } +} + +func TestResizeViewportUsesV2Accessors(t *testing.T) { + t.Parallel() + + model := Model{} + _, earlyReturn := model.handleMsg(tea.WindowSizeMsg{Width: 100, Height: 40}) + + assert.True(t, earlyReturn) + assert.Equal(t, 74, model.logView.Width()) + assert.Equal(t, 34, model.logView.Height()) + assert.Equal(t, 74, model.proxyLogView.Width()) + assert.Equal(t, 34, model.proxyLogView.Height()) + assert.Equal(t, 74, model.toolsView.Width()) + assert.Equal(t, 34, model.toolsView.Height()) + assert.Equal(t, 74, model.insp.logView.Width()) + assert.Equal(t, 6, model.insp.logView.Height()) +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 8318c9de55..de5b37cbf4 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -8,8 +8,8 @@ import ( "context" "strings" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" mcpclient "github.com/stacklok/toolhive-core/mcpcompat/client" "github.com/stacklok/toolhive-core/mcpcompat/mcp" diff --git a/pkg/tui/proxylogs.go b/pkg/tui/proxylogs.go index 483050c94b..3eb55e92ee 100644 --- a/pkg/tui/proxylogs.go +++ b/pkg/tui/proxylogs.go @@ -7,7 +7,7 @@ import ( "context" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/stacklok/toolhive/pkg/workloads" ) diff --git a/pkg/tui/registry.go b/pkg/tui/registry.go index e46af28419..74bd318768 100644 --- a/pkg/tui/registry.go +++ b/pkg/tui/registry.go @@ -7,8 +7,8 @@ import ( "context" "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" regtypes "github.com/stacklok/toolhive-core/registry/types" "github.com/stacklok/toolhive/pkg/registry" diff --git a/pkg/tui/search_test.go b/pkg/tui/search_test.go index 10b21a25b1..ea66d5d975 100644 --- a/pkg/tui/search_test.go +++ b/pkg/tui/search_test.go @@ -6,7 +6,7 @@ package tui import ( "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/stretchr/testify/assert" ) diff --git a/pkg/tui/tools.go b/pkg/tui/tools.go index e28ba07fd2..1ccbd233a6 100644 --- a/pkg/tui/tools.go +++ b/pkg/tui/tools.go @@ -7,7 +7,7 @@ import ( "context" "errors" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" mcpclient "github.com/stacklok/toolhive-core/mcpcompat/client" "github.com/stacklok/toolhive-core/mcpcompat/mcp" diff --git a/pkg/tui/update.go b/pkg/tui/update.go index c4614ebdb4..20463663b1 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -8,7 +8,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" mcpclient "github.com/stacklok/toolhive-core/mcpcompat/client" "github.com/stacklok/toolhive-core/mcpcompat/mcp" @@ -208,8 +208,10 @@ func (m *Model) handleMsg(msg tea.Msg) (tea.Cmd, bool) { m.height = msg.Height m.resizeViewport() return nil, true - case tea.KeyMsg: + case tea.KeyPressMsg: return m.handleKey(msg), false + case tea.PasteMsg: + return m.handlePaste(msg), true case tickMsg: return tea.Batch(m.refreshWorkloads(), scheduleRefresh()), false case workloadsRefreshMsg: @@ -267,6 +269,42 @@ func (m *Model) handleMsg(msg tea.Msg) (tea.Cmd, bool) { return nil, false } +func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd { + if m.showHelp { + return nil + } + if m.registry.open { + if m.runForm.open { + return m.runFormForwardToField(msg) + } + if !m.registry.detail { + m.registry.filter += msg.Content + m.registry.idx = 0 + m.registry.scrollOff = 0 + } + return nil + } + + switch { + case m.panel == panelInspector && m.insp.fieldIdx >= 0: + return m.inspForwardToField(msg) + case m.panel == panelInspector && m.insp.filterActive: + m.insp.filterQuery += msg.Content + m.insp.toolIdx = 0 + m.inspRebuildForm() + case m.logSearchActive: + m.logSearchQuery += msg.Content + rebuildSearch(m.logSearchParams()) + case m.proxyLogSearchActive: + m.proxyLogSearchQuery += msg.Content + rebuildSearch(m.proxyLogSearchParams()) + case m.filterActive: + m.filterQuery += msg.Content + m.selectedIdx = 0 + } + return nil +} + func (m *Model) handleWorkloadsRefresh(msg workloadsRefreshMsg) (tea.Cmd, bool) { core.SortWorkloadsByName(msg.workloads) m.workloads = msg.workloads @@ -291,7 +329,7 @@ func (m *Model) handleLogLine(msg logLineMsg) (tea.Cmd, bool) { if len(m.logLines) > maxLogLines { m.logLines = m.logLines[len(m.logLines)-maxLogLines:] } - m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width(), m.logHScrollOff)) if m.logFollow { m.logView.GotoBottom() } @@ -306,7 +344,7 @@ func (m *Model) handleProxyLogLine(msg proxyLogLineMsg) (tea.Cmd, bool) { if len(m.proxyLogLines) > maxLogLines { m.proxyLogLines = m.proxyLogLines[len(m.proxyLogLines)-maxLogLines:] } - m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width(), m.proxyLogHScrollOff)) m.proxyLogView.GotoBottom() if m.proxyLogCh != nil { return readProxyLogLine(m.proxyLogCh), false @@ -341,7 +379,7 @@ func (m *Model) handleToolsFetched(msg toolsFetchedMsg) { m.toolsErr = msg.err m.toolsLoading = false m.toolsSelectedIdx = 0 - m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width(), m.toolsSelectedIdx)) m.toolsView.GotoTop() } } @@ -399,7 +437,7 @@ func (m *Model) handleInspCallResult(msg inspCallResultMsg) { } // handleKey dispatches key events and returns a follow-up tea.Cmd if any. -func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { // Registry overlay has its own key handling. if m.registry.open { return m.handleRegistryKey(msg) diff --git a/pkg/tui/update_inspector.go b/pkg/tui/update_inspector.go index 79997b87b5..b607febf15 100644 --- a/pkg/tui/update_inspector.go +++ b/pkg/tui/update_inspector.go @@ -6,9 +6,9 @@ package tui import ( "time" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" "github.com/stacklok/toolhive-core/mcpcompat/mcp" ) @@ -16,7 +16,7 @@ import ( // handleInspectorKey handles key input when the inspector panel is active. // //nolint:gocyclo // key-handler switch; complexity is inherent to dispatching over all inspector key bindings -func (m *Model) handleInspectorKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleInspectorKey(msg tea.KeyPressMsg) tea.Cmd { // Info modal captures all input — any key closes it. if m.insp.showInfo { m.insp.showInfo = false @@ -165,7 +165,7 @@ func (m *Model) handleInspectorKey(msg tea.KeyMsg) tea.Cmd { } // handleInspFilterKey handles key input while the inspector tool filter is active. -func (m *Model) handleInspFilterKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleInspFilterKey(msg tea.KeyPressMsg) tea.Cmd { switch { case key.Matches(msg, keys.Escape): m.insp.filterActive = false @@ -200,7 +200,7 @@ func (m *Model) handleInspFilterKey(msg tea.KeyMsg) tea.Cmd { return m.inspNavigateUp() case key.Matches(msg, keys.Down): return m.inspNavigateDown() - case msg.Type == tea.KeyBackspace: + case msg.Code == tea.KeyBackspace: if len(m.insp.filterQuery) > 0 { r := []rune(m.insp.filterQuery) m.insp.filterQuery = string(r[:len(r)-1]) @@ -208,8 +208,8 @@ func (m *Model) handleInspFilterKey(msg tea.KeyMsg) tea.Cmd { m.inspRebuildForm() } default: - if msg.Type == tea.KeyRunes { - m.insp.filterQuery += msg.String() + if msg.Text != "" { + m.insp.filterQuery += msg.Text m.insp.toolIdx = 0 m.inspRebuildForm() } @@ -323,9 +323,9 @@ func (m *Model) inspCopyNode() { } } -// inspForwardToField forwards a key message to the currently focused field. -func (m *Model) inspForwardToField(msg tea.KeyMsg) tea.Cmd { - return formForwardKey(m.insp.fields, m.insp.fieldIdx, msg) +// inspForwardToField forwards a message to the currently focused field. +func (m *Model) inspForwardToField(msg tea.Msg) tea.Cmd { + return formForwardMessage(m.insp.fields, m.insp.fieldIdx, msg) } // inspDoCall starts an async tool call with the current field values. diff --git a/pkg/tui/update_navigation.go b/pkg/tui/update_navigation.go index cd6b1d186c..955034578d 100644 --- a/pkg/tui/update_navigation.go +++ b/pkg/tui/update_navigation.go @@ -6,9 +6,9 @@ package tui import ( "context" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" mcpclient "github.com/stacklok/toolhive-core/mcpcompat/client" "github.com/stacklok/toolhive/pkg/core" @@ -17,7 +17,7 @@ import ( ) // handleConfirmDeleteKey handles key input while waiting for delete confirmation. -func (m *Model) handleConfirmDeleteKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleConfirmDeleteKey(msg tea.KeyPressMsg) tea.Cmd { switch { case key.Matches(msg, keys.Delete): m.confirmDelete = false @@ -29,7 +29,7 @@ func (m *Model) handleConfirmDeleteKey(msg tea.KeyMsg) tea.Cmd { } // handleFilterKey handles key input while the filter prompt is active. -func (m *Model) handleFilterKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleFilterKey(msg tea.KeyPressMsg) tea.Cmd { switch { case key.Matches(msg, keys.Escape) || key.Matches(msg, keys.Quit): m.filterActive = false @@ -37,14 +37,14 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) tea.Cmd { m.selectedIdx = 0 case key.Matches(msg, keys.Enter): m.filterActive = false - case msg.Type == tea.KeyBackspace: + case msg.Code == tea.KeyBackspace: if len(m.filterQuery) > 0 { r := []rune(m.filterQuery) m.filterQuery = string(r[:len(r)-1]) } default: - if msg.Type == tea.KeyRunes { - m.filterQuery += msg.String() + if msg.Text != "" { + m.filterQuery += msg.Text } } return nil @@ -53,7 +53,7 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) tea.Cmd { // handleNormalKey handles key input in normal (non-filter) mode. // //nolint:gocyclo // key-handler switch; complexity is inherent to dispatching over all normal-mode key bindings -func (m *Model) handleNormalKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleNormalKey(msg tea.KeyPressMsg) tea.Cmd { switch { case key.Matches(msg, keys.Quit): if m.mcpClient != nil { @@ -128,13 +128,13 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) tea.Cmd { m.logSearchQuery = "" m.logSearchMatches = nil m.logSearchIdx = 0 - m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width(), m.logHScrollOff)) } if m.panel == panelProxyLogs && m.proxyLogSearchQuery != "" { m.proxyLogSearchQuery = "" m.proxyLogSearchMatches = nil m.proxyLogSearchIdx = 0 - m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width(), m.proxyLogHScrollOff)) } case key.Matches(msg, keys.SearchNext): @@ -179,7 +179,7 @@ func (m *Model) handleNormalKey(msg tea.KeyMsg) tea.Cmd { func (m *Model) toolsNavigateUp() tea.Cmd { if m.toolsSelectedIdx > 0 { m.toolsSelectedIdx-- - m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width(), m.toolsSelectedIdx)) m.toolsScrollToSelected() } return nil @@ -189,7 +189,7 @@ func (m *Model) toolsNavigateUp() tea.Cmd { func (m *Model) toolsNavigateDown() tea.Cmd { if m.toolsSelectedIdx < len(m.tools)-1 { m.toolsSelectedIdx++ - m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width, m.toolsSelectedIdx)) + m.toolsView.SetContent(buildToolsContent(m.tools, m.toolsView.Width(), m.toolsSelectedIdx)) m.toolsScrollToSelected() } return nil @@ -201,10 +201,10 @@ func (m *Model) toolsScrollToSelected() { // The header is 2 lines (count + blank line). const headerLines = 2 line := headerLines + m.toolsSelectedIdx - if line < m.toolsView.YOffset { + if line < m.toolsView.YOffset() { m.toolsView.SetYOffset(line) - } else if line >= m.toolsView.YOffset+m.toolsView.Height { - m.toolsView.SetYOffset(line - m.toolsView.Height + 1) + } else if line >= m.toolsView.YOffset()+m.toolsView.Height() { + m.toolsView.SetYOffset(line - m.toolsView.Height() + 1) } } @@ -253,7 +253,7 @@ func (m *Model) hScrollLeft() { if m.logHScrollOff < 0 { m.logHScrollOff = 0 } - m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width(), m.logHScrollOff)) } case panelProxyLogs: if m.proxyLogHScrollOff > 0 { @@ -261,7 +261,7 @@ func (m *Model) hScrollLeft() { if m.proxyLogHScrollOff < 0 { m.proxyLogHScrollOff = 0 } - m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width(), m.proxyLogHScrollOff)) } case panelInfo, panelTools, panelInspector: // h-scroll not applicable to these panels @@ -276,13 +276,13 @@ func (m *Model) hScrollRight() { maxOff := maxLineLen(m.logLines) if m.logHScrollOff+step <= maxOff { m.logHScrollOff += step - m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width, m.logHScrollOff)) + m.logView.SetContent(buildHScrollContent(m.logLines, m.logView.Width(), m.logHScrollOff)) } case panelProxyLogs: maxOff := maxLineLen(m.proxyLogLines) if m.proxyLogHScrollOff+step <= maxOff { m.proxyLogHScrollOff += step - m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width, m.proxyLogHScrollOff)) + m.proxyLogView.SetContent(buildHScrollContent(m.proxyLogLines, m.proxyLogView.Width(), m.proxyLogHScrollOff)) } case panelInfo, panelTools, panelInspector: // h-scroll not applicable to these panels @@ -493,14 +493,14 @@ func (m *Model) resizeViewport() { mainWidth := m.width - sidebarWidth - 1 // 1 for the divider // mainStyle Height = m.height-2; title(1)+tabBar(1)+sep(1)+toolbar(1) = 4 overhead logHeight := max(m.height-6, 1) - m.logView.Width = mainWidth - m.logView.Height = logHeight - m.proxyLogView.Width = mainWidth - m.proxyLogView.Height = logHeight + m.logView.SetWidth(mainWidth) + m.logView.SetHeight(logHeight) + m.proxyLogView.SetWidth(mainWidth) + m.proxyLogView.SetHeight(logHeight) // Tools viewport: same height as logs, rebuild content to reflect new width. - if m.toolsView.Width != mainWidth || m.toolsView.Height != logHeight { - m.toolsView.Width = mainWidth - m.toolsView.Height = logHeight + if m.toolsView.Width() != mainWidth || m.toolsView.Height() != logHeight { + m.toolsView.SetWidth(mainWidth) + m.toolsView.SetHeight(logHeight) if len(m.tools) > 0 { m.toolsView.SetContent(buildToolsContent(m.tools, mainWidth, m.toolsSelectedIdx)) } @@ -509,10 +509,10 @@ func (m *Model) resizeViewport() { const inspLogHeight = 6 // inspH = m.height - 5 (from renderInspector); 8 lines of REQUEST/RESPONSE headers overhead. const inspHeaderOverhead = 8 - m.insp.logView.Width = mainWidth - m.insp.logView.Height = inspLogHeight - m.insp.respView.Width = mainWidth - m.insp.respView.Height = max(m.height-10-inspLogHeight, 3) + m.insp.logView.SetWidth(mainWidth) + m.insp.logView.SetHeight(inspLogHeight) + m.insp.respView.SetWidth(mainWidth) + m.insp.respView.SetHeight(max(m.height-10-inspLogHeight, 3)) m.insp.treeVisH = max(m.height-5-inspHeaderOverhead, 3) } diff --git a/pkg/tui/update_registry.go b/pkg/tui/update_registry.go index 398060a8c6..d834af4aa8 100644 --- a/pkg/tui/update_registry.go +++ b/pkg/tui/update_registry.go @@ -7,15 +7,15 @@ import ( "context" "strings" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" regtypes "github.com/stacklok/toolhive-core/registry/types" ) // handleRegistryKey handles key input while the registry overlay is open. -func (m *Model) handleRegistryKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleRegistryKey(msg tea.KeyPressMsg) tea.Cmd { // Run form captures all input while open. if m.runForm.open { return m.handleRunFormKey(msg) @@ -48,7 +48,7 @@ func (m *Model) handleRegistryKey(msg tea.KeyMsg) tea.Cmd { m.registry.idx++ m.clampRegistryScroll() } - case msg.Type == tea.KeyBackspace: + case msg.Code == tea.KeyBackspace: if len(m.registry.filter) > 0 { r := []rune(m.registry.filter) m.registry.filter = string(r[:len(r)-1]) @@ -56,8 +56,8 @@ func (m *Model) handleRegistryKey(msg tea.KeyMsg) tea.Cmd { m.registry.scrollOff = 0 } default: - if msg.Type == tea.KeyRunes { - m.registry.filter += msg.String() + if msg.Text != "" { + m.registry.filter += msg.Text m.registry.idx = 0 m.registry.scrollOff = 0 } @@ -66,7 +66,7 @@ func (m *Model) handleRegistryKey(msg tea.KeyMsg) tea.Cmd { } // handleRegistryDetailKey handles key input in the detail view. -func (m *Model) handleRegistryDetailKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleRegistryDetailKey(msg tea.KeyPressMsg) tea.Cmd { switch { case key.Matches(msg, keys.Escape): m.registry.detail = false @@ -136,7 +136,7 @@ func (m *Model) openRunForm(item regtypes.ServerMetadata) tea.Cmd { } // handleRunFormKey handles key input while the run form is open. -func (m *Model) handleRunFormKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleRunFormKey(msg tea.KeyPressMsg) tea.Cmd { if m.runForm.running { return nil } @@ -171,8 +171,8 @@ func (m *Model) blurAllRunFormFields() { formBlurAll(m.runForm.fields, &m.runForm.idx) } -func (m *Model) runFormForwardToField(msg tea.KeyMsg) tea.Cmd { - return formForwardKey(m.runForm.fields, m.runForm.idx, msg) +func (m *Model) runFormForwardToField(msg tea.Msg) tea.Cmd { + return formForwardMessage(m.runForm.fields, m.runForm.idx, msg) } // runFormSubmit validates required fields and launches the run command. diff --git a/pkg/tui/update_search.go b/pkg/tui/update_search.go index 15f7516d16..a3a96203a1 100644 --- a/pkg/tui/update_search.go +++ b/pkg/tui/update_search.go @@ -4,12 +4,13 @@ package tui import ( + "image/color" "strings" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/stacklok/toolhive/cmd/thv/app/ui" ) @@ -28,7 +29,7 @@ type searchParams struct { } // handleSearchKey is the shared key handler for both log and proxy-log search. -func handleSearchKey(msg tea.KeyMsg, p searchParams) tea.Cmd { +func handleSearchKey(msg tea.KeyPressMsg, p searchParams) tea.Cmd { switch { case key.Matches(msg, keys.Escape): // Esc clears the search entirely and restores normal log content. @@ -36,11 +37,11 @@ func handleSearchKey(msg tea.KeyMsg, p searchParams) tea.Cmd { *p.query = "" *p.matches = nil *p.idx = 0 - p.vp.SetContent(buildHScrollContent(p.lines, p.vp.Width, p.hOff)) + p.vp.SetContent(buildHScrollContent(p.lines, p.vp.Width(), p.hOff)) case key.Matches(msg, keys.Enter): // Enter closes the prompt but keeps highlights and the current match. *p.active = false - case msg.Type == tea.KeyBackspace: + case msg.Code == tea.KeyBackspace: if len(*p.query) > 0 { // Remove last rune (not last byte) to handle multi-byte UTF-8. r := []rune(*p.query) @@ -48,8 +49,8 @@ func handleSearchKey(msg tea.KeyMsg, p searchParams) tea.Cmd { rebuildSearch(p) } default: - if msg.Type == tea.KeyRunes { - *p.query += msg.String() + if msg.Text != "" { + *p.query += msg.Text rebuildSearch(p) } } @@ -62,7 +63,7 @@ func rebuildSearch(p searchParams) { *p.matches = nil *p.idx = 0 if *p.query == "" { - p.vp.SetContent(buildHScrollContent(p.lines, p.vp.Width, p.hOff)) + p.vp.SetContent(buildHScrollContent(p.lines, p.vp.Width(), p.hOff)) return } lq := strings.ToLower(*p.query) @@ -84,11 +85,11 @@ func scrollToSearchMatch(p searchParams) { if len(*p.matches) == 0 { // Re-render without highlights when there are no matches. if *p.query != "" { - p.vp.SetContent(buildHighlightedLogContent(p.lines, *p.query, nil, 0, p.vp.Width, p.hOff)) + p.vp.SetContent(buildHighlightedLogContent(p.lines, *p.query, nil, 0, p.vp.Width(), p.hOff)) } return } - p.vp.SetContent(buildHighlightedLogContent(p.lines, *p.query, *p.matches, *p.idx, p.vp.Width, p.hOff)) + p.vp.SetContent(buildHighlightedLogContent(p.lines, *p.query, *p.matches, *p.idx, p.vp.Width(), p.hOff)) // Scroll the viewport so the current match line is visible. matchLine := (*p.matches)[*p.idx] p.vp.SetYOffset(matchLine) @@ -121,7 +122,7 @@ func (m *Model) proxyLogSearchParams() searchParams { } // handleLogSearchKey handles key input while the log search prompt is open. -func (m *Model) handleLogSearchKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleLogSearchKey(msg tea.KeyPressMsg) tea.Cmd { return handleSearchKey(msg, m.logSearchParams()) } @@ -131,7 +132,7 @@ func (m *Model) scrollToMatch() { } // handleProxyLogSearchKey processes key events when proxy log search is active. -func (m *Model) handleProxyLogSearchKey(msg tea.KeyMsg) tea.Cmd { +func (m *Model) handleProxyLogSearchKey(msg tea.KeyPressMsg) tea.Cmd { return handleSearchKey(msg, m.proxyLogSearchParams()) } @@ -199,7 +200,7 @@ func buildHighlightedLogContent(lines []string, query string, matches []int, cur // highlightSubstring wraps all case-insensitive occurrences of query within line // with a lipgloss background color. It operates on rune indices so that // multi-byte UTF-8 characters and Unicode case mappings are handled correctly. -func highlightSubstring(line, query, lowerQuery string, bg lipgloss.Color) string { +func highlightSubstring(line, query, lowerQuery string, bg color.Color) string { if query == "" { return line } diff --git a/pkg/tui/view.go b/pkg/tui/view.go index a9c7a6127d..cfa2879e26 100644 --- a/pkg/tui/view.go +++ b/pkg/tui/view.go @@ -7,32 +7,19 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/stacklok/toolhive/cmd/thv/app/ui" ) -// View renders the full TUI to a string. -// We build exactly m.height lines by slotting body lines into a fixed array -// and placing the 2-line statusbar at the last two rows. This avoids any -// off-by-one ambiguity from lipgloss Height padding or trailing-newline -// counting differences between lipgloss and BubbleTea's "\n"-split renderer. -// oscSetBg is the OSC 11 sequence that sets the terminal's own default -// background colour. Every cell that has no explicit background (log text, -// tool descriptions, text-input interiors, etc.) will inherit this colour, -// giving the whole TUI a uniform #1e2030 background without having to style -// every individual element. oscResetBg restores the original colour on exit. -const oscSetBg = "\x1b]11;#1e2030\x07" -const oscResetBg = "\x1b]111;\x07" - -// View implements tea.Model and renders the full TUI to a string. -func (m Model) View() string { +// View implements tea.Model and renders the full TUI. +func (m Model) View() tea.View { if m.quitting { - // Reset terminal background before handing control back to the shell. - return oscResetBg + return newView("") } if m.width == 0 || m.height < 2 { - return "Loading…\n" + return newView("Loading…\n") } sidebar := m.renderSidebar() @@ -83,19 +70,22 @@ func (m Model) View() string { out[m.height-2] = sbParts[0] out[m.height-1] = sbParts[1] - // Prepend the OSC 11 sequence so the terminal's default background is - // #1e2030 for this frame. Every area with no explicit background colour - // (log lines, tool text, text-input interiors, …) will therefore show - // the same dark tone as the statusbar with no further changes needed. - full := oscSetBg + strings.Join(out, "\n") + full := strings.Join(out, "\n") if m.showHelp { - return m.renderHelpOverlay() + return newView(m.renderHelpOverlay()) } if m.registry.open { - return m.renderRegistryOverlay() + return newView(m.renderRegistryOverlay()) } - return full + return newView(full) +} + +func newView(content string) tea.View { + view := tea.NewView(content) + view.AltScreen = true + view.BackgroundColor = ui.ColorBg + return view } // renderSidebar renders the left server list. diff --git a/pkg/tui/view_helpers.go b/pkg/tui/view_helpers.go index 3d36a08bc5..5f17ed7739 100644 --- a/pkg/tui/view_helpers.go +++ b/pkg/tui/view_helpers.go @@ -6,8 +6,8 @@ package tui import ( "strings" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" "github.com/stacklok/toolhive/cmd/thv/app/ui" rt "github.com/stacklok/toolhive/pkg/container/runtime" diff --git a/pkg/tui/view_info.go b/pkg/tui/view_info.go index 9688ad907b..0e4bd26ccf 100644 --- a/pkg/tui/view_info.go +++ b/pkg/tui/view_info.go @@ -8,7 +8,7 @@ import ( "slices" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/stacklok/toolhive/cmd/thv/app/ui" "github.com/stacklok/toolhive/pkg/core" diff --git a/pkg/tui/view_inspector.go b/pkg/tui/view_inspector.go index e883eda658..2f3b98fafa 100644 --- a/pkg/tui/view_inspector.go +++ b/pkg/tui/view_inspector.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/stacklok/toolhive-core/mcpcompat/mcp" "github.com/stacklok/toolhive/cmd/thv/app/ui" @@ -86,7 +86,7 @@ func (m Model) renderToolInfoModal(base string, w, h int) string { return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, modal, lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(ui.ColorDim), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(ui.ColorDim)), ) } diff --git a/pkg/tui/view_registry.go b/pkg/tui/view_registry.go index d262f58bcc..7b9334df42 100644 --- a/pkg/tui/view_registry.go +++ b/pkg/tui/view_registry.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" regtypes "github.com/stacklok/toolhive-core/registry/types" "github.com/stacklok/toolhive/cmd/thv/app/ui" @@ -106,7 +106,7 @@ func (m Model) renderRegistryListOverlay() string { BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). Render(sb.String()), lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(ui.ColorDim), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(ui.ColorDim)), ) } @@ -149,7 +149,7 @@ func (m Model) renderRegistryDetailOverlay() string { BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). Render(sb.String()), lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(ui.ColorDim), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(ui.ColorDim)), ) } @@ -361,7 +361,7 @@ func (m Model) renderRunFormOverlay() string { BorderForeground(ui.ColorPurple).Padding(0, 1).Width(boxW). Render(sb.String()), lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(ui.ColorDim), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(ui.ColorDim)), ) } diff --git a/pkg/tui/view_statusbar.go b/pkg/tui/view_statusbar.go index e12daccf5d..898b5cba33 100644 --- a/pkg/tui/view_statusbar.go +++ b/pkg/tui/view_statusbar.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/stacklok/toolhive/cmd/thv/app/ui" ) @@ -16,8 +16,8 @@ import ( // //nolint:gocyclo // renders all status-bar states per panel; helper extraction done in separate funcs func (m Model) renderStatusBar() string { - const statusBg = lipgloss.Color("#1e2030") - const badgeBg = lipgloss.Color("#2a2f45") + statusBg := lipgloss.Color("#1e2030") + badgeBg := lipgloss.Color("#2a2f45") // badge renders a key name with a contrasting background box. // We use manual spaces instead of Padding to keep measurement predictable. @@ -207,7 +207,7 @@ func (m Model) renderHelpOverlay() string { lipgloss.Center, lipgloss.Center, helpContent, lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(ui.ColorDim), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(ui.ColorDim)), ) + "\n(press any key to close)" }