From ac052bbb4c8d6ec1d6f113b797326beee56587bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Cupia=C5=82?= <98488731+mooshroom4422@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:23:46 +0200 Subject: [PATCH] feat: free-scrolling (#262) * feat: free-scrolling * fix: anchor * fix: textual private vars --- EvoScientist/cli/tui_interactive.py | 124 +++++++++++----------------- 1 file changed, 50 insertions(+), 74 deletions(-) diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 9d5bad1..685c2ca 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -52,6 +52,7 @@ from .history_suggester import HistorySuggester from .status_bar import ( STATUS_BAR_BG, STATUS_DIM, + STATUS_GOOD, STATUS_HINT_BUSY, STATUS_HINT_IDLE, STATUS_HINT_WRITING, @@ -443,6 +444,8 @@ def run_textual_interactive( self._status_phase: ResearchPhase = ResearchPhase.IDLE self._turn_started_at: datetime | None = None self._compacting_widget: CompactingWidget | None = None + self._chat_following: bool = True + self._new_content_below: bool = False # ── Background agent / MCP loading ─────────────────── @@ -551,7 +554,7 @@ def run_textual_interactive( title=title, ) await container.mount(picker) - self._schedule_scroll_to_bottom(container, delays=()) + self._anchor_chat(container) picker.focus() return await self._wait_for_thread_pick(picker) @@ -568,7 +571,7 @@ def run_textual_interactive( pre_filter_tag=pre_filter_tag, ) await container.mount(browser) - self._schedule_scroll_to_bottom(container, delays=()) + self._anchor_chat(container) browser.focus() return await self._wait_for_skill_browse(browser) @@ -585,7 +588,7 @@ def run_textual_interactive( pre_filter_tag=pre_filter_tag, ) await container.mount(browser) - self._schedule_scroll_to_bottom(container, delays=()) + self._anchor_chat(container) browser.focus() return await self._wait_for_mcp_browse(browser) @@ -605,7 +608,7 @@ def run_textual_interactive( current_provider=current_provider, ) await container.mount(picker) - self._schedule_scroll_to_bottom(container, delays=()) + self._anchor_chat(container) picker.focus() return await self._wait_for_model_pick(picker) @@ -997,37 +1000,16 @@ def run_textual_interactive( # ── Widget helpers ───────────────────────────────────── - def _schedule_scroll_to_bottom( - self, - container: VerticalScroll, - *, - delays: tuple[float, ...] = (0.3, 0.8), - immediate: bool = True, - ) -> None: - """Schedule deferred scrolls so the viewport lands at the bottom. - - Markdown- and list-heavy widgets lay out across multiple refresh - cycles, so a single ``scroll_end()`` may fire against a stale - ``virtual_size`` and leave the viewport mid-content. Re-schedule - ``scroll_end`` at each delay to follow subsequent reflows. - """ - if immediate: - self.call_after_refresh( - lambda: container.scroll_end(animate=False), - ) - for delay in delays: - self.set_timer( - delay, - lambda: self.call_after_refresh( - lambda: container.scroll_end(animate=False), - ), - ) + def _anchor_chat(self, container: VerticalScroll | None = None) -> None: + """Re-engage the scroll anchor so the viewport pins to the bottom.""" + if container is None: + container = self.query_one("#chat", VerticalScroll) + container.anchor() def _append_system(self, text: str, style: str = "dim") -> None: """Mount a SystemMessage widget into #chat.""" container = self.query_one("#chat", VerticalScroll) container.mount(SystemMessage(text, msg_style=style)) - container.scroll_end(animate=False) def _mount_renderable(self, renderable: Any) -> None: """Mount a Rich renderable (e.g. Table) as a Static widget.""" @@ -1044,7 +1026,6 @@ def run_textual_interactive( container.mount(CompactSummaryWidget(renderable.summary_text)) else: container.mount(Static(renderable)) - container.scroll_end(animate=False) async def _start_compacting_indicator(self) -> None: """Show a transient timer widget while /compact is running.""" @@ -1053,7 +1034,6 @@ def run_textual_interactive( widget = CompactingWidget() self._compacting_widget = widget await container.mount(widget) - container.scroll_end(animate=False) async def _stop_compacting_indicator(self) -> None: """Remove the transient /compact progress widget, if present.""" @@ -1268,6 +1248,8 @@ def run_textual_interactive( ) container = self.query_one("#chat", VerticalScroll) + self._chat_following = True + self._new_content_below = False # 1. Mount user message + loading spinner if not skip_user_message: @@ -1310,26 +1292,6 @@ def run_textual_interactive( _todo_sent = False _media_sent: set[str] = set() _MIN_THINKING_LEN = 200 - _scroll_pending = False - - def _schedule_scroll() -> None: - """Throttle scroll_end to at most once per 200ms. - - Uses call_after_refresh so the scroll happens after Textual - finishes its layout pass — otherwise scroll_end may see - stale widget heights and not scroll far enough. - """ - nonlocal _scroll_pending - if not _scroll_pending: - _scroll_pending = True - self.set_timer(0.2, _do_scroll) - - def _do_scroll() -> None: - nonlocal _scroll_pending - _scroll_pending = False - self.call_after_refresh( - lambda: container.scroll_end(animate=False), - ) metadata = build_metadata(self._workspace_dir, self._current_model) response = "" @@ -1415,7 +1377,6 @@ def run_textual_interactive( if suffix: await response_display.assistant.append_content(suffix) - _schedule_scroll() return final_text def _finalize_active_summarization() -> None: @@ -1511,6 +1472,7 @@ def run_textual_interactive( thinking_w = None summarization_w = None try: + _anchor_engaged = False async for event in stream_agent_events( self._agent_loader.agent, _stream_input, @@ -1520,6 +1482,22 @@ def run_textual_interactive( if is_stream_cancel_requested(cancel_scope): response = await _mark_cancelled_response() break + if container.max_scroll_y > 0: + at_end = container.is_vertical_scroll_end + if not _anchor_engaged: + _anchor_engaged = True + if at_end: + container.anchor() + else: + self._chat_following = False + self._new_content_below = True + elif self._chat_following and not at_end: + self._chat_following = False + self._new_content_below = True + elif not self._chat_following and at_end: + container.anchor() + self._chat_following = True + self._new_content_below = False event_type = state.handle_event(event) new_phase = state.compute_phase() @@ -1625,7 +1603,6 @@ def run_textual_interactive( ) await container.mount(ToolSelectionWidget(tools)) - _schedule_scroll() elif event_type == "text": chunk = event.get("content", "") @@ -1835,7 +1812,6 @@ def run_textual_interactive( _prompt.disabled = True ask_w = AskUserWidget(questions) await container.mount(ask_w) - _schedule_scroll() self.call_after_refresh(ask_w.focus_active) result = await self._wait_for_ask_user(ask_w) try: @@ -1914,7 +1890,6 @@ def run_textual_interactive( approval_w = ApprovalWidget(action_reqs) await container.mount(approval_w) - _schedule_scroll() decided_event = await self._wait_for_approval(approval_w) await approval_w.remove() _prompt.disabled = False @@ -1961,11 +1936,6 @@ def run_textual_interactive( if response_display.assistant is None: response_display.assistant = AssistantMessage(clean) await container.mount(response_display.assistant) - self._schedule_scroll_to_bottom( - container, - delays=(0.15, 0.4, 0.8, 1.5), - immediate=False, - ) elif response_display.assistant._content != clean: response_display.assistant._content = clean await response_display.assistant.stop_stream() @@ -1994,9 +1964,6 @@ def run_textual_interactive( error_msg = event.get("message", "Unknown error") self._append_system(f"Error: {error_msg}", style="red") - # Scroll after Textual processes the layout update - _schedule_scroll() - response = (state.response_text or "").strip() except asyncio.CancelledError: @@ -2066,8 +2033,11 @@ def run_textual_interactive( and len(state.thinking_text) >= _MIN_THINKING_LEN ): on_thinking_cb(state.thinking_text.rstrip()) - # Final scrolls to ensure last content is visible. - self._schedule_scroll_to_bottom(container) + # Re-anchor so final content is visible, but only + # if the user hasn't scrolled away. + if self._chat_following: + self._anchor_chat(container) + self._new_content_below = False # HITL / ask_user: if interrupt was handled, loop back to resume stream if is_stream_cancel_requested(cancel_scope): @@ -2194,7 +2164,6 @@ def run_textual_interactive( f"[{msg.channel_type}: Received from {msg.sender}]", style="dim", ) - container.scroll_end(animate=False) # Build channel callbacks (fire-and-forget to avoid blocking UI) def _send_to_channel(coro, label: str) -> None: @@ -2840,12 +2809,9 @@ def run_textual_interactive( await container.mount( SystemMessage("── End of history ──", msg_style="dim") ) - # History can hold dozens of Markdown-heavy AssistantMessages - # whose async layout keeps growing virtual_size for several - # seconds; schedule enough retries to catch the final reflow. - self._schedule_scroll_to_bottom( - container, delays=(0.1, 0.3, 0.6, 1.0, 1.8, 3.0) - ) + # Anchor so the viewport pins to the bottom as Markdown-heavy + # AssistantMessages finish their async layout reflows. + self._anchor_chat(container) # ── Quit handling ────────────────────────────────────── @@ -3049,8 +3015,18 @@ def run_textual_interactive( hint = Text.assemble( (hint_label, hint_style), - (" │ ", f"on {STATUS_BAR_BG} {STATUS_DIM}"), ) + if ( + self._busy + and self._status_phase != ResearchPhase.THINKING + and self._new_content_below + ): + hint.append(" │ ", style=f"on {STATUS_BAR_BG} {STATUS_DIM}") + hint.append( + "↓ New content below", + style=f"on {STATUS_BAR_BG} {STATUS_GOOD} bold", + ) + hint.append(" │ ", style=f"on {STATUS_BAR_BG} {STATUS_DIM}") remaining_width = max(1, width - len(hint.plain)) metrics = build_status_text( self._status_snapshot,