import difflib from textual.containers import Horizontal from textual.css.query import NoMatches from textual.message import Message from textual.widget import Widget from textual.widgets import Button, SelectionList, Static, Switch, TextArea class MultiAgentSelector(Widget): class AgentsSelected(Message): def __init__(self, selected_agents): super().__init__() self.selected_agents = selected_agents def __init__(self, all_agents: list[dict]): super().__init__() self.all_agents = all_agents self._match_type = "fuzzy" @property def match_type(self): return self._match_type @match_type.setter def match_type(self, value): self._match_type = value def compose(self): yield Static("🔍 Multi-Agent Selector", id="selector_title") text_area = TextArea( id="device_input", placeholder="Paste device names here (one per line)", ) text_area.styles.height = 10 text_area.styles.overflow_y = "auto" yield text_area with Horizontal() as switch_container: switch_container.styles.height = "auto" switch_container.styles.align = ("left", "middle") switch_label = Static("Match Type: Fuzzy", id="match_switch_label") switch_label.styles.width = "auto" switch_label.styles.padding = (0, 1) yield switch_label switch = Switch(value=False, id="match_switch") switch.styles.width = "auto" yield switch yield Button("Search", id="search_button") yield SelectionList(id="match_results") yield Static(id="unmatched_label") yield Horizontal( Button("Select All", id="select_all"), Button("Select None", id="select_none"), ) yield Button("Continue with Selected", id="submit_selection", variant="primary") def on_switch_changed(self, event: Switch.Changed): self.match_type = "exact" if event.value else "fuzzy" self.query_one("#match_switch_label", Static).update( f"Match Type: {self.match_type.capitalize()}" ) def on_button_pressed(self, event: Button.Pressed): btn_id = event.button.id # can be None for internal buttons # Only query when needed try: match_list = self.query_one("#match_results", SelectionList) except NoMatches: # UI not mounted yet or id changed—just ignore gracefully return if btn_id == "select_all": match_list.select_all() event.stop() elif btn_id == "select_none": match_list.deselect_all() event.stop() elif btn_id == "submit_selection": selected = list(match_list.selected) self.post_message(self.AgentsSelected(selected)) event.stop() elif btn_id == "search_button": self.update_matches() event.stop() def update_matches(self): raw_input = self.query_one("#device_input", TextArea).text.strip() device_names = [line.strip() for line in raw_input.split("\n") if line.strip()] matched, unmatched = self.match_devices(device_names) match_list = self.query_one("#match_results", SelectionList) match_list.clear_options() for name in matched: match_list.add_option((name, name)) unmatched_label = self.query_one("#unmatched_label", Static) if unmatched: unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}") else: unmatched_label.update("") def match_devices(self, device_names: list[str]) -> tuple[list[str], list[str]]: if not self.all_agents or not device_names: return [], device_names agent_names = [agent["hostname"] for agent in self.all_agents] matched = set() unmatched = [] for name in device_names: if self.match_type == "exact": # Case-insensitive exact match name_lower = name.lower() exact_match = None for agent_name in agent_names: if agent_name.lower() == name_lower: exact_match = agent_name break if exact_match: matched.add(exact_match) else: unmatched.append(name) else: # Fuzzy match matches = difflib.get_close_matches(name, agent_names, n=5, cutoff=0.5) if matches: matched.update(matches) else: unmatched.append(name) return sorted(matched), unmatched