diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index feba280..591a7ea 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -24,6 +24,7 @@ "planning_status", "plan_ready", "turn_meta", + "context_summary", ] content: str = "" meta: dict = field(default_factory=dict) @@ -219,7 +220,22 @@ self._current_thinking = None return None - if msg_type in ("context_compressed", "heartbeat", "session_sync"): + if msg_type == "context_compressed": + # The compressor replaced the discarded turns with a summary. Surface + # that text in the chat as a distinct block so the user can see what + # was kept — for both forced /compact and mid-turn auto-compress. + item = ChatItem( + kind="context_summary", + content=msg.get("summary", "") or "", + meta={ + "messages_before": msg.get("messages_before"), + "messages_after": msg.get("messages_after"), + }, + ) + self.items.append(item) + return item + + if msg_type in ("heartbeat", "session_sync"): return None # Unknown event — store as status for debugging. diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index fd6010c..f0acc07 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, subagent, todo, turn_meta +from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent, todo, turn_meta, summary def default_registry() -> RendererRegistry: @@ -25,6 +25,7 @@ reg.register(planning.PlanningStatusRenderer()) reg.register(planning.PlanReadyRenderer()) reg.register(turn_meta.TurnMetaRenderer()) + reg.register(summary.ContextSummaryRenderer()) reg.register(markdown_content.MarkdownRenderer()) reg.register(diff.DiffRenderer()) reg.register(artifact.ArtifactRenderer()) diff --git a/clients/terminal/tui/renderers/summary.py b/clients/terminal/tui/renderers/summary.py new file mode 100644 index 0000000..7dc67c1 --- /dev/null +++ b/clients/terminal/tui/renderers/summary.py @@ -0,0 +1,46 @@ +"""Renderer for the context-compression summary block.""" + +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 clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer +from .markdown_content import ThemedMarkdownRenderable, _theme_aware_code_theme + + +class ContextSummaryRenderer(ContentRenderer): + """Render the context-compression summary as a bordered card. + + The compressor discards old turns and replaces them with a summary message; + this renderer shows that summary text (what was kept) with a header giving + the before/after message counts. Rendered for both forced /compact and + mid-turn auto-compress, so the user can always see what was summarized. + """ + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "context_summary" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + before = msg.get("messages_before") + after = msg.get("messages_after") + title = "Context compressed" + if before is not None and after is not None: + title = f"Context compressed: {before} → {after} messages" + code_theme = _theme_aware_code_theme(theme.name) + body = ThemedMarkdownRenderable( + Markdown(msg.get("content", "") or "", code_theme=code_theme, inline_code_theme=code_theme), + theme.name, + ) + return Panel( + body, + title=title, + title_align="left", + border_style=theme.text_dim.hex, + box=ROUNDED, + ) \ No newline at end of file diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index a144530..460e7f7 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -415,21 +415,21 @@ # Auto-compression happens mid-turn, while _streaming is True (a # stream_start already kicked off the activity spinner) — just # relabel it. A forced /compact has NO streaming turn, so _streaming - # is False: start the spinner on compression_started and stop it + - # emit a chat status line on context_compressed as user feedback. + # is False: start the spinner on compression_started and stop it on + # context_compressed. if msg_type == "compression_started": if self._streaming: self._status_bar.activity_label("compressing") else: self._status_bar.activity_start("compressing") else: # context_compressed + # Render the summary block in the chat for both forced /compact + # and mid-turn auto-compress — the summary text is what the + # compressor kept, useful to see what was discarded. The block's + # header carries the before/after counts, so the old one-line + # "Context compacted: N → M" status is no longer emitted. + self._chat_panel.handle_ws_event(payload) if not self._streaming: - before = payload.get("messages_before") - after = payload.get("messages_after") - if before is not None and after is not None: - self._chat_panel.handle_ws_event( - {"type": "status", "content": f"Context compacted: {before} → {after} messages"} - ) self._status_bar.activity_stop() return elif msg_type == "stream_delta" and self._streaming: diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index 33ab039..64ac41b 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -53,6 +53,13 @@ } if kind == "turn_meta": return {"type": "turn_meta", "elapsed_seconds": item.meta.get("elapsed_seconds")} + if kind == "context_summary": + return { + "type": "context_summary", + "content": item.content, + "messages_before": item.meta.get("messages_before"), + "messages_after": item.meta.get("messages_after"), + } return {"type": "plain", "content": item.content} diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index 5421228..41019a2 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -314,6 +314,31 @@ assert model.items[0].content == "new" +def test_chat_model_context_compressed_creates_summary_item() -> None: + """A live context_compressed event surfaces the summary text as a distinct + chat item (for both forced /compact and mid-turn auto-compress), carrying + the before/after counts so the renderer can head the block with them.""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + item = model.handle_ws_event( + { + "type": "context_compressed", + "summary": "Earlier the user asked to build a TUI client.", + "messages_before": 42, + "messages_after": 8, + "context_tokens": 1200, + "max_context_tokens": 4096, + } + ) + assert item is not None + assert item.kind == "context_summary" + assert item.content == "Earlier the user asked to build a TUI client." + assert item.meta["messages_before"] == 42 + assert item.meta["messages_after"] == 8 + assert model.items[-1] is item + + @pytest.mark.anyio async def test_chat_panel_load_history_renders(monkeypatch) -> None: from rich.console import Console diff --git a/tests/clients/test_summary_renderer.py b/tests/clients/test_summary_renderer.py new file mode 100644 index 0000000..eee735c --- /dev/null +++ b/tests/clients/test_summary_renderer.py @@ -0,0 +1,62 @@ +"""Tests for the context-compression summary renderer.""" + +from __future__ import annotations + +from rich.console import Console + +from clients.terminal.tui.renderers.summary import ContextSummaryRenderer +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_summary_renderer_accepts_context_summary() -> None: + set_active_theme("gnexus-dark") + renderer = ContextSummaryRenderer() + msg = { + "type": "context_summary", + "content": "Earlier the user asked to build a TUI client.", + "messages_before": 42, + "messages_after": 8, + } + assert renderer.accepts(msg) + assert not renderer.accepts({"type": "status", "content": "x"}) + + +def test_summary_renderer_header_has_counts() -> None: + set_active_theme("gnexus-dark") + renderer = ContextSummaryRenderer() + msg = { + "type": "context_summary", + "content": "Earlier the user asked to build a TUI client.", + "messages_before": 42, + "messages_after": 8, + } + panel = renderer.render(msg) + assert panel.title == "Context compressed: 42 → 8 messages" + + +def test_summary_renderer_renders_summary_text() -> None: + set_active_theme("gnexus-dark") + renderer = ContextSummaryRenderer() + msg = { + "type": "context_summary", + "content": "## Summary\n- Built a TUI client\n- Added /compact", + "messages_before": 42, + "messages_after": 8, + } + text = _render_text(renderer.render(msg)) + assert "Summary" in text + assert "Built a TUI client" in text + + +def test_summary_renderer_handles_missing_counts() -> None: + set_active_theme("gnexus-dark") + renderer = ContextSummaryRenderer() + msg = {"type": "context_summary", "content": "text", "messages_before": None, "messages_after": None} + panel = renderer.render(msg) + assert panel.title == "Context compressed" \ No newline at end of file