diff --git a/clients/terminal/render.py b/clients/terminal/render.py index 2596125..27637e2 100644 --- a/clients/terminal/render.py +++ b/clients/terminal/render.py @@ -87,5 +87,22 @@ self._print("[context compressed]", color="bright_black") return + if msg_type == "planning_status": + if self.show_events: + label = msg.get("label", "") or "planning…" + prefix = "(subagent) " if msg.get("is_subagent") else "" + self._print(f"{prefix}[planning] {label}", color="bright_black") + return + + if msg_type == "plan_ready": + if self.show_events: + plan = msg.get("plan", "") or "" + title = "subagent plan" if msg.get("is_subagent") else "plan" + self._print(f"\n[{title}]", color="cyan") + if plan: + self._print(plan, color="bright_black") + self._print(f"[/{title}]", color="cyan") + return + if self.show_events: self._print(f"[event: {msg_type}] {msg}", color="bright_black") diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 446c718..9c94c80 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -21,6 +21,8 @@ "tool_call", "error", "status", + "planning_status", + "plan_ready", ] content: str = "" meta: dict = field(default_factory=dict) @@ -94,6 +96,45 @@ self.items.append(item) return item + if msg_type == "planning_status": + label = msg.get("label", "") or "" + is_subagent = bool(msg.get("is_subagent", False)) + phase = msg.get("phase") + # Non-subagent planning is a transient rolling indicator: update the + # last pending planning line in place so the phases (Analysis → + # Execution plan → Plan review) share one line instead of stacking. + # Subagent planning is appended as its own dim line. + if not is_subagent and self.items and self.items[-1].kind == "planning_status" and not bool( + self.items[-1].meta.get("is_subagent", False) + ): + self.items[-1].content = label + self.items[-1].meta = {"phase": phase, "is_subagent": False} + return self.items[-1] + item = ChatItem( + kind="planning_status", + content=label, + meta={"phase": phase, "is_subagent": is_subagent}, + ) + self.items.append(item) + return item + + if msg_type == "plan_ready": + plan = msg.get("plan", "") or "" + is_subagent = bool(msg.get("is_subagent", False)) + # The transient non-subagent planning indicator is consumed by the + # finalized plan card. + if not is_subagent and self.items and self.items[-1].kind == "planning_status" and not bool( + self.items[-1].meta.get("is_subagent", False) + ): + self.items.pop() + item = ChatItem( + kind="plan_ready", + content=plan, + meta={"is_subagent": is_subagent}, + ) + self.items.append(item) + return item + if msg_type == "stream_stopped": self._current_assistant = None self._current_thinking = None diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index e54dc36..5a5f774 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 +from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning def default_registry() -> RendererRegistry: @@ -17,6 +17,8 @@ reg.register(tool.ToolResultRenderer()) reg.register(error.ErrorRenderer()) reg.register(status.StatusRenderer()) + reg.register(planning.PlanningStatusRenderer()) + reg.register(planning.PlanReadyRenderer()) reg.register(markdown_content.MarkdownRenderer()) reg.register(diff.DiffRenderer()) reg.register(artifact.ArtifactRenderer()) diff --git a/clients/terminal/tui/renderers/planning.py b/clients/terminal/tui/renderers/planning.py new file mode 100644 index 0000000..5a9333a --- /dev/null +++ b/clients/terminal/tui/renderers/planning.py @@ -0,0 +1,65 @@ +"""Renderers for planning events (planning_status, plan_ready).""" + +from __future__ import annotations + +from rich.box import ROUNDED +from rich.console import RenderableType +from rich.markdown import Markdown +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer +from .markdown_content import ThemedMarkdownRenderable, _theme_aware_code_theme + + +class PlanningStatusRenderer(ContentRenderer): + """Render a planning-phase progress line. + + 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. + """ + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "planning_status" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + label = msg.get("label", "") or "planning…" + 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) + + +class PlanReadyRenderer(ContentRenderer): + """Render the finalized plan as a card. + + The plan text is markdown (## Plan, **Task:**, **Steps:** …) and is rendered + with the themed Markdown renderer inside a panel. Subagent plans use a dim + border so they read as nested context, not the main turn's plan. + """ + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "plan_ready" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + plan = msg.get("plan", "") or "" + is_subagent = bool(msg.get("is_subagent", False)) + code_theme = _theme_aware_code_theme(theme.name) + body = ThemedMarkdownRenderable( + Markdown(plan, code_theme=code_theme, inline_code_theme=code_theme), + theme.name, + ) + title = "subagent plan" if is_subagent else "Plan" + border = theme.text_dim if is_subagent else theme.accent + return Panel( + body, + title=title, + title_align="left", + border_style=border.hex, + box=ROUNDED, + ) \ No newline at end of file diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index e0ad614..9d332ef 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -82,6 +82,27 @@ renderables.append( self._registry.render({"type": "status", "content": item.content}) ) + elif item.kind == "planning_status": + renderables.append( + self._registry.render( + { + "type": "planning_status", + "label": item.content, + "phase": item.meta.get("phase"), + "is_subagent": item.meta.get("is_subagent", False), + } + ) + ) + elif item.kind == "plan_ready": + renderables.append( + self._registry.render( + { + "type": "plan_ready", + "plan": item.content, + "is_subagent": item.meta.get("is_subagent", False), + } + ) + ) else: renderables.append( self._registry.render({"type": "plain", "content": item.content}) diff --git a/tests/clients/test_planning_renderers.py b/tests/clients/test_planning_renderers.py new file mode 100644 index 0000000..141a7da --- /dev/null +++ b/tests/clients/test_planning_renderers.py @@ -0,0 +1,56 @@ +"""Tests for planning event renderers.""" + +from __future__ import annotations + +import io + +from rich.console import Console + +from clients.terminal.tui.renderers.planning import PlanningStatusRenderer, PlanReadyRenderer +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_planning_status_renderer_accepts_and_renders_label() -> None: + set_active_theme("gnexus-dark") + renderer = PlanningStatusRenderer() + msg = {"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False} + assert renderer.accepts(msg) + text = _render_text(renderer.render(msg)) + assert "Planning" in text + assert "Analysis" in text + + +def test_planning_status_subagent_prefixed_and_dim() -> None: + set_active_theme("gnexus-dark") + renderer = PlanningStatusRenderer() + msg = {"type": "planning_status", "phase": 2, "label": "Execution plan", "is_subagent": True} + text = _render_text(renderer.render(msg)) + assert "(subagent)" in text + assert "Execution plan" in text + + +def test_plan_ready_renderer_accepts_and_renders_plan() -> None: + set_active_theme("gnexus-dark") + renderer = PlanReadyRenderer() + plan = "## Plan\n\n**Task:** do thing\n**Steps:**\n1. Step one → TOOL: fs\n" + msg = {"type": "plan_ready", "plan": plan, "is_subagent": False} + assert renderer.accepts(msg) + panel = renderer.render(msg) + assert panel.title == "Plan" + rendered = _render_text(panel) + assert "Task" in rendered + assert "Step one" in rendered + + +def test_plan_ready_subagent_uses_dim_title() -> None: + 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 diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index 10e42fd..fa924f9 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -216,6 +216,58 @@ @pytest.mark.anyio +async def test_planning_status_rolls_in_place() -> None: + """Successive non-subagent planning_status events update one line, not stack.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event({"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False}) + chat.handle_ws_event({"type": "planning_status", "phase": 2, "label": "Execution plan", "is_subagent": False}) + chat.handle_ws_event({"type": "planning_status", "phase": 3, "label": "Plan review", "is_subagent": False}) + await pilot.pause() + planning = [it for it in chat._model.items if it.kind == "planning_status"] + assert len(planning) == 1 + assert planning[0].content == "Plan review" + assert planning[0].meta["phase"] == 3 + + +@pytest.mark.anyio +async def test_plan_ready_consumes_planning_indicator_and_adds_card() -> None: + """plan_ready removes the pending planning line and appends a plan card.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event({"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False}) + chat.handle_ws_event( + {"type": "plan_ready", "plan": "## Plan\n**Steps:**\n1. Do thing\n", "is_subagent": False} + ) + await pilot.pause() + items = chat._model.items + assert not any(it.kind == "planning_status" for it in items) + plans = [it for it in items if it.kind == "plan_ready"] + assert len(plans) == 1 + assert "## Plan" in plans[0].content + assert items[-1].kind == "plan_ready" + + +@pytest.mark.anyio +async def test_subagent_planning_appended_separately() -> None: + """Subagent planning does not merge into the parent's rolling indicator.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event({"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False}) + chat.handle_ws_event({"type": "planning_status", "phase": 1, "label": "sub plan", "is_subagent": True}) + await pilot.pause() + planning = [it for it in chat._model.items if it.kind == "planning_status"] + assert len(planning) == 2 + assert planning[0].content == "Analysis" + assert planning[0].meta["is_subagent"] is False + assert planning[1].content == "sub plan" + assert planning[1].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: