feat: free-scrolling (#262)

* feat: free-scrolling

* fix: anchor

* fix: textual private vars
This commit is contained in:
Wiktor Cupiał
2026-06-08 16:23:46 +02:00
committed by GitHub
parent b4ffb35d71
commit ac052bbb4c
+50 -74
View File
@@ -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,