Skip to content

Commit fea2308

Browse files
committed
Hide already registered clients in thv client setup
When selecting clients in `thv client setup`, clients that are already registered for all selected groups are now filtered out of the selection list. If all installed clients are already registered, the command exits with a clear message instead of showing an empty list. Closes #1308 Signed-off-by: carlos <21148423+carlos-gn@users.noreply.github.com>
1 parent e836463 commit fea2308

5 files changed

Lines changed: 358 additions & 16 deletions

File tree

cmd/thv/app/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package app
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"sort"
1011

@@ -124,6 +125,10 @@ func clientSetupCmdFunc(cmd *cobra.Command, _ []string) error {
124125

125126
selectedClients, selectedGroups, confirmed, err := ui.RunClientSetup(availableClients, availableGroups)
126127
if err != nil {
128+
if errors.Is(err, ui.ErrAllClientsRegistered) {
129+
fmt.Println("All installed clients are already registered for the selected groups.")
130+
return nil
131+
}
127132
return fmt.Errorf("error running interactive setup: %w", err)
128133
}
129134
if !confirmed {

cmd/thv/app/ui/clients_setup.go

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
package ui
66

77
import (
8+
"errors"
89
"fmt"
10+
"sort"
911
"strings"
1012

1113
tea "github.com/charmbracelet/bubbletea"
@@ -15,6 +17,10 @@ import (
1517
"github.com/stacklok/toolhive/pkg/groups"
1618
)
1719

20+
// ErrAllClientsRegistered is returned when all available clients are already
21+
// registered for the selected groups.
22+
var ErrAllClientsRegistered = errors.New("all installed clients are already registered for the selected groups")
23+
1824
var (
1925
docStyle = lipgloss.NewStyle().Margin(1, 2)
2026
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
@@ -29,13 +35,18 @@ const (
2935
)
3036

3137
type setupModel struct {
38+
// UnfilteredClients holds all installed clients before group-based filtering.
39+
UnfilteredClients []client.ClientAppStatus
40+
// Clients holds the clients displayed in the selection list. After filtering,
41+
// SelectedClients indices refer to positions in this slice (not UnfilteredClients).
3242
Clients []client.ClientAppStatus
3343
Groups []*groups.Group
3444
Cursor int
3545
SelectedClients map[int]struct{}
3646
SelectedGroups map[int]struct{}
3747
Quitting bool
3848
Confirmed bool
49+
AllFiltered bool
3950
CurrentStep setupStep
4051
}
4152

@@ -63,9 +74,15 @@ func (m *setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6374
if len(m.SelectedGroups) == 0 {
6475
return m, nil // Stay on group selection step
6576
}
66-
// Move to client selection step
77+
// Filter clients and move to client selection step
78+
m.filterClientsBySelectedGroups()
6779
m.CurrentStep = stepClientSelection
6880
m.Cursor = 0
81+
if len(m.Clients) == 0 {
82+
m.AllFiltered = true
83+
m.Quitting = true
84+
return m, tea.Quit
85+
}
6986
return m, nil
7087
}
7188
// Final confirmation
@@ -114,11 +131,7 @@ func (m *setupModel) View() string {
114131
b.WriteString("\nUse ↑/↓ (or j/k) to move, 'space' to select, 'enter' to continue, 'q' to quit.\n")
115132
} else {
116133
if len(m.SelectedGroups) > 0 {
117-
var selectedGroupNames []string
118-
for i := range m.SelectedGroups {
119-
selectedGroupNames = append(selectedGroupNames, m.Groups[i].Name)
120-
}
121-
fmt.Fprintf(&b, "Selected groups: %s\n\n", strings.Join(selectedGroupNames, ", "))
134+
fmt.Fprintf(&b, "Selected groups: %s\n\n", strings.Join(m.sortedSelectedGroupNames(), ", "))
122135
}
123136
b.WriteString("Select clients to register:\n\n")
124137
for i, cli := range m.Clients {
@@ -130,6 +143,33 @@ func (m *setupModel) View() string {
130143
return docStyle.Render(b.String())
131144
}
132145

146+
// filterClientsBySelectedGroups replaces Clients with a filtered subset
147+
// that excludes clients already registered in all selected groups, and
148+
// resets SelectedClients since the indices would no longer be valid.
149+
func (m *setupModel) filterClientsBySelectedGroups() {
150+
if len(m.SelectedGroups) == 0 {
151+
return
152+
}
153+
154+
var selectedGroups []*groups.Group
155+
for i := range m.SelectedGroups {
156+
selectedGroups = append(selectedGroups, m.Groups[i])
157+
}
158+
159+
m.Clients = client.FilterClientsAlreadyRegistered(m.UnfilteredClients, selectedGroups)
160+
m.SelectedClients = make(map[int]struct{})
161+
}
162+
163+
// sortedSelectedGroupNames returns selected group names in sorted order.
164+
func (m *setupModel) sortedSelectedGroupNames() []string {
165+
names := make([]string, 0, len(m.SelectedGroups))
166+
for i := range m.SelectedGroups {
167+
names = append(names, m.Groups[i].Name)
168+
}
169+
sort.Strings(names)
170+
return names
171+
}
172+
133173
func renderGroupRow(m *setupModel, i int, group *groups.Group) string {
134174
cursor := " "
135175
if m.Cursor == i {
@@ -182,12 +222,31 @@ func RunClientSetup(
182222
currentStep = stepGroupSelection
183223
}
184224

225+
// When skipping group selection, filter out already-registered clients
226+
displayClients := clients
227+
if currentStep == stepClientSelection && len(selectedGroupsMap) > 0 {
228+
var selectedGroups []*groups.Group
229+
for i := range selectedGroupsMap {
230+
selectedGroups = append(selectedGroups, availableGroups[i])
231+
}
232+
displayClients = client.FilterClientsAlreadyRegistered(clients, selectedGroups)
233+
if len(displayClients) == 0 {
234+
groupNames := make([]string, 0, len(selectedGroupsMap))
235+
for i := range selectedGroupsMap {
236+
groupNames = append(groupNames, availableGroups[i].Name)
237+
}
238+
sort.Strings(groupNames)
239+
return nil, groupNames, false, ErrAllClientsRegistered
240+
}
241+
}
242+
185243
model := &setupModel{
186-
Clients: clients,
187-
Groups: availableGroups,
188-
SelectedClients: make(map[int]struct{}),
189-
SelectedGroups: selectedGroupsMap,
190-
CurrentStep: currentStep,
244+
UnfilteredClients: clients,
245+
Clients: displayClients,
246+
Groups: availableGroups,
247+
SelectedClients: make(map[int]struct{}),
248+
SelectedGroups: selectedGroupsMap,
249+
CurrentStep: currentStep,
191250
}
192251

193252
p := tea.NewProgram(model)
@@ -197,16 +256,19 @@ func RunClientSetup(
197256
}
198257

199258
m := finalModel.(*setupModel)
259+
260+
if m.AllFiltered {
261+
groupNames := m.sortedSelectedGroupNames()
262+
return nil, groupNames, false, ErrAllClientsRegistered
263+
}
264+
200265
var selectedClients []client.ClientAppStatus
201266
for i := range m.SelectedClients {
202-
selectedClients = append(selectedClients, clients[i])
267+
selectedClients = append(selectedClients, m.Clients[i])
203268
}
204269

205270
// Convert selected group indices back to group names
206-
var selectedGroupNames []string
207-
for i := range m.SelectedGroups {
208-
selectedGroupNames = append(selectedGroupNames, m.Groups[i].Name)
209-
}
271+
selectedGroupNames := m.sortedSelectedGroupNames()
210272

211273
return selectedClients, selectedGroupNames, m.Confirmed, nil
212274
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package ui
5+
6+
import (
7+
"testing"
8+
9+
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/stacklok/toolhive/pkg/client"
14+
"github.com/stacklok/toolhive/pkg/groups"
15+
)
16+
17+
func TestSetupModelUpdate_GroupToClientTransition(t *testing.T) {
18+
t.Parallel()
19+
20+
tests := []struct {
21+
name string
22+
allClients []client.ClientAppStatus
23+
grps []*groups.Group
24+
selectedGroups map[int]struct{}
25+
wantStep setupStep
26+
wantQuitting bool
27+
wantAllFiltered bool
28+
wantClientCount int
29+
}{
30+
{
31+
name: "filters already-registered clients on transition",
32+
allClients: []client.ClientAppStatus{
33+
{ClientType: client.VSCode, Installed: true},
34+
{ClientType: client.Cursor, Installed: true},
35+
{ClientType: client.ClaudeCode, Installed: true},
36+
},
37+
grps: []*groups.Group{
38+
{Name: "group1", RegisteredClients: []string{"vscode"}},
39+
},
40+
selectedGroups: map[int]struct{}{0: {}},
41+
wantStep: stepClientSelection,
42+
wantQuitting: false,
43+
wantAllFiltered: false,
44+
wantClientCount: 2, // cursor and claude-code remain
45+
},
46+
{
47+
name: "sets AllFiltered when all clients are already registered",
48+
allClients: []client.ClientAppStatus{
49+
{ClientType: client.VSCode, Installed: true},
50+
{ClientType: client.Cursor, Installed: true},
51+
},
52+
grps: []*groups.Group{
53+
{Name: "group1", RegisteredClients: []string{"vscode", "cursor"}},
54+
},
55+
selectedGroups: map[int]struct{}{0: {}},
56+
wantStep: stepClientSelection,
57+
wantQuitting: true,
58+
wantAllFiltered: true,
59+
wantClientCount: 0,
60+
},
61+
{
62+
name: "does not transition without group selection",
63+
allClients: []client.ClientAppStatus{
64+
{ClientType: client.VSCode, Installed: true},
65+
},
66+
grps: []*groups.Group{
67+
{Name: "group1", RegisteredClients: []string{}},
68+
},
69+
selectedGroups: map[int]struct{}{}, // none selected
70+
wantStep: stepGroupSelection, // stays on group step
71+
wantQuitting: false,
72+
wantAllFiltered: false,
73+
wantClientCount: 1,
74+
},
75+
}
76+
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
t.Parallel()
80+
81+
m := &setupModel{
82+
UnfilteredClients: tt.allClients,
83+
Clients: tt.allClients,
84+
Groups: tt.grps,
85+
SelectedClients: make(map[int]struct{}),
86+
SelectedGroups: tt.selectedGroups,
87+
CurrentStep: stepGroupSelection,
88+
}
89+
90+
// Press enter to transition
91+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
92+
result := updated.(*setupModel)
93+
94+
assert.Equal(t, tt.wantStep, result.CurrentStep)
95+
assert.Equal(t, tt.wantQuitting, result.Quitting)
96+
assert.Equal(t, tt.wantAllFiltered, result.AllFiltered)
97+
assert.Len(t, result.Clients, tt.wantClientCount)
98+
})
99+
}
100+
}
101+
102+
func TestSetupModelUpdate_ClientSelection(t *testing.T) {
103+
t.Parallel()
104+
105+
clients := []client.ClientAppStatus{
106+
{ClientType: client.VSCode, Installed: true},
107+
{ClientType: client.Cursor, Installed: true},
108+
}
109+
110+
m := &setupModel{
111+
UnfilteredClients: clients,
112+
Clients: clients,
113+
Groups: []*groups.Group{{Name: "g1"}},
114+
SelectedClients: make(map[int]struct{}),
115+
SelectedGroups: map[int]struct{}{0: {}},
116+
CurrentStep: stepClientSelection,
117+
}
118+
119+
// Toggle first client with space
120+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
121+
result := updated.(*setupModel)
122+
_, selected := result.SelectedClients[0]
123+
assert.True(t, selected, "first client should be selected after space")
124+
125+
// Toggle it off
126+
updated, _ = result.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
127+
result = updated.(*setupModel)
128+
_, selected = result.SelectedClients[0]
129+
assert.False(t, selected, "first client should be deselected after second space")
130+
131+
// Confirm with enter
132+
updated, cmd := result.Update(tea.KeyMsg{Type: tea.KeyEnter})
133+
result = updated.(*setupModel)
134+
assert.True(t, result.Confirmed)
135+
assert.True(t, result.Quitting)
136+
assert.False(t, result.AllFiltered)
137+
require.NotNil(t, cmd, "should return a quit command")
138+
}

pkg/client/filter.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package client
5+
6+
import (
7+
"github.com/stacklok/toolhive/pkg/groups"
8+
)
9+
10+
// FilterClientsAlreadyRegistered returns only clients that are NOT already
11+
// registered in all of the provided groups. A client is excluded only when
12+
// every group in selectedGroups already lists it in RegisteredClients.
13+
func FilterClientsAlreadyRegistered(
14+
clients []ClientAppStatus,
15+
selectedGroups []*groups.Group,
16+
) []ClientAppStatus {
17+
if len(selectedGroups) == 0 {
18+
return clients
19+
}
20+
21+
var filtered []ClientAppStatus
22+
for _, cli := range clients {
23+
if !isClientRegisteredInAllGroups(string(cli.ClientType), selectedGroups) {
24+
filtered = append(filtered, cli)
25+
}
26+
}
27+
return filtered
28+
}
29+
30+
func isClientRegisteredInAllGroups(clientName string, selectedGroups []*groups.Group) bool {
31+
for _, group := range selectedGroups {
32+
found := false
33+
for _, registered := range group.RegisteredClients {
34+
if registered == clientName {
35+
found = true
36+
break
37+
}
38+
}
39+
if !found {
40+
return false
41+
}
42+
}
43+
return true
44+
}

0 commit comments

Comments
 (0)