feat: free-scrolling (#262)
* feat: free-scrolling * fix: anchor * fix: textual private vars
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user