diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 307535f..446c718 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -43,8 +43,12 @@ msg_type = msg.get("type") if msg_type == "stream_start": - self._current_assistant = ChatItem(kind="assistant_message", content="") - self.items.append(self._current_assistant) + # A new turn starts. Do NOT eagerly create an assistant bubble here — + # that would place the (possibly empty) answer at the TOP, above the + # tools/thinking that follow, where scroll_end hides it. Instead reset + # the current-assistant pointer and let the first stream_delta create + # the bubble lazily, at the position where text actually arrives. + self._current_assistant = None return None if msg_type == "thinking_delta": @@ -66,11 +70,16 @@ return None if msg_type == "tool_started": + # Drop the current assistant pointer so the next stream_delta opens a + # fresh bubble BELOW the tool cards (the final answer ends up at the + # bottom, visible after scroll_end — not stranded at the top). + self._current_assistant = None item = ChatItem(kind="tool_started", meta=msg) self.items.append(item) return item if msg_type == "tool_call": + self._current_assistant = None item = ChatItem(kind="tool_call", meta=msg) self.items.append(item) return item @@ -92,7 +101,21 @@ self.items.append(item) return item - if msg_type in ("stream_end", "context_compressed", "heartbeat", "session_sync"): + if msg_type == "stream_end": + # Purge assistant/thinking bubbles that never received any text so the + # conversation does not show empty "Navi" panels. + self.items = [ + it + for it in self.items + if not ( + it.kind in ("assistant_message", "thinking_block") and not it.content + ) + ] + self._current_assistant = None + self._current_thinking = None + return None + + if msg_type in ("context_compressed", "heartbeat", "session_sync"): return None # Unknown event — store as status for debugging. diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index f534887..0b4201d 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -111,6 +111,77 @@ @pytest.mark.anyio +async def test_stream_start_does_not_create_empty_assistant_item() -> None: + """stream_start must not eagerly add an empty assistant bubble. + + The answer bubble is created lazily on the first stream_delta so it lands at + the position where text arrives, not stranded at the top of the turn. + """ + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event({"type": "stream_start"}) + await pilot.pause() + assert chat._model._current_assistant is None + assert not any(item.kind == "assistant_message" for item in chat._model.items) + + +@pytest.mark.anyio +async def test_final_answer_appears_after_tools() -> None: + """The final assistant answer must render BELOW the tool cards, not above them. + + Regression: stream_start used to create the assistant bubble at the top, so + the final answer (accumulated there) was scrolled out of view by scroll_end + while tools/thinking below stayed visible. Now each text segment opens its + own bubble at the point it arrives, so the final answer lands at the bottom. + """ + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event({"type": "stream_start"}) + chat.handle_ws_event({"type": "thinking_delta", "delta": "hmm"}) + chat.handle_ws_event({"type": "thinking_end"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "I'll list X. "}) + chat.handle_ws_event({"type": "tool_started", "tool": "filesystem", "args": {}}) + chat.handle_ws_event( + {"type": "tool_call", "tool": "filesystem", "args": {}, "result": "a\nb", "success": True} + ) + chat.handle_ws_event({"type": "thinking_delta", "delta": "now answer"}) + chat.handle_ws_event({"type": "thinking_end"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "X contains a and b."}) + chat.handle_ws_event({"type": "stream_end", "content": "X contains a and b."}) + await pilot.pause() + + items = chat._model.items + # The last item is the final answer. + assert items[-1].kind == "assistant_message" + assert items[-1].content == "X contains a and b." + # It must come after the last tool_call. + last_tool = max(i for i, it in enumerate(items) if it.kind == "tool_call") + last_answer = max(i for i, it in enumerate(items) if it.kind == "assistant_message") + assert last_answer > last_tool + # No empty assistant bubbles. + assert all(it.content for it in items if it.kind == "assistant_message") + + +@pytest.mark.anyio +async def test_empty_assistant_item_purged_on_stream_end() -> None: + """stream_end drops assistant/thinking bubbles that never received any text.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event({"type": "stream_start"}) + # An empty delta creates a bubble with no content. + chat.handle_ws_event({"type": "stream_delta", "delta": ""}) + chat.handle_ws_event({"type": "stream_end", "content": ""}) + await pilot.pause() + assert not any( + item.kind in ("assistant_message", "thinking_block") and not item.content + for item in chat._model.items + ) + + +@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: