Files
AirlockTools/TUI/multiagentselector.py
T

208 lines
7.6 KiB
Python

import difflib
import re
from typing import List, Optional
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.widget import Widget
from textual.widgets import (
Button,
Footer,
Header,
SelectionList,
Static,
Switch,
TextArea,
)
from models.agent import Agent
class MultiAgentSelector(Widget):
class AgentsSelected(Message):
def __init__(self, selected_agents: List[Agent]):
super().__init__()
self.selected_agents = selected_agents
def __init__(self, all_agents: Optional[List[Agent]]):
super().__init__()
self.all_agents = all_agents
self._match_type = "exact"
@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 Header(show_clock=True, icon="")
title_text = Static("🖧 Agent Selector", id="selector_title")
title_text.styles.margin = (0, 0, 0, 1)
yield title_text
with Horizontal() as main_layout:
main_layout.styles.height = "auto"
# Left side - Input and controls
with Vertical() as left_pane:
left_pane.styles.width = "1fr"
left_pane.styles.height = "auto"
text_area = TextArea(
id="device_input",
placeholder="Paste device names here (one per line). Supports wildcards: * and ?",
)
text_area.styles.height = 10
text_area.styles.overflow_y = "auto"
yield text_area
with Horizontal(id="switch_search_container") as switch_search:
switch = Switch(value=False, id="match_switch")
switch.styles.width = "auto"
switch.styles.margin = (1, 0, 0, 0)
switch.styles.padding = (0, 0, 0, 0)
yield switch
switch_label = Static("Match: Exact", id="match_switch_label")
switch_label.styles.width = "auto"
switch_label.styles.margin = (2, 1, 0, 0)
yield switch_label
search = Button("🔍 Search", id="search_button")
search.styles.margin = (1, 0, 0, 0)
yield search
with Horizontal() as select_buttons:
select_buttons.styles.margin = (0, 0, 0, 0)
select_none_button = Button("🚫 Select None", id="select_none")
select_none_button.styles.margin = (1, 1, 0, 1)
yield select_none_button
select_all_button = Button("✅ Select All", id="select_all")
select_all_button.styles.margin = (1, 0, 0, 1)
yield select_all_button
with Horizontal() as button_row:
button_row.styles.height = "auto"
button_row.styles.margin = (1, 0, 0, 0)
back_button = Button("← Back", id="back_button")
back_button.styles.width = "1fr"
back_button.styles.margin = (0, 0, 0, 1)
yield back_button
submit_button = Button(
"▶ Select & Continue", id="submit_selection", variant="primary"
)
submit_button.styles.margin = (0, 5, 2, 1)
submit_button.styles.padding = (0, 6, 0, 0)
yield submit_button
# Right side - Results
with Vertical() as right_pane:
right_pane.styles.width = "2fr"
yield SelectionList(id="match_results")
yield Static(id="unmatched_label")
yield Footer()
def on_switch_changed(self, event: Switch.Changed):
self.match_type = "fuzzy" if event.value else "exact"
self.query_one("#match_switch_label", Static).update(
f"Match: {self.match_type.capitalize()}"
)
def on_button_pressed(self, event: Button.Pressed):
btn_id = event.button.id
try:
match_list = self.query_one("#match_results", SelectionList)
except NoMatches:
return
if btn_id == "back_button":
self.app.pop_screen()
event.stop()
elif 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":
# Get selected hostnames
selected_hostnames = list(match_list.selected)
# Convert back to Agent objects
selected_agents = [
agent
for agent in self.all_agents
if agent.hostname in selected_hostnames
]
self.post_message(self.AgentsSelected(selected_agents))
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:
# Check if the name contains wildcards
has_wildcards = "*" in name or "?" in name
if has_wildcards:
# Use regex for wildcard matching
pattern = re.escape(name)
pattern = pattern.replace(r"\*", ".*").replace(r"\?", ".")
regex = re.compile(f"^{pattern}$", re.IGNORECASE)
wildcard_matches = [
agent_name for agent_name in agent_names if regex.match(agent_name)
]
if wildcard_matches:
matched.update(wildcard_matches)
else:
unmatched.append(name)
elif 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