Skip to content

Commit 64bceab

Browse files
committed
feat: progressive tool filtering for permission screens
Replace built-in list filter with always-visible filter bar using two-mode design (filter/action) in both the denied tools editor and namespace tool permissions editor. Filter is case-insensitive substring match; bulk ops always act on the full unfiltered set.
1 parent 1b47823 commit 64bceab

4 files changed

Lines changed: 640 additions & 112 deletions

File tree

internal/tui/views/tool_deny_editor.go

Lines changed: 108 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/Bigsy/mcpmu/internal/tui/theme"
1010
"github.com/charmbracelet/bubbles/key"
1111
"github.com/charmbracelet/bubbles/list"
12+
"github.com/charmbracelet/bubbles/textinput"
1213
tea "github.com/charmbracelet/bubbletea"
1314
"github.com/charmbracelet/lipgloss"
1415
)
@@ -44,6 +45,11 @@ type ToolDenyEditorModel struct {
4445
// Current deny state: toolName -> denied
4546
denied map[string]bool
4647

48+
// Filter
49+
filterInput textinput.Model
50+
allItems []list.Item // full unfiltered list, set in Show()
51+
filterFocused bool
52+
4753
// Key bindings
4854
escKey key.Binding
4955
enterKey key.Binding
@@ -56,16 +62,19 @@ func NewToolDenyEditor(th theme.Theme) ToolDenyEditorModel {
5662
l := list.New([]list.Item{}, delegate, 0, 0)
5763
l.Title = "Denied Tools"
5864
l.SetShowStatusBar(false)
59-
l.SetFilteringEnabled(true)
65+
l.SetFilteringEnabled(false)
6066
l.SetShowHelp(false)
6167
l.Styles.Title = th.Title
62-
l.FilterInput.PromptStyle = th.Primary
63-
l.FilterInput.Cursor.Style = th.Primary
68+
69+
ti := textinput.New()
70+
ti.Placeholder = "/ to filter..."
71+
ti.CharLimit = 100
6472

6573
return ToolDenyEditorModel{
66-
theme: th,
67-
list: l,
68-
denied: make(map[string]bool),
74+
theme: th,
75+
list: l,
76+
denied: make(map[string]bool),
77+
filterInput: ti,
6978
escKey: key.NewBinding(
7079
key.WithKeys("esc"),
7180
key.WithHelp("esc", "cancel"),
@@ -100,6 +109,10 @@ func (m *ToolDenyEditorModel) Show(serverName string, tools []mcp.Tool, deniedTo
100109
})
101110
}
102111

112+
m.allItems = items
113+
m.filterInput.SetValue("")
114+
m.filterInput.Blur()
115+
m.filterFocused = false
103116
m.list.SetItems(items)
104117
m.list.SetDelegate(newToolDenyDelegate(m.theme, m.denied))
105118
}
@@ -121,7 +134,24 @@ func (m *ToolDenyEditorModel) SetSize(width, height int) {
121134
if height < 30 {
122135
editorHeight = height - 5
123136
}
124-
m.list.SetSize(editorWidth-6, editorHeight-6)
137+
m.list.SetSize(editorWidth-6, editorHeight-8)
138+
}
139+
140+
// applyFilter filters the list items based on the current filter input value.
141+
func (m *ToolDenyEditorModel) applyFilter() {
142+
query := strings.ToLower(m.filterInput.Value())
143+
if query == "" {
144+
m.list.SetItems(m.allItems)
145+
return
146+
}
147+
var filtered []list.Item
148+
for _, item := range m.allItems {
149+
ti := item.(toolDenyItem)
150+
if strings.Contains(strings.ToLower(ti.toolName), query) {
151+
filtered = append(filtered, item)
152+
}
153+
}
154+
m.list.SetItems(filtered)
125155
}
126156

127157
// Update handles messages.
@@ -130,28 +160,22 @@ func (m *ToolDenyEditorModel) Update(msg tea.Msg) tea.Cmd {
130160
return nil
131161
}
132162

133-
// When filtering is active, let the list handle most keys
134-
if m.list.FilterState() == list.Filtering {
163+
kmsg, isKey := msg.(tea.KeyMsg)
164+
if !isKey {
135165
var cmd tea.Cmd
136166
m.list, cmd = m.list.Update(msg)
137167
return cmd
138168
}
139169

140-
switch msg := msg.(type) {
141-
case tea.KeyMsg:
170+
if m.filterFocused {
142171
switch {
143-
case key.Matches(msg, m.escKey):
144-
if m.list.FilterState() == list.FilterApplied {
145-
m.list.ResetFilter()
146-
return nil
147-
}
148-
m.visible = false
149-
return func() tea.Msg {
150-
return ToolDenyResult{ServerName: m.serverName, Submitted: false}
151-
}
152-
case key.Matches(msg, m.enterKey):
172+
case key.Matches(kmsg, m.escKey):
173+
// Exit filter mode, keep filter text
174+
m.filterFocused = false
175+
m.filterInput.Blur()
176+
return nil
177+
case key.Matches(kmsg, m.enterKey):
153178
m.visible = false
154-
// Collect denied tools
155179
var denied []string
156180
for toolName, isDenied := range m.denied {
157181
if isDenied {
@@ -165,16 +189,66 @@ func (m *ToolDenyEditorModel) Update(msg tea.Msg) tea.Cmd {
165189
Submitted: true,
166190
}
167191
}
168-
case key.Matches(msg, m.spaceKey):
192+
case key.Matches(kmsg, m.spaceKey):
169193
if item := m.list.SelectedItem(); item != nil {
170194
ti := item.(toolDenyItem)
171195
m.denied[ti.toolName] = !m.denied[ti.toolName]
172196
m.list.SetDelegate(newToolDenyDelegate(m.theme, m.denied))
173197
}
174198
return nil
199+
case kmsg.Type == tea.KeyUp || kmsg.Type == tea.KeyDown:
200+
var cmd tea.Cmd
201+
m.list, cmd = m.list.Update(msg)
202+
return cmd
203+
default:
204+
// Send to textinput, then apply filter
205+
var cmd tea.Cmd
206+
m.filterInput, cmd = m.filterInput.Update(msg)
207+
m.applyFilter()
208+
return cmd
175209
}
176210
}
177211

212+
// Action mode
213+
switch {
214+
case kmsg.Type == tea.KeyRunes && string(kmsg.Runes) == "/":
215+
m.filterFocused = true
216+
m.filterInput.Focus()
217+
return nil
218+
case key.Matches(kmsg, m.escKey):
219+
if m.filterInput.Value() != "" {
220+
m.filterInput.SetValue("")
221+
m.applyFilter()
222+
return nil
223+
}
224+
m.visible = false
225+
return func() tea.Msg {
226+
return ToolDenyResult{ServerName: m.serverName, Submitted: false}
227+
}
228+
case key.Matches(kmsg, m.enterKey):
229+
m.visible = false
230+
var denied []string
231+
for toolName, isDenied := range m.denied {
232+
if isDenied {
233+
denied = append(denied, toolName)
234+
}
235+
}
236+
return func() tea.Msg {
237+
return ToolDenyResult{
238+
ServerName: m.serverName,
239+
DeniedTools: denied,
240+
Submitted: true,
241+
}
242+
}
243+
case key.Matches(kmsg, m.spaceKey):
244+
if item := m.list.SelectedItem(); item != nil {
245+
ti := item.(toolDenyItem)
246+
m.denied[ti.toolName] = !m.denied[ti.toolName]
247+
m.list.SetDelegate(newToolDenyDelegate(m.theme, m.denied))
248+
}
249+
return nil
250+
}
251+
178252
var cmd tea.Cmd
179253
m.list, cmd = m.list.Update(msg)
180254
return cmd
@@ -185,7 +259,17 @@ func (m ToolDenyEditorModel) View() string {
185259
if !m.visible {
186260
return ""
187261
}
188-
return m.list.View()
262+
263+
filterLabel := m.theme.Faint.Render("Filter: ")
264+
filterView := m.filterInput.View()
265+
filterBar := filterLabel + filterView
266+
267+
listView := m.list.View()
268+
if len(m.list.Items()) == 0 && m.filterInput.Value() != "" {
269+
listView = "\n" + m.theme.Faint.Render(" No matching tools") + "\n"
270+
}
271+
272+
return filterBar + "\n\n" + listView
189273
}
190274

191275
// RenderOverlay renders the editor as a centered overlay.

internal/tui/views/tool_deny_editor_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,158 @@ func TestToolDenyEditor_Cancel(t *testing.T) {
143143
}
144144
}
145145

146+
// ============================================================================
147+
// Filter Tests
148+
// ============================================================================
149+
150+
func newDenyEditorWithTools(t *testing.T) ToolDenyEditorModel {
151+
t.Helper()
152+
th := theme.New()
153+
editor := NewToolDenyEditor(th)
154+
editor.SetSize(100, 50)
155+
tools := []mcp.Tool{
156+
{Name: "read_file", Description: "Read a file"},
157+
{Name: "read_resource", Description: "Read a resource"},
158+
{Name: "write_file", Description: "Write a file"},
159+
{Name: "delete_file", Description: "Delete a file"},
160+
}
161+
editor.Show("srv1", tools, nil)
162+
return editor
163+
}
164+
165+
func sendRune(editor *ToolDenyEditorModel, r rune) {
166+
editor.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
167+
}
168+
169+
func enterFilterMode(editor *ToolDenyEditorModel) {
170+
sendRune(editor, '/')
171+
}
172+
173+
func TestToolDenyEditor_FilterReducesList(t *testing.T) {
174+
editor := newDenyEditorWithTools(t)
175+
176+
if len(editor.list.Items()) != 4 {
177+
t.Fatalf("expected 4 items, got %d", len(editor.list.Items()))
178+
}
179+
180+
// Enter filter mode and type "read"
181+
enterFilterMode(&editor)
182+
for _, r := range "read" {
183+
sendRune(&editor, r)
184+
}
185+
186+
items := editor.list.Items()
187+
if len(items) != 2 {
188+
t.Errorf("expected 2 filtered items, got %d", len(items))
189+
}
190+
for _, item := range items {
191+
ti := item.(toolDenyItem)
192+
if ti.toolName != "read_file" && ti.toolName != "read_resource" {
193+
t.Errorf("unexpected item %q in filtered list", ti.toolName)
194+
}
195+
}
196+
}
197+
198+
func TestToolDenyEditor_FilterClearOnEsc(t *testing.T) {
199+
editor := newDenyEditorWithTools(t)
200+
201+
// Enter filter mode, type, then exit filter mode
202+
enterFilterMode(&editor)
203+
for _, r := range "read" {
204+
sendRune(&editor, r)
205+
}
206+
// Esc exits filter mode (keeps text)
207+
editor.Update(tea.KeyMsg{Type: tea.KeyEsc})
208+
// Now in action mode with filter text — esc clears filter
209+
editor.Update(tea.KeyMsg{Type: tea.KeyEsc})
210+
211+
if editor.filterInput.Value() != "" {
212+
t.Error("filter text should be cleared")
213+
}
214+
if len(editor.list.Items()) != 4 {
215+
t.Errorf("expected all 4 items restored, got %d", len(editor.list.Items()))
216+
}
217+
}
218+
219+
func TestToolDenyEditor_FilterModeEscKeepsText(t *testing.T) {
220+
editor := newDenyEditorWithTools(t)
221+
222+
enterFilterMode(&editor)
223+
for _, r := range "read" {
224+
sendRune(&editor, r)
225+
}
226+
227+
// Esc should exit filter mode but keep text
228+
editor.Update(tea.KeyMsg{Type: tea.KeyEsc})
229+
230+
if editor.filterFocused {
231+
t.Error("filter should not be focused after esc")
232+
}
233+
if editor.filterInput.Value() != "read" {
234+
t.Errorf("expected filter text 'read', got %q", editor.filterInput.Value())
235+
}
236+
// List should still be filtered
237+
if len(editor.list.Items()) != 2 {
238+
t.Errorf("expected 2 items (still filtered), got %d", len(editor.list.Items()))
239+
}
240+
}
241+
242+
func TestToolDenyEditor_SpaceWorksInFilterMode(t *testing.T) {
243+
editor := newDenyEditorWithTools(t)
244+
245+
enterFilterMode(&editor)
246+
for _, r := range "delete" {
247+
sendRune(&editor, r)
248+
}
249+
250+
// Should have 1 item: delete_file
251+
if len(editor.list.Items()) != 1 {
252+
t.Fatalf("expected 1 filtered item, got %d", len(editor.list.Items()))
253+
}
254+
255+
// Space should toggle the selected item
256+
editor.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
257+
if !editor.denied["delete_file"] {
258+
t.Error("delete_file should be denied after space toggle in filter mode")
259+
}
260+
}
261+
262+
func TestToolDenyEditor_FilterNoMatches(t *testing.T) {
263+
editor := newDenyEditorWithTools(t)
264+
265+
enterFilterMode(&editor)
266+
for _, r := range "xyznonexistent" {
267+
sendRune(&editor, r)
268+
}
269+
270+
if len(editor.list.Items()) != 0 {
271+
t.Errorf("expected 0 items, got %d", len(editor.list.Items()))
272+
}
273+
274+
// Space should be a no-op (no panic)
275+
editor.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
276+
}
277+
278+
func TestToolDenyEditor_BackspaceToEmpty(t *testing.T) {
279+
editor := newDenyEditorWithTools(t)
280+
281+
enterFilterMode(&editor)
282+
for _, r := range "ab" {
283+
sendRune(&editor, r)
284+
}
285+
286+
// Backspace twice to empty
287+
editor.Update(tea.KeyMsg{Type: tea.KeyBackspace})
288+
editor.Update(tea.KeyMsg{Type: tea.KeyBackspace})
289+
290+
if editor.filterInput.Value() != "" {
291+
t.Errorf("expected empty filter, got %q", editor.filterInput.Value())
292+
}
293+
if len(editor.list.Items()) != 4 {
294+
t.Errorf("expected all 4 items restored, got %d", len(editor.list.Items()))
295+
}
296+
}
297+
146298
func TestToolDenyEditor_ShowWithEmptyDenyList(t *testing.T) {
147299
th := theme.New()
148300
editor := NewToolDenyEditor(th)

0 commit comments

Comments
 (0)