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