Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 2 additions & 36 deletions cmd/thv/app/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
14 changes: 7 additions & 7 deletions cmd/thv/app/ui/clients_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions cmd/thv/app/ui/clients_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions cmd/thv/app/ui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"os"
"strings"

"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"github.com/spf13/cobra"
"golang.org/x/term"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -259,5 +259,5 @@ func renderParentHelp(cmd *cobra.Command) {
lipgloss.NewStyle().Foreground(ColorDim).Render(
"Run thv "+cmd.Name()+" <command> --help for details."))

fmt.Print(sb.String())
_, _ = lipgloss.Print(sb.String())
}
4 changes: 2 additions & 2 deletions cmd/thv/app/ui/selected_groups_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions cmd/thv/app/ui/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
package ui

import (
"fmt"
"os"
"sync"
"time"

"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"golang.org/x/term"
)

Expand Down Expand Up @@ -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++
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
2 changes: 1 addition & 1 deletion cmd/thv/app/ui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"fmt"
"strings"

"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"

rt "github.com/stacklok/toolhive/pkg/container/runtime"
)
Expand Down
21 changes: 9 additions & 12 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading