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

@@ -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."""