diff --git a/docs/en/guides/configuration/chat-options.md b/docs/en/guides/configuration/chat-options.md
index 009feffa..cf564b18 100644
--- a/docs/en/guides/configuration/chat-options.md
+++ b/docs/en/guides/configuration/chat-options.md
@@ -132,12 +132,13 @@ Provider "Anthropic", base URL `https://api.anthropic.com`. Don't add `/v1` to t
| What you want | Key | Value |
| --- | --- | --- |
| Prompt caching | `extra_body` | `{"cache_control": {"type": "ephemeral"}}` |
-| Adaptive thinking | `thinking` | `{"type": "adaptive"}` |
+| Adaptive thinking with readable summaries | `thinking` | `{"type": "adaptive", "display": "summarized"}` |
| Thinking with a fixed budget (older models) | `thinking` | `{"type": "enabled", "budget_tokens": 8000}` |
| Effort | `output_config` | `{"effort": "medium"}` |
| Interleaved thinking on older models at another address | `additional_beta_flags` | `["interleaved-thinking-2025-05-14"]` |
- Use `adaptive` only on models that support it; recent models reject the older `enabled`/`budget_tokens` form. For ordinary fixed-budget thinking, set "Max Output Tokens" higher than `budget_tokens` (manual interleaved thinking has different budget rules).
+- `display: "summarized"` asks the service to return a readable summary of the model's thinking. Newer Claude models, including Opus 5.5 and Fable 5.1, hide this text by default. Add `"display": "summarized"` to your existing `thinking` object to request it; iCode's "Show model thinking" setting controls whether returned text is shown, but does not request it from the service. To hide the summary at the API level, use `"display": "omitted"`; this leaves thinking enabled and does not reduce its token charges.
- Newer Claude models reject non-default `temperature`, `top_p`, and `top_k` values, so leave them unset.
- `additional_beta_flags` and `betas` add beta flags to the request, as does an `anthropic-beta` header in "HTTP Extra Headers". iCode sends them all, after its own flags, in one `anthropic-beta` header. An `anthropic-beta` key in an `extra_headers` row replaces all of these flags, iCode's own included; iCode then adds only the flags the thinking settings need.
- With fixed-budget thinking at Anthropic's own address, iCode adds the interleaved-thinking flag itself; see [Claude thinking settings](./models.md#claude-thinking-settings). At another address, add it yourself as in the table above. Adaptive thinking already interleaves and needs no flag. Interleaving depends on the exact model, and accepting a beta flag does not mean it takes effect.
diff --git a/docs/en/guides/configuration/models.md b/docs/en/guides/configuration/models.md
index ba094021..e1dc1a78 100644
--- a/docs/en/guides/configuration/models.md
+++ b/docs/en/guides/configuration/models.md
@@ -72,7 +72,9 @@ If you edit the model profile currently in use, the updated configuration applie
### Claude thinking settings
-Claude Opus 5.5, Fable 5.1 and Sonnet 5.5 tie the thinking they return to the conversation before it. When that earlier conversation changes, for example after iCode compacts the context, the model service may refuse to read that thinking back. When one of these models uses adaptive thinking (`"thinking": {"type": "adaptive"}` in ["Chat Options"](./chat-options.md#anthropic)) at Anthropic's own address, iCode asks the service to leave out thinking that no longer matches instead of refusing the request. The model then sees less of its earlier reasoning, but the request goes through. On accounts where the service would not otherwise check this, the request makes it check, so such thinking is left out there too.
+To request adaptive thinking with readable summaries, add a `thinking` row with the value `{"type": "adaptive", "display": "summarized"}` in ["Chat Options"](./chat-options.md#anthropic).
+
+Claude Opus 5.5, Fable 5.1 and Sonnet 5.5 tie the thinking they return to the conversation before it. When that earlier conversation changes, for example after iCode compacts the context, the model service may refuse to read that thinking back. When one of these models uses adaptive thinking at Anthropic's own address, iCode asks the service to leave out thinking that no longer matches instead of refusing the request. The model then sees less of its earlier reasoning, but the request goes through. On accounts where the service would not otherwise check this, the request makes it check, so such thinking is left out there too.
To change this, open the profile's file in the `models` folder of the iCode configuration directory (`~/.chrys/models/` on macOS and Linux, `%APPDATA%\chrys\models\` on Windows; the file's `name:` line shows the profile name), add a `thinking_block_binding` line with one of these values, and restart iCode:
diff --git a/docs/en/guides/configuration/settings.md b/docs/en/guides/configuration/settings.md
index e065dd84..d3a6bf97 100644
--- a/docs/en/guides/configuration/settings.md
+++ b/docs/en/guides/configuration/settings.md
@@ -55,6 +55,7 @@ The "Takes effect" column in the following tables describes each setting's usual
| Theme | Changes the TUI theme. To adjust colors or create a custom theme, see [Customize themes](themes.md). | Immediately |
| UI Language | Changes only iCode interface text, not conversation content, the model's output language, or existing records. You can also switch the interface language with `/language`. | Immediately |
| Show model thinking | Shows the model's thinking above its reply as it arrives, when the model service returns it as readable text. Thinking the service returns only in encrypted form is never shown. Thinking already in the conversation stays as it is when you change this. | New replies and sessions you open afterwards |
+| Automatically fold completed turns | Enabled by default (`ui.chat.fold_finished_turns`). After a successful reply, folds thinking, tool calls and intermediate replies under **Made N tool calls**, or **Activity** when there were no tools or the turn is in compressed history. User messages, including mid-turn additions, and the final reply remain visible. Click the header to toggle one section, or type `/fold` to collapse or expand activity sections, tool groups and thinking with the keyboard. Errors, interruptions and the last turn recovered after a crash stay visible; a turn without a visible final reply is not automatically folded. Existing folds keep your choice. | Future completed turns and sessions you open afterwards |
#### Input
diff --git a/docs/en/guides/daily-use/sessions.md b/docs/en/guides/daily-use/sessions.md
index e9823702..d403a359 100644
--- a/docs/en/guides/daily-use/sessions.md
+++ b/docs/en/guides/daily-use/sessions.md
@@ -57,6 +57,10 @@ When you resume a long conversation, iCode shows its most recent part first so y
A resumed conversation shows the model's saved thinking folded under **Thinking** above the reply it led to; click **Thinking** to unfold it. If a reply was interrupted or failed, the thinking shown for the request that was cut off is not saved, so it does not appear. To hide thinking, see [Show model thinking](../configuration/settings.md#appearance).
+Successful turns also fold their intermediate activity by default. Each mid-turn user addition stays visible and starts a separate activity section. Click **Made N tool calls** or **Activity** to inspect that section while keeping its inner details in their current state. A lone thinking block uses its own header. For keyboard access, `/fold` toggles activity sections, tool groups and thinking together: it collapses them if any are open, and expands them otherwise. Thinking that is still being written and controls waiting for your input stay open. Copy commands still include intermediate replies and keep their original message counts. Turn off **Automatically fold completed turns** in [Settings](../configuration/settings.md#appearance) to keep future completed turns and subsequently opened sessions expanded. The last turn recovered from a crash stays open.
+
+Clicking an activity header keeps focus in the input field. A restored turn waiting for sub-agents stays open. Compressed history shows only some tool details, so these sections are all labeled **Activity**.
+
If a resumed conversation continues on a different model service than before, some earlier reasoning may be left out; see [Switch the current model profile](../configuration/models.md#switch-the-current-model-profile).
## Delete old sessions
diff --git a/docs/en/reference/settings.md b/docs/en/reference/settings.md
index 538bd799..fca994c0 100644
--- a/docs/en/reference/settings.md
+++ b/docs/en/reference/settings.md
@@ -40,6 +40,7 @@ In the tables, “None” means there is no corresponding environment variable,
| `ui.editor.keymap` | `CHRYS_EDITOR_KEYMAP` | `standard` | String; `standard`, `emacs`, or `vim` selects the input editor's key bindings |
| `ui.chat.tool_groups_expanded` | None | `false` | Boolean; whether newly displayed tool groups expand their details by default |
| `ui.chat.show_thinking` | None | `true` | Boolean; whether the TUI chat shows the model's thinking above its reply, when the model service returns it as readable text. Thinking the service returns only in encrypted form is never shown |
+| `ui.chat.fold_finished_turns` | None | `true` | Boolean; automatically fold intermediate activity after successful replies. Applies to future completed turns and subsequently opened sessions; existing sections keep their state. User messages and final replies remain visible |
| `ui.chat.file_snapshot_inline_chars` | `CHRYS_TUI_FILE_SNAPSHOT_INLINE_CHARS` | `131072` | Integer; limits the before-and-after file content loaded directly with a file edit card, measured by their combined byte size. Within the limit, content loads with the card and the header shows the exact numbers of added and deleted lines. Above the limit, if snapshot references are available, content is read only when the diff is expanded, and the header does not show exact line counts. `0` or a negative value sets the limit to zero; inline content may still be retained when no snapshot references are available. This setting controls only how the card loads content; `mutations.snapshot.max_file_mb` and `mutations.snapshot.skip_binary` determine whether file content is backed up |
| `history.prompt.enabled` | `CHRYS_HISTORY_DISABLE` | `true` | Boolean; save and read input history across sessions. Disabling it does not delete existing records. The environment variable has the opposite meaning: only the exact value `1` disables history; other values do not |
| `app.update_check` | `CHRYS_UPDATE_CHECK` | `true` | Boolean; once a day, when the TUI starts, look up the newest iCode version and, when it is newer, show it on the welcome screen with the command that upgrades iCode. The request carries none of your personal information or data. `icode run`, `icode acp` and `icode serve` never check |
diff --git a/docs/en/reference/tui-slash-commands.md b/docs/en/reference/tui-slash-commands.md
index dd064b06..07040d77 100644
--- a/docs/en/reference/tui-slash-commands.md
+++ b/docs/en/reference/tui-slash-commands.md
@@ -47,7 +47,7 @@ In this table:
| `/language [locale]` | — | Switch the interface language. See [Appearance](../guides/configuration/settings.md#appearance). | `locale`: switch directly to the specified language. Supported values are `system` (follow the system), `en` (English), and `zh-Hans` (Simplified Chinese). With no argument, press **Space** or **Enter** to show available values. |
| `/chdir [path]` | `/cd` | Switch the working directory for the current session. See [Switch working directories in a session](../guides/daily-use/workspaces.md#change-the-working-directory-during-a-session). | `path`: an existing target directory, specified as an absolute path or a path relative to the current working directory. Omit it to open the directory picker. |
| `/copy [N]`
`/copy agent [N\|all]`
`/copy user [N\|all]`
`/copy all` | — | Copy conversation content to the clipboard. | No argument: copy the most recent agent message (text the agent shows between tool calls counts as a separate message).
`N`: the number of recent agent messages to copy; must be a positive integer. `/copy N` is shorthand for `/copy agent N`.
`agent [N\|all]`: copy the most recent `N` agent messages or all agent messages. If no count is given, copy the most recent one.
`user [N\|all]`: copy the most recent `N` user messages or all user messages. If no count is given, copy the most recent one.
`all`: copy the full conversation history. |
-| `/fold` | — | Collapse or expand all tool call groups and **Thinking** blocks in the current conversation. Collapsed ones show only their headers, making long sessions easier to browse. Thinking that is still being written stays open. | — |
+| `/fold` | — | Toggle activity sections, tool call groups and **Thinking** blocks together. If any are open, collapse them all; otherwise expand them all, including details inside activity sections. Thinking that is still being written and controls waiting for your input stay open. | — |
| `/diff` | — | Open the diff view to see files added, modified, and deleted during the current session line by line, so you can review the agent's changes. Files inside the current working directory appear in the directory tree; files outside it appear under **External**, including while a turn is running. | — |
| `/rollback`
`/rollback `
`/rollback to ` | — | Roll back the conversation while keeping current file changes, or roll back the conversation and restore the file changes that iCode can recover. Rollback also stops the [background commands](../guides/configuration/tools.md#commands-that-keep-running-in-the-background) that the discarded turns started. Rollback cannot be undone. | No argument: open the rollback picker, with the most recent available target selected by default. The file restoration option in the lower-left corner of the "Rollback" dialog (its label includes the file count) controls whether file changes are also restored.
`N`: immediately discard the most recent `N` turns; must be a positive integer. A turn starts with a user submission and ends when that agent run finishes.
`to N`: keep turns 1 through `N` and discard later turns. `N` must be a nonnegative integer; `0` returns to the start of the session.
Both `/rollback N` and `/rollback to N` run immediately (iCode asks first only when they would stop running background commands) and restore the file changes from the discarded turns that iCode can recover. |
| `/approval [manual\|auto\|bypass]` | — | Switch the current approval mode. The chosen mode is also saved as the default for the next launch (`bypass` is saved as `auto`). See [Configure approval modes](../guides/configuration/approval.md). | `manual`: the user decides on calls that require approval.
`auto`: the approval judge model decides; calls it cannot confirm are safe still go to the user.
`bypass`: skip approval for tool calls.
No argument: press **Space** or **Enter** to show approval mode suggestions. |
diff --git a/docs/zh-Hans/guides/configuration/chat-options.md b/docs/zh-Hans/guides/configuration/chat-options.md
index c035ef94..d8bbd6c4 100644
--- a/docs/zh-Hans/guides/configuration/chat-options.md
+++ b/docs/zh-Hans/guides/configuration/chat-options.md
@@ -132,12 +132,13 @@ GPT-5.6 及之后的模型使用 `prompt_cache_options`,有效期为 `30m`,
| 用途 | 字段名 | 值 |
| --- | --- | --- |
| 提示词缓存 | `extra_body` | `{"cache_control": {"type": "ephemeral"}}` |
-| 自适应思考 | `thinking` | `{"type": "adaptive"}` |
+| 自适应思考并返回可读摘要 | `thinking` | `{"type": "adaptive", "display": "summarized"}` |
| 固定预算的思考(较早的模型) | `thinking` | `{"type": "enabled", "budget_tokens": 8000}` |
| 投入程度(effort) | `output_config` | `{"effort": "medium"}` |
| 其他地址上较早模型的交错思考 | `additional_beta_flags` | `["interleaved-thinking-2025-05-14"]` |
- 只有支持自适应思考的模型才能使用 `adaptive`;较新的模型会拒绝旧的 `enabled` / `budget_tokens` 形式。普通固定预算思考要求“最大输出词元数”大于 `budget_tokens`,手动交错思考的预算规则有所不同。
+- `display: "summarized"` 用于请求服务返回可读的思考摘要。较新的 Claude 模型(包括 Opus 5.5 和 Fable 5.1)默认隐藏这部分文字。请在已有的 `thinking` 对象中加入 `"display": "summarized"`;iCode 的“显示模型思考”设置控制是否显示返回的文字,不会向服务请求摘要。如需让接口隐藏摘要,可改为 `"display": "omitted"`;这不会关闭思考,也不会减少思考词元的费用。
- 较新的 Claude 模型会拒绝非默认的 `temperature`、`top_p` 和 `top_k` 值,请不要设置。
- `additional_beta_flags` 和 `betas` 用于给请求增加 Beta 标志,“HTTP 附加请求头”中的 `anthropic-beta` 也一样。iCode 会把它们排在自己的标志之后,合并到同一个 `anthropic-beta` 请求头中发送。`extra_headers` 行中的 `anthropic-beta` 会替换所有这些标志,包括 iCode 自己的标志;此时 iCode 只补上思考设置所需的标志。
- 在 Anthropic 官方地址上使用固定预算的思考时,iCode 会自动添加交错思考的标志,详见 [Claude 思考设置](./models.md#claude-思考设置)。在其他地址上,请按上表自行添加。自适应思考已自动支持交错思考,无需该标志。交错思考的支持情况因模型而异,服务接受某个 Beta 标志并不代表它实际生效。
diff --git a/docs/zh-Hans/guides/configuration/models.md b/docs/zh-Hans/guides/configuration/models.md
index 945dd1cc..34da5c89 100644
--- a/docs/zh-Hans/guides/configuration/models.md
+++ b/docs/zh-Hans/guides/configuration/models.md
@@ -72,7 +72,9 @@
### Claude 思考设置
-Claude Opus 5.5、Fable 5.1 和 Sonnet 5.5 会把返回的思考内容与它之前的对话绑定。之前的对话发生变化后(例如 iCode 压缩了上下文),模型服务可能拒绝读回这些思考内容。当这些模型在 Anthropic 官方地址上使用自适应思考(在 [“Chat 选项”](./chat-options.md#anthropic)中设置 `"thinking": {"type": "adaptive"}`)时,iCode 会请服务略去对不上的思考内容,而不是拒绝请求。这样模型看到的早先推理会变少,但请求可以正常完成。对于服务原本不做这项检查的账号,这项要求也会让服务开始检查,对不上的思考内容同样会被略去。
+如需使用自适应思考并返回可读摘要,请在 [“Chat 选项”](./chat-options.md#anthropic)中添加 `thinking` 行,值设为 `{"type": "adaptive", "display": "summarized"}`。
+
+Claude Opus 5.5、Fable 5.1 和 Sonnet 5.5 会把返回的思考内容与它之前的对话绑定。之前的对话发生变化后(例如 iCode 压缩了上下文),模型服务可能拒绝读回这些思考内容。当这些模型在 Anthropic 官方地址上使用自适应思考时,iCode 会请服务略去对不上的思考内容,而不是拒绝请求。这样模型看到的早先推理会变少,但请求可以正常完成。对于服务原本不做这项检查的账号,这项要求也会让服务开始检查,对不上的思考内容同样会被略去。
如需更改,请在 iCode 配置目录的 `models` 文件夹中(macOS 和 Linux 为 `~/.chrys/models/`,Windows 为 `%APPDATA%\chrys\models\`;文件中的 `name:` 一行即配置名称)打开该配置的文件,添加一行 `thinking_block_binding`,取下列值之一,然后重启 iCode:
diff --git a/docs/zh-Hans/guides/configuration/settings.md b/docs/zh-Hans/guides/configuration/settings.md
index 3acfadcb..0c0ce13a 100644
--- a/docs/zh-Hans/guides/configuration/settings.md
+++ b/docs/zh-Hans/guides/configuration/settings.md
@@ -55,6 +55,7 @@ iCode 设置控制界面外观、默认智能体、会话、工具、通知和
| 主题 | 切换 TUI 使用的主题。需要调整配色或创建自定义主题时,请参阅[自定义主题](themes.md)。 | 立即 |
| 界面显示语言 | 只改变 iCode 界面文案,不改变会话内容、模型输出语言或已有记录。也可输入 `/language` 切换界面显示语言。| 立即 |
| 显示模型思考 | 模型服务以可读文本返回模型的思考时,在回复上方实时显示。服务只以加密形式返回的思考不会显示。修改后,对话中已有的思考保持不变。 | 新回复及之后打开的会话 |
+| 自动折叠已完成轮次 | 默认开启(`ui.chat.fold_finished_turns`)。成功回复后,将思考、工具调用和中间回复收起为**调用了 N 次工具**;没有工具或轮次属于压缩历史时使用**执行过程**。用户消息、中途补充和最终回复始终可见。点击标题可切换单个片段;只用键盘时,输入 `/fold` 可统一收起或展开过程块、工具组和思考。错误、中断以及崩溃恢复的最后一轮保持可见;没有可见最终回复的轮次不自动折叠。已有折叠块保留你的选择。 | 之后完成的轮次及之后打开的会话 |
#### 输入
diff --git a/docs/zh-Hans/guides/daily-use/sessions.md b/docs/zh-Hans/guides/daily-use/sessions.md
index ff226768..eb57c842 100644
--- a/docs/zh-Hans/guides/daily-use/sessions.md
+++ b/docs/zh-Hans/guides/daily-use/sessions.md
@@ -57,6 +57,10 @@
恢复的对话会在回复上方折叠显示保存下来的模型思考(**思考**),点击**思考**可将其展开。回复被中断或失败时,被打断的那次请求中显示过的思考不会保存,因此不会出现。如需隐藏思考,请参阅[显示模型思考](../configuration/settings.md#外观)。
+成功完成的轮次默认还会收起中间执行过程。中途补充的用户消息始终可见,并将过程分成独立的片段。点击**调用了 N 次工具**或**执行过程**可查看对应片段,内层详情保持原来的开合状态。只有一个思考块时沿用思考自己的标题。只用键盘时,可用 `/fold` 统一切换过程块、工具组和思考:有展开的内容就全部收起,否则全部展开;仍在写出的思考和等待你输入的控件除外。复制命令仍包含中间回复,消息计数保持原有规则。如需让之后完成的轮次及之后打开的会话保持展开,可在[设置](../configuration/settings.md#外观)中关闭**自动折叠已完成轮次**。崩溃恢复的最后一轮保持展开。
+
+点击过程标题不会移走输入框焦点。恢复后仍在等待子 Agent 的轮次保持展开。压缩历史只显示部分工具详情,所以这些片段统一显示**执行过程**。
+
如果恢复的对话改用了与之前不同的模型服务,部分早先的推理内容可能会被略去,详见[切换当前模型配置](../configuration/models.md#切换当前模型配置)。
## 删除旧会话
diff --git a/docs/zh-Hans/reference/settings.md b/docs/zh-Hans/reference/settings.md
index 4ffff8c8..b5fd6a59 100644
--- a/docs/zh-Hans/reference/settings.md
+++ b/docs/zh-Hans/reference/settings.md
@@ -40,6 +40,7 @@ llm:
| `ui.editor.keymap` | `CHRYS_EDITOR_KEYMAP` | `standard` | 字符串;`standard`、`emacs`、`vim`,选择输入编辑器的按键模式 |
| `ui.chat.tool_groups_expanded` | 无 | `false` | 布尔值;新出现的工具组是否默认展开详情 |
| `ui.chat.show_thinking` | 无 | `true` | 布尔值;模型服务以可读文本返回模型的思考时,TUI 对话是否在回复上方显示它。服务只以加密形式返回的思考不会显示 |
+| `ui.chat.fold_finished_turns` | 无 | `true` | 布尔值;成功回复后自动折叠中间执行过程。适用于之后完成的轮次及之后打开的会话,已有过程块保持当前状态。用户消息和最终回复始终可见 |
| `ui.chat.file_snapshot_inline_chars` | `CHRYS_TUI_FILE_SNAPSHOT_INLINE_CHARS` | `131072` | 整数;控制文件编辑卡片直接载入的改动前后内容上限,按两者字节数合计判断。不超过上限时随卡片载入,标题显示具体的增删行数;超过上限且有可用快照引用时,展开差异才读取,标题不显示具体行数。`0` 或负数将上限设为零;没有可用快照引用时仍可能保留内联内容。此设置只控制卡片的内容载入方式;是否备份文件内容由 `mutations.snapshot.max_file_mb` 和 `mutations.snapshot.skip_binary` 决定 |
| `history.prompt.enabled` | `CHRYS_HISTORY_DISABLE` | `true` | 布尔值;跨会话保存和读取输入历史,关闭不会删除已有记录。环境变量含义相反:只有精确的 `1` 禁用历史,其他值不禁用 |
| `app.update_check` | `CHRYS_UPDATE_CHECK` | `true` | 布尔值;TUI 每天启动时最多查询一次 iCode 的最新版本,有新版本时在欢迎页显示新版本号和升级 iCode 的命令。查询请求不包含你的任何个人信息或数据。`icode run`、`icode acp` 和 `icode serve` 不会查询 |
diff --git a/docs/zh-Hans/reference/tui-slash-commands.md b/docs/zh-Hans/reference/tui-slash-commands.md
index 928948a4..6d153b96 100644
--- a/docs/zh-Hans/reference/tui-slash-commands.md
+++ b/docs/zh-Hans/reference/tui-slash-commands.md
@@ -47,7 +47,7 @@
| `/language [locale]` | — | 切换界面显示语言。详见[外观设置](../guides/configuration/settings.md#外观)。 | `locale`:直接切换到指定语言。当前支持 `system`(跟随系统)、`en`(英文)和 `zh-Hans`(简体中文)。省略参数时,按空格键或 **Enter** 显示可用值。 |
| `/chdir [path]` | `/cd` | 切换当前会话的工作目录。详见[在会话中切换工作目录](../guides/daily-use/workspaces.md#在会话中切换工作目录)。 | `path`:已存在的目标目录,可以使用绝对路径或相对于当前工作目录的路径。省略时打开目录选择器。 |
| `/copy [N]`
`/copy agent [N\|all]`
`/copy user [N\|all]`
`/copy all` | — | 将对话内容复制到剪贴板。 | 省略参数:复制最近一条智能体消息(智能体在工具调用之间显示的文本也算作单独一条)。
`N`:要复制的最近智能体消息数,必须是正整数;`/copy N` 是 `/copy agent N` 的简写。
`agent [N\|all]`:复制最近 `N` 条或全部智能体消息;省略数量时复制最近一条。
`user [N\|all]`:复制最近 `N` 条或全部用户消息;省略数量时复制最近一条。
`all`:复制完整对话记录。 |
-| `/fold` | — | 折叠或展开当前对话中的全部工具调用组和**思考**。折叠后只显示标题,便于查看较长的会话。仍在写出的思考保持展开。 | — |
+| `/fold` | — | 统一切换执行过程块、工具调用组和**思考**。有展开的内容就全部收起,否则全部展开,包括过程块里的详情;仍在写出的思考和等待你输入的控件除外。 | — |
| `/diff` | — | 打开差异视图,逐行显示当前会话产生的新增、修改和删除文件,便于检查智能体所做的更改。当前工作目录内的文件显示在目录树中,目录外的文件显示在 **External(外部)** 分组中;正在运行的轮次也按此规则显示。 | — |
| `/rollback`
`/rollback `
`/rollback to ` | — | 回滚分为两种:仅回滚对话并保留当前文件变更;同时回滚对话,并恢复 iCode 能够恢复的文件变更。回滚还会停止被丢弃轮次启动的[后台命令](../guides/configuration/tools.md#在后台继续运行的命令)。回滚结果无法撤销。 | 省略参数:打开回滚选择器,默认选择最近的可回滚目标。通过“回滚”窗口左下角的文件还原选项(名称中会显示文件数量),可以决定是否同时恢复文件变更。
`N`:立即丢弃最近 `N` 轮,必须是正整数。从一次用户提交开始,到该次智能体运行结束为一轮。
`to N`:保留第 1 轮至第 `N` 轮并丢弃之后的轮次;`N` 必须是非负整数,`0` 表示回到会话开始处。
`/rollback N` 和 `/rollback to N` 都会立即执行(只有会停止正在运行的后台命令时才会先询问),并恢复被丢弃轮次中 iCode 能够恢复的文件变更。 |
| `/approval [manual\|auto\|bypass]` | — | 切换当前审批模式,所选模式也会保存为下次启动的默认模式(`bypass` 保存为 `auto`)。详见[配置审批模式](../guides/configuration/approval.md)。 | `manual`:需要审批的调用由用户决定。
`auto`:由审批裁判模型判断;无法确认安全的调用仍交由用户决定。
`bypass`:跳过工具调用审批。
省略参数:按空格键或 **Enter** 显示审批模式建议列表。 |
diff --git a/locales/chrys.pot b/locales/chrys.pot
index 87be413b..8bc8e0b5 100644
--- a/locales/chrys.pot
+++ b/locales/chrys.pot
@@ -1324,6 +1324,12 @@ msgstr ""
msgid "settings.ui.chat.file_snapshot_inline_chars.label"
msgstr ""
+#. English: Automatically fold completed turns
+#. chrys-meta={"fingerprint":"0dbfa66db6994053f1b028a5a8be48037876a9958c58f1ed65c1162649a2d074","multiline":false,"placeholders":[]}
+#: src/chrys/foundation/config/settings.py
+msgid "settings.ui.chat.fold_finished_turns.label"
+msgstr ""
+
#. English: Show model thinking
#. chrys-meta={"fingerprint":"009db1db6b24ef8953ec841eb4ac172e471351f342f9207b47e638425f480839","multiline":false,"placeholders":[]}
#: src/chrys/foundation/config/settings.py
@@ -4777,6 +4783,21 @@ msgstr ""
msgid "tui.chat.copy_user_message_tooltip"
msgstr ""
+#. English: Activity
+#. chrys-meta={"fingerprint":"adc05a800b4e9d0dfccd9a893e52c89939fe10aab58b7d973b7b2897b27291b2","multiline":false,"placeholders":[]}
+#: src/chrys/app/tui/widgets/chat/process_block.py
+msgid "tui.chat.process.activity"
+msgstr ""
+
+#. English: Made {count} tool call
+#. English-plural: Made {count} tool calls
+#. chrys-meta={"fingerprint":"41af2a8a84334c679a6c341d4c0db0ddef12a52423d2958f299e45e6806afcb0","multiline":false,"placeholders":[]}
+#: src/chrys/app/tui/widgets/chat/process_block.py
+msgid "tui.chat.process.tool_calls"
+msgid_plural "tui.chat.process.tool_calls#plural"
+msgstr[0] ""
+msgstr[1] ""
+
#. English: {message} Retrying in {delay_seconds}s
#. ({attempt}/{max_attempts})...
#. chrys-meta={"fingerprint":"2a539b6b7df6647ca02c8adcddd628c1096c2c87869f07ce542cec434098d512","multiline":true,"placeholders":["attempt","delay_seconds","max_attempts","message"]}
@@ -5030,8 +5051,8 @@ msgstr ""
msgid "tui.commands.description.exit"
msgstr ""
-#. English: Toggle collapse on all tool groups and thinking
-#. chrys-meta={"fingerprint":"0a350fec3d8fb98f2e2079d424e9db64a7d92cb018ef1b2960ad05aa44cd0691","multiline":false,"placeholders":[]}
+#. English: Toggle collapse on activity, tool groups and thinking
+#. chrys-meta={"fingerprint":"0010b6235021bc3af66568d5a6d1f642d73830b454299291955fc019c2bc82a9","multiline":false,"placeholders":[]}
#: src/chrys/app/tui/screens/main/commands.py
msgid "tui.commands.description.fold"
msgstr ""
@@ -7231,11 +7252,13 @@ msgstr ""
msgid "tui.man.exit.body"
msgstr ""
-#. English: Toggle collapse state on all tool groups and thinking in the
-#. current conversation view. Collapsed items show only a summary header.
-#. Thinking that is still being written stays open. Useful for decluttering the
-#. view when reviewing long sessions.
-#. chrys-meta={"fingerprint":"483797533da9798bf665088afe22021ed15359b62af8166f236e619781309453","multiline":true,"placeholders":[]}
+#. English: Toggle collapse state on activity sections, tool groups and
+#. thinking in the current conversation view. If any are open, collapse them
+#. all; otherwise expand them all. Thinking that is still being written and
+#. controls waiting for your input stay open. Click an activity header to
+#. toggle just that section. Useful for decluttering the view when reviewing
+#. long sessions.
+#. chrys-meta={"fingerprint":"9c42fb5c1abaf8d9c6288243716520774e7e69f68320962187da68b601a3a4fe","multiline":true,"placeholders":[]}
#: src/chrys/app/tui/screens/main/commands.py
msgid "tui.man.fold.body"
msgstr ""
@@ -10345,6 +10368,13 @@ msgstr ""
msgid "tui.settings.hint.tools.web_search.private_origins"
msgstr ""
+#. English: Fold activity after successful replies. Applies to future completed
+#. turns and sessions opened afterwards; existing folds keep their state.
+#. chrys-meta={"fingerprint":"dd6f495b926ae2dd26c75f387f91bd697b778178dc885330561ea6187c6faa50","multiline":false,"placeholders":[]}
+#: src/chrys/app/tui/screens/settings/layout.py
+msgid "tui.settings.hint.ui.chat.fold_finished_turns"
+msgstr ""
+
#. English: Show the model's thinking in the chat as it arrives, when the model
#. service returns it as readable text. Applies to new replies and to sessions
#. opened afterwards.
diff --git a/locales/zh-Hans/LC_MESSAGES/chrys.po b/locales/zh-Hans/LC_MESSAGES/chrys.po
index 309b4964..d99d40a9 100644
--- a/locales/zh-Hans/LC_MESSAGES/chrys.po
+++ b/locales/zh-Hans/LC_MESSAGES/chrys.po
@@ -1348,6 +1348,12 @@ msgstr "仅在自动审查标记后弹出审批对话框"
msgid "settings.ui.chat.file_snapshot_inline_chars.label"
msgstr "内联文件快照上限(字符)"
+#. English: Automatically fold completed turns
+#. chrys-meta={"fingerprint":"0dbfa66db6994053f1b028a5a8be48037876a9958c58f1ed65c1162649a2d074","multiline":false,"placeholders":[]}
+#: src/chrys/foundation/config/settings.py
+msgid "settings.ui.chat.fold_finished_turns.label"
+msgstr "自动折叠已完成轮次"
+
#. English: Show model thinking
#. chrys-meta={"fingerprint":"009db1db6b24ef8953ec841eb4ac172e471351f342f9207b47e638425f480839","multiline":false,"placeholders":[]}
#: src/chrys/foundation/config/settings.py
@@ -4822,6 +4828,20 @@ msgstr "复制"
msgid "tui.chat.copy_user_message_tooltip"
msgstr "复制你的原始消息"
+#. English: Activity
+#. chrys-meta={"fingerprint":"adc05a800b4e9d0dfccd9a893e52c89939fe10aab58b7d973b7b2897b27291b2","multiline":false,"placeholders":[]}
+#: src/chrys/app/tui/widgets/chat/process_block.py
+msgid "tui.chat.process.activity"
+msgstr "执行过程"
+
+#. English: Made {count} tool call
+#. English-plural: Made {count} tool calls
+#. chrys-meta={"fingerprint":"41af2a8a84334c679a6c341d4c0db0ddef12a52423d2958f299e45e6806afcb0","multiline":false,"placeholders":[]}
+#: src/chrys/app/tui/widgets/chat/process_block.py
+msgid "tui.chat.process.tool_calls"
+msgid_plural "tui.chat.process.tool_calls#plural"
+msgstr[0] "调用了 {count} 次工具"
+
#. English: {message} Retrying in {delay_seconds}s
#. ({attempt}/{max_attempts})...
#. chrys-meta={"fingerprint":"2a539b6b7df6647ca02c8adcddd628c1096c2c87869f07ce542cec434098d512","multiline":true,"placeholders":["attempt","delay_seconds","max_attempts","message"]}
@@ -5077,11 +5097,11 @@ msgstr "查看当前会话的文件更改"
msgid "tui.commands.description.exit"
msgstr "退出 {app}"
-#. English: Toggle collapse on all tool groups and thinking
-#. chrys-meta={"fingerprint":"0a350fec3d8fb98f2e2079d424e9db64a7d92cb018ef1b2960ad05aa44cd0691","multiline":false,"placeholders":[]}
+#. English: Toggle collapse on activity, tool groups and thinking
+#. chrys-meta={"fingerprint":"0010b6235021bc3af66568d5a6d1f642d73830b454299291955fc019c2bc82a9","multiline":false,"placeholders":[]}
#: src/chrys/app/tui/screens/main/commands.py
msgid "tui.commands.description.fold"
-msgstr "切换所有工具组和思考的折叠状态"
+msgstr "切换执行过程、工具组和思考的折叠状态"
#. English: Fork the current session
#. chrys-meta={"fingerprint":"c4b9cc658e7eb5d8d395596af4361d2795cca4a52aca435c870a7eeae0aaeb50","multiline":false,"placeholders":[]}
@@ -7328,18 +7348,21 @@ msgstr ""
"\n"
"如果智能体正在运行,退出前会提示你进行确认。"
-#. English: Toggle collapse state on all tool groups and thinking in the
-#. current conversation view. Collapsed items show only a summary header.
-#. Thinking that is still being written stays open. Useful for decluttering the
-#. view when reviewing long sessions.
-#. chrys-meta={"fingerprint":"483797533da9798bf665088afe22021ed15359b62af8166f236e619781309453","multiline":true,"placeholders":[]}
+#. English: Toggle collapse state on activity sections, tool groups and
+#. thinking in the current conversation view. If any are open, collapse them
+#. all; otherwise expand them all. Thinking that is still being written and
+#. controls waiting for your input stay open. Click an activity header to
+#. toggle just that section. Useful for decluttering the view when reviewing
+#. long sessions.
+#. chrys-meta={"fingerprint":"9c42fb5c1abaf8d9c6288243716520774e7e69f68320962187da68b601a3a4fe","multiline":true,"placeholders":[]}
#: src/chrys/app/tui/screens/main/commands.py
msgid "tui.man.fold.body"
msgstr ""
-"切换当前对话视图中所有工具组和思考的折叠状态。\n"
+"统一切换当前对话中的执行过程块、工具组和思考。\n"
"\n"
-"折叠后只显示摘要标题。\n"
-"仍在写出的思考保持展开。\n"
+"有展开的内容就全部收起,否则全部展开。\n"
+"仍在写出的思考和等待你输入的控件除外。\n"
+"点击过程标题可以只切换该片段。\n"
"查看较长会话时可用它精简视图。"
#. English: Create an independent branch of the current session. The current
@@ -10531,6 +10554,13 @@ msgstr "智能体未指定数量时每次搜索返回的结果数;默认 {defa
msgid "tui.settings.hint.tools.web_search.private_origins"
msgstr "JSON 列表,列出网络搜索可以访问的私有网络或本地网络来源。"
+#. English: Fold activity after successful replies. Applies to future completed
+#. turns and sessions opened afterwards; existing folds keep their state.
+#. chrys-meta={"fingerprint":"dd6f495b926ae2dd26c75f387f91bd697b778178dc885330561ea6187c6faa50","multiline":false,"placeholders":[]}
+#: src/chrys/app/tui/screens/settings/layout.py
+msgid "tui.settings.hint.ui.chat.fold_finished_turns"
+msgstr "成功回复后折叠执行过程。适用于之后完成的轮次和之后打开的会话;已有折叠块保持当前状态。"
+
#. English: Show the model's thinking in the chat as it arrives, when the model
#. service returns it as readable text. Applies to new replies and to sessions
#. opened afterwards.
diff --git a/scripts/chat_panel_workloads.py b/scripts/chat_panel_workloads.py
index 5c31e025..d884ff13 100644
--- a/scripts/chat_panel_workloads.py
+++ b/scripts/chat_panel_workloads.py
@@ -390,6 +390,24 @@ async def _populate_realistic_session(
await asyncio.sleep(0)
+async def _populate_process_history(panel: ChatPanel, *, turns: int, markdown_lines: int = 80) -> None:
+ """Stream thinking and intermediate markdown with injected boundaries before successful completion."""
+ for turn in range(turns):
+ await panel.add_user_message(f"Request {turn}: inspect and verify.")
+ for segment in range(2):
+ if segment:
+ await panel.add_user_message(f"Also check case {turn}.", is_injection=True)
+ reasoning_id = f"reasoning-{turn}-{segment}"
+ await panel.add_thinking_delta(reasoning_id, "reasoning", "Checking evidence.\n\n" * markdown_lines)
+ panel.finish_thinking_segment(reasoning_id)
+ await _stream_answer(panel, f"middle-{turn}-{segment}", "Intermediate findings.\n\n" * markdown_lines)
+ call_id = f"read-{turn}-{segment}"
+ await panel.add_tool_start(call_id, "read_file", KIND_FILESYSTEM_READ, args={"path": "sample.py"})
+ await panel.add_tool_result(call_id, "read_file", "ok")
+ await _finish_turn(panel, f"final-{turn}", f"Verified case {turn}.")
+ await asyncio.sleep(0)
+
+
async def _populate_sub_agent_markdown(
panel: ChatPanel,
*,
diff --git a/scripts/profile_chat_panel.py b/scripts/profile_chat_panel.py
index 02cc10e3..f4374037 100644
--- a/scripts/profile_chat_panel.py
+++ b/scripts/profile_chat_panel.py
@@ -34,6 +34,7 @@
from chat_panel_workloads import ( # noqa: E402
_populate_file_diff,
_populate_mixed_markdown,
+ _populate_process_history,
_populate_realistic_session,
_populate_sub_agent_markdown,
_populate_table_markdown,
@@ -58,6 +59,9 @@ class Metrics:
tool_sets_per_turn: int
expected_tool_calls: int
collapsed: bool
+ fold_finished_turns: bool
+ displayed_transcript_entries: int
+ process_blocks: int
wait_for_diffs: bool
scroll_driver: str
scroll_wait: str
@@ -151,8 +155,14 @@ class ChatProfileApp(TuiVariableDefaultsMixin, App[None]):
}
"""
+ def __init__(self, *, fold_finished_turns: bool = False) -> None:
+ self._fold_finished_turns = fold_finished_turns
+ super().__init__()
+
def compose(self) -> ComposeResult:
- yield ChatPanel(cwd="/tmp/chrys-profile")
+ yield ChatPanel(
+ cwd="/tmp/chrys-profile", show_thinking=lambda: True, fold_finished_turns=lambda: self._fold_finished_turns
+ )
def _rss_mb(process: psutil.Process) -> float:
@@ -205,6 +215,8 @@ async def _populate_panel(panel: ChatPanel, args: argparse.Namespace) -> None:
turns=args.turns,
collapsed=args.collapsed,
)
+ elif args.workload == "process-history":
+ await _populate_process_history(panel, turns=args.turns, markdown_lines=args.markdown_lines)
elif args.workload == "sub-agent-markdown":
await _populate_sub_agent_markdown(
panel,
@@ -239,6 +251,8 @@ async def _populate_panel(panel: ChatPanel, args: argparse.Namespace) -> None:
def _expected_tool_calls(args: argparse.Namespace) -> int:
+ if args.workload == "process-history":
+ return args.turns * 2
if args.workload == "file-diff":
return args.turns * args.tool_sets_per_turn * 3
if args.workload == "realistic-session":
@@ -273,7 +287,7 @@ async def run_profile(args: argparse.Namespace) -> Metrics:
tracemalloc.start()
rss_before = _rss_mb(process)
- async with ChatProfileApp().run_test(size=tuple(args.size)) as pilot:
+ async with ChatProfileApp(fold_finished_turns=args.fold_finished_turns).run_test(size=tuple(args.size)) as pilot:
panel = pilot.app.query_one(ChatPanel)
async def wait_for_frame() -> None:
@@ -377,6 +391,9 @@ def post_mouse_scroll(index: int, midpoint: int) -> None:
tool_sets_per_turn=args.tool_sets_per_turn,
expected_tool_calls=_expected_tool_calls(args),
collapsed=args.collapsed,
+ fold_finished_turns=args.fold_finished_turns,
+ displayed_transcript_entries=sum(widget.display for widget in panel.transcript_content_children()),
+ process_blocks=len(panel.query("ProcessBlock")),
wait_for_diffs=args.wait_for_diffs,
scroll_driver=args.scroll_driver,
scroll_wait=args.scroll_wait,
@@ -428,7 +445,14 @@ def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--workload",
- choices=("file-diff", "realistic-session", "sub-agent-markdown", "table-markdown", "mixed-markdown"),
+ choices=(
+ "file-diff",
+ "realistic-session",
+ "sub-agent-markdown",
+ "table-markdown",
+ "mixed-markdown",
+ "process-history",
+ ),
default="file-diff",
)
parser.add_argument(
@@ -501,6 +525,11 @@ def _parse_args() -> argparse.Namespace:
)
parser.add_argument("--size", type=int, nargs=2, default=(120, 36), metavar=("WIDTH", "HEIGHT"))
parser.add_argument("--expanded", dest="collapsed", action="store_false")
+ parser.add_argument(
+ "--fold-finished-turns",
+ action="store_true",
+ help="Fold successful process history, as the main chat does by default.",
+ )
parser.set_defaults(collapsed=True)
parser.add_argument("--instrument-markdown", action="store_true")
parser.add_argument("--no-tracemalloc", action="store_true", help="Disable tracemalloc for latency-focused runs.")
diff --git a/src/chrys/app/tui/app.py b/src/chrys/app/tui/app.py
index d2e98d47..984fc5c6 100644
--- a/src/chrys/app/tui/app.py
+++ b/src/chrys/app/tui/app.py
@@ -956,6 +956,7 @@ def _build_main_screen(self) -> MainScreen:
on_editor_keymap_changed=self._record_editor_keymap_override,
tool_groups_expanded=lambda: self._settings_handle.settings.tool_groups_expanded,
show_thinking=lambda: self._settings_handle.settings.chat_show_thinking,
+ fold_finished_turns=lambda: self._settings_handle.settings.chat_fold_finished_turns,
approval_mode=self._engine.approval_mode,
reset_program_status=self._program_status_reporter.reset_context,
)
diff --git a/src/chrys/app/tui/screens/main/commands.py b/src/chrys/app/tui/screens/main/commands.py
index 36692bc3..35ce2fa9 100644
--- a/src/chrys/app/tui/screens/main/commands.py
+++ b/src/chrys/app/tui/screens/main/commands.py
@@ -59,7 +59,9 @@
"tui.commands.description.copy",
fallback="Copy agent, user, or all turns to clipboard",
)
-_DESCRIPTION_FOLD = msg("tui.commands.description.fold", fallback="Toggle collapse on all tool groups and thinking")
+_DESCRIPTION_FOLD = msg(
+ "tui.commands.description.fold", fallback="Toggle collapse on activity, tool groups and thinking"
+)
_DESCRIPTION_DIFF = msg(
"tui.commands.description.diff",
fallback="View file changes for the current session",
@@ -293,10 +295,12 @@
_MAN_FOLD_BODY = msg(
"tui.man.fold.body",
fallback=(
- "Toggle collapse state on all tool groups\n"
+ "Toggle collapse state on activity sections, tool groups\n"
"and thinking in the current conversation view.\n\n"
- "Collapsed items show only a summary header.\n"
- "Thinking that is still being written stays open.\n"
+ "If any are open, collapse them all; otherwise expand them all.\n"
+ "Thinking that is still being written and controls waiting\n"
+ "for your input stay open.\n"
+ "Click an activity header to toggle just that section.\n"
"Useful for decluttering the view when reviewing long sessions."
),
multiline=True,
diff --git a/src/chrys/app/tui/screens/main/copy_actions.py b/src/chrys/app/tui/screens/main/copy_actions.py
index 1a499fe9..2407294c 100644
--- a/src/chrys/app/tui/screens/main/copy_actions.py
+++ b/src/chrys/app/tui/screens/main/copy_actions.py
@@ -111,7 +111,7 @@ def copy_agent_responses(self, arg: str) -> None:
self._view.notify(message, title=COPIED_TITLE.bind(), timeout=2)
def toggle_fold(self) -> None:
- """Collapse or expand all tool groups and thinking."""
+ """Collapse or expand all process sections, tool groups and thinking."""
collapsed = self._view.toggle_chat_fold_all()
label = "Folded" if collapsed else "Unfolded"
self._debug("Fold", label)
diff --git a/src/chrys/app/tui/screens/main/screen.py b/src/chrys/app/tui/screens/main/screen.py
index 28cbf18f..9d0c6c00 100644
--- a/src/chrys/app/tui/screens/main/screen.py
+++ b/src/chrys/app/tui/screens/main/screen.py
@@ -291,6 +291,7 @@ def __init__(
on_editor_keymap_changed: Callable[[str], None] | None = None,
tool_groups_expanded: Callable[[], bool] | None = None,
show_thinking: Callable[[], bool] | None = None,
+ fold_finished_turns: Callable[[], bool] | None = None,
approval_mode: ApprovalMode = ApprovalMode.MANUAL,
reset_program_status: Callable[[], None] | None = None,
) -> None:
@@ -312,6 +313,7 @@ def __init__(
self._on_editor_keymap_changed = on_editor_keymap_changed
self._tool_groups_expanded = tool_groups_expanded or (lambda: True)
self._show_thinking = show_thinking
+ self._fold_finished_turns = fold_finished_turns
self._localization = localization
self._locale_controller = locale_controller
self._gc_freeze_participants: tuple[GcFreezeParticipant, ...] = ()
@@ -591,6 +593,10 @@ def _apply_show_thinking(self, value: bool) -> None:
"""Put the choice in force; thinking already shown, or already left out, stays as it is."""
cast("ChrysApp", self.app).settings_handle.override(chat_show_thinking=value)
+ def _apply_fold_finished_turns(self, value: bool) -> None:
+ """Existing process headers keep the user's choice."""
+ cast("ChrysApp", self.app).settings_handle.override(chat_fold_finished_turns=value)
+
def _approval_timeout_seconds(self) -> int:
return cast("ChrysApp", self.app).settings_handle.settings.approval_timeout_seconds
@@ -626,6 +632,7 @@ def _new_settings_coordinator(self) -> SettingsCoordinator:
apply_tool_groups_expanded=self._apply_tool_groups_expanded,
apply_approval_defer_while_judging=self._apply_approval_defer_while_judging,
apply_show_thinking=self._apply_show_thinking,
+ apply_fold_finished_turns=self._apply_fold_finished_turns,
list_themes=lambda: sorted(app.available_themes),
save_notifications=self._schedule_notification_settings_save,
notification_service=self._notification_service,
@@ -1042,6 +1049,7 @@ def compose(self) -> ComposeResult:
locale_controller=self._locale_controller,
tool_groups_expanded=self._tool_groups_expanded,
show_thinking=self._show_thinking,
+ fold_finished_turns=self._fold_finished_turns,
sub_agent_transcript_loader=self._load_sub_agent_transcript,
).data_bind(
agent_running=MainScreen.agent_running_state,
diff --git a/src/chrys/app/tui/screens/main/session_handlers.py b/src/chrys/app/tui/screens/main/session_handlers.py
index 45247870..7dc9872a 100644
--- a/src/chrys/app/tui/screens/main/session_handlers.py
+++ b/src/chrys/app/tui/screens/main/session_handlers.py
@@ -807,6 +807,7 @@ async def on_session_restored(self, event: SessionRestored) -> None:
initial_profile=event.initial_agent_profile or display,
file_snapshots=file_snapshots or None,
compressed_blocks=compressed_blocks_map or None,
+ recovered_from_sidecar=event.recovered_from_sidecar,
)
s.update_toc()
diff --git a/src/chrys/app/tui/screens/main/settings_coordinator.py b/src/chrys/app/tui/screens/main/settings_coordinator.py
index d366338e..d2bea61b 100644
--- a/src/chrys/app/tui/screens/main/settings_coordinator.py
+++ b/src/chrys/app/tui/screens/main/settings_coordinator.py
@@ -72,6 +72,7 @@ class SettingsCoordinatorCallbacks:
apply_tool_groups_expanded: Callable[[bool], None]
apply_approval_defer_while_judging: Callable[[bool], None]
apply_show_thinking: Callable[[bool], None]
+ apply_fold_finished_turns: Callable[[bool], None]
def _values_by_key(loaded: LoadedSettings) -> dict[str, Any]:
@@ -233,6 +234,9 @@ def apply_live(self, key: str, value: Any) -> None:
elif key == "ui.chat.show_thinking":
self._queue.schedule({key: bool(value)})
self._callbacks.apply_show_thinking(bool(value))
+ elif key == "ui.chat.fold_finished_turns":
+ self._queue.schedule({key: bool(value)})
+ self._callbacks.apply_fold_finished_turns(bool(value))
else:
msg_text = f"{key}: no live writer registered"
raise ValueError(msg_text)
diff --git a/src/chrys/app/tui/screens/main/view_adapter.py b/src/chrys/app/tui/screens/main/view_adapter.py
index 891062b0..b72f6b73 100644
--- a/src/chrys/app/tui/screens/main/view_adapter.py
+++ b/src/chrys/app/tui/screens/main/view_adapter.py
@@ -797,12 +797,14 @@ async def replay_history(
initial_profile: str,
file_snapshots: dict[str, list[FileSnapshotPayload]] | None,
compressed_blocks: dict[str, CompressedBlock] | None,
+ recovered_from_sidecar: bool = False,
) -> None:
await self._screen.query_one(ChatPanel).replay_history(
messages,
initial_profile=initial_profile,
file_snapshots=file_snapshots,
compressed_blocks=compressed_blocks,
+ recovered_from_sidecar=recovered_from_sidecar,
)
async def add_answer_delta(self, segment_id: str, text: str) -> None:
diff --git a/src/chrys/app/tui/screens/settings/layout.py b/src/chrys/app/tui/screens/settings/layout.py
index 24d5e352..1dadb866 100644
--- a/src/chrys/app/tui/screens/settings/layout.py
+++ b/src/chrys/app/tui/screens/settings/layout.py
@@ -62,6 +62,10 @@
"tui.settings.hint.ui.chat.tool_groups_expanded",
fallback="Expand new tool groups by default. When off, click the tool status at any time to view details.",
)
+_HINT_UI_CHAT_FOLD_FINISHED_TURNS = msg(
+ "tui.settings.hint.ui.chat.fold_finished_turns",
+ fallback="Fold activity after successful replies. Applies to future completed turns and sessions opened afterwards; existing folds keep their state.",
+)
_HINT_APP_UPDATE_CHECK = msg(
"tui.settings.hint.app.update_check",
fallback="Once a day at startup, look up the newest version and show it on the welcome screen. Sends no personal data.",
@@ -308,6 +312,7 @@ class SettingsTab:
SettingRowSpec("ui.theme", hint=_HINT_UI_THEME),
SettingRowSpec("ui.locale", hint=_HINT_UI_LOCALE),
SettingRowSpec("ui.chat.show_thinking", hint=_HINT_UI_CHAT_SHOW_THINKING),
+ SettingRowSpec("ui.chat.fold_finished_turns", hint=_HINT_UI_CHAT_FOLD_FINISHED_TURNS),
),
),
SettingsSection(
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 89372e5b..bf704b99 100644
--- a/src/chrys/app/tui/widgets/chat/agent_transcript_surface.py
+++ b/src/chrys/app/tui/widgets/chat/agent_transcript_surface.py
@@ -832,7 +832,11 @@ async def mount_transcript_widget(self, widget: Widget) -> bool:
await self.mount(widget)
return widget.is_attached and not widget._closing and not widget._pruning and self._accepts_transcript_updates()
- async def mount_transcript_widgets(self, widgets: list[Widget]) -> None:
+ async def mount_transcript_widgets(
+ self, widgets: list[Widget], *, process_failures_after: tuple[Widget, ...] = ()
+ ) -> None:
+ # Nested transcripts do not fold completed turns.
+ _ = process_failures_after
if not self._accepts_transcript_updates():
return
precompose_tree(widgets)
diff --git a/src/chrys/app/tui/widgets/chat/messages.py b/src/chrys/app/tui/widgets/chat/messages.py
index 0de65616..5a644160 100644
--- a/src/chrys/app/tui/widgets/chat/messages.py
+++ b/src/chrys/app/tui/widgets/chat/messages.py
@@ -4,6 +4,7 @@
from __future__ import annotations
+import asyncio
import contextlib
import time
from collections.abc import Callable, Iterable
@@ -25,10 +26,18 @@
from chrys.app.tui.clipboard import copy_text_to_clipboards
from chrys.app.tui.copy_messages import COPIED_TITLE
from chrys.app.tui.i18n import render_str, widget_localizer
-from chrys.app.tui.support.gc_freeze import GcAbsorbReason, GcAbsorbRequested
-from chrys.app.tui.util.removal import remove_all_shielded
+from chrys.app.tui.support.gc_freeze import (
+ GcAbsorbReason,
+ GcAbsorbRequested,
+ GcReclaimReason,
+ GcReclaimRequested,
+ GcRemovedContent,
+ current_gc_freeze_epoch,
+)
+from chrys.app.tui.util.removal import remove_all_shielded, remove_shielded
from chrys.app.tui.util.source_text import sanitize_source_text
from chrys.app.tui.widgets.chat.image_preview import ChatImagePreview, ImagePreviewGrid, extract_image_previews
+from chrys.app.tui.widgets.chat.process_visibility import ProcessMember
from chrys.app.tui.widgets.click_affordance import ClickAffordance
from chrys.app.tui.widgets.markdown import VirtualizedMarkdown
from chrys.app.tui.widgets.markdown.parser import create_user_text_markdown_parser
@@ -363,6 +372,11 @@ def is_injection(self) -> bool:
"""Whether this user message is a mid-turn injection."""
return self._is_injection
+ @property
+ def is_compressed(self) -> bool:
+ """Whether this message belongs to archived, compressed history."""
+ return self._compressed
+
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
"""User message content is copied by its header/body children."""
_ = selection
@@ -414,7 +428,7 @@ def on_click(self) -> None:
self.post_message(self.Clicked())
-class StreamedMarkdownMessage(Widget):
+class StreamedMarkdownMessage(ProcessMember):
"""A transcript message whose markdown body can stream in.
Holds what streaming needs and what keeps it cheap: the body catches up at
@@ -699,6 +713,17 @@ class AgentMessage(StreamedMarkdownMessage):
}
"""
+ @property
+ def is_process_final(self) -> bool:
+ """A visible answer to keep outside a successful turn's process."""
+ return (
+ self._is_final
+ and not self._is_intermediate
+ and not self._is_structured_completion
+ and not self.is_status
+ and bool(self.text)
+ )
+
def __init__(
self,
text: str = "",
@@ -710,7 +735,10 @@ def __init__(
timestamp: str = "",
duration_ms: int | None = None,
segment_ids: Iterable[str] = (),
+ is_status: bool = False,
) -> None:
+ self.is_status = is_status
+ """Persisted lifecycle prose is not an agent's successful final answer."""
self.segment_ids = frozenset(segment_ids)
"""The content segments whose answer text this message shows, for a bare Continue to withdraw."""
self._profile_name = profile_name
@@ -881,6 +909,8 @@ def __init__(
self._channel = channel
self._absorb_after_parse = False
"""The body was built on opening: its first parse is stable content the GC freeze can take."""
+ self._body_freeze_epoch: int | None = None
+ self._body_structure_lock = asyncio.Lock()
super().__init__(text, is_final=is_final, classes="--compressed" if is_compressed else "")
if collapsed:
# Before mount, and without the watcher: the body is built when it first opens.
@@ -906,20 +936,44 @@ def compose(self) -> ComposeResult:
yield self._new_body()
def watch_collapsed(self, collapsed: bool) -> None:
- if not collapsed and self._md_widget is None:
- if self.is_mounted:
- self._open_body()
- else:
- # Registered, not mounted yet (a replay batch mounting): its compose may have run already.
- self.call_later(self._open_body)
super().watch_collapsed(collapsed)
+ self.call_later(self._sync_thinking_body)
- def _open_body(self) -> None:
- """Build the body that a block folded at replay composed without, on its first opening."""
- if self.collapsed or self._md_widget is not None:
- return
- self._absorb_after_parse = True
- self.mount(self._new_body())
+ def _new_body(self) -> VirtualizedMarkdown:
+ self._body_freeze_epoch = current_gc_freeze_epoch()
+ return super()._new_body()
+
+ async def _sync_thinking_body(self) -> None:
+ """Release folded completed markdown; rebuild only the latest requested open state."""
+ async with self._body_structure_lock:
+ if not self.is_mounted or self._closing or self._pruning or not self.app.is_running:
+ return
+ body = self._md_widget
+ if self.collapsed and self.foldable and body is not None:
+ epoch = self._body_freeze_epoch
+ removed = (
+ GcRemovedContent.watch(built_at_epoch=epoch, nodes=body.walk_children(with_self=True))
+ if epoch is not None and epoch == current_gc_freeze_epoch()
+ else None
+ )
+ self._absorb_after_parse = False
+ with self.app.batch_update():
+ if self.app.focused in body.walk_children(with_self=True):
+ self.screen.set_focus(None, scroll_visible=False)
+ try:
+ await remove_shielded(body)
+ finally:
+ self._md_widget = None
+ self._body_freeze_epoch = None
+ self._clear_arrangement_cache()
+ _ = self.displayed_children
+ self._give_back_held_rows()
+ self.post_message(
+ GcReclaimRequested(GcReclaimReason.STABLE_CONTENT_REMOVED, prompt=False, removed=removed)
+ )
+ if not self.collapsed and self._md_widget is None and not self._closing and not self._pruning:
+ self._absorb_after_parse = True
+ await self.mount(self._new_body())
def on_virtualized_markdown_table_of_contents_updated(
self, event: VirtualizedMarkdown.TableOfContentsUpdated
@@ -973,7 +1027,7 @@ class Pressed(Message):
"""Posted when the inline retry/continue action is clicked."""
-class ErrorMessage(Widget):
+class ErrorMessage(ProcessMember):
"""Error display with red accent."""
DEFAULT_CSS = """
@@ -1056,7 +1110,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
self.post_message(ConversationStatusAction.Pressed())
-class RetryMessage(Static):
+class RetryMessage(Static, ProcessMember):
"""Transient retry notice — shown while the executor waits before retrying."""
DEFAULT_CSS = """
@@ -1096,7 +1150,7 @@ def _render_message(self, reference: MessageRef) -> str:
return render_str(widget_localizer(self), reference)
-class InterruptedMessage(Widget):
+class InterruptedMessage(ProcessMember):
"""Interruption notice — yellow for user interrupts, red for errors."""
DEFAULT_CSS = """
diff --git a/src/chrys/app/tui/widgets/chat/panel.py b/src/chrys/app/tui/widgets/chat/panel.py
index a5ecf345..a4238898 100644
--- a/src/chrys/app/tui/widgets/chat/panel.py
+++ b/src/chrys/app/tui/widgets/chat/panel.py
@@ -11,6 +11,7 @@
import itertools
import logging
from collections.abc import Awaitable, Callable
+from fractions import Fraction
from typing import TYPE_CHECKING, Any
from weakref import WeakKeyDictionary, WeakSet
@@ -62,6 +63,8 @@
remove_withdrawn_answer,
)
from chrys.app.tui.widgets.chat.ports import ChatTranscriptPanelMarker, TranscriptLocalizationPort
+from chrys.app.tui.widgets.chat.process_block import ProcessBlock
+from chrys.app.tui.widgets.chat.process_controller import ProcessController
from chrys.app.tui.widgets.chat.renderers.ask_user import AskUserInlineResized, AskUserToolCall
from chrys.app.tui.widgets.chat.renderers.execute import ExecuteToolCall
from chrys.app.tui.widgets.chat.renderers.sub_agent import SubAgentToolCall
@@ -128,13 +131,17 @@ def __init__(
locale_controller: LocaleController | None = None,
tool_groups_expanded: Callable[[], bool] | None = None,
show_thinking: Callable[[], bool] | None = None,
+ fold_finished_turns: Callable[[], bool] | None = None,
sub_agent_transcript_loader: Callable[[str], Awaitable[PersistedSubAgentTranscript | None]] | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
# Document-order stamps for top-level transcript children; weak keys
# so removed widgets drop their entries with the widget itself.
- self._transcript_seqs: WeakKeyDictionary[Widget, int] = WeakKeyDictionary()
+ self._transcript_seqs: WeakKeyDictionary[Widget, Fraction] = WeakKeyDictionary()
+ self._fold_finished_turns = fold_finished_turns or (lambda: False)
+ self._process_controller = ProcessController(self)
+ self._replay_process_protect_last = False
self._ask_user_tool_widgets: WeakSet[AskUserToolCall] = WeakSet()
self._replay_progress_callback: Callable[[int, int], None] | None = None
self._locale_controller = locale_controller
@@ -434,10 +441,10 @@ def mount(self, *widgets, **kwargs): # type: ignore[override]
kwargs["before"] = spacer
for widget in widgets:
if isinstance(widget, Widget) and widget not in self._transcript_seqs:
- self._transcript_seqs[widget] = next(_transcript_seq_counter)
+ self._transcript_seqs[widget] = Fraction(next(_transcript_seq_counter))
return super().mount(*widgets, **kwargs)
- def transcript_seq(self, widget: Widget) -> int | None:
+ def transcript_seq(self, widget: Widget) -> Fraction | None:
"""Document-order stamp for a top-level child; ``None`` if never
mounted through this panel."""
return self._transcript_seqs.get(widget)
@@ -446,7 +453,43 @@ def stamp_transcript_order(self, widgets: list[Widget]) -> None:
"""Stamp document order up front for entries mounted newest first."""
for widget in widgets:
if widget not in self._transcript_seqs:
- self._transcript_seqs[widget] = next(_transcript_seq_counter)
+ self._transcript_seqs[widget] = Fraction(next(_transcript_seq_counter))
+
+ async def mount_transcript_after(self, anchor: Widget, widget: Widget) -> bool:
+ """Insert between neighboring exact order keys, without changing existing selection anchors."""
+ if anchor.parent is not self or not self.can_mount_replay():
+ return False
+ previous = self._transcript_seqs[anchor]
+ following = min(
+ (
+ seq
+ for node, seq in self._transcript_seqs.items()
+ if seq > previous and not self._chrome.is_infrastructure(node)
+ ),
+ default=previous + 1,
+ )
+ self._transcript_seqs[widget] = (previous + following) / 2
+ await self.mount(widget, after=anchor)
+ return widget.parent is self and not widget._pruning
+
+ def on_tool_group_process_state_changed(self, event: ToolGroup.ProcessStateChanged) -> None:
+ event.stop()
+ block = self._process_controller.block_for_group(event.group)
+ if block is None:
+ return
+ with self.app.batch_update():
+ self._process_controller.member_changed(block)
+
+ def on_process_block_toggle_requested(self, event: ProcessBlock.ToggleRequested) -> None:
+ event.stop()
+ if event.block.parent is not self:
+ return
+ self.note_user_scroll()
+ self.set_anchor_released(True)
+ self._auto_scroll_paused_by_user = True
+ with self.app.batch_update():
+ event.block.requested_collapsed = not event.block.requested_collapsed
+ event.block.try_finalize_fold()
def on_mount(self) -> None:
if self._locale_controller is not None:
@@ -749,13 +792,20 @@ async def mount_transcript_widget(self, widget: Widget) -> bool:
and not self._pruning
)
- async def mount_transcript_widgets(self, widgets: list[Widget]) -> None:
+ async def mount_transcript_widgets(
+ self, widgets: list[Widget], *, process_failures_after: tuple[Widget, ...] = ()
+ ) -> None:
"""Mount replayed entries, newest first, and return once the view is filled.
Entries beyond the first few viewports are prepended above by a
panel-owned task after this returns (see ``replay_mount``);
:meth:`wait_replay_complete` waits for them.
"""
+ self._process_controller.remember_replay_failures(process_failures_after)
+ if self._fold_finished_turns():
+ widgets = self._process_controller.prepare_replay(
+ widgets, protect_last_turn=self._replay_process_protect_last
+ )
await self._replay_mount.mount(widgets)
@property
@@ -1039,6 +1089,7 @@ async def _clear_and_rebuild(self) -> None:
# A prepend ends at a batch boundary first: never remove children
# around a mount that is still registering.
await self._replay_mount.stop()
+ self._process_controller.clear()
self._scroll_controller.reset_for_clear()
if self.is_attached:
self.screen.clear_selection()
@@ -1075,26 +1126,34 @@ def set_update_notice(self, notice: UpdateNotice | None) -> None:
self._chrome.set_update_notice(notice)
def toggle_fold_all(self) -> bool:
- """Collapse or expand all tool groups and thinking.
+ """Collapse or expand process sections, tool groups and thinking.
- If any are expanded, collapses all. If all are already collapsed,
- expands all. Returns the new collapsed state.
+ If any eligible visible item is expanded, collapse all; otherwise
+ expand all, including controls inside process sections. Return the
+ new collapsed state.
"""
folds: list[ToolGroup | ThinkingMessage] = [
group for group in self.query(ToolGroup) if not group.collapse_locked
]
# Thinking that still streams stays open, as a click on its header leaves it.
folds.extend(message for message in self.query(ThinkingMessage) if message.foldable)
+ processes_collapsed = self._process_controller.folds_collapsed()
# Entries still waiting to be prepended take the choice as they mount.
pending_collapsed = self._replay_mount.pending_folds_collapsed()
- if not folds and pending_collapsed is None:
+ if not folds and pending_collapsed is None and processes_collapsed is None:
return False
- any_expanded = any(not fold.collapsed for fold in folds) or pending_collapsed is False
- for fold in folds:
- fold.collapsed = any_expanded
- self._replay_mount.set_fold_state(any_expanded)
+ any_expanded = (
+ processes_collapsed is False
+ or any(not fold.process_hidden and not fold.collapsed for fold in folds)
+ or pending_collapsed is False
+ )
+ with self.app.batch_update():
+ self._process_controller.set_fold_state(any_expanded)
+ for fold in folds:
+ fold.collapsed = any_expanded
+ self._replay_mount.set_fold_state(any_expanded)
return any_expanded
def get_agent_responses(self) -> list[tuple[str, str]]:
@@ -1174,6 +1233,9 @@ async def add_agent_message(
structured_output_completed=structured_output_completed,
created_at=created_at,
)
+ if self._fold_finished_turns():
+ with self.app.batch_update():
+ await self._process_controller.complete_live()
def _schedule_anchor_sync(self) -> None:
"""Sync scroll position to the bottom after the next refresh.
@@ -1714,6 +1776,7 @@ async def replay_history(
initial_profile: str = "",
file_snapshots: dict[str, list[FileSnapshotPayload]] | None = None,
compressed_blocks: dict[str, CompressedBlock] | None = None,
+ recovered_from_sidecar: bool = False,
) -> None:
"""Replay saved session messages into the chat panel.
@@ -1726,12 +1789,16 @@ async def replay_history(
Args:
messages: Deserialized session messages.
"""
- await self._replay_renderer.replay_history(
- messages,
- initial_profile=initial_profile,
- file_snapshots=file_snapshots,
- compressed_blocks=compressed_blocks,
- )
+ self._replay_process_protect_last = recovered_from_sidecar
+ try:
+ await self._replay_renderer.replay_history(
+ messages,
+ initial_profile=initial_profile,
+ file_snapshots=file_snapshots,
+ compressed_blocks=compressed_blocks,
+ )
+ finally:
+ self._replay_process_protect_last = False
async def _replay_compressed_block(self, block: CompressedBlock, replay_profile: str) -> str:
"""Compatibility delegate for compressed-block replay rendering."""
diff --git a/src/chrys/app/tui/widgets/chat/ports.py b/src/chrys/app/tui/widgets/chat/ports.py
index bc9c63f1..31014d78 100644
--- a/src/chrys/app/tui/widgets/chat/ports.py
+++ b/src/chrys/app/tui/widgets/chat/ports.py
@@ -31,8 +31,14 @@ async def mount_transcript_widget(self, widget: Widget) -> bool:
"""Mount one widget and report whether it remained attached."""
...
- async def mount_transcript_widgets(self, widgets: list[Widget]) -> None:
- """Mount replayed entries; older ones may still be landing when this returns."""
+ async def mount_transcript_widgets(
+ self, widgets: list[Widget], *, process_failures_after: tuple[Widget, ...] = ()
+ ) -> None:
+ """Mount replayed entries with omitted failure boundaries anchored after existing widgets.
+
+ Older entries may still be landing when this returns. Surfaces without
+ completed-turn folding need not retain the failure boundaries.
+ """
...
async def remove_transcript_widget(self, widget: Widget) -> None: ...
diff --git a/src/chrys/app/tui/widgets/chat/process_block.py b/src/chrys/app/tui/widgets/chat/process_block.py
new file mode 100644
index 00000000..5211f23d
--- /dev/null
+++ b/src/chrys/app/tui/widgets/chat/process_block.py
@@ -0,0 +1,93 @@
+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
+
+"""A persistent process header whose first-stage content remains adjacent siblings."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from rich.text import Text
+from textual.events import Click
+from textual.message import Message
+from textual.widgets import Static
+
+from chrys.app.tui.i18n import render_str, widget_localizer
+from chrys.app.tui.widgets.chat.messages import ThinkingMessage
+from chrys.app.tui.widgets.chat.process_visibility import ProcessMember
+from chrys.app.tui.widgets.chat.tool_call import ToolGroup
+from chrys.foundation.i18n import msg
+
+_CALLS = msg(
+ "tui.chat.process.tool_calls", fallback="Made {count} tool call", plural_fallback="Made {count} tool calls"
+)
+_ACTIVITY = msg("tui.chat.process.activity", fallback="Activity")
+
+
+class ProcessBlock(Static, can_focus=False):
+ """Collapse intent survives blocking members; their terminal notifications retry it."""
+
+ @dataclass
+ class ToggleRequested(Message):
+ block: ProcessBlock
+
+ DEFAULT_CSS = """
+ ProcessBlock {
+ height: 1;
+ padding: 0 1 0 0;
+ color: $text-muted;
+ overflow: hidden;
+ }
+ """
+
+ def __init__(
+ self, members: list[ProcessMember], *, segment_index: int, tool_count: int, is_compressed: bool = False
+ ) -> None:
+ super().__init__()
+ self.members = members
+ self.segment_index = segment_index
+ self.tool_count = tool_count
+ self.is_compressed = is_compressed
+ self.collapsed = False
+ self.requested_collapsed = True
+
+ def focus_on_click(self) -> bool:
+ """Keep transcript clicks in the composer, as tool and thinking headers do."""
+ return False
+
+ @property
+ def blocked(self) -> bool:
+ return any(
+ (isinstance(member, ToolGroup) and (not member.all_complete or member.collapse_locked))
+ or (isinstance(member, ThinkingMessage) and not member.foldable)
+ for member in self.members
+ )
+
+ def render(self) -> Text:
+ message = _CALLS.bind(count=self.tool_count) if self.tool_count and not self.is_compressed else _ACTIVITY.bind()
+ text = Text(f"{'▶' if self.collapsed else '▼'} {render_str(widget_localizer(self), message)}")
+ if self.content_size.width:
+ text.truncate(self.content_size.width, overflow="ellipsis")
+ return text
+
+ def try_finalize_fold(self) -> None:
+ """Apply the latest intent, including after the last interactive member finishes."""
+ collapsed = self.requested_collapsed and not self.blocked
+ if self.is_mounted and collapsed:
+ focused = self.app.focused
+ if focused is not None and any(focused in member.walk_children(with_self=True) for member in self.members):
+ self.screen.set_focus(None, scroll_visible=False)
+ for member in self.members:
+ if collapsed and isinstance(member, ThinkingMessage):
+ member.collapsed = True
+ member.set_process_hidden(collapsed)
+ if self.collapsed != collapsed:
+ self.collapsed = collapsed
+ self.refresh(layout=False)
+
+ def action_toggle_process(self) -> None:
+ self.post_message(self.ToggleRequested(self))
+
+ def on_click(self, event: Click) -> None:
+ event.prevent_default()
+ event.stop()
+ self.action_toggle_process()
diff --git a/src/chrys/app/tui/widgets/chat/process_controller.py b/src/chrys/app/tui/widgets/chat/process_controller.py
new file mode 100644
index 00000000..55964dbe
--- /dev/null
+++ b/src/chrys/app/tui/widgets/chat/process_controller.py
@@ -0,0 +1,162 @@
+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
+
+"""Apply one process plan to live and restored transcripts without copying widget state."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from typing import Protocol
+from weakref import WeakKeyDictionary, WeakSet, ref
+
+from textual.widget import Widget
+
+from chrys.app.tui.widgets.chat.messages import (
+ AgentMessage,
+ ErrorMessage,
+ InterruptedMessage,
+ RetryMessage,
+ ThinkingMessage,
+ UserMessage,
+)
+from chrys.app.tui.widgets.chat.process_block import ProcessBlock
+from chrys.app.tui.widgets.chat.process_model import ProcessEntry, plan_process_segments
+from chrys.app.tui.widgets.chat.process_visibility import ProcessMember
+from chrys.app.tui.widgets.chat.tool_call import ToolGroup
+
+
+class ProcessHost(Protocol):
+ def transcript_content_children(self) -> list[Widget]: ...
+
+ async def mount_transcript_after(self, anchor: Widget, widget: Widget) -> bool: ...
+
+
+def process_entry(widget: Widget) -> ProcessEntry:
+ """Explicit membership whitelist; all notices retain their own visibility."""
+ if isinstance(widget, UserMessage):
+ return ProcessEntry("injection" if widget.is_injection else "user", compressed=widget.is_compressed)
+ if isinstance(widget, ThinkingMessage):
+ return ProcessEntry("thinking")
+ if isinstance(widget, AgentMessage):
+ if widget.is_status:
+ return ProcessEntry("failure")
+ return ProcessEntry("answer" if widget.is_process_final else "intermediate")
+ if isinstance(widget, ToolGroup):
+ return ProcessEntry("tool", widget.process_tool_count)
+ if isinstance(widget, (ErrorMessage, InterruptedMessage)):
+ return ProcessEntry("failure")
+ if isinstance(widget, RetryMessage):
+ return ProcessEntry("retry")
+ return ProcessEntry("other")
+
+
+class ProcessController:
+ """Own completed headers and event-driven retries of blocked folds."""
+
+ def __init__(self, host: ProcessHost) -> None:
+ self._host = host
+ self._completed: WeakSet[Widget] = WeakSet()
+ self._blocks: WeakSet[ProcessBlock] = WeakSet()
+ self._groups: WeakKeyDictionary[ToolGroup, ref[ProcessBlock]] = WeakKeyDictionary()
+ self._replay_failures_after: WeakSet[Widget] = WeakSet()
+
+ def clear(self) -> None:
+ self._completed.clear()
+ self._blocks.clear()
+ self._groups.clear()
+ self._replay_failures_after.clear()
+
+ def remember_replay_failures(self, preceding_widgets: tuple[Widget, ...]) -> None:
+ """Keep omitted statuses in document order without mounting status controls."""
+ self._replay_failures_after.update(preceding_widgets)
+
+ def _plan(self, widgets: list[Widget], *, protect_last_turn: bool = False) -> dict[int, ProcessBlock]:
+ insertions: dict[int, ProcessBlock] = {}
+ segments = plan_process_segments(
+ [
+ replace(process_entry(widget), failure_after=True)
+ if widget in self._replay_failures_after
+ else process_entry(widget)
+ for widget in widgets
+ ],
+ protect_last_turn=protect_last_turn,
+ )
+ for segment in segments:
+ if not segment.fold:
+ continue
+ anchor = widgets[segment.anchor]
+ if anchor in self._completed:
+ continue
+ if not segment.members:
+ self._completed.add(anchor)
+ continue
+ members = [widgets[index] for index in segment.members]
+ if any(not isinstance(member, ProcessMember) for member in members):
+ raise TypeError("A planned process member must support process visibility")
+ typed_members = [member for member in members if isinstance(member, ProcessMember)]
+ if len(typed_members) == 1 and isinstance(thinking := typed_members[0], ThinkingMessage):
+ if thinking.foldable:
+ thinking.collapsed = True
+ self._completed.add(anchor)
+ continue
+ block = ProcessBlock(
+ typed_members,
+ segment_index=segment.index,
+ tool_count=segment.tool_count,
+ is_compressed=segment.compressed,
+ )
+ insertions[segment.anchor] = block
+ return insertions
+
+ def _activate(self, anchor: Widget, block: ProcessBlock) -> None:
+ """Hide members only once their reachable header owns the segment."""
+ self._completed.add(anchor)
+ self._blocks.add(block)
+ for member in block.members:
+ if isinstance(member, ToolGroup):
+ self._groups[member] = ref(block)
+ block.try_finalize_fold()
+
+ async def complete_live(self) -> None:
+ widgets = self._host.transcript_content_children()
+ starts = [
+ index for index, widget in enumerate(widgets) if isinstance(widget, UserMessage) and not widget.is_injection
+ ]
+ if not starts:
+ return
+ widgets = widgets[starts[-1] :]
+ for index, block in self._plan(widgets).items():
+ if await self._host.mount_transcript_after(widgets[index], block):
+ self._activate(widgets[index], block)
+
+ def prepare_replay(self, widgets: list[Widget], *, protect_last_turn: bool) -> list[Widget]:
+ insertions = self._plan(widgets, protect_last_turn=protect_last_turn)
+ result: list[Widget] = []
+ for index, widget in enumerate(widgets):
+ result.append(widget)
+ if block := insertions.get(index):
+ result.append(block)
+ # Detached replay owns both header and members in one pending
+ # sequence; their visibility must be settled before row budgeting.
+ self._activate(widget, block)
+ return result
+
+ def block_for_group(self, group: ToolGroup) -> ProcessBlock | None:
+ reference = self._groups.get(group)
+ return reference() if reference is not None else None
+
+ def member_changed(self, block: ProcessBlock) -> None:
+ count = sum(member.process_tool_count for member in block.members if isinstance(member, ToolGroup))
+ if block.tool_count != count:
+ block.tool_count = count
+ block.refresh(layout=False)
+ block.try_finalize_fold()
+
+ def folds_collapsed(self) -> bool | None:
+ blocks = [block for block in self._blocks if not block.blocked]
+ return all(block.collapsed for block in blocks) if blocks else None
+
+ def set_fold_state(self, collapsed: bool) -> None:
+ """Honor a global keyboard choice for mounted and pending process sections."""
+ for block in self._blocks:
+ block.requested_collapsed = collapsed
+ block.try_finalize_fold()
diff --git a/src/chrys/app/tui/widgets/chat/process_model.py b/src/chrys/app/tui/widgets/chat/process_model.py
new file mode 100644
index 00000000..b8195f98
--- /dev/null
+++ b/src/chrys/app/tui/widgets/chat/process_model.py
@@ -0,0 +1,71 @@
+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
+
+"""Shared live/replay process boundaries, independent of Textual widgets."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+type EntryKind = Literal["user", "injection", "answer", "thinking", "intermediate", "tool", "failure", "retry", "other"]
+
+
+@dataclass(frozen=True)
+class ProcessEntry:
+ kind: EntryKind
+ tools: int = 0
+ compressed: bool = False
+ failure_after: bool = False
+ """An omitted replay status follows this entry, without becoming process content."""
+
+
+@dataclass(frozen=True)
+class ProcessSegment:
+ opener: int
+ anchor: int
+ index: int
+ members: tuple[int, ...]
+ tool_count: int
+ fold: bool
+ compressed: bool
+
+
+def plan_process_segments(entries: list[ProcessEntry], *, protect_last_turn: bool = False) -> list[ProcessSegment]:
+ """Only persisted user boundaries number segments; notices never become members."""
+ openers = [index for index, entry in enumerate(entries) if entry.kind == "user"]
+ result: list[ProcessSegment] = []
+ for turn, opener in enumerate(openers):
+ end = openers[turn + 1] if turn + 1 < len(openers) else len(entries)
+ anchors = [opener, *(index for index in range(opener + 1, end) if entries[index].kind == "injection")]
+ terminals = [
+ index
+ for index in range(opener + 1, end)
+ if entries[index].kind in {"answer", "failure"} or entries[index].failure_after
+ ]
+ final = (
+ terminals[-1]
+ if terminals and entries[terminals[-1]].kind == "answer" and not entries[terminals[-1]].failure_after
+ else None
+ )
+ success = final is not None
+ protected = protect_last_turn and turn == len(openers) - 1
+ for segment, anchor in enumerate(anchors):
+ stop = anchors[segment + 1] if segment + 1 < len(anchors) else end
+ members = tuple(
+ index
+ for index in range(anchor + 1, stop)
+ if index != final
+ and entries[index].kind in {"answer", "thinking", "intermediate", "tool", "failure", "retry"}
+ )
+ result.append(
+ ProcessSegment(
+ opener,
+ anchor,
+ segment,
+ members,
+ sum(entries[index].tools for index in members),
+ success and not protected,
+ entries[opener].compressed,
+ )
+ )
+ return result
diff --git a/src/chrys/app/tui/widgets/chat/process_visibility.py b/src/chrys/app/tui/widgets/chat/process_visibility.py
new file mode 100644
index 00000000..e02b238e
--- /dev/null
+++ b/src/chrys/app/tui/widgets/chat/process_visibility.py
@@ -0,0 +1,31 @@
+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
+
+"""One display writer for entries with independent content and process visibility."""
+
+from __future__ import annotations
+
+from textual.widget import Widget
+
+
+class ProcessMember(Widget):
+ """A process may hide an entry without overwriting its own visibility."""
+
+ _process_hidden: bool = False
+ _content_visible: bool = True
+
+ @property
+ def process_hidden(self) -> bool:
+ return self._process_hidden
+
+ def set_process_hidden(self, hidden: bool) -> None:
+ self._process_hidden = hidden
+ self._sync_process_display()
+
+ def set_content_visible(self, visible: bool) -> None:
+ self._content_visible = visible
+ self._sync_process_display()
+
+ def _sync_process_display(self) -> None:
+ visible = self._content_visible and not self._process_hidden
+ if self.display != visible:
+ self.display = visible
diff --git a/src/chrys/app/tui/widgets/chat/replay.py b/src/chrys/app/tui/widgets/chat/replay.py
index 3e3920f6..a9b67939 100644
--- a/src/chrys/app/tui/widgets/chat/replay.py
+++ b/src/chrys/app/tui/widgets/chat/replay.py
@@ -177,6 +177,7 @@ class ReplayAgentMessage:
status_code: str | None = None
status_count: int | None = None
duration_ms: int | None = None
+ is_status: bool = False
@dataclass(frozen=True)
@@ -206,6 +207,11 @@ class ReplayInterrupted:
status_count: int | None = None
+@dataclass(frozen=True)
+class ReplaySuppressedStatus:
+ """An unsuccessful terminal status whose prose is omitted from compressed history."""
+
+
ReplayEntry = (
ReplayTurnMarker
| ReplayUserMessage
@@ -214,6 +220,7 @@ class ReplayInterrupted:
| ReplayStructuredCompletion
| ReplayContextFold
| ReplayInterrupted
+ | ReplaySuppressedStatus
)
@@ -808,6 +815,8 @@ def _build_entries(
# through as plain text only — chrome payloads never surface
# as conversation tool activity.
if suppress_marker_text:
+ if role == "assistant" and (marker in HistoryMarkerKind.STATUS_MARKERS or status_code is not None):
+ entries.append(ReplaySuppressedStatus())
continue
text_contents = [
{**content, "text": sanitize_legacy_block(str(content.get("text", "")))}
@@ -822,6 +831,7 @@ def _build_entries(
status_code=status_code,
status_count=_status_count(extra),
duration_ms=_trajectory_duration_ms(extra),
+ is_status=marker in HistoryMarkerKind.STATUS_MARKERS or status_code is not None,
)
)
continue
@@ -906,7 +916,7 @@ def finalize_candidate() -> None:
if isinstance(entry, ReplayUserMessage) and not entry.is_injection:
finalize_candidate()
continue
- if isinstance(entry, ReplayInterrupted):
+ if isinstance(entry, ReplayInterrupted | ReplaySuppressedStatus):
candidate_index = None
continue
if not isinstance(entry, ReplayAgentMessage) or entry.is_intermediate:
@@ -1001,6 +1011,7 @@ async def replay_history(
replay_profile = initial_profile or self._default_profile()
seen_user_in_turn = False
widgets: list[Widget] = []
+ process_failures_after: list[Widget] = []
for entry in plan.entries:
if isinstance(entry, ReplayTurnMarker):
@@ -1012,7 +1023,11 @@ async def replay_history(
block = compressed_blocks.get(entry.context_id) if compressed_blocks else None
if block:
replay_profile = await self._replay_compressed_block(
- block, replay_profile, widgets=widgets, show_reasoning=show_reasoning
+ block,
+ replay_profile,
+ widgets=widgets,
+ show_reasoning=show_reasoning,
+ process_failures_after=process_failures_after,
)
freed_messages = len(block.messages) if block else 0
turn_range = block.turn_range if block else (0, 0)
@@ -1092,6 +1107,7 @@ async def replay_history(
duration_ms=entry.duration_ms,
widgets=widgets,
show_reasoning=show_reasoning,
+ is_status=entry.is_status or entry.status_code is not None,
)
continue
@@ -1099,7 +1115,7 @@ async def replay_history(
widgets.append(_structured_completion_message(entry, replay_profile, is_compressed=False))
if widgets:
- await self._mount.mount_transcript_widgets(widgets)
+ await self._mount.mount_transcript_widgets(widgets, process_failures_after=tuple(process_failures_after))
self._ui.after_replay_history_mounted()
def _resolve_status(self, status_code: str | None, count: int | None) -> str | None:
@@ -1127,14 +1143,24 @@ async def _replay_compressed_block(
*,
widgets: list[Widget] | None = None,
show_reasoning: bool = False,
+ process_failures_after: list[Widget] | None = None,
) -> str:
"""Render a compressed block through the canonical replay planner."""
replay_widgets = widgets if widgets is not None else []
+ failure_anchors = process_failures_after if process_failures_after is not None else []
serialized_messages = [message.to_dict() for message in block.messages]
plan = self._planner.build_plan(serialized_messages, suppress_marker_text=True, show_reasoning=show_reasoning)
plan.apply_compatibility_annotations()
seen_user_in_turn = False
for entry in plan.entries:
+ if isinstance(entry, ReplayInterrupted | ReplaySuppressedStatus):
+ # Omitted prose still ends the preceding activity unsuccessfully.
+ # Anchor the fact to existing content so it never creates a copy
+ # entry, a process member, or a row in the replay mount budget.
+ if replay_widgets:
+ failure_anchors.append(replay_widgets[-1])
+ continue
+
if isinstance(entry, ReplayTurnMarker):
self._toc.apply_turn_marker(entry.turn_index)
seen_user_in_turn = False
@@ -1201,6 +1227,7 @@ async def _replay_compressed_block(
is_compressed=True,
widgets=replay_widgets,
show_reasoning=show_reasoning,
+ is_status=entry.is_status or entry.status_code is not None,
)
continue
@@ -1208,7 +1235,7 @@ async def _replay_compressed_block(
replay_widgets.append(_structured_completion_message(entry, replay_profile, is_compressed=True))
if widgets is None:
- await self._mount.mount_transcript_widgets(replay_widgets)
+ await self._mount.mount_transcript_widgets(replay_widgets, process_failures_after=tuple(failure_anchors))
return replay_profile
async def _replay_assistant(
@@ -1221,6 +1248,7 @@ async def _replay_assistant(
is_compressed: bool = False,
widgets: list[Widget] | None = None,
show_reasoning: bool = False,
+ is_status: bool = False,
) -> None:
"""Replay a single assistant message, preserving text/thinking/tool-call order."""
replay_widgets = widgets if widgets is not None else []
@@ -1244,6 +1272,7 @@ def answer_message(text: str, segment_ids: tuple[str, ...], *, intermediate: boo
timestamp="" if intermediate else timestamp,
duration_ms=None if intermediate else duration_ms,
segment_ids=segment_ids,
+ is_status=is_status,
)
async def flush_text(*, settled: bool = False, thinking_follows: bool = False) -> None:
diff --git a/src/chrys/app/tui/widgets/chat/replay_mount.py b/src/chrys/app/tui/widgets/chat/replay_mount.py
index 4e4f86e0..56a7988a 100644
--- a/src/chrys/app/tui/widgets/chat/replay_mount.py
+++ b/src/chrys/app/tui/widgets/chat/replay_mount.py
@@ -40,6 +40,7 @@
from chrys.app.tui.widgets.chat.messages import AgentMessage, ThinkingMessage, UserMessage
from chrys.app.tui.widgets.chat.ports import ReplayMountHost
+from chrys.app.tui.widgets.chat.process_block import ProcessBlock
from chrys.app.tui.widgets.chat.scroll_controller import ChatGcPauseClaim
from chrys.app.tui.widgets.chat.tool_call import ToolGroup
@@ -54,6 +55,7 @@
_PREPEND_BATCH_ROWS = 300
_MIN_WRAP_WIDTH = 20
_ENTRY_CHROME_ROWS = 3
+_MAX_FOREGROUND_ENTRIES = 4 * REPLAY_MOUNT_BATCH_SIZE
def estimated_rows(widget: Widget, width: int) -> int:
@@ -63,6 +65,10 @@ def estimated_rows(widget: Widget, width: int) -> int:
counts its lines plus soft wraps at ``width``; other entries replay
collapsed and count as their header and margins.
"""
+ if not widget.display:
+ return 0
+ if isinstance(widget, ProcessBlock):
+ return 1
if not isinstance(widget, (AgentMessage, UserMessage)):
return _ENTRY_CHROME_ROWS
text = widget.text
@@ -115,6 +121,8 @@ async def mount(self, widgets: list[Widget]) -> None:
row_budget=_TAIL_VIEWPORTS * max(_MIN_VIEWPORT_ROWS, viewport.height),
min_entries=_MIN_TAIL_ENTRIES,
width=viewport.width,
+ max_entries=_MAX_FOREGROUND_ENTRIES,
+ max_visible_entries=REPLAY_MOUNT_BATCH_SIZE,
)
if tail_start == 0:
await self._mount_foreground(widgets)
@@ -168,7 +176,9 @@ def abandon(self) -> None:
def pending_folds_collapsed(self) -> bool | None:
"""Collapsed state pending tool groups and thinking will mount with; None without any."""
- if not any(isinstance(widget, ToolGroup | ThinkingMessage) for widget in self._pending):
+ if not any(
+ isinstance(widget, ToolGroup | ThinkingMessage) and not widget.process_hidden for widget in self._pending
+ ):
return None
# Replay builds every tool group and thinking block collapsed.
return True if self._fold_collapsed is None else self._fold_collapsed
@@ -266,13 +276,13 @@ def _after_batch_mounted(self, batch: list[Widget]) -> None:
if not root.is_attached:
continue
if isinstance(root, ThinkingMessage):
- if root.foldable:
+ if root.foldable and not root.process_hidden:
root.collapsed = collapsed
continue
groups = [root] if isinstance(root, ToolGroup) else []
groups.extend(root.query(ToolGroup))
for group in groups:
- if not group.collapse_locked:
+ if not group.collapse_locked and not group.process_hidden:
group.collapsed = collapsed
# Mounted is not laid out: the batch gets its geometry on the next refresh.
if not self._host.call_after_refresh(self._laid_out, batch):
@@ -316,11 +326,27 @@ def _release_gc_pause(self, gc_pause: ChatGcPauseClaim, *, collect_first: bool)
gc_pause.release(collect_first=collect_first)
@staticmethod
- def _batch_start(widgets: list[Widget], end: int, *, row_budget: int, min_entries: int, width: int) -> int:
+ def _batch_start(
+ widgets: list[Widget],
+ end: int,
+ *,
+ row_budget: int,
+ min_entries: int,
+ width: int,
+ max_entries: int = REPLAY_MOUNT_BATCH_SIZE,
+ max_visible_entries: int = REPLAY_MOUNT_BATCH_SIZE,
+ ) -> int:
"""First index of the entries before ``end`` that fill ``row_budget`` rows."""
start = end
rows = 0
- while start > 0 and end - start < REPLAY_MOUNT_BATCH_SIZE and (rows < row_budget or end - start < min_entries):
+ visible_entries = 0
+ while (
+ start > 0
+ and end - start < max_entries
+ and visible_entries < max_visible_entries
+ and (rows < row_budget or end - start < min_entries)
+ ):
start -= 1
+ visible_entries += int(widgets[start].display)
rows += estimated_rows(widgets[start], width)
return start
diff --git a/src/chrys/app/tui/widgets/chat/selection_controller.py b/src/chrys/app/tui/widgets/chat/selection_controller.py
index d975a75c..da92b7df 100644
--- a/src/chrys/app/tui/widgets/chat/selection_controller.py
+++ b/src/chrys/app/tui/widgets/chat/selection_controller.py
@@ -31,6 +31,7 @@
from collections.abc import Iterator
from dataclasses import dataclass
+from fractions import Fraction
from typing import TYPE_CHECKING, TypeGuard
from textual.geometry import Offset, Region
@@ -46,7 +47,7 @@
from chrys.app.tui.widgets.chat.panel import ChatPanel
-DocKey = tuple[int, ...]
+DocKey = tuple[Fraction | int, ...]
# Orders a gap representative key after every real key that shares its
# ``before`` prefix; leaf keys never extend below a leaf, so any suffix works.
diff --git a/src/chrys/app/tui/widgets/chat/tool_call.py b/src/chrys/app/tui/widgets/chat/tool_call.py
index e271f09f..b86815bf 100644
--- a/src/chrys/app/tui/widgets/chat/tool_call.py
+++ b/src/chrys/app/tui/widgets/chat/tool_call.py
@@ -45,6 +45,7 @@
from chrys.app.tui.util.static_update import update_static_in_place
from chrys.app.tui.util.visibility import is_widget_shown_on_active_screen
from chrys.app.tui.widgets.chat.file_snapshot import FileSnapshotPayload
+from chrys.app.tui.widgets.chat.process_visibility import ProcessMember
from chrys.app.tui.widgets.chat.tool_view_builders import (
TOOL_VIEW_EMPTY,
TOOL_VIEW_OUTPUT,
@@ -1488,7 +1489,7 @@ def render(self) -> Text:
# ---------------------------------------------------------------------------
-class ToolGroup(ToolCopyExcludedMixin, Widget):
+class ToolGroup(ToolCopyExcludedMixin, ProcessMember):
"""Collapsible group of tool calls within a single agent turn.
Consecutive tool starts are grouped together. The title shows progress
@@ -1526,6 +1527,12 @@ class ToolGroup(ToolCopyExcludedMixin, Widget):
collapsed: reactive[bool] = reactive(False)
+ @dataclass
+ class ProcessStateChanged(Message):
+ """A process owner may retry its fold after tool or interaction state changes."""
+
+ group: ToolGroup
+
def __init__(
self,
*,
@@ -1533,6 +1540,7 @@ def __init__(
tool_widget_configurator: Callable[[Widget], None] | None = None,
) -> None:
self._collapse_lock_call_ids: set[str] = set()
+ self._process_status: tuple[int, bool, bool] | None = None
super().__init__()
self._live = live
self._tool_widget_configurator = tool_widget_configurator
@@ -1551,7 +1559,7 @@ def __init__(
self._content_mounted = True
self._content_freeze_epoch: int | None = None
"""GC freeze epoch read before the oldest mounted tool widget was built; None while none is."""
- self.display = False # hidden until first tool is added
+ self.set_content_visible(False) # hidden until first tool is added
def compose(self) -> ComposeResult:
with Horizontal(id="tg-header"):
@@ -1612,10 +1620,12 @@ def lock_collapse_for(self, call_id: str) -> None:
self._collapse_lock_call_ids.add(call_id)
if self.collapsed:
self.collapsed = False
+ self._notify_process_state()
def unlock_collapse_for(self, call_id: str) -> None:
"""Release a live inline control's collapse lock."""
self._collapse_lock_call_ids.discard(call_id)
+ self._notify_process_state()
@property
def collapse_locked(self) -> bool:
@@ -1657,6 +1667,7 @@ def clear_ask_user_inline_prompts(self) -> None:
if isinstance(tc, AskUserToolCall):
tc.clear_inline_prompt()
self._collapse_lock_call_ids.clear()
+ self._notify_process_state()
async def add_tool(
self,
@@ -1694,8 +1705,7 @@ async def add_tool(
canonical_status=canonical_status,
activity_order=self._next_activity_order(),
)
- if not self.display:
- self.display = True
+ self.set_content_visible(True)
self._end_time = None
self._ensure_timer()
async with self._content_structure_lock:
@@ -1819,7 +1829,7 @@ async def add_collapsed_replay_tool(
activity_order=self._next_activity_order(),
)
self._tools.pop(call_id, None)
- self.display = True
+ self.set_content_visible(True)
self._content_mounted = False
self._reconcile_done_count()
if self.all_complete:
@@ -2061,6 +2071,18 @@ def all_complete(self) -> bool:
total = len(self._tool_records) if self._tool_records else len(self._tools)
return self._done == total and total > 0
+ @property
+ def process_tool_count(self) -> int:
+ """Calls in this group, excluding the inner activity of sub-agents."""
+ return len(self._tool_records)
+
+ def _notify_process_state(self) -> None:
+ status = (self.process_tool_count, self.all_complete, self.collapse_locked)
+ if status != self._process_status:
+ self._process_status = status
+ if self.is_attached:
+ self.post_message(self.ProcessStateChanged(self))
+
def _elapsed_ms(self) -> int:
end = self._end_time if self._end_time is not None else time.monotonic()
return int((end - self._start_time) * 1000)
@@ -2093,6 +2115,7 @@ def _title_text(self) -> Text:
return Text(f"{arrow} {title}", no_wrap=True, overflow="ellipsis")
def _update_title(self) -> None:
+ self._notify_process_state()
title = self._title_text()
self._sync_activity_indicator()
if title.plain == self._last_title_plain:
diff --git a/src/chrys/foundation/config/settings.py b/src/chrys/foundation/config/settings.py
index c2b825ff..cfa0d33b 100644
--- a/src/chrys/foundation/config/settings.py
+++ b/src/chrys/foundation/config/settings.py
@@ -683,6 +683,9 @@ def probe_session_root(raw: str) -> Path | None:
fallback="Expand tool details by default",
)
_LABEL_UI_CHAT_SHOW_THINKING = msg("settings.ui.chat.show_thinking.label", fallback="Show model thinking")
+_LABEL_UI_CHAT_FOLD_FINISHED_TURNS = msg(
+ "settings.ui.chat.fold_finished_turns.label", fallback="Automatically fold completed turns"
+)
_LABEL_UI_EDITOR_KEYMAP = msg("settings.ui.editor.keymap.label", fallback="Editor keymap")
_LABEL_WORKSPACE_MRU_MAX_ENTRIES = msg("settings.workspace.mru_max_entries.label", fallback="Recent workspaces to keep")
_LABEL_APPROVAL_TIMEOUT_SECONDS = msg(
@@ -1503,6 +1506,19 @@ class Settings:
),
)
+ chat_fold_finished_turns: bool = field(
+ default=True,
+ metadata=spec(
+ key="ui.chat.fold_finished_turns",
+ label=_LABEL_UI_CHAT_FOLD_FINISHED_TURNS,
+ coerce=bool_coercer(),
+ apply=Apply.LIVE,
+ group="ui",
+ kind=Kind.BOOL,
+ project_merge=ProjectMerge.DENY,
+ ),
+ )
+
# Maximum combined before/after snapshot bytes chat tool records keep
# inline. ``0`` keeps nothing inline.
chat_file_snapshot_inline_chars: int = field(
diff --git a/src/chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo b/src/chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo
index 65982082..b49425d9 100644
Binary files a/src/chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo and b/src/chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo differ
diff --git a/tests/app/tui/i18n/test_command_suggestions_i18n.py b/tests/app/tui/i18n/test_command_suggestions_i18n.py
index c398b3fa..eefa0b88 100644
--- a/tests/app/tui/i18n/test_command_suggestions_i18n.py
+++ b/tests/app/tui/i18n/test_command_suggestions_i18n.py
@@ -168,7 +168,7 @@ def capture_pages(pages: list[ManPageSpec], *, start_index: int = 0) -> None:
" /language - Set display language\n"
" /chdir - Change working directory\n"
" /copy - Copy agent, user, or all turns to clipboard\n"
- " /fold - Toggle collapse on all tool groups and thinking\n"
+ " /fold - Toggle collapse on activity, tool groups and thinking\n"
" /diff - View file changes for the current session\n"
" /rollback - Discard recent turns or return to a specific turn\n"
" /approval - Switch approval mode: manual → auto → bypass\n"
diff --git a/tests/app/tui/screens/main/test_process_folding.py b/tests/app/tui/screens/main/test_process_folding.py
new file mode 100644
index 00000000..5f34bbc9
--- /dev/null
+++ b/tests/app/tui/screens/main/test_process_folding.py
@@ -0,0 +1,103 @@
+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
+
+"""Process folding through real completion events on a populated MainScreen."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+from chrys.app.tui.widgets.chat.panel import ChatPanel
+from chrys.app.tui.widgets.chat.process_block import ProcessBlock
+from chrys.app.tui.widgets.chrome.input_bar import InputBar
+from chrys.foundation.events.bus import EventBus
+from chrys.foundation.events.types import InvocationContentDelta, InvocationContentFinished, InvocationMessage
+from chrys.foundation.models.invocations import InvocationOrigin
+from tests.support.pilot_barrier import screen_is_settled
+from tests.support.tui_app_harness import SessionGenerationEngine, make_chrys_app
+from tests.support.tui_helpers import click_when_settled
+from tests.support.waiting import wait_for
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+async def test_fifty_completed_turns_fold_without_global_restyle_or_recompose(tmp_path: Path) -> None:
+ bus = EventBus()
+ app = make_chrys_app(tmp_path, engine=SessionGenerationEngine(), event_bus=bus)
+ async with app.run_test(size=(120, 36)) as pilot:
+ main = app._main_screen
+ assert main is not None
+ panel = main.query_one(ChatPanel)
+ for turn in range(50):
+ await panel.add_user_message(f"Question {turn}")
+ main._set_agent_running(True)
+ origin = InvocationOrigin("turn", "s1", f"turn-{turn}", None)
+ await bus.publish(
+ InvocationContentDelta(
+ origin=origin, channel="answer", segment_id=f"middle-{turn}", text="Checking the evidence."
+ ),
+ raise_handler_errors=True,
+ )
+ await bus.publish(
+ InvocationContentFinished(origin=origin, channel="answer", segment_id=f"middle-{turn}"),
+ raise_handler_errors=True,
+ )
+ await panel.add_tool_start(f"c-{turn}", "read_file", "filesystem.read")
+ await panel.add_tool_result(f"c-{turn}", "read_file", "ok")
+ await bus.publish(
+ InvocationContentDelta(
+ origin=origin, channel="answer", segment_id=f"final-{turn}", text=f"Answer {turn}."
+ ),
+ raise_handler_errors=True,
+ )
+ await bus.publish(
+ InvocationContentFinished(origin=origin, channel="answer", segment_id=f"final-{turn}"),
+ raise_handler_errors=True,
+ )
+ await bus.publish(InvocationMessage(origin=origin, text=f"Answer {turn}."), raise_handler_errors=True)
+ await wait_for(lambda: screen_is_settled(app, main), pilot=pilot, description="fifty completed turns settled")
+ blocks = list(panel.query(ProcessBlock))
+ assert len(blocks) == 50
+ assert all(block.collapsed for block in blocks)
+ assert all(block not in main.focus_chain for block in blocks)
+ input_bar = main.query_one(InputBar)
+ input_bar.focus_input()
+ composer = input_bar.query_one("#chat-input")
+ await wait_for(
+ lambda: composer.has_focus and screen_is_settled(app, main), pilot=pilot, description="composer focused"
+ )
+ block = blocks[-1]
+ with (
+ patch.object(main, "update_node_styles", wraps=main.update_node_styles) as styles,
+ patch.object(main, "recompose", wraps=main.recompose) as recomposes,
+ patch.object(main, "_refresh_layout", wraps=main._refresh_layout) as layouts,
+ ):
+ await click_when_settled(pilot, block)
+ assert not block.collapsed
+ assert composer.has_focus
+ await click_when_settled(pilot, block)
+ assert block.collapsed
+ assert composer.has_focus
+ await wait_for(lambda: screen_is_settled(app, main), pilot=pilot, description="process toggle settled")
+ assert not styles.called
+ assert not recomposes.called
+ assert layouts.call_count < 12
+ before = layouts.call_count
+ block.try_finalize_fold()
+ await pilot.pause()
+ assert layouts.call_count == before
+ await pilot.press("x")
+ assert input_bar.value == "x"
+ input_bar.value = ""
+ await pilot.press("/", "f", "o", "l", "d", "enter")
+ await wait_for(
+ lambda: all(not item.collapsed for item in blocks), pilot=pilot, description="keyboard opens activity"
+ )
+ assert composer.has_focus
+ assert all(member.display for item in blocks for member in item.members)
+ await pilot.press("/", "f", "o", "l", "d", "enter")
+ await wait_for(
+ lambda: all(item.collapsed for item in blocks), pilot=pilot, description="keyboard closes activity"
+ )
+ assert composer.has_focus
diff --git a/tests/app/tui/screens/settings/test_coordinator.py b/tests/app/tui/screens/settings/test_coordinator.py
index 4924ebd7..68b51f4f 100644
--- a/tests/app/tui/screens/settings/test_coordinator.py
+++ b/tests/app/tui/screens/settings/test_coordinator.py
@@ -117,6 +117,7 @@ def __init__(
self.tool_groups_expanded: list[bool] = []
self.defer_while_judging: list[bool] = []
self.show_thinking: list[bool] = []
+ self.fold_finished_turns: list[bool] = []
self.saved_notifications: list[NotificationSettings] = []
self.notification_service = _NotificationService()
self.turn_task: asyncio.Task[None] | None = None
@@ -144,6 +145,7 @@ def __init__(
apply_tool_groups_expanded=self.tool_groups_expanded.append,
apply_approval_defer_while_judging=self.defer_while_judging.append,
apply_show_thinking=self.show_thinking.append,
+ apply_fold_finished_turns=self.fold_finished_turns.append,
list_themes=lambda: ["textual-dark", "chrys", "chrys-legacy"],
save_notifications=self.saved_notifications.append,
notification_service=lambda: self.notification_service,
@@ -246,6 +248,17 @@ async def test_the_show_thinking_choice_applies_at_once_and_saves_without_a_relo
assert h.coordinator.reload_dirty() is False
+async def test_process_folding_choice_applies_live_and_saves_without_reload(monkeypatch: pytest.MonkeyPatch) -> None:
+ h = _Harness(monkeypatch)
+ await h.install_engine()
+ h.coordinator.apply_live("ui.chat.fold_finished_turns", False)
+ assert h.fold_finished_turns == [False]
+ await h.close_and_settle()
+ assert h.recorder.calls == [({"ui.chat.fold_finished_turns": False}, ())]
+ assert h.reloads == 0
+ assert h.coordinator.reload_dirty() is False
+
+
async def test_written_values_project_until_the_live_settings_catch_up(monkeypatch: pytest.MonkeyPatch) -> None:
h = _Harness(monkeypatch)
assert h.coordinator.projected_value(_RELOAD_KEY) is True
diff --git a/tests/app/tui/screens/settings/test_dialog.py b/tests/app/tui/screens/settings/test_dialog.py
index a9631ed1..b1639656 100644
--- a/tests/app/tui/screens/settings/test_dialog.py
+++ b/tests/app/tui/screens/settings/test_dialog.py
@@ -82,7 +82,7 @@ async def test_mounting_the_dialog_writes_nothing_and_focuses_a_control() -> Non
assert ports.notification_ports.saved == []
assert isinstance(dialog.focused, Select)
rows = dialog.rows()
- assert len(rows) == ROW_COUNT == 37
+ assert len(rows) == ROW_COUNT == 38
assert all(row.spec.key != "trajectory.verify_commands" for row in rows)
assert dialog.query_one(TabbedContent).active == pane_id(GENERAL_TAB_ID)
diff --git a/tests/app/tui/screens/settings/test_layout.py b/tests/app/tui/screens/settings/test_layout.py
index ba29be6e..af369f51 100644
--- a/tests/app/tui/screens/settings/test_layout.py
+++ b/tests/app/tui/screens/settings/test_layout.py
@@ -62,7 +62,12 @@ def test_the_thinking_display_preference_sits_with_the_appearance_rows() -> None
assert general is not None
appearance = next(section for section in general.sections if section.title.fallback == "Appearance")
- assert [row.key for row in appearance.rows] == ["ui.theme", "ui.locale", "ui.chat.show_thinking"]
+ assert [row.key for row in appearance.rows] == [
+ "ui.theme",
+ "ui.locale",
+ "ui.chat.show_thinking",
+ "ui.chat.fold_finished_turns",
+ ]
def test_tools_tab_lists_each_tool_category_as_its_own_section() -> None:
diff --git a/tests/app/tui/widgets/chat/test_collaborators.py b/tests/app/tui/widgets/chat/test_collaborators.py
index 27b1f344..fe67d469 100644
--- a/tests/app/tui/widgets/chat/test_collaborators.py
+++ b/tests/app/tui/widgets/chat/test_collaborators.py
@@ -42,7 +42,9 @@ async def mount_transcript_widget(self, widget: Widget) -> bool:
self.widgets.append(widget)
return True
- async def mount_transcript_widgets(self, widgets: list[Widget]) -> None:
+ async def mount_transcript_widgets(
+ self, widgets: list[Widget], *, process_failures_after: tuple[Widget, ...] = ()
+ ) -> None:
self.calls.append(f"mount_batch:{len(widgets)}")
self.widgets.extend(widgets)
diff --git a/tests/app/tui/widgets/chat/test_live_thinking.py b/tests/app/tui/widgets/chat/test_live_thinking.py
index dccaeead..bb45ca9e 100644
--- a/tests/app/tui/widgets/chat/test_live_thinking.py
+++ b/tests/app/tui/widgets/chat/test_live_thinking.py
@@ -337,11 +337,14 @@ async def test_thinking_names_what_it_shows_and_folds_from_its_header_once_compl
await click_when_settled(pilot, thinking.query_one(_AgentHeader))
await wait_for(lambda: thinking.collapsed, pilot=pilot, description="complete thinking folded")
- body = thinking.query_one(VirtualizedMarkdown)
- assert body.display is False
+ await wait_for(lambda: not thinking.query(VirtualizedMarkdown), pilot=pilot, description="folded body removed")
assert thinking.query_one(_AgentHeader).render().plain == "▶ Thinking"
await click_when_settled(pilot, thinking.query_one(_AgentHeader))
- await wait_for(lambda: not thinking.collapsed and body.display, pilot=pilot, description="unfolded")
+ await wait_for(
+ lambda: not thinking.collapsed and bool(thinking.query(VirtualizedMarkdown)),
+ pilot=pilot,
+ description="unfolded body rebuilt",
+ )
async def test_fold_all_folds_complete_thinking_with_the_tool_groups_but_leaves_thinking_that_streams_open() -> None:
diff --git a/tests/app/tui/widgets/chat/test_process_folding.py b/tests/app/tui/widgets/chat/test_process_folding.py
new file mode 100644
index 00000000..4cec7ac7
--- /dev/null
+++ b/tests/app/tui/widgets/chat/test_process_folding.py
@@ -0,0 +1,653 @@
+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
+
+"""Completed process folding preserves user boundaries, copy and logical selection order."""
+
+from __future__ import annotations
+
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+
+from chrys.app.tui.widgets.chat.messages import AgentMessage, InterruptedMessage, ThinkingMessage, UserMessage
+from chrys.app.tui.widgets.chat.panel import ChatPanel
+from chrys.app.tui.widgets.chat.process_block import ProcessBlock
+from chrys.app.tui.widgets.chat.process_controller import process_entry
+from chrys.app.tui.widgets.chat.process_model import ProcessEntry, plan_process_segments
+from chrys.app.tui.widgets.chat.replay_mount import ReplayMountController, estimated_rows
+from chrys.app.tui.widgets.chat.selection_controller import ChatSelectionController
+from chrys.app.tui.widgets.chat.tool_call import ToolGroup
+from chrys.app.tui.widgets.markdown.widget import VirtualizedMarkdown
+from chrys.foundation.models.history_markers import HistoryMarkerKind
+from chrys.kernel import Content, Message
+from chrys.service.context.providers.history import CompressedBlock
+from tests.support.tui_helpers import ChatPanelApp, click_when_settled, rich_plain
+from tests.support.waiting import wait_for
+
+
+def _history() -> list[dict[str, Any]]:
+ return [
+ Message("user", [Content.from_text("prompt")]).to_dict(),
+ Message(
+ "assistant",
+ [
+ Content.from_text_reasoning(text="Look."),
+ Content.from_text("Checking."),
+ Content.from_function_call(call_id="c1", name="read_file", arguments="{}"),
+ ],
+ ).to_dict(),
+ Message("tool", [Content.from_function_result(call_id="c1", result="ok")]).to_dict(),
+ Message(
+ "user", [Content.from_text("also this")], additional_properties={HistoryMarkerKind.INJECTED_KEY: True}
+ ).to_dict(),
+ Message(
+ "assistant",
+ [
+ Content.from_text("One more."),
+ Content.from_function_call(call_id="c2", name="read_file", arguments="{}"),
+ ],
+ ).to_dict(),
+ Message("tool", [Content.from_function_result(call_id="c2", result="ok")]).to_dict(),
+ Message("assistant", [Content.from_text("Done.")]).to_dict(),
+ ]
+
+
+def _archive(history: list[dict[str, Any]]) -> tuple[dict[str, Any], CompressedBlock]:
+ block = CompressedBlock(compressed_context_id="archive", messages=[Message.from_dict(item) for item in history])
+ summary = Message(
+ "assistant",
+ [Content.from_text("Condensed")],
+ additional_properties={HistoryMarkerKind.KEY: HistoryMarkerKind.SUMMARY, "_block_id": "archive"},
+ ).to_dict()
+ return summary, block
+
+
+def _terminal_status(marker: str, status_code: str | None) -> dict[str, Any]:
+ return Message(
+ "assistant",
+ [Content.from_text("Stopped before completing the reply")],
+ additional_properties={
+ HistoryMarkerKind.KEY: marker,
+ "_interrupted_by": "error",
+ **({HistoryMarkerKind.STATUS_CODE_KEY: status_code} if status_code is not None else {}),
+ "_invocation_ids": ["child"],
+ },
+ ).to_dict()
+
+
+@pytest.mark.parametrize("inside", [True, False], ids=["inside-archive", "after-archive"])
+@pytest.mark.parametrize(
+ ("marker", "status_code"),
+ [
+ (HistoryMarkerKind.INTERRUPTED, HistoryMarkerKind.STATUS_EXECUTION_FAILED),
+ (HistoryMarkerKind.AWAITING_SUB_AGENTS, HistoryMarkerKind.STATUS_AWAITING_SUB_AGENTS),
+ (HistoryMarkerKind.AWAITING_SUB_AGENTS, None),
+ ],
+)
+async def test_compressed_terminal_status_prevents_folding(inside: bool, marker: str, status_code: str | None) -> None:
+ history = _history()
+ history[-1] = Message("assistant", [Content.from_text("Partial answer")]).to_dict()
+ status = _terminal_status(marker, status_code)
+ summary, block = _archive([*history, status] if inside else history)
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history([summary] if inside else [summary, status], compressed_blocks={"archive": block})
+ await panel.wait_replay_complete()
+ assert not panel.query(ProcessBlock)
+ assert all(message.display for message in panel.query(AgentMessage))
+ if inside:
+ assert not panel.query(InterruptedMessage)
+ assert all(not message.is_status for message in panel.query(AgentMessage))
+ assert panel.get_agent_responses() == [
+ ("Agent", "Checking."),
+ ("Agent", "One more."),
+ ("Agent", "Partial answer"),
+ ]
+ assert panel.get_all_messages() == [
+ ("You", "prompt"),
+ ("Agent", "Checking."),
+ ("You", "also this"),
+ ("Agent", "One more."),
+ ("Agent", "Partial answer"),
+ ]
+
+
+@pytest.mark.parametrize("answer_inside", [True, False], ids=["success-inside-archive", "success-after-archive"])
+async def test_compressed_failure_followed_by_success_still_folds(answer_inside: bool) -> None:
+ history = _history()
+ final = history.pop()
+ history.append(Message("assistant", [Content.from_text("Partial answer")]).to_dict())
+ history.append(_terminal_status(HistoryMarkerKind.INTERRUPTED, HistoryMarkerKind.STATUS_EXECUTION_FAILED))
+ summary, block = _archive([*history, final] if answer_inside else history)
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history(
+ [summary] if answer_inside else [summary, final], compressed_blocks={"archive": block}
+ )
+ await panel.wait_replay_complete()
+ assert _summary(panel) == [(0, 0, True, True), (1, 0, True, True)]
+ assert not panel.query(InterruptedMessage)
+ assert panel.get_agent_responses() == [
+ ("Agent", "Checking."),
+ ("Agent", "One more."),
+ ("Agent", "Partial answer"),
+ ("Agent", "Done."),
+ ]
+ assert panel.toggle_fold_all() is False
+ assert all(message.display for message in panel.query(AgentMessage))
+ await wait_for(
+ lambda: all(message.query(VirtualizedMarkdown) for message in panel.query(ThinkingMessage)),
+ pilot=pilot,
+ description="expanded archived thinking body restored",
+ )
+
+
+@pytest.mark.parametrize("thinking", [False, True])
+async def test_omitted_status_never_creates_an_empty_process_or_wraps_lone_thinking(thinking: bool) -> None:
+ history = [Message("user", [Content.from_text("prompt")]).to_dict()]
+ if thinking:
+ history.append(Message("assistant", [Content.from_text_reasoning(text="Evidence")]).to_dict())
+ history.extend(
+ [
+ _terminal_status(HistoryMarkerKind.INTERRUPTED, HistoryMarkerKind.STATUS_EXECUTION_FAILED),
+ Message("assistant", [Content.from_text("Done.")]).to_dict(),
+ ]
+ )
+ summary, block = _archive(history)
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history([summary], compressed_blocks={"archive": block})
+ await panel.wait_replay_complete()
+ assert not panel.query(ProcessBlock)
+ assert not panel.query(InterruptedMessage)
+ assert len(panel.query(ThinkingMessage)) == int(thinking)
+ assert all(message.collapsed and message.display for message in panel.query(ThinkingMessage))
+ assert panel.get_all_messages() == [("You", "prompt"), ("Agent", "Done.")]
+
+
+@pytest.mark.parametrize("boundary", ["thinking", "injection"])
+async def test_omitted_failure_after_a_non_answer_still_ends_the_turn(boundary: str) -> None:
+ history = _history()
+ if boundary == "thinking":
+ history.append(Message("assistant", [Content.from_text_reasoning(text="Checking again")]).to_dict())
+ else:
+ history.append(
+ Message(
+ "user",
+ [Content.from_text("one last check")],
+ additional_properties={HistoryMarkerKind.INJECTED_KEY: True},
+ ).to_dict()
+ )
+ history.append(_terminal_status(HistoryMarkerKind.INTERRUPTED, None))
+ summary, block = _archive(history)
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history([summary], compressed_blocks={"archive": block})
+ await panel.wait_replay_complete()
+ assert not panel.query(ProcessBlock)
+ assert all(message.display for message in panel.query(AgentMessage))
+
+
+async def test_omitted_failure_survives_setting_changes_until_a_later_success() -> None:
+ enabled = False
+ history = _history()
+ history.append(_terminal_status(HistoryMarkerKind.INTERRUPTED, None))
+ summary, block = _archive(history)
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: enabled).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history([summary], compressed_blocks={"archive": block})
+ await panel.wait_replay_complete()
+ enabled = True
+ await panel.add_agent_message("")
+ assert not panel.query(ProcessBlock)
+ await panel.add_agent_message("Recovered.")
+ assert _summary(panel) == [(0, 0, True, True), (1, 0, True, True)]
+ assert panel.get_agent_responses()[-1] == ("Agent", "Recovered.")
+
+
+async def _live(panel: ChatPanel) -> None:
+ await panel.add_user_message("prompt")
+ await panel.add_thinking_delta("r1", "reasoning", "Look.")
+ panel.finish_thinking_segment("r1")
+ for call_id, text in [("c1", "Checking."), ("c2", "One more.")]:
+ if call_id == "c2":
+ await panel.add_user_message("also this", is_injection=True)
+ await panel.add_answer_delta(call_id, text)
+ panel.finish_answer_segment(call_id, completed=True)
+ await panel.add_tool_start(call_id, "read_file", "filesystem.read", args={})
+ await panel.add_tool_result(call_id, "read_file", "ok")
+ await panel.add_system("notice")
+ await panel.add_answer_delta("final", "Done.")
+ panel.finish_answer_segment("final", completed=True)
+ await panel.add_agent_message("Done.")
+
+
+def _summary(panel: ChatPanel) -> list[tuple[int, int, bool, bool]]:
+ return [
+ (block.segment_index, block.tool_count, block.is_compressed or block.tool_count == 0, block.collapsed)
+ for block in panel.query(ProcessBlock)
+ ]
+
+
+async def test_live_and_replay_share_segments_counts_and_default_fold_state() -> None:
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await _live(panel)
+ await pilot.pause()
+ expected = _summary(panel)
+ assert expected == [(0, 1, False, True), (1, 1, False, True)]
+ copied = panel.get_all_messages()
+ assert copied == [
+ ("You", "prompt"),
+ ("Agent", "Checking."),
+ ("You", "also this"),
+ ("Agent", "One more."),
+ ("Agent", "Done."),
+ ]
+ assert all(message.display for message in panel.query(UserMessage))
+ assert [message.text for message in panel.query(AgentMessage) if message.display] == ["Done."]
+ await wait_for(
+ lambda: not panel.query(ThinkingMessage).first().query(VirtualizedMarkdown),
+ pilot=pilot,
+ description="folded thinking body removed",
+ )
+ await panel.clear()
+ await panel.replay_history(_history())
+ await panel.wait_replay_complete()
+ assert _summary(panel) == expected
+ assert panel.get_all_messages() == copied
+
+
+@pytest.mark.parametrize("fold", [False, True])
+@pytest.mark.parametrize("failed", [False, True])
+async def test_archived_turn_does_not_build_omitted_local_tool_groups(fold: bool, failed: bool) -> None:
+ history = _history()
+ if failed:
+ history[-1] = Message(
+ "assistant",
+ [Content.from_text("Failed")],
+ additional_properties={HistoryMarkerKind.KEY: HistoryMarkerKind.INTERRUPTED, "_interrupted_by": "error"},
+ ).to_dict()
+ archived = CompressedBlock(compressed_context_id="archive", messages=[Message.from_dict(item) for item in history])
+ summary = Message(
+ "assistant",
+ [Content.from_text("Condensed")],
+ additional_properties={HistoryMarkerKind.KEY: HistoryMarkerKind.SUMMARY, "_block_id": "archive"},
+ ).to_dict()
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: fold).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history([summary], compressed_blocks={"archive": archived})
+ await panel.wait_replay_complete()
+ assert not panel.query(ToolGroup)
+ assert all(block.tool_count == 0 for block in panel.query(ProcessBlock))
+ if failed:
+ assert not panel.query(ProcessBlock)
+ if fold and not failed:
+ assert _summary(panel) == [(0, 0, True, True), (1, 0, True, True)]
+
+
+@pytest.mark.parametrize("retained_kind", ["hosted", "informational"])
+async def test_mixed_compressed_turn_always_uses_activity_title(retained_kind: str) -> None:
+ history = _history()
+ if retained_kind == "hosted":
+ retained = [
+ Content.from_hosted_tool_call(
+ "search", tool_name="web_search", status="completed", hosted_family="search", hosted_provider="openai"
+ ).to_dict(),
+ Content.from_hosted_tool_result(
+ "search",
+ tool_name="web_search",
+ result="found",
+ status="completed",
+ hosted_family="search",
+ hosted_provider="openai",
+ ).to_dict(),
+ ]
+ else:
+ call = Content.from_function_call(call_id="server", name="server_task", arguments="{}").to_dict()
+ call["informational_only"] = True
+ retained = [call]
+ # Both process segments retain a call, while their local calls stay omitted.
+ history[1]["contents"].extend(retained)
+ history[4]["contents"].extend(retained)
+ archived = CompressedBlock(compressed_context_id="mixed", messages=[Message.from_dict(item) for item in history])
+ summary = Message(
+ "assistant",
+ [Content.from_text("Condensed")],
+ additional_properties={HistoryMarkerKind.KEY: HistoryMarkerKind.SUMMARY, "_block_id": "mixed"},
+ ).to_dict()
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history([summary], compressed_blocks={"mixed": archived})
+ await panel.wait_replay_complete()
+ blocks = list(panel.query(ProcessBlock))
+ assert len(blocks) == 2
+ assert all(block.tool_count == 1 and block.is_compressed for block in blocks)
+ assert [rich_plain(block.render()).strip() for block in blocks] == ["▶ Activity", "▶ Activity"]
+ assert panel.toggle_fold_all() is False
+ assert [rich_plain(block.render()).strip() for block in blocks] == ["▼ Activity", "▼ Activity"]
+ await wait_for(
+ lambda: all(group._content_mounted and group._tools for group in panel.query(ToolGroup)),
+ pilot=pilot,
+ description="retained tool details mounted",
+ )
+
+
+def test_compressed_opener_marks_every_process_segment() -> None:
+ segments = plan_process_segments(
+ [
+ ProcessEntry("user", compressed=True),
+ ProcessEntry("tool", 1),
+ ProcessEntry("injection"),
+ ProcessEntry("tool", 3),
+ ProcessEntry("answer"),
+ ]
+ )
+ assert [segment.compressed for segment in segments] == [True, True]
+
+
+@pytest.mark.parametrize("status_code", [None, HistoryMarkerKind.STATUS_AWAITING_SUB_AGENTS])
+async def test_restored_awaiting_sub_agents_status_cannot_complete_a_turn(status_code: str | None) -> None:
+ history = _history()
+ history.append(
+ Message(
+ "assistant",
+ [Content.from_text("Awaiting 2 sub-agents")],
+ additional_properties={
+ HistoryMarkerKind.KEY: HistoryMarkerKind.AWAITING_SUB_AGENTS,
+ **({HistoryMarkerKind.STATUS_CODE_KEY: status_code} if status_code is not None else {}),
+ "_invocation_ids": ["a", "b"],
+ },
+ ).to_dict()
+ )
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history(history)
+ await panel.wait_replay_complete()
+ status = panel.query(AgentMessage).last()
+ assert status.is_status and not status.is_process_final
+ assert process_entry(status).kind == "failure"
+ assert not panel.query(ProcessBlock)
+ assert all(group.display for group in panel.query(ToolGroup))
+
+
+async def test_failed_header_mount_keeps_members_visible_and_can_be_retried(monkeypatch: pytest.MonkeyPatch) -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ mount = panel.mount_transcript_after
+
+ async def fail_first_segment(anchor, block):
+ assert all(member.display for member in block.members)
+ if block.segment_index == 0:
+ return False
+ return await mount(anchor, block)
+
+ monkeypatch.setattr(panel, "mount_transcript_after", fail_first_segment)
+ await _live(panel)
+ assert _summary(panel) == [(1, 1, False, True)]
+ assert panel.query(ToolGroup).first().display
+ existing = panel.query_one(ProcessBlock)
+ monkeypatch.setattr(panel, "mount_transcript_after", mount)
+ await panel._process_controller.complete_live()
+ assert _summary(panel) == [(0, 1, False, True), (1, 1, False, True)]
+ assert panel.query(ProcessBlock).last() is existing
+
+
+async def test_unowned_tool_state_notifications_do_not_enter_batch_update() -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await _live(panel)
+ group = panel.query(ToolGroup).first()
+ with patch.object(pilot.app, "batch_update", wraps=pilot.app.batch_update) as batches:
+ panel.on_tool_group_process_state_changed(ToolGroup.ProcessStateChanged(ToolGroup(live=False)))
+ batches.assert_not_called()
+ panel.on_tool_group_process_state_changed(ToolGroup.ProcessStateChanged(group))
+ batches.assert_called_once()
+
+
+async def test_tool_free_activity_uses_fallback_title_and_single_thinking_has_no_wrapper() -> None:
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.add_user_message("prompt")
+ await panel.add_answer_delta("middle", "An intermediate update.")
+ panel.finish_answer_segment("middle", completed=True)
+ await panel.add_thinking_delta("r", "reasoning", "Thinking.")
+ panel.finish_thinking_segment("r")
+ await panel.add_answer_delta("final", "Done.")
+ panel.finish_answer_segment("final", completed=True)
+ await panel.add_agent_message("Done.")
+ assert _summary(panel) == [(0, 0, True, True)]
+ await panel.clear()
+ await panel.add_user_message("prompt")
+ await panel.add_thinking_delta("only", "reasoning", "Thinking.")
+ panel.finish_thinking_segment("only")
+ await panel.add_agent_message("Done.")
+ assert not panel.query(ProcessBlock)
+ assert panel.query_one(ThinkingMessage).collapsed
+
+
+async def test_failure_prompt_cleanup_has_no_precreated_header() -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.add_user_message("failed prompt")
+ await panel.add_error("failed")
+ await panel.add_user_message("replacement")
+ assert [message.text for message in panel.query(UserMessage)] == ["replacement"]
+ assert not panel.query(ProcessBlock)
+
+
+async def test_manual_expand_persists_and_fold_toggles_processes_with_their_contents() -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await _live(panel)
+ block = panel.query(ProcessBlock).first()
+ await click_when_settled(pilot, block)
+ assert not block.collapsed
+ await panel.add_user_message("next")
+ assert not block.collapsed
+ assert panel.toggle_fold_all()
+ assert block.collapsed
+ assert panel.toggle_fold_all() is False
+ assert all(not item.collapsed for item in panel.query(ProcessBlock))
+ assert all(group.display and not group.collapsed for group in panel.query(ToolGroup))
+
+
+async def test_fold_can_open_existing_processes_after_automatic_folding_is_disabled() -> None:
+ enabled = True
+ async with ChatPanelApp(show_thinking=lambda: True, fold_finished_turns=lambda: enabled).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await _live(panel)
+ enabled = False
+ thinking = panel.query_one(ThinkingMessage)
+ await wait_for(lambda: not thinking.query(VirtualizedMarkdown), pilot=pilot, description="thinking released")
+ assert panel.toggle_fold_all() is False
+ assert all(not block.collapsed for block in panel.query(ProcessBlock))
+ assert all(member.display for block in panel.query(ProcessBlock) for member in block.members)
+ await wait_for(
+ lambda: bool(thinking.query(VirtualizedMarkdown)),
+ pilot=pilot,
+ description="keyboard expansion restores thinking",
+ )
+ assert not thinking.collapsed
+ assert panel.toggle_fold_all() is True
+ assert all(block.collapsed for block in panel.query(ProcessBlock))
+
+
+@pytest.mark.parametrize("lock", [False, True], ids=["running-tool", "ask-user-lock"])
+async def test_terminal_member_notification_retries_deferred_folding(lock: bool) -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.add_user_message("prompt")
+ await panel.add_tool_start("c1", "read_file", "filesystem.read")
+ group = panel.query_one(ToolGroup)
+ if lock:
+ await panel.add_tool_result("c1", "read_file", "ok")
+ group.lock_collapse_for("question")
+ await panel.add_agent_message("Done.")
+ block = panel.query_one(ProcessBlock)
+ assert not block.collapsed and group.display
+ if lock:
+ group.unlock_collapse_for("question")
+ else:
+ await panel.add_tool_result("c1", "read_file", "ok")
+ await wait_for(lambda: block.collapsed, pilot=pilot, description="last blocker notification folds process")
+ assert not group.display
+ group.set_content_visible(True)
+ assert not group.display
+
+
+@pytest.mark.parametrize("lock", [False, True], ids=["running-tool", "ask-user-lock"])
+async def test_fold_expansion_survives_the_last_blocker_finishing(lock: bool) -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await _live(panel)
+ await panel.add_user_message("next")
+ await panel.add_tool_start("pending", "read_file", "filesystem.read")
+ group = panel.query(ToolGroup).last()
+ if lock:
+ await panel.add_tool_result("pending", "read_file", "ok")
+ group.lock_collapse_for("question")
+ await panel.add_agent_message("Done.")
+ block = panel.query(ProcessBlock).last()
+ assert block.blocked and block.requested_collapsed and not block.collapsed
+ assert panel.toggle_fold_all() is False
+ assert not block.requested_collapsed
+ if lock:
+ group.unlock_collapse_for("question")
+ else:
+ await panel.add_tool_result("pending", "read_file", "ok")
+ await pilot.pause()
+ assert not block.blocked and not block.collapsed and not block.requested_collapsed
+ assert group.display
+ await wait_for(
+ lambda: all(item._content_mounted and item._tools for item in panel.query(ToolGroup)),
+ pilot=pilot,
+ description="expanded tool details mounted",
+ )
+
+
+async def test_fold_with_only_blocked_sections_preserves_pending_intent() -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.add_user_message("prompt")
+ await panel.add_tool_start("pending", "read_file", "filesystem.read")
+ await panel.add_tool_result("pending", "read_file", "ok")
+ group = panel.query_one(ToolGroup)
+ group.lock_collapse_for("question")
+ await panel.add_agent_message("Done.")
+ block = panel.query_one(ProcessBlock)
+ assert block.requested_collapsed and not block.collapsed
+ assert panel.toggle_fold_all() is False
+ assert block.requested_collapsed and not block.collapsed and group.display
+ group.unlock_collapse_for("question")
+ await wait_for(lambda: block.collapsed, pilot=pilot, description="preserved pending fold applied")
+
+
+async def test_sidecar_last_turn_and_disabled_setting_leave_process_visible() -> None:
+ enabled = True
+ async with ChatPanelApp(fold_finished_turns=lambda: enabled).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.replay_history(_history(), recovered_from_sidecar=True)
+ await panel.wait_replay_complete()
+ assert not panel.query(ProcessBlock)
+ await panel.clear()
+ enabled = False
+ await _live(panel)
+ assert not panel.query(ProcessBlock)
+
+
+@pytest.mark.parametrize("replay", [False, True], ids=["live-add", "replay-add"])
+async def test_adding_tools_cannot_override_process_visibility(replay: bool) -> None:
+ async with ChatPanelApp().run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ group = ToolGroup(live=False)
+ group.set_process_hidden(True)
+ await panel.mount(group)
+ if replay:
+ await group.add_collapsed_replay_tool("c1", "read_file", "filesystem.read", result="ok", lazy=True)
+ else:
+ await group.add_tool("c1", "read_file", "filesystem.read")
+ assert not group.display
+ group.set_process_hidden(False)
+ assert group.display
+
+
+async def test_inserted_header_preserves_cross_entry_selection_and_prepend_anchor() -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await _live(panel)
+ await pilot.pause()
+ children = panel.transcript_content_children()
+ stamps = [panel.transcript_seq(child) for child in children]
+ assert None not in stamps
+ assert stamps == sorted(stamps)
+ first = panel.query(UserMessage).first().query_one(VirtualizedMarkdown)
+ last = panel.query(AgentMessage).last().query_one(VirtualizedMarkdown)
+ selection = ChatSelectionController(pilot.app.screen)
+ start = selection._leaf_anchor(panel, first, None)
+ end = selection._leaf_anchor(panel, last, None)
+ assert start is not None and end is not None
+ leaves = [leaf for leaf, _ in selection._iter_range(panel, start, end)]
+ assert leaves[-1] is last
+ user = panel.query(UserMessage).first()
+ assert panel.transcript_entry_after(user) is panel.query(ProcessBlock).first()
+
+
+def test_empty_segments_and_transient_notices_never_renumber_segments() -> None:
+ entries = [
+ ProcessEntry("user"),
+ ProcessEntry("other"),
+ ProcessEntry("injection"),
+ ProcessEntry("retry"),
+ ProcessEntry("tool", 2),
+ ProcessEntry("other"),
+ ProcessEntry("answer"),
+ ]
+ planned = plan_process_segments(entries)
+ assert [(segment.index, segment.tool_count, segment.members) for segment in planned] == [(0, 0, ()), (1, 2, (3, 4))]
+
+
+def test_hidden_members_use_no_tail_budget_and_do_not_exhaust_first_batch_limit() -> None:
+ widgets = [AgentMessage("visible\n" * 40)]
+ for _ in range(80):
+ hidden = AgentMessage("hidden\n" * 1000)
+ hidden.set_process_hidden(True)
+ widgets.append(hidden)
+ widgets.append(AgentMessage("final"))
+ assert estimated_rows(widgets[1], 100) == 0
+ start = ReplayMountController._batch_start(
+ widgets, len(widgets), row_budget=40, min_entries=2, width=100, max_entries=128
+ )
+ assert start == 0
+
+
+def test_foreground_budget_caps_visible_entries_and_total_hidden_entries() -> None:
+ visible = [AgentMessage("short") for _ in range(40)]
+ start = ReplayMountController._batch_start(
+ visible, len(visible), row_budget=1000, min_entries=2, width=100, max_entries=128
+ )
+ assert len(visible) - start == 32
+ hidden = [AgentMessage("hidden") for _ in range(300)]
+ for widget in hidden:
+ widget.set_process_hidden(True)
+ start = ReplayMountController._batch_start(
+ hidden, len(hidden), row_budget=1000, min_entries=2, width=100, max_entries=128
+ )
+ assert len(hidden) - start == 128
+
+
+@pytest.mark.parametrize("ending", ["error", "interrupted", "empty"])
+async def test_unsuccessful_or_empty_final_never_hides_the_last_process(ending: str) -> None:
+ async with ChatPanelApp(fold_finished_turns=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.add_user_message("prompt")
+ await panel.add_tool_start("c", "read_file", "filesystem.read")
+ await panel.add_tool_result("c", "read_file", "ok")
+ if ending == "error":
+ await panel.add_error("failed")
+ elif ending == "interrupted":
+ await panel.add_interrupted()
+ else:
+ await panel.add_agent_message("")
+ assert not panel.query(ProcessBlock)
+ assert panel.query_one(ToolGroup).display
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 f5f91468..8c2d8bd9 100644
--- a/tests/app/tui/widgets/chat/test_replay_tail_first.py
+++ b/tests/app/tui/widgets/chat/test_replay_tail_first.py
@@ -831,11 +831,15 @@ async def test_a_toc_jump_queued_before_an_unmount_never_runs(monkeypatch: pytes
assert jumps == []
-async def test_fold_all_during_prepend_reaches_tool_groups_mounted_later(monkeypatch: pytest.MonkeyPatch) -> None:
- async with ChatPanelApp().run_test(size=_SIZE) as pilot:
+@pytest.mark.parametrize("fold_processes", [False, True])
+async def test_fold_all_during_prepend_reaches_tool_groups_mounted_later(
+ monkeypatch: pytest.MonkeyPatch, fold_processes: bool
+) -> None:
+ count = _TOOL_TURNS * 2 if fold_processes else _TOOL_TURNS
+ async with ChatPanelApp(fold_finished_turns=lambda: fold_processes).run_test(size=_SIZE) as pilot:
cp = pilot.app.query_one(ChatPanel)
gate = _PrependGate(cp, monkeypatch)
- await cp.replay_history(_turns(_TOOL_TURNS, with_tools=True))
+ await cp.replay_history(_turns(count, with_tools=True))
await gate.wait_held(pilot)
mounted_groups = list(cp.query(ToolGroup))
assert mounted_groups
@@ -849,8 +853,8 @@ async def test_fold_all_during_prepend_reaches_tool_groups_mounted_later(monkeyp
await _wait_complete(cp)
groups = list(cp.query(ToolGroup))
- assert len(groups) == _TOOL_TURNS
- assert [group.collapsed for group in groups] == [False] * _TOOL_TURNS
+ assert len(groups) == count
+ assert [group.collapsed for group in groups] == [False] * count
# Everything is expanded now, so the next fold-all collapses.
assert cp.toggle_fold_all() is True
diff --git a/tests/app/tui/widgets/chat/test_replayed_thinking.py b/tests/app/tui/widgets/chat/test_replayed_thinking.py
index a8354504..42606916 100644
--- a/tests/app/tui/widgets/chat/test_replayed_thinking.py
+++ b/tests/app/tui/widgets/chat/test_replayed_thinking.py
@@ -346,10 +346,16 @@ async def test_a_replayed_block_builds_its_body_when_first_opened_and_hands_it_t
assert [message.reason for message in absorbs[absorbs_before:]] == [GcAbsorbReason.STABLE_CONTENT_MOUNTED]
await click_when_settled(pilot, thinking.query_one(_AgentHeader))
- assert (thinking.collapsed, body.display) == (True, False)
+ await wait_for(
+ lambda: not thinking.query(VirtualizedMarkdown), pilot=pilot, description="folded thinking body released"
+ )
+ assert thinking.collapsed
await click_when_settled(pilot, thinking.query_one(_AgentHeader))
- assert thinking.query_one(VirtualizedMarkdown) is body
- assert body.display is True
+ await wait_for(
+ lambda: bool(thinking.query(VirtualizedMarkdown)), pilot=pilot, description="thinking body rebuilt"
+ )
+ assert thinking.query_one(VirtualizedMarkdown) is not body
+ assert thinking.query_one(VirtualizedMarkdown).display is True
async def test_fold_all_opens_and_folds_replayed_thinking_with_the_tool_groups() -> None:
@@ -362,12 +368,22 @@ async def test_fold_all_opens_and_folds_replayed_thinking_with_the_tool_groups()
assert panel.toggle_fold_all() is False
assert [group.collapsed, *(message.collapsed for message in thinking)] == [False, False, False]
+ await wait_for(
+ lambda: all(message.query(VirtualizedMarkdown) for message in thinking),
+ pilot=pilot,
+ description="fold-all rebuilds thinking bodies",
+ )
bodies = [message.query_one(VirtualizedMarkdown) for message in thinking]
assert [body.display for body in bodies] == [True, True]
assert panel.toggle_fold_all() is True
assert [group.collapsed, *(message.collapsed for message in thinking)] == [True, True, True]
assert [body.display for body in bodies] == [False, False]
+ await wait_for(
+ lambda: all(not message.query(VirtualizedMarkdown) for message in thinking),
+ pilot=pilot,
+ description="fold-all releases thinking bodies",
+ )
async def test_fold_all_between_a_batch_registering_and_mounting_still_opens_its_thinking(
diff --git a/tests/app/tui/widgets/chat/test_thinking_body_release.py b/tests/app/tui/widgets/chat/test_thinking_body_release.py
new file mode 100644
index 00000000..c5941d68
--- /dev/null
+++ b/tests/app/tui/widgets/chat/test_thinking_body_release.py
@@ -0,0 +1,80 @@
+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
+
+"""Thinking body release uses the same young/frozen removal contract as tool groups."""
+
+from __future__ import annotations
+
+import pytest
+
+from chrys.app.tui.support import gc_freeze
+from chrys.app.tui.support.gc_freeze import GcReclaimRequested
+from chrys.app.tui.widgets.chat.messages import ThinkingMessage
+from chrys.app.tui.widgets.chat.panel import ChatPanel
+from chrys.app.tui.widgets.markdown.widget import VirtualizedMarkdown
+from tests.support.tui_helpers import GcMessageChatPanelApp
+from tests.support.waiting import wait_for
+
+
+@pytest.mark.parametrize("frozen", [False, True], ids=["young", "frozen"])
+async def test_fold_releases_body_with_epoch_aware_reclaim(monkeypatch: pytest.MonkeyPatch, frozen: bool) -> None:
+ app = GcMessageChatPanelApp(show_thinking=lambda: True)
+ async with app.run_test() as pilot:
+ panel = app.query_one(ChatPanel)
+ await panel.add_user_message("prompt")
+ await panel.add_thinking_delta("r1", "reasoning", "# Evidence\n\nLook at the file.")
+ panel.finish_thinking_segment("r1")
+ message = panel.query_one(ThinkingMessage)
+ await wait_for(
+ lambda: message.query_one(VirtualizedMarkdown).shown_source == message.text,
+ pilot=pilot,
+ description="thinking parse ready",
+ )
+ body = message.query_one(VirtualizedMarkdown)
+ epoch = gc_freeze.current_gc_freeze_epoch()
+ if frozen:
+ monkeypatch.setattr(gc_freeze, "_freeze_epoch", epoch + 1)
+ message.collapsed = True
+ await wait_for(
+ lambda: any(isinstance(event, GcReclaimRequested) for event in app.gc_messages),
+ pilot=pilot,
+ description="thinking reclaim request",
+ )
+ reclaim = next(event for event in app.gc_messages if isinstance(event, GcReclaimRequested))
+ assert reclaim.prompt is False
+ assert not message.query(VirtualizedMarkdown)
+ assert not body.is_attached
+ if frozen:
+ assert reclaim.removed is None
+ else:
+ assert reclaim.removed is not None
+ assert reclaim.removed.built_at_epoch == epoch
+ assert body in [node() for node in reclaim.removed.nodes]
+ message.collapsed = False
+ await wait_for(lambda: bool(message.query(VirtualizedMarkdown)), pilot=pilot, description="thinking rebuilt")
+ assert message.query_one(VirtualizedMarkdown) is not body
+ await wait_for(
+ lambda: message.query_one(VirtualizedMarkdown).shown_source == message.text,
+ pilot=pilot,
+ description="rebuilt thinking text ready",
+ )
+
+
+async def test_fast_collapse_expand_keeps_one_body_and_no_automatic_completion_fold() -> None:
+ async with GcMessageChatPanelApp(show_thinking=lambda: True).run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ await panel.add_user_message("prompt")
+ await panel.add_thinking_delta("r", "reasoning", "Reasoning.")
+ panel.finish_thinking_segment("r")
+ await panel.add_agent_message("Done.")
+ message = panel.query_one(ThinkingMessage)
+ assert not message.collapsed
+ message.collapsed = True
+ message.collapsed = False
+ message.collapsed = True
+ message.collapsed = False
+ await wait_for(
+ lambda: len(message.query(VirtualizedMarkdown)) == 1,
+ pilot=pilot,
+ description="one final body after rapid toggles",
+ )
+ assert not message.collapsed
diff --git a/tests/app/tui/widgets/test_tool_replay_rendering.py b/tests/app/tui/widgets/test_tool_replay_rendering.py
index 4ed21c69..e63a2bca 100644
--- a/tests/app/tui/widgets/test_tool_replay_rendering.py
+++ b/tests/app/tui/widgets/test_tool_replay_rendering.py
@@ -363,8 +363,8 @@ def mount_spy(*widgets: Widget, **kwargs: object):
cp.set_replay_progress_callback(lambda current, total: progress.append((current, total)))
await cp.replay_history(messages)
- # The restore's progress covers the newest batch, which is all that
- # mounts before replay returns.
+ # Visible entries retain the original foreground cap even when
+ # their rows would fit in the three-viewport budget.
assert batch_sizes == [batch_size]
assert progress == [(0, batch_size), (batch_size, batch_size)]
@@ -377,6 +377,31 @@ def mount_spy(*widgets: Widget, **kwargs: object):
]
+async def test_replay_foreground_has_a_hard_cap_even_for_hidden_entries(monkeypatch: pytest.MonkeyPatch) -> None:
+ widgets: list[Widget] = [UserMessage("prompt")]
+ for _ in range(300):
+ hidden = AgentMessage("intermediate")
+ hidden.set_process_hidden(True)
+ widgets.append(hidden)
+ widgets.append(AgentMessage("answer"))
+ async with ChatPanelApp().run_test() as pilot:
+ panel = pilot.app.query_one(ChatPanel)
+ mount = panel.mount
+ batches: list[int] = []
+
+ def record_mount(*entries: Widget, **kwargs: object):
+ batches.append(len(entries))
+ return mount(*entries, **kwargs)
+
+ monkeypatch.setattr(panel, "mount", record_mount)
+ await panel.mount_transcript_widgets(widgets)
+ assert batches == [32, 32, 32, 32]
+ assert len(panel.pending_transcript_widgets()) == len(widgets) - 128
+ await panel.wait_replay_complete()
+ assert not panel.pending_transcript_widgets()
+ assert max(batches) <= 32
+
+
async def test_replay_history_duplicate_call_id_distinct_snapshots() -> None:
"""Duplicate call_ids (LLM reuse after rejection) keep distinct snapshots.
diff --git a/tests/foundation/config/test_settings.py b/tests/foundation/config/test_settings.py
index 03e066d5..607b77ad 100644
--- a/tests/foundation/config/test_settings.py
+++ b/tests/foundation/config/test_settings.py
@@ -829,6 +829,12 @@ def test_thinking_shows_by_default_and_only_the_user_settings_change_it_live() -
assert (found.apply, found.env, found.project_merge) == (Apply.LIVE, None, ProjectMerge.DENY)
+def test_completed_turn_folding_is_user_owned_and_enabled_by_default() -> None:
+ found = specs_by_key(Settings)["ui.chat.fold_finished_turns"]
+ assert Settings().chat_fold_finished_turns is True
+ assert (found.apply, found.env, found.project_merge) == (Apply.LIVE, None, ProjectMerge.DENY)
+
+
def test_tool_groups_default_collapsed_and_persist_native_bool(fake_config_dir: Path) -> None:
assert Settings().tool_groups_expanded is False
diff --git a/tests/foundation/i18n/_catalog_oracle_ids.py b/tests/foundation/i18n/_catalog_oracle_ids.py
index aa7bfe34..0d7c16f6 100644
--- a/tests/foundation/i18n/_catalog_oracle_ids.py
+++ b/tests/foundation/i18n/_catalog_oracle_ids.py
@@ -1585,6 +1585,8 @@
"tui.chat.copy_user_message_tooltip",
"tui.chat.retry_message",
"tui.chat.thinking_label",
+ "tui.chat.process.activity",
+ ("tui.chat.process.tool_calls", "tui.chat.process.tool_calls#plural"),
"tui.chat.thinking_summary_label",
"tui.app.quit_help",
"tui.app.quit_help_title",
diff --git a/tests/foundation/i18n/_settings_catalog_oracle_ids.py b/tests/foundation/i18n/_settings_catalog_oracle_ids.py
index 95f428de..09596aac 100644
--- a/tests/foundation/i18n/_settings_catalog_oracle_ids.py
+++ b/tests/foundation/i18n/_settings_catalog_oracle_ids.py
@@ -73,6 +73,7 @@
"settings.ui.approval.defer_while_judging.label",
"settings.ui.chat.file_snapshot_inline_chars.label",
"settings.ui.chat.show_thinking.label",
+ "settings.ui.chat.fold_finished_turns.label",
"settings.ui.chat.tool_groups_expanded.label",
"settings.ui.editor.keymap.label",
"settings.ui.locale.label",
@@ -128,6 +129,7 @@
"tui.settings.hint.tools.ask_user.timeout_seconds",
"tui.settings.hint.tools.search.respect_gitignore",
"tui.settings.hint.ui.chat.show_thinking",
+ "tui.settings.hint.ui.chat.fold_finished_turns",
"tui.settings.hint.ui.chat.tool_groups_expanded",
"tui.settings.hint.ui.locale",
"tui.settings.hint.ui.theme",
diff --git a/tests/foundation/i18n/test_catalog_oracle.py b/tests/foundation/i18n/test_catalog_oracle.py
index f7c59bae..394d606c 100644
--- a/tests/foundation/i18n/test_catalog_oracle.py
+++ b/tests/foundation/i18n/test_catalog_oracle.py
@@ -23,7 +23,7 @@ def test_live_catalog_artifacts_contain_effective_translations_and_are_loadable(
pot = _read_catalog(i18n.POT_PATH)
po = _read_catalog(i18n.ZH_PO_PATH)
- assert len(EXPECTED_MESSAGE_IDS) == 2407
+ assert len(EXPECTED_MESSAGE_IDS) == 2411
assert {_entry_id(message) for message in pot if message.id} == EXPECTED_MESSAGE_IDS
assert {_entry_id(message) for message in po if message.id} == EXPECTED_MESSAGE_IDS
assert set(_effective_catalog_entries(po)) == EXPECTED_MESSAGE_IDS
diff --git a/tests/support/tui_helpers.py b/tests/support/tui_helpers.py
index dc5fa8e5..bbd36b31 100644
--- a/tests/support/tui_helpers.py
+++ b/tests/support/tui_helpers.py
@@ -643,13 +643,19 @@ def __init__(
localization: TranscriptLocalizationPort | None = None,
*,
show_thinking: Callable[[], bool] | None = None,
+ fold_finished_turns: Callable[[], bool] | None = None,
) -> None:
self._localization = localization
self._show_thinking = show_thinking
+ self._fold_finished_turns = fold_finished_turns
super().__init__()
def compose(self) -> ComposeResult:
- yield ChatPanel(localization=self._localization, show_thinking=self._show_thinking)
+ yield ChatPanel(
+ localization=self._localization,
+ show_thinking=self._show_thinking,
+ fold_finished_turns=self._fold_finished_turns,
+ )
class GcMessageChatPanelApp(ChatPanelApp):