diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index eb954f5..2103492 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -6,9 +6,12 @@ from typing import Any from rich.console import RenderableType +from rich.segment import Segment from rich.text import Text from textual.app import ComposeResult from textual.containers import ScrollableContainer +from textual.geometry import Offset +from textual.selection import Selection from textual.widgets import Static from clients.terminal.tui.chat_model import ChatItem, ChatModel @@ -97,6 +100,10 @@ super().__init__(renderable) self._item: ChatItem = item self._signature: str = signature + # The raw rich renderable (Panel/Text/Group/...) this widget shows, kept + # alongside Textual's wrapped Visual so get_selection can re-render it to + # text and strip the bubble chrome. Updated together with the Visual. + self._rich_renderable: RenderableType = renderable def maybe_update(self, item: ChatItem, registry) -> bool: """Re-render only if the item's signature changed. Returns True if updated.""" @@ -106,9 +113,111 @@ return False self._item = item self._signature = sig - self.update(registry.render(msg)) + rendered = registry.render(msg) + self._rich_renderable = rendered + self.update(rendered) return True + # ── text selection ─────────────────────────────────────────────────────── + + def _rendered_lines(self) -> list[str]: + """Re-render this item's renderable to plain text lines, the same way + Textual draws it (same console, same width → identical line layout to + what is on screen). Used by get_selection to align mouse offsets with + the rendered characters before stripping the bubble chrome. + """ + renderable = self._rich_renderable + if renderable is None or not self.size.width: + return [] + app = self.app + console = app.console + width = self.size.width + options = app.console_options.update(highlight=False, width=width, height=None) + segments = console.render(renderable, options.update_width(width)) + return ["".join(seg.text for seg in line) for line in Segment.split_lines(segments)] + + @staticmethod + def _denoise(lines: list[str]) -> tuple[list[str], int, bool]: + """Split rendered lines into clean content + the column content starts at. + + Chat items render rich ``Panel``s (rounded borders + "Navi"/"You" + titles). The outer panel adds one top and one bottom border line and a + ``│ `` / `` │`` gutter on every content line; stripping those gives the + selectable text the user actually wants to copy. Non-panel renderables + (plain ``Text``/``Group`` — status lines, filesystem tool output, + turn_meta) have no chrome: their lines are kept verbatim with no column + shift, so the selection maps 1:1 onto what is on screen. + + Returns ``(content_lines, content_col, is_panel)``: ``content_lines`` + is the clean text (one entry per content row, trailing panel padding + stripped), ``content_col`` is how far in a rendered line the content + begins (0 for plain, pad+2 for a panel), ``is_panel`` flags the layout. + """ + n = len(lines) + + def is_border(line: str) -> bool: + s = line.lstrip(" ") + return bool(s) and s[0] in "╭╮╰╯┌┐└┘" + + if n >= 3 and is_border(lines[0]) and is_border(lines[-1]): + spans: list[tuple[int, str]] | None = [] + for line in lines[1:-1]: + left = line.find("│") + if left < 0: + spans = None + break + right = line.rfind("│") + content_col = left + 2 + end = right if right > left else len(line) + spans.append((content_col, line[content_col:end].rstrip())) + if spans and all(s[0] == spans[0][0] for s in spans): + return [s[1] for s in spans], spans[0][0], True + return list(lines), 0, False + + def get_selection(self, selection: Selection) -> tuple[str, str] | None: + """Clean, chrome-free text under the selection. + + The base ``Widget.get_selection`` only extracts text from widgets whose + render is ``Text``/``Content``; our chat items render rich ``Panel``s, so + it returns ``None`` and a drag-select over a message copies nothing. We + re-render the item to text (line layout identical to the screen), strip + the outer panel borders and gutters, and remap the mouse offsets onto the + clean content so a partial-line selection still lands on the right + characters. ``Ctrl+C`` then copies the result via OSC 52. + """ + lines = self._rendered_lines() + if not lines: + return None + content_lines, content_col, is_panel = self._denoise(lines) + if not content_lines: + return None + n_render = len(lines) + n_content = len(content_lines) + + def map_point(offset: Offset | None) -> Offset | None: + if offset is None: + return None + y, x = offset.y, offset.x + if is_panel: + if y <= 0: + return Offset(x=0, y=0) + if y >= n_render - 1: + return Offset(x=len(content_lines[-1]), y=n_content - 1) + cy = y - 1 + else: + cy = y + cy = max(0, min(cy, n_content - 1)) + cx = max(0, x - content_col) + cx = min(cx, len(content_lines[cy])) + return Offset(x=cx, y=cy) + + try: + mapped = Selection(map_point(selection.start), map_point(selection.end)) + text = mapped.extract("\n".join(content_lines)) + except Exception: + return None + return text, "\n" + class ChatPanel(ScrollableContainer): """Scrollable conversation panel. diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index ad229f4..1aa996c 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -515,3 +515,186 @@ chat.handle_ws_event({"type": "stream_delta", "delta": "more " * 20}) await pilot.pause() assert chat.scroll_y == chat.max_scroll_y + + +# ─── text selection (copy without bubble chrome) ────────────────────────────── + + +def _rendered_lines_of(widget) -> list[str]: + """The exact screen lines a _ChatItemView draws (re-rendered via the app + console at the widget width), for asserting selection coordinates.""" + return widget._rendered_lines() + + +@pytest.mark.anyio +async def test_get_selection_returns_clean_message_content() -> None: + """A whole-message selection returns the bubble's text without the rounded + border or the "You" title — the base Widget.get_selection returns None for + Panel renderables, so without the override drag-select copies nothing.""" + from textual.geometry import Offset + from textual.selection import Selection + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.add_user_message("Hello world") + await pilot.pause() + + widget = list(chat._widgets.values())[0] + text, _ = widget.get_selection(Selection(None, None)) + assert text == "Hello world" + # No panel chrome leaks into the copy. + assert "╭" not in text and "│" not in text and "You" not in text + + +@pytest.mark.anyio +async def test_get_selection_partial_line_aligns_to_content() -> None: + """A partial-line selection maps the mouse column onto the content column + (skipping the ``│ `` gutter), so the copied substring is exactly the + highlighted characters — not offset by the border.""" + from textual.geometry import Offset + from textual.selection import Selection + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.add_user_message("Hello world") + await pilot.pause() + + widget = list(chat._widgets.values())[0] + lines = _rendered_lines_of(widget) + # Layout sanity: top border, one content line, bottom border; content + # starts two columns in (after ``│ ``). + assert lines[0].lstrip().startswith("╭") + assert lines[1].startswith("│ Hello world") + assert lines[-1].lstrip().startswith("╰") + + # Select columns 2..7 on the content line → "Hello" (H at col 2). + text, _ = widget.get_selection(Selection(Offset(x=2, y=1), Offset(x=7, y=1))) + assert text == "Hello" + + +@pytest.mark.anyio +async def test_get_selection_across_two_content_lines() -> None: + """A selection spanning two content rows returns both rows' content joined, + without borders between them.""" + from textual.geometry import Offset + from textual.selection import Selection + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.add_user_message("alpha\nbeta") + await pilot.pause() + + widget = list(chat._widgets.values())[0] + lines = _rendered_lines_of(widget) + content_rows = [ln for ln in lines if ln.startswith("│")] + assert len(content_rows) >= 2 + + text, _ = widget.get_selection(Selection(Offset(x=2, y=1), Offset(x=4, y=2))) + assert text == "alpha\nbe" + + +@pytest.mark.anyio +async def test_get_selection_clamps_top_border_to_content_start() -> None: + """Starting the drag on the title/border line does not pull chrome into the + copy — it clamps to the start of the first content line.""" + from textual.geometry import Offset + from textual.selection import Selection + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.add_user_message("Hello world") + await pilot.pause() + + widget = list(chat._widgets.values())[0] + text, _ = widget.get_selection(Selection(Offset(x=0, y=0), Offset(x=7, y=1))) + assert text == "Hello" + assert "You" not in text + + +@pytest.mark.anyio +async def test_get_selection_plain_text_widget_has_no_chrome() -> None: + """Non-panel items (status line, turn_meta) render plain Text — selection + returns the text verbatim with no column shift.""" + from textual.geometry import Offset + from textual.selection import Selection + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.handle_ws_event({"type": "status", "content": "working"}) + await pilot.pause() + + widget = list(chat._widgets.values())[0] + assert widget._item.kind == "status" + lines = _rendered_lines_of(widget) + assert lines == ["• working"] + text, _ = widget.get_selection(Selection(None, None)) + assert text == "• working" + + +@pytest.mark.anyio +async def test_get_selection_filesystem_tool_output_is_selectable() -> None: + """Filesystem tool output renders a plain Group/Text (no outer panel), so a + selection over the result returns the content — the resume "can't copy tool + output" symptom alongside the missing highlights.""" + from textual.geometry import Offset + from textual.selection import Selection + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.handle_ws_event( + { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "read", "path": "/a.py"}, + "result": "1: line one\n2: line two", + "success": True, + "metadata": {}, + } + ) + await pilot.pause() + + widget = list(chat._widgets.values())[0] + assert widget._item.kind == "tool_call" + text, _ = widget.get_selection(Selection(None, None)) + assert "line one" in text + assert "line two" in text + # Plain renderable — no panel borders anywhere. + assert "│" not in text and "╭" not in text + + +@pytest.mark.anyio +async def test_screen_copy_text_strips_chrome_across_messages() -> None: + """End-to-end: Screen.get_selected_text (what Ctrl+C copies) walks every + selected widget's get_selection, so selecting across a user bubble and an + assistant bubble yields both bodies joined — no borders, no titles.""" + from textual.geometry import Offset + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.add_user_message("question") + chat.handle_ws_event({"type": "stream_start"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "answer"}) + chat.handle_ws_event({"type": "stream_end", "content": "answer"}) + await pilot.pause() + + screen = pilot.app.screen + screen.text_select_all() + await pilot.pause() + text = screen.get_selected_text() + assert "question" in text + assert "answer" in text + assert "╭" not in text and "│" not in text