diff --git a/clients/terminal/render.py b/clients/terminal/render.py index 27637e2..8fb00b4 100644 --- a/clients/terminal/render.py +++ b/clients/terminal/render.py @@ -49,6 +49,15 @@ self._thinking_buffer.clear() return + if msg_type == "turn_thinking": + if self.show_thinking: + thinking = msg.get("thinking", "") or "" + prefix = "(subagent) " if msg.get("is_subagent") else "" + self._print(f"\n{prefix}[thinking]", color="bright_black") + self._print(thinking, color="bright_black") + self._print(f"{prefix}[/thinking]", color="bright_black") + return + if msg_type == "stream_delta": self._print(msg.get("delta", ""), nl=False) return diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 9c94c80..80539d2 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -64,6 +64,18 @@ self._current_thinking = None return None + if msg_type == "turn_thinking": + # A complete thinking block from a (sub-agent) non-streaming turn. + # Stored as a thinking_block with an is_subagent flag so the renderer + # can style/indent it as nested sub-agent reasoning. + item = ChatItem( + kind="thinking_block", + content=msg.get("thinking", "") or "", + meta={"is_subagent": bool(msg.get("is_subagent", False))}, + ) + self.items.append(item) + return item + if msg_type == "stream_delta": if self._current_assistant is None: self._current_assistant = ChatItem(kind="assistant_message", content="") diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index 5a5f774..7bc1203 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -4,7 +4,7 @@ from .base import ContentRenderer from .registry import RendererRegistry -from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning +from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent def default_registry() -> RendererRegistry: @@ -13,6 +13,9 @@ reg.register(message.UserMessageRenderer()) reg.register(message.AssistantMessageRenderer()) reg.register(thinking.ThinkingRenderer()) + # spawn_agent cards must be checked before the generic tool renderers. + reg.register(subagent.SpawnAgentStartedRenderer()) + reg.register(subagent.SpawnAgentResultRenderer()) reg.register(tool.ToolStartedRenderer()) reg.register(tool.ToolResultRenderer()) reg.register(error.ErrorRenderer()) diff --git a/clients/terminal/tui/renderers/planning.py b/clients/terminal/tui/renderers/planning.py index 5a9333a..d0d850a 100644 --- a/clients/terminal/tui/renderers/planning.py +++ b/clients/terminal/tui/renderers/planning.py @@ -5,6 +5,7 @@ from rich.box import ROUNDED from rich.console import RenderableType from rich.markdown import Markdown +from rich.padding import Padding from rich.panel import Panel from rich.text import Text @@ -19,7 +20,8 @@ Non-subagent planning is a transient indicator that rolls through phases (Analysis → Execution plan → Plan review) in one line; subagent planning is - shown dimmed so it does not bleed into the parent turn's indicator. + shown dimmed and indented so it reads as nested inside the spawn_agent card + and does not bleed into the parent turn's indicator. """ def accepts(self, msg: dict) -> bool: @@ -31,7 +33,10 @@ is_subagent = bool(msg.get("is_subagent", False)) prefix = "(subagent) " if is_subagent else "" color = theme.text_dim if is_subagent else theme.info - return Text(f"{prefix}⚙ Planning · {label}", style=color.hex) + line = Text(f"{prefix}⚙ Planning · {label}", style=color.hex) + if is_subagent: + return Padding(line, (0, 0, 0, 2)) + return line class PlanReadyRenderer(ContentRenderer): @@ -56,10 +61,13 @@ ) title = "subagent plan" if is_subagent else "Plan" border = theme.text_dim if is_subagent else theme.accent - return Panel( + panel = Panel( body, title=title, title_align="left", border_style=border.hex, box=ROUNDED, - ) \ No newline at end of file + ) + if is_subagent: + return Padding(panel, (0, 0, 0, 2)) + return panel \ No newline at end of file diff --git a/clients/terminal/tui/renderers/subagent.py b/clients/terminal/tui/renderers/subagent.py new file mode 100644 index 0000000..c4b3502 --- /dev/null +++ b/clients/terminal/tui/renderers/subagent.py @@ -0,0 +1,72 @@ +"""Renderers for the spawn_agent tool (sub-agent delegation cards).""" + +from __future__ import annotations + +from rich.box import ROUNDED +from rich.console import RenderableType +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + +_SPAWN_TOOL = "spawn_agent" + + +def _truncate(text: str, limit: int) -> str: + text = (text or "").strip() + if len(text) <= limit: + return text + return text[:limit].rstrip() + " …" + + +class SpawnAgentStartedRenderer(ContentRenderer): + """Render the start of a spawn_agent call as a distinct sub-agent card.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "tool_started" and msg.get("tool") == _SPAWN_TOOL + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + args = msg.get("args") or {} + profile_id = args.get("profile_id") or "default" + task = args.get("task") or "" + briefing = args.get("briefing") or "" + + title = f"↘ subagent · {profile_id}" + body = Text(_truncate(task, 240), style=theme.text.hex) + if briefing: + body.append("\n", style=theme.text_dim.hex) + body.append(f"briefing: {_truncate(briefing, 160)}", style=theme.text_dim.hex) + return Panel( + body, + title=title, + title_align="left", + border_style=theme.secondary.hex, + box=ROUNDED, + ) + + +class SpawnAgentResultRenderer(ContentRenderer): + """Render the result of a spawn_agent call as a distinct sub-agent card.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "tool_call" and msg.get("tool") == _SPAWN_TOOL + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + args = msg.get("args") or {} + profile_id = args.get("profile_id") or "default" + success = msg.get("success", True) + result = msg.get("result") + color = theme.tool_success if success else theme.tool_error + title = f"↙ subagent · {profile_id} {'✓' if success else '✗'}" + body = Text(_truncate(str(result) if result is not None else "", 600), style=theme.text_dim.hex) + return Panel( + body, + title=title, + title_align="left", + border_style=color.hex, + box=ROUNDED, + ) \ No newline at end of file diff --git a/clients/terminal/tui/renderers/thinking.py b/clients/terminal/tui/renderers/thinking.py index 93e05e4..76ee3aa 100644 --- a/clients/terminal/tui/renderers/thinking.py +++ b/clients/terminal/tui/renderers/thinking.py @@ -2,8 +2,9 @@ from __future__ import annotations -from rich.console import RenderableType from rich.box import ROUNDED +from rich.console import RenderableType +from rich.padding import Padding from rich.panel import Panel from rich.text import Text @@ -13,7 +14,12 @@ class ThinkingRenderer(ContentRenderer): - """Render a complete thinking block.""" + """Render a complete thinking block. + + Sub-agent thinking (``is_subagent=True``) is rendered with a dim border, a + ``subagent thinking`` title, and a left indent so it reads as nested inside + the parent spawn_agent card rather than as a top-level turn event. + """ def accepts(self, msg: dict) -> bool: return msg.get("type") == "thinking_block" @@ -21,10 +27,20 @@ def render(self, msg: dict) -> RenderableType: theme = get_active_theme() text = msg.get("content", "") + is_subagent = bool(msg.get("is_subagent", False)) + if is_subagent: + panel = Panel( + Text(text, style=theme.text_dim.hex), + title="subagent thinking", + title_align="left", + border_style=theme.text_dim.hex, + box=ROUNDED, + ) + return Padding(panel, (0, 0, 0, 2)) return Panel( Text(text, style=theme.text_dim.hex), title="thinking", title_align="left", border_style=theme.thinking.hex, box=ROUNDED, - ) + ) \ No newline at end of file diff --git a/clients/terminal/tui/renderers/tool.py b/clients/terminal/tui/renderers/tool.py index e1e4a82..16bffe2 100644 --- a/clients/terminal/tui/renderers/tool.py +++ b/clients/terminal/tui/renderers/tool.py @@ -7,6 +7,7 @@ from rich.console import RenderableType from rich.json import JSON as RichJSON from rich.box import ROUNDED +from rich.padding import Padding from rich.panel import Panel from rich.text import Text @@ -16,7 +17,11 @@ class ToolStartedRenderer(ContentRenderer): - """Render a tool call start marker.""" + """Render a tool call start marker. + + Sub-agent tool calls (``is_subagent=True``) are indented to read as nested + inside the parent spawn_agent card. + """ def accepts(self, msg: dict) -> bool: return msg.get("type") == "tool_started" @@ -33,17 +38,24 @@ body = Text(str(args), style=theme.text_dim.hex) else: body = Text("") - return Panel( + panel = Panel( body, title=title, title_align="left", border_style=theme.tool_border.hex, box=ROUNDED, ) + if bool(msg.get("is_subagent", False)): + return Padding(panel, (0, 0, 0, 2)) + return panel class ToolResultRenderer(ContentRenderer): - """Render a tool call result.""" + """Render a tool call result. + + Sub-agent tool results (``is_subagent=True``) are indented to read as nested + inside the parent spawn_agent card. + """ def accepts(self, msg: dict) -> bool: return msg.get("type") == "tool_call" @@ -56,10 +68,13 @@ color = theme.tool_success if success else theme.tool_error title = f"← {tool} {'✓' if success else '✗'}" body = Text(str(result) if result is not None else "", style=theme.text_dim.hex) - return Panel( + panel = Panel( body, title=title, title_align="left", border_style=color.hex, box=ROUNDED, ) + if bool(msg.get("is_subagent", False)): + return Padding(panel, (0, 0, 0, 2)) + return panel diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index 9d332ef..f4b9b0a 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -68,7 +68,13 @@ ) elif item.kind == "thinking_block": renderables.append( - self._registry.render({"type": "thinking_block", "content": item.content}) + self._registry.render( + { + "type": "thinking_block", + "content": item.content, + "is_subagent": item.meta.get("is_subagent", False), + } + ) ) elif item.kind == "tool_started": renderables.append(self._registry.render({"type": "tool_started", **item.meta})) diff --git a/tests/clients/test_planning_renderers.py b/tests/clients/test_planning_renderers.py index 141a7da..80d09a2 100644 --- a/tests/clients/test_planning_renderers.py +++ b/tests/clients/test_planning_renderers.py @@ -2,8 +2,6 @@ from __future__ import annotations -import io - from rich.console import Console from clients.terminal.tui.renderers.planning import PlanningStatusRenderer, PlanReadyRenderer @@ -52,5 +50,5 @@ set_active_theme("gnexus-dark") renderer = PlanReadyRenderer() msg = {"type": "plan_ready", "plan": "## Plan\n**Steps:**\n1. x\n", "is_subagent": True} - panel = renderer.render(msg) - assert panel.title == "subagent plan" \ No newline at end of file + out = _render_text(renderer.render(msg)) + assert "subagent plan" in out \ No newline at end of file diff --git a/tests/clients/test_subagent_renderers.py b/tests/clients/test_subagent_renderers.py new file mode 100644 index 0000000..b5b58fa --- /dev/null +++ b/tests/clients/test_subagent_renderers.py @@ -0,0 +1,110 @@ +"""Tests for sub-agent card renderers (spawn_agent + nested styling).""" + +from __future__ import annotations + +from rich.console import Console + +from clients.terminal.tui.renderers.planning import PlanningStatusRenderer, PlanReadyRenderer +from clients.terminal.tui.renderers.subagent import ( + SpawnAgentStartedRenderer, + SpawnAgentResultRenderer, +) +from clients.terminal.tui.renderers.thinking import ThinkingRenderer +from clients.terminal.tui.renderers.tool import ToolStartedRenderer, ToolResultRenderer +from clients.terminal.tui.themes import set_active_theme + + +def _render_text(renderable) -> str: + console = Console(record=True, width=80, force_terminal=True, color_system=None) + console.print(renderable) + return console.export_text() + + +def test_spawn_started_card_shows_profile_and_task() -> None: + set_active_theme("gnexus-dark") + renderer = SpawnAgentStartedRenderer() + msg = { + "type": "tool_started", + "tool": "spawn_agent", + "args": {"task": "audit the auth flow", "profile_id": "developer"}, + "is_subagent": False, + } + assert renderer.accepts(msg) + panel = renderer.render(msg) + assert "subagent" in str(panel.title) + assert "developer" in str(panel.title) + assert "audit the auth flow" in _render_text(panel) + + +def test_spawn_started_does_not_accept_other_tools() -> None: + renderer = SpawnAgentStartedRenderer() + assert not renderer.accepts({"type": "tool_started", "tool": "filesystem", "args": {}}) + + +def test_spawn_result_card_shows_status() -> None: + set_active_theme("gnexus-dark") + renderer = SpawnAgentResultRenderer() + msg = { + "type": "tool_call", + "tool": "spawn_agent", + "args": {"profile_id": "developer"}, + "result": "auth flow is fine", + "success": True, + "is_subagent": False, + } + assert renderer.accepts(msg) + panel = renderer.render(msg) + title = str(panel.title) + assert "developer" in title + assert "✓" in title + assert "auth flow is fine" in _render_text(panel) + + +def test_subagent_thinking_is_indented_and_labeled() -> None: + set_active_theme("gnexus-dark") + renderer = ThinkingRenderer() + msg = {"type": "thinking_block", "content": "hmm nested", "is_subagent": True} + out = _render_text(renderer.render(msg)) + assert "subagent thinking" in out + assert "hmm nested" in out + # Indented: the content lines start with spaces. + assert any(line.startswith(" ") for line in out.splitlines()) + + +def test_subagent_planning_status_is_indented() -> None: + set_active_theme("gnexus-dark") + renderer = PlanningStatusRenderer() + msg = {"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": True} + out = _render_text(renderer.render(msg)) + assert "(subagent)" in out + assert any(line.startswith(" ") for line in out.splitlines()) + + +def test_subagent_plan_card_is_indented() -> None: + set_active_theme("gnexus-dark") + renderer = PlanReadyRenderer() + msg = {"type": "plan_ready", "plan": "## Plan\n**Steps:**\n1. x\n", "is_subagent": True} + out = _render_text(renderer.render(msg)) + assert "subagent plan" in out + assert any(line.startswith(" ") for line in out.splitlines()) + + +def test_subagent_tool_card_is_indented() -> None: + set_active_theme("gnexus-dark") + started = ToolStartedRenderer().render( + {"type": "tool_started", "tool": "filesystem", "args": {}, "is_subagent": True} + ) + result = ToolResultRenderer().render( + {"type": "tool_call", "tool": "filesystem", "result": "ok", "success": True, "is_subagent": True} + ) + assert any(line.startswith(" ") for line in _render_text(started).splitlines()) + assert any(line.startswith(" ") for line in _render_text(result).splitlines()) + + +def test_non_subagent_tool_card_not_indented() -> None: + set_active_theme("gnexus-dark") + started = ToolStartedRenderer().render( + {"type": "tool_started", "tool": "filesystem", "args": {}, "is_subagent": False} + ) + # A top-level card has border lines starting at column 0 (e.g. "╭──"). + assert any(line.startswith("╭") for line in _render_text(started).splitlines()) \ No newline at end of file diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index fa924f9..f37e971 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -268,6 +268,22 @@ @pytest.mark.anyio +async def test_turn_thinking_creates_subagent_thinking_block() -> None: + """turn_thinking events become thinking_block items flagged is_subagent.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event( + {"type": "turn_thinking", "thinking": "let me consider…", "is_subagent": True} + ) + await pilot.pause() + blocks = [it for it in chat._model.items if it.kind == "thinking_block"] + assert len(blocks) == 1 + assert blocks[0].content == "let me consider…" + assert blocks[0].meta["is_subagent"] is True + + +@pytest.mark.anyio async def test_unknown_slash_command_shows_error() -> None: """Unknown slash command produces an error item in chat.""" async with NaviCodeTui(new_session=True).run_test() as pilot: