diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index b3d66b4..984153a 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -35,13 +35,40 @@ class ChatModel: """Accumulate stream deltas and tool events into ChatItems.""" - def __init__(self) -> None: + def __init__(self, cap: int | None = 600) -> None: self.items: list[ChatItem] = [] + # Bound on retained history so a very long autonomous session does not + # grow ``items`` without limit. The visible window (ChatPanel) is much + # smaller than this cap, so trimming the front only drops items that + # were already off-screen — the truncation hint in the panel still + # reads correctly (``hidden = len(items) - visible_limit``). None = no + # trimming (tests / small sessions). + self._cap = cap self._current_assistant: ChatItem | None = None self._current_thinking: ChatItem | None = None + def _trim(self) -> None: + """Drop the oldest items past the retention cap (front of the list).""" + cap = self._cap + if cap is None: + return + excess = len(self.items) - cap + if excess > 0: + del self.items[:excess] + + def _append(self, item: ChatItem) -> ChatItem: + """Append an item and trim the front past the retention cap. + + Central append point so every grow path (live events, history replay, + user messages) keeps ``items`` bounded without each caller repeating + the trim. Returns the item for the ``return self._append(item)`` pattern. + """ + self.items.append(item) + self._trim() + return item + def add_user_message(self, text: str) -> None: - self.items.append(ChatItem(kind="user_message", content=text)) + self._append(ChatItem(kind="user_message", content=text)) self._current_assistant = None def load_history(self, messages: list[dict]) -> None: @@ -69,25 +96,25 @@ continue role = m.get("role") if role == "user": - self.items.append(ChatItem(kind="user_message", content=m.get("content") or "")) + self._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)) + self._append(ChatItem(kind="thinking_block", content=thinking)) content = m.get("content") or "" if m.get("is_plan"): - self.items.append(ChatItem(kind="plan_ready", content=content, meta={"is_subagent": False})) + self._append(ChatItem(kind="plan_ready", content=content, meta={"is_subagent": False})) elif content: # Match the live stream: stream_delta (assistant text) arrives # BEFORE tool_started, so the text bubble sits above the tool # cards, not below them. Emitting tool_started first would strand # the answer under its own tool calls on resume. - self.items.append(ChatItem(kind="assistant_message", content=content)) + self._append(ChatItem(kind="assistant_message", content=content)) for tc in m.get("tool_calls") or []: tcid = tc.get("id") if tcid: args_by_id[tcid] = tc.get("arguments") or {} - self.items.append( + self._append( ChatItem( kind="tool_started", meta={"tool": tc.get("name", ""), "args": tc.get("arguments") or {}}, @@ -99,11 +126,11 @@ # the live stream_end appends at the end of a turn. elapsed = m.get("elapsed_seconds") if elapsed is not None: - self.items.append(ChatItem(kind="turn_meta", meta={"elapsed_seconds": elapsed})) + self._append(ChatItem(kind="turn_meta", meta={"elapsed_seconds": elapsed})) elif role == "tool": tcid = m.get("tool_call_id") args = args_by_id.pop(tcid, {}) if tcid else {} - self.items.append( + self._append( ChatItem( kind="tool_call", meta={ @@ -137,7 +164,7 @@ if msg_type == "thinking_delta": if self._current_thinking is None: self._current_thinking = ChatItem(kind="thinking_block", content="") - self.items.append(self._current_thinking) + self._append(self._current_thinking) self._current_thinking.content += msg.get("delta", "") return None @@ -154,13 +181,13 @@ content=msg.get("thinking", "") or "", meta={"is_subagent": bool(msg.get("is_subagent", False))}, ) - self.items.append(item) + self._append(item) return item if msg_type == "stream_delta": if self._current_assistant is None: self._current_assistant = ChatItem(kind="assistant_message", content="") - self.items.append(self._current_assistant) + self._append(self._current_assistant) self._current_assistant.content += msg.get("delta", "") return None @@ -170,23 +197,23 @@ # 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) + self._append(item) return item if msg_type == "tool_call": self._current_assistant = None item = ChatItem(kind="tool_call", meta=msg) - self.items.append(item) + self._append(item) return item if msg_type == "error": item = ChatItem(kind="error", content=msg.get("message", "")) - self.items.append(item) + self._append(item) return item if msg_type == "status": item = ChatItem(kind="status", content=msg.get("content", "")) - self.items.append(item) + self._append(item) return item if msg_type == "planning_status": @@ -208,7 +235,7 @@ content=label, meta={"phase": phase, "is_subagent": is_subagent}, ) - self.items.append(item) + self._append(item) return item if msg_type == "plan_ready": @@ -225,30 +252,31 @@ content=plan, meta={"is_subagent": is_subagent}, ) - self.items.append(item) + self._append(item) return item if msg_type == "stream_stopped": self._current_assistant = None self._current_thinking = None item = ChatItem(kind="status", content="Generation stopped by user") - self.items.append(item) + self._append(item) return item 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 - ) - ] + # conversation does not show empty "Navi" panels. Empty bubbles are + # created at the END of the turn (the current assistant/thinking that + # got no delta), so a tail-trim is sufficient — no full-copy scan of + # the whole history (O(tail) instead of O(n)). + while self.items and self.items[-1].kind in ( + "assistant_message", + "thinking_block", + ) and not self.items[-1].content: + self.items.pop() # Record the total time the agent spent on this request (all steps) # as a dim metadata line below the answer. elapsed_seconds is the # authoritative value measured by the backend over the whole turn. - self.items.append( + self._append( ChatItem(kind="turn_meta", meta={"elapsed_seconds": msg.get("elapsed_seconds")}) ) self._current_assistant = None @@ -267,7 +295,7 @@ "messages_after": msg.get("messages_after"), }, ) - self.items.append(item) + self._append(item) return item if msg_type in ("heartbeat", "session_sync"): @@ -277,10 +305,10 @@ # schedule_recall lifecycle (scheduled/fired/cancelled/skipped/ # rescheduled), pushed out-of-band by the scheduler/orchestrator. item = ChatItem(kind="recall", meta=msg) - self.items.append(item) + self._append(item) return item # Unknown event — store as status for debugging. item = ChatItem(kind="status", content=f"{msg_type}: {msg}") - self.items.append(item) + self._append(item) return item diff --git a/clients/terminal/tui/screens/theme_picker.py b/clients/terminal/tui/screens/theme_picker.py index 28cc1ba..b93b0e1 100644 --- a/clients/terminal/tui/screens/theme_picker.py +++ b/clients/terminal/tui/screens/theme_picker.py @@ -84,6 +84,11 @@ self._themes = ThemeRegistry.all() self._filtered = list(self._themes) self._list_items: list[ListItem] = [] + # Debounced live preview: apply_theme re-renders every chat bubble, so + # applying it on every highlight while scrolling the theme list would + # stutter. Coalesce rapid highlight moves into one apply per ~100 ms. + self._preview_timer = None + self._pending_preview: str | None = None def compose(self) -> ComposeResult: with Container(): @@ -121,7 +126,33 @@ app.apply_theme() self._render_list() + def _schedule_preview(self, name: str) -> None: + """Remember the latest highlighted theme and apply it once per ~100 ms. + + Re-scheduling the timer is deliberately avoided: a rapid burst of + highlights coalesces — the single pending fire picks up the freshest + ``_pending_preview``. """ + self._pending_preview = name + if self._preview_timer is None: + self._preview_timer = self.set_timer(0.1, self._apply_pending_preview) + + def _apply_pending_preview(self) -> None: + self._preview_timer = None + name = self._pending_preview + self._pending_preview = None + if name is not None: + self._preview_theme(name) + + def _cancel_pending_preview(self) -> None: + if self._preview_timer is not None: + self._preview_timer.stop() + self._preview_timer = None + self._pending_preview = None + def _restore_original(self) -> None: + # Cancel any pending preview first, otherwise it would fire after the + # restore and re-apply the highlighted (not original) theme. + self._cancel_pending_preview() app = self.app app.theme = self._original_theme set_active_theme(self._original_theme) @@ -136,6 +167,10 @@ self._render_list() def _select_highlighted(self) -> None: + # The caller (ThemesCommand callback) applies the chosen theme itself; + # cancel a pending preview so it cannot fire after dismiss and override + # the selection (or restore) with whatever was highlighted mid-scroll. + self._cancel_pending_preview() list_view = self.query_one("#theme-list", ListView) highlighted = list_view.index if highlighted is not None and 0 <= highlighted < len(self._filtered): @@ -157,7 +192,7 @@ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: index = self._list_items.index(event.item) if event.item in self._list_items else None if index is not None: - self._preview_theme(self._filtered[index]) + self._schedule_preview(self._filtered[index]) def on_key(self, event: events.Key) -> None: list_view = self.query_one("#theme-list", ListView) diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 1d7f4b9..24df484 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -144,6 +144,12 @@ self._status_panel.set_theme(self._theme_name) if self._todo_panel: self._todo_panel.refresh_content() + if self._chat_panel: + # Re-render chat bubbles in the new palette. The active theme is + # already set globally, so a fresh registry.render picks it up; the + # per-widget Content/height caches are dropped so the next paint + # rebuilds instead of returning the old-theme cached Content. + self._chat_panel.refresh_content() async def _startup(self) -> None: session_id = await self._resolve_session( diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index 1a02b7c..7558ad5 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -432,7 +432,11 @@ def __init__(self) -> None: super().__init__() - self._model = ChatModel() + # Retain ~3x the visible window in memory: the visible cap bounds what + # is laid out, this bounds what is kept at all, so a long autonomous + # session does not grow ``items`` without limit while still leaving + # headroom for scroll-up beyond the visible window. + self._model = ChatModel(cap=self._max_visible_items() * 3) self._registry = default_registry() # Reading mode: render items without their Panel bubble chrome so a raw # terminal Shift+drag + Ctrl+Shift+C copies clean text in terminals that @@ -477,6 +481,22 @@ for widget in self._widgets.values(): widget._rich_renderable = self._render_for(widget._item) widget._content_cache = None + widget._height_cache = None + widget.refresh(layout=True) + + def refresh_content(self) -> None: + """Re-render every visible item with the current theme. + + After a theme switch ``apply_theme`` has already set the active theme + globally; the item renderers read ``get_active_theme()`` so a fresh + ``registry.render`` picks up the new palette. The cached Content/height + (keyed on the old renderable) are dropped so the next paint rebuilds. + No remount — scroll position is preserved, same as reading-mode toggle. + """ + for widget in self._widgets.values(): + widget._rich_renderable = self._render_for(widget._item) + widget._content_cache = None + widget._height_cache = None widget.refresh(layout=True) def compose(self) -> ComposeResult: diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index 9be018f..8d1987f 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -450,6 +450,56 @@ assert [b.content for b in blocks] == ["first reasoning", "second reasoning"] +def test_chat_model_cap_trims_oldest_front() -> None: + """items stays bounded: past the retention cap the oldest (front) are + dropped so a long autonomous session does not grow without limit (3.P3).""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel(cap=5) + for i in range(10): + model.add_user_message(f"m{i}") + assert len(model.items) == 5 + # Front trimmed: the five kept are the most recent. + assert [it.content for it in model.items] == ["m5", "m6", "m7", "m8", "m9"] + + +def test_chat_model_cap_none_keeps_everything() -> None: + """cap=None disables trimming (used by tests / small sessions).""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel(cap=None) + for i in range(20): + model.add_user_message(f"m{i}") + assert len(model.items) == 20 + + +def test_chat_model_stream_end_purge_keeps_nonempty_assistant() -> None: + """A non-empty assistant answer survives stream_end; only the trailing + empty bubble(s) are purged (tail-trim, 1.P1).""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel(cap=None) + model.handle_ws_event({"type": "stream_delta", "delta": "answer"}) + model.handle_ws_event({"type": "stream_end", "elapsed_seconds": 5}) + assert any(it.kind == "assistant_message" and it.content == "answer" for it in model.items) + assert model.items[-1].kind == "turn_meta" + assert model.items[-1].meta["elapsed_seconds"] == 5 + + +def test_chat_model_stream_end_purges_trailing_empty_thinking_and_assistant() -> None: + """Empty thinking/assistant bubbles at the tail are purged on stream_end + (tail-trim) instead of a full-history scan (1.P1).""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel(cap=None) + model.handle_ws_event({"type": "thinking_delta", "delta": ""}) # empty thinking block + model.handle_ws_event({"type": "thinking_end"}) # closes it but leaves empty block in items + model.handle_ws_event({"type": "stream_end"}) + # The empty thinking block at the tail was purged. + assert not any(it.kind == "thinking_block" and not it.content for it in model.items) + assert model.items[-1].kind == "turn_meta" + + 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 diff --git a/tests/clients/test_tui_themes.py b/tests/clients/test_tui_themes.py index c2749f2..2ea6f4f 100644 --- a/tests/clients/test_tui_themes.py +++ b/tests/clients/test_tui_themes.py @@ -63,3 +63,84 @@ await pilot.press("enter") await pilot.pause() assert tui_settings.mouse is not original + + +@pytest.mark.anyio +async def test_apply_theme_re_renders_chat_bubbles() -> None: + """A theme switch must re-render already-drawn chat bubbles in the new + palette — refresh_content drops the cached Content so the next paint + rebuilds against the active theme (regression for 5.B1).""" + 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": "stream_delta", "delta": "themed answer"}) + await pilot.pause(0.2) # let the throttled rebuild flush + + assistant = next( + w for w in chat._widgets.values() if w._item.kind == "assistant_message" + ) + renderable_before = assistant._rich_renderable + # Sanity: the bubble rendered something. + assert renderable_before is not None + + pilot.app._theme_name = "gnexus-light" + pilot.app.apply_theme() + await pilot.pause() + + # The renderable was rebuilt (fresh Markdown against the new theme). + assert assistant._rich_renderable is not renderable_before + # The Content/height caches were dropped by refresh_content and then + # rebuilt against the NEW renderable on the next paint — so the cache + # keys reference the current renderable, not the old one. + if assistant._content_cache is not None: + assert assistant._content_cache[0][0] == id(assistant._rich_renderable) + if assistant._height_cache is not None: + assert assistant._height_cache[0][0] == id(assistant._rich_renderable) + + +@pytest.mark.anyio +async def test_theme_picker_debounces_live_preview() -> None: + """Rapid highlight moves coalesce into one apply_theme (~100 ms), not one + per move — apply_theme now re-renders every chat bubble (refresh_content), + so debouncing keeps the picker smooth (regression for 4.P2).""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app._run_command("/themes") + await pilot.pause() + screen = pilot.app.screen + assert screen.__class__.__name__ == "ThemePickerScreen" + + original = pilot.app._theme_name + # Two rapid scheduled previews — the second coalesces into the first's + # pending timer instead of scheduling a second apply. + screen._schedule_preview("gnexus-light") + timer_first = screen._preview_timer + screen._schedule_preview("gnexus-dark") + assert screen._preview_timer is timer_first # no new timer + assert screen._pending_preview == "gnexus-dark" # latest wins + assert pilot.app._theme_name == original # not applied yet (debounced) + + await pilot.pause(0.15) # past the debounce window + assert screen._preview_timer is None + assert pilot.app._theme_name == "gnexus-dark" + + +@pytest.mark.anyio +async def test_theme_picker_escape_cancels_pending_preview() -> None: + """Escape restores the original theme and cancels a pending debounced + preview, so it cannot fire after the restore and re-apply the highlighted + theme (regression for 4.P2).""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app._run_command("/themes") + await pilot.pause() + screen = pilot.app.screen + original = pilot.app._theme_name + + screen._schedule_preview("gnexus-light") + assert screen._preview_timer is not None + screen._restore_original() + assert screen._preview_timer is None # cancelled + await pilot.pause(0.15) # past the window — pending would have fired + assert pilot.app._theme_name == original # restored, not light