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.
This commit is contained in:
0xallam
2026-02-07 10:26:14 -08:00
committed by Ahmed Allam
parent 0a63ffba63
commit e0b750dbcd
5 changed files with 160 additions and 12 deletions

View File

@@ -43,7 +43,9 @@ class AgentState(BaseModel):
self.iteration += 1 self.iteration += 1
self.last_updated = datetime.now(UTC).isoformat() 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} message = {"role": role, "content": content}
if thinking_blocks: if thinking_blocks:
message["thinking_blocks"] = thinking_blocks message["thinking_blocks"] = thinking_blocks

View File

@@ -3,6 +3,28 @@ Screen {
color: #d4d4d4; 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 { #splash_screen {
height: 100%; height: 100%;
width: 100%; width: 100%;

View File

@@ -1,6 +1,5 @@
from typing import Any, ClassVar from typing import Any, ClassVar
from rich.padding import Padding
from rich.text import Text from rich.text import Text
from textual.widgets import Static from textual.widgets import Static
@@ -9,7 +8,6 @@ from .registry import register_tool_renderer
FIELD_STYLE = "bold #4ade80" FIELD_STYLE = "bold #4ade80"
BG_COLOR = "#141414"
@register_tool_renderer @register_tool_renderer
@@ -58,7 +56,10 @@ class FinishScanRenderer(BaseToolRenderer):
text.append("\n ") text.append("\n ")
text.append("Generating final report...", style="dim") 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") css_classes = cls.get_css_classes("completed")
return Static(padded, classes=css_classes) return Static(padded, classes=css_classes)

View File

@@ -3,7 +3,6 @@ from typing import Any, ClassVar
from pygments.lexers import PythonLexer from pygments.lexers import PythonLexer
from pygments.styles import get_style_by_name from pygments.styles import get_style_by_name
from rich.padding import Padding
from rich.text import Text from rich.text import Text
from textual.widgets import Static from textual.widgets import Static
@@ -18,7 +17,6 @@ def _get_style_colors() -> dict[Any, str]:
FIELD_STYLE = "bold #4ade80" FIELD_STYLE = "bold #4ade80"
BG_COLOR = "#141414"
@register_tool_renderer @register_tool_renderer
@@ -215,7 +213,10 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
text.append("\n ") text.append("\n ")
text.append("Creating report...", style="dim") 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") css_classes = cls.get_css_classes("completed")
return Static(padded, classes=css_classes) return Static(padded, classes=css_classes)

View File

@@ -38,6 +38,9 @@ from strix.llm.config import LLMConfig
from strix.telemetry.tracer import Tracer, set_global_tracer from strix.telemetry.tracer import Tracer, set_global_tracer
logger = logging.getLogger(__name__)
def get_package_version() -> str: def get_package_version() -> str:
try: try:
return pkg_version("strix-agent") return pkg_version("strix-agent")
@@ -91,6 +94,7 @@ class ChatTextArea(TextArea): # type: ignore[misc]
class SplashScreen(Static): # type: ignore[misc] class SplashScreen(Static): # type: ignore[misc]
ALLOW_SELECT = False
PRIMARY_GREEN = "#22c55e" PRIMARY_GREEN = "#22c55e"
BANNER = ( BANNER = (
" ███████╗████████╗██████╗ ██╗██╗ ██╗\n" " ███████╗████████╗██████╗ ██╗██╗ ██╗\n"
@@ -667,6 +671,7 @@ class QuitScreen(ModalScreen): # type: ignore[misc]
class StrixTUIApp(App): # type: ignore[misc] class StrixTUIApp(App): # type: ignore[misc]
CSS_PATH = "assets/tui_styles.tcss" CSS_PATH = "assets/tui_styles.tcss"
ALLOW_SELECT = True
SIDEBAR_MIN_WIDTH = 140 SIDEBAR_MIN_WIDTH = 140
@@ -783,13 +788,16 @@ class StrixTUIApp(App): # type: ignore[misc]
chat_history.can_focus = True chat_history.can_focus = True
status_text = Static("", id="status_text") status_text = Static("", id="status_text")
status_text.ALLOW_SELECT = False
keymap_indicator = Static("", id="keymap_indicator") keymap_indicator = Static("", id="keymap_indicator")
keymap_indicator.ALLOW_SELECT = False
agent_status_display = Horizontal( agent_status_display = Horizontal(
status_text, keymap_indicator, id="agent_status_display", classes="hidden" status_text, keymap_indicator, id="agent_status_display", classes="hidden"
) )
chat_prompt = Static("> ", id="chat_prompt") chat_prompt = Static("> ", id="chat_prompt")
chat_prompt.ALLOW_SELECT = False
chat_input = ChatTextArea( chat_input = ChatTextArea(
"", "",
id="chat_input", id="chat_input",
@@ -807,6 +815,7 @@ class StrixTUIApp(App): # type: ignore[misc]
agents_tree.guide_style = "dashed" agents_tree.guide_style = "dashed"
stats_display = Static("", id="stats_display") stats_display = Static("", id="stats_display")
stats_display.ALLOW_SELECT = False
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel") vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
@@ -1005,6 +1014,33 @@ class StrixTUIApp(App): # type: ignore[misc]
text.append(message) text.append(message)
return text, f"chat-placeholder {placeholder_class}" 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: def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Any:
renderables: list[Any] = [] renderables: list[Any] = []
@@ -1036,10 +1072,10 @@ class StrixTUIApp(App): # type: ignore[misc]
if not renderables: if not renderables:
return Text() return Text()
if len(renderables) == 1: if len(renderables) == 1 and isinstance(renderables[0], Text):
return renderables[0] return renderables[0]
return Group(*renderables) return self._merge_renderables(renderables)
def _render_streaming_content(self, content: str, agent_id: str | None = None) -> Any: def _render_streaming_content(self, content: str, agent_id: str | None = None) -> Any:
cache_key = agent_id or self.selected_agent_id or "" cache_key = agent_id or self.selected_agent_id or ""
@@ -1072,10 +1108,10 @@ class StrixTUIApp(App): # type: ignore[misc]
if not renderables: if not renderables:
result = Text() result = Text()
elif len(renderables) == 1: elif len(renderables) == 1 and isinstance(renderables[0], Text):
result = renderables[0] result = renderables[0]
else: else:
result = Group(*renderables) result = self._merge_renderables(renderables)
self._streaming_render_cache[cache_key] = (content_len, result) self._streaming_render_cache[cache_key] = (content_len, result)
return result return result
@@ -1622,7 +1658,7 @@ class StrixTUIApp(App): # type: ignore[misc]
interrupted_text.append("\n") interrupted_text.append("\n")
interrupted_text.append("", style="yellow") interrupted_text.append("", style="yellow")
interrupted_text.append("Interrupted by user", style="yellow dim") 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) return AgentMessageRenderer.render_simple(content)
@@ -1931,6 +1967,92 @@ class StrixTUIApp(App): # type: ignore[misc]
sidebar.remove_class("-hidden") sidebar.remove_class("-hidden")
chat_area.remove_class("-full-width") 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: async def run_tui(args: argparse.Namespace) -> None:
"""Run strix in interactive TUI mode with textual.""" """Run strix in interactive TUI mode with textual."""