From 31bf8653d9d8b5ab4188c2848e0d845d491c0b88 Mon Sep 17 00:00:00 2001 From: "Jiaqi (0x7c13) Liu" Date: Sun, 25 Oct 2026 00:53:09 +0800 Subject: [PATCH] fix(tui): follow new content only from the bottom, and steady the chat The log screen (F6) and a sub-agent's View details went to new content even while the user read further up. Both now follow new content only while they are at the bottom: a scroll up stops the following, and a scroll back to the bottom resumes it (app/tui/util/bottom_follow.py). Every scroll request starts in Textual's _scroll_to, so the views decide there, by where the scroll heads; a selection dragged past the edge is judged by where the view goes. A following details view waits for the scrolls Textual runs after the next refresh before it moves to new content. Past 1500 lines the log drops its oldest lines without moving the ones a scrolled-up reader is on. The chat panel: - leaves the 1fr bottom spacer out of the arrangement once content fills the viewport, so a scrollbar no longer flashes after tools expand; - while an answer streams, keeps the rows its text took at the same width (an unclosed ** or a link wraps a line for a moment) and the rows a folded tool group gave up, each up to a third of the panel, so a view following the bottom no longer moves down a row and back; - brings a new user message into view with the layout that places it, not a deferred scroll_visible that a held batch_update postpones (on Windows the message stayed below the viewport until the reply came); - counts rows a replay prepends above a revealed message as no reply to follow, and drops a TOC jump still held when a message is sent. The scheduled GC freeze calibration had failed since the chat panel began streaming model text: scripts/profile_chat_panel.py still called add_agent_message(is_final=...). Its workloads move to scripts/chat_panel_workloads.py, which drives the live API and is built by a PR test. --- .github/workflows/ci.yml | 4 +- .github/workflows/gc-freeze-calibration.yml | 2 + AGENTS.md | 4 +- docs/en/guides/configuration/agents.md | 2 +- docs/en/guides/running/workflows.md | 2 +- docs/zh-Hans/guides/configuration/agents.md | 2 +- docs/zh-Hans/guides/running/workflows.md | 2 +- scripts/chat_panel_workloads.py | 482 +++++++++++++ scripts/profile_chat_panel.py | 461 +----------- src/chrys/app/tui/screens/logs/screen.py | 62 +- src/chrys/app/tui/util/bottom_follow.py | 141 ++++ .../widgets/chat/agent_transcript_surface.py | 38 +- .../app/tui/widgets/chat/live_renderer.py | 8 +- src/chrys/app/tui/widgets/chat/messages.py | 75 ++ src/chrys/app/tui/widgets/chat/panel.py | 24 +- src/chrys/app/tui/widgets/chat/ports.py | 2 + .../app/tui/widgets/chat/scroll_controller.py | 140 +++- src/chrys/app/tui/widgets/chat/tool_call.py | 8 + src/chrys/app/tui/widgets/markdown/widget.py | 8 + tests/app/tui/screens/test_log_viewer.py | 435 ++++++++++++ tests/app/tui/screens/test_tool_view_modal.py | 181 ++++- .../widgets/chat/test_chat_panel_workloads.py | 79 +++ .../widgets/chat/test_replay_tail_first.py | 25 + .../app/tui/widgets/test_chat_panel_scroll.py | 660 +++++++++++++++++- tests/support/tui_helpers.py | 9 +- 25 files changed, 2331 insertions(+), 525 deletions(-) create mode 100644 scripts/chat_panel_workloads.py create mode 100644 src/chrys/app/tui/util/bottom_follow.py create mode 100644 tests/app/tui/widgets/chat/test_chat_panel_workloads.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cc2332c8..81ea71e13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,8 @@ jobs: python-version-file: .python-version allow-prereleases: true - run: uv sync - - run: uv run ruff check src/ tests/ scripts/chrys_test.py scripts/check_wheel.py scripts/calibrate_gc_freeze.py scripts/gc_freeze_calibration_math.py - - run: uv run ruff format --check src/ tests/ scripts/chrys_test.py scripts/check_wheel.py scripts/calibrate_gc_freeze.py scripts/gc_freeze_calibration_math.py + - run: uv run ruff check src/ tests/ scripts/chrys_test.py scripts/check_wheel.py scripts/calibrate_gc_freeze.py scripts/gc_freeze_calibration_math.py scripts/chat_panel_workloads.py + - run: uv run ruff format --check src/ tests/ scripts/chrys_test.py scripts/check_wheel.py scripts/calibrate_gc_freeze.py scripts/gc_freeze_calibration_math.py scripts/chat_panel_workloads.py test_linux: name: Unix shard (${{ matrix.label }}) diff --git a/.github/workflows/gc-freeze-calibration.yml b/.github/workflows/gc-freeze-calibration.yml index 0c91573a7..79cd42924 100644 --- a/.github/workflows/gc-freeze-calibration.yml +++ b/.github/workflows/gc-freeze-calibration.yml @@ -15,7 +15,9 @@ on: paths: - ".github/workflows/gc-freeze-calibration.yml" - "scripts/calibrate_gc_freeze.py" + - "scripts/chat_panel_workloads.py" - "scripts/gc_freeze_calibration_math.py" + - "scripts/profile_chat_panel.py" - "src/chrys/app/tui/support/gc_freeze.py" - "tests/app/tui/support/test_gc_freeze_calibration.py" diff --git a/AGENTS.md b/AGENTS.md index 5ea0c8f6a..19475eb26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ uv run icode acp # ACP stdio server; also: serve, agent, mod uv run python scripts/chrys_test.py --smart --paths # default verification; both modes run below normal CPU priority uv run python scripts/chrys_test.py --full # complete non-integration suite; only when asked uv run pytest tests/x/test_y.py::test_fn -n0 # debugging only -uv run ruff check src/ tests/ scripts/chrys_test.py scripts/check_wheel.py scripts/calibrate_gc_freeze.py scripts/gc_freeze_calibration_math.py # pyproject's fix = true EDITS files (--no-fix inspects); CI auto-fixes too, so only unfixable errors fail there -uv run ruff format --check src/ tests/ scripts/chrys_test.py scripts/check_wheel.py scripts/calibrate_gc_freeze.py scripts/gc_freeze_calibration_math.py +uv run ruff check src/ tests/ scripts/chrys_test.py scripts/check_wheel.py scripts/calibrate_gc_freeze.py scripts/gc_freeze_calibration_math.py scripts/chat_panel_workloads.py # pyproject's fix = true EDITS files (--no-fix inspects); CI auto-fixes too, so only unfixable errors fail there +uv run ruff format --check src/ tests/ scripts/chrys_test.py scripts/check_wheel.py scripts/calibrate_gc_freeze.py scripts/gc_freeze_calibration_math.py scripts/chat_panel_workloads.py uv run ty check --error-on-warning --python-platform linux src/chrys # repeat for darwin and win32; the src/chrys argument is required uv run python scripts/i18n.py extract # then update, translate, compile, check (see i18n) ``` diff --git a/docs/en/guides/configuration/agents.md b/docs/en/guides/configuration/agents.md index ced54050e..96e5aee78 100644 --- a/docs/en/guides/configuration/agents.md +++ b/docs/en/guides/configuration/agents.md @@ -161,7 +161,7 @@ These settings work as follows: During a task, the agent uses each sub-agent's tool name and description to decide whether to delegate work to it. You can also request a specific sub-agent in the input field, for example: `Call to complete `. A successful result from the corresponding sub-agent tool confirms that the configuration has taken effect. -A sub-agent returns its final reply to the agent that called it. Text it writes between tool calls appears on its card but is not returned. The card shows each piece of text once the sub-agent has finished writing it. A model request that fails before any of its text has appeared on the card is retried as in the main conversation; once some has appeared, the card keeps it and shows the error. In `icode run`, ACP clients and workflows, a sub-agent's text is not shown as it is written, so text it has already written does not by itself stop such a retry. If the final reply has no text, all the text the sub-agent wrote is returned, in order. If the sub-agent fails, its card shows the reason. An external ACP agent returns what its **Result** setting selects; see [Set the result and timeouts](../extensions/external-acp-agents.md#set-the-result-and-timeouts). +A sub-agent returns its final reply to the agent that called it. Text it writes between tool calls appears on its card but is not returned. The card shows each piece of text once the sub-agent has finished writing it. A model request that fails before any of its text has appeared on the card is retried as in the main conversation; once some has appeared, the card keeps it and shows the error. In `icode run`, ACP clients and workflows, a sub-agent's text is not shown as it is written, so text it has already written does not by itself stop such a retry. If the final reply has no text, all the text the sub-agent wrote is returned, in order. If the sub-agent fails, its card shows the reason. An external ACP agent returns what its **Result** setting selects; see [Set the result and timeouts](../extensions/external-acp-agents.md#set-the-result-and-timeouts). Click **View details** on the card to see everything the sub-agent did: the view opens at its newest content and keeps showing new tool calls and text as they arrive while you stay at the bottom; scroll up to read earlier content and it stays where you are until you scroll back to the bottom. **Notes:** diff --git a/docs/en/guides/running/workflows.md b/docs/en/guides/running/workflows.md index a726a3d84..a08d785b1 100644 --- a/docs/en/guides/running/workflows.md +++ b/docs/en/guides/running/workflows.md @@ -18,7 +18,7 @@ Alternatively, click “APP MODE: Chat” in the upper left and select “Workfl After selecting the example, explore these tabs: -- **Workflow**: Shows the execution graph, including nodes, connections, and execution states. When the graph is larger than the panel, drag it with the mouse to move around. After a run starts, click a node to view its input and output. Tabs under each one switch between “Markdown” (the default), “Plain text” (the text unformatted), “Data” (structured data as fields) and “Progress messages” (what the node reported while it ran); only the tabs with content appear. Agent nodes also have a “Transcript” tab for model responses and tool calls; each piece of the agent's text appears there once the model has finished writing it. +- **Workflow**: Shows the execution graph, including nodes, connections, and execution states. When the graph is larger than the panel, drag it with the mouse to move around. After a run starts, click a node to view its input and output. Tabs under each one switch between “Markdown” (the default), “Plain text” (the text unformatted), “Data” (structured data as fields) and “Progress messages” (what the node reported while it ran); only the tabs with content appear. Agent nodes also have a “Transcript” tab for model responses and tool calls; each piece of the agent's text appears there once the model has finished writing it. The transcript opens at its newest content and keeps showing new content as it arrives while you stay at the bottom; scroll up to read earlier content and it stays where you are until you scroll back to the bottom. - **Info**: Shows the workflow name, description, script location, execution environment, and node configuration, including agents and models. - **Source**: Shows the Python code that defines the workflow. Both “Workflow” and “Source” are read-only; to change a workflow, edit its Python source file. - **Input**: Shows the input submitted for the run after it starts, with Markdown formatting. Click “copy” to copy the input exactly as it was submitted. diff --git a/docs/zh-Hans/guides/configuration/agents.md b/docs/zh-Hans/guides/configuration/agents.md index d09a050e0..5e81b52a8 100644 --- a/docs/zh-Hans/guides/configuration/agents.md +++ b/docs/zh-Hans/guides/configuration/agents.md @@ -161,7 +161,7 @@ iCode 默认提供的 Code Agent 和 Q&A Agent 可以作为主智能体,Explor 智能体在执行任务时,会根据子智能体的工具名称和工具描述判断是否向其分派任务。也可以在输入框中指定使用某个子智能体,例如:`调用 <工具名称> 完成 <任务>`。对应的子智能体工具成功返回结果,说明配置已经生效。 -子智能体将最终回复返回给调用它的智能体。子智能体在工具调用之间输出的文本显示在其卡片中,但不会返回。卡片中的每段文本在子智能体写完后才显示。模型请求失败时,若其文本尚未在卡片中显示,按与主对话相同的规则重试;若已显示部分文本,卡片保留该文本并显示错误。`icode run`、ACP 客户端和工作流不会实时显示子智能体的文本,因此子智能体已输出的文本本身不会阻止上述重试。最终回复无文本时,将子智能体输出的全部文本按顺序拼接后返回。子智能体失败时,其卡片显示失败原因。外部 ACP 智能体返回的内容由其“结果”设置决定,参阅[设置结果和超时](../extensions/external-acp-agents.md#设置结果和超时)。 +子智能体将最终回复返回给调用它的智能体。子智能体在工具调用之间输出的文本显示在其卡片中,但不会返回。卡片中的每段文本在子智能体写完后才显示。模型请求失败时,若其文本尚未在卡片中显示,按与主对话相同的规则重试;若已显示部分文本,卡片保留该文本并显示错误。`icode run`、ACP 客户端和工作流不会实时显示子智能体的文本,因此子智能体已输出的文本本身不会阻止上述重试。最终回复无文本时,将子智能体输出的全部文本按顺序拼接后返回。子智能体失败时,其卡片显示失败原因。外部 ACP 智能体返回的内容由其“结果”设置决定,参阅[设置结果和超时](../extensions/external-acp-agents.md#设置结果和超时)。单击卡片上的“查看详情”可查看子智能体执行的全部内容:打开时显示最新内容;停留在底部时,新的工具调用和文本到来会随之显示;向上滚动查看之前的内容时,视图停在原处,直到滚回底部。 **注意**: diff --git a/docs/zh-Hans/guides/running/workflows.md b/docs/zh-Hans/guides/running/workflows.md index 67ea045aa..9ba0f7bd9 100644 --- a/docs/zh-Hans/guides/running/workflows.md +++ b/docs/zh-Hans/guides/running/workflows.md @@ -18,7 +18,7 @@ 选择示例后,可以通过以下页签了解工作流: -- **工作流**:展示执行图,包括节点、连接关系和执行状态。执行图超出面板时,可以按住鼠标拖动来移动视图。运行后,点击图中的节点可以查看该节点的输入、输出,下方的子页签可切换“Markdown”(默认)、“纯文本”(未经排版的原文)、“数据”(结构化数据按字段展示)和“进度消息”(节点运行时报告的进度),只显示有内容的页签;智能体节点还提供“对话记录”,用于查看模型回复和工具调用;智能体的每段文本在模型写完后才出现在其中。 +- **工作流**:展示执行图,包括节点、连接关系和执行状态。执行图超出面板时,可以按住鼠标拖动来移动视图。运行后,点击图中的节点可以查看该节点的输入、输出,下方的子页签可切换“Markdown”(默认)、“纯文本”(未经排版的原文)、“数据”(结构化数据按字段展示)和“进度消息”(节点运行时报告的进度),只显示有内容的页签;智能体节点还提供“对话记录”,用于查看模型回复和工具调用;智能体的每段文本在模型写完后才出现在其中。对话记录打开时显示最新内容;停留在底部时,新内容到来会随之显示;向上滚动查看之前的内容时,视图停在原处,直到滚回底部。 - **信息**:展示工作流名称、描述、脚本位置、执行环境和节点配置,包括智能体及模型。 - **源代码**:工作流通过 Python 代码定义,此处显示对应的源代码。“工作流”和“源代码”页均为只读;如需修改工作流,需要编辑对应的 Python 源代码文件。 - **输入**:启动后按 Markdown 格式展示本次运行提交的输入内容。点击“复制”可复制提交时的原始内容。 diff --git a/scripts/chat_panel_workloads.py b/scripts/chat_panel_workloads.py new file mode 100644 index 000000000..5c31e025a --- /dev/null +++ b/scripts/chat_panel_workloads.py @@ -0,0 +1,482 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""Synthetic long chat transcripts for the chat panel profiler and the GC freeze calibration. + +Each workload drives a ``ChatPanel`` through the calls a live turn makes: +user messages, answer text streamed as segments (text a tool call follows +settles as intermediate text), tool starts and results, and the completed +answer. Importing it applies no patches: the scripts that measure apply +Chrys's Textual patches (``apply_all``) before they import it, and a test +imports it without rewriting the installed Textual. +""" + +from __future__ import annotations + +import asyncio + +from chrys.app.tui.widgets.chat.panel import ChatPanel +from chrys.app.tui.widgets.chat.renderers.sub_agent import SubAgentToolCall +from chrys.app.tui.widgets.chat.tool_renderers import register_kind_renderer +from chrys.service.tools.kinds import ( + KIND_FILESYSTEM_READ, + KIND_FILESYSTEM_WRITE, + KIND_SEARCH, + KIND_SHELL, + KIND_SUB_AGENT, +) + + +def _sample_file(name: str, turn: int, tool_index: int, *, changed: bool) -> str: + lines = [ + f"# {name}", + "", + "from __future__ import annotations", + "", + ] + for idx in range(36): + value = turn * 1000 + tool_index * 100 + idx + if changed and idx in {4, 12, 20, 28}: + lines.append(f"VALUE_{idx} = {value + 17}") + lines.append(f"def changed_{idx}() -> str:") + lines.append(f" return 'turn={turn} tool={tool_index} idx={idx}'") + else: + lines.append(f"VALUE_{idx} = {value}") + lines.append(f"def function_{idx}() -> int:") + lines.append(f" return VALUE_{idx}") + lines.append("") + return "\n".join(lines) + "\n" + + +def _read_file_result(path: str, turn: int, tool_index: int) -> str: + body = _sample_file(path, turn, tool_index, changed=False).splitlines() + preview = body[:18] + lines = [f"File: {path} ({len(body)} lines, {sum(len(line) for line in body)} chars)"] + lines.extend(f"{idx}|{line}" for idx, line in enumerate(preview, 1)) + lines.append("[Truncated: showing first 18 lines]") + return "\n".join(lines) + + +def _large_markdown_result(turn: int, tool_index: int, target_lines: int) -> str: + """Build a deterministic large markdown final answer for sub-agent cards.""" + lines = [ + f"# Sub-agent report {turn}-{tool_index}", + "", + "This synthetic result mixes headings, paragraphs, lists, block quotes, tables, and fenced code.", + "", + ] + section = 0 + while len(lines) < target_lines: + lines.extend( + [ + f"## Finding {section}", + "", + ( + "The renderer should handle a long markdown answer without making chat-panel " + f"scrolling scale with every offscreen row. This paragraph belongs to turn {turn}, " + f"tool {tool_index}, section {section}." + ), + "", + "- Observation: the answer includes enough structure to exercise markdown-it token parsing.", + "- Evidence: repeated paragraphs and fenced blocks force layout and strip rendering paths.", + "- Action: keep expensive rendering lazy when the containing tool group is collapsed.", + "", + "> A quoted note adds nested markdown block styling and wrapping work.", + "", + "| Metric | Value | Notes |", + "| --- | ---: | --- |", + f"| turn | {turn} | synthetic |", + f"| tool | {tool_index} | synthetic |", + f"| section | {section} | generated |", + "", + "```python", + ] + ) + for code_line in range(10): + value = turn * 100_000 + tool_index * 1_000 + section * 10 + code_line + lines.append(f"def measured_case_{section}_{code_line}() -> int:") + lines.append(f" return {value}") + lines.append("") + lines.extend(["```", ""]) + section += 1 + return "\n".join(lines) + "\n" + + +def _table_cell_text(turn: int, result_index: int, table_index: int, row: int, col: int, cell_words: int) -> str: + """Build deterministic markdown-table cell text with controllable wrapping pressure.""" + tokens = [ + f"r{row}", + f"c{col}", + f"case-{turn}-{result_index}-{table_index}", + f"value-{row * (col + 1)}", + ] + tokens.extend(f"wrap-{word_index}-{(row + 1) * (col + 1)}" for word_index in range(max(0, cell_words))) + return " ".join(tokens) + + +def _table_markdown_result( + turn: int, + result_index: int, + target_lines: int, + *, + table_columns: int, + table_rows: int, + cell_words: int, +) -> str: + """Build markdown dominated by large tables for table virtualization profiling.""" + table_columns = max(1, table_columns) + table_rows = max(1, table_rows) + lines = [ + f"# Table-heavy report {turn}-{result_index}", + "", + ( + "This synthetic answer is dominated by markdown tables so the benchmark can isolate " + "table layout, strip construction, and scroll rendering cost." + ), + "", + ] + table_index = 0 + while len(lines) < target_lines: + lines.extend( + [ + f"## Wide table {table_index}", + "", + "| " + " | ".join(f"Column {col}" for col in range(table_columns)) + " |", + "| " + " | ".join("---" for _ in range(table_columns)) + " |", + ] + ) + for row in range(table_rows): + cells = [ + _table_cell_text(turn, result_index, table_index, row, col, cell_words) for col in range(table_columns) + ] + lines.append("| " + " | ".join(cells) + " |") + lines.append("") + table_index += 1 + return "\n".join(lines) + "\n" + + +def _mixed_markdown_result( + turn: int, + result_index: int, + target_lines: int, + *, + fence_lines: int, + table_columns: int, + table_rows: int, + cell_words: int, +) -> str: + """Build a mixed normal agent response with long fences, quotes, and tables.""" + table_columns = max(1, table_columns) + table_rows = max(1, table_rows) + fence_lines = max(1, fence_lines) + lines = [ + f"# Mixed markdown response {turn}-{result_index}", + "", + ( + "This synthetic answer mimics a long agent reply with dense prose, block quotes, " + "wide tables, and long fenced code blocks interleaved throughout the response." + ), + "", + ] + section = 0 + while len(lines) < target_lines: + lines.extend( + [ + f"## Section {section}", + "", + ( + "The scroll path should stay smooth when this response alternates between ordinary " + "paragraph wrapping, quoted notes, tabular status data, and code fences. " + f"This paragraph belongs to turn {turn}, message {result_index}, section {section}." + ), + "", + "> A quoted diagnostic note exercises block quote border rendering and wrapped text.", + ">", + "> Nested-looking quoted content adds enough rows to expose per-line style overhead.", + "", + "| " + " | ".join(f"Column {col}" for col in range(table_columns)) + " |", + "| " + " | ".join("---" for _ in range(table_columns)) + " |", + ] + ) + for row in range(table_rows): + cells = [ + _table_cell_text(turn, result_index, section, row, col, cell_words) for col in range(table_columns) + ] + lines.append("| " + " | ".join(cells) + " |") + lines.extend(["", "```python"]) + for code_line in range(fence_lines): + value = turn * 1_000_000 + result_index * 100_000 + section * 1_000 + code_line + lines.append( + f"result_{section}_{code_line} = transform_input({value}, label='turn-{turn}-message-{result_index}')" + ) + if code_line % 6 == 5: + lines.append( + f"if result_{section}_{code_line} % 3 == 0: audit_trail.append(('section', {section}, {code_line}))" + ) + lines.extend( + [ + "```", + "", + "- Follow-up: retain row-level cache locality while scrolling.", + "- Follow-up: avoid first-visible-block work on the UI thread.", + "", + ] + ) + section += 1 + return "\n".join(lines) + "\n" + + +def _expand_all_groups(panel: ChatPanel) -> None: + for group in panel.query("ToolGroup"): + group.collapsed = False + + +async def _stream_answer(panel: ChatPanel, segment_id: str, text: str) -> None: + """Stream *text* as one answer segment; a tool call after it settles it as intermediate text.""" + await panel.add_answer_delta(segment_id, text) + panel.finish_answer_segment(segment_id, completed=True) + + +async def _finish_turn(panel: ChatPanel, segment_id: str, text: str) -> None: + """Stream *text* as the turn's answer and complete the turn.""" + await _stream_answer(panel, segment_id, text) + await panel.add_agent_message(text) + + +async def _populate_file_diff(panel: ChatPanel, *, turns: int, tool_sets_per_turn: int, collapsed: bool) -> None: + panel.set_tool_kinds( + { + "read_file": KIND_FILESYSTEM_READ, + "write_file": KIND_FILESYSTEM_WRITE, + "edit_file": KIND_FILESYSTEM_WRITE, + } + ) + await panel.add_user_message("Start synthetic long session with many file operations.") + for turn in range(turns): + await _stream_answer(panel, f"intro-{turn}", f"Turn {turn}: inspecting and editing files.") + for tool_index in range(tool_sets_per_turn): + path = f"src/pkg/module_{turn:02d}_{tool_index:02d}.py" + + read_id = f"read-{turn}-{tool_index}" + await panel.add_tool_start( + read_id, + "read_file", + KIND_FILESYSTEM_READ, + args={"path": path}, + ) + await panel.add_tool_result(read_id, "read_file", _read_file_result(path, turn, tool_index), 11) + + write_id = f"write-{turn}-{tool_index}" + write_after = _sample_file(path, turn, tool_index, changed=True) + await panel.add_tool_start( + write_id, + "write_file", + KIND_FILESYSTEM_WRITE, + args={"path": path, "overwrite": True}, + ) + await panel.add_tool_result( + write_id, + "write_file", + f"Wrote {path} ({len(write_after.splitlines())} lines)", + 17, + file_snapshot=("", write_after), + ) + + edit_id = f"edit-{turn}-{tool_index}" + before = _sample_file(path, turn, tool_index, changed=False) + after = _sample_file(path, turn, tool_index, changed=True) + await panel.add_tool_start( + edit_id, + "edit_file", + KIND_FILESYSTEM_WRITE, + args={"path": path}, + ) + await panel.add_tool_result( + edit_id, + "edit_file", + f"Edited {path}", + 23, + file_snapshot=(before, after), + ) + + await _finish_turn(panel, f"answer-{turn}", f"Turn {turn}: done.") + if not collapsed: + _expand_all_groups(panel) + await asyncio.sleep(0) + + +def _shell_output(turn: int) -> str: + lines = [f"$ uv run pytest tests/test_module_{turn}.py -q"] + lines.extend(f"tests/test_module_{turn}.py::test_case_{index} PASSED [ {index * 2 + 2:3d}%]" for index in range(36)) + lines.append(f"36 passed in {1.0 + turn * 0.01:.2f}s") + return "\n".join(lines) + + +async def _populate_realistic_session( + panel: ChatPanel, + *, + turns: int, + collapsed: bool, +) -> None: + """Mimic a real agent session: every tool renderer plus mixed markdown per turn. + + Per turn: user message, intermediate agent message, read_file, grep, + edit_file (inline diff), write_file (inline diff), shell output, a + sub-agent markdown report, and a final agent message with mixed markdown + (prose, quotes, a table, and a highlighted fence). + """ + register_kind_renderer(KIND_SUB_AGENT, SubAgentToolCall) + panel.set_tool_kinds( + { + "read_file": KIND_FILESYSTEM_READ, + "write_file": KIND_FILESYSTEM_WRITE, + "edit_file": KIND_FILESYSTEM_WRITE, + "shell": KIND_SHELL, + "grep": KIND_SEARCH, + "explore_agent": KIND_SUB_AGENT, + } + ) + for turn in range(turns): + await panel.add_user_message(f"Request {turn}: refactor module {turn}, run tests, and summarize findings.") + await _stream_answer(panel, f"intro-{turn}", f"Turn {turn}: inspecting, editing, and verifying files.") + + path = f"src/pkg/module_{turn:03d}.py" + + read_id = f"read-{turn}" + await panel.add_tool_start(read_id, "read_file", KIND_FILESYSTEM_READ, args={"path": path}) + await panel.add_tool_result(read_id, "read_file", _read_file_result(path, turn, 0), 11) + + grep_id = f"grep-{turn}" + await panel.add_tool_start(grep_id, "grep", KIND_SEARCH, args={"pattern": f"function_{turn}", "path": "src/"}) + await panel.add_tool_result( + grep_id, "grep", "\n".join(f"src/pkg/module_{turn:03d}.py:{10 + index}: match" for index in range(12)), 9 + ) + + edit_id = f"edit-{turn}" + before = _sample_file(path, turn, 0, changed=False) + after = _sample_file(path, turn, 0, changed=True) + await panel.add_tool_start(edit_id, "edit_file", KIND_FILESYSTEM_WRITE, args={"path": path}) + await panel.add_tool_result(edit_id, "edit_file", f"Edited {path}", 23, file_snapshot=(before, after)) + + write_id = f"write-{turn}" + await panel.add_tool_start( + write_id, "write_file", KIND_FILESYSTEM_WRITE, args={"path": path, "overwrite": True} + ) + await panel.add_tool_result( + write_id, "write_file", f"Wrote {path} ({len(after.splitlines())} lines)", 17, file_snapshot=("", after) + ) + + shell_id = f"shell-{turn}" + await panel.add_tool_start( + shell_id, "shell", KIND_SHELL, args={"command": f"uv run pytest tests/test_module_{turn}.py -q"} + ) + await panel.add_tool_result(shell_id, "shell", _shell_output(turn), 1_450) + + agent_id = f"agent-{turn}" + await panel.add_tool_start( + agent_id, + "explore_agent", + KIND_SUB_AGENT, + args={"prompt": f"Verify refactor of module {turn}", "profile": "Explore"}, + ) + await panel.add_tool_result(agent_id, "explore_agent", _large_markdown_result(turn, 0, 90), 2_400) + + await _finish_turn( + panel, + f"answer-{turn}", + _mixed_markdown_result(turn, 0, 110, fence_lines=40, table_columns=6, table_rows=8, cell_words=2), + ) + if not collapsed: + _expand_all_groups(panel) + await asyncio.sleep(0) + + +async def _populate_sub_agent_markdown( + panel: ChatPanel, + *, + turns: int, + tool_sets_per_turn: int, + collapsed: bool, + markdown_lines: int, +) -> None: + register_kind_renderer(KIND_SUB_AGENT, SubAgentToolCall) + panel.set_tool_kinds({"explore_agent": KIND_SUB_AGENT}) + await panel.add_user_message("Start synthetic long session with many sub-agent final reports.") + for turn in range(turns): + await _stream_answer(panel, f"intro-{turn}", f"Turn {turn}: delegating focused investigations.") + for tool_index in range(tool_sets_per_turn): + call_id = f"sub-agent-{turn}-{tool_index}" + prompt = ( + f"Investigate scrolling and loading behavior for turn {turn}, case {tool_index}. " + "Return a structured markdown report." + ) + await panel.add_tool_start( + call_id, + "explore_agent", + KIND_SUB_AGENT, + args={"prompt": prompt, "profile": "Explore"}, + ) + await panel.add_tool_result( + call_id, + "explore_agent", + _large_markdown_result(turn, tool_index, markdown_lines), + 2_400, + ) + + await _finish_turn(panel, f"answer-{turn}", f"Turn {turn}: findings incorporated.") + if not collapsed: + _expand_all_groups(panel) + await asyncio.sleep(0) + + +async def _populate_table_markdown( + panel: ChatPanel, + *, + turns: int, + messages_per_turn: int, + markdown_lines: int, + table_columns: int, + table_rows: int, + cell_words: int, +) -> None: + await panel.add_user_message("Start synthetic long session with table-heavy agent markdown.") + for turn in range(turns): + for result_index in range(messages_per_turn): + await panel.add_agent_message( + _table_markdown_result( + turn, + result_index, + markdown_lines, + table_columns=table_columns, + table_rows=table_rows, + cell_words=cell_words, + ), + ) + await asyncio.sleep(0) + + +async def _populate_mixed_markdown( + panel: ChatPanel, + *, + turns: int, + messages_per_turn: int, + markdown_lines: int, + fence_lines: int, + table_columns: int, + table_rows: int, + cell_words: int, +) -> None: + await panel.add_user_message("Start synthetic long session with mixed agent markdown.") + for turn in range(turns): + for result_index in range(messages_per_turn): + await panel.add_agent_message( + _mixed_markdown_result( + turn, + result_index, + markdown_lines, + fence_lines=fence_lines, + table_columns=table_columns, + table_rows=table_rows, + cell_words=cell_words, + ), + ) + await asyncio.sleep(0) diff --git a/scripts/profile_chat_panel.py b/scripts/profile_chat_panel.py index eb194b0ac..02cc10e33 100644 --- a/scripts/profile_chat_panel.py +++ b/scripts/profile_chat_panel.py @@ -31,22 +31,20 @@ apply_all() +from chat_panel_workloads import ( # noqa: E402 + _populate_file_diff, + _populate_mixed_markdown, + _populate_realistic_session, + _populate_sub_agent_markdown, + _populate_table_markdown, +) from textual import events # noqa: E402 from textual.app import App, ComposeResult # noqa: E402 from chrys.app.tui.theme import TuiVariableDefaultsMixin # noqa: E402 from chrys.app.tui.widgets.chat.panel import ChatPanel # noqa: E402 -from chrys.app.tui.widgets.chat.renderers.sub_agent import SubAgentToolCall # noqa: E402 -from chrys.app.tui.widgets.chat.tool_renderers import register_kind_renderer # noqa: E402 from chrys.app.tui.widgets.markdown import VirtualizedMarkdown # noqa: E402 from chrys.service.state.serializers import deserialize_compressed_block # noqa: E402 -from chrys.service.tools.kinds import ( # noqa: E402 - KIND_FILESYSTEM_READ, - KIND_FILESYSTEM_WRITE, - KIND_SEARCH, - KIND_SHELL, - KIND_SUB_AGENT, -) _TIMINGS: dict[str, list[float]] = {} _TIMINGS_LOCK = threading.Lock() @@ -161,451 +159,6 @@ def _rss_mb(process: psutil.Process) -> float: return process.memory_info().rss / (1024 * 1024) -def _sample_file(name: str, turn: int, tool_index: int, *, changed: bool) -> str: - lines = [ - f"# {name}", - "", - "from __future__ import annotations", - "", - ] - for idx in range(36): - value = turn * 1000 + tool_index * 100 + idx - if changed and idx in {4, 12, 20, 28}: - lines.append(f"VALUE_{idx} = {value + 17}") - lines.append(f"def changed_{idx}() -> str:") - lines.append(f" return 'turn={turn} tool={tool_index} idx={idx}'") - else: - lines.append(f"VALUE_{idx} = {value}") - lines.append(f"def function_{idx}() -> int:") - lines.append(f" return VALUE_{idx}") - lines.append("") - return "\n".join(lines) + "\n" - - -def _read_file_result(path: str, turn: int, tool_index: int) -> str: - body = _sample_file(path, turn, tool_index, changed=False).splitlines() - preview = body[:18] - lines = [f"File: {path} ({len(body)} lines, {sum(len(line) for line in body)} chars)"] - lines.extend(f"{idx}|{line}" for idx, line in enumerate(preview, 1)) - lines.append("[Truncated: showing first 18 lines]") - return "\n".join(lines) - - -def _large_markdown_result(turn: int, tool_index: int, target_lines: int) -> str: - """Build a deterministic large markdown final answer for sub-agent cards.""" - lines = [ - f"# Sub-agent report {turn}-{tool_index}", - "", - "This synthetic result mixes headings, paragraphs, lists, block quotes, tables, and fenced code.", - "", - ] - section = 0 - while len(lines) < target_lines: - lines.extend( - [ - f"## Finding {section}", - "", - ( - "The renderer should handle a long markdown answer without making chat-panel " - f"scrolling scale with every offscreen row. This paragraph belongs to turn {turn}, " - f"tool {tool_index}, section {section}." - ), - "", - "- Observation: the answer includes enough structure to exercise markdown-it token parsing.", - "- Evidence: repeated paragraphs and fenced blocks force layout and strip rendering paths.", - "- Action: keep expensive rendering lazy when the containing tool group is collapsed.", - "", - "> A quoted note adds nested markdown block styling and wrapping work.", - "", - "| Metric | Value | Notes |", - "| --- | ---: | --- |", - f"| turn | {turn} | synthetic |", - f"| tool | {tool_index} | synthetic |", - f"| section | {section} | generated |", - "", - "```python", - ] - ) - for code_line in range(10): - value = turn * 100_000 + tool_index * 1_000 + section * 10 + code_line - lines.append(f"def measured_case_{section}_{code_line}() -> int:") - lines.append(f" return {value}") - lines.append("") - lines.extend(["```", ""]) - section += 1 - return "\n".join(lines) + "\n" - - -def _table_cell_text(turn: int, result_index: int, table_index: int, row: int, col: int, cell_words: int) -> str: - """Build deterministic markdown-table cell text with controllable wrapping pressure.""" - tokens = [ - f"r{row}", - f"c{col}", - f"case-{turn}-{result_index}-{table_index}", - f"value-{row * (col + 1)}", - ] - tokens.extend(f"wrap-{word_index}-{(row + 1) * (col + 1)}" for word_index in range(max(0, cell_words))) - return " ".join(tokens) - - -def _table_markdown_result( - turn: int, - result_index: int, - target_lines: int, - *, - table_columns: int, - table_rows: int, - cell_words: int, -) -> str: - """Build markdown dominated by large tables for table virtualization profiling.""" - table_columns = max(1, table_columns) - table_rows = max(1, table_rows) - lines = [ - f"# Table-heavy report {turn}-{result_index}", - "", - ( - "This synthetic answer is dominated by markdown tables so the benchmark can isolate " - "table layout, strip construction, and scroll rendering cost." - ), - "", - ] - table_index = 0 - while len(lines) < target_lines: - lines.extend( - [ - f"## Wide table {table_index}", - "", - "| " + " | ".join(f"Column {col}" for col in range(table_columns)) + " |", - "| " + " | ".join("---" for _ in range(table_columns)) + " |", - ] - ) - for row in range(table_rows): - cells = [ - _table_cell_text(turn, result_index, table_index, row, col, cell_words) for col in range(table_columns) - ] - lines.append("| " + " | ".join(cells) + " |") - lines.append("") - table_index += 1 - return "\n".join(lines) + "\n" - - -def _mixed_markdown_result( - turn: int, - result_index: int, - target_lines: int, - *, - fence_lines: int, - table_columns: int, - table_rows: int, - cell_words: int, -) -> str: - """Build a mixed normal agent response with long fences, quotes, and tables.""" - table_columns = max(1, table_columns) - table_rows = max(1, table_rows) - fence_lines = max(1, fence_lines) - lines = [ - f"# Mixed markdown response {turn}-{result_index}", - "", - ( - "This synthetic answer mimics a long agent reply with dense prose, block quotes, " - "wide tables, and long fenced code blocks interleaved throughout the response." - ), - "", - ] - section = 0 - while len(lines) < target_lines: - lines.extend( - [ - f"## Section {section}", - "", - ( - "The scroll path should stay smooth when this response alternates between ordinary " - "paragraph wrapping, quoted notes, tabular status data, and code fences. " - f"This paragraph belongs to turn {turn}, message {result_index}, section {section}." - ), - "", - "> A quoted diagnostic note exercises block quote border rendering and wrapped text.", - ">", - "> Nested-looking quoted content adds enough rows to expose per-line style overhead.", - "", - "| " + " | ".join(f"Column {col}" for col in range(table_columns)) + " |", - "| " + " | ".join("---" for _ in range(table_columns)) + " |", - ] - ) - for row in range(table_rows): - cells = [ - _table_cell_text(turn, result_index, section, row, col, cell_words) for col in range(table_columns) - ] - lines.append("| " + " | ".join(cells) + " |") - lines.extend(["", "```python"]) - for code_line in range(fence_lines): - value = turn * 1_000_000 + result_index * 100_000 + section * 1_000 + code_line - lines.append( - f"result_{section}_{code_line} = transform_input({value}, label='turn-{turn}-message-{result_index}')" - ) - if code_line % 6 == 5: - lines.append( - f"if result_{section}_{code_line} % 3 == 0: audit_trail.append(('section', {section}, {code_line}))" - ) - lines.extend( - [ - "```", - "", - "- Follow-up: retain row-level cache locality while scrolling.", - "- Follow-up: avoid first-visible-block work on the UI thread.", - "", - ] - ) - section += 1 - return "\n".join(lines) + "\n" - - -def _expand_all_groups(panel: ChatPanel) -> None: - for group in panel.query("ToolGroup"): - group.collapsed = False - - -async def _populate_file_diff(panel: ChatPanel, *, turns: int, tool_sets_per_turn: int, collapsed: bool) -> None: - panel.set_tool_kinds( - { - "read_file": KIND_FILESYSTEM_READ, - "write_file": KIND_FILESYSTEM_WRITE, - "edit_file": KIND_FILESYSTEM_WRITE, - } - ) - await panel.add_user_message("Start synthetic long session with many file operations.") - for turn in range(turns): - await panel.add_agent_message(f"Turn {turn}: inspecting and editing files.", is_final=False) - for tool_index in range(tool_sets_per_turn): - path = f"src/pkg/module_{turn:02d}_{tool_index:02d}.py" - - read_id = f"read-{turn}-{tool_index}" - await panel.add_tool_start( - read_id, - "read_file", - KIND_FILESYSTEM_READ, - args={"path": path}, - ) - await panel.add_tool_result(read_id, "read_file", _read_file_result(path, turn, tool_index), 11) - - write_id = f"write-{turn}-{tool_index}" - write_after = _sample_file(path, turn, tool_index, changed=True) - await panel.add_tool_start( - write_id, - "write_file", - KIND_FILESYSTEM_WRITE, - args={"path": path, "overwrite": True}, - ) - await panel.add_tool_result( - write_id, - "write_file", - f"Wrote {path} ({len(write_after.splitlines())} lines)", - 17, - file_snapshot=("", write_after), - ) - - edit_id = f"edit-{turn}-{tool_index}" - before = _sample_file(path, turn, tool_index, changed=False) - after = _sample_file(path, turn, tool_index, changed=True) - await panel.add_tool_start( - edit_id, - "edit_file", - KIND_FILESYSTEM_WRITE, - args={"path": path}, - ) - await panel.add_tool_result( - edit_id, - "edit_file", - f"Edited {path}", - 23, - file_snapshot=(before, after), - ) - - await panel.add_agent_message(f"Turn {turn}: done.", is_final=True) - if not collapsed: - _expand_all_groups(panel) - await asyncio.sleep(0) - - -def _shell_output(turn: int) -> str: - lines = [f"$ uv run pytest tests/test_module_{turn}.py -q"] - lines.extend(f"tests/test_module_{turn}.py::test_case_{index} PASSED [ {index * 2 + 2:3d}%]" for index in range(36)) - lines.append(f"36 passed in {1.0 + turn * 0.01:.2f}s") - return "\n".join(lines) - - -async def _populate_realistic_session( - panel: ChatPanel, - *, - turns: int, - collapsed: bool, -) -> None: - """Mimic a real agent session: every tool renderer plus mixed markdown per turn. - - Per turn: user message, intermediate agent message, read_file, grep, - edit_file (inline diff), write_file (inline diff), shell output, a - sub-agent markdown report, and a final agent message with mixed markdown - (prose, quotes, a table, and a highlighted fence). - """ - register_kind_renderer(KIND_SUB_AGENT, SubAgentToolCall) - panel.set_tool_kinds( - { - "read_file": KIND_FILESYSTEM_READ, - "write_file": KIND_FILESYSTEM_WRITE, - "edit_file": KIND_FILESYSTEM_WRITE, - "shell": KIND_SHELL, - "grep": KIND_SEARCH, - "explore_agent": KIND_SUB_AGENT, - } - ) - for turn in range(turns): - await panel.add_user_message(f"Request {turn}: refactor module {turn}, run tests, and summarize findings.") - await panel.add_agent_message(f"Turn {turn}: inspecting, editing, and verifying files.", is_final=False) - - path = f"src/pkg/module_{turn:03d}.py" - - read_id = f"read-{turn}" - await panel.add_tool_start(read_id, "read_file", KIND_FILESYSTEM_READ, args={"path": path}) - await panel.add_tool_result(read_id, "read_file", _read_file_result(path, turn, 0), 11) - - grep_id = f"grep-{turn}" - await panel.add_tool_start(grep_id, "grep", KIND_SEARCH, args={"pattern": f"function_{turn}", "path": "src/"}) - await panel.add_tool_result( - grep_id, "grep", "\n".join(f"src/pkg/module_{turn:03d}.py:{10 + index}: match" for index in range(12)), 9 - ) - - edit_id = f"edit-{turn}" - before = _sample_file(path, turn, 0, changed=False) - after = _sample_file(path, turn, 0, changed=True) - await panel.add_tool_start(edit_id, "edit_file", KIND_FILESYSTEM_WRITE, args={"path": path}) - await panel.add_tool_result(edit_id, "edit_file", f"Edited {path}", 23, file_snapshot=(before, after)) - - write_id = f"write-{turn}" - await panel.add_tool_start( - write_id, "write_file", KIND_FILESYSTEM_WRITE, args={"path": path, "overwrite": True} - ) - await panel.add_tool_result( - write_id, "write_file", f"Wrote {path} ({len(after.splitlines())} lines)", 17, file_snapshot=("", after) - ) - - shell_id = f"shell-{turn}" - await panel.add_tool_start( - shell_id, "shell", KIND_SHELL, args={"command": f"uv run pytest tests/test_module_{turn}.py -q"} - ) - await panel.add_tool_result(shell_id, "shell", _shell_output(turn), 1_450) - - agent_id = f"agent-{turn}" - await panel.add_tool_start( - agent_id, - "explore_agent", - KIND_SUB_AGENT, - args={"prompt": f"Verify refactor of module {turn}", "profile": "Explore"}, - ) - await panel.add_tool_result(agent_id, "explore_agent", _large_markdown_result(turn, 0, 90), 2_400) - - await panel.add_agent_message( - _mixed_markdown_result(turn, 0, 110, fence_lines=40, table_columns=6, table_rows=8, cell_words=2), - is_final=True, - ) - if not collapsed: - _expand_all_groups(panel) - await asyncio.sleep(0) - - -async def _populate_sub_agent_markdown( - panel: ChatPanel, - *, - turns: int, - tool_sets_per_turn: int, - collapsed: bool, - markdown_lines: int, -) -> None: - register_kind_renderer(KIND_SUB_AGENT, SubAgentToolCall) - panel.set_tool_kinds({"explore_agent": KIND_SUB_AGENT}) - await panel.add_user_message("Start synthetic long session with many sub-agent final reports.") - for turn in range(turns): - await panel.add_agent_message(f"Turn {turn}: delegating focused investigations.", is_final=False) - for tool_index in range(tool_sets_per_turn): - call_id = f"sub-agent-{turn}-{tool_index}" - prompt = ( - f"Investigate scrolling and loading behavior for turn {turn}, case {tool_index}. " - "Return a structured markdown report." - ) - await panel.add_tool_start( - call_id, - "explore_agent", - KIND_SUB_AGENT, - args={"prompt": prompt, "profile": "Explore"}, - ) - await panel.add_tool_result( - call_id, - "explore_agent", - _large_markdown_result(turn, tool_index, markdown_lines), - 2_400, - ) - - await panel.add_agent_message(f"Turn {turn}: findings incorporated.", is_final=True) - if not collapsed: - _expand_all_groups(panel) - await asyncio.sleep(0) - - -async def _populate_table_markdown( - panel: ChatPanel, - *, - turns: int, - messages_per_turn: int, - markdown_lines: int, - table_columns: int, - table_rows: int, - cell_words: int, -) -> None: - await panel.add_user_message("Start synthetic long session with table-heavy agent markdown.") - for turn in range(turns): - for result_index in range(messages_per_turn): - await panel.add_agent_message( - _table_markdown_result( - turn, - result_index, - markdown_lines, - table_columns=table_columns, - table_rows=table_rows, - cell_words=cell_words, - ), - is_final=True, - ) - await asyncio.sleep(0) - - -async def _populate_mixed_markdown( - panel: ChatPanel, - *, - turns: int, - messages_per_turn: int, - markdown_lines: int, - fence_lines: int, - table_columns: int, - table_rows: int, - cell_words: int, -) -> None: - await panel.add_user_message("Start synthetic long session with mixed agent markdown.") - for turn in range(turns): - for result_index in range(messages_per_turn): - await panel.add_agent_message( - _mixed_markdown_result( - turn, - result_index, - markdown_lines, - fence_lines=fence_lines, - table_columns=table_columns, - table_rows=table_rows, - cell_words=cell_words, - ), - is_final=True, - ) - await asyncio.sleep(0) - - def _persisted_initial_profile(meta: object) -> str: """Resolve the oldest transcript label from persisted session metadata.""" if not isinstance(meta, dict): diff --git a/src/chrys/app/tui/screens/logs/screen.py b/src/chrys/app/tui/screens/logs/screen.py index c9439a506..a6253c735 100644 --- a/src/chrys/app/tui/screens/logs/screen.py +++ b/src/chrys/app/tui/screens/logs/screen.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from rich.text import Text from textual import on @@ -19,11 +19,16 @@ from chrys.app.tui.copy_messages import COPIED_TITLE from chrys.app.tui.i18n import render_str, widget_localizer from chrys.app.tui.screens.dialogs.base import BaseDialog +from chrys.app.tui.util.bottom_follow import BottomFollow from chrys.app.tui.widgets import HatchedEmptyState from chrys.foundation.i18n import msg if TYPE_CHECKING: + from typing import Self + + from rich.console import RenderableType from textual.app import ComposeResult + from textual.events import Resize _NO_ACTIVE_TAB = msg("tui.logs.copy.no_active_tab", fallback="No active tab to copy from") @@ -195,14 +200,58 @@ def install_log_handler() -> _TuiLogHandler: return _handler +class _ModuleLog(RichLog): + """One module's log: new lines keep it at the bottom while the user leaves it there.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._bottom_follow = BottomFollow(self) + + def _scroll_to(self, x: float | None = None, y: float | None = None, **kwargs: Any) -> bool: + with self._bottom_follow.scroll(y): + return super()._scroll_to(x, y, **kwargs) + + def watch_scroll_y(self, old_value: float, new_value: float) -> None: + super().watch_scroll_y(old_value, new_value) + self._bottom_follow.scrolled(old_value, new_value) + + def watch_virtual_size(self) -> None: + self._bottom_follow.resized() + + def on_resize(self, event: Resize) -> None: + # No super(): Textual runs RichLog's own handler next in the same MRO walk. + self._bottom_follow.resized() + + def write( + self, + content: RenderableType | object, + width: int | None = None, + expand: bool = False, + shrink: bool = True, + scroll_end: bool | None = None, + animate: bool = False, + ) -> Self: + start = self._start_line + super().write(content, width, expand, shrink, scroll_end, animate) + dropped = self._start_line - start + if dropped and not self._bottom_follow.following: + # Past ``max_lines`` the oldest lines go and the rest move up: keep the + # lines the user is reading where they were. A scroll still animating + # (Page Up, Home) lands where it was headed first, or the move would + # cut it short. + self.app.animator.force_stop_animation(self, "scroll_y") + self.scroll_to(y=max(0.0, self.scroll_y - dropped), animate=False, immediate=True) + return self + + def _make_log_widget(module: str) -> RichLog: - widget = RichLog( + widget = _ModuleLog( id=f"lv-log-{module}", max_lines=MAX_DISPLAY_LINES, wrap=False, highlight=True, markup=False, - auto_scroll=True, + auto_scroll=False, ) widget.display = False return widget @@ -339,10 +388,8 @@ def _refresh_logs(self) -> None: def _flush_module(self, module: str) -> None: """Write any new lines for *module* into its RichLog widget. - Only scrolls to the bottom when at least one new line was actually - written — idle tabs with no activity stay at their current - scroll position so the user isn't yanked back to the tail every - refresh tick. + New lines keep a log scrolled to its bottom there; one the user + scrolled up stays where they put it. """ try: # Guard before advancing the flushed marker; the actual write path @@ -399,7 +446,6 @@ def _drain_pending_logs(self, *, from_timer: bool = False) -> None: log_widget.write(line, scroll_end=False) self._rendered_any_lines[module] = True self._sync_empty_state(module) - log_widget.scroll_end(animate=False) if pending: self._schedule_drain() diff --git a/src/chrys/app/tui/util/bottom_follow.py b/src/chrys/app/tui/util/bottom_follow.py new file mode 100644 index 000000000..038440a9e --- /dev/null +++ b/src/chrys/app/tui/util/bottom_follow.py @@ -0,0 +1,141 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""Bottom-follow for scroll views whose content grows at the end, as the chat follows.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Generator + + from textual.widget import Widget + + +class BottomFollow: + """Keep a scroll view at its bottom while the user leaves it there. + + The view follows while it is at the bottom: content that lands below, or a + viewport that shrinks, takes it to the new bottom. A scroll up stops the + following, and the view then stays where the user put it until a scroll + takes it back to the bottom, or a size change leaves it there. + + Every scroll the view is asked for (keys, wheel, scrollbar, code) starts in + Textual's ``_scroll_to``, which the view runs inside :meth:`scroll`: a + scroll decides as it starts, by where it heads. The view's other moves are + judged by where it goes (:meth:`scrolled`): a selection dragged past its + edge scrolls it a row at a time without asking for a scroll. + + Textual runs some scrolls after the next refresh (a ``VerticalScroll``'s + ``scroll_to``), toward a row worked out as they were asked for: the view + tells the follow of each (:meth:`defer`), which then leaves the view where + it is until the scroll has run. + + The view wires its hooks: ``_scroll_to`` runs inside :meth:`scroll`, + ``watch_scroll_y`` calls :meth:`scrolled`, and ``watch_virtual_size`` and + ``on_resize`` call :meth:`resized`. + """ + + def __init__(self, view: Widget) -> None: + self._view = view + self._following = True + self._destination = 0.0 + self._settle_scheduled = False + self._pinning = False + self._deferred = 0 + self._settle_after_deferred = False + + @property + def following(self) -> bool: + """Whether the view keeps to its bottom.""" + return self._following + + @property + def pinning(self) -> bool: + """Whether the scroll under way is this follow's own move to the bottom, not the user's.""" + return self._pinning + + @contextmanager + def scroll(self, y: float | None) -> Generator[None]: + """Run one scroll the view was asked for, and decide by where it heads. + + A scroll that heads to the bottom follows; one that heads down from + where the view is keeps a following view following, so content that + lands below it first is followed; any other stops the following. The + scroll still animating lands first, as Textual would land it, so where + the view is means where the user last sent it. + """ + if y is None: + yield + return + view = self._view + view.app.animator.force_stop_animation(view, "scroll_y") + start = view.scroll_y + yield + # Textual's own target: clamped to the bottom and rounded, as the scroll animates to it. + self._destination = target = view.scroll_target_y + self._following = target >= view.max_scroll_y or (self._following and target > start) + + def defer(self) -> None: + """Note a scroll Textual runs after the next refresh; :meth:`deferred_ran` once it has run.""" + self._deferred += 1 + + def deferred_ran(self) -> None: + """A scroll noted by :meth:`defer` has run: settle the view, if a size change waited for it.""" + self._deferred -= 1 + if not self._deferred and self._settle_after_deferred: + self._settle_after_deferred = False + self.resized() + + def scrolled(self, old: float, new: float) -> None: + """A move up a row stops the following; a move down that shows the bottom row follows. + + By the row Textual shows (it rounds), since a selection's auto-scroll + moves part of a row a frame. A view above its bottom is one a size + change just clamped there (the bottom moved up past it): :meth:`_settle` + decides once that change has laid out, since a scroll may still be on + its way up. + """ + bottom = self._view.max_scroll_y + if old > bottom: + return + if new > old: + if round(new) >= bottom: + self._following = True + elif round(new) < round(old): + self._following = False + + def resized(self) -> None: + """Settle the view once the size change has laid out: :meth:`_settle`.""" + if not self._settle_scheduled and self._view.call_after_refresh(self._settle): + self._settle_scheduled = True + + def _settle(self) -> None: + """A view the size change left at its bottom follows; a following one goes to its new bottom. + + Not while a scroll animates toward a row above the bottom: the size + change clamped it on its way up, and it goes on up. A following view + waits for the scrolls :meth:`defer` noted, which were worked out from + where it is. + """ + self._settle_scheduled = False + view = self._view + if not view.is_attached: + return + bottom = view.max_scroll_y + if view.scroll_y >= bottom: + heading_up = view.app.animator.is_being_animated(view, "scroll_y") and self._destination < bottom + if not heading_up: + self._following = True + return + if not self._following: + return + if self._deferred: + self._settle_after_deferred = True + return + self._pinning = True + try: + view.scroll_end(animate=False, immediate=True, x_axis=False) + finally: + self._pinning = False diff --git a/src/chrys/app/tui/widgets/chat/agent_transcript_surface.py b/src/chrys/app/tui/widgets/chat/agent_transcript_surface.py index d6d449a34..89372e5ba 100644 --- a/src/chrys/app/tui/widgets/chat/agent_transcript_surface.py +++ b/src/chrys/app/tui/widgets/chat/agent_transcript_surface.py @@ -17,6 +17,7 @@ from textual.widget import Widget from chrys.app.tui.i18n import render_str, widget_localizer +from chrys.app.tui.util.bottom_follow import BottomFollow from chrys.app.tui.util.removal import remove_shielded from chrys.app.tui.widgets.chat.messages import ( AgentMessage, @@ -484,7 +485,11 @@ def render(self, reference: MessageRef) -> str: class AgentTranscriptSurface(VerticalScroll, can_focus=True): - """A nested transcript using the main live renderer and tool registry.""" + """A nested transcript using the main live renderer and tool registry. + + It opens at its newest content and, as the chat does, keeps up with what + lands below while the user leaves it at the bottom. + """ DEFAULT_CSS = """ AgentTranscriptSurface { @@ -514,6 +519,7 @@ def __init__( super().__init__(**kwargs) self._scroll_gc_guard = ManualScrollGcGuard(self) + self._bottom_follow = BottomFollow(self) self._journal = journal self._profile_name = profile_name self._opening_prompt = opening_prompt @@ -632,12 +638,38 @@ def on_unmount(self) -> None: def _on_scrollbar_grabbed_changed(self, grabbed: object) -> None: self._scroll_gc_guard.set_scrollbar_grabbed(grabbed is not None) + def scroll_to(self, x: float | None = None, y: float | None = None, **kwargs: Any) -> None: + """Let the bottom follow wait for a scroll Textual runs after the next refresh. + + A key's or the scrollbar's scroll heads for a row worked out from where + the view is now: were the follow to take the view to new content first, + Down at the bottom would scroll back up. + """ + deferred = y is not None and not kwargs.get("immediate", False) + if deferred: + self._bottom_follow.defer() + super().scroll_to(x, y, **kwargs) + # Posted after Textual's own deferred scroll, so it runs once that has. + if deferred and not self.call_after_refresh(self._bottom_follow.deferred_ran): + self._bottom_follow.deferred_ran() + + def _scroll_to(self, x: float | None = None, y: float | None = None, **kwargs: Any) -> bool: + with self._bottom_follow.scroll(y): + return super()._scroll_to(x, y, **kwargs) + def watch_scroll_y(self, old_value: float, new_value: float) -> None: - """Keep cyclic GC out of manual and animated scroll frames.""" + """Keep cyclic GC out of manual and animated scroll frames, but for the moves that follow the bottom.""" super().watch_scroll_y(old_value, new_value) - if round(old_value) != round(new_value): + self._bottom_follow.scrolled(old_value, new_value) + if round(old_value) != round(new_value) and not self._bottom_follow.pinning: self._scroll_gc_guard.pause() + def watch_virtual_size(self) -> None: + self._bottom_follow.resized() + + def on_resize(self) -> None: + self._bottom_follow.resized() + def _on_mouse_scroll_up(self, event: textual_events.MouseScrollUp) -> None: """Pause GC before Textual starts the wheel scroll.""" self._scroll_gc_guard.pause() diff --git a/src/chrys/app/tui/widgets/chat/live_renderer.py b/src/chrys/app/tui/widgets/chat/live_renderer.py index 497b16680..7289e1327 100644 --- a/src/chrys/app/tui/widgets/chat/live_renderer.py +++ b/src/chrys/app/tui/widgets/chat/live_renderer.py @@ -179,12 +179,16 @@ async def add_answer_delta(self, segment_id: str, text: str) -> None: if not self._blank_segment_text.strip(): return await self._status.remove_trailing_status() - if self._tools.current_tool_group is not None: - self._tools.current_tool_group.collapsed = True + group = self._tools.current_tool_group + folded = (0, 0) + if group is not None: + folded = (group.rows_folding_gives_up(), group.styles.margin.bottom) + group.collapsed = True self._tools.end_group() message = AgentMessage( self._blank_segment_text, is_final=False, profile_name=self._profile_name, segment_ids=(segment_id,) ) + message.take_rows_folded_above(*folded) self._blank_segment_text = "" self._current_agent_msg = message await self._mount.mount_transcript_widget(message) diff --git a/src/chrys/app/tui/widgets/chat/messages.py b/src/chrys/app/tui/widgets/chat/messages.py index dd1ea40e4..6bcea54d1 100644 --- a/src/chrys/app/tui/widgets/chat/messages.py +++ b/src/chrys/app/tui/widgets/chat/messages.py @@ -475,6 +475,12 @@ def __init__( """A callback that brings the markdown body up to date is queued on this widget.""" self._body_synced_at = float("-inf") """When the markdown body was last handed new text (``time.monotonic()``).""" + self._holding_rows = not is_final + """The floors of :meth:`get_content_height` apply: the answer streams, or its final text is not shown yet.""" + self._streamed_height: tuple[int, int] | None = None + """The most rows its text has taken while it streams, as (content width, rows); None until laid out.""" + self._folded_above = (0, 0) + """Rows a tool group above gave up by folding as this answer started, and that group's bottom margin.""" self._is_final = is_final self._profile_name = profile_name self._is_intermediate = is_intermediate @@ -675,6 +681,9 @@ def stream_update(self, text: str, is_final: bool = False, *, timestamp: str = " self._sync_body() self.remove_class("--streaming") self._show_copy_button_if_ready() + if self._streamed_height is None or self._md_widget is None: + self._give_back_held_rows() + # Otherwise once the body shows the final text: on_virtualized_markdown_table_of_contents_updated. elif not self._body_sync_scheduled: delay = self._body_synced_at + _STREAMED_BODY_INTERVAL_SECONDS - time.monotonic() if delay > 0: @@ -709,6 +718,72 @@ def _sync_body(self) -> None: if self._md_widget is not None: self._md_widget.update(self.text) + def on_virtualized_markdown_table_of_contents_updated( + self, event: VirtualizedMarkdown.TableOfContentsUpdated + ) -> None: + """The body installed a parse, as it announces after each: once it shows the final text, the held rows go back. + + It parses that text off the event loop: given back before then, the + message would take the old text's height for a frame, and a view that + follows the bottom would move down and back up as the final text took + the rows again. A parse that fails leaves the old text, and the rows. + """ + if self._holding_rows and self._is_final and event.markdown.shown_source == self.text: + self._give_back_held_rows() + + def _give_back_held_rows(self) -> None: + """End both floors (:meth:`get_content_height`): the message is as tall as its text from now on.""" + if not self._holding_rows: + return + laid_out = self._streamed_height is not None + self._holding_rows = False + self._streamed_height = None + self._folded_above = (0, 0) + if laid_out: + # The final text may change nothing else that would lay the message out again. + self.refresh(layout=True) + + def take_rows_folded_above(self, rows: int, margin: int) -> None: + """Fill the *rows* a tool group gives up by folding as this answer starts below it. + + *margin* is the group's bottom margin, which this message's top margin + collapses with. While the answer streams, the rows stay blank below its + text (see :meth:`get_content_height`), so the content above does not + move down and then back up as the answer grows. + """ + self._folded_above = (rows, margin) + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + """While the answer streams, it gives up no rows it took. + + Until its closing markup arrives, a ``**`` or a link's URL can wrap a + line that then fits again: the message would give up a row and take it + back as more text arrives, and a transcript that follows the bottom + would move everything on screen down a row and back up each time. The + row stays blank below the text instead, until the text fills it or the + answer's final text shows; so do the rows a tool group above gave up as + the answer started (:meth:`take_rows_folded_above`). Either holds at most a third + of the panel's height as it is now: more blank rows would leave the + text above the view, so the message gives them back at once, and the + view moves as before. + """ + height = super().get_content_height(container, viewport, width) + if not self._holding_rows: + return height + most_held = container.height // 3 + streamed = self._streamed_height + if streamed is not None and streamed[0] == width and streamed[1] - height <= most_held: + height = max(height, streamed[1]) + self._streamed_height = (width, height) + rows, margin = self._folded_above + if rows: + spacing = self.styles.margin + # Less the margins this message adds: its top one collapses with the group's bottom one. + folded = rows + margin - max(margin, spacing.top) - spacing.bottom - self.styles.gutter.height + if folded <= most_held: + height = max(height, folded) + return height + def _size_updated(self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True) -> bool: # Textual sets a changed virtual size as a reactive that asks for another # layout, but the layout calling this has placed the message already and a diff --git a/src/chrys/app/tui/widgets/chat/panel.py b/src/chrys/app/tui/widgets/chat/panel.py index 5d9648c67..48a6c59da 100644 --- a/src/chrys/app/tui/widgets/chat/panel.py +++ b/src/chrys/app/tui/widgets/chat/panel.py @@ -477,7 +477,8 @@ def arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: """Defuse Textual anchor pins while chat layout is settling. The compositor writes ``scroll_y`` directly from arranged height. - Keep that math current, clamp stale negative pins, and skip one + Keep that math current, clamp stale negative pins, leave out the + spacer row past content that fills the viewport, and skip one anchored shrink frame; ``_size_updated`` handles the validator side. """ return self._scroll_controller.arrange(size, optimal=optimal) @@ -672,18 +673,20 @@ def watch_virtual_size(self, old: Size, new: Size) -> None: """Post-layout hook: re-engage anchor, sync scrollbar, toggle spacer. 1. Anchor re-engage on growth: ``add_user_message`` releases the - anchor (via ``scroll_visible(top=True)``) so the user's message - shows at viewport top while the response streams in below. - Once the response overflows, resume following the bottom. + anchor when the content lets the user's message reach the viewport + top (``scroll_user_message_to_top``), so it shows there while the + response streams in below. Once the response overflows, resume + following the bottom; rows a replay lands above the view are no + response. 2. Drive ``scroll_y`` through its own setter (not the compositor's ``set_reactive`` pin) so ``watch_scroll_y`` → ``ScrollBar.position`` marks the scrollbar dirty on the same frame the content scrolls. Otherwise the thumb freezes for one frame because Textual's pin writes ``vertical_scrollbar._reactive_position`` directly. - 3. Bottom spacer (``1fr``, min-height 1) — hide when content fills - the viewport (no stray 1-cell tail), re-show when it shrinks - back below (e.g. ``ToolGroup`` auto-collapse) so the next - growth phase has a flexible tail again. + 3. Bottom spacer (``1fr``) — hide when content fills the viewport + (:meth:`arrange` already left out the one row Textual still gives + it then), re-show when it shrinks back below (e.g. ``ToolGroup`` + auto-collapse) so the next growth phase has a flexible tail again. The negative-pin defense lives in :meth:`arrange` — it must run before the compositor reads the anchor flag. @@ -851,6 +854,8 @@ def on_replay_started(self) -> None: def scroll_user_message_to_top(self, widget: Widget) -> None: """Scroll a newly mounted user message to the viewport top.""" + # The message is where the user looks now, not a turn a TOC jump still waits for. + self._replay_mount.forget_jump() self._scroll_controller.scroll_user_message_to_top(widget) def on_final_response_started(self) -> None: @@ -977,6 +982,9 @@ def set_scroll_target_y_reactive(self, y: float) -> None: def set_scroll_target_y(self, y: float) -> None: self.scroll_target_y = y + def stop_scroll_y_animation(self) -> None: + self.app.animator.force_stop_animation(self, "scroll_y") + def set_vertical_scrollbar_position(self, y: float) -> None: # Like the compositor's own anchor pin: move the thumb without watchers. self.vertical_scrollbar.set_reactive(ScrollBar.position, y) diff --git a/src/chrys/app/tui/widgets/chat/ports.py b/src/chrys/app/tui/widgets/chat/ports.py index c9359af5b..bc9c63f15 100644 --- a/src/chrys/app/tui/widgets/chat/ports.py +++ b/src/chrys/app/tui/widgets/chat/ports.py @@ -206,6 +206,8 @@ def set_scroll_target_y_reactive(self, y: float) -> None: ... def set_scroll_target_y(self, y: float) -> None: ... + def stop_scroll_y_animation(self) -> None: ... + def set_vertical_scrollbar_position(self, y: float) -> None: ... def get_container_size(self) -> Size: ... diff --git a/src/chrys/app/tui/widgets/chat/scroll_controller.py b/src/chrys/app/tui/widgets/chat/scroll_controller.py index e022c877e..0703ff686 100644 --- a/src/chrys/app/tui/widgets/chat/scroll_controller.py +++ b/src/chrys/app/tui/widgets/chat/scroll_controller.py @@ -167,8 +167,21 @@ def __init__(self, host: ChatScrollHost) -> None: self.view_hold_active: bool = False self._view_hold_child: tuple[Widget, int] | None = None self._view_hold_arrangement: DockArrangeResult | None = None + self._view_hold_rows = 0 + """How far the view hold moved the view since the last layout's size update: rows landed above it. + + A geometry read can arrange before that layout, which then gets the same arrangement again. + """ self._settle_reanchor_generation = 0 - """Advanced by a TOC jump; a settle re-pin queued before it leaves the view where the jump put it.""" + """Advanced by a TOC jump or a new message's reveal: a re-pin or replay end queued before it does nothing.""" + self._spacer_rows = 0 + """Rows the bottom spacer took in the last arrange: 0 when it was hidden or left out.""" + self._arrangement: DockArrangeResult | None = None + """The last arrangement that placed the transcript.""" + self._reveal: Widget | None = None + """A new user message to bring into view once a layout places it.""" + self._reveal_top = -1 + """Where that layout put the top of ``_reveal``, margin included; -1 until one does.""" def set_agent_running(self, running: bool) -> None: """Mirror current run state and reset the final gate only on run start.""" @@ -187,12 +200,71 @@ def on_replay_started(self) -> None: self.final_response_started = False def scroll_user_message_to_top(self, widget: Widget) -> None: - """Scroll a newly mounted user message to the viewport top.""" + """Scroll a newly mounted user message to the viewport top, as far as the content allows. + + The layout that places the message decides it: :meth:`arrange` reads + where the message landed and :meth:`size_updated` scrolls there once + that layout's height is in, never a callback. Textual runs + ``call_after_refresh`` callbacks whenever the screen idles while a + ``batch_update`` (a footer recompose as a run starts) holds layout + back, so a deferred ``scroll_visible`` could find the message + unplaced and do nothing; and when it replaced a failed turn, the + content did not grow enough to bring a scrolled-up view down to it. + """ + self._reveal = widget + self._reveal_top = -1 + # ``outer_size`` is set by a layout only: ``size`` reads the region, which a + # lazily rebuilt widget map gives before any layout applies the new height. + if widget.outer_size and self._arrangement is not None: + # A layout placed it while it mounted, and that layout's height is in. + self._place_reveal(self._arrangement) + if self._reveal_top >= 0: + self._reveal_placed() + + def _place_reveal(self, result: DockArrangeResult) -> None: + widget = self._reveal + if widget is None: + return + if not widget.is_attached: + self._reveal = None + return + placements = result.placements + for index in range(len(placements) - 1, -1, -1): + placement = placements[index] + if placement.widget is widget: + self._reveal_top = placement.region.y - placement.margin.top + return + + def _reveal_placed(self) -> None: + """Put the message's top at the viewport top, or follow the bottom when it is there. + + As ``scroll_to`` and Textual's anchor check do: a message the content + lets reach the top holds the view there, until the reply's growth + resumes the following; one that ends the content follows the bottom. + """ + host = self._host + # A scroll animation still running would carry the view away again. Stopping + # it puts the view at the animation's end, which is no scroll of the user's, + # and comes before the anchor is decided: an end at the bottom re-engages it. + programmatic = self.programmatic_scroll self.programmatic_scroll = True try: - widget.scroll_visible(top=True, immediate=True, animate=False) + host.stop_scroll_y_animation() finally: - self.programmatic_scroll = False + self.programmatic_scroll = programmatic + max_y = host.max_scroll_y + y = min(self._reveal_top, max_y) + self._reveal = None + self._reveal_top = -1 + if y < max_y: + # A settle re-pin queued by the shrink before it (a failed turn's + # cleanup) would take the view on to the bottom. + self._settle_reanchor_generation += 1 + if host.is_anchored: + host.set_anchor_released(y < max_y) + if host.scroll_y != y: + self.set_scroll_y_programmatically(y) + self.sync_scroll_to_bottom_button() def on_final_response_started(self) -> None: """Resume bottom-follow once when the agent starts its final answer.""" @@ -217,7 +289,7 @@ def scroll_inline_prompt_to_top_after_refresh(self, widget: Widget) -> None: self._host.call_after_refresh(self._host.scroll_to_widget_top, widget) def after_replay_history_mounted(self) -> None: - """Schedule final replay scroll-to-bottom, unless a TOC jump lands first.""" + """Schedule final replay scroll-to-bottom, unless a TOC jump or a new message's reveal lands first.""" self._host.call_after_refresh(self._scroll_end_unless_jumped, self._settle_reanchor_generation) def _scroll_end_unless_jumped(self, generation: int) -> None: @@ -253,6 +325,11 @@ def arrange(self, size: Size, *, optimal: bool = False) -> DockArrangeResult: self._host.set_container_size(Size(container_size.width, new_container_h)) result = self._host.call_super_arrange(size, optimal=optimal) self._host.place_fixed_scroll_button(result, size) + if not optimal: + self._arrangement = result + self._spacer_rows = self._arrange_bottom_spacer(result, size) + if self._reveal is not None: + self._place_reveal(result) virtual_h = result.spatial_map.total_region.bottom if virtual_h < size.height and self._host.scroll_y < 0: if self._host.is_anchored and not self._host.is_anchor_released(): @@ -270,6 +347,32 @@ def arrange(self, size: Size, *, optimal: bool = False) -> DockArrangeResult: self._hold_view(result) return result + def _arrange_bottom_spacer(self, result: DockArrangeResult, size: Size) -> int: + """Return the rows the bottom spacer takes, leaving it out once the content fills the viewport. + + Textual gives ``1fr`` one row when no space remains, so content that + grows to the viewport height would lay out one row past it until + :meth:`watch_virtual_size` hides the spacer: for that frame a scrollbar + shows, and a following view scrolls by the row and back. + """ + spacer = self._host.bottom_spacer() + if spacer is None or not spacer.display: + return 0 + placements = result.placements + for index in range(len(placements) - 1, -1, -1): + placement = placements[index] + if placement.widget is spacer: + if placement.region.y < size.height: + return placement.region.height + del placements[index] + result.widgets.discard(spacer) + result._spatial_map = None + return 0 + if not placement.fixed: + # The spacer ends the transcript: this arrangement left it out already. + break + return 0 + def begin_view_hold(self) -> None: """Keep a scrolled-up view on its content while entries land above it.""" self.view_hold_active = True @@ -309,6 +412,7 @@ def _hold_view(self, result: DockArrangeResult) -> None: if placement.widget is child: delta = placement.region.y - old_y if delta: + self._view_hold_rows += delta scroll_y = max(0.0, scroll_y + delta) host.set_scroll_y_reactive(scroll_y) host.set_scroll_target_y_reactive(scroll_y) @@ -329,8 +433,10 @@ def _first_visible_child(result: DockArrangeResult, scroll_y: float) -> tuple[Wi return None def note_toc_jump(self) -> None: - """A TOC jump moved the view; a settle re-pin already queued must not pull it back to the bottom.""" + """A TOC jump moved the view; a settle re-pin or a reveal already queued must not move it again.""" self._settle_reanchor_generation += 1 + self._reveal = None + self._reveal_top = -1 def _reanchor_after_settle_unless_jumped(self, generation: int) -> None: if generation == self._settle_reanchor_generation: @@ -391,8 +497,12 @@ def size_updated(self, size: Size, virtual_size: Size, container_size: Size, lay # the watcher runs. old_virtual_size = self._host.virtual_size changed = self._host.call_super_size_updated(size, virtual_size, container_size, False) - if layout and self._host.virtual_size != old_virtual_size: - self.watch_virtual_size(old_virtual_size, self._host.virtual_size) + if layout: + if self._host.virtual_size != old_virtual_size: + self.watch_virtual_size(old_virtual_size, self._host.virtual_size) + self._view_hold_rows = 0 + if self._reveal_top >= 0: + self._reveal_placed() return changed finally: self.programmatic_scroll = programmatic @@ -533,7 +643,8 @@ def watch_virtual_size(self, old: Size, new: Size) -> None: new_h = new.height if ( self.agent_running - and new_h > old_h + # Rows a replay lands above the view, which the view hold moved down with, are no reply. + and new_h - self._view_hold_rows > old_h and self._host.is_anchored and self._host.is_anchor_released() and not self.auto_scroll_paused_by_user @@ -549,8 +660,7 @@ def watch_virtual_size(self, old: Size, new: Size) -> None: if spacer is not None: viewport_h = self._host.size.height if spacer.display: - natural_h = new_h - spacer.size.height - if natural_h >= viewport_h: + if new_h - self._spacer_rows >= viewport_h: spacer.display = False elif new_h < viewport_h: spacer.display = True @@ -564,6 +674,9 @@ def reset_for_clear(self) -> None: self.scrollbar_grabbed = False self.resume_gc_after_manual_scroll(schedule_collect=False) self.held_shrink_height = -1 + self._arrangement = None + self._reveal = None + self._reveal_top = -1 self.agent_running = False self.auto_scroll_paused_by_user = False self.final_response_started = False @@ -572,6 +685,7 @@ def reset_for_clear(self) -> None: self.view_hold_active = False self._view_hold_child = None self._view_hold_arrangement = None + self._view_hold_rows = 0 self._host.anchor() def stop(self) -> None: @@ -583,3 +697,7 @@ def stop(self) -> None: self.resume_gc_after_manual_scroll(schedule_collect=False) self.end_view_hold() self._view_hold_child = None + self._view_hold_rows = 0 + self._arrangement = None + self._reveal = None + self._reveal_top = -1 diff --git a/src/chrys/app/tui/widgets/chat/tool_call.py b/src/chrys/app/tui/widgets/chat/tool_call.py index 6e6a0175f..e271f09f3 100644 --- a/src/chrys/app/tui/widgets/chat/tool_call.py +++ b/src/chrys/app/tui/widgets/chat/tool_call.py @@ -2120,6 +2120,14 @@ def _sync_activity_indicator(self) -> None: else: indicator.pause_animation() + def rows_folding_gives_up(self) -> int: + """The rows of tool calls below the header, which folding the laid-out group gives up.""" + try: + content = self.query_one("#tg-content") + except NoMatches: + return 0 + return content.outer_size.height if content.display else 0 + def watch_collapsed(self, collapsed: bool) -> None: try: content = self.query_one("#tg-content") diff --git a/src/chrys/app/tui/widgets/markdown/widget.py b/src/chrys/app/tui/widgets/markdown/widget.py index 10bb9bacf..4f6d5486f 100644 --- a/src/chrys/app/tui/widgets/markdown/widget.py +++ b/src/chrys/app/tui/widgets/markdown/widget.py @@ -490,6 +490,8 @@ def __init__( self._last_parsed_line = 0 self._pending_parse: _PendingParse | None = None """The scheduled parse that has not read ``_markdown`` yet; new requests join it.""" + self._shown_source = "" + """The source the installed blocks were parsed from.""" # Virtualization state self._blocks: list[MarkdownBlock] = [] @@ -592,6 +594,11 @@ def source(self) -> str: """The markdown source.""" return self._markdown or "" + @property + def shown_source(self) -> str: + """The source of the document installed so far: ``source`` once its parse has landed.""" + return self._shown_source + async def _on_mount(self, _: Mount) -> None: # ty: ignore[invalid-method-override] # Textual dispatch awaits async handlers. initial_markdown = self._initial_markdown self._initial_markdown = None @@ -1830,6 +1837,7 @@ async def _run_parse(self, request: _PendingParse) -> None: report.cancel() watch.ended() self._install_blocks(blocks) + self._shown_source = markdown lines = markdown.splitlines() self._last_parsed_line = len(lines) - (1 if lines and lines[-1] else 0) diff --git a/tests/app/tui/screens/test_log_viewer.py b/tests/app/tui/screens/test_log_viewer.py index f50a7f6a9..1d029114d 100644 --- a/tests/app/tui/screens/test_log_viewer.py +++ b/tests/app/tui/screens/test_log_viewer.py @@ -5,8 +5,11 @@ from __future__ import annotations import logging +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING import pytest +from textual import events from textual.app import App, ComposeResult from textual.widgets import RichLog, Static, TabbedContent @@ -23,6 +26,13 @@ ) from chrys.app.tui.widgets.hatch import HatchedEmptyState from chrys.foundation.config.settings import Settings +from tests.support.tui_helpers import make_wheel, resize_when_settled +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for, wait_until + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from textual.pilot import Pilot def _record(name: str, msg: str, level: int = logging.INFO) -> logging.LogRecord: @@ -252,6 +262,431 @@ async def test_every_log_tab_scrollbar_is_one_cell_and_flush_right() -> None: assert log_widget.vertical_scrollbar.region.right == container.content_region.right +def _at_bottom(log_widget: RichLog) -> bool: + return log_widget.max_scroll_y > 0 and log_widget.scroll_y == log_widget.max_scroll_y + + +class _OpenLog: + """The log viewer opened on its ``chrys`` log, which new lines can land in.""" + + def __init__(self, pilot: Pilot[None], handler: _TuiLogHandler, screen: LogViewerScreen) -> None: + self.pilot = pilot + self.widget = screen.query_one("#lv-log-chrys", RichLog) + self._handler = handler + self._screen = screen + self._landed = 0 + + def animating(self) -> bool: + return self.pilot.app.animator.is_being_animated(self.widget, "scroll_y") + + def land(self, count: int = 20) -> None: + """Log *count* new lines and show them, as the viewer's refresh does.""" + for _ in range(count): + self._handler.emit(_record("chrys", f"new-{self._landed}")) + self._landed += 1 + self._screen._refresh_logs() + + async def assert_followed(self) -> None: + """Land new lines: the log goes to its new bottom.""" + bottom = self.widget.max_scroll_y + self.land() + await wait_for( + lambda: self.widget.max_scroll_y > bottom and _at_bottom(self.widget), + pilot=self.pilot, + description="new lines are followed", + ) + + async def assert_not_followed(self) -> None: + """Land new lines: the log stays on the row it shows.""" + reading, bottom = self.widget.scroll_y, self.widget.max_scroll_y + self.land() + assert self.widget.max_scroll_y > bottom + assert not await wait_until(lambda: self.widget.scroll_y != reading, pilot=self.pilot, timeout=0.5) + + +@asynccontextmanager +async def _open_log(*, lines: int = 100, size: tuple[int, int] = (120, 20)) -> AsyncIterator[_OpenLog]: + """Open the log viewer on a ``chrys`` log of *lines* lines, at its newest.""" + handler = _TuiLogHandler() + handler.setFormatter(logging.Formatter("%(message)s")) + for index in range(lines): + handler.emit(_record("chrys", f"line-{index}")) + screen = LogViewerScreen() + screen._handler = handler + async with _LogViewerApp().run_test(size=size) as pilot: + await pilot.app.push_screen(screen) + log = _OpenLog(pilot, handler, screen) + await wait_for(lambda: _at_bottom(log.widget), pilot=pilot, description="the log opens at its newest line") + yield log + + +def _drag_selection(log: _OpenLog, *, row: int, delta_y: int) -> None: + """Move the mouse that holds a selection to *row* of the log, as the terminal reports a drag.""" + region = log.widget.content_region + x, y = region.x + 3, region.y + row + log.pilot.app.post_message(events.MouseMove(None, x, y, 0, delta_y, 1, False, False, False, screen_x=x, screen_y=y)) + + +@pytest.mark.asyncio +async def test_new_lines_follow_a_log_only_while_it_is_scrolled_to_the_bottom() -> None: + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + await log.assert_followed() + + log_widget.post_message(make_wheel(log_widget, up=True)) + await wait_for( + lambda: 0 < log_widget.scroll_y < log_widget.max_scroll_y, pilot=pilot, description="scrolled up" + ) + await log.assert_not_followed() + + # A scroll heading to the bottom follows again from the first frame of its move. + log_widget.scroll_to(y=log_widget.max_scroll_y, duration=1.0) + await wait_for(log.animating, pilot=pilot, description="a scroll heading to the bottom") + await log.assert_followed() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("thumb_pressed", [False, True], ids=["home", "home-then-thumb-press"]) +async def test_a_scroll_up_cut_short_by_a_taller_view_does_not_follow_new_lines(thumb_pressed: bool) -> None: + """A taller view clamps a scroll heading up to the new bottom: that is no return to it, the scroll goes on up. + + Its frames stay at that bottom until the scroll passes it: lines that land meanwhile are not followed. A + press on the scrollbar as it goes, which sets Textual's scroll target to where the scroll has got to, changes + nothing. + """ + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + bottom = log_widget.max_scroll_y + + log_widget.scroll_home(duration=ENGINE_TURN_TIMEOUT, easing="linear", immediate=True) + # Before its first frame: a line that lands now must not take the view back down. + assert not log_widget._bottom_follow.following + await wait_for(log.animating, pilot=pilot, description="Home animating") + if thumb_pressed: + # As ``ScrollBar`` does when the mouse captures its thumb. + log_widget.release_anchor() + await resize_when_settled(pilot, 120, 35) + await wait_for( + lambda: log_widget.max_scroll_y < bottom and log_widget.scroll_y == log_widget.max_scroll_y, + pilot=pilot, + description="the taller view clamped the scroll to its bottom", + ) + assert log.animating() + + log.land() + assert not await wait_until(lambda: _at_bottom(log_widget), pilot=pilot, timeout=0.5) + assert log.animating() + pilot.app.animator.force_stop_animation(log_widget, "scroll_y") + assert log_widget.scroll_y == 0 + await log.assert_not_followed() + + +@pytest.mark.asyncio +async def test_page_down_from_the_bottom_a_taller_view_holds_home_at_follows_new_lines() -> None: + """Page Down stops Home, which lands at the top, and heads back to the bottom the taller view held Home at. + + Its target repeats the one Textual's ``release_anchor()`` set as Page Down started. + """ + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + bottom = log_widget.max_scroll_y + log_widget.scroll_home(duration=ENGINE_TURN_TIMEOUT, easing="linear", immediate=True) + await wait_for(log.animating, pilot=pilot, description="Home animating") + await resize_when_settled(pilot, 120, 35) + await wait_for( + lambda: log_widget.max_scroll_y < bottom and log_widget.scroll_y == log_widget.max_scroll_y, + pilot=pilot, + description="the taller view clamped Home to its bottom", + ) + + log_widget.scroll_page_down(duration=ENGINE_TURN_TIMEOUT, easing="linear") + assert log.animating() + assert log_widget.scroll_y == 0 + assert log_widget.scroll_target_y == log_widget.max_scroll_y + await log.assert_followed() + + +@pytest.mark.asyncio +async def test_a_scroll_to_the_bottom_cut_short_by_another_scroll_lands_there_and_follows() -> None: + """A sideways scroll (Right) stops End where it was headed, but leaves Textual's scroll target where End had got to.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + log_widget.scroll_home(animate=False, immediate=True) + + log_widget.scroll_end(duration=ENGINE_TURN_TIMEOUT, easing="linear") + await wait_for(log.animating, pilot=pilot, description="End animating") + log_widget.scroll_to(x=0, animate=False, immediate=True) + assert _at_bottom(log_widget) + assert log_widget.scroll_target_y < log_widget.max_scroll_y + await log.assert_followed() + + +@pytest.mark.asyncio +async def test_a_wheel_up_as_end_scrolls_stops_the_following() -> None: + """The wheel stops End at the bottom, then scrolls up from there: a scroll up, though from a target End had not reached.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + log_widget.scroll_home(animate=False, immediate=True) + + log_widget.scroll_end(duration=ENGINE_TURN_TIMEOUT, easing="linear") + await wait_for(log.animating, pilot=pilot, description="End animating") + log_widget.post_message(make_wheel(log_widget, up=True)) + await wait_for( + lambda: not log.animating() and 0 < log_widget.scroll_y < log_widget.max_scroll_y, + pilot=pilot, + description="the wheel scrolled up from the bottom", + ) + await log.assert_not_followed() + + +@pytest.mark.asyncio +async def test_a_scroll_up_from_where_end_is_headed_stops_the_following_before_its_first_frame() -> None: + """The wheel asks for rows above End's target, below where End has got to: End lands first, so it scrolls up. + + Lines that land before its first frame must not take the view back down. + """ + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + log_widget.scroll_home(animate=False, immediate=True) + + log_widget.scroll_end(duration=ENGINE_TURN_TIMEOUT, easing="linear") + await wait_for(log.animating, pilot=pilot, description="End animating") + wheel_target = log_widget.scroll_target_y - pilot.app.scroll_sensitivity_y + assert log_widget.scroll_y < wheel_target + # As the wheel's notch up does, from Textual's scroll target. + log_widget.scroll_to(y=wheel_target, duration=ENGINE_TURN_TIMEOUT, easing="linear") + assert _at_bottom(log_widget) + assert not log_widget._bottom_follow.following + + log.land() + assert not await wait_until(lambda: _at_bottom(log_widget), pilot=pilot, timeout=0.5) + + +@pytest.mark.asyncio +async def test_a_scroll_to_the_row_end_has_got_to_stops_the_following() -> None: + """End lands at the bottom first, so a scroll to where it had got to is a scroll up, as the wheel late in End asks.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + log_widget.scroll_home(animate=False, immediate=True) + + log_widget.scroll_end(duration=ENGINE_TURN_TIMEOUT, easing="linear") + await wait_for(log.animating, pilot=pilot, description="End animating") + reading = round(log_widget.scroll_y) + log_widget.scroll_to(y=reading, animate=False, immediate=True) + assert log_widget.scroll_y == reading < log_widget.max_scroll_y + await log.assert_not_followed() + + +@pytest.mark.asyncio +async def test_a_scroll_down_a_taller_view_cuts_short_at_its_bottom_follows() -> None: + """The scroll was headed past the new bottom: it ends there, though it was not aimed at the bottom when it started.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + log_widget.scroll_to(y=50, animate=False, immediate=True) + + log_widget.scroll_to(y=60, duration=ENGINE_TURN_TIMEOUT, easing="linear") + await wait_for(log.animating, pilot=pilot, description="the scroll down animating") + await resize_when_settled(pilot, 120, 70) + await wait_for( + lambda: log_widget.max_scroll_y < 50 and _at_bottom(log_widget), + pilot=pilot, + description="the taller view clamped the scroll to its bottom", + ) + pilot.app.animator.force_stop_animation(log_widget, "scroll_y") + assert _at_bottom(log_widget) + await log.assert_followed() + + +@pytest.mark.asyncio +async def test_a_press_on_the_scrollbar_as_end_scrolls_keeps_it_following() -> None: + """The press marks where End has got to as Textual's scroll target, and End goes on: lines that land are followed.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + log_widget.scroll_home(animate=False, immediate=True) + + log_widget.scroll_end(duration=ENGINE_TURN_TIMEOUT, easing="linear") + await wait_for(log.animating, pilot=pilot, description="End animating") + # As ``ScrollBar`` does when the mouse captures its thumb. + log_widget.release_anchor() + assert log.animating() + await log.assert_followed() + + +@pytest.mark.asyncio +async def test_a_selection_dragged_past_the_edge_of_a_log_follows_only_back_at_its_bottom() -> None: + """Textual scrolls the log a row at a time while a selection is held past its edge, asking for no scroll.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + height = log_widget.content_region.height + bottom = log_widget.max_scroll_y + + await pilot.mouse_down(log_widget, offset=(3, height - 2)) + _drag_selection(log, row=0, delta_y=2 - height) + await wait_for(lambda: log_widget.scroll_y < bottom - 3, pilot=pilot, description="the selection scrolls up") + await pilot.mouse_up(log_widget, offset=(3, 0)) + await log.assert_not_followed() + + await pilot.mouse_down(log_widget, offset=(3, 1)) + _drag_selection(log, row=height - 1, delta_y=height - 2) + await wait_for(lambda: _at_bottom(log_widget), pilot=pilot, description="the selection scrolls to the bottom") + await pilot.mouse_up(log_widget, offset=(3, height - 1)) + await log.assert_followed() + + +@pytest.mark.asyncio +async def test_lines_that_land_before_a_taller_view_settles_a_following_log_are_followed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A taller view clamps a following log to its new bottom, which is no scroll up of the user's. + + The viewer's refresh may write lines before the follow settles that size change, after the refresh. + """ + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + bottom = log_widget.max_scroll_y + follow = log_widget._bottom_follow + settle = follow._settle + landed: list[bool] = [] + + def land_then_settle() -> None: + if not landed and log_widget.max_scroll_y < bottom: + landed.append(True) + log.land(5) + settle() + + monkeypatch.setattr(follow, "_settle", land_then_settle) + await resize_when_settled(pilot, 120, 35) + await wait_for( + lambda: bool(landed) and _at_bottom(log_widget), + pilot=pilot, + description="the lines that landed meanwhile are followed", + ) + assert log_widget.max_scroll_y < bottom + + +@pytest.mark.asyncio +async def test_a_log_follows_while_it_shows_its_bottom_row_through_moves_of_part_of_a_row() -> None: + """A selection's auto-scroll moves part of a row a frame, and Textual shows the rounded row.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + + def auto_scroll(rows: float) -> None: + # As Screen's auto-scroll does each frame while a selection is held past the log's edge. + log_widget.scroll_y += rows + log_widget.scroll_target_y = log_widget.scroll_y + + auto_scroll(-1 / 3) + assert log_widget.scroll_offset.y == log_widget.max_scroll_y + await log.assert_followed() + + auto_scroll(-4 / 3) + assert log_widget.scroll_offset.y == log_widget.max_scroll_y - 1 + assert not log_widget._bottom_follow.following + auto_scroll(1) + assert log_widget.scroll_offset.y == log_widget.max_scroll_y + await log.assert_followed() + + # Home's first frames still show the bottom row: they are no move back to it. + bottom = log_widget.max_scroll_y + log_widget.scroll_home(duration=40 * ENGINE_TURN_TIMEOUT, easing="linear", immediate=True) + await wait_for(lambda: log_widget.scroll_y < bottom, pilot=pilot, description="Home's first frame") + assert log_widget.scroll_offset.y == bottom + assert not log_widget._bottom_follow.following + log.land() + assert not await wait_until(lambda: _at_bottom(log_widget), pilot=pilot, timeout=0.5) + + +@pytest.mark.asyncio +async def test_a_taller_view_that_ends_exactly_at_a_scrolled_up_log_bottom_follows_new_lines() -> None: + """The view grows by just the rows it was scrolled up: no scroll moves, and the newest line shows at the bottom.""" + async with _open_log(size=(120, 23)) as log: + log_widget, pilot = log.widget, log.pilot + taller_bottom = log_widget.max_scroll_y + await resize_when_settled(pilot, 120, 20) + await wait_for( + lambda: log_widget.max_scroll_y > taller_bottom and _at_bottom(log_widget), + pilot=pilot, + description="the shorter view keeps to the bottom", + ) + log_widget.scroll_to(y=taller_bottom, animate=False, immediate=True) + assert not log_widget._bottom_follow.following + + await resize_when_settled(pilot, 120, 23) + await wait_for( + lambda: log_widget.max_scroll_y == taller_bottom and log_widget._bottom_follow.following, + pilot=pilot, + description="the taller view ends at the line being read, and follows", + ) + assert log_widget.scroll_y == taller_bottom + await log.assert_followed() + + +@pytest.mark.asyncio +async def test_a_log_a_taller_view_pushes_to_its_bottom_follows_though_the_scroll_target_was_left_above() -> None: + """A sideways scroll that stops Page Down leaves Textual's scroll target where Page Down had got to.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + log_widget.scroll_home(animate=False, immediate=True) + + log_widget.scroll_to(y=60, duration=ENGINE_TURN_TIMEOUT, easing="linear") + await wait_for(log.animating, pilot=pilot, description="the scroll down animating") + log_widget.scroll_to(x=0, animate=False, immediate=True) + assert log_widget.scroll_y == 60 + assert log_widget.scroll_target_y < 60 + assert not log_widget._bottom_follow.following + + await resize_when_settled(pilot, 120, 60) + await wait_for( + lambda: log_widget.max_scroll_y < 60 and _at_bottom(log_widget), + pilot=pilot, + description="the taller view pushed the log to its bottom", + ) + assert log_widget.scroll_target_y < log_widget.max_scroll_y + await log.assert_followed() + + +@pytest.mark.asyncio +async def test_a_following_log_scrolled_down_before_it_reaches_new_lines_keeps_following() -> None: + """A scroll down while the follow's move to the new bottom waits for a refresh heads on toward it.""" + async with _open_log() as log: + log_widget, pilot = log.widget, log.pilot + bottom = log_widget.max_scroll_y + log.land() + assert log_widget.max_scroll_y > bottom + log_widget.scroll_to(y=log_widget.scroll_y + 1, animate=False, immediate=True) + assert log_widget.scroll_y < log_widget.max_scroll_y + await wait_for(lambda: _at_bottom(log_widget), pilot=pilot, description="the log reached its new bottom") + + +@pytest.mark.asyncio +async def test_a_scrolled_up_full_log_keeps_its_lines_in_place_as_the_oldest_go() -> None: + """Past ``MAX_DISPLAY_LINES`` each new line drops the oldest; the lines being read must not move up.""" + async with _open_log(lines=MAX_DISPLAY_LINES + 50) as log: + log_widget, pilot = log.widget, log.pilot + await wait_for( + lambda: len(log_widget.lines) == MAX_DISPLAY_LINES and _at_bottom(log_widget), + pilot=pilot, + description="the full log opens at its newest line", + ) + + log_widget.post_message(make_wheel(log_widget, up=True)) + await wait_for( + lambda: 0 < log_widget.scroll_y < log_widget.max_scroll_y, pilot=pilot, description="scrolled up" + ) + reading = log_widget.lines[int(log_widget.scroll_y)].text + log.land() + assert log_widget.lines[-1].text.startswith("new-19") + assert log_widget.lines[int(log_widget.scroll_y)].text == reading + + # A scroll still animating lands where it was headed, as the lines it was headed to move up. + log_widget.scroll_page_up(duration=1.0) + await wait_for(log.animating, pilot=pilot, description="Page Up animating") + headed = log_widget.lines[int(log_widget.scroll_target_y)].text + log.land(1) + assert not log.animating() + assert log_widget.lines[int(log_widget.scroll_y)].text == headed + + @pytest.mark.asyncio async def test_empty_state_stays_visible_after_filtered_handler_buffer_trim() -> None: h = _TuiLogHandler() diff --git a/tests/app/tui/screens/test_tool_view_modal.py b/tests/app/tui/screens/test_tool_view_modal.py index 70f660bb9..483beeedf 100644 --- a/tests/app/tui/screens/test_tool_view_modal.py +++ b/tests/app/tui/screens/test_tool_view_modal.py @@ -11,7 +11,6 @@ import pytest from rich.console import Console, Group from rich.syntax import Syntax -from textual import events as textual_events from textual import on from textual.app import App, ComposeResult from textual.events import MouseDown, MouseUp @@ -26,6 +25,8 @@ AgentTranscriptJournal, AgentTranscriptSurface, TranscriptAssistantOp, + TranscriptToolResultOp, + TranscriptToolStartOp, ) from chrys.app.tui.widgets.chat.messages import AgentMessage from chrys.app.tui.widgets.chat.renderers.ask_user import AskUserToolCall @@ -58,7 +59,8 @@ from chrys.foundation.i18n.formatting import format_message from chrys.foundation.tool_kinds import KIND_FILESYSTEM_READ from chrys.kernel import Content -from tests.support.waiting import wait_for +from tests.support.tui_helpers import make_wheel +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for, wait_until def test_tool_view_chrome_keeps_english_and_localizes_chinese() -> None: @@ -196,6 +198,10 @@ def _on_tool_view_requested(self, event: ToolViewRequested) -> None: self.push_screen(ToolDetailModal.from_request(event)) +def _at_bottom(surface: AgentTranscriptSurface) -> bool: + return surface.max_scroll_y > 0 and surface.scroll_y == surface.max_scroll_y + + @pytest.mark.asyncio async def test_view_button_revealed_and_opens_modal() -> None: class ToolApp(_ToolViewHostApp): @@ -284,37 +290,27 @@ async def test_sub_agent_detail_uses_one_scroll_owner_and_pauses_gc_during_scrol async with _ToolViewHostApp().run_test(size=(100, 30)) as pilot: pilot.app.push_screen(modal) await wait_for( - lambda: surface.is_attached and surface.virtual_size.height > surface.size.height, + lambda: _at_bottom(surface), pilot=pilot, - description="scrollable sub-agent detail transcript", + description="scrollable sub-agent detail transcript at its newest content", ) + # Its own moves to the bottom are no scroll gesture. + assert not scroll_gc_paused() output_pane = modal.query_one("#tool-view-output", TabPane) assert surface.parent is output_pane gc_states_during_scroll: list[bool] = [] - original_scroll_down = surface._scroll_down_for_pointer + original_scroll_up = surface._scroll_up_for_pointer - def scroll_down_spy(**kwargs: object) -> bool: + def scroll_up_spy(**kwargs: object) -> bool: gc_states_during_scroll.append(scroll_gc_paused()) - return original_scroll_down(**kwargs) - - monkeypatch.setattr(surface, "_scroll_down_for_pointer", scroll_down_spy) - await surface._on_message( - textual_events.MouseScrollDown( - surface, - x=1, - y=1, - delta_x=0, - delta_y=1, - button=0, - shift=False, - meta=False, - ctrl=False, - ) - ) + return original_scroll_up(**kwargs) + + monkeypatch.setattr(surface, "_scroll_up_for_pointer", scroll_up_spy) + await surface._on_message(make_wheel(surface, up=True)) await wait_for( - lambda: surface.scroll_y > 0 and surface._scroll_gc_guard.paused, + lambda: surface.scroll_y < surface.max_scroll_y and surface._scroll_gc_guard.paused, pilot=pilot, description="detail scroll with GC paused", ) @@ -329,6 +325,145 @@ def scroll_down_spy(**kwargs: object) -> bool: ) +@pytest.mark.asyncio +async def test_sub_agent_detail_follows_new_tools_only_while_scrolled_to_the_bottom() -> None: + journal = AgentTranscriptJournal() + for index in range(40): + journal.record(TranscriptAssistantOp(f"Transcript line {index}: " + ("content " * 12))) + surface = AgentTranscriptSurface(journal) + modal = ToolDetailModal(title="Explore", input_widgets=[], output_widgets=[surface], initial_tab="output") + tools = 0 + + def add_tool() -> int: + nonlocal tools + tools += 1 + call_id = f"read-{tools}" + # Text first, so the tool opens a group of its own rather than joining the last one. + journal.record(TranscriptAssistantOp(f"Reading, step {tools}.", segment_id=f"step-{tools}")) + journal.record(TranscriptToolStartOp(call_id, "read_file", KIND_FILESYSTEM_READ, {"path": "README.md"})) + journal.record(TranscriptToolResultOp(call_id, "read_file", "contents")) + return surface.max_scroll_y + + async with _ToolViewHostApp().run_test(size=(100, 30)) as pilot: + pilot.app.push_screen(modal) + await wait_for(lambda: _at_bottom(surface), pilot=pilot, description="the detail opens at its newest content") + + bottom = add_tool() + await wait_for( + lambda: surface.max_scroll_y > bottom and _at_bottom(surface), + pilot=pilot, + description="a new tool keeps a view at the bottom there", + ) + + surface.post_message(make_wheel(surface, up=True)) + await wait_for(lambda: 0 < surface.scroll_y < surface.max_scroll_y, pilot=pilot, description="scrolled up") + reading = surface.scroll_y + bottom = add_tool() + await wait_for(lambda: surface.max_scroll_y > bottom, pilot=pilot, description="the second tool lands") + assert not await wait_until(lambda: surface.scroll_y != reading, pilot=pilot, timeout=0.5) + + # A scroll heading to the bottom follows again from the first frame of its move. + surface.scroll_page_down(duration=1.0) + await wait_for( + lambda: pilot.app.animator.is_being_animated(surface, "scroll_y"), + pilot=pilot, + description="Page Down heading to the bottom", + ) + bottom = add_tool() + await wait_for( + lambda: surface.max_scroll_y > bottom and _at_bottom(surface), + pilot=pilot, + description="back at the bottom, a new tool is followed again", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("request_kind", ["page-down", "down"]) +async def test_a_scroll_down_asked_as_new_content_lands_keeps_a_following_detail_following( + monkeypatch: pytest.MonkeyPatch, request_kind: str +) -> None: + """Asked for as new content lands, a scroll down heads on from where the view was, and the view follows. + + Page Down (the key, or a click on the scrollbar's track below its thumb) and Down work out their rows as they + are asked for; Textual scrolls after the next refresh, when the follow would take the view to the new content. + """ + journal = AgentTranscriptJournal() + for index in range(40): + journal.record(TranscriptAssistantOp(f"Transcript line {index}: " + ("content " * 12))) + surface = AgentTranscriptSurface(journal) + modal = ToolDetailModal(title="Explore", input_widgets=[], output_widgets=[surface], initial_tab="output") + + async with _ToolViewHostApp().run_test(size=(100, 30)) as pilot: + pilot.app.push_screen(modal) + follow = surface._bottom_follow + # The settle the opening queued has run: one still queued would pin the view before the seam. + await wait_for( + lambda: _at_bottom(surface) and not follow._settle_scheduled, + pilot=pilot, + description="the detail opens at its newest content", + ) + bottom = surface.max_scroll_y + settle = follow._settle + asked: list[bool] = [] + + def page_down_then_settle() -> None: + if not asked and surface.max_scroll_y > bottom: + asked.append(True) + if request_kind == "page-down": + surface.scroll_page_down(duration=ENGINE_TURN_TIMEOUT, easing="linear") + else: + surface.scroll_down(duration=ENGINE_TURN_TIMEOUT, easing="linear") + settle() + + monkeypatch.setattr(follow, "_settle", page_down_then_settle) + # Far more than a page, in one answer that lands at once. + journal.record( + TranscriptAssistantOp("\n\n".join(f"New line {index}." for index in range(60)), segment_id="new") + ) + await wait_for( + lambda: ( + bool(asked) and _at_bottom(surface) and not pilot.app.animator.is_being_animated(surface, "scroll_y") + ), + pilot=pilot, + description="the view is at the new content's bottom", + ) + assert follow.following + + +@pytest.mark.asyncio +async def test_home_asked_as_content_outgrows_a_detail_that_fit_keeps_it_at_the_top( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The view that fit its content was at its top and bottom at once: Home there is no scroll to the bottom.""" + journal = AgentTranscriptJournal() + journal.record(TranscriptAssistantOp("A short opening line.")) + surface = AgentTranscriptSurface(journal) + modal = ToolDetailModal(title="Explore", input_widgets=[], output_widgets=[surface], initial_tab="output") + + follow = surface._bottom_follow + settle = follow._settle + asked: list[bool] = [] + + def home_then_settle() -> None: + if not asked and surface.max_scroll_y > 0: + asked.append(True) + surface.scroll_home(animate=False) + settle() + + # Before the detail opens: a settle the opening queued would otherwise pin the view first. + monkeypatch.setattr(follow, "_settle", home_then_settle) + async with _ToolViewHostApp().run_test(size=(100, 30)) as pilot: + pilot.app.push_screen(modal) + await wait_for(lambda: surface.is_mounted, pilot=pilot, description="the detail opens") + assert surface.max_scroll_y == 0 + journal.record( + TranscriptAssistantOp("\n\n".join(f"New line {index}." for index in range(60)), segment_id="new") + ) + await wait_for(lambda: bool(asked), pilot=pilot, description="Home asked for as the content outgrew the view") + assert not await wait_until(lambda: surface.scroll_y != 0, pilot=pilot, timeout=0.5) + assert not follow.following + + @pytest.mark.asyncio async def test_image_tool_view_output_renders_image_without_caption() -> None: class ToolApp(_ToolViewHostApp): diff --git a/tests/app/tui/widgets/chat/test_chat_panel_workloads.py b/tests/app/tui/widgets/chat/test_chat_panel_workloads.py new file mode 100644 index 000000000..69bb1df8d --- /dev/null +++ b/tests/app/tui/widgets/chat/test_chat_panel_workloads.py @@ -0,0 +1,79 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""The synthetic transcripts that the chat panel profiler and the GC freeze calibration build. + +Only the scheduled GC freeze calibration runs those scripts; building every +workload here keeps them on the chat panel's current API. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest +from scripts.chat_panel_workloads import ( + _populate_file_diff, + _populate_mixed_markdown, + _populate_realistic_session, + _populate_sub_agent_markdown, + _populate_table_markdown, +) + +from chrys.app.tui.widgets.chat.panel import ChatPanel +from chrys.app.tui.widgets.chat.tool_call import BaseToolCard +from tests.support.tui_helpers import ChatPanelApp + +_TURN = ["intermediate", "answer"] + + +@pytest.mark.parametrize( + ("populate", "messages", "tool_calls"), + [ + (lambda panel: _populate_realistic_session(panel, turns=2, collapsed=False), _TURN * 2, 12), + (lambda panel: _populate_file_diff(panel, turns=2, tool_sets_per_turn=1, collapsed=False), _TURN * 2, 6), + ( + lambda panel: _populate_sub_agent_markdown( + panel, turns=2, tool_sets_per_turn=1, collapsed=False, markdown_lines=20 + ), + _TURN * 2, + 2, + ), + ( + lambda panel: _populate_table_markdown( + panel, turns=2, messages_per_turn=2, markdown_lines=10, table_columns=2, table_rows=2, cell_words=0 + ), + ["answer"] * 4, + 0, + ), + ( + lambda panel: _populate_mixed_markdown( + panel, + turns=2, + messages_per_turn=2, + markdown_lines=10, + fence_lines=2, + table_columns=2, + table_rows=2, + cell_words=0, + ), + ["answer"] * 4, + 0, + ), + ], + ids=["realistic-session", "file-diff", "sub-agent-markdown", "table-markdown", "mixed-markdown"], +) +async def test_a_workload_builds_its_turns_through_the_live_chat_api( + populate: Callable[[ChatPanel], Awaitable[None]], + messages: list[str], + tool_calls: int, +) -> None: + """Text before a tool call settles as intermediate text, and each turn's answer completes.""" + async with ChatPanelApp().run_test(size=(120, 36)) as pilot: + panel = pilot.app.query_one(ChatPanel) + await populate(panel) + await pilot.pause() + + shown = panel.mounted_agent_messages() + assert ["intermediate" if message.has_class("--intermediate") else "answer" for message in shown] == messages + assert not any(message.has_class("--streaming") for message in shown) + assert len(panel.query(BaseToolCard)) == tool_calls diff --git a/tests/app/tui/widgets/chat/test_replay_tail_first.py b/tests/app/tui/widgets/chat/test_replay_tail_first.py index 6d5df4b65..f769bc962 100644 --- a/tests/app/tui/widgets/chat/test_replay_tail_first.py +++ b/tests/app/tui/widgets/chat/test_replay_tail_first.py @@ -659,6 +659,31 @@ async def test_user_scroll_drops_a_toc_jump_still_waiting_for_its_turn( assert reading.virtual_region.y - cp.scroll_y == offset +async def test_a_message_sent_drops_a_toc_jump_still_waiting_for_its_turn(monkeypatch: pytest.MonkeyPatch) -> None: + """The new message is what the user looks at now; the turn they jumped to before must not take the view.""" + async with ChatPanelApp().run_test(size=_SIZE) as pilot: + cp = pilot.app.query_one(ChatPanel) + gate = _PrependGate(cp, monkeypatch) + await cp.replay_history(_turns()) + await gate.wait_held(pilot) + await _settle(cp, pilot) + oldest_turn = cp.toc_items[0].turn_id + + cp.scroll_to_turn(oldest_turn) + cp.agent_running = True + await cp.add_user_message("new question") + message = cp.query(UserMessage).last() + await wait_for(lambda: bool(message.outer_size), pilot=pilot, description="the message laid out") + + gate.release.set() + await _wait_complete(cp) + await _settle(cp, pilot) + + target = next(child for child in cp.children if isinstance(child, UserMessage) and child.id == oldest_turn) + assert not target.has_class("-highlighted") + assert cp.scroll_y <= message.virtual_region.y < cp.scroll_y + cp.size.height + + @pytest.mark.parametrize("refreshes", [0, 1]) async def test_a_toc_jump_right_after_a_restore_is_not_pulled_back_to_the_bottom( monkeypatch: pytest.MonkeyPatch, refreshes: int diff --git a/tests/app/tui/widgets/test_chat_panel_scroll.py b/tests/app/tui/widgets/test_chat_panel_scroll.py index af931389e..88bde1fd2 100644 --- a/tests/app/tui/widgets/test_chat_panel_scroll.py +++ b/tests/app/tui/widgets/test_chat_panel_scroll.py @@ -4,20 +4,30 @@ from __future__ import annotations +import asyncio +import threading +from collections.abc import Iterator +from contextlib import contextmanager + import pytest from textual.geometry import Size from textual.widget import Widget from textual.widgets import Static +from chrys.app.tui.widgets.chat.messages import AgentMessage, MessageHeaderRow, UserMessage from chrys.app.tui.widgets.chat.panel import ChatPanel, _ChatBottomSpacer, _ScrollToBottomButton +from chrys.app.tui.widgets.chat.tool_call import ToolGroup from chrys.app.tui.widgets.markdown import VirtualizedMarkdown +from chrys.app.tui.widgets.markdown.blocks import MarkdownBlock +from chrys.foundation.tool_kinds import KIND_FILESYSTEM_READ from tests.app.tui.widgets._scroll_gc import install_fake_chat_panel_gc from tests.support.pilot_barrier import screen_is_settled from tests.support.tui_helpers import ( ChatPanelApp, _simulate_chat_panel_user_scroll_y, + resize_when_settled, ) -from tests.support.waiting import wait_for +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for async def _fill_eight_turns(cp: ChatPanel, pilot: object) -> None: @@ -36,14 +46,19 @@ async def _scrolled_to_bottom(cp: ChatPanel, pilot: object) -> None: await pilot.pause() +def _shows(body: VirtualizedMarkdown, text: str) -> bool: + """The markdown body was handed *text* and no parse is left to install it: its rows are the text's.""" + return body.source == text and body._pending_parse is None and not body.lock.is_locked + + async def test_chat_panel_bottom_spacer_lifecycle() -> None: """Spacer is hidden during welcome, shown after it's dismissed, and always trails content. The spacer's ``height: 1fr`` has an effective ``min-height: 1`` in Textual — leaving it visible while the welcome widget (``height: 100%``) is present produces a phantom 1-row scrollbar. After the first user message the - welcome is removed and the spacer becomes the flexible tail that gives - ``scroll_visible(top=True)`` room to scroll into. + welcome is removed and the spacer becomes the flexible tail below the + new user message. """ async with ChatPanelApp().run_test() as pilot: cp = pilot.app.query_one(ChatPanel) @@ -199,6 +214,637 @@ async def test_chat_panel_dismiss_welcome_preserves_hidden_spacer_after_first_tu assert spacer.display is False +def _record_layouts(monkeypatch: pytest.MonkeyPatch) -> list[tuple[int, float]]: + """Record the arranged virtual height and the scroll offset after each chat panel layout.""" + layouts: list[tuple[int, float]] = [] + size_updated = ChatPanel._size_updated + + def record(panel: ChatPanel, size: Size, virtual_size: Size, container_size: Size, layout: bool = True) -> bool: + changed = size_updated(panel, size, virtual_size, container_size, layout) + layouts.append((virtual_size.height, panel.scroll_y)) + return changed + + monkeypatch.setattr(ChatPanel, "_size_updated", record) + return layouts + + +async def test_content_growing_past_the_viewport_lays_out_no_spacer_row_below_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Textual gives the spacer's ``1fr`` one row once no space remains; ``arrange`` leaves it out. + + That row showed a scrollbar for a frame, and scrolled the view by it and back. + """ + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + cp = pilot.app.query_one(ChatPanel) + spacer = cp.query_one(_ChatBottomSpacer) + await cp.add_user_message("hello") + await pilot.pause() + assert spacer.display is True + layouts = _record_layouts(monkeypatch) + + await cp.mount(Static("\n".join(f"line {index}" for index in range(30)))) + await wait_for( + lambda: spacer.display is False and screen_is_settled(pilot.app, cp.screen), + pilot=pilot, + description="the spacer hid once the content filled the viewport", + ) + + content_height = cp.virtual_size.height + assert content_height > cp.size.height + assert layouts + assert all(height <= content_height and scroll_y <= cp.scroll_y for height, scroll_y in layouts), layouts + + +async def test_a_streamed_answer_keeps_the_row_its_closing_markup_gives_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Until its ``**`` closes, a line wraps that then fits again: the row stays blank until the text fills it. + + Given back, it moved a view that follows the bottom down a row and back up as the text grew. + """ + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + await _scrolled_to_bottom(cp, pilot) + cp.agent_running = True + await cp.add_answer_delta("segment-1", "start") + answer = cp.mounted_agent_messages()[-1] + body = answer.query_one(VirtualizedMarkdown) + await wait_for( + lambda: _shows(body, "start") and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the answer started", + ) + # One word short of the width: the unclosed ``**b`` wraps, the closed bold ``b`` fits. + text = "x" * (body.content_size.width - 3) + " **b" + answer.stream_update(text) + await wait_for( + lambda: ( + body.virtual_size.height == 2 and cp.scroll_y == cp.max_scroll_y and screen_is_settled(app, cp.screen) + ), + pilot=pilot, + description="the unclosed markup wrapped", + ) + height, bottom = answer.outer_size.height, cp.virtual_size.height + layouts = _record_layouts(monkeypatch) + + answer.stream_update(text + "**") + await wait_for( + lambda: body.virtual_size.height == 1 and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the closed markup fits one row", + ) + assert (answer.outer_size.height, cp.virtual_size.height) == (height, bottom) + + answer.stream_update(text + "** and more") + await wait_for( + lambda: body.virtual_size.height == 2 and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the text filled the row again", + ) + assert layouts + assert all(layout == (bottom, cp.max_scroll_y) for layout in layouts), layouts + + # Once the answer ends, it is as tall as its text. + answer.stream_update(text + "**", is_final=True) + await wait_for( + lambda: answer.outer_size.height == height - 1 and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the finished answer gave its blank row back", + ) + assert cp.scroll_y == cp.max_scroll_y + + +async def test_a_streamed_answer_gives_back_at_once_more_rows_than_a_third_of_the_view() -> None: + """A closing fence can turn many rows of source into a short diagram: held blank, they would leave the text above the view.""" + async with ChatPanelApp().run_test(size=(80, 24)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + await _scrolled_to_bottom(cp, pilot) + cp.agent_running = True + await cp.add_answer_delta("segment-1", "start") + answer = cp.mounted_agent_messages()[-1] + body = answer.query_one(VirtualizedMarkdown) + tall = "\n\n".join(f"line {index}" for index in range(12)) + answer.stream_update(tall) + await wait_for( + lambda: _shows(body, tall) and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the answer streamed tall", + ) + rows = answer.content_size.height + + answer.stream_update("short") + await wait_for( + lambda: _shows(body, "short") and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the answer's text took fewer rows", + ) + assert rows - _text_rows(answer) > cp.size.height // 3 + assert answer.content_size.height == _text_rows(answer) + assert cp.scroll_y == cp.max_scroll_y + + +def _text_rows(answer: AgentMessage) -> int: + """The rows of the answer's header and text.""" + return ( + answer.query_one(MessageHeaderRow).outer_size.height + answer.query_one(VirtualizedMarkdown).outer_size.height + ) + + +async def _following_a_tool_group(cp: ChatPanel, pilot: object, tools: int) -> ToolGroup: + """Settle a view that follows the bottom below a running group of *tools* finished tool calls.""" + app = cp.app + await _scrolled_to_bottom(cp, pilot) + cp.agent_running = True + for index in range(tools): + await cp.add_tool_start( + f"c-{index}", "read_file", KIND_FILESYSTEM_READ, f"f{index}.py", {"path": f"f{index}.py"} + ) + await cp.add_tool_result(f"c-{index}", "read_file", "line\n" * 3, 10) + await wait_for( + lambda: cp.scroll_y == cp.max_scroll_y and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the tool calls laid out at the bottom", + ) + return cp.query(ToolGroup).last() + + +async def test_an_answer_fills_the_rows_its_tool_group_gives_up_by_folding(monkeypatch: pytest.MonkeyPatch) -> None: + """The answer's start folds the tool group above it: the rows stay blank below the text until it fills them. + + Given up, they moved a view that follows the bottom down by them, and back up as the answer grew. + """ + async with ChatPanelApp().run_test(size=(80, 24)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + group = await _following_a_tool_group(cp, pilot, tools=1) + header = group.query_one("#tg-header") + header_y, bottom = header.region.y, cp.virtual_size.height + layouts = _record_layouts(monkeypatch) + + await cp.add_answer_delta("segment-1", "The answer starts") + answer = cp.mounted_agent_messages()[-1] + body = answer.query_one(VirtualizedMarkdown) + text = "The answer starts and goes on" + answer.stream_update(text) + await wait_for( + lambda: group.collapsed and _shows(body, text) and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the answer streamed below the folded group", + ) + assert _text_rows(answer) < answer.content_size.height + assert header.region.y == header_y + assert layouts + assert all(layout == (bottom, cp.max_scroll_y) for layout in layouts), layouts + + # Once the answer ends, it is as tall as its text. + answer.stream_update(text, is_final=True) + await wait_for( + lambda: answer.content_size.height == _text_rows(answer) and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the finished answer gave its blank rows back", + ) + assert cp.virtual_size.height < bottom + assert cp.scroll_y == cp.max_scroll_y + + +async def test_a_tool_group_folding_more_than_a_third_of_the_view_brings_the_content_above_into_view() -> None: + """Rows that many would leave the answer below a blank third of the view: the view moves instead.""" + async with ChatPanelApp().run_test(size=(80, 24)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + group = await _following_a_tool_group(cp, pilot, tools=3) + bottom = cp.virtual_size.height + assert group.rows_folding_gives_up() > cp.size.height // 3 + + await cp.add_answer_delta("segment-1", "The answer starts") + answer = cp.mounted_agent_messages()[-1] + body = answer.query_one(VirtualizedMarkdown) + await wait_for( + lambda: group.collapsed and _shows(body, "The answer starts") and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the answer started below the folded group", + ) + assert answer.content_size.height == _text_rows(answer) + assert cp.virtual_size.height < bottom + assert cp.scroll_y == cp.max_scroll_y + + +async def test_text_a_tool_call_follows_gives_back_the_rows_it_held_while_it_streamed() -> None: + """Settled as intermediate text, the message is as tall as its text, though nothing else about it changed.""" + async with ChatPanelApp().run_test(size=(80, 24)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + group = await _following_a_tool_group(cp, pilot, tools=1) + await cp.add_answer_delta("segment-1", "A short note") + answer = cp.mounted_agent_messages()[-1] + body = answer.query_one(VirtualizedMarkdown) + await wait_for( + lambda: group.collapsed and _shows(body, "A short note") and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the answer started below the folded group", + ) + assert _text_rows(answer) < answer.content_size.height + cp.finish_answer_segment("segment-1", completed=True) + + await cp.add_tool_start("c-next", "read_file", KIND_FILESYSTEM_READ, "next.py", {"path": "next.py"}) + await wait_for( + lambda: ( + answer.has_class("--intermediate") + and _shows(body, "A short note") + and screen_is_settled(app, cp.screen) + ), + pilot=pilot, + description="the next tool call settled the answer as intermediate text", + ) + assert answer.content_size.height == _text_rows(answer) + + +@contextmanager +def _held_parses(monkeypatch: pytest.MonkeyPatch, *sources: str) -> Iterator[dict[str, threading.Event]]: + """Hold each markdown parse of one of *sources* until its event is set; leaving the block sets them all.""" + gates = {source: threading.Event() for source in sources} + build_blocks = VirtualizedMarkdown._build_blocks + + def held_build_blocks(markdown: VirtualizedMarkdown, source: str) -> list[MarkdownBlock]: + gate = gates.get(source) + if gate is not None: + gate.wait(timeout=ENGINE_TURN_TIMEOUT) + return build_blocks(markdown, source) + + monkeypatch.setattr(VirtualizedMarkdown, "_build_blocks", held_build_blocks) + try: + yield gates + finally: + for gate in gates.values(): + gate.set() + + +async def _an_answer_below_a_folded_group(cp: ChatPanel, pilot: object) -> tuple[AgentMessage, VirtualizedMarkdown]: + """Start an answer below the tool group it folds, in a view that follows the bottom: it holds the rows the group gave up.""" + app = cp.app + group = await _following_a_tool_group(cp, pilot, tools=1) + await cp.add_answer_delta("segment-1", "The answer starts") + answer = cp.mounted_agent_messages()[-1] + body = answer.query_one(VirtualizedMarkdown) + await wait_for( + lambda: group.collapsed and _shows(body, "The answer starts") and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the answer started below the folded group", + ) + assert _text_rows(answer) < answer.content_size.height + return answer, body + + +async def test_a_finished_answer_holds_its_rows_until_its_final_text_shows(monkeypatch: pytest.MonkeyPatch) -> None: + """The body parses the final text off the event loop: given back before, the rows showed an older text's height. + + A view that follows the bottom moved down, and back up once the final text took the rows again; so it did + when the parse of the text streamed before landed after the final update. + """ + streamed = "The answer starts and" + final = "The answer starts\n\nand fills the rows" + async with ChatPanelApp().run_test(size=(80, 24)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + answer, body = await _an_answer_below_a_folded_group(cp, pilot) + held, bottom = answer.content_size.height, cp.virtual_size.height + layouts = _record_layouts(monkeypatch) + with _held_parses(monkeypatch, streamed, final) as gates: + answer.stream_update(streamed) + await wait_for( + lambda: body.source == streamed and body.lock.is_locked, + pilot=pilot, + description="the streamed text parses", + ) + answer.stream_update(final, is_final=True) + gates[streamed].set() + await wait_for( + lambda: ( + body.shown_source == streamed and body._pending_parse is None and screen_is_settled(app, cp.screen) + ), + pilot=pilot, + description="the streamed text showed while the final text parsed", + ) + assert body.lock.is_locked + assert answer.content_size.height == held + await wait_for( + lambda: _shows(body, final) and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the final text showed", + ) + assert answer.content_size.height == _text_rows(answer) == held + assert all(height == bottom for height, _scroll_y in layouts), layouts + assert cp.scroll_y == cp.max_scroll_y + + +async def test_a_retry_removes_an_answer_whose_final_text_still_parses(monkeypatch: pytest.MonkeyPatch) -> None: + """Nothing the message runs waits for its body's parse: a wait there held off its removal until the parse ended.""" + final = "The answer starts\n\nand fills the rows" + async with ChatPanelApp().run_test(size=(80, 24)) as pilot: + cp = pilot.app.query_one(ChatPanel) + answer, body = await _an_answer_below_a_folded_group(cp, pilot) + with _held_parses(monkeypatch, final): + answer.stream_update(final, is_final=True) + await wait_for(lambda: body._pending_parse is None, description="the final text parses") + removal = asyncio.create_task(cp.withdraw_answer(("segment-1",))) + await wait_for(removal.done, description="the answer was removed") + await removal + assert body.lock.is_locked + assert answer not in cp.mounted_agent_messages() + assert not answer.is_attached + + +async def test_a_final_text_that_fails_to_parse_leaves_the_text_shown_before(monkeypatch: pytest.MonkeyPatch) -> None: + """The failure is the body's: a wait for the parse in the message's handlers raised it there, and the App exited.""" + final = "The answer starts\n\nand cannot be parsed" + build_blocks = VirtualizedMarkdown._build_blocks + + def failing_build_blocks(markdown: VirtualizedMarkdown, source: str) -> list[MarkdownBlock]: + if source == final: + raise RuntimeError("the parser failed") + return build_blocks(markdown, source) + + async with ChatPanelApp().run_test(size=(80, 24)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + answer, body = await _an_answer_below_a_folded_group(cp, pilot) + monkeypatch.setattr(VirtualizedMarkdown, "_build_blocks", failing_build_blocks) + answer.stream_update(final, is_final=True) + await wait_for( + lambda: body._pending_parse is None and not body.lock.is_locked and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the final parse failed", + ) + assert app.is_running + assert body.shown_source == "The answer starts" + + +async def test_the_rows_a_folded_tool_group_gave_up_are_held_only_while_they_fit_a_third_of_the_view() -> None: + """A shorter terminal gives them back: they would leave the answer above a view of blank rows.""" + async with ChatPanelApp().run_test(size=(80, 24)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + answer, _body = await _an_answer_below_a_folded_group(cp, pilot) + held = answer.content_size.height + + await resize_when_settled(pilot, 80, 10) + await wait_for( + lambda: screen_is_settled(app, cp.screen), + pilot=pilot, + description="the chat laid out at the shorter height", + ) + assert held > cp.size.height // 3 + assert answer.content_size.height == _text_rows(answer) + + +def _in_view(cp: ChatPanel, widget: Widget) -> bool: + return bool(widget.region) and cp.region.contains_region(widget.region) + + +async def test_a_scrolled_up_view_comes_down_to_a_new_message_that_mounted_while_a_batch_held_layout() -> None: + """The footer recomposes inside ``batch_update`` as a run starts, which can cover the message's mount. + + Textual runs the screen's ``call_after_refresh`` callbacks whenever it idles in a + batch, before any layout, so a scroll deferred to one found the message unplaced and + did nothing. The failed turn it replaced left the content shorter, so growth did not + bring the view down either: the message stayed below the viewport until the reply. + """ + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + await _fill_eight_turns(cp, pilot) + await cp.add_user_message("a question that failed") + await cp.add_error("Connection error") + await pilot.pause() + _simulate_chat_panel_user_scroll_y(cp, 0) + await pilot.pause() + # A geometry read since that layout leaves Textual's map of every widget built + # without the new message, so the message reads no region until a layout places it. + assert cp.query(UserMessage).last().region + cp.agent_running = True + callbacks_ran: list[bool] = [] + with app.batch_update(): + await cp.add_user_message("new question") + cp.call_after_refresh(callbacks_ran.append, True) + await wait_for(lambda: callbacks_ran, pilot=pilot, description="refresh callbacks ran inside the batch") + message = cp.query(UserMessage).last() + assert not message.outer_size + # The composer's blinking cursor wakes the screen in the app. + app.screen.check_idle() + await wait_for( + lambda: _in_view(cp, message) and cp.scroll_y == cp.max_scroll_y and cp._anchor_released is False, + pilot=pilot, + description="the new message in view, following the bottom", + ) + + +async def test_a_tall_message_sent_again_after_a_failed_turn_shows_from_its_top() -> None: + """Removing the failed turn queues a settle re-pin to the bottom, which must not undo the reveal after it.""" + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + await _fill_eight_turns(cp, pilot) + prompt = "\n".join(f"prompt {index}" for index in range(30)) + await cp.add_user_message(prompt) + await cp.add_error("Connection error") + cp.scroll_end(immediate=True, animate=False) + await wait_for( + lambda: cp.scroll_y == cp.max_scroll_y and screen_is_settled(app, cp.screen), + pilot=pilot, + description="following the bottom past the failed turn", + ) + assert cp._anchor_released is False + cp.agent_running = True + + await cp.add_user_message(prompt) + message = cp.query(UserMessage).last() + await wait_for( + lambda: message.outer_size and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the message laid out", + ) + assert cp.scroll_y == message.virtual_region_with_margin.y < cp.max_scroll_y + assert cp._anchor_released is True + + +async def test_a_new_message_laid_out_while_it_mounted_comes_into_view_at_once() -> None: + """A message a layout placed before its reveal was asked for needs no further layout.""" + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + cp = pilot.app.query_one(ChatPanel) + await _fill_eight_turns(cp, pilot) + _simulate_chat_panel_user_scroll_y(cp, 0) + await pilot.pause() + message = UserMessage("new question") + await cp.mount_transcript_widget(message) + await wait_for( + lambda: message.outer_size and screen_is_settled(pilot.app, cp.screen), + pilot=pilot, + description="the message laid out", + ) + assert cp.scroll_y == 0 + + cp.scroll_user_message_to_top(message) + assert cp.scroll_y == cp.max_scroll_y + assert cp._anchor_released is False + + +async def test_a_new_message_taller_than_the_viewport_shows_from_its_top() -> None: + """The view holds the message's first line at the top; the reply's growth resumes following.""" + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + cp = pilot.app.query_one(ChatPanel) + await _scrolled_to_bottom(cp, pilot) + cp.agent_running = True + await cp.add_user_message("\n".join(f"line {index}" for index in range(30))) + message = cp.query(UserMessage).last() + await wait_for( + lambda: message.outer_size and screen_is_settled(pilot.app, cp.screen), + pilot=pilot, + description="the message laid out", + ) + assert cp.scroll_y == message.virtual_region_with_margin.y < cp.max_scroll_y + assert cp._anchor_released is True + + await cp.add_agent_message("\n".join(f"answer {index}" for index in range(10))) + await wait_for( + lambda: cp.scroll_y == cp.max_scroll_y and cp._anchor_released is False, + pilot=pilot, + description="the reply resumed following the bottom", + ) + + +@pytest.mark.parametrize("geometry_read_first", [False, True], ids=["layout", "geometry_read_first"]) +async def test_entries_a_replay_lands_above_a_new_message_are_no_reply_to_follow(geometry_read_first: bool) -> None: + """The view hold keeps a tall message's top in place; only growth below it resumes following.""" + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + await _scrolled_to_bottom(cp, pilot) + cp.begin_view_hold() + cp.agent_running = True + await cp.add_user_message("\n".join(f"line {index}" for index in range(30))) + message = cp.query(UserMessage).last() + await wait_for( + lambda: message.outer_size and screen_is_settled(pilot.app, cp.screen), + pilot=pilot, + description="the message laid out", + ) + assert cp.scroll_y == message.virtual_region_with_margin.y < cp.max_scroll_y + assert cp._anchor_released is True + + older = Static("\n".join(f"older {index}" for index in range(30))) + if geometry_read_first: + with app.batch_update(): + await cp.mount_replay_batch([older], before=cp.transcript_content_children()[0]) + # As a scroll-only refresh leaves the map: the read arranges the batch, the layout again. + app.screen._compositor._full_map_invalidated = True + assert older.size and not older.outer_size + app.screen.check_idle() + else: + await cp.mount_replay_batch([older], before=cp.transcript_content_children()[0]) + await wait_for( + lambda: older.outer_size.height == 30 and screen_is_settled(app, cp.screen), + pilot=pilot, + description="older entries landed above", + ) + cp.end_view_hold() + assert cp.scroll_y == message.virtual_region_with_margin.y + assert cp._anchor_released is True + + # The rows the hold counted went with the layout that placed them: growth below follows, however small. + reply = Static("\n".join(f"reply {index}" for index in range(10))) + await cp.mount(reply, before=cp.bottom_spacer()) + await wait_for( + lambda: reply.outer_size.height == 10 and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the reply grew below the message", + ) + assert cp._anchor_released is False + assert cp.scroll_y == cp.max_scroll_y + + +async def test_a_new_message_waits_for_the_layout_that_gives_the_panel_its_height() -> None: + """A geometry read can place the message before any layout gave the panel its new height. + + Textual rebuilds its map of every widget lazily, the message included, while the + panel keeps the virtual size of its last layout. Judged then, a message taller than + the viewport seemed to end the content, and the view followed the bottom past its top. + """ + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + await _fill_eight_turns(cp, pilot) + _simulate_chat_panel_user_scroll_y(cp, 0) + await pilot.pause() + cp.agent_running = True + message = UserMessage("\n".join(f"line {index}" for index in range(30))) + with app.batch_update(): + await cp.mount_transcript_widget(message) + # As a scroll-only refresh leaves the map: the next geometry read rebuilds it. + app.screen._compositor._full_map_invalidated = True + assert message.size and not message.outer_size + cp.scroll_user_message_to_top(message) + app.screen.check_idle() + await wait_for( + lambda: message.outer_size and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the message laid out", + ) + assert cp.scroll_y == message.virtual_region_with_margin.y < cp.max_scroll_y + assert cp._anchor_released is True + + +async def test_stopping_a_scroll_animation_for_a_new_message_is_no_user_scroll() -> None: + """The stop lands the view at the animation's end first, a move up the user did not make: no pause.""" + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + await _fill_eight_turns(cp, pilot) + cp.agent_running = True + message = UserMessage("\n".join(f"line {index}" for index in range(30))) + await cp.mount_transcript_widget(message) + await wait_for( + lambda: message.outer_size and screen_is_settled(app, cp.screen), + pilot=pilot, + description="the message laid out", + ) + cp.scroll_to(y=0, animate=True, duration=1.0, immediate=True) + assert app.animator.is_being_animated(cp, "scroll_y") + assert cp._auto_scroll_paused_by_user is False + + cp.scroll_user_message_to_top(message) + assert not app.animator.is_being_animated(cp, "scroll_y") + assert cp.scroll_y == message.virtual_region_with_margin.y + assert cp._auto_scroll_paused_by_user is False + + +async def test_a_new_message_stops_a_scroll_animation_under_way() -> None: + """A scroll the user started still animating when the message lands would carry the view away from it.""" + async with ChatPanelApp().run_test(size=(80, 20)) as pilot: + app = pilot.app + cp = app.query_one(ChatPanel) + await _scrolled_to_bottom(cp, pilot) + cp.agent_running = True + cp.scroll_to(y=0, animate=True, duration=1.0, immediate=True) + assert app.animator.is_being_animated(cp, "scroll_y") + + await cp.add_user_message("\n".join(f"line {index}" for index in range(30))) + message = cp.query(UserMessage).last() + await wait_for( + lambda: ( + message.outer_size + and not app.animator.is_being_animated(cp, "scroll_y") + and screen_is_settled(app, cp.screen) + ), + pilot=pilot, + description="the message laid out and no scroll animating", + ) + assert cp.scroll_y == message.virtual_region_with_margin.y < cp.max_scroll_y + assert cp._anchor_released is True + + async def test_chat_panel_arrange_defuses_short_canvas_anchor_pin() -> None: """When the arranged canvas is shorter than the container, ``arrange`` must release the bottom-anchor + reset ``scroll_y`` BEFORE the compositor reads @@ -842,7 +1488,7 @@ async def test_chat_panel_textual_anchor_contract() -> None: * ``widget._anchored`` — bool flag set by ``widget.anchor()`` * ``widget._anchor_released`` — bool toggled on user scroll-away / restored on scroll-to-bottom * ``widget._container_size`` — ``Size`` mutated inside our ``arrange()`` override so the compositor's anchor pin reads the fresh value - * ``widget.anchor()`` / ``widget.release_anchor()`` / ``scroll_visible(top=...)`` + * ``widget.anchor()`` / ``widget.release_anchor()`` All four are either private (``_``-prefixed) or depend on private state, so a Textual upgrade could rename / remove them silently. This test asserts the @@ -885,9 +1531,9 @@ async def test_chat_panel_textual_anchor_contract() -> None: cp.anchor() assert cp._anchored is True, "anchor() must set _anchored=True" - # release_anchor() flips _anchor_released True — this is what - # scroll_visible(top=True) triggers, and what watch_virtual_size - # flips back to False to re-engage auto-follow. + # release_anchor() flips _anchor_released True — this is what a user + # scroll triggers, and what watch_virtual_size flips back to False to + # re-engage auto-follow. cp._anchor_released = False cp.release_anchor() assert cp._anchor_released is True, "release_anchor() must set _anchor_released=True" diff --git a/tests/support/tui_helpers.py b/tests/support/tui_helpers.py index 0f47eca8f..b1577deae 100644 --- a/tests/support/tui_helpers.py +++ b/tests/support/tui_helpers.py @@ -19,7 +19,7 @@ from textual import on from textual.app import App, ComposeResult from textual.errors import NoWidget -from textual.events import Click +from textual.events import Click, MouseScrollDown, MouseScrollUp from textual.message import Message from textual.messages import Prune from textual.widget import Widget @@ -731,6 +731,13 @@ def make_click( ) +def make_wheel(widget: Widget, *, up: bool) -> MouseScrollUp | MouseScrollDown: + """Build one mouse-wheel notch over *widget*, scrolling it up or down.""" + return (MouseScrollUp if up else MouseScrollDown)( + widget, x=1, y=1, delta_x=0, delta_y=-1 if up else 1, button=0, shift=False, meta=False, ctrl=False + ) + + def click_widget(widget: Widget) -> Click: """Deliver a click at the widget's origin straight to its ``on_click`` and return the event.""" event = make_click(widget)