diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py index 64987c6..d2ff530 100644 --- a/clients/terminal/tui/commands/builtin.py +++ b/clients/terminal/tui/commands/builtin.py @@ -31,6 +31,34 @@ aliases = f" ({', '.join(cmd.meta.aliases)})" if cmd.meta.aliases else "" key = f" [{cmd.meta.keybind}]" if cmd.meta.keybind else "" lines.append(f" /{cmd.meta.name}{aliases}{key} — {cmd.meta.description}") + + # Keys (not slash commands) so they're discoverable from /help too. + lines.append("") + lines.append("[b]Keys[/b]") + lines.append(" Ctrl+P — command palette") + lines.append(" Ctrl+R — reading mode (toggle)") + lines.append(" Ctrl+X Q — quit") + lines.append(" Ctrl+X C — compact context") + lines.append(" Ctrl+X T — toggle thinking blocks") + lines.append(" Esc — stop generation") + lines.append(" Ctrl+Shift+Up/Down, Ctrl+End — scroll chat") + + # Copying text out of the TUI — the non-obvious bit worth surfacing. + lines.append("") + lines.append("[b]Copying text[/b]") + lines.append( + " Drag (no Shift) + Ctrl+C — copy a message/output (clean, no borders)." + ) + lines.append( + " Works via OSC 52; in terminals without it (e.g. GNOME Console) use:" + ) + lines.append( + " Ctrl+R → Shift+drag → Ctrl+Shift+C — copy raw selection to the system" + ) + lines.append( + " clipboard. Reading mode hides the chrome and the bubble borders so the" + ) + lines.append(" selection is just the conversation text.") ctx.chat_panel.handle_ws_event({"type": "status", "content": "\n".join(lines)}) diff --git a/clients/terminal/tui/renderers/base.py b/clients/terminal/tui/renderers/base.py index d5ca6e5..c54a792 100644 --- a/clients/terminal/tui/renderers/base.py +++ b/clients/terminal/tui/renderers/base.py @@ -9,6 +9,29 @@ from rich.console import RenderableType +def _strip_panel(renderable: "RenderableType") -> "RenderableType": + """Drop the bubble chrome for reading-mode (plain) rendering. + + Most chat items render a rich ``Panel`` (rounded border + title) or a + ``Padding(Panel, ...)`` for sub-agent indent. In reading mode the goal is to + show just the content so a raw terminal ``Shift+drag`` + ``Ctrl+Shift+C`` + copies clean text without the ``│``/``╭─╮`` borders — so unwrap the Panel (or + padded Panel) to its body. Renderables that are already borderless (plain + ``Text``/``Group``/status lines) pass through unchanged. + """ + from rich.panel import Panel + from rich.padding import Padding + + if isinstance(renderable, Panel): + return renderable.renderable + if isinstance(renderable, Padding): + inner = renderable.renderable + if isinstance(inner, Panel): + return inner.renderable + return inner + return renderable + + class ContentRenderer(ABC): """Render a single WebSocket event or chat message into a Rich renderable.""" @@ -21,3 +44,15 @@ def render(self, msg: dict) -> "RenderableType": """Return a Rich renderable.""" raise NotImplementedError + + def render_plain(self, msg: dict) -> "RenderableType": + """Return a borderless renderable for reading mode. + + The default unwraps the Panel/Padding that :meth:`render` produces so the + body shows without bubble chrome. Renderers whose body is itself a styled + markdown renderable (assistant answers, finalized plans) override this to + return the raw markdown text instead — otherwise rich ``Markdown`` would + re-wrap fenced code blocks in their own Panels and the borders would leak + back into a raw-terminal copy. + """ + return _strip_panel(self.render(msg)) \ No newline at end of file diff --git a/clients/terminal/tui/renderers/message.py b/clients/terminal/tui/renderers/message.py index 75871b1..72c8f34 100644 --- a/clients/terminal/tui/renderers/message.py +++ b/clients/terminal/tui/renderers/message.py @@ -64,3 +64,11 @@ border_style=_hex_style(theme.assistant_bubble), box=ROUNDED, ) + + def render_plain(self, msg: dict) -> RenderableType: + # In reading mode return the raw markdown text rather than unwrapping the + # Panel's Markdown body: rich Markdown re-wraps fenced code blocks in + # their own Panels, so the borders would leak back into a raw-terminal + # copy. Raw markdown copies clean (fences stay as ```…``` text). + theme = get_active_theme() + return Text(msg.get("content", "") or "", style=_hex_style(theme.text)) diff --git a/clients/terminal/tui/renderers/planning.py b/clients/terminal/tui/renderers/planning.py index d0d850a..d8497b2 100644 --- a/clients/terminal/tui/renderers/planning.py +++ b/clients/terminal/tui/renderers/planning.py @@ -70,4 +70,10 @@ ) if is_subagent: return Padding(panel, (0, 0, 0, 2)) - return panel \ No newline at end of file + return panel + + def render_plain(self, msg: dict) -> RenderableType: + # Raw markdown plan text (same reason as assistant messages): rich + # Markdown would wrap fenced code blocks in Panels and the borders would + # leak into a raw-terminal copy. + return Text(msg.get("plan", "") or "") \ No newline at end of file diff --git a/clients/terminal/tui/renderers/registry.py b/clients/terminal/tui/renderers/registry.py index b3ebfc1..866cbfa 100644 --- a/clients/terminal/tui/renderers/registry.py +++ b/clients/terminal/tui/renderers/registry.py @@ -24,3 +24,14 @@ if renderer.accepts(msg): return renderer.render(msg) return str(msg) + + def render_plain(self, msg: dict) -> "RenderableType": + """Borderless renderable for reading mode (first accepting renderer wins). + + Mirrors :meth:`render` but calls each renderer's ``render_plain``, which + drops Panel/Padding chrome so a raw terminal selection copies clean text. + """ + for renderer in self._renderers: + if renderer.accepts(msg): + return renderer.render_plain(msg) + return str(msg) diff --git a/clients/terminal/tui/themes.py b/clients/terminal/tui/themes.py index d823a45..73d45a2 100644 --- a/clients/terminal/tui/themes.py +++ b/clients/terminal/tui/themes.py @@ -88,6 +88,15 @@ "tui-prompt-border": self.prompt_border.hex, "tui-selection": self.selection.hex, "tui-link": self.link.hex, + # Textual's drag-select highlight (Screen's ``screen--selection`` + # component style reads ``$screen-selection-background``/``-foreground`` + # from the theme variables). Without these the highlight falls back to + # a barely-visible grey block, so users see "nothing selected" even + # though the selection exists and Ctrl+C would copy. Use the theme's + # bright ``selection`` colour for the background and the dark + # ``background`` for the text so selected content stays readable. + "screen-selection-background": self.selection.hex, + "screen-selection-foreground": self.background.hex, } def to_textual_theme(self) -> TextualTheme: diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index f481819..c9cbc27 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -31,12 +31,45 @@ class NaviCodeTui(App): """OpenCode-inspired terminal UI for Navi.""" + # Reading mode: hide every piece of chrome around the conversation (right + # status/todo column, input prompt, bottom status bar, and the chat panel's + # own outer border) so the terminal shows only the message column. The + # reason this exists is that GNOME Console (and other terminals without + # OSC 52 support) can't copy Textual's selection to the *system* clipboard — + # only the raw terminal selection (Shift+drag) reaches the OS via + # Ctrl+Shift+C. Hiding the chrome lets Shift+drag grab just the conversation + # instead of the status bar / prompt / side panels. (Message bubble borders + # still leak into the copy in this first cut; a borderless plain render + # follows in a second step.) + DEFAULT_CSS = """ + NaviCodeTui.reading-mode #tui-right-column { + display: none; + } + NaviCodeTui.reading-mode InputBox { + display: none; + } + NaviCodeTui.reading-mode StatusBar { + display: none; + } + NaviCodeTui.reading-mode ChatPanel { + border: none; + padding: 0; + } + /* A blank line between borderless messages so they don't run together when + the bubble borders are gone. The empty margin rows are part of the raw + terminal selection, which is fine — they read as message separators. */ + NaviCodeTui.reading-mode ChatPanel _ChatItemView { + margin: 0 0 1 0; + } + """ + BINDINGS = [ ("ctrl+p", "command_palette", "Palette"), ("ctrl+x q", "quit", "Quit"), ("ctrl+x c", "compact", "Compact"), ("ctrl+x t", "toggle_thinking", "Thinking"), ("escape", "stop_stream", "Stop"), + ("ctrl+r", "toggle_reading_mode", "Reading"), # Scroll the chat panel from the keyboard while focus stays on the # input box. These keys are not claimed by TextArea (it uses shift+up/ # down for select and ctrl+e/end for line end), so they bubble to the @@ -83,7 +116,7 @@ def compose(self) -> ComposeResult: with Horizontal(): yield self._chat_panel - with Vertical(): + with Vertical(id="tui-right-column"): yield self._status_panel yield self._todo_panel yield self._input_box @@ -459,6 +492,26 @@ """Jump the chat to the bottom (resumes auto-follow on the next sync).""" self._chat_panel.scroll_end(animate=False) + def action_toggle_reading_mode(self) -> None: + """Hide the chrome around the chat so Shift+drag + Ctrl+Shift+C copies it. + + Toggles the ``reading-mode`` CSS class on the app, which hides the right + status/todo column, the input prompt, and the bottom status bar, and + drops the chat panel's outer border — leaving only the message column on + screen. Use ``Shift+drag`` to select the conversation and ``Ctrl+Shift+C`` + to copy it to the system clipboard (works in terminals without OSC 52, + e.g. GNOME Console). Press ``Ctrl+R`` again to restore the full UI and + return focus to the input box. + """ + self.toggle_class("reading-mode") + reading = self.has_class("reading-mode") + # Re-render the chat without/with bubble borders. Done after the class + # toggle so the new widgets paint in the chosen style. + self._chat_panel.set_reading_mode(reading) + if not reading: + self._input_box.focus_input() + self.refresh_bindings() + async def action_quit(self) -> None: if self._bridge: await self._bridge.stop() diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index 2103492..9d6a55d 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -7,11 +7,14 @@ from rich.console import RenderableType from rich.segment import Segment +from rich.style import Style as RichStyle from rich.text import Text from textual.app import ComposeResult from textual.containers import ScrollableContainer +from textual.content import Content from textual.geometry import Offset from textual.selection import Selection +from textual.style import Style from textual.widgets import Static from clients.terminal.tui.chat_model import ChatItem, ChatModel @@ -83,9 +86,19 @@ """One chat item as its own widget. Making each message a distinct widget is what keeps streaming cheap: a - per-token ``stream_delta`` only re-renders *this* widget (via - ``Static.update``), not the whole conversation — Textual refreshes just the - changed region instead of re-laying-out every visible item. + per-token ``stream_delta`` only re-renders *this* widget, not the whole + conversation — Textual refreshes just the changed region instead of + re-laying-out every visible item. + + The item's rich renderable (Panel/Markdown/Group/…) is rendered to a + Textual ``Content`` in ``render()`` rather than handed to ``Static`` as a + raw rich renderable. Only ``Text``/``str``/``Content`` visuals are + selection-aware in Textual; a raw rich ``Panel`` wraps into a ``RichVisual`` + which neither shows the drag-select highlight nor returns text from + ``get_selection`` — so a drag over a message looked like "nothing + selected". Building a ``Content`` makes the widget selection-aware (the + magenta highlight renders) while ``get_selection`` strips the bubble + chrome so the copy is clean. """ DEFAULT_CSS = """ @@ -97,15 +110,20 @@ """ def __init__(self, renderable: RenderableType, *, item: ChatItem, signature: str) -> None: - super().__init__(renderable) + super().__init__("") 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. + # The raw rich renderable (Panel/Text/Group/...) this widget shows. Kept + # alongside the rendered Content so render() can rebuild on resize and + # get_selection can strip the bubble chrome from the rendered text. self._rich_renderable: RenderableType = renderable + # (id(renderable), width) -> Content cache. Rebuilding a Content renders + # the rich renderable to segments, so we cache by content identity + + # width: a repeated paint at the same width/ content reuses the Content, + # and a resize or token update (new renderable) invalidates it. + self._content_cache: tuple[tuple[int, int], Content] | None = None - def maybe_update(self, item: ChatItem, registry) -> bool: + def maybe_update(self, item: ChatItem, registry, reading: bool = False) -> bool: """Re-render only if the item's signature changed. Returns True if updated.""" msg = _item_msg(item) sig = _signature(msg) @@ -113,28 +131,94 @@ return False self._item = item self._signature = sig - rendered = registry.render(msg) - self._rich_renderable = rendered - self.update(rendered) + self._rich_renderable = ( + registry.render_plain(msg) if reading else registry.render(msg) + ) + self._content_cache = None + # render() rebuilds the Content from _rich_renderable; layout=True so the + # widget re-measures (a longer/shorter message changes height: auto). + self.refresh(layout=True) return True - # ── text selection ─────────────────────────────────────────────────────── + # ── rendering + 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. + def _build_content(self) -> Content: + """Render the item's rich renderable to a selection-aware ``Content``, + the same way Textual would draw it (same console, same width → identical + line layout). Cached by (renderable identity, width) so repeated paints + are cheap and resize/token-update invalidate it. """ 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)] + if renderable is None or width <= 0: + return Content("") + key = (id(renderable), width) + if self._content_cache is not None and self._content_cache[0] == key: + return self._content_cache[1] + console = self.app.console + # Textual's selection-aware Content stores spans with Textual ``Style`` + # objects (the magenta selection_style is merged in via Style + Style at + # paint time); rich segments carry rich ``Style`` objects, which crash + # that merge with "unsupported operand type(s) for +". Convert each + # segment's rich Style to a Textual Style (mirroring Content.from_rich_text) + # so the selection highlight composes cleanly over our panel borders. + ansi_theme = self.app.ansi_theme + to_style = Style.from_rich_style + options = console.options.update(width=width, highlight=False) + segments = list(console.render(renderable, options)) + lines = list(Segment.split_lines(segments)) + parts: list[tuple[str, Any]] = [] + last = len(lines) - 1 + for i, line in enumerate(lines): + for seg in line: + if seg.text: + # rich segments may carry ``style=None`` (e.g. a bare + # Segment with no style); Style.from_rich_style would crash + # on None, so fall back to rich's empty default Style. + parts.append( + (seg.text, to_style(seg.style or RichStyle(), ansi_theme)) + ) + if i < last: + parts.append(("\n", None)) + content = Content.assemble(*parts, end="") + self._content_cache = (key, content) + return content + + def render(self): # type: ignore[override] + try: + return self._build_content() + except Exception: + return Content("") + + def get_content_height(self, container, viewport, width: int) -> int: # type: ignore[override] + """Auto-height of the bubble, computed directly from the rich renderable. + + Textual measures ``height: auto`` widgets by calling ``_render()`` (which + wraps ``render()``) and asking the resulting Visual for its height. Our + ``render()`` builds a selection-aware ``Content`` keyed by width, so every + width change yields a *new* Content/Visual object — and since Textual's + ``_render`` visual cache is not width-keyed, that spreads the width-settling + cascade (0 → panel-width → final) across multiple refreshes. A deferred + ``scroll_end`` (``call_after_refresh``) then lands at an *intermediate* + ``max_scroll_y`` instead of the final one, so a freshly-filled chat no + longer sticks to the bottom. + + Computing the height straight from the rich renderable (the same thing a + raw ``RichVisual`` does) is a pure function of ``width`` with no Content + object churn, so Textual reaches the layout fixpoint in one refresh and + the deferred scroll lands at the true bottom. ``render()`` still supplies + the selection-aware Content for painting. + """ + renderable = self._rich_renderable + if not width or renderable is None: + return 0 + try: + console = self.app.console + options = console.options.update(width=width, highlight=False) + segments = list(console.render(renderable, options)) + return sum(1 for _ in Segment.split_lines(segments)) + except Exception: + return 0 @staticmethod def _denoise(lines: list[str]) -> tuple[list[str], int, bool]: @@ -177,16 +261,20 @@ 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 + The widget renders a selection-aware ``Content`` (so the magenta + highlight draws), but that Content carries the panel borders verbatim; + ``get_selection`` would otherwise copy the borders too. We take the + Content's plain 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: + try: + content = self._build_content() + except Exception: + return None + lines = content.plain.split("\n") + if not lines or lines == [""]: return None content_lines, content_col, is_panel = self._denoise(lines) if not content_lines: @@ -247,6 +335,11 @@ super().__init__() self._model = ChatModel() self._registry = default_registry() + # Reading mode: render items without their Panel bubble chrome so a raw + # terminal Shift+drag + Ctrl+Shift+C copies clean text in terminals that + # lack OSC 52 (e.g. GNOME Console). Toggled by the app; when it changes + # every visible widget is re-rendered in place. + self._reading_mode: bool = False # Truncation hint shown at the top when the history exceeds the cap. self._hint_view = Static("", id="chat-truncation-hint") self._hint_view.styles.display = "none" @@ -256,6 +349,33 @@ # it stays bounded by the window cap, not by total history length. self._widgets: dict[int, _ChatItemView] = {} + def _render_for(self, item: ChatItem): + """Renderable for an item, honouring the current reading mode. + + Reading mode uses the registry's borderless ``render_plain`` (raw text / + unwrapped bodies) instead of the Panel-based ``render``, so the bubble + borders never reach the screen and a raw terminal selection copies clean. + """ + msg = _item_msg(item) + if self._reading_mode: + return self._registry.render_plain(msg) + return self._registry.render(msg) + + def set_reading_mode(self, reading: bool) -> None: + """Switch between bordered (normal) and borderless (reading) rendering. + + Re-renders every visible item widget in place — no remount — so scroll + position is preserved. Items mounted later (new stream events) pick up + the mode via :meth:`_render_for`. + """ + if reading == self._reading_mode: + return + self._reading_mode = reading + for widget in self._widgets.values(): + widget._rich_renderable = self._render_for(widget._item) + widget._content_cache = None + widget.refresh(layout=True) + def compose(self) -> ComposeResult: yield self._hint_view @@ -347,14 +467,14 @@ if widget is None: msg = _item_msg(item) widget = _ChatItemView( - self._registry.render(msg), + self._render_for(item), item=item, signature=_signature(msg), ) self._widgets[key] = widget self.mount(widget) else: - widget.maybe_update(item, self._registry) + widget.maybe_update(item, self._registry, self._reading_mode) if stick_to_bottom: self.scroll_end(animate=False) \ No newline at end of file diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index 1aa996c..9f40e8f 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -521,9 +521,10 @@ 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() + """The exact screen lines a _ChatItemView draws (the plain text of the + selection-aware Content its render() builds, line layout identical to the + screen), for asserting selection coordinates.""" + return widget._build_content().plain.split("\n") @pytest.mark.anyio @@ -698,3 +699,65 @@ assert "question" in text assert "answer" in text assert "╭" not in text and "│" not in text + + +# ─── reading mode (borderless render for raw-terminal copy) ────────────────── + + +@pytest.mark.anyio +async def test_reading_mode_strips_bubble_borders_in_place() -> None: + """set_reading_mode(True) re-renders every visible item without Panel chrome + (and back with it on False), in place — so a raw Shift+drag + Ctrl+Shift+C in + a terminal without OSC 52 copies clean text. Markdown answers keep raw + fences (no code-block Panels).""" + async with NaviCodeTui(new_session=True).run_test(size=(100, 24)) as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.add_user_message("Hello world") + chat.handle_ws_event({"type": "stream_start"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "Answer with ```py\nx=1\n``` block"}) + chat.handle_ws_event({"type": "stream_end", "content": "x"}) + await pilot.pause() + + widgets = list(chat._widgets.values()) + w_user = next(w for w in widgets if w._item.kind == "user_message") + w_ans = next(w for w in widgets if w._item.kind == "assistant_message") + + # Normal mode: panels with borders. + assert "╭" in w_user._build_content().plain + assert "╭" in w_ans._build_content().plain + + # Enter reading mode: borders gone, content intact, scroll preserved. + scroll_before = chat.scroll_y + chat.set_reading_mode(True) + await pilot.pause() + user_plain = w_user._build_content().plain + ans_plain = w_ans._build_content().plain + assert "╭" not in user_plain and "│" not in user_plain + assert "Hello world" in user_plain + # Raw markdown fences preserved (no code-block Panel borders). + assert "```py" in ans_plain and "╭" not in ans_plain + assert chat.scroll_y == scroll_before + + # Exit reading mode: borders return. + chat.set_reading_mode(False) + await pilot.pause() + assert "╭" in w_user._build_content().plain + assert "╭" in w_ans._build_content().plain + + +@pytest.mark.anyio +async def test_new_items_in_reading_mode_are_borderless() -> None: + """Items streamed in while reading mode is on mount without bubble borders.""" + async with NaviCodeTui(new_session=True).run_test(size=(100, 24)) as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + chat.set_reading_mode(True) + await pilot.pause() + chat.add_user_message("late message") + await pilot.pause() + w = list(chat._widgets.values())[0] + plain = w._build_content().plain + assert "╭" not in plain and "late message" in plain diff --git a/tests/clients/test_message_renderers.py b/tests/clients/test_message_renderers.py index 12ffbf2..4730b5b 100644 --- a/tests/clients/test_message_renderers.py +++ b/tests/clients/test_message_renderers.py @@ -64,4 +64,41 @@ # Raw markdown syntax is preserved for user input. assert "## not a heading" in out assert "**bold**" in out - assert str(renderer.render({"type": "user_message", "content": "x"}).title) == "You" \ No newline at end of file + assert str(renderer.render({"type": "user_message", "content": "x"}).title) == "You" + +# ─── reading mode (render_plain: borderless for raw-terminal copy) ──────────── + + +def test_assistant_render_plain_returns_raw_markdown_without_borders() -> None: + """In reading mode an assistant answer is raw markdown text, not a Panel. + + Rich Markdown would wrap fenced code blocks in their own Panels, leaking + borders into a raw-terminal (Shift+drag + Ctrl+Shift+C) copy. render_plain + returns the raw text so fences stay as ```…``` and nothing borders remain. + """ + from rich.panel import Panel + from rich.text import Text + + set_active_theme("gnexus-dark") + renderer = AssistantMessageRenderer() + r = renderer.render_plain( + {"type": "assistant_message", "content": "## Result\n\n```python\nx = 1\n```"} + ) + assert isinstance(r, Text) + assert not isinstance(r, Panel) + assert "## Result" in r.plain + assert "```python" in r.plain + assert "x = 1" in r.plain + + +def test_user_render_plain_strips_panel_to_text() -> None: + """User message render_plain is the bare text, no bubble border/title.""" + from rich.panel import Panel + from rich.text import Text + + set_active_theme("gnexus-dark") + renderer = UserMessageRenderer() + r = renderer.render_plain({"type": "user_message", "content": "Hello world"}) + assert isinstance(r, Text) + assert not isinstance(r, Panel) + assert r.plain == "Hello world" diff --git a/tests/clients/test_render_plain.py b/tests/clients/test_render_plain.py new file mode 100644 index 0000000..0ecc24a --- /dev/null +++ b/tests/clients/test_render_plain.py @@ -0,0 +1,66 @@ +"""Reading-mode (borderless) rendering: registry.render_plain drops Panel chrome. + +Every Panel-based renderer must unwrap to a borderless body so a raw terminal +Shift+drag + Ctrl+Shift+C copies clean text (no ``│``/``╭``) in terminals +without OSC 52 support (e.g. GNOME Console). +""" + +from __future__ import annotations + +import pytest +from rich.panel import Panel +from rich.padding import Padding + +from clients.terminal.tui.renderers import default_registry +from clients.terminal.tui.themes import set_active_theme + +set_active_theme("gnexus-dark") + + +def _is_bordered(renderable) -> bool: + """True if the renderable (or a Padding-wrapped Panel) still carries a border.""" + if isinstance(renderable, Panel): + return True + if isinstance(renderable, Padding) and isinstance(renderable.renderable, Panel): + return True + return False + + +@pytest.mark.parametrize( + "msg", + [ + {"type": "user_message", "content": "hi"}, + {"type": "assistant_message", "content": "## H\n\n```py\nx=1\n```"}, + {"type": "thinking_block", "content": "reasoning"}, + {"type": "tool_started", "tool": "foo", "args": {"a": 1}}, + {"type": "tool_call", "tool": "foo", "result": "bar", "success": True}, + {"type": "tool_started", "tool": "filesystem", "args": {"action": "read", "path": "x.py"}}, + {"type": "tool_call", "tool": "filesystem", "result": "ok", "success": True, "args": {"action": "read"}}, + {"type": "tool_started", "tool": "spawn_agent", "args": {"task": "do thing"}}, + {"type": "tool_call", "tool": "spawn_agent", "result": "done", "success": True}, + {"type": "tool_started", "tool": "todo", "args": {}}, + {"type": "tool_call", "tool": "todo", "result": "plan set", "success": True}, + {"type": "error", "message": "boom"}, + {"type": "planning_status", "label": "Analysis"}, + {"type": "plan_ready", "plan": "## Plan\nstep 1"}, + {"type": "turn_meta", "elapsed_seconds": 3}, + {"type": "context_summary", "content": "summarized"}, + {"type": "recall_update", "status": "fired"}, + {"type": "diff", "content": "--- a\n+++ b\n@@\n-x\n+y\n", "path": "f"}, + {"type": "artifact", "content": "art"}, + {"type": "plain", "content": "raw"}, + ], +) +def test_render_plain_has_no_panel_border(msg) -> None: + reg = default_registry() + r = reg.render_plain(msg) + assert not _is_bordered(r), f"render_plain for {msg['type']} still bordered: {r!r}" + + +def test_render_plain_user_text_is_verbatim() -> None: + reg = default_registry() + from rich.text import Text + + r = reg.render_plain({"type": "user_message", "content": "Hello world"}) + assert isinstance(r, Text) + assert r.plain == "Hello world" diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index ea43430..65bf64a 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -965,3 +965,43 @@ await pilot.press("ctrl+shift+down") await pilot.pause() assert chat.scroll_y > up_y + + +@pytest.mark.anyio +async def test_reading_mode_hides_chrome_around_chat() -> None: + """Ctrl+R toggles reading mode: the right column, input prompt, and status + bar are hidden and the chat panel loses its outer border, so only the + message column remains — letting Shift+drag select just the conversation + for Ctrl+Shift+C (raw terminal copy) in terminals without OSC 52.""" + async with NaviCodeTui(new_session=True).run_test(size=(80, 24)) as pilot: + await pilot.pause() + app = pilot.app + right = app.query_one("#tui-right-column") + input_box = app.query_one("InputBox") + status_bar = app.query_one("StatusBar") + chat = app.query_one("ChatPanel") + + # Initially the full UI is shown. + assert not app.has_class("reading-mode") + assert right.styles.display != "none" + assert input_box.styles.display != "none" + assert status_bar.styles.display != "none" + + # Ctrl+R enters reading mode. + await pilot.press("ctrl+r") + await pilot.pause() + assert app.has_class("reading-mode") + assert right.styles.display == "none" + assert input_box.styles.display == "none" + assert status_bar.styles.display == "none" + # The chat panel's outer border is dropped so it isn't part of the copy. + # ``border: none`` resolves to an empty border type ("") on each edge. + assert chat.styles.border_top[0] == "" + + # Ctrl+R again restores the full UI. + await pilot.press("ctrl+r") + await pilot.pause() + assert not app.has_class("reading-mode") + assert right.styles.display != "none" + assert input_box.styles.display != "none" + assert status_bar.styles.display != "none"