"""Unit tests for message widgets markup safety."""

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from rich.style import Style
from textual.app import App, ComposeResult
from textual.content import Content

from deepagents_code import theme
from deepagents_code.input import INPUT_HIGHLIGHT_PATTERN
from deepagents_code.widgets.messages import (
    AppMessage,
    AssistantMessage,
    DiffMessage,
    ErrorMessage,
    QueuedUserMessage,
    SkillMessage,
    SummarizationMessage,
    ToolCallMessage,
    UserMessage,
    _MutedRichMarkdown,
    _strip_frontmatter,
    _strip_prompt_prefix,
    _strip_success_exit_line,
)

# Content that previously caused MarkupError crashes
MARKUP_INJECTION_CASES = [
    "[foo] bar [baz]",
    "}, [/* deps */]);",
    "array[0] = value[1]",
    "[bold]not markup[/bold]",
    "[/dim]",
    "const x = arr[i];",
    "[unclosed bracket",
    "nested [[brackets]]",
]


class TestUserMessageMarkupSafety:
    """Test UserMessage handles content with brackets safely."""

    @pytest.mark.parametrize("content", MARKUP_INJECTION_CASES)
    def test_user_message_no_markup_error(self, content: str) -> None:
        """UserMessage should not raise MarkupError on bracket content."""
        msg = UserMessage(content)
        assert msg._content == content

    def test_user_message_preserves_content_exactly(self) -> None:
        """UserMessage should preserve user content without modification."""
        content = "[bold]test[/bold] with [brackets]"
        msg = UserMessage(content)
        assert msg._content == content


class TestErrorMessageMarkupSafety:
    """Test ErrorMessage handles content with brackets safely."""

    @pytest.mark.parametrize("content", MARKUP_INJECTION_CASES)
    def test_error_message_no_markup_error(self, content: str) -> None:
        """ErrorMessage should not raise MarkupError on bracket content."""
        # Instantiation should not raise - this is the key test
        ErrorMessage(content)

    def test_error_message_instantiates(self) -> None:
        """ErrorMessage should instantiate with bracket content."""
        error = "Failed: array[0] is undefined"
        msg = ErrorMessage(error)
        assert msg is not None

    def test_error_message_has_prefix_and_body(self) -> None:
        """ErrorMessage content should have `'Error: '` prefix followed by the body."""
        msg = ErrorMessage("something broke")
        rendered = msg.render()
        assert isinstance(rendered, Content)
        assert rendered.plain == "Error: something broke"

    def test_error_message_accepts_content_with_link_span(self) -> None:
        """Pre-built `Content` with `link` spans passes through to render output."""
        from textual.style import Style as TStyle

        url = "https://docs.langchain.com/oss/python/deepagents/code/providers"
        body = Content.assemble(
            "see ",
            (url, TStyle(underline=True, link=url)),
        )
        rendered = ErrorMessage(body).render()
        assert isinstance(rendered, Content)
        links = [
            getattr(span.style, "link", None)
            for span in rendered.spans
            if getattr(span.style, "link", None)
        ]
        assert links == [url]
        assert rendered.plain == f"Error: see {url}"

    def test_error_message_click_on_link_opens_url(self) -> None:
        """Click on a `link`-styled span should route through `open_style_link`."""
        from types import SimpleNamespace

        msg = ErrorMessage("see https://example.com")
        event = SimpleNamespace(
            style=SimpleNamespace(link="https://example.com"),
            app=SimpleNamespace(notify=MagicMock()),
            stop=MagicMock(),
        )
        with patch(
            "deepagents_code.widgets.messages.open_style_link"
        ) as mock_open_link:
            msg.on_click(event)  # ty: ignore

        mock_open_link.assert_called_once_with(event)

    def test_error_message_click_off_link_no_ops(self) -> None:
        """Click outside a link span should not perform timestamp side effects."""
        from types import SimpleNamespace

        msg = ErrorMessage("plain error, no URL")
        event = SimpleNamespace(
            style=SimpleNamespace(link=None),
            app=SimpleNamespace(notify=MagicMock()),
            stop=MagicMock(),
        )
        with patch(
            "deepagents_code.widgets.messages.open_style_link"
        ) as mock_open_link:
            msg.on_click(event)  # ty: ignore

        mock_open_link.assert_not_called()


class TestAppMessageMarkupSafety:
    """Test AppMessage handles content with brackets safely."""

    @pytest.mark.parametrize("content", MARKUP_INJECTION_CASES)
    def test_app_message_no_markup_error(self, content: str) -> None:
        """AppMessage should not raise MarkupError on bracket content."""
        # Instantiation should not raise - this is the key test
        AppMessage(content)

    def test_app_message_instantiates(self) -> None:
        """AppMessage should instantiate with bracket content."""
        content = "Status: processing items[0-10]"
        msg = AppMessage(content)
        assert msg is not None

    def test_app_message_str_gets_dim_italic(self) -> None:
        """String input should be rendered as dim italic `Content`."""
        msg = AppMessage("hello")
        rendered = msg._Static__content  # ty: ignore
        assert isinstance(rendered, Content)
        assert rendered.plain == "hello"

    def test_app_message_content_passthrough(self) -> None:
        """Pre-styled `Content` should pass through unchanged."""
        pre = Content.styled("styled", "bold cyan")
        msg = AppMessage(pre)
        rendered = msg._Static__content  # ty: ignore
        assert rendered is pre

    def test_app_message_markdown_uses_muted_wrapper(self) -> None:
        """`markdown=True` should route through `_MutedRichMarkdown`."""
        msg = AppMessage("### heading", markdown=True)
        rendered = msg._Static__content  # ty: ignore
        assert isinstance(rendered, _MutedRichMarkdown)

    def test_app_message_markdown_requires_string(self) -> None:
        """`markdown=True` with non-string input should raise `TypeError`."""
        pre = Content.styled("styled", "bold")
        with pytest.raises(TypeError):
            AppMessage(pre, markdown=True)


class TestMutedRichMarkdown:
    """Tests for the muted markdown theme wrapper."""

    _DOC = (
        "### Installed optional dependencies\n"
        "\n"
        "| Extra | Package | Version |\n"
        "| --- | --- | --- |\n"
        "| anthropic | langchain-anthropic | 1.4.1 |\n"
    )

    @staticmethod
    def _render(renderable: object) -> str:
        import io

        from rich.console import Console

        console = Console(
            file=io.StringIO(),
            force_terminal=True,
            color_system="truecolor",
            width=80,
            legacy_windows=False,
        )
        console.print(renderable)
        return console.file.getvalue()  # ty: ignore

    def test_strips_heading_and_table_colors(self) -> None:
        """Muted wrapper should drop magenta/cyan from headings and tables."""
        muted = self._render(_MutedRichMarkdown(self._DOC))

        # Some Rich versions paint headings/tables magenta/cyan by default.
        # The wrapper should not emit those hues regardless of Rich's baseline.
        assert "\x1b[35m" not in muted
        assert ";35m" not in muted
        assert "\x1b[36m" not in muted
        assert ";36m" not in muted

    def test_applies_dim_to_body_and_headings(self) -> None:
        """Muted wrapper should layer `dim` onto body, headings, and tables."""
        muted = self._render(_MutedRichMarkdown(self._DOC))

        # `dim` is ANSI code 2. Heading should be bold+dim ("1;2"),
        # plain cells should be dim ("2m"), and both must be present.
        assert "\x1b[1;2m" in muted
        assert "\x1b[2m" in muted

    def test_render_failure_falls_back_to_plain_source(self) -> None:
        """A crash inside Rich markdown rendering must not escape.

        If the themed render path raises, the wrapper should emit the raw
        source so the chat view stays up; the full stream would otherwise
        tear down when Textual asks the widget for content.
        """
        wrapped = _MutedRichMarkdown("# heading\n\nbody")
        # Force the inner Markdown renderable to raise when consumed.
        wrapped._markdown = MagicMock()
        wrapped._markdown.__rich_console__ = MagicMock(side_effect=RuntimeError("boom"))

        rendered = self._render(wrapped)
        assert "body" in rendered


class TestAssistantMessageMarkdownRendering:
    """Tests for assistant markdown render lifecycle."""

    async def test_write_initial_content_uses_full_markdown_update(self) -> None:
        """Preloaded assistant messages should not keep stream state alive."""
        msg = AssistantMessage("```python\nprint('hello')\n```")
        markdown = MagicMock()
        markdown.update = AsyncMock()
        msg._markdown = markdown

        await msg.write_initial_content()

        markdown.update.assert_awaited_once_with("```python\nprint('hello')\n```")
        assert msg._stream is None

    async def test_stop_stream_rerenders_complete_markdown(self) -> None:
        """Completed streams should get a full parse after incremental updates."""
        msg = AssistantMessage()
        markdown = MagicMock()
        markdown.update = AsyncMock()
        stream = MagicMock()
        stream.stop = AsyncMock()
        msg._markdown = markdown
        msg._stream = stream
        msg._content = "```python\nprint('wrapped text')\n```"

        await msg.stop_stream()

        stream.stop.assert_awaited_once_with()
        markdown.update.assert_awaited_once_with(
            "```python\nprint('wrapped text')\n```"
        )
        assert msg._stream is None

    async def test_set_content_replaces_stream_with_single_update(self) -> None:
        """Replacing content should cancel the stream and update exactly once."""
        msg = AssistantMessage()
        markdown = MagicMock()
        markdown.update = AsyncMock()
        stream = MagicMock()
        stream.stop = AsyncMock()
        msg._markdown = markdown
        msg._stream = stream
        msg._content = "old streamed content"

        await msg.set_content("```python\nnew content\n```")

        stream.stop.assert_awaited_once_with()
        markdown.update.assert_awaited_once_with("```python\nnew content\n```")
        assert msg._stream is None
        assert msg._content == "```python\nnew content\n```"


class _AssistantMessageApp(App[None]):
    """Minimal app that mounts an AssistantMessage for timer-based tests."""

    def compose(self) -> ComposeResult:
        widget = AssistantMessage()
        widget.id = "assistant"
        yield widget


class TestAssistantMessageStreamCoalescing:
    """Tests for the throttled streaming flush that keeps input responsive."""

    async def test_append_buffers_until_flush(self) -> None:
        """Tokens accumulate in `_content` but defer the markdown write."""
        async with _AssistantMessageApp().run_test() as pilot:
            msg = pilot.app.query_one("#assistant", AssistantMessage)
            stream = MagicMock()
            stream.write = AsyncMock()
            msg._stream = stream

            await msg.append_content("hello ")
            await msg.append_content("world")

            # No immediate write — tokens are buffered for the timer.
            stream.write.assert_not_awaited()
            assert msg._content == "hello world"
            assert msg._pending_append == "hello world"
            assert msg._flush_timer is not None

    async def test_timer_flushes_coalesced_text_once(self) -> None:
        """The throttled timer writes buffered tokens as a single fragment."""
        async with _AssistantMessageApp().run_test() as pilot:
            msg = pilot.app.query_one("#assistant", AssistantMessage)
            stream = MagicMock()
            stream.write = AsyncMock()
            msg._stream = stream

            await msg.append_content("foo")
            await msg.append_content("bar")
            await asyncio.sleep(msg._STREAM_FLUSH_INTERVAL * 2)
            await pilot.pause()

            stream.write.assert_awaited_once_with("foobar")
            assert msg._pending_append == ""

    async def test_stop_stream_flushes_and_cancels_timer(self) -> None:
        """Stopping the stream drains buffered text and clears the timer."""
        async with _AssistantMessageApp().run_test() as pilot:
            msg = pilot.app.query_one("#assistant", AssistantMessage)
            markdown = MagicMock()
            markdown.update = AsyncMock()
            stream = MagicMock()
            stream.write = AsyncMock()
            stream.stop = AsyncMock()
            msg._markdown = markdown
            msg._stream = stream

            await msg.append_content("partial")
            await msg.stop_stream()

            stream.write.assert_awaited_once_with("partial")
            stream.stop.assert_awaited_once_with()
            assert msg._flush_timer is None
            assert msg._pending_append == ""

    async def test_set_content_drains_and_cancels_active_timer(self) -> None:
        """`set_content` cancels a live flush timer and drops the buffer."""
        async with _AssistantMessageApp().run_test() as pilot:
            msg = pilot.app.query_one("#assistant", AssistantMessage)
            markdown = MagicMock()
            markdown.update = AsyncMock()
            stream = MagicMock()
            stream.write = AsyncMock()
            stream.stop = AsyncMock()
            msg._markdown = markdown
            msg._stream = stream

            await msg.append_content("buffered")
            assert msg._flush_timer is not None

            await msg.set_content("replacement")
            # Give a stale timer the chance to fire if it was not cancelled.
            await asyncio.sleep(msg._STREAM_FLUSH_INTERVAL * 2)
            await pilot.pause()

            assert msg._flush_timer is None
            assert msg._pending_append == ""
            # Buffered token must not bleed into the replacement render.
            stream.write.assert_not_awaited()
            markdown.update.assert_awaited_once_with("replacement")

    async def test_timer_created_once_across_appends(self) -> None:
        """Repeated appends reuse a single flush timer rather than spawning many."""
        async with _AssistantMessageApp().run_test() as pilot:
            msg = pilot.app.query_one("#assistant", AssistantMessage)
            stream = MagicMock()
            stream.write = AsyncMock()
            msg._stream = stream

            await msg.append_content("a")
            timer = msg._flush_timer
            assert timer is not None

            await msg.append_content("b")
            await msg.append_content("c")

            assert msg._flush_timer is timer

    async def test_flush_drains_successive_batches(self) -> None:
        """Each flush writes the latest batch; an empty buffer is a no-op."""
        async with _AssistantMessageApp().run_test() as pilot:
            msg = pilot.app.query_one("#assistant", AssistantMessage)
            stream = MagicMock()
            stream.write = AsyncMock()
            msg._stream = stream

            await msg.append_content("first")
            await msg._flush_pending_append()
            stream.write.assert_awaited_once_with("first")

            # Idle tick with nothing buffered must not write again.
            await msg._flush_pending_append()
            assert stream.write.await_count == 1

            await msg.append_content("second")
            await msg._flush_pending_append()
            assert stream.write.await_count == 2
            stream.write.assert_awaited_with("second")

    async def test_append_empty_text_is_noop(self) -> None:
        """Empty tokens neither buffer text nor arm the flush timer."""
        async with _AssistantMessageApp().run_test() as pilot:
            msg = pilot.app.query_one("#assistant", AssistantMessage)

            await msg.append_content("")

            assert msg._flush_timer is None
            assert msg._pending_append == ""
            assert msg._content == ""

    async def test_flush_restores_buffer_when_write_fails(self) -> None:
        """A failed write keeps the buffer for retry and never escapes the timer."""
        async with _AssistantMessageApp().run_test() as pilot:
            msg = pilot.app.query_one("#assistant", AssistantMessage)
            stream = MagicMock()
            stream.write = AsyncMock(side_effect=RuntimeError("render boom"))
            msg._stream = stream

            await msg.append_content("kept")
            # Must not raise: an escaping exception here would crash the app
            # via the Textual timer's exception handler.
            await msg._flush_pending_append()

            stream.write.assert_awaited_once_with("kept")
            assert msg._pending_append == "kept"

            # Text arriving after the failure queues behind the retried fragment.
            await msg.append_content(" more")
            assert msg._pending_append == "kept more"


class TestSummarizationMessage:
    """Tests for summarization notification widget."""

    def test_summarization_message_instantiates(self) -> None:
        """SummarizationMessage should instantiate with default content."""
        msg = SummarizationMessage()
        assert msg is not None

    def test_summarization_message_is_app_message(self) -> None:
        """SummarizationMessage should be treated like an AppMessage."""
        msg = SummarizationMessage()
        assert isinstance(msg, AppMessage)

    def test_summarization_message_str_input(self) -> None:
        """String input should be rendered as bold cyan `Content`."""
        msg = SummarizationMessage("custom text")
        rendered = msg._Static__content  # ty: ignore
        assert isinstance(rendered, Content)
        assert rendered.plain == "custom text"

    def test_summarization_message_content_passthrough(self) -> None:
        """Pre-styled `Content` should pass through unchanged."""
        pre = Content.styled("pre-styled", "bold cyan")
        msg = SummarizationMessage(pre)
        rendered = msg._Static__content  # ty: ignore
        assert rendered is pre


class TestToolCallMessageMarkupSafety:
    """Test ToolCallMessage handles output with brackets safely."""

    @pytest.mark.parametrize("output", MARKUP_INJECTION_CASES)
    def test_tool_output_no_markup_error(self, output: str) -> None:
        """ToolCallMessage should not raise MarkupError on bracket output."""
        msg = ToolCallMessage("test_tool", {"arg": "value"})
        msg._output = output
        assert msg._output == output

    def test_tool_call_with_bracket_args(self) -> None:
        """ToolCallMessage should handle args containing brackets."""
        args = {"code": "arr[0] = val[1]", "file": "test.py"}
        msg = ToolCallMessage("write_file", args)
        assert msg._args == args

    def test_tool_header_escapes_markup_in_label(self) -> None:
        """Task description widget should safely render bracket content."""
        msg = ToolCallMessage(
            "task",
            {"description": "Search for closing tag [/dim] mismatches"},
        )

        # Header shows subagent type; description is a separate dim widget.
        widgets = list(msg.compose())
        # Second widget is the task description line (Static with dim style).
        # Content.styled() produces a Content object stored on the Static.
        content = widgets[1]._Static__content  # ty: ignore
        assert "[/dim]" in content.plain

    def test_tool_args_line_escapes_markup_values(self) -> None:
        """Inline args line should escape bracket content in argument values."""
        msg = ToolCallMessage(
            "custom_tool",
            {"pattern": "[foo]", "note": "raw [/dim] text"},
        )

        widgets = list(msg.compose())
        args_widget = widgets[1]
        content = args_widget._Static__content  # ty: ignore
        assert isinstance(content, Content)
        assert "[foo]" in content.plain
        assert "[/dim]" in content.plain

    def test_ask_user_args_are_collapsed_by_default(self) -> None:
        """`ask_user` should show compact header without inline raw args."""
        msg = ToolCallMessage(
            "ask_user",
            {
                "questions": [
                    {
                        "question": 'Your prompt is just "hi" - what should I build?',
                        "type": "text",
                    }
                    for _ in range(4)
                ]
            },
        )

        widgets = list(msg.compose())
        visible = []
        for widget in widgets[:3]:
            content = widget._Static__content  # ty: ignore
            visible.append(content.plain if isinstance(content, Content) else content)
        visible_plain = "\n".join(visible)

        assert "ask_user(4 questions)" in visible_plain
        assert "Your prompt is just" not in visible_plain
        assert msg.has_expandable_args is True


class TestToolCallMessageTodos:
    """Tests for `write_todos` output formatting."""

    def test_todo_preview_truncates_long_content(self) -> None:
        """Collapsed todo preview should keep the compact character limit."""
        long = "Implement " + "very detailed authentication flow " * 4
        msg = ToolCallMessage("write_todos")

        result = msg._format_todos_output(
            repr([{"content": long, "status": "in_progress"}]),
            is_preview=True,
        )

        assert result.content.plain.endswith("...")
        assert long not in result.content.plain
        assert result.truncation == "full todo text"

    async def test_todo_collapsed_short_output_uses_preview_formatting(self) -> None:
        """Collapsed todos should truncate even when raw output fits generically."""
        from textual.app import App, ComposeResult

        long = "Implement " + "very detailed authentication flow " * 3
        assert len(long) > 70
        output = repr([{"content": long, "status": "pending"}])
        assert len(output) < ToolCallMessage._PREVIEW_CHARS

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("write_todos")

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_success(output)
            await pilot.pause()

            assert app.msg._preview_widget is not None
            assert app.msg._hint_widget is not None
            content = app.msg._preview_widget._Static__content  # ty: ignore
            assert isinstance(content, Content)
            assert "..." in content.plain
            assert long not in content.plain
            assert app.msg._hint_widget.display is True

    async def test_todo_short_fully_visible_output_does_not_expand(self) -> None:
        """Clicking fully visible todo output should not show a collapse hint."""
        from textual.app import App, ComposeResult

        output = repr([{"content": "Write tests", "status": "pending"}])

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("write_todos")

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_success(output)
            await pilot.pause()

            assert app.msg._hint_widget is not None
            assert app.msg._hint_widget.display is False

            app.msg.toggle_output()
            await pilot.pause()

            assert app.msg._expanded is False
            assert app.msg._hint_widget.display is False

    def test_todo_expanded_shows_full_wrapped_content(self) -> None:
        """Expanded todo output should wrap long content without truncating."""
        long = (
            "Implement the new authentication flow using OAuth2 with PKCE for "
            "the CLI login command and preserve readable todo output"
        )
        msg = ToolCallMessage("write_todos")

        result = msg._format_todos_output(
            repr([{"content": long, "status": "in_progress"}]),
            is_preview=False,
        )
        plain = result.content.plain

        assert "..." not in plain
        assert long.replace(" ", "") == plain.split("active ", 1)[1].replace(
            "\n             ",
            "",
        ).replace(" ", "")
        assert "\n             " in plain

    def test_todo_expanded_continuation_aligns_content_column(self) -> None:
        """Wrapped continuation lines should align under the todo text."""
        long = "Write integration tests for " + "token refresh revocation " * 4
        msg = ToolCallMessage("write_todos")

        result = msg._format_todos_output(
            repr([{"content": long, "status": "pending"}]),
            is_preview=False,
        )
        lines = result.content.plain.splitlines()
        todo_start = next(
            index for index, line in enumerate(lines) if "todo   " in line
        )

        assert len(lines) > todo_start + 1
        assert lines[todo_start + 1].startswith("             ")


class _ToolMsgApp(App[None]):
    """Single-`ToolCallMessage` Textual app for pilot-driven tests."""

    def __init__(self, tool_name: str, args: dict | None = None) -> None:
        super().__init__()
        self.msg = ToolCallMessage(tool_name, args)

    def compose(self) -> ComposeResult:
        yield self.msg


def _tool_msg_app(tool_name: str, args: dict | None = None) -> _ToolMsgApp:
    """Build a single-`ToolCallMessage` Textual app for pilot-driven tests.

    Args:
        tool_name: Tool name the message represents.
        args: Optional tool-call arguments.

    Returns:
        An unmounted `App` exposing the message as `app.msg`.
    """
    return _ToolMsgApp(tool_name, args)


class TestToolCallMessageOutputGutter:
    """The output glyph lives in a fixed gutter so wrapped lines stay aligned."""

    async def test_glyph_in_gutter_not_baked_into_content(self) -> None:
        """The output marker renders in its own gutter column, not in content.

        Regression: when a single long output line soft-wraps, the wrapped
        remainder must not fall under the glyph. Keeping the glyph in a fixed
        gutter (instead of baked into the first content line) lets the content
        widget own a single hanging indent for every wrapped line.
        """
        from deepagents_code.config import get_glyphs

        # Two logical lines; the first is long enough to soft-wrap in a terminal.
        output = (
            "[stderr] fatal: ambiguous argument 'main..branch': unknown revision "
            "or path not in the working tree.\n[stderr] Use '--' to separate paths."
        )

        app = _tool_msg_app("execute", {"command": "git diff main..branch"})
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_success(output)
            await pilot.pause()

            glyph = get_glyphs().output_prefix
            assert app.msg._preview_widget is not None
            content = app.msg._preview_widget._Static__content  # ty: ignore

            # Content is bare: no glyph, and no hand-rolled hanging indent on
            # any logical line (alignment is owned by the gutter layout).
            assert glyph not in content.plain
            assert all(not line.startswith(" ") for line in content.plain.split("\n"))

            # The glyph renders exactly once, in the gutter beside the content.
            assert app.msg._preview_row is not None
            assert app.msg._preview_row.display is True
            gutters = app.msg._preview_row.query(".tool-output-gutter")
            assert len(gutters) == 1
            gutter_content = gutters.first()._Static__content  # ty: ignore
            assert gutter_content == glyph

    async def test_collapsed_preview_preserves_uniform_leading_indent(self) -> None:
        """Collapsed preview keeps line 0's indent so indented rows align.

        Regression: the preview branch pre-stripped the output, lstripping the
        first line only while continuation lines kept their indent. Uniformly
        indented output (e.g. `git branch -r`, which prefixes every branch with
        two spaces) then rendered with line 0 flush and the rest indented. The
        formatter must preserve the shared leading indent across all rows.
        """
        # Mirror `git branch -r`: every row indented by two spaces, > preview
        # line budget so the collapsed preview is shown.
        output = "\n".join(f"  origin/branch-{i}" for i in range(8))

        app = _tool_msg_app("execute", {"command": "git branch -r"})
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_success(output)
            await pilot.pause()

            assert app.msg._preview_widget is not None
            assert app.msg._expanded is False
            content = app.msg._preview_widget._Static__content  # ty: ignore

            preview_lines = content.plain.split("\n")
            # Every visible row — including the first — keeps git's two-space
            # indent, so they share a left edge beside the glyph gutter.
            assert preview_lines
            assert all(line.startswith("  origin/") for line in preview_lines)


class TestToolCallMessageSearchOutput:
    """Tests for grep/glob result formatting in `_format_search_output`."""

    def test_glob_list_output_has_no_hardcoded_indent(self) -> None:
        """Glob (list) results must not carry a hardcoded leading indent.

        Alignment is owned by the output gutter layout; the formatter emits
        bare paths so results aren't double-indented under the output marker.
        """
        msg = ToolCallMessage("glob", {"pattern": "**/*.py"})
        result = msg._format_search_output(
            "['/tmp/zzz_a.py', '/tmp/zzz_b.py']", is_preview=False
        )
        lines = result.content.plain.split("\n")
        assert lines
        assert all(not line.startswith(" ") for line in lines)

    def test_grep_line_output_has_no_hardcoded_indent(self) -> None:
        """Grep (line-based) results must not carry a hardcoded leading indent.

        This is a distinct branch from the glob list path: `ast.literal_eval`
        fails for grep output, so it falls through to line-based formatting.
        """
        msg = ToolCallMessage("grep", {"pattern": "x"})
        result = msg._format_search_output(
            "file.py:1:match one\nfile.py:2:match two", is_preview=False
        )
        assert result.content.plain.split("\n") == [
            "file.py:1:match one",
            "file.py:2:match two",
        ]

    def test_grep_preview_truncates_long_single_line(self) -> None:
        """Grep previews should cap long single-line output by characters."""
        msg = ToolCallMessage("grep", {"pattern": "x"})
        output = "file.py:1:" + "x" * ToolCallMessage._PREVIEW_CHARS

        result = msg._format_search_output(output, is_preview=True)

        # The visible slice is exactly the leading char budget of the input,
        # not just any string of the right length.
        assert result.content.plain == output[: ToolCallMessage._PREVIEW_CHARS]
        assert len(result.content.plain) == ToolCallMessage._PREVIEW_CHARS
        assert result.truncation is not None
        assert result.truncation.endswith("more chars")

    def test_grep_preview_truncates_long_multiline_by_chars(self) -> None:
        """Grep previews should cap long multi-line output by characters."""
        msg = ToolCallMessage("grep", {"pattern": "x"})
        # Two wide lines, each under the budget but together over it, so both
        # become rows (no hidden line) and the second is char-sliced — forcing
        # the char hint over the line hint. Width derives from the budget.
        char_run = ToolCallMessage._PREVIEW_CHARS // 2
        lines = [f"file.py:{index}:" + "x" * char_run for index in range(2)]

        result = msg._format_search_output("\n".join(lines), is_preview=True)

        assert len(result.content.plain) == ToolCallMessage._PREVIEW_CHARS
        assert result.truncation is not None
        assert result.truncation.endswith("more chars")

    def test_glob_preview_truncates_long_paths_by_chars(self) -> None:
        """Glob previews cap wide path lists by characters with a file hint."""
        msg = ToolCallMessage("glob", {"pattern": "**/*.py"})
        # Two paths that each fit under the budget but together overflow it, so
        # both become rows (no hidden line) and the second is char-sliced —
        # forcing the char hint rather than the file-count hint.
        long_path = "/tmp/" + "z" * (ToolCallMessage._PREVIEW_CHARS // 2) + ".py"
        output = repr([long_path, long_path])

        result = msg._format_search_output(output, is_preview=True)

        assert len(result.content.plain) == ToolCallMessage._PREVIEW_CHARS
        assert result.truncation is not None
        assert result.truncation.endswith("more chars")

    def test_grep_preview_truncates_by_line_count(self) -> None:
        """Grep previews over the line cap report hidden lines, not chars."""
        msg = ToolCallMessage("grep", {"pattern": "x"})
        output = "\n".join(f"file.py:{index}:hit" for index in range(8))

        result = msg._format_search_output(output, is_preview=True)

        # 8 short lines, preview cap is 5 → 3 hidden, counted as lines.
        assert result.truncation == "3 more lines"

    def test_glob_preview_truncates_by_file_count(self) -> None:
        """Glob previews over the line cap report hidden files, not lines."""
        msg = ToolCallMessage("glob", {"pattern": "**/*.py"})
        paths = [f"/tmp/result_{index}.py" for index in range(8)]

        result = msg._format_search_output(repr(paths), is_preview=True)

        # The "files" unit is what distinguishes the glob path from grep.
        assert result.truncation == "3 more files"

    def test_grep_preview_prefers_line_count_when_both_caps_hit(self) -> None:
        """When both caps trip, the hidden-line count wins over chars."""
        msg = ToolCallMessage("grep", {"pattern": "x"})
        output = "\n".join(f"file.py:{index}:" + "y" * 100 for index in range(10))

        result = msg._format_search_output(output, is_preview=True)

        assert result.truncation is not None
        assert result.truncation.endswith("more lines")

    def test_search_full_output_is_untruncated(self) -> None:
        """Non-preview formatting returns every row with no truncation hint."""
        msg = ToolCallMessage("grep", {"pattern": "x"})
        lines = [f"file.py:{index}:" + "z" * 200 for index in range(10)]

        result = msg._format_search_output("\n".join(lines), is_preview=False)

        assert result.truncation is None
        assert result.content.plain.split("\n") == lines


class TestToolCallMessageExpandHint:
    """Tests for the preview/expand hint on collapsed tool output."""

    async def test_long_single_line_search_output_truncates_and_expands(self) -> None:
        """Long single-line grep/glob output should use the shared char cap."""
        from textual.app import App, ComposeResult

        output = "Invalid glob pattern: " + "a" * ToolCallMessage._PREVIEW_CHARS
        assert "\n" not in output
        assert len(output) > ToolCallMessage._PREVIEW_CHARS

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("glob", {"pattern": "**/*.py"})

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_success(output)
            await pilot.pause()

            assert app.msg._hint_widget is not None
            assert app.msg._hint_widget.display is True
            assert app.msg._has_expandable_output() is True
            preview = app.msg._preview_widget._Static__content  # ty: ignore[unresolved-attribute]
            assert len(preview.plain) == ToolCallMessage._PREVIEW_CHARS

            app.msg.toggle_output()
            await pilot.pause()

            assert app.msg._expanded is True
            assert app.msg._hint_widget.display is True
            full = app.msg._full_widget._Static__content  # ty: ignore[unresolved-attribute]
            assert full.plain == output

    async def test_short_error_force_expanded_has_no_collapse_hint(self) -> None:
        """A short force-expanded error must not show a collapse affordance.

        `set_error` force-expands so the full error is always visible. When the
        error is short enough that the collapsed form would be identical, there
        is nothing to collapse — so no hint, and toggling is a no-op.
        """
        from textual.app import App, ComposeResult

        error = "Error: glob timed out after 20.0s. Try a narrower path."
        assert "\n" not in error
        assert len(error) < ToolCallMessage._PREVIEW_CHARS

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("glob", {"pattern": "**/*.py"})

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_error(error)
            await pilot.pause()

            assert app.msg._expanded is True
            assert app.msg._hint_widget is not None
            assert app.msg._hint_widget.display is False
            assert app.msg._has_expandable_output() is False

            app.msg.toggle_output()
            await pilot.pause()

            # Nothing to collapse — stays expanded with the hint hidden.
            assert app.msg._expanded is True
            assert app.msg._hint_widget.display is False

    async def test_multiline_error_force_expanded_offers_collapse(self) -> None:
        """A long force-expanded error should still offer a collapse affordance.

        The positive counterpart to the short-error case: a multi-line error
        exceeds the line threshold and the formatter truncates it, so a smaller
        collapsed form exists and the collapse hint must appear.
        """
        error = "\n".join(f"line {index} of the traceback" for index in range(10))
        assert error.count("\n") + 1 > ToolCallMessage._PREVIEW_LINES

        app = _tool_msg_app("glob", {"pattern": "**/*.py"})
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_error(error)
            await pilot.pause()

            assert app.msg._expanded is True
            assert app.msg._has_expandable_output() is True
            assert app.msg._hint_widget is not None
            assert app.msg._hint_widget.display is True
            hint = app.msg._hint_widget._Static__content  # ty: ignore
            assert "collapse" in hint.plain

            app.msg.toggle_output()
            await pilot.pause()

            assert app.msg._expanded is False
            assert app.msg._hint_widget.display is True
            collapsed = app.msg._hint_widget._Static__content
            assert "expand" in collapsed.plain

    async def test_long_grep_output_truncates_and_expands(self) -> None:
        """A multi-line grep result should preview-truncate then expand on toggle."""
        output = "\n".join(f"file.py:{index}:hit {index}" for index in range(8))
        assert output.count("\n") + 1 > ToolCallMessage._PREVIEW_LINES

        app = _tool_msg_app("grep", {"pattern": "hit"})
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_success(output)
            await pilot.pause()

            assert app.msg._expanded is False
            assert app.msg._has_expandable_output() is True
            assert app.msg._preview_widget is not None
            assert app.msg._full_widget is not None
            assert app.msg._hint_widget is not None
            assert app.msg._hint_widget.display is True
            hint = app.msg._hint_widget._Static__content  # ty: ignore
            assert "expand" in hint.plain
            # The preview hides the trailing lines.
            preview = app.msg._preview_widget._Static__content  # ty: ignore
            assert "hit 7" not in preview.plain

            app.msg.toggle_output()
            await pilot.pause()

            assert app.msg._expanded is True
            full = app.msg._full_widget._Static__content
            assert "hit 7" in full.plain
            collapsed = app.msg._hint_widget._Static__content
            assert "collapse" in collapsed.plain

    async def test_short_non_todo_output_renders_full_without_hint(self) -> None:
        """Short non-todo output uses non-preview formatting and shows no hint.

        Guards the merged collapsed branch: `is_preview` must stay `False` for
        a non-`write_todos` tool below the size threshold, so the full content
        is shown rather than a truncated preview.
        """
        # Five lines: under `_PREVIEW_LINES` (6) but over the file formatter's
        # own four-line preview cap, so a stray `is_preview=True` would truncate.
        output = "\n".join(f"line {index}" for index in range(5))
        assert output.count("\n") + 1 < ToolCallMessage._PREVIEW_LINES

        app = _tool_msg_app("read_file", {"path": "/tmp/x"})
        async with app.run_test() as pilot:
            await pilot.pause()
            app.msg.set_success(output)
            await pilot.pause()

            assert app.msg._expanded is False
            assert app.msg._has_expandable_output() is False
            assert app.msg._preview_widget is not None
            assert app.msg._hint_widget is not None
            assert app.msg._hint_widget.display is False
            preview = app.msg._preview_widget._Static__content  # ty: ignore
            assert "line 0" in preview.plain
            assert "line 4" in preview.plain


class TestToolCallMessageExpandableArgs:
    """Tests for the `ask_user` expandable-arguments toggle."""

    def test_has_expandable_args_false_for_non_ask_user(self) -> None:
        """Only `ask_user` should expose expandable args."""
        msg = ToolCallMessage("read_file", {"path": "/tmp/x"})
        assert msg.has_expandable_args is False

    def test_has_expandable_args_false_for_ask_user_without_args(self) -> None:
        """Empty args dict should not be expandable."""
        msg = ToolCallMessage("ask_user", {})
        assert msg.has_expandable_args is False

    def test_tool_name_property_exposes_underlying_name(self) -> None:
        """Public `tool_name` property should mirror the constructor arg."""
        msg = ToolCallMessage("ask_user", {"questions": []})
        assert msg.tool_name == "ask_user"

    def test_toggle_args_no_op_before_mount(self) -> None:
        """Calling `toggle_args` before mount should not flip state."""
        msg = ToolCallMessage("ask_user", {"questions": [{"question": "?"}]})
        # Without `on_mount`, widget refs are None — `_update_args_display`
        # short-circuits and the expanded flag should not be flipped either,
        # since the user can't possibly see the result.
        msg.toggle_args()
        assert msg._args_expanded is True  # state flips
        # but rendering is a no-op:
        assert msg._args_widget is None

    async def test_toggle_args_swaps_display_state(self) -> None:
        """`toggle_args` should flip the args widget's display after mount."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage(
                    "ask_user",
                    {"questions": [{"question": "Name?", "type": "text"}]},
                )

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            msg = app.msg

            # Initial state: hint visible, full args hidden.
            assert msg._args_widget is not None
            assert msg._args_hint_widget is not None
            assert msg._args_widget.display is False
            assert msg._args_hint_widget.display is True

            msg.toggle_args()
            await pilot.pause()
            assert msg._args_expanded is True
            assert msg._args_widget.display is True

            msg.toggle_args()
            await pilot.pause()
            assert msg._args_expanded is False
            assert msg._args_widget.display is False

    async def test_on_click_routes_ask_user_to_toggle_args(self) -> None:
        """Clicking an `ask_user` row (no output) should expand args."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage(
                    "ask_user",
                    {"questions": [{"question": "?"}]},
                )

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            msg = app.msg
            event = MagicMock()
            msg.on_click(event)
            await pilot.pause()
            event.stop.assert_called_once()
            assert msg._args_expanded is True

    async def test_toggle_output_does_not_fall_through_to_args(self) -> None:
        """`toggle_output` is strictly about output; args stay collapsed."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage(
                    "ask_user",
                    {"questions": [{"question": "?"}]},
                )

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            msg = app.msg
            msg.toggle_output()
            await pilot.pause()
            assert msg._args_expanded is False


class TestToolCallMessageShellCommand:
    """Test ToolCallMessage shows full shell command for errors.

    When a shell command fails, users need to see the full command to debug.
    The header is truncated for display, but the full command should be
    included in the error output for visibility.
    """

    def test_shell_error_includes_full_command(self) -> None:
        """Error output should include the full command that was executed."""
        long_cmd = "pip install " + " ".join(f"package{i}" for i in range(50))
        assert len(long_cmd) > 120  # Exceeds truncation limit

        msg = ToolCallMessage("execute", {"command": long_cmd})
        msg.set_error("Command not found: pip")

        # The error output should include the full command
        assert long_cmd in msg._output

    def test_shell_error_command_prefix(self) -> None:
        """Error output should have shell prompt prefix."""
        cmd = "echo hello"
        msg = ToolCallMessage("execute", {"command": cmd})
        msg.set_error("Permission denied")

        # Output should have shell prompt prefix
        assert msg._output.startswith("$ ")
        assert cmd in msg._output

    def test_non_shell_error_unchanged(self) -> None:
        """Non-shell tools should not have command prepended."""
        msg = ToolCallMessage("read_file", {"path": "/etc/passwd"})
        error = "Permission denied"
        msg.set_error(error)

        assert msg._output == error
        assert not msg._output.startswith("$ ")

    def test_shell_error_with_none_command(self) -> None:
        """Shell tool with None command should fall back to error-only output."""
        msg = ToolCallMessage("execute", {"command": None})
        error = "Some error"
        msg.set_error(error)

        assert "$ None" not in msg._output
        assert msg._output == error

    def test_shell_error_with_empty_command(self) -> None:
        """Shell tool with empty command should fall back to error-only output."""
        msg = ToolCallMessage("execute", {"command": ""})
        error = "Some error"
        msg.set_error(error)

        assert msg._output == error
        assert not msg._output.startswith("$ ")

    def test_shell_error_with_whitespace_command(self) -> None:
        """Shell tool with whitespace command should fall back to error-only output."""
        msg = ToolCallMessage("execute", {"command": "   "})
        error = "Some error"
        msg.set_error(error)

        assert msg._output == error

    def test_shell_error_with_no_command_key(self) -> None:
        """Shell tool with no command key should fall back to error-only output."""
        msg = ToolCallMessage("execute", {"other_arg": "value"})
        error = "Some error"
        msg.set_error(error)

        assert msg._output == error
        assert not msg._output.startswith("$ ")

    def test_format_shell_output_styles_only_first_line_dim(self) -> None:
        """Shell output formatting should only style the first command line in dim."""
        msg = ToolCallMessage("execute", {"command": "echo test"})
        output = "$ echo test\ntest output\n$ not a command"
        result = msg._format_shell_output(output, is_preview=False)

        assert isinstance(result.content, Content)
        lines = result.content.split("\n")
        # First line (the command) should be styled dim
        assert lines[0].plain == "$ echo test"
        assert "dim" in lines[0].markup
        # Subsequent lines should NOT be dim
        assert lines[2].plain == "$ not a command"
        assert "dim" not in lines[2].markup

    def test_format_shell_output_preview_truncates_long_single_line(self) -> None:
        """Preview should char-truncate single-line output past the budget."""
        msg = ToolCallMessage("execute", {"command": "gh api graphql"})
        # One huge JSON-like line, well past _PREVIEW_CHARS (400).
        output = "x" * 5000
        result = msg._format_shell_output(output, is_preview=True)

        assert result.truncation is not None
        assert "more chars" in result.truncation
        assert len(result.content.plain) <= msg._PREVIEW_CHARS

    def test_format_shell_output_preview_short_no_truncation(self) -> None:
        """Short shell output should not report any truncation in preview."""
        msg = ToolCallMessage("execute", {"command": "echo hi"})
        output = "$ echo hi\nhi"
        result = msg._format_shell_output(output, is_preview=True)

        assert result.truncation is None
        assert result.content.plain == output

    def test_format_shell_output_preview_cumulative_chars_exceed_budget(self) -> None:
        """Many small lines whose total exceeds the budget should char-truncate.

        Char budget is hit, but some lines weren't even attempted — hidden line
        count is the more useful signal than hidden char count.
        """
        msg = ToolCallMessage("execute", {"command": "noisy"})
        # 4 lines of 200 chars => 800 + 3 separators, well past 400.
        output = "\n".join("x" * 200 for _ in range(4))
        result = msg._format_shell_output(output, is_preview=True)

        assert result.truncation is not None
        assert "more lines" in result.truncation
        # Rendered content stays under budget.
        assert len(result.content.plain) <= msg._PREVIEW_CHARS

    def test_format_shell_output_preview_preserves_dim_when_first_line_clipped(
        self,
    ) -> None:
        """Char-clipping line 0 must keep the `$ ` prefix dim styling."""
        msg = ToolCallMessage("execute", {"command": "echo"})
        output = "$ " + ("x" * 5000)
        result = msg._format_shell_output(output, is_preview=True)

        first_line = result.content.split("\n")[0]
        assert first_line.plain.startswith("$ ")
        assert "dim" in first_line.markup

    def test_format_shell_output_full_never_truncates(self) -> None:
        """`is_preview=False` must render full output regardless of size."""
        msg = ToolCallMessage("execute", {"command": "big"})
        output = "x" * 5000
        result = msg._format_shell_output(output, is_preview=False)

        assert result.truncation is None
        assert result.content.plain == output

    def test_format_output_preserves_first_line_leading_indent(self) -> None:
        """`_format_output` must keep the first line's own leading indentation.

        A bare `strip()` lstrips only the first line while continuation lines
        keep their indent, so uniformly indented command output (e.g.
        `git branch -r`, which prefixes every branch with two spaces) renders
        misaligned. All rows should retain their leading spaces.
        """
        msg = ToolCallMessage("execute", {"command": "git branch -r"})
        # Mirror `git branch -r`: every row indented by two spaces, trailing \n.
        output = "  origin/HEAD -> origin/main\n  origin/main\n  origin/dev\n"
        result = msg._format_output(output, is_preview=False)

        lines = result.content.plain.split("\n")
        assert lines == [
            "  origin/HEAD -> origin/main",
            "  origin/main",
            "  origin/dev",
        ]
        # Every line shares the same leading indent, so they align beside the
        # fixed glyph gutter.
        assert all(line.startswith("  ") for line in lines)

    def test_format_output_still_trims_leading_blank_lines(self) -> None:
        """Leading blank lines are trimmed while first-line indent survives."""
        msg = ToolCallMessage("execute", {"command": "noop"})
        result = msg._format_output("\n\n  indented\n", is_preview=False)

        assert result.content.plain == "  indented"


class TestToolCallMessageFileOutput:
    """Tests for `_format_file_output` char-budget handling.

    Files with very long lines (minified HTML/JS/CSS) used to overflow the
    preview because only line count was capped. Preview now caps both, and
    prefers line counts over char counts in the truncation hint when both
    were hit.
    """

    def test_format_file_output_preview_truncates_long_single_line(self) -> None:
        """A single huge line must be char-clipped under the preview budget.

        Single-line input: no lines hidden, so the hint reports remaining chars.
        """
        msg = ToolCallMessage("read_file", {"path": "/tmp/big.html"})
        output = "x" * 5000
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation == f"{5000 - msg._PREVIEW_CHARS} more chars"
        assert len(result.content.plain) <= msg._PREVIEW_CHARS

    def test_format_file_output_preview_cumulative_chars_exceed_budget(self) -> None:
        """Within the 4-line cap, total chars past budget prefers `more lines`.

        Some lines weren't even attempted — line count is more useful than
        char count when the line cap also kicked in.
        """
        msg = ToolCallMessage("read_file", {"path": "/tmp/big.html"})
        # 4 x 200-char lines: line 0 fits (200), line 1 clips (199), lines 2-3
        # are never attempted, so 2 lines are hidden.
        output = "\n".join("x" * 200 for _ in range(4))
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation == "2 more lines"
        assert len(result.content.plain) <= msg._PREVIEW_CHARS

    def test_format_file_output_preview_line_truncation_when_under_char_budget(
        self,
    ) -> None:
        """Many short lines should report `more lines` truncation."""
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        output = "\n".join(f"line {i}" for i in range(20))
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation == "16 more lines"

    def test_format_file_output_preview_short_no_truncation(self) -> None:
        """Short file content should render fully with no truncation hint."""
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        output = "hello\nworld"
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation is None
        assert result.content.plain == output

    def test_format_file_output_full_never_truncates(self) -> None:
        """`is_preview=False` must render full output regardless of size."""
        msg = ToolCallMessage("read_file", {"path": "/tmp/big.html"})
        output = "x" * 5000
        result = msg._format_file_output(output, is_preview=False)

        assert result.truncation is None
        assert result.content.plain == output

    def test_format_file_output_preview_exact_budget_boundary(self) -> None:
        """A single line that exactly fills the budget should not truncate."""
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        output = "x" * msg._PREVIEW_CHARS
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation is None
        assert result.content.plain == output

    def test_format_file_output_preview_trailing_newline_at_budget(self) -> None:
        r"""Trailing newline at exact budget shouldn't produce a phantom hint.

        File content fits in the budget exactly; the trailing `\n` is a
        text-file convention, not real hidden content.
        """
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        output = "x" * msg._PREVIEW_CHARS + "\n"
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation is None

    def test_format_file_output_preview_trailing_newline_short_file(self) -> None:
        r"""Short file ending in `\n` should not report a phantom extra line."""
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        output = "hello\nworld\n"
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation is None
        assert result.content.plain == "hello\nworld"

    def test_format_file_output_preview_empty_output(self) -> None:
        """Empty output should produce empty content with no truncation hint."""
        msg = ToolCallMessage("read_file", {"path": "/tmp/empty"})
        result = msg._format_file_output("", is_preview=True)

        assert result.truncation is None
        assert result.content.plain == ""

    def test_format_file_output_preview_exactly_four_short_lines(self) -> None:
        """Exactly 4 short lines should render fully with no truncation."""
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        output = "\n".join(f"line {i}" for i in range(4))
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation is None
        assert result.content.plain == output

    def test_format_file_output_preview_budget_hit_on_separator(self) -> None:
        """Separator-cost path must trigger truncation when line 0 fills budget.

        When line 0 exactly fills the budget, the next line's separator
        triggers the `remaining <= 0` branch (distinct from the
        `len(line) > remaining` branch). Line count should be reported since
        lines were hidden.
        """
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        output = "x" * msg._PREVIEW_CHARS + "\nsecond\nthird"
        result = msg._format_file_output(output, is_preview=True)

        assert result.truncation == "2 more lines"

    def test_format_output_compacts_line_number_gutter(self) -> None:
        r"""Line-number gutters are tightened, all rows aligned to one column.

        `read_file` emits `f"{line_num:6d}\t{line}"` — a 6-wide right-justified
        number plus a tab — which renders far from the line numbers and (when
        the first row's padding was stripped) misaligned. The TUI recomputes a
        compact gutter: numbers right-justified to the widest number present,
        two spaces, then the original source indentation.
        """
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        # cat -n style: 6-wide right-justified number + tab + source line.
        output = '     1\t"""doc"""\n     2\t\n     3\t    indented'
        result = msg._format_output(output, is_preview=False)

        # No tab, no 6-wide pad: `{num}  ` gutter, then the original source
        # indentation (the 4 spaces on line 3) preserved verbatim.
        assert result.content.plain == '1  """doc"""\n2  \n3      indented'

    def test_compact_line_gutter_right_justifies_to_widest_number(self) -> None:
        r"""Multi-digit line numbers set a uniform, right-justified gutter."""
        # Lines 9 and 10: single- vs double-digit numbers must align right.
        output = "     9\tnine\n    10\tten"
        compacted = ToolCallMessage._compact_line_gutter(output)

        assert compacted == " 9  nine\n10  ten"

    def test_compact_line_gutter_handles_continuation_markers(self) -> None:
        r"""`N.M` wrapped-line markers are gutters and drive the column width.

        Long lines are chunked by the SDK with decimal continuation markers
        (`f"{line_num}.{chunk_idx}"`). The marker's width (e.g. `1.1` = 3)
        must set the right-justified column like any other line number.
        """
        output = "     1\tfirst\n   1.1\twrapped"
        compacted = ToolCallMessage._compact_line_gutter(output)

        assert compacted == "  1  first\n1.1  wrapped"

    def test_compact_line_gutter_preserves_source_tabs(self) -> None:
        r"""Only the first (gutter) tab is consumed; source tabs stay put.

        Tab-indented source means a tab immediately after the gutter tab.
        `partition` splits on the first tab only, so the source tab survives.
        """
        output = "     1\t\tdef foo():"
        compacted = ToolCallMessage._compact_line_gutter(output)

        assert compacted == "1  \tdef foo():"

    def test_compact_line_gutter_passes_through_non_numbered(self) -> None:
        """Output without a cat -n gutter is returned unchanged."""
        output = "plain text\nno line numbers here"
        assert ToolCallMessage._compact_line_gutter(output) == output

    def test_compact_line_gutter_rejects_malformed_number_heads(self) -> None:
        r"""Heads that aren't a bare `N`/`N.M` are treated as source, not gutter.

        Guards against corrupting tab-separated data whose first column merely
        resembles a number (leading/trailing dot, multiple dots).
        """
        # Leading dot, trailing dot, and multi-dot heads must all pass through.
        output = "   .5\tweird\n   5.\talso\n 1.2.3\tnope"
        assert ToolCallMessage._compact_line_gutter(output) == output

    def test_compact_line_gutter_preview_truncates_with_compacted_gutters(
        self,
    ) -> None:
        """Compaction runs before truncation: previews show compact gutters.

        The char budget and `more lines` hint operate on the already-compacted
        string, so a long cat -n file previews with tight gutters and a
        line-count hint.
        """
        msg = ToolCallMessage("read_file", {"path": "/tmp/a.py"})
        output = "\n".join(f"{i:6d}\tline {i}" for i in range(1, 21))
        result = msg._format_file_output(output, is_preview=True)

        rendered = result.content.plain.split("\n")
        assert rendered[0] == " 1  line 1"  # width 2 (max line number is 20)
        assert result.truncation == "16 more lines"

    def test_compact_line_gutter_empty_output(self) -> None:
        """Empty output has no gutter lines and is returned unchanged."""
        assert ToolCallMessage._compact_line_gutter("") == ""


class TestToolCallMessageAwaitingApproval:
    """Tests for `set_awaiting_approval` / `clear_awaiting_approval`."""

    def test_set_awaiting_approval_hides_widget(self) -> None:
        """`set_awaiting_approval` should mark the widget as hidden."""
        msg = ToolCallMessage("execute", {"command": "echo hi"})
        assert msg._awaiting_approval is False
        msg.set_awaiting_approval()
        assert msg._awaiting_approval is True
        assert msg.display is False

    def test_clear_awaiting_approval_restores_widget(self) -> None:
        """`clear_awaiting_approval` should restore visibility."""
        msg = ToolCallMessage("execute", {"command": "echo hi"})
        msg.set_awaiting_approval()
        msg.clear_awaiting_approval()
        assert msg._awaiting_approval is False
        assert msg.display is True

    def test_clear_awaiting_approval_no_op_when_not_set(self) -> None:
        """Clearing before setting should not touch widget visibility."""
        msg = ToolCallMessage("execute", {"command": "echo hi"})
        msg.clear_awaiting_approval()
        assert msg._awaiting_approval is False

    async def test_awaiting_approval_round_trip_in_mounted_widget(self) -> None:
        """Mounted widget should hide on set, reappear on clear."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("execute", {"command": "echo hi"})

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            msg = app.msg
            assert msg.display is True
            msg.set_awaiting_approval()
            await pilot.pause()
            assert msg.display is False
            msg.clear_awaiting_approval()
            await pilot.pause()
            assert msg.display is True


class TestToolCallMessageRunningSpinner:
    """Tests for `set_running` / `pause_running` spinner state."""

    async def test_set_running_shows_status_widget(self) -> None:
        """`set_running` should reveal the status row and start the timer."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("grep", {"pattern": "foo"})

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            msg = app.msg
            assert msg._status_widget is not None
            # Pending tools hide the status row until they run.
            assert msg._status_widget.display is False

            msg.set_running()
            await pilot.pause()
            assert msg._status == "running"
            assert msg._status_widget.display is True
            assert msg._animation_timer is not None

    async def test_pause_running_hides_status_and_stops_timer(self) -> None:
        """`pause_running` should revert a running tool to its pending look."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("grep", {"pattern": "foo"})

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            msg = app.msg
            msg.set_running()
            await pilot.pause()

            msg.pause_running()
            await pilot.pause()
            assert msg._status == "pending"
            assert msg._start_time is None
            assert msg._animation_timer is None
            assert msg._status_widget is not None
            assert msg._status_widget.display is False

    async def test_pause_running_no_op_when_not_running(self) -> None:
        """Pausing a pending tool should leave its status untouched."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("grep", {"pattern": "foo"})

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            msg = app.msg
            assert msg._status == "pending"
            msg.pause_running()
            await pilot.pause()
            assert msg._status == "pending"
            assert msg._status_widget is not None
            assert msg._status_widget.display is False

    async def test_set_running_resumes_after_pause(self) -> None:
        """A paused tool should be resumable via `set_running` (HITL approve)."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("write_file", {"file_path": "a.txt"})

            def compose(self) -> ComposeResult:
                yield self.msg

        app = _Harness()
        async with app.run_test() as pilot:
            await pilot.pause()
            msg = app.msg
            msg.set_running()
            await pilot.pause()
            msg.pause_running()
            await pilot.pause()
            assert msg._status == "pending"

            msg.set_running()
            await pilot.pause()
            assert msg._status == "running"
            assert msg._start_time is not None
            assert msg._animation_timer is not None
            assert msg._status_widget is not None
            assert msg._status_widget.display is True


class TestToolCallMessageRejectReason:
    """Tests for surfacing a user-supplied HITL reject reason."""

    async def test_set_rejected_with_reason_renders_line(self) -> None:
        """`set_rejected(reason=...)` should display the reason beneath the status."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("execute", {"command": "echo hi"})

            def compose(self) -> ComposeResult:
                yield self.msg

        async with _Harness().run_test() as pilot:
            await pilot.pause()
            app = pilot.app
            assert isinstance(app, _Harness)
            msg = app.msg
            msg.set_rejected(reason="please dry-run first")
            await pilot.pause()
            assert msg._reject_reason == "please dry-run first"
            assert msg._reject_reason_widget is not None
            assert msg._reject_reason_widget.display is True

    async def test_set_rejected_without_reason_hides_line(self) -> None:
        """`set_rejected()` with no reason keeps the reason line hidden."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("execute", {"command": "echo hi"})

            def compose(self) -> ComposeResult:
                yield self.msg

        async with _Harness().run_test() as pilot:
            await pilot.pause()
            app = pilot.app
            assert isinstance(app, _Harness)
            msg = app.msg
            msg.set_rejected()
            await pilot.pause()
            assert msg._reject_reason is None
            assert msg._reject_reason_widget is not None
            assert msg._reject_reason_widget.display is False

    async def test_blank_reason_does_not_set_attribute(self) -> None:
        """Whitespace-only reasons are treated as no reason."""
        from textual.app import App, ComposeResult

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("execute", {"command": "echo hi"})

            def compose(self) -> ComposeResult:
                yield self.msg

        async with _Harness().run_test() as pilot:
            await pilot.pause()
            app = pilot.app
            assert isinstance(app, _Harness)
            msg = app.msg
            msg.set_rejected(reason="   ")
            await pilot.pause()
            assert msg._reject_reason is None

    async def test_reason_with_markup_brackets_renders_safely(self) -> None:
        """User-controlled reasons must round-trip through Rich markup unscathed.

        `from_markup` with `$reason` substitution should escape any literal
        bracket sequences so the reason line never throws a MarkupError.
        """
        from textual.app import App, ComposeResult

        hostile = "[bold red]boom[/bold red] [/dim] $x"

        class _Harness(App[None]):
            def __init__(self) -> None:
                super().__init__()
                self.msg = ToolCallMessage("execute", {"command": "echo hi"})

            def compose(self) -> ComposeResult:
                yield self.msg

        async with _Harness().run_test() as pilot:
            await pilot.pause()
            app = pilot.app
            assert isinstance(app, _Harness)
            msg = app.msg
            msg.set_rejected(reason=hostile)
            await pilot.pause()
            assert msg._reject_reason == hostile
            assert msg._reject_reason_widget is not None
            assert msg._reject_reason_widget.display is True
            rendered = str(msg._reject_reason_widget.render())
            assert "boom" in rendered
            assert "$x" in rendered


class TestUserMessageHighlighting:
    """Test UserMessage highlighting of `@mentions` and `/commands`."""

    def test_at_mention_highlighted(self) -> None:
        """`@file` mentions should be styled in the output."""
        content = "look at @README.md please"
        matches = list(INPUT_HIGHLIGHT_PATTERN.finditer(content))
        assert len(matches) == 1
        assert matches[0].group() == "@README.md"

    def test_slash_command_highlighted_at_start(self) -> None:
        """Slash commands at start should be detected."""
        content = "/help me with something"
        matches = list(INPUT_HIGHLIGHT_PATTERN.finditer(content))
        assert len(matches) == 1
        assert matches[0].group() == "/help"
        assert matches[0].start() == 0

    def test_slash_command_not_matched_mid_text(self) -> None:
        """Slash in middle of text should not match as command due to ^ anchor."""
        content = "check the /usr/bin path"
        matches = list(INPUT_HIGHLIGHT_PATTERN.finditer(content))
        # The ^ anchor means /usr doesn't match when not at start of string
        assert len(matches) == 0

    def test_multiple_at_mentions(self) -> None:
        """Multiple `@mentions` should all be detected."""
        content = "compare @file1.py with @file2.py"
        matches = list(INPUT_HIGHLIGHT_PATTERN.finditer(content))
        assert len(matches) == 2
        assert matches[0].group() == "@file1.py"
        assert matches[1].group() == "@file2.py"

    def test_at_mention_with_path(self) -> None:
        """`@mentions` with paths should be fully captured."""
        content = "read @src/utils/helpers.py"
        matches = list(INPUT_HIGHLIGHT_PATTERN.finditer(content))
        assert len(matches) == 1
        assert matches[0].group() == "@src/utils/helpers.py"

    def test_no_matches_in_plain_text(self) -> None:
        """Plain text without `@` or `/` should have no matches."""
        content = "just some normal text here"
        matches = list(INPUT_HIGHLIGHT_PATTERN.finditer(content))
        assert len(matches) == 0


def _render_content(widget: UserMessage | QueuedUserMessage) -> Content:
    """Extract the `Content` object from a message widget's render method."""
    result = widget.render()
    assert isinstance(result, Content)
    return result


class TestUserMessageModeRendering:
    """Test `UserMessage` renders mode-specific prefix indicators and colors.

    Without an active Textual app, `get_theme_colors` falls back to
    `DARK_COLORS`, so assertions check for hex values from that palette.
    """

    def test_shell_prefix_renders_dollar_indicator(self) -> None:
        """`UserMessage('!ls')` should render with `'$ '` prefix and shell body."""
        content = _render_content(UserMessage("!ls"))
        assert content.plain == "$ ls"
        first_span = content._spans[0]
        assert theme.DARK_COLORS.mode_bash in str(first_span.style)

    def test_incognito_shell_prefix_renders_dollar_indicator(self) -> None:
        """`UserMessage('!!ls')` should strip the full incognito prefix."""
        content = _render_content(UserMessage("!!ls"))
        assert content.plain == "$ ls"
        first_span = content._spans[0]
        assert theme.DARK_COLORS.mode_incognito in str(first_span.style)

    def test_command_prefix_renders_slash_indicator(self) -> None:
        """`UserMessage('/help')` should render with `'/ '` prefix and body."""
        content = _render_content(UserMessage("/help"))
        assert content.plain == "/ help"
        first_span = content._spans[0]
        assert theme.DARK_COLORS.mode_command in str(first_span.style)

    def test_normal_message_renders_angle_bracket(self) -> None:
        """`UserMessage('hello')` should render with `'> '` prefix."""
        content = _render_content(UserMessage("hello"))
        assert content.plain == "> hello"
        first_span = content._spans[0]
        assert theme.DARK_COLORS.primary in str(first_span.style)

    def test_empty_content_renders_angle_bracket(self) -> None:
        """`UserMessage('')` should not crash and should render `'> '` prefix."""
        content = _render_content(UserMessage(""))
        assert content.plain == "> "


class TestModeColorsDrift:
    """Ensure `_mode_color` handles every mode in `MODE_PREFIXES`."""

    def test_mode_color_returns_non_primary_for_all_modes(self) -> None:
        from deepagents_code.config import MODE_PREFIXES
        from deepagents_code.widgets.messages import _mode_color

        primary = _mode_color(None)
        for mode in MODE_PREFIXES:
            color = _mode_color(mode)
            assert color != primary, (
                f"_mode_color({mode!r}) returned primary; add a branch for this mode"
            )


class TestQueuedUserMessageModeRendering:
    """Test `QueuedUserMessage` renders mode-specific prefix indicators (dimmed)."""

    def test_shell_prefix_renders_dimmed_dollar(self) -> None:
        """`QueuedUserMessage('!ls')` should render dimmed `'$ '` prefix."""
        content = _render_content(QueuedUserMessage("!ls"))
        assert content.plain == "$ ls"

    def test_incognito_shell_prefix_renders_dimmed_dollar(self) -> None:
        """`QueuedUserMessage('!!ls')` should strip the full incognito prefix."""
        content = _render_content(QueuedUserMessage("!!ls"))
        assert content.plain == "$ ls"

    def test_command_prefix_renders_dimmed_slash(self) -> None:
        """`QueuedUserMessage('/help')` should render dimmed `'/ '` prefix."""
        content = _render_content(QueuedUserMessage("/help"))
        assert content.plain == "/ help"

    def test_normal_message_renders_dimmed_angle_bracket(self) -> None:
        """`QueuedUserMessage('hello')` should render dimmed `'> '` prefix."""
        content = _render_content(QueuedUserMessage("hello"))
        assert content.plain == "> hello"

    def test_empty_content_renders_angle_bracket(self) -> None:
        """`QueuedUserMessage('')` should not crash and should render `'> '`."""
        content = _render_content(QueuedUserMessage(""))
        assert content.plain == "> "


class TestStripPromptPrefix:
    """Unit tests for `_strip_prompt_prefix` selection trimming."""

    def test_passes_through_none(self) -> None:
        """A `None` result (no extractable text) stays `None`."""
        from textual.selection import SELECT_ALL

        assert _strip_prompt_prefix(None, SELECT_ALL) is None

    def test_select_all_drops_prefix(self) -> None:
        """Select-all (`Selection(None, None)`) trims the two-column prefix."""
        from textual.selection import SELECT_ALL

        assert _strip_prompt_prefix(("> hello", "\n"), SELECT_ALL) == (
            "hello",
            "\n",
        )

    def test_selection_from_row_zero_drops_prefix(self) -> None:
        """A row-0 selection starting at column 0 trims the prefix."""
        from textual.geometry import Offset
        from textual.selection import Selection

        selection = Selection(Offset(0, 0), Offset(7, 0))
        assert _strip_prompt_prefix(("> hello", "\n"), selection) == ("hello", "\n")

    def test_partial_prefix_selection_trims_remaining_glyph(self) -> None:
        """Starting inside the prefix trims only the still-included columns."""
        from textual.geometry import Offset
        from textual.selection import Selection

        selection = Selection(Offset(1, 0), Offset(7, 0))
        assert _strip_prompt_prefix((" hello", "\n"), selection) == ("hello", "\n")

    def test_selection_starting_in_body_is_untouched(self) -> None:
        """A selection beginning past the prefix keeps the body verbatim."""
        from textual.geometry import Offset
        from textual.selection import Selection

        selection = Selection(Offset(4, 0), Offset(7, 0))
        assert _strip_prompt_prefix(("llo", "\n"), selection) == ("llo", "\n")

    def test_selection_starting_below_row_zero_is_untouched(self) -> None:
        """Selections that begin on later rows carry no prefix to strip."""
        from textual.geometry import Offset
        from textual.selection import Selection

        selection = Selection(Offset(0, 1), Offset(5, 1))
        assert _strip_prompt_prefix(("world", "\n"), selection) == ("world", "\n")


class _SelectionApp(App[None]):
    """Mount user-message widgets so `get_selection` has an active app."""

    def compose(self) -> ComposeResult:
        yield UserMessage("hello world", id="user")
        yield UserMessage("!ls", id="shell-user")
        yield QueuedUserMessage("hi there", id="queued")
        yield QueuedUserMessage("!pwd", id="shell-queued")


class TestUserMessageGetSelection:
    """Triple-click / select-all should copy the body, not the prefix glyph."""

    async def test_user_message_select_all_excludes_prefix(self) -> None:
        from textual.selection import SELECT_ALL

        async with _SelectionApp().run_test() as pilot:
            widget = pilot.app.query_one("#user", UserMessage)
            result = widget.get_selection(SELECT_ALL)
            assert result is not None
            assert result[0] == "hello world"

    async def test_queued_message_select_all_excludes_prefix(self) -> None:
        from textual.selection import SELECT_ALL

        async with _SelectionApp().run_test() as pilot:
            widget = pilot.app.query_one("#queued", QueuedUserMessage)
            result = widget.get_selection(SELECT_ALL)
            assert result is not None
            assert result[0] == "hi there"

    async def test_body_selection_preserved(self) -> None:
        from textual.geometry import Offset
        from textual.selection import Selection

        async with _SelectionApp().run_test() as pilot:
            widget = pilot.app.query_one("#user", UserMessage)
            selection = Selection(Offset(8, 0), Offset(13, 0))
            result = widget.get_selection(selection)
            assert result is not None
            assert result[0] == "world"

    async def test_user_message_select_all_starts_after_prompt_prefix(self) -> None:
        from textual.geometry import Offset
        from textual.selection import Selection

        async with _SelectionApp().run_test() as pilot:
            widget = pilot.app.query_one("#user", UserMessage)
            widget.text_select_all()
            assert pilot.app.screen.selections[widget] == Selection(Offset(2, 0), None)

    async def test_shell_user_select_all_starts_after_prompt_prefix(self) -> None:
        from textual.geometry import Offset
        from textual.selection import Selection

        async with _SelectionApp().run_test() as pilot:
            widget = pilot.app.query_one("#shell-user", UserMessage)
            widget.text_select_all()
            assert pilot.app.screen.selections[widget] == Selection(Offset(2, 0), None)
            result = widget.get_selection(pilot.app.screen.selections[widget])
            assert result is not None
            assert result[0] == "ls"

    async def test_queued_message_select_all_starts_after_prompt_prefix(self) -> None:
        from textual.geometry import Offset
        from textual.selection import Selection

        async with _SelectionApp().run_test() as pilot:
            widget = pilot.app.query_one("#queued", QueuedUserMessage)
            widget.text_select_all()
            assert pilot.app.screen.selections[widget] == Selection(Offset(2, 0), None)

    async def test_shell_queued_select_all_starts_after_prompt_prefix(self) -> None:
        from textual.geometry import Offset
        from textual.selection import Selection

        async with _SelectionApp().run_test() as pilot:
            widget = pilot.app.query_one("#shell-queued", QueuedUserMessage)
            widget.text_select_all()
            assert pilot.app.screen.selections[widget] == Selection(Offset(2, 0), None)
            result = widget.get_selection(pilot.app.screen.selections[widget])
            assert result is not None
            assert result[0] == "pwd"


class TestAppMessageAutoLinksDisabled:
    """Tests that `auto_links` is disabled to prevent hover flicker."""

    def test_auto_links_is_false(self) -> None:
        """`AppMessage` should disable Textual's `auto_links`."""
        assert AppMessage.auto_links is False


_WEBBROWSER_OPEN = "deepagents_code.widgets._links.webbrowser.open"


class TestAppMessageOnClickOpensLink:
    """Tests for `AppMessage.on_click` opening style-embedded hyperlinks."""

    def test_click_on_link_opens_browser(self) -> None:
        """Clicking a styled link should call `webbrowser.open`."""
        msg = AppMessage("test")
        event = MagicMock()
        event.style = Style(link="https://example.com")

        with patch(_WEBBROWSER_OPEN) as mock_open:
            msg.on_click(event)

        mock_open.assert_called_once_with("https://example.com")
        event.stop.assert_called_once()

    def test_click_without_link_is_noop(self) -> None:
        """Clicking on non-link text should not open the browser."""
        msg = AppMessage("test")
        event = MagicMock()
        event.style = Style()

        with patch(_WEBBROWSER_OPEN) as mock_open:
            msg.on_click(event)

        mock_open.assert_not_called()
        event.stop.assert_not_called()

    def test_click_with_browser_error_is_graceful(self) -> None:
        """Browser failure should not crash the widget."""
        msg = AppMessage("test")
        event = MagicMock()
        event.style = Style(link="https://example.com")

        with patch(_WEBBROWSER_OPEN, side_effect=OSError("no display")):
            msg.on_click(event)  # should not raise

        event.stop.assert_not_called()

    def test_click_on_suspicious_url_is_blocked(self) -> None:
        """Suspicious Unicode URL should not be opened."""
        msg = AppMessage("test")
        event = MagicMock()
        event.style = Style(link="https://аpple.com")

        with patch(_WEBBROWSER_OPEN) as mock_open:
            msg.on_click(event)

        mock_open.assert_not_called()
        event.stop.assert_not_called()


class TestMountMessageIdSync:
    """Tests for widget id sync in `_mount_message`."""

    def test_widget_id_assigned_from_message_data(self) -> None:
        """Widget with no id should get the MessageData id after from_widget."""
        from deepagents_code.widgets.message_store import MessageData

        widget = UserMessage("hello")
        assert widget.id is None

        data = MessageData.from_widget(widget)
        # Simulate what _mount_message does
        if not widget.id:
            widget.id = data.id

        assert widget.id == data.id
        assert widget.id is not None

    def test_widget_with_existing_id_is_preserved(self) -> None:
        """Widget with an explicit id should keep it."""
        from deepagents_code.widgets.message_store import MessageData

        widget = UserMessage("hello", id="my-custom-id")
        data = MessageData.from_widget(widget)

        if not widget.id:
            widget.id = data.id

        assert widget.id == "my-custom-id"


class TestGenericPreviewTruncation:
    """Tests for generic MCP tool preview truncation fallback."""

    def _make_msg(self, tool_name: str = "mcp_custom_tool") -> ToolCallMessage:
        """Create a ToolCallMessage with the given tool name."""
        return ToolCallMessage(tool_name, {})

    def test_unknown_tool_many_lines_truncated_in_preview(self) -> None:
        """Unknown tool output exceeding line limit should be truncated."""
        msg = self._make_msg()
        output = "\n".join(f"line {i}" for i in range(10))
        result = msg._format_output(output, is_preview=True)
        assert result.truncation is not None
        assert "more lines" in result.truncation

    def test_unknown_tool_long_single_line_truncated_in_preview(self) -> None:
        """Unknown tool output exceeding char limit should be char-truncated."""
        msg = self._make_msg()
        output = "x" * 500
        result = msg._format_output(output, is_preview=True)
        assert result.truncation is not None
        assert "100 more chars" in result.truncation
        assert len(result.content.plain) == 400

    def test_unknown_tool_short_output_no_truncation(self) -> None:
        """Short output from unknown tool should pass through untruncated."""
        msg = self._make_msg()
        output = "short output"
        result = msg._format_output(output, is_preview=True)
        assert result.truncation is None
        assert result.content.plain == "short output"

    def test_unknown_tool_exact_preview_lines_not_truncated(self) -> None:
        """Output with exactly _PREVIEW_LINES lines should NOT be line-truncated."""
        msg = self._make_msg()
        output = "\n".join(f"line {i}" for i in range(msg._PREVIEW_LINES))
        result = msg._format_output(output, is_preview=True)
        # Boundary: exactly at limit should pass through without line truncation
        truncation = result.truncation or ""
        assert result.truncation is None or "more lines" not in truncation

    def test_unknown_tool_full_output_no_truncation(self) -> None:
        """Non-preview mode should return full output regardless of length."""
        msg = self._make_msg()
        output = "x" * 500
        result = msg._format_output(output, is_preview=False)
        assert result.truncation is None
        assert result.content.plain == output


class TestStripFrontmatter:
    """Test _strip_frontmatter helper."""

    def test_strips_yaml_frontmatter(self) -> None:
        text = "---\nname: test\ndescription: A test\n---\n\n# Body\nContent"
        assert _strip_frontmatter(text) == "# Body\nContent"

    def test_no_frontmatter_unchanged(self) -> None:
        text = "# No frontmatter\nJust content"
        assert _strip_frontmatter(text) == text

    def test_unclosed_frontmatter_unchanged(self) -> None:
        text = "---\nname: test\nno closing marker"
        assert _strip_frontmatter(text) == text

    def test_empty_string(self) -> None:
        assert _strip_frontmatter("") == ""

    def test_leading_whitespace_before_frontmatter(self) -> None:
        text = "\n  ---\nname: test\n---\n\nBody"
        assert _strip_frontmatter(text) == "Body"

    def test_frontmatter_only(self) -> None:
        text = "---\nname: test\n---\n"
        assert _strip_frontmatter(text) == ""


class TestSkillMessageMarkupSafety:
    """Test SkillMessage handles content with brackets safely."""

    @pytest.mark.parametrize("content", MARKUP_INJECTION_CASES)
    def test_skill_message_no_markup_error(self, content: str) -> None:
        """SkillMessage should not raise on bracket content."""
        msg = SkillMessage(
            skill_name="test",
            description=content,
            body=content,
            args=content,
        )
        # Construction should not raise; compose() needs a running app
        # (Markdown widget) so we verify fields instead.
        assert msg._description == content
        assert msg._args == content

    def test_skill_message_stores_fields(self) -> None:
        msg = SkillMessage(
            skill_name="web-research",
            description="Research topics",
            source="user",
            body="# Instructions\nDo stuff",
            args="find quantum",
        )
        assert msg._skill_name == "web-research"
        assert msg._description == "Research topics"
        assert msg._source == "user"
        assert msg._body == "# Instructions\nDo stuff"
        assert msg._args == "find quantum"
        assert msg._expanded is False

    def test_skill_message_strips_frontmatter(self) -> None:
        """Body with frontmatter should have it stripped for display."""
        body = "---\nname: test\ndescription: A test\n---\n\n# Real content"
        msg = SkillMessage(skill_name="test", body=body)
        assert msg._stripped_body == "# Real content"
        # Raw body preserved for serialization
        assert msg._body == body

    def test_skill_message_no_args_skips_field(self) -> None:
        """When no args are provided, internal state should reflect that."""
        msg = SkillMessage(skill_name="test", args="")
        assert msg._args == ""
        assert msg._description == ""

    def test_skill_message_with_description_and_args(self) -> None:
        msg = SkillMessage(
            skill_name="test",
            description="A test skill",
            args="do something",
        )
        assert msg._description == "A test skill"
        assert msg._args == "do something"

    def test_skill_message_toggle_state(self) -> None:
        msg = SkillMessage(skill_name="test", body="some body")
        assert msg._expanded is False
        msg._expanded = True
        assert msg._expanded is True


class TestStripSuccessExitLine:
    """Test _strip_success_exit_line helper."""

    def test_strips_success_trailer(self) -> None:
        text = "hello world\n[Command succeeded with exit code 0]"
        assert _strip_success_exit_line(text) == "hello world"

    def test_strips_success_trailer_with_trailing_whitespace(self) -> None:
        text = "output\n[Command succeeded with exit code 0]  \n"
        assert _strip_success_exit_line(text) == "output"

    def test_preserves_failed_exit_code(self) -> None:
        text = "error\n[Command failed with exit code 1]"
        assert _strip_success_exit_line(text) == text

    def test_preserves_non_zero_success_code(self) -> None:
        """Only exit code 0 is stripped; other codes are untouched."""
        text = "output\n[Command succeeded with exit code 2]"
        assert _strip_success_exit_line(text) == text

    def test_empty_string(self) -> None:
        assert _strip_success_exit_line("") == ""

    def test_no_trailer(self) -> None:
        text = "just some output"
        assert _strip_success_exit_line(text) == text

    def test_only_trailer(self) -> None:
        text = "[Command succeeded with exit code 0]"
        assert _strip_success_exit_line(text) == ""

    def test_preserves_mid_string_trailer(self) -> None:
        """Trailer not at end of string should be left intact."""
        text = "before\n[Command succeeded with exit code 0]\nafter"
        assert _strip_success_exit_line(text) == text

    def test_set_success_strips_trailer(self) -> None:
        """Integration: set_success should strip the exit code 0 line."""
        msg = ToolCallMessage("execute", {"command": "echo hi"})
        msg.set_success("hi\n[Command succeeded with exit code 0]")
        assert msg._output == "hi"


class TestUserMessageCancelled:
    """`set_cancelled` dims a prompt whose turn was interrupted."""

    async def test_set_cancelled_adds_dim_class(self) -> None:
        """`set_cancelled` adds the `-cancelled` class that dims the prompt."""

        class _Harness(App[None]):
            def compose(self) -> ComposeResult:
                yield UserMessage("hello")

        app = _Harness()
        async with app.run_test() as pilot:
            msg = app.query_one(UserMessage)
            assert not msg.has_class("-cancelled")
            msg.set_cancelled()
            await pilot.pause()
            assert msg.has_class("-cancelled")
