Skip to content

Commit e2f9464

Browse files
authored
feat(tui): improve transcript scrolling and tool panels (#5714)
* feat(tui): improve transcript scrolling and tool panels * feat(tui): default code project to launch directory * feat(tui): print session resume hint on exit * feat(tui): clarify agent directory label * fix(tui): address copilot review feedback * fix(tui): quote Windows resume project path * feat(tui): remind users to inspect folded activity * fix(tui): replay resume history with tool params * fix(tui): update slash help usages * fix(tui): defer resume history replay * fix(tui): stabilize windows resume tests
1 parent cc5327b commit e2f9464

20 files changed

Lines changed: 988 additions & 110 deletions

src/qwenpaw/agents/acp/server.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import annotations
1313

1414
import asyncio
15+
import json
1516
import logging
1617
from pathlib import Path
1718
from typing import Any
@@ -146,6 +147,21 @@ def __init__(self) -> None:
146147
self._reasoning_msg_ids: set[str] = set()
147148
self._streamed_text_msg_ids: set[str] = set()
148149

150+
@staticmethod
151+
def _tool_raw_input(data: dict[str, Any]) -> Any:
152+
arguments = data.get("arguments")
153+
if arguments is None:
154+
return None
155+
if isinstance(arguments, str):
156+
stripped = arguments.strip()
157+
if not stripped:
158+
return None
159+
try:
160+
return json.loads(stripped)
161+
except ValueError:
162+
return stripped
163+
return arguments
164+
149165
# pylint: disable=too-many-return-statements, too-many-branches
150166
def process(
151167
self,
@@ -192,6 +208,7 @@ def process(
192208
),
193209
str(data.get("name") or "tool"),
194210
status="in_progress",
211+
raw_input=self._tool_raw_input(data),
195212
),
196213
]
197214
return []

src/qwenpaw/cli/tui/app.py

Lines changed: 133 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,66 @@
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+
77137
class 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

Comments
 (0)