From e0b750dbcd31d512f2eb19924538894e2cd5f9a3 Mon Sep 17 00:00:00 2001 From: 0xallam Date: Sat, 7 Feb 2026 10:26:14 -0800 Subject: [PATCH] feat: Add mouse text selection auto-copy to clipboard in TUI Enable native text selection across tool components and agent messages with automatic clipboard copy, toast notification, and decorative icon stripping. Replace Padding wrappers with Text to support selection across multiple renderables. --- strix/agents/state.py | 4 +- strix/interface/assets/tui_styles.tcss | 22 +++ .../tool_components/finish_renderer.py | 7 +- .../tool_components/reporting_renderer.py | 7 +- strix/interface/tui.py | 132 +++++++++++++++++- 5 files changed, 160 insertions(+), 12 deletions(-) diff --git a/strix/agents/state.py b/strix/agents/state.py index 1f32d61..6af402e 100644 --- a/strix/agents/state.py +++ b/strix/agents/state.py @@ -43,7 +43,9 @@ class AgentState(BaseModel): self.iteration += 1 self.last_updated = datetime.now(UTC).isoformat() - def add_message(self, role: str, content: Any, thinking_blocks: list[dict[str, Any]] | None = None) -> None: + def add_message( + self, role: str, content: Any, thinking_blocks: list[dict[str, Any]] | None = None + ) -> None: message = {"role": role, "content": content} if thinking_blocks: message["thinking_blocks"] = thinking_blocks diff --git a/strix/interface/assets/tui_styles.tcss b/strix/interface/assets/tui_styles.tcss index 0edeb42..7ebefd2 100644 --- a/strix/interface/assets/tui_styles.tcss +++ b/strix/interface/assets/tui_styles.tcss @@ -3,6 +3,28 @@ Screen { color: #d4d4d4; } +.screen--selection { + background: #2d3d2f; + color: #e5e5e5; +} + +ToastRack { + dock: top; + align: right top; + margin-bottom: 0; + margin-top: 1; +} + +Toast { + width: 25; + background: #000000; + border-left: outer #22c55e; +} + +Toast.-information .toast--title { + color: #22c55e; +} + #splash_screen { height: 100%; width: 100%; diff --git a/strix/interface/tool_components/finish_renderer.py b/strix/interface/tool_components/finish_renderer.py index 17c8051..62c2128 100644 --- a/strix/interface/tool_components/finish_renderer.py +++ b/strix/interface/tool_components/finish_renderer.py @@ -1,6 +1,5 @@ from typing import Any, ClassVar -from rich.padding import Padding from rich.text import Text from textual.widgets import Static @@ -9,7 +8,6 @@ from .registry import register_tool_renderer FIELD_STYLE = "bold #4ade80" -BG_COLOR = "#141414" @register_tool_renderer @@ -58,7 +56,10 @@ class FinishScanRenderer(BaseToolRenderer): text.append("\n ") text.append("Generating final report...", style="dim") - padded = Padding(text, 2, style=f"on {BG_COLOR}") + padded = Text() + padded.append("\n\n") + padded.append_text(text) + padded.append("\n\n") css_classes = cls.get_css_classes("completed") return Static(padded, classes=css_classes) diff --git a/strix/interface/tool_components/reporting_renderer.py b/strix/interface/tool_components/reporting_renderer.py index 8a2f045..98cc85d 100644 --- a/strix/interface/tool_components/reporting_renderer.py +++ b/strix/interface/tool_components/reporting_renderer.py @@ -3,7 +3,6 @@ from typing import Any, ClassVar from pygments.lexers import PythonLexer from pygments.styles import get_style_by_name -from rich.padding import Padding from rich.text import Text from textual.widgets import Static @@ -18,7 +17,6 @@ def _get_style_colors() -> dict[Any, str]: FIELD_STYLE = "bold #4ade80" -BG_COLOR = "#141414" @register_tool_renderer @@ -215,7 +213,10 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer): text.append("\n ") text.append("Creating report...", style="dim") - padded = Padding(text, 2, style=f"on {BG_COLOR}") + padded = Text() + padded.append("\n\n") + padded.append_text(text) + padded.append("\n\n") css_classes = cls.get_css_classes("completed") return Static(padded, classes=css_classes) diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 102693b..e345f0c 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -38,6 +38,9 @@ from strix.llm.config import LLMConfig from strix.telemetry.tracer import Tracer, set_global_tracer +logger = logging.getLogger(__name__) + + def get_package_version() -> str: try: return pkg_version("strix-agent") @@ -91,6 +94,7 @@ class ChatTextArea(TextArea): # type: ignore[misc] class SplashScreen(Static): # type: ignore[misc] + ALLOW_SELECT = False PRIMARY_GREEN = "#22c55e" BANNER = ( " ███████╗████████╗██████╗ ██╗██╗ ██╗\n" @@ -667,6 +671,7 @@ class QuitScreen(ModalScreen): # type: ignore[misc] class StrixTUIApp(App): # type: ignore[misc] CSS_PATH = "assets/tui_styles.tcss" + ALLOW_SELECT = True SIDEBAR_MIN_WIDTH = 140 @@ -783,13 +788,16 @@ class StrixTUIApp(App): # type: ignore[misc] chat_history.can_focus = True status_text = Static("", id="status_text") + status_text.ALLOW_SELECT = False keymap_indicator = Static("", id="keymap_indicator") + keymap_indicator.ALLOW_SELECT = False agent_status_display = Horizontal( status_text, keymap_indicator, id="agent_status_display", classes="hidden" ) chat_prompt = Static("> ", id="chat_prompt") + chat_prompt.ALLOW_SELECT = False chat_input = ChatTextArea( "", id="chat_input", @@ -807,6 +815,7 @@ class StrixTUIApp(App): # type: ignore[misc] agents_tree.guide_style = "dashed" stats_display = Static("", id="stats_display") + stats_display.ALLOW_SELECT = False vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel") @@ -1005,6 +1014,33 @@ class StrixTUIApp(App): # type: ignore[misc] text.append(message) return text, f"chat-placeholder {placeholder_class}" + @staticmethod + def _merge_renderables(renderables: list[Any]) -> Text: + """Merge renderables into a single Text for mouse text selection support.""" + combined = Text() + for i, item in enumerate(renderables): + if i > 0: + combined.append("\n") + StrixTUIApp._append_renderable(combined, item) + return combined + + @staticmethod + def _append_renderable(combined: Text, item: Any) -> None: + """Recursively append a renderable's text content to a combined Text.""" + if isinstance(item, Text): + combined.append_text(item) + elif isinstance(item, Group): + for j, sub in enumerate(item.renderables): + if j > 0: + combined.append("\n") + StrixTUIApp._append_renderable(combined, sub) + else: + inner = getattr(item, "renderable", None) + if inner is not None: + StrixTUIApp._append_renderable(combined, inner) + else: + combined.append(str(item)) + def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Any: renderables: list[Any] = [] @@ -1036,10 +1072,10 @@ class StrixTUIApp(App): # type: ignore[misc] if not renderables: return Text() - if len(renderables) == 1: + if len(renderables) == 1 and isinstance(renderables[0], Text): return renderables[0] - return Group(*renderables) + return self._merge_renderables(renderables) def _render_streaming_content(self, content: str, agent_id: str | None = None) -> Any: cache_key = agent_id or self.selected_agent_id or "" @@ -1072,10 +1108,10 @@ class StrixTUIApp(App): # type: ignore[misc] if not renderables: result = Text() - elif len(renderables) == 1: + elif len(renderables) == 1 and isinstance(renderables[0], Text): result = renderables[0] else: - result = Group(*renderables) + result = self._merge_renderables(renderables) self._streaming_render_cache[cache_key] = (content_len, result) return result @@ -1622,7 +1658,7 @@ class StrixTUIApp(App): # type: ignore[misc] interrupted_text.append("\n") interrupted_text.append("⚠ ", style="yellow") interrupted_text.append("Interrupted by user", style="yellow dim") - return Group(streaming_result, interrupted_text) + return self._merge_renderables([streaming_result, interrupted_text]) return AgentMessageRenderer.render_simple(content) @@ -1931,6 +1967,92 @@ class StrixTUIApp(App): # type: ignore[misc] sidebar.remove_class("-hidden") chat_area.remove_class("-full-width") + def on_mouse_up(self, _event: events.MouseUp) -> None: + self.set_timer(0.05, self._auto_copy_selection) + + _ICON_PREFIXES: ClassVar[tuple[str, ...]] = ( + "🐞 ", + "🌐 ", + "📋 ", + "🧠 ", + "◆ ", + "◇ ", + "◈ ", + "→ ", + "○ ", + "● ", + "✓ ", + "✗ ", + "⚠ ", + "▍ ", + "▍", + "┃ ", + "• ", + ">_ ", + " ", + "<~> ", + "[ ] ", + "[~] ", + "[•] ", + ) + + _DECORATIVE_LINES: ClassVar[frozenset[str]] = frozenset( + { + "● In progress...", + "✓ Done", + "✗ Failed", + "✗ Error", + "○ Unknown", + } + ) + + @staticmethod + def _clean_copied_text(text: str) -> str: + lines = text.split("\n") + cleaned: list[str] = [] + for line in lines: + stripped = line.lstrip() + if stripped in StrixTUIApp._DECORATIVE_LINES: + continue + if stripped and all(c == "─" for c in stripped): + continue + out = line + for prefix in StrixTUIApp._ICON_PREFIXES: + if stripped.startswith(prefix): + leading = line[: len(line) - len(line.lstrip())] + out = leading + stripped[len(prefix) :] + break + cleaned.append(out) + return "\n".join(cleaned) + + def _auto_copy_selection(self) -> None: + copied = False + + try: + if self.screen.selections: + selected = self.screen.get_selected_text() + self.screen.clear_selection() + if selected: + cleaned = self._clean_copied_text(selected) + self.copy_to_clipboard(cleaned if cleaned.strip() else selected) + copied = True + except Exception: # noqa: BLE001 + logger.debug("Failed to copy screen selection", exc_info=True) + + if not copied: + try: + chat_input = self.query_one("#chat_input", ChatTextArea) + selected = chat_input.selected_text + if selected and selected.strip(): + self.copy_to_clipboard(selected) + chat_input.move_cursor(chat_input.cursor_location) + copied = True + except Exception: # noqa: BLE001 + logger.debug("Failed to copy chat input selection", exc_info=True) + + if copied: + self.notify("Copied to clipboard", timeout=2) + async def run_tui(args: argparse.Namespace) -> None: """Run strix in interactive TUI mode with textual."""