Skip to content

Commit 8931db6

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 8931db6

File tree

3 files changed

+315
-3
lines changed

3 files changed

+315
-3
lines changed

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: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package ui
66

77
import (
8+
"errors"
89
"fmt"
910
"strings"
1011

@@ -15,6 +16,10 @@ import (
1516
"github.com/stacklok/toolhive/pkg/groups"
1617
)
1718

19+
// ErrAllClientsRegistered is returned when all available clients are already
20+
// registered for the selected groups.
21+
var ErrAllClientsRegistered = errors.New("all installed clients are already registered for the selected groups")
22+
1823
var (
1924
docStyle = lipgloss.NewStyle().Margin(1, 2)
2025
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
@@ -29,6 +34,7 @@ const (
2934
)
3035

3136
type setupModel struct {
37+
AllClients []client.ClientAppStatus
3238
Clients []client.ClientAppStatus
3339
Groups []*groups.Group
3440
Cursor int
@@ -63,9 +69,16 @@ func (m *setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6369
if len(m.SelectedGroups) == 0 {
6470
return m, nil // Stay on group selection step
6571
}
66-
// Move to client selection step
72+
// Filter clients and move to client selection step
73+
m.filterClientsBySelectedGroups()
6774
m.CurrentStep = stepClientSelection
6875
m.Cursor = 0
76+
if len(m.Clients) == 0 {
77+
// All clients already registered for selected groups
78+
m.Confirmed = false
79+
m.Quitting = true
80+
return m, tea.Quit
81+
}
6982
return m, nil
7083
}
7184
// Final confirmation
@@ -130,6 +143,58 @@ func (m *setupModel) View() string {
130143
return docStyle.Render(b.String())
131144
}
132145

146+
// filterClientsBySelectedGroups removes clients that are already registered
147+
// in all selected groups. A client is hidden only when every selected group
148+
// already lists it in RegisteredClients.
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 = FilterClientsAlreadyRegistered(m.AllClients, selectedGroups)
160+
m.SelectedClients = make(map[int]struct{})
161+
}
162+
163+
// FilterClientsAlreadyRegistered returns only clients that are NOT already
164+
// registered in all of the provided groups. Exported for testing.
165+
func FilterClientsAlreadyRegistered(
166+
clients []client.ClientAppStatus,
167+
selectedGroups []*groups.Group,
168+
) []client.ClientAppStatus {
169+
if len(selectedGroups) == 0 {
170+
return clients
171+
}
172+
173+
var filtered []client.ClientAppStatus
174+
for _, cli := range clients {
175+
if !isClientRegisteredInAllGroups(string(cli.ClientType), selectedGroups) {
176+
filtered = append(filtered, cli)
177+
}
178+
}
179+
return filtered
180+
}
181+
182+
func isClientRegisteredInAllGroups(clientName string, selectedGroups []*groups.Group) bool {
183+
for _, group := range selectedGroups {
184+
found := false
185+
for _, registered := range group.RegisteredClients {
186+
if registered == clientName {
187+
found = true
188+
break
189+
}
190+
}
191+
if !found {
192+
return false
193+
}
194+
}
195+
return true
196+
}
197+
133198
func renderGroupRow(m *setupModel, i int, group *groups.Group) string {
134199
cursor := " "
135200
if m.Cursor == i {
@@ -182,8 +247,27 @@ func RunClientSetup(
182247
currentStep = stepGroupSelection
183248
}
184249

250+
// When skipping group selection, filter out already-registered clients
251+
displayClients := clients
252+
if currentStep == stepClientSelection && len(selectedGroupsMap) > 0 {
253+
var selectedGroups []*groups.Group
254+
for i := range selectedGroupsMap {
255+
selectedGroups = append(selectedGroups, availableGroups[i])
256+
}
257+
displayClients = FilterClientsAlreadyRegistered(clients, selectedGroups)
258+
if len(displayClients) == 0 {
259+
// All clients are already registered for the auto-selected group(s)
260+
var groupNames []string
261+
for i := range selectedGroupsMap {
262+
groupNames = append(groupNames, availableGroups[i].Name)
263+
}
264+
return nil, groupNames, false, ErrAllClientsRegistered
265+
}
266+
}
267+
185268
model := &setupModel{
186-
Clients: clients,
269+
AllClients: clients,
270+
Clients: displayClients,
187271
Groups: availableGroups,
188272
SelectedClients: make(map[int]struct{}),
189273
SelectedGroups: selectedGroupsMap,
@@ -197,9 +281,19 @@ func RunClientSetup(
197281
}
198282

199283
m := finalModel.(*setupModel)
284+
285+
// Check if we quit because all clients were filtered out
286+
if !m.Confirmed && len(m.Clients) == 0 && m.CurrentStep == stepClientSelection {
287+
var groupNames []string
288+
for i := range m.SelectedGroups {
289+
groupNames = append(groupNames, m.Groups[i].Name)
290+
}
291+
return nil, groupNames, false, ErrAllClientsRegistered
292+
}
293+
200294
var selectedClients []client.ClientAppStatus
201295
for i := range m.SelectedClients {
202-
selectedClients = append(selectedClients, clients[i])
296+
selectedClients = append(selectedClients, m.Clients[i])
203297
}
204298

205299
// Convert selected group indices back to group names
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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 TestFilterClientsAlreadyRegistered(t *testing.T) {
18+
t.Parallel()
19+
20+
tests := []struct {
21+
name string
22+
clients []client.ClientAppStatus
23+
selectedGroups []*groups.Group
24+
wantClients []client.ClientApp
25+
}{
26+
{
27+
name: "no groups selected returns all clients",
28+
clients: []client.ClientAppStatus{
29+
{ClientType: client.VSCode, Installed: true},
30+
{ClientType: client.Cursor, Installed: true},
31+
},
32+
selectedGroups: nil,
33+
wantClients: []client.ClientApp{client.VSCode, client.Cursor},
34+
},
35+
{
36+
name: "client registered in all selected groups is hidden",
37+
clients: []client.ClientAppStatus{
38+
{ClientType: client.VSCode, Installed: true},
39+
{ClientType: client.Cursor, Installed: true},
40+
},
41+
selectedGroups: []*groups.Group{
42+
{Name: "group1", RegisteredClients: []string{"vscode", "cursor"}},
43+
{Name: "group2", RegisteredClients: []string{"vscode"}},
44+
},
45+
wantClients: []client.ClientApp{client.Cursor},
46+
},
47+
{
48+
name: "client registered in only some groups is kept",
49+
clients: []client.ClientAppStatus{
50+
{ClientType: client.VSCode, Installed: true},
51+
{ClientType: client.Cursor, Installed: true},
52+
},
53+
selectedGroups: []*groups.Group{
54+
{Name: "group1", RegisteredClients: []string{"vscode"}},
55+
{Name: "group2", RegisteredClients: []string{"cursor"}},
56+
},
57+
wantClients: []client.ClientApp{client.VSCode, client.Cursor},
58+
},
59+
{
60+
name: "all clients already registered returns empty",
61+
clients: []client.ClientAppStatus{
62+
{ClientType: client.VSCode, Installed: true},
63+
{ClientType: client.Cursor, Installed: true},
64+
},
65+
selectedGroups: []*groups.Group{
66+
{Name: "group1", RegisteredClients: []string{"vscode", "cursor"}},
67+
},
68+
wantClients: nil,
69+
},
70+
{
71+
name: "single group with no registered clients returns all",
72+
clients: []client.ClientAppStatus{
73+
{ClientType: client.VSCode, Installed: true},
74+
{ClientType: client.Cursor, Installed: true},
75+
},
76+
selectedGroups: []*groups.Group{
77+
{Name: "group1", RegisteredClients: []string{}},
78+
},
79+
wantClients: []client.ClientApp{client.VSCode, client.Cursor},
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
t.Parallel()
86+
result := FilterClientsAlreadyRegistered(tt.clients, tt.selectedGroups)
87+
88+
var gotClients []client.ClientApp
89+
for _, c := range result {
90+
gotClients = append(gotClients, c.ClientType)
91+
}
92+
93+
assert.Equal(t, tt.wantClients, gotClients)
94+
})
95+
}
96+
}
97+
98+
func TestSetupModelUpdate_GroupToClientTransition(t *testing.T) {
99+
t.Parallel()
100+
101+
tests := []struct {
102+
name string
103+
allClients []client.ClientAppStatus
104+
grps []*groups.Group
105+
selectedGroups map[int]struct{}
106+
wantStep setupStep
107+
wantQuitting bool
108+
wantClientCount int
109+
}{
110+
{
111+
name: "filters already-registered clients on transition",
112+
allClients: []client.ClientAppStatus{
113+
{ClientType: client.VSCode, Installed: true},
114+
{ClientType: client.Cursor, Installed: true},
115+
{ClientType: client.ClaudeCode, Installed: true},
116+
},
117+
grps: []*groups.Group{
118+
{Name: "group1", RegisteredClients: []string{"vscode"}},
119+
},
120+
selectedGroups: map[int]struct{}{0: {}},
121+
wantStep: stepClientSelection,
122+
wantQuitting: false,
123+
wantClientCount: 2, // cursor and claude-code remain
124+
},
125+
{
126+
name: "quits when all clients are already registered",
127+
allClients: []client.ClientAppStatus{
128+
{ClientType: client.VSCode, Installed: true},
129+
{ClientType: client.Cursor, Installed: true},
130+
},
131+
grps: []*groups.Group{
132+
{Name: "group1", RegisteredClients: []string{"vscode", "cursor"}},
133+
},
134+
selectedGroups: map[int]struct{}{0: {}},
135+
wantStep: stepClientSelection,
136+
wantQuitting: true,
137+
wantClientCount: 0,
138+
},
139+
{
140+
name: "does not transition without group selection",
141+
allClients: []client.ClientAppStatus{
142+
{ClientType: client.VSCode, Installed: true},
143+
},
144+
grps: []*groups.Group{
145+
{Name: "group1", RegisteredClients: []string{}},
146+
},
147+
selectedGroups: map[int]struct{}{}, // none selected
148+
wantStep: stepGroupSelection, // stays on group step
149+
wantQuitting: false,
150+
wantClientCount: 1,
151+
},
152+
}
153+
154+
for _, tt := range tests {
155+
t.Run(tt.name, func(t *testing.T) {
156+
t.Parallel()
157+
158+
m := &setupModel{
159+
AllClients: tt.allClients,
160+
Clients: tt.allClients,
161+
Groups: tt.grps,
162+
SelectedClients: make(map[int]struct{}),
163+
SelectedGroups: tt.selectedGroups,
164+
CurrentStep: stepGroupSelection,
165+
}
166+
167+
// Press enter to transition
168+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
169+
result := updated.(*setupModel)
170+
171+
assert.Equal(t, tt.wantStep, result.CurrentStep)
172+
assert.Equal(t, tt.wantQuitting, result.Quitting)
173+
assert.Len(t, result.Clients, tt.wantClientCount)
174+
})
175+
}
176+
}
177+
178+
func TestSetupModelUpdate_ClientSelection(t *testing.T) {
179+
t.Parallel()
180+
181+
clients := []client.ClientAppStatus{
182+
{ClientType: client.VSCode, Installed: true},
183+
{ClientType: client.Cursor, Installed: true},
184+
}
185+
186+
m := &setupModel{
187+
AllClients: clients,
188+
Clients: clients,
189+
Groups: []*groups.Group{{Name: "g1"}},
190+
SelectedClients: make(map[int]struct{}),
191+
SelectedGroups: map[int]struct{}{0: {}},
192+
CurrentStep: stepClientSelection,
193+
}
194+
195+
// Toggle first client with space
196+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
197+
result := updated.(*setupModel)
198+
_, selected := result.SelectedClients[0]
199+
assert.True(t, selected, "first client should be selected after space")
200+
201+
// Toggle it off
202+
updated, _ = result.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
203+
result = updated.(*setupModel)
204+
_, selected = result.SelectedClients[0]
205+
assert.False(t, selected, "first client should be deselected after second space")
206+
207+
// Confirm with enter
208+
updated, cmd := result.Update(tea.KeyMsg{Type: tea.KeyEnter})
209+
result = updated.(*setupModel)
210+
assert.True(t, result.Confirmed)
211+
assert.True(t, result.Quitting)
212+
require.NotNil(t, cmd, "should return a quit command")
213+
}

0 commit comments

Comments
 (0)