7474)
7575
7676
77+ class TranscriptScroll (VerticalScroll ):
78+ """The transcript container; follows the end only when requested.
79+
80+ Textual's ``anchor()`` immediately calls ``scroll_end()``, which
81+ bottom-aligns underfilled launch content like the welcome logo. Keep the
82+ first transcript unanchored, then arm the anchor lazily after explicit
83+ user actions request following and the transcript has real scroll range.
84+ """
85+
86+ def __init__ (self , * children , ** kwargs ) -> None :
87+ super ().__init__ (* children , ** kwargs )
88+ self ._follow_end = False
89+ self ._anchor_released = False
90+
91+ def follow_end (self ) -> None :
92+ """Keep subsequent content pinned to the end once scrolling exists."""
93+ self ._follow_end = True
94+ self ._anchor_released = False
95+ self .sync_follow_end ()
96+
97+ def follow_future_content (self ) -> None :
98+ """Follow later transcript growth without moving current content."""
99+ self ._follow_end = True
100+
101+ def sync_follow_end (self ) -> None :
102+ """Apply the requested end-follow state after layout has settled."""
103+ self .call_after_refresh (self ._sync_follow_end )
104+
105+ def release_anchor (self ) -> None :
106+ super ().release_anchor ()
107+ self .sync_follow_end ()
108+
109+ def watch_scroll_y (self , old_value : float , new_value : float ) -> None :
110+ super ().watch_scroll_y (old_value , new_value )
111+ self .sync_follow_end ()
112+
113+ def _sync_follow_end (self ) -> None :
114+ if self .max_scroll_y <= 0 :
115+ if self .is_anchored :
116+ self .anchor (False )
117+ self ._anchor_released = False
118+ return
119+
120+ if self ._anchor_released :
121+ if self .scroll_y >= self .max_scroll_y :
122+ self ._anchor_released = False
123+ self ._follow_end = True
124+ else :
125+ self ._follow_end = False
126+ return
127+
128+ if not self ._follow_end :
129+ return
130+
131+ if self .is_anchored :
132+ self .scroll_end (immediate = True , animate = False )
133+ else :
134+ self .anchor ()
135+
136+
77137class PawApp (App ):
78138 """Streaming chat over a :class:`TuiTransport` (ACP)."""
79139
@@ -158,14 +218,18 @@ def __init__(
158218 agent : str = "default" ,
159219 target : str | None = None ,
160220 resume_session_id : str | None = None ,
221+ workspace_dir : str | None = None ,
222+ project_dir : str | None = None ,
161223 ) -> None :
162224 super ().__init__ ()
163225 self ._transport = transport
164226 self ._agent = agent
165227 self ._target = target
166228 # When launched with --resume, the transport opens this session and
167- # replays its history; skip the welcome banner so the two don't mix .
229+ # replays its history below the welcome banner.
168230 self ._resume_session_id = resume_session_id
231+ self ._workspace_dir = workspace_dir
232+ self ._project_dir = project_dir
169233 self ._assistant : AssistantMessage | None = None
170234 self ._thought : ThoughtMessage | None = None
171235 self ._activity : ActivityLine | None = None
@@ -215,7 +279,7 @@ def __init__(
215279 # -- layout --------------------------------------------------------------
216280 def compose (self ) -> ComposeResult :
217281 yield StatusBar ()
218- yield VerticalScroll (id = "transcript" )
282+ yield TranscriptScroll (id = "transcript" )
219283 yield self ._menu
220284 yield self ._permission
221285 yield PromptInput (
@@ -232,27 +296,35 @@ def compose(self) -> ComposeResult:
232296
233297 async def on_mount (self ) -> None :
234298 self .query_one ("#prompt" , PromptInput ).focus ()
299+ # Do not anchor the launch transcript: Textual's anchor immediately
300+ # scrolls to the end, which can bottom-align the welcome logo before
301+ # any chat content exists. Submitting input requests following later.
235302 self ._status ().set (agent = self ._agent )
236303 self ._apply_theme_prompt (self ._theme_prompt , notify = False )
304+ await self ._mount (self ._welcome_message (), sync_follow = False )
237305 if self ._resume_session_id is not None :
238306 await self ._mount (
239- InfoMessage ("Resumed previous session — replaying history…" ),
307+ InfoMessage ("Resumed previous session." ),
308+ sync_follow = False ,
240309 )
241310 else :
242- await self ._mount (
243- WelcomeMessage (
244- palette_for_prompt (self ._theme_prompt ),
245- accent_for_prompt (self ._theme_prompt ),
246- ),
247- )
311+ self ._transcript ().follow_future_content ()
248312 self ._consume ()
249313
250314 # -- helpers -------------------------------------------------------------
251315 def _status (self ) -> StatusBar :
252316 return self .query_one (StatusBar )
253317
254- def _transcript (self ) -> VerticalScroll :
255- return self .query_one ("#transcript" , VerticalScroll )
318+ def _transcript (self ) -> TranscriptScroll :
319+ return self .query_one ("#transcript" , TranscriptScroll )
320+
321+ def _welcome_message (self ) -> WelcomeMessage :
322+ return WelcomeMessage (
323+ palette_for_prompt (self ._theme_prompt ),
324+ accent_for_prompt (self ._theme_prompt ),
325+ workspace_dir = self ._workspace_dir ,
326+ project_dir = self ._project_dir ,
327+ )
256328
257329 def _set_command_catalog (self ) -> None :
258330 seen : set [str ] = set ()
@@ -297,17 +369,21 @@ async def _refresh_recent_sessions(self) -> None:
297369 return
298370 self ._set_recent_sessions (sessions )
299371
300- async def _mount (self , widget ) -> None :
372+ async def _mount (self , widget , * , sync_follow : bool = True ) -> None :
301373 await self ._transcript ().mount (widget )
302- self ._scroll_transcript_end ()
374+ # No unconditional scroll: if following was requested and the user
375+ # hasn't scrolled away, the transcript lazily anchors after layout.
376+ if sync_follow :
377+ self ._transcript ().sync_follow_end ()
303378
304- def _scroll_transcript_end (self , * , defer : bool = False ) -> None :
305- if defer :
306- self .call_after_refresh (
307- lambda : self ._transcript ().scroll_end (animate = False ),
308- )
309- return
310- self ._transcript ().scroll_end (animate = False )
379+ def _scroll_transcript_end (self ) -> None :
380+ """Jump to the end of the transcript and resume following.
381+
382+ Reserved for explicit user actions (submitting input): scroll_end
383+ re-arms the anchor even if the user had scrolled away, so it must
384+ never run for agent-driven events.
385+ """
386+ self ._transcript ().follow_end ()
311387
312388 async def _ensure_activity_line (self ) -> ActivityLine :
313389 await self ._ensure_turn_label ()
@@ -369,6 +445,11 @@ async def _submit_prompt(self) -> None:
369445 prompt .set_programmatic_value ("" )
370446 self ._resize_prompt ("" )
371447 self ._menu .display = False
448+ # Submitting input is the one action that jumps back to the end:
449+ # the user wants to see their message land and the reply follow.
450+ # (Queue auto-delivery on turn end deliberately does not — it isn't
451+ # a user action, so it must not move a reading user's viewport.)
452+ self ._scroll_transcript_end ()
372453 if text .startswith ("/" ):
373454 await self ._handle_local_command (text )
374455 return
@@ -497,9 +578,15 @@ def action_toggle_inspection(self) -> None:
497578 thought .collapsed = not self ._inspection_mode
498579 thought .set_class (not self ._inspection_mode , "hidden" )
499580 for panel in self .query (ToolPanel ):
581+ # Inspection opens every panel so params + output are readable
582+ # without a click per tool; leaving restores the tidy default
583+ # (finished collapsed, running open).
584+ panel .collapsed = not self ._inspection_mode and panel .is_done
500585 self ._apply_tool_visibility (panel )
501586 self ._apply_activity_visibility ()
502- self ._scroll_transcript_end (defer = True )
587+ # No explicit scroll: if the user is following, the anchor keeps the
588+ # end pinned through the re-layout; if they scrolled up to inspect
589+ # something specific, toggling modes must not yank them away.
503590 mode = "inspection" if self ._inspection_mode else "friendly"
504591 self .notify (f"{ mode } mode" , timeout = 2 )
505592
@@ -513,15 +600,7 @@ async def _handle_local_command(self, raw: str) -> None:
513600 command , _ , rest = raw .partition (" " )
514601 match command :
515602 case "/help" :
516- await self ._mount (
517- InfoMessage (
518- "Type /resume to pick a recent session from the "
519- "suggestions (or /resume list to browse all), "
520- "/theme <prompt> to personalize the background, "
521- "or /inspect for details. Model and provider "
522- "commands (e.g. /model) are handled by QwenPaw." ,
523- ),
524- )
603+ await self ._mount (InfoMessage (_HELP_TEXT ))
525604 case "/resume" :
526605 await self ._handle_resume_command (rest .strip ())
527606 case "/theme" :
@@ -647,10 +726,10 @@ async def _resume_session(self, session_id: str) -> None:
647726 # Clear the context-usage bar too; the next model call on the resumed
648727 # session reports fresh occupancy via ``usage_update``.
649728 self ._status ().set (used = 0 , size = 0 )
650- # Mounted before the load so it sits above the replayed transcript;
651- # the replay updates only land once load_session is awaited below.
729+ await self ._mount (self ._welcome_message (), sync_follow = False )
652730 await self ._mount (
653- InfoMessage ("Resumed previous session — replaying history…" ),
731+ InfoMessage ("Resumed previous session." ),
732+ sync_follow = False ,
654733 )
655734 try :
656735 await self ._transport .load_session (session_id )
@@ -831,7 +910,7 @@ async def _dispatch(self, event) -> None:
831910 self ._assistant = AssistantMessage ()
832911 await self ._mount (self ._assistant )
833912 await self ._assistant .append (event .text )
834- self ._scroll_transcript_end ()
913+ self ._transcript (). sync_follow_end ()
835914 self ._stream_chars += len (event .text )
836915 self ._refresh_tokens ()
837916
@@ -849,6 +928,7 @@ async def _dispatch(self, event) -> None:
849928 await self ._mount (self ._thought )
850929 self ._apply_thought_visibility (self ._thought )
851930 self ._thought .append (event .text )
931+ self ._transcript ().sync_follow_end ()
852932 # Reasoning counts toward output tokens too.
853933 self ._stream_chars += len (event .text )
854934 self ._refresh_tokens ()
@@ -888,7 +968,9 @@ async def _dispatch(self, event) -> None:
888968 status = event .status ,
889969 output = event .output ,
890970 params = event .params ,
971+ auto_collapse = not self ._inspection_mode ,
891972 )
973+ self ._transcript ().sync_follow_end ()
892974 self ._apply_tool_visibility (panel )
893975 # Surface any files the tool returned (e.g. send_file_to_user) as
894976 # their own clickable transcript line, since the panel collapses.
@@ -1066,6 +1148,23 @@ def _local_commands() -> list[SlashCommand]:
10661148 return commands
10671149
10681150
1151+ _HELP_TEXT = """Slash commands:
1152+ /help — show this help
1153+ /resume — pick a recent session
1154+ /resume list — browse all resumable sessions
1155+ /resume <id-prefix> — resume a matching session
1156+ /theme or /theme gallery — open the theme gallery
1157+ /theme <theme-id|prompt> — apply a named or custom theme
1158+ /inspect — toggle thought/tool inspection
1159+ /model — show the current model
1160+ /model list — list available models
1161+ /model <provider>:<model> — switch model
1162+ /model reset — reset to the global default model
1163+ /clear — clear the current session context
1164+ /compact — compact current context
1165+ /skills — list enabled skills"""
1166+
1167+
10691168_LONG_PASTE_CHAR_THRESHOLD = 2000
10701169_LONG_PASTE_LINE_THRESHOLD = 12
10711170_ATTACHMENT_DIR = "attachments"
0 commit comments