diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 80539d2..4d3f8dd 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -41,6 +41,50 @@ self.items.append(ChatItem(kind="user_message", content=text)) self._current_assistant = None + def load_history(self, messages: list[dict]) -> None: + """Replace the item list with the session's stored messages. + + Maps persisted Message dicts (as returned by GET /sessions/{id}) back + into the same ChatItems the live stream produces, so a resumed session + shows its past conversation. Display-only context markers + (is_display=False: the context-only user message, compressor summaries, + compression events) are skipped — only what the user would have seen + live is rebuilt. + """ + self.items.clear() + self._current_assistant = None + self._current_thinking = None + + for m in messages: + if not m.get("is_display", True): + continue + role = m.get("role") + if role == "user": + self.items.append(ChatItem(kind="user_message", content=m.get("content") or "")) + elif role == "assistant": + thinking = m.get("thinking") + if thinking: + self.items.append(ChatItem(kind="thinking_block", content=thinking)) + for tc in m.get("tool_calls") or []: + self.items.append( + ChatItem( + kind="tool_started", + meta={"tool": tc.get("name", ""), "args": tc.get("arguments") or {}}, + ) + ) + content = m.get("content") or "" + if m.get("is_plan"): + self.items.append(ChatItem(kind="plan_ready", content=content, meta={"is_subagent": False})) + elif content: + self.items.append(ChatItem(kind="assistant_message", content=content)) + elif role == "tool": + self.items.append( + ChatItem( + kind="tool_call", + meta={"tool": m.get("name") or "", "result": m.get("content") or "", "success": True}, + ) + ) + def handle_ws_event(self, msg: dict) -> ChatItem | None: msg_type = msg.get("type") diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 026cc0c..15cf1b1 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -168,9 +168,11 @@ async def attach_session(self, session_id: str) -> None: self._ctx.session_id = session_id + history: list[dict] = [] try: session = api.get_session(session_id) self._ctx.profile_id = session.get("profile_id") or settings.default_profile_id + history = session.get("messages") or [] except Exception: self._ctx.profile_id = settings.default_profile_id @@ -191,6 +193,9 @@ ) self._status_panel.set_backend(settings.base_url) self._status_panel.set_theme(self._theme_name) + # Replay the session's past conversation before the connection banner + # so a resumed/switched session shows its history instead of a blank chat. + self._chat_panel.load_history(history) if cwd := self._ctx.cwd: self._chat_panel.handle_ws_event({"type": "status", "content": f"cwd: {cwd}"}) diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index 3d53a38..9844a73 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -103,6 +103,12 @@ self._model.add_user_message(text) self._refresh() + def load_history(self, messages: list[dict]) -> None: + """Populate the chat panel with a session's stored messages on resume.""" + self._model.load_history(messages) + self._chat_render_cache.clear() + self._refresh() + def handle_ws_event(self, msg: dict) -> None: self._model.handle_ws_event(msg) self._refresh() diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index 5557542..1c9350e 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -157,4 +157,119 @@ cached_ids = set(chat._chat_render_cache.keys()) assert not (cached_ids & empty_ids) # And the empty bubble is no longer in the model. - assert not any(it.kind == "assistant_message" for it in chat._model.items) \ No newline at end of file + assert not any(it.kind == "assistant_message" for it in chat._model.items) + +# ─── load_history (session resume) ───────────────────────────────────────── + + +def _hist_msg(**kw) -> dict: + """A persisted Message dict as GET /sessions/{id} returns it.""" + base = {"is_display": True} + base.update(kw) + return base + + +def test_chat_model_load_history_maps_roles() -> None: + from clients.terminal.tui.chat_model import ChatModel + + messages = [ + _hist_msg(role="user", content="hello"), + _hist_msg(role="assistant", thinking="hmm", content=""), + _hist_msg( + role="assistant", + content="", + tool_calls=[{"id": "1", "name": "filesystem", "arguments": {"path": "/x"}}], + ), + _hist_msg(role="tool", name="filesystem", content="listed: a, b"), + _hist_msg(role="assistant", content="X has a and b."), + ] + model = ChatModel() + model.load_history(messages) + + kinds = [it.kind for it in model.items] + assert kinds == [ + "user_message", + "thinking_block", + "tool_started", + "tool_call", + "assistant_message", + ] + assert model.items[0].content == "hello" + assert model.items[1].content == "hmm" + assert model.items[2].meta["tool"] == "filesystem" + assert model.items[2].meta["args"] == {"path": "/x"} + assert model.items[3].meta["result"] == "listed: a, b" + assert model.items[4].content == "X has a and b." + + +def test_chat_model_load_history_skips_non_display() -> None: + """Context-only user message, summaries, compression events are not shown.""" + from clients.terminal.tui.chat_model import ChatModel + + messages = [ + _hist_msg(role="user", content="display me", is_display=True), + _hist_msg(role="user", content="context only", is_display=False), + _hist_msg(role="assistant", content="summary", is_display=False, is_summary=True), + _hist_msg(role="assistant", content="real answer"), + ] + model = ChatModel() + model.load_history(messages) + contents = [(it.kind, it.content) for it in model.items] + assert contents == [("user_message", "display me"), ("assistant_message", "real answer")] + + +def test_chat_model_load_history_plan_block() -> None: + from clients.terminal.tui.chat_model import ChatModel + + messages = [ + _hist_msg(role="assistant", content="1. step\n2. step", is_plan=True), + ] + model = ChatModel() + model.load_history(messages) + assert model.items[0].kind == "plan_ready" + assert "step" in model.items[0].content + + +def test_chat_model_load_history_clears_existing() -> None: + """Loading history replaces any items already in the model (session switch).""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + model.add_user_message("old") + assert len(model.items) == 1 + + model.load_history([_hist_msg(role="user", content="new")]) + assert len(model.items) == 1 + assert model.items[0].content == "new" + + +@pytest.mark.anyio +async def test_chat_panel_load_history_renders(monkeypatch) -> None: + from rich.console import Console + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + captured = _capture_groups(chat) + chat.clear() + + chat.load_history( + [ + _hist_msg(role="user", content="hi there"), + _hist_msg(role="assistant", content="hello back"), + ] + ) + await pilot.pause() + group = captured[-1] + # Two rendered bubbles, no truncation hint. + assert len(group.renderables) == 2 + + console = Console(record=True, width=80, force_terminal=True, color_system=None) + + def _text(r) -> str: + console.print(r) + return console.export_text(clear=True) + + rendered = _text(group.renderables[0]) + _text(group.renderables[1]) + assert "hi there" in rendered + assert "hello back" in rendered diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index eb883d5..dd1aae4 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -567,3 +567,44 @@ pilot.app.post_message(ConnectionStatusChanged(connected=False, detail="")) await pilot.pause() assert activity._active is False + + +@pytest.mark.anyio +async def test_attach_session_loads_history(monkeypatch: pytest.MonkeyPatch) -> None: + """Resuming/attaching a session replays its stored messages into the chat.""" + import clients.terminal.api as api_module + import clients.terminal.tui.tui_app as tui_app_module + + class FakeBridge: + def __init__(self, app, session_id, cwd=None) -> None: + self.client = None + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + monkeypatch.setattr(tui_app_module, "WsBridge", FakeBridge) + + messages = [ + {"role": "user", "content": "earlier question", "is_display": True}, + {"role": "assistant", "content": "earlier answer", "is_display": True}, + {"role": "user", "content": "context-only", "is_display": False}, + ] + monkeypatch.setattr(api_module, "get_session", lambda sid: { + "session_id": sid, + "profile_id": "navi_code", + "messages": messages, + }) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await pilot.app.attach_session("sess-resume") + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + contents = [it.content for it in chat._model.items] + assert "earlier question" in contents + assert "earlier answer" in contents + # The context-only message (is_display=False) was skipped. + assert "context-only" not in contents