diff --git a/strix/interface/assets/tui_styles.tcss b/strix/interface/assets/tui_styles.tcss index f54589e..b200c19 100644 --- a/strix/interface/assets/tui_styles.tcss +++ b/strix/interface/assets/tui_styles.tcss @@ -1,13 +1,14 @@ Screen { - background: #1a1a1a; + background: #000000; color: #d4d4d4; } #splash_screen { height: 100%; width: 100%; - background: #1a1a1a; + background: #000000; color: #22c55e; + align: center middle; content-align: center middle; text-align: center; } @@ -17,6 +18,7 @@ Screen { height: auto; background: transparent; text-align: center; + content-align: center middle; padding: 2; } @@ -24,7 +26,7 @@ Screen { height: 100%; padding: 0; margin: 0; - background: #1a1a1a; + background: #000000; } #content_container { @@ -39,10 +41,14 @@ Screen { margin-left: 1; } +#sidebar.-hidden { + display: none; +} + #agents_tree { height: 1fr; background: transparent; - border: round #262626; + border: round #1a1a1a; border-title-color: #a8a29e; border-title-style: bold; padding: 1; @@ -62,16 +68,20 @@ Screen { background: transparent; } +#chat_area_container.-full-width { + width: 100%; +} + #chat_history { height: 1fr; background: transparent; - border: round #1a1a1a; + border: round #0a0a0a; padding: 0; margin-bottom: 0; margin-right: 0; - scrollbar-background: #0f0f0f; - scrollbar-color: #262626; - scrollbar-corner-color: #0f0f0f; + scrollbar-background: #000000; + scrollbar-color: #1a1a1a; + scrollbar-corner-color: #000000; scrollbar-size: 1 1; } @@ -113,7 +123,7 @@ Screen { #chat_input_container { height: 3; background: transparent; - border: round #525252; + border: round #333333; margin-right: 0; padding: 0; layout: horizontal; @@ -210,23 +220,20 @@ Screen { margin-top: 0 !important; margin-bottom: 0 !important; padding: 0 1; - background: #0a0a0a; - border: round #1a1a1a; - border-left: thick #f59e0b; + background: transparent; + border: none; width: 100%; } .tool-call.status-completed { - border-left: thick #22c55e; - background: #0d1f12; + background: transparent; margin: 0 !important; margin-top: 0 !important; margin-bottom: 0 !important; } .tool-call.status-running { - border-left: thick #f59e0b; - background: #1f1611; + background: transparent; margin: 0 !important; margin-top: 0 !important; margin-bottom: 0 !important; @@ -234,8 +241,7 @@ Screen { .tool-call.status-failed, .tool-call.status-error { - border-left: thick #ef4444; - background: #1f0d0d; + background: transparent; margin: 0 !important; margin-top: 0 !important; margin-bottom: 0 !important; @@ -257,202 +263,39 @@ Screen { margin: 0 !important; margin-top: 0 !important; margin-bottom: 0 !important; -} - -.browser-tool { - border-left: thick #06b6d4; -} - -.browser-tool.status-completed { - border-left: thick #06b6d4; - background: transparent; - margin: 0 !important; - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.browser-tool.status-running { - border-left: thick #0891b2; - background: transparent; - margin: 0 !important; - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.terminal-tool { - border-left: thick #22c55e; -} - -.terminal-tool.status-completed { - border-left: thick #22c55e; - background: transparent; -} - -.terminal-tool.status-running { - border-left: thick #16a34a; - background: transparent; -} - -.python-tool { - border-left: thick #3b82f6; -} - -.python-tool.status-completed { - border-left: thick #3b82f6; - background: transparent; -} - -.python-tool.status-running { - border-left: thick #2563eb; - background: transparent; -} - -.agents-graph-tool { - border-left: thick #fbbf24; -} - -.agents-graph-tool.status-completed { - border-left: thick #fbbf24; - background: transparent; -} - -.agents-graph-tool.status-running { - border-left: thick #f59e0b; - background: transparent; -} - -.file-edit-tool { - border-left: thick #10b981; -} - -.file-edit-tool.status-completed { - border-left: thick #10b981; - background: transparent; -} - -.file-edit-tool.status-running { - border-left: thick #059669; - background: transparent; -} - -.proxy-tool { - border-left: thick #06b6d4; -} - -.proxy-tool.status-completed { - border-left: thick #06b6d4; - background: transparent; -} - -.proxy-tool.status-running { - border-left: thick #0891b2; - background: transparent; -} - -.notes-tool { - border-left: thick #fbbf24; -} - -.notes-tool.status-completed { - border-left: thick #fbbf24; - background: transparent; -} - -.notes-tool.status-running { - border-left: thick #f59e0b; - background: transparent; -} - -.thinking-tool { - border-left: thick #a855f7; -} - -.thinking-tool.status-completed { - border-left: thick #a855f7; - background: transparent; -} - -.thinking-tool.status-running { - border-left: thick #9333ea; - background: transparent; -} - -.web-search-tool { - border-left: thick #22c55e; -} - -.web-search-tool.status-completed { - border-left: thick #22c55e; - background: transparent; -} - -.web-search-tool.status-running { - border-left: thick #16a34a; - background: transparent; -} - -.finish-tool { - border-left: thick #dc2626; -} - -.finish-tool.status-completed { - border-left: thick #dc2626; - background: transparent; -} - -.finish-tool.status-running { - border-left: thick #b91c1c; - background: transparent; -} - -.reporting-tool { - border-left: thick #ea580c; -} - -.reporting-tool.status-completed { - border-left: thick #ea580c; - background: transparent; -} - -.reporting-tool.status-running { - border-left: thick #c2410c; - background: transparent; -} - -.scan-info-tool { - border-left: thick #22c55e; - background: transparent; - margin: 0 !important; - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.scan-info-tool.status-completed { - border-left: thick #22c55e; - background: transparent; -} - -.scan-info-tool.status-running { - border-left: thick #16a34a; - background: transparent; -} - -.subagent-info-tool { - border-left: thick #22c55e; - background: transparent; - margin: 0 !important; - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.subagent-info-tool.status-completed { - border-left: thick #22c55e; background: transparent; } +.browser-tool.status-completed, +.browser-tool.status-running, +.terminal-tool.status-completed, +.terminal-tool.status-running, +.python-tool.status-completed, +.python-tool.status-running, +.agents-graph-tool.status-completed, +.agents-graph-tool.status-running, +.file-edit-tool.status-completed, +.file-edit-tool.status-running, +.proxy-tool.status-completed, +.proxy-tool.status-running, +.notes-tool.status-completed, +.notes-tool.status-running, +.thinking-tool.status-completed, +.thinking-tool.status-running, +.web-search-tool.status-completed, +.web-search-tool.status-running, +.finish-tool.status-completed, +.finish-tool.status-running, +.reporting-tool.status-completed, +.reporting-tool.status-running, +.scan-info-tool.status-completed, +.scan-info-tool.status-running, +.subagent-info-tool.status-completed, .subagent-info-tool.status-running { - border-left: thick #16a34a; background: transparent; + margin: 0 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; } Tree { @@ -470,7 +313,7 @@ Tree > .tree--label { background: transparent; padding: 0 1; margin-bottom: 1; - border-bottom: solid #262626; + border-bottom: solid #1a1a1a; text-align: center; } @@ -510,7 +353,7 @@ Tree > .tree--label { } Tree:focus { - border: round #262626; + border: round #1a1a1a; } Tree:focus > .tree--label { @@ -554,7 +397,7 @@ StopAgentScreen { width: 30; height: auto; border: round #a3a3a3; - background: #1a1a1a 98%; + background: #000000 98%; } #stop_agent_title { @@ -616,8 +459,8 @@ QuitScreen { padding: 1; width: 24; height: auto; - border: round #525252; - background: #1a1a1a 98%; + border: round #333333; + background: #000000 98%; } #quit_title { @@ -680,7 +523,7 @@ HelpScreen { width: 40; height: auto; border: round #22c55e; - background: #1a1a1a 98%; + background: #000000 98%; } #help_title { diff --git a/strix/interface/tui.py b/strix/interface/tui.py index ffe24ca..7cc5629 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -121,7 +121,7 @@ class SplashScreen(Static): # type: ignore[misc] yield panel_static def on_mount(self) -> None: - self._animation_timer = self.set_interval(0.45, self._animate_start_line) + self._animation_timer = self.set_interval(0.05, self._animate_start_line) def on_unmount(self) -> None: if self._animation_timer is not None: @@ -146,10 +146,15 @@ class SplashScreen(Static): # type: ignore[misc] Align.center(self._build_tagline_text()), Align.center(Text(" ")), Align.center(start_line.copy()), + Align.center(Text(" ")), + Align.center(self._build_url_text()), ) return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6)) + def _build_url_text(self) -> Text: + return Text("strix.ai", style=Style(color=self.PRIMARY_GREEN, bold=True)) + def _build_welcome_text(self) -> Text: text = Text("Welcome to ", style=Style(color="white", bold=True)) text.append("Strix", style=Style(color=self.PRIMARY_GREEN, bold=True)) @@ -163,13 +168,25 @@ class SplashScreen(Static): # type: ignore[misc] return Text("Open-source AI hackers for your apps", style=Style(color="white", dim=True)) def _build_start_line_text(self, phase: int) -> Text: - emphasize = phase % 2 == 1 - base_style = Style(color="white", dim=not emphasize, bold=emphasize) - strix_style = Style(color=self.PRIMARY_GREEN, bold=bool(emphasize)) + full_text = "Starting Strix Agent" + text_len = len(full_text) - text = Text("Starting ", style=base_style) - text.append("Strix", style=strix_style) - text.append(" Cybersecurity Agent", style=base_style) + shine_pos = phase % (text_len + 8) + + text = Text() + for i, char in enumerate(full_text): + dist = abs(i - shine_pos) + + if dist <= 1: + style = Style(color="bright_white", bold=True) + elif dist <= 3: + style = Style(color="white", bold=True) + elif dist <= 5: + style = Style(color="#a3a3a3") + else: + style = Style(color="#525252") + + text.append(char, style=style) return text @@ -286,6 +303,8 @@ class QuitScreen(ModalScreen): # type: ignore[misc] class StrixTUIApp(App): # type: ignore[misc] CSS_PATH = "assets/tui_styles.tcss" + SIDEBAR_MIN_WIDTH = 100 + selected_agent_id: reactive[str | None] = reactive(default=None) show_splash: reactive[bool] = reactive(default=True) @@ -319,15 +338,20 @@ class StrixTUIApp(App): # type: ignore[misc] "Generating", "Scanning", "Analyzing", - "Probing", "Hacking", "Testing", "Exploiting", - "Investigating", + "Pwning", + "Loading", + "Running", + "Working", + "Strixing", + "Thinking", + "Reasoning", ] self._agent_verbs: dict[str, str] = {} # agent_id -> current_verb self._agent_verb_timers: dict[str, Any] = {} # agent_id -> timer - self._agent_dot_states: dict[str, int] = {} # agent_id -> dot_count (0-3) + self._agent_dot_states: dict[str, float] = {} # agent_id -> shine position self._dot_animation_timer: Any | None = None self._setup_cleanup_handlers() @@ -620,6 +644,45 @@ class StrixTUIApp(App): # type: ignore[misc] return "\n\n".join(content_lines) + def _get_status_display_content( + self, agent_id: str, agent_data: dict[str, Any] + ) -> tuple[str | Text, str, bool]: + status = agent_data.get("status", "running") + + simple_statuses = { + "stopping": ("Agent stopping...", "", False), + "stopped": ("Agent stopped", "", False), + "completed": ("Agent completed", "", False), + } + + if status in simple_statuses: + return simple_statuses[status] + + if status == "llm_failed": + error_msg = agent_data.get("error_message", "") + display_msg = ( + f"[red]{escape_markup(error_msg)}[/red]" + if error_msg + else "[red]LLM request failed[/red]" + ) + self._stop_dot_animation() + return (display_msg, "[dim]Send message to retry[/dim]", False) + + if status == "waiting": + animated_text = self._get_animated_waiting_text(agent_id) + return (animated_text, "[dim]Send message to resume[/dim]", True) + + if status == "running": + verb = ( + self._get_agent_verb(agent_id) + if self._agent_has_real_activity(agent_id) + else "Initializing Agent" + ) + animated_text = self._get_animated_verb_text(agent_id, verb) + return (animated_text, "[dim]ESC to stop | CTRL-C to quit and save[/dim]", True) + + return ("", "", False) + def _update_agent_status_display(self) -> None: try: status_display = self.query_one("#agent_status_display", Horizontal) @@ -638,52 +701,20 @@ class StrixTUIApp(App): # type: ignore[misc] try: agent_data = self.tracer.agents[self.selected_agent_id] - status = agent_data.get("status", "running") + content, keymap, should_animate = self._get_status_display_content( + self.selected_agent_id, agent_data + ) - if status == "stopping": - self._safe_widget_operation(status_text.update, "Agent stopping...") - self._safe_widget_operation(keymap_indicator.update, "") - self._safe_widget_operation(status_display.remove_class, "hidden") - elif status == "stopped": - self._safe_widget_operation(status_text.update, "Agent stopped") - self._safe_widget_operation(keymap_indicator.update, "") - self._safe_widget_operation(status_display.remove_class, "hidden") - elif status == "completed": - self._safe_widget_operation(status_text.update, "Agent completed") - self._safe_widget_operation(keymap_indicator.update, "") - self._safe_widget_operation(status_display.remove_class, "hidden") - elif status == "llm_failed": - error_msg = agent_data.get("error_message", "") - display_msg = ( - f"[red]{escape_markup(error_msg)}[/red]" - if error_msg - else "[red]LLM request failed[/red]" - ) - self._safe_widget_operation(status_text.update, display_msg) - self._safe_widget_operation( - keymap_indicator.update, "[dim]Send message to retry[/dim]" - ) - self._safe_widget_operation(status_display.remove_class, "hidden") - self._stop_dot_animation() - elif status == "waiting": - animated_text = self._get_animated_waiting_text(self.selected_agent_id) - self._safe_widget_operation(status_text.update, animated_text) - self._safe_widget_operation( - keymap_indicator.update, "[dim]Send message to resume[/dim]" - ) - self._safe_widget_operation(status_display.remove_class, "hidden") - self._start_dot_animation() - elif status == "running": - current_verb = self._get_agent_verb(self.selected_agent_id) - animated_text = self._get_animated_verb_text(self.selected_agent_id, current_verb) - self._safe_widget_operation(status_text.update, animated_text) - self._safe_widget_operation( - keymap_indicator.update, "[dim]ESC to stop | CTRL-C to quit and save[/dim]" - ) - self._safe_widget_operation(status_display.remove_class, "hidden") - self._start_dot_animation() - else: + if not content: self._safe_widget_operation(status_display.add_class, "hidden") + return + + self._safe_widget_operation(status_text.update, content) + self._safe_widget_operation(keymap_indicator.update, keymap) + self._safe_widget_operation(status_display.remove_class, "hidden") + + if should_animate: + self._start_dot_animation() except (KeyError, Exception): self._safe_widget_operation(status_display.add_class, "hidden") @@ -707,8 +738,6 @@ class StrixTUIApp(App): # type: ignore[misc] stats_panel = Panel( stats_content, - title="📊 Live Stats", - title_align="left", border_style="#22c55e", padding=(0, 1), ) @@ -743,26 +772,43 @@ class StrixTUIApp(App): # type: ignore[misc] if self.selected_agent_id == agent_id: self._update_agent_status_display() - def _get_animated_verb_text(self, agent_id: str, verb: str) -> str: + def _get_shine_style(self, dist: float) -> Style: + if dist <= 0.5: + return Style(color="bright_white", bold=True) + if dist <= 1.5: + return Style(color="white", bold=True) + if dist <= 2.5: + return Style(color="#a3a3a3") + return Style(color="#525252") + + def _get_animated_verb_text(self, agent_id: str, verb: str) -> Text: if agent_id not in self._agent_dot_states: - self._agent_dot_states[agent_id] = 0 + self._agent_dot_states[agent_id] = 0.0 - dot_count = self._agent_dot_states[agent_id] - dots = "." * dot_count - return f"{verb}{dots}" + shine_pos = self._agent_dot_states[agent_id] + text = Text() + for i, char in enumerate(verb): + dist = abs(i - shine_pos) + text.append(char, style=self._get_shine_style(dist)) - def _get_animated_waiting_text(self, agent_id: str) -> str: + return text + + def _get_animated_waiting_text(self, agent_id: str) -> Text: if agent_id not in self._agent_dot_states: - self._agent_dot_states[agent_id] = 0 + self._agent_dot_states[agent_id] = 0.0 - dot_count = self._agent_dot_states[agent_id] - dots = "." * dot_count + shine_pos = self._agent_dot_states[agent_id] + word = "Waiting" + text = Text() + for i, char in enumerate(word): + dist = abs(i - shine_pos) + text.append(char, style=self._get_shine_style(dist)) - return f"Waiting{dots}" + return text def _start_dot_animation(self) -> None: if self._dot_animation_timer is None: - self._dot_animation_timer = self.set_interval(0.6, self._animate_dots) + self._dot_animation_timer = self.set_interval(0.008, self._animate_dots) def _stop_dot_animation(self) -> None: if self._dot_animation_timer is not None: @@ -776,8 +822,15 @@ class StrixTUIApp(App): # type: ignore[misc] status = agent_data.get("status", "running") if status in ["running", "waiting"]: has_active_agents = True - current_dots = self._agent_dot_states.get(agent_id, 0) - self._agent_dot_states[agent_id] = (current_dots + 1) % 4 + if status == "waiting": + verb = "Waiting" + elif self._agent_has_real_activity(agent_id): + verb = self._get_agent_verb(agent_id) + else: + verb = "Initializing Agent" + text_len = len(verb) + current_shine = self._agent_dot_states.get(agent_id, 0.0) + self._agent_dot_states[agent_id] = (current_shine + 0.12) % (text_len + 3) if ( has_active_agents @@ -796,6 +849,16 @@ class StrixTUIApp(App): # type: ignore[misc] ) not in ["running", "waiting"]: del self._agent_dot_states[agent_id] + def _agent_has_real_activity(self, agent_id: str) -> bool: + initial_tools = {"scan_start_info", "subagent_start_info"} + + for _exec_id, tool_data in list(self.tracer.tool_executions.items()): + if tool_data.get("agent_id") == agent_id: + tool_name = tool_data.get("tool_name", "") + if tool_name not in initial_tools: + return True + return False + def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]: chat_events = [ { @@ -1030,25 +1093,6 @@ class StrixTUIApp(App): # type: ignore[misc] status = tool_data.get("status", "unknown") result = tool_data.get("result") - tool_colors = { - "terminal_execute": "#22c55e", - "browser_action": "#06b6d4", - "python_action": "#3b82f6", - "agents_graph_action": "#fbbf24", - "file_edit_action": "#10b981", - "proxy_action": "#06b6d4", - "notes_action": "#fbbf24", - "thinking_action": "#a855f7", - "web_search_action": "#22c55e", - "finish_action": "#dc2626", - "reporting_action": "#ea580c", - "scan_start_info": "#22c55e", - "subagent_start_info": "#22c55e", - "llm_error_details": "#dc2626", - } - - color = tool_colors.get(tool_name, "#737373") - from strix.interface.tool_components.registry import get_tool_renderer renderer = get_tool_renderer(tool_name) @@ -1090,9 +1134,7 @@ class StrixTUIApp(App): # type: ignore[misc] content = "\n".join(lines) - lines = content.split("\n") - bordered_lines = [f"[{color}]▍[/{color}] {line}" for line in lines] - return "\n".join(bordered_lines) + return content @on(Tree.NodeHighlighted) # type: ignore[misc] def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None: @@ -1293,6 +1335,23 @@ class StrixTUIApp(App): # type: ignore[misc] plain_text = re.sub(r"\[.*?\]", "", content) widget.update(plain_text) + def on_resize(self, event: events.Resize) -> None: + if self.show_splash or not self.is_mounted: + return + + try: + sidebar = self.query_one("#sidebar", Vertical) + chat_area = self.query_one("#chat_area_container", Vertical) + except (ValueError, Exception): + return + + if event.size.width < self.SIDEBAR_MIN_WIDTH: + sidebar.add_class("-hidden") + chat_area.add_class("-full-width") + else: + sidebar.remove_class("-hidden") + chat_area.remove_class("-full-width") + async def run_tui(args: argparse.Namespace) -> None: """Run strix in interactive TUI mode with textual.""" diff --git a/strix/interface/utils.py b/strix/interface/utils.py index 78e4023..efc1fc9 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -134,6 +134,12 @@ def build_live_stats_text(tracer: Any, agent_config: dict[str, Any] | None = Non if not tracer: return stats_text + if agent_config: + llm_config = agent_config["llm_config"] + model = getattr(llm_config, "model_name", "Unknown") + stats_text.append(f"🧠 Model: {model}") + stats_text.append("\n") + vuln_count = len(tracer.vulnerability_reports) tool_count = tracer.get_real_tool_count() agent_count = len(tracer.agents) @@ -165,12 +171,6 @@ def build_live_stats_text(tracer: Any, agent_config: dict[str, Any] | None = Non stats_text.append("\n") - if agent_config: - llm_config = agent_config["llm_config"] - model = getattr(llm_config, "model_name", "Unknown") - stats_text.append(f"🧠 Model: {model}") - stats_text.append("\n") - stats_text.append("🤖 Agents: ", style="bold white") stats_text.append(str(agent_count), style="dim white") stats_text.append(" • ", style="dim white")