diff --git a/EvoScientist/cli/widgets/thinking_widget.py b/EvoScientist/cli/widgets/thinking_widget.py index a76eb36..97d84eb 100644 --- a/EvoScientist/cli/widgets/thinking_widget.py +++ b/EvoScientist/cli/widgets/thinking_widget.py @@ -2,6 +2,7 @@ Renders a Rich Panel matching the Rich CLI's style: blue border, "Thinking" title with spinner, dim content. +Collapses to a one-line summary after finalize; click to expand/collapse. """ from __future__ import annotations @@ -9,23 +10,27 @@ from __future__ import annotations from rich.panel import Panel from rich.text import Text +from textual.events import Click from textual.widgets import Static _SPINNER_FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f" _MAX_DISPLAY_CHARS = 1000 +_MAX_EXPANDED_CHARS = 5000 class ThinkingWidget(Static): """Collapsible panel showing the model's extended thinking. Uses Rich ``Panel`` for rendering, matching the Rich CLI output exactly. + While streaming, shows tail-truncated content with spinner. + After finalize, collapses to a one-line summary; click to expand/collapse. Usage:: w = ThinkingWidget(show_thinking=True) await container.mount(w) w.append_text("reasoning chunk...") - w.finalize() # stop spinner + w.finalize() # stop spinner, collapse """ DEFAULT_CSS = """ @@ -40,6 +45,7 @@ class ThinkingWidget(Static): self._content = "" self._is_active = True self._show = show_thinking + self._collapsed = False self._frame = 0 self._timer_handle = None if not show_thinking: @@ -54,18 +60,39 @@ class ThinkingWidget(Static): self._frame = (self._frame + 1) % len(_SPINNER_FRAMES) self._refresh_display() + def _char_count_label(self) -> str: + n = len(self._content) + if n >= 1000: + return f"{n / 1000:.1f}k chars" + return f"{n:,} chars" + def _refresh_display(self) -> None: if self._is_active: + # Streaming: show tail-truncated content with spinner char = _SPINNER_FRAMES[self._frame] title = f"Thinking {char}" + display = self._content.rstrip() + if len(display) > _MAX_DISPLAY_CHARS: + display = "..." + display[-_MAX_DISPLAY_CHARS:] + body = Text(display, style="dim") if display else Text("...", style="dim") + elif self._collapsed: + # Finalized + collapsed: first line preview + click hint + title = f"Thinking ({self._char_count_label()})" + first_line = self._content.strip().split("\n")[0].strip() + if len(first_line) > 80: + first_line = first_line[:77] + "\u2026" + preview = Text(first_line, style="dim") + preview.append(" [click to expand]", style="dim italic") + body = preview else: - title = "Thinking" + # Finalized + expanded: show with middle elision + title = f"Thinking ({self._char_count_label()})" + display = self._content.rstrip() + if len(display) > _MAX_EXPANDED_CHARS: + half = _MAX_EXPANDED_CHARS // 2 + display = display[:half] + "\n\n... (truncated) ...\n\n" + display[-half:] + body = Text(display, style="dim") if display else Text("(empty)", style="dim") - display = self._content.rstrip() - if len(display) > _MAX_DISPLAY_CHARS: - display = "..." + display[-_MAX_DISPLAY_CHARS:] - - body = Text(display, style="dim") if display else Text("...", style="dim") self.update(Panel(body, title=title, border_style="blue", padding=(0, 1))) def append_text(self, chunk: str) -> None: @@ -74,9 +101,17 @@ class ThinkingWidget(Static): self._refresh_display() def finalize(self) -> None: - """Mark thinking as complete — stop spinner, dim border.""" + """Mark thinking as complete — stop spinner, collapse.""" self._is_active = False + self._collapsed = True if self._timer_handle is not None: self._timer_handle.stop() self._timer_handle = None self._refresh_display() + + def on_click(self, event: Click) -> None: + """Toggle collapsed/expanded state after finalize.""" + if self._is_active: + return + self._collapsed = not self._collapsed + self._refresh_display() diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py index 95412d6..135223b 100644 --- a/EvoScientist/stream/display.py +++ b/EvoScientist/stream/display.py @@ -804,7 +804,7 @@ def _run_streaming( async def _periodic_refresh() -> None: try: while True: - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) live.refresh() except asyncio.CancelledError: pass