Skip to content

Commit 4564542

Browse files
joestumpjoestump-agent
authored andcommitted
feat(ui): add Channels dialog listing channel-capable MCP servers
Adds a filterable dialog (via the command palette) showing every MCP server that declared the claude/channel capability, its connection state, and whether it is enabled for this session. Assisted-by: Claude Fable 5
1 parent 5efd663 commit 4564542

4 files changed

Lines changed: 460 additions & 0 deletions

File tree

internal/ui/dialog/channels.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
package dialog
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
7+
"charm.land/bubbles/v2/help"
8+
"charm.land/bubbles/v2/key"
9+
"charm.land/bubbles/v2/textinput"
10+
tea "charm.land/bubbletea/v2"
11+
"charm.land/lipgloss/v2"
12+
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
13+
"github.com/charmbracelet/crush/internal/ui/common"
14+
"github.com/charmbracelet/crush/internal/ui/list"
15+
"github.com/charmbracelet/crush/internal/ui/styles"
16+
"github.com/charmbracelet/crush/internal/workspace"
17+
uv "github.com/charmbracelet/ultraviolet"
18+
"github.com/sahilm/fuzzy"
19+
)
20+
21+
// ChannelsID is the identifier for the channels dialog.
22+
const ChannelsID = "channels"
23+
24+
// ChannelItem wraps a channel server's ClientInfo as a filterable list item.
25+
type ChannelItem struct {
26+
*list.Versioned
27+
info mcp.ClientInfo
28+
t *styles.Styles
29+
m fuzzy.Match
30+
cache map[int]string
31+
focused bool
32+
}
33+
34+
var _ ListItem = &ChannelItem{Versioned: list.NewVersioned()}
35+
36+
// NewChannelItem creates a new ChannelItem.
37+
func NewChannelItem(t *styles.Styles, info mcp.ClientInfo) *ChannelItem {
38+
return &ChannelItem{
39+
Versioned: list.NewVersioned(),
40+
t: t,
41+
info: info,
42+
}
43+
}
44+
45+
// Finished implements list.Item.
46+
func (c *ChannelItem) Finished() bool { return true }
47+
48+
// Filter implements ListItem.
49+
func (c *ChannelItem) Filter() string {
50+
return c.info.Name
51+
}
52+
53+
// ID implements ListItem.
54+
func (c *ChannelItem) ID() string {
55+
return c.info.Name
56+
}
57+
58+
// SetFocused implements ListItem.
59+
func (c *ChannelItem) SetFocused(focused bool) {
60+
if c.focused == focused {
61+
return
62+
}
63+
c.cache = nil
64+
c.focused = focused
65+
if c.Versioned != nil {
66+
c.Bump()
67+
}
68+
}
69+
70+
// SetMatch implements ListItem.
71+
func (c *ChannelItem) SetMatch(match fuzzy.Match) {
72+
if sameFuzzyMatch(c.m, match) {
73+
return
74+
}
75+
c.cache = nil
76+
c.m = match
77+
if c.Versioned != nil {
78+
c.Bump()
79+
}
80+
}
81+
82+
// Render implements ListItem.
83+
func (c *ChannelItem) Render(width int) string {
84+
itemStyles := ListItemStyles{
85+
ItemBlurred: c.t.Dialog.NormalItem,
86+
ItemFocused: c.t.Dialog.SelectedItem,
87+
InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
88+
InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
89+
}
90+
91+
info := fmt.Sprintf("%s %dt", c.info.State.String(), c.info.Counts.Tools)
92+
93+
return renderItem(itemStyles, c.info.Name, info, c.focused, width, c.cache, &c.m)
94+
}
95+
96+
// Channels is a dialog that lists channel-capable MCP servers and their
97+
// connection state.
98+
type Channels struct {
99+
com *common.Common
100+
help help.Model
101+
list *list.FilterableList
102+
input textinput.Model
103+
ws workspace.Workspace
104+
keyMap struct {
105+
Next,
106+
Previous,
107+
UpDown,
108+
Close key.Binding
109+
}
110+
}
111+
112+
var _ Dialog = (*Channels)(nil)
113+
114+
// NewChannels creates a new channels dialog.
115+
func NewChannels(com *common.Common, ws workspace.Workspace) *Channels {
116+
d := &Channels{
117+
com: com,
118+
ws: ws,
119+
}
120+
121+
help := help.New()
122+
help.Styles = com.Styles.DialogHelpStyles()
123+
d.help = help
124+
125+
d.list = list.NewFilterableList(d.channelItems()...)
126+
d.list.Focus()
127+
d.list.SetSelected(0)
128+
129+
d.input = textinput.New()
130+
d.input.SetVirtualCursor(false)
131+
d.input.Placeholder = "Type to filter"
132+
d.input.SetStyles(com.Styles.TextInput)
133+
d.input.Focus()
134+
135+
d.keyMap.UpDown = key.NewBinding(
136+
key.WithKeys("up", "down"),
137+
key.WithHelp("↑/↓", "choose"),
138+
)
139+
d.keyMap.Next = key.NewBinding(
140+
key.WithKeys("down"),
141+
key.WithHelp("↓", "next"),
142+
)
143+
d.keyMap.Previous = key.NewBinding(
144+
key.WithKeys("up"),
145+
key.WithHelp("↑", "previous"),
146+
)
147+
closeKey := CloseKey
148+
closeKey.SetHelp("esc", "close")
149+
d.keyMap.Close = closeKey
150+
151+
return d
152+
}
153+
154+
// channelItems builds the list items from current MCP server states, filtered
155+
// to only show channel-capable servers, sorted by name.
156+
func (d *Channels) channelItems() []list.FilterableItem {
157+
states := d.ws.MCPGetStates()
158+
names := make([]string, 0, len(states))
159+
for name, info := range states {
160+
if info.Channel {
161+
names = append(names, name)
162+
}
163+
}
164+
sort.Strings(names)
165+
items := make([]list.FilterableItem, 0, len(names))
166+
for _, name := range names {
167+
info := states[name]
168+
info.Name = name
169+
items = append(items, NewChannelItem(d.com.Styles, info))
170+
}
171+
return items
172+
}
173+
174+
// ID implements Dialog.
175+
func (d *Channels) ID() string { return ChannelsID }
176+
177+
// HandleMsg implements Dialog.
178+
func (d *Channels) HandleMsg(msg tea.Msg) Action {
179+
switch msg := msg.(type) {
180+
case tea.KeyPressMsg:
181+
switch {
182+
case key.Matches(msg, d.keyMap.Close):
183+
return ActionClose{}
184+
case key.Matches(msg, d.keyMap.Previous):
185+
d.list.Focus()
186+
if d.list.IsSelectedFirst() {
187+
d.list.SelectLast()
188+
} else {
189+
d.list.SelectPrev()
190+
}
191+
d.list.ScrollToSelected()
192+
case key.Matches(msg, d.keyMap.Next):
193+
d.list.Focus()
194+
if d.list.IsSelectedLast() {
195+
d.list.SelectFirst()
196+
} else {
197+
d.list.SelectNext()
198+
}
199+
d.list.ScrollToSelected()
200+
case msg.Code == tea.KeyEnter:
201+
// Channels have no per-item action yet; swallow Enter so it does
202+
// not fall through into the filter input.
203+
return nil
204+
default:
205+
var cmd tea.Cmd
206+
d.input, cmd = d.input.Update(msg)
207+
value := d.input.Value()
208+
d.list.SetFilter(value)
209+
d.list.ScrollToTop()
210+
d.list.SetSelected(0)
211+
return ActionCmd{cmd}
212+
}
213+
}
214+
return nil
215+
}
216+
217+
// selectedChannel returns the currently selected ChannelItem, or nil.
218+
func (d *Channels) selectedChannel() *ChannelItem {
219+
item := d.list.SelectedItem()
220+
if item == nil {
221+
return nil
222+
}
223+
if ci, ok := item.(*ChannelItem); ok {
224+
return ci
225+
}
226+
return nil
227+
}
228+
229+
// Cursor returns the cursor position relative to the dialog.
230+
func (d *Channels) Cursor() *tea.Cursor {
231+
return InputCursor(d.com.Styles, d.input.Cursor())
232+
}
233+
234+
// Draw implements Dialog.
235+
func (d *Channels) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
236+
t := d.com.Styles
237+
width := max(0, min(defaultDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
238+
height := max(0, min(defaultDialogHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
239+
innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
240+
heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
241+
t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
242+
t.Dialog.HelpView.GetVerticalFrameSize() +
243+
t.Dialog.View.GetVerticalFrameSize()
244+
245+
d.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1))
246+
247+
listHeight := height - heightOffset
248+
listWidth := max(0, innerWidth-3)
249+
d.list.SetSize(listWidth, listHeight)
250+
d.help.SetWidth(innerWidth)
251+
252+
rc := NewRenderContext(t, width)
253+
rc.Title = "Channels"
254+
inputView := t.Dialog.InputPrompt.Render(d.input.View())
255+
rc.AddPart(inputView)
256+
listView := t.Dialog.List.Height(d.list.Height()).Render(d.list.Render())
257+
scrollbar := common.Scrollbar(t, listHeight, d.list.TotalHeight(), listHeight, d.list.Offset())
258+
if scrollbar != "" {
259+
listView = lipgloss.JoinHorizontal(lipgloss.Top, listView, scrollbar)
260+
}
261+
rc.AddPart(listView)
262+
rc.Help = d.help.View(d)
263+
264+
view := rc.Render()
265+
cur := d.Cursor()
266+
DrawCenterCursor(scr, area, view, cur)
267+
return cur
268+
}
269+
270+
// ShortHelp implements help.KeyMap.
271+
func (d *Channels) ShortHelp() []key.Binding {
272+
return []key.Binding{
273+
d.keyMap.UpDown,
274+
d.keyMap.Close,
275+
}
276+
}
277+
278+
// FullHelp implements help.KeyMap.
279+
func (d *Channels) FullHelp() [][]key.Binding {
280+
return [][]key.Binding{
281+
{d.keyMap.UpDown, d.keyMap.Close},
282+
}
283+
}

0 commit comments

Comments
 (0)